1. 递归到底是什么一只函数调用自己的“套娃游戏”递归这个词听起来挺唬人但说白了就是一个函数在执行过程中调用了自己。这像什么像小时候拆套娃打开一个发现里面还有一个一模一样的再打开又一个直到最后那个最小的没法再拆了就一路往回装。递归也是这么个流程一路“拆”下去递推拆到底之后再一路“装”回来回溯每一步都等着下一步的结果回来。我做了十年开发带过不少新人发现最费劲的就是帮他们克服“别再往深处想了”这个坎。新手看到递归函数第一反应是拿笔去跟踪每一层调用画一个巨复杂的调用图把自己绕晕。实际完全不用。递归的精髓只有两条问题是同构的能拆成更小的同规模问题。比如阶乘n! 就是 n × (n-1)!而 (n-1)! 和 n! 的解法一模一样只是规模小了 1。这就是递归能成立的最根本原因。那递归适合谁来学我认为所有写代码的人都该掌握尤其是刚接触编程语言、数据结构的人。递归不只是个语法技巧它是在训练一种“分而治之”的抽象思维。后面你用递归去写树的遍历、快速排序、回溯算法全都是一脉相承的套路。这篇内容我们就拿两个最经典的案例——阶乘和斐波那契数列——把递归从原理到实战拆得明明白白从“看得懂”到“写得顺手”再进一步到“知道什么时候不该用递归”。1.1 递归的“三板斧”递推、终止、回归递归函数的运行本质上就是一句话函数调用自己但参数在变终会触底。一个合格的递归函数必须包含三部分终止条件Base Case递归的“底线”决定了递归什么时候开始回归。没有终止条件的递归就是死循环最终会栈溢出。递推公式Recursive Case把原问题拆成子问题的表达式也就是函数调用自己的那一行。返回值与调用关系每一层递归把自己的计算结果返回给上一层一层层组合出最终结果。我用生活化的方式再解释一遍。终止条件就是套娃里那个最小的实心木头娃娃它无法再打开所以递归“到底了”。递推公式就是你打开一个娃发现里面还有娃于是你继续做同样的事。回归阶段就是你拿到最里层的结果一个接一个套回去凑出完整的答案。这也解释了为什么递归代码看久了会“看不懂”——因为它是倒着思考的。人类直觉是“从前往后推”而递归是从“最终结果反推上一层结果”再到更上一层直到已知的边界。代码看着简单但脑内模拟复杂所以高手写递归都有一个习惯只关注当前这层做什么其他的信任递归去处理。1.2 递归和栈的暧昧关系没有栈就没有递归所有递归底层都是“函数调用栈”在兜底。每个函数被调用时就生成了一个栈帧存储了局部变量、返回地址。递归调用自己就是不断地往同一个调用栈里压入新栈帧。等到命中终止条件才开始一个个弹栈把结果带回上一层。我用一个比喻你把一叠盘子往柜子里放后来要用最底下的盘子就得从最上面一个接一个拿走。递归的“递”是压盘子“归”是取盘子。这个机制解释了递归的两个核心问题为什么递归太深会爆栈每层递归都占栈空间层数太多栈就顶不住了。常见默认栈也就 1MB 到 8MB几万层递归就很容易“Stack Overflow”。为什么递归的返回值要接到上一层调用上每一层栈帧都在等下一层的返回值所以必须要有个明确的 return 把结果向上传递。这里先有个记忆点能用迭代写出来的尽量优先迭代。递归适合的是“结构天然嵌套、迭代逻辑反而不直观”的场景。这也是我在后面讲斐波那契时特别要对比的重点。2. 从阶乘开始让人一看就懂的递归第一课阶乘是教科书经典因为它足够简单。数学定义是n! n × (n-1) × (n-2) × ... × 1但这个定义是“展开式”不好直接写成程序。把它改写成递归形式就是n! n × (n-1)! 0! 1看这个表达天然就是一个递归结构要求 n!先求 (n-1)!求到 0! 时有明确值 1终止。代码几乎没有思考成本def factorial(n: int) - int: # 终止条件0! 和 1! 都为 1 if n 1: return 1 # 递推公式n! n * (n-1)! return n * factorial(n - 1)我看过很多教材直接给这个代码然后让学生自己体会。我的建议是不要“体会”动手执行一遍。比如算factorial(4)程序真正的执行顺序是factorial(4) 4 * factorial(3) factorial(3) 3 * factorial(2) factorial(2) 2 * factorial(1) factorial(1) 1 ← 触底开始回归 factorial(2) 2 * 1 2 factorial(3) 3 * 2 6 factorial(4) 4 * 6 24注意看函数并不是一口气算出结果而是先一路向下“预订”任务到底之后才自下而上算出来。这就是“递推 回归”最直观的演示。2.1 写阶乘时最容易踩的几个坑第一终止条件写 而不是 。很多新手写if n 0: return 1能跑但不稳。如果调用方传了一个负数进来递归永远到不了 0直接死循环到爆栈。可以改成if n 1: return 1把负数和 0 都兜住。防御性编程是工程习惯不是小题大做。第二小心大数的爆炸增长。阶乘增长极快20!已经超过 64 位整数的上限。别觉得 Python 是大整数就无所谓其他语言很容易溢出。真要做大数阶乘简单递归就不合适了。第三递归层数限制。Python 里factorial(1000)就会直接报RecursionError因为超过默认递归深度限制。不是你的代码逻辑错了是解释器不让无限递归。2.2 换成迭代怎么写和递归对照着看递归写法很好读但性能上每层函数调用都有开销。阶乘用循环写更直接def factorial_iter(n: int) - int: result 1 for i in range(2, n 1): result * i return result两种写法对比如下对比项递归版迭代版代码可读性和数学定义一致很清晰需要两步理解循环乘到 n性能有函数调用栈开销慢一些无额外栈帧快很多栈风险n 太大会栈溢出不涉及调用栈只管算适合场景学习递归概念、思路演示实际项目中正式计算用所以我的态度一直是小规模、教学场景用递归没事真做工程能用循环就用循环。递归的价值在于帮你理解分治思想不在于帮你省代码。3. 斐波那契数列递归的另一面镜子斐波那契数列是递归第二经典案例但和第二经典案例一样它同时把递归的“优美”和“陷阱”暴露得淋漓尽致。数列定义F(0) 0 F(1) 1 F(n) F(n-1) F(n-2)这定义本身就可以直接翻译成代码def fib(n: int) - int: if n 0: return 0 if n 1: return 1 return fib(n - 1) fib(n - 2)干净、优雅、和公式一一对应。但如果你拿这个函数去算fib(40)在你机器上可能已经要等个一两秒了算fib(50)你会怀疑程序卡死了。这是为什么因为这个递归的展开方式是指数级爆炸的。我画一个简化的调用树演示fib(6)会调用fib(5)和fib(4)fib(5)又调用fib(4)和fib(3)……你很快会发现fib(3)会被反反复复计算很多遍。实际上fib(n)的时间复杂度是 O(2^n)n 稍微大一点就是天文数字。3.1 为什么朴素递归这么慢重复计算太多我用一个表格来展示fib(6)的调用分布被调用的子问题被计算的次数fib(5)1fib(4)2fib(3)3fib(2)5fib(1)8fib(0)5fib(2)被算了 5 次这些都是白算的。这就是朴素递归最大的问题没有意识到同一个子问题已经被解决过了。解决思路很自然把算过的结果存起来下次直接用。这就是记忆化递归。def fib_memo(n: int, memo: dict | None None) - int: if memo is None: memo {} if n in memo: return memo[n] if n 0: return 0 if n 1: return 1 memo[n] fib_memo(n - 1, memo) fib_memo(n - 2, memo) return memo[n]加上一个字典做缓存之后每个n只算一次时间复杂度直接降到 O(n)。同样的fib(50)从我刚说的“卡死”变成秒出结果。3.2 记忆化 vs 迭代到底谁更好当你能用递归做到 O(n) 时是不是递归就值得推荐了还是不够。记忆化递归虽然解决了重复计算但调用栈开销依然存在递归层数深了照样有爆栈风险。斐波那契的迭代版更简单def fib_iter(n: int) - int: if n 0: return 0 a, b 0, 1 for _ in range(2, n 1): a, b b, a b return b只用两个变量滚动更新时间 O(n)空间 O(1)没有递归调用没有任何爆栈风险。所以你说递归好还是迭代好在斐波那契这个场景下迭代几乎是完胜的。但这里必须说清楚迭代之所以简单是因为斐波那契的“递推关系”恰好是线性的、自底向上的。很多问题天然不具备这种线性特征比如树的遍历、JSON 解析、图搜索。到那些场景里迭代很难写递归却几乎是最自然的方式。3.3 尾递归优化递归能不能“抢救”一下有些函数式语言如 Haskell、Scala和部分编译器会把“尾递归”优化成迭代避免栈溢出。尾递归指递归调用是函数返回前的最后一个操作返回值不再参与额外计算。把斐波那契改写成尾递归形式def fib_tail(n: int, a: int 0, b: int 1) - int: if n 0: return a if n 1: return b return fib_tail(n - 1, b, a b)形式上确实是尾递归而且每个 n 只算一次。但要注意Python 解释器默认不做尾递归优化层数太深照样炸。所以不要因为在别的语言里尾递归好用就在 Python 里随手用。用之前先确认你的运行环境和语言是否支持优化。4. 递归实战从阶乘斐波那契进阶到真实场景光会阶乘和斐波那契其实只算入门。面试和真实项目里递归真正大显身手的地方是树的遍历、目录结构解析、快速排序、回溯算法八皇后、数独、深度优先搜索DFS。这些结构的共同点是本身就是嵌套结构自相似性极强。4.1 一个“递归思路迁移”的例子目录遍历假设你要统计一个文件夹下所有文件的总大小。目录的天然结构是树一个目录下有文件也可能有子目录子目录下又套子目录。用递归写就是原生的import os def get_dir_size(path: str) - int: total 0 for entry in os.scandir(path): if entry.is_dir(follow_symlinksFalse): total get_dir_size(entry.path) # 递归走进子目录 else: total entry.stat().st_size return total你看这个代码几乎不需要额外设计“如果是目录就再调用自己”这个递归逻辑自然就写出来了。如果用迭代写你得自己维护一个栈或者队列来模拟遍历顺序思维负担大得多。这种场景递归就远胜过迭代。所以我判断“该不该用递归”的标准就一条问题本身是不是嵌套结构如果是递归就是最优解。4.2 快速排序递归在算法中的经典应用快速排序就是把数组分成比基准小的部分和比基准大的部分再对这两部分分别做同样的快排。这个“分别做同样的快排”就是递归调用。核心逻辑用 Python 写起来极其精简def quick_sort(arr: list) - list: if len(arr) 1: return arr pivot arr[len(arr) // 2] # 取中间元素当基准 left [x for x in arr if x pivot] middle [x for x in arr if x pivot] right [x for x in arr if x pivot] return quick_sort(left) middle quick_sort(right)这是一个教学版写法不是最优版本但胜在能让人一眼看懂递归分治思想。有人问快排用递归写是不是会栈溢出确实有这种风险。所以工程上有很多“非递归快排”用显式栈模拟递归过程。这里就说到了和热搜词里“快速排序非递归”相关的内容。非递归快排的核心思路是既然递归靠系统栈保存待处理的边界那我自己开一个栈来存边界就行了。示例思路def quick_sort_iter(arr: list) - list: stack [(0, len(arr) - 1)] while stack: low, high stack.pop() if low high: continue # 分区逻辑省略选基准和交换 pivot_index partition(arr, low, high) stack.append((low, pivot_index - 1)) stack.append((pivot_index 1, high)) return arr这种改写的精巧之处在于递归转换成迭代本质就是把“隐式的函数调用栈”换成“显式的数据栈”。理解了这句话你能手动改写绝大多数简单递归。4.3 树的遍历递归几乎不可替代的场景说到树相关的操作比如二叉树的前序、中序、后序遍历递归写法几乎就是标准答案def preorder(root): if root is None: return [] return [root.val] preorder(root.left) preorder(root.right)三行写完。非递归写中序遍历要维护栈代码长一倍还得小心指针方向。所以工程实践中树的遍历、XML/JSON 解析、文件目录操作能用递归就递归不要折腾自己用迭代硬撸。4.4 递归改迭代的通用技巧显式模拟栈网上经常看到“递归改迭代”的面试题。通用套路只有一个核心思路递归代码里每次函数调用产生的局部状态改成一个元组存进自建的栈里。比如快排非递归每个待处理的子数组是一个(low, high)比如二叉树前序遍历每个待访问的节点就是一个栈元素比如 JSON 解析每个待展开的对象就是一个待处理的上下文。你在递归里 return 结果改迭代时就要在栈里额外存一个“状态标记”用来标识“我在等子问题返回”。这就是为什么有些递归改迭代会改成“状态机”本质就是手动模拟函数调用栈。我这里给一个很容易套用的模板思路# 递归版本 def solve(x): if base_case(x): return base_answer sub solve(reduce(x)) return combine(sub) # 迭代版本思路 stack [(x, 0)] ans None while stack: cur, state stack.pop() if state 0: if base_case(cur): ans base_answer else: stack.append((cur, 1)) # 等子结果回来 stack.append((reduce(cur), 0)) # 先处理子问题 else: ans combine(ans) # 子结果回来后组合理解这个模板你就不怕“非递归化”的面试题了。这也是我认为递归学习里最值得花时间练的一个进阶技能。5. 递归调试与排错从爆栈到答案错误递归是出了名的“写起来容易调起来头大”。本节分享一些我实际踩过的坑和排查经验。5.1 RecursionError / StackOverflow递归过深或终止条件写错这是最常见的报错。遇到之后不要第一时间去调大递归深度限制先审查这两点有没有终止条件这个递归最终能不能到一个已知答案的分支每次递归调用时问题规模真的变小了吗如果fib(n)调用的是fib(n1)那就永远结束不了。排查方法在函数开头打印参数和当前深度。写一个临时调试计数器可以快速确认递推方向是否正确。打印两三次之后基本能定位问题def debug_fib(n: int, depth: int 0): print( * depth ffib({n})) if n 0: return 0 if n 1: return 1 return debug_fib(n - 1, depth 1) debug_fib(n - 2, depth 1)5.2 返回值类型不对整数变 None写递归时忘记写return是新手三大坑之一。比如def factorial(n: int): if n 1: return 1 factorial(n - 1) * n # 漏了 return这样函数最外层返回None所有层的结果全丢了。排查时先确认所有分支都有 return包括终止条件和递归分支。5.3 重复计算严重程序“慢”但不是死循环前面斐波那契讲过fib(50)可以跑到天荒地老。这种情况程序不会报任何错就是慢。判断方法是递归参数一直不变或者重复出现。解决办法就是记忆化或者改写迭代。这里送一条实操心得看到递归函数里同一个参数被反复调用第一时间加缓存不要犹豫。5.4 传参被外层修改共享可变对象惹的祸递归里如果传的是同一个 list 或者 dict在某一层修改了它会影响其他层的计算结果。需要保证每一层传递的是副本或者设计成不可变对象。这个问题的隐蔽性极高往往表现为“结果和预期偶尔一样偶尔不一样”。排查方法是在递归入口处把参数的类型和内容打出来观察是否被意外修改。5.5 递归深度限制调整非必要不要动Python 可以手动调大递归深度import sys sys.setrecursionlimit(100000)但我不建议你随便调原因有二递归深度越大栈溢出的风险越高程序可能直接崩溃与其调高限制不如改写迭代或加缓存治标兼治本。5.6 一份递归调试速查表症状可能原因解决办法RecursionError没有终止条件 / 递归深度超限检查 Base Case减小输入规模结果全部是None漏写 return确保所有分支都有返回值运行极慢重复子问题指数级展开记忆化 / 改迭代不同层数据互相污染共享可变对象传副本或改用不可变类型结果正确但偶发错误全局变量被修改避免在递归中修改全局状态栈溢出崩溃编译器未做尾递归优化改迭代或显示栈模拟6. 递归的“道与术”什么场景该用什么场景该绕开学完上面这些最重要的其实是建立一个“何时用递归”的判断力。我把它总结成三条经验第一如果问题本身是递归定义的并且子问题之间重叠很少用递归写代码最漂亮。典型如树的遍历、目录解析、语法树运算。这时的递归是“最优解”。第二如果子问题大量重叠比如斐波那契纯递归是陷阱。你用递归写出了最直观的解法却不自觉地写出了一个指数级算法。这时必须引入记忆化或者改写迭代。第三如果递归深度可能很大优先考虑迭代或显式栈模拟。系统栈是你不可控的资源不要赌它够用。最后分享一个小技巧当你真的拿不准该不该用递归时先写出递归版本理清思路再评估性能。递归版本的价值在于“验证思路”迭代版本的价值在于“上线运行”。先把思路验证对再用迭代换性能这个顺序比直接纠结用哪种写法要高效得多。我自己很多次都是先写一个超简单的递归确认逻辑没毛病再动手改写成非递归版本。这样既享受了递归带来的思维清晰也拿到了迭代版本的稳定性。