☰
从兔子繁殖到矩阵快速幂:递归优化与斐波那契递推全解析
2026/11/3 16:48:48 网站建设 项目流程

兔子繁殖这道题,我在不同场合讲过不下二十遍,从刚入行的新人到带过的实习生,几乎每个人第一次手写递归都会栽在它上面。它表面上是道小学奥数式的应用题:一对小兔子出生,第二个月长成大兔子,从第三个月起每个月生一对小兔子,问第 n 个月一共有多少对兔子。但真正把它拆开来看,里面藏着递归设计的三要素、递归树的重复计算问题、递推与记忆化的取舍、大数溢出的边界,以及从 O(2^n) 一路优化到 O(log n) 的完整链路。可以说,一道兔子繁殖,把递归法该讲的东西全讲完了。这篇内容适合三类人看:正在学递归但写不出终止条件的新手、能写出答案但说不清复杂度的人、以及面试前想把递推类题目一次性打通的人。我会从数学模型怎么来的讲起,一路写到矩阵快速幂的落地代码,中间穿插我自己踩过的坑和排查顺序,代码给 Python、Java、C++ 三个版本,你可以直接抄。

1. 兔子繁殖问题的数学本质拆解

1.1 从故事到递推式:三个隐含假设决定了全部答案

很多人拿到这道题第一反应是画图,从第一个月画到第六个月,画出 1、1、2、3、5、8,然后恍然大悟"哦是斐波那契"。但我要说的是,光记住数列没有意义,你得知道这个数列是被什么假设逼出来的。原题里有三个关键约束,缺一个结论就变样。

第一个假设是成长周期固定为一个月。小兔子出生当月不算成熟,第二个月才长成大兔子。第二个假设是成熟个体每月稳定产出一对,不多不少,且从成熟的那个月起就开始生。第三个假设是没有死亡、没有外部迁入迁出,兔子只增不减,且雌雄比例恒定为 1:1,每对兔子贡献一对新兔。这三条摆在一起,才构成了一个封闭的、确定性的、离散时间的人口增长模型。

为什么强调这个推导过程?因为实际工程里你遇到的递推问题,边界条件往往就是被这类"假设"悄悄改掉的。比如把"第二个月成熟"改成"第三个月成熟",递推式立刻从 f(n)=f(n-1)+f(n-2) 变成 f(n)=f(n-1)+f(n-3),这就是经典的"母牛生小牛"问题。把"每月生一对"改成"每月生两对",系数就变成了 2。你如果只会背斐波那契,题目稍微一改就废了。

所以我在教别人的时候,第一步永远不是写代码,而是让他把每一条假设翻译成一个可以量化的条件,写在纸上。这一步花五分钟,后面能省两小时。

1.2 递推关系是怎么推出来的:按"上个月留下来的"加"这个月新生的"

严谨一点的推导是这样的。设 f(n) 表示第 n 个月月末兔子对数的总和。第 n 个月的兔子可以分成两类:一类是第 n-1 个月就已经存在的,另一类是第 n 个月新出生的。

第 n-1 个月已经存在的兔子,到第 n 个月全部还在,数量就是 f(n-1),这部分没有损耗。

第 n 个月新出生的兔子,只能由"在第 n 个月已经成熟"的母兔生产。而一只兔子要成熟,必须在第 n-1 个月就已经存在,并且它在第 n-1 个月时至少已经满两个月龄。换句话说,第 n 个月能生育的兔子,恰好就是第 n-2 个月月末就已经存在的全部兔子——因为第 n-2 个月存在的兔子,到第 n 个月时年龄至少是两个月,全部成熟了。所以新生数量等于 f(n-2)。

两部分相加:f(n) = f(n-1) + f(n-2),n ≥ 3。

这个推导的关键点在于"第 n 个月能生育的兔子 = 第 n-2 个月的总数"这一步转换,很多人卡在这里,觉得应该是 f(n-1)。你可以这样想:第 n-1 个月新出生的那批兔子,到第 n 个月才一个月大,还不能生,所以不能算进生育群体;剩下能生的正好是第 n-2 个月就存在的所有兔子。

1.3 初始项到底取 f(1)=1 还是 f(0)=0

这是第二个容易翻车的地方。初始条件取错,整个数列就偏移了。标准兔子问题的设定是:第 1 个月有一对新生的兔子,第 2 个月它们长成大兔子但还没生,所以前两个月都是 1。

