动态规划Dynamic Programming 简单总结

最近在看DP有关的问题,还是感觉得总结总结才行,古人云:温故而知新嘛。

一、什么是DP

DP常用于求解最优问题,类似与分治法,基本思想是:
将待求问题分解为若干子问题,从子问题中寻找原问题的解。
但是不同的是,DP分解得到的子问题往往较多,而且有些子问题会被多次重复计算。
DP法将已解决的子问题的答案保存下来,避免了重复运算,提高效率。

而DP算法一般有三种:

1.记忆化搜索

对于一些递归形式的解答,由于递归深度搜索时,可能会多次计算重复值,使用DP记录已经计算的值,能大大减少时间复杂度。
举例,最常见的0/1背包问题
有n个价值为vi,重量为wi的不同物品,从其中挑选重量不超过W的物体,求最大的总价值。
这个问题可以用递归解:

//递归解 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]

//挑选到第i个物品,剩余重量为j
int solve(int i,int j){
	int res;
	if(i == n) 
		res = 0; //没有物品了
	if(j < w[i]){  //无法装下第i个物品
		res = solve(i + 1,j);
	}
	else {  //能装下,尝试一下装或者不装,取大的
		res = max(res(i + 1,j),res(i + 1,j - w[i]) + v[i]);
	}
	return res;
}
//尽管递归的思想很棒,但是每次搜索都会有两次分支,而且搜索深度为n,时间复杂度最坏为o(2^n)
//一旦n较大,妥妥地TLE

那么如何优化递归呢?
看下图,我们发现solve(3,2)递归计算了多次,第二次计算时,明明已经知道结果了还白白计算,造成时间上的消耗,于是DP的记忆化搜索的特点就体现了:记录第一次计算的值。
在这里插入图片描述
修改一下我们之前的递归:

//记忆化搜索 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]
int dp[MAX_N+1][MAX_W+1];

// -1代表没计算过
memset(dp,-1,sizeof(dp));

//从第i个物品开始挑选,总重量小于j
int solve(int i,int j){
	if(dp[i][j] >= 0){
		return dp[i][j]; //计算过的直接返回
	}
	int res;
	if(i == n) 
		res = 0; //没有物品了
	if(j < w[i]){  //无法装下第i个物品
		res = solve(i + 1,j);
	}
	else {  //能装下,尝试一下装或者不装,取大的
		res = max(res(i + 1,j),res(i + 1,j - w[i]) + v[i]);
	}
	return dp[i][j] = res; //这里存储第一次计算结果
}
//简单的记录了计算结果,复杂度为o(nW)(因为组合数不过nW种),第二次执行相同递归都会直接返回

2.递推关系式下的DP

仔细观察之前的记忆搜索过程,我们发现,
记 dp[i][j] 为 从第i个物品开始挑选,总重量小于j, 有如下递推式:
dp[i][j] =
借助递推式,我们可以抛弃递归函数,用for循环将dp全部计算出来:

//递推式 0 / 1背包
int n,W;
int v[MAX_N],w[MAX_N]
int dp[MAX_N+1][MAX_W+1];

// 初始化,其实全局变量一般默认int 为0
memset(dp,0,sizeof(dp));

//从第i个物品开始挑选,总重量小于j
int solve(int i,int j){
   for(int i = n - 1;i  >= 0;--i){  //从第n个物品开始
   		for(int j = 0,j <= W;++j){  //从总重量小的开始
   			if(j < w[i]){  //无法装下第i个物品
   				dp[i][j] = dp[i+1][j];
   			}
   			else {  //能装下,尝试一下装或者不装,取大的
   				dp[i][j] = max(dp[i+1][j]),dp[i+1][j - w[i]] + v[i]);
   			}
   		}	
   }
   printf("%d\n",dp[0][W]);
}
//复杂度为o(nW)(因为组合数不过nW种),较递归形式简洁了许多

对于同一题,dp [i][j] 的含义也多种多样,比如 0 / 1 背包中还可以这样定义dp:
在这里插入图片描述
根据不同的递推式,计算时开始的循环方向也不一致,上面的就是i从0到n-1的方向…

二、经典的DP问题

尽管DP的思想,我们看起来、理解起来不是太难,但是不去体会一些经典的例题,对于初学DP的人来,说想要一下子得到递推式,实在还是有点困难,以下根据一些资料整理了一些比较经典的DP
1. LCS(Longest Common Subsequence) ——两字符串的最长公共子序列
2. 完全背包问题
3. 多重部分和问题
4. LIS (Longest Incresing Subsequence) ——某字符串的最长上升子序列
5. 划分数
6. 多重集组合数

