一篇动态规划 全解(dp模型整理&背包九讲)

一、啥是动态规划

动态规划和贪心一样是求最优解的一种思想、方法,而不像搜索或排序那样有固定的模板和顺序,在使用动态规划时由于各种问题的性质不同,确定最优解的条件也互不相同,因而解题方法也不同,不存在一种万能的动态规划算法。但是我们可以对常用的动归进行总结分析,进而形成对某一类问题的独特解法,方便我们做题。
动态规划一般大体可分为线性动规(最长公共子序列),区域动规(石子合并),树形动规(数字三角),背包动规(01背包、完全背包)四类。


二、动态规划的基本思想

动态规划往往是求一种问题的最优解,动态规划和分治的基本思想类似,也是将一个问题分解成若干个子问题,再对子问题求分解,进而求出原问题的解,但若各个子问题间不是相互独立的,一个子问题的解会影响到下一个子问题的解,若分治递归,则会分成太多的子问题,且许多子问题重复计算,十分复杂,这时就需要动归了,我们可以用一张表把所有子问题的解都存下来,不管该子问题的解是否能用到,只要他被计算过,我们就把他存下来,如果下次我们需要计算的子问题存在于表中,那么我们就直接调用他的解,不用再次计算。

动态规划主要就是两个方面:

  • 一是状态表示(化零为整),就是选择好状态数组的每个维度分别表示什么状态,然后想好状态数组的值表示什么含义,一般情况状态数组的值就是我们所求的答案,在状态表示中还要想好我们求的是什么,一般是求一个最大值max或最小值min或计算方案数count。
  • 二是状态计算(化整为零),就是求出状态转移方程,一般从两个方面转移:从上一步计算如何转移到这一步或是当前步怎么转移到下一步,一般多见的都是前者,后者不常见,只有在上一步转入此步有很多种情况而此步转移到下一步情况很少时才用后者,后面例题会讲到。求状态转移方程关键是看最后一步不相同的地方

三、线性动规
  • 例1:最长公共子序列
    就是求出数组a和数组b中共有的子序列,这是一道很经典的线性dp问题

我们先状态表示,用二维数组f[i][j]表示,其中f[i][j]是指数组a中以i结尾和数组b中以j结尾的两个序列的最长公共子序列的长度。 此时我们求的是max。

而状态转移我们就要把整个序列化成很多个子序列分别计算子序列的最长公共子序列的长度然后进而算出整个的最长公共子序列的长度,这就要化整为零,在计算i,j结尾的子序列是要先判断a[i]和b[j]是否相等,若相等,直接根据定义在以i - 1和j - 1的最长公共子序列的长度后加1即可,既f[i][j] = f[i-1][j-1]+1,若a[i]和b[j]不相等,则最大值就是以 i 和 j -1结尾或 i -1和 j 结尾,我们只需要让f[i][j]等于他们两个中的最大值即可。
所以有代码

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int a[N],b[N],f[N][N];

int main()
{
	int n;
	cin >> n;
	for(int i = 1;i <= n;i++)
		cin >> a[i];
	for(int i = 1;i <= n;i++)
		cin >> b[i];     
	
	for(int i = 1;i <= n;i++)
		for(int j = 1;j <= n;j++)
		{
			f[i][j] = max(f[i-1][j],f[i][j-1]);	//如果a[i]和b[j]不相等时,状态转移方程,从前一步转移到这一步
			if(a[i] == b[j])	//如果a[i]和b[j]相等时,状态转移方程
				f[i][j] = max(f[i][j],f[i-1][j-1]+1);
		}
	cout << f[n][n];

    return 0;
}
  • 例2:最长公共上升子序列
    这是上一题的升级版,基于上一题但是做法略有不同。

首先是状态表示,用二维数组f[i][j]表示,其中f[i][j]是指数组a中以i结尾和数组b中以j结尾的子序列的最长公共上升长度。且公共增长子序列的最后一位就是b[j] 因为我们要求增长,所以我们要保存最后一位的值来判断下一个公共值是否也满足增长。所以我们要求b[j]恰好就是公共增长子序列的最后一位。