月份 n状态描述兔子对数 f(n)
1一对小兔1
2一对大兔,尚未生育1
3一对大兔 + 一对新生小兔2
4一对大兔 + 一对一月龄 + 一对新生3
5两对大兔 + 一对一月龄 + 一对新生5
6三对大兔 + 两对一月龄 + 两对新生8

如果你在代码里把 f(0) 设成 0、f(1) 设成 1,那得到的数列是 0,1,1,2,3,5,8,数学上这叫标准斐波那契数列,和兔子问题只差一个下标偏移。这两种写法都对,但必须全局统一。我见过太多人前面用 f(1)=1、f(2)=1,中间某处又写了个 f(0)=0 去做循环起点,结果算出来永远差一位,调半天找不到原因。

我的建议是:写代码前先在注释里把前六项钉死,运行完立刻对照。如果输出是 1,1,2,3,5,8 就对了,如果出现 0,1,1,2,3 或者 1,2,3,5,8,说明下标定义跑偏了,回头改,不要试图在后面的逻辑里打补丁。

2. 朴素递归的第一版实现与它的真实代价

2.1 最直白的递归写法:三行代码对应三要素

按递推式直接翻译成代码,就是最朴素的递归版本。Python 版本如下:

def fib(n): if n <= 2: return 1 return fib(n - 1) + fib(n - 2) print([fib(i) for i in range(1, 11)]) # 输出: [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

这三行代码恰好对应递归的三要素,我在带人时会把它们拆开讲清楚:

  • 终止条件:n <= 2时直接返回 1。这是递归的"刹车",没有它程序会无限往下调用直到栈溢出。注意这里用<=而不是==,是为了防御 n 传入 0 或负数的情况,虽然兔子问题里 n 是正整数,但写成<=更稳。
  • 递推关系:fib(n-1) + fib(n-2)。这一行是递归的"发动机",它把规模为 n 的问题降解成两个规模更小的同类子问题。这里必须保证子问题的规模严格小于原问题,否则永远收敛不了。
  • 返回值语义:函数返回的是"第 n 个月的兔子对数"。这一条最容易被忽略,但它是所有错误的根源。如果你在某个分支里返回了别的东西,比如返回了子问题的调用次数,整个语义就乱了。

Java 和 C++ 版本骨架完全一样,只是要注意返回类型,n 小的时候用int够,n 超过 46 就得换long。我在第 4.2 节会专门讲溢出的边界在哪。

2.2 调用树长什么样:为什么 n=40 就已经跑不动了

朴素递归的问题不在结果,而在过程。以 fib(5) 为例,展开调用树你会发现:

  • fib(5) 调用 fib(4) 和 fib(3)
  • fib(4) 调用 fib(3) 和 fib(2)
  • fib(3) 被计算了两次
  • fib(2) 被计算了三次

到了 fib(40),fib(2) 会被重复计算上亿次。这不是夸张,实测下来 Python 跑 fib(35) 大约需要 3 到 4 秒,fib(40) 就要接近 40 秒。我做过一个粗略统计,朴素递归的函数调用总次数大约是 2*f(n)-1,而 f(40) = 102334155,也就是说光函数调用就是两亿次量级。

时间复杂度写出来是 O(2^n),但这个上界其实偏松。精确一点的分析是:递归树的每个节点产生两个子节点,但两侧深度不同,实际增长速率约等于黄金比例 φ ≈ 1.618 的 n 次方,也就是 O(1.618^n)。1.618^40 大约是 2.28 亿,和实测吻合。所以说 O(2^n) 是个安全但粗略的上界,面试时你答 O(2^n) 没人扣分,但能说出 1.618^n 会显得你真跑过。

空间复杂度是 O(n),因为递归深度最大就是 n,调用栈占用线性空间。这一点常被误认为也是指数级,其实不是——栈是随调用深度增长的,同一时刻栈上最多 n 层,子调用返回后就弹栈了。

2.3 递归深度不是主要矛盾,重复计算才是

初学者经常把两个问题搞混:一个是"递归太深会栈溢出",另一个是"递归太慢"。在兔子繁殖这道题里,n 通常不会超过几百,递归深度完全不是问题,Python 默认递归上限 1000 层,跑 n=500 都没事。真正要命的是重复计算。

