刚学Python那阵子,我写斐波那契一定用递归,三行代码解决问题,觉得特别优雅。后来接了个实际需求,输入规模稍微大一点,程序跑了半天没出结果,还甩了个 RecursionError 给我。循环和递归这对组合,几乎每个写Python的人都会在某个时刻被它们教育一次。这篇不讲教科书上的定义,而是把两者的执行成本、适用边界、互相改写的具体手法拆开讲清楚,顺带把我踩过的几个坑原样摆出来。如果你正纠结某段逻辑到底该写 for 循环还是递归函数,或者手上有一段跑得慢、动不动就爆栈的递归代码等着优化,下面这些内容应该能直接对上你的问题。
1. 递归在Python里为什么既慢又容易爆
很多人对递归的困惑集中在一个点上:明明逻辑是对称的、代码是干净的,为什么一到Python里就变得又慢又脆弱。答案藏在解释器的执行模型里,不在算法本身。
1.1 一次递归调用在底层到底做了什么
CPython执行一个函数调用时,会创建一个帧对象(frame),里面装着局部变量表、求值栈、指向前一帧的指针、以及对应的代码对象引用。每递归一层,就多压一个帧。循环则完全不同,它在同一帧里反复跳转字节码,变量复用同一块内存,除了循环体本身的运算,几乎没有额外开销。
拿两段功能完全相同的代码对比,差异一目了然:
import dis def sum_loop(n): total = 0 for i in range(n): total += i return total def sum_rec(n): if n == 0: return 0 return n + sum_rec(n - 1) dis.dis(sum_loop) dis.dis(sum_rec)sum_loop的字节码里只有一次函数调用(range),主体是FOR_ITER循环跳转。sum_rec每一层都要走一次完整的调用协议:PRECALL、CALL、参数打包、帧创建、帧销毁、返回值传递。一次调用的成本在几十到上百纳秒的量级,看起来微不足道,但递归意味着这个成本乘以深度。n 到一千,就是一千次完整的调用往返。
更关键的是,递归写法往往伴随着重复计算。教科书上的朴素斐波那契是最典型的例子,fib(30)会展开成四十多万次调用,其中绝大多数是同一个子问题的重复求解。循环写法天然带一张表或者两个滚动变量,根本不存在这个问题。
1.2 默认递归上限1000这个数字的来由
sys.getrecursionlimit()在CPython里默认返回1000。这个限制不是随便定的,它保护的其实是C层面的调用栈。Python的每一层Python函数递归,在解释器内部大致对应若干层C函数调用,而C栈的大小是进程启动时固定分配的(主线程通常是几MB到8MB,取决于平台)。如果允许无限递归,C栈会先于Python层面耗尽,结果是进程直接崩溃,连异常都来不及抛。
这一点非常重要,因为它决定了你调高上限时的风险性质:
import sys print(sys.getrecursionlimit()) # 1000 sys.setrecursionlimit(100000)很多人以为把上限调到十万就安全了,实际上只是把"抛出可捕获的 RecursionError"换成了"可能直接段错误"。我在一个数据处理脚本里试过这个操作,深度跑到三万多的时候进程无声无息地没了,连 traceback 都没有,排查了半天才反应过来是栈溢出。
如果确实需要更深的递归,比较稳妥的做法是给递归工作单独开一个线程,并且显式指定更大的栈空间:
import sys import threading def run(): sys.setrecursionlimit(200000) # 在这里执行深递归逻辑 ... threading.stack_size(64 * 1024 * 1024) # 64MB t = threading.Thread(target=run) t.start() t.join()即便如此,我依然建议把深度超过几千的逻辑改写成迭代。理由很简单:栈空间是稀缺资源,而堆不是。
1.3 栈空间和堆空间的成本差异
递归的中间状态全部堆在调用栈上,栈的大小在创建时就固定了;循环的中间状态可以放在堆上的容器里,理论上只受物理内存限制。这个差异在处理"深度不可预知"的数据时是致命的。
我做过一个嵌套JSON结构的扁平化处理,测试数据最深只有五六层,上线后遇到用户导入的一份数据嵌套了四十多层,递归版本直接崩溃。改成显式栈的循环版本之后,深度只受内存约束,几万层也照跑不误。
| 维度 | 递归 | 循环 |
|---|---|---|
| 单次迭代开销 | 高,涉及帧创建与销毁 | 低,仅字节码跳转 |
| 空间位置 | 调用栈,大小固定 | 堆,受内存总量约束 |
| 深度上限 | 受 recursionlimit 与 C 栈双重限制 | 几乎无上限 |
| 代码可读性 | 树形、分治类问题上更直观 | 线性流程更直观 |
| 调试难度 | 栈帧嵌套深,traceback 长 | 断点位置明确 |
| 中断与恢复 | 难以在中间态暂停 | 天然支持状态保存 |
这张表我建议贴在显示器旁边。每次要写递归之前扫一眼,判断你到底是在用递归换可读性,还是在给自己埋雷。
2. 先看清数据长什么样,再决定写哪种
循环和递归的选择从来不是风格问题,而是数据结构形态问题。我见过太多人凭直觉选,然后在数据规模上来之后返工。
2.1 线性结构基本没有犹豫的余地
列表、字典、字符串、单链表、文件行流,这些本质上是线性结构,用循环表达最自然,性能也最好。有人为了"代码优雅"把列表求和写成递归,除了炫技没有任何收益,还额外承担了爆栈风险。
单链表反转是个有意思的边界案例。递归版本只有五行,看起来非常漂亮:
def reverse_rec(head): if head is None or head.next is None: return head new_head = reverse_rec(head.next) head.next.next = head head.next = None return new_head但它对长度的容忍度就是递归上限。链表长度一旦超过几千,这个写法就废了。迭代版本多几行,却能处理任意长度:
def reverse_iter(head): prev = None while head: nxt = head.next head.next = prev prev = head head = nxt return prev我在实际项目里一律用迭代版本。链表这种结构,长度本身就是不可控的输入参数,没有任何理由为省三行代码去赌上限。
2.2 深度不可控的输入必须走循环
判断标准其实很简单:这个数据的深度是你能在写代码时保证的,还是外部输入的?凡是外部输入,一律按循环设计。
典型的危险来源包括:用户上传的嵌套JSON、配置文件的层级引用、目录树的遍历、表达式解析、依赖关系图。这些场景的深度完全由数据决定,你的代码没有任何约束力。
一个嵌套字典的深度计算,递归版本和迭代版本对比:
def depth_rec(obj): if not isinstance(obj, dict) or not obj: return 1 return 1 + max(depth_rec(v) for v in obj.values()) def depth_iter(obj): stack = [(obj, 1)] best = 1 while stack: cur, d = stack.pop() best = max(best, d) if isinstance(cur, dict): for v in cur.values(): stack.append((v, d + 1)) return best递归版本在读代码时更符合直觉,但遇到超过一千层的畸形输入就会挂。迭代版本多了一个显式的栈容器,换来的是对任何输入都不崩溃的确定性。生产环境里,确定性比优雅值钱得多。
2.3 递归真正划算的四类场景
反过来说,有几种问题的结构本身就是递归定义的,这时候硬写循环反而会大幅增加心智负担。
第一类是树形结构。二叉树的前中后序遍历、目录树渲染、组织架构展开,递归写法和数据的定义形式一一对应,几乎不用思考。
第二类是回溯搜索。全排列、组合、子集、八皇后、迷宫路径,核心套路是"做选择、递归、撤销选择",用循环模拟这个流程会写出一堆手工维护的状态变量。
第三类是分治。归并排序、快速排序、二分查找的变体,把问题拆成同构的子问题,递归是最贴合的表达。
第四类是表达式与语法解析。递归下降解析器几乎是这类问题的标准解法,因为文法本身就是递归的。
这四类的共同点是:深度由算法的分治层数决定,通常是 log n 级别,天然可控。二叉树深度在平衡时是 log n,回溯的深度等于解的长度,分治的深度等于拆分次数。只要不是恶意构造的数据,几十层顶天了,完全在递归上限之内。
2.4 一张判断表,三秒钟做决定
| 数据形态 | 推荐写法 | 理由 |
|---|---|---|
| 数组、字符串、文件流 | 循环 | 线性遍历,递归无收益 |
| 链表、链式结构 | 循环 | 长度不可控,递归易爆 |
| 外部输入的嵌套结构 | 循环 + 显式栈 | 深度完全不可信 |
| 平衡二叉树 | 递归 | 深度 log n,可读性收益大 |
| 退化链表形态的树 | 循环 | 深度可能等于节点数 |
| 回溯搜索 | 递归 | 状态回滚天然贴合 |
| 分治算法 | 递归 | 子问题同构 |
| 高频调用的热路径 | 循环 | 调用开销累积明显 |
注意第四行和第五行的区别。同样是树,平衡树和退化树的深度差了几个数量级。如果你的数据来源不可靠,树可能退化成链表,这时候递归写法就得改成显式栈。我在处理一个分类层级数据时就吃过这个亏,测试数据层级均匀,线上出现了某个分支挂了两千多层的情况。
3. 递归改循环的几种具体手法
知道该改是一回事,能顺利改出来是另一回事。递归转迭代有几种成熟的套路,掌握之后大部分场景都能直接套。
3.1 显式栈模拟:把"待办事项"摆到明面上
递归的本质是解释器替你维护了一个调用栈。转迭代的核心思路就是把这个栈自己建出来,用列表或者collections.deque承载。
以二叉树前序遍历为例,递归版本是:
def preorder_rec(root): if root is None: return [] return [root.val] + preorder_rec(root.left) + preorder_rec(root.right)改成显式栈,注意入栈顺序要反过来,因为栈是后进先出:
def preorder_iter(root): if root is None: return [] out, stack = [], [root] while stack: node = stack.pop() out.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return out这套改写的关键是分清"处理顺序"和"入栈顺序"。前序是根左右,出栈时先拿到根,那么入栈就得先右后左。很多人第一次写会顺手先压左子树,结果输出变成根右左,排查半天。
中序遍历就没这么简单了,需要一路向左压栈到底:
def inorder_iter(root): out, stack = [], [] cur = root while cur or stack: while cur: stack.append(cur) cur = cur.left cur = stack.pop() out.append(cur.val) cur = cur.right return out后序遍历更绕,一个常用技巧是用"根右左"的顺序遍历再反转结果,或者用双栈。这里不展开,重点在于:显式栈能表达任何递归,但复杂度会随递归逻辑的复杂程度上升。如果递归函数在调用之后还有后置处理(比如归并排序里的合并步骤),显式栈就需要额外记录状态标记,代码量会明显膨胀。
我的经验是:单路递归(前序、叶子收集、路径求和)改起来很轻松;双路递归带后置合并(归并、树形DP)改起来成本高,不如保留递归,只要保证深度可控。
3.2 把递归改写成尾调用形式,再手工展开
有些递归天然可以写成尾递归,也就是递归调用是函数的最后一个动作,返回值直接透传。这类递归改成循环几乎是无痛的,只需要把累积结果变成循环变量。
以阶乘为例,普通递归:
def fact_rec(n): if n <= 1: return 1 return n * fact_rec(n - 1)这里的乘法发生在递归返回之后,所以不是尾递归。引入累加器改写:
def fact_tail(n, acc=1): if n <= 1: return acc return fact_tail(n - 1, acc * n)现在递归调用是最后一步,可以直接翻译成循环:
def fact_iter(n): acc = 1 for i in range(2, n + 1): acc *= i return acc需要说明的是,Python解释器并不做尾调用优化,写成尾递归形式并不会减少栈帧。这个改写手法的价值在于:一旦你把逻辑整理成尾递归形状,转循环就是机械操作,不会出错。
顺带提一句上面代码里的acc=1。这是安全的,因为整数不可变。但如果累加器是列表或字典,写成默认参数就是灾难,后面第5节会专门讲。
3.3 用队列替代栈,DFS 换 BFS
有些递归看起来是在做深度搜索,但你真正需要的只是"遍历所有节点",不关心顺序。这种情况下把栈换成队列,逻辑立刻变简单,而且天然不会有深度问题。
from collections import deque def bfs_collect(root): if root is None: return [] out, q = [], deque([root]) while q: node = q.popleft() out.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) return out这个改写的前提是"遍历顺序无所谓"。如果业务逻辑依赖先处理子节点再处理父节点(比如计算树的高度、汇总子树统计),BFS 就不适用了,因为它是按层推进的,父节点会在子节点之前被处理。判断依据很简单:问自己一句"我需不需要用到子问题的返回值"。需要,就不能换 BFS。
3.4 生成器加 yield from,把递归摊平成可迭代流
生成器是Python里被低估的工具。当你需要"按某种顺序逐个产出元素",而不是"一次性构造完整列表"时,生成器版本的递归在内存上友好得多。
def walk(node): if node is None: return yield node.val yield from walk(node.left) yield from walk(node.right) for v in walk(root): process(v)这里有个必须说清楚的点:yield from并不会消除递归深度。每个yield from依然对应一次函数调用,栈依然在增长。生成器版本省的是内存里的结果列表,不是栈空间。我见过有人以为用了 yield 就没有递归限制了,结果照样报错。
不过生成器有一个额外好处:调用方可以在任意位置中断,不用等整棵树遍历完。做流式处理、分页输出、提前终止搜索时,这个特性非常好用。
3.5 保留递归形式,用记忆化消除重复计算
有些问题改写成迭代非常别扭,但慢的原因是重复计算而不是调用开销。这种时候最划算的做法是保留递归,加一层缓存。
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n < 2: return n return fib(n - 1) + fib(n - 2)朴素递归的fib(35)要跑几秒,加上缓存之后几乎是瞬间返回。代价是每个不同的 n 都会在缓存里留一条记录,n 很大时内存会涨。maxsize=None表示无上限,适合 n 有界且不太大的场景;如果 n 可能到百万级,就该考虑改成自底向上的循环了。
这里的选择标准是:状态空间小、重复子问题多,用记忆化递归;状态空间大或者本身就是线性推进,用循环加滚动变量。
4. 缓存与性能:那些数字背后的真实情况
聊性能不能只凭感觉,得有几个可以对照的量级。下面的数据来自我在普通开发机上的测试,具体数值会随机器变化,但相对关系是稳定的。
4.1 循环与递归的开销量级对比
| 场景 | 循环写法 | 递归写法 | 备注 |
|---|---|---|---|
| 求和 n=1000 | 微秒级 | 百微秒级 | 纯调用开销差异 |
| 求和 n=900 | 微秒级 | 百微秒级 | 接近上限仍可运行 |
| 求和 n=2000 | 微秒级 | 抛 RecursionError | 直接不可用 |
| 斐波那契 n=30 朴素 | 微秒级 | 数百毫秒 | 指数级重复计算 |
| 斐波那契 n=30 记忆化 | 微秒级 | 微秒级 | 缓存抹平差异 |
| 树遍历 一万节点 | 毫秒级 | 毫秒级 | 差异不明显 |
从这张表能读出两个结论。第一,递归的绝对开销在深度上千时才变得刺眼,几十层的递归根本感觉不到。第二,真正让递归变慢的通常不是调用开销,而是算法层面的重复计算。很多人把"递归慢"当成铁律,实际上优化方向找错了。
4.2 lru_cache 的收益边界和它的坑
lru_cache用起来只需一行装饰器,但它有几个必须知道的约束。
第一个坑是参数必须可哈希。传列表、字典、集合进去会直接抛TypeError。解决办法是转成元组或者不可变结构:
@lru_cache(maxsize=None) def solve(state): # state 必须是 tuple / frozenset / 数字 / 字符串 ...第二个坑是缓存键包含所有参数。如果函数有多个参数,但其中某个参数在递归过程中始终不变(比如整个矩阵、整个图),它会被反复塞进缓存键里,既浪费内存又拖慢哈希计算。这个场景更适合把不变参数放到闭包外层,只对变化的部分做缓存。
第三个坑是缓存会保留对象的引用。如果你的参数是大对象,缓存不清理就等于内存泄漏。用cache_clear()手動清,或者改用maxsize限制条目数。
第四个坑是递归深度不会因为缓存而降低。缓存只减少调用次数,不减少调用深度。所以深递归加缓存依然会爆栈。
4.3 手写记忆化的可控性优势
当lru_cache的约束碍事时,自己维护一个字典反而更灵活:
def min_path(grid): memo = {} rows, cols = len(grid), len(grid[0]) def dfs(i, j): if i == rows - 1 and j == cols - 1: return grid[i][j] key = (i, j) if key in memo: return memo[key] best = float('inf') if i + 1 < rows: best = min(best, dfs(i + 1, j)) if j + 1 < cols: best = min(best, dfs(i, j + 1)) memo[key] = grid[i][j] + best return memo[key] return dfs(0, 0)手写版本的几个好处:可以把memo的作用域限制在函数内部,调用结束自动回收;可以在缓存里存额外的中间信息;可以针对特定键做定制化的失效策略。代价是每次都要记得在返回前写入缓存,漏写就等于没缓存,而且这种 bug 非常隐蔽,结果正确但慢得离谱。
我个人的习惯是:纯函数式的数值递归用lru_cache,涉及复杂状态的搜索用字典手写。两种都写熟之后,切换成本很低。
5. 我在循环和递归上踩过的坑
这一节的内容,教科书上基本不会写,但每一个都能让你多花两小时排查。
5.1 可变默认参数在递归里的连锁反应
def collect_leaves(node, result=[]): if node is None: return result if node.left is None and node.right is None: result.append(node.val) collect_leaves(node.left, result) collect_leaves(node.right, result) return result这段代码单次调用看起来完全正确,但连续调用两次就会发现第二次的结果里带着第一次的数据。原因是默认参数[]只在函数定义时创建一次,所有调用共享同一个列表对象。递归场景下这个问题更隐蔽,因为递归的多个分支都在往同一个列表里追加。
正确写法是把默认值设为None,在函数体内初始化:
def collect_leaves(node, result=None): if result is None: result = [] ...这个坑我踩过不止一次,而且每次都是"这个函数昨天还好好的"这种场景,因为问题只在第二次调用时才暴露。
5.2 递归里共享可变状态导致结果翻倍
比默认参数更隐蔽的是闭包变量。看这个例子:
def all_paths(node): paths = [] path = [] def dfs(cur): if cur is None: return path.append(cur.val) if cur.left is None and cur.right is None: paths.append(path[:]) # 必须拷贝 dfs(cur.left) dfs(cur.right) path.pop() dfs(node) return pathspaths.append(path)如果忘了切片拷贝,最后所有的路径都会指向同一个列表对象,而且这个列表在递归结束时已经被pop空了。你得到的是一堆空列表。这个错误在调试时非常迷惑,因为打印中间状态时一切正常,只有最终结果是错的。
原则很明确:凡是把可变容器存进结果集,一律拷贝一份。
5.3 循环里的闭包延迟绑定
这个坑严格说不属于递归,但它在"用循环生成一堆函数"的场景里高频出现,和递归经常混着用:
funcs = [] for i in range(3): funcs.append(lambda: i) print([f() for f in funcs]) # [2, 2, 2]期望输出[0, 1, 2],实际全是 2。原因是 lambda 里的i是自由变量,在调用时才去外层作用域查找,而那时循环已经结束,i停在最后一个值。
修复方式是让每个 lambda 绑定自己的默认参数:
funcs = [] for i in range(3): funcs.append(lambda i=i: i)如果是用递归构造函数列表,同样的问题会出现,而且更难看懂。我在写一个规则引擎时遇到过,生成的十几条规则全都用了最后一个参数,排查了一下午才发现是这个原因。
5.4 RecursionError 的定位方法
报错信息通常长这样,traceback 有上千行,根本看不出问题在哪:
RecursionError: maximum recursion depth exceeded有效的做法是加打印,或者在关键位置插入深度检查:
import sys def rec(n, depth=0): if depth % 200 == 0: print(f"current depth = {depth}", file=sys.stderr) if n == 0: return 0 return rec(n - 1, depth + 1)更快的定位方式是临时把上限调高一点点,让程序吐出完整的 traceback,然后看 traceback 尾部反复出现的是哪个函数调用:
import sys sys.setrecursionlimit(2000)注意只调高一档,不要直接调到十万,否则可能变成段错误而不是异常。看到重复出现的调用链之后,通常就能发现是缺少终止条件,还是终止条件写错了边界。
最常见的两种原因:一是终止条件用了==而不是<=或>=,遇到跳过的值就永远到不了;二是递归参数在某条路径上没有向终止条件靠近,比如应该是n - 1写成了n。
5.5 忘记 return 导致递归返回 None
def search(node, target): if node is None: return None if node.val == target: return node if target < node.val: search(node.left, target) # 少了 return else: search(node.right, target)这段代码能找到目标,但返回永远是 None。原因是内层递归的返回值没有通过 return 传出来,外层函数执行完 if 分支之后继续往下走到函数末尾,隐式返回 None。
这个错误在带多个分支的递归里特别容易犯,因为代码"看起来"是完整的。检查方法很土但有效:每个分支的末尾都必须有 return,把你写的每个递归分支单独看一眼返回值。
6. 可以直接抄走的几套模板
下面这几套是我日常用得最多的结构,改改就能套到具体业务上。
6.1 树遍历的三套写法
递归版本适合深度可控的树:
def traverse(node): if node is None: return [] left = traverse(node.left) right = traverse(node.right) return [node.val] + left + right显式栈适合深度未知、只需要前序的场景:
def traverse_iter(root): if root is None: return [] out, stack = [], [root] while stack: node = stack.pop() out.append(node.val) stack.extend(c for c in (node.right, node.left) if c) return out带状态标记的显式栈适合需要后序处理(先处理子节点再处理自己)的场景:
def traverse_post(root): out, stack = [], [(root, False)] while stack: node, visited = stack.pop() if node is None: continue if visited: out.append(node.val) else: stack.append((node, True)) stack.append((node.right, False)) stack.append((node.left, False)) return out第三种写法是通用解法,任何递归都能用它翻译。visited标记相当于"这个节点的子问题已经处理完了,现在处理我自己",对应递归里的后置部分。
6.2 回溯的固定骨架
回溯是递归最出彩的地方,骨架几乎不用改:
def backtrack(choices, path, result, used): if len(path) == len(choices): result.append(path[:]) return for i, c in enumerate(choices): if used[i]: continue used[i] = True path.append(c) backtrack(choices, path, result, used) path.pop() used[i] = False三件事必须成对出现:标记占用与解除标记、追加元素与弹出元素。任何一个没对上,结果就会错得莫名其妙。我建议写完之后对着这三对操作逐个打勾检查。
另外一个细节:result.append(path[:])里的切片不能省。回溯过程中path会被反复修改,不拷贝就是存了一堆引用。
6.3 分治加记忆化的组合
from functools import lru_cache @lru_cache(maxsize=None) def solve(state): if is_base(state): return base_value(state) best = None for sub in split(state): cand = combine(solve(sub)) if best is None or cand < best: best = cand return best这套结构的关键约束是state必须可哈希,所以split返回的子状态也得是元组或数字,不能是列表。另外maxsize=None意味着缓存不设上限,递归层数多、状态空间大时要留意内存增长。
如果内存吃紧,可以改成手动管理缓存,并在适当的时机清理:
memo = {} def solve(state): if state in memo: return memo[state] ... memo[state] = best return best最后再提一个我个人的习惯:写完递归之后,我一定会造一个深度接近上限的测试用例跑一遍。深度 900 到 1000 之间最能暴露问题,既能跑出结果,又能提前发现边界处理上的疏漏。这个测试用例只要五行代码,能省下线上排查的时间远不止五分钟。
上面这些内容里,我个人在实际操作中最看重的是第2节那张判断表。递归写得漂不漂亮是次要的,能不能在数据规模变化时不出事才是主要的。我现在写任何递归之前,都会先问一句"这个深度我能保证吗",答不上来就老老实实写循环。