让我们一个一个来:

1.LCS(Longest Common Subsequence) ——两字符串的最长公共子序列

事先说明:子序列是指字符串 s1 s2 s3 … sn 得到的保持原元素前后关系,但是可以跳过某些元素的子序列,举例: abced 与 bacd 它们都有子序列 bcd.

现在的问题是,给我们两个字符串,两字符串的最长公共子序列的长度是多少?
这显然可以分解为很多个子问题,不妨设:
在这里插入图片描述在这里插入图片描述
不难得出递推式为:
在这里插入图片描述
其中下式实际上就等于 dp [ i ] [ j ] + 1,因为 dp[i][j+1] 和 dp[i+1][j] 不可能大于 dp[i][j] 部分:
在这里插入图片描述
代码实现:

int n,m; 
char s1[MAX_N],s2[MAX_M];
int dp[MAX_N + 1][MAX_M + 1];//默认为0

void solve(){
	for(int i = 0;i < n;++i){
		for(int j = 0;j < m;++j){
			if(s1[i] == s2[j]){
				dp[i+1][j+1] = dp[i][j] + 1;
			}else{
				dp[i+1][j+1] = max(dp[i+1][j],dp[i][j+1]);
			}
		}
	}
	printf("%d\n",dp[n][m]);
}

2.完全背包问题

在0 / 1背包的基础上,每件物品可以选多件!
我们很容易在0 / 1 背包的基础上得到:
这个图片的递推式少了一个i
这个图片的递推式少了一个i…
尝试实现以下:

int dp[MAX_N+1][MAX_M+1];
void solve(){
	for(int i = 0;i < n;++i){
		for(int j = 0;j <= W;++j){
			for(int k = 0;k * w[i] <= j;++k){
				dp[i+1][j] = max(dp[i+1][j],dp[i][j - k * w[i]] + v[i] * k);
			}
		}
	}
}
//最坏算法复杂度为O(nW * W),不够友好

实际上我们发现:
在dp [ i+1 ][ j ] 中 计算 dp[ i ] [ j - k * w[i] ] + v[i] * k (k >=1 )
实际上跟在 dp [ i+1 ] [ j - w[i] ] 中 计算 dp[ i ] [ j - w[i] - (k-1) * w[i] ] + v[i] * (k-1) (k >=1 )是一样的,
那么我们进行一些变形有:
在这里插入图片描述
这样我们就省去了k的循环,复杂度又变为O(nW)
实现一下:

int dp[MAX_N+1][MAX_M+1];
void solve(){
	for(int i = 0;i < n;++i){
		for(int j = 0;j <= W;++j){
			if(j < w[i]){
				dp[i+1][j] = dp[i][j];
			}
			else dp[i+1][j] = max(dp[i][j],dp[i+1][j - w[i]] + v[i]);
		}
	}
}

其实我们可以用一个一维数组来维护dp,节省空间:
对于 0 / 1背包:

// dp[i][j] = max( dp[i-1][j],dp[i-1][j-w[i]] + v[i]) 发现dp[i][j]只跟上一行有关
int dp[MAX_W+1];
void solve(){
	for(int i = 0;i < n;++i){  //一件一件来
		for(int j = W;j >= w[i];--j){  
		//逆序是确保状态正确转移,即求dp[j]前dp[j - w[i]]还没更新,停留在前i-1个物体的状态
			dp[j] = max(dp[j],dp[j - w[i]] + v[i]); //不装和装
		}
	}
	printf("%d\n",dp[W]);
}

对完全背包:

// dp[i][j] = max( dp[i-1][j],dp[i][j-w[i]] + v[i]) 发现dp[i][j]只跟上下两行有关
// 完全跟0/1的区别就是 j-w[i]时完全还停在i而0/1则转移到前i-1了
int dp[MAX_W+1];
void solve(){
	for(int i = 0;i < n;++i){
		for(int j = w[i];j <= W;++j){ 
		//正序是确保状态正确转移,即求dp[j]前dp[j - w[i]]已经更新,停留在前i个物体的状态
			dp[j] = max(dp[j],dp[j - w[i]] + v[i]); //不装和装
		}
	}
	printf("%d\n",dp[W]);
}

两者差异只有循环方向上的差异·····虽然一维看起来简单,但是用不好就容易产生bug,需要谨慎使用

多重背包