但如果你把这道题当成通用的递归训练,就得知道深度的边界在哪。假设有人问"第 5000 个月有多少对兔子",Python 直接抛RecursionError: maximum recursion depth exceeded,因为默认限制是 1000。这时候要么用sys.setrecursionlimit(10000)手动放宽,要么干脆改用迭代。我个人的态度是:能用迭代就别动递归上限,改上限只是把崩溃点往后推,真到了几万层,物理栈内存照样撑不住,而且崩的时候往往是段错误,连堆栈信息都没有,排查成本极高。

顺带说一句,Java 默认栈大小一般是 512KB 到 1MB,每层栈帧几十字节,大概也就撑个几千到一万层;C++ 类似。所以递归深度超过一万,基本都得换成显式栈或者迭代。

2.4 递归三要素的自查清单

写完递归函数,我习惯按这个顺序过一遍,基本上能拦住 90% 的 bug:

  1. 终止条件是否覆盖了所有"最小规模"的情况。兔子问题里最小规模是 n=1 和 n=2,都要返回 1。如果只写了if n == 1: return 1,那 n=2 时会继续往下调 fib(1) 和 fib(0),fib(0) 又进入不了终止分支,直接无限递归。
  2. 每次递归调用是否让问题规模单调减小。fib(n-1)和fib(n-2)都小于 n,满足;如果写成fib(n)就是死循环。
  3. 返回值是否和函数语义一致。所有分支返回的必须是同一种东西,不能一个分支返回数量、另一个分支返回布尔值。
  4. 是否存在可以合并的重复分支。这是从"能跑"到"跑得快"的分水岭,也是下一节要展开的内容。

提示:这三要素不是背下来的口诀,而是排查工具。当你发现递归函数不返回或者返回错,按这四条逐条对,比盯着代码瞎看快得多。

3. 三条优化路线的取舍与实现

3.1 记忆化递归:给重复计算装上缓存

既然瓶颈是重复计算,最直接的想法就是把算过的结果存起来。这叫记忆化(Memoization),属于自顶向下的优化,代码改动极小:

from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n <= 2: return 1 return fib(n - 1) + fib(n - 2) print(fib(100)) # 输出: 354224848179261915075

lru_cache这一行装饰器干的事就是:函数被调用时先查缓存,命中直接返回,没命中才真正执行,执行完把结果写进缓存。加上它之后,每个 n 只会被真正计算一次,时间复杂度从 O(1.618^n) 直接降到 O(n),空间复杂度 O(n) 用于存缓存。

如果你在面试或者不允许用装饰器的环境,手写一个字典版本更清楚:

def fib(n, memo=None): if memo is None: memo = {} if n <= 2: return 1 if n in memo: return memo[n] memo[n] = fib(n - 1, memo) + fib(n - 2, memo) return memo[n]

这里有个新手常踩的坑:把 memo 定义成默认参数def fib(n, memo={})。Python 的默认参数只在函数定义时求值一次,所有调用共享同一个字典,看起来能用,实际上多次调用之间会串数据。虽然对斐波那契这种纯函数不影响结果,但这是坏习惯,一旦换成有副作用的问题就会出诡异 bug。正确做法就是上面那样,默认给 None,函数内部再初始化。

记忆化递归的优点是保留了递归的思维直观性,你不用担心计算顺序,想到哪算到哪;缺点是仍然有 O(n) 的栈开销,n 特别大时会栈溢出。所以它适合"递推式复杂、顺序不好确定"的场景,比如树形 DP、博弈类问题。对兔子繁殖这种线性递推,其实有点杀鸡用牛刀。

3.2 迭代递推:两个变量就能搞定的空间优化

既然 f(n) 只依赖前两项,那根本不需要存整个数组,也不需要递归栈,两个滚动变量就够:

def fib(n): if n <= 2: return 1 a, b = 1, 1 # a 代表 f(n-2),b 代表 f(n-1) for _ in range(3, n + 1): a, b = b, a + b # 先算新的 b,同时旧的 b 挪给 a return b

这段代码的时间复杂度 O(n),空间复杂度 O(1),是实际工程里最推荐的写法。我特别喜欢拿它当例子讲"滚动变量"这个技巧,因为它把"只需要保留有限个历史状态"这件事表现得极其干净。

a, b = b, a + b这一行是 Python 的元组解包,右边的b和a+b会先全部算完再赋值,所以不用担心覆盖问题。换成 Java 或 C++ 就得老老实实用临时变量:

public static long fib(int n) { if (n <= 2) return 1L; long a = 1, b = 1, tmp; for (int i = 3; i <= n; i++) { tmp = a + b; a = b; b = tmp; } return b; }

