[算法笔记]动态规划

一.动态规划

动态规划是用来解决一类最优问题的算法思想。简单来说,动态规划将一个复杂的问题分解成若干的子问题,通过综合子问题的最优解来得到原问题的最优解。
动态规划可以将每个求解过的子问题记录下来,这样下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算,依次来提高计算效率。
使用递归或是地推来实现动态规划,递归写法称作记忆化搜索

二.递归写法

eg:求解Fibonacci数列
数列定义 F0=1,F1=1,Fn=Fn-1+Fn-2(n>=2)
递归代码:

int F(int n) {
	if (n == 0 || n == 1)return 1;
	else return F(n - 1) + F(n - 2);
}

递归中要一直进行重复计算,时间复杂度O(2^n),即每次都会计算F(n-1)与F(n-2)两个分支。
为避免重复计算,可以开一个一维数组dp,用来保存已经计算过的结果,其中dp[n]记录F(n)的结果,dp[n]=-1表示还没有被计算过。

int F(int n) {
	if (n == 0 || n == 1)return 1;
	if (dp[n] != -1)return dp[n];//已经计算过,直接返回结果

	else {
		dp[n] = dp[n - 1] + dp[n - 2];//计算F(n),保存在dp[n]中
		return dp[n];//
	}
}

即把已经计算过的内容记录下来,当下次需要计算相同的内容时,就能直接使用上次计算的结果,可以省去大多无效计算,这就是记忆化搜索,斐波那契中可以将求解的时间复杂度由指数级别降低到线性级别。

如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现 ,那么就称这个问题拥有重叠子问题,动态规划通过记录重叠子问题的解,来使下次碰到相同的子问题时直接使用之前记录的结果,以此避免大量重复计算。因此,一个问题必须拥有重叠子问题,才能够使用动态规划取解决。

三.递推写法

eg:数塔问题
将一些数字排成数塔的形状,其中第一层有一个数字,第二层有两个数字,…第n层有n个数字。现在要从第一层走到第n层,每次只能走向下一层连接的两个数字中的一个,问:最后路径上所有数字相加得到的和最大是多少?
在这里插入图片描述
如果尝试穷举所有路径,然后记录路径上数字之和的最大值,由于每层中的每个数字都会有两条分支路径,因此可以得到时间复杂度O(2^n),这在n很大的情况下是不可以接受的。

分析:
一开始从第一层5出发,按照9->12->6的顺序来到6,并枚举从6出发到达最低层的所有路径,但是,当按照9->15->6来到6的时候有需要枚举从6到底层的所有路径,这就导致了从6出发到最底层的所有路径会被反复的访问,于是就可以在第一次枚举从6出发到达最底层的所有路径时就把路径上能产生的最大和记录下来,再次访问到6的时候就可以直接调用。

现在令dp[i][j]表示从第i行第j个数字到达底层的所有路径中的最大值,例如dp[3][2]就是图中6到底层的路径最大和,定义这个数组之后,dp[1][1]就是想要的最终答案。

如果要求出从位置(1,1)到达底层的最大和dp[1][1],那么就需要求出两个子问题,即从(2,1)与(2,2)分别到最底层的最大路径和dp[2][1]与dp[2][2]。
则dp[1][1]=max(dp[1][2]+dp[2][2])+f[1][1].

于是可以归纳出:如果需要求出dp[i][j],则一定要求出它的两个子问题即从(i+1,j)与(i+1,j+1)到达最底层的最大路径和dp[i+1][j]与dp[i+1][j+1],则dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j].

该方程称为状态转移方程,将状态dp[i][j]转移为dp[i+1][j]与dp[i+1][j]两个状态中,而数塔最后一层的dp值总是等于元素本身,即dp[i][j]==f[i][j],把这种可以直接确定结果的部分称之为边界,而动态规划的递推写法总是从边界出发,将状态扩散到整个dp数组。

代码实现:

#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn = 1000;
int f[maxn][maxn], dp[maxn][maxn];
int main() {
	int n;
	(void)scanf("%d", &n);
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= i; j++) {
			(void)scanf("%d", &f[i][j]);
		}
	}
	for (int j = 1; j <= n; j++) {
		dp[n][j] = f[n][j];//边界
	}
	for (int i = n - 1; i >= 1; i--) {
		for (int j = 1; j <= i; j++) {
			dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + f[i][j];
		}
	}
	printf("%d\n", dp[1][1]);
	return 0;
}
//input
5
9
12 16
10 6 8
2 18 9 6
19 7 10 4 16
//output
59

