到底什么是dp思想(内含大量经典例题,附带详细解析)
Posted yinbiao
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了到底什么是dp思想(内含大量经典例题,附带详细解析)相关的知识,希望对你有一定的参考价值。
期末了,通过写博客的方式复习一下dp,把自己理解的dp思想通过样例全部说出来
说说我所理解的dp思想
dp一般用于解决多阶段决策问题,即每个阶段都要做一个决策,全部的决策是一个决策序列,要你求一个
最好的决策序列使得这个问题有最优解
将待求解的问题分为若干个相互联系的子问题,只在第一次遇到的时候求解,然后将这个子问题的答案保存
下来,下次又遇到的时候直接拿过来用即可
dp和分治的不同之处在于分治分解而成的子问题必须没有联系(有联系的话就包含大量重复的子问题,那
么这个问题就不适宜分治,虽然分治也能解决,但是时间复杂度太大,不划算),所以用dp的问题和用分
治的问题的根本区别在于分解成的子问题之间有没有联系,这些子问题有没有重叠,即有没有重复子问题
dp和贪心的不同之处在于每一次的贪心都是做出不可撤回的决策(即每次局部最优),而在dp中还有考察
每个最优决策子序列中是否包含最优决策子序列,即是否具有最优子结构性质,贪心中每一步都只顾眼前
最优,并且当前的选择是不会依赖以前的选择的,而dp,在选择的时候是从以前求出的若干个与本步骤
相关的子问题中选最优的那个,加上这一步的值来构成这一步那个子问题的最优解
讲得再多不如看几个很经典的样例,带你初步入门dp
我不会讲很多具体该怎么做,而是剖析这些经典例题中的dp思想,真真正正的1懂得了dp思想的话,做题事
半功倍(自己深有体会)
样例1:数字三角形问题
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
从顶部向下走,每次只能走下面或者右下,走完全程,问你怎么走使得权值最大(问题描述不是很详细,关
于数字三角形问题是什么问题请百度)
那么dp的思想到底是怎么体现的呢?
dp是要先分解成很多相互联系的子问题,要解决一个子问题,依赖于前面和此子问题相关的已经解决的子
问题中选一个最优的加上这个子问题的解,就是这个子问题的最优解
具体做法:
1.分析问题的最优解,找出最优解的性质,并刻画其结构特征:
问题的最优解:所有走法中最大的权值是多少?
最优解的性质和结构特征:只能向正下或者右下走,每走一行的最大权值等于前面一行的最大权值加上这一
行的走的两个方向中的最大值
2.递归的定义最优值:
要找到从0行出发的最优值,就要找到从第1行出发的最优值
要找到从1行出发的最优值,就要找到从第2行出发的最优值
………………………
要找到第3行出发的最优值,就要找到从最后一行出发的最优值
为什么是这样呢?我们分析一下
题目要你求从0行出发的最优值,那么我们就是要找到从第一行出发的最优值,加上第0行到第1行的最优值
但是,很重要的一点,我们需要递归求解,要先求解从倒数第一行出发的最优值,然后根据从倒数第一行出
发的最优值求出从倒数第二行出发的最优值
3.采用自底向上的方式计算问题的最优值:
这个就是我上面说的,要先求解从倒数第一行出发的最优值,然后根据从倒数第一行出发的最优值求出从倒
数第二行出发的最优值,自底向上的计算,迭代的方式求解子问题
4.根据计算最优值时间得到的信息,构造最优解
这个就是问你具体是怎么走的,我们需要在求解子问题的时候保存一些信息,采用构造出最优解(最优值和
最优解是不同的,最优值在本问题中是一个走法中权值之和最大的那一个,而最优解是具体的走法),这里
题目没有要求就是不用去构造最优解,构造起来也挺麻烦的。。。。
解法:
dp【i】【j】:代表从第i行第j列出发得到的最优值
dp【i】【j】=max(dp【i+1】【j】,dp【i+1】【j+1】)+a【i】【j】
表示从第i行第j列出发的最优值等于到i+1行的两种走法中最大的那一个加上出发点的权值
贴个链接:
https://www.cnblogs.com/yinbiao/p/8995253.html贴个代码:
#include<bits/stdc++.h> using namespace std; int main() { int n; scanf("%d",&n);//n行 int a[n][n]; memset(a,0,sizeof(a)); for(int i=0;i<n;i++) { for(int j=0;j<=i;j++) { scanf("%d",&a[i][j]); } } int dp[n][n]; memset(dp,0,sizeof(dp)); for(int j=0;j<n;j++) { dp[n-1][j]=a[n-1][j]; } for(int i=n-2;i>=0;i--) { for(int j=0;j<=i;j++) { dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+a[i][j]; } } printf("%d ",dp[0][0]); return 0; }
经典样例2:最长公共子序列问题 (LCS问题)
给你两个序列,问你他们的最长LCS序列的长度是多少?(序列可以是不连续的,只要元素的相对位置一
样)(不了解LCS问题的自行百度)
那么在LCS问题中dp的思想体现在哪里呢?
重复子问题:(超级容易发现的一个)
我们要求x1~xi,Y1~Yj的LCS,那么是不是要求x1~xi-1,Y1~Yi-1的LCS
我们要求x1~xi-1,y1~yi-1的LCS,那么是不是要求x1~xi-2,Y1~yi-2的LCS
所以我们要求的x1~xi,Y1~Yj的LCS这个大问题中,包含了很多的重复子问题
具体做法:
c【i】【j】表示x1~xi,Y1~Yj的LCS序列长度
x【i】==y【j】 c【i】【j】=c【i-1】【j-1】+1
x【i】!=y【j】 c【i】【j】=max(c【i-1】【j】,c【i】【j-1)
i==0||j==0 c【i】【j】=0
贴个代码(求最优值和最优解)
#include<bits/stdc++.h> #define max_v 1005 using namespace std; char x[max_v],y[max_v]; int dp[max_v][max_v]; int l1,l2; int dfs(int i,int j) { if(i==-1||j==-1) return 0 ; if(x[i]==y[j])//来自左上角 { dfs(i-1,j-1); cout<<x[i]<<" ";//先递归到最后再输出,,这样就是顺序的 } else { if(dp[i-1][j]>dp[i][j-1])//来自上面 { dfs(i-1,j); } else//来自左边 { dfs(i,j-1); } } return 0; } int main() { int t; scanf("%d",&t); getchar(); while(t--) { scanf("%s",x); scanf("%s",y); int l1=strlen(x); int l2=strlen(y); memset(dp,0,sizeof(dp)); for(int i=1; i<=l1; i++) { for(int j=1; j<=l2; j++) { if(x[i-1]==y[j-1]) { dp[i][j]=dp[i-1][j-1]+1; } else { dp[i][j]=max(dp[i-1][j],dp[i][j-1]); } } } printf("%d ",dp[l1][l2]); dfs(l1,l2); cout<<endl; } return 0; } /* 2 ABCBDAB BDCABA */
经典样例三:矩阵连乘问题,纸牌问题,石头合并问题(都是一类问题,一起分析)
给定n个矩阵{A1,A2…..An},其中A【i】与A【i+1】是可乘的,如何确定计算的次序,使得乘法的总次数最少
首先我们要明白,计算的次序不同,那么乘法的总次数也不同
类似的问题:给你n张牌,每张排都有一个数字,相邻的两张牌的权值可以相乘,相乘的两张牌可以合并为
一张牌,新牌的权值是原来的两张牌的乘积
这个问题还有石头合并问题都是同一类的问题,属于区间dp问题
石头合并问题:给你一堆石头,排成一行,相邻的两个石头可以合并,合并成的石头的权值为原来两个石头
的权值之和
先来分析矩阵连乘问题:
给你一个一维数组
30,35,15,5,10,20,25
只要相邻的矩阵才可以相乘
思考一下,dp的思想是如何体现的
第一步我们是要把问题分解成很多互相有联系的子问题(重复子问题是用dp的基础)
简单的思考一下,每次矩阵相乘,最简单的就是两个可以相乘的矩阵相乘(A1,A2,A3),那最大的乘法次数就是A1*A2*A3
但是如果是多个呢,我们是不是可以简化成下面这样
A【i】,A【i+1】………………….A【k】………………A【j-1】,A【j】
讲他们分成两个抽象矩阵
第一个:A【i】….A【k】
第二个:A【k+1】…..A【j】
把大问题抽象成两个抽象矩阵相乘,那么更加最简单的那种抽象一下就知道求所有矩阵乘法的最大次数,就
是求第一个抽象矩阵自己内部要乘的次数和第二个抽象矩阵内部自己要求的乘法次数然后加上这这两个抽象
矩阵合并为一个大的抽象矩阵要乘的次数
那么大问题是这样的,大问题里面是不是有很多这样的小问题,而且这些小问题还是重复的,比如A【k】
的选择不同,那么乘的次序结果也不一样,A【k】的选择可以导致很多问题都有重复的部分,如果多次计
算的话,无疑是很不明智的,这样的话跟分治就是没有什么区别了,这样的问题就叫做重复子问题
A【k】的选择不同的话,会导致子问题有很多重复的部分,前面我们说了的,同时A【k】的选择不同的话
会导致两个抽象矩阵相乘的结果也不一样,所以我们就要在所有的A【k】选择中找一个最小的
所以我们现在在这个问题里面找到了dp思想的具体体现:大量的重复子问题
具体做法:
dp【i】【j】:代表矩阵i,矩阵i+1………….矩阵j的最少乘法次数
总结上述:
dp【i】【j】=min(dp【i】【k】+dp【k+1】【j】
i<=k<=j-1
贴个代码:
#include<bits/stdc++.h> using namespace std; #define max_v 1005 int dp[max_v][max_v],a[max_v],s[max_v][max_v]; void f(int i,int j) { if(i==j) return ; f(i,s[i][j]); f(s[i][j]+1,j); printf("A[%d:%d]*A[%d:%d] ",i,s[i][j],s[i][j]+1,j); } int main() { int n; scanf("%d",&n); for(int i=0;i<=n;i++) { scanf("%d",&a[i]); } for(int i=1;i<=n;i++) { dp[i][i]=0; } for(int r=2;r<=n;r++) { for(int i=1;i<=n-r+1;i++) { int j=i+r-1; dp[i][j]=dp[i+1][j]+a[i-1]*a[i]*a[j]; s[i][j]=i; for(int k=i+1;k<j;k++) { int t=dp[i][k]+dp[k+1][j]+a[i-1]*a[k]*a[j]; if(t<dp[i][j]) { dp[i][j]=t; s[i][j]=k; } } } } f(1,n); } /* 6 30 35 15 5 10 20 25 A[2:2]*A[3:3] A[1:1]*A[2:3] A[4:4]*A[5:5] A[4:5]*A[6:6] A[1:3]*A[4:6] */
分析了矩阵连乘问题,再来分析一下石头合并问题
石头合并问题:其实这个问题跟矩阵连乘问题真的是一样的
非常非常的类似
A1,A2………………….An
也是分解成两个抽象的石头
A【i】,A【i+1】………A【k】……….A【j】
第一个抽象石头:A【i】……..A【k】
第二个抽象石头:A【k+1】…….A【j】
我们现在把大问题分解成了两个抽象的石头合并问题
问的是你合并完成后最小的权值是多少
大问题的最小权值等于第一个抽象石头合并的权值加上第二个抽象石头合并的权值,再加上这两个抽象的石头合并的权值
我们知道,A【k】的选择不同,会导致最后权值的不同,也会导致大量重复的子问题(前面在矩阵连乘wen他中具体分析了)
所以我们要在所有的A【k】选择中,选择一个合并花费最小的
现在我们把大问题分解成了这样一个问题,那么每个抽象的石头也还可以当初一个大问题继续分解呀,所以
就分解成了很多子问题
具体做法:
dp【i】【j】:代表合并第i到第j个石头的最小花费
sum【i】:表示1~i个石头的权值之和
dp【i】【j】=min(dp【i】【k】+dp【k+1】【j】)+sum【j】-sum【i】+a【i】
为什么是sum【j】-sum【i】+a【i】呢?
因为我们要合并从第i个石头到第j个石头所需要的花费就是第i个石头到第j个石头的权值的和呀
贴个代码:
#include<bits/stdc++.h> using namespace std; int main() { int n; while(~scanf("%d",&n)) { int a[n+1]; for(int i=1; i<=n; i++) { scanf("%d",&a[i]); } int sum[n+1]; int dp[n+1][n+1]; for(int i=1; i<=n; i++) { int t=0; for(int j=1; j<=i; j++) { t=t+a[j]; } sum[i]=t; } for(int i=1; i<=n; i++) { dp[i][i]=0; } for(int r=2; r<=n; r++) { for(int i=1; i<=n-r+1; i++) { int j=i+r-1; int t=dp[i][i]+dp[i+1][j]+sum[j]-sum[i]+a[i]; for(int k=i; k<=j-1; k++) { if(t>dp[i][k]+dp[k+1][j]+sum[j]-sum[i]+a[i]) { t=dp[i][k]+dp[k+1][j]+sum[j]-sum[i]+a[i]; } } dp[i][j]=t; } } printf("%d ",dp[1][n]); } return 0; } /* 样例输入 3 1 2 3 7 13 7 8 16 21 4 18 样例输出 9 239 */
经典样例四:最长递增子序列
比如
1,7,3,5,8,4,8
问你最长的递增的子序列的长度是多少
这个问题的最优解有多个,但是最优值只有一个:长度为4
1,3,5,9
1,3,5,8
1,3,4,8
这三个都是最优解,但是他们长度都是一样的,长度为4
这些是我们看出来的
那我们如何用dp的思想解题呢
第一步分解成很多互相有联系的子问题
要求第n个元素结尾的LIS序列的长度,就要求以第n-1个元素结尾的LIS序列的长度
要求第n-1个元素结尾的LIS序列的长度,就要求以第n-2个元素结尾的LIS序列的长度
…………..
假设第n-1个元素结尾的LIS序列的长度为2,且第n个元素是大于第n-1个元素的(递增的),那么以第n
个元素结尾的LIS序列的长度不就是以第n-1个元素结尾的LIS序列的长度加上1吗?
再回过头来看看这些子问题
他们中是不是含有大量重复的子问题
dp【n】:代表以第n个元素结尾的LIS序列的长度
比如我要求dp【n】,就要求dp【n-2】,dp【n-3】
在要求dp【n-1】的时候,也还要求dp【n-2】,dp【n-3】一次
这个就是求了很多次,想想当n足够大的时候,子问题足够多的时候,求的重复的子问题是不是很多很多
这样的话速度太慢
所以这个时候,dp的作用就是体现出来了,保存已经求解过的子问题的值,下次又遇到这个子问题的时
候,直接拿出来用就好啦
做法:
dp【1】=1
dp【i】=max(dp【j】+1) 要求:a【j】<a【i】,j<i
就是在第i个元素的前面找到LIS序列长度最大的,加上1,(先决条件是递增的)
贴个代码:(最优值和一个最优解)
#include<bits/stdc++.h> using namespace std; #define max_v 1005 int a[max_v],dp[max_v]; void f(int n,int result) { bool flag=false; if(n<0||result==0) return ; if(dp[n]==result) { flag=true; result--; } f(n-1,result); if(flag) printf("%d ",a[n]); } int main() { int n; while(~scanf("%d",&n)) { for(int i=0;i<n;i++) { scanf("%d",&a[i]); } memset(dp,0,sizeof(dp)); dp[0]=1; for(int i=1;i<n;i++) { int t=0; for(int j=0;j<i;j++) { if(a[j]<a[i]) { if(t<dp[j]) { t=dp[j]; } } } dp[i]=t+1; } int t=0; for(int i=0;i<n;i++) { if(t<dp[i]) { t=dp[i]; } } printf("%d ",t); f(n,t); printf(" "); } return 0; } /* 输入: 7 1 7 3 5 9 4 8 输出: 4 1 3 4 8*/
经典样例五:最大子段和问题
比如:
-2,11,-4,13,-5,-2
什么叫最大字段和?就是连续的数字的和最大是多少,注意是段,而不是序列,序列可以是离散的,而段必
须的连续的
所以这个问题dp思想体现在哪里呢?
这个问题其实跟LIS,LCS问题都差不多,都是线性dp问题
第一步:分解成很多有联系的子问题
要求以第n个元素结尾的最大字段和是多少,就要求以第n-1个元素结尾的最大字段和是多少
要求以第n-1个元素结尾的最大子段和是多少,就要求以第n-2个元素结尾的最大字段和是多少
为什么是这样呢?
仔细思考一下
以求第n-1个元素的1最大字段和为例
如果我们知道了以第n-2个元素的最大字段和是多少,如果是正的,加上第n个元素值即可,如果是负数,
那还不如不加呢,这样第n个元素的最大字段和还大一点,因为你加上一个负数肯定比原来的数小了呀
那么dp思想中的重复子问题体现在哪里呢?
体现在第一步,跟LIS问题中的体现是一样的,这里不再赘述
贴个代码:(最优解)
#include<bits/stdc++.h> using namespace std; int main() { int t; scanf("%d",&t); while(t--) { int n; scanf("%d",&n); int a[n+1]; for(int i=1;i<=n;i++) { scanf("%d",&a[i]); } int dp[n+1]; dp[1]=a[1]; for(int i=2;i<=n;i++) { int x=dp[i-1]; if(x<0) { x=0; } dp[i]=x+a[i]; } int maxvalue=dp[1]; for(int i=2;i<=n;i++) { if(maxvalue<dp[i]) { maxvalue=dp[i]; } } printf("%d ",maxvalue); } return 0; } /* 输入: 2 6 -2 11 -4 13 -5 -2 输出; 20 */
经典样例六:01背包问题
背包问题可以说是很经典的问题之一了,01背包问题,就是说每个物品只有两种选择,装还不装,且物品
不可分割
我先不讲01背包问题应该怎么做,讲01背包里面蕴含的dp思想
dp适用于多阶段决策问题,就是每个阶段都要做决策,且你做的决策会影响的最终的结果,导致最终结果的值有所不同
这个决策的概念在01背包里面用的可以说是体现的非常非常的透彻了,因为你每个阶段都要做决策呀,这
个物品我到底是选还是不选呢
声明一个 大小为 m[n][c] 的二维数组,m[ i ][ j ] 表示 在面对第 i 件物品,且背包容量为 j 时所能获得的最大价值 ,那么我们可以很容易分析得出 m[i][j] 的计算方法,
(1). j < w[i] 的情况,这时候背包容量不足以放下第 i 件物品,只能选择不拿
m[ i ][ j ] = m[ i-1 ][ j ]
(2). j>=w[i] 的情况,这时背包容量可以放下第 i 件物品,我们就要考虑拿这件物品是否能获取更大的价值。
如果拿取,m[ i ][ j ]=m[ i-1 ][ j-w[ i ] ] + v[ i ]。 这里的m[ i-1 ][ j-w[ i ] ]指的就是考虑了i-1件物品,背包容量为j-w[i]时的最大价值,也是相当于为第i件物品腾出了w[i]的空间。
如果不拿,m[ i ][ j ] = m[ i-1 ][ j ] , 同(1)
究竟是拿还是不拿,自然是比较这两种情况那种价值最大。
状态转移方程:
if(j>=w[i])
m[i][j]=max(m[i-1][j],m[i-1][j-w[i]]+v[i]);
else
m[i][j]=m[i-1][j];
代码如下:(二维dp解决01背包问题,一维dp解决01背包问题)
#include<bits/stdc++.h> using namespace std; int ZeroOnePack(int v[],int w[],int n,int c)//v1,v2....vn价值 w1,w2,w3...wn重量 n表示n个物品 c表示背包容量 { int dp[n+1][c+1]; memset(dp,0,sizeof(dp)); for(int i=1; i<=n; i++) { for(int j=0; j<=c; j++) { if(j>=w[i]) { dp[i][j]=max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);//第i个物品放入之后,那么前面i-1个物品可能会因为剩余空间不够无法放入 } else { dp[i][j]=dp[i-1][j]; } } } return dp[n][c]; } //空间优化,采用一维数组 int ZeroOnePack_improve(int v[],int w[],int n,int c)//v1,v2....vn价值 w1,w2,w3...wn重量 n表示n个物品 c表示背包容量 { int dp[c+1]; memset(dp,0,sizeof(dp)); for(int i=1; i<=n; i++) { for(int j=c; j>=0; j--) { if(j>=w[i]) dp[j]=max(dp[j],dp[j-w[i]]+v[i]); } } return dp[c]; } int main() { int t; scanf("%d",&t); while(t--) { int n,c; scanf("%d %d",&n,&c); int v[n+1],w[n+1]; for(int i=1; i<=n; i++) { scanf("%d",&v[i]); } for(int i=1; i<=n; i++) { scanf("%d",&w[i]); } // printf("%d ",ZeroOnePack(v,w,n,c)); printf("%d ",ZeroOnePack_improve(v,w,n,c)); } return 0; } /* 1 5 10 1 2 3 4 5 5 4 3 2 1 14 */
以上是关于到底什么是dp思想(内含大量经典例题,附带详细解析)的主要内容,如果未能解决你的问题,请参考以下文章