文章目录
1.简介
1.1 什么是动态规划?
动态规划(Dynamic Programming)是求解过程中最优化的数学方法 (也叫全局最优)。把多步骤过程转化为一系列单步骤问题,利用各步骤之间的关系,逐个求解。
(动态规划是一种用途很广的问题求解方法,并不是一个特定的算法,而是一种思想,一种手段)
1.2什么时候使用动态规划
一句话来讲就是:如果要求一个问题的全局最优解(通常是最大值或者最短长度等等),而且该问题能够分解成若干个子问题,并且小问题之间也存在重叠的子问题,则考虑采用动态规划。(类似于递归思想,大事化小,小事化了的思想)
能采用动态规划求解的问题的一般要具有3个性质:
- 最优化原理
如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。(即大问题分解成小问题) - 无后效性
即在某一状态或者说某一步后,非以后更新的状态就不会受以后的决策影响(即继承关系) - 有重叠子问题
即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)(比如局部最优问题,子问题之间独立,不如使用贪心思想)(即当前状态更新的时候与之前的状态有关系)
2.斐波那契数列及延伸问题(dp引入)
2.1斐波那契数列
f(n) = f(n-1) + f(n-2) (n >= 2)
2.2问题引入
母牛繁殖问题:一头母牛,它每年年初生一头小母牛。每头小母牛从第四个年头开始,每年年初也生一头小母牛。请编程实现在第n年的时候,共有多少头母牛?
2.21递归分治解决
int fib(n){
if(n < 2) return n;
return f(n-1) + f(n-4);
}
但是,粗略计算后时间复杂度问题有点大(O(sqrt(2)n),n 的值在60的时候需要计算的时间就很多了,所以就需要引入新的方法
2.22利用记忆化搜索降低时间复杂度
于是引入记忆化搜索,将已经计算过的部分保存在数组里面
f[1] = 1;
f[0] = 0;
int fib(n){
if(f[n] != 0) return f[n];
return f[n] = f(n-1) + f(n-2);
}
这样时间复杂度就会降到 O(n)
3.最短向下/上路径问题
3.1数字三角形问题
数字三角形【洛谷P1216】
写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点。
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
在上面的样例中,从 7→3→8→7→5 的路径产生了最大
就要考虑全局最优的问题,而不是仅考虑眼前就是下一步的走法
dp 的关键就是列出动态方程
dp[i][j] = max(dp[i-1][j],dp[i-1][j+1]); //i表示行数,从下到上;j表示列数,从左到右
表示当前一步的最长路径值和上一步的这个这点以及上一步的右边一个点都有关。从下到上每一层的最大值都和对应得下一层的值有关系,通过构成这种关系,每一层都存储从下到上延伸得最大值,到第一层就会产生最大序列和
在这里在简单说一下这个动态方程满足dp得三个性质,下面的问题就需要自己去考虑:
1.最优化原理:想求最大值的路径,我分解成到某一个点的最大路径,然后通过逐层汇总遍历,找到每一层所有路径到三角形最上面一个点的最大值即可。
2.无后效性:举例,第一层的最长长度值与第三层的无关,即判断第一层的最长长度时候不会影响到第三层最长长度的大小,因为最值已经保存,不会再更改
3.有重叠子问题:举例,即在到达第三层第三个点的某一个路径的最大值,可能即会使用第四层的第三个值,也可能会用到第四个值,需要计较判断是走哪一个路径使它的值更大,可能两条路都相同,所以就会有子问题重叠的情况
满足dp的三个性质,不理解的可以再看一遍,因为理解这三个性质,就会对以后的其他较难理解 dp 问题会更好的理解
4.最长曼哈顿路径问题
4.1 问题描述 LMP(Longest Manhattan Path)
在某一个非负整数构成的n × m 矩阵中,找出从左上角通往右下角的一条路径,使得路径总长(沿途所经整数的总和)最大(你只能在矩阵内部向右走或者向下走)
4.2利用动态规划解决
这里同上一个问题类似,考虑每两斜层之间的关系就可,列出动态方程:
dp[i][j] = max(dp[i][j+1],dp[i+1][j]) + w[i][j];
从左上到右下,一步一步考虑问题,最后将整张 n × m 地图都进行赋值一遍,动态数组 dp 表示左上的点都这一点的最大距离,到达右下的点就是最大点的汇集。这样就可以利用动态方程来求解最优问题
4.3记忆化搜索
当然这个题也可以用记忆化搜索求解,从右下的状态考虑,利用dfs一次向上,记录并向上运行。
return dp[i][j] = max{d(i-1,j),d(i,j-1)} + w[i][j];
具体详细就不展开,感兴趣的同学可以找模板题试一试,如果还想学可以直接来找我
4.4拓展问题
哈曼顿距离(模板题目,可以先放掉往下学习)
题目链接
描述
一个街区有很多住户,街区的街道只能为东西、南北两种方向。
住户只可以沿着街道行走。
各个街道之间的间隔相等。
用(x,y)来表示住户坐在的街区。
例如(4,20),表示用户在东西方向第4个街道,南北方向第20个街道。
现在要建一个邮局,使得各个住户到邮局的距离之和最少。
求现在这个邮局应该建在那个地方使得所有住户距离之和最小;
输入
第一行一个整数n<20,表示有n组测试数据,下面是n组数据;
每组第一行一个整数m<20,表示本组有m个住户,下面的m行
每行有两个整数0<x,y<100,表示某个用户所在街区的坐标。
m行后是新一组的数据;
输出
每组数据输出到邮局最小的距离和,回车结束;
首先考虑一下两个点之间的距离(就是曼哈顿距离),因为我们只能走街区
所以从 (xi,yi) 到 (xj,yj) 的距离一定就是|xi - xj| + |yi - yj|
因为两点之间不能走直线,必须从街区的水平和竖直方向走,所以距离就是x、y的绝对值之和
想要找多个点到某一点的最小值,我们这么来考虑,两个点到达
终点的最小距离的方法就是:重点放在这两点之间,不然会有多
的路径算在里面。对于三个点,我们是不是给终点放在中间点就
可以使路径最短,因为终点放在其他位置总会多算一截(可以自己画图考虑一下)
对于4个点及以上,我们仍然给他放到中间,使路径最小(具体证明我这里就不再说了,这是一个类型题,想证明可以参考一下i其他人的博客)
我们为了解决问题,考虑方法就是对x、y轴的值进行排序,然后将终点变成这些x、y点的中位点,计算最大和最小值的绝对值就是两点到终点的距离
然后考虑所有就是dp了所有情况,然后找到最小长度
对数组a、b 排完序之后,动态方程就是
for(int i = 1;i < m/2;i++)
sum += a[m-i-1] - a[i] + b[m-i-1] - b[i];
5.最长上升子序列(LIS Longest Increasing Subsequence)
5.1问题描述
给你一段数字,如 4 1 3 5 2,让你找出这段数中,最长的上升子序列?(有时候问的是最长非递减子序列,注意判断是否需要考虑相同的情况)
5.2 正常考虑思路
定义:
a[i] 存储所有的数字序列
dp[i] 表示在第 i 个点判断,存储长度最大值
从一个点出发(也叫对一个数字进行分析),判断是否前面的数有大于它的,若有,在大于他的数的 dp 状态保留这个数的长度 加一,若无,就保留大于这个数的下标为 j 的元素的长度(表示从第 j 个开始,找之后的上升序列)。for循环遍历考虑所有的点,当考虑到刚才长度加一的点时,判断在它后面时候也有大于它的点,然后操作同上面相同。根据此,在最后一个点的时候,相应表示的就是所有路径有几个点到最后的长度,输出 dp[n] 即可
for(int i = 1;i <= n;i++)
dp[i] = 1;
for(int j = 1;j < i;j++)
if(a[i] > a[j]) dp[i] = max(dp[i],dp[j]+1);
这个是比较好想的维护一维数组的方法,但是时间复杂度太高,104~105 的数据就会被卡死,所以就有另外一种办法来优化时间复杂度
5.3 利用二分优化时间
我们其实不难看出,对于n2 的做法而言,其实就是暴力枚举:将每个状态都分别比较一遍。但其实有些没有必要的状态的枚举,导致浪费许多时间,当元素个数到了104~105 以上时,就已经超时了。而此时,我们可以通过另一种动态规划的方式来降低时间复杂度:
定义:
f[num] 表示现在目前最长长度 num 的最小尾元素值,最后的 num 就是最长的序列
那么如果这种长度的子序列的结尾元素越小,后面的元素就可以更方便地加入到这条我们臆测的、可作为结果、的上升子序列中。
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;
就是说:用数组 f 记录最大上升序列的末尾值,一旦大于末尾值就直接添加,否则通过二分查找的方法替换大于前面的某一个数
5.4类似例题
洛谷【P1020】
这个问题一就是考虑最长非递增序列,而问题二就是再找一次最大递增序列就可以求出解来,就是最少需要的次数。具体分析也可以参照题解来学习
6.最长公共子序列(LCS Longest Common Subsequence)
6.1 问题描述
给定两个字符串,问两个字符串的公共子序列中,最长的长度为多少?是哪个?
6.2 子串 和 子序列
子串( Substring)
原串中连续的一部分
子序列(Subsequnce)
原串中任意抽出任意多个字符(可以不连续,但先后顺序不能变),组成的字符串
6.3正常方法考虑
比如给定序列:
3 2 1 5 4
1 5 2 4 3
判断每一个字符相同后,动态方程都要和之前的情况做比对,是否大小能满足更新(满足最长序列),满足怎么赋值,不满足怎么状态转移
我们用数组dp[i][j] 表示第一行的前第 i 个,和第二行的前第 j 个数的LCS长度,然后就考虑每一步进行的情况。
当两行序列A,B,当前两个元素(A[i],B[i])不相同时,考虑继承:
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
当两行序列A,B,当前两个元素(A[i],B[i])相同时,考虑更新:
dp[i][j] = max(dp[i][j],dp[i-1[j-1]+1]);
具体代码如下
#include<stdio.h>
int max(int a,int b){
return a>=b?a:b;
}
int a[1010];
int b[1010];
int dp[1010][1010] = {0};
int main()
{
int n;
scanf("%d",&n);
for(int i = 1;i <= n;i++) scanf("%d",&a[i]);
for(int i = 1;i <= n;i++) scanf("%d",&b[i]);
int s = 0;
for(int i = 1;i <= 1000;i++){
for(int j = 1;j <= 1000;j++){
dp[i][j] = max(dp[i-1][j],dp[i][j-1]); //不更新考虑继承
if(a[i] == b[j]) dp[i][j] = max(dp[i][j],dp[i-1][j-1]+1); //相同则考虑更新
}
}
printf("%d",dp[n][n]); //最后输出第一行前n个数,和第二行前n个数的LCS最长即可
return 0;
}
6.3二分法优化
上面算法需要 n2 的时间复杂度,而且数组也不能开的过大,所以我们就考虑一种方法将LCS问题转化为LIS问题,利用二分法就行优化。
序号 1 2 3 4 5
A[] = 3 2 1 5 4
B[] = 1 5 2 4 3
现在引入一个新的数组 t[] 来实现数组的数字映射,因为A、B两个数组元素相同并且是 1-n 个数的排列组合,所以这样考虑
序号 i 1 2 3 4 5
A[] = 3 2 1 5 4
t[A[i]]=i 3 2 1 5 4
B[i]=t[A[i]] 3 4 2 5 1
那么很显然t中的数字如果递增
那么表示A中的序号递增就表示这个递增的序列就是A的子序列, 并且我们在B中一直的顺序遍历(从前向后)那么显然这个序列也是B的子序列, 那么这个序列就是A,B公共子序列
理解上的话,可以通过B 中的元素值来找数组 t 的下标,对应的就是A 中元素的值,这样带入两次理解一下
那么最大化这个子序列就是最大化t数组中的最长上升子序列LIS算法
这样我们就把LCS问题变成LIS问题用n log n解决了。
代码如下
#include<stdio.h>
int a[100001];
int t[100010];
int b[100010];
int min(int a,int b){
return a>=b?b:a;
}
int main()
{
int n;
scanf("%d",&n);
for(int i = 1;i <= n;i++){
scanf("%d",&a[i]);
t[a[i]] = i; //a数组存在 t 数组中
}
int f[100010] = {0};
for(int i = 1;i <= n;i++){
int k;
scanf("%d",&k);
b[i] = t[k]; //按第二行元素的值在数组 t 中的下标将元素存储在B 数组中
f[i] = 0x7fffffff;
}
int num = 0;
for(int i = 1;i <= n;i++){ //同LIS二分求最长递增序列
if(b[i] >= f[num]) f[++num] = b[i];
else{
int l = 0,r = num,mid;
while(l < r){ //一般二分查找都用于寻找某一个数 (或周围的数)
mid = (l+r)/2;
if(f[mid] > b[i]) r = mid; //这部分赋值,就是找出第最大的小于b[i]的值 和 最小的大于b[i]的值
else l = mid+1;
}
f[l] = min(f[l],b[i]);
}
}
printf("%d",num); //最后输出的 num 就是1相同子序列
return 0;
}
6.4其他问题拓展
这里再补充一个LCS的问题,它求的是所有公共子集的个数,继承的时候考虑的是相加和,而不是取最大值更新
题目解析
还有一个有关线性的字符串与这个题目有一点相似关系的动态规划问题,有时间也可以学习一下,也是一种模板问题
7.总结
大致的有关线性的动态规划简单问题也就这几种模型,并没有深入展开来写,具体遇到题目再思考、再学习记录,因为动态规划变化较多,所以需要掌握基本类型,其他的自己拓展即可。
动态规划关键在于应用,如果学完就可以多做题目巩固+更多对动态规划的理解
下一个问题解决的是背包问题,只写背包变形的 5 种问题,这样有关最基本的动态规划问题就ok,大家抽时间学习即可。