干货满满!全面详解如何用递归解题!
作者 | kunge
责编 | Elle
前言
递归是算法中一种非常重要的思想,应用也很广,小到阶乘,再在工作中用到的比如统计文件夹大小,大到 Google 的 PageRank 算法都能看到,也是面试官很喜欢的考点
最近看了不少递归的文章,收获不小,不过我发现大部分网上的讲递归的文章都不太全面,主要的问题在于解题后大部分都没有给出相应的时间/空间复杂度,而时间/空间复杂度是算法的重要考量!递归算法的时间复杂度普遍比较难(需要用到归纳法等),换句话说,如果能解决递归的算法复杂度,其他算法题题的时间复杂度也基本不在话下。另外,递归算法的时间复杂度不少是不能接受的,如果发现算出的时间复杂度过大,则需要转换思路,看下是否有更好的解法 ,这才是根本目的,不要为了递归而递归!
本文试图从以下几个方面来讲解递归
什么是递归?
递归算法通用解决思路
实战演练(从初级到高阶)
力争让大家对递归的认知能上一个新台阶,特别会对递归的精华:时间复杂度作详细剖析,会给大家总结一套很通用的求解递归时间复杂度的套路,相信你看完肯定会有收获
简单地说,就是如果在函数中存在着调用函数本身的情况,这种现象就叫递归。
以阶乘函数为例,如下, 在 factorial 函数中存在着 factorial(n - 1) 的调用,所以此函数是递归函数
public int factorial(int n) {
if (n < =1) {
return 1;
}
return n * factorial(n - 1)
}
求解问题 f(6), 由于 f(6) = n * f(5), 所以 f(6) 需要拆解成 f(5) 子问题进行求解,同理 f(5) = n * f(4) ,也需要进一步拆分,... ,直到 f(1), 这是「递」,f(1) 解决了,由于 f(2) = 2 f(1) = 2 也解决了,.... f(n)到最后也解决了,这是「归」,所以递归的本质是能把问题拆分成具有相同解决思路的子问题,。。。直到最后被拆解的子问题再也不能拆分,解决了最小粒度可求解的子问题后,在「归」的过程中自然顺其自然地解决了最开始的问题。
我们在上一节仔细剖析了什么是递归,可以发现递归有以下两个特点
一个问题可以分解成具有相同解决思路的子问题,子子问题,换句话说这些问题都能调用同一个函数
经过层层分解的子问题最后一定是有一个不能再分解的固定值的(即终止条件),如果没有的话,就无穷无尽地分解子问题了,问题显然是无解的。
所以解递归题的关键在于我们首先需要根据以上递归的两个特点判断题目是否可以用递归来解。
经过判断可以用递归后,接下来我们就来看看用递归解题的基本套路(四步曲):
先定义一个函数,明确这个函数的功能,由于递归的特点是问题和子问题都会调用函数自身,所以这个函数的功能一旦确定了, 之后只要找寻问题与子问题的递归关系即可
接下来寻找问题与子问题间的关系(即递推公式),这样由于问题与子问题具有相同解决思路,只要子问题调用步骤 1 定义好的函数,问题即可解决。所谓的关系最好能用一个公式表示出来,比如 f(n) = n * f(n-) 这样,如果暂时无法得出明确的公式,用伪代码表示也是可以的, 发现递推关系后,要寻找最终不可再分解的子问题的解,即(临界条件),确保子问题不会无限分解下去。由于第一步我们已经定义了这个函数的功能,所以当问题拆分成子问题时,子问题可以调用步骤 1 定义的函数,符合递归的条件(函数里调用自身)
将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中
最后也是很关键的一步,根据问题与子问题的关系,推导出时间复杂度,如果发现递归时间复杂度不可接受,则需转换思路对其进行改造,看下是否有更靠谱的解法
听起来是不是很简单,接下来我们就由浅入深地来看几道递归题,看下怎么用上面的几个步骤来套
实战演练(从初级到高阶)
热身赛
输入一个正整数n,输出n!的值。其中n!=123*…*n,即求阶乘
套用上一节我们说的递归四步解题套路来看看怎么解
定义这个函数,明确这个函数的功能,我们知道这个函数的功能是求 n 的阶乘, 之后求 n-1, n-2 的阶乘就可以调用此函数了
/**
* 求 n 的阶乘
*/
public int factorial(int n) {
}
/**
* 求 n 的阶乘
*/
public int factorial(int n) {
// 第二步的临界条件
if (n < =1) {
return 1;
}
// 第二步的递推公式
return n * factorial(n-1)
}
4.求时间复杂度由于 f(n) = n * f(n-1) = n * (n-1) * .... * f(1),总共作了 n 次乘法,所以时间复杂度为 n。
入门题
一只青蛙可以一次跳 1 级台阶或一次跳 2 级台阶,例如:跳上第 1 级台阶只有一种跳法:直接跳 1 级即可。跳上第 2 级台阶有两种跳法:每次跳 1 级,跳两次;或者一次跳 2 级。问要跳上第 n 级台阶有多少种跳法?
/**
* 跳 n 极台阶的跳法
*/
public int f(int n) {
}
2.寻找问题与子问题之前的关系这两者之前的关系初看确实看不出什么头绪,但仔细看题目,一只青蛙只能跳一步或两步台阶,自上而下地思考,也就是说如果要跳到 n 级台阶只能从 从 n-1 或 n-2 级跳, 所以问题就转化为跳上 n-1 和 n-2 级台阶的跳法了,如果 f(n) 代表跳到 n 级台阶的跳法,那么从以上分析可得 f(n) = f(n-1) + f(n-2),显然这就是我们要找的问题与子问题的关系,而显然当 n = 1, n = 2, 即跳一二级台阶是问题的最终解,于是递推公式系为
/**
* 跳 n 极台阶的跳法
*/
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2)
}
4.计算时间复杂度由以上的分析可知f(n) 满足以下公式
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// map 即保存中间态的键值对, key 为 n,value 即 f(n)
if (map.get(n)) {
return map.get(n)
}
return f(n-1) + f(n-2)
}
那么改造后的时间复杂度是多少呢,由于对每一个计算过的 f(n) 我们都保存了中间态 ,不存在重复计算的问题,所以时间复杂度是 O(n), 但由于我们用了一个键值对来保存中间的计算结果,所以空间复杂度是 O(n)。问题到这里其实已经算解决了,但身为有追求的程序员,我们还是要问一句,空间复杂度能否继续优化?
f(1) = 1
f(2) = 2
f(3) = f(1) + f(2) = 3
f(4) = f(3) + f(2) = 5
....
f(n) = f(n-1) + f(n-2)
最底层 f(1), f(2) 的值是确定的,之后的 f(3), f(4) ,...等问题都可以根据前两项求解出来,一直到 f(n)。所以我们的代码可以改造成以下方式
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int result = 0;
int pre = 1;
int next = 2;
for (int i = 3; i < n + 1; i ++) {
result = pre + next;
pre = next;
next = result;
}
return result;
}
改造后的时间复杂度是 O(n), 而由于我们在计算过程中只定义了两个变量(pre,next),所以空间复杂度是O(1)
初级题
public static class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public TreeNode invertTree(TreeNode root) {
}
2.查找问题与子问题的关系,得出递推公式我们之前说了,解题要采用自上而下的思考方式,那我们取前面的1, 2,3 结点来看,对于根节点 1 来说,假设 2, 3 结点下的节点都已经翻转,那么只要翻转 2, 3 节点即满足需求
public TreeNode invertTree(TreeNode root) {
// 叶子结果不能翻转
if (root == null) {
return null;
}
// 翻转左节点下的左右节点
TreeNode left = invertTree(root.left);
// 翻转右节点下的左右节点
TreeNode right = invertTree(root.right);
// 左右节点下的二叉树翻转好后,翻转根节点的左右节点
root.right = left;
root.left = right;
return root;
}
4.时间复杂度分析由于我们会对每一个节点都去做翻转,所以时间复杂度是 O(n),那么空间复杂度呢,这道题的空间复杂度非常有意思,我们一起来看下,由于每次调用 invertTree 函数都相当于一次压栈操作, 那最多压了几次栈呢, 仔细看上面函数的下一段代码
TreeNode left = invertTree(root.left);
从根节点出发不断对左结果调用翻转函数, 直到叶子节点,每调用一次都会压栈,左节点调用完后,出栈,再对右节点压栈....,下图可知栈的大小为3, 即树的高度,如果是完全二叉树 ,则树的高度为logn, 即空间复杂度为O(logn)
// 将 n 个圆盘从 a 经由 b 移动到 c 上
public void hanoid(int n, char a, char b, char c) {
}
2.查找问题与子问题的关系首先我们看如果 A 柱子上只有两块圆盘该怎么移
// 将 n 个圆盘从 a 经由 b 移动到 c 上
public void hanoid(int n, char a, char b, char c) {
if (n <= 0) {
return;
}
// 将上面的 n-1 个圆盘经由 C 移到 B
hanoid(n-1, a, c, b);
// 此时将 A 底下的那块最大的圆盘移到 C
move(a, c);
// 再将 B 上的 n-1 个圆盘经由A移到 C上
hanoid(n-1, b, a, c);
}
public void move(char a, char b) {
printf("%c->%c\n", a, b);
}
从函数的功能上看其实比较容易理解,整个函数定义的功能就是把 A 上的 n 个圆盘 经由 B 移到 C,由于定义好了这个函数的功能,那么接下来的把 n-1 个圆盘 经由 C 移到 B 就可以很自然的调用这个函数,所以明确函数的功能非常重要,按着函数的功能来解释,递归问题其实很好解析,切忌在每一个子问题上层层展开死抠,这样这就陷入了递归的陷阱,计算机都会栈溢出,何况人脑
进阶题
细胞分裂 有一个细胞 每一个小时分裂一次,一次分裂一个子细胞,第三个小时后会死亡。那么n个小时候有多少细胞?
public int allCells(int n) {
}
2.接下来寻找问题与子问题间的关系(即递推公式)首先我们看一下一个细胞出生到死亡后经历的所有细胞分裂过程
public int allCells(int n) {
return aCell(n) + bCell(n) + cCell(n);
}
/**
* 第 n 小时 a 状态的细胞数
*/
public int aCell(int n) {
if(n==1){
return 1;
}else{
return aCell(n-1)+bCell(n-1)+cCell(n-1);
}
}
/**
* 第 n 小时 b 状态的细胞数
*/
public int bCell(int n) {
if(n==1){
return 0;
}else{
return aCell(n-1);
}
}
/**
* 第 n 小时 c 状态的细胞数
*/
public int cCell(int n) {
if(n==1 || n==2){
return 0;
}else{
return bCell(n-1);
}
}
只要思路对了,将递推公式转成代码就简单多了,另一方面也告诉我们,可能一时的递归关系我们看不出来,此时可以借助于画图来观察规律
总结
声明:本文为作者投稿,版权归作者个人所有。
【End】热
热 文 推 荐 文 推 荐
☞图灵奖得主Bengio:深度学习不会被取代,我想让AI会推理、计划和想象
点击阅读原文,参与有奖调查!
- 解题:不用循环、递归,如何从 1 打印到 100(转)
- Github全面详解-21 如何找到适合你的开源项目
- 如何走上更高平台分享传递干货知识:(开通个人微信公众号:大数据躺过的坑)(图文详解)(博主推荐)
- 干货 | 详解如何用深度学习消除背景,实现抠图
- 解题:不用循环、递归,如何从 1 打印到 100?
- 如何快速全面建立自己的大数据知识体系? 大数据 ETL 用户画像 机器学习 阅读232 作者经过研发多个大数据产品,将自己形成关于大数据知识体系的干货分享出来,希望给大家能够快速建立起大数据
- 关于docker swarm有满满干货的一篇文章,讲了如何用service来作nginx负责proxy已级无缝升级策略
- 干货满满:小公司(网站&APP)没有数据方面的预算,推广运营人员如何用数据提升业务?
- 页面跳转:转发和重定向详解之区别(借钱的例子)以及如何进行选择!内涵干货!
- Android应用坐标系统全面详解
- Git客户端安装图文详解如何安装配置GitHub操作流程攻略
- Snort里如何将读取的包记录存到指定的目录下(图文详解)
- 如何利用 LTE/4G 伪基站+GSM 中间人攻击攻破所有短信验证 ,纯干货!| 硬创公开课
- iOS如何优雅地消除应用角标详解
- 微信小程序如何修改本地缓存key中单个数据的详解
- Android触摸事件如何实现笔触画布详解
- 矩阵与拼接屏如何连接之方案详解
- hibernate注解详解(较全面)