动态规划入门题集合

用例题逐步剖析动态规划

前言:大一党,小白一只,欢迎大佬来指错。

最近小编沉迷于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 <bits/stdc++.h>
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数组的含义

dp数组很少是一维的(变量单一),一般都是二维(有两个变量,有时还要相互作比较max,min,有公式什么的),这里的两个变量分别表示题目中的最重要的两个变化的值,最后的值一定是题目所求的含义相关(一般是跟题目含义一模一样,也有的要适当变化)。

看题吧。

  • (1)钱币兑换问题(两层递推+一维数组)

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 <bits/stdc++.h>
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;
}

  • (2)字串距离(中规中矩的dp题)

题目
设有字符串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,巨大的数据规模,只能用有效的方法:动态规划。
文字分析(比较繁琐):
记<A1, A2, …, Ai>为A串中A1到Ai的一个扩展串,<B1, B2, …, Bj>为B串中B1到Bj的一个扩展串。
这两个扩展串形成最佳匹配的条件是(1)长度一样;(2)对应位置字符距离之和最小。
首先分析扩展串<A1, A2, …, Ai>与扩展串<B1, B2, …, Bj>长度一样的构造方法。扩展串<A1, A2, …, Ai>与扩展串<B1, B2, …, Bj>可以从下列三种情况扩张成等长:
(1)<A1, A2, …, Ai>与<B1, B2, …, Bj-1>为两个等长的扩展串,则在<A1, A2, …, Ai>后加一空格,<B1, B2, …, Bj-1>加字符Bj;
(2)<A1, A2, …, Ai-1>与<B1, B2, …, Bj>为两个等长的扩展串,则在<A1, A2, …, Ai-1>添加字符Ai,在<B1, B2, …, Bj>后加一空格;
(3)<A1, A2, …, Ai-1>与<B1, B2, …, Bj-1>为两个等长的扩展串,则在<A1, A2, …, Ai-1>后添加字符Ai,在<B1, B2, …, Bj-1>后添加字符Bj。
其次,如何使扩展成等长的这两个扩展串为最佳匹配,即对应位置字符距离之和最小,其前提是上述三种扩展方法中,被扩展的三对等长的扩展串都应该是最佳匹配,以这三种扩展方法形成的等长扩展串(A1, A2, …, Ai>和<B1, B2, …, Bj>也有三种不同情形,其中对应位置字符距离之和最小的是最佳匹配。

将上面所有文字换成公式
记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 <bits/stdc++.h>
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;
}
  • (3)尼克的任务(逆向dp经典例题)

之前的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,所得的空暇时间最大。
因此,有下面的状态转移方程:
其中,Tj表示从第i分钟开始的任务所持续的时间。

下面是题目所给样例的minute值的求解。

在这里插入图片描述
在这里插入图片描述
注:选任务号为该时刻开始做的任务之一,0表示无该时刻开始的任务。
最优子结构:问题所求得最后结果为从第1分钟开始到最后时刻所获最大的空暇时间minute[1]。

//逆向dp 
#include <bits/stdc++.h>
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;
}
  • (4)最大字段和问题(一维)与最大子矩阵和(二维)

问题一:最大字段和(dp必看题)
有一由n个整数组成的序列A={a1,a2,…an,},截取其中从i−j的子段并计算字段和,那么最大的字段和为多少?

一维的话,和数字塔差不多。不难理解,网上还有分治法(二分),暴力,常规等等。自己去找吧。这里我直接给代码。

#include <bits/stdc++.h>
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 <bits/stdc++.h>
//题目大意:给出一个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:基本每个代码都加了说明,有点累啊。

猜你喜欢

转载自blog.csdn.net/weixin_45606191/article/details/103511132