这里还需要说明背包问题的另一种,叫做多重背包,它介于0/1背包和完全背包之前,差别在于:
它的每个背包有确定的数量,不都是1,也不能任意取。
我们解决多重背包一般有两种思路:
第一种就是二进制优化,转换成01背包

例如:一件物品有26件,每一件的权值是w,26可以写成(1+2+4+8)+11,所以就把这种物品分解成权重为w,2w,4w,8w,11w的五种物品,这五种物品组合,一定能组成小于等于26的任意一个数,这样就把有26件的一种物品换成了五种各有一件的物品,多重背包用0/1背包就能解决了。

实现代码:

int dp[MAX_W+1];
struct Wooden{
	int h,a,c; //价值,重量,数量
};
Wooden wooden[MAX_K];
Wooden wooden_01[MAX_K * N];

void solve(){
	for(int i = 0;i < K;++i){ 
		int num = wooden[i].c; // 背包的数量
		for(int j = 1;j <= num;j *= 2){ //2进制拆分
			wooden_01[number01].a = j * wooden[i].a; //重量组合
			wooden_01[number01++].h = j * wooden[i].h; //价值组合 	
			num -= j;		
		}
		if(num > 0){ //1 2 4 6 8 ..... 最后 num = num - 2^c + 1
			wooden_01[number01].a = j * wooden[i].a;
			wooden_01[number01++].h = num * wooden[i].h; //长度组合
		}
	}
	fill(dp,dp+MAX_A,0); 
	int max_highest = 0; 
	for(int i = 0;i < number01;++i){  //对0/1的每个背包 
		for(int j = W;j >= wooden_01[i].a;--j){
			dp[j] = max(dp[j],dp[j - wooden_01[i].a] + wooden_01[i].h); //不装和装
		}
	}
	printf("%d\n",dp[W]);
}

另一种方法是维护一个数组num, 类似完全背包
num [ i ] [ j ] 表示在往背包里试着装第i件物品时,背包容量使用了j时,装了多少件i物品。
w [ i ] 为物品 i 的重量,则有
num [ i ] [ j ] = num [ i ] [ j - w [ i ] ] +1;
可以看到 num [i][j] 只跟这一行有关系,可以用一维数组优化

实现代码:

int wi[MAX_N]; //重量
int vi[MAX_N];  //价值
int ni[MAX_N]; //数量
int dp[MAX_W];
int num[MAX_W];

void solve(){
	for(int i = 0;i < n; ++i)
	{
	    memset(num,0,sizeof(num));
	    memset(dp,0,sizeof(dp));
	    for(int j = wi[i];j <= W;++j) 
	    //注意状态转移num [ i ] [ j ] = num [ i ] [ j - w [ i ] ] +1;,正向更新
	    {
	        if(dp[j-wi[i]] + vi[i] > dp[j] && num[j-wi[i]] < ni[i]) //装且有剩的能装装
	        {
	            dp[j] = dp[j-wi[i]] + vi[i];
	            num[j] = num[j-wi[i]] + 1;
	        }
	    }
	}
	printf("%d\n",dp[W]);
}

3.多重部分和问题

有n个不同物品,分别有不同的价值和数量,判断是否可以从这些物体中选出组合恰好让其价值和为K
我们很容易想到:
在这里插入图片描述
在这里插入图片描述
但是这样求解太耗时了,而且效率上也很差,只是得到了一个bool数组。
我们可以更改一下dp定义:
在这里插入图片描述
有递推式:
在这里插入图片描述
最终看dp[n][K]>=0就是是否有解了
实现:

int dp[MAX_K + 1];

