递归与分治分析
适合用递归算法来解决的常见问题有:
(1)二分搜索技术;
(2)大整数乘法;
(3)Strassen矩阵乘法;
(4)棋盘覆盖;
(5)合并排序和快速排序;
(6)线性时间选择;
(7)最接近点对问题;
(8)循环赛日程表。
算法总体思想
对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止。分治法的设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解。
直接或间接地调用自身的算法称为递归算法。用函数自身给出定义的函数称为递归函数。
由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。
分治与递归经常同时应用在算法设计之中,并由此产生许多高效算法。
例 1 整数的划分问题
将正整数n表示成一系列正整数之和:n=n1+n2+…+nk,其中n1≥n2≥…≥nk≥1,k≥1。正整数n的这种表示称为正整数n的划分。求正整数n的不同划分个数。 例如正整数6有如下11种不同的划分:
6;
5+1;
4+2,4+1+1;
3+3,3+2+1,3+1+1+1;
2+2+2,2+2+1+1,2+1+1+1+1;
1+1+1+1+1+1。
这个例子中,问题本身都具有比较明显的递归关系,因而容易用递归函数直接求解。在本例中,如果设p(n)为正整数n的划分数,则难以找到递归关系,因此考虑增加一个自变量:将最大加数n1不大于m的划分个数记作q(n,m)。可以建立q(n,m)的如下递归关系。
(1) q(n,1)=1, n≥1
(2) q(n,m)=q(n,n),m≥n;
(3) q(n,n)=1+q(n,n-1);
正整数n的划分由n1=n的划分和n1≤n-1的划分组成。
(4) q(n,m)=q(n,m-1)+q(n-m,m),n>m>1;
正整数n的最大加数n1不大于m的划分由n1=m的划分和n1≤n-1 的划分组成。
从而,可以得到计算q(n,m)的递归算法如下,其中,正整数n的划分数p(n)=q(n,n)。
int q(int n, int m)
{
if(n<1|| m<1) return 0;
if((1== n) || (1 == m)) return 1;
if(n== m) return q(n, m-1) – 1;
returnq(n, m-1) + q(n – m, m);
}
从该递归模拟图来可以看出,递归程序有一个重复计算的缺点(q(2,2)、q(2,1)分别计算了两次),这是因为每次递归调用是相对独立的,它们的计算结果都没有保存下来,所以下次再遇到相同子问题时又得重新计算一次,这样就导致了重复计算问题。在问题规模较小时,这种重复计算的开销往往不是很大,就像上图所示的一样,但是随着问题规模的增大,这种开销就会越来越大,甚至会导致程序无法在有限时间内计算出正确结果。如以下的程序用递归算法求完成n阶Hanoi的移动,当输入盘数大于20时,在普通的机器上就要运行很久才能执行完成。
#include<iostream.h> int main() { void Hanoi(int n,char x,char y,char z); int n; cout<<"请输入圆盘数:"<<endl; cin>>n; Hanoi(n,'A','B','C'); cout<<endl<<endl; return 1; } void Hanoi(int n,char x,char y,char z) {//将x上编号为1至n-1的盘子移到塔座z上,塔座y可用作辅助塔 if(n>0) { //将x上编号为1至n-1的圆盘移到y,z作辅助塔 Hanoi(n-1,x,z,y); //将编号为n的圆盘从x移到z cout<<endl<<n<<":"<<x<<"-->"<<z; //将y上编号为1至n-1的圆盘移到x,x作辅助塔 Hanoi(n-1,y,x,z); } }其次,每次递归调用都要将相应的调用信息压入系统堆栈,所以随着递归层次的加深,程序占用的栈空间越大(即占用的内存空间越大),而且系统堆栈的深度是有上限的,如果递归层次太深,那么将导致栈溢出。下面我们用表格来模拟一下这个算法的递归工作栈的前面一些操作过程(即函数调用时相关参数信息的进栈和出栈情况),以加深对递归调用的理解。
从图中我们可以看出,每次递归调用并不会直接得到结果,而是导致更深一层的调用,随着调用层次的增加,相应问题的规模才会逐渐减小,直到最后问题规模小到了程序期望的程度,然后最后一次调用才能直接返回计算结果(注意只是返回最后一次调用的结果,而不是整个程序的结果),然后栈顶的函数调用信息出栈,同时将其计算结果传递(即返回)给与它紧临下一层的函数,同时该层成为新的栈顶,又继续重复相同的操作。程序在运行的整个过程中,在很大时间比里一直要系统堆栈来保存很多函数调用的参数信息,所以也就不难理解为什么递归调用通常会占用较大的内存空间。而且每次相关函数出栈后,它的计算结果也随着销毁了,下一次再遇到相同子问题时,又要重新计算,所以这就是为什么有些递归算法时间复杂度比较大的原因。
虽然递归算法有这些缺点,但是其应用还是相当广泛的。因为有对于很多问题,使用递归算法来解决时,会使得对问题的分析大大降低,解决问题的算法也很容易实现。最重要的是很多问题的递归调用层次不会很深,重复计算的问题也不明显(如合并排序算法、快速排序算法等),所以递归算法还是有很大的使用价值的。
例 2 合并排序(Merge sort)
基本思想:将待排序元素分成大小大致相同的2个子集合,分别对2个子集合进行排序,最终将排好序的子集合合并成为所要求的排好序的集合。
合并排序过程的简单模拟图:
算法如下:
//消除递归后的合并排序算法 public static void MergeSort (Comparable[] a) { Comparable[] b = new Comparable[a.length]; int s = 1; while (s < a.length) { // 将待排序的数组分段 MergePass (a, b, s); // 合并到数组b s += s; MergePass (b, a, s); // 合并到数组a s += s; } } //MergePass用于合并排好序的相邻数组段,具体的合并操作由 Merge()方法来完成 public static void MergePass (Comparable[] x, Comparable[] y, int s) { // 合并大小为s的相邻 2 段子数组 int i = 0; while (i <= x.length - (s << 1)) {// 合并大小为S的相邻2段子数组 Merge (x, y, i, i + s - 1, 2*s -1); i += (s << 1); } if (i + s < x.length) { // 剩下的元素个数少于 2s 多于S Merge (x, y, i, i + s - 1, x.length - 1); } else { // 剩下的元素个数少于S for (int j = i; j <= x.length - 1; j++) y[j] = x[j]; } } // 将两个已经排好序的序列合并成一个序列 public static void Merge(Comparable c[], Comparable d[], int l, int m, int r) { // 合并c[l:m] 和 c[m+1:r]到d[l:r] int i = l, j = m + 1, k = l; // 计数器 while (i <= m && j <= r) { if (c[i].compareTo(c[j]) <= 0) d[ k++ ] = c[ i++ ]; else d[ k++ ] = c[ j++ ]; } if (i > m) { for (int q = j; q <= r; q++) d[ k++ ] = c[ q++ ]; } else { for (int q = i; q <= m; q++) d[ k++ ] = c[ q++ ]; } }
合并排序算法复杂度:(1)最坏时间复杂度:O(nlogn)(2)平均时间复杂度:O(nlogn)(3)辅助空间:O(n)
例 3 快速排序(Quick Sort)
快速排序是对起泡排序的一种改进,它的基本思想是,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键比另一部分的小,然后对这两部分记录继续进行排序,直到整个序列有序。假设输入子数组为a[p:r],则其的快排过程如下:
(1)分解(divide):以a[p]为基准元素将a[p:r]划分成3段a[a:q-1]、a[q]和a[q+1:r],使得a[p, q-1]中的任何元素小于等于a[q],a[q+1:r]中的任何元素大于等于a[q],下标q在划分过程中确定。
(2)递归求解(conquer):通过递归调用快排算法,分别对a[p:q-1]和a[q+1:r]进行排序。
(3)合并(merge):由于对a[p:q+1]和a[q+1:r]的排序是就地进行的,所以在a[p:q+1]和a[q+1:r]都已排好序后不需要执行任何计算,a[p:r]就已排好序。
上述操作过程可用以下算法来描述:
//快速排序算法 void QuickSort(int *array, int p, int r) { int q; if(p < r) { q = Partition(array, p, r);//计算基准元位置 QuickSort(array, p, q-1);//对基准元左侧进行排序 QuickSort(array, q+1, r);//对基准元右侧进行排序 } }
下面给出一个完整的参考程序:
#include <stdio.h> #define SIZE 100 //输入序列最大长度 //交换两个元素 void swap(int *x, int *y) { int temp = *x; *x = *y; *y = temp; } //以array[p]为基准元,将小于基准元的元素交换到数组的左侧,大于基准元的元素交换到数组的右侧 //注意如果以array[r]为基准元,且array[r]是array[p:r]中最大的元素,则Partition会返回q-r,从而 //造成qSort函数陷入死循环 int Partition(int *array, int p, int r) { int i, j, x; i = p; j = r + 1; x = array[p]; while(1) { while(array[++i] < x);//从左向右扫描,寻找比基准元大的元素 while(array[--j] > x);//从右向左扫描,寻找比基准元小的元素 if(i >= j) break; swap(&array[i], &array[j]);//交换两元素 } array[p] = array[j]; array[j] = x;//把基准元素放到它在数组中的最终位置,从而把数组一分为二 return j;//返回划分元位置 } //快速排序算法 void QuickSort(int *array, int p, int r) { int q; if(p < r) { q = Partition(array, p, r);//计算基准元位置 QuickSort(array, p, q-1);//对基准元左侧进行排序 QuickSort(array, q+1, r);//对基准元右侧进行排序 } } //输出指定的数组,用于输出排序结果 void OutputResult(int *array, int size) { for(int i = 0; i < size; i++) { printf("%-2d", array[i]); } putchar('\n'); } int main(void) { int array[SIZE], i, sum; printf("Please input some numbers(less than 100 numbers): "); scanf("%d",&sum); for(i = 0; i < sum; i++) { scanf("%d",&array[i]); } QuickSort(array,0,sum-1); printf("After ordering, the array become like this:\n"); OutputResult(array,sum); return 0; }
或许大家都很熟悉,分治与递归策略在解决二分搜索、大整数乘法、Strassen矩阵乘法、棋盘覆盖、合并排序、快速排序等经典问题上非常成功,如果我们对这些问题进行更深入的思考和扩展,会很容易推导一些别的常见问题的解法。下面举几个例子:
1. 二分搜索的应用
假设有 n 个不同的整数从小到大排好序后存于T[1:n]中,若存在一个下标i,1<= i < n,使得T[i] = i,设计一个算法找到这个下标,要求算法在最坏情况下的计算时间为O(logn)。
分析:由于 n 个整数是不同的,且已排好序,因此对任意 1 <= i <= n-1有T[i] <= T[i+1] – 1,从而在T[i]的左侧有T[i] < i ,而在T[i] 的右侧有T[i] > i ,由此很容易联想到用二分搜索的思想:如果子序列的中间那个数T[middle] = i,则直接返回下标middle即可,否则(1)如果T[middle] < i,则T[i] = i的元素就在T[middle] 的右侧;(2)如果T[middle] > i,则T[i] = i的元素就在T[middle]的左侧;接着我们再对T[i] = i所在的那一段进行递归查找即可。由于每次二分一次,搜索范围就减半,所以此算法时间复杂度为O(logn)。从这个问题我们更深刻的认识到,分治算法的最显著特征就是通过对问题进行一步步的分解,直到分解得到的子问题可直接求解为止。
2. 利用快速排序算法找中位数
给定由 n 个互不相同的数组成的集合S,设计一个O(n)时间算法找出S中的中位数。
分析:这里的中位数是指在有序序列中,位于中间的那个数。要解决此问题,我们可以先将整个数组排好序,然后中位数也就得出来了,排序问题的计算时间下界为Ω(nlogn),看起来不错。但是现在我们只想找出中位数,为什么非得对整个数组排好序呢?通过这种方法来完成任务,所做的工作量显然比我们期望的多了。那有没有更好的办法呢?答案是肯定的,在快速排序的时候,划分基准元素X左边的元素的关键字都比X的小,而X右边的元素的关键字都比X的大。假设在第一次划分后,X左边有k个数,X右边有n-k-1个数,数组中间元素的下标为mid,如果此时X的下标刚好等于mid,那么X就是我们要找的中位数了,如果X的下标比mid小,则显然中位数必然在X右边的n-k-1个数里,那么我们只需对X右侧的n-k-1个数递归地找出第(mid-k)个最小的元素就可以了,该数即为整个数组的中位数;同理,如果X的下标大于mid,那中位数就在X左侧的k个数里了,我们对左侧递归查找就可以了。这样,在平均情况下,这个算法的时间复杂度就为O(logn),而在最差情况下,时间复杂度为O(n),这比Ω(nlogn)的复杂度好了很多。
3. 利用分治思想设计查找序列中的最大值、最小值的最优算法
给定数组a[0:n-1],设计一个算法,在最坏情况下用[3n/2- 2](向上取整)次比较找出a[0:n-1]中元素的最大值和最小值。
分析:显然不管我们用先排序后得最大、最小值,还是通过一次遍历得最大、最小值,都无法满足只用[3n/2- 2]次比较的条件,所以我们得想别的办法。想想看,如果已知最小值出现在数组的左侧,最大值出现在数组的右侧,我们就可以通过n次比较来找出最大、最小值了。所以问题的关键是我们能不能通过0.5n次比较,将数组分割成两部分,使得最小值交换到数组的左侧,最大值交换到数组的右侧,答案是肯定的。假设数组中间元素的下标为mid,最左侧元素下标为left,最右侧元素下标为right,我们可以从左向右扫描这个数组,扫描到中间即可,每扫描一个元素,就比较一下a[left]和a[right],如果a[left]> a[right],那我们就交换它们的值,否则不做任何操作,只继续扫描。这样当我们扫描到mid的后,对于整个数组有a[i] >= a[i+mid],1 <= i <= mid。显然此时最小值已被交换到数组的左侧,而最大值被交换到数组的右侧了,而这个扫描操作恰好进行了0.5n次比较,所以整个算法可以在[3n/2- 2](向上取整)次比较找出a[0:n-1]中元素的最大值和最小值了。
最后我们再来谈谈如何解决递归算法的缺点:
在递归算法中消除递归调用,使其转化为非递归算法。
(1)采用一个用户定义的栈来模拟系统的递归调用工作栈。该方法通用性强,但本质上还是递归,只不过人工做了本来由编译器做的事情,优化效果不明显。
(2)用递推来实现递归函数。
(3)通过变换能将一些递归转化为尾递归,从而迭代求出结果。
后两种方法在时空复杂度上均有较大改善,但其适用范围有限。