2、递归分析入门
一、引例
上一篇介绍的最长子序列和问题的分治算法的分析中,提出了一个递推式,我们没有求解该递推式的上界。实际上,我们经常在递归算法的分析里遇到递推式,很显然这是由于递归本身的结构决定的。这一篇我们就简单地介绍一下怎么做分治算法下的递归分析。实际上递归有多种情况,除了分治算法,比较常见的还有搜索,这就不在本篇的讨论范围之内。
在分析分治算法之前,先来分析一个不太“优”的递归算法。看下面的求解斐波那契数列第$N$项的算法:
1 int Fib(int N) { 2 if (N == 0 || N == 1) 3 return 1; 4 return Fib(N - 1) + Fib(N - 2); 5 }
这个算法经常被当作递归的“反面教材”,因为它的冗余递归太多:求解$Fib(N-1)$时,实际上已经求了$Fib(N-2)$,可是后面又调用了一次。这个算法有多坏呢?我们建立下面的关于这个算法时间上界的递推式:
$T(0)=T(1)=1$
$T(N)=T(N-1)+T(N-2)$
可以很容易就发现$T(N)=Fib_N$,也就是时间以斐波那契数列级增长。斐波那契数列有很多研究,譬如前后两项之比的极限是黄金分割比$Phi = frac{sqrt{5}+1}{2}$,这告诉我们这个算法的时间是指数级增长!指数增长一般是难以忍受的,常见情况里仅快于阶乘增长。可见上面这个算法有多差。
上面的分析已经告诉了我们怎么做递归分析,就是求其时间上界数列的递推式,从递推式里求解通项公式或者至少知道增长等级。这个算法一旦优化就不适合递归了,因此这里就不对它进行优化了。下面我们分析一个经典的分治递归算法。
二、归并排序分析
排序算法是非常重要而基础的,我们有好多种排序算法,而不管怎么考虑,归并排序都一定是其中最经典的之一。简要来说,归并排序的思路是这样的:递归地求解,假设我们已经有了两个排好序的$N$项序列,把它们合并成$2N$项的有序序列就好了。至于这两个序列的来源,我们可以把它们等分成两部分,让这两部分是各自排好序的,然后合并;可见这个过程应该递归下去。这个递归当然有基本情况:$N=1$时,无需再分即有序。归并排序的主算法可以这样实现:
1 void merge_Sort(int A[], int l, int r) { 2 if (r - l > 1) { 3 merge_Sort(A, l, (l + r) / 2); //分治 4 merge_Sort(A, (l + r) / 2, r); 5 } 6 _merge_sort(A, l, (l + r) / 2, r); //归并 7 return; 8 }
其中第6行的归并函数的某种实现方式如下:
1 int tempL[MXN], tempR[MXN]; //归并排序的临时数组 2 3 void _merge_sort(int A[], int l, int mid, int r) { //归并步骤 4 for (int i = l; i < mid; i++) tempL[i - l] = A[i]; //将两侧的数记入临时数组 5 for (int j = mid; j < r; j++) tempR[j - mid] = A[j]; 6 tempL[mid - l] = tempR[r - mid] = 0x7fffffff; 7 int cnt = l, i = 0, j = 0; 8 for (; l + i < mid && mid + j < r;cnt++) { //比较归并 9 if (tempL[i] <= tempR[j]) A[cnt] = tempL[i++]; 10 else A[cnt] = tempR[j++]; 11 } 12 while (l + i < mid) A[cnt++] = tempL[i++]; //余项归并 13 while (mid + j < r) A[cnt++] = tempR[j++]; 14 return; 15 }
当然归并排序的主算法和归并函数有很多种实现和优化的方式,这里就不展开了,但是总之,归并函数的时间复杂度上界不可能低于$O(N)$。
下面我们就可以来分析归并排序的时间复杂度。根据主算法,写出它的时间上界的递推式:
$T(N) = 2T(N/2) + N$
为了方便分析,我们首先假设:$N = 2^k$,这样$N/2$就有一直都是有意义的。这种情况下,我们有两种处理方式:
1、做这样的变换:
$frac{T(N)}{N} = frac{T(N/2)}{N/2} + 1$
可见${ frac{T(2^k)}{2^k} }$是一个关于$k$的等差数列,又由于$frac{T(1)}{1} = 1$,我们就可以求出:
$frac{T(N)}{N} = k + 1 = log N + 1$
$T(N) = N log N + N = O(N log N)$
这是第一种分析方法。
2、第二种分析方法比较“暴力”:有下面两个式子:
$T(N) = 2T(N/2) + N$
$T(N/2)=2T(N/4)+N/2$
代入得:
$T(N) = 4T(N/4)+2N$
不断代入,直到最后得到:
$T(N) = 2^k T(1) + kN = N + N log N = O(N log N)$
这就是第二种分析方法。
上面我们假设了$N=2^k$,如果不是这样的呢?通过刚刚的分析我们知道,只要$N = p2^k$,我们就可以把求解$T(N)$转化成求解$T(p)$,因此我们下面只分析奇数的情况。
为了得到相同的结论,我们设$N = 2^k + m, 0 leq m < 2^k$,做数学归纳法:
1° 首先,$k=0$的特殊情况,我们有$T(1) = 1$;$k = 1$时,我们有
$T(2) = 2T(1) + 2 = 4 = N log N + N$、$T(3) = T(1)+T(2) + 3 = 8 = N log N + N$
(当$log N$不是整数时我们向上取整,下面我们将看到最后一个约等号写成等号是不影响上界的,因为$2$是$2$的幂,它的$log N$不需要向上取整,但统一向上取整仍得到一个上界);
2° 然后归纳假设对$0 leq k < n$,对任意的$m$,都成立$T(N) = N log N + N$,那么在$N = n = 2^n + m$时($m$仍然是任意的):
若$m$是偶数,我们就有:$T(N) = 2T(2^{n-1} + m/2) + N$,根据归纳假设就有:
$T(N) = 2[n(2^{n-1}+m/2) + 2^{n-1} + m/2] + N$
$=(n + 1)(2^n + m) + N$
$=(n + 1)N + N = N log N + N$
若$m$是奇数,不妨设$m = 2q+1$,则有$T(N) = T(2^{n-1} + q) + T(2^{n-1}+q+1) + N$,根据归纳假设就有:
$T(N) = n(2^{n-1}+q) + 2^{n-1} + q + n(2^{n-1} + q + 1) + 2^{n-1} + q + 1 +N$
$=n(2^n + 2q + 1) + 2^n +2q + 1 + N$
$=(n + 1)N + N = N log N + N$
至此根据数学归纳法原理,得证$T(N) = N log N + N = O(N log N)$。
以上就是解决分治算法的时间复杂度的思路。可以看到如果除了分治步骤,其余的步骤是$O(N)$的话,一般就会成为$O(N log N)$。如果不是,则要另行分析。