数据结构与算法复习——2、递归分析入门

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 }
Fibonacci

 这个算法经常被当作递归的“反面教材”,因为它的冗余递归太多:求解$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 }
merge sort

其中第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 }
merge

当然归并排序的主算法和归并函数有很多种实现和优化的方式,这里就不展开了,但是总之,归并函数的时间复杂度上界不可能低于$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)$。如果不是,则要另行分析。

posted @ 2021-01-27 18:37  Halifuda  阅读(276)  评论(0编辑  收藏  举报