C++ 版本结构一致,只是把long换成long long:

long long fib(int n) { if (n <= 2) return 1; long long a = 1, b = 1, tmp; for (int i = 3; i <= n; ++i) { tmp = a + b; a = b; b = tmp; } return b; }

迭代版的另一个好处是天然支持取模。如果要算 f(10^9) 对 10^9+7 取模的结果,递归版根本开不了那么大的栈,迭代版只要循环 10 亿次就行——虽然慢,但至少不崩。当然 10 亿次循环也不现实,这就轮到下一节的矩阵快速幂出场了。

3.3 矩阵快速幂:把 O(n) 压到 O(log n)

兔子繁殖的递推式是线性的、常系数的,这类递推都可以用矩阵乘法表达。核心恒等式是:

[ f(n) ] [ 1 1 ] ^ (n-2) [ f(2) ] [ f(n-1) ] = [ 1 0 ] * [ f(1) ]

更常见的写法是用[[1,1],[1,0]]的 k 次幂,其左上角元素就是 f(k+1)。推导过程不复杂:设转移矩阵 M = [[1,1],[1,0]],那么 [f(n), f(n-1)]^T = M * [f(n-1), f(n-2)]^T,反复代入 n-2 次就得到上面的式子。

关键在于,矩阵的幂可以用快速幂来做:M^k 可以拆成 M^(k/2) 的平方,偶数情况一路折半,奇数情况多乘一次 M,总共只需要 O(log k) 次矩阵乘法。每次 2x2 矩阵乘法是常数时间,所以整体复杂度 O(log n)。

MOD = 1_000_000_007 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, e): R = [[1, 0], [0, 1]] # 单位矩阵,快速幂的累乘器 while e: if e & 1: R = mat_mul(R, M) M = mat_mul(M, M) e >>= 1 return R def fib(n): if n <= 2: return 1 M = mat_pow([[1, 1], [1, 0]], n - 2) return (M[0][0] + M[0][1]) % MOD

验证一下:n=5 时,M^(3) = [[3,2],[2,1]],左上角加右上角 = 3+2 = 5,正确。

Java 版本要特别注意乘法溢出,因为两个小于 MOD 的数相乘接近 10^18,刚好在 long 的安全范围内(long 上限约 9.22×10^18),但如果是三次累加就会溢出,所以每次加法后立刻取模:

static final long MOD = 1_000_000_007L; static long[][] mul(long[][] a, long[][] b) { long[][] c = new long[2][2]; for (int i = 0; i < 2; i++) { for (int j = 0; j < 2; j++) { for (int k = 0; k < 2; k++) { c[i][j] = (c[i][j] + a[i][k] * b[k][j]) % MOD; } } } return c; } static long fib(long n) { if (n <= 2) return 1L; long[][] res = {{1, 0}, {0, 1}}; long[][] base = {{1, 1}, {1, 0}}; long e = n - 2; while (e > 0) { if ((e & 1) == 1) res = mul(res, base); base = mul(base, base); e >>= 1; } return (res[0][0] + res[0][1]) % MOD; }

用这个写法跑 n=10^18,耗时基本在微秒级,肉眼感觉不到。这已经不是"优化"了,是换了套算法。

3.4 四种方案横向对比:到底该选哪个

方案时间复杂度空间复杂度适用场景主要风险
朴素递归O(1.618^n)O(n) 栈教学演示、验证递推式n>35 就卡死
记忆化递归O(n)O(n) 缓存 + O(n) 栈递推式复杂、顺序难定n 大时栈溢出
迭代递推O(n)O(1)绝大多数实际场景n 极大时循环太慢
矩阵快速幂O(log n)O(1)n 达到 10^9 以上实现易错、需取模

我的选择习惯是:面试写迭代,笔试看数据范围,工程里看是否有大数需求。如果 n ≤ 10^6,迭代最稳,代码短、无坑、好调试。如果 n 到 10^9 甚至 10^18,那就只能上矩阵快速幂,同时必须配取模。记忆化递归我一般只在讲课时用,因为它最好地展示了"优化"这个动作本身。

4. 落地实操:从写代码到验证通过的完整流程

4.1 开写之前先把测试用例钉死

前面说了要钉死前六项,这里给一套完整的手工计算表,你在本地跑代码时直接拿来对:

