先搞懂:什么是动态规划
理解:
动态规划(Dynamic Programming,简称 DP)就是:把一个大问题拆成一串小问题,把每个小问题的答案存起来,用的时候直接拿,不再重新算一遍。
听起来很抽象,其实生活里到处都是。比如爬楼梯时想问走到第 10 阶一共有多少种走法,你不需要真的把所有路线都走一遍------只要知道到第 8 阶有几种、到第 9 阶有几种,加起来就行了。而到第 8 阶有几种又可以由第 5、6、7 阶推出来......一层层往回推,最后站在地面,1 种走法,这种显然已知的答案上。这就是最朴素的动态规划。
两个关键词:状态 + 转移
所有 DP 问题都绕不开这两件事:
| 关键词 | 含义 | 类比 |
|---|---|---|
| 状态 | 用一个东西(通常是 dp 数组的一项)表示「某个子问题的答案」 |
「到第 i 阶有多少种走法」就是状态 |
| 转移 | 用更小的状态推出现在这个状态 | 「到第 i 阶 = 从第 i-1 阶上来 + 从第 i-2 阶上来」 |
状态决定了存什么;
转移决定了怎么算。
这两步定下来,剩下的初始化、填表顺序、返回值其实都是收尾工作。
为什么要存起来:重叠子问题
如果直接写递归,很多子问题会被重复计算。以斐波那契 f(n) = f(n-1) + f(n-2) 为例,算 f(5) 时的调用关系:
cpp
f(5)
/ \
f(4) f(3)
/ \ / \
f(3) f(2) f(2) f(1)
/ \
f(2) f(1)
可以看到 f(3) 被算了两次,f(2) 被算了三次。n 越大,重复计算的规模会指数级爆炸。而如果第一次算完就把结果记下来,下次直接查表,复杂度立刻从指数级降到线性级------这就是动态规划最核心的价值:用空间换时间,每个状态只算一次。
两种写法,本质是一件事
| 写法 | 方向 | 特点 |
|---|---|---|
| 记忆化搜索(递归 + 缓存) | 自顶向下 | 从大问题往下拆,拆到已知答案再往回返 |
递推(dp 数组 / 循环) |
自底向上 | 从小状态开始,一路填到目标状态 |
两者思路完全等价。这篇博客用的是递推写法,因为它的代码更短、更好写,也方便后面做空间优化。
什么时候能用 DP
满足下面两点,基本就可以往 DP 上想:
重叠子问题:不同的大问题会反复用到同一个小问题的答案;
最优子结构 / 可递推:大问题的答案能由小问题的答案组合出来(求和、求最值、求方案数都算)。
至于怎么落地成代码,就是我们下面要反复用的五部曲:
状态表示 --> 状态转移方程 --> 初始化 --> 填表顺序 --> 返回值
记住这五步,DP 就不再是「玄学」,而是一套可以照着走完的流程。
一、题目一:1137. 第 N 个泰波那契数
题目解析
题目链接 1137. 第 N 个泰波那契数 - 力扣(LeetCode)
泰波那契序列 T(n) 定义如下:
T(0) = 0
T(1) = 1
T(2) = 1当
n >= 0时,T(n + 3) = T(n) + T(n + 1) + T(n + 2)
给定整数 n,返回第 n 个泰波那契数 T(n) 的值。
示例 1
bash
输入:n = 4
输出:4
解释:
T(3) = 0 + 1 + 1 = 2
T(4) = 1 + 1 + 2 = 4
示例 2
bash
输入:n = 25
输出:1389537
提示
0 <= n <= 37答案保证是一个 32 位整数,即
answer <= 2^31 - 1
序列前几项:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| T(i) | 0 | 1 | 1 | 2 | 4 | 7 | 13 | 24 |
可以看出,它本质上是斐波那契的三项版:每一项都由前面连续三项相加得到。
算法原理:动态规划五部曲
这道题是最经典的线性 DP 入门题,用状态表示 → 状态转移方程 → 初始化 → 填表顺序 → 返回值这套固定流程来分析,思路会非常清晰。
状态表示
什么是状态表示? 就是用一个 dp 数组(也可以叫 dp 表),dp[i] 到底代表什么含义。
怎么确定 dp[i] 的含义? 有两个来源:
题目要求 :题目要我们求第
n个泰波那契数;经验 + 题目要求 :结合题目的字面含义,直接用「第
i个泰波那契数」作为状态。
所以:
dp[i]表示:第i个泰波那契数的值。
这里不用绕弯子------题目问什么,状态就表示什么,这也是最简单、最不容易出错的一种状态定义方式。
状态转移方程
状态转移方程回答的是:dp[i] 等于什么?
根据题目给的递推关系 T(n + 3) = T(n) + T(n + 1) + T(n + 2),把下标整体减 3,就得到:
cpp
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
这一步的关键是从分析问题的过程中发现重复子问题 :想求第 i 项,只需要知道第 i-1、i-2、i-3 项,而这三项又可以继续往下拆,直到拆成已知的 dp[0]、dp[1]、dp[2]。
常见错误:直接套斐波那契写成
dp[i] = dp[i-1] + dp[i-2],漏掉dp[i-3]。一定要回到题目给的递推式去验证。
初始化
初始化的作用只有一个:保证填表的时候不越界。
dp[i] 依赖前三项,那么 i = 0, 1, 2 这三个位置就没法用递推式算出来,必须提前给定:
cpp
dp[0] = 0
dp[1] = 1
dp[2] = 1
对应 dp 表:
cpp
下标 i : 0 1 2 3 4 5 6 ...
dp[i] : 0 1 1 ? ? ? ? ...
↑ ↑
已知 从 i = 3 开始用递推式填
填表顺序
填表顺序的原则是:为了保证填写当前状态的时候,所需要的状态已经计算过了。
dp[i] 需要用到 dp[i-1]、dp[i-2]、dp[i-3],也就是左边的状态。所以顺序是:
从左往右 (
i从 3 递增到n)。
返回值
题目要求返回第 n 个泰波那契数,而 dp[n] 正好就是这个含义,所以返回:
cpp
return dp[n];
至此,五部曲全部完成,代码几乎是翻译过来的。
基础版代码实现
cpp
class Solution {
public:
int tribonacci(int n) {
if (n == 0) return 0;
if (n == 1 || n == 2) return 1;
vector<int> dp(n + 1);
dp[0] = 0;
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
}
return dp[n];
}
};
时间复杂度:
O(n)空间复杂度:
O(n)
空间优化:从 O(n) 到 O(1)
观察状态转移方程:
cpp
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
填 dp[i] 时,只会用到紧挨着它的前三个状态,而 dp[i-4] 及更早的状态以后再也不会被用到。既然如此,整个 dp 数组其实是浪费的------我们只需要保存最近的三项就够了。
于是可以用三个变量 a、b、c 分别代表前三个状态,再用 d 表示当前要算的新状态,整体向右滚动:

也就是说,每一轮循环里做的三件事是:
用前三项算出当前项
d = a + b + c;三个指针整体右移:
a = b; b = c; c = d;循环结束时,
d恰好停留在T(n)上。
优化后的代码
cpp
class Solution {
public:
int tribonacci(int n) {
if (n == 0) return 0;
if (n == 1 || n == 2) return 1;
int a = 0, b = 1, c = 1, d = 0;
for (int i = 3; i <= n; i++) {
d = a + b + c;
a = b;
b = c;
c = d;
}
return d;
}
};
时间复杂度:
O(n)空间复杂度:
O(1)
这也就是常说的滚动数组思想:当状态转移只依赖固定个数的前驱状态时,就可以把数组压缩成几个变量。
二、题目二:面试题 08.01. 三步问题
同样的五部曲,再走一道计数型的线性 DP,体会一下状态表示和初始化是怎么跟着题意变化的。
题目解析
题目链接 面试题 08.01. 三步问题 - 力扣(LeetCode)
三步问题。有个小孩正在上楼梯,楼梯有 n 阶台阶,小孩一次可以上 1 阶、2 阶或 3 阶。实现一种方法,计算小孩有多少种上楼梯的方式。结果可能很大,你需要对结果取模 1000000007。
示例 1
cpp
输入:n = 3
输出:4
说明:有四种走法
示例 2
cpp
输入:n = 5
输出:13
说明:n = 5 一共有 13 种方法
提示
1 <= n <= 1000000
先把前面几项手动数出来(也可以先写个暴力递归验证):
| n | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 方法数 | 1 | 2 | 4 | 7 | 13 | 24 | 44 |
规律很明显:从第 4 项开始,每一项都等于前三项之和。
算法原理:动态规划五部曲
状态表示
经验 + 题目要求 :以 i 位置为结尾,想想到达 i 位置时的状态。
dp[i]表示:到达第i阶台阶时,一共有多少种方法。
和泰波那契那题不同,这里的状态含义不是第 i 个数是多少,而是到达第 i 个位置的方案数」------这是计数型 DP 的典型写法。
状态转移方程
以 i 位置的状态,根据「最近的一步」来划分问题 。也就是说:到达第 i 阶的最后一步,只可能是以下三种情况:
最后一步从
i - 1迈 1 阶上来:有dp[i - 1]种方法;最后一步从
i - 2迈 2 阶上来:有dp[i - 2]种方法;最后一步从
i - 3迈 3 阶上来:有dp[i - 3]种方法。
这三种情况互不重叠,且覆盖了所有走法,所以直接相加:
cpp
dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3]
公式和泰波那契完全一样,但来路完全不同:泰波那契是「题目直接给的递推式」,这里是「按最后一步划分」推出来的。这个套路在计数类 DP 里非常常用。
初始化
dp[i] 依赖前三项,所以 dp[1]、dp[2]、dp[3] 必须先填好,否则会越界。注意这里的初值不是题目白给的,而是根据语义数出来的:
dp[1] = 1:到第 1 阶只有1一种走法(走一步 1 阶);
dp[2] = 2:1+1、2,共2种;
dp[3] = 4:1+1+1、1+2、2+1、3,共4种。
填表顺序
dp[i] 依赖 i - 1、i - 2、i - 3,都是左边的状态,所以:
从左往右 (
i从 4 递增到n)。
返回值
题目求的是到达第 n 阶的方法数,正好就是 dp[n]:
cpp
return dp[n];
代码实现
cpp
class Solution {
public:
int waysToStep(int n) {
const int MOD = 1e9 + 7;
if (n == 1 || n == 2) return n;
if (n == 3) return 4;
vector<int> dp(n + 1);
dp[1] = 1;
dp[2] = 2;
dp[3] = 4;
for (int i = 4; i <= n; i++) {
dp[i] = ((dp[i - 1] + dp[i - 2]) % MOD + dp[i - 3]) % MOD;
}
return dp[n];
}
};
时间复杂度:
O(n)空间复杂度:
O(n)
关于那个两次取模的写法 :三个小于 1e9 + 7 的数直接相加,最大接近 3 * 1e9,会超出 int 的范围(约 2.1e9)。所以先让前两项相加取模,再加第三项取模,保证中间结果始终在 int 内。也可以改成 long long 一次取模。
空间优化
和泰波那契一样,dp[i] 只依赖前三项,同样可以把数组压缩成三个变量:
cpp
class Solution {
public:
int waysToStep(int n) {
const int MOD = 1e9 + 7;
if (n == 1 || n == 2) return n;
if (n == 3) return 4;
int a = 1, b = 2, c = 4, d = 0;
for (int i = 4; i <= n; i++) {
d = ((a + b) % MOD + c) % MOD;
a = b;
b = c;
c = d;
}
return d;
}
};
空间复杂度降到 O(1)。不过本题 n <= 10^6,O(n) 的数组本来就够用,不优化也完全能过。
三、题目三:746. 使用最小花费爬楼梯
前两道都是求数值 / 求方案数,这道开始进入真正的最优化:求最小花费。而且这道题可以用两种完全相反的状态定义来做,正好用来理解状态定义决定了一切。
题目解析
题目链接:746. 使用最小花费爬楼梯 - 力扣(LeetCode)
给你一个整数数组 cost,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1
cpp
输入:cost = [10, 15, 20]
输出:15
解释:从下标 1 的台阶开始,支付 15,向上爬两个台阶,直接到达楼梯顶部。总花费为 15。
示例 2
cpp
输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6
解释:从下标 0 开始 → 2 → 4 → 6 → 7 → 9 → 顶部,支付 1 + 1 + 1 + 1 + 1 + 1 = 6。
提示
2 <= cost.length <= 1000
0 <= cost[i] <= 999
关键概念:楼梯顶部在数组之外 。数组下标是
0 ~ n-1,顶部是第n级。这一点决定了dp数组要开n + 1还是n。
解法一:以 i 位置为结尾(从左往右)
状态表示
经验 + 题目要求,以 i 位置为结尾:
dp[i]表示:到达 第i个位置时的最小花费。
注意到达两个字------它只包含走到这里的钱,不包含从这个台阶继续往上走要付的钱。
状态转移方程
用之前的状态推导 dp[i],根据最近的一步来划分 。要到达第 i 级,最后一步只有两种可能:
从
i - 1走一步上来:先花dp[i-1]到达i-1,再支付cost[i-1]走一步
cppdp[i - 1] + cost[i - 1]从
i - 2走两步上来:先花dp[i-2]到达i-2,再支付cost[i-2]走两步
cppdp[i - 2] + cost[i - 2]
两种走法都能到达 i,要求最小花费,所以取较小值:
cpp
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
注意这里用的是 cost[i-1]、cost[i-2],而不是 cost[i]------因为花的是离开上一个台阶的钱。
初始化
题目说可以从下标 0 或 1 开始,也就是说「站在第 0 / 1 级上」还没花任何钱,所以:
cpp
dp[0] = 0
dp[1] = 0
这两个初值同样是为了保证填表时不越界(i = 2 时用不到 dp[-1])。
填表顺序
dp[i] 依赖 dp[i-1]、dp[i-2],所以:
从左往右 (
i从 2 到n)。
返回值
题目要求到达楼梯顶部,也就是第 n 级:
cpp
return dp[n];
代码实现
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> dp(n + 1);
dp[0] = dp[1] = 0;
for (int i = 2; i < n + 1; i++) {
dp[i] = min(cost[i - 1] + dp[i - 1], cost[i - 2] + dp[i - 2]);
}
return dp[n];
}
};
时间复杂度:
O(n)空间复杂度:
O(n)
因为只依赖前两项,同样可以用两个变量滚动,空间降到 O(1)(解法二同理)。
解法二:以 i 位置为起点(从右往左)
换一个视角:不问「怎么到 i」,而问「从 i 出发还要花多少」。
状态表示
经验 + 题目要求,以 i 位置为起点:
dp[i]表示:从第i个台阶出发,到达楼梯顶部的最小花费。
这个状态里包含了在 i 处支付的 cost[i],因为只要从这里出发就一定要付这笔钱。
状态转移方程
用之后的状态推导 dp[i]。站在 i 上,先支付 cost[i],然后面临两个选择:
走一步到
i + 1:cost[i] + dp[i + 1]走两步到
i + 2:cost[i] + dp[i + 2]
取较小值,并把公共的 cost[i] 提出来:
cpp
dp[i] = min(dp[i + 1], dp[i + 2]) + cost[i]
初始化
最靠右的两级没有「后面两级」可用,得直接给:
cpp
dp[n - 1] = cost[n - 1] // 再走一步就上顶部
dp[n - 2] = cost[n - 2] // 直接走两步上顶部
填表顺序
dp[i] 依赖右边的状态,所以:
从右往左 (
i从n - 3递减到 0)。
返回值
起点可以是 0 或 1,两者取最小:
cpp
return min(dp[0], dp[1]);
代码实现
cpp
class Solution {
public:
int minCostClimbingStairs(vector<int>& cost) {
int n = cost.size();
vector<int> dp(n);
dp[n - 1] = cost[n - 1], dp[n - 2] = cost[n - 2];
for (int i = n - 3; i >= 0; i--) {
dp[i] = min(dp[i + 1], dp[i + 2]) + cost[i];
}
return min(dp[0], dp[1]);
}
};
时间复杂度:
O(n)空间复杂度:
O(n)
两种解法怎么选
| 解法一(以 i 结尾) | 解法二(以 i 为起点) | |
|---|---|---|
| 状态含义 | 到达 i 的最小花费 |
从 i 出发的最小花费 |
| 转移方向 | 由 i-1、i-2 推 i |
由 i+1、i+2 推 i |
| 填表顺序 | 从左往右 | 从右往左 |
dp 大小 |
n + 1(含顶部) |
n(只有台阶) |
| 返回值 | dp[n] |
min(dp[0], dp[1]) |
两者都对,本质是同一张图的两种遍历方向。以 i 为结尾更贴合到达某处的问法,以 i 为起点更贴合从某处出发的问法。做题时哪种顺就写哪种,但状态定义一旦定了,转移方向、填表顺序、返回值就得全部跟着它走,不能混。
四、三题对比与总结
三道题用同一套模板,但细节上的差异恰好是动态规划里最容易出错的地方:
| 对比项 | 1137. 泰波那契数 | 面试题 08.01. 三步问题 | 746. 最小花费爬楼梯 |
|---|---|---|---|
| 类型 | 递推 / 数列计算 | 计数 | 最优化(最小值) |
| 状态表示 | dp[i]:第 i 个泰波那契数 |
dp[i]:到达第 i 阶的方法数 |
dp[i]:到达第 i 级的最小花费 |
| 转移来源 | 题目直接给出递推式 | 按「最后一步」划分 | 按「最近一步」划分后取 min |
| 方程 | dp[i-1] + dp[i-2] + dp[i-3] |
dp[i-1] + dp[i-2] + dp[i-3] |
min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]) |
| 初始化 | 0, 1, 1(题目给定) |
1, 2, 4(按语义数出来) |
dp[0] = dp[1] = 0(站在起点不花钱) |
| 填表顺序 | 从左往右 | 从左往右 | 从左往右(倒序写法则从右往左) |
| 取模 | 不需要 | 1e9 + 7,注意防溢出 |
不需要 |
| 空间优化 | 三个变量滚动 | 三个变量滚动 | 两个变量滚动 |
把这三道题放在一起,其实就是在回答动态规划到底怎么想:
状态表示 :题目问什么,
dp[i]就表示什么;计数题表示到达 i 的方案数,最优化题表示到达 i 的最小 / 最大代价。状态转移方程 :数列题直接翻译递推式;计数题按最近一步划分后相加;最优化题按最近一步划分后取
min/max。初始化:为了保证填表不越界,初值要么题目给,要么按语义数;同时初值本身的含义要说得清(比如站在起点不花钱)。
填表顺序:依赖左边 → 从左往右;依赖右边 → 从右往左。
返回值 :对应题目所求的那个状态;有多个合法起点时记得取
min。空间优化 :只依赖固定个数的前驱 → 滚动数组,
O(n) → O(1)。