递推与递归总是分不清?一文理清概念、实现与性能差异
发布时间:2026/9/4 11:28:25来源:尧图网络
你说递归和递推老是分不清看例题都能看懂自己一写就乱。这其实不是个例而是算法入门阶段最常见的一道坎。很多人从数学课本里知道了“第三项等于前两项之和”这样的递推式又在编程课里学会了“函数调用自己”的递归写法。但两门课各讲各的没有一座桥把它们连起来结果就是概念都会背代码写不对题目换一层皮就懵。这篇文章与其说是书评不如说是一次“重走经典学习路线”。不管是那本已经绝版的老书还是你手头任何一本算法教材真正的核心都不在书名而在于它是否帮你理顺了这条逻辑链数列是什么递推式怎么描述数列递归又是如何在代码里实现这种“由前到后”的推演。读完这篇文章你会得到三个东西一套不会混淆的概念框架几个可以直接运行的递推/递归示例以及一份从数学公式到代码实现的“翻译手册”。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 递归和递推的性能对比用一组数据直观感受一下nfib_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, memoNone): 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 种走法111、12、21第 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的起点和终点先处理边界 n1、n2再进入循环带-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, depth0): print( * depth ffib({n}) 开始) if n 2: print( * depth ffib({n}) 返回 1) return 1 left fib_debug(n - 1, depth 1) right fib_debug(n - 2, depth 1) result left right print( * depth ffib({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 节的排查表格很快就能找到手感。
网站建设高端定制企业官网