10.递归算法最佳解析
关注公众号 码哥字节,设置星标获取最新推送。后台回复 “加群” 进入技术交流群获更多技术成长。
摘要:递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力
推荐用户注册领取佣金很多人都遇到过,很多 App 在推广的时候都是这个套路。「萧何」引荐「韩信」加入刘邦阵营,「韩信」又引荐了那些年上铺的兄弟「韩大胆」加入。我们就可以认为「韩大胆」的最终推荐人是「萧何」,「韩信」的最终推荐人是「萧何」,而「萧何」没有最终推荐人。
用数据库记录他们之间的关系,soldier_id
表示士兵 id,referrer_id
表示推荐人 id。
soldier_id | reference_id |
---|---|
韩信 | 萧何 |
韩大胆 | 韩信 |
那么问题来了,给定一个士兵 id,如何查找这个用户的「最终推荐人」,带着这个问题,我们正式进入递归。
递归三要素
有两个最难理解的知识点,一个是 动态规划,一个是递归。
大学军训,都会经历过排队报数,报数过程中自己开小差看见了一个漂亮小学姐,不知道旁边的哥们刚说的数字,所以再问一下左边哥们刚报了多少,只要在他说的数字 + 1 就知道自己树第几个了,关键是现在你旁边的哥们 看见漂亮小学姐竟然忘记刚刚自己说的数字了,也要继续问他左边的老铁,就这样一直往前问,直到第一个报数的孩子,然后一层层把数字传递到自己。
这就是一个非常标准的递归求解过程,问的过程叫「递」,回来的过程交「归」。转换成递推公式:
f(n)=f(n-1) + 1, 存在 f(1) = 1
f(n) 表示自己的数字,f(n - 1) 表示前面一个人的报数,f(1) 表示第一个人知道自己是第一个报的数字
。
根据递推公式,很容易的转换成递归代码:
public int f(int n) { if (n == 1) return 1; return f(n-1) + 1; }
到底什么问题可以用递归解决呢?总结了三个必要元素,只要满足一下三个条件,就可以使用递归解决。
1.一个问题可以分解多个子问题
就是可以分解恒数字规模更小的问题,比如要知道自己的报数,可以分解『前一个人的报数』这样的子问题。
2.问题本身与分解后的子问题,除了数据规模不同,求解算法相同
『求解自己的报数』和前面一个人『求解自己的报数』思路是一模一样。
3.存在递归终止条件
问题分解成子问题的过程中,不能出现无限循环,所以需要一个终止条件,就像第一排或者其中任何一个知道自己报数的孩子不需要再询问上一个人的数字,f(1) = 1 就是递归终止条件。
如何编写递归代码
其实最关键的就是 写出递推公式,找到终止条件,然后把递推公式转成 代码就容易多了。
再举一个「青蛙跳台阶」的算法问题,假设有 n 个台阶,每次可以跳 1 个或者 2 个台阶,走这 n 个台阶有多少种走法?
再仔细想想,实际上,根据第一步的走法可以把所有的走法分两类,第一类是第一步走了 1 个台阶,另一种是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后, n-1 个台阶的走法 + 先走 2 阶后, n-2 个台阶的走法。
f(n) = f(n-1) + f(n-2)
继续分析终止条件,当只有一个台阶的时候不需要再继续递归,f (1) = 1。似乎还不够,假如有两个台阶呢?分别用 n = 2、n=3 验证下。f(2) = 2 也是终止条件之一。
所以该递归的终止条件就是 f(1) = 1,f(2) = 2。
f(1) = 1; f(2) = 2; f(n) = f(n-1) + f(n-2);
根据公式转成代码则是
public int f(n) { if(n == 1) return 1; if(n ==2) return 2; return f(n-1) + f(n-2); }
划重点了:写递归大妈的关键就是找到如何将大问题分解成小问题的规律,并且基于此写出递推公式,再推出终止条件,租后将地推公式和终止条件翻译成代码。
对于递归代码,我们不要试图去弄清楚整个递和归的问题,这个不适合我们的正常思维,我们大脑更适合平铺直叙的思维,当看到递归切勿妄想把递归过程平铺展开,否则会陷入一层一层往下调用的循环。
当遇到一个问题 1 可以分解若干个 2,3,4 问题,我们只要假设 2,3,4 已经解决,在此基础上思考如何解决 A。这样就容易多了。
所以当遇到递归,编写 代码的关键就是 把问题抽象成一个递推公式,不要想一层层的调用关系,找到终止条件。
防止栈溢出
递归最大的问题就是要防止栈溢出以及死循环。为何递归容易造成栈溢出呢?我们回想下之前说过的栈数据结构,不清楚的朋友可以翻阅历史文章。函数调用会使用栈来保存临时变量,每次调用一个函数都会把临时变量封装成栈帧压入线程对应的栈中,等方法结束返回时,才出栈。如果递归的数据规模比较大,调用层次很深就会导致一直压入栈,而栈的大小通常不会很大就会导致堆栈溢出的情况。
Exception in thread "main" java.lang.StackOverflowError
如何防止呢?
我们只能在代码里面限制最大深度,直接返回错误,使用一个全局变量表示递归的深度,每次执行都 + 1,当超过指定阈值还没有结束的时候直接返回错误。
警惕重复计算
青蛙跳台阶的问题就有重复计算的问题,我们试着把递归过程分解下,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。为了避免重复计算,我们可以通过一个数据结构(比如 HashMap)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。
public int f(int n) { if (n == 1) return 1; if (n == 2) return 2; // hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n) if (hasSolvedMap.containsKey(n)) { return hasSovledMap.get(n); } int ret = f(n-1) + f(n-2); hasSovledMap.put(n, ret); return ret; }
递归的空间复杂度因为每次调用都会在栈上保存一次临时变量,所以它的空间复杂度就是 O(N),而不是 O(1)。
如何将递归转换成非递归代码
递归有利有弊,递归写起来很简洁,而不好的地方就是空间复杂度是 O(n),有堆栈溢出风险,存在重复计算。要根具体情况来选择是否需要递归。
还是军训排队报数的例子,如何变成非递归。
f(n) = f(n-1) +1;
public int f(n) { int r = 1; for(int i = 2; i <= n; i++) { r += 1; } return r; }
对于台阶问题也是可以改成循环实现。
public int f(int n) { if (n == 1) return 1; if (n == 2) return 2; int ret = 0; int pre = 2; int prepre = 1; for (int i = 3; i <= n; ++i) { ret = pre + prepre; prepre = pre; pre = ret; } return ret; }
寻找最佳推荐人
现在递归说完了,我们如何解答开篇的问题:根据士兵 id 找到最佳推荐人?
public int findRootReferId(int soldierId) { Integer referId = "select reference_id from [table] where soldier_id = soldierId"; if (referId == null) return soldierId; return findRootReferId(referId); }
递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。
不过递归代码也比较难写、难理解。编写递归代码的关键就是不要把自己绕进去,正确姿势是写出递推公式,找出终止条件,然后再翻译成递归代码。
递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。
推荐阅读
4.线性表之数组
5.链表导论-心法篇
7.双向链表正确实现
原创不易,觉得有用希望随手「在看」「收藏」「转发」三连。