一、背包问题
二、线性DP
三、区间DP
四、计数类DP
五、数位统计DP
六、状态压缩DP
七、树形DP
八、记忆化搜索
一、背包问题
01背包问题
n个物品,每个物品的体积是vi ,价值是wi ,背包的容量是m mm
若每个物品最多只能装一个,且不能超过背包容量,则背包的最大价值是多少?
1、状态表示f(i,j)——需要几枚(有可能是i,或者i、j,或者i、j、t)来表示这个状态,如这里的条件是体积和包含前i个物品,于是我们可以把这两个作为状态中的i,j。而f(i,j)的值表达的则是他的属性,即这里的最大价值
集合——f(i,j)表示的是什么状态,i和j是什么,需要几个参数
属性——存的数集合的属性——如最大价值,最小代价等等
2、状态计算——如何进行状态转移
集合划分,如何把当前f(i,j)这个集合划分成更小的集合表示
第一种不含第i件物品,所以此时f(i,j) = f(i - 1, j)
第二种包含第i件物品,所以此时f(i,j)= f(i - 1,j - vi)+ wi 由于当前选法都包含第i件物品,所以我们可以去掉第i件物品,此时的他们的最大最小的排序也不会发生变化,所以找的就是在在不超过第i-1件物品内,体积不超过j-vi的最大值
最后的f(i,j)其实是他们两种情况取一个max
模板:
int n; // 物品总数
int m; // 背包容量
int v[N]; // 重量
int w[N]; // 价值
// ---------------二维形式---------------
int f[N][M]; // f[i][j]表示在考虑前i个物品后,背包容量为j条件下的最大价值
for(int i = 1; i <= n; ++i)
for(int j = 1; j <= m; ++j)
if(j < v[i]) f[i][j] = f[i-1][j]; // 当前重量装不进,价值等于前i-1个物品
else f[i][j] = max(f[i-1][j], f[i-1][j-v[i]] + w[i]); // 能装,需判断
cout << f[n][m];
// ---------------一维形式---------------
int f[M]; // f[j]表示背包容量为j条件下的最大价值
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]; // 注意是m不是n
完全背包问题
每个物品可以取任意个
题目:
有 N 种物品和一个容量为 V 的背包,每种物品都有无限件可用。第 i 种物品的费用是 c[i],价值是 w[i] 。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
这个问题非常类似于 01背包问题,所不同的是每种物品都有无限件。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取 0 件、取 1 件、取 2 件 ......等很多种。如果仍然按照解 01背包时的思路,令 f[i][v] 表示前 i 种物品恰放入一个容量为 v 的背包的最大权值。仍然可以按照每种物品不同的策略写出状态转移方程:
这跟 01背包问题一样有 O(VN) 个状态需要求解,但求解每个状态的时间已经不是常数了,求解状态 f[i][v] 的时间是 ,总的复杂度可以认为是
,是比较大的。
代码:
#include <bits/stdc++.h>
#define N 1002
using namespace std;
int f[N][N];
int w[N];
int v[N];
int main()
{
int n,W; cin >> n >> W;
for(int i=1;i<=n;i++)
{
cin >> w[i] >> v[i];
}
for ( int i = 1; i <= n; i++ )
{
for ( int j = 0; j <= W; j ++)
{
for (int k = 0; k*w <= j; k++)
{
f[i][j] = max(f[i-1][j],f[i-1][j-w[i]*k] + v[i]*k);
}
}
}
cout << f[n][W] <<endl;
return 0;
}
空间复杂度优化后的代码:
#include <bits/stdc++.h>
#define N 1002
using namespace std;
int f[N];
int w[N];
int v[N];
int main()
{
int n,W; cin >> n >> W;
for(int i=1;i<=n;i++)
{
cin >> w[i] >> v[i];
}
for ( int i = 1; i <= n; i++ )
{
for ( int j = W; j >= 0; j --)
{
for (int k = 0; k*w <= j; k++)
{
f[j] = max(f[j],f[j-w[i]*k] + v[i]*k);
}
}
}
cout << f[W] <<endl;
return 0;
}
一个简单有效的优化
完全背包问题有一个很简单有效的优化,是这样的:若两件物品 i 、j 满足 且 ,则将物品 j 去掉,不用考虑。这个优化的正确性显然:任何情况下都可以将价值小费用高的 j 换成物美价廉的 i ,得到至少不会更差的方案。对于随机生成的数据,这个方法往往会大大减少物品的件数,从而加快速度。这个优化可以简单的 Θ(N2) 实现,一般都可以承受。
然而这个并不能改善最坏情况的复杂度,因为有可能特别设计的数据可以一件物品也去不掉。另外,针对背包问题而言,比较不错的一种方法是:首先将费用大于 V 的物品去掉,然后使用类似计数排序的做法,计算出费用相同的物品中价值最高的哪个,可以 Θ(V + N) 地完成这个优化。
转化为01背包问题求解
既然 01背包问题是最基本的背包问题,那么我们可以考虑把完全背包问题转化为 01背包问题来解。最简单的想法是,考虑到第 i 种物品最多选 V/c[i] 件,于是可以把第 i 种物品转化为 V/c[i] 件费用及价值均不变的物品,然后求解这个 01背包问题。
这样完全没有改进基本思路的复杂度,但毕竟给了我们将完全背包问题转化为 01背包问题的思路:将一种物品拆成多件物品。
更高效的转化方法是:把第 i 种物品拆成费用为 、价值为 的若干件物品,其中 k 满足 。这是二进制的思想,因为不管最优策略选几件第 i 种物品,总可以表示成若干个 件物品的和。这样把每种物品拆成 件物品,是一个很大的改进。
模板:
int n; // 物品总数
int m; // 背包容量
int v[N]; // 重量
int w[N]; // 价值
// ---------------二维形式---------------
// 未优化
int f[N][M]; // f[i][j]表示在考虑前i个物品后,背包容量为j条件下的最大价值
for (int i = 1; i <= n; i++)
for (int j = 1; 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]);
// 已优化
int f[N][M]; // f[i][j]表示在考虑前i个物品后,背包容量为j条件下的最大价值
for(int i = 1; i <= n; ++i)
for(int j = 1; j <= m; ++j)
if(j < v[i]) f[i][j] = f[i-1][j]; // 当前重量装不进,价值等于前i-1个物品
else f[i][j] = max(f[i-1][j], f[i][j-v[i]] + w[i]); // 能装,需判断
cout << f[n][m];
// ---------------一维形式---------------
int f[M]; // f[j]表示背包容量为j条件下的最大价值
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]; // 注意是m不是n
形式上和01背包差不多,在二维数组表示下,主要差别在
在选择第i物品时,用的是f[i][j-v]+w,而不是f[i-1][j-v]+w
上述条件决定了在每次迭代时,必须正向遍历,而不是反向遍历
在一维数组表示下,主要差别只表现为迭代的顺序(正向或反向)
在一维数组表示下,01背包只能反向是因为它主要用到上一行的数据来更新当前行数据,如果正向遍历,则会修改上一行的数据,出现写后读错误;完全背包只能正向是因为它需要用到当前行的数据更新,如果反向遍历,使用的是上一行的数据,则不符合公式
多重背包问题
第i 个物品至多拿si个。
二进制优化
将k化成二进制,每个二进制的代表数代表一个新的物体,用于对应倍数的体积和价钱
将k个,化成多个新物体
同时包含不同堆的情况,可以很好的表示所有可能
例子,不能选128,是因为这样可以表示超出200的数,所以选择200 - 127 = 73 ,这样可以表示。
// 读入物品个数时顺便打包
int k = 1; // 当前包裹大小
while (k <= s)
{
cnt ++ ; // 实际物品种数
v[cnt] = a * k;
w[cnt] = b * k;
s -= k;
k *= 2; // 倍增包裹大小
}
if (s > 0)
{
// 不足的单独放一个,即C
cnt ++ ;
v[cnt] = a * s;
w[cnt] = b * s;
}
n = cnt; // 更新物品种数
// 转换成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]);
cout << f[m] << endl;
用二进制优化后,问题转换成01背包问题
分组背包问题
有 N 组物品和一个容量是 V 的背包。
每组物品有若干个,同一组内的物品最多只能选一个。
每件物品的体积是 vij,价值是 wij,其中 i 是组号,j 是组内编号。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
输出最大价值
模板
int n; // 物品总数
int m; // 背包容量
int v[N][S]; // 重量
int w[N][S]; // 价值
int s[N]; // 各组物品种数
// 读入数据
for (int i = 1; i <= n; i ++ )
{
cin >> s[i];
for (int j = 1; j <= s[i]; j ++ )
cin >> v[i][j] >> w[i][j];
}
// 处理数据
for (int i = 1; i <= n; i ++ )
for (int j = m; j >= 1; j -- )
for (int k = 1; 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;
二、线性DP
数字三角形
代码:
// 自顶向下(未压缩`f`)
const int INF = 1e9;
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 ++ )
for (int j = 1; j <= i; j ++ )
f[i][j] = max(f[i - 1][j - 1] + a[i][j], f[i - 1][j] + a[i][j]);
int res = -INF;
for (int j = 1; j <= n; j ++ ) res = max(res, f[n][j]);
实际上可压缩f,此时只能反向遍历行
还可自底向上实现,若压缩,只能正向遍历行
可以用0x80初始化f,使得元素都小于-2e9
时间复杂度= O ( 状 态 × 转 移 ) = O ( n *n)
最长上升子序列
代码:
// 朴素法
for (int i = 1; i <= n; i ++ )
{
f[i] = 1; // 只有a[i]一个数
for (int j = 1; j < i; j ++ )
if (a[j] < a[i])
f[i] = max(f[i], f[j] + 1);
}
int res = 0;
for (int i = 1; i <= n; i ++ ) res = max(res, f[i]);
// 二分优化
vector<int>stk;//模拟堆栈
stk.push_back(arr[0]);
for (int i = 1; i < n; ++i) {
if (arr[i] > stk.back())
stk.push_back(arr[i]); // 如果该元素大于栈顶元素,将该元素入栈
else
*lower_bound(stk.begin(), stk.end(), arr[i]) = arr[i]; // 替换掉第一个大于或者等于这个数字的那个数
}
cout << stk.size() << endl;
// yxc二分优化
int len = 0; // 最长上升子序列长度(数组q的长度)
for (int i = 0; i < n; i ++ )
{
// 在数组q中二分查找第1个>= a[i]的数(结果)
int l = 0, r = len;
while (l < r)
{
int mid = l + r + 1 >> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
len = max(len, l + 1); // q[l] < a[i] < q[l + 1]
q[l + 1] = a[i];
}
printf("%d\n", len);
朴素法时间复杂度= O ( 状 态 × 转 移 ) = O ( n × n )
改进(贪心+二分)
令数组q保存长度为i的上升子序列末尾元素的最小值,例如125和123优先保存123的3,因为它更能接上的后缀种类更多
q[]是单调递增的,否则存在q[i-1]>q[i],说明长度为i的上升子序列的最小末尾元素比长度为i-1的还小,这与q[i-1]的定义不符
为了使上升子序列最长,应在q[]中找到<a[i]的最大q[j],使得q[j]<a[i]<q[j+1],此时子序列的长度为j+1,且q[j+1]=a[i]。这步用整数二分实现
改进后,时间复杂度变为O ( n log n )
最长公共子序列
给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。
代码:
char a[N], b[N];
int f[N][N];
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);
}
printf("%d\n", f[n][m]);
最短编辑距离
给定两个字符串A和B,只允许对A进行字符插入,字符删除和字符替换,求把A变成B的最少操作次数
代码:
// 初始化边界
for (int i = 0; i <= m; i ++ ) f[0][i] = i; // 把B变成空串需要删除字符的次数
for (int i = 0; i <= n; i ++ ) f[i][0] = i; // 把空串B扩充成A需要插入字符的次数
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= m; j ++ )
{
f[i][j] = min(f[i - 1][j] + 1, f[i][j - 1] + 1);
if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1]);
else f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
}
printf("%d\n", f[n][m]);
说明
三、区间DP
石子合并:相邻两堆石子可以合并,代价为二者石子数的和,求最小代价
代码:
int s[N]; // 前缀和
int f[N][N];
for (int i = 1; i <= n; i ++ ) s[i] += s[i - 1]; // 初始化前缀和
for (int len = 2; len <= n; len ++ ) // len=1时不合并(类似归并排序的merge)
// 固定窗口大小,从小到大遍历
for (int i = 1; i + len - 1 <= n; i ++ )
{
// 固定窗口左端点,则可确定窗口右端点,注意边界
int l = i, r = i + len - 1;
// 窗口内划分
f[l][r] = 0x7f7f7f7; // 初始化为无穷大
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]);
}
printf("%d\n", f[1][n]);
四、计数类DP
把n拆分成1~n的和的方案数(不考虑顺序)
完全背包解法
可以看做有n种物品,第i 种物品的体积为i,背包的容量为n,每个物品可以拿无数次,求装满背包的方案数
假设当前背包容量为j ,则第i 个物品至多装$k=\lfloor \frac{j}{i} \rfloor $个
代码:
// 未压缩f
f[0][0] = 1;
for (int i = 1; i <= n; i ++)
for (int j = 0; j <= n; j ++)
if (j >= i) f[i][j] = (f[i - 1][j] + f[i][j - i]) % mod;
else f[i][j] = f[i - 1][j];
cout << f[n][n] << endl;
// 压缩f
f[0] = 1;
for (int i = 1; i <= n; i ++ )
for (int j = i; j <= n; j ++ )
f[j] = f[j] + f[j - i];
cout << f[n] << endl;
其它算法
代码:
f[1][1] = 1;
for (int i = 2; 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;
五、数位统计DP
多用于统计数中出现某个数的次数
关键:分类讨论
思路关键:
1、碰到求多个区间内的个数啊值啊——很有可能会可以用前缀和,所以问题就简化为从1-n的个数的问题
2、考虑从1到n的个数的问题,就可以用数学的排列组合取思考。
代码:
/*
001~abc-1, 999
abc
1. num[i] < x, 0
2. num[i] == x, 0~efg
3. num[i] > x, 0~999
*/
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;
}
int power10(int x)
{
int res = 1;
while (x -- ) res *= 10;
return res;
}
int count(int n, int x)
{
if (!n) return 0; // 特判
// 拆分
vector<int> num;
while (n)
{
num.push_back(n % 10);
n /= 10;
}
n = num.size();
// 核心
int res = 0;
for (int i = n - 1 - !x; i >= 0; i -- ) // 当x=0时,从左起第2位开始遍历
{
// 考虑左起第1位时不存在abc,跳过
if (i < n - 1)
{
res += get(num, n - 1, i + 1) * power10(i);
if (!x) res -= power10(i); // x=0,abc不能全为0,排除这种情况
}
// 尽管左起第1位不存在abc,但存在efg,因此保留这部分
if (num[i] == x) res += get(num, i - 1, 0) + 1;
else if (num[i] > x) res += power10(i);
}
return res;
}
for (int i = 0; i <= 9; i ++ )
cout << count(b, i) - count(a - 1, i) << ' '; // 类似前缀和思想
六、状态压缩DP
二进制表示状态——状态压缩
状态压缩dp——状态表示中,其中有一维是二进制状态
状态压缩问题——n不能太大,n<=20,2的n次方已经是极限了
状态压缩dp中状态表示里有一维为二进制状态表示,另外一维为其他表示
状态计算:
1、他可能由几种状态转变过来
2、假设由k状态转变过来,k状态是否合法(是否与当前状态有冲突,要满足k状态为当前状态前驱的条件)
3、判断题目是求和还是最大或者最小
蒙德里安的梦想
代码:
最短Hamilton路径
给定一张 n 个点的带权无向图,点从 0 ~n − 1 标号,求起点 0 到终点 n − 1 的最短Hamilton路径。 Hamilton路径的定义是从 0 到n − 1 不重不漏地经过每个点恰好一次。
将f[i][j]所表示的集合中的所有路线,按倒数第二个点分成若干类,其中第k类是指倒数第二个点是k的所有路线。那么f[i][j]的值就是每一类的最小值,再取个min。而第k类的最小值就是f[i - (1 << j)][k] + w[k][j]。
从定义出发,最后f[(1 << n) - 1][n - 1]就表示所有“经过了所有点,且最后位于点n-1的路线”的长度的最小值,也就是我们要求的答案。
代码:
memset(f, 0x3f, sizeof f); // 初始化为无穷大
f[1][0] = 0; // 表示只有起点0且最后位于起点0的路线的长度是0,此时点集i的最后一位是1,其余为0,因为点集只有起点0,故i=1
for (int i = 0; i < 1 << n; i ++ ) // 穷举所有可能的点集
for (int j = 0; j < n; j ++ ) // 从当前点集找一个点(二进制串中位为1的位置)
if (i >> j & 1)
for (int k = 0; k < n; k ++ ) // 从当前点集找另外一个点(可以和之前找的相同)
if (i >> k & 1)
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]); // 尝试从后找的点到达点j
cout << f[(1 << n) - 1][n - 1]; // 所有点都在点集,且终点是n-1
最短哈密顿距离
哈密顿距离,通过所有点且不重复的路径。
代码:
#include <cstring>
#include <iostream>
#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);
f[1][0] = 0;
for (int i = 0; i < 1 << n; i ++ )
for (int j = 0; j < n; j ++ )
if (i >> j & 1)
for (int k = 0; k < n; k ++ )
if (i >> k & 1)
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
cout << f[(1 << n) - 1][n - 1];
return 0;
}
七、树形DP
1、题目的信息结构为树形
2、树形搜索更新状态
3、状态一般表示为f(当前节点,是否包含当前节点)也可以是其他状态
4、根据子节点和父节点关系更新状态
给定一个带结点值的树,求一个结点集合,使得集合里任意两个结点都不相邻,且结点值的和最大
代码:
int n;
int h[N], e[N], ne[N], idx;
int happy[N];
int f[N][2];
bool has_fa[N]; // 标记是否存在父节点
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; i = ne[i])
{
int j = e[i]; // 子结点
dfs(j); // 递归子结点
// 状态转移
f[u][1] += f[j][0];
f[u][0] += max(f[j][0], f[j][1]);
}
}
// 核心
memset(h, -1, sizeof h); // 初始化邻接表头指针
// 读入树结构
for (int i = 0; i < n - 1; i ++ )
{
int a, b;
scanf("%d%d", &a, &b);
add(b, a); // 尽管是无向图,但只需要保留一条边(上司指向下属)
has_fa[a] = true; // 标记存在父节点
}
// 找树根,不存在父节点的就是树根
int root = 1;
while (has_fa[root]) root ++ ;
dfs(root); // 从根节点开始遍历
printf("%d\n", max(f[root][0], f[root][1]));
说明
- 使用邻接表表示树
- DFS+动态规划
八、记忆化搜索
记忆化搜索
1、用f【】【】来记录状态下的搜索结果
2、记录该状态是否被搜索过——可以给f【】【】赋初值为-1,如果没有则为-1
3、搜索——递推的方式
4、搜索到结果返回的时候,两者间状态的关系,即父节点和子节点对于f数组的关系
给一个矩阵,求一条路径,使得它的长度最长,且路径上的值是递减的。(只能往上下左右移动)
记忆化搜索指保存中间结果,避免重复计算,用空间换时间
代码
int g[N][N];
int f[N][N];
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
int dp(int x, int y)
{
if (f[x][y] != -1) return f[x][y]; // 已经计算过,不必重复计算(记忆化搜索)
f[x][y] = 1;
for (int i = 0; i < 4; i++) {
int a = x + dx[i], b = y + dy[i];
if (g[a][b] < g[x][y])
f[x][y] = max(f[x][y], dfs(a, b) + 1);
}
return f[x][y];
}
memset(g, 0x3f, sizeof g); // 边界为无穷大,不能滑到,可以省去边界判断
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));
关键:很多时候对于状态的初始化!!!!
考虑如果只有一个的时候的情况或者边界的情况,或者最大最小值的影响