动态规划(普及组)
入门篇:动态规划思想
动态规划向来都是OI竞赛生涯中的分水岭。
开篇杂谈
文章中有任何地方不懂可联系我\(qq:2832853025\),退役前全天在线。
前置技能
-
DFS搜索。
-
记忆化搜索。
-
递推式。(高中必修五数学)
个人理解
照搬定义肯定不是传授知识的好办法,呢只是老师PPT上面爱放的东西。
在我个人的理解中,动态规划只是搜索的一种优化方法,但是并不可以优化所有的搜索。一般的来说,符合下面三条情况的搜索是可以转化为动态规划的思想来做的。
-
重叠子问题。
-
最优子结构。
-
子问题无后效性。
通俗的借用花姐的话:** _ 现在决定未来,未来与过去无关。_ **
例1:最大子段和
题面
给出一段序列,选出其中连续且非空的一段使得这段和最大。
题解
如果没有学过动态规划思想,拿到这道题一般会有两种思路:
- 贪心遍历
- 暴力搜索 or 记忆化搜索
首先想贪心的,我不反驳,这题贪心可以写,但是可以有数据hack掉的,这并不是一种保险的写法,除非你在考场上是真的想不出来新的解法。
其次这道题也可以用爆搜来做,但是数据应该是比较水的(我没有尝试),出了意外就是TLE,所以也是非常不保险的写法。
用记忆化搜索的,肯定是没有问题的,看了好多代码,发现大部分的记忆化用了前缀和的方法,挺好的。
是时候引出来我们的正解了:动态规划
-
因为子段是连续且非空,所以就存在两个状态,两种状态找最大值。
a. 前面的段加上当前的数。
b. 以当前段为起点。
-
可以得出递推式 \(dp[i] = max(dp[i-1] + num[i], num[i])\)
-
于是根据递推式写代码就好了。
代码
#include<bits/stdc++.h>
using namespace std;
int n,dp[200010],num,ans = -9999999;
int main(int argc, char const *argv[])
{
cin >> n;
for(int i = 1;i <= n;++i)
{
cin >> num;
dp[i] = max(dp[i-1]+num,num);
ans = max(dp[i],ans);
}
cout<< ans <<endl;
return 0;
}
总结
判断一道题目是不是动态规划:
-
重叠子结构
-
最优子结构
-
子问题无后效性
动态规划题目拿到手里怎么做:
-
我从哪里来 -> 到哪里去
-
写出状态转移
-
写出状态转移方程
-
代码实现
例2: P1216 [USACO1.5]数字三角形 Number Triangles
题面
观察下面的数字金字塔。
写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点。
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
在上面的样例中,从7 到 3 到 8 到 7 到 5 的路径产生了最大。
题解
由例1的总结我们来做这道题。
判断这是不是一个动态规划问题:
-
每个点可能被重复的搜索。 -> 重叠子结构
-
每部搜索我们只要最大值。 -> 最优子结构
-
每个点的最大值确定后不再改变。 -> 无后效性
可以确定这道题可以用动态规划来解,但是我要补锅,为什么我说所有的动态规划题目都可以用搜索来解。
方法1
暴力搜,前置技能要求内容,不解释。
int solve(int i,int j)
{
return a[i][j] + (i == n ? max(solve(i+1),j),solve(i+1,j+1)));
}
方法2
记忆化,前置技能要求内容,不解释。
int solve(int i,int j)
{
if(d[i][j] >= 0) return d[i][j];
return d[i][j] = a[i][j] + (i == n ? max(solve(i+1),j),solve(i+1,j+1)));
}
方法3
-
我从哪里来 -> 当前点下面左边点和右边点选最大值。
-
到哪里去 -> 递推到最上面的顶点。
-
\(dp[i][j] :\) 表示第i横第j个点的最大值。
-
\(dp[i][j] = max(dp[i+1][j],dp[i+1][j+1])\)
代码
我的代码对初学者很友好,尽可能的模块化。
#include<bits/stdc++.h>
using namespace std;
int mapp[10010][10010],dp[10010][10010];
int n;
void init()
{
cin >> n;
for(int i = 1;i <= n;++i)
{
for(int j = 1;j <= i;++j)
{
cin >> mapp[i][j];
}
}
}
void DP()
{
for(int i = n;i >=1;i--)
{
for(int j = 1;j <= i;++j)
{
dp[i][j] = max(dp[i+1][j+1],dp[i+1][j])+mapp[i][j];
}
}
cout << dp[1][1];
}
int main(int argc, char const *argv[])
{
init();
DP();
return 0;
}
入门篇拔高题: P1057 传球游戏
题面
上体育课的时候,小蛮的老师经常带着同学们一起做游戏。这次,老师带着同学们一起做传球游戏。
游戏规则是这样的:n个同学站成一个圆圈,其中的一个同学手里拿着一个球,当老师吹哨子时开始传球,每个同学可以把球传给自己左右的两个同学中的一个(左右任意),当老师再次吹哨子时,传球停止,此时,拿着球没有传出去的那个同学就是败者,要给大家表演一个节目。
聪明的小蛮提出一个有趣的问题:有多少种不同的传球方法可以使得从小蛮手里开始传的球,传了m次以后,又回到小蛮手里。两种传球方法被视作不同的方法,当且仅当这两种方法中,接到球的同学按接球顺序组成的序列是不同的。比如有三个同学1号、2号、3号,并假设小蛮为1号,球传了33次回到小蛮手里的方式有1->2->3->1和1->3->2->1,共2种。
题解
依然是用上面的方法:
分析:
我从哪里来 -> 到哪里去
-> 来自左边或右边的同学 -> 到我手里
-> $dp[i][j] $: 传了第i次,到j手里的方案数
-> \(dp[i][j] = dp[i-1][j-1] + dp[i-1][j+1]\);
边界条件:
j = 1 : 左边没人,但是是环,左边是最右边的呢个人。
-> 当j=1时 \(dp[i][j] = dp[i-1][n] + dp[i-1][2]\);
j = n : 右边没人,但是是环,右边是最左边的呢个人。
-> 当j=n时 \(dp[i][j] = dp[i-1][n-1] + dp[i-1][1]\);
初始化:
dp[0][1] = 1;dp[1][n] = 1;dp[1][2] = 1;
代码
#include<bits/stdc++.h>
using namespace std;
int n,m,dp[100][100];
void init();
void DP();
inline void init()
{
cin >> n >> m;
dp[0][1] = 1;dp[1][n] = 1;dp[1][2] = 1;
DP();
}
inline void DP()
{
for(int i = 1;i <= m;++i)
{
for(int j = 1;j <= n;++j)
{
if(j == 1) dp[i][j] = dp[i-1][n] + dp[i-1][2];
else if(j == n) dp[i][j] = dp[i-1][n-1] + dp[i-1][1];
else dp[i][j] = dp[i-1][j-1] + dp[i-1][j+1];
}
}
cout << dp[m][1];
}
int main(int argc, char const *argv[])
{
init();
return 0;
}
渐入佳境 :线性动态规划
在一个困难的嵌套决策链中,决策出最优解。
最长上升子序列(\(LIS\))
例1 \(LIS\)
题面
例:由6个数,分别是: 1 7 6 2 3 4,求最长上升子序列。
-
最长上升子序列的元素不一定相邻
-
最长上升子序列一定是原序列的子集
所以这个例子中的\(LIS\)就是:1 2 3 4,共4个
题解
一、\(n^2\)做法
-
对于第i个数有两种状态,要 or 不要。 -> 我从哪里来
-
到第n个数最长上升子序列长度。 -> 到哪里去
-
\(dp[i]\) : 到第i个数的最长上升子序列长度。
-
要: $dp[i] = dp[i-1] + 1; \(; 不要:\)dp[i] = 1; $
for(int i=1;i<=n;i++)
{
dp[i]=1;
for(int j=1;j<i;j++)
if(data[j]<data[i] && dp[i]<dp[j]+1)
dp[i]=dp[j]+1;
}
二、\(nlogn\)做法
直接搬花姐博客:
将原来的dp数组的存储由数值换成该序列中,上升子序列长度为i的上升子序列,的最小末尾数值
这其实就是一种几近贪心的思想:我们当前的上升子序列长度如果已经确定,那么如果这种长度的子序列的结尾元素越小,后面的元素就可以更方便地加入到这条我们臆测的、可作为结果、的上升子序列中。
int n;
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>a[i];
f[i]=0x7fffffff;
//初始值要设为INF
/*原因很简单,每遇到一个新的元素时,就跟已经记录的f数组当前所记录的最长
上升子序列的末尾元素相比较:如果小于此元素,那么就不断向前找,直到找到
一个刚好比它大的元素,替换;反之如果大于,么填到末尾元素的下一个q,INF
就是为了方便向后替换啊!*/
}
f[1]=a[1];
int len=1;//通过记录f数组的有效位数,求得个数
/*因为上文中所提到我们有可能要不断向前寻找,
所以可以采用二分查找的策略,这便是将时间复杂
度降成nlogn级别的关键因素。*/
for(int i=2;i<=n;i++)
{
int l=0,r=len,mid;
if(a[i]>f[len])f[++len]=a[i];
//如果刚好大于末尾,暂时向后顺次填充
else
{
while(l<r)
{
mid=(l+r)/2;
if(f[mid]>a[i])r=mid;
//如果仍然小于之前所记录的最小末尾,那么不断
//向前寻找(因为是最长上升子序列,所以f数组必
//然满足单调)
else l=mid+1;
}
f[l]=min(a[i],f[l]);//更新最小末尾
}
}
cout<<len;
例2 P1091 合唱队形
题面
N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学排成合唱队形。合唱队形是指这样的一种队形:设K位同学从左到右依次编号为1,2,..,K,他们的身高分别为T1,T2…,Tk,则他们的身高满足1<..
你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。
题解
LIS模板题 ,不解释。
代码
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 105;
int n,num[MAXN],ans = 1;
int dp1[MAXN],dp2[MAXN];
int main(void)
{
scanf("%d",&n);
for(int i = 1; i <= n; i ++)
scanf("%d",&num[i]),dp1[i] = dp2[i] = 1;
//第一遍 最长上升
for(int i = 1; i <= n; i ++)
for(int j = 1; j < i; j ++)
if(num[j] < num[i])
dp1[i] = max(dp1[i],dp1[j] + 1);
//第二遍 最长下降
for(int i = n; i >= 1; i --)
for(int j = n; j > i; j --)
if(num[j] < num[i])
dp2[i] = max(dp2[i],dp2[j] + 1);
for(int i = 1; i <= n; i ++)
ans = max(ans,dp1[i] + dp2[i] - 1);//去掉最中间算重的人
printf("%d\n",n - ans);
return 0;
}
最长公共子序列(\(LCS\))
题面
给定2个序列:
1 2 3 4 5
3 2 1 4 5
试求出最长的公共子序列。
题解
依然是以上的动态规划解题套路:
从哪里来 -> 到哪里去 -> 状态转移 -> 状态转移方程 -> 代码实现
代码
#include<iostream>
using namespace std;
int dp[1001][1001],a1[2001],a2[2001],n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;i++)scanf("%d",&a1[i]);
for(int i=1;i<=m;i++)scanf("%d",&a2[i]);
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
{
dp[i][j]=max(dp[i-1][j],dp[i][j-1]);
if(a1[i]==a2[j])
dp[i][j]=max(dp[i][j],dp[i-1][j-1]+1);
}
cout<<dp[n][m];
}
背包问题
详见洛谷日报:背包问题 (附单调队列优化多重背包
区间动态规划
区间DP:是指在一段区间上进行的一系列动态规划。
-
对于区间DP这一类问题,我们需要计算区间\([1,n]\)的答案,通常用一个二维数组\(dp\)表示,其中\(dp[x][y]\)表示区间\([x,y]\)。
-
有些题目,\(dp[l][r]\)由\(dp[l][r-1]\)与\(dp[l+1][r]\)推得;也有些题目,我们需要枚举区间\([l,r]\)内的中间点,由两个子问题合并得到,也可以说\(dp[l][r]\)由\(dp[l][k]\)与\(dp[k+1][r]\)推得,其中\(l≤k<r\)。
-
对于长度为n的区间\(DP\),我们可以先计算[1,1],[2,2]..[n,n]的答案,再计算\([1,2],[2,3]..[n-1,n]\),以此类推,直到得到原问题的答案。
例1 石子合并
题面
当前有\(N\)堆石子,他们并列在一排上,每堆石子都有一定的数量。我们需要把这些石子合并成为一堆,每次合并都只能把相邻的两堆合并到一起,每一次合并的代价都是这两堆石子的数量之和,经过\(N-1\)次合并后成为一堆。求把这些石子合并成一堆所需的最小代价。
题解
-
根据动态规划的思想,我们只要求出每两堆石子合并的最小代价,然后再求出每三堆石子合并的最小代价,并以此类推就能最终求出n堆石子合并的最小代价。(来自哪里,到哪里去)
-
我们把\(dp[i][j]\)定义为合并第i堆石子到第j堆石子所需的最小代价。(写出状态转移)
-
很容易就能得到\(dp[i][j]=min(dp[i][k]+dp[k+1][j])+sum[j]-sum[i-1]\)。显然通过这个式子,我们可以按区间长度从小到大的顺序进行枚举来不断让石子进行合并,最终就能获得合并成一堆石子的最小代价。(写出状态转移方程)
-
时间复杂度是\(O(n^3)\)。
代码
memset(dp, 0, sizeof(dp));
for (int l = 2; l <= n; ++l) {
for (int i = 1, j; i <= n - l + 1; ++i) {
j = i + l - 1;
dp[i][j] = inf;
for (int k = i; k < j; ++k) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
dp[i][j] += sum[j] - sum[i - 1];
}
}
/*放抄袭*/
河南师大附中双语国际学校 17级文献
https://www.luogu.org/blog/Chicago01/alg-2
参考文献
《算法竞赛入门经典(第二版)》 动态规划初步
《挑战程序设计竞赛》 背包问题