前阵子在一段历史代码里看到一个递归算法,函数很短,逻辑也简单,但我随手丢了几个测试值进去之后,结果相当有意思。这个函数长这样:
python复制def solve(n):
if n <= 1:
return 1
elif n >= 5:
return n * solve(n - 2)
第一眼看上去像是求某种连乘,分支条件分成两段,n <= 1 返回 1,n >= 5 递归往下减 2。但问题恰恰藏在中间:n 等于 2、3、4 的时候,函数会走到哪?我在代码评审里问过不少人,多数人盯着 n >= 5 和 n <= 1 看了半天,也没立刻发现这个区间竟然没有任何返回值。
这篇文章就从这段函数说起,把递归算法的执行机制、调用栈、边界条件、重构思路一次讲透。不管你是刚学递归的学生,还是写了好几年业务代码但很少碰递归的工程师,这篇文章都能给你一些值得带走的东西。
1. 先看懂这段代码:它的每个分支都在干什么
1.1 逐行拆解 solve(n)
我们先把这段递归算法翻译成大白话,看看每一行到底做了什么。
第一行 def solve(n),定义函数,接收一个参数 n。这个函数本身并不长,核心逻辑就两条路径:
- 当
n <= 1时,直接返回常量1。这是递归里最标准的做法,叫基线条件,为的是让递归能在某个点上停下来,不要再无限调用自己。 - 当
n >= 5时,返回n * solve(n - 2)。这是递归的前进分支,每次计算都会调用一个参数更小的自身,看起来像是要在 n 足够大时不断往下卷。
问题来了:n 如果落在 2 到 4 之间,比如 solve(3),既不满足 n <= 1,也不满足 n >= 5,这个函数没有写 else,也没有写 return。在 Python 里,一个函数如果执行到了末尾而没有任何显式的 return,它会默认返回 None。也就是说,solve(3) 返回的不是一个数字,而是 None。
这是这段代码最隐蔽的一个坑。递归算法里,任何一条分支路径都必须有明确的返回值,否则上层拿到 None 继续做乘法,结果只能是 TypeError: unsupported operand type(s). 我们用几个具体的输入验证一下:
| 输入 n | 走哪个分支 | 返回结果 | 说明 |
|---|---|---|---|
| 0 | n <= 1 |
1 | 正常 |
| 1 | n <= 1 |
1 | 正常 |
| 2 | 没有分支匹配 | None | 直接返回 None |
| 3 | 没有分支匹配 | None | 直接返回 None |
| 4 | 没有分支匹配 | None | 直接返回 None |
| 5 | n >= 5 |
5 * solve(3) | solve(3) = None,最终抛异常 |
1.2 递归三要素在这个函数上的对照
写过递归的人都知道,一个合格的递归算法需要满足三个基本条件:
- 基线条件:存在某种输入,函数可以直接返回结果,不再调用自身。
- 递归条件:函数在求解过程中会调用自身,而且调用时问题规模要减小。
- 收敛性:每递归一次,离基线条件就更近一步,最终必然能到达基线。
对照一下 solve(n):
- 基线条件确实存在,
n <= 1时返回 1。 - 递归条件也写出来了,
n >= 5时调用solve(n - 2)。 - 问题是收敛性只对部分输入成立。对于
n = 5,递归到3就停了,因为 3 不比 5 大也不比 1 小,递归链直接断掉,返回一个None而不是继续往 1 收。
换句话说,这个函数设计的初衷可能是想让奇数一路减到 1,偶数一路减到 2,但写分支条件的时候搞错了阈值。n >= 5 意味着 5 和 6 以后才开始递归,而递归链只走了几步就卡在 3 或 4 上,根本到不了真正的终点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 递归的本质:调用栈是如何支撑整个计算过程的
2.1 栈帧模型:一摞等待执行的计算
要真正理解上面这个函数为什么出问题,不能只看表面逻辑,需要理解递归到底在计算机里是怎么执行的。
每次调用一个函数,运行时都会在内存里分配一段叫"栈帧"的区域,用来保存这个函数的局部变量、参数,以及一条"执行到哪了"的记录。递归调用本质上是函数的自我嵌套:外层函数还没执行完,中途调用了自己,那外层就先挂起,等内层返回结果再继续。
我习惯用"一摞便签纸"来类比:
