什么是动态规划
动态规划(dynamic programming)是一种通过求解组合子问题的方法来递归的求解原问题的方法,与分治法极为相似,但是其特点在于,在递归的时候会大量的遇到相同的子问题。我们会记住该子问题的结果,避免重复求解。
动态规划大量的用于解决最优化问题中,当然某些不是最优化的问题也可以用分治法解决,比如爬梯子问题、背包问题。凡是能将问题刻画为若干子问题组合的题目,都可以用动态规划来求解。
如何来设计动态规划算法
我们以一个简单的例子来说明:打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例 1:
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入: [2,7,9,3,1]
输出: 12
解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
1 找子问题
对于最优化问题,找子问题有一个关键的要素,就是一定要有选择,通常如何选择用max或者min。而选择的项目就是一群与主问题结构相似且规模更小的子问题。且子问题通常要被描述成一个或多个输入,单个数值输出的形式:
y
=
d
p
(
x
1
,
x
2
,
.
.
.
)
,
其
中
y
∈
R
y=dp(x_1,x_2,...),其中y\in\mathbb{R}
y=dp(x1,x2,...),其中y∈R
所以对于本问题,我们可以根据原问题直接写出子问题的输入输出结构
输入:整数数组s
输出:能剽窃到的最高金额y
然后就是处理dp函数的实现,dp函数内部一定要实现如何分割子问题,就是将s分割成一个或者多个比s小的序列,对于最优问题如何去找这个分割点,就在于如何去做选择。小偷站在 s [ 0 ] s[0] s[0]家门口,需要做出选择偷还是不偷,如果选择偷的话,那他就不能选择偷下一家,如果不偷那他就可以偷下一家,小偷需要权衡这两种情况获得的利益y,才能做出选择,所以可以分成两个dp问题。
- 偷s[0]家的,拿到了钱,那么不能偷s[1]家的,所以在s[2]家即以后组成新的数组,又考虑同样的选择问题。
- 不偷s[0]家的,直接来到下一家,所以在s[1]家即以后组成新的数组,又考虑同样的选择问题。
2 递归的定义最优解
找到子问题就好办了,我们用公式写出dp函数,这里注意dp函数就代表小偷的盈利。
s
i
s_i
si代表数组s中第i个元素之后的子数组。
s
0
s_0
s0代表整个数组。s[k]代表第k个元素的数值。s最后一个元素为s[m]
d
p
(
s
i
)
=
m
a
x
(
d
p
(
s
i
+
1
)
⏞
不
偷
,
s
[
i
]
+
d
p
(
s
i
+
2
)
⏞
偷
)
dp(s_i)=max(\overbrace{dp(s_{i+1})}^{不偷},\overbrace{s[i]+dp(s_{i+2})}^偷)
dp(si)=max(dp(si+1)
不偷,s[i]+dp(si+2)
偷)
养成好习惯,递归公式注意初始值。
d
p
(
s
m
)
=
s
[
m
]
d
p
(
s
m
+
1
)
=
0
dp(s_m)=s[m]\\ dp(s_{m+1})=0
dp(sm)=s[m]dp(sm+1)=0
3 自底向上计算
有了递归公式,我们虽然可以直接用嵌套函数来写,不过堆栈扛不住。像斐波那契数列那样,我们从初始值开始往上算,就不用浪费堆栈来调函数了。上面我们看出,显然这个初始值不是很好看,所以我们换个表述方法,小偷从最后那个家开始往前偷,效果是一样:
d
p
(
s
i
)
=
m
a
x
(
d
p
(
s
i
−
1
)
⏞
不
偷
,
s
[
i
]
+
d
p
(
s
i
−
2
)
⏞
偷
)
d
p
(
s
0
)
=
s
[
0
]
d
p
(
∅
)
=
0
或
者
说
d
p
(
s
−
1
)
=
0
dp(s_i)=max(\overbrace{dp(s_{i-1})}^{不偷},\overbrace{s[i]+dp(s_{i-2})}^偷)\\ dp(s_0)=s[0]\\ dp(\varnothing)=0 或者说dp(s_{-1})=0
dp(si)=max(dp(si−1)
不偷,s[i]+dp(si−2)
偷)dp(s0)=s[0]dp(∅)=0或者说dp(s−1)=0
dp输入本来是个字符串,但是给我的有用信息只有一个i,所以直接构成一个一维数组。(如果给我的信息有n个维度,则需要申请n维数组)
d
p
(
i
)
=
m
a
x
(
d
p
(
i
−
1
)
⏞
不
偷
,
s
[
i
]
+
d
p
(
i
−
2
)
⏞
偷
)
d
p
(
0
)
=
s
[
0
]
d
p
(
1
)
=
m
a
x
(
s
[
0
]
,
s
[
1
]
)
dp(i)=max(\overbrace{dp(i-1)}^{不偷},\overbrace{s[i]+dp(i-2)}^偷)\\ dp(0)=s[0]\\ dp(1)=max(s[0],s[1])
dp(i)=max(dp(i−1)
不偷,s[i]+dp(i−2)
偷)dp(0)=s[0]dp(1)=max(s[0],s[1])
依次计算
d
p
(
2
)
,
d
p
(
3
)
,
.
.
.
,
d
p
(
m
)
dp(2),dp(3),...,dp(m)
dp(2),dp(3),...,dp(m)
这里值得注意的是由于dp[-1]不存在,所以我用dp[1]来充当一个初始值了,在某些时候,尤其是二维数组的时候,为了优化计算,可以考虑dp[-1]存在的情况,就是数组整体右移,将dp[-1]的情况用dp[0]代替。以空间换时间。
4 存储最优解
如果题目有要求需要存储最优解。在dp递归的时候,可以顺便记录下所有的选择,最后可以一次串起来。
总结
这里只举例了一维的情况,当dp输入包含两个维度的信息的时候,则需要二维dp数组来计算。可以通过画矩阵图,搞清楚dp[i,j]的依赖关系,然后再设计计算顺序。如果是多维空间的话,数组量将特别大,这个时候会可以根据这种依赖关系来降低空间复杂度。有时候这个dp点只会和邻近的dp点有关系,不会和历史dp点有关系,这时候历史dp点就可以舍弃不要。
dp难点在于找子问题,这也是需要训练的地方,可以针对两种题来做。
- 对于最优化问题,就是在于选择不同的情况,在题目中找出需要做出选择情况的时候,这里”情况“一般就是子问题。
- 对于非优化问题,一般就是那种排列组合题,其实和上面思想一样,只不过 m a x ( d p 1 , d p 2 , . . . ) max(dp_1,dp_2,...) max(dp1,dp2,...)变成了 d p 1 + d p 2 + . . . dp_1+dp_2+... dp1+dp2+...或者 d p 1 × d p 2 × . . . dp_1\times dp_2 \times... dp1×dp2×...。也是要分“情况”,只不过这里不需要做选择了。
写在最后
本人也处于学习阶段,奈何dp问题怎么找子问题确实难,所以必须给自己做个总结,不然稍微变通一下就不会了。大神直接忽略。
最近开通了个人博客,老被人说我语言描述能力太差,自己以后还是多写写东西吧。
个人博客地址