一、问题:斐波那契数列
斐波那契数列(Fibonacci Sequence)是计算机科学中最经典的入门问题。它的数学定义如下:
- F(0)=0F(0) = 0F(0)=0
- F(1)=1F(1) = 1F(1)=1
- F(2)=1F(2) = 1F(2)=1
- 当 n>2n > 2n>2 时,F(n)=F(n−1)+F(n−2)F(n) = F(n-1) + F(n-2)F(n)=F(n−1)+F(n−2)
展开前几项:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...
相邻两项的比值 F(n+1)F(n)\frac{F(n+1)}{F(n)}F(n)F(n+1) 会无限趋近于黄金分割比 ϕ≈1.618\phi \approx 1.618ϕ≈1.618。但在算法领域,斐波那契数列真正的价值不在于数列本身,而在于它完美展示了"重叠子问题"和"动态规划"的核心思想,是理解 DP 最理想的起点。
二、暴力递归:最直觉但最慢的解法
按照数学定义,最直觉的写法就是直接翻译成递归函数:
java
public static int fib(int N) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
return fib(N - 1) + fib(N - 2);
}
递归的终止条件
java
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
这三行叫递归的"出口"或"边界条件" 。斐波那契数列的 base case 是 F(0)=0F(0)=0F(0)=0、F(1)=1F(1)=1F(1)=1、F(2)=1F(2)=1F(2)=1。当 N 走到这些值的时候,函数不再往下递归,直接返回已知结果。如果没有这些终止条件,递归会一直往下调用到负数,最终栈溢出崩溃。
致命问题:指数级的时间复杂度
虽然代码简洁,但效率极低。以 fib(5) 为例,递归调用过程如下:
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \
fib(2) fib(1)
可以看到:
fib(3)被计算了 2 次fib(2)被计算了 3 次
当 N 更大时,这种重复呈指数级增长。计算 fib(20) 时,fib(18) 会被重复计算成千上万次。时间复杂度约为 O(2n)O(2^n)O(2n) ,空间复杂度是递归调用栈的深度 O(n)O(n)O(n)。
根本原因:存在大量重叠子问题(Overlapping Subproblems),同一个子问题被反复求解。
💡 核心心法:千万不要看不起暴力解法,动态规划问题最困难的就是写出这个暴力解法,即状态转移方程。只要写出暴力解法,优化方法无非是用"备忘录"或者 DP table,再无奥妙可言。
三、备忘录递归:自顶向下 + 记忆化
既然同一个子问题会被重复计算,那我们只要算过一次就记下来,下次直接用,不就行了?
这就是**备忘录(Memoization)**的思想:
java
import java.util.HashMap;
import java.util.Map;
public static int fibMemo(int N) {
Map<Integer, Integer> memo = new HashMap<>();
return helper(N, memo);
}
private static int helper(int N, Map<Integer, Integer> memo) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
if (memo.containsKey(N)) return memo.get(N); // 算过了,直接返回
int res = helper(N - 1, memo) + helper(N - 2, memo); // 没算过,递归求解
memo.put(N, res); // 算完存起来
return res;
}
效果:
- 每个子问题只计算一次,时间复杂度降为 O(n)O(n)O(n)
- 空间复杂度:O(n)O(n)O(n)(备忘录哈希表 + 递归栈)
备忘录本质上还是递归,只是"带记忆的递归"。它从大问题出发,拆到小问题,碰到算过的就直接返回------这叫自顶向下。
四、DP 数组:自底向上填表
如果我们把备忘录独立出来变成一张完整的表 ,然后用循环从最小的子问题开始,一步步推到最终答案,这就是动态规划(Dynamic Programming)的标准写法。
核心思想
| 递归(自顶向下) | DP(自底向上) |
|---|---|
| 从大问题出发,拆成小问题 | 从小问题出发,逐步合成大问题 |
| 用递归栈 | 用循环 |
| 按需计算(备忘录) | 顺序填表 |
Java 实现(DP 数组版)
java
public static int fibDP(int N) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
// dp[i] 表示第 i 个斐波那契数 F(i)
int[] dp = new int[N + 1]; // 长度 N+1,下标 0 ~ N,默认全 0
// 对应 C++ 的 vector<int> dp(N + 1, 0);
// Base cases:填好已知的"地基"
dp[1] = 1;
dp[2] = 1;
// 从底向上,一步步推导
for (int i = 3; i <= N; i++) {
dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
}
return dp[N]; // 直接取答案
}
状态转移方程
书上给出的数学形式:
f(n)={1,n=1,2f(n−1)+f(n−2),n>2f(n) = \begin{cases} 1, & n=1,2 \\ f(n-1)+f(n-2), & n>2 \end{cases}f(n)={1,f(n−1)+f(n−2),n=1,2n>2
为什么叫"状态转移"?把 f(n)f(n)f(n) 想作一个状态 nnn,这个状态由状态 n−1n-1n−1 和状态 n−2n-2n−2 相加"转移"而来。状态转移方程直接代表着暴力解法,它是问题的核心。
以 N=5 走一遍
初始:dp = [0, 1, 1, 0, 0, 0]
| 循环轮次 | i | dpi = dpi-1 + dpi-2 | dp 数组状态 |
|---|---|---|---|
| 第1轮 | 3 | dp3 = dp2 + dp1 = 1+1 = 2 | 0, 1, 1, **2**, 0, 0 |
| 第2轮 | 4 | dp4 = dp3 + dp2 = 2+1 = 3 | 0, 1, 1, 2, **3**, 0 |
| 第3轮 | 5 | dp5 = dp4 + dp3 = 3+2 = 5 | 0, 1, 1, 2, 3, **5** |
返回 dp[5] = 5 ✓
时间复杂度:O(n)O(n)O(n),空间复杂度:O(n)O(n)O(n)
五、状态压缩:空间降到 O(1)
🔑 关键洞察:根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的 DP table 来存储所有状态,只要想办法存储之前的两个状态就行了。
这就是状态压缩 :如果发现每次状态转移只需要 DP table 中的一部分,就可以缩小 DP table 的大小,只记录必要的数据。上面例子相当于把 DP table 的大小从 NNN 缩小到 2。
书上 C++ 代码
cpp
int fib(int N) {
if (N == 0) return 0;
if (N == 2 || N == 1) return 1;
int prev = 1, curr = 1;
for (int i = 3; i <= N; i++) {
int sum = prev + curr;
prev = curr;
curr = sum;
}
return curr;
}
Java 实现
java
public static int fibCompressed(int N) {
if (N == 0) return 0;
if (N == 1 || N == 2) return 1;
int prev = 1; // F(n-2),初始对应 F(1)=1
int curr = 1; // F(n-1),初始对应 F(2)=1
for (int i = 3; i <= N; i++) {
int sum = prev + curr; // 当前值 = 前两项之和
prev = curr; // 滚动:旧的"前一项"变成"前前项"
curr = sum; // 滚动:当前值变成新的"前一项"
}
return curr;
}
以 N=5 走一遍
| i | sum = prev + curr | prev(更新后) | curr(更新后) |
|---|---|---|---|
| 初始 | --- | 1 (F(1)) | 1 (F(2)) |
| 3 | 1+1 = 2 | 1 → 1 | 2 |
| 4 | 1+2 = 3 | 1 → 2 | 3 |
| 5 | 2+3 = 5 | 2 → 3 | 5 |
返回 curr = 5 ✓
时间复杂度:O(n)O(n)O(n),空间复杂度:O(1)O(1)O(1) ------ 这是面试中最推荐的写法。
📌 延伸 :后续动态规划章节还会看到,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n2)O(n^2)O(n2) 压到 O(n)O(n)O(n)。状态压缩是 DP 优化中极为重要的一课。
六、四种方法完整对比
| 方法 | 核心思想 | 时间 | 空间 | 代码特征 |
|---|---|---|---|---|
| 暴力递归 | 直接翻译定义 | O(2n)O(2^n)O(2n) | O(n)O(n)O(n) 栈 | return fib(n-1)+fib(n-2) |
| 备忘录 | 自顶向下 + 记忆 | O(n)O(n)O(n) | O(n)O(n)O(n) | Map/数组 存已算结果 |
| DP 数组 | 自底向上填表 | O(n)O(n)O(n) | O(n)O(n)O(n) | dp[i]=dp[i-1]+dp[i-2] |
| 状态压缩 | 滚动变量,只留必要状态 | O(n)O(n)O(n) | O(1)O(1)O(1) | prev, curr 两个变量 |
七、从斐波那契到真正的动态规划
⚠️ 重要澄清:斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示为得到最优解法而逐步求精的过程。
真正的动态规划问题通常包含"求最值"这一要素。比如凑零钱问题(LeetCode 322):
- 给定 k 种硬币,面值
c1, c2, ..., ck,数量无限 - 目标金额
amount - 求凑出
amount所需的最少硬币数,凑不出返回 -1
函数签名:
java
int coinChange(int[] coins, int amount);
示例:coins = [1, 2, 5], amount = 11 → 5 + 5 + 1 = 3 枚
思考路径和斐波那契完全一样:
- 先写暴力(递归枚举所有凑法)
- 发现重叠子问题 → 加备忘录 / 改自底向上 DP
- 找状态转移 :
dp[i] = min(dp[i-c]) + 1(对所有可行硬币 c) - 看能否状态压缩(这个一维可以压缩,但保留数组语义更清晰)
八、核心心法总结
千万不要看不起暴力解法,动态规划问题最困难的就是写出这个暴力解法,即状态转移方程。只要写出暴力解法,优化方法无非是用"备忘录"或者 DP table,再无奥妙可言。
状态转移方程直接代表着暴力解法,它是问题的核心。
状态压缩:每次状态转移只需要 DP table 中的一部分,可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据。
斐波那契数列虽然简单,但它浓缩了动态规划的全部优化思路:暴力递归 → 发现重叠子问题 → 备忘录消除重复 → DP table 系统化 → 状态压缩极致优化。当你以后面对背包、最长公共子序列、股票买卖等复杂 DP 问题时,思路都是这条路径的延伸。斐波那契就是那把打开 DP 大门的钥匙。🔑