而状态转移我们就要找到最后一个不同点,我们已经默认b[j]是公共增长子序列的最后一位,那么我们就以a[i]是否在公共增长子序列中为划分,若不在,那就直接f[i][j] = f[i-1][j],若在,那就要找到公共的倒数第二位,找到后给其加一就是现在的值,所以我们就需要在f[i][k] (1 <= k < j)中找到满足b[k] < b[j] 的f的最大值,加一即可。
这里根据我们的定义来看,最终答案是f[n][1…j]中的最大值,因为我们不知道公共增长子序列是以哪一位结尾的,所以1到j中都有可能。

#include<iostream>

using namespace std;
const int N = 1010;
int a[N],b[N],f[N][N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> a[i];
	for(int i = 1;i <= m;i++)
		cin >> b[i];
	
	for(int i = 1;i <= n;i++)
	{
		for(int j = 1;j <= m;j++)
		{
			f[i][j] = f[i-1][j];	//a[i]不在所求子序列中时
			if(a[i] == b[j])	//a[i]在所求子序列中时
			{
				int maxv = 0;
				for(int k = 1;k < j;k++)	//从1到j循环一遍,找出最大值作为倒数第二个
					maxv = max(maxv,f[i-1][j]);
				f[i][j] = maxv + 1;
			}
		}
	}
	
	int res = 0;
	for(int i = 1;i <= m;i++)	//根据上述定义,找出最大值
		res = max(res,f[n][i]);
	cout << res;

	return 0;
}

但是这里有三层循环,时间复杂度达到了三次方,对于较大数据会很复杂,所以我们对k这一层优化一下,优化成两次循环。k这一层的作用就是找出前j个中的最大值,那么我们可以用一个maxv把每次的最大值存下来,就不用再去找了。

#include<iostream>

using namespace std;
const int N = 1010;
int a[N],b[N],f[N][N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> a[i];
	for(int i = 1;i <= m;i++)
		cin >> b[i];

	for(int i = 1;i <= n;i++)
	{
		int maxv = 0;	//maxv是记录f[i][1...j]的最大值
		for(int j = 1;j <= m;j++)
		{
			f[i][j] = f[i-1][j];
			if(a[i] == b[j])
				f[i][j] = maxv + 1;
			if(b[j] < a[i])	   //满足b[j] < a[i]时,更新maxv
				maxv = max(maxv,f[i-1][j]);
		}
	}

	int res = 0;
	for(int i = 1;i <= m;i++)
		res = max(res,f[n][i]);
	cout << res;
	
	return 0;
}

对于这个优化写法,我们一定要先理解并写出不优化的好理解的方案后,再做等价代换,换掉一层循环。


四、区域动规
  • 例1:石子合并
    题意是有n堆石子,每次能将相邻的两堆合并,每次合并都需要两堆重量之和的费用,现在求将所有石子合并成一堆的最低费用。

首先是状态表示,用二维数组f[i][j]表示,其中f[i][j]表示将第i堆到第j堆合并所需要的最低费用。

然后是状态计算,我们需要求出状态转移方程,我们将该问题化为子问题间接计算,我们要求出i到j合并的费用,可以在i到j中找到一个k,使得i到k和k+1到j的石子合并所需的费用最小,这样用该费用和加上i到j的重量,就是将i到j合并的最小费用即f[i][j]。

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int s[N],f[N][N];

int main()
{
	int n;
	cin >> n;
	for(int i = 1;i <= n;i++)
	{
		cin >> s[i];
		s[i] += s[i-1];		//s数组存储前缀和,方便算费用
	}
	for(int len = 2;len <= n;len++)		//从小到大列举所有长度的情况
	{
		for(int i = 1;i < n;i++)	//依次列举左端点,一般区域动规都如此遍历
		{
			j = i + len - 1;	//计算出右端点
			int minv = 0x3f3f3f;
			for(int k = i;k < j;k++)
				minv = min(minv,f[i][k] + f[k+1][j]);
			f[i][j] = minv + s[j] - s[i-1];
		}
	}
	cout << f[1][n];

	return 0;
}

