动态规划入门
动态规划原理
何为动态规划?
动态规划(\(\text {Dynamic programming}\)),简称 DP。
DP 并不是一种算法,与模拟、贪心一样,而是一种解决问题的方式。
DP 的基本思想为「将给定的问题拆分为一个个规模更小的子问题,直到子问题可以直接解决,返回/保存这个值,再根据方程一步步推出原本问题的答案。」,
有没有发现, DP 的基本思想怎么好像和递归、递推的差不多?没错,DP 就是基于递归或递推运行的。
DP 的术语:
- 决策,指在问题中可以选择的操作,如在元神中与 \(\texttt {NPC}\) 对话时,你可以选择对话回复的内容。
- 策略,由一系列决策组成的集合。
- 状态,指将原问题划分为更小的子问题时,用来描述子问题的属性或变量的取值。
- 状态空间,指问题中所有可能状态的集合。
DP 的特性为:
- 无后效性,已经求解的子问题,不会再受到后续决策的影响。
- 最优子结构,
DP问题的最优解所包含的子问题的解也是最优的。 - 子任务重叠,有大量重叠的子问题,但是不是所有
DP问题都有这个特性。
DP 往往用于求最优解问题,例如问题
\(\texttt {Hello Kitty}\) 想搞点花生 \(\texttt {77}\) ,她来到一片有网格道路的 \(r \times c\) 的矩阵花生地,她要从左上角进入,右下角出来。地里每个道路的交叉点上都有种着一株花生苗,上面有若干颗花生,用 \(a_{ij}\) 表示,经过一株花生苗就能摘走该它上面所有的花生。\(\texttt {Hello Kitty}\) 只能向右或向下走,不能向左或向上走。问 \(\texttt {Hello Kitty}\) 最多能够 \(7\) 到多少颗花生。
对于这个问题,DP 时要经过以下三个步骤:
-
确定状态和表达方式,如本题,假设要走到 \((i,j)\) 处,就只能从上面和左边走过来,也就是 \((i - 1,j)\) 和 \((i,j - 1)\) 两种状态,
可以用 \(dp(i,j)\) 来表示从 \((1,1)\) 走到 \((i,j)\) 所能摘到最多的花生数。
-
分解子问题,我们看 \(1.\),会发现我们可以把 \(dp(r,c)\) 分解成 \(dp(r-1,c),dp(r,c - 1),dp(r - 1,c - 1)\) 三个子问题,
而这些子问题又可以分解为子问题,最后分解为 \(dp(1,1)\),此时确定边界为 \(dp(1,1) = a_{1,1}\)。
-
列出状态转移方程,状态转移方程就是通过规模更小的子问题推导出本子问题的方程,此题如要推导出 \(dp(i,j)\),
状态转移方程就是 \(dp(i,j) = \max(dp(i,j - 1),dp(i - 1,j)) + a_{ij}\)。
-
按顺序求解子问题,按照前面想好的,确定
DP顺序(一般自底向上),优化空间(滚动数组),优化时间(记忆化搜索)等。
线性 DP
数字三角形模型
一个数字三角形如下
\[\large\begin{array}{{3}{r@{.}l}} \ & \ &\ &\ &7&\ &\ &\ &\\\ &\ &\ &3&\ &8&\ &\ &\\\ &\ &8&\ &1&\ &0&\ &\ \\ &2 &\ &7 &\ &4 &\ &4\ &\\\ 4&\ &5 &\ &2 &\ &6 &\ &5 \\ \end{array} \]写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以走到右下方的点。
给定数字三角形的层数为 \(r\)。
\(1 \le r \le 10^3\)[1]
首先确定状态和表达方式,很明显是左下和右下 \(2\) 种状态,用 \(dp(i,j)\) 表示「从顶点走到 \((i,j)\) 的所有走法中,经过数字之和的 \(\max\) 值」。
我们可以这样想,要求出 \(dp(i,j)\),先想如要走到本点,就只有左上与右上可以走来,那么我必然会取左上与右上中较大的一个,再加上本点的值。
那就可以写出状态转移方程 \(dp(i,j) = \max(dp(i - 1,j - 1),dp(i - 1,j)) + a_{ij}\),这里我们用 \(a_{ij}\) 来表示 \((i,j)\) 上的数字。
\((i - 1,j - 1),(i - 1,j)\) 分别表示左上与右上的数。
最后我们枚举 \(i,j\),再将 \(dp\) 数组的第 \(r\) 行办理,找出 \(\max\) ,输出。
#include<bits/stdc++.h>
using namespace std;
int a[1005][1005],dp[1005][1005],r; // a 数组与 DP table
int main(){
cin>>r;
for(int i = 1;i <= r;i ++)
for(int j = 1;j <= i;j ++)
cin>>a[i][j];
// 以上为输入
for(int i = 1;i <= r;i ++) // 枚举层
for(int j = 1;j <= i;j ++) // 枚举列
dp[i][j] = max(dp[i - 1][j - 1],dp[i - 1][j]) + a[i][j]; // 状态转移方程
int maxn = 0;
for(int i = 1;i <= r;i ++)
maxn = max(dp[r][i],maxn); // 遍历取 max
cout<<maxn;
return 0; // 谢幕
}
最长上升子序列
给出一个由 \(n(n\le 5000)\) 个不超过 \(10^6\) 的正整数组成的序列。请输出这个序列的最长上升子序列(\(\text {LIS}\))的长度。
最长上升子序列是指,从原序列中按顺序取出一些数字排在一起,这些数字是逐渐增大的。[2]
同样的,先确定状态和表达方式,为加入 \(\text {LIS}\) 和不加入 \(\text {LIS}\),用 \(dp(i)\) 来表示「对于前 \(i\) 个整数它们的 \(\text {LIS}\)」。
我们可以这样想,要求出 \(dp(i)\),如果不加入 \(\text {LIS}\),那么我自己就是一条长度为 \(1\) 的 \(\text {LIS}\),反之,我就在前 \(i\) 个的每个 \(\text {LIS}\) 中尝试加入,
找出加入那个 \(\text {LIS}\) 长度最长,加入后就是原 \(\text {LIS} + 1\)。
状态转移方程 \(a_j < a_i,j < i,dp(i) = \max(dp(i),dp(j) + 1)\)。
枚举 \(i\),最后遍历 \(dp\) 数组找出 \(\max\)。
#include<bits/stdc++.h>
using namespace std;
int dp[5005],n,a[5005],maxn;
int main(){
cin>>n;
dp[1] = 1;
cin>>a[1]; // 预处理 i = 1 的情况
for(int i = 2;i <= n;i ++){
dp[i] = 1; // 先初始化为 1
cin>>a[i]; // 输入
for(int j = 1;j < i;j ++) // 枚举 j
if(a[j] < a[i])dp[i] = max(dp[i],dp[j] + 1); // 状态转移方程
}
for(int i = 1;i <= n;i ++)
maxn = max(maxn,dp[i]); // 遍历查找 max
cout<<maxn;
return 0; // 完结撒花
}
背包 DP
更好的大神资料:\(\text {背包九讲}\)
\(\texttt {01}\)背包问题
\(\texttt {01}\)背包问题如下
\(N\) 种物品,背包容量为 \(M\),给定每种物品的体积 \(w_i\),价值 \(v_i\),问如何使放入背包的物品价值之和最大?
\(N \le 100,1 \le M \le 10^3\)
首先确定状态和表达方式,我们会发现,每种物品都对应了不取与取 \(2\) 种状态(可以用数字 \(0\) 和 \(1\) 表示,因此被称作\(\texttt {01}\)背包问题),
所以,我们可以用 \(dp(i,j)\) 来表示「对于前 \(i\) 个物品,物品体积不超过 \(j\) 的情况下,所有取法中的价值 \(\max\) 值」。
状态转移方程就是
对应不取[3]和取[4]的状态,然后取 \(\max\);同时考虑背包容量不够的情况,直接不取。
//此处只给出核心代码
int n,m,dp[N][M] = {0},w[N],v[N];// n,m,DP table 与记录体积、价值的数组 w,v
//输入……
for(int i = 1;i <= n;i ++){ // 枚举子问题(枚举前 i 个物品)
for(int j = w[i];j <= m;i ++){ // 枚举背包容量
dp[i][j] = dp[i - 1][j];
if(j >= w[i])dp[i][j] = max(dp[i - 1][j],dp[i - 1][j - w[i]] + v[i]); // 状态转移方程
}
}
cout<<dp[n][m]; // 输出结果
完结撒花 ·❀\(≧▽≦)/❀· ——吗?
「同样」的问题,上面的代码能 \(\color {green} {\texttt {Accepted}}\) 吗?
\(N\) 种物品,背包容量为 \(M\),给定每种物品的体积 \(w_i\),价值 \(v_i\),问如何使放入背包的物品价值之和最大?
\(N \le 3402,1 \le M \le 12880\)[5]

