知识点十二:递归树

前言

我们都知道,递归代码的时间复杂度分析起来很麻烦。在排序那一节讲过,如何利用递推公式,求解归并排序和快速排序的时间复杂度,但是,有些情况下,比如快排的平均时间复杂度的分析,用递推公式的话,会涉及非常复杂的数学推导。那么,除了用递推公式这种比较复杂的分析方法,有没有更简单的方法呢?

接下来,我们一起来了解下树这种数据结构的一种特殊应用——递归树。某些情况下,借助递归树可以快速分析递归算法的时间复杂度。

递归树与时间复杂度分析

递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树。下面画了一棵斐波那契数列的递归树,可以看到,节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。
在这里插入图片描述
看起来并不复杂。现在,我们就通过几个实际的递归算法,来看下如何用递归树来求解时间复杂度。

一、分析归并排序的时间复杂度

归并排序每次会将数据规模一分为二。把归并排序画成递归树,就是下面这个样子:
在这里插入图片描述
因为每次分解都是一分为二,所以代价很低,我们把这个分解操作在时间上的消耗记作常量 1。归并算法中比较耗时的是归并操作,即把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的时间记作 n。现在,我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n∗h)。

结合归并排序的原理,可以看出来,归并排序的递归树是一棵满二叉树。我们知道,满二叉树的高度大约是 log2​n,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。

二、分析快速排序的时间复杂度

在用递归树推导之前,我们先来回忆一下用递推公式的分析方法。为什么说用递推公式来求解平均时间复杂度非常复杂?

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T(n)=2T(n/2​)+n,很容易就能推导出时间复杂度是 O(nlogn)。但是,我们并不可能每次分区都这么幸运,正好一分为二。假设平均情况下,每次分区之后,两个分区的大小比例为 1:k。当 k=9 时,如果用递推公式的方法来求解时间复杂度的话,递推公式就写成 T(n)=T(n/10​)+T(9n/10​)+n。这个公式可以推导出时间复杂度,但是推导过程非常复杂。

那我们来看看,用递归树来分析快速排序的平均情况时间复杂度,是不是会比较简单呢?
还是取 k 等于 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:
在这里插入图片描述
快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,整个快排过程遍历的数据个数就是 h∗n ,也就是说,时间复杂度就是 O(h∗n)。

因为每次分区并不是均匀地一分为二,所以快速排序的递归树并不是满二叉树。这样一个递归树的高度是多少呢?

我们知道,快速排序结束的条件就是待排序的小区间大小为 1时,也就是当叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10,最长的一个路径每次都乘以 9/10​。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 log10​n,最长的路径是 log10/9​​n。
在这里插入图片描述
所以,遍历数据的个数总和就介于 nlog10​n 和 nlog910​​n 之间。根据复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)。

如果 k=99,也就是说,每次分区极其不平均,两个区间大小是 1:99,这个时候的时间复杂度是多少呢?可以类比上面 k=9 的分析过程。当 k=99 的时候,树的最短路径就是 log100​n,最长路径是 log100/99​​n,所以总遍历数据个数介于 nlog100​n 和 nlog100/99​​n 之间。尽管底数变了,但是时间复杂度也仍然是 O(nlogn)。

也就是说,对于 k 等于 9,99,甚至是 999,9999……,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O(nlogn)

三、分析斐波那契数列的时间复杂度

递归那一节中,我们举了一个跨台阶的例子,那个例子实际上就是一个斐波那契数列。

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

先把上面的递归代码画成递归树,就是下面这个样子:
在这里插入图片描述
这棵递归树的高度是多少呢?
f(n) 分解为 f(n−1) 和 f(n−2),每次数据规模都是 −1 或者 −2,叶子节点的数据规模是 1 或者 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 −1,那最长路径大约就是 n;如果每次都是 −2,那最短路径大约就是 n/2​。

每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作常量 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗是 2,第三层的总时间消耗就是 22。依次类推,第 k 层的时间消耗就是 2k−1,那整个算法的总的时间消耗就是每一层时间消耗之和。如果路径长度都为 n,那这个时间总和就是 2n−1。
在这里插入图片描述
如果路径长度都是 n/2​ ,那整个算法的总的时间消耗就是 2n/2​−1。
在这里插入图片描述
所以,这个算法的时间复杂度就介于 O(2n) 和 O(2n/2​) 之间。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本上知道了上面算法的时间复杂度是指数级的,非常高。

