文章目录
前言
动态规划,DP(Dynamic Programming),运筹学的一个分支,同时也是求解决策过程最优化问题的过程。它和贪心
一样,不是具体的某一种算法,而是一种思想。
DP的学习和使用,非常地具有挑战性,会使用DP,经常可以写出令人拍案叫绝的代码。所以它也是算法竞赛,数学建模等中的一个热点问题
DP优化是对方程进行等价变换
个人博客codeslogan: dynamic programming
背包问题
f ( i , j ) f(i,j) f(i,j)存的是集合中所有选法的属性,是一个数
0-1背包
每个物品只能选一次,即放入/不放入背包,使利益最大化
状态转移方程:
f [ i , j ] = m a x ( f [ i − 1 , j ] , f [ i − 1 , j − v ] + w ) f[i,j] = max(f[i-1,j],f[i-1,j-v]+w) f[i,j]=max(f[i−1,j],f[i−1,j−v]+w)
二维
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int f[N][N];
int main() {
scanf("%d%d", &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++) {
// 当前背包容量小于物品重量
if (j < v[i]) f[i][j] = f[i-1][j]; // 表示不选
else f[i][j] = max(f[i-1][j], f[i-1][j-v[i]] + w[i]); // 表示选上
}
}
cout << f[n][m] << endl;
return 0;
}
一维
如果顺序遍历,那么数组中来自上一轮的状态,会因顺序原因被污染。导致出现需要上一轮的状态时,却已经被覆盖了的情况。
换言之,如果j
是顺序递增的话,则相当于f[i][j]
变得是由f[i][j-v[i]]
推出来的,
而不是由原来的f[i-1][j-v[i]]
推的。与我们的状态转移方程不符。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int f[N];
int main() {
scanf("%d%d", &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 >= v[i]; j--)
f[j] = max(f[j], f[j-v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}
完全背包
物品无限量,同一个物品可以重复放入背包中
O(n^3),朴素做法
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m; // 物品数量,背包容量
int v[N], w[N]; // 体积,价值
int f[N][N];
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++) // 重量
for (int k = 0; k * v[i] <= j; k++) // 物品个数
f[i][j] = max(f[i][j], f[i-1][j-k*v[i]] + k*w[i]);
cout << f[n][m] << endl;
return 0;
}
O(n^2)
对k进行优化,三维降二维,状态转移方程优化推导如下:
f [ i , j ] = m a x ( f [ i − 1 , j ] , f [ i − 1 , j − v ] + w , f [ i − 1 , j − 2 v ] + 2 w , . . . ) f[i,j] = max(f[i-1,j],f[i-1,j-v]+w,f[i-1,j-2v]+2w,...) f[i,j]=max(f[i−1,j],f[i−1,j−v]+w,f[i−1,j−2v]+2w,...)
因为i-1
个物品的最大值已经在上一轮循环确定了下来,所以我们只需讨论第i
个物品应该选多少个
将j
替换为j-v
f [ i , j − v ] = m a x ( f [ i − 1 , j − v ] , f [ i − 1 , j − 2 v ] + w , f [ i − 1 , j − 3 v ] + 2 w , . . . ) f[i,j-v] = max(f[i-1,j-v],f[i-1,j-2v]+w,f[i-1,j-3v]+2w,...) f[i,j−v]=max(f[i−1,j−v],f[i−1,j−2v]+w,f[i−1,j−3v]+2w,...)
f [ i , j − v ] + w = m a x ( f [ i − 1 , j − v ] + w , f [ i − 1 , j − 2 v ] + 2 w , f [ i − 1 , j − 3 v ] + 3 w , . . . ) f[i,j-v]+w = max(f[i-1,j-v]+w,f[i-1,j-2v]+2w,f[i-1,j-3v]+3w,...) f[i,j−v]+w=max(f[i−1,j−v]+w,f[i−1,j−2v]+2w,f[i−1,j−3v]+3w,...)
将上述式子代入原方程,进行等价替换,得到状态转移方程:
f [ i , j ] = m a x ( f [ i − 1 , j ] , f [ i , j − v ] + w ) f[i,j]=max(f[i-1,j],f[i,j-v]+w) f[i,j]=max(f[i−1,j],f[i,j−v]+w)
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int f[N][N];
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++) {
if (j < v[i]) f[i][j] = f[i-1][j];
else f[i][j] = max(f[i-1][j], f[i][j-v[i]] + w[i]);
}
cout << f[n][m] << endl;
return 0;
}
降维
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int f[N];
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 = v[i]; j <= m; j++) // 从小到大循环
f[j] = max(f[j], f[j-v[i]] + w[i]);
cout << f[m] << endl;
return 0;
}
同时处理输入和状态转移
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int f[N];
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i ++ ) {
int v, w;
cin >> v >> w;
for (int j = v; j <= m; j++)
f[j] = max(f[j], f[j-v] + w);
}
cout << f[m] << endl;
return 0;
}
多重背包
每个物品的数量不同
朴素作法
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110;
int v[N], w[N], s[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] >> s[i];
for (int i = 1; i <= n; i ++ )
for (int j = 0; j <= m; j++)
for (int k = 0; k <= s[i] && k * v[i] <= j; k++)
f[i][j] = max(f[i][j], f[i-1][j-v[i]*k] + w[i]*k);
cout << f[n][m] << endl;
return 0;
}
二进制优化
将一个物品的个数,表示成一段从1开始的二进制数
例如,200:1、2、4、8、16、32、64、73
为什么不选128呢?因为如果加128,可以表示的数就达到了255,超出了200。到64时,表示的数范围为127,补上一个73,就能够表示从1-200之间的任何一个数
- 同时留意使用二进制优化时,需要开辟的数组空间为
物品种数xlog2(某种物品的数量,向上取整)
。因为我们在把物品数量拆成二进制表示时,要考虑到要用多少个数,才能表示出物品数量,得出上述结果
#include <iostream>
#include <algorithm>
using namespace std;
// 当有1000种物品,每种物品的数量为2000,得25000
const int N = 25000; // 留意开辟空间
int f[N];
int v[N], w[N];
int n, m;
int main() {
cin >> n >> m;
int cnt = 0;
for (int i = 1; i <= n; i++) {
int a, b, s;
cin >> a >> b >> s;
// 二进制优化核心代码
int k = 1;
while (k <= s) {
cnt++;
v[cnt] = a * k;
w[cnt] = b * k;
s -= k;
k *= 2;
}
if (s > 0) {
cnt++;
v[cnt] = a * s;
w[cnt] = b * s;
}
}
n = cnt;
// 0-1背包
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]);
cout << f[m] << endl;
return 0;
}
分组背包
每一组中选取一样物品加入背包
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110;
int n, m;
int f[N];
int v[N][N], w[N][N], s[N];
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> s[i];
for (int j = 0; j < s[i]; j++) {
cin >> v[i][j] >> w[i][j];
}
}
for (int i = 1; i <= n; i++)
for (int j = m; j >= 0; j--)
for (int k = 0; k < s[i]; k++)
if (v[i][k] <= j)
f[j] = max(f[j], f[j-v[i][k]] + w[i][k]);
cout << f[m] << endl;
return 0;
}
线性DP
数字三角形
DP里的Hello World!
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 510, INF = 1e9;
int n;
int a[N][N], f[N][N];
int main() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
scanf("%d", &a[i][j]);
for (int i = 0; i <= n; i++)
for (int j = 0; j <= i + 1; j++) // 每行需要多初始化一个,考虑到两边
f[i][j] = -INF;
f[1][1] = a[1][1];
for (int i = 2; i <= n; i++) // 若从1开始,结果为负无穷,由前状态转移而来
for (int j = 1; j <= i; j++) // 从2开始才有前状态
f[i][j] = max(f[i-1][j-1] + a[i][j], f[i-1][j] + a[i][j]);
int res = -INF;
for (int i = 1; i <= n; i++) res = max(res, f[n][i]);
cout << res << endl;
return 0;
}
最长上升子序列
到这里为止,学习动态规划的感想是:根据题目,找到其中的一个状态,分析它是由前一个状态怎么转换得来。
比方说到下图中的6这个位置上,那么要求以6结尾的最长递增子序列要通过遍历它的前六个数,判断与它们的大小关系,再加1,取最大值。而前6个数的最长又是怎么求出来的?就是根据前5个数,以此类推
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int a[N], f[N], pre[N];
int n;
void PrintPath(int v) {
if (pre[v] == 0) {
printf("%d", a[v]);
return;
}
PrintPath(pre[v]);
printf(" %d", a[v]);
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i ++ ) scanf("%d", &a[i]);
for (int i = 1; i <= n; i ++ ) {
f[i] = 1; // 初始化,只有1个数以第i个数结尾
for (int j = 1; j < i; j++) {
if (a[j] < a[i]) {
if (f[i] < f[j] + 1) {
f[i] = f[j] + 1;
pre[i] = j; // 记录前一个点的下标,用于还原路径
}
}
}
}
int res = -1e9, t;
for (int i = 1; i <= n; i ++ ) {
if (f[i] > res) {
res = f[i];
t = i; // 记录最长路径的最后一个下标
}
}
cout << res << endl;
PrintPath(t);
return 0;
}
最长公共子序列
- 状态表示
f
[
i
,
j
]
f[i,j]
f[i,j]:
s1
的前i
个字符和s2
的前j
个字符的最长公共子序列 - 状态计算: s 1 [ i ] = s 2 [ j ] s_1[i] = s_2[j] s1[i]=s2[j] 和 s 1 [ i ] ! = s 2 [ j ] s_1[i] != s_2[j] s1[i]!=s2[j]
当两个字符相等时,容易理解,由 f [ i − 1 ] [ j − 1 ] + 1 f[i-1][j-1] + 1 f[i−1][j−1]+1转移而来
而当两个字符不等时,两个字符中一定有一个可以抛弃 f [ i − 1 ] [ j ] , f [ i ] [ j − 1 ] f[i-1][j], f[i][j-1] f[i−1][j],f[i][j−1]另
外一个可能存在于最长序列,也有可能不存在,取最大值避免了复杂的讨论情况
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
char s1[N], s2[N];
int f[N][N];
int main() {
cin >> n >> m >> s1 + 1 >> s2 + 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 (s1[i] == s2[j]) f[i][j] = max(f[i][j], f[i-1][j-1] + 1);
}
cout << f[n][m] << endl;
return 0;
}
区间DP
- 状态表示
f
[
i
,
j
]
f[i,j]
f[i,j]:从第
i
堆石子到第j
堆石子合并所需的代价 - 状态计算:将区间划分成 [ i , k ] [i,k] [i,k]和 [ k + 1 , j ] [k+1,j] [k+1,j], k = i , i + 1 , . . . , j − 1 k = i, i+1, ..., j-1 k=i,i+1,...,j−1
通过枚举区间长度
,找出代价的最小值
状态转移: f [ l ] [ k ] + f [ k + 1 ] [ r ] + s [ r ] − s [ l − 1 ] f[l][k] + f[k+1][r] + s[r] - s[l-1] f[l][k]+f[k+1][r]+s[r]−s[l−1]
最后一次转移加上它的代价
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 310;
int n;
int f[N][N];
int s[N];
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i ++ ) scanf("%d", &s[i]);
// 前缀和,确定任一区间的权重
for (int i = 1; i <= n; i ++ ) s[i] += s[i-1];
// len=1的时候无须合并,所以从2开始
for (int len = 2; len <= n; len++)
// 枚举所有长度为len的情况
for (int i = 1; i + len - 1 <= n; i++) {
int l = i, r = i + len -1;
f[l][r] = 1e8;
// 在区间范围内进行划分
for (int k = l; k <= r; k++)
f[l][r] = min(f[l][r], f[l][k] + f[k+1][r] + s[r] - s[l-1]);
}
cout << f[1][n] << endl;
return 0;
}
计数类DP
状态表示中属性为count的DP问题
整数划分
一个正整数n可以表示 成若干个正整数之和,形如: n = n 1 + n 2 + . . . + n k n = n_1 + n_2 + ... + n_k n=n1+n2+...+nk,其中 n 1 ≥ n 2 ≥ . . . ≥ n k , k ≥ 1 n_1 \ge n_2 \ge ... \ge n_k, k \ge 1 n1≥n2≥...≥nk,k≥1。
我们将这样的一种表示 称为正整数n的一种划分。
现在给定一个正整数n,请你求出n共有多少种不同的划分方法。
输入格式
共一行,包含一个整数n。
输出格式
共一行,包含一个整数,表示总划分数量。
由于答案可能 很大,输出结果请对 1 0 9 + 7 10^9+7 109+7取模。
数据范围
1 ≤ n ≤ 1000 1 \le n \le 1000 1≤n≤1000
转化为完全背包问题
- 状态表示
f
[
i
,
j
]
f[i,j]
f[i,j]:从
1-i
当中选,使体积恰好是j
的数量
- 状态计算:针对第i个数时,有不选、选i、选2i、……、选si这些情况,容易将其转化为完全背包问题
选几个i,就要在之前的状态中提前预留出位置,因此减去
f [ i , j ] = f [ i − 1 , j ] + f [ i − 1 , j − i ] + f [ i − 1 , j − 2 i ] + . . . + f [ i − 1 , j − s i ] f[i,j] = f[i-1,j]+f[i-1,j-i]+f[i-1,j-2i]+...+f[i-1,j-si] f[i,j]=f[i−1,j]+f[i−1,j−i]+f[i−1,j−2i]+...+f[i−1,j−si]
将j
等价替换为j-i
f [ i , j − i ] = f [ i − 1 , j − i ] + f [ i − 1 , j − 2 i ] + f [ i − 1 , j − 3 i ] + . . . + f [ i − 1 , j − s i ] f[i,j-i] = f[i-1,j-i]+f[i-1,j-2i]+f[i-1,j-3i]+...+f[i-1,j-si] f[i,j−i]=f[i−1,j−i]+f[i−1,j−2i]+f[i−1,j−3i]+...+f[i−1,j−si]
代入原式,得
f [ i , j ] = f [ i − 1 , j ] + f [ i , j − i ] f[i,j]=f[i-1,j]+f[i,j-i] f[i,j]=f[i−1,j]+f[i,j−i]
#include <iostream>
using namespace std;
const int N = 1010, INF = 1e9 + 7;
int n;
int f[N];
int main() {
cin >> n;
f[0] = 1; // 一个数都不选,总和为0时的方案
for (int i = 1; i <= n; i ++ )
for (int j = i; j <= n; j++)
f[j] = (f[j] + f[j-i]) % INF;
cout << f[n] << endl;
return 0;
}
计数类DP
- 状态表示
f
[
i
,
j
]
f[i,j]
f[i,j]:所有数的总和为
i
,并且表示成j
个数的和的个数
- 状态计算:将集合划分成两种情况:
j个数
中最小值为1和最小值大于1- 最小值为1: f [ i − 1 , j − 1 ] f[i-1,j-1] f[i−1,j−1]
- 最小值大于1: f [ i − j , j ] f[i-j,j] f[i−j,j](每个值都减去1,一共减j,方案数不变)
状态转移方程为
f [ i , j ] = f [ i − 1 , j − 1 ] + f [ i − j , j ] f[i,j]=f[i-1,j-1]+f[i-j,j] f[i,j]=f[i−1,j−1]+f[i−j,j]
最后的结果需要对式求和 f [ n ] [ i ] , i = 1 , 2 , . . . , n f[n][i],i=1, 2,...,n f[n][i],i=1,2,...,n
#include <iostream>
using namespace std;
const int N = 1010, mod = 1e9 + 7;
int n;
int f[N][N];
int main() {
cin >> n;
f[0][0] = 1;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
f[i][j] = (f[i-1][j-1] + f[i-j][j]) % mod;
int res = 0;
for (int i = 1; i <= n; i ++ ) res = (res + f[n][i]) % mod;
cout << res << endl;
return 0;
}
数位统计DP
状态压缩DP
树形DP
记忆化搜索
参考资料
AcWing算法基础课,欢迎大家报名,强推噢!!!y总带你感受算法之美!