前言
动态规划,DP(Dynamic Programming),运筹学的一个分支,同时也是求解决策过程最优化问题的过程。它和贪心
一样,不是具体的某一种算法,而是一种思想。
DP的学习和使用,非常地具有挑战性,会使用DP,经常可以写出令人拍案叫绝的代码。所以它也是算法竞赛,数学建模等中的一个热点问题
DP优化是对方程进行等价变换
DP基础,codeslogan
背包问题
[HTML_REMOVED]
$f(i,j)$存的是集合中所有选法的属性,是一个数
[HTML_REMOVED]
0-1背包
每个物品只能选一次,即放入/不放入背包,使利益最大化
状态转移方程:
$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] = 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] = max(f[i-1,j-v],f[i-1,j-2v]+w,f[i-1,j-3v]+2w,…)$
$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]=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!
[HTML_REMOVED]
#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个数,以此类推
[HTML_REMOVED]
[HTML_REMOVED]
#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]$:
s1
的前i
个字符和s2
的前j
个字符的最长公共子序列 - 状态计算:$s_1[i] = s_2[j]$ 和 $s_1[i] != s_2[j]$
当两个字符相等时,容易理解,由$f[i-1][j-1] + 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]$:从第
i
堆石子到第j
堆石子合并所需的代价 - 状态计算:将区间划分成$[i,k]$和$[k+1,j]$,$k = i, i+1, …, j-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_1 \ge n_2 \ge … \ge n_k, k \ge 1$。
我们将这样的一种表示 称为正整数n的一种划分。
现在给定一个正整数n,请你求出n共有多少种不同的划分方法。
输入格式
共一行,包含一个整数n。
输出格式
共一行,包含一个整数,表示总划分数量。
由于答案可能 很大,输出结果请对$10^9+7$取模。
数据范围
$1 \le n \le 1000$
转化为完全背包问题
- 状态表示$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-2i]+…+f[i-1,j-si]$
将j
等价替换为j-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]=f[i-1,j]+f[i,j-i]$
#include <iostream>
using namespace std;
const int N = 1010, mod = 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]) % mod;
cout << f[n] << endl;
return 0;
}
计数类DP
- 状态表示$f[i,j]$:所有数的总和为
i
,并且表示成j
个数的和的个数
- 状态计算:将集合划分成两种情况:
j个数
中最小值为1和最小值大于1 - 最小值为1:$f[i-1,j-1]$
- 最小值大于1:$f[i-j,j]$(每个值都减去1,一共减j,方案数不变)
状态转移方程为
$f[i,j]=f[i-1,j-1]+f[i-j,j]$
最后的结果需要对式求和$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
计数问题
给定两个整数 $a$ 和 $b$,求 $a$ 和 $b$ 之间的所有数字中 $0∼9$ 的出现次数。
-
解法一:暴力枚举
-
解法二:枚举每一位上
x
出现的次数
举例如下
$abcdefg$
x在第4位(d)上出现的次数
- $000∼abc-1$,出现$abc*1000$次,1000为
10^efg的长度
判断的数在最高位时,不用考虑情况1
这里需要讨论一种特殊情况,若判断的x是0出现的次数,则要从001开始,意味着$(abc-1)*1000$
- $abc$
- $d<x$,出现0次
- $d=x$,出现$efg+1$次(000)
- $d>x$,出现1000次(efg:000-999)
另外一种特殊情况是,0不可能在第一位出现,所有求0出现的次数时,应该从数的第二位开始
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int a, b;
// 求10^x
int power10(int x) {
int res = 1;
while (x--) {
res *= 10;
}
return res;
}
// 将num中l到r表示的数提取成一个数
int get(vector<int> num, int l, int r){
int res = 0;
for (int i = l; i >= r; i--) {
res = res * 10 + num[i];
}
return res;
}
// 数位DP
int count(int n, int x) {
// 1到0中不含有任何数
if (!n) return 0;
vector<int> num;
// 将n倒序存放
while (n) {
num.push_back(n % 10);
n /= 10;
}
// 获取n的位数,用于遍历
n = num.size();
int res = 0;
// 遍历x在n的每一位上出现的次数,并作和
// 若x为0,则x必不出现在n的最高位,直接从第二位开始计算
for (int i = n - 1 - !x; i >= 0; i--) {
// 情况一
if (i < n - 1) {
res += get(num, n-1, i+1) * power10(i);
if (!x) res -= power10(i);
}
// 情况二
if (num[i] == x) res += get(num, i-1, 0) + 1;
else if (num[i] > x) res += power10(i);
}
return res;
}
// 暴力解法
int force_count(int n, int x) {
int res = 0;
for (int i = 1; i <= n; i++)
for (int j = i; j; j /= 10)
if (j % 10 == x) res++;
return res;
}
int main() {
while (cin >> a >> b, a || b) {
if (a > b) swap(a, b);
for(int i = 0; i < 10; i++) {
// 以前缀和的方式,求ab区间i出现的次数
cout << count(b, i) - count(a-1, i) << ' ';
}
cout << endl;
}
return 0;
}
状态压缩DP
所谓的状态压缩DP,就是用二进制数保存状态。
通过几道题,得出状态压缩问题有点类似蒙特卡罗。状态压缩DP通过枚举二进制来表示每一状态,和蒙特卡罗中枚举随机数来得到最后的答案非常的相似。
状态压缩位数$N \le 20$,约等于数据量$10^6$
蒙德里安的猜想
- 状态表示$f[i,j]$:
f[i][j]
表示已经将前i -1
列摆好,且从第i−1
列,伸出到第i
列的状态是j
的所有方案数。且j
是一个由二进制表示出来的数,如10010:第1列和第4列有砖 - 状态计算:$f[i,j]+=f[i-1,k]$
状态转移需要满足两个条件:
- 不与前一个状态冲突
(j&k)==0
- 此状态中0的个数
(j|k)
为偶
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 12, M = 1 << N;
bool st[M];
int n, m;
long long f[N][M];
int main() {
while (cin >> n >> m, n || m) {
// 枚举所有奇数个0的状态,并将其设置为false
for (int i = 0; i < 1 << n; i++) {
st[i] = true;
int cnt = 0;
for (int j = 0; j < n; j++) {
if (i >> j & 1) {
if (cnt & 1) st[i] = false;
cnt = 0;
} else cnt++;
}
if (cnt & 1) st[i] = false;
}
memset(f, 0, sizeof f);
f[0][0] = 1;
for (int i = 1; i <= m; i++)
for (int j = 0; j < 1 << n; j++)
for (int k = 0; k < 1 << n; k++)
// 若不与前一个状态冲突且此状态中0的个数为偶
if ((j & k) == 0 && st[j|k]) f[i][j] += f[i-1][k];
cout << f[m][0] << endl;
}
return 0;
}
最短Hamilton路径
- 状态表示$f[i,j]$:走过的点用二进制记录在
i
中,从0
到达j
的最短路径 - 状态计算:$f[i,j]=f[i-{j}, k] + w[k.j]$
二进制数100101意思是经过了第1、3、6点(倒着看)
0->....->k->j
从k
走到j
点需要满足条件:k可以到达j。
不过在DP中,是先枚举出了状态,再去判断这个状态是否满足这个条件,如果满足再进行相应的计算
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 20, M = 1 << N;
int n;
int w[N][N];
int f[M][N];
int main() {
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
cin >> w[i][j];
memset(f, 0x3f, sizeof f);
// 初始化,当在第一个点时,路径为0
f[1][0] = 0;
for (int i = 0; i < 1 << n; i++) // 枚举可能的所有到达情况
for (int j = 0; j < n; j++) // 枚举,当前到达了j点
if (i >> j & 1) // 表示从0可以到达j点
for (int k = 0; k < n; k++) // 枚举点k(j的前一个点)
if ((i - (1 << j)) >> k & 1) // 先减掉目的点j后,需要先能到达k点
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
cout << f[(1 << n)-1][n-1] << endl;
return 0;
}
树形DP
没有上司的舞会
一道非常经典的树形DP问题
- 状态表示$f[i][0],f[i][1]$:0表示当前
i
结点不选,1表示选 - 状态计算:$f[i][0] += max(f[j][0], f[j][1])$ $f[i][1] += f[j][0]$
如果当前结点未选的话,代表未去舞会,那么他的下属就有两种选择,去或不去,选取最大的
如果当前结点被选的话,他的下属只有不去这一种选法
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 6010;
int n;
int h[N], e[N], ne[N], idx;
int happy[N];
bool st[N];
int f[N][2];
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void dfs(int u) {
f[u][1] = happy[u];
for (int i = h[u]; i != -1; i = ne[i]) {
int j = e[i];
dfs(j);
// 上司不去舞会
f[u][0] += max(f[j][0], f[j][1]);
// 上司去舞会
f[u][1] += f[j][0];
}
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> happy[i];
memset(h, -1, sizeof h);
for (int i = 0; i < n-1; i++) {
int a, b;
cin >> a >> b;
st[a] = true;
add(b, a);
}
int root = 1;
while(st[root]) root++;
dfs(root);
cout << max(f[root][0], f[root][1]) << endl;
return 0;
}
记忆化搜索
滑雪
- 状态表示$f[i,j]$:所有从
(i,j)
出发的最长路径 - 状态计算:$f[i,j] = max(f[i,j-1]+1,f[i,j+1]+1,f[i-1,j]+1,f[i+1,j]+1)$
枚举出上下左右的4个点,(i,j)
由它们的最大值转移而来
如果一个点的dp[][]
不为初始化值-1
,那么就说明计算过,不必再次计算。这也是记忆化搜索的一个特点。
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 310;
int n, m;
int f[N][N], h[N][N];
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
int dp(int x, int y) {
// 引用:v相当于f[x][y]
int &v = f[x][y];
if (v != -1) return v;
// 无格可走,最小为1
v = 1;
for (int i = 0; i < 4; i++) {
int a = x + dx[i], b = y + dy[i];
if (a >= 1 && a <= n && b >= 1 && b <= m && h[a][b] < h[x][y])
v = max(v, dp(a, b) + 1);
}
return v;
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> h[i][j]; // 读入高度
// 初始化状态为-1
memset(f, -1, sizeof f);
int res = 0;
// 枚举每一个点
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
res = max(res, dp(i, j));
cout << res << endl;
return 0;
}