递归树: 如何借助树来求解递归算法的时间复杂度

------ 本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程 ------

今天,来讲树这种数据结构的一种特殊的应用,递归树。

我们都知道,递归代码的时间复杂度分析起来很麻烦,我们在排序(下)那里讲过,如何利用递推公式,求解归并排序的时间复杂度,但是,有此情况,比如快排的平均时间复杂度的分析,用递推公式的话,会涉及非常复杂的数学推导。

除了用递推公式这种复杂的分析方法,有没有更简单的方法呢?今天,我们就来学习另外一种方法,借助递归树分析递归算法的时间复杂度。

递归树与时间复杂度分析

递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为更小的问题。这样一层一层地分解,直到问题规模被分解得足够小,不用继续递归分解为止。

如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树。我这里画了一棵斐波那契数列的递归树,你可以看看。节点的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。递归树: 如何借助树来求解递归算法的时间复杂度_第1张图片

通过这个例子,你对递归的样子应该有个研发的认识了,看起来并不复杂。现在,我们就来看,如何用递归树来求解时间复杂度。

归并算法,它的递归代码非常简洁。现在我们就借助归并排序来看看,如何用递归树,来分析递归代码的时间复杂度。

归并排序的原理我就不详细介绍了,它每次会将数据规则一分为二。我们把归并排序画成递归树,就是下面的样子:递归树: 如何借助树来求解递归算法的时间复杂度_第2张图片

因为每次分解都是一分为二,所有代价很低,我们把时间上消耗记叙常量1。归并算法中比较四季风的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗时间总和是一样的,跟要排序的数据的数据规模无关。我们把每一层归并操作消耗的时间记作 n 。

现在,我们只需要知道这棵树的高度h,用高度h 乘以每一层的时间消耗n,就可以得到总的时间复杂度O(n*h)。

可以看出来,归并排序是一棵满二叉树,那h = log2n ,所以归并排序的时间复杂度就是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 倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:递归树: 如何借助树来求解递归算法的时间复杂度_第3张图片

快速排序的过程上,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是n。我们现在只要求出递归的高度h,这个快排过程遍历的个数就是 hn ,也就是说,时间复杂度就是O(hn)。

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

我们知道,快速排序结束的条件就是待排序的小区间,大小为1,也就是说叶子节点的数据规模是1。从根节点n 到叶子节点1,递归树中最短的一个路径是每次都乘以 1/10,最长的路径是每次都乘以9/10。递归树: 如何借助树来求解递归算法的时间复杂度_第4张图片

所以,根据复杂度大O表示法,对数复杂度的底数不管是多少,我们统一写成logn,所有当大小比例是1:9时,快速排序的时间复杂度仍然是O(nlogn)。当 k = 99时,算出的时间复杂度也一样。

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

// 斐波那契数列 
int f(int n){
	if(n == 1){
		return 1;
	}
		if(n == 2){
		return 2;
	}
	return f(n-1) + f(n-2)

}

我们把斐波那契数列画成递归树,就是下面的样子:
递归树: 如何借助树来求解递归算法的时间复杂度_第5张图片

这棵递归树的高度是多少呢?

f(n) 分解为f(n - 1) 和 f(n-2),每次数据规模都是-1或者-2,叶子节点的数据规模是1或者2。所以,从根节点到叶子节点,每条路径长度是不一样的。如果每次都是-1,那最长的路径大约是n;如果每次都是 -2,那最短路径大约就是 n/2。

每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作1 。所以,从上往下,第一层总时间消耗是 1,第二层总时间消耗是 2,第三层的总时间消耗就是4,依次类推,第k+1 层的时间消耗就是2^k。
如果路径长度为n,那这个总全就是 2^n -1。
在这里插入图片描述

如果路径长度都是 n/2,那整个算法的总时间消耗就是2^(n/2) -1。
在这里插入图片描述

所以,这个算法的时间复杂度就介于O(2n)和O(2n)/4之间。虽然这样得到结果还不够精确,只是现代战争范围,但是我们也基于上知道了上面的算法的时间复杂度是指数级的,非常高。

实战三:分析全排列的时间复杂度

我们高中时,学过排列组合。“如何把n 个数据的所有排列都找出来”。这就是全排列的问题。

举个例子,比如1、2、3这三个数,是有六种排列方式的:

  • 1,2,3
  • 1,3,2
  • 2,1,3
  • 2,3,1
  • 3,2,1
  • 3,1,2
    如果我们确定了最后一位数据,那就变成了求解剩下 n-1个数据的排列问题。而最后一位数据可以是 n个数据中的任意一个。所以“n 个数据的排列”问题。就可以分解成n 个“n-1个数据的排列问题。
    如果我们把它写成递推公式,就是下面这个样子:
假设数组中存储的是 1,2,3...n。
f(1,2,3...n) = {最后一位是1,f(n - 1)} + {最后一位是2,f(n - 1)} + ...+ {最后一位是n,f(n - 1)}

递推公式用代码实现,就是下面的样子

	public void pringPermutations(int[] data, int n, int k) {
		// n 表示数组大小,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 tem = data[i];
			data[i] = data[k-1];
			data[k-1] = tem;
			
			pringPermutations(data, n, k-1);
			
			tem = data[i];
			data[i] = data[k-1];
			data[k-1] = tem;
		}
	}

如果不用我前面讲的递归树的分析方法,这个递归代码的时间复杂度会比较难分析。现在,我们来看下,如何借助递归树,分析出这个代码的时间复杂度。
递归树: 如何借助树来求解递归算法的时间复杂度_第6张图片

第一层分解有n次交换操作,第二层有n 个节点,每个节点分解需要 n-1 次交换,所以第二层的交换次数是n*(n-1),同理,第三层交换次数就是n*(n-1)(n-2)。各层交换次数总合就是 n + n(n-1) + n*(n-1)(n-2) +… +n!
也就是说全排列的递归算法的时间复杂度大于O(n!),小于O(n
n!)。虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。

这里稍微说一下,掌握分析的方法很重要,思路是重点,不要你好幸福于精确的时间复杂度到底是多少。

内容小结

今天,我们用递归树分析了递归代码的时间复杂度。加上我们在排序那一节讲到的递推公式的时间复杂度分析方法,我们现在已经学习了两种递归代码的时间复杂度分析方法了。

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

时间复杂度分析的理念知识并不多,也不复杂,掌握起来也不难,但是,在我们平时工作中,面对的代码千差万别,能够灵活应用学到的复杂度分析方法,来分析现有的代码,并不是件简单的事情,这个需要多实践。

你可能感兴趣的:(递归树: 如何借助树来求解递归算法的时间复杂度)