你说递归和递推老是分不清,看例题都能看懂,自己一写就乱。这其实不是个例,而是算法入门阶段最常见的一道坎。
很多人从数学课本里知道了“第三项等于前两项之和”这样的递推式,又在编程课里学会了“函数调用自己”的递归写法。但两门课各讲各的,没有一座桥把它们连起来,结果就是:概念都会背,代码写不对,题目换一层皮就懵。
这篇文章与其说是书评,不如说是一次“重走经典学习路线”。不管是那本已经绝版的老书,还是你手头任何一本算法教材,真正的核心都不在书名,而在于它是否帮你理顺了这条逻辑链:数列是什么,递推式怎么描述数列,递归又是如何在代码里实现这种“由前到后”的推演。
读完这篇文章,你会得到三个东西:一套不会混淆的概念框架,几个可以直接运行的递推/递归示例,以及一份从数学公式到代码实现的“翻译手册”。
1. 递推与递归,为什么总是学不会
先看一个很典型的提问:
斐波那契数列的第三项等于前两项之和,这个我知道。可是为什么代码里有时候写循环,有时候写递归?它们不都是在算同一个数列吗?
这个问题背后,藏着三个一直被混为一谈的概念:数列、递推式、递归。
它们分别属于不同的层面:
- 数列是研究对象,是一串有规律的数。
- 递推式是描述数列的一种数学方式,强调“由前项推出后项”。
- 递归是程序的一种控制结构,指函数直接或间接地调用自身。
数学课教的是递推式,它不关心你怎么算,只关心项与项之间的关系。编程课教的是递归函数,它默认你理解数学归纳法,却很少回头解释“递推式”和“递归”之间的关系。被跳过的这座桥,恰恰是理解动态规划、分治算法、树形遍历的基础。
还有一个容易被忽略的原因:很多接触递推递归的同学,是在算法竞赛场景里第一次感受到痛苦的。CSP、蓝桥杯、ACM 这类训练中,递推和递归出现频率极高,而且题目往往不会直接说“请用递推”,而是把模型藏在故事里。这时如果底层概念是模糊的,题目稍微绕一点就会崩盘。
所以,先别急着刷题。把数列、递推式、递归三个词放在同一张桌子上看清楚,后面的路才会顺。
2. 数列、递推式、递归:三个概念要分清
2.1 数列:一个按规律排列的“数字序列”
数列,说通俗点,就是一串按顺序排列的数字。例如:
1, 1, 2, 3, 5, 8, 13, ...这串数的每一项都有一个位置编号。第 1 项是 1,第 3 项是 2,第 6 项是 8。在数学里,第 n 项通常记作a_n或F(n),整个序列写作{a_n}。
数列可以用两种方式描述:
- 通项公式:直接给出第 n 项关于 n 的表达式,比如等差数列
a_n = 2n + 1,代入 n 就能算出任意一项。 - 递推公式:给出前几项的值,再给出后一项与前面项的关系式。
通项公式当然好,但很多实际问题根本写不出通项公式,只能知道“这一项怎么由前一项或前几项算出来”。这时候,递推式就是最自然的描述方式。
2.2 递推式:用“前项”定义“后项”
递推式的核心是:给你一组初值,再给你一个项与项之间的换算规则。
斐波那契数列的递推式写法是:
F(1) = 1 F(2) = 1 F(n) = F(n - 1) + F(n - 2) (n >= 3)这里的F(n) = F(n - 1) + F(n - 2)就是所谓的“第三项等于前两项之和”。
要注意,递推式必须与初值一起出现。如果只有关系式而没有初值,这个式子是无法计算的。例如给你F(n) = F(n - 1) + F(n - 2),但不告诉你F(1)和F(2),你根本不知道第一项该填什么。
初值 + 递推关系,这两部分合在一起,才是完整的递推定义。
2.3 递归:一种“自己调用自己”的程序写法
递归属于程序实现层面。一个递归函数通常包含两部分:
- 递归基(base case):问题已经小到可以直接返回答案的情况。
- 递归步(recursive step):把当前问题拆成更小的同类问题,然后调用自身。
一个最小的递归例子是阶乘:
def factorial(n): # 递归基:0! 和 1! 都等于 1 if n <= 1: return 1 # 递归步:n! = n * (n-1)! return n * factorial(n - 1)这个函数的逻辑是:我想知道n!,就先要知道(n-1)!;想知道(n-1)!,又得知道(n-2)!……一直拆到1!这个可以直接回答的问题,然后再一层层把结果乘回来。
递归程序能跑起来,依赖的是系统的调用栈。每次调用自身,都会把当前函数的状态压入栈中,等更深层的调用返回后,再恢复现场继续执行。这个概念后面会反复用到。
2.4 三者关系:对象、数学工具、算法实现
用一张表来看清它们的定位:
| 概念 | 层面 | 典型表达 | 核心问题 |
|---|---|---|---|
| 数列 | 数学对象 | 1, 1, 2, 3, 5, ... | 研究的是“一串数有什么规律” |
| 递推式 | 数学描述 | F(n) = F(n-1) + F(n-2) | 研究的是“后一项如何由前项算出” |
| 递归 | 程序实现 | return fib(n-1) + fib(n-2) | 研究的是“函数如何自我调用来分解问题” |
一句话概括:数列是问题,递推式是数学表达,递归是计算机程序对这类问题的实现策略之一。
3. 先看懂最简单模型:斐波那契数列的多种实现
把理论落在代码上。斐波那契数列是最好的入门样本,因为它的递推式极其清晰,又同时适合用递归和递推来实现。
3.1 数学定义
斐波那契数列的第 n 项定义如下:
F(1) = 1 F(2) = 1 F(n) = F(n - 1) + F(n - 2) (n >= 3)这个定义本身就是后面代码的“施工图”。
3.2 用递推循环实现
顺着数学定义写循环,就是标准的递推写法:从已知的第 1、2 项开始,一路推到第 n 项。
def fib_loop(n): if n <= 0: return 0 if n <= 2: return 1 a, b = 1, 1 # a = F(1), b = F(2) for _ in range(3, n + 1): a, b = b, a + b # 滚动更新:新的 b 是前两项之和 return b print(fib_loop(10)) # 输出 55这段代码里,变量a和b一直保存相邻的两项。每循环一次,就计算下一项,再整体向后挪一位。它的时间和空间表现都很好,时间复杂度 O(n),空间复杂度 O(1)。
3.3 用递归实现
如果照着数学定义直接翻译成 Python,就有经典的递归版本:
def fib_rec(n): if n <= 0: return 0 if n <= 2: return 1 return fib_rec(n - 1) + fib_rec(n - 2) print(fib_rec(10)) # 输出 55这个版本非常直观,每一行几乎都在对照递推式。初学者往往会觉得:这个代码比循环版本更容易理解,对吧?
问题在于,它非常慢。
调用fib_rec(10)时,程序会先算fib_rec(9)和fib_rec(8);算fib_rec(9)又要算fib_rec(8)和fib_rec(7)。同一个fib_rec(8)会被反复计算多次,调用次数呈指数增长。
3.4 递归和递推的性能对比
用一组数据直观感受一下:
| n | fib_loop 执行次数 | fib_rec 调用次数 | 说明 |
|---|---|---|---|
| 10 | 循环 8 次 | 调用约 177 次 | 还没拉开差距 |
| 20 | 循环 18 次 | 调用约 21891 次 | 差距开始明显 |
| 30 | 循环 28 次 | 调用约 2692537 次 | 递归已经很吃力 |
| 40 | 循环 38 次 | 调用约 3.3 亿次 | 普通机器明显卡顿 |
这里最要命的问题不是调用次数多,而是递归树中有大量重复子问题。fib_rec(8)被算了无数遍,但这些结果并没有被保存下来,用完就丢,下一次还要重算。
这也是递推相对递归的天然优势之一:递推从底部开始,每个子问题只算一次,结果天然被后续步骤复用。
4. 递推和递归的本质区别与联系
很多文章会把递推和递归并列起来对比,但真正要说清楚,应该从三个维度来切:方向、机制、场景。
4.1 方向不同:一个自底向上,一个自顶向下
递推是“自底向上”的思考。你从已知的起点出发,按照规则一步步推进,最终到达目标项。比如求F(10),递推的思路是:先算F(3),再算F(4)……一路小跑到F(10)。
递归是“自顶向下”的思考。你从目标项出发,把大问题拆成小问题。求F(10),先假装已经知道了F(9)和F(8),只要把它们加起来就行;而F(9)又依赖于F(8)和F(7)……这样一路拆到可以直接回答的F(1)和F(2),再一层层返回。
一个简单的类比:
- 递推像盖楼:从地基开始,第一层、第二层……一直按顺序盖上去。
- 递归像拆楼加回填:你先站到目标楼层,发现要建第 10 层得先有第 9 层,于是往下找,一直找到地基,再一层层往回把楼“砌”出来。
两者最终看到的是同一栋楼,但施工路径相反。
4.2 执行机制不同:一个是循环,一个是调用栈
递推在代码层面通常表现为循环和几个变量,不涉及函数反复自我调用,栈的深度是稳定的。
递归则依赖系统调用栈。每次调用自身,都要把当前函数的参数、局部变量、返回地址压入栈。递归深度有多少,栈就可能涨多高。一旦递归深度过大,比如几万层,程序就会抛出栈溢出,Python 里对应的是RecursionError。
因此,在工程上有一个经验判断:如果问题的规模可能很大,优先考虑递推循环;如果问题天然呈树形结构,递归写起来更省心,但要确认递归深度可控。
4.3 联系:递归可以改成递推,递推也可以理解成递归
很多初学者以为递推和递归是两种不同的“题型”,其实它们是同一数学模型的两面。
一个暴力递归如果存在大量重复计算,可以加一个缓存来优化,也就是“带备忘录的递归”,本质上是用额外空间缓存中间结果:
def fib_memo(n, memo=None): if memo is None: memo = {} if n <= 0: return 0 if n <= 2: return 1 if n in memo: return memo[n] result = fib_memo(n - 1, memo) + fib_memo(n - 2, memo) memo[n] = result return result带缓存后,每个n只计算一次,时间复杂度从指数级降为 O(n)。
如果再把“带备忘录的递归”的递归栈去掉,变成从底部开始的循环,那就是标准的递推。反过来,任何递推循环也都可以写成递归形式,只是未必优雅。
所以在算法训练中,经常把递推视为动态规划的一个子类:定义状态、写出状态转移方程,然后自底向上计算。状态转移方程,本质上就是一种递推式。
4.4 递归这个词,还藏在各种工具里
递归不只在函数里出现。SVN、Git 等版本控制工具中,很多命令都支持“递归操作”选项。
比如 SVN 中设置svn:ignore属性,如果只对某个目录设置,不递归,那么这个目录下的子目录不会继承该忽略规则;如果要让忽略规则对当前目录的所有子目录递归生效,就需要使用-R参数。
这里的“递归”指的是一种向下遍历目录树的行为:从当前目录出发,穿透每个子目录去执行同一操作。它和函数递归的底层实现不同,但思维模型一致:把一个大目录拆成无数个小目录,每个小目录都执行同样的规则。
在工程上,遇到带-R参数的递归操作要格外谨慎。一次递归操作可能影响整棵目录树,尤其涉及删除、移动、属性覆盖时,最好先在测试副本上验证。这个道理同样适用于递归算法:递归能让代码简洁,也能让问题放大得超出预期。
5. 完整示例:递归法将一个整数转换成字符串
有了概念基础,看一道经典的递归练习题:将一个整数 n 转换成字符串。比如输入1234,输出"1234",输入-907,输出"-907"。
这道题在 C 语言教材中很常见,用来训练递归和控制输出顺序。这里用 Python 实现,但核心的递归思路是通用的。
难点在于:我们很容易取出最后一位,比如1234 % 10 = 4,但输出顺序要求先输出最前面的1,不能先把4放到字符串前面。
解决办法是:先递归处理高位,再拼接当前低位。
5.1 返回字符串的写法
def int_to_str(n): # 处理负数:把负号单独处理,问题转化为正数的转换 if n < 0: return "-" + int_to_str(-n) # 递归基:一位数直接转成字符串 if n < 10: return str(n) # 递归步:先处理去掉最后一位的高位部分,再拼接最后一位 return int_to_str(n // 10) + str(n % 10)运行测试:
print(int_to_str(1234)) # 输出: 1234 print(int_to_str(-907)) # 输出: -907 print(int_to_str(0)) # 输出: 0为什么要这样设计?
看int_to_str(1234)的执行过程:
n = 1234,不满足n < 10,进入递归步。需要先执行int_to_str(1234 // 10),也就是int_to_str(123)。n = 123,继续调用int_to_str(12)。n = 12,继续调用int_to_str(1)。n = 1,满足递归基,直接返回字符串"1"。- 回到
n = 12那一层,拿到"1"后拼接str(12 % 10)即"2",得到"12"。 - 回到
n = 123那一层,拼接"3",得到"123"。 - 回到
n = 1234那一层,拼接"4",最终得到"1234"。
注意:递归的“回溯”顺序决定了字符串拼接是正序的。先深入到最高位,再沿着调用链一层层把低位数字加到末尾。这个问题天然适合递归,因为“先处理高位”这种动作需要等到最深一层才真正开始返回。
5.2 另一种写法:边递归边输出
如果只要求打印,可以直接把递归过程写成这样:
def print_digits(n): if n < 0: print("-", end="") print_digits(-n) elif n < 10: print(n, end="") else: print_digits(n // 10) print(n % 10, end="")这段代码的更直观之处在于:print_digits(n // 10)先执行,直到递归到最高位,才开始从最高位逐个print,于是打印顺序就是自然的高位到低位。
很多初学递归的同学会在这里犯一个典型错误:想先从低位输出,于是直接在递归前print(n % 10),结果得到的是逆序字符串。理解“递归调用前做什么”和“递归调用后做什么”,是掌握递归的关键。
6. 实战:递推模板与空间优化
递推写代码虽然比递归容易控制,但也需要一套稳定套路。结合实际算法题中最常见的模型——爬楼梯问题,来看递推的标准写法。
6.1 问题背景
假设你正在爬楼梯,每次可以走 1 级或 2 级台阶。问走到第 n 级台阶有多少种不同的走法。
这个问题的递推式是:
dp[1] = 1 dp[2] = 2 dp[n] = dp[n - 1] + dp[n - 2] (n >= 3)它和斐波那契数列结构相同,只是初值不同。走到第 n 级台阶,最后一步可能是从第 n-1 级跨 1 级上来,也可能是从第 n-2 级跨 2 级上来,所以方法数是两者之和。
6.2 标准递推数组版
最直观的递推实现是开一个一维数组,把每一步的结果都存下来:
def climb_stairs_dp(n): if n == 1: return 1 if n == 2: return 2 dp = [0] * (n + 1) dp[1] = 1 dp[2] = 2 for i in range(3, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n]这种写法的好处是结构清晰,状态数组dp完整记录了从第 1 级到第 n 级的全部答案。以后如果题目要求输出中间某级的结果,可以直接查表。
它的空间复杂度是 O(n)。当 n 很大时,其实没有必要保留全部状态,因为dp[i]只依赖dp[i-1]和dp[i-2],更早的数据用不上了。
6.3 滚动变量优化版
把数组压缩成两个滚动变量,空间复杂度降为 O(1):
def climb_stairs_on(n): if n == 1: return 1 if n == 2: return 2 prev2, prev1 = 1, 2 # prev2 = dp[i-2], prev1 = dp[i-1] for _ in range(3, n + 1): cur = prev1 + prev2 prev2, prev1 = prev1, cur return prev1运行结果验证:
for i in range(1, 8): print(i, climb_stairs_on(i))输出:
1 1 2 2 3 3 4 5 5 8 6 13 7 21这个结果和递推式完全一致。可以看到,第 3 级是 3 种走法:1+1+1、1+2、2+1;第 4 级是 5 种走法,正好等于第 3 级与第 2 级方法数之和。
6.4 从题目中提炼递推模板
爬楼梯问题能提炼出一个通用的递推/动态规划思考模板,写题时按三步来:
- 定义状态:
dp[i]表示什么?在爬楼梯问题中,表示“到达第 i 级台阶的走法总数”。 - 写出状态转移方程:
dp[i]如何由前序状态推出来?核心是分析最后一步有几种选法。 - 确定边界条件:
dp[1]、dp[2]这类最小规模问题的答案是什么?
只要这三步确定,递推代码就顺理成章了。很多同学写递推题卡住,不是因为代码语法不会,而是因为没有先把状态转移方程写在纸上。
7. 常见问题与排查方法
把上面的内容浓缩成一份排错清单,遇到问题先对照表格定位。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
Python 递归执行时报RecursionError | 递归深度超过解释器默认限制,或递归没有收敛 | 查看报错栈,确认是哪一层递归仍在继续 | 补上递归基;若数据量实在大,改写成递推循环或显式栈 |
| 递归版本计算斐波那契数列非常慢 | 存在大量重复子问题 | 打印函数调用次数,观察同一 n 是否被反复计算 | 使用备忘录缓存结果,或改成自底向上递推 |
| 整数转字符串时,负数输出不对 | 只处理了正数,或对负号处理位置不对 | 用-123等测试用例逐层跟踪 | 在递归函数入口先判断负数,把负号拼在最前面再递归转正数 |
整数转字符串时0输出为空 | 递归基把0的情况漏掉,或用了if n // 10 == 0之外的错误分支 | 单独测试int_to_str(0) | 递归基应包含一位数情况,包括 0 |
| 递推数组越界或取值错误 | 循环范围写错,或对 n 较小的情况没先过滤 | 检查range的起点和终点 | 先处理边界 n=1、n=2,再进入循环 |
带-R的版本控制递归操作影响范围过大 | 对递归参数含义理解不足,直接在生产目录执行 | 在测试副本上先执行,观察影响范围 | 执行前确认工作副本路径,避免在敏感目录直接递归操作 |
这里特别强调两个高频坑。
第一个是递归的“没有收敛”。如果递归函数缺少递归基,或者递归步没有让问题规模变小,程序就会无限调用自己。比如:
# 错误示例:缺少递归基 def bad_rec(n): return n * bad_rec(n + 1)这个函数里,n越来越大,永远不可能到达终止条件。实际上就算有递归基,只要参数在朝远离边界的方向变化,一样会出问题。
第二个是递推的“边界遗漏”。很多递推问题的公式在 n 很小时并不成立。比如爬楼梯问题的状态转移方程要求n >= 3,如果不先处理n == 1和n == 2,代码一运行就会越界或者答案错误。正确顺序永远是:先判断小规模边界,再写迭代逻辑。
8. 最佳实践与工程建议
概念清楚了,代码会写了,还差一些方法论。以下建议是从“应付作业”到“能解决实际问题”之间比较关键的经验。
8.1 写代码前,先在纸上推出前几项
递归和递推的题目,最难的部分往往是找到规律。不要一开始就在 IDE 里敲代码,而是拿纸笔把数列的前 6 到 8 项写出来,再反推递推式。
例如爬楼梯问题,如果不写出来,很多人会想当然地觉得“每次有两种走法,所以是 2 的幂”,但这个直觉是错的。只有老老实实列出:1 级有 1 种,2 级有 2 种,3 级有 3 种,4 级有 5 种,才能意识到这是斐波那契式的叠加规律。
8.2 递归函数的三个纪律
写递归时,养成三个固定动作:
- 递归基放在函数最前面,让阅读代码的人一眼看到终止条件。
- 递归步必须让问题规模变小,否则调用链永远不会返回。
- 确认返回值与递归基的返回值类型一致,避免某个分支返回数字、某个分支返回字符串,最后拼接时报类型错误。
拿整数转字符串的例子来说,递归基返回的是str(n),所以递归步中拼接str(n % 10)才能保证类型统一。如果递归基返回了整数,整个函数结构就会出问题。
8.3 如何调试递归:日志缩进法
递归函数调试起来比较头疼,因为你很难直观看到调用过程。一个很实用的技巧是在函数里加上缩进日志,让每次调用的层级可视化:
def fib_debug(n, depth=0): print(" " * depth + f"fib({n}) 开始") if n <= 2: print(" " * depth + f"fib({n}) 返回 1") return 1 left = fib_debug(n - 1, depth + 1) right = fib_debug(n - 2, depth + 1) result = left + right print(" " * depth + f"fib({n}) 返回 {result}") return result print(fib_debug(5))运行后,你会看到一株清晰的递归调用树,每一层的缩进代表一次函数调用。如果哪一层迟迟没有返回,往往就是递归基或者递归步出了问题。
8.4 生产环境中的递归:能不用就不用?
后端开发、数据处理、文件遍历等工程场景中,递归并非不能使用,而是要控制风险。
最核心的准则是:不要让递归深度与输入规模线性绑定。Python 默认递归深度大约在 1000 左右,如果输入数据可能达到百万级,而你又为每个数据都产生一次递归调用,程序必然崩。此时应该改用循环,或者用显式栈模拟递归。
以树的先序遍历为例,递归版本很简洁:
def preorder_rec(root): if root is None: return [] return [root.val] + preorder_rec(root.left) + preorder_rec(root.right)用显式栈模拟递归的版本,可以避免深度过大的问题:
def preorder_stack(root): if root is None: return [] result = [] stack = [root] while stack: node = stack.pop() result.append(node.val) # 注意入栈顺序:先右后左,出栈时才能先处理左子树 if node.right is not None: stack.append(node.right) if node.left is not None: stack.append(node.left) return result这个例子不是让你以后杜绝递归,而是想说明:递归只是解决问题的一种姿势,当你发现递归受限时,还可以通过“手动维护栈”的方式,把递归的调用过程显式表达出来。
8.5 递归操作要尊重权限边界
如果在版本控制或者文件系统命令中看到“递归”选项,务必先确认操作范围。递归的优点是可以让一条命令覆盖整棵目录树,缺点也是同一个。一个属性设置、一次批量替换,如果递归执行,可能影响数百个目录和文件。
正确做法是:先在测试副本上执行带-R的命令,用status或diff查看变更范围,确认无误后再在目标目录执行。涉及批量修改时,最好能够随时回滚。
9. 结语:学不会的解法,在系统化,不在刷题量
回到标题里的问题:《数列・递推・递归》为什么值得系统学?因为它代表的是一条被很多现代教程简化掉的知识链。
老书也好,新教程也罢,真正有价值的编排顺序是固定的:先理解数列是按规律排列的一组数;再理解递推式是用初值和关系描述这个规律;最后理解递归是用函数自我调用来实现这种推演。步骤不能反,也不能跳。
如果你现在还在为递推和递归头疼,我建议你按这个顺序走一遍:
- 找一道经典题,比如斐波那契数列。
- 在纸上写出前 8 项。
- 写出递推式,标注初值和关系。
- 分别用递推循环和递归实现一遍。
- 对比两种实现的输出和开销。
- 尝试把递归改成带备忘录的版本。
这套动作做完,递推和递归就不再是两个需要死记的抽象名词,而是一条可以从数学定义直接翻译成代码的路径。
哪怕那本老书真的买不到了,这套思维方法也不会过期。建议先把文中的三个代码示例都手敲一遍,遇到报错就对照第 7 节的排查表格,很快就能找到手感。