资讯动态

递归入门:从阶乘理解递归、调用栈与基线条件

发布时间:2026/10/17 12:27:17 来源:尧图企业网站定制
说到函数递归很多人第一反应是“函数自己调用自己”这句话说对了一半。真正让我彻底理解递归的不是概念定义而是一个最简单也最经典的例子——阶乘计算。n! n × (n-1)!这个数学公式里天然嵌套着“我里面还有我”的结构用它来理解递归的核心思想比背一百遍定义都管用。这篇文章适合刚学编程、被递归绕晕的新手也适合想系统梳理递归和调用栈原理的开发者。我会从阶乘入手把递归的拆解思路、代码写法、调用栈机制、常见坑位一次讲透最后再带你看看递归在真实项目里到底怎么用。1. 递归的本质先想清楚“怎么拆”再动手写代码1.1 用阶乘破题一个最简单的递归模型我见过太多初学者一上来就盯着递归函数的代码看盯着盯着就晕了。代码里确实有“函数调用自身”这回事但如果你只盯代码永远理解不了递归。正确的姿势是先忘掉代码回到数学。阶乘的定义是这样的n! n × (n-1)!这个公式有意思的地方在于它把“求 n 的阶乘”这个问题变成了“求 n-1 的阶乘然后乘上一个 n”。而“求 n-1 的阶乘”又是一个和原问题结构完全相同、只是规模更小的问题。这就是递归的核心思想把一个大问题拆成一个更小但同构的子问题然后沿着这条链一直拆下去直到拆到一个可以直接回答的边界。用生活类比一下你想知道自己排队排在第几位你问前面那个人“你是第几位”他也不知道于是他又问前面的人……一直问到队伍第一个人他说“我是第一”然后信息再一个一个传回来。在这个例子里“问前一个人”就是递归调用“我是第一”就是基线条件而每个人往后传话的过程就是返回值一层层返回。阶乘之所以是递归的“Hello World”就是因为它把这个过程压缩到了一个式子、几行代码里没有任何无关干扰。理解透阶乘再看树遍历、快速排序、回溯算法会发现骨子里都是同一套东西。1.2 为什么递归能成立自相似结构与问题规模递减递归不是玄学它能成立靠两个客观前提。第一个前提是自相似性。一个大问题里包含的“子问题”形式上和大问题一模一样只是参数不同。阶乘问题里n! 和 (n-1)! 的结构完全相同目录遍历问题里遍历一个文件夹和遍历它的子文件夹是同一个操作快速排序里排左半部分和排右半部分用的是同一个 partition 逻辑。如果没有这种自相似性递归就无从下手。第二个前提是问题规模必须递减。递归调用每次都必须把问题往“更小”的方向推一步否则就是在原地打转。阶乘里 n 变成 n-1目录遍历里大文件夹变成子文件夹二分查找里搜索区间每次缩小一半。这个“变小”的方向最终会指向一个不需要再拆的边界也就是基线条件。我经常跟同事说写递归的本质是在写两个东西怎么拆递去和怎么停基线。递去解决“大问题变子问题”基线解决“子问题最终变成什么”。这两个点想清楚代码写起来非常快想不清楚代码就会变成一场灾难。1.3 递归函数的两大支柱基线条件与递归条件一个合格的递归函数代码结构上必然包含两部分。我习惯把递归条件放在前面基线条件放在最后但顺序其实没有硬性规定关键是逻辑要完备。先看这段阶乘代码def factorial(n): # 递归条件继续拆分 if n 1: return n * factorial(n - 1) # 基线条件直接给出答案 return 1递归条件里函数带着缩小后的参数再次调用自己这一步是把问题“扔向下一个层级”。基线条件则像一个路标告诉函数“到这里就别再往前走了答案就是 1”。注意基线条件不是可选项而是必选项。缺少基线的递归函数就像一个没有出口的迷宫——每一层都继续往下调直到把调用栈撑爆系统抛出栈溢出错误。这个坑我在第 4 章会具体讲。很多人写递归时容易犯一个隐蔽的错误基线条件写了但条件判断和递归条件之间有“缝隙”也就是某些输入值既进不了递归条件也进不了基线条件。比如上面代码如果遇到 n 0会走 return 1 的分支没问题但如果把基线条件改成 n 1而调用时传入 n 0函数就会一路递归到 -1、-2直到栈溢出。这种边界值漏洞最容易出现在刚接触递归的代码里。2. 从数学公式到代码实现阶乘递归的完整拆解2.1 先手推一遍n! 是怎么被“拆”和“合”的理解递归最有效的方法是亲手在纸上把整个调用过程展开一遍。我们以 factorial(5) 为例把每一步都摊开来看。递去的过程层层展开factorial(5) 5 * factorial(4) factorial(4) 4 * factorial(3) factorial(3) 3 * factorial(2) factorial(2) 2 * factorial(1) factorial(1) 1 ← 打到基线开始回头归来的时候层层返回factorial(1) 1 factorial(2) 2 * 1 2 factorial(3) 3 * 2 6 factorial(4) 4 * 6 24 factorial(5) 5 * 24 120注意递去的时候每一层的计算其实都“悬着”5 到底乘什么取决于 factorial(4) 的返回值factorial(4) 又取决于 factorial(3)……这种“悬而未决”的状态是靠调用栈保存的。也就是说递归不是一个瞬间完成的事它分了两趟第一趟把所有问题压进栈里第二趟再从栈顶一个一个弹出、计算结果。我让团队里的小朋友练递归第一个作业就是把 factorial(6) 的展开过程手写一遍不许偷看代码。这个练习看起来傻但效果奇好。写过一遍之后你再看递归代码脑子里会自动浮现“拆”和“合”两个方向而不是一片空白。2.2 代码怎么写以Python为例的三种常见写法同一个阶乘递归根据你对“递归条件”和“基线条件”的组织方式可以写出几种等价形式。我用 Python 展示三种你可以感受一下区别。写法一先判断基线再递归最推荐给新手def factorial(n): if n 1: return 1 return n * factorial(n - 1)写法二先递归后判断少用容易踩坑def factorial(n): if n 1: return n * factorial(n - 1) else: return 1写法三用三元表达式代码最短但可读性见仁见智def factorial(n): return 1 if n 1 else n * factorial(n - 1)三种写法执行效果完全一样选哪种取决于你的风格和团队规范。我个人的建议是新手阶段用第一种逻辑顺序符合“先看边界再往前走”的自然思维等递归写熟练了再放飞自我用简写。补充一句不管哪种写法递归调用都必须出现在函数的 return 表达式里或者作为计算结果的一部分。如果你只是在函数中间呆呆地调用了一下 factorial(n-1) 却不接住结果、不参与运算那这个递归没有任何意义——它只是白白占了一层栈。2.3 调用栈里发生了什么递归过程的“账簿”每次函数调用系统都会在内存里开辟一块区域用来存放这次调用的局部变量、参数和返回地址。这块区域叫栈帧。递归调用几十层系统就压进去几十个栈帧后进先出一层层叠着。以 factorial(3) 为例调用栈的压栈顺序是main 调用 factorial(3) → 压入 factorial(3) 的栈帧 factorial(3) 调用 factorial(2) → 压入 factorial(2) 的栈帧 factorial(2) 调用 factorial(1) → 压入 factorial(1) 的栈帧当 factorial(1) 命中基线、返回 1 后系统开始弹栈factorial(1) 的栈帧销毁返回值交给 factorial(2)factorial(2) 算出 2销毁交给 factorial(3)factorial(3) 算出 6销毁交回给 main。把调用栈理解成一个“账簿”特别合适每一层的参数和中间状态都被记在账上等最底层的答案出来后再顺着账目一路算回去。如果你在分布式日志里调试过递归会看得更清楚——每一层调用都有独立的 trace返回时再逐层归并。这也解释了为什么递归往往比循环更耗内存循环只需要一个变量反复更新而递归最高峰时要把所有层级的状态同时存在内存里。空间复杂度是 O(n)而不是 O(1)。这个代价在阶乘这种浅层递归里无所谓但当递归深度到达数千上万时就会变成实实在在的问题。3. 递归与迭代的对决什么时候用递归什么时候用循环3.1 相同目标两种思路factorial 的循环实现还是阶乘我们用循环写一遍def factorial_iter(n): result 1 for i in range(2, n 1): result * i return result两种实现最终都能算出 n!但思考路径完全不同。递归的思考路径是“自顶向下”先把问题表达成“n 乘以后续结果”至于后续结果怎么来递归会自己解决。循环的思考路径是“自底向上”从 1 开始一层层往上乘乘到 n 为止。前者像你委托别人干活层层转包后者像你自己从头干到尾一手一脚。这两种思路没有绝对的谁优谁劣但有各自擅长和不擅长的场景。我见过不少人一听说递归概念好、思维清晰就把所有循环改写成递归结果代码跑起来又慢又占内存这是典型的“拿着锤子看什么都是钉子”。3.2 复杂度的公平对比时间、空间、可读性我们把阶乘的递归和迭代放在同一个天平上量一量。维度递归版迭代版时间复杂度O(n)O(n)空间复杂度O(n)调用栈逐层堆积O(1)只有两个变量可读性直观对应数学定义需要理解累乘逻辑栈溢出风险有深度过大时无调试难度较高需要跟踪多层调用较低单层循环从数据上看迭代版好像全面胜出。但在真实项目里选型远不是这么简单。递归的真正优势在于当你要解决的问题本身就是树状、层状、盒子里套盒子时递归代码的结构与问题结构天然一致而强制用循环去模拟这种嵌套结构要么需要手写栈要么代码会丑得没法看。举个例子遍历目录树这件事递归版三五行搞定如果用迭代你得自己维护一个待遍历的目录列表不断往里面压子目录再弹出处理代码长一倍不说逻辑也绕。这时候递归的可读性优势会完全压倒它的空间开销。3.3 尾递归优化一种让递归“接近循环”的改造递归的空间开销大源头在于“每一层都要保留状态等着底层返回后继续算”。如果能让递归在进入下一层之前就把当前这层的结果算完、不再依赖下层返回值那栈帧就没有保存的必要了。这种形式的递归叫尾递归。以阶乘为例普通递归是n * factorial(n-1)乘法的结果依赖下一层的返回值改成尾递归要把“已经算好的部分结果”作为参数往下传def factorial_tail(n, acc1): if n 1: return acc return factorial_tail(n - 1, acc * n)这里每次递归调用前acc * n已经算完了递归返回值直接原样返回不参与任何额外运算。形式上这就是“递归版循环”——acc 相当于循环里的 resultn 相当于循环变量。但要注意尾递归能否真正被优化取决于语言和编译器。像早期的 JS 引擎、部分 Python 实现并不做尾递归优化你写了尾递归也照样爆栈。我的建议是把尾递归当作一种“思维训练”来理解不要把它当成解决所有递归性能问题的银弹。在真正追求极致性能的场景直接上循环别绕弯子。这一对比下来你会发现递归和迭代不是敌人而是互补的两个工具。判断标准很简单问题本身有天然的嵌套/树状结构用递归问题本质是线性累加累乘用循环。阶乘其实两种都能玩但只有当你理解了两者的差异你才真的读懂了递归的定位。4. 递归踩坑现场栈溢出、重复计算与调试手法4.1 无限递归与栈溢出基线条件的致命缺失递归写多了你一定见过这个报错RecursionError: maximum recursion depth exceeded或者在其他语言里表现为栈溢出、段错误。这个错误十有八九是同一个原因递归没有在正确的时机停下来。最常见的情况是基线条件漏写或者条件写错。比如def bad_factorial(n): return n * bad_factorial(n - 1) # 没有基线必炸这段代码无论传什么正整数都会一路递归下去直到把调用栈撑爆。另一个常见情况是基线条件覆盖不到某些输入比如只处理了n 1却没处理n 0。别小看这种边界漏洞它比“完全没基线”更隐蔽因为函数跑起来看似正常只有特定输入才会触发爆炸。实操心得写完任何递归函数先问自己三个问题——基线条件存在吗所有输入最终都会走到基线吗每次递归调用都会让问题规模变小吗三个问题都回答“是”再往下写测试。这一步能挡掉 90% 的递归崩溃。还有一个不那么明显的坑参数变化没有单调递减。比如你把n改成n // 2看起来变小了但如果输入是负数-5 // 2等于 -3-3 // 2 等于 -2越除越大绝对值方向上没收敛到 1照样无限递归。递归的“递减”必须严格指向基线不能只是“看起来在变小”。4.2 重复计算问题以斐波那契为例阶乘递归本身没有重复计算问题但初学者很容易把递归用到不该用的地方最典型的反面教材就是斐波那契数列def fib(n): if n 1: return n return fib(n - 1) fib(n - 2)这个函数逻辑上完全正确但性能惨不忍睹。fib(6) 要算 fib(5) 和 fib(4)fib(5) 又要算 fib(4) 和 fib(3)……你会发现 fib(4) 被重复计算了两遍fib(3) 被重复计算了三遍越往下重复得越厉害。整个计算量是指数级的n 到 40 就卡得令人窒息。这个坑叫“递归树里的重叠子问题”在台阶游戏、组合数、部分动态规划问题里尤其常见。解决思路也有几条一是加缓存记忆化把算过的结果存进字典二是改成自底向上的动态规划三是直接用循环。阶乘为什么没这个问题因为阶乘的递归链条是单线的每个子问题只出现一次不存在分叉自然没有重叠。我给新人的建议是看到递归函数里有多个递归调用比如f(n-1) f(n-2)这种两个分支的先停下来算一算会不会有重复计算再决定要不要用递归。递归不等于低效盲目递归才会低效。4.3 调试递归的正确姿势打印“递去”与“归来”递归难调试因为它不像循环那样只有一个执行流。递归里同一个函数被调用了无数次你很难分清当前日志是哪一层的。我常用的调试手段是“压栈式打印”在函数入口和出口处各打印一次当前参数和返回值配合缩进看出层级。def factorial_debug(n, depth0): print( * depth f- 进入 factorial({n})) if n 1: print( * depth f- 返回 1) return 1 result n * factorial_debug(n - 1, depth 1) print( * depth f- 返回 {result} (来自 {n} * factorial({n-1}))) return result运行后你会看到清晰的“递去”和“归来”两段日志- 进入 factorial(3) - 进入 factorial(2) - 进入 factorial(1) - 返回 1 - 返回 2 (来自 2 * factorial(1)) - 返回 6 (来自 3 * factorial(2))从这段日志能直观看到箭头向内是递归展开箭头向外是结果返回。配合这个视角排查问题你会一眼看穿是哪一层出了问题——是参数没减小、基线没接住还是返回值的拼凑逻辑写错了。另一个实用技巧是“小参数法”先跑 factorial(1)、factorial(2)确认最底层行为正确再逐步加大参数。这样每次改动的影响范围都可控不会一出问题就是一团迷雾。5. 从阶乘出发递归的经典应用场景5.1 树形结构与目录遍历递归的主场阶乘帮你理解了递归的骨架但真正让递归大放异彩的场景是有“树形结构”的地方。最常见的例子是文件目录遍历一个文件夹里有子文件夹子文件夹里又有孙文件夹这就是一棵天然的树。import os def walk_dir(path, depth0): for entry in os.listdir(path): full_path os.path.join(path, entry) if os.path.isdir(full_path): walk_dir(full_path, depth 1) # 递归进入子目录 else: print( * depth entry)这段代码里的自相似性非常明显遍历“当前目录”和遍历“子目录”用的是同一个函数。递去的过程是不断打开更深的子文件夹基线条件就是“遇到文件打印完就返回”。如果你用循环实现同样功能得自己维护一个栈或者队列代码的思维负担明显更高。树形递归在真实业务里到处可见部门组织架构的上下级关系、评论区的楼中楼、电商分类的多级嵌套菜单、语法解析里的抽象语法树。凡是数据长成树状递归几乎就是最优解。你在这些场景里写递归时会发现和阶乘是同一套套路找自相似点确定基线把问题推向下层。5.2 汉诺塔与分治思想拆到只剩最小块汉诺塔是递归教学里仅次于阶乘的经典题三根柱子N 个大小不同的圆盘要把所有盘子从 A 移到 C每次只能移一个且大盘子不能压在小盘子上。这个问题的递归解法非常漂亮三行代码就能描述def hanoi(n, source, target, auxiliary): if n 1: print(f移动盘子 1{source} - {target}) return hanoi(n - 1, source, auxiliary, target) # 把上面 n-1 个盘子移到辅助柱 print(f移动盘子 {n}{source} - {target}) # 最大的盘子直接移目标柱 hanoi(n - 1, auxiliary, target, source) # 再把 n-1 个盘子从辅助柱移回目标柱你不需要真的去模拟每一步移动只需要相信把 n-1 个盘子搬走 → 搬最大的盘子 → 把 n-1 个盘子搬回来。这就是分治思想最纯粹的样子——问题一分为二每个子问题都是同构的、规模更小的问题基线就是“只剩一个盘子时直接移动”。汉诺塔跟阶乘的区别在于它不是单条链而是双分支递归每个递归调用的结果还相互独立。这正好能帮你补上“递归树”的想象——一个递归调用可以开枝散叶每个分支都走自己的递去和归来。想通汉诺塔再看归并排序、快速排序会发现分治这套心法如出一辙。5.3 回溯算法递归的更深层舞台如果说阶乘是递归的入门演练汉诺塔是分治的经典样板那回溯算法就是递归在“搜索与尝试”场景下的终极形态。八皇后、迷宫寻路、数独求解、括号生成这些问题本质上都是“把所有可能路径走一遍遇到死路就回头”。回溯里的递归调用经常配合一个“撤销操作”代码模式长这样def backtrack(path, options): if not options: # 找到一组完整解 return for option in options: path.append(option) # 做选择 backtrack(path, options - {option}) # 递归深入 path.pop() # 撤销选择回到上一层网上很多人把回溯叫“带撤销的深搜”这个说法非常准确。递归在这里扮演的角色是“往下试探”而撤销操作则是“退回上一步”。你如果只盯着单一层代码会觉得“这不就是在循环里调自己吗”但如果你画出递归树会看到每一次递归都是一条独立的分支路径撤销操作就是剪枝后回到分岔口重新选择。从阶乘到回溯递归的“拆解”思想没有变只是问题复杂度变了。阶乘的拆是线性的回溯的拆是树状的。所以我的建议一直是先把阶乘吃透再去看别的递归因为所有递归的骨架都在阶乘里——递去、基线、归来这三个动作缺一不可。6. 实操总结与常见问题速查6.1 递归“三板斧”写递归函数的自查清单写了这么多年代码我把写递归的心法浓缩成三个步骤每次动手前过一遍。第一步找递归结构。问自己这个问题能拆成“一个操作 一个同构子问题”吗子问题是什么阶乘的子问题是 (n-1)!目录遍历的子问题是子目录归并排序的子问题是左右半区。找不到递归结构说明问题本身可能不适合用递归。第二步定基线条件。问自己拆到什么程度可以直接给答案不用再拆阶乘是 n1 时返回 1目录遍历是遇到文件时打印并返回汉诺塔是一个盘子时直接移动。基线必须简单、明确、覆盖所有“最小输入”。第三步验证递去方向。问自己每一次递归调用参数是否都严格向基线收敛不会出现跳过基线、反向增大、原地打转的情况这一步可以用小参数跑一遍确认无误后再加大输入。这三步走完递归函数基本就稳了。你会发现大部分初学者写不好递归不是代码能力弱而是跳过了前两步、直接上手第三步结果就变成“写完代码再猜哪里错了”。6.2 常见问题与排查技巧速查表我在带人写递归的过程中把大家最容易踩的问题整理成了一张速查表你写代码时可以直接对照着查。现象常见原因排查思路栈溢出/递归深度超限基线条件缺失或写错检查基线能否覆盖所有输入特别是 0、负数和空值函数返回值不对递归调用结果没参与计算或 return 拼错手写展开前 3 层对比每一步的返回值计算特别慢存在大量重复子问题如无缓存斐波那契加缓存或改成自底向上的循环/动态规划参数变化异常递减方向不单调甚至反向增大打印每层入参观察参数是否向基线收敛调用顺序混乱递归调用前后的任务顺序安排不清晰明确“递去时该做什么、归来时该做什么”区分前序遍历与后序遍历除了这张表我还想分享一个“半成品测试法”如果一个递归函数你写到一半没把握先写一个只处理两层错误的版本比如if n 2: return 2跑通后再补上递归部分。这种做法可以帮你隔离“过小的基线导致逻辑错误”和“递归部分本身写错”这两类问题比一上来就写完整递归再猜错要高效得多。6.3 关于递归的三点经验之谈最后说几点我自己的体会可能对你有用。第一学递归一定要手动展开不要懒。哪怕是阶乘这种简单问题你在纸上画几遍“拆开 → 触底 → 返回”的过程对调用栈的理解会有一个质的飞跃。这个习惯我保持到了现在遇到复杂的递归函数我第一反应也是手写展开几步找规律而不是硬读代码。第二别被“递归效率低”这句话吓退。递归有开销但它的价值在于“代码结构匹配问题结构”。对于树形结构、回溯搜索这类场景递归的可读性和正确性优势远超那点性能损失。真正的问题是很多人滥用递归——线性累加也递归、简单遍历也递归这才把递归的名声搞坏了。第三理解递归是一个“螺旋上升”的过程。第一次看书看懂了写代码还是会卡第二个月再写突然通了再过半年用递归写回溯算法又会遇到新的理解瓶颈。这很正常因为递归不是一个知识点而是一种思维方式。把阶乘这个例子彻底吃透就是给这种思维方式打了一个最牢靠的地基后面遇到多复杂的递归你都能从这块地基上找到解法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