这才是面试官想听的:详解「递归」正确的打开方式
即将开播:5月14日,Jenkins在K8S下的三种部署流程和实战演示
本文转载自微信公众号「 码农田小齐」,转载本文请联系 码农田小齐公众号。
前言
递归,是一个非常重要的概念,也是面试中非常喜欢考的。因为它不但能考察一个程序员的算法功底,还能很好的考察对时间空间复杂度的理解和分析。
本文只讲一题,也是几乎所有算法书讲递归的第一题,但力争讲出花来,在这里分享四点不一样的角度,让你有不同的收获。
- 时空复杂度的详细分析
- 识别并简化递归过程中的重复运算
- 披上羊皮的狼
- 适当炫技助我拿到第一份工作
算法思路
大家都知道,一个方法自己调用自己就是递归,没错,但这只是对递归最表层的理解。
那么递归的实质是什么?
答:递归的实质是能够把一个大问题分解成比它小点的问题,然后我们拿到了小问题的解,就可以用小问题的解去构造大问题的解。
那小问题的解是如何得到的?
答:用再小一号的问题的解构造出来的,小到不能再小的时候就是到了零号问题的时候,也就是 base case 了。
那么总结一下递归的三个步骤:
Base case:就是递归的零号问题,也是递归的终点,走到最小的那个问题,能够直接给出结果,不必再往下走了,否则,就会成死循环;
拆解:每一层的问题都要比上一层的小,不断缩小问题的 size,才能从大到小到 base case;
组合:得到了小问题的解,还要知道如何才能构造出大问题的解。
所以每道递归题,我们按照这三个步骤来分析,把这三个问题搞清楚,代码就很容易写了。
斐波那契数列
这题虽是老生常谈了,但相信我这里分享的一定会让你有其他收获。
题目描述
斐波那契数列是一位意大利的数学家,他闲着没事去研究兔子繁殖的过程,研究着就发现,可以写成这么一个序列:1,1,2,3,5,8,13,21… 也就是每个数等于它前两个数之和。那么给你第 n 个数,问 F(n) 是多少。
解析
用数学公式表示很简单:
代码也很简单,用我们刚总结的三步:
- base case: f(0) = 0, f(1) = 1.
- 分解:f(n-1), f(n-2)
- 组合:f(n) = f(n-1) + f(n-2)
那么写出来就是:
class Solution { public int fib(int N) { if (N == 0) { return 0; } else if (N == 1) { return 1; } return fib(N-1) + fib(N-2); } }
但是这种解法 Leetcode 给出的速度经验只比 15% 的答案快,因为,它的时间复杂度实在是太高了!
过程分析
那这就是我想分享的第一点,如何去分析递归的过程。
首先我们把这颗 Recursion Tree 画出来,比如我们把 F(5) 的递归树画出来:
那实际的执行路线是怎样的?
首先是沿着最左边这条线一路到底:F(5) → F(4) → F(3) → F(2) → F(1),好了终于有个 base case 可以返回 F(1) = 1 了,然后返回到 F(2) 这一层,再往下走,就是 F(0),又触底反弹,回到 F(2),得到 F(2) = 1+0 =1 的结果,把这个结果返回给 F(3),然后再到 F(1),拿到结果后再返回 F(3) 得到 F(3) = 左 + 右 = 2,再把这个结果返上去...
这种方式本质上是由我们计算机的冯诺伊曼体系造就的,目前一个 CPU 一个核在某一时间只能执行一条指令,所以不能 F(3) 和 F(4) 一起进行了,一定是先执行了 F(4) (本代码把 fib(N-1) 放在前面),再去执行 F(3).
我们在 IDE 里 debug 就可以看到栈里面的情况:这里确实是先走的最左边这条线路,一共有 5 层,然后再一层层往上返回。
时间复杂度分析
- 如何评价一个算法的好坏?
很多问题都有多种解法,毕竟条条大路通罗马。但如何评价每种方法的优劣,我们一般是用大 O 表达式来衡量时间和空间复杂度。
- 时间复杂度:随着自变量的增长,算法所需时间的增长情况。
这里大 O 表示的是一个算法在 worst case 的表现情况,这就是我们最关心的,不然春运抢车票的时候系统 hold 不住了,你跟我说这个算法很优秀?
当然还有其他衡量时间和空间的方式,比如
- Theta: 描述的是 tight bound
- Omega(n): 这个描述的是 best case,最好的情况,没啥意义
这也给我们了些许启发,不要说你平时表现有多好,没有意义;面试衡量的是你在 worst case 的水平;不要说面试没有发挥出你的真实水平,扎心的是那就是我们的真实水平。
- 那对于这个题来说,时间复杂度是多少呢?
答:因为我们每个节点都走了一遍,所以是把所有节点的时间加起来就是总的时间。
在这里,我们在每个节点上做的事情就是相加求和,是 O(1) 的操作,且每个节点的时间都是一样的,所以:
总时间 = 节点个数 * 每个节点的时间
那就变成了求节点个数的数学题:
在 N = 5 时,
最上面一层有1个节点,
第二层 2 个,
第三层 4 个,
第四层 8 个,
第五层 16 个,如果填满的话,想象成一颗很大的树:)
这里就不要在意这个没填满的地方了,肯定是会有差这么几个 node,但是大 O 表达的时间复杂度我们刚说过了,求的是 worst case.
那么总的节点数就是:
1 + 2 + 4 + 8 + 16
这就是一个等比数列求和了,当然你可以用数学公式来算,但还有个小技巧可以帮助你快速计算:
其实前面每一层的节点相加起来的个数都不会超过最后一层的节点的个数,总的节点数最多也就是最后一层节点数 * 2,然后在大 O 的时间复杂度里面常数项也是无所谓的,所以这个总的时间复杂度就是:
最后一层节点的个数:2^n
没看懂?别慌,去 B 站/油管看我的视频讲解哦,搜「田小齐」就好了。
空间复杂度分析
一般书上写的空间复杂度是指:
- 算法运行期间所需占用的所有内存空间
但是在公司里大家常用的,也是面试时问的指的是
Auxiliary space complexity:
- 运行算法时所需占用的额外空间。
举例说明区别:比如结果让你输出一个长度为 n 的数组,那么这 O(n) 的空间是不算在算法的空间复杂度里的,因为这个空间是跑不掉的,不是取决于你的算法的。
那空间复杂度怎么分析呢?
我们刚刚说到了冯诺伊曼体系,从图中也很容易看出来,是最左边这条路线占用 stack 的空间最多,一直不断的压栈,也就是从 5 到 4 到 3 到 2 一直压到 1,才到 base case 返回,每个节点占用的空间复杂度是 O(1),所以加起来总的空间复杂度就是 O(n).
我在上面