如果一个问题的最优解可以由其子问题的最优解构造出来,那么称这个问题具有最优子结构。最优子结构保证了动态规划中原问题的最优解可以有子问题的最优解推导而来,因此,一个问题必须拥有最优子结构,才能使用动态规划取解决,如数塔问题中每一个位置的dp值都可以由它的两个子问题推到得到。

四.动态规划与分治,贪心策略

1)动态规划与分治:
分治与动态规划都是将问题分解为子问题,然后合并子问题与原问题的解,但不同在于分治法分解出的子问题都是不重叠的,而动态规划解决的问题拥有重叠子问题,例如归并排序和快速排序,都是分别处理左子序列与右子序列,然后将左右序列合并,过程中不出现重叠,而且分治法解决的不一定是最优化问题,而动态规划解决的一定是最优化问题。

2)动态规划与贪心:
贪心与动态规划都要求原问题必须拥有最优子结构,二者的区别在于,贪心法使用的计算方式类似于自顶向下,但并不是等所有子问题求解完毕后再选择一个子问题去使用,而是通过一种策略直接选择一个子问题去求解,没被选择的直接抛弃,以一种单链流水的方式进行,这种最优选择的正确性需要使用归纳法证明。

动态规划不管是使用自底向上还是自顶向下的计算方式,都是从边界向上开始得到目标问题的解,总是会考虑所有子问题,并选择继承能得到最优结果的那个,对暂时没有被继承的子问题,由于重叠子问题的存在,后期可能会再次考虑它们,因此还有机会称为全局最优的一部分。

五.动态规划经典题目

1)最长连续子序列
给定一个数字序列A1 A2… An,求i,j,使得Ai+…+Aj最大,输出这个最大和。
eg:

-2 11 -4 13 -5 -2

最大和应该为11+(-4)+13=20

动态规划思路:
令dp[i]表示以A[i]作为末尾的连续序列的最大和。本题中
dp[0]=-2
dp[1]=11
dp[2]=11+(-4)=7
dp[3]=11+(-4)+13=20
dp[4]=11+(-4)+13+(-5)=15
dp[5]=11+(-4)+13+(-5)+(-2)=13
然后求出dp数组中的最大值即dp[3]=20为答案。

因为dp[i]要求必须是以A[i]为结尾的连续序列,那么有一下两种情况:
1)最大和的连续序列只有一个元素 即A[i]开始 A[i]结束
2)最大和的连续序列有多个元素,从前面某处的A[p]开始,一直到A[i]结尾。

第一种情况,最大和为A[i]本身,第二种情况,最大和为dp[i-1]+A[i]。
于是得到状态转移方程:dp[i]=max{A[i],dp[i-1]+A[i]}

这个式子只与i和i之前的元素有关,且边界为dp[0]=A[0],由此可以从小到大枚举i,即可以得到整个dp数组。
然后遍历数组,取dp[i]中最大值,即为最大连续子序列的和。

代码实现:

#include<cstdio>
#include<algorithm>
#include<vector>
using namespace std;
const int maxn = 10010;
int main() {
	int n, k = 0;
	(void)scanf("%d", &n);
	vector<int> A(n), dp(n);
	for (int i = 0; i < n; i++) {
		(void)scanf("%d", &A[i]);
	}
	dp[0] = A[0];
	for (int i = 1; i < n; i++) {
		dp[i] = max(A[i], dp[i - 1] + A[i]);
		if (dp[i] > dp[k])k = i;//保存最大值
	}
	printf("%d", dp[k]);
	return 0;
}
//input
6
-2 11 -4 13 -5 -2
//output
20

2)最长不下降子序列(LIS)
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是非下降的。
如A={1,2,3,-1,-2,7,9},下标从1开始,它的最长不下降子序列是1 2 3 7 9。

动态规划思路:
令dp[i]表示以A[i]结尾的最长不下降子序列长度。对A[i]来说会有如下两种可能:
1)如果存在A[i]之前的元素A[j](j<i)使得A[j]<=A[i]且dp[j]+1>dp[i],即把A[i]跟在以A[j]结尾的LIS后面时能比当前以A[i]结尾的LIS长度更长(如果长度和之前相等,则不会加入,只有更长才会加入,即dp[j]+1>dp[i]),那么就把A[i]跟在以A[j]结尾的LIS后面,形成一条更长的LIS序列,此时dp[i]=dp[j]+1。
2)如果A[i]之前的元素都比A[i]大,那么A[i]就只好自己形成一条LIS,长度为1。
3)边界为dp[i]=1,即每次对i操作时假设每个A[i]自成一个子序列。

状态转移方程:
dp[i]=max{1,dp[j]+1};(j=1,2…i-1&&A[j]<A[i])

代码实现

#include<cstdio>
#include<algorithm>
#include<vector>
using namespace std;
int main() {
	int n;
	(void)scanf("%d", &n);
	vector<int> A(n + 1), dp(n + 1);
	for (int i = 1; i <= n; i++) {
		(void)scanf("%d", &A[i]);
	}
	int ans = -1;
	for (int i = 1; i <= n; i++) {
		dp[i] = 1;//假设每个元素自称一个子序列
		for (int j = 1; j < i; j++) {
			if (A[i] >= A[j] && (dp[j] + 1 > dp[i])) {
				dp[i] = dp[j] + 1;
			}
		}
		if (dp[i] > ans)ans = dp[i];
	}
	printf("%d", ans);
	return 0;
}

输出

//input
7
1 2 3 -1 -2 7 9
//output
5

3)最长公共子序列(LCS)
问题描述:
给定两个字符串(或数字序列A或B)求一个字符串,使得这个字符串是A和B最长公共部分,子序列可以不连续
如字符串sadstory与字符串adminsorry的最长公共子序列adsory 长度为6.

动态规划思想:
令dp[i][j]表示A的i号位与B的j号位之前的LCS长度,下标从1开始,如dp[4][5]表示sads与admin的LCS长度,可以根据A[i]与B[j]的情况,分为一下两种决策。
1)若A[i]==B[j],则字符串A与字符串B的LCS增加了1位,即有dp[i][j]=dp[i-1][j-1]+1。
如样例中dp[4][6]表示sads与admins的LCS长度,比较A[4]与B[6]都位s,则dp[4][6]=dp[3][5]+1.即为3
2)如果A[i]!=B[i]则A的i号位与B的j号位之前的LCS无法延长,因此dp[i][j]会继承dp[i][j-1]与dp[i-1][j]中的较大值。
如样例中的dp[3][3]表示sad与adm的LCS长度,比较A[3]与B[3]发现d不等于m,这样dp[3][3]无法在原先的基础上延长,因此继承自’‘sa’‘与’'adm"的LCS,sad与ad的LCS中的较大值,即sad与ad的LCS长度:2。
于是状态转移方程:
1)当A[i]==B[j]时,dp[i][j]=dp[i-1][j-1]+1
2)当A[i]!=B[j]时,dp[i][j]=max(dp[i-1][j],dp[i][j-1])

边界 dp[i][0]=dp[0][j]=0(0<=i<=n,0<=j<=m)

则dp[i][j]只与其当前的状态有关, 由边界出发可以得到整个dp数组,最终dp[n][m]就是需要的答案,时间复杂度O(mn).

代码实现:

#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
const int maxn = 210;
int dp[maxn][maxn], a[maxn], b[maxn];
void lcs(string s1, string s2) {
	int m = s1.length(), n = s2.length();
	for (int i = 0; i <= m; i++) dp[i][0] = 0;
	for (int i = 0; i <= n; i++) dp[0][i] = 0;
	for (int i = 1; i <= m; i++) {
		for (int j = 1; j <= n; j++) {
			if (s1[i - 1] == s2[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[m][n]);
	return;
}
int main() {
	string a, b;
	cin >> a >> b;
	lcs(a, b);
	return 0;
}
//input
sadstory
adminsorry
//output
6

4)最长回文子串
给出一个字符串S,求S的最长回文子串的长度
eg:”PATZJUJZTACCBCC“的最长回文子串ATZJUJZTA,长度为9.

用dp[i][j]表示s[i]至s[j]所表示的子串是否是回文子串,是则为1,不是则为0。
若s[i]==s[j],那么只要s[i+1]至s[j-1]是回文串,则s[i]到s[j]是回文串。如果s[i+1]到s[j-1]不是回文串,那么s[i]至[j]也不是回文串。
若s[i]!=s[j],那么s[i]到s[j]一定不是回文串。

状态转移方程:
1)s[i]==s[j] dp[i][j]=dp[i+1][j-1].
2)s[i]!=s[j] dp[i][j]=0.

边界dp[i][i]=1,dp[i][i+1]=(s[i]==s[i+1])?1:0.

如果按照ij的从小到大的顺序来枚举两个子串的两个端点,然后更新dp[i][j],会无法保证dp[i+1][j-1]已经被计算过,从而无法得到正确的dp[i][j].
由于边界表示为长度为1和2的子串,且每次转移时都对子串的长度减1,可以考虑按照子串的长度 与子串的初始位置进行遍历,如第一遍遍历长度为3的子串,第二遍再计算出长度为4的子串…,子串长度最多可以取到S.length()。

