递归与分治分析
适合用递归算法来解决的常见问题有:
(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)通过变换能将一些递归转化为尾递归,从而迭代求出结果。
后两种方法在时空复杂度上均有较大改善,但其适用范围有限。