读懂递归
复习
- 栈:后进先出,函数调用靠它保存信息
- 函数调用与运行栈:调用信息如何被保存
- 算法与正确性:既要正确,也要能终止
TL;DR
- 递归就是函数调用自己来解决问题
- 递归必须有基本情况,否则会无限递归
- 每次调用都压入调用栈,返回时逐层弹出
- 读懂递归,就是沿着调用栈去追踪
正文
前面讲栈时提到,函数调用是靠“调用栈”来保存信息的。有意思的是,当函数调用自己时,这套机制依然成立。这种自己调用自己的写法,就是递归(recursion)。
函数调用自己
先看一个最简单的例子——计算阶乘:n! = n × (n-1) × … × 1。
用递归可以这样写:
阶乘(n):
如果 n 是 0 或 1,返回 1
否则,返回 n × 阶乘(n - 1)
注意这里有两个部分,缺一不可:
- 基本情况(base case):
n是 0 或 1 时直接返回 1,不再递归 - 递归情况:把问题变成“更小的同类问题”,再乘上
n
没有出口,就会一直走下去
基本情况为什么必须有?因为它就是递归的“出口”。
如果没有它,函数就会不停地调用自己:阶乘(n) 调 阶乘(n-1),再调 阶乘(n-2)……永远停不下来,直到把调用栈堆满,程序崩溃——这就是栈溢出(stack overflow)。递归一定要能收敛到某个不再递归的点。
用栈的视角看递归
递归到底是怎么执行的?把它和调用栈联系起来就很清楚了。
以 阶乘(4) 为例:
阶乘(4)需要阶乘(3),于是先把 4 压栈,去算阶乘(3)阶乘(3)又需要阶乘(2),再压栈……- 一直到
阶乘(1),命中基本情况,返回 1 - 然后开始逐层弹出:
阶乘(2) = 2 × 1,阶乘(3) = 3 × 2……最终得到阶乘(4) = 24
所以,递归并没有什么魔法。它只是把“先挂起、等更小的结果、再回来继续算”这个过程,交给了调用栈。看懂了栈,就看懂了递归的执行过程。
那么,怎样主动设计出一个正确的递归?下一章来谈。
思考题 1
为什么递归必须有“基本情况”?
思考题 2
递归调用和调用栈之间是什么关系?
小结
知识点
- 递归是函数调用自身来解决问题
- 必须有基本情况作为出口
- 缺少出口会导致无限递归与栈溢出
- 递归的执行过程可借助调用栈理解
参考资料
- Wikipedia(zh):递归:函数调用自身的编程方法
- Wikipedia(zh):调用栈:保存函数调用信息的结构
思考题答案(仅供参考)
思考题 1
因为它是递归的终止条件。没有它,问题会一直缩小却永不停止,函数不断调用自己,最终耗尽调用栈导致栈溢出。有了基本情况,递归才能在有限步内收敛并返回。
思考题 2
递归调用本身就借助调用栈来实现:每深入一层调用,相关信息就被压入栈;命中基本情况后,再逐层弹出并完成计算。因此调用栈正是理解递归执行过程的钥匙。
协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
封面图
设计师 | 南国微雪