代码实现:

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn = 1010;
char s[maxn];
int dp[maxn][maxn];
int main() {
	(void)scanf("%s", s);
	int len = strlen(s), ans = 1;
	//fill(dp[0], dp + maxn * maxn, 0);
	memset(dp, 0, sizeof(dp));
	for (int i = 0; i < len; i++) {
		dp[i][i] = 1;//长度为1的子串
		if (i < len - 1) {
			if (s[i] == s[i + 1]) {//长度为2的子串
				dp[i][i + 1] = 1;
				ans = 2;//最长回文子串
			}
		}
	}
	for (int l = 3; l <= len; l++) {
		for (int i = 0; i + l - 1 < len; i++) {//枚举左串的起始顶点
			int j = i + l - 1;//右端点
			if (s[i] == s[j] && dp[i + 1][j - 1] == 1) {
				dp[i][j] = 1;
				ans = l;
			}
		}
	}
	printf("%d\n", ans);
	return 0;
}
//input
PATZJUJZTACCBCC
//ouput
9

5)DAG最长路

1.给定一个有向无环图,求解图中所有路径中权值之和最大的那条。
令dp[i]表示从i号顶点出发能获得的最长的路径长度,这样所有dp[i]的最大值是整个DAG的最长路径长度。
如果从i号顶点出发能够到达顶点j1 j2…jk,而dp[j1],dp[j2],dp[jk]已知,
则dp[i]=max{dp[j]+length[i->j]};即在所有能够到达的顶点中选择dp[j]+length(i->j)最长的一条。
可以采用逆拓扑排序和递归的方法进行求解:

int DP(int i) {
	if (dp[i] > 0)return dp[i];//dp[i]已经得到
	for (int j = 0; j < n; j++) {
		if (G[i][j] != inf) {
			dp[i] = max(dp[i], dp[j] + G[i][j]);
		}
	}
	return dp[i];
}

由于从出度为0的顶点出发的最长路径为0,因此边界的这些顶点的dp值为0,但具体实现中可以对整个dp数组初始化为0,这样dp函数当前方位的顶点i的出度为0的时候就会返回dp[i]=0。遇到出度不是0的顶点则会递归求解,递归过程中遇见已经计算过的顶点则会直接返回对应的dp值,逻辑上按照了逆拓扑排序的程序

dijkstra中开辟了一个pre数组来保存每一个节点的前驱,发现更短的路的时候对pre数组进行修改,那么在求解最长路径的时候可以开一个post数组来保存每一个节点的后记,发现更长的路径的时候对post数组进行修改。

对动态规划问题而言,如果需要得到方案,都可以采用这种方法,记录每次的决策策略,然后在dp数组计算完成之后根据具体的情况进行递归或迭代来获取具体方案。

#include<cstdio>
#include<vector>
#include<algorithm>
using namespace std;
const int maxn = 1010;
const int inf = 0x3fffffff;
int dp[maxn], n, G[maxn][maxn];
vector<int> post[maxn];
int DP(int i) {
	if (dp[i] > 0)return dp[i];//dp[i]已经得到
	for (int j = 0; j < n; j++) {
		int temp = max(dp[i], dp[j] + G[i][j]);
		if (G[i][j] != inf) {
			if (temp > dp[i]) {
				dp[i] = temp;
				post[i].clear();
				post[i].push_back(j);//表示i节点的后继是j
			}
			else if (temp == dp[i]) {
				post[i].push_back(j);
			}
		}
	}
	return dp[i];
}

经典DAG最长路径题目:矩形嵌套问题
给出n个矩形的长和宽,如果两个矩形A和B,A的长宽分别小于B的长宽,则可以说A可以嵌套在B中。
现在要求一个矩形序列,使得这个序列中任意两个相邻的矩形都满足前面的矩形可以嵌套与后一个矩形之中,
且序列的长度最长,如果有多个这样的最长序列,选择矩形编号字典序最小的那个。

可以将每个矩形抽象成为一个顶点,A可以被B包含抽象成为A->B的一条有向边。
则可以转换成为求DAG最长路径。
也可以将矩形以长宽从小到大排列,转换成为求最长上升子序列(LIS).

6)背包问题

多阶段动态规划问题:这类动态规划问题,可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,对于这种问题,只需要从第一个问题开始,按照阶段的顺序解决每个阶段中状态的计算,就可以得到最后一个阶段的状态的解。

