十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

斐波那契数列Python修正:从递归炸栈到递推与矩阵快速幂的完整指南

斐波那契数列Python修正:从递归炸栈到递推与矩阵快速幂的完整指南 帮朋友调试一段Python代码时我印象最深的一句话是“斐波那契数列不就是一行 return n if n 2 else fib(n-1) fib(n-2) 吗我写对了可跑 f(36) 就是不动是不是电脑太慢了”我盯着他屏幕上那个写得很“标准”但完全没有优化的递归版哭笑不得。电脑不慢Python也不慢慢的是一个指数爆炸的调用树。也正是这件事让我决定把这几年围绕斐波那契数列的修正经验整理出来。斐波那契数列看起来只是一道入门题但往深了挖它同时是测试循环、递归、记忆化、生成器、矩阵运算、以及一类“带边界条件的状态转移”问题的绝佳例子。这篇笔记针对标题里“4-6斐波那契数列python修正”核心讲三件事初学阶段最容易写错的几种版本、从递归到递推再到快速幂的性能修正链路、以及一个容易被忽略的趣味联动——杨辉三角斜线数字之和恰好构成斐波那契数列。无论你是刚装完Python、准备配VSCode的新手还是想把这题讲明白的爱好者这篇都值得参考。1. 先理清斐波那契数列的定义从数学公式到代码之间隔着一层“边界条件”很多人在写代码前根本没把数列的定义在纸上写清楚。这是网上大量“错误版本”的第一个根源。1.1 教材里的两种起点直接决定你返回的是“第几项”斐波那契数列最常见的数学定义有两种它俩都没错但会导致最终函数的索引语义完全不同。第一种是从 0 开始F(0) 0F(1) 1F(n) F(n-1) F(n-2)第二种是从 1 开始F(1) 1F(2) 1F(3) 2如果你用第一种定义fib(0)返回 0用第二种定义fib(1)才返回 1。这是“第一个坑”。同一个fib(5)有的教材答案是 5有的教材答案是 8原因就在这里。作为Python实现我个人的修正建议是统一采用从0开始的定义并且把边界条件明确写成if n 0: return 0之类而不是写一个只有if n 2的“裸版本”。为什么因为n 2在 n 为负数时也会走到递归分支导致无限递归最终抛RecursionError。这也是后文要讲的第一个真实错误版本。1.2 用“状态转移”的视角去理解递推公式与其把F(n) F(n-1) F(n-2)当成一条数学公式我更建议初学者把它理解成一个状态转移过程当前项 前一项 前两项“前一项”会被下一轮计算复用每一轮只需要两个状态变量就能从 F(0)、F(1) 一路推到 F(n)这个视角转化很重要。当你把公式从“递归倒推”转成“递推正推”后面很多优化自然就顺了。F0 0 F1 1 F2 F1 F0 # 1 F3 F2 F1 # 2 F4 F3 F2 # 3 F5 F4 F3 # 5看到没有每次新算出的 Fk转身就作为“前一项”参与下一轮计算。这个“滚动”的直觉是写出稳定高效版本的前提。1.3 关键词场景映射为什么“修正”往往从边界开始这个项目标题里有个关键词是“python修正”市面上大量把“斐波那契数列”和“python”组合在一起的教程真正的问题往往不是看不懂公式而是代码没有覆盖边界条件。比如n 0 时函数返回什么n 1 时函数返回什么n 为负数时函数会不会炸栈n 是非整数比如 3.5时递归会不会永远不终止这些边界细节才是“修正”二字的真义。如果你现在手边的fib函数只写了def fib(n): return n if n 2 else fib(n-1) fib(n-2)那么建议立刻补上负数和整数检查。改完之后至少不会再半夜收到RecursionError的报警。2. 新手最容易写错的三个版本递归炸栈、循环多算一项、变量更新顺序颠倒我帮人改过很多次代码发现“斐波那契数列写不对”的原因从来不是不知道 F(n) 等于前两项之和而是以下几个特别隐蔽的细节。2.1 版本一忘写边界条件或者边界只写一半这是最常见的样子def fib_bad(n): return fib_bad(n-1) fib_bad(n-2)没写终止条件直接无限递归。还有人写了if n 0: return 0但没写if n 1: return 1结果 n1 时照样递归到 n-1 甚至更深然后抛异常。修正方法def fib_fixed_01(n): if n 0: return 0 if n 1: return 1 return fib_fixed_01(n-1) fib_fixed_01(n-2)很多人会问为什么建议用n 0而不是n 0原因很简单当 n 是负数时n 0不成立递归会一直把 n 减下去永远到不了终止条件直到 Python 的递归深度限制默认约 1000 层把程序拦下来。加一个n 0或者if n 0: raise ValueError(...)都能让函数行为可控。2.2 版本二循环里多算一项导致结果整体偏移再来看一个“看着很顺眼但输出不对”的循环版def fib_wrong_02(n): a, b 0, 1 for _ in range(n): a, b b, a b return b如果调用fib_wrong_02(5)你期望第 5 项是 5按 F(0)0 的定义但它返回的是 b 经过 5 次更新后的结果也就是 8。这个“多算一步”的偏差会让人摸不着头脑。要修正得先想清楚循环结束后 a 和 b 各代表什么。如果采用“第 i 项”从 0 开始的语义def fib_fixed_02(n): if n 0: return 0 a, b 0, 1 for _ in range(n - 1): a, b b, a b return b这里range(n-1)的含义是从 F(1) 出发再推 n-1 次得到 F(n)。我建议你在纸上把 n5 时的 a、b 变化全部写出来逐行对照很快就明白这个边界处理的重要性。2.3 版本三变量更新顺序抄错把 a 的新值覆盖成旧值这是我最常看到的一种“微小但致命”的错误def fib_wrong_03(n): a, b 0, 1 for _ in range(n): a b b a b return b问题出在哪a b执行后a 已经变成了原来的 b紧接着b a b实际上变成了b 原b 原b也就是直接翻倍。整个数列变成 0, 1, 2, 4, 8……完全不是斐波那契。修正方式要么用 Python 的同步赋值特性a, b b, a b要么用临时变量tmp a b a b b tmp关于这个错误我想多说几句底层原理。Python 执行a, b b, a b时会先把右侧的b和a b计算出来再统一解包赋值给左侧。这就规避了“先改 a 影响 b”的问题。如果你用 C、Java 之类的语言就必须显式用临时变量。很多老手从别的语言转过来时特别喜欢写第二个版本临时变量版反而不习惯一行同步赋值。2.4 本章小结改代码之前先在纸上列出前 6 项我经常建议初学者做的事是先把预期输出写出来。n预期结果F(0)0 版本00112132435568然后跑你的函数对比这张表。如果不对就用 print 把每一轮循环里的 a、b 打印出来。这个笨办法排查 99% 的“循环版写错”问题都有效。比我盯着代码看更靠谱。3. 递归版性能修正从指数爆炸到记忆化核心是消除重复计算如果说上面都是小打小闹的语法修正那这一节就是性能层面的关键修正。3.1 为什么朴素递归会指数爆炸基于定义直接写的递归def fib(n): if n 0: return 0 if n 1: return 1 return fib(n-1) fib(n-2)代码本身没有错但性能极差。原因在于fib(n-1)和fib(n-2)是两个独立调用它们各自会再去算更小的子问题完全没有共享结果。我把调用树画在脑子里给朋友看fib(6) 调用 fib(5) 和 fib(4)fib(5) 调用 fib(4) 和 fib(3)fib(4) 调用 fib(3) 和 fib(2)fib(3) 调用 fib(2) 和 fib(1)...其中fib(4)被重复调用了两次fib(3)被重复调用了三次越往下重复次数越多。整个计算量接近 2 的 n 次方量级。实测下来fib(35)就已经能感觉到明显的卡顿fib(40)往往要等好几秒fib(50)基本是天文数字级别的运算。3.2 修正思路一手动加一个字典缓存最简单的修正是把算过的结果存起来memo {0: 0, 1: 1} def fib_memo(n): if n in memo: return memo[n] memo[n] fib_memo(n-1) fib_memo(n-2) return memo[n]每次递归前先查缓存已经算过就直接返回。这样每个 n 只计算一次整体时间复杂度从指数级降到 O(n)。但它的缺点是memo 字典是全局的如果你同时用多线程或多次调用不同定义容易互相干扰。3.3 修正思路二直接用标准库 functools.lru_cache更省心的方式是用 Python 自带的缓存装饰器from functools import lru_cache lru_cache(maxsizeNone) def fib(n): if n 0: return 0 if n 1: return 1 return fib(n-1) fib(n-2)lru_cache会自动把不同 n 对应结果缓存起来。我第一次跑 fib(50) 时几乎秒出的那个瞬间彻底理解了“缓存是递归的好朋友”这句话。这个装饰器还有一个好处它把缓存放在函数内部不会污染全局命名空间如果需要清空数据调用fib.cache_clear()就行。Python 3.9 之后还有from functools import cache cache def fib(n): ...效果类似代码更短。3.4 关于尾递归的一个提醒不推荐在 Python 里写尾递归版本的斐波那契。虽然理论上尾递归可以优化但 Python 官方解释器CPython并没有实现尾递归优化递归深度仍然受限于sys.getrecursionlimit()。所以与其费劲把递归改成尾递归形式不如直接用迭代或记忆化。这一点是很多教程没有讲清楚的。3.5 修正后的性能对照给你一个直观的对比基于我实测的经验不同机器会有浮动但量级感受是真实的方式n30n40n50朴素递归毫秒级秒级卡顿等不起字典记忆化递归微秒级微秒级毫秒级lru_cache 递归微秒级微秒级毫秒级递推循环微秒级微秒级毫秒级初学阶段知道这三种递归修正方法就足够了。后面如果不满足于线性复杂度可以接着看矩阵快速幂。4. 工程上更稳的实现方式递推循环、生成器、矩阵快速幂递归加缓存虽然代码直观但它仍然占用递归栈。工程代码里我更推荐递推循环和生成器它们更省内存、更可控。4.1 递推循环空间复杂度 O(1)这是我最常用的版本def fib_iterative(n): if n 0: return 0 a, b 0, 1 for _ in range(n - 1): a, b b, a b return b它的思想是滚动状态a 永远表示“上一项”b 永远是“当前项”。每次循环更新一步一共走 n-1 步走到第 n 项。时间复杂度 O(n)空间复杂度 O(1)。写法和前面的错误版本很像区别只在于循环次数和边界处理是否正确。这也是很多面试官想要的标准答案不依赖递归不占额外空间。4.2 生成器适合“我需要前10000个斐波那契数”的场景如果需求是逐个产出数列而不是只求某一项生成器是最优解def fib_generator(): a, b 0, 1 while True: yield a a, b b, a b用法fib fib_generator() for _ in range(10): print(next(fib))输出0 1 1 2 3 5 8 13 21 34生成器的优势在于懒加载。你不需要一次性把一万个值全部算好放进内存用到哪个就产出哪个。这种写法在类似“股票数据流逐项递推”“自然数序列分批处理”的业务场景里特别顺手。之前我做过一个指标采集程序就是靠生成器处理递增数值序列内存占用一直很稳定。4.3 矩阵快速幂当 n 大到上亿时才需要的“杀手锏”斐波那契数列其实可以用矩阵运算来求| F(n1) F(n) | | 1 1 |^n | F(1) F(0) | | F(n) F(n-1) | | 1 0 | | F(0) F(-1)? |实际书写时很多人会写成[ F(n1) ] [1 1]^n [F(1)] [ F(n) ] [1 0] [F(0)]利用矩阵的幂运算再结合快速幂技巧可以把时间复杂度降到 O(log n)。当 n 是 10 的 9 次方级别时递推循环也会显得吃力矩阵快速幂仍然能快速出结果。这块属于进阶内容初学阶段可以跳过但如果你面试遇到“求第 1 亿个斐波那契数的后 6 位”之类的问题建议研究一下这个方案。代码示意简版def mat_mul(A, B): return [[A[0][0]*B[0][0] A[0][1]*B[1][0], A[0][0]*B[0][1] A[0][1]*B[1][1]], [A[1][0]*B[0][0] A[1][1]*B[1][0], A[1][0]*B[0][1] A[1][1]*B[1][1]]] def mat_pow(M, k): R [[1, 0], [0, 1]] while k: if k 1: R mat_mul(R, M) M mat_mul(M, M) k 1 return R def fib_matrix(n): if n 0: return 0 M [[1, 1], [1, 0]] P mat_pow(M, n - 1) return P[0][0]如果你看不太懂矩阵乘法没关系把它当成一个“后期可选的加速工具”就好。先用递推循环跑通业务遇到大规模计算再来研究它。4.4 四种实现方式的横向对比方式时间复杂度空间复杂度适用场景朴素递归O(2^n)O(n)调用栈理解公式演示递归思想递归缓存O(n)O(n)缓存代码直观适合学习和演示递推循环O(n)O(1)工程常用最推荐矩阵快速幂O(log n)O(1)不算结果存储超大 n 值进阶算法5. 一个容易被忽略的彩蛋杨辉三角斜线数字之和恰好构成斐波那契数列写斐波那契数列时顺带聊杨辉三角看起来有点跳跃但它正是这个标题相关热词里被频繁提起的一个知识点。我当初看到时也觉得挺神奇搞明白之后对组合数和递推关系的理解都更扎实了。5.1 从杨辉三角里“发现”斐波那契杨辉三角的前几行这里按左对齐显示第0行1 第1行1 1 第2行1 2 1 第3行1 3 3 1 第4行1 4 6 4 1 第5行1 5 10 10 5 1现在从右上往左下方向看斜线第一条斜线1 → 和是 1第二条斜线1 → 和是 1第三条斜线1 1 2第四条斜线1 2 3第五条斜线1 3 1 5第六条斜线1 4 3 8和分别是 1, 1, 2, 3, 5, 8……这正好就是斐波那契数列。我当时看到这个规律后第一反应是“这不是巧合一定有组合数层面的对应关系”。沿着这个思路查资料才发现这个斜线求和过程在组合数学里可以写成组合数和的形式本质上反映的是“每次走1步或2步上台阶”的计数模型。5.2 用代码验证这个规律如果你想自己验证可以用下面这段 Python 生成杨辉三角的某几行再去按斜线累加def yanghui_triangle(rows): tri [] for i in range(rows): row [1] * (i 1) for j in range(1, i): row[j] tri[i-1][j-1] tri[i-1][j] tri.append(row) return tri tri yanghui_triangle(8) fib_seq [] for k in range(1, 8): total 0 i k - 1 j 0 while i 0 and j len(tri[i]): total tri[i][j] i - 1 j 1 fib_seq.append(total) print(fib_seq)输出就是[1, 1, 2, 3, 5, 8, 13]从这个角度看斐波那契数列不只是“兔子生小兔子”的数学游戏它跟组合数、排列路径、动态规划都有千丝万缕的联系。5.3 现实应用上楼梯问题为什么是斐波那契有一道经典的算法题一次可以走 1 级或 2 级台阶走上 n 级台阶有多少种走法答案恰好是斐波那契数列的第 n1 项或第 n2 项取决于你从哪一项开始计。原因是走到第 n 级台阶的最后一步要么是从第 n-1 级走 1 级上来要么是从第 n-2 级走 2 级上来所以总的走法数满足同样的递推关系。这个例子说明理解斐波那契数列的正循环/递归写法不只是为了“证明你会写 Python”更是为了后面理解动态规划里的状态转移思想打基础。如果你日后接触到背包问题、股票买卖问题、路径计数问题会发现核心都是“把问题拆成前一个状态和后一个状态的关系”跟斐波那契的递推逻辑一脉相承。5.4 关于“教科书里不会写”的排错技巧如果你想在 Python 里验证“斜线求和”是不是真的是斐波那契千万不要手动一条一条数。写代码时注意一点杨辉三角的行列索引和列表下标从 0 开始而斜线的起点在不同表示法下有差异。我当时第一次写就是因为把第0行当成第1行导致斜线起点错位算出来 2, 3, 5 却怎么都对不上。排查方法很简单把每一行以及每一条斜线涵盖的数字都打印出来逐行核对。一个笨办法可以省掉一晚上的困惑。6. 跑通斐波那契代码之前的环境准备给刚接触 Python 的读者的实战建议因为这个项目标题相关热词里大量出现 python 安装、vscode python 环境配置、linux 系统安装 python 之类的内容我猜不少读者卡在了“代码写好了却跑不起来”或“不知道在哪里跑”这一步。环境是斐波那契之外的额外话题但确实挡了很多人。6.1 最省心的环境组合如果你只是想快速验证斐波那契数列的递归、递推、生成器版本我个人推荐本地安装官方 Python然后直接用 IDLE 或者 VSCode 跑脚本不需要一上来就折腾 Docker、虚拟环境。下面是具体步骤附带我踩过的坑。安装 Python 时Windows 用户注意勾选 “Add Python to PATH”。很多人装完以后在命令行敲python提示找不到命令90% 是这个勾没勾。Linux 用户则要注意系统自带的 Python 版本可能比较旧有些包需要 3.9 以上比如functools.cache。如果你用的 Ubuntu装新版 Python 的推荐方式是添加 deadsnakes PPA或者在官网下载源码编译。VSCode 里有个高频坑装完 Python 插件后右下角会让你选择解释器。如果你系统里同时有 Python 3.8 和 3.11一定要选中你要用的那个。否则可能出现“命令行里 python 能跑VSCode 里一运行就说没有某个模块”的诡异情况其实只是选错了解释器。6.2 验证环境是否正常的三个命令在终端或命令提示符里依次敲python --versionpip --versionpython -c print(hello)如果三条都正常说明基础环境没问题。接下来把上面任意一版 fib 函数保存成fib.py用python fib.py跑一遍。跑不出来优先检查缩进、括号、中英文符号。Python 对缩进极其敏感这是新手报错第一大来源。6.3 如果你用 Anaconda / Miniconda相关热词里出现“conda create -n env python3.8 -y”之类的内容说明用 conda 管理 Python 环境的人也不少。我的建议是斐波那契这种单文件脚本不需要 conda直接用系统 Python 就行。但如果你同时在做机器学习、数据分析需要不同项目用不同包版本conda 是多环境管理的靠谱选择。创建环境的经典命令conda create -n fib-demo python3.11 -y conda activate fib-demo激活之后终端前面会出现(fib-demo)字样在这个环境里装什么包都不影响其他项目。跑完斐波那契实验conda deactivate退出。6.4 一个初学者友好的小脚本模板把验证逻辑放在if __name__ __main__:里是工程上推荐的做法它让这个文件既可以作为模块被别处 import又可以单独运行。下面是我常用来演示给学生的完整模板from functools import lru_cache lru_cache(maxsizeNone) def fib(n): if n 0: return 0 if n 1: return 1 return fib(n - 1) fib(n - 2) if __name__ __main__: for i in range(10): print(ffib({i}) {fib(i)})运行后应该看到从 0 到 34 共 10 项结果。如果和你预期一致那你的环境、语法、逻辑就都通了。6.5 我的一次实测经验递归缓存和递推循环的表现差异我曾经在同一个台机器上分别跑过fib(100000)的 lru_cache 递归版和递推循环版。在 Python 默认递归深度限制下纯递归版还没等算完就直接碰到递归深度上限报错了递推循环版则很快跑出结果。这让我更加确定初学可以借递归理解概念工程实现优先选递推循环。另外补充一点lru_cache会缓存函数的调用参数和返回值。如果你在同一个进程里多次调用fib(100)第二次几乎是瞬间返回。这和“生成器一次只能产出一次”不同因为生成器没有缓存能力。搞清楚两者的差异你就知道什么时候用函数、什么时候用生成器。6.6 再往深一步如何用动态规划视角优化状态如果你已经理解了递推循环a, b b, a b会发现它其实是一种极其简洁的动态规划写法状态a 表示 F(i-2)b 表示 F(i-1)转移下一轮 a 变成 F(i-1)b 变成 F(i)初始状态a0, b1 对应 i1这种滚动变量的技巧在后面做最长子序列、背包问题时都经常用到。等你把斐波那契数列的所有写法都吃透了再回头去看那些“看起来很难”的算法会发现它们背后的状态转移思维和这题一模一样。这也是为什么不管过了多少年面试和入门教程总爱拿它说事。我自己在实际写代码时的习惯是默认用递推循环需要流式产出就用生成器需要演示递归思想和缓存机制就用lru_cache。遇到超大 n 才考虑矩阵快速幂。希望这篇修正笔记能帮你少走一点弯路。如果你在跑代码时碰到了具体的报错信息建议先把报错完整抄下来再逐行对照上面几个版本九成以上都能对上号。
返回列表