动态规划专题
1. 递归问题
从最小值走上几组寻找、推断规律.
1. 走台阶问题
- 题目:每次只能往前走 1 级或 2 级,问输入楼梯级数时,一共有多少中走法
- 思路:假设 f(n-2),f(n-1) 都已经求出,那么不难得知楼梯级数为 n 时,等价于 n-2 级楼梯时往前走两步或 n-1 级时往前走 1 步。
- 状态转移方程:
dp[n] = dp[n-1] + dp[n-2]
- 代码:太简单了,就是斐波那契数列
2. 全错问题
- 题目:输入要发送的邮件总数目 n(1<1n<=20),试求给这 n 个人发邮件,全部发错的可能排列方式的数量。
- 思路:n 个全错:① n-1个全错,然后将第 n 个不断和前 n-1 个交换,得到的还是全错排序 ② n-1 个中只有一个对的(即 n-2 个全错),将第 n 个和这唯一一个对的交换
- 状态转移方程:
dp[n] = dp[n-1]*(n-1)+dp[n-2]*(n-1)
- 代码:还是很简单。
2. 最长递增序列
-
题目:从数列中按照先后顺序取出数字,组成新数列且递增,求符合该要求的最长子列长度。
-
解法:dp[i] 表示考虑第 i 个字符时的最长子序列。外层 for 循环数列每一个元素,内层 for 将 list[i] 和 list[j] 比较,判断 list[i] 可以插在哪个子列后面,则更新子列。如果可以插在某个子列后面(假设子列取到前 j 个元素),那么 dp[i] = dp[j]+1
-
状态转移方程:
dp[i] = max{1, dp[j]+1 | aj<ai && j<i}
边界条件:dp[i] = 1
-
代码
// 炮弹防御问题:输入炮弹个数、每个炮弹高度,每次拦截炮弹的高度不能超过前一次,求最多拦截多少个炮弹 // 测试数据: 300 207 155 300 299 170 158 65 // 输出:6 void topic03(){ int n=0, dp[100], list[100], ans=0; scanf("%d", &n); for (int i=0; i<n; i++) scanf("%d", &list[i]); fill(dp, dp+n, 1); for (int i=0; i<n; i++){ for (int j=0; j<i; j++) if (list[i] <= list[j]) dp[i]=max(dp[i], dp[j]+1); if (ans < dp[i]) ans = dp[i]; } printf("%d\n", ans); }
3. 最长公共子串
- 题目:有两个字符串 S1 和 S2,求一个最长公共子串,即求字符串 S3,它同时为 S1 和 S2 的子串,且要求它的长度最长,并确定这个长度
- 解法:A[i]、B[j] 分别为 S1、S2 的前 i、j 个长度的子串,那么 dp[i] 的值等于A[i-1] 与B[j-1] 的最长子串数 +1 如果 list[i]==list[j],否则等于 max{ A[i]和B[j-1]的最长子串,A[i-1]和B[j]最长子串 }
- 状态转移方程:
dp[i][j] = dp[i-1][j-1]+1 | list[i]==list[j]
或dp[i][j] = max{ dp[i][j-1], dp[i-1][j] }
边界条件:dp[i][0]=0, dp[0][j]=0
- 代码:
for (int i=0; i<=len(s1); i++) dp[i][0] = 0; for (int i=0; i<=len(s2); i++) dp[0][i] = 0; for (int i=1; i<=len(s1); i++) for (int j=1; j<=len(s2); j++) if (s1[i] == s2[j]) dp[i][j] = dp[i-1][j-1]+1; else dp[i][j] = max(dp[i][j-1], dp[i-1][j]);
4. 最长回文子串
- 题目:给出一个字符串 S,求 S 的最长回文子串
- 分析:令 dp[i][j] 表示 S[i] 到 S[j],
- 如果 S[i] == S[j] 那么只要 S[i+1] 到 S[j-1] 是回文串,那么 S[i] 到 S[j] 就是回文串,若 S[i+1] 到 S[j-1] 不是回文串,那 S[i] 到 S[j] 也不是回文串。
- 如果 S[i] != S[j],那么 S[i] 到 S[j] 一定不是回文串。
- 状态转移方程:
d p [ i ] [ j ] = { d p [ i + 1 ] [ j − 1 ] , S [ i ] = S [ j ] 0 , S [ i ] ̸ = S [ j ] dp[i][j] = \begin{cases} dp[i+1][j-1] & \text{, } S[i]=S[j] \\ 0 & \text{, } S[i]\not =S[j] \end{cases} dp[i][j]={dp[i+1][j−1]0, S[i]=S[j], S[i]̸=S[j]
边界: d p [ i ] [ i ] = 1 , d p [ i ] [ i + 1 ] = ( S [ i ] = = S [ i + 1 ] ) ? ( 1 ) : ( 0 ) ; dp[i][i] = 1,dp[i][i+1]=(S[i]==S[i+1])? (1): (0); dp[i][i]=1,dp[i][i+1]=(S[i]==S[i+1])?(1):(0); - 代码
int MAX = 2001;
char in[MAX];
int dp[MAX][MAX];
void MaxSubStr(){
scanf("%s", in);
fill(dp, dp+MAX*MAX, 0);
int ans=1, len=strlen(in);
// 初始化 dp[i][i] 与 dp[i][i+1]
for (int i=0; i<len; i++){
dp[i][i] = 1;
if (i<len-1 && in[i]==in[i+1]){
dp[i][i+1] = 1cccccccccccc; ans=2;
}
}
// 状态转移方程
for (int L=3; L<len; L++) // 枚举子串长度,从 3 开始
for (int i=0; i+L<=len; i++){ // 枚举子串起始位置
int j = i+L-1;
if (in[i]=in[j] && dp[i+1][j-1]==1){ // 如果收尾相等且[i+1,j-1]回文,当前串为回文串
dp[i][j] = 1;
ans = L;
}
}
printf("%d\n", ans); // 输出最大值
}
5. 状态转移方程
1. 最低疲劳度问题
2. 分柑橘问题
6. 背包问题:0-1 背包
- 题目:每个物品有其价值和重量且每种物品数量为1,背包允许最大负重 T,求怎样放物品能使得背包价值最大
- 思路:我们求背包在负重为 j 时背包的最大价值,j 的范围为 [0, T],对于背包处于重量 j 时,我们从 1 开始循环考虑到第 n 种物品,对于物品 i,其有两种状态:在背包中、不在背包中。不在背包中时,背包的最大价值等于考虑 i-1 且背包重 j 时背包的最大价值。在背包中时,背包的最大价值等于考虑 i-1 且背包中为 j-items[i].cost 时的价值加上当前物品 i 的价值。所以当考虑 i 时,背包的最大价值为上述两个价值中的最大值。
- 状态转移方程:
dp[j] = max{ dp[j-items[i].cost]+items[i].value, dp[j] }
边界条件:dp[i]=0
(代表背包重量为 0 时,考虑任何个物品价值都为 0) - 代码:
struct Item{ int c; int v; }items[1001]; int dps[1001]; void topic06(){ int n=0, m=0; scanf("%d %d", &n, &m); for (int i=1; i<=m; i++) scanf("%d %d", &items[i].c, &items[i].v); for (int i=0; i<=n; i++) dps[i] = 0; // 当背包重量为 0 时,最大价值为 0 for (int i=1; i<=m; i++) for (int j=n; j>=items[i].c; j--) dps[j] = max(dps[j-items[i].c]+items[i].v, dps[j]); printf("%d\n", dps[n]); }
0-1 背包:每个物体的个数为 1
分析:dp[i][j] 表示当考虑第 i 个物品且背包负重为 j 时背包的最大价值。有如下状态转移方程:
dp[i][j] = max{ dp[i-1][j-cost[i]]+value[i], dp[i-1][j] }
解释:当我们考虑当前的背包状态时,背包最大价值为 dp[i][j],首先要确定一个事实,当前背包的重量 j 是已经确定了的,但是 j 的组成方式有两个不同的情况:
- 当前物品 i 在背包中,此时 j = other01 + cost[i]
- 当前物品不在背包中,则 j = other01 + (cost[x]+cost[y]),其中 cost[x]+cost[y]<=cost[i]。
所谓当前物品不在背包中(也就是 ②),即把 ① 中的物品 i 移除背包,而用 i 之前的几种物品组合代替 i 放到背包中x
说两点:
1.组合后的重量不一定完全等于 i 的重量。
2.可以看到,物品 x、y 和 i 不会同时出现在背包中,因为 other 的重量在两种情况中是相同的,放入了 x、y 就无法放入 i所以,当前物品 i 在背包中时:
- 背包的最大价值 = 当前物品不在背包且没有其他物品替代当前物品时背包的最大价值 + 当前物品的价值。
反之,当前物品 i 不在背包时:
- 背包的最大价值 = 当前物品不在背包且当前物品空出的重量被其他物品代替时背包的最大价值
*注:我们无需考虑第一种情况中的 dp[i-1][j-cost[i]] 和第二种情况中的 dp[i-1][j],这些会在程序递推的过程中会算出来(这也就是动态转移方程的精髓!)
优化:我们的动态转移方程为:
dp[i][j] = max{ dp[i-1][j-cost[i]]+value[i], dp[i-1][j] }
很显然,我们要用两个 for 循环来分别迭代 i 和 j,但我们发现 max 函数中的两个状态的物品数量都是 i-1,事实上我们可以把 dp[i][j] 优化为 dp[j],即去掉考虑第几个物品这个属性,可以这么优化的原因在于我们对只考虑 x 个物品(x < m)并时背包的最大价值是多少并不感兴趣,因此我们在求考虑第 i 个物品且重量为 j 时的最大价值时,我们直接用新的数据覆盖掉上一次(i-1, j)时的数据即可。我们在递推中利用了上一次(i-1, j)数据,但我们最终不需要,因此我们直接覆盖他就行。
(我比较喜欢这么写是因为我认为使用 dp[i] 比使用 dp[i][j] 看起来要优雅~)*注:此种方式要注意:内层 for 循环,求考虑 i 个物品背包不同重量对应的最大价值时,必须由大到小更新。因为 j-items[i].c < j,如果 j 由小到大,则意味着 dp[j-items[i].c] 在 dp[j] 已经被更新过了(小的先被更新)!
7. 背包问题:完全背包
- 题目:每个物品数量不限,背包必须满
- 思路:完全背包的解法和 0-1 背包的解法基本相同,唯一的差别在于怎么更新 dp[j]。完全背包和 0-1 背包相比,不同点在于 0-1 背包中物体只有一个,其状态也只有:放入、不放入两种,但是完全背包中物体数量不限,相当于物体状态有放入 1 个、2 个、3 个……不确定种。完全背包的关键在于如何在 0-1 背包的基础上实现描述一种物品在背包中放入不同的数量。我们在 0-1 背包中为了防止更新 dp[j] 时,dp[j-items[i].c] 已经被更新过的情况,所以让 j 从大到小更新。而完全背包刚好相反,我们就是要在更新 j 之前将 dp[j-items[i].c] 更新了(这里的更新,就是在背包中加入一个物品 i,这样我们遍历 j 从 items[i].c - t,这样我们就能实现对同一物品的不同数量的分析)
- 状态转移方程:
dp[j] = max{ dp[j-items[i].cost]+items[i].value, dp[j] }
- 代码:
void topic06(){ int n=0, m=0; scanf("%d %d", &n, &m); for (int i=1; i<=m; i++) scanf("%d %d", &items[i].c, &items[i].v); for (int i=0; i<=n; i++) dps[i] = 0; // 当背包重量为 0 时,最大价值为 0 for (int i=1; i<=m; i++) for (int j=items[i].c; j<=n; j--) dps[j] = max(dps[j-items[i].c]+items[i].v, dps[j]); printf("%d\n", dps[n]); }
8. 背包问题:多重背包
- 题目:每个物品数量为1,背包可以不满
- 思路:将物品拆分为 x 份,每份的个数分别为:
2
0
,
2
1
,
2
2
,
…
…
2
x
−
1
,
k
−
2
x
+
1
2^0, 2^1, 2^2, …… 2^{x-1}, k-2^x+1
20,21,22,……2x−1,k−2x+1。可以由数学规律可知,这 x 份物体能表示出[1, k]内的任意一个值。这样处理之后就是一个 0-1 背包问题了。所以解题流程如下:
- 将每种物品分为 x 份保存在 lists 里面。(要格外注意,lists 的容量应该大过输入的种类)
- 将 lists 看做新的输入,对他进行 0-1 背包处理
- 状态转移方程:
dp[j] = max{ dp[j-items[i].cost]+items[i].value, dp[j] }
- 代码:
Item lists[2001]; void topic07(){ int t=0, n=0, c=0, v=0, s=0, p=1; for (int i=1; i<=n; i++){ scanf("%d %d %d", &c, &v, &s); for (int x=1; s>x; s-=x,x*=2,p++) lists[p].c = c*x, lists[p].v = v*x; } // 0-1 背包处理 for (int i=1; i<=n; i++) for (int j=p-1; j>=lists[i].c; j--) dps[j] = max(dps[j], dp[j-list[i].c]+lists[i].v); printf("%d\n", dps[t]); }
9.基于DAG的动态规划
对于二元关系,将题目抽象为求有向无环图的最长、短路径
- 题目:输入 n 个矩形(每个矩形包括长宽信息),大矩形可以嵌套小矩形,求最大的嵌套个数。
- 分析:把每个矩形看做一个节点,矩形 a 可以嵌套在矩形 b 中,则认为 a 指向 b(存在一条单向路径),则可以将输入抽象为一个有向无环图,而求最大嵌套个数,即求最长路径长度。
- 状态转移方程:
dp[i] = max{dp[i], dp[j]+1}
- 代码:
// 求以 i 节点为起始节点时的最长路径
// 如果当前点已经求过最长路径,则退出。
// 否则:枚举其所有的邻接点,以当前节点为起始点的最长路径长度为原状态(
// 上一个最大值)与以当前邻接点为起始点的最长路径长度加 1
int items[1001][2];
int G[1001][1001];
int dp[1001];
int GetMaxLongPath(int i){
if (dp[i] != 0) return dp[i]; // 记忆化搜索。减少损耗。
for (int j=1; j<=n; j++)
if (G[i][j] == 1) // 如果联通
dp[i] = max(dp[i], GetMaxLongPath(j)+1);
return dp[i];
}
int main(){
// ① 输入与初始化
int n=0;
scanf("%d", &n);
for (int i=1; i<=n; i++)
scanf("%d %d", &items[i][0], &items[i][1]);
fill(G, G+1001*1001, 0);
fill(dp. dp+1001, 0);
// ② 建图
for (int i=1; i<=n; i++)
for (int j=1; j<=n; j++)
if (items[i][0]>items[j][0] && items[i][1]>items[j][1])
G[i][j] = 1;
// ③ 递归求解
for (int i=1; i<=n; i++)
GetMaxLongPath(i);
printf("%d\n", );
return 0;
}
10. 背包问题总结
注意,背包问题最终都可以化归到 0-1 背包问题上来,所以我们可以看到状态转移方程都是一样的。注意,上述问题都是求最大背包数量,如果要求最小背包数量应该也会修改代码(简单的修改为 min 即可)。