☰
Python循环与递归:执行成本、栈溢出与改写优化
2026/10/31 16:56:38 网站建设 项目流程

刚学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 paths

paths.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节那张判断表。递归写得漂不漂亮是次要的,能不能在数据规模变化时不出事才是主要的。我现在写任何递归之前,都会先问一句"这个深度我能保证吗",答不上来就老老实实写循环。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询