五、树形动规
  • 例1:数字三角形
    如下图的一个三角形,求出从顶点到底边的一条路径,使得路径上的数字和最大。
    数字三角形
    我们将其存在一个二维数组里:
    7
    3 8
    8 1 0
    2 7 4 4
    4 5 2 6 5

关于状态表示,我们用一个二维数组f[i][j]来表示,我们从下往上来看,其中f[i][j]表示从底边走到i,j点的最大路径之和。

状态计算就是找到左下方和右下方的两个的最大值,加上来更新此点,即f[i][j] = max(f[i+1][j] , f[i+1][j+1])

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int f[N][N];

int main()
{
	int n;
	cin >> n;
	for(int i = 1;i <= n;i++)
		for(int j = 1;j <= i;j++)
			cin >> f[i][j];
	
	for(int i = n-1;i >= 1;i--)	//从倒数第二行开始往上走
	{
		for(int j = 1;j <= i;j++)
		{
			f[i][j] = max(f[i+1][j],f[i+1][j+1];
		}
	}
	cout << f[1][1];	//根据定义输出顶端的值

	return 0;
}

六、背包动规

背包在这里又分为六种情况

  1. 0 1背包
    即每种物品只有一件,只能选或不选。
  2. 完全背包
    即每种物品有无限多件,可以选择任意件数
  3. 多重背包
    即每种物品有有限件数,可以选择范围内的任意件数
  4. 混合背包
    即物品既有只有一件的也有有无限件的也有有限件的,将上述三种情况混合在一起
  5. 二维费用背包
    即物品有两种费用限制,例如同时不超过v体积不超过m重量
  6. 分组背包
    即物品被分为若干组,需要从每组只能选一个,组内物品是互相排斥的

最基础的情况:01背包:

  • 例1:0 1背包
    有n个物品,每个物品只有一件,现有背包容积为v,且已知第i个物品的体积为vi,价值为wi,求背包所能装的物品的最大价值。

首先是状态表示,我们用二维数组f[i][j]表示,其中f[i][j]是指只考虑前i个物品且背包容积为j时所能装的最大价值。

然后是状态计算,对于每个物品,我们可以考虑装或不装,我们要计算出这两种情况的具体价值,取其较大的即可。对于第i个物品,我们考虑装或不装。若装,即f[i][j] = f[i-1][j-v[i]]+w[i],即只考虑前i-1个物品且背包腾出v[i]的体积来装i的最大价值再加上w[i]的价值;若不装,很简单f[i][j] = f[i-1][j],和不考虑第i个物品是一样的。

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int v[N],w[N],f[N][N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> v[i] >> w[i];

	for(int i = 1;i <= n;i++);	//循环物品
	{
		for(int j = 1;j <= m;j++)	//循环体积
		{
			if(j >= v[i])
				f[i][j] = max(f[i-1][j],f[i-1][j-v[i]]+w[i]);
		}
	}
	cout << f[n][m];

	return 0;
}

对于这个代码我们还能优化一下,对于时间复杂度我们没法优化了,但是对于空间复杂度可以做以优化,对于f这个二维数组的前几行我们都不需要,每次都只需要当前的最后一行,所以我们可以用一个一维滚动数组来代替。

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int v[N],w[N],f[N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> v[i] >> w[i];
	
	for(int i = 1;i <= n;i++)
	{
		for(int j = m;j >= v[i];j--)	//一定要从后往前滚动,这样的f[j]就是上一轮的f[j]相当于f[i-1][j]和上一种方法等同
		{
			f[j] = max(f[j],f[j-v[i]]+w[i]);	//和上一种方法比较代码,其实他们的效果是等价的
		}
	}
	cout << f[m];

	return 0;
}
  • 例2:完全背包
    有n个物品,每个物品有无限件,现有背包容积为v,且已知第i个物品的体积为vi,价值为wi,求背包所能装的物品的最大价值。

首先是状态表示,和01背包简化后一样,我们直接用一维数组f[j]表示,其中f[j]表示背包体积为j时做能装的最大价值。外层同样要用i来循环只考虑前i件物品,

而状态计算和01背包一样,只有一点区别,就是这里j的循环要从小到大循环,从小到大循环就代表着f[j-v[i]]已经被计算过了,意思是第i件物品已经被选了若干件了,这时再比较还选不选i哪个最大,作为f[j]。

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 1010;
int v[N],w[N],f[N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> v[i] >> w[i];

	for(int i = 1;i <= n;i++)
	{
		for(int j = v[i];j <= m;j++)	//j从小到大循环,与01背包的唯一不同
		{
			f[j] = max(f[j],f[j-v[i]]+w[i]);	//因为从小到大循环,所以f[j-v[i]]是已经被计算过的
		}
	}
	cout << f[m];

	return 0;
}
  • 例3:多重背包
    有n个物品,每个物品有si件,现有背包容积为v,且已知第i个物品的体积为vi,价值为wi,求背包所能装的物品的最大价值。

关于多重背包最简单的想法就是把每个物品从0到s件都取一遍,然后求出最大值即可
状态表示和01背包一样。
状态计算时加入k,k从1到s循环,f[j] = max(f[j],f[j - k * v[i]] + k * w[i]);最开始时f[j]就相当于k等于0,即不选第i件物品,所以k直接从1循环到s即可。

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 110;
int v[N],w[N],s[N],f[N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
		cin >> v[i] >> w[i] >> s[i];
		
	for(int i = 1;i <= n;i++)	//循环物品
	{
		for(int j = m;j > 0;j--)	//循环体积
		{
			for(int k = 1;k <= s[i] && k*v[i] <= j;k++)	//循环找出该物品取几件时价值最大
			{
				f[j] = max(f[j],f[j-k*v[i]] + k*w[i]);
			}
		}
	}
	cout << f[m];

	return 0;
}

但是这样时间复杂度就是三次方了,数据多时过于复杂,所以就得优化一下。而换一个想法就是拆分,将多重背包变为01背包。举个例子,例如1号物品v[i] = 2,w[i] = 3,s[i] = 2即有两件,2号物品v[i] = 3,w[i] = 4,s[i] = 3即有三件,我们就可把这两件1号物品拆成两件v[i]相同w[i]相同编号不同的两件物品,2号物品同理,那我们就相当于有5件物品的01背包问题,编号为1.2.3.4.5.我们再按01背包做即可。
但是对于每个物品都拆分一遍,会拆出来平方级别的物品,空间太大,还是需要优化。所以我们就用二进制优化,因为二进制能表示出2的n次方种情况。
例如某物品数量为7,我们如何拆分能用最少的数组合出小于等于7的所有数,最笨的方法是像上面一样拆成7个1,可以达到目的,但是为了最少我们可拆成2的0次方,2的1次方,2的2次方,即1,2,4三个数,自己试试用这三个数就可以组合出所有小于等于7的数。这样我们可以把2个物品看成一个整体,4个物品看整一个整体,这样就只用3个物品就能表示出选0-7件时的情况。
但是对于s=12我们怎么拆分,我们可以分为1,2,4,5注意不是1,2,4,8,因为1,2,4,8可以组合出0-15,超过了我们的12件限制,所以最后一个数,我们用12 - 1 - 2 - 4 = 5。

#include<iostream>
#include<vector>

using namespace std;
const int N = 2010;
int f[N];
struct Good	//每一个商品装到Good结构体里
{
	int v,w;
}
vector<Good> goods;

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
	{
		int v,w,s;
		cin >> v >> w >> s;
		for(int k = 1;k <= s;k *= 2)	//二进制拆分
		{
			s -= k;
			goods.push_back({v*k,w*k});
 		}
 		if(s > 0)
			goods.push_back({v*s,w*s});	//将剩下的放进去
	}
	for(int i = 0;i < goods.size();i++)
	{
		for(int j = m;j >= goods[i].v;j--)	//这里和01背包一样,当01来做
		{
			f[j] = max(f[j],f[j-goods[i].v]+goods[i].w);
		}
	}
	cout << f[m];

	return 0;
}