输入 n期望输出 f(n)说明
11边界,只有初始的一对
21边界,还没生育
32第一次生育
43—
55—
1055常见校验点
206765中等规模
5012586269025超过 int 范围

把这几个点写成断言,是防止改动引入回归的最省事办法:

cases = {1: 1, 2: 1, 3: 2, 4: 3, 5: 5, 10: 55, 20: 6765, 50: 12586269025} for n, expect in cases.items(): got = fib(n) assert got == expect, f"n={n} 期望 {expect} 实际 {got}" print("全部通过")

跑出"全部通过"这四个字,心里才踏实。这套用例我从学这道题开始一直用到现在,前后改了十几年没换过。

4.2 溢出边界:n 到多少就不再安全

这是兔子繁殖题里最容易被忽略、也最容易在线上翻车的地方。不同语言、不同整数类型的承载上限完全不一样:

类型最大值对应的最大 n
int(32 位有符号)2,147,483,647n = 46(f(46)=1836311903)
long long(64 位有符号)9,223,372,036,854,775,807n = 92
Python int无上限任意

具体的临界点:f(46) = 1836311903,f(47) = 2971215073,已经超过 int 上限。f(92) = 7540113804746346429,还在 long 范围内;f(93) = 12200160415121876738,超过 2^63-1,直接溢出成负数。所以如果你用 Java 的long算 f(93),会得到一个负数,这不是 bug,是溢出。

处理办法有三种,看场景选:

  • 只关心结果对某个数取模:最省事,全程取模,永远不会溢出,矩阵快速幂就属于这一类。
  • 需要精确大数:Python 天然支持;Java 用BigInteger;C++ 得自己写高精度或者用第三方库。
  • 只关心数量级:用浮点和 Binet 通项公式估算,但要注意精度问题,n 大了浮点会失准。

Binet 公式是另一个有趣的东西:f(n) = (φ^n - ψ^n) / √5,其中 φ = (1+√5)/2,ψ = (1-√5)/2。这个公式理论上 O(1),但实际用不了——n=70 左右双精度浮点就开始丢精度,算出来的整数部分是错的。我试过用 Python 的 Decimal 提高精度,能撑到 n=200 左右,再往上还是不行,而且速度还不如迭代。所以它只适合做理论分析或者粗略估算,别拿它写生产代码。

4.3 取模运算的坑:负数、时机、和多处取模

取模看起来简单,实际上有几个细节容易出问题。

第一个是负数取模。在 Python 里-1 % 7等于 6,符合数学定义;但在 Java 和 C++ 里-1 % 7等于 -1,是截断取余。如果你的中间结果可能为负(比如写了a - b这种减法),Java 那边必须写成((a - b) % MOD + MOD) % MOD才能保证结果非负。兔子繁殖本身是纯加法,一般不会出这个问题,但换成组合数、卡特兰数之类的题目就会踩。

第二个是取模的时机。原则是"能早取就早取",因为加法乘法的取模满足分配律:(a%m + b%m) % m == (a+b) % m。你在每次加法后立刻取模,中间值就永远不会超过 2m,自然不会溢出。反过来,如果攒到最后再取,中间可能早就溢出了,取模也救不回来。

第三个是多矩阵连乘时每一层都要取模。矩阵快速幂里一次乘法涉及 8 次乘加,每加一个乘积就取一次模最稳。我在 3.3 节的 Java 代码里就是这么写的,c[i][j] = (c[i][j] + a[i][k] * b[k][j]) % MOD,虽然多几次取模运算,但换来的是绝对安全。

注意:a[i][k] * b[k][j]这一步在 long 下是安全的,因为两个数都小于 10^9+7,乘积小于 10^18,未超 long 上限。但如果你的模数是 10^18 级别,这一步就会溢出,必须用__int128或者乘法转加法的技巧。选模数时心里要有一根弦。

4.4 对拍验证:手算靠不住的时候怎么办

当你把三种实现(朴素递归、迭代、矩阵快速幂)都写完了,最有效的验证方式是互相对拍:小范围内三个结果必须完全一致。

for n in range(1, 31): a = fib_recursive(n) # 朴素递归,n小的时候跑得动 b = fib_iter(n) # 迭代 c = fib_matrix(n) # 矩阵快速幂(小 n 时不取模或模数足够大) assert a == b == c, f"n={n}: {a} {b} {c}" print("三种实现结果一致")

