前言:大一党,小白一只,欢迎大佬来指错。
最近小编沉迷于dp(动态规划)无法自拔,刚接触时感觉是很迷啊,顺序计算,一般会逆向思维,复杂度比递归小。相比暴搜,虽然顺序思维,但逆序计算,又要备忘录、剪枝……无脑但好烦。刷了几道题后,我说不上熟悉dp,但也谈谈自习过程中的感悟吧。
自学动态规划只要熟悉三个重要概念就没什么问题了:(PS:最最最最重要的三个点)
1、最优子结构
2、边界
3、状态转移公式
很晦涩难懂对吧,先埋个伏笔。
PS:但不是最简单的哟
题目:
有一个国家发现了5座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是10人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿。要求用程序求解出,国王要想得到尽可能多的黄金,应该选择挖取哪几座金矿?
这道题我看了网上好多讲解,有个用漫画讲解的最适合dp零基础,是真的良心。
什么是动态规划?
理解:
其实国王到第五座金矿就可以知道最多黄金数了,为什么?
他可以问身边两个大臣。
一个问他:如果这座金矿不挖,其余4座可以得到的最多黄金数是多少?
另一个问:如果这座金矿一定挖,其余4座可以得到的最多黄金数是多少?
这两大臣也学国王推卸责任,分别再问身边两个手下类似的问题:如果这座金矿不挖 // 挖,其余3座可以得到的最多黄金数是多少?
等等,以此类推。
直到人不够或到第一个金矿。
最多2的5次方的人问下来,不就知道了吗。(所以不做备忘录,复杂度为2的n次方)。
当然做备忘录,可以达到O(n)的复杂度。
步骤
第一步:确定dp数组的含义
maxGold[i][j]保存了i个人挖前j个金矿能够得到的最大金子数。
第二步:确定边界
第三步:关于dp数组的关系式,就是状态转移公式。
在这题中,之前说的三个重要概念分别指什么?
1、最优子结构:maxGold[people][mineNum]
2、边界 :mineNum == 0 如果仅有一个金矿时
3、状态转移公式:
分类:
(1)如果给出的人够开采这座金矿,考虑开采与不开采两种情况,取最大值
retMaxGold = max(GetMaxGold(people -peopleNeed[mineNum],mineNum -1) + gold[mineNum],GetMaxGold(people,mineNum - 1));
(2)给出的人不够开采这座金矿,仅考虑不开采的情况
retMaxGold = GetMaxGold(people,mineNum - 1);
如果所有数未知需要输入,下面详细的通用的代码。
#include
using namespace std;
typedef long long ll;
const int max_n = 100;//程序支持的最多金矿数
const int max_people = 10000;//程序支持的最多人数
int n;//金矿数
int peopleTotal;//可以用于挖金子的人数
int peopleNeed[max_n];//每座金矿需要的人数
int gold[max_n];//每座金矿能够挖出来的金子数
int maxGold[max_people][max_n];
//maxGold[i][j]保存了i个人挖前j个金矿能够得到的最大金子数,等于-1时表示未知
void init(){ //初始化数据
cin>>peopleTotal>>n;
for(int i;i<n;i++) cin>>peopleNeed[i]>>gold[i];
for(int i=0; i<=peopleTotal; i++)
for(int j=0; j<n; j++)
maxGold[i][j] = -1;
//等于-1时表示未知 [对应动态规划中的“做备忘录”]
}
//获得在仅有people个人和前mineNum个金矿时能够得到的最大金子数,
//注意“前多少个”也是从0开始编号的
int GetMaxGold(int people, int mineNum){
int retMaxGold; //申明返回的最大金子数
if(maxGold[people][mineNum] != -1){ //备忘录
retMaxGold = maxGold[people][mineNum]; //获得保存起来的值
}
else if(mineNum == 0){//如果仅有一个金矿时 [对应动态规划中的“边界”]
if(people >= peopleNeed[mineNum]){ //当给出的人数足够开采这座金矿
retMaxGold = gold[mineNum]; //得到的最大值就是这座金矿的金子数
}
else{ //否则这唯一的一座金矿也不能开采
retMaxGold = 0;
}
}
//如果给出的人够开采这座金矿
else if(people >= peopleNeed[mineNum]){
//考虑开采与不开采两种情况,取最大值
retMaxGold = max(GetMaxGold(people - peopleNeed[mineNum],mineNum -1) + gold[mineNum],
GetMaxGold(people,mineNum - 1));
}
else{//否则给出的人不够开采这座金矿
//仅考虑不开采的情况
retMaxGold = GetMaxGold(people,mineNum - 1);
}
maxGold[people][mineNum] = retMaxGold; //做备忘录
return retMaxGold;
}
int main(){
init();
//输出给定peopleTotal个人和n个金矿能够获得的最大金子数,再次提醒编号从0开始,所以最后一个金矿编号为n-1
cout<<GetMaxGold(peopleTotal,n-1);
return 0;
}
不想多说。因为有篇文章讲的太详细了。
数字塔问题及优化
在这里,暂时告别dp的记忆递归法,dp主要还是用递推式。
dp数组很少是一维的(变量单一),一般都是二维(有两个变量,有时还要相互作比较max,min,有公式什么的),这里的两个变量分别表示题目中的最重要的两个变化的值,最后的值一定是题目所求的含义相关(一般是跟题目含义一模一样,也有的要适当变化)。
看题吧。
1分,2分,3分硬币,将钱N兑换成硬币有多少种兑法?
Input
每行只有一个正整数N,N小于32768。
Output
对应每个输入,输出兑换方法数。
Sample Input
2934
12553
Sample Output
718831
13137761
分析:
确定dp数组含义:变量只有一个,一维数组,再根据题意,dp[n]一定指钱N兑换成硬币有多少种兑法。
这题先感受一下递推式:
i = 1: 从dp[1] 到 dp[MAXN] 都是 1
因为i = 1时代表只能选1分的硬币,自然只有一种。
i = 2:dp数组递增。因为当前可以选2分,种类数越多了。
同理i=3.
1、最优子结构:dp[n]
2、边界 :dp[0]=1
3、状态转移公式:dp[j] = dp[j] + dp[j-i];
类似如果换成1分,3分,7分。可以储存进一个数组a[3],代码里的一些i改成a[i]就可以了。状态转移公式变成:dp[j] = dp[j] + dp[j-a[i]];
这只是个热身。
//1分,2分,3分硬币,将钱N兑换成硬币有多少种兑法
#include
using namespace std;
typedef long long LL;
const int inf = (int) 1e9+7;
const int MAXN = 32767+10; //假设 N<32767
LL dp[MAXN];
int main(){
int n;
const int m = 3;
memset(dp, 0, sizeof(dp));
dp[0] = 1;
// dp[1] = 1; //错误,下面dp[j-i]会有出入.
for( int i=1; i<=m; i++ ){
//递推式循环哪里外层从1到3,代表三种硬币,
//同时这三种硬币的价值等于他们的序号。
for( int j=i; j<=MAXN; j++ ) //内层j代表的是j分钱
dp[j] = dp[j] + dp[j-i];
}
while( scanf("%d", &n) != EOF ){
printf("%d\n", dp[n]);
}
return 0;
}
题目:
设有字符串X,我们称在X的头尾及中间插入任意多个空格后构成的新字符串为X的扩展串,如字符串X为”abcbcd”,则字符串“abcb□cd”,“□a□bcbcd□”和“abcb□cd□”都是X的扩展串,这里“□”代表空格字符。
如果A1是字符串A的扩展串,B1是字符串B的扩展串,A1与B1具有相同的长度,那么我扪定义字符串A1与B1的距离为相应位置上的字符的距离总和,而两个非空格字符的距离定义为它们的ASCII码的差的绝对值,而空格字符与其他任意字符之间的距离为已知的定值K,空格字符与空格字符的距离为0。在字符串A、B的所有扩展串中,必定存在两个等长的扩展串A1、B1,使得A1与B1之间的距离达到最小,我们将这一距离定义为字符串A、B的距离。
请你写一个程序,求出字符串A、B的距离。
【输入】
输入文件第一行为字符串A,第二行为字符串B。A、B均由小写字母组成且长度均不超过2000。第三行为一个整数K(1≤K≤100),表示空格与其他字符的距离。
【输出】
输出文件仅一行包含一个整数,表示所求得字符串A、B的距离。
【样例】
blast.in blast.out
cmc 10
snmn
2
预估(废话):
字符串A和B的扩展串最大长度是A和B的长度之和。如字符串A为“abcbd”,字符串B为“bbcd”,它们的长度分别是la=5、lb=4,则它们的扩展串长度最大值为LA+LB=9,即A的扩展串的5个字符分别对应B的扩展串中的5个空格,相应B的扩展串的4个字符对应A的扩展串中的4个空格。例如下面是两个字符串的长度为9的扩展串:
a□b c□b□d□
□b□□b□c□d
而A和B的最短扩展串长度为la与lb的较大者,下面是A和B的长度最短的扩展串:
a b cbd
b□bcd
因此,两个字符串的等长扩展串的数量是非常大的,寻找最佳“匹配”(对应位置字符距离和最小)的任务十分繁重,用穷举法无法忍受,何况本题字符串长度达到2000,巨大的数据规模,只能用有效的方法:动态规划。
文字分析(比较繁琐):
记
这两个扩展串形成最佳匹配的条件是(1)长度一样;(2)对应位置字符距离之和最小。
首先分析扩展串
(1)
(2)
(3)
其次,如何使扩展成等长的这两个扩展串为最佳匹配,即对应位置字符距离之和最小,其前提是上述三种扩展方法中,被扩展的三对等长的扩展串都应该是最佳匹配,以这三种扩展方法形成的等长扩展串(A1, A2, …, Ai>和
将上面所有文字换成公式
记g[i, j]为字符串A的子串A1, A2, …, Ai与字符串B的子串B1, B2, …, Bj的距离.
1、 g[i][j]表示以i,j为两个序列的末尾的最小值。
2、则有下列状态转移方程:
如果a[i-1,j-1]==b[i-1,j-1] , g[i,j]=g[i-1,j-1];
否则
g[i, j]=Min{g[i-1, j]+k, g[i, j-1]+k, g[i-1, j-1]+ a[i]b[i] } 0≤i≤La 0≤j≤Lb
其中,k位字符与字符之间的距离;a[i]b[i] 为字符ai与字符bi的距离。
3、初始值(边界):g[0, 0]=0 g[0, j]=jk g[i, 0]=ik
#include
using namespace std;
typedef long long ll;
int main(){
string a,b;
int k;
getline(cin,a);
getline(cin,b);
cin>>k;
int t1=a.size();
int t2=b.size();
int dp[t1+1][t2+1];
//边界考虑
for(int i=0;i<=t1;i++) dp[i][0]=i*k;
for(int j=0;j<=t2;j++) dp[0][j]=j*k;
//递推
for(int i=1;i<=t1;i++){
for(int j=1;j<=t2;j++){
if(a[i-1]==b[j-1]) dp[i][j]=dp[i-1][j-1];
else
dp[i][j]=min(dp[i-1][j-1]+abs(a[i-1]-b[j-1]),min(dp[i-1][j],dp[i][j-1])+k);
}
}
cout<<dp[t1][t2]<<endl;
}
之前的dp都是顺序求出,dp数组(最优子结构)很好找。这题dp数组的含义挺难找到的,得逆向思维。
题目:
尼克每天上班之前都连接上英特网,接收他的上司发来的邮件,这些邮件包含了尼克主管的部门当天要完成的全部任务,每个任务由一个开始时刻与一个持续时间构成。
尼克的一个工作日为N分钟,从第一分钟开始到第N分钟结束。当尼克到达单位后他就开始干活。如果在同一时刻有多个任务需要完戍,尼克可以任选其中的一个来做,而其余的则由他的同事完成,反之如果只有一个任务,则该任务必需由尼克去完成,假如某些任务开始时刻尼克正在工作,则这些任务也由尼克的同事完成。如果某任务于第P分钟开始,持续时间为T分钟,则该任务将在第P+T-1分钟结束。
写一个程序计算尼克应该如何选取任务,才能获得最大的空暇时间。
【输入】
输入数据第一行含两个用空格隔开的整数N和K(1≤N≤10000,1≤K≤10000),N表示尼克的工作时间,单位为分钟,K表示任务总数。
接下来共有K行,每一行有两个用空格隔开的整数P和T,表示该任务从第P分钟开始,持续时间为T分钟,其中1≤P≤N,1≤P+T-1≤N。
【输出】
输出文件仅一行,包含一个整数,表示尼克可能获得的最大空暇时间。
【样例】
lignja.in
15 6
1 2
1 6
4 11
8 5
8 1
11 5
lignja.out
4
【算法分析】
1≤K≤10000。采用穷举方法显然是不合适的。
根据求最大的空暇时间这一解题要求,先将K个任务放在一边,以分钟为阶段,设置minute[i]表示从第i分钟开始到最后一分钟所能获得的最大空暇时间,决定该值的因素主要是从第i分钟起到第n分钟选取哪几个任务,与i分钟以前开始的任务无关。由后往前逐一递推求各阶段的minute值:
(1)边界(初始值)minute[n+1]=0
(2)对于minute[i],在任务表中若找不到从第i分钟开始做的任务,则minute[i]比minute[i+1]多出一分钟的空暇时间;若任务表中有一个或多个从第i分钟开始的任务,这时,如何选定其中的一个任务去做,使所获空暇时间最大,是求解的关键。下面我们举例说明。
设任务表中第i分钟开始的任务有下列3个:
任务K1 P1=i T1=5
任务K2 P2=i T2=8
任务K3 P3=i T3=7
而已经获得的后面的minute值如下:
minute[i+5]=4,minute[i+8]=5,minute[i+7]=3
若选任务K1,则从第i分钟到第i+1分钟无空暇。这时从第i分钟开始能获得的空暇时间与第i+5分钟开始获得的空暇时间相同。因为从第i分钟到i+5-1分钟这时段中在做任务K1,无空暇。因此,minute[i]=minute[i+5]=4。
同样,若做任务K2,这时的minute[i]=minute[i+8]=5
若做任务K3,这时的minute[i]=minute[1+7]=3
显然选任务K2,所得的空暇时间最大。
因此,有下面的状态转移方程:
下面是题目所给样例的minute值的求解。
注:选任务号为该时刻开始做的任务之一,0表示无该时刻开始的任务。
最优子结构:问题所求得最后结果为从第1分钟开始到最后时刻所获最大的空暇时间minute[1]。
//逆向dp
#include
using namespace std;
typedef long long ll;
const int maxn=1e4+2;
typedef struct p{
int start,duration; //分别表示开始时间和持续的时间
}p1;
p1 sz[maxn];
int dp[maxn],count[maxn];
bool cmp(p1 a,p1 b){
return a.start>b.start;
}
int main(){
int n,k;
cin>>n>>k;
for(int i=0;i<k;i++) cin>>sz[i].start>>sz[i].duration;
sort(sz,sz+k,cmp);
//dp[i]表示从时间 i 开始的最大空闲时间
int ans=0;
for(int i=n;i>0;i--){
//该时间无任务,那么空暇时间为 上一时间加1
if(sz[ans].start!=i) dp[i]=dp[i+1]+1;
else{
//该时间有任务,那么空暇时间为 当前任务结束时间的最大空余时间
//比较dp[i]的原因:当前时间点可能有多个任务
while(sz[ans].start==i){
dp[i]=max(dp[i],dp[i+sz[ans].duration]);
ans++; //下一个任务
}
}
}
cout<<dp[1]<<endl;
}
问题一:最大字段和(dp必看题)
有一由n个整数组成的序列A={a1,a2,…an,},截取其中从i−j的子段并计算字段和,那么最大的字段和为多少?
一维的话,和数字塔差不多。不难理解,网上还有分治法(二分),暴力,常规等等。自己去找吧。这里我直接给代码。
#include
using namespace std;
typedef long long ll;
int main(){
int n;
cin>>n;
int *a;
a=(int*)malloc((n)*sizeof(int));
for(int i=0;i<n;i++) cin>>a[i];
int maxsum=0,temp=0;
for(int i=0;i<n;i++){
temp+=a[i];
if(temp>maxsum) maxsum=temp;
else if(temp<0) temp=0;
}
cout<<maxsum<<endl;
free(a);
}
核心代码的下面这种写法更常用:
int maxsum(int *a,int n)
{
int b=0,sum=0;
for(int i=1;i<=n;i++)
{
if(b>0) b+=a[i];
else b=a[i];
if(b>sum) sum=b;
}
return sum;
}
还有同时可以记录最大字段和的首尾位置:
//在找最优值的时候记录两个端点位置:
const int maxn=110;
int a[maxn];
int n;
int maxsum(int n, int *a, int &left,int &right){
int ret=0;
int dp=0;
int l=0,r=0;
for(int i=0; i<n; i++){
if (dp>0) {dp+=a[i]; r++;}
else {dp=a[i]; l=i; r=l;}
if (dp>=ret){
ret=dp;
left=l;
right=r;
}
}
return ret;
}
int main()
{
while(scanf("%d",&n)!=-1)
{
for(int i=0; i<n; i++) scanf("%d",&a[i]);
int left=-1,right=-1; //-1表示没有子段可以取
int ans=maxsum(n,a,left,right);
printf("最大子段和为%d 起始位置为%d 终止位置为%d\n",ans,left,right);
}
return 0;
}
难点是延伸到二维的最大字段和问题——最大子矩阵问题:
问题概述:
给出一个n*n的矩阵,每个点都有一个权值,
现在要从中选取一个子矩阵要求权值和最大,问这个最大权值和是多少。
测试案例
4
0 -2 -7 0
9 2 -6 2
-4 1 -4 1
-1 8 0 -2
答案:15
最好自己逐个调试一下,看一下过程,讲解有点多,但本质没什么变化。完全熟悉最大字段和后不难理解。
#include
//题目大意:给出一个n*n的矩阵,每个点都有一个权值,
//现在要从中选取一个子矩阵要求权值和最大,问这个最大权值和是多少
using namespace std;
typedef long long LL;
const int inf=0x3f3f3f3f;
const int N=110;
int n;
int maze[N][N];//维护最初的矩阵
int sum[N];//维护的一维矩阵
int solve()//最大连续子段和
{
int tempmax=-inf;//答案
int temp=0;//当前的子段和
for(int i=1;i<=n;i++)
{
if(temp<0)//不要前面的
temp=sum[i];
else//要前面的
temp+=sum[i];
tempmax=max(tempmax,temp);//实时更新答案
}
return tempmax;
}
int main(){
while(scanf("%d",&n)!=EOF){
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
scanf("%d",&maze[i][j]);
int ans=-inf;
for(int i=1;i<=n;i++)//行起点
{
memset(sum,0,sizeof(sum));
for(int j=i;j<=n;j++)//行终点
{
for(int k=1;k<=n;k++)
sum[k]+=maze[j][k];
ans=max(ans,solve());
}
}
printf("%d\n",ans);
}
return 0;
}
暂时到这里,小编正在准备dp例题第二章的内容,如果大家有什么经典题、好题的话,下面讨论区发个链接,加个说明,一起汇总给大家。
PS:基本每个代码都加了说明,有点累啊。