01背包问题:
有n件物品,每件物品的重量为w[i],价值为c[i],现在只有一个容量为V的包,问如何让选取物品放入背包,使得背包内物品的总价值最大,其中每件物品都只有一件。

暴力枚举对于每一件物品都有放与不放两种策略,n个物品就有2^n种情况。
动态规划思想:
令dp[i][v]表示前i件物品(1<=i<=n,0<=v<=V)恰好装入容量为v的背包中可以获得的最大价值。
对于第i件物品的策略:
1)不放第i件物品,那么问题转换成为前i-1件物品恰好装入v的最大价值,也即dp[i-1][v]。
2)放第i件物品,那么问题转换成为前i-1件物品恰好装入容量为v-w[i]中能获得的最大价值,即dp[i-1][v-w[i]]+c[i]。

于是状态转移方程:dp[i][v]=max{dp[i-1][v],dp[i-1][v-w[i]]+c[i]}(1<=i<=n,w[i]<=v<=V)
边界dp[0][v]=0(0件物品放入任何容量的背包中都只能获得价值0)
dp[i][v]只与之前的状态dp[i-1][]有关。最后枚举dp[n][v]取最大值就是结果

当输入为5件商品,背包容量为8时:

//input
5 8
3 5 1 2 2
4 5 2 1 3

可以得到dp矩阵

  0 1 2 3 4 5 6 7 8
0 0 0 0 0 0 0 0 0 0
1 0 0 0 4 4 4 4 4 4
2 0 0 0 0 0 5 5 5 9
3 0 2 2 2 2 5 7 7 9
4 0 0 2 3 3 5 7 7 9
5 0 0 3 3 5 6 7 8 10

取到dp矩阵中最大的即可:10

代码实现:

#include<cstdio>
#include<algorithm>
using namespace std;
const int maxn = 100;
const int maxv = 1000;
int w[maxn], c[maxn], dp[maxv][maxv];
//每件物品重量w[i],价值c[i]
int main() {
	int n, V, MAX = 0;
	(void)scanf("%d %d", &n, &V);
	for (int i = 1; i <= n; i++) {
		(void)scanf("%d", &w[i]);
	}
	for (int i = 1; i <= n; i++) {
		(void)scanf("%d", &c[i]);
	}
	for (int v = 0; v <= V; v++) {
		dp[0][v] = 0;//前0件物品放入任何容量为v的包中都只能获得价值0
	}
	for (int i = 1; i <= n; i++) {
		for (int v = w[i]; v <= V; v++) {
			dp[i][v] = max(dp[i - 1][v], dp[i - 1][v - w[i]] + c[i]);
			if (dp[i][v] > MAX)MAX = dp[i][v];
		}
	}
	printf("%d\n", MAX);

}

01背包问题的每件物品都可以看作一个阶段,这个阶段中的状态有dp[i][0]~dp[i][V],它们均由上一个阶段的状态得到。事实上,对能够划分阶段的问题来说,都可以尝试把阶段作为状态的一维。

完全背包问题:
有n种物品,每种物品单件重量为w[i],价值为c[i],现在有一个容量为V的背包,问如何选取物品放入背包,使得背包内的物品总价值最大,其中每件物品都有无穷件。

与01背包的区别在于,完全背包的物品数量每种有无穷件,选取物品对同一物品可以选1件,选2件…,只要容量不超过V即可。而01背包问题的每种物品只有一种。

同样令dp[i][v]表示前i件物品恰好放入容量为v的背包中能获得的最大价值。
同01背包问题一样,也有两种策略:
1)不放第i件物品,dp[i][v]=dp[i-1][v].
2)放第i件物品,这里的处理和01背包不同,01背包的每个物品只能选择以此,因此选择放第i件物品就必须转移到dp[i-1][v-w[i]]状态,完全背包放入之后还可以选择继续放入第i件物品,直到第二维的v-w[i]无法保持,所以这种情况下dp[i][v]=max(dp[i-1][v],dp[i][v-w[i]]+c[i])
边界还是dp[0][v]=0.
将01背包中的for循环做如下修改:

for (int i = 1; i <= n; i++) {
		for (int v = w[i]; v <= V; v++) {
			dp[i][v] = max(dp[i - 1][v], dp[i][v - w[i]] + c[i]);//修改状态转移方程
			if (dp[i][v] > MAX)MAX = dp[i][v];
		}
	}
//input
5 8
3 5 1 2 2
4 5 2 1 3
//output
16
发布了142 篇原创文章 · 获赞 1 · 访问量 4561

猜你喜欢

转载自blog.csdn.net/weixin_44699689/article/details/104649521
今日推荐