0. 前言
大家好,我是多选参数的程序锅,一个正在 neng 操作系统、学数据结构和算法以及 Java 的硬核菜鸡。本篇将主要介绍递归相关的内容,下面是本篇的内容提纲。
1. 递归基础
★争哥:从我自己学习数据结构和算法的经历来看,我觉得最难理解的知识点,一个是动态规划,另一个是递归。好吧,在众多不太熟练的数据结构和算法中,我也是这两个。
”
**递归从编程形式上看是函数自己调用自己,是一种编程方法。**很多数据结构和算法的实现都会采用递归这种方式,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。那么怎么理解递归呢?递归其实分为两个过程,去的过程叫过“递”,回来的过程叫做"归"。比如我们坐在电影院里看电影,想知道自己坐的是第几排(别说电影票上有写),那么我们会问前面一排的人,它是第几排,这个过程叫过“递”;之后前面一排的人同样会问再前面一排的人他是第几排,以此类推。当问到第一排的人之后,第一排的人向第二排的人回了个 1,以此类推;我们前面一排的人会给我们回了个第 n-1 排,那么这个过程叫做“归”,从而得到我们是第 n 排。
1.1. 递归使用需要满足的三个条件
要想使用递归一定要以下这三个条件,简单来说就是可以分解成子问题,这些子问题的解法和原问题思路一样,有终止条件。
一个问题的解可以分成几个子问题的解。子问题的意思是数据规模更小的问题,也就是说一个数据规模比较大的问题解可以由几个数据规模比较小的问题的解组成。
子问题除了数据规模不同之外,求解思路完全一样。也就是子问题的求解方法和当前问题的求解方法是一样。
存在递归终止条件。当前问题会被分解成子问题,子问题又会被分解成更小的子问题,以此类推下去,显然不能无限递下去,一定要终止条件,从而有归的过程。
1.2. 编写递归代码的技巧
写递归代码最关键的是找到大问题分解为小问题的规律,并且基于此写出递推公式;之后再确定终止条件(也叫做基线条件);最后将这些翻译成代码即可。
另外在编程思考递归过程的时候,千万不要铺开模拟递归的过程,也就是千万不要试图想清楚整个递和归的过程,这种实际上会进入一个思维误区。其实,**只需要考虑两层即可,即假设子问题已有答案,然后思考原问题和子问题的解怎么联系起来。**比如一个问题 A 可以分解为若干子问题 B、C、D,那么假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A 即可。不要去想一层层调用关系,不要试图用人脑分解递归的每个步骤,屏蔽掉这些细节。
1.3. 递归方式存在的弊端
在递归实现代码时,会遇到很多问题,比如堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等问题。
堆栈溢出
因为递归的本质是函数调用,而函数调用过程中会使用栈来保存临时变量(栈中保存着未完成的函数调换用)。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有栈溢出的风险。
那么如何避免栈溢出呢?可以设置递归的层次,一旦超过一定层次之后,就不在往下递归了,直接返回报错。但是这种方式不能完全解决问题,因为可能层次设置太大,在未达到一定层次之前就已经栈溢出了。因此,这种方式适合最大深度比较小的。
重复计算
在递归的过程中还会出现重复计算的问题,如下面这个递归过程中就存在大量的重复计算:想要计算f(5),需要先计算 f(4) 和 f(3) ,而计算 f(4) 又会计算 f(3),f(3) 就被重复计算了。
为了避免重复计算,可以使用一个数据结构(比如散列表)来保存已经求解过的 f(k) 值。当递归到 k 的时候判断,f(k) 是否已经求解过了,如果求解过了,那么直接返回,不需要重复计算。
函数调用耗时多、空间复杂度高
递归中会涉及到很多函数调用,当函数调用的数量比较多的时候,会使得耗时比较多。同时,由于调用一次就会在内核栈中保存一次现场数据,因此空间复杂度也会比较大。
1.4. 如何改写为非递归代码
针对上述递归存在的问题,可以将递归代码转化为非递归的形式。**一般来说,递归代码都可以改写成非递归代码的形式。**因为递归本身就是借助栈来实现的,只不过递归使用的是系统栈或者虚拟机提供的。假如我们自己实现一个栈,模拟入栈、出栈的过程的话,那也是可以的(比如图的深度优先比例时可以使用栈和循环来实现,一般情况都是使用递归)。
上述说到了模拟栈的方式,但是在有些递归代码改为非递归代码的形式中,不一定要那么做。**对于同一个问题而言,递归代码是从最大的问题开始,先层层分解,分解完成之后会得到结果,再将结果层层返回,这是有一个有去有回的过程;假如我们知道子问题的答案的话,可以直接从子问题的答案开始,然后子问题求出大的问题的答案,这种相当于只取了归的过程。**比如有这么个递归式:f(n)=f(n-1)+1,终止条件是f(1)=1,那么改为非递归的形式,如下所示。下面这种方式,其实就相当于从子问题的答案出发,从而推得更大问题的解,比如 f(1) = 1,推得 f(2) = f(1)+1=2。
int f(int n) {
int ret = 1;
for (int i=2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
从上述的例子中我们可以得出这样一句话,使用递归可以让解决方法更清晰,但是并没有性能上的优势;而使用循环的性能更好。
2. 递归树---递归代码的复杂度分析
递归代码的复杂度一般比较难分析,一般可以通过递推公式推导的方式来求解复杂度。但是有时候递推公式推导比较繁琐,这个时候我们可以使用递归树的方式来分析递归算法的复杂度。(个人认为其实掌握递归树即可,递推公式最终也可以转换为递归树,只是递推公式时没有显式的树过程)。
递归的思想就是将大问题分解为小问题来求解,然后再将小问题分解成小小问题。这样一层层分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。那么将这个过程画成一颗树,这颗树就叫做递归树。
比如斐波那契使用递归的方式求解时,就可以画出下面这样一颗递归树。节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个子问题的求解。
下面通过举几个例子来讲解递归树求解的方法。
2.1. 归并排序
归并排序的每次分解都是一分为二,整个递归过程画成递归树之后如图所示。m(n) 的时间复杂度为 m(n/2) 的时间复杂度乘以 2,加上合并所需要的时间复杂度。而 m(n/2) 的时间复杂度等于 m(n/4) 的时间复杂度乘以 2,加上合并所需要的时间度。以此类推,最终时间复杂度为 m(1) 乘以 n,再加上这过程的合并操作所需的时间复杂度。
每一层合并操作所需要的时间复杂度是 O(n),m(1) 的时间复杂度为 O(1)。合并的次数为高度
(从 0 开始算),那么最终时间复杂度为 (高度+1)*O(n)
。从归并排序的原理和递归树来看,归并排序的递归树是一颗满二叉树。那么这颗数的高度为