动态规划核心思想
把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解
而要理解这句话,那就要从递推开始说起:
递推
递推的特点在于,每一项都和他前面的若干项有一定关联,这种关联一般可以通过递推关系式来表示,然后可以通过其前面若干项得出某项的数据。——摘自计蒜客
而对于递推来说,最核心的就是找到递推方程。递推方程可以从第n个元素开始想,亦可从第m个元素作为结尾开始想,思考问题的角度有很多种。我们的目的是寻找
f
(
n
)
f(n)
f(n) 与
f
(
n
−
1
)
f(n-1)
f(n−1)以及
f
(
n
−
2
)
f(n-2)
f(n−2)乃至前几项之间的关系,写出关于
f
(
n
−
1
)
,
f
(
n
−
2
)
.
.
.
f(n-1),f(n-2)...
f(n−1),f(n−2)...的递推方程
f
(
n
)
=
F
(
f
(
n
−
1
)
,
f
(
n
−
2
)
,
.
.
.
)
)
f(n) = F(f(n-1),f(n-2),...))
f(n)=F(f(n−1),f(n−2),...))。而函数
F
F
F的参数个数,代表了你要在递推的开始时所需要求出的最少基础数据
(
f
(
1
)
,
f
(
2
)
,
.
.
.
)
(f(1),f(2),...)
(f(1),f(2),...),亦称边界值。
斐波那契的例子就不举了,我们从下面这个问题开始着手:
我写了n封信,对应有n个信封,如果所有的信都装错了信封,那么会有多少种不同的情况?
我们先将信件排列成从
1
−
n
1-n
1−n的序列,从第n封信件入手。将第n的信封放到前面某一个下标为m的信封里面。
将第n个信件放入前面n-1个信封中自然就有n-1种情况。那么我们再对第m个信件进行分析,发现有两种情况:
- 如果将第m个信件放入第n个信封,此时的情况就是n和m的信件分别交叉放入对方的信封中,那么你们想想,对于剩余的信封信件,是不是相当于处理与问题初始相同的情况,只不过数目变成n-2而已?此时我们记这种情况为f(n-2)
- 但如果此时我们将第m封信件放入除第n封信外的其他信封里,那么第m封信件只能选择n-2种放置方式(因为第m封信既放不到第n个信封中,又因为第n个信件已经占了自己的信封,所以只用n-2中选择),而对于剩下的信件而言,也是除自身外n-2个选择,那么每个信封都对应n-2中选择,这不就是f(n-1)情况中的每个信件的放置数量吗?
那么我们可以知道,两种情况是互斥的,所以需直接相加再乘以每种情况的系数,写出递推方程:
f
(
n
)
=
(
n
−
1
)
∗
(
f
(
n
−
1
)
+
f
(
n
−
2
)
)
f(n) = (n-1) * (f(n-1) + f(n-2))
f(n)=(n−1)∗(f(n−1)+f(n−2))
而我们此时只要求出f(1) f(2) 即可用循环地推的方式求出答案了。
递推的应用
递推可以大幅度优化一些回溯搜索算法,减少相当的时间复杂度。
举一个题目:
棋盘上A点有一个过河卒,需要走到目标B点。卒行走的规则:可以向下、或者向右。同时在棋盘上C点有一个对方的马,该马所在的点和所有跳跃一步可达的点称为对方马的控制点。因此称之为“马拦过河卒”。
棋盘用坐标表示,A点(0, 0)、B点(n, m)(n, m为不超过20的整数),同样马的位置坐标是需要给出的。
现在要求你计算出卒从A点能够到达B点的路径的条数,假设马的位置是固定不动的,并不是卒走一步马走一步。
解法:对于每个点的路径数目来说,都来自于其上方与左方的路径数目相加,即 F F Fi,j = F F F i−1,j + F F Fi,j−1 然后记得进行一些合理访问上的判断:写成这样吧
f[0][0] = 1;
for (int i = 0;i <= n; ++i) {
for (int j = 0; j <= m; ++j) {
if (i != 0) {
f[i][j] = f[i][j] + f[i-1][j];
}
if (j != 0) {
f[i][j] = f[i][j] + f[i][j-1];
}
}
}
// f[n][m]即为点(n,m)的路径数目。
简单的递推题就不推荐给大家做了,这里有道比较考验思维的题目,现贴出来
墙壁涂色
(https://www.jisuanke.com/course/736/37741)
思路:思考思考,填第n的元素的时候有哪些互斥情况,每一种情况又有多少数量?
提示:
不满足递推方程的个数是大于等于边界值的,需要注意,有时候要多求一到两个f(m+1)的值。,比如墙壁涂色的题目,前面3项都不符合递推式。
动态规划——最优类问题
递推与动态规划的区别
动态规划有着递归的思想,但递归一般用于处理判定性问题与计数问题(相信大家在上文能够感受到了),而动态规划一般用来解决最优解问题,但在强调一遍,动态规划的思想脱离不了递归。
最重要的思想是,对于任意一个元素,我们要找到能对他处理的决策。
对于递归算法,最重要的是写出递归方程,而同样的,对于动态规划,最重要的是写出状态转移方程.
直接上一道题目:
[外链图片转存失败(img-cr37nQHf-1563336677912)(https://res.jisuanke.com/img/upload/20170113/ae2c2eb61390a08f469136dffa05754874681391.png)]
图中的数字代表消耗值,问从出发地到家里,最少消耗的体力值是多少。
分析:到达一个点,无非是从左侧和下侧过来的,那么写出一个点消耗最少的状态转移方程:
d
p
(
i
,
j
)
=
m
i
n
(
d
p
(
i
−
1
,
j
)
,
d
p
(
i
,
j
−
1
)
)
+
a
i
j
dp(i,j) = min(dp(i-1,j), dp(i,j-1)) + a ij
dp(i,j)=min(dp(i−1,j),dp(i,j−1))+aij从左下往右上遍历即可
接着贴一个过河问题:
问题描述在漆黑的夜里,N位旅行者来到了一座狭窄而且没有护栏的桥边。如果不借助手电筒的话,大家是无论如何也不敢过桥去的。不幸的是,N个人一共只带了一只手电筒,而桥窄得只够让两个人同时过。如果各自单独过桥的话,N人所需要的时间已知;而如果两人同时过桥,所需要的时间就是走得比较慢的那个人单独行动时所需的时间。问题是,如何设计一个方案,让这N人尽快过桥。
输入
第一行是一个整数T(1<=T<=20)表示测试数据的组数 每组测试数据的第一行是一个整数N(1<=N<=1000)表示共有N个人要过河。每组测试数据的第二行是N个整数Si,表示此人过河所需要花时间。(0<Si<=100)
输出
输出所有人都过河需要用的最少时间
先给每个人按小排序。我们不妨从已经过去了n-2个人和已经过去了n-1个人且手电筒都在已过桥的一侧的情况开始分析。
01背包
——要不就选,要不就不选的背包
对于01背包,先确定这个问题的状态。共有N个物品,背包总承重为V,那么可以根据物品和容量来确定一个状态。前i个物品,放在背包里,总重量不超过j的前提下,所获得的最大价值为dp[i][j]。
是否将第i个物品装入背包中,就是决策。为了使价值最大化,如果第i个物品放入背包后,总重量不超过限制且总价值比之前要大,那么就将第i个物品放入背包。根据这个逻辑写出转移方程:
存在
j
<
w
[
i
]
,
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
j<w[i],dp[i][j]=dp[i-1][j]
j<w[i],dp[i][j]=dp[i−1][j]即不选第i个物品
存在
w
[
i
]
≤
j
≤
C
,
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
−
1
]
[
i
−
w
[
i
]
]
+
v
[
i
]
)
w[i]≤j≤C,dp[i][j]=max(dp[i-1][j],dp[i-1][i-w[i]]+v[i])
w[i]≤j≤C,dp[i][j]=max(dp[i−1][j],dp[i−1][i−w[i]]+v[i])选上此物品。
01背包有两种写法,一个是未空间优化的,好理解:
for (int i = 1; i <= N; ++i) {
for (int j = 0; j <= V; ++j) {
if(j >= w[i]) {
dp[i][j] = max(dp[i - 1][j - w[i]] + v[i], dp[i - 1][j]);
}
else {
dp[i][j] = dp[i-1][j];
}
}
}
另外一个是空间优化过的,鉴于一维是冗余的:
for (int i = 1; i <= n; ++i)
for (int j = v; j >= w[i]; --j) ///空间优化过的,从背包大小V开始
dp[j] = max(dp[j - w[i]] + v[i], dp[j]);
放一题:习题:蒜头君的购物袋 1此题可把所占体积也理解为单位体积价值为1的物品,那么就是求物品价值的最大值,然后减去体积即可。
完全背包
——可以无限选的背包
完全背包相对于01背包而言,其实就是一个物品可以无限被选取,最后实现背包价值的最大化
for (int i = 1; i <= n; ++ i)
for (int j = c[i]; j <= v; ++ j)
dp[j] = max(dp[j - c[i]] + w[i], dp[j]);
可以从代码中看出,完全背包就是空间优化版的01背包,在第二重循环修改的循环条件,从c[i]开始,以递增方式递归
多重背包
——选有限个的背包
一个物品存在有限数量,求多重背包的最大价值。
其实可以将物品数量二进制拆分化,例如14,可以写成1+2+4+7,那么可以发现,1~14个数量都可以用1,2,4,7这几个数字组合出来,比如要个12,就是12 = 1+4+7,将对应倍乘的体积与价值存到体积—价值序列中(比如,就把4个物品看成一个新物体,体积和价值都是原物体的四倍,将这个新物体丢进序列就可),那么就能转化成01背包了。
此处没有代码,就是01背包多了几个新物品而已,这个算法很好写,就不介绍了。
LIS(最长上升子序列)
在原序列取任意多项,不改变他们在原来数列的先后次序,得到的序列称为原序列的子序列。最长上升子序列,就是给定序列的一个最长的、数值从低到高排列的子序列,最长子序列不一定是唯一的。例如,序列2,1,5,3,6,4,6,3的最长上升子序列为1,3,4,6和2,3,4,6,长度均为4。
先确定动态规划的状态,这个问题可以用序列某一项作为结尾来作为一个状态。用
d
p
[
i
]
dp[i]
dp[i]表示一定以第i项为结尾的最长上升子序列。用
a
[
i
]
a[i]
a[i] 表示第i项的值,如果有
j
<
i
j<i
j<i且
a
[
j
]
<
a
[
i
]
a[j]<a[i]
a[j]<a[i],那么把第i项接在第j项后面构成的子序列长度为:
d
p
[
i
]
=
d
p
[
j
]
+
1
dp[i]=dp[j]+1
dp[i]=dp[j]+1。
要使
d
p
[
i
]
dp[i]
dp[i]为以
i
i
i结尾的最长上升子序列,需要枚举所有满足条件的
j
j
j。所以转移方程是:
存在
j
<
i
(
1
<
=
i
,
j
)
j<i(1<=i,j)
j<i(1<=i,j)&&
a
[
j
]
<
a
[
i
]
,
d
p
[
i
]
=
m
a
x
(
d
p
[
i
]
,
d
p
[
j
]
+
1
)
a[j]<a[i],dp[i]=max(dp[i],dp[j]+1)
a[j]<a[i],dp[i]=max(dp[i],dp[j]+1)
那么代码是:
int dp[MAX_N], a[MAX_N], n;
int ans = 0; // 保存最大值
for (int i = 1; i <= n; ++i) {
dp[i] = 1;
for (int j = 1; j < i; ++j) {
if (a[j] < a[i]) {
dp[i] = max(dp[i], dp[j] + 1);
}
}
ans = max(ans, dp[i]);
}
cout << ans << endl; // ans 就是最终结果
让我们再写一个优化版的LIS:
const int MAX_N = 10;
int main()
{
int ans[MAX_N], a[MAX_N]={0,2,1,5,3,6,4,6,3}, dp[MAX_N], n=8; // ans 用来保存每个 dp 值对应的最小值,a 是原数组
int len; // LIS chccc
ans[1] = a[1];
len = 1;
for (int i = 2; i <= n; ++i) {
if (a[i] > ans[len]) {
ans[++len] = a[i];
} else {
int pos = lower_bound(ans + 1, ans + len + 1, a[i]) - ans;
/**要知道,这里的ans数组并不是最终的最长上升子序列
***而它也能求出结果
***因为当二分找到的是ans[len]的值时,那么此时的a[i]与ans[len]有着同样的dp值
***那么修改了ans[len]之后,ans[len]的值就变小了,符合我们的预期
***如果二分修改的是前面的值,不影响结果,因为序列仍在继续往前走
**/
ans[pos] = a[i];
}
}
cout << len << endl; // len 就是最终结果
return 0;
}
LCS(最长公共子序列)
给定两个序列S1和S2,求二者公共子序列S3的最长的长度。
有了前面的基础,可以发现这个问题仍然可以按照序列的长度来划分状态,也就是S1的前i个字符和S2的前j个字符的最长公共子序列长度,记为lcs[i][j]。
那么当S1的第i个元素与S2的第j元素相等,就可以写出状态方程1:
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
−
1
]
+
1
dp[i][j] = dp[i-1][j-1]+1
dp[i][j]=dp[i−1][j−1]+1当S1的第i个元素与S2的第j元素不等时,状态方程2:
d
p
[
i
]
[
j
]
=
m
a
x
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
dp[i][j] = max(dp[i-1][j],dp[i][j-1])
dp[i][j]=max(dp[i−1][j],dp[i][j−1]),最终答案就是dp[lena][lenb]
string a,b;
memset(dp,0,sizeof(dp));
cin>>a>>b;
int lena = a.size();
int lenb = b.size();
///注意,这里从1开始,一直到lena,lenb,最终答案就是dp[lena][lenb]
for(int i=1;i<=lena;++i){
for(int j=1;j<=lenb;++j){
if(a[i-1]==b[j-1]){
dp[i][j] = dp[i-1][j-1]+1;
}
else{
dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
}
}
}
cout<<dp[lena][lenb]<<endl;
关于LIS,按例贴一道题目:习题:删除最少的元素 画图可知,只要在某一个节点(循环取此节点)将后面的数都取负数,然后做一个最长不升子序列就可以了。
(也可以优化,先从前面开始做最长不升子序列,在从后面做最长不升子序列,dp1[i]+dp2[i] - 1就是取a[i]为最低值的最长ans序列,对dp1[i]+dp2[i]跑个循环即可)
关于LCS,也贴一道题目:习题:回文串 把回文串的顺序倒转后,与原串是一样的。
那么我们只要把给定的字符串顺序倒转与原串求最长公共子序列,再用字符串总长度减去最长公共子序列的长度就是相差的字符个数,也就是答案。(虽然我不知道为什么,就是很神奇,算作求回文的一个LCS应用吧)
状态压缩DP
状压DP应该是动态规划入门最难的一个算法,目前笔者遇到的问题有两类:
- 一类用来处理矩阵中一行的取值方式跟上面行的信息相关联,那么这时候就可以用二进制枚举第一行(如果前两行有关联,多加个第二行),然后滚动dp解决
- 另一类用来处理从上一层枚举的结果进一步枚举的问题,用子集dp解决(如果一次枚举就枚举完,那么结果就是枚举完的结果,否则就是子集枚举结果的最小之和(一般拆成两个互补的子集))。
滚动dp
话不多说,对于第一类直接上题:习题:灌溉机器人
#include <iostream>
#include <cstring>
using namespace std;
const int MAX_N = 100;
const int MAX_M = 10;
int state[MAX_N + 1];//i行状态
//int dp[MAX_N + 1][1 << MAX_M][1 << MAX_M];//i行状态为j i-1行状态为k时包含的最多1的个数
int dp[2][1 << MAX_M][1 << MAX_M];//i行状态为j i-1行状态为k时包含的最多1的个数
bool not_intersect(int now, int prev) {
return (now & prev) == 0;
}
bool fit(int now, int flag) {
return (now | flag) == flag;
}
bool ok(int x) {
// 行内自己不相交,返回true
return ( (x & (x / 2)) == 0 ) && ( (x & (x / 4)) == 0 );
}
int calc(int now) {
int s = 0; // 统计 now 的二进制形式中有多少个 1
while (now) {
s += (now & 1); // 判断 now 二进制的最后一位是否为 1,如果是则累加
now >>= 1; // now 右移一位
}
return s;
}
int main() {
int n, m;
cin >> n >> m;
// 初始化所有数组
memset(state, 0, sizeof(state));
memset(dp, 0, sizeof(dp));
char c;
for (int i = 1; i <= n; ++i) {
for (int j = 0; j < m; ++j) {
int flag;
cin >> c;
if(c == 'P') {
flag = 1;
} else {
flag = 0;
}
state[i] |= (1 << j) * flag; // 将 (i,j) 格子的状态放入 state[i] 中,state[i] 表示第 i 行的可选格子组成的集合
}
}
//处理第1行边界
for(int j = 0; j < (1 << m); ++j) {
if (!ok(j) || !fit(j, state[1])) { // 如果第1行状态不合法则不执行后面的枚举
continue;
}
int cnt = calc(j);
dp[1%2][j][0] = cnt;
}
//处理第2行边界
for(int j = 0; j < (1 << m); ++j) {
if (!ok(j) || !fit(j, state[2])) { // 如果第2行状态不合法则不执行后面的枚举
continue;
}
int cnt = calc(j);
for (int k = 0; k < (1 << m); ++k) {
if (ok(k) && fit(k, state[1]) && not_intersect(j, k)) { // 第1行合法且第2行 第1行不冲突
dp[2%2][j][k]= dp[1%2][k][0] + cnt; // 更新当前行、当前状态的最优解
}
}
}
//正常处理
for (int i = 3; i <= n; ++i) {
for (int j = 0; j < (1 << m); ++j) { // 枚举当前行的状态
if (!ok(j) || !fit(j, state[i])) { // 如果当前行状态不合法则不执行后面的枚举
continue;
}
int cnt = calc(j); // 统计当前行一共选了多少个格子
for (int k = 0; k < (1 << m); ++k) {//上行状态为k
if (!ok(k) || !fit(k, state[i-1])) { // 如果上行状态不合法则不执行后面的枚举
continue;
}
for(int l = 0; l < (1 << m); ++l) {//上上行状态为l
if (!ok(l) || !fit(l, state[i-2])) { // 如果上上行状态不合法则不执行后面的枚举
continue;
}
if(not_intersect(j, k) && not_intersect(k, l) && not_intersect(j, l)) {
dp[i%2][j][k]= max(dp[i%2][j][k], dp[(i - 1)%2][k][l] + cnt); // 更新当前行、当前状态的最优解
}
}
}
}
}
int ans = 0; // 保存最终答案
for (int i = 0; i < (1 << m); ++i) {
for(int j = 0; j < (1 << m); ++j)
ans = max(ans, dp[n%2][i][j]); // 枚举所有状态,更新最大值
}
cout << ans << endl;
return 0;
}
滚动dp的特点(对于矩阵状压)就是有多少个关联行的个数,那么就有多少个边界值==初始行。通过枚举第一行以及关联的第m行将初始行确立下来,进而遍历所有行,复杂度为 O ( 3 n + n × 2 n ) O(3^n+n×2^n) O(3n+n×2n), n × 2 n n×2^n n×2n中的 n n n来自于遍历所有行, 2 n 2^n 2n来自枚举, 3 n 3^n 3n来自初始化(上面举的这道题大约复杂度为 2 n ∗ 2 n 2^n * 2^n 2n∗2n,因为关联行有两行)
tips:这里用了滚动数组,可以节省空间。DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。可以通过取模来实现。
子集dp
第二类伪代码:
for (int t = 1; t < (1 << n); t++) { // 枚举当前状态
dp[t] = t满足条件(G) ? ans : inf; // 判断当前状态是否是回文,如果是回文则步骤数为 1
for(int i = t; i; i = (i - 1) & t) { // 枚举 t 的所有子集
dp[t] = min(dp[t], dp[i] + dp[t ^ i]); // 更新当前状态的解的最小值
} ///↑这个是两个互补的子集
}
printf("%d\n", dp[(1 << n) - 1]); // 输出最终答案
///答案是全员枚举的下标,dp[(1<<n) - 1];
上一道题:习题:蒜头君的积木
代码:
#include<bits/stdc++.h>
using namespace std;
typedef unsigned long long LL;
int linked[20][20] = {0};
int n;
int dp[1<<20] = {0};
const int INF = 0x3f3f3f3f;
LL qp(int data,int n)
{
LL ans = 1;
while(n){
if(n&1){
ans=(ans*data)%(1LL<<32);
}
data = (data*data)%(1LL<<32);
n>>=1;
}
return ans;
}
bool ban(int x)
{
for(int i=0;i<n;i++){
if(x&(1<<i)){
for(int k=i+1;k<n;k++){
if(x&(1<<k)&&linked[i][k]){
return false;
}
}
}
}
return true;
}
int main()
{
cin>>n;
LL ans = 0;
char op;
getchar();
for(int i=0;i<n;i++){
for(int j=0;j<n;j++){
op=getchar();
if(op=='1') linked[i][j] = 1;
}
getchar();
}
for(int i=0;i<(1<<n);i++){
if(ban(i)){
dp[i] = 1;
}
else{
dp[i] = INF;
for(int k=i;k;k=(k-1)&i){
dp[i] = min(dp[i],dp[i^k]+dp[k]);
}
}
ans += dp[i]*qp(233,i)%(1LL<<32);
ans%=(1LL<<32);
}
cout<<ans-1<<endl;
return 0;
}
基本的动态规划到这里就介绍结束了,本文仅作为个人复习向。在此向广大博友推荐计蒜客的动态规划课程,这里是链接:动态规划——计蒜客基本本博内容都来源于此课程,上课程的时候可以结合本博进行参考。如有纰漏,望指正。