DP 学习笔记(一):DP 基础知识,基础 DP 类型
orange_new · · 算法·理论
基本概念
动态规划是一种非常常见的算法,它将大问题划分为与它一样但数据规模更小的小问题,而大问题的的最优解决方案又来自于小问题的最优解决方案。简称为 DP(Dynamic Programming)。
动态规划优于暴力枚举的原因是它对于每一个问题,不是从头开始解决,而是基于之前解决的规模更小问题计算得来,这可以大大降低时间复杂度,而思维难度则上升了不止一个难度,而且它可以与数学(概率 DP)、字符串(自动机 DP)、图论(最短路算法)、数据结构(数据结构优化 DP)等多种信息竞赛中的重要版块进行深度融合,因此需要我们认真学习。
一些定义
状态:当前所求问题的信息;
函数:当前所求问题的答案,一般叫做 DP 值;
状态转移方程:如何通过当前所求问题的状态,找到它可以由哪几个小问题推出,并通过那几个小问题的函数推出当前问题的函数。一般用一个递推式子表示。
时间复杂度:
DP 能解决的问题一般具有以下
斐波那契数列是一种具有递推关系的数列,它的每一个数字都是前两个数字的和:
重叠子问题
简单来说,就是求解大问题的最优解决方案时,需要将大问题拆分成若干个小问题,小问题会被拆分成更小的问题,这些拆分出的小问题可能会有重复。比如求解斐波那契数列的第五项
可以发现,
最优子结构
首先,大问题的最优解包含小问题的最优解,也就是当大问题取得最优解时,小问题也取得最优解。其次,小问题的最优解可以推出大问题的最优解,这就是最优子结构。
在斐波那契数列的求解过程中,求
无后效性
简单来说,就是当我们求出某个问题的最优解时,我们就不再关心这个最优解是如何得到的,也就不再改变这个值了,而是将这个解作为已知继续推出其它问题的最优解。
求解斐波那契数列的过程中,当我们求出
无后效性是可以使用 DP 的前提条件,当后续的操作会影响到之前操作的值时,就无法通过重叠子问题来优化枚举的复杂度,也就无法使用 DP。一般求解 DP 问题都需要考虑 DP 的顺序,让问题没有后效性。
DP 一般有以下
记忆化搜索
在搜索时,如果遇到之前求解过的状态,就直接将它的 DP 值拿来用,而不用继续往下递归。
求解斐波那契数列的记忆化搜索代码:
int f[N];
int fib(int x){
if(x == 1 || x == 2)
return 1;
if(f[x])
return x;
else
return fib(x - 1) + fib(x - 2);
}
填表法
考虑当前状态是由哪几个状态转移而来。
求解斐波那契数列的填表法代码:
int f[N];
f[1] = f[2] = 1;
for(int i = 3; i <= n; i++)
f[i] = f[i - 1] + f[i - 2];
填表法也是最常见的 DP 写法。
刷表法
考虑当前状态会影响到后续哪几个状态的求解。
求解斐波那契数列的刷表法代码:
int f[N];
f[1] = 1;
for(int i = 1; i <= n; i++){
f[i + 1] += f[i];
f[i + 2] += f[i];
}
背包 DP
一类非常经典的线性 DP 题,因此专门提出来讲。
一些定义:
01背包
P1048 [NOIP2005 普及组] 采药
记
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int M = 109, T = 1009;
int w[M], v[M], dp[M][T], n, m;
int main(){
scanf("%d%d", &m, &n);
for(int i = 1; i <= n; i++)
scanf("%d%d", &w[i], &v[i]);
for(int i = 1; i <= n; i++)
for(int j = 0; j <= m; j++){
if(w[i] > j)
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j - w[i]] + v[i], dp[i - 1][j]);
}
printf("%d", dp[n][t]);
return 0;
}
滚动数组优化01背包
滚动数组可以优化背包的空间复杂度。
可以发现,
交替滚动
开两行数组,一行存计算过的旧的一行,一行存当前计算的一行。
完整代码:
int dp[2][N];
int now = 0, old = 1;
for(int i = 1; i <= n; i++){
swap(old, now);
for(int j = 0; j <= m; j++){
if(w[i] > j)
dp[now][j] = dp[old][j];
else
dp[now][j] = max(dp[old][j], dp[old][j - w[i]] + v[i]);
}
}
自我滚动
只开一行,一边计算,一边更新。这时候内层循环要倒着来枚举,下面来说明。
考虑当前状态由那些状态更新来:
发现
完整代码:
int dp[N];
for(int i = 1; i <= n; i++)
for(int j = m; j >= w[i]; j--)
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
bitset优化01背包
当我们在求解某类
首先,这种可行性的背包的转移方程就是
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 100010;
int n, m, dp[N];
int v[N], w[N], c[N];
int new_n;
int new_v[N], new_w[N], new_c[N];
int main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++)
scanf("%d%d%d", &v[i], &w[i], &c[i]);
int new_n = 0;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= c[i]; j <<= 1){
c[i] -= j;
new_w[++new_n] = j * w[i];
new_v[new_n] = j * v[i];
}
if(c[i]){
new_w[++new_n] = c[i] * w[i];
new_v[new_n] = c[i] * v[i];
}
}
for(int i = 1; i <= new_n; i++)
for(int j = m; j >= new_w[i]; j--)
dp[j] = max(dp[j], dp[j - new_w[i]] + new_v[i]);
printf("%d", dp[m]);
return 0;
}
单调队列优化多重背包
见DP 学习笔记(三):与斜率有关的 DP 优化
混合背包
P1833 樱花
将
完整代码(使用了单调队列优化):
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9;
int n, m, h1, h2, m1, m2;
int dp[N], q[N], num[N];
int w, v, c;
int main(){
scanf("%d:%d %d:%d %d", &h1, &m1, &h2, &m2, &n);
m = 60 * (h2 - h1) + m2 - m1;
for(int i = 1; i <= n; i++){
scanf("%d%d%d", &w, &v, &c);
if(c == 0)
c = 100000000;
if(c > m / w)
c = m / w;
for(int b = 0; b < w; b++){
int head = 1, tail = 1;
for(int y = 0; y <= (m - b) / w; y++){
int tmp = dp[b + y * w] - y * v;
while(head < tail && q[tail - 1] <= tmp)
tail--;
q[tail] = tmp;
num[tail++] = y;
while(head < tail && y - num[head] > c)
head++;
dp[b + y * w] = max(dp[b + y * w], q[head] + y * v);
}
}
}
printf("%d", dp[m]);
return 0;
}
二维费用背包
P1855 榨取kkksc03
在
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 209;
int dp[N][N], w[N], t[N], n, m, T;
int main(){
scanf("%d%d%d", &n, &m, &T);
for(int i = 1; i <= n; i++)
scanf("%d%d", &w[i], &t[i]);
for(int i = 1; i <= n; i++)
for(int j = m; j >= w[i]; j--)
for(int k = T; k >= t[i]; k--)
dp[j][k] = max(dp[j][k], dp[j - w[i]][k - t[i]] + 1);
printf("%d", dp[m][T]);
return 0;
}
分组背包
和 01 背包很像,就是不再枚举每个物品,而是枚举每个组别再枚举这个组别中选哪个物品就可以了。
P1757 通天之分组背包
#include <bits/stdc++.h>
using namespace std;
const int N = 109, M = 1e3 + 9;
int dp[M], w[M], v[M], n, m, cnt;
vector <int> vec[N];
int main(){
scanf("%d%d", &m, &n);
for(int i = 1; i <= n; i++){
int num;
scanf("%d%d%d", &w[i], &v[i], &num);
cnt = max(cnt, num);
vec[num].push_back(i);
}
for(int k = 1; k <= cnt; k++)
for(int j = m; j >= 0; j--)
for(int i = 0; i < vec[k].size(); i++)
if(j >= w[vec[k][i]])
dp[j] = max(dp[j], dp[j - w[vec[k][i]]] + v[vec[k][i]]);
else
dp[j] = dp[j];
printf("%d", dp[m]);
return 0;
}
树形背包 (有依赖的背包)
P1064 [NOIP 2006 提高组] 金明的预算方案
这是树形背包的来源,但是这并不是最泛化的树形背包。
由于买附件一定要先买主件,而买主件则没有限制,因此一共有以下
-
什么都不买;
-
只买主件;
-
买主件和第一个附件(如果有);
-
买主件和第二个附件(如果有);
-
买主件和两个附件(如果都有);
我们依然按照 01 背包的 DP 状态设法,记
注意一下下标,然后这道题就做完了。
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 3.2e4 + 9, M = 69;
int v[M][3], p[M][3], dp[N], n, m, v1, p1, q1;
signed main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= m; i++){
scanf("%d%d%d", &v1, &p1, &q1);
if(q1){
if(v[q1][1]){
v[q1][2] = v1;
p[q1][2] = p1;
} else {
v[q1][1] = v1;
p[q1][1] = p1;
}
} else {
v[i][0] = v1;
p[i][0] = p1;
}
}
for(int i = 1; i <= m; i++){
for(int j = n; j >= 1; j--){
if(v[i][0] <= j)
dp[j] = max(dp[j], dp[j - v[i][0]] + v[i][0] * p[i][0]);
if(v[i][0] + v[i][1] <= j)
dp[j] = max(dp[j], dp[j - v[i][0] - v[i][1]] + v[i][0] * p[i][0] + v[i][1] * p[i][1]);
if(v[i][0] + v[i][2] <= j)
dp[j] = max(dp[j], dp[j - v[i][0] - v[i][2]] + v[i][0] * p[i][0] + v[i][2] * p[i][2]);
if(v[i][0] + v[i][1] + v[i][2] <= j)
dp[j] = max(dp[j], dp[j - v[i][0] - v[i][1] - v[i][2]] + v[i][0] * p[i][0] + v[i][1] * p[i][1] + v[i][2] * p[i][2]);
}
}
printf("%d", dp[n]);
return 0;
}
P2014 [CTSC1997] 选课
这其实也不是特别泛化的树形背包,因为每个课程的重量是
首先可以发现,如果按照依赖关系建图的话,会连出一个森林,此时背包间的合并比较麻烦,因此我们建出一个超级根作为所有树根的直接先修课(比如学会如何写字),我们再强制选超级根就行了。因此我们只需要将
由于是在树上,因此我们就将 DP 函数改成
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 3e2 + 9;
struct Edge{
int v, nex;
} e[N];
int head[N], ecnt;
void addEdge(int u, int v){
e[++ecnt] = Edge{v, head[u]};
head[u] = ecnt;
}
int dp[N][N], n, m;
void DP(int u){
for(int i = head[u]; i; i = e[i].nex){
int v = e[i].v;
DP(v);
for(int i = m + 1; i >= 1; i--)
for(int j = 0; j <= i - 1; j++)
dp[u][i] = max(dp[u][i], dp[u][i - j] + dp[v][j]);
}
}
int main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++){
int fa;
scanf("%d%d", &fa, &dp[i][1]);
addEdge(fa, i);
}
DP(0);
printf("%d\n", dp[0][m + 1]);
return 0;
}
泛化物品背包
泛化物品
考虑这样一个物品,它的价值不是一个定值,而是会随着费用的变化而变化。就像同样一套题目,你在一天的早晨和晚上都去做它,效率肯定是不一样的,说明它的价值和时间有关系。这就是泛化物品的概念。
如果从更数学的角度来说,就是我们定义了一个价值函数
-
-
多重背包的
h 函数为v = [w_i \mid w]\left[ \displaystyle\frac{w}{w_i} \leq c_i \right] \displaystyle\frac{wv_i}{w_i} ,表示只有当放入物品的费用为w_i 的倍数且选的物品个数\leq c_i 时才会获得选的物品个数\times v_i 的收益; -
树形背包例题P1064 [NOIP 2006 提高组] 金明的预算方案中,主件和附件一起可以看成一个物品组,你给这个物品组分配不同的花费,就会得到不同的价值,这也是一个泛化背包问题。
泛化物品合并
现在我们要做的,就是选定一个费用,然后把两个泛化物品合并起来,此时我们需要考虑如何将费用分配给这两个物品。
假设分配给这两个物品分别
对于其中的物品都是泛化物品的背包问题,求它的答案的过程也就是求所有这些泛化物品之和的过程。假设最后合成的泛化物品的价值为
P1417 烹调方案
这就是一个比较简单的泛化物品背包问题,甚至不需要合并操作。
我们考虑两个食材
完整代码:
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 9;
struct Ingredient{
int a, b, c;
} p[N];
int dp[N], n, m, ans;
bool cmp(Ingredient x, Ingredient y) {
return x.c * y.b < y.c * x.b;
}
signed main(){
scanf("%lld%lld", &m, &n);
for(int i = 1; i <= n; i++)
scanf("%d", &p[i].a);
for(int i = 1; i <= n; i++)
scanf("%d", &p[i].b);
for(int i = 1; i <= n; i++)
scanf("%d", &p[i].c);
sort(p + 1, p + n + 1, cmp);
for(int i = 1; i <= n; i++)
for(int j = m; j >= p[i].c; j--)
dp[j] = max(dp[j], dp[j - p[i].c] + p[i].a - j * p[i].b);
for(int i = 1; i <= m; i++)
ans = max(ans, dp[i]);
printf("%lld", ans);
return 0;
}
背包方案与计数
输出任意最优方案
对于 01 背包的函数
其实,也可以不存
输出字典序最小的最优方案
AcWing12 背包问题求具体方案
一个 01 背包问题可能会有多组物品,每组都可以作为答案,现在我们要求出所有组物品中排序好后字典序最小的哪一个。
我们考虑到最优的情况一定是
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9;
int dp[N][N], w[N], v[N], n, m;
vector <int> vec;
signed main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++)
scanf("%d%d", &w[i], &v[i]);
for(int i = n; i >= 1; i--){
for(int j = 0; j <= m; j++){
if(j < w[i])
dp[i][j] = dp[i + 1][j];
else
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j - w[i]] + v[i]);
}
}
for(int i = 1, j = m; i <= n; i++){
if(j >= w[i] && dp[i][j] == dp[i + 1][j - w[i]] + v[i]){
vec.push_back(i);
j -= w[i];
}
}
for(int i : vec)
printf("%d ", i);
return 0;
}
求第 k 优方案
HDU2639 Bone Collector II
真是太神秘了,生活中应该没有人去求第
还是以
其实对于几乎所有求第
另外还要注意题目对于第
完整代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9, M = 1e3 + 9, K = 39;
int dp[M][K], w[N], v[N], tmp[K], n, m, k, T;
int main(){
scanf("%d", &T);
while(T--){
scanf("%d%d%d", &n, &m, &k);
for(int i = 1; i <= n; i++)
scanf("%d", &v[i]);
for(int i = 1; i <= n; i++)
scanf("%d", &w[i]);
memset(dp, 0, sizeof(dp));
for(int i = 1; i <= n; i++){
for(int j = m; j >= w[i]; j--){
int c1 = 1, c2 = 1, cnt = 1;
while(cnt <= k && c1 <= k && c2 <= k){
if(dp[j][c1] > dp[j - w[i]][c2] + v[i])
tmp[cnt] = dp[j][c1++];
else
tmp[cnt] = dp[j - w[i]][c2++] + v[i];
if(tmp[cnt] != tmp[cnt - 1])
cnt++;
}
while(cnt <= k && c1 <= k){
tmp[cnt] = dp[j][c1++];
if(tmp[cnt] != tmp[cnt - 1])
cnt++;
}
while(cnt <= k && c2 <= k){
tmp[cnt] = dp[j - w[i]][c2++] + v[i];
if(tmp[cnt] != tmp[cnt - 1])
cnt++;
}
for(int l = 1; l <= k; l++)
dp[j][l] = tmp[l];
}
}
printf("%d\n", dp[m][k]);
}
return 0;
}
输出方案数
此时我们就不用考虑是否是最优解了,而需要统计装满背包的所有方案。
如果
对于
输出最优方案数
AcWing11 背包问题求方案数
结合求方案总数和求最优方案,我们只需要最后输出
完整代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 1e3 + 9, M = 1e3 + 9, MOD = 1e9 + 7;
int dp[M], cnt[M], w[N], v[N], n, m;
int main(){
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++)
scanf("%d%d", &w[i], &v[i]);
for(int i = 0; i <= m; i++)
cnt[i] = 1;
for(int i = 1; i <= n; i++)
for(int j = m; j >= w[i]; j--){
if(dp[j - w[i]] + v[i] > dp[j]){
dp[j] = dp[j - w[i]] + v[i];
cnt[j] = cnt[j - w[i]];
} else if(dp[j - w[i]] + v[i] == dp[j])
cnt[j] = (cnt[j - w[i]] + cnt[j]) % MOD;
}
printf("%d", cnt[m]);
return 0;
}
背包合并
背包增减
大容量背包问题
回顾一下背包问题。形式化的表述就是你需要找到一组非负整数
你需要最大化
此时如果
背包问题可以用一个朴素的
一般性分析
任何人一开始接触背包问题时,一定知道一个错误的贪心方法,那就是对于所有物品按照性价比从大到小排序,贪心尽量选满。这个方案会存在一个位置
我们先来证明一个引理。
引理 2.12.1.1
对于一个大小为
证明:
设
根据这个引理,我们可以得出一个定理。
定理 2.12.1.2
存在一组最优选取方案
证明:
考虑反证法,假设存在一个位置
我们发现,只有满足
记所有多选取的物品重量集合为
-
如果
|S| < W ,那么最多只能选择W - 1 个重量为W 的物品,那么多选取的物品的重量和\displaystyle\sum_{i \in S} i \leq W(W - 1) \leq W^2 - w_q = (t_q - x_q - 1) w_q ,此时我们多选一个q 物品,可以把减掉的那个1 补上,依然合法,而且会优于当前方案。 -
如果
|S| \geq W ,根据引理 2.4.3.1,S 中存在一个大小不超过w_q 的非空子集S' 满足\displaystyle\sum_{i \in S'} i \bmod w_q = 0 ,则在S 中去掉S' 并多选取\displaystyle\frac{sum}{w_q} 个物品q 不会改变总体积,但是提升了总性价比。依然会优于当前方案。
这两种情况都与
多重背包
完全背包
见图上算法学习笔记(一):图论基础知识、最短路相关、生成树相关
背包 DP 难题
P3188 [HNOI2007] 梦幻岛宝珠
LOJ6089 小 Y 的背包计数问题
非常有启发性的一道题目。
由于所有数字的和一定,因此我们考虑根号分治,最后两部分做一个卷积即可。对于重量小于
现在考虑重量大于
-
往背包中加入一个重量为
\sqrt n 的物品; -
让背包中所有物品的重量增加
1 。
可以发现这样可以生成所有可能的物品序列。
那么这样做有什么好处呢?此时我们就可以设计出另外一种 DP 方式了。我们设
:::info[点击查看代码]
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 1e5 + 9, B = 4e2 + 9, MOD = 23333333;
int dp[N], dp2[B][N], sum[N], sum2[N], n, ans, len;
signed main(){
scanf("%lld", &n);
dp[0] = sum[0] = 1;
len = sqrt(n) + 2;
for(int i = 1; i < len; i++){
for(int j = 1; j <= n; j++){
if(j >= i)
sum[j] = (sum[j - i] + dp[j]) % MOD;
else
sum[j] = dp[j];
if(j >= i * (i + 1))
dp[j] = (sum[j] - sum[j - i * (i + 1)] + MOD) % MOD;
else
dp[j] = sum[j];
}
}
dp2[0][0] = sum2[0] = 1;
for(int i = 1; i <= n / len; i++)
for(int j = i * len; j <= n; j++){
dp2[i][j] = (dp2[i - 1][j - len] + dp2[i][j - i]) % MOD;
sum2[j] = (sum2[j] + dp2[i][j]) % MOD;
}
for(int i = 0; i <= n; i++)
ans = (ans + dp[i] * sum2[n - i]) % MOD;
printf("%lld", ans);
return 0;
}
:::
P6453 [COCI 2008/2009 #4] PERIODNI
线性 DP
子序列问题
P1020 [NOIP 1999 提高组] 导弹拦截
P1439 【模板】最长公共子序列
P1091 [NOIP 2004 提高组] 合唱队形
方格取数问题
P7074 [CSP-J2020] 方格取数
P1004 [NOIP 2000 提高组] 方格取数
区间 DP
P1880 [NOI1995] 石子合并
P1220 关路灯
P1063 [NOIP 2006 提高组] 能量项链
状压 DP
普通状压 DP
P1896 [SCOI2005] 互不侵犯
P2704 [NOI2001] 炮兵阵地
P1879 [USACO06NOV] Corn Fields G
轮廓线 DP
数位 DP
P2602 [ZJOI2010] 数字计数
P2657 [SCOI2009] windy 数
P4124 [CQOI2016] 手机号码
树形 DP
普通树形 DP
P2015 二叉苹果树
P1352 没有上司的舞会
换根 DP
参考资料
-
算法竞赛 罗勇军、郭卫斌
-
算法竞赛进阶指南 李煜东
-
背包问题九讲 崔添翼