首先动态规划问题的一般形式就是求最值,动态规划其实是运筹学中的一种最优化方法,只不过在计算机中的应用比较多,比如求最长递增子序列,最小编辑距离等
既然是求解最值,核心问题就是穷举
,因为要求最值,肯定要把所有可行的解全部穷举出来,然后在其中寻找最值,遇到最值问题,就要思考如何穷举所有可能结果。
动态规划的穷举与一般的穷举有所不同,因为
- 这类问题存在
重叠子问题
,如果暴力穷举,效率极其低下,所以需要使用备忘录或者dp table来优化穷举过程,避免不必要的计算 - 动态规划类问题一定会具有
最优子结构
,这样才能通过子问题的最值得到原问题的最值 - 对于动态规划类问题,只有列出
状态转移方程
,才能正确的进行穷举
想要写出正确的状态转移方程,需要思考:
- 这个问题的base case即最简单的情况是什么?
- 这个问题有什么状态?
- 对于每个状态,可以做出什么选择使得状态发生改变?
- 如果定义dp数组/函数的含义来表现状态和选择?
代码可以套用如下框架:
// 初始化base case
dp[0][0][...] = base case
// 进行状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2,...)
下面使用斐波那契数列问题和凑零钱问题解释动态规划的基本问题
斐波那契数列
1. 暴力递归
func fib(n int){
if n == 0{
return 0
}
if n == 1 || n == 2 {
return 1
}
return fib(n-1)+fib(n-2)
}
该方式的递归树如下
递归算法的事件复杂度就是用子问题个数乘以解决一个子问题需要的时间
首先计算子问题个数,及递归树中结点的总数,显然二叉树结点总数为指数级别的,所以求子问题个数的时间复杂度为 O ( 2 n ) O(2^n) O(2n)。
然后计算解决一个子问题需要的时间,在本算法中,没有循环,只有fib(n-1) + fib(n-2) 加法操作,故时间复杂度为 O ( 1 ) O(1) O(1)。
所以总的时间复杂度为 O ( 2 n ) O(2^n) O(2n),指数级别
通过观察递归树,该算法如此低效的原因是因为这个算法存在大量的重复计算,比如计算f(10)的时候需要计算f(8),计算f(9)的时候也需要重复计算f(8),使用f(n+8)时均需要重复进行计算,所以这个算法极其低效
这就是动态规划的第一个性质:重叠子问题
2. 带备忘录的递归算法
在上述暴力递归的算法中,由于重复计算导致了算法的低效性,所以我们可以构造一个备忘录
来记录已经求解出来的子问题答案,每一次遇到子问题就到备忘录中寻找,如果发现之前已经解决过,那么直接把答案拿出来,不需要再费时去计算了。
一般使用一个数组充当备忘录,当然也可以使用哈希表。
func fib(num int)int{
// 初始化一个dp table
dp := make([]int, num+1)
return sub(dp, num)
}
func sub(dp []int, num int)int{
if num == 0{
return 0
}
if num == 1 || num == 2{
return 1
}
// 如果结果已经在备忘录中
// 从备忘录中拿出来数据就可以了
if dp[num] != 0{
return dp[num]
}
// 否则求解这个问题
// 并且将这个问题的答案保存在备忘录中
dp[num] = sub(dp, num-1)+sub(dp, num-2)
return dp[num]
}
这个算法时间复杂度为 O ( N ) O(N) O(N),效率大大提高,这种解法与迭代的动态规划差不多,但是这种解法是自顶而下,动态规划是自底而上的。
dp 数组的迭代求法
func fib(num int)int{
if num == 0{
return 0
}
if num == 1|| num == 2{
return 1
}
dp := make([]int, num+1)
// base case
dp[1], dp[2]=1, 1
// 迭代求解,变化方程按照状态转移方程
for i:=3; i<=num; i++{
dp[i] = dp[i-1] + dp[i-2]
}
return dp[num]
}
状态转移方程如下
f
(
n
)
=
{
1
n
=
1
,
2
f
(
n
−
1
)
+
f
(
n
−
2
)
n
>
2
f(n)= \begin{cases} 1 & n=1,2 \\ f(n-1) + f(n-2) & n>2 \end{cases}
f(n)={1f(n−1)+f(n−2)n=1,2n>2
对于备忘录解法和动态递归解法都是围绕这个方程进行求解的,只不过备忘录解法的时候是正向使用,而动态递归使用该方程的时候是逆向,由子问题推出源问题
千万不要看不起暴力解法,动态规划最难的就是写出这个暴力解法,即状态转移方程,只要写出暴力解法,优化方法无非是使用备忘录或者dp table。
在上面的例子中,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态相关,其实不需要一个那么长的dp数组保存全部的状态,只需要记录之前的两个状态便可,这样可以进一步优化空间复杂度
func fib(num int) int {
if num == 0 {
return 0
}
if num == 1 || num == 2 {
return 1
}
pre, curr := 1, 1
sum := 0
for i := 3; i <= num; i++ {
sum = pre + curr
curr, pre = sum, curr
}
return sum
}
这一个技巧就是所谓的状态压缩
,如果我们发现每次状态转移只需要dp table中的一部分,那么可以尝试用状态压缩来缩小dp table的大小,只记录必要的数据
凑零钱问题
1.暴力递归
首先这个问题是动态规划问题,因为这个问题具有最优子结构,要满足最优子结构,那么各个子问题之间就必须相互独立
。
按照下面的步骤进行求解:
-
确定base base。最简单的情况自然就是amount=0的时候返回0
-
确定状态,也就是原问题和子问题中的变量。由于硬币的数量无限,硬币的面额也是给定的,只有目标金额会不断的向base case靠近,所以唯一的状态就是目标金额amount
-
确定选择,也就是导致状态发生改变的行为。很明显我们选择不同的面值就相当于做出了不同的选择
-
明确dp函数/数组含义。暴力递归方法中是自顶而下的解法,所以会有一个递归的dp函数,一边拿来说函数的参数就是状态转移中的变量,也就是上面说的状态,函数的返回值就是题目要求我们计算的量,就本体而言,状态只有一个,即目标金额,题目要求我们计算凑出目标金额所需的最少硬币数量,所以我们可以如下定义
dp(n):输入一个目标金额n,返回凑出目标金额的n的最小硬币数量
有了上面的这些步骤,我们可以写出对应的伪代码:
func coinChange(coins []int, amount int) int{
func dp(n int) int{
// 不同选择的条件下最小的硬币数量
for _, coin := range coins{
res = min(res, 1 + dp(n-coin))
}
return res
}
return dp(amout)
}
根据上面的伪代码,我们加上base case,即可获取到最终的答案,目标金额为0的时候,需要硬币0,目标金额小于0的时候,无解返回-1
func coinChange(coins []int, amount int) int {
var dp func(n int) int
dp = func(n int)int{
if n == 0{
return 0
}
if n < 0 {
return -1
}
// 定义为最大值
var res = 2<<32
for _, coin := range coins{
sub := dp(n-coin)
if sub == -1{
continue
}
res = min(res, 1+sub)
}
if res == 2<<32{
return -1
}else{
return res
}
}
return dp(amount)
}
func min(a, b int)int{
if a > b {
return b
}else{
return a
}
}
上面的形式就是状态转移方程:
d
p
(
n
)
=
{
0
n
=
0
I
N
F
n
<
0
m
i
n
{
d
p
(
n
−
c
o
i
n
)
+
1
∣
c
o
i
n
∈
c
o
i
n
s
}
n
>
0
dp(n)= \begin{cases} 0 & n=0 \\ INF & n < 0 \\ min\{dp(n-coin)+1| coin\in coins\} & n > 0 \end{cases}
dp(n)=⎩⎪⎨⎪⎧0INFmin{dp(n−coin)+1∣coin∈coins}n=0n<0n>0
2.带备忘录的递归
func coinChange(coins []int, amount int) int {
var dp func(n int) int
memo := make(map[int]int)
dp = func(n int) int {
if n == 0 {
return 0
}
if n < 0 {
return -1
}
// 如果在备忘录中,取出来即可不必再次计算
if an, ok := memo[n]; ok {
return an
}
// coin的最大值
var res = MAX
// 做出选择
for _, coin := range coins {
sub := dp(n - coin)
if sub == -1 {
continue
}
res = min(res, sub+1)
}
if res == MAX {
memo[n] = -1
return -1
} else {
memo[n] = res
return res
}
}
return dp(amount)
}
3. dp数组的迭代解法
dp数组定义:当目标金额为i时,至少需要dp[i]枚硬币凑出
func coinChange(coins []int, amount int) int {
dp := make([]int, amount+1)
dp[0] = 0
for i := 1; i < len(dp); i++ {
dp[i] = amount + 1
// 不同选择下的状态转移
for _, coin := range coins {
if i-coin < 0 {
continue
}
dp[i] = min(dp[i], 1+dp[i-coin])
}
}
if dp[amount] == amount+1 {
return -1
} else {
return dp[amount]
}
}
最后的总结
计算机解决问题没有其他的任何特殊技巧,唯一方法就是穷举,列出所有的可能结果。算法的设计就是先思考如何穷举,然后再追求聪明的穷举
列出状态转移方程,就是再解决如何穷举的问题,之所以它难,一是因为很多穷举都需要使用递归实现,而是有的问题本身的解空间复杂,不容易穷举
备忘录和dp table就是在追求聪明的穷举,用空间复杂度降低时间复杂度