对拍的价值在于:你不需要知道正确答案是什么,只要三种独立实现的思路都指向同一个值,那大概率就是对的。这是我在刷题和做工程时最依赖的验证手段。手算容易错一位,三种代码同时错成一样的概率极低——除非你的下标定义从一开始就是三份一起抄错的,所以还是要配合 4.1 节的定点用例一起用。

另外一个小技巧:把 n 从 1 到 30 的结果整串打印出来,肉眼扫一眼有没有出现负数(溢出信号)、有没有突然变大跳变(下标错位信号),很多问题一眼就能看出来。

5. 常见问题排查与同类题型延伸

5.1 报错速查表:这些坑我基本都踩过

现象大概率原因排查动作
结果差一位,永远少一个初始项定义不统一打印前六项对照 1,1,2,3,5,8
n=40 以上卡住不动用了朴素递归换迭代或加缓存
抛出 RecursionError递归深度超默认上限改迭代,或谨慎调高上限
输出负数long / int 溢出换大数类型或全程取模
矩阵版结果和迭代对不上幂次算成 n 而不是 n-2检查指数与单位矩阵
取模后全部为 0模数取得太小或写错常量检查 MOD 常量定义

这张表我建议你保存下来,遇到问题先对号入座,比从零开始读代码快得多。特别是第一行"差一位"和第四行"负数",在同类递推题里的出现频率高得离谱。

5.2 递归写不出来的排查顺序

如果让你现场手写递归,卡壳了,按这个顺序过:

  1. 先问自己"最小的、不需要再分解的情况是什么",把终止条件写死。兔子问题里就是 n=1 和 n=2。
  2. 再问"规模为 n 的答案,能不能用规模更小的答案拼出来",能拼出来就把表达式写出来,这就是递推关系。
  3. 检查返回值类型是否统一,所有分支必须返回同一语义的东西。
  4. 最后才考虑性能,先让它跑对,再让它跑快。

这个顺序千万不能颠倒。我见过太多人一开始就纠结"会不会太慢",结果连递推式都没写对,优化了个寂寞。先正确,再高效,这句话在递归题上是铁律。

另一个常见心理障碍是"想不清楚递归怎么展开"。我的建议是不要在脑子里展开,人类大脑最多能同时跟踪三层调用,再深就乱了。你就相信数学归纳法:假设 f(n-1) 和 f(n-2) 都能返回正确结果,那么 f(n) 的表达式也一定是对的。这种"信任子问题"的心态是写好递归的前提。

5.3 一类题的通用套路:换个故事还是同一个递推

兔子繁殖之所以经典,是因为它是最小可用的递推载体。把它稍微变形,就是面试里的常客:

跳台阶问题:每次可以跳 1 级或 2 级,问跳到第 n 级有多少种跳法。答案就是 f(n)=f(n-1)+f(n-2),和兔子繁殖一模一样,只是故事换了。理解成"最后一步是跳 1 级还是 2 级",递推式立刻出来。

母牛生小牛:每头母牛从第 4 年开始每年生一头小母牛,问第 n 年有多少头。递推式变成 f(n)=f(n-1)+f(n-3),因为能生的只有 3 年前就存在的那些。这就是改变"成熟周期"参数的直接结果。

骨牌铺方格:用 1×2 的骨牌铺满 2×n 的方格,问方案数。同样是 f(n)=f(n-1)+f(n-2)——最右边要么竖放一块,要么横放两块。

打家劫舍类问题:不能偷相邻的两家,问最大收益。递推式是 f(n)=max(f(n-1), f(n-2)+a[n]),结构一样,只是加法换成了取最大值。

我的经验是,这类题的通用解法分三步:定义状态、写递推式、定边界。状态定义决定了一切,比如"f(n) 表示第 n 个月的总对数"这种定义是自洽的;如果你定义成"第 n 个月新生的对数",递推式就得重写。定义清楚之后,递推式往往是自然的,边界条件只需要手工算前几项。

最后分享一个我自己用了很多年的小习惯:每写完一个递推解,我都会用三个不同的 n 值手算一遍,通常是 n=1、n=2、n=3 加上一个中等值比如 n=6。手算三次花不了两分钟,但能拦住绝大多数边界错误。这个习惯是从一次线上事故里养成的——当时一个统计模块的初始项写成了 0 和 1,导致所有结果整体偏移一位,测试用例恰好都是大数,谁也没发现,上线三天后才被业务方指出来。从那以后我就认准一个理:递推题的正确性,靠的不是聪明,靠的是把边界一个一个钉死。

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

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

立即咨询