\(\texttt {MLE}\) 了 \(2\) 个点……
计算一下,题目给了 \(128\ \text{MiB}\),转换成 \(\text {Byte}\)就是 \(16,777,216\ \text{Byte}\),而一个 int \(4\ \text{Byte}\),
而我们一共使用了 \((3402 \times 12880 + 3402 \times 2) \times 4 = 175,298,265 \ \text{Byte}\),我们用了 \(9\) 位数,但题目才给我们 \(8\) 位数,严重爆内存!
这时我们就要用一种名为「滚动数组」的小技巧了,把二维数组「滚」成一维数组。
何为滚动?
滚动就是新数据覆盖旧数据,把无用的旧数据丢弃,节约空间,就像数据在滚动一样(某些事情可不能这么干,会被骂的)。
我们观察输入数据为
4 6
1 4
2 6
3 12
2 7
时,DP table 的状态:
4 4 4 4 4 4
4 6 10 10 10 10
4 6 12 16 18 22
4 7 12 16 19 23
如果我们横着看,可以清晰的发现,每一层都是由上一层通过状态转移方程转移过来的,
例如 \(dp(3,3) = 12\),就是由 \(dp(3,3) = \max(dp(2,3),dp(3,0) + 12),(dp(2,3) = 10,dp(3,0) = 0)\) 转移而来的,
因此,我们发现计算转移时,可以直接用一个一维数组记录当前层,但转移方程中的 \(dp(i - 1,j)\) 怎么使用呢?
答:根本不需要使用,因为使用一维存储当前层,所以进入下一层时,数组里还会遗留上一层的数据,
因此,当我们要使用上一层的同列旧元素时,直接使用本元素就可以了。
\(\texttt {01}\)背包状态转移方程(滚动数组):\(dp(j) = \max(dp(j),dp(j - w_i) + v_i)\),简洁优美。
但是注意!与原本不同的除了状态转移方程,还有 \(j\) 不能正序枚举,必须逆序枚举。
\(\text {Why?}\)
我们可以手玩一下:
假设我们正序进行,此时 \(i = 5,j = 10,w_i = 1\),如果我们要计算 \(dp(10)\) 也就是 \(dp(5,10)\) 时,
状态转移需要的 \(dp(9)\) 即 \(dp(5,9)\) 位置早就计算完成,被更新为新的数据了!
但是假设我们逆序进行,开始计算 \(dp(10)\) 时,\(dp(9)\) 还未开始计算,所以还是旧数据,可以直接使用,
以此类推,每个 \(dp(j)\) 所需要的旧数据都在前面(设需要旧数据的为 \(dp(k)\),则一定 \(k < j\) )没有被覆盖,
所以逆序枚举就可以使递推顺利进行。
//此处只给出核心代码,too
int n,m,dp[M] = {0},w[N],v[N];// n,m,一维 DP table 与记录体积、价值的数组 w,v
//输入……
for(int i = 1;i <= n;i ++){ // 枚举子问题(枚举前 i 个物品)
for(int j = m;j >= w[i];i --){ // 枚举背包容量
dp[i][j] = max(dp[j],dp[j - w[i]] + v[i]); // 状态转移方程
}
}
cout<<dp[m]; // 输出结果
\(\texttt {01}\)背包是背包问题中最基础也是最重要的一部分,所有的背包问题都是基于\(\texttt {01}\)背包的变种,
学好\(\texttt {01}\)背包就能轻松学会背包 DP,四舍五入就是学一个就学完了!

浙公网安备 33010602011771号