前言
动态规划一直是面试和竞赛的常考算法。笔者对于动态规划算法略有研究,在参与ACM-ICPC竞赛阶段时,是队内的动态规划选手。
这里指出动态规划的本质:是对深度搜索的记忆化+宽度搜索转化+优化。
不同于别人的文章,本文指出动态规划的一般步骤如下:
1.写出指数级的深度搜索算法来搜索所有可能的解空间。
2.通过记忆化将深度搜索的复杂度降至多项式级。
3.记忆化数组即转移方程,我们将深度搜索改造成依赖记忆化数组的宽度搜索。
4.优化转移方程。
现有的大部分讲解动态规划的文章略去了步骤1,2,往往直接提出转移方程进行转移或者优化。笔者认为这是不科学的且很难应用到其余题目。只有在非常熟练的掌握动态规划算法后,才能够很自然的写出转移方程,这对于初学者这是极难的。所以步骤1,2不应跳过。
引入问题:01背包
给出如下问题:有 n n n个物品,第 i i i物品有两个属性重量 a a [ i ] aa[i] aa[i],价值 b b [ i ] bb[i] bb[i]。现有一个承重为 m m m的背包,问能装的最大价值是多少?
步骤1:使用深度搜索遍历解空间
考虑枚举每个物品用或者不用,来枚举整个解空间。
注意,笔者的物品下标是[1,n]。
void dfs(int id, int sum, int val) { //搜索到了第i个物品,前面选的物品重量和是sum,价值和是val
if (sum > m) return;
if (id > n) { //递归终止条件
ans = max(ans, val);
return;
}
dfs(id + 1, sum, val); //第i个物品不选
dfs(id + 1, sum + aa[i], val + bb[i]); //第i个物品选
}
复杂度 O ( 2 n ) O(2^n) O(2n)。
步骤2:使用记忆化优化
我们发现对于相同的两个状态 ( i d , s u m , v a l ) (id,sum,val) (id,sum,val),如果三个参数完全相同,那么 d f s dfs dfs的后续过程也将完全相同,所以只要跑一次即可。
void dfs(int id, int sum, int val) {
if (vis[id][sum][val]) return; //如果三元组(id,sum,val)已经搜索过了,就不必搜索了
vis[id][sum][val] = true; //打上搜索过的标记
if (sum > m) return;
if (id > n) {
ans = max(ans, val);
return;
}
dfs(id + 1, sum, val);
dfs(id + 1, sum + aa[i], val + bb[i]);
}
由于相同 ( i d , s u m , v a l ) (id,sum,val) (id,sum,val)只会跑一次,记忆化以后复杂度只和三元组 ( i d , s u m , v a l ) (id,sum,val) (id,sum,val)的个数相关。复杂度 O ( n ∗ m ∗ s u m ( v a l ) ) O(n*m*sum(val)) O(n∗m∗sum(val))。
步骤3:宽度搜索化
我们用宽度搜索的方式实现它。深度搜索每次搜索完
n
n
n个数后,才会折返选择其他方案。而宽度搜索则会把第
i
d
id
id个数的所有
(
s
u
m
,
v
a
l
)
(sum,val)
(sum,val)二元对处理完后,才会去处理
i
d
+
1
id+1
id+1。
注意,默认
v
i
s
vis
vis中都是
f
a
l
s
e
false
false。
val[0][0][0] = true;
for (int id = 1; id <= n ; id++) {
for (int sum = 0; sum <= m; sum++) {
for (int val = 0; val <= maxv; val++) { //枚举三元组(id-1,sum,val)
if (vis[id - 1][sum][val]) {
vis[id][sum][val] = true; //不选第i个数,向三元组(id,sum,val)转移
if (sum + aa[i] <= m && val + bb[i] <= maxv) vis[id][sum + aa[i]][val + bb[i]] = true; //选第i个数,向三元组(id,sum+aa[i],val+bb[i])转移
}
}
}
}
复杂度 O ( n ∗ m ∗ s u m ( v a l ) ) O(n*m*sum(val)) O(n∗m∗sum(val))。
步骤4:优化dp方程
优化1:贪心减少方程维数
考虑两个三元组
(
i
d
,
s
u
m
,
v
a
l
1
)
(id,sum,val1)
(id,sum,val1),
(
i
d
,
s
u
m
,
v
a
l
2
)
(id,sum,val2)
(id,sum,val2),如果有
v
a
l
1
>
v
a
l
2
val1>val2
val1>val2,那么在有所选择了
(
i
d
,
s
u
m
,
v
a
l
2
)
(id,sum,val2)
(id,sum,val2)三元组的地方,都可以用
(
i
d
,
s
u
m
,
v
a
l
1
)
(id,sum,val1)
(id,sum,val1)来代替使得方案更优,所以我们只需要对
(
i
d
,
s
u
m
)
(id,sum)
(id,sum)对应的最大
v
a
l
val
val进行转移即可。
改造
v
i
s
vis
vis数组,
d
p
1
[
i
d
]
[
s
u
m
]
dp1[id][sum]
dp1[id][sum]表示
(
i
d
,
s
u
m
)
(id,sum)
(id,sum)组的最大
v
a
l
val
val,因为只有
(
i
d
,
s
u
m
,
d
p
[
i
d
]
[
s
u
m
]
)
(id,sum,dp[id][sum])
(id,sum,dp[id][sum])这个三元组才可能构成最优的答案。
注意,笔者默认
d
p
1
dp1
dp1数组的所有数都是0。
for (int id = 1; id <= n; id++) {
for (int sum = 0; sum <= m; sum++) { 枚举三元组(id-1,sum,dp[id-1][sum])
dp1[id][sum] = max(dp1[id][sum], dp1[id - 1][sum]); //不选第i个数,向三元组(id,sum,dp[id-1][sum])转移
if (sum + aa[i] <= m) dp1[id][sum + aa[i]] = max(dp1[id][sum + aa[i]], dp1[id - 1][sum] + bb[i]); //选第i个数,向三元组(id,sum+aa[i],dp[id-1][sum]+bb[i])转移
}
}
复杂度
O
(
n
∗
m
)
O(n*m)
O(n∗m)。
值得注意的是,这已经和最优的01背包解法有相同的时间复杂度,只是空间上略逊。由于空间的廉价性,已经完全可以接受了。
优化2:空间优化
我们观察转移数组发现对于
i
d
id
id而言,有用的
d
p
1
dp1
dp1数组只有
d
p
1
[
i
d
]
dp1[id]
dp1[id]和
d
p
1
[
i
d
−
1
]
dp1[id-1]
dp1[id−1]。所以我们只要存储这两个
d
p
1
dp1
dp1数组即可。
注意,这里
d
p
2
dp2
dp2等同于
d
p
1
[
i
d
−
1
]
dp1[id-1]
dp1[id−1],
r
e
s
res
res等同于
d
p
1
[
i
d
]
dp1[id]
dp1[id]。
for (int id = 1; id <= n; i++) {
memset(res, 0, sizeof(res));
for (int sum = 0; sum <= m; sum++) {
res[sum] = max(res[sum], dp2[sum]);
if (sum + aa[i] <= m) res[sum + aa[i]] = max(res[sum + aa[i]], dp2[sum] + bb[i]);
}
for (int sum = 0; sum <= m; sum++) dp2[sum] = res[sum];
}
时间复杂度 O ( n ∗ m ) O(n*m) O(n∗m),空间复杂度 O ( m ) O(m) O(m)。
优化3:空间复用
进一步的发现 d p dp dp数组可以复用。
for (int id = 1; id <= n; i++) {
for (int sum = m - aa[i]; sum >= 0; sum--) {
dp3[sum + aa[i]] = max(dp3[sum + aa[i]], dp3[sum] + bb[i]);
}
}
时间复杂度 O ( n ∗ m ) O(n*m) O(n∗m),空间复杂度 O ( m ) O(m) O(m)。
01背包的主流写法
可以进一步的等价主流写法。
for (int id = 1; id <= n; i++) {
for (int sum = m; sum >= aa[i]; sum--) {
dp[sum] = max(dp[sum], dp[sum - aa[i]] + bb[i]);
}
}
时间复杂度 O ( n ∗ m ) O(n*m) O(n∗m),空间复杂度 O ( m ) O(m) O(m)。
练手题
给你长度为 n n n的数组 s a sa sa,要求从中选取若干个数,且任意两个数下标不能连续。问能够选择的数的和最大是多少?
总结
主流的动态规划讲解并不能够从根源指出转移方程从何而来,因此学习的人并不能很好的迁移到其他动态规划问题。本文直指动态规划的本源——记忆化搜索,从最朴素的遍历整个解空间,每一步有理有据,有章程的得到了合理的转移方程,适合所有的动态规划问题。难度大的动态规划问题,无非是更要求有技巧的去搜索整个解空间而已。
值得指出的是,我们通过记忆化得到的记忆化数组(即转移方程),通常是布尔类型的。我们常常能够通过贪心的方式将转移方程赋予更多的意义并转换成其他含义更丰富的数据类型。
尾声
笔者在长期的动态规划解题中,逐渐理解了动态规划的本质。最近在教别人学习动态规划的过程中,对动态规划问题做了十分详细的解释,进而整理成该文章,希望能够帮助读者更加清楚的看到动态规划的本质,并且能够对动态规划有所入门。
动态规划是一个十分迷人的算法,它尝试使用十分少量的状态去描述已有决策,从而消除已有决策对后续决策的影响。动态规划的优化问题也是十分有趣的。通过结合题目各种优美的性质,进行状态精简,或者结合数据结构优化的过程也能够带来快乐。希望读者能够更多的接触动态规划,能够体会动态规划的魅力。