news 2026/10/2 19:12:56

深入理解递归:从阶乘、斐波那契到递归改迭代

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解递归:从阶乘、斐波那契到递归改迭代

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)1
fib(4)2
fib(3)3
fib(2)5
fib(1)8
fib(0)5

fib(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_symlinks=False): 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(n+1),那就永远结束不了。

排查方法:在函数开头打印参数和当前深度。写一个临时调试计数器,可以快速确认递推方向是否正确。打印两三次之后基本能定位问题:

def debug_fib(n: int, depth: int = 0): print(" " * depth + f"fib({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. 递归的“道与术”:什么场景该用,什么场景该绕开

学完上面这些,最重要的其实是建立一个“何时用递归”的判断力。我把它总结成三条经验:

第一,如果问题本身是递归定义的,并且子问题之间重叠很少,用递归写代码最漂亮。典型如树的遍历、目录解析、语法树运算。这时的递归是“最优解”。

第二,如果子问题大量重叠,比如斐波那契,纯递归是陷阱。你用递归写出了最直观的解法,却不自觉地写出了一个指数级算法。这时必须引入记忆化或者改写迭代。

第三,如果递归深度可能很大,优先考虑迭代或显式栈模拟。系统栈是你不可控的资源,不要赌它够用。

最后分享一个小技巧:当你真的拿不准该不该用递归时,先写出递归版本理清思路,再评估性能。递归版本的价值在于“验证思路”,迭代版本的价值在于“上线运行”。先把思路验证对,再用迭代换性能,这个顺序比直接纠结用哪种写法要高效得多。我自己很多次都是先写一个超简单的递归,确认逻辑没毛病,再动手改写成非递归版本。这样既享受了递归带来的思维清晰,也拿到了迭代版本的稳定性。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 19:12:55

WinRAR升级7.13:堵住压缩包攻击的安全漏洞

说起WinRAR&#xff0c;很多人第一反应是"那个解压软件"&#xff0c;然后顺手点开下载站随便装一个。但要是你电脑上装的还是几年前的3.x、5.x系列&#xff0c;或者那些带着"绿色版""去广告版"字样的第三方修改包&#xff0c;我劝你尽快换到7.13…

作者头像 李华
网站建设 2026/10/2 19:12:27

压力表调试、附件选型与故障排查实战指南

1. 压力表项目上手&#xff1a;先搞懂调试、附件与故障背后的逻辑先说个我在现场常遇到的场景&#xff1a;一套新设备调试&#xff0c;仪表柜里装着一排压力表&#xff0c;工艺人员催着开机&#xff0c;可指针不是卡滞就是回零不准&#xff0c;甚至表盘里还进了水汽。这时候大部…

作者头像 李华
网站建设 2026/10/2 19:11:12

热点事件怎么看?一套可复用的信息判断与情绪管理方法论

最近后台有朋友连着问了我好几遍同一个问题&#xff1a;"高广辉的事大家怎么看&#xff1f;"问的人一多&#xff0c;我意识到这背后是种挺普遍的焦虑——热点来得又快又猛&#xff0c;谁都怕自己显得迟钝、冷漠&#xff0c;更怕的是站错了队&#xff0c;回头被打脸。…

作者头像 李华
网站建设 2026/10/2 19:11:12

COSCon‘25产研开源协同论坛:开源如何链接科研与产业

1. 为什么今年特别值得看&#xff1a;产研协同从口号变成了大会主线每年这个时候&#xff0c;开源圈子里的朋友都在刷同一个话题&#xff1a;COSCon 的议程什么时候出来。今年我照例先翻了翻各论坛的议题清单&#xff0c;发现最值得单独拿出来聊的不是某个具体的技术分享&#…

作者头像 李华
网站建设 2026/10/2 19:09:59

Excel原生甘特图:零插件项目进度管理实战指南

1. 为什么“Excel之甘特图”不是炫技&#xff0c;而是项目管理里最实在的生存技能 你有没有遇到过这样的场景&#xff1a;老板在晨会上甩出一张PPT&#xff0c;上面写着“Q3上线新系统”&#xff0c;底下一行小字“开发周期6周&#xff0c;测试2周&#xff0c;上线前需完成UAT验…

作者头像 李华
网站建设 2026/10/2 19:09:58

Windows SDK与WDK安装的系统级准入机制解析

1. 为什么“SDK和WDK的安装”不是一句操作指令&#xff0c;而是一道系统级准入门槛 你点开搜索引擎输入“SDK和WDK的安装”&#xff0c;刷出来的结果里&#xff0c;90%是零散的截图、跳转链接、报错截图配一句“重装就完事了”。但真正做过Windows底层开发、驱动调试、内核模块…

作者头像 李华