一文学会递归解题!

前言

递归是算法中一种很是重要的思想,应用也很广,小到阶乘,再在工做中用到的好比统计文件夹大小,大到 Google 的 PageRank 算法都能看到,也是面试官很喜欢的考点java

最近看了很多递归的文章,收获不小,不过我发现大部分网上的讲递归的文章都不太全面,主要的问题在于解题后大部分都没有给出相应的时间/空间复杂度,而时间/空间复杂度是算法的重要考量!递归算法的时间复杂度广泛比较难(须要用到概括法等),换句话说,若是能解决递归的算法复杂度,其余算法题题的时间复杂度也基本不在话下。另外,递归算法的时间复杂度很多是不能接受的,若是发现算出的时间复杂度过大,则须要转换思路,看下是否有更好的解法 ,这才是根本目的,不要为了递归而递归!python

本文试图从如下几个方面来说解递归程序员

  1. 什么是递归?
  2. 递归算法通用解决思路
  3. 实战演练(从初级到高阶)

力争让你们对递归的认知能上一个新台阶,特别会对递归的精华:时间复杂度做详细剖析,会给你们总结一套很通用的求解递归时间复杂度的套路,相信你看完确定会有收获面试

什么是递归

简单地说,就是若是在函数中存在着调用函数自己的状况,这种现象就叫递归。算法

以阶层函数为例,以下, 在 factorial 函数中存在着 factorial(n - 1) 的调用,因此此函数是递归函数函数

public int factorial(int n) {
    if (n < =1) {
        return 1;
    }
    return n * factorial(n - 1)
}

进一步剖析「递归」,先有「递」再有「归」,「递」的意思是将问题拆解成子问题来解决, 子问题再拆解成子子问题,...,直到被拆解的子问题无需再拆分红更细的子问题(便可以求解),「归」是说最小的子问题解决了,那么它的上一层子问题也就解决了,上一层的子问题解决了,上上层子问题天然也就解决了,....,直到最开始的问题解决,文字说可能有点抽象,那咱们就以阶层 f(6) 为例来看下它的「递」和「归」。工具


求解问题 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. 一个问题能够分解成具备相同解决思路的子问题,子子问题,换句话说这些问题都能调用同一个函数
  2. 通过层层分解的子问题最后必定是有一个不能再分解的固定值的(即终止条件),若是没有的话,就无穷无尽地分解子问题了,问题显然是无解的。

因此解递归题的关键在于咱们首先须要根据以上递归的两个特色判断题目是否能够用递归来解。3d

通过判断能够用递归后,接下来咱们就来看看用递归解题的基本套路(四步曲):

  1. 先定义一个函数,明确这个函数的功能,因为递归的特色是问题和子问题都会调用函数自身,因此这个函数的功能一旦肯定了, 以后只要找寻问题与子问题的递归关系便可
  2. 接下来寻找问题与子问题间的关系(即递推公式),这样因为问题与子问题具备相同解决思路,只要子问题调用步骤 1 定义好的函数,问题便可解决。所谓的关系最好能用一个公式表示出来,好比 f(n) = n * f(n-) 这样,若是暂时没法得出明确的公式,用伪代码表示也是能够的, 发现递推关系后,要寻找最终不可再分解的子问题的解,即(临界条件),确保子问题不会无限分解下去。因为第一步咱们已经定义了这个函数的功能,因此当问题拆分红子问题时,子问题能够调用步骤 1 定义的函数,符合递归的条件(函数里调用自身)
  3. 将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中
  4. 最后也是很关键的一步,根据问题与子问题的关系,推导出时间复杂度,若是发现递归时间复杂度不可接受,则需转换思路对其进行改造,看下是否有更靠谱的解法

听起来是否是很简单,接下来咱们就由浅入深地来看几道递归题,看下怎么用上面的几个步骤来套

实战演练(从初级到高阶)

热身赛

输入一个正整数n,输出n!的值。其中n!=123n,即求阶乘

套用上一节咱们说的递归四步解题套路来看看怎么解

  1. 定义这个函数,明确这个函数的功能,咱们知道这个函数的功能是求 n 的阶乘, 以后求 n-1, n-2 的阶乘就能够调用此函数了
/**
 * 求 n 的阶乘
 */
public int factorial(int n) {
}

2.寻找问题与子问题的关系
阶乘的关系比较简单, 咱们以 f(n) 来表示 n 的阶乘, 显然 f(n) = n * f(n - 1), 同时临界条件是 f(1) = 1,即
$$
f(n)=
\begin{cases}
1, n = 1 \
n * f(n-1)
\end{cases}
$$

3.将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中

/**
 * 求 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 级台阶有多少种跳法?

咱们继续来按四步曲来看怎么套路

1.定义一个函数,这个函数表明了跳上 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, 即跳一二级台阶是问题的最终解,因而递推公式系为

$$
f(n)=
\begin{cases}
1, n = 1 \
2, n = 2 \
f(n-1) + f(n-2)
\end{cases}
$$

3.将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中
补充后的函数以下

/**
 * 跳 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) 知足如下公式
$$
f(n)=
\begin{cases}
1, n = 1 \
2, n = 2 \
f(n-1) + f(n-2)
\end{cases}
$$

斐波那契的时间复杂度计算涉及到高等代数的知识, 这里不作详细推导,有兴趣的同窗能够点击这里查看,咱们直接结出结论

由些可知时间复杂度是指数级别,显然不可接受,那回过头来看为啥时间复杂度这么高呢,假设咱们要计算 f(6),根据以上推导的递归公式,展现以下

能够看到有大量的重复计算, f(3) 计算了 3 次, 随着 n 的增大,f(n) 的时间度天然呈指数上升了

5.优化
既然有这么多的重复计算,咱们能够想到把这些中间计算过的结果保存起来,若是以后的计算中碰到一样须要计算的中间态,直接在这个保存的结果里查询便可,这就是典型的 以空间换时间,改造后的代码以下

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)。问题到这里其实已经算解决了,但身为有追求的程序员,咱们仍是要问一句,空间复杂度可否继续优化?

5.使用循环迭代来改造算法
咱们在分析问题与子问题关系(f(n) = f(n-1) + f(n-2))的时候用的是自顶向下的分析方式,但其实咱们在解 f(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)

简单总结一下: 分析问题咱们须要采用自上而下的思惟,而解决问题有时候采用自下而上的方式能让算法性能获得极大提高,思路比结论重要

初级题

接下来咱们来看下一道经典的题目: 反转二叉树
将左边的二叉树反转成右边的二叉树

接下来让咱们看看用咱们以前总结的递归解法四步曲如何解题

1.定义一个函数,这个函数表明了 翻转以 root 为根节点的二叉树

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 节点即知足需求

对于2, 3 结点来讲,也是翻转其左右节点便可,依此类推,对每个根节点,依次翻转其左右节点,因此咱们可知问题与子问题的关系是
翻转(根节点) = 翻转(根节点的左节点) + 翻转(根节点的右节点)

invert(root) = invert(root->left) + invert(root->right)

而显然递归的终止条件是当结点为叶子结点时终止(由于叶子节点没有左右结点)

3.将第二步的递推公式用代码表示出来补充到步骤 1 定义的函数中

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,此时空间复杂度为 O(n),总的来看,空间复杂度为O(n)

说句题外话,这道题当初曾引发轰动,由于 Mac 下著名包管理工具 homebrew 的做者 Max Howell 当初解不开这道题,结果被 Google 拒了,也就是说若是你解出了这道题,就超越了这位世界大神,想一想是否是很激动

中级题

接下来咱们看一下大学时学过的汉诺塔问题:
  以下图所示,从左到右有A、B、C三根柱子,其中A柱子上面有从小叠到大的n个圆盘,现要求将A柱子上的圆盘移到C柱子上去,期间只有一个原则:一次只能移到一个盘子且大盘子不能在小盘子上面,求移动的步骤和移动的次数

接下来套用咱们的递归四步法看下这题怎么解

1.定义问题的递归函数,明确函数的功能,咱们定义这个函数的功能为:把 A 上面的 n 个圆盘经由 B 移到 C

// 将 n 个圆盘从 a 经由 b 移动到 c 上
public void hanoid(int n, char a, char b, char c) {
}

2.查找问题与子问题的关系
首先咱们看若是 A 柱子上只有两块圆盘该怎么移

前面咱们屡次提到,分析问题与子问题的关系要采用自上而下的分析方式,要将 n 个圆盘经由 B 移到 C 柱上去,能够按如下三步来分析
* 将 上面的 n-1 个圆盘当作是一个圆盘,这样分析思路就与上面提到的只有两块圆盘的思路一致了
* 将上面的 n-1 个圆盘经由 C 移到 B
* 此时将 A 底下的那块最大的圆盘移到 C
* 再将 B 上的 n-1 个圆盘经由A移到 C上

有人问第一步的 n - 1 怎么从 C 移到 B,重复上面的过程,只要把 上面的 n-2个盘子经由 A 移到 B, 再把A最下面的盘子移到 C,最后再把上面的 n - 2 的盘子经由A 移到 B 下..., 怎么样,是否是找到规律了,不过在找问题的过程当中 切忌把子问题层层展开,到汉诺塔这个问题上切忌再分析 n-3,n-4 怎么移,这样会把你绕晕,只要找到一层问题与子问题的关系得出能够用递归表示便可。

由以上分析可得

move(n from A to C) = move(n-1 from A to B) + move(A to C) + move(n-1 from B to C`)

必定要先得出递归公式,哪怕是伪代码也好!这样第三步推导函数编写就容易不少,终止条件咱们很容易看出,当 A 上面的圆盘没有了就不移了

3.根据以上的递归伪代码补充函数的功能

// 将 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 就能够很天然的调用这个函数,因此明确函数的功能很是重要,按着函数的功能来解释,递归问题其实很好解析,切忌在每个子问题上层层展开死抠,这样这就陷入了递归的陷阱,计算机都会栈溢出,况且人脑

4.时间复杂度分析
从第三步补充好的函数中咱们能够推断出

f(n) = f(n-1) + 1 + f(n-1) = 2f(n-1) + 1
= 2(2f(n-2) + 1) + 1 = 2 * 2 * f(n-2) + 2 + 1 = 22 * f(n-3) + 2 + 1
= 22 * f(n-3) + 2 + 1 = 22 * (2f(n-4) + 1) = 23 * f(n-4) + 22 + 1
= .... // 不断地展开
= 2n-1 + 2n-2 + ....+ 1

显然时间复杂度为 O(2n),很明显指数级别的时间复杂度是不能接受的,汉诺塔非递归的解法比较复杂,你们能够去网上搜一下

进阶题

现实中大厂中的不少递归题都不会用上面这些相对比较容易理解的题,更加地是对递归问题进行相应地变形, 来看下面这道题

细胞分裂 有一个细胞 每个小时分裂一次,一次分裂一个子细胞,第三个小时后会死亡。那么n个小时候有多少细胞?

照样咱们用前面的递归四步曲来解

1.定义问题的递归函数,明确函数的功能
咱们定义如下函数为 n 个小时后的细胞数

public int allCells(int n) {
}

2.接下来寻找问题与子问题间的关系(即递推公式
首先咱们看一下一个细胞出生到死亡后经历的全部细胞分裂过程

图中的 A 表明细胞的初始态, B表明幼年态(细胞分裂一次), C 表明成熟态(细胞分裂两次),C 再经历一小时后细胞死亡
以 f(n) 表明第 n 小时的细胞分解数
fa(n) 表明第 n 小时处于初始态的细胞数,
fb(n) 表明第 n 小时处于幼年态的细胞数
fc(n) 表明第 n 小时处于成熟态的细胞数
则显然 f(n) = fa(n) + fb(n) + fc(n)
那么 fa(n) 等于多少呢,以n = 4 (即一个细胞经历完整的生命周期)为例

仔细看上面的图

能够看出
fa(n) = fa(n-1) + fb(n-1) + fc(n-1), 当 n = 1 时,显然 fa(1) = 1

fb(n) 呢,看下图可知 fb(n) = fa(n-1)。 当 n = 1 时 fb(n) = 0

fc(n) 呢,看下图可知 fc(n) = fb(n-1)。当 n = 1,2 时 fc(n) = 0

综上, 咱们得出的递归公式以下

f(n) = fa(n) + fb(n) + fc(n)

$$
f_a(n)=
\begin{cases}
f_a(n-1) + f_b(n-1) + f_c(n-1)\
1, n = 1 \
f(n-1) + f(n-2)
\end{cases}
$$

$$
f_b(n)=
\begin{cases}
f_a(n-1)\
0, n = 1 \
\end{cases}
$$

$$
f_c(n)=
\begin{cases}
f_b(n-1)\
0, n = 1 \
0, n = 2 \
\end{cases}
$$

3.根据以上的递归公式咱们补充一下函数的功能

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);
    }
}

只要思路对了,将递推公式转成代码就简单多了,另外一方面也告诉咱们,可能一时的递归关系咱们看不出来,此时能够借助于画图来观察规律

4.求时间复杂度
由第二步的递推公式咱们知道
f(n) = 2aCell(n-1) + 2aCell(n-2) + aCell(n-3)

以前青蛙跳台阶时间复杂度是指数级别的,而这个方程式显然比以前的递推公式(f(n) = f(n-1) + f(n-2)) 更复杂的,因此显然也是指数级别的

总结

大部分递归题其实仍是有迹可寻的, 按照以前总结的解递归的四个步骤能够比较顺利的解开递归题,一些比较复杂的递归题咱们须要勤动手,画画图,观察规律,这样能帮助咱们快速发现规律,得出递归公式,一旦知道了递归公式,将其转成递归代码就容易多了,不少大厂的递归考题并不能简单地看出递归规律,每每会在递归的基础上多加一些变形,不过万遍不离其宗,咱们多采用自顶向下的分析思惟,多练习,相信递归不是什么难事

公众号「码海」,欢迎关注!

相关文章
相关标签/搜索