学习《数据结构与算法之美》时的笔记。
如何理解递归
例:领导分配给你一个任务量为n的任务,你做了一部分,然后把剩下的n-1分配给别人,别人又做了一部分,把剩下的n-2分配给了另一个人,直到最后一个人,做了一部分,然后把任务完成返回给他上一个人。
这个任务下发的过程叫递,任务返回的过程叫归。
基本上,所有的递归问题都可以用递推公式来表示,上面这个例子,递推公式表示为:f(n)=f(n-1)+1 其中,f(1)=1
。
上述递归问题的代码表示如下:
1 | int f(int n) |
递归需要满足的条件
1)一个问题可以分解为几个字问题的解
2)这个问题与分解之后的字问题,除了数据规模不同,求解思路完全一样
3)存在递归终止条件
举个栗子:
树的定义是一种递归形式的定义,对于树的前序遍历,有:
1 | void PreOrder(struct TreeNode *root) |
在一些树的相关题目中,以这种递推的思想来处理问题还是比较方便的。
递归代码如何实现
写递归代码最关键的是写出递推公式,找到终止条件。
青蛙跳台阶问题:
假设有n个台阶,每次青蛙可以跳1个台阶或者2个台阶,请问走完这n个台阶有多少种走法?
例如:有7个台阶,可以2,2,2,1上去,也可以1,2,1,1,2这样上去,总之走法很多,如何编程实现呢?
分析:根据第一步走法把所有走法分为2类,第一类:第一步走了1个台阶,第二类:第一步走了2个台阶。所以n个台阶的走法 =(先走1个台阶后,n-1个台阶的走法)+(先走2个台阶后,n-2个台阶的走法)
用递推公式表示就是:
f(n) = f(n-1)+f(n-2)
递推的终止条件:
当有一个台阶时,不需要再继续递归,只有一中走法。
当有两个台阶时,可以每次走1个台阶,走两次,也可以一次两个。
当有三个台阶时,就分解为有一个台阶和两个台阶的问题了。
所以有:
1 | int f(int n) |
人脑几乎没办法把整个递归的过程一步一步想清楚,所以很多时候,我们只需要把它抽象成一个递推公式,不用想一层一层的调用关系,这些交给计算机来做就好了。
要注意栈溢出!
如果递归求解的数据规模很大,调用层次很深,就会有栈溢出的发生。
我的一次栈溢出的经历:在练习用递归模拟strlen函数功能时,如下代码:
1 | int my_strlen(char *str) |
这里传进去的始终是str,所以没有终止的一直压栈,导致栈溢出。
解决栈溢出的方法:
1)限制递归深度
这种做法并不能完全解决问题,因为最大允许的递归深度和当前线程剩余的栈空间大小有关,事先无法计算,如果实时计算,代码过于复杂,影响可读性。所以对于最大深度比较小的情况,如10、50,就可以用这种方法,否则这种方法并不是很实用。
2)在堆区模拟系统的工作栈
如果需要可以采用这种方法。
警惕重复计算
以青蛙跳台阶的问题为例:
从图中可以看到:想要计算f(5),需要先计算f(4)和f(3),而f(4)还需要计算f(3),因此,f(3)被重复计算了多次,这就是重复计算问题!
还有个例子:递归方法求第n个斐波那契数,也会有同一n重复计算多次的现象。
当计算第50个斐波那契数时,f(3)被计算了上亿次,这个计算花了10分钟左右!
重复计算的解决方法:
为了避免这种重复计算带来的负面开销,可以通过引入散列表来保存已经求过的f(k)。当递归调用到f(k)时,先看下是否求解过了,如果是直接从散列表中取这个值,就不需要再次计算了。
递归代码改写为非递归代码
递归有利有弊,递归的问题不仅仅就上面说的重复计算和栈溢出,递归的空间复杂度有时也比较高。
如果用递归方法求第n个斐波那契数,时间开销比较大,还是用循环比较好。
对于上文中两个例子改写如下:
1 | /* 领导分配工作 */ |
1 | /* 青蛙跳台阶问题 */ |
这种思路其实是将递归改成了“手动”递归,本质没有变,增加了实现难度。
经典递归问题
- 在n个球中,任意取m个(不放回),求有多少种取法
1 |
|
- 求n个元素的全排列
1 | // abc acb bac bca cab cba |
- 两个串的最大公共子序列的长度
1 | // 可解! |
- 字符串反转
1 | // 反转串 |
- 组合问题
1 | // 组合问题 |
- 杨辉三角
1 | // 杨辉三角 |
- 整数划分问题
1 | // 整数划分问题 |
参考文章:《数据结构与算法之美》