四、分析全排列的时间复杂度

我们在高中的时候都学过排列组合。“如何把 n 个数据的所有排列都找出来”,这就是全排列的问题。
举个例子。比如,1,2,3 这样 3 个数据,有下面这几种不同的排列:

1, 2, 3
1, 3, 2
2, 1, 3
2, 3, 1
3, 1, 2
3, 2, 1

如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。
如果我们确定了最后一位数据,那就变成了求解剩下 n−1 个数据的排列问题。而最后一位数据可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,“n 个数据的排列” 的问题,就可以分解成 n 个 “n−1 个数据的排列” 的子问题。
如果我们把它写成递推公式,就是下面这个样子:

假设数组中存储的是123...n。
f(1,2,...n) = {最后一位是1, f(n-1)} + {最后一位是2, f(n-1)} +...+{最后一位是n, f(n-1)}

把递推公式改写成代码,就是下面这个样子:

// 调用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
  if (k == 1) {
    for (int i = 0; i < n; ++i) {
      System.out.print(data[i] + " ");
    }
    System.out.println();
  }

  for (int i = 0; i < k; ++i) {
    int tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;

    printPermutations(data, n, k - 1);

    tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;
  }
}

如果不用前面讲的递归树分析方法,这个递归代码的时间复杂度会比较难分析。现在,我们就来看下如何借助递归树,轻松分析出这个代码的时间复杂度。

首先,我们还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。
在这里插入图片描述
第一层分解有 n 次交换操作。第二层有 n 个节点,每个节点分解需要 n−1 次交换,所以第二层总的交换次数是 n∗(n−1)。第三层有 n∗(n−1) 个节点,每个节点分解需要 n−2 次交换,所以第三层总的交换次数是 n∗(n−1)∗(n−2)。以此类推,第 k 层总的交换次数就是 n∗(n−1)∗(n−2)∗…∗(n−k+1)。最后一层的交换次数就是 n∗(n−1)∗(n−2)∗…∗2∗1。每一层的交换次数之和就是总的交换次数。

n + n*(n-1) + n*(n-1)*(n-2) +... + n*(n-1)*(n-2)*...*2*1

这个公式的求和比较复杂,我们看最后一个数,n∗(n−1)∗(n−2)∗…∗2∗1 等于 n!,而前面的 n−1 个数都小于最后一个数,所以,总和肯定小于 n∗n!,也就是说,全排列的递归算法的时间复杂度大于 O(n!),小于 O(n∗n!),虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。

小结

一、递归树
1.递归的思想:将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。
2.递归树:如果把递归这个一层一层的问题分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树。

二、用递归树来求解时间复杂度

  1. 分析归并排序的时间复杂度
    归并排序的递归树是一棵满二叉树,满二叉树的高度大约是 log2​n,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。
  2. 分析快速排序的时间复杂度
    对于 k 等于 9,99,甚至是 999,9999……,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O(nlogn)。
  3. 分析斐波那契数列的时间复杂度
    f(n) 分解为 f(n−1) 和 f(n−2),每次数据规模都是 −1 或者 −2,叶子节点的数据规模是 1 或者 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 −1,那最长路径大约就是 n;如果每次都是 −2,那最短路径大约就是 n/2​。每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗是 2,第三层的总时间消耗就是 22。依次类推,第 k 层的时间消耗就是 2k−1,那整个算法的总的时间消耗就是每一层时间消耗之和。
    如果路径长度都为 n,那这个总和就是 2n−1。如果路径长度都是 n/2​ ,那整个算法的总的时间消耗就是 2n/2​−1。所以,这个算法的时间复杂度就介于 O(2n) 和 O(2n/2​) 之间。
  4. 分析全排列的时间复杂度
    如果我们确定了最后一位数据,那就变成了求解剩下 n−1 个数据的排列问题。而最后一位数据可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,“n 个数据的排列” 的问题,就可以分解成 n 个 “n−1 个数据的排列” 的子问题。
    第 k 层总的交换次数就是 n∗(n−1)∗(n−2)∗…∗(n−k+1)。总的交换次数就是每一层的交换次数之和:
    在这里插入图片描述
    总和肯定小于 n∗n!,也就是说,全排列的递归算法的时间复杂度大于 O(n!),小于 O(n∗n!)

三、递推公式法vs递推树法
有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。

参考

《数据结构与算法之美》
王争
前Google工程师

  • 5
    点赞
  • 66
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值