如果你最近在某刷题平台的每日一题里刷到“波斐契那数列”,第一反应多半是这名字是不是写错了。我第一次看到这个标题时,还以为是某个冷门数学猜想,点开题目描述才发现,定义清清楚楚写着:F(0)=0,F(1)=1,从第三项开始每一项等于前两项之和。这不就是斐波那契数列本尊嘛。“波斐契那”大概率是拼音输入法里手滑打出来的产物,或者纯粹就是社区里某个同学故意搞怪。但正是这种看似“错别字”的题目,让我把递归、记忆化、递推、矩阵快速幂到刷题tracker整套流程完整捋了一遍。这篇文章适合正在刷题准备面试的人,也适合想认真建立自己刷题记录体系的初级开发者。我会从解题思路讲到每日一题打卡管理,把能踩的坑都放上来。
1. 先读懂题:波斐契那到底考的什么
1.1 从“名字玩梗”看题目意图
先说结论:波斐契那数列就是斐波那契数列,英文Fibonacci,数学上经常写作Fib。这类每日一题的标题里偶尔会出现谐音、错字、地名梗,目的不是让你猜谜,而是让题目在列表里更有辨识度。可千万别被标题带偏,点进去之后一定要看题目描述和样例,这才是真正决定答题策略的地方。
斐波那契数列的递推关系本身非常简单,但正因为简单,出题人才能在上面堆出大量变化。最基础的考法是“给你一个n,求第n项的值”,稍微进阶一点会加上模数,比如输出F(n) % (10^9+7),再进阶一点会限制n的范围来到10^18,逼你放弃线性递推。拿到题目后,我一般先看两个东西:第一个是n的最大值,第二个是是否要求取模。这两个信息基本决定了你要用哪种解法,而不是一上来就写递归。
1.2 输入输出格式里的隐藏信息
平台上的每日一题,输入输出往往只有一句话。以我常见的版本为例:
- 输入一个整数n。
- 输出斐波那契数列的第n项。
- 部分题目要求多组输入,直到文件结束(EOF)。
- 部分题目会先给一个T,表示测试组数。
- 有些版本下标从0开始,有些从1开始。
这些细节看着无关痛痒,实际上处处是坑。n的范围决定了算法复杂度:如果n <= 40,递归都能过;如果n <= 10^7,必须用O(n)递推;如果n <= 10^18,矩阵快速幂跑不了;如果n大到无法描述,甚至需要皮萨诺周期找循环节。没有读清楚范围就开写,等于把命运交给运气。
2. 五种解法,从入门到进阶
2.1 递归版:代码最短,效率最差
很多人的第一反应是写成递归:
def fib_recursive(n): if n <= 1: return n return fib_recursive(n - 1) + fib_recursive(n - 2)这个写法在面试里可以当热身,但它有一个致命问题:重复计算。数列展开之后是一棵指数级增长的递归树,为了算F(5),F(3)会被算两遍,F(2)会被算三遍,n到了40左右就已经明显卡顿,n到了50以上基本等不出结果。复杂度是O(2^n),空间复杂度由于递归栈的存在是O(n)。
我第一次刷这道题时就吃过这个亏,在本地跑了个n=45,等了半天没出结果,还以为电脑死机了。后来画出递归树才明白,问题在于没有把已经算过的子问题存起来。这个版本的作用只有一个:帮你理解递推关系。
2.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),空间复杂度也是O(n)。记忆化搜索本质上是动态规划的一种实现方式,只是它顺着递归的路径去填表,专业术语叫“自顶向下”。这种方式写起来直观,不容易搞错状态转移顺序,特别适合初学动态规划的人。
但记忆化不是没有缺点:递归层数太深会爆栈。Python默认递归深度大概在1000左右,如果题目给的n超过这个数,即便缓存扛得住递归深度也扛不住。所以真正写在OJ上的时候,很多老手宁愿用循环递推,也不愿意依赖递归。
> 提示:递归深度问题在Python里可以通过sys.setrecursionlimit调高,但这是治标不治本。C++也会有栈空间限制,竞赛里可能直接RE。2.3 递推与滚动数组:工程里最稳的选择
面试或者实际开发中,我推荐用最朴素的迭代:
def fib_iter(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return areturn a还是return b取决于你对下标和循环次数的定义。如果用n=0返回0,n=1返回1,上面这样写是能对的。如果需要取模,就在内层顺便模:
MOD = 10**9 + 7 def fib_mod(n): a, b = 0, 1 for _ in range(n): a, b = b, (a + b) % MOD return a这个版本的时间复杂度O(n),空间复杂度O(1)。它用两个变量滚动保存中间结果,不再需要整个数组。这就是所谓的滚动数组思想,和用dp数组记录所有状态相比,省内存但是不影响结果。
在C/C++里特别注意一点:a, b = b, a + b这种写法只有Python能用,C语言必须先暂存旧值。我早年经常因为temp搞错顺序而WA,后来养成了一个习惯:不管什么语言,先写清楚int tmp = b; b = (a + b) % MOD; a = tmp;再简写。
2.4 矩阵快速幂:n到了10^18也不慌
如果题目把n的上限调到10^18,O(n)的递推已经是天文数字,完全不可行。这时候要用到斐波那契和线性代数之间的关系。
斐波那契递推可以写成矩阵形式:
[ \begin{bmatrix} F_{n} \ F_{n-1} \end{bmatrix}
\begin{bmatrix} 1 & 1 \ 1 & 0 \end{bmatrix} \begin{bmatrix} F_{n-1} \ F_{n-2} \end{bmatrix} ]
把矩阵不断乘下去,就能得到:
[ \begin{bmatrix} F_{n} \ F_{n-1} \end{bmatrix}
\begin{bmatrix} 1 & 1 \ 1 & 0 \end{bmatrix}^{n-1} \begin{bmatrix} F_{1} \ F_{0} \end{bmatrix} ]
所以核心问题变成了“矩阵的n-1次幂怎么求”。整数快速幂的思想大家很熟,二进制拆解指数,把乘法变成连乘。矩阵快速幂就是把这个思想搬过来,只不过底数从整数变成了2x2矩阵,乘法从普通乘法变成了矩阵乘法。
MOD = 10**9 + 7 def mat_mul(A, B): return [ [(A[0][0] * B[0][0] + A[0][1] * B[1][0]) % MOD, (A[0][0] * B[0][1] + A[0][1] * B[1][1]) % MOD], [(A[1][0] * B[0][0] + A[1][1] * B[1][0]) % MOD, (A[1][0] * B[0][1] + A[1][1] * B[1][1]) % MOD] ] def mat_pow(M, p): res = [[1, 0], [0, 1]] while p: if p & 1: res = mat_mul(res, M) M = mat_mul(M, M) p >>= 1 return res def fib_matrix(n): if n == 0: return 0 base = [[1, 1], [1, 0]] return mat_pow(base, n - 1)[0][0]这里res初始化为单位矩阵,相当于整数快速幂里的ans = 1。矩阵快速幂的时间复杂度是O(log n),n=10^18也能轻松跑下来。更重要的是,这种“把递推变矩阵”的思路不局限于斐波那契,任何线性递推关系都能用,比如常见的二阶线性递推、三阶线性递推,甚至可以扩展成状态机转移矩阵。学一次,后面很多题都能复用。
2.5 通项公式和查表:真的有O(1)解法?
斐波那契有著名的比内公式:
[ F(n) = \frac{1}{\sqrt{5}} \left( \left(\frac{1+\sqrt{5}}{2}\right)^n - \left(\frac{1-\sqrt{5}}{2}\right)^n \right) ]
理论上用浮点数可以直接算出第n项,时间复杂度接近O(1)。但实际工程里,尤其是OJ上,我基本不推荐。原因很现实:浮点数有精度损失,n稍微大一点就会算错;如果要取模,带根号的数在模意义下根本没有办法直接算。这个公式适合用来估算规模、生成数学直觉,不适合作为AC解法。
另一种看似O(1)的做法是预处理查表。如果题目里n的范围很小,比如n <= 1000,而且有多次查询,那么可以预先算一个斐波那契数组放进内存,之后每次查询直接输出fib[n]。这是用空间换时间,适合给那些题目多变体、但单次范围小的场景。
3. 把每日一题秒变tracker:我的刷题记录法
3.1 还没开始写代码,先建好四张表
很多人刷题像逛商场,这家店看一眼,那家店碰一下,最后买了一堆用不上的东西。每日一题的价值在于持续积累,但如果没有记录体系,三天之后连自己做过什么都记不住。所以我一直坚持用一个专门的文件维护tracker,结构上可以拆成四张表。
| 表名 | 核心字段 | 用途 |
|---|---|---|
| 题单表 | 日期、题号、题目名、难度、标签 | 记录每天刷了什么 |
| 提交表 | 提交时间、状态、语言、耗时、错误原因 | 追踪每次尝试结果 |
| 错题表 | 首次WA日期、错误原因、正确思路、复现日期 | 专门喂养薄弱点 |
| 复盘表 | 每周总计、知识点分布、二刷结果 | 做周期性回顾 |
这四张表可以放在一个Excel里,也可以用表格软件,甚至直接用一个Markdown文件维护。工具不重要,重要的是字段别乱。我的个人习惯是“一题一行”,当天AC之后马上填充,不拖到第二天,因为记忆冷掉之后补记录的成本会变得很高。
3.2 每日一题的标准动作
刷每日一题千万不要一上来就写代码。我给自己固定了一套流程,总时长控制在30到60分钟:
- 读题3分钟:把输入输出格式、数据范围画出来。
- 思考5分钟:先想暴力怎么做,再想怎么优化,哪怕没有完整思路也要写出一个方向。
- 写代码15分钟:按思考方向写第一版,先求正确,再求效率。
- 自测5分钟:拿样例跑,再自己构造几个边界样例,比如n=0、n=1、n=最大值。
- 提交加记录5分钟:AC之后把题目标签和思路写进tracker;如果WA,把错误原因记到错题表。
这套流程看起来死板,但是非常适合需要长期坚持的人。因为决策成本降低了,你不必每天纠结“从哪道题开始”“要不要看题解”,照着流程走就行。坚持一个月之后,你会明显感觉到对输入输出边界的敏感度提高了一大截。
3.3 复盘标签怎么写:别只会标“动态规划”
跟踪记录里最容易被忽略的是标签体系。很多人会在标签里写“递归”“矩阵快速幂”,这样太粗了。我的做法是给每道题打三层标签:
- 知识点标签:递推、矩阵乘法、快速幂、记忆化
- 熟练度标签:0=完全不会,1=看题解才懂,2=独立AC,3=秒杀
- 回访标签:需要二刷、需要三刷、可以炫耀
比如这道波斐契那数列,我的tracker里大概是这样的:
2025-01-12 波斐契那数列 知识点:递推 / 矩阵快速幂 / 滚动数组 熟练度:2 回访:一个月后看看矩阵快速幂能不能秒写 备注:主要是递归重复计算坑了我一次,其余很顺。回访标签尤其重要。很多题做一遍是记不住的,大脑不是保险箱,一周之后能回忆起来的只有框架。定期把“需要二刷”的题拿出来重写,才能真正把知识变成自己的。我现在复盘频率是每周日一次,把一周的错题表扫一遍,重点重写熟练度<=1的题目。
4. 实战踩坑实录:从WA到AC的四个坎
4.1 取模的时机,比你想的容易错
当题目要求输出对某个大数取模时,最常见错误是等算完再取模。C/C++环境下,斐波那契数列在第47项左右就超过了2^31,第94项左右超过2^63,如果不边算边模,中间过程直接溢出,最终结果自然是错的。正确姿势是每一步加法都取模:
a = b; b = (a + tmp) % MOD;这是一个很经典的问题:把“先加后模”和“边算边模”算出来的结果在数学上是一样的,但工程上后者的中间值永远不会爆。Python虽然大数不会溢出,但大整数运算慢,加上取模之后也能显著降低数字规模、提升速度,所以即使Python也建议边算边模。
4.2 下标从0开始还是从1开始
这个坑简直防不胜防。题目如果写F(0)=0,F(1)=1,那么n=2应该输出1,n=3应该输出2。可有些题目的描述会写成“第一项为1,第二项为1”,下标从1开始。同一个斐波那契,两种定义会导致所有结果整体偏移一位。
我的检查方法是先跑样例:样例n=2如果输出1,说明从0开始;如果输出1也是这样,从1开始也是1,那就用n=3测试,从0开始输出2,从1开始输出1。拿边界测试压住定义,比盯着题目描述猜更可靠。
4.3 多组输入和EOF处理
各大刷题平台的每日一题经常沿用传统ACM模式,要求“多组输入,直到文件结束”。第一次遇到这种要求的人很容易只处理一组就结束。C语言的正确写法往往是:
int n; while (scanf("%d", &n) != EOF) { printf("%lld\n", solve(n)); }如果用cin,则写成:
int n; while (cin >> n) { cout << solve(n) << endl; }如果题目是先给T组,那么先读T,再循环T次即可。这两种模式千万不要混,用错了要么超时要么报错。
4.4 递归爆栈与虚拟内存
递归写法还有一个很容易被忽视的问题:栈溢出。在一些平台上,进程栈空间只有几十MB,递归深度过高会直接Runtime Error。n=1000的递归在C++里可能就有点危险,Python默认递归限制是1000,n只要超过这个数,不管你记忆化写得再好,一样会挂。所以我一直强调,OJ上面如果已经知道n可以很大,第一选择是递推而不是递归,除非题目明确要你练习递归分治思路。
4.5 大数问题的特殊处理
有些题目不取模,而是要求输出完整的斐波那契数,比如n=100时,结果有21位;n=1000时已经超过200位。C/C++内置类型完全扛不住,需要手写高精度加法,或者直接用Python这类大数友好的语言。但在竞赛里这类题目不算主流,大部分情况下题目都会用模数帮你把数字规模压下来。看到“输出第n项”没给模数时,先回头看看n的范围,如果n<=1000而没给模数,那多半是考验高精度而不是简单的递推。
5. 斐波那契还能怎么变形
5.1 跳台阶和兔子问题
一个最经典的变化是跳台阶:一只青蛙一次可以跳1级也可以跳2级,问跳到n级台阶一共有多少种方法。设f(n)为跳到第n级的方法数,最后一次跳1级时前面有f(n-1)种,最后一次跳2级时前面有f(n-2)种,所以f(n)=f(n-1)+f(n-2),和斐波那契一模一样。区别只在边界条件:f(1)=1,f(2)=2。
这类变形题就是套了一层生活背景的斐波那契。遇到这种题,我的做题习惯是先不急着写代码,先找出“最后一步有哪几种可能”,然后把加法写出来,递推关系自然就有了。背景换得再花哨,状态转移方程不变。
5.2 前缀和与区间查询
斐波那契还有一个重要的恒等式:
[ \sum_{i=1}^{n} F(i) = F(n+2) - 1 ]
如果题目让你求“从L到R的斐波那契之和”,可以直接用前缀和思维转换成F(R+2)-F(L+1),不用真的把每一项加一遍。这个公式的证明用数学归纳法就能完成,但它真正的价值在于:以后凡是遇到区间求和问题,先想想有没有前缀和或差分思想。
5.3 皮萨诺周期:模意义下的循环节
如果n大到无法用矩阵快速幂解决,比如n是10^100级别,我们还可以利用斐波那契数列模某个数之后的循环性。斐波那契模10的情况下,从第61项开始进入循环,周期是60。对于模10^9+7,周期会非常大,但仍然存在。这已经是竞赛进阶内容,普通每日一题不会直接考,但如果你把矩阵快速幂的代码都写熟练了,皮萨诺周期可以作为下一步的探索方向。
写在最后
刷题这件事,最难的其实不是某一道题的解,而是每天能不能坐在屏幕前把一道题啃完。我维护自己的刷题tracker已经一年多,中间断过好几周,但我发现只要记录表在,随时可以接上。波斐契那数列这道每日一题,看起来只是斐波那契的一次换皮,但通过它同时复习了递归优化、矩阵快速幂和刷题记录习惯的建立,我觉得比单纯刷十道同类型题更有价值。
如果你刚开始用每日一题打卡,我的建议很朴素:一天一道,做完写进tracker,每周日翻一次错题。三个月后再回头看,你会发现自己已经积累了很多人“刷了半年”都攒不下的复盘经验。这也是我作为一个刷题多年的人,最想分享的一条经验。