介绍
动态规划的核心将原问题 视作 若干个重叠子问题 逐层递进,每个子问题的求解过程都构成一个”阶段"。在完成前一个阶段的以后,下个阶段根据本阶段的答案计算出新的结果。每个阶段都有利用之前阶段的答案,因此有个很好听的名字叫做 状态转移 。
上文的介绍看烦了吧,我们举个有趣的例子:
老王有三个孩子,王大,王二,王三,他们的年龄分别是a,b,c,老王去世了很久,年龄不再发生变化。在2021的一天,三个孩子说,我的父亲是我们三个年龄之和。那么在这一年,老王的年龄为 a+b+c,我们通过三个孩子的年龄之和计算得到了老王的年龄,三个孩子的年龄可以认为是一种状态,通过三个状态的结果计算出另外一个状态(老王的年龄),这就是状态转移。老王的年龄被我们拆分成三个孩子年龄之和,老王的年龄是我们要求的问题,三个孩子的年龄就是子问题。
上文说,每个阶段都利用之前的阶段的答案。比如当我们在进行某一阶段的计算时,需要利用的之前阶段(子问题)的答案来计算,因此需要确保一个条件,确保(之前阶段)子问题的答案不会再发生变化,这个条件也有一个很好听的名字——”无后向性”。
再举个例子:
2022年,三个孩子年龄都+1,我们就无法算出老王的正确年龄了,如果我们想一直通过三个孩子的年龄之和得到老王的年龄,那么必须确保孩子的年龄再也不会发生变化,也就是上文所说的,“无后向性”。
典型例题
据说动态规划至少刷不同类型的100题才算入门~
(本文章将会持续更新题解和题目链接——2021.9.12)
各种入门题目类型:由简单到难~
- 基础dp问题混合
- 背包问题
- 01背包
- 完全背包
- 多重背包
- 混合背包
- 二位费用背包
- 分组背包
- 有依赖背包
- 背包求解方案数
- 背包问题求具体方案
- 序列dp问题
- 序列dp问题
基础dp问题混合
数字三角形
设
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]为从
(
i
,
j
)
(i,j)
(i,j)开始向下的最大值之和
从下到上递推,对于点
i
.
j
i.j
i.j,其状态转移方称为
d
p
[
i
]
[
j
]
=
a
[
i
]
[
j
]
+
m
a
x
(
d
p
[
i
+
1
]
[
j
]
,
d
p
[
i
+
1
]
[
j
+
1
]
)
dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1])
dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1])。
#include<bits/stdc++.h>
typedef long long ll;
using namespace std;
const int N=1005;
int a[N][N];
int dp[N][N];//也可以用降维节约空间
int main() {
int r;
cin>>r;
for(int i=1;i<=r;i++){
for(int j=1;j<=i;j++){
cin>>a[i][j];
}
}
for(int i=r;i>=0;i--){
for(int j=1;j<=i;j++){
dp[i][j]=a[i][j]+max(dp[i+1][j],dp[i+1][j+1]);
}
}
cout<<dp[1][1];
return 0;
}
挖地雷
本着求什么设什么的小技巧,设
d
p
[
i
]
dp[i]
dp[i]从
i
i
i出发挖的数字最大之和。
所以每次遍历的时候,找前面和自己连通的
j
j
j,然后递推即可,但是需要维护最后的答案,因此设置
d
p
[
N
]
[
2
]
dp[N][2]
dp[N][2]
d
p
[
i
]
[
0
]
dp[i][0]
dp[i][0]代表 从
i
i
i出发挖的数字最大之和,
d
p
[
i
]
[
1
]
dp[i][1]
dp[i][1]代表递推上一个的阶段下标。
全程按顺序进行,保证无后向性。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=25;
int g[N][N];
int a[N],v[N];
int n,mx;
int dp[N][2];
int reidx=0;
int main() {
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
for(int i=1;i<n;i++){
for(int j=i+1;j<=n;j++){
int x;
cin>>x;
g[i][j]=x;
}
}
for(int i=1;i<=n;i++){
dp[i][0]=a[i];
dp[i][1]=i;
for(int j=1;j<i;j++){
if(g[j][i]){
if(dp[j][0]+a[i]>dp[i][0]){
dp[i][0]=dp[j][0]+a[i];
dp[i][1]=j;
}
}
}
if(dp[i][0]>mx){
mx=dp[i][0];
reidx=i;
}
mx=max(mx,dp[i][0]);
}
vector<int>res;//倒序取出答案
while(1){
res.push_back(reidx);
if(reidx==dp[reidx][1])break;
reidx=dp[reidx][1];
}
for(int i=(int)res.size()-1;i>=0;i--){
if(i!=(int)res.size()-1){
cout<<" ";
}
cout<<res[i];
}
puts("");
cout<<mx;
return 0;
}
5倍经验日
01背包的变式,不选择获得 x x x,而选择就会获得更多,获得 x 2 x2 x2,所以我们可以将不选择的经验累加到变量里,然后将 x 2 − = x x2-=x x2−=x,这样就转化成01背包了
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e3+5;
ll w[N],v[N],lo[N],dp[N];
int main() {
int n,m;
cin>>n>>m;
int cnt=0;
for(int i=1;i<=n;i++){
cin>>lo[i]>>v[i]>>w[i];
v[i]-=lo[i];
cnt+=lo[i];
}
for(int i=1;i<=n;i++) {
for (int j = m; j >= w[i]; j--) {
dp[j] = max(dp[j - w[i]] + v[i], dp[j]);
}
}
cout<<(dp[m]+cnt)*5;
return 0;
}
过河卒
将马控制的点位标记一下,对于每个格子其步数只能从上或左转而来,直接按顺序遍历的时候转移一下即可。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=22;
int book[N][N];
ll dp[N][N]={1};
void f(int x,int y){
if(x>=0 && y>=0)
book[x][y]=1;
}
int main() {
int bx,by,mx,my;
cin>>bx>>by>>mx>>my;
f(mx,my);
f(mx+2,my+1),f(mx+1,my+2),f(mx-1,my+2),f(mx-2,my+1);
f(mx-2,my-1),f(mx-1,my-2),f(mx+1,my-2),f(mx+2,my-1);
for(int i=0;i<=bx;i++){
for(int j=0;j<=by;j++){
if(book[i][j])continue;
if(j && !book[i][j-1])dp[i][j]+=dp[i][j-1];//转移 注意控制边界
if(i && !book[i-1][j])dp[i][j]+=dp[i-1][j];
}
}
cout<<dp[bx][by];
return 0;
}
最大约数和
用普通的筛法预处理一下,这题就变成了01背包
#include<bits/stdc++.h>
using namespace std;
const int N =1005;
int a[N],b[N];
int dp[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
for (int j = i * 2; j <= n; j += i) {
a[j] += i;
}
}
for (int i = 1; i <= n; i++) {
for (int j = n; j >= i; j--) {
dp[j] = max(dp[j - i] + a[i], dp[j]);
}
}
cout<<dp[n];
}
序列dp问题
导弹拦截(最长(不)上升子序列
有两问:
- 最长不上升子子序列长度
- 最少需要多少套系统才能拦截完
第一问就是实打实的求解最长不上升子序列
传统的O(n^2)解法就不多赘述了,我们用二分去优化此dp的过程,使时间复杂度到达O(nlogn)
我们可以维护好一个不上升序列,当我们遇到新数字的时候,就根据二分查找替换其在序列里的位置。
比如数据:4 2 2 3 1
维护过程 []
- [4]
- [4,2]
- [4,2,2]
- [4,3,2]
- [4,3,2,1]
我们看第四步,3把2的位置替换了,为了构造出更紧密的序列,使得后面的机会更大。实际上对于3而言,它的序列是[4,3],后面的数据和它无关。当后面有比3更小的数据,就可以认为是跟在3后面,而不是跟在2后面。
代码很短,但是细节要细细体会。
#include<bits/stdc++.h>
using namespace std;
typedef long long int ll;
const int N=1e5+5;
int a[N],n;
int dp1[N],le;//用来存储序列
int main(){
while(cin>>a[n++]){};
n--;
dp1[0]=a[0],le++;
for(int i=1;i<n;i++){
int j=upper_bound(dp1,dp1+le,a[i],greater<int>())-dp1;
dp1[j]=a[i];
le=max(le,j+1);
}
cout<<le;//最长不上升子序列长度
}
第二题就是求解最长上升子序列,思路和上面一样~ 完整代码如下:
#include<bits/stdc++.h>
using namespace std;
typedef long long int ll;
const int N=1e5+5;
int a[N],n;
int dp1[N],le;
int sys[N],idx;
int main(){
while(cin>>a[n++]){};
n--;
//1.最大不上升子序列 找到小于目标值 然后替换;
dp1[0]=a[0],le++;
sys[idx++]=a[0];
for(int i=1;i<n;i++){
int j=upper_bound(dp1,dp1+le,a[i],greater<int>())-dp1;
int j2=lower_bound(sys,sys+idx,a[i])-sys;
dp1[j]=a[i];
sys[j2]=a[i];
le=max(le,j+1);
idx=max(idx,j2+1);
}
cout<<le<<endl<<idx;
}
滑雪
二维最长不递减子序列
每个点位可以从上下左右4个方向比自己大的数字递推而来,但是为了保证无后向性,需要从最大的数字开始遍历。
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int,int> pii;
const int N=1005;
int a[N][N];
int dp[N][N];
int main() {
int r,c;
int res=0;
cin>>r>>c;
vector<pair<int,pii>>v;
for(int i=1;i<=r;i++){
for(int j=1;j<=c;j++){
scanf("%d",&a[i][j]);
v.push_back({a[i][j],{i,j}});
}
}
sort(v.begin(),v.end(),greater<pair<int,pii>>());
for(auto i:v){
int value=i.first,x=i.second.first,y=i.second.second;
int mx=0;
if(a[x-1][y]>a[x][y])mx=max(mx,dp[x-1][y]);
if(a[x][y-1]>a[x][y])mx=max(mx,dp[x][y-1]);
if(a[x+1][y]>a[x][y])mx=max(mx,dp[x+1][y]);
if(a[x][y+1]>a[x][y])mx=max(mx,dp[x][y+1]);
dp[x][y]=mx+1;
res=max(res,dp[x][y]);
}
cout<<res<<endl;
return 0;
}
最大子段和
设
d
p
[
i
]
dp[i]
dp[i]为:考虑
[
0
,
i
]
[0,i]
[0,i],并且必须包括元素
i
i
i的最大子段之和。
元素数组
r
[
i
]
r[i]
r[i]。
那么有如下的状态转移
d
p
[
i
]
=
r
[
i
]
(
d
p
[
i
−
1
]
<
0
)
dp[i]=r[i](dp[i-1]<0)
dp[i]=r[i](dp[i−1]<0)
d
p
[
i
]
=
d
p
[
i
−
1
]
+
r
[
i
]
(
d
p
[
i
−
1
]
>
=
0
)
dp[i]=dp[i-1]+r[i](dp[i-1]>=0)
dp[i]=dp[i−1]+r[i](dp[i−1]>=0)
代码如下:
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=2e5+5;
int r[N];
int dp[N];
int main() {
int n;
cin>>n;
int res=INT_MIN;
for(int i=1;i<=n;i++){
cin>>r[i];
}
for(int i=1;i<=n;i++){
if(dp[i-1]>0)dp[i]=r[i]+dp[i-1];
else dp[i]=r[i];
res=max(res,dp[i]);
}
cout<<res;
return 0;
}
最长回文子串
在力扣有清晰的题解,在这我就简单讲解一下,我们如何设计 d p dp dp数组从而推演。
抓住回文特点:回文去掉两头还是回文。
长度为1的回文串可推出长度为3的回文串是否为回文串(判断头和尾),长度为2的同理也能推出长度为4的回文串是否为回文串。
因此回文串具有递推性。因此我们可以通过长度去递推不同长度的回文子串。
设计1:
本着求什么设什么的心态 我们设
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]为包括字符
s
[
i
]
,
s
[
j
]
s[i],s[j]
s[i],s[j]最长回文子串的长度。
d p dp dp的三大核心:边界,转移方程,无后效性。
边界:
从长度为1和长度为2的回文串开始
无后效性:
字符串长度为
x
x
x,从长度
x
−
2
x-2
x−2的串递推而来,因此枚举长度,故无后效性。(人话:按长度枚举,枚举过的了状态就不会变了)
转移方程:
l
e
=
j
−
i
+
1
le=j-i+1
le=j−i+1
d
p
[
i
]
[
j
]
=
1
(
l
e
=
1
)
dp[i][j]=1 (le=1)
dp[i][j]=1(le=1)
d
p
[
i
]
[
j
]
=
d
p
[
i
+
1
]
[
j
−
1
]
+
1
(
s
[
i
]
=
s
[
j
]
)
dp[i][j]=dp[i+1][j-1]+1 (s[i]=s[j])
dp[i][j]=dp[i+1][j−1]+1(s[i]=s[j])
d
p
[
i
]
[
j
]
=
2
(
l
e
=
2
&
&
s
[
i
]
=
s
[
j
]
)
dp[i][j]=2 (le=2 \ \&\& \ s[i]=s[j])
dp[i][j]=2(le=2 && s[i]=s[j])
代码1:
class Solution {
public:
string longestPalindrome(string s) {
int mx=0,le=s.length();
int dp[1005][1005]={};
string ans;
for(int i=1;i<=le;i++){
for(int j=0;j+i-1<le;j++){
int k=j+i-1;
if(j==k)dp[j][k]=1;
else if(i==2 && s[j]==s[k]){
dp[j][k]=2;
}
else{
if(s[j]==s[k] && dp[j+1][k-1]){
dp[j][k]=dp[j+1][k-1]+2;
}
}
if(dp[j][k]>mx ){
mx=dp[j][k];
ans=s.substr(j,i);
}
}
}
return ans;
}
};
最长公共子序列
设:
两个序列
a
[
i
]
,
b
[
i
]
a[i],b[i]
a[i],b[i]
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j],考虑序列
a
a
a的前
i
i
i个,并且以
a
[
i
]
a[i]
a[i]为结尾,考虑序列
b
b
b的前
j
j
j个,并且以
b
[
j
]
b[j]
b[j]为结尾的最大长度。
故有如下的状态转移:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
1
]
+
1
(
a
[
i
]
=
b
[
i
]
]
)
dp[i][j]=dp[i-1][j-1]+1\ \ (a[i]=b[i]])
dp[i][j]=dp[i−1][j−1]+1 (a[i]=b[i]])
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
(
a
[
i
]
≠
b
[
i
]
]
)
dp[i][j]=max(dp[i-1][j],dp[i][j-1]) \ \ (a[i]\ne b[i]])
dp[i][j]=max(dp[i−1][j],dp[i][j−1]) (a[i]=b[i]])
时间复杂度: O ( n 2 ) O(n^2) O(n2)
说一下为什么不能如下转移方程不成立:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
+
1
(
a
[
i
]
=
b
[
i
]
)
dp[i][j]=max(dp[i-1][j],dp[i][j-1])+1 \ (a[i]=b[i])
dp[i][j]=max(dp[i−1][j],dp[i][j−1])+1 (a[i]=b[i])
因为 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j] 或 d p [ i ] [ j − 1 ] dp[i][j-1] dp[i][j−1]已经将 b [ j ] b[j] b[j]或 a [ i ] a[i] a[i]考虑进去了,如果转移到 d p [ i ] [ j ] dp[i][j] dp[i][j]就可能造成了重复计算。
#include<bits/stdc++.h>
using namespace std;
const int N =1005;
int a[N],b[N];
int dp[N][N];
int main() {
int n;
cin >> n;
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=1;i<=n;i++)cin>>b[i];
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(a[i]==b[j])
dp[i][j]=dp[i-1][j-1]+1;
else
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
}
cout<<dp[n][n];
}
在我给的链接——洛谷P1439有一个很奇妙的解法
重复一下原题~
两个序列,都是n的全排列,求最长公共子序列
如:
a=[3,1,2,5,4]
b=[3,2,5,4,1]
我们可以将第一个序列作为标准序列,数字只是数字而已~
做法如下,制作一个映射:
mp[3]=1,mp[1]=2,mp[2]=3,mp[5]=4,mp[4]=5
接下来 数字都全部丢映射里才算
那么序列变为
a
=
[
1
,
2
,
3
,
4
,
5
]
a=[1,2,3,4,5]
a=[1,2,3,4,5]
b
=
[
1
,
3
,
4
,
5
,
2
]
b=[1,3,4,5,2]
b=[1,3,4,5,2]
我们细细观察,
a
a
a序列都是上升序列,
b
b
b序列是上升序列的全排序,如果
b
b
b的子序列
b
x
bx
bx满足上升条件,可以认为
b
x
bx
bx是a的子序列。
假设我们已经获得了最长公共子序列
b
x
bx
bx
b
x
bx
bx满足什么性质?
- 因为在 a a a之中,所以一定是递增的
- 也在 b b b中,所以是 b b b的其中一个递增序列
- b b b的所有递增序列中的最长的一个,就是最长上升子序列
结论: b b b里的最上升长子序列就是 a , b a,b a,b的最长公共子序列
问题就转化成了求
b
b
b的最长公共子序列
很常规的二分优化
最后时间复杂度
O
(
n
2
)
O(n^2)
O(n2)
#include<bits/stdc++.h>
using namespace std;
const int N =1e5+5;
int a[N],b[N];
int mp[N];
int dp[N],le,res;
int main() {
int n;
cin>>n;
for(int i=1;i<=n;i++){
cin>>a[i];
mp[a[i]]=i;
}
for(int i=1;i<=n;i++)cin>>b[i];
for(int i=1;i<=n;i++){
int j=upper_bound(dp,dp+le,mp[b[i]])-dp;
dp[j]=mp[b[i]];
le=max(le,j+1);
}
cout<<le;
}
最长回文子序列
没有找到模板题,其实都差不多,我们重点理解一下回文子序列的求法。
题意
题目求解的是最少添加多少字符串使得字符串变成回文字符串
简单讲一下思路:先求最长回文子序列,如字符串 a f b e b a afbeba afbeba,其最长回文字符串为 a b e b a abeba abeba,我们发现被“剔除”的字符串为 f f f,我们将“被剔除”的字符串插回字符串里,并且在对应的位置插入对称的字符串。
所以"被剔除"的字符串的长度就是我们最终的答案
求解最长回文子序列的思路:
回文的关键是从前读和从后面读是一样的,所以我们将原字符串
s
1
s1
s1倒置构成新字符串
s
2
s2
s2,求其最长公共子序列即可。
AC代码
#include<bits/stdc++.h>
using namespace std;
const int N =1e3+5;
char s1[N],s2[N];
int dp[N][N];
int main() {
scanf("%s",s1+1);
int le=strlen(s1+1);
for(int i=1;i<=le;i++){
s2[i]=s1[le-i+1];
}
int res=0;
for(int i=1;i<=le;i++){
for(int j=1;j<=le;j++){
if(s1[i]==s2[j]){
dp[i][j]=dp[i-1][j-1]+1;
}
else{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
}
res=max(dp[i][j],res);
}
}
cout<<le-res;
}