void solve(){
	memset(dp,-1,sizeof(dp));
	dp[0] = 0;
	for(int i = 0; i < n;++i){
		for(int j = 0;j <= K; ++j){
			if(dp[j] >= 0){
				dp[j] = m[i];
			}else if(j < a[i] || dp[j - a[i]] <= 0){
				dp[j] = -1;
			}else {
				dp[j] = dp[j - a[i]] - 1;
			}
		}
	}
	if(dp[K] >= 0) printf("Yes\n);
}

4.LIS (Longest Incresing Subsequence) ——某字符串的最长上升子序列

给定某字符串s1 s2 s3…sn,求其最长的上升子序列
这个dp定义我们可能想类似于LCS的做法:
在这里插入图片描述
很容易得到:
在这里插入图片描述
写出递推式就是:
在这里插入图片描述
实现代码:

int n;
char a[MAX_N];
int dp[MAX_N];

//O(n^2)
void solve(){
	int res = 0;
	for(int i = 0;i < n;++i){
		dp[i] = 1;//初始化为只包含ai
		for(int j = 0;j < i;++j){
			if(a[i] > a[j]){
				dp[i] = max(dp[i],dp[j] + 1);
			}
		}
		res = max(res,dp[i]);
	}
	printf("%d\n",res);
}

可能有人觉得上面的dp时间上还是比较复杂,我们还有一种新鲜的优化:
我们设dp为:
在这里插入图片描述
对同等长度的LIS来说,末尾元素越小越有优势;
那么我们的递推关系为:
从前往后考虑元素,对每个aj, 当 i == 0 || dp[i - 1] < aj,有dp[i] = min(dp[i],aj)
最终我们只需要找出使得:dp[i] < INF的最大的i 就得出结果了,至于更新和查找,用二分搜索即可。

实现代码:

#include<algorithm>
//时间复杂度为O(nlogn)
int n,INF;
int dp[MAX_N];
void solve(){
	fill(dp,dp+n,INF);
	for(int i = 0;i < n;++i){
		*lower_bound(dp,dp+n,a[i]) = a[i]; //对同等长度的LIS来说,末尾元素越小越有优势
    //*lower_bound(dp,dp+n,a[i]) 从dp到dp+n已经排好序的序列中找到第一个大于等于a[i]的指针
	}
	printf("%d\n",lower_bound(dp,dp+n,INF)-dp);//取index
}

在这里插入图片描述

5.划分数

将n个相同的物品 分为 不超过 m组,总共有多少中分法?
一般根据题目的两个已知变量,我们常常会定义子问题为:
在这里插入图片描述
然后大家容易想到先取出k个,剩余j-k个分为i- 1组,有:
在这里插入图片描述
但是这样不正确!Why?
因为这样的话, 1 + 1 + 2 会认为 跟 1 + 2 + 1不一样,导致重复计算!!!

我们改良递推式为如下形式:
在这里插入图片描述
dp[ i ] [ j - i ] 代表:i 组每组都少放一个
dp[ i - 1 ] [ j ] 代表:让某一组为空
理由是:对于dp[ i ] [ j ] 其分组,分组组值为 ai,
若任意 ai > 0,ai -1 对应 dp[ i ] [ j - i ] ,真正意义上的划分i组(不为空,每组最小为1)
若存在 ai == 0 ,对应dp[ i - 1 ] [ j ] ( j 的 i -1划分),某种程度上的划分小于 i组(至少有一组为空)

实现:

int n,m;
int dp[MAX_M+1][MAX_N+1];
void solve(){
	dp[0][0] = 1;
	for(int i = 1; i <= m;++i){
		for(int j = 0;j <= n;++j){
			if(j - i >= 0 ){
				dp[i][j] = dp[i - 1][j] + dp[i][j - i];
			}else {
				dp[i][j] = dp[i - 1][j];
			}
		}
	}
	printf("%d\n",dp[m][n]);
}

6.多重集组合数

有n种不同物品,每种物品有数量Ai个,同类不可区分,不同类可区分,从其中取m个物品有多少种取法?
定义子问题为:
在这里插入图片描述
递推关系为:
在这里插入图片描述
可以直接用上式计算,时间复杂度最坏为O(n * m^2) ;

不过由于我们有:
dp[i+1][j] = dp[i][j] + dp[i][j-1]+…+dp[i][j−ai] ,
dp[i+1][j−1] = dp[i][j−1] +… + dp[i][j−ai−1]
所以dp[i+1][j]=dp[i+1][j-1] + dp[i][j] - dp[i][j-ai-1]
在这里插入图片描述
实现:

int n,m;
int a[MAX_N];
int dp[MAX_N+1][MAX_M+1];
void solve(){
	//一个都不取
	for(int i = 0;i <= n; ++i){
		dp[i][0] = 1;
	}
	for(int i = 0;i < n; ++i){
		for(int j = 1;j <= m;++j){
			if(j - 1 - a[i] >= 0){
				dp[i+1][j] = dp[i+1][j-1] + dp[i][j] - dp[i][j - 1 - a[i]];
			}else {
				dp[i+1][j] = dp[i+1][j-1] + dp[i][j];
			}
		}
	}
	printf("%d\n",dp[n][m]);
}

以上就是我初步对DP的一些总结,参考了《挑战程序设计竞赛》的部分资料,如果文章有误,还请指出~

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值