动态规划是算法学习中最容易「听懂却写不出」的一类内容:概念并不复杂,但真正面对题目时,状态怎么定义、转移怎么写、循环往哪个方向走,往往让人卡住。本文希望用一条清晰的路径,把动态规划从思想到代码串起来。
**面向读者:**已经掌握 C++ 基础语法(数组、vector、函数、递归),了解时间复杂度和空间复杂度的基本概念,但还没有系统学习过动态规划的读者;也适合作为竞赛入门或面试复习的速查资料。
**覆盖范围:**文章先讲清动态规划的核心要素与两种实现方式,再依次拆解斐波那契数列、0-1 背包、最长上升子序列(LIS)、最长公共子序列(LCS)、区间 DP、树形 DP、状态压缩 DP 等经典模型,每个模型都配有可直接编译运行的 C++ 代码;最后补充常用优化技巧与常见错误排查方法。
**阅读建议:**建议按顺序阅读,前三节务必吃透,因为后面的所有模型都是「状态定义 + 转移方程 + 遍历顺序」的重复演练。阅读代码时不要只看,最好自己敲一遍并修改测试数据观察输出;遇到某个模型卡住时,可以先跳过,等积累了几个模型后再回头理解,往往会有新的体会。
目录
- 一、什么是动态规划
- 二、动态规划的核心要素
- 三、动态规划的两种实现方式
- 四、经典入门:斐波那契数列
- [五、经典模型一:0-1 背包](#五、经典模型一:0-1 背包)
- 六、经典模型二:最长上升子序列(LIS)
- 七、经典模型三:最长公共子序列(LCS)
- [八、经典模型四:区间 DP](#八、经典模型四:区间 DP)
- [九、经典模型五:树形 DP](#九、经典模型五:树形 DP)
- [十、状态压缩 DP 简介](#十、状态压缩 DP 简介)
- 十一、动态规划的优化技巧
- 十二、常见错误与调试建议
- 十三、总结
一、什么是动态规划
动态规划(Dynamic Programming,简称 DP)是一种通过把原问题拆解为相互重叠的子问题,并保存子问题结果来避免重复计算的算法思想。它通常适用于满足以下两个条件的问题:
- 最优子结构:原问题的最优解可以由子问题的最优解推导出来。
- 重叠子问题:不同分支在求解过程中会反复遇到相同的子问题。
与分治算法相比,动态规划的关键区别在于子问题之间存在重叠,因此需要「记忆化」;与贪心算法相比,动态规划会考虑所有可能的决策,而不是只做局部最优选择。
二、动态规划的核心要素
1. 状态定义
状态是动态规划的灵魂。状态定义要能完整描述当前局面,并且便于转移。例如在背包问题中,dp[i][j] 表示「前 i 个物品、容量为 j 时的最大价值」。
2. 状态转移方程
转移方程描述状态之间如何推导。写转移方程时,先想清楚「当前决策有哪几种选择」,再对每种选择取最优值。
3. 初始化与边界
边界条件是递推的起点,例如 dp[0] = 0。初始化错误是动态规划最常见的 bug 来源之一。
4. 遍历顺序
遍历顺序必须保证「计算当前状态时,它依赖的状态已经计算完毕」。一维 DP 通常从前向后,二维 DP 要结合依赖方向确定内外层循环。
三、动态规划的两种实现方式
1. 记忆化搜索(自顶向下)
用递归描述问题,并用数组或哈希表记录已经计算过的状态。写法直观,适合状态转移关系复杂、难以确定遍历顺序的场景。
2. 递推(自底向上)
按照依赖顺序从小到大填表,通常常数更小、没有递归栈开销,是竞赛和工程中的主流写法。
四、经典入门:斐波那契数列
朴素递归会重复计算大量子问题,时间复杂度为指数级。加入记忆化后降为 O(n)。
cpp
#include <iostream>
#include <vector>
using namespace std;
// 记忆化搜索版本
long long fib(int n, vector<long long>& memo) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
return memo[n] = fib(n - 1, memo) + fib(n - 2, memo);
}
// 递推版本,空间可优化到 O(1)
long long fibIter(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
long long c = a + b;
a = b;
b = c;
}
return b;
}
int main() {
int n = 40;
vector<long long> memo(n + 1, -1);
cout << fib(n, memo) << endl;
cout << fibIter(n) << endl;
return 0;
}
五、经典模型一:0-1 背包
有 n 个物品,第 i 个物品重量为 w[i]、价值为 v[i],背包容量为 W。每个物品最多选一次,求能获得的最大价值。
状态定义:dp[j] 表示容量为 j 时的最大价值。由于每个物品只能用一次,一维写法必须倒序遍历容量。
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int knapsack01(int W, const vector<int>& w, const vector<int>& v) {
int n = w.size();
vector<int> dp(W + 1, 0);
for (int i = 0; i < n; ++i) {
for (int j = W; j >= w[i]; --j) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[W];
}
int main() {
vector<int> w = {2, 3, 4, 5};
vector<int> v = {3, 4, 5, 8};
cout << knapsack01(8, w, v) << endl; // 输出 12
return 0;
}
易错点:0-1 背包一维写法必须倒序;完全背包(物品可无限次选)则要正序遍历容量。
六、经典模型二:最长上升子序列(LIS)
给定数组 a,求最长严格上升子序列的长度。基础做法是 O(n²) 的 DP,进阶做法用贪心加二分优化到 O(n log n)。
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// O(n^2) 动态规划
int lisDp(const vector<int>& a) {
int n = a.size();
vector<int> dp(n, 1);
int ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (a[j] < a[i]) dp[i] = max(dp[i], dp[j] + 1);
}
ans = max(ans, dp[i]);
}
return ans;
}
// O(n log n) 贪心 + 二分
int lisFast(const vector<int>& a) {
vector<int> tails;
for (int x : a) {
auto it = lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return tails.size();
}
int main() {
vector<int> a = {10, 9, 2, 5, 3, 7, 101, 18};
cout << lisDp(a) << endl; // 4
cout << lisFast(a) << endl; // 4
return 0;
}
七、经典模型三:最长公共子序列(LCS)
给定两个字符串 s 和 t,求它们的最长公共子序列长度。状态 dp[i][j] 表示 s 前 i 个字符与 t 前 j 个字符的 LCS 长度。
cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
int lcs(const string& s, const string& t) {
int n = s.size(), m = t.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
if (s[i - 1] == t[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[n][m];
}
int main() {
cout << lcs("abcde", "ace") << endl; // 3
return 0;
}
八、经典模型四:区间 DP
区间 DP 以区间长度作为阶段,从小到大枚举区间,典型题目如「石子合并」「最长回文子序列」。核心是枚举分割点 k。
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 石子合并:求合并成一堆的最小代价
int stoneMerge(const vector<int>& a) {
int n = a.size();
vector<int> pre(n + 1, 0);
for (int i = 0; i < n; ++i) pre[i + 1] = pre[i] + a[i];
const int INF = 1e9;
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int len = 2; len <= n; ++len) {
for (int i = 0; i + len - 1 < n; ++i) {
int j = i + len - 1;
dp[i][j] = INF;
for (int k = i; k < j; ++k) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + pre[j + 1] - pre[i]);
}
}
}
return dp[0][n - 1];
}
int main() {
vector<int> a = {4, 1, 1, 4};
cout << stoneMerge(a) << endl; // 18
return 0;
}
九、经典模型五:树形 DP
树形 DP 在树上做递推,通常用 DFS 后序遍历,把子树结果汇总到父节点。典型题目是「没有上司的舞会」:每个节点有开心值,父子不能同时选,求最大开心值。
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
vector<vector<int>> g;
vector<int> happy;
vector<vector<int>> dp; // dp[u][0] 不选 u,dp[u][1] 选 u
void dfs(int u, int parent) {
dp[u][0] = 0;
dp[u][1] = happy[u];
for (int v : g[u]) {
if (v == parent) continue;
dfs(v, u);
dp[u][0] += max(dp[v][0], dp[v][1]);
dp[u][1] += dp[v][0];
}
}
int main() {
int n = 5;
g.assign(n, {});
happy = {0, 10, 20, 30, 40};
dp.assign(n, vector<int>(2, 0));
// 构造一棵树:0 为根
g[0] = {1, 2};
g[1] = {0, 3, 4};
g[2] = {0};
g[3] = {1};
g[4] = {1};
dfs(0, -1);
cout << max(dp[0][0], dp[0][1]) << endl;
return 0;
}
十、状态压缩 DP 简介
当状态维度较多但每一维取值很少时,可以把状态压缩成一个整数(通常用二进制位表示集合),这就是状态压缩 DP。典型题目如「旅行商问题(TSP)」「棋盘覆盖」。
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// TSP:从 0 出发遍历所有点再回到 0 的最短路径
int tsp(const vector<vector<int>>& dist) {
int n = dist.size();
const int INF = 1e9;
vector<vector<int>> dp(1 << n, vector<int>(n, INF));
dp[1][0] = 0; // 只访问了 0 号点,当前在 0
for (int mask = 1; mask < (1 << n); ++mask) {
for (int u = 0; u < n; ++u) {
if (dp[mask][u] == INF) continue;
for (int v = 0; v < n; ++v) {
if (mask & (1 << v)) continue;
int next = mask | (1 << v);
dp[next][v] = min(dp[next][v], dp[mask][u] + dist[u][v]);
}
}
}
int ans = INF;
for (int u = 1; u < n; ++u) {
ans = min(ans, dp[(1 << n) - 1][u] + dist[u][0]);
}
return ans;
}
int main() {
vector<vector<int>> dist = {
{0, 2, 9, 10},
{1, 0, 6, 4},
{15, 7, 0, 8},
{6, 3, 12, 0}
};
cout << tsp(dist) << endl;
return 0;
}
十一、动态规划的优化技巧
- 滚动数组:当第 i 层只依赖第 i-1 层时,可以把二维数组压缩成一维,降低空间复杂度。
- 前缀和 / 差分:把区间求和类转移从 O(n) 降到 O(1)。
- 单调队列优化 :适用于转移形如
dp[i] = min(dp[j] + cost(j, i))且决策点单调的场景。 - 斜率优化:把转移方程整理成直线形式,用凸包维护最优决策点。
- 矩阵快速幂:线性递推且转移固定时,可把 O(n) 降到 O(log n)。
十二、常见错误与调试建议
- 状态定义不完整:状态缺少必要信息,导致转移时无法区分不同局面。
- 初始化遗漏:求最小值时忘记把 dp 初始化为 INF,求最大值时忘记初始化为极小值。
- 遍历顺序错误:0-1 背包正序遍历容量会变成完全背包,这是最经典的错误。
- 边界越界 :注意
i - 1、j - w[i]等下标是否合法。 - 数据溢出 :答案较大时使用
long long,并注意 INF 相加溢出。
调试建议:先写暴力搜索,再写记忆化搜索,最后改写成递推,并用手写小数据对拍验证。
十三、总结
动态规划的学习路径可以概括为:先掌握状态定义与转移方程的基本套路,再通过背包、LIS、LCS、区间 DP、树形 DP、状态压缩等经典模型积累经验,最后学习滚动数组、单调队列、斜率优化等进阶技巧。建议每道题都先写出状态含义和转移方程,再动手编码,这样能显著减少调试时间。