闫氏DP分析法 学习笔记
求解的过程
DP问题 == 有限集上的最值问题
DP的两个阶段:
- 化零为整,寻找共性——状态表示
-
- 集合
所谓 化零为整 是指我们对于零星的情况,不是一个一个去枚举,而是每次根据这些零星的情况的某些特性去枚举一类情况(即一个子集)
- 集合
f(i)需要考虑的问题:
-
- 属性
表示的是一个怎样的集合?
f(i)保存的值是什么意思(max/min/count/…)?与集合有什么关系?
- 属性
- 化整为零,寻找不同——状态计算
如何去求f(i)呢?
我们要把它们划分成若干个不同的子集来求,每一个子集分别去求。需要满足的条件有:
-
-
- 不重复(可以不满足,例如求min,max。但是求count的时候,就不能重复)
-
-
-
- 不遗漏
例如,我们假设f(i)表示的是某个集合的最大值,那么可以每个子集的最大值的最大值。
那么问题来了,进行集合f(i)的划分的依据?
寻找最后一个不同点。
- 不遗漏
-
01背包
问题:
有N件物品和一个容量是V的背包,每件物品只能使用一次
第i件物品的体积是\(w[i]\),价值是\(v[i]\)
求将这些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大
分析:
是一个有限集合的最值问题
- 状态表示:f(i,j)
-
- 集合:只考虑前i个物品,且总体积不超过j的选择方案集合
-
- 属性:max f(N,V)
- 状态计算:
-
- 集合1,不选第i个物品的方案,\(max f(i-1,j)\)
-
- 集合2,选第i个物品的方案,\(max f(i-1,j-w_i)+v_i\)
两个集合满足:不重复和不遗漏
- 集合2,选第i个物品的方案,\(max f(i-1,j-w_i)+v_i\)
-
- 将集合1和集合2取最大值就是所求答案 \(f(i,j)=max( f(i-1,j), f(i-1,j-w_i)+v_i)\)
- 优化
分析状态计算可知,f(i,j)只记录f(i-1)即可。
朴素代码
概述: 严格按照状态转移方程 \(f[i][j] = \max(f[i-1][j], f[i-1][j-w[i]]+v[i])\) 实现。
缺点: 使用二维数组,空间复杂度 \(O(N \times V)\)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N],w[N];
int f[N][N];
int n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
cin >> v[i] >> w[i]; // 读入 体积 价值
for(int i=1;i<=n;i++){//i表示只考虑前i个物品
for(int j=0;j<=m;j++){//j表示当前背包容量
f[i][j] = f[i-1][j]; //该行必须有,保证“放不下w[i]”和“放得下但不选w[i]”时初始化当前状态
if(j>=w[i]) f[i][j]=max(f[i-1][j] , f[i-1][ j-w[i] ] + v[i]); //j>=w[i] 是为了保证背包能够放下,且防止数组越界
}
}
cout<<f[n][m];
return 0;
}
一维优化
DP问题的优化是对代码进行等价替换,和题目无关。
优化内容: 空间优化。将二维数组压缩为一维数组。
原理:
- 观察发现 \(f[i][j]\) 只依赖于上一层 \(f[i-1][<j]\) 的数据
- 因此我们倒序更新就能保证 更新\(f[i][j]\) 时, 依赖的上一层 \(f[i-1][<j]\) 的数据存在。
关键点: 内层循环必须倒序(j 从 m 到 w[i])。这是为了保证在计算 f[j] 时,用到的 f[j-w[i]] 是上一层(即 \(i-1\) 时)的状态,而不是本层已经被更新过的状态(防止一个物品被重复放入)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N], w[N];
int f[N];
int n, m;
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> v[i] >> w[i]; // 读入 体积 价值
for (int i = 1; i <= n; i++)
{
for (int j = m; j >= 0; j--) // 必须倒序
{
if (j >= w[i])
f[j] = max(f[j], f[ j- w[i] ] + v[i]);
}
/*
等价于for (int j = m; j >= w[i]; j--)
{
f[j] = max(f[j], f[ j-w[i] ] + v[i]);
}
*/
}
cout << f[m];
return 0;
}
完全背包
完全背包的每个物品可以用无限次,其他条件与01背包一致。
- 状态表示:
-
- 集合:只考虑前i个物品,且总体积不超过j的选择方案集合
-
- 属性:max
- 状态计算:
-
- 集合0:选0个第i个物品,\(f(i-1,j)\)
-
- 集合1:选1个第i个物品,\(f(i-1,j-w_i)+v_i\)
-
- 集合 …
-
- 集合K:选k个第i个物品,\(f(i-1,j-kw_i)+kv_i\)
-
- 集合 …
每个集合具有排他性
- 集合 …
推导\(f(i-1,j-k w_i)+k v_i\)
我们容易得到
\(f(i,j) = max(f(i-1,j),f(i-1,j-V_i)+W_i,f(i-1,j-2V_i)+2W_i,…,f(i-1,j-kV_i)+kW_i)\)
但是对于\(f(i,j-v) = max(f(i-1,j-V_i),f(i-1,j-2V_i)+W_i,…,f(i-1,j-kV_i)+kW_i)\)
立即推:\(f(i,j) = max( f(i-1,j),f(i,j-V_i)+W_i)\)
三重循环朴素代码
概述: 01背包朴素代码+完全背包定义的直接实现。
逻辑: 枚举第 \(i\) 个物品,枚举体积 \(j\),再枚举该物品选 \(k\) 个。
缺点: 效率最低。时间复杂度接近 \(O(N \times V^2)\),空间复杂度 \(O(N \times V)\)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int N = 1010;
const int INF = 0x3f3f3f3f;
int v[N], w[N];
int f[N][N];
int n, m;
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> v[i] >> w[i]; // 读入 体积 价值
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
f[i][j] = f[i - 1][j]; // 不选第 i 种物品
for (int k = 1; k * w[i] <= j; k++) {
f[i][j] = max( f[i][j], f[i - 1][ j - k * w[i] ] + k * v[i]) ;
}
}
}
cout << f[n][m];
return 0;
}
三重循环+一维数组
优化内容: 空间优化。
原理: 01背包的一维优化+完全背包定义的直接实现。
注意: 虽然空间降到了 \(O(V)\),但依然保留了三层循环,时间复杂度还是 \(O(N \times V^2)\)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int N = 1010;
int v[N], w[N];
int dp[N]; // 一维数组
int n, m;
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> v[i] >> w[i]; // 读入 体积 价值
// 初始化
for (int j = 0; j <= m; j++)
dp[j] = 0;
// 三重循环版完全背包(1维数组)
for (int i = 1; i <= n; i++) { // 物品
for (int j = m; j >= 0; j--) { // 背包容量 ,必须倒序
//*****************只多这些
for (int k = 1; k * w[i] <= j; k++) { // 选 k 个
//只多这些*****************
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
}
}
}
cout << dp[m] << endl;
return 0;
}
二重循环+二维数组
优化内容: 时间优化。
原理: 利用数学推导 \(f(i,j) = \max(f(i-1,j), f(i,j-w_i)+v_i)\)。
通过观察递推式发现,我们不需要显式地枚举 \(k\),而是可以通过“拿一个物品 \(i\) 后,状态转移到当前层 \(f[i][j-w_i]\)”来等价替换。
结果: 去掉了最内层的 \(k\) 循环,将时间复杂度从立方级降低到 \(O(N \times V)\)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N],w[N];
int f[N][N];
int n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)
cin>>v[i]>>w[i];
for(int i=1;i<=n;i++){
for(int j=0;j<=m;j++){
f[i][j]=f[i-1][j];
if(j>=w[i]) f[i][j]=max( f[i][j], f[i][ j-w[i] ] + v[i]); //注意这里是 f[i] ,不是 f[i-1] ,该变化决定了能不能重复拿当前物品
}
}
cout<<f[n][m];
return 0;
}
二重循环+一维数组
优化内容: 空间+代码的最优解。
原理: 01背包的一维优化 + 数学推导去除 \(k\) 循环
- 对比01背包: 01背包需要倒序遍历以读取“上一层”状态。
- 完全背包逻辑: 完全背包的状态转移 \(f[j] = \max(f[j], f[j-w[i]]+v[i])\) 中,我们需要的是当前层(即已经放入过物品 \(i\))的状态。
关键点: 内层循环改为正序(j 从 w[i] 到 m)。这意味着在计算 f[j] 时,f[j-w[i]] 已经是本轮更新过的值(可能已经装入了 \(k\) 个物品 \(i\)),从而实现了“物品可以使用无限次”的效果。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int v[N], w[N];
int f[N];
int n, m;
int main()
{
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> v[i] >> w[i];
for (int i = 1; i <= n; i++)
{
for (int j = w[i]; j <= m; j++)
{
f[j] = max(f[j], f[ j-w[i] ] + v[i]);
}
}
cout << f[m];
return 0;
}
01背包 vs 完全背包 对比表
对比朴素01背包 和 二重循环优化完全背包
| 对比维度 | 01背包 (0/1 Knapsack) | 完全背包 (Unbounded Knapsack) |
|---|---|---|
| 物品限制 | 每件物品只能用 1 次 | 每件物品可以用无限次 |
| 二维状态方程 | \(f[i][j] = \max(f[i-1][j], f[\mathbf{i-1}][j-v] + w)\) 依赖上一行的状态 |
\(f[i][j] = \max(f[i-1][j], f[\mathbf{i}][j-v] + w)\) 依赖当前行已更新的状态 |
| 一维遍历顺序 | 倒序 (j 从 m \(\to\) v[i]) |
正序 (j 从 v[i] \(\to\) m) |
| 为什么这样遍历? | 倒序是为了防止覆盖。 计算 \(f[j]\) 时需要用到 \(f[j-v]\),倒序保证 \(f[j-v]\) 还是上一层(i-1) 的旧数据(即还没选过i)。 |
正序是为了利用覆盖。 计算 \(f[j]\) 时,我们需要 \(f[j-v]\) 已经是这一层(i) 更新过的数据(即可能已经选过i了,再选一个)。 |
| 时间复杂度 | \(O(N \times V)\) | \(O(N \times V)\) (优化后) |
| 空间复杂度 | \(O(V)\) | \(O(V)\) |
代码核心逻辑对比: 只需改变内层循环的方向:
// 假设 v[i] 是体积,w[i] 是价值,m 是背包容量
// 【01背包】:必须倒序
for (int i = 1; i <= n; i++) {
for (int j = m; j >= v[i]; j--) { // <--- 关键区别:倒序
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
}
// 【完全背包】:必须正序
for (int i = 1; i <= n; i++) {
for (int j = v[i]; j <= m; j++) { // <--- 关键区别:正序
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
}
助记口诀
01倒,完全正。
(01背包怕重复,所以倒着走;完全背包求重复,所以顺着走。)
区间DP
合并石子问题
每次只能合并相邻的两堆,合并的代价为这两堆石子的质量和,合并后与这两堆石子相邻的石子和新堆相邻,合并时由于选择的顺序不同,合并的代价也不同
比如有4堆 1 3 5 2 ,我们可以先合并1、2堆,代价为4,得到4,5,2,又合并1,2堆代价为9,得到9,2,再合并得到11,总代价为4+9+11
找出一种合适的方法,使总合并代价最小
- 状态表示:
-
- 集合:所有将[i,j]合并成一堆的方案的集合
-
- 属性:min
- 状态计算:
-
- f[i,j] = f[i,k]+f[k+1,j]+s[j]-s[i-1];(s为本步骤代价)
分析:
对于区间[i,j],可以划分为左边和右边,问题的每一步都是左边和右边进行合并
我们把左半边的最后一堆设为分界点 k ,
因此我们问题变成,如何合并[i,k]和[k+1,j](
容易想到两部分是完全独立的,可以分开计算,
对于求f(i,k)和f(k+1,j)就是在各个子集中求出最小值,
然后再对它们取个 min 就是f(i,j)。
- f[i,j] = f[i,k]+f[k+1,j]+s[j]-s[i-1];(s为本步骤代价)
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int n;
int s[N]; //前n项和
int f[N][N];
int main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>s[i],s[i]+=s[i-1];
for(int len=2;len<=n;len++){
for(int i=1;i+len-1<=n;i++){
int j=i+len-1;
f[i][j]=1e8;
for(int k=i;k<j;k++)
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+s[j]-s[i-1]);
}
}
cout<<f[1][n]<<endl;
return 0;
}
线性dp
求最长公共子序列
给定两个长度分别为N和M的字符串A,B,求即是A的子序列又是B的子序列的字符串长度是多少。
- 状态表示:
-
- 集合:所有A[1,i]与B[1,j]的公共子序列的集合
-
- 属性:max
- 状态计算:
-
- f[i,j] = max(f[i-1,j],f[i,j-1],f[i-1,j-1]+1)
对最后一个不同点可以划分四类:
- f[i,j] = max(f[i-1,j],f[i,j-1],f[i-1,j-1]+1)
公共子序列不包含A [i] 也不包含 B [j]
公共子序列不包含A [i] 包含 B [j]
公共子序列包含A [i] 不包含 B [j]
公共子序列包含A [i] 也包含 B [j]
四种状态不重复不遗漏具有排他性
四种状态分别用00,01,10,11表示。
-
- 当情况为11时
当情况为11时,其必满足A [ i ] = B [ j ](字符相等)
将这一子集根据变与不变划分为两个部分:
一是前面 A 的 i - 1个字符与 B 的 j - 1个字符的情况(变的部分),就是 f(i-1,j-1)
二是A [ i ] = b [ j ](不变的部分),就是1
此时f(i,j) =f(i-1,j-1)+1
- 当情况为11时
-
- 当情况是00时
易得f(i,j) = f(i-1,j-1)
- 当情况是00时
-
- 当情况是01时
由于f(i-1,j) 包含两种情况,一种是B [ j ] 在公共子序列中,一种是B [ j ] 不在公共子序列中 。
但本题重复对于求最大值没有影响,所以可以直接使用
即f(i,j) = f(i-1,j)
- 当情况是01时
-
- 当情况是10时
f(i,j) = f(i,j-1) 。原因同上
- 当情况是10时
由上可以看出,01子集已包含00情况下的式子,
所以我们在代码构造中只需要计算 01、10、11三个部分即可。
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
#define PII pair<int, int>
#define x first
#define y second
const int maxn = 1e6 + 10;
const int INF = 0x3f3f3f3f;
const ll M = 1e9 + 7;
const int N = 1010;
int n,m;
char a[N],b[N];
int f[N][N];
int main(){
cin>>n>>m>>a+1>>b+1;
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
f[i][j] = max(f[i-1][j],f[i][j-1]);
if(a[i]==b[j]) f[i][j]=max(f[i][j],f[i-1][j-1]+1);
}
}
cout<<f[n][m]<<endl;
return 0;
}

浙公网安备 33010602011771号