一、动态规划的核心本质
动态规划(Dynamic Programming,简称 DP)是一种将复杂问题分解为若干个重叠的子问题,通过求解子问题的最优解来推导原问题最优解的算法思想。
1. 核心特征(必须满足)
- 最优子结构:原问题的最优解包含其子问题的最优解(子问题的最优解能推导出原问题的最优解)。
- 重叠子问题:求解原问题时会反复用到同一个子问题的解(这是 DP 能优化的关键,避免重复计算)。
- 无后效性:某一阶段的状态确定后,不受后续决策的影响(当前状态只和过去有关,和未来无关)。
2. DP vs 暴力递归(核心区别)
| 特性 | 暴力递归 | 动态规划 |
|---|---|---|
| 子问题处理 | 重复计算相同子问题 | 记录子问题解(记忆化) |
| 时间复杂度 | 指数级(如O(2n)) | 多项式级(如O(n)) |
| 空间复杂度 | 递归栈开销 | 额外空间存储子问题解 |
二、动态规划的解题四步走(核心方法论)
掌握这四步,就能解决绝大多数 DP 问题,逻辑清晰且不易出错:
步骤 1:定义状态(最关键)
- 用
dp[i]/dp[i][j]表示 "以 i 为结尾 / 在 i,j 状态下的最优解 / 满足条件的结果"。 - 状态定义的核心:让子问题能够通过状态关联起来。
步骤 2:推导状态转移方程(核心逻辑)
- 描述 "大问题如何由小问题推导而来",是 DP 的核心公式。
- 例如:
dp[i] = dp[i-1] + dp[i-2](斐波那契数列)。
步骤 3:初始化状态(避免边界错误)
- 确定 DP 数组的初始值(边界条件),是计算的起点。
- 例如:斐波那契数列中
dp[0]=0, dp[1]=1。
步骤 4:确定遍历顺序(保证计算顺序正确)
- 确保计算
dp[i]时,其依赖的dp[i-1]/dp[i-2]已经被计算过。
三、经典案例(C++ 实现,从易到难)
案例 1:斐波那契数列(入门级)
问题描述
求斐波那契数列的第 n 项,定义:F(0)=0,F(1)=1,F(n)=F(n−1)+F(n−2)(n≥2)。
解题步骤
- 状态定义 :
dp[i]表示第 i 项斐波那契数。 - 转移方程 :
dp[i] = dp[i-1] + dp[i-2](i≥2)。 - 初始化 :
dp[0]=0, dp[1]=1。 - 遍历顺序:从 2 到 n 依次计算。
C++ 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
// 普通版
int fib(int n) {
if(n < 0) return -1;
if(n == 0) return 0;
if(n == 1) return 1;
vector<int> dp(n+1);
dp[0] = 0;
dp[1] = 1;
for(int i=2; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
// 空间优化版
int fib_opt(int n) {
if(n < 0) return -1;
if(n == 0) return 0;
if(n == 1) return 1;
int a=0, b=1, res;
for(int i=2; i<=n; i++) {
res = a + b;
a = b;
b = res;
}
return res;
}
int main() {
int n=10;
cout << fib(n) << endl; // 55
cout << fib_opt(n) << endl;// 55
return 0;
}
代码解释
- 普通版:用
vector存储所有子问题的解,直观但占用 O (n) 空间。 - 优化版:由于
dp[i]只依赖dp[i-1]和dp[i-2],只需用两个变量滚动更新,空间复杂度降为 O (1)(DP 常见优化技巧)。
案例 2:爬楼梯(基础应用)
问题描述
假设你正在爬楼梯,需要 n 阶才能到达楼顶。每次可以爬 1 阶或 2 阶,问有多少种不同的方法爬到楼顶?
解题步骤
- 状态定义 :
dp[i]表示爬到第 i 阶的方法数。 - 转移方程 :
dp[i] = dp[i-1] + dp[i-2](最后一步要么爬 1 阶,要么爬 2 阶)。 - 初始化 :
dp[1]=1(1 阶只有 1 种方法),dp[2]=2(2 阶有两种:1+1 或 2)。 - 遍历顺序:从 3 到 n 依次计算。
C++ 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
int climb(int n) {
if(n <= 0) return 0;
if(n == 1) return 1;
if(n == 2) return 2;
vector<int> dp(n+1);
dp[1] = 1;
dp[2] = 2;
for(int i=3; i<=n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
int main() {
int n=5;
cout << climb(n) << endl; // 8
return 0;
}
案例 3:最大子数组和(中等难度,经典 DP)
问题描述
给定一个整数数组nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
解题步骤
- 状态定义 :
dp[i]表示以第 i 个元素结尾的连续子数组的最大和。 - 转移方程 :
dp[i] = max(nums[i], dp[i-1] + nums[i])(要么从当前元素重新开始,要么接上前面的子数组)。 - 初始化 :
dp[0] = nums[0](第一个元素的最大子数组和就是自己)。 - 遍历顺序:从 1 到 n-1 依次计算,同时记录全局最大值。
C++ 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
int maxSub(int a[], int n) {
if(n == 0) return 0;
vector<int> dp(n);
dp[0] = a[0];
int mx = dp[0];
for(int i=1; i<n; i++) {
dp[i] = max(a[i], dp[i-1]+a[i]);
mx = max(mx, dp[i]);
}
return mx;
}
// 空间优化版
int maxSub_opt(int a[], int n) {
if(n == 0) return 0;
int pre = a[0], mx = pre;
for(int i=1; i<n; i++) {
pre = max(a[i], pre+a[i]);
mx = max(mx, pre);
}
return mx;
}
int main() {
int a[] = {-2,1,-3,4,-1,2,1,-5,4};
int n = sizeof(a)/sizeof(a[0]);
cout << maxSub(a, n) << endl; // 6
cout << maxSub_opt(a, n) << endl;// 6
return 0;
}
关键解释
- 转移方程的逻辑:如果
dp[i-1] + nums[i]比nums[i]小,说明前面的子数组是 "拖后腿" 的,不如直接从nums[i]重新开始。 - 全局最大值:
dp[i]只表示以 i 结尾的最大和,最终答案需要遍历所有dp[i]找最大值。
案例 4:0-1 背包问题(进阶核心)
问题描述
有 n 件物品和一个容量为 V 的背包。第 i 件物品的重量是w[i],价值是v[i]。每件物品只能选一次,问将哪些物品装入背包,可使总价值最大?
解题步骤
- 状态定义 :
dp[i][j]表示前 i 件物品,在背包容量为 j 时的最大价值。 - 转移方程 :
- 不选第 i 件物品:
dp[i][j] = dp[i-1][j] - 选第 i 件物品(前提 j≥w [i]):
dp[i][j] = dp[i-1][j - w[i]] + v[i] - 最终:
dp[i][j] = max(不选, 选)
- 不选第 i 件物品:
- 初始化 :
dp[0][j] = 0(0 件物品,价值为 0)dp[i][0] = 0(容量为 0,价值为 0)
- 遍历顺序:先遍历物品(1 到 n),再遍历容量(1 到 V)。
C++ 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
// 二维版
int bag(int w[], int v[], int n, int V) {
vector<vector<int>> dp(n+1, vector<int>(V+1, 0));
for(int i=1; i<=n; i++) {
for(int j=1; j<=V; j++) {
if(j < w[i-1]) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i-1]] + v[i-1]);
}
}
}
return dp[n][V];
}
// 一维优化版
int bag_opt(int w[], int v[], int n, int V) {
vector<int> dp(V+1, 0);
for(int i=0; i<n; i++) {
for(int j=V; j>=w[i]; j--) {
dp[j] = max(dp[j], dp[j-w[i]] + v[i]);
}
}
return dp[V];
}
int main() {
int w[] = {2,3,4,5}; // 重量
int v[] = {3,4,5,6}; // 价值
int n = sizeof(w)/sizeof(w[0]);
int V = 8; // 容量
cout << bag(w, v, n, V) << endl; // 10
cout << bag_opt(w, v, n, V) << endl;// 10
return 0;
}
关键解释
- 一维数组优化的核心:倒序遍历容量,避免同一个物品被多次选择(如果正序,
dp[j - w[i]]已经被更新,会重复选当前物品,变成完全背包)。 - 0-1 背包是 DP 的核心模型,很多问题(如分割等和子集、目标和)都可以转化为背包问题。
四、动态规划的常见优化技巧
- 空间优化 :
- 滚动数组:如斐波那契、爬楼梯的 O (1) 空间优化。
- 一维数组代替二维数组:如 0-1 背包的一维 DP。
- 状态压缩:合并重复状态,减少 DP 数组维度。
- 单调队列优化:针对某些区间最值的转移方程(如滑动窗口最大值类 DP)。
五、如何判断一个问题是否能用 DP 解决?
- 问题具有最优子结构(求最值、计数类问题);
- 问题存在重叠子问题(暴力递归会重复计算);
- 问题满足无后效性(当前状态不受未来决策影响)。
总结
- 核心思想:动态规划的本质是 "分解子问题 + 记录子问题解 + 推导原问题解",核心是避免重复计算。
- 解题四步:定义状态 → 推导转移方程 → 初始化 → 确定遍历顺序(这是解决所有 DP 问题的通用框架)。
- C++ 实现要点 :
- 优先用
vector存储 DP 数组,避免数组越界; - 先写直观的二维 DP,再优化为一维(空间);
- 注意边界条件(如 n=0、容量 = 0 等)和数组下标(0/1 起始)。
- 优先用
掌握这些内容后,你可以从简单 DP 问题入手,逐步挑战中等、困难级别的题目(如最长递增子序列、编辑距离、完全背包等),核心是多练、多总结状态定义和转移方程的规律。