一、背包问题
二、线性DP
三、区间DP
四、计数类DP
五、数位统计DP
六、状态压缩DP
七、树形DP
八、记忆化搜索
一、背包问题
(一)01背包问题
1、问题描述及分析
特点:n个物品,每个物品的体积是v[i],价值是w[i] ,背包的容量是m
问题:若每个物品最多只能用一次,且不能超过背包容量,则背包的最大价值是多少?
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。
AcWing 2.01背包问题
题目描述
有N件物品和一个容量V是的背包。每件物品只能使用一次。
第件物品的体积是vI,价值是wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 vi,wi,用空格隔开,分别表示第i件物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
1 ≤ N, V ≤ 1000
1 ≤ vi,wi≤ 1000
输入样式:
4 5
1 2
2 4
3 4
4 5
输出样式:
8
2、二维形式代码
#include<bits/stdc++.h>
using namespace std;
const int N = 1010;
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
// ---------------二维形式---------------
int f[N][N]; // f表示某种状态下的最大价值,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];
3、一维形式(最终版本)
最终版本就是一维形式:
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
// ---------------一维形式---------------
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
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包j是从大到小。
为什么呢?
倒叙遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!
举一个例子:物品0的重量v[0] = 1,价值w[0] = 15
如果正序遍历
f[1] = f[1 - v[0]] + w[0] = 15
f[2] = f[2 - v[0]] + w[0] = 30
此时f[2]就已经是30了,在计算f[2]的时候,w[0]一共加了两次。意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒叙遍历,就可以保证物品只放入一次呢?
倒叙就是先算f[2]
f[2] = f[2 - v[0]] + w[0] = f[1] + w[0] = 15
(dp数组已经都初始化为0)
f[1] = f[1 - v[0]] + w[0] = f[0] + w[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。
(二)完全背包问题
1、问题描述及分析
特点:有 N 种物品和一个容量为 V 的背包,每种物品都有无限件可用。第 i 种物品的体积是 v[i],价值是 w[i] 。也就是从每种物品的角度考虑,与它相关的策略已并非取或不取两种,而是有取 0 件、取 1 件、取 2 件 …等很多种。
问题:求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
f[i][j]的求法,在完全背包问题中是否选择i上,分为两种
一、选择i,且个数是k(可以理解成物品i取1件、取2件、取3件)
1、去掉k个物品i
2、求Max,f[i - 1][j - k * v[i]]
3、在加回来k个物品i,此时为f[i][j] = f[i - 1][j - k * v[i]] + k * w[i]
二、不选择i
1、f[i][j] = f[i - 1][j]
三、求max上面两个f[i][j]
2、朴素做法代码以及优化流程:
const int N = 1010;
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
int f[N][N]; // f表示某种状态下的最大价值,f[i][j]表示在考虑前i个物品后,背包容量不超过j条件下的最大价值
// 读入n,m,以及v,m
cin >> n >> m;
for(int i = 1; i <= n; i ++) cin >> v[i] >> w[i];
// ---------------朴素做法O(n³)~374ms---------------
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 - 1][j], f[i - 1][j - k * v[i]] + k * w[i])
}
// -------------------------------------
我们观察发现f[i,j] = max(f[i-1,j], f[i-1,j-v]+w, f[i-1,j-2v]+2w, f[i-1,j-3v]+3w,......)
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]的除了第一项的所有部分,相比于f[i,j-v]的所有部分,只相差了一个w,所以f[i,j]的表达式可以简写为
f[i,j] = max(f[i-1,j], f[i,j-v] + w),这时可以省略掉第三层k的循环。减少时间复杂度
// ---------------优化做法O(n²)~62ms---------------
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])
}
3、二维形式优化
形式上和01背包差不多,在二维数组表示下,主要差别在
在选择第i物品时,用的是f[i][j - v] + w
,而不是f[i - 1][j - v] + w
完全背包:f[i][j] = max(f[i - 1][j], f[i][j - v[i]] + w[i])
01背包:f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i])
4、一维DP形式(最终版本)
// ---------------一维形式~43ms---------------
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
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背包问题的最终版本代码的区别只有一个:就是遍历j的时候,01背包代码是从m降序到v[i]的,而完全背包问题遍历j的时候,是从v[i]正序到m的。
01背包只能反向是因为它主要用到上一行的数据来更新当前行数据,如果正向遍历,则会修改上一行的数据,出现写后读错误;完全背包只能正向是因为它需要用到当前行的数据更新,如果反向遍历,使用的是上一行的数据,则不符合公式
(三)多重背包问题
1、问题描述及分析
特点:第i 个物品至多有s[i]个。
状态转移方程为:f[i][j] = max(f[i - 1][j], f[i - 1][j - k * v[i]] + k * w[i])
k = 0, 1 , 2, 3, … , si
2、暴力写法二维版本O(n³)
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
int f[N][N]; // f表示某种状态下的最大价值,f[i][j]表示在考虑前i个物品后,背包容量不超过j条件下的最大价值
for(int i = 1; i <= n; i ++)
for(int j = 0; j <= m; j ++)
for(int k = 0; k * v[i] <= j && k <= s[i]; k ++)
{
f[i][j] = max(f[i - 1][j], f[i - 1][j - k * v[i]] + k * w[i])
}
3、二进制优化方式
比如s[i] = 1023.
正常第三层循环k的取值是0,1,2,3,… ,1023
现在我们把k分成10组(1023对应二进制位数),分别代表1,2,4,8,…,512,每组最多被选择一次。我们可以通过对这10组进行任意选择的排列组合构成从0~1023的所有情况。
这10组的每一组是否选择,我们可以看成每一个01背包问题。说通俗一点就是使用10个新的物品,代表了第i个物品,然后通过枚举这10个物品选或不选,来表示第i个物品的所有方案!此时,就将多重背包的第三层k的复杂度,从n,降为了logn。
例子:s = 200,此时分组为1,2,4,8,16,32,64,73。为什么不能有128,因为如果有128,从1,2,4…加到128的值就大于200了,所以不能让分组的数进行排列组合的值的大小最终大于s的值。所以只用在最后补上200 - 64 - 32 - … - 2 - 1 = 73
把第i个有s个物品,分解成logs个新的物品。每个物品是一个单独的01背包问题。因为只有被选或者不被选这两种
随笔:c++的运算速度是一秒差不多1亿条
代码:
#include<bits/stdc++.h>
using namespace std;
const int N = 250000, M = 2010;
// ---------------一维形式---------------
int n, m; // n表示物体个数,m表示背包容量
int v[N], w[N]; // v表示体积,w表示价值
int f[N]; // f[j]表示背包容量为j条件下的最大价值
int main()
{
cin >> n >> m; // 输入物体个数n和背包容量m
int cnt = 0;
for(int i = 1; i <= n; i++)
{
int a, b, s;
cin >> a >> b >> s; // a代表体积v,b代表价值w,s代表第i个物品的可拿取个数
// 读入物品个数时顺便打包
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;
return 0;
}
只看代码可能有点难理解。举个例子,输入样例为
4 5
1 2 3
2 4 1
3 4 3
4 5 2
输出样例为
10
我们现在分析
首先一共有4个物品,背包容量为5。
s[1] = 3,将被分解成[1,2]两个01背包问题。存储到数组中:
v[0] = 1 * 1, w[0] = 1 * 2;
v[1] = 2 * 1, w[1] = 2 * 2;
s[2] = 1,分组为[1],共1个01背包问题,存储到数组中:
v[2] = 1 * 2, w[2] = 1 * 4;
s[3] = 3,分组为[1,2],共2个01背包问题,存储到数组中:
v[3] = 1 * 3, w[3] = 1 * 4;
v[4] = 2 * 3, w[4] = 2 * 4;
s[4] = 2,分组为[1,2],共2个01背包问题,存储到数组中:
v[3] = 1 * 4, w[3] = 1 * 5;
v[4] = 2 * 4, w[4] = 2 * 5;
核心:重点体会这个思路,s为3的时候本来需要遍历三次。但是将他分成二进制
来表示的话,也就是分解成[1(2的0次方),2(2的1次方)]两组。此时将这两组
看成01背包问题,只存在被选或者不被选,那么通过每一组的选与不选的不同组
合就可以表示s的取值为0.1.2.3的所有情况。从而完成降低时间复杂度!
(四)分组背包问题
1、问题描述及分析
特点:有 N 组物品和一个容量是 V 的背包。每组物品有若干个,同一组内的物品最多只能选一个。
问题:每件物品的体积是 v[i][j],价值是 w[i][j],其中 i 是组号,j 是组内编号。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。求最大价值是多少?
2、模板代码
int n; // 物品总数
int m; // 背包容量
int v[N][S]; // 重量 v[i][j]代表
int w[N][S]; // 价值
int s[N]; // 各组物品种数
cin >> n >> m;
// 读入数据
for (int i = 1; i <= n; i ++ )
{
cin >> s[i]; // 第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 >= 1; j -- ) // 从大到小枚举所有体积
for (int k = 0; k < s[i]; k ++ )
if (v[i][k] <= j) // 第i组第k个的体积小于等于
f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
cout << f[m] << endl;
二、线性DP
(一)数字三角形
898.数字三角形
题目描述
给定一个如下图所示的数字三角形,从顶部出发,在每一结点可以选择移动至其左下方的结点或移动至其右下方的结点,一直走到底层,要求找出一条路径,使路径上的数字的和最大。
输入格式
第一行包含整数n,表示数字三角形的层数
接下来n行,每行包含若干整数,其中第i行表示数字三角形第i 层包含的整数
输出格式
输出一个整数,表示最大的路径数字和。
数据范围
1 ≤ n ≤ 100
-10000 ≤ 三角形中的整数 ≤ 10000
输入样式:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出样式:
30
分析:
代码:
#include < bits/stdc++.h>
using namespace std;
// 自顶向下(未压缩`f`)
const int N =510, INF = 1e9;
int n;
int a[N][N]; // a来存储每个坐标的点
int f[N][N]; // f[i][j]代表从起点出发到达(i,j)这个坐标的时候的路径的权重之和的最大值
int main()
{
cin >> n;
for (int i = 1; i <= n; i ++ ) // 行数从1开始
for (int j = 1; j <= i; j ++ ) // 列数也从1开始
cin >> a[i][j];
// 初始化
for (int i = 0; i <= n; i ++ )
for (int j = 0; j <= i + 1; j ++ ) // 每一行要多初始化一个元素,到i+1
f[i][j] = -INF;
f[1][1] = a[1][1]; // 初始化第一个f
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 ++ ) // 答案在最后一行中判断最大的f是哪一个
{
res = max(res, f[n][j]);
}
}
计算DP题目的时间复杂度:
O(状态 * 转移)= O ( n * n)
(二)最长上升子序列
1、问题描述及分析
题目描述
给定一个长度为N的数列,求数值严格单调递增的子序列的长度最长是多少。
输入格式
第一行包含整数N。
第二行包含N个整数,表示完整序列。
输出格式
输出一个整数,表示最大长度。
数据范围
1 ≤ N ≤ 1000
-10^9 ≤ 数列中的整数 ≤ 10^9
输入样式:
7
3 1 2 1 8 5 6
输出样式:
4
2、朴素做法
#include < bits/stdc++.h>
using namespace std;
const int N = 510;
int n;
int a[N], f[N]; // a[i]代表第i个元素;f[i]代表到第i个元素为止的最长上升子序列的长度
int g[N]; // g[k]代表第k个元素的上一个元素(按照最长上升子序列顺序)的索引号是什么——————可以用来记录这个最长上升子序列
int main()
{
cin >> n;
for(int i = 1; i <= n; i ++) cin >> a[i];
// 朴素法 --------------------------------------------------------------------
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]);
// 朴素法如何存储最长上升子序列--------------------------------------------------------------------
for (int i = 1; i <= n; i ++ )
{
f[i] = 1; // 只有a[i]一个数
for (int j = 1; j < i; j ++ )
if (a[j] < a[i])
if (f[i] < f[j] + 1)
{
f[i] = f[j] + 1;
g[i] = j;
}
}
// 输出最长上升子序列
int k = 1
for(int i = 1; i <= n; i ++ )
if(f[k] < f[i]) // 记录最大序列长度
k = i;
for(int i = 0, len = f[k]; i < len; i ++)
{
cout << a[k];
k = g[k]; // 因为g[k]存储的是上一个
}
return 0;
}
3、二分优化
// 二分优化--------------------------------------------------------------------
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;
4、yxc二分优化
// 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);
(三)最长公共子序列
1、问题描述及分析
给定长度分别为N和M的字符串A和B,求即是A的子序列又是B的子序列的字符串长度最长是多少。
输入格式
第一行包含两个整数N和M
第二行包含一个长度为N的字符串,表示字符串A
第三行包含一个长度为M的字符串,表示字符串B
字符串均有小写字母构成
输出格式
输出一个整数,表示最大长度
分析
这里需要注意,00和01和10和11的子序列的关系,并不是完全互斥的,而是有重复的。