1. 递归算法:先别背模板,搞懂它到底在做什么
很多人一听到“递归”两个字,第一反应是“自己调用自己”。这句话对了一半,但只记住这句话基本写不出好递归。我见过不少新手,背住了递归模板,写出来的函数却要么死循环,要么算到一半结果完全不对。
先给一个非常经典的例子,就是从网上流传的一些算法题变化来的伪代码:
code复制solve(n)
if n <= 1:
return 1
else if n >= 5:
return n * solve(n - 2)
你可能觉得这个函数写得怪怪的,甚至看不出它要算什么。这恰恰说明,理解递归不能只看代码长什么样,而要搞清楚一个问题:它到底是怎么一步步把大问题拆成小问题的?
递归的本质,是用“重复”去解决“规模不同但结构相同”的问题。你可以把递归看成一面镜子:函数在执行过程中遇到比自己规模更小的同类问题,就再次调用自己,然后用一个明确的“底部”条件让调用链停下来。
这个底部条件,我们叫它“递归出口”或者“基准情形”。没有出口的递归就是死循环,Python会直接抛RecursionError,连程序都救不回来。
所以我的建议是:学习递归,不要先急着写代码。先找一张纸,把你认为递归函数会怎么执行的过程画出来。画不出来,说明你还没理解问题是“怎么被拆开的”。等你能画清楚,代码自然就写出来了。
这篇文章我会用一个少见的递归函数例子切入,把递归的原理、运行机制、应用场景、调试方法和性能优化全部理一遍。Python只是载体,重点在思维模型。适合学过Python基础语法、但一遇到递归就发怵的人。
1.1 递归的直觉:拆盒子
我经常用一个生活化类比来解释递归:你收到一个盒子,里面套着更小的盒子,再里面又套着更小的盒子,最里面是一张纸条,写着你要的答案。你能做的操作只有打开盒子,看到小盒子就继续打开。
递归函数就是这样:遇到“大盒子”就执行一段逻辑,发现里面还是“同类的盒子”,于是再次调用自己。直到打开的是一个“答案纸条”,不再有盒子,函数返回。
对应到上面的伪代码:
solve(n):打开一个大盒子;if n <= 1:纸条上的条件成立,返回1;else if n >= 5:盒子里还套着一个更小的盒子,规模是n - 2;- 你拿着这个更小的盒子,再执行同样流程。
这个例子特别之处在于:n的取值不是连续变化的,而是以2为步长跳跃。也就是说,如果从n = 10开始,调用链是:
code复制solve(10) = 10 * solve(8)
solve(8) = 8 * solve(6)
solve(6) = 6 * solve(4)
solve(4) = ???
注意,当n = 4时,两个条件都不满足:既不<= 1,也不>= 5。这就出问题了。如果这个函数是在实际Python代码里,运行到n = 4时,函数没有返回值,Python会默认返回None,然后上层做乘法时直接报错。
所以这个伪代码并不是一个设计良好的递归函数,它正好暴露了递归最容易犯的错误之一:边界条件覆盖不完整。这也是我选择它作为切入点的原因,你可以直观地看到“递归不完整”时会发生什么。
1.2 递归的三大要素
我总结了很多年教学和带人写代码的经验,一个合格递归必须同时满足三条:
第一,有明确的终止条件。 不能无限调用下去。终止条件必须能被当前输入直接命中。
第二,每次递归调用都向着终止条件靠近。 换句话说,问题规模要逐步缩小。比如n变成n - 2、n // 2、len(arr) // 2这种。如果每一次调用的参数一点没变,那就变成死循环了。
第三,子问题的解能组合成原问题的解。 什么意思?就是你把一个大问题拆成小问题,小问题算完之后,必须能通过某种运算(乘法、加法、拼接、取最值等)还原出大问题的答案。
对照刚才那个伪代码:
- 终止条件:
n <= 1,有; - 规模缩小:
n - 2,每一步会变小,也满足; - 组合解:
n * solve(n - 2),看起来成立。
但缺失了中间段的处理。这个函数的输入域是n = 0, 1, 2, 3, 4, ...,而递归只处理了n <= 1和n >= 5,中间的一段2 <= n <= 4悬空了。这才是它不能跑的真正原因。
所以在写递归之前,先回答自己三个问题:
- 参数有哪些取值区间?
- 每个区间分别怎么处理?
- 最小、最简单的输入返回什么?
如果你能把这三个问题写清楚,再翻译成代码,递归对你来说就不会再是玄学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 递归的底层运行机制:调用栈是怎么“记住”每一层的
递归之所以让很多人觉得难,是因为它在运行时行为比较特别:不是一口气算到底,而是先一层层“往下钻”,到底之后一层层“往回返”。这个“钻下去”和“返回来”的过程,依赖的是内存里一个叫“调用栈”的区域。
2.1 函数调用的本质
在Python里,每次调用一个函数,系统都会在内存调用栈上压入一帧。这一帧保存了函数的参数、局部变量、返回地址等信息。函数执行完毕,这一帧就出场(弹出)。
普通函数调用是两个栈帧之间的切换:main调用foo,压入foo的帧;foo返回,弹出foo的帧,回到main继续执行。
递归不过是“同一个函数”不断压入新的栈帧。每调用一次solve,就压入一个solve帧;每返回一次,就弹出一个。这就是为什么递归深度太大会爆栈——栈内存是有限的,不是无限大。
Python默认递归深度限制通常是1000层。这不是严谨的“1000次函数调用”,而是嵌套调用层数。如果你写了一个深度达到1001层的递归,Python会抛出RecursionError。后面我会专门讲这个坑。
2.2 用手工推演的方式理解返回值传递
我建议你亲手推演一个简单递归,比如经典的阶乘:
python复制def fact(n):
if n <= 1:
return 1
return n * fact(n - 1)
调用fact(5):
code复制fact(5) = 5 * fact(4)
fact(4) = 4 * fact(3