此处也可以用单调队列优化

  • 例4:混合背包
    有n个物品,及体积为m的背包。
    物品有三类,第一类物品只有一件(01背包),第二类物品有无限件(
    完全背包),第三类物品有si件(多重背包)求背包能装的最大价值

混合背包并不难,只是把上面3个问题混合到一块了;我们对于每一种物品先判断一下他是哪一类的,如何按照这类方法去转移即可。

#include<iostream>
#include<vector>

using namespace std;
const int N = 1010;
int f[N];
struct Good
{
	int kind;
	int v,w;
};
vector<Good> goods;

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 1;i <= n;i++)
	{
		int v,w,s;
		cin >> v >> w >> s;
		if(s < 0)	//如果s输入-1代表为01背包
			goods.push_back({-1,v,w});
		else if(s == 0)	//如果s输入0代表完全背包
			goods.push_back({0,v,w});
		else		//如果s输入一个数,代表多重背包即有s件可选
		{
			for(int k = 1;k <= s;k *= 2)	//二进制拆分
			{
				s -= k;
				goods.push_back({-1,v*k,w*k});	//直接拆成01背包,按01背包处理
			}
			if(s > 0)
				goods.push_back({-1,v*s,w*s});
		}
	}
	
	for(int i = 0;i < goods.size();i++)	
	{
		if(goods[i].kind < 0)	//分类转移
			for(int j = m;j >= goodss[i].v;j--)	//01背包从后往前循环
				f[j] = max(f[j],f[j-goods[i].v]+goods[i].w);
		else
			for(int j = goods[i].v;j <= m;j++)	//完全背包从前往后循环
				f[j] = max(f[j],f[j-goods[i].v]+goods[i].w);
	}
	cout << f[m];

	return 0;
}
  • 例5:二维费用的背包
    有n个物品,每个物品只有1件,有容量为v,可承受最大重量为m的背包, 求背包能装的最大价值

二维费用背包和普通背包思路一样,只是多了一维的限制,这实际就是就是一个01背包问题
这里就需要一个二维数组f[i][j]一个是体积限制,一个是重量限制。

#include<iostream>

using namespace std;
const int N = 110;
int f[N][N];

int main()
{
	int n,v,m;
	cin >> n >> v >> m;
	for(int i = 1;i <= n;i++)	//循环物品
	{
		int vi,mi,wi;
		cin >> vi >> mi >> wi;
		for(int j = v;j >= vi;j--)	//循环体积
		{
			for(int k = m;k >= mi;k--)	//循环重量(多一层)
			{
				f[j][k] = max(f[j][k],f[j-vi][k-mi] + wi);	//对两个维度的费用都进行转移
			}
		}
	}
	cout << f[v][m];

	return 0;
}
  • 例6:分组背包
    有n组物品和一个容量是m的背包
    每组物品有若干个,同一组内的物品最多只能选一个

分组背包和多重背包比较像,可以说多重背包是分组背包的一种特殊情况,即组内所有物品都相同就为多重背包,所以多重背包可以用这一特殊性加以优化,而分组背包就和未优化的多重背包差不多

#include<iostream>
#include<algorithm>

using namespace std;
const int N = 110;
int v[N],w[N],f[N];

int main()
{
	int n,m;
	cin >> n >> m;
	for(int i = 0;i < n;i++)	//有n组
	{
		int s;		//每组s个数据
		cin >> s;
		for(int j = 1;j <= s;j++)
			cin >> v[j] >> w[j];
		for(int j = m;j > 0;j--)
		{
			for(int k = 1;k <= s;k++)
			{
				if(j > v[k])
					f[j] = max(f[j],f[j-v[k]] + w[k];
			}
		}
	}
	cout << f[m];

	return 0;
}

[1]如有问题请指出
[2]背包问题总结于背包九讲

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值