动态规划详解进阶.md 36.1 KB
Newer Older
L
labuladong 已提交
1 2
# 动态规划详解

3 4
<p align='center'>
<a href="https://github.com/labuladong/fucking-algorithm" target="view_window"><img alt="GitHub" src="https://img.shields.io/github/stars/labuladong/fucking-algorithm?label=Stars&style=flat-square&logo=GitHub"></a>
L
labuladong 已提交
5
<a href="https://appktavsiei5995.pc.xiaoe-tech.com/index" target="_blank"><img class="my_header_icon" src="https://img.shields.io/static/v1?label=精品课程&message=查看&color=pink&style=flat"></a>
6 7 8 9
<a href="https://www.zhihu.com/people/labuladong"><img src="https://img.shields.io/badge/%E7%9F%A5%E4%B9%8E-@labuladong-000000.svg?style=flat-square&logo=Zhihu"></a>
<a href="https://space.bilibili.com/14089380"><img src="https://img.shields.io/badge/B站-@labuladong-000000.svg?style=flat-square&logo=Bilibili"></a>
</p>

L
labuladong 已提交
10 11
![](https://labuladong.github.io/algo/images/souyisou1.png)

L
labuladong 已提交
12
**通知:[数据结构精品课](https://aep.h5.xeknow.com/s/1XJHEO) 已更新到 V2.0;[第 13 期刷题打卡](https://mp.weixin.qq.com/s/eUG2OOzY3k_ZTz-CFvtv5Q) 最后一天报名!另外,建议你在我的 [网站](https://labuladong.github.io/algo/) 学习文章,体验更好。**
13 14 15



L
labuladong 已提交
16
读完本文,你不仅学会了算法套路,还可以顺便解决如下题目:
17

L
labuladong 已提交
18 19 20 21 22
| LeetCode | 力扣 | 难度 |
| :----: | :----: | :----: |
| [322. Coin Change](https://leetcode.com/problems/coin-change/) | [322. 零钱兑换](https://leetcode.cn/problems/coin-change/) | 🟠
| [509. Fibonacci Number](https://leetcode.com/problems/fibonacci-number/) | [509. 斐波那契数](https://leetcode.cn/problems/fibonacci-number/) | 🟢
| - | [剑指 Offer II 103. 最少的硬币数目](https://leetcode.cn/problems/gaM7Ch/) | 🟠
23 24 25

**-----------**

L
labuladong 已提交
26
> 本文有视频版:[动态规划框架套路详解](https://www.bilibili.com/video/BV1XV411Y7oE)
L
labuladong 已提交
27 28

这篇文章是我们公众号半年前一篇 200 多赞赏的成名之作 [动态规划详解](https://mp.weixin.qq.com/s/1V3aHVonWBEXlNUvK3S28w) 的进阶版。由于账号迁移的原因,旧文无法被搜索到,所以我润色了本文,并添加了更多干货内容,希望本文成为解决动态规划的一部「指导方针」。
L
labuladong 已提交
29

30
动态规划问题(Dynamic Programming)应该是很多读者头疼的,不过这类问题也是最具有技巧性,最有意思的。本书使用了整整一个章节专门来写这个算法,动态规划的重要性也可见一斑。
L
labuladong 已提交
31

L
labuladong 已提交
32
本文解决几个问题:
L
labuladong 已提交
33

L
labuladong 已提交
34
动态规划是什么?解决动态规划问题有什么技巧?如何学习动态规划?
L
labuladong 已提交
35

L
labuladong 已提交
36
刷题刷多了就会发现,算法技巧就那几个套路,我们后续的动态规划系列章节,都在使用本文的解题框架思维,如果你心里有数,就会轻松很多。所以本文放在第一章,来扒一扒动态规划的裤子,形成一套解决这类问题的思维框架,希望能够成为解决动态规划问题的一部指导方针。本文就来讲解该算法的基本套路框架,下面上干货。
L
labuladong 已提交
37

L
labuladong 已提交
38
首先,**动态规划问题的一般形式就是求最值**。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。
L
labuladong 已提交
39

L
labuladong 已提交
40
既然是要求最值,核心问题是什么呢?**求解动态规划的核心问题是穷举**。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。
L
labuladong 已提交
41

L
labuladong 已提交
42
动态规划这么简单,就是穷举就完事了?我看到的动态规划问题都很难啊!
L
labuladong 已提交
43

L
labuladong 已提交
44
首先,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出**正确的「状态转移方程」**,才能正确地穷举。而且,你需要判断算法问题是否**具备「最优子结构」**,是否能够通过子问题的最值得到原问题的最值。另外,动态规划问题**存在「重叠子问题」**,如果暴力穷举的话效率会很低,所以需要你使用「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。
L
labuladong 已提交
45

L
labuladong 已提交
46
以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移方程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我来提供我总结的一个思维框架,辅助你思考状态转移方程:
L
labuladong 已提交
47

L
labuladong 已提交
48
**明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 `dp` 数组/函数的含义**
L
labuladong 已提交
49

L
labuladong 已提交
50
按上面的套路走,最后的解法代码就会是如下的框架:
L
labuladong 已提交
51

52
```python
L
labuladong 已提交
53 54 55 56 57 58 59 60
# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
    for 选择 in 所有可能的选择:
        # 此时的状态已经因为做了选择而改变
        result = 求最值(result, dp(状态1, 状态2, ...))
    return result

# 自底向上迭代的动态规划
61
# 初始化 base case
L
labuladong 已提交
62
dp[0][0][...] = base case
63 64 65 66 67 68 69 70
# 进行状态转移
for 状态1 in 状态1的所有取值
    for 状态2 in 状态2的所有取值
        for ...
            dp[状态1][状态2][...] = 求最值(选择1选择2...)
```

下面通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明白什么是重叠子问题(斐波那契数列没有求最值,所以严格来说不是动态规划问题),后者主要举集中于如何列出状态转移方程。
L
labuladong 已提交
71 72 73

### 一、斐波那契数列

L
labuladong 已提交
74
力扣第 509 题「斐波那契数」就是这个问题,请读者不要嫌弃这个例子简单,**只有简单的例子才能让你把精力充分集中在算法背后的通用思想和技巧上,而不会被那些隐晦的细节问题搞的莫名其妙**。想要困难的例子,接下来的动态规划系列里有的是。
75

L
labuladong 已提交
76 77 78 79
**1、暴力递归**

斐波那契数列的数学形式就是递归的,写成代码就是这样:

L
labuladong 已提交
80
```java
L
labuladong 已提交
81 82 83 84 85 86
int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1) + fib(N - 2);
}
```

87
这个不用多说了,学校老师讲递归的时候似乎都是拿这个举例。我们也知道这样写代码虽然简洁易懂,但是十分低效,低效在哪里?假设 n = 20,请画出递归树:
L
labuladong 已提交
88

L
labuladong 已提交
89
![](https://labuladong.github.io/algo/images/动态规划详解进阶/1.jpg)
L
labuladong 已提交
90

L
labuladong 已提交
91
> PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
92

L
labuladong 已提交
93 94
这个递归树怎么理解?就是说想要计算原问题 `f(20)`,我就得先计算出子问题 `f(19)``f(18)`,然后要计算 `f(19)`,我就要先算出子问题 `f(18)``f(17)`,以此类推。最后遇到 `f(1)` 或者 `f(2)` 的时候,结果已知,就能直接返回结果,递归树不再向下生长了。

L
labuladong 已提交
95
**递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间**
L
labuladong 已提交
96

97
首先计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。
L
labuladong 已提交
98

99
然后计算解决一个子问题的时间,在本算法中,没有循环,只有 `f(n - 1) + f(n - 2)` 一个加法操作,时间为 O(1)。
L
labuladong 已提交
100

101
所以,这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别,爆炸。
L
labuladong 已提交
102 103 104 105 106 107 108 109 110 111 112

观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如 `f(18)` 被计算了两次,而且你可以看到,以 `f(18)` 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 `f(18)` 这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:**重叠子问题**。下面,我们想办法解决这个问题。

**2、带备忘录的递归解法**

明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

L
labuladong 已提交
113
```java
L
labuladong 已提交
114 115
int fib(int N) {
    // 备忘录全初始化为 0
L
labuladong 已提交
116
    int[] memo = new int[N + 1];
117
    // 进行带备忘录的递归
L
labuladong 已提交
118 119
    return helper(memo, N);
}
L
labuladong 已提交
120 121 122 123 124

int helper(int[] memo, int n) {
    // base case
    if (n == 0 || n == 1) return n;
    // 已经计算过,不用再计算了
L
labuladong 已提交
125
    if (memo[n] != 0) return memo[n];
126
    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
L
labuladong 已提交
127 128 129 130 131 132
    return memo[n];
}
```

现在,画出递归树,你就知道「备忘录」到底做了什么。

L
labuladong 已提交
133
![](https://labuladong.github.io/algo/images/动态规划详解进阶/2.jpg)
L
labuladong 已提交
134 135 136

实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

L
labuladong 已提交
137
![](https://labuladong.github.io/algo/images/动态规划详解进阶/3.jpg)
L
labuladong 已提交
138

L
labuladong 已提交
139
**递归算法的时间复杂度怎么计算?就是用子问题个数乘以解决一个子问题需要的时间**
L
labuladong 已提交
140 141 142 143 144

子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是 `f(1)`, `f(2)`, `f(3)` ... `f(20)`,数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。

解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。

L
labuladong 已提交
145
所以,本算法的时间复杂度是 O(n),比起暴力算法,是降维打击。
L
labuladong 已提交
146

L
labuladong 已提交
147
至此,带备忘录的递归解法的效率已经和迭代的动态规划解法一样了。实际上,这种解法和常见的动态规划解法已经差不多了,只不过这种解法是「自顶向下」进行「递归」求解,我们更常见的动态规划代码是「自底向上」进行「递推」求解。
L
labuladong 已提交
148

149
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 `f(20)`,向下逐渐分解规模,直到 `f(1)``f(2)` 这两个 base case,然后逐层返回答案,这就叫「自顶向下」。
L
labuladong 已提交
150

L
labuladong 已提交
151
啥叫「自底向上」?反过来,我们直接从最底下、最简单、问题规模最小、已知结果的 `f(1)``f(2)`(base case)开始往上推,直到推到我们想要的答案 `f(20)`。这就是「递推」的思路,这也是动态规划一般都脱离了递归,而是由循环迭代完成计算的原因。
L
labuladong 已提交
152

L
labuladong 已提交
153
**3、`dp` 数组的迭代(递推)解法**
L
labuladong 已提交
154

L
labuladong 已提交
155
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,通常叫做 DP table,在这张表上完成「自底向上」的推算岂不美哉!
L
labuladong 已提交
156

L
labuladong 已提交
157
```java
L
labuladong 已提交
158
int fib(int N) {
L
fixbug  
labuladong 已提交
159
    if (N == 0) return 0;
L
labuladong 已提交
160
    int[] dp = new int[N + 1];
L
labuladong 已提交
161
    // base case
L
labuladong 已提交
162 163 164
    dp[0] = 0; dp[1] = 1;
    // 状态转移
    for (int i = 2; i <= N; i++) {
L
labuladong 已提交
165
        dp[i] = dp[i - 1] + dp[i - 2];
L
labuladong 已提交
166 167
    }

L
labuladong 已提交
168 169 170 171
    return dp[N];
}
```

L
labuladong 已提交
172
![](https://labuladong.github.io/algo/images/动态规划详解进阶/4.jpg)
L
labuladong 已提交
173 174 175 176 177

画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。

这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:

L
labuladong 已提交
178 179 180
![](https://labuladong.github.io/algo/images/动态规划详解进阶/fib.png)

为啥叫「状态转移方程」?其实就是为了听起来高端。
L
labuladong 已提交
181

L
labuladong 已提交
182
`f(n)` 的函数参数会不断变化,所以你把参数 `n` 想做一个状态,这个状态 `n` 是由状态 `n - 1` 和状态 `n - 2` 转移(相加)而来,这就叫状态转移,仅此而已。
L
labuladong 已提交
183

L
labuladong 已提交
184
你会发现,上面的几种解法中的所有操作,例如 `return f(n - 1) + f(n - 2)``dp[i] = dp[i - 1] + dp[i - 2]`,以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。
L
labuladong 已提交
185

L
labuladong 已提交
186
可见列出「状态转移方程」的重要性,它是解决问题的核心,而且很容易发现,其实状态转移方程直接代表着暴力解法。
L
labuladong 已提交
187

L
labuladong 已提交
188
**千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力解,即状态转移方程**
L
labuladong 已提交
189

L
labuladong 已提交
190 191 192 193 194 195 196 197 198
只要写出暴力解,优化方法无非是用备忘录或者 DP table,再无奥妙可言。

这个例子的最后,讲一个细节优化。

细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。

所以,可以进一步优化,把空间复杂度降为 O(1)。这也就是我们最常见的计算斐波那契数的算法:

```java
L
labuladong 已提交
199
int fib(int n) {
L
labuladong 已提交
200 201 202 203 204 205 206 207 208 209 210 211
    if (n == 0 || n == 1) {
        // base case
        return n;
    }
    // 分别代表 dp[i - 1] 和 dp[i - 2]
    int dp_i_1 = 1, dp_i_2 = 0;
    for (int i = 2; i <= n; i++) {
        // dp[i] = dp[i - 1] + dp[i - 2];
        int dp_i = dp_i_1 + dp_i_2;
        // 滚动更新
        dp_i_2 = dp_i_1;
        dp_i_1 = dp_i;
L
labuladong 已提交
212
    }
L
labuladong 已提交
213
    return dp_i_1;
L
labuladong 已提交
214 215 216
}
```

L
labuladong 已提交
217 218 219
这一般是动态规划问题的最后一步优化,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试缩小 DP table 的大小,只记录必要的数据,从而降低空间复杂度。

上述例子就相当于把DP table 的大小从 `n` 缩小到 2。后续的动态规划章节中我们还会看到这样的例子,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)。
220 221

有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在说明重叠子问题的消除方法,演示得到最优解法逐步求精的过程。下面,看第二个例子,凑零钱问题。
L
labuladong 已提交
222 223 224

### 二、凑零钱问题

L
labuladong 已提交
225 226 227
这是力扣第 322 题「零钱兑换」:

给你 `k` 种面值的硬币,面值分别为 `c1, c2 ... ck`,每种硬币的数量无限,再给一个总金额 `amount`,问你**最少**需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:
L
labuladong 已提交
228 229 230 231 232 233 234 235

```java
// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);
```

比如说 `k = 3`,面值分别为 1,2,5,总金额 `amount = 11`。那么最少需要 3 枚硬币凑出,即 11 = 5 + 5 + 1。

L
fixbug  
labuladong 已提交
236
你认为计算机应该如何解决这个问题?显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。
L
labuladong 已提交
237 238 239 240 241

**1、暴力递归**

首先,这个问题是动态规划问题,因为它具有「最优子结构」的。**要符合「最优子结构」,子问题间必须互相独立**。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

242
比如说,假设你考试,每门科目的成绩都是互相独立的。你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
L
labuladong 已提交
243

L
labuladong 已提交
244 245 246
得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,「每门科目考到最高」这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,不能同时达到满分,数学分数高,语文分数就会降低,反之亦然。
L
labuladong 已提交
247

L
labuladong 已提交
248
这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为「每门科目考到最高」的子问题并不独立,语文数学成绩户互相影响,无法同时最优,所以最优子结构被破坏。
L
labuladong 已提交
249

L
labuladong 已提交
250
回到凑零钱问题,为什么说它符合最优子结构呢?假设你有面值为 `1, 2, 5` 的硬币,你想求 `amount = 11` 时的最少硬币数(原问题),如果你知道凑出 `amount = 10, 9, 6` 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 `1, 2, 5` 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的。
251

L
labuladong 已提交
252
> PS:关于最优子结构的问题,后文 [动态规划答疑篇](https://labuladong.github.io/article/fname.html?fname=最优子结构) 还会再举例探讨。
L
labuladong 已提交
253

L
labuladong 已提交
254
那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程?
L
labuladong 已提交
255

L
labuladong 已提交
256
**1、确定 base case**,这个很简单,显然目标金额 `amount` 为 0 时算法返回 0,因为不需要任何硬币就已经凑出目标金额了。
257

L
labuladong 已提交
258
**2、确定「状态」,也就是原问题和子问题中会变化的变量**。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 `amount`
259

L
labuladong 已提交
260
**3、确定「选择」,也就是导致「状态」产生变化的行为**。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。
L
labuladong 已提交
261

L
labuladong 已提交
262
**4、明确 `dp` 函数/数组的定义**。我们这里讲的是自顶向下的解法,所以会有一个递归的 `dp` 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。
L
labuladong 已提交
263

L
labuladong 已提交
264
**所以我们可以这样定义 `dp` 函数:`dp(n)` 表示,输入一个目标金额 `n`,返回凑出目标金额 `n` 所需的最少硬币数量**
265 266

搞清楚上面这几个关键点,解法的伪码就可以写出来了:
L
labuladong 已提交
267

L
labuladong 已提交
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
```java
// 伪码框架
int coinChange(int[] coins, int amount) {
    // 题目要求的最终结果是 dp(amount)
    return dp(coins, amount)
}

// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(int[] coins, int n) {
    // 做选择,选择需要硬币最少的那个结果
    for (int coin : coins) {
        res = min(res, 1 + dp(n - coin))
    }
    return res
}
L
labuladong 已提交
283 284
```

285
根据伪码,我们加上 base case 即可得到最终的答案。显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:
L
labuladong 已提交
286

L
labuladong 已提交
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
```java
int coinChange(int[] coins, int amount) {
    // 题目要求的最终结果是 dp(amount)
    return dp(coins, amount)
}

// 定义:要凑出金额 n,至少要 dp(coins, n) 个硬币
int dp(int[] coins, int amount) {
    // base case
    if (amount == 0) return 0;
    if (amount < 0) return -1;

    int res = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 计算子问题的结果
        int subProblem = dp(coins, amount - coin);
        // 子问题无解则跳过
        if (subProblem == -1) continue;
        // 在子问题中选择最优解,然后加一
        res = Math.min(res, subProblem + 1);
    }

    return res == Integer.MAX_VALUE ? -1 : res;
}
L
labuladong 已提交
311 312
```

L
labuladong 已提交
313 314 315 316
> PS:这里 `coinChange` 和 `dp` 函数的签名完全一样,所以理论上不需要额外写一个 `dp` 函数。但为了后文讲解方便,这里还是另写一个 `dp` 函数来实现主要逻辑。

> 另外,我经常看到有人问,子问题的结果为什么要加 1(`subProblem + 1`),而不是加硬币金额之类的。我这里统一提示一下,动态规划问题的关键是 `dp` 函数/数组的定义,你这个函数的返回值代表什么?你回过头去搞清楚这一点,然后就知道为什么要给子问题的返回值加 1 了。

L
labuladong 已提交
317 318
至此,状态转移方程其实已经完成了,以上算法已经是暴力解法了,以上代码的数学形式就是状态转移方程:

L
labuladong 已提交
319
![](https://labuladong.github.io/algo/images/动态规划详解进阶/coin.png)
L
labuladong 已提交
320 321 322

至此,这个问题其实就解决了,只不过需要消除一下重叠子问题,比如 `amount = 11, coins = {1,2,5}` 时画出递归树看看:

L
labuladong 已提交
323
![](https://labuladong.github.io/algo/images/动态规划详解进阶/5.jpg)
L
labuladong 已提交
324

L
labuladong 已提交
325
**递归算法的时间复杂度分析:子问题总数 x 解决每个子问题所需的时间**
L
labuladong 已提交
326

L
labuladong 已提交
327 328 329 330 331
子问题总数为递归树的节点个数,但算法会进行剪枝,剪枝的时机和题目给定的具体硬币面额有关,所以可以想象,这棵树生长的并不规则,确切算出树上有多少节点是比较困难的。对于这种情况,我们一般的做法是按照最坏的情况估算一个时间复杂度的上界。

假设目标金额为 `n`,给定的硬币个数为 `k`,那么递归树最坏情况下高度为 `n`(全用面额为 1 的硬币),然后再假设这是一棵满 `k` 叉树,则节点的总数在 `k^n` 这个数量级。

接下来看每个子问题的复杂度,由于每次递归包含一个 for 循环,复杂度为 `O(k)`,相乘得到总时间复杂度为 `O(k^n)`,指数级别。
L
labuladong 已提交
332 333 334

**2、带备忘录的递归**

335
类似之前斐波那契数列的例子,只需要稍加修改,就可以通过备忘录消除子问题:
L
labuladong 已提交
336

L
labuladong 已提交
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
```java
int[] memo;

int coinChange(int[] coins, int amount) {
    memo = new int[amount + 1];
    // 备忘录初始化为一个不会被取到的特殊值,代表还未被计算
    Arrays.fill(memo, -666);

    return dp(coins, amount);
}

int dp(int[] coins, int amount) {
    if (amount == 0) return 0;
    if (amount < 0) return -1;
    // 查备忘录,防止重复计算
    if (memo[amount] != -666)
        return memo[amount];

    int res = Integer.MAX_VALUE;
    for (int coin : coins) {
        // 计算子问题的结果
        int subProblem = dp(coins, amount - coin);
        // 子问题无解则跳过
        if (subProblem == -1) continue;
        // 在子问题中选择最优解,然后加一
        res = Math.min(res, subProblem + 1);
    }
    // 把计算结果存入备忘录
    memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
    return memo[amount];
}
L
labuladong 已提交
368 369
```

L
labuladong 已提交
370
不画图了,很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 `n`,即子问题数目为 `O(n)`。处理一个子问题的时间不变,仍是 `O(k)`,所以总的时间复杂度是 `O(kn)`
L
labuladong 已提交
371 372 373

**3、dp 数组的迭代解法**

374 375 376
当然,我们也可以自底向上使用 dp table 来消除重叠子问题,关于「状态」「选择」和 base case 与之前没有区别,`dp` 数组的定义和刚才 `dp` 函数类似,也是把「状态」,也就是目标金额作为变量。不过 `dp` 函数体现在函数参数,而 `dp` 数组体现在数组索引:

**`dp` 数组的定义:当目标金额为 `i` 时,至少需要 `dp[i]` 枚硬币凑出**
L
labuladong 已提交
377

378
根据我们文章开头给出的动态规划代码框架可以写出如下解法:
L
labuladong 已提交
379

L
labuladong 已提交
380 381 382
```java
int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
L
labuladong 已提交
383
    // 数组大小为 amount + 1,初始值也为 amount + 1
L
labuladong 已提交
384 385
    Arrays.fill(dp, amount + 1);

L
labuladong 已提交
386 387
    // base case
    dp[0] = 0;
388
    // 外层 for 循环在遍历所有状态的所有取值
L
labuladong 已提交
389
    for (int i = 0; i < dp.length; i++) {
390
        // 内层 for 循环在求所有选择的最小值
L
labuladong 已提交
391 392
        for (int coin : coins) {
            // 子问题无解,跳过
L
labuladong 已提交
393 394 395 396
            if (i - coin < 0) {
                continue;
            }
            dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
L
labuladong 已提交
397 398 399 400 401 402
        }
    }
    return (dp[amount] == amount + 1) ? -1 : dp[amount];
}
```

L
labuladong 已提交
403
![](https://labuladong.github.io/algo/images/动态规划详解进阶/6.jpg)
L
labuladong 已提交
404

L
labuladong 已提交
405
> PS:为啥 `dp` 数组中的值都初始化为 `amount + 1` 呢,因为凑成 `amount` 金额的硬币数最多只可能等于 `amount`(全用 1 元面值的硬币),所以初始化为 `amount + 1` 就相当于初始化为正无穷,便于后续取最小值。为啥不直接初始化为 int 型的最大值 `Integer.MAX_VALUE` 呢?因为后面有 `dp[i - coin] + 1`,这就会导致整型溢出。
L
labuladong 已提交
406 407 408 409 410 411 412 413 414 415 416

### 三、最后总结

第一个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。

第二个凑零钱的问题,展示了如何流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。

如果你不太了解动态规划,还能看到这里,真得给你鼓掌,相信你已经掌握了这个算法的设计技巧。

**计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举**,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

L
labuladong 已提交
417
列出状态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。
L
labuladong 已提交
418 419 420

备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活?

421 422
之后我们会有一章专门讲解动态规划问题,如果有任何问题都可以随时回来重读本文,希望读者在阅读每个题目和解法时,多往「状态」和「选择」上靠,才能对这套框架产生自己的理解,运用自如。

L
labuladong 已提交
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495
接下来可阅读:

* [动态规划设计:最长递增子序列](https://labuladong.github.io/article/fname.html?fname=动态规划设计:最长递增子序列)



<hr>
<details>
<summary><strong>引用本文的文章</strong></summary>

 - [Dijkstra 算法模板及应用](https://labuladong.github.io/article/fname.html?fname=dijkstra算法)
 - [base case 和备忘录的初始值怎么定?](https://labuladong.github.io/article/fname.html?fname=备忘录等基础)
 - [一个方法团灭 LeetCode 打家劫舍问题](https://labuladong.github.io/article/fname.html?fname=抢房子)
 - [一个方法团灭 LeetCode 股票买卖问题](https://labuladong.github.io/article/fname.html?fname=团灭股票问题)
 - [东哥带你刷二叉树(纲领篇)](https://labuladong.github.io/article/fname.html?fname=二叉树总结)
 - [两种思路解决单词拼接问题](https://labuladong.github.io/article/fname.html?fname=单词拼接)
 - [分治算法详解:运算优先级](https://labuladong.github.io/article/fname.html?fname=分治算法)
 - [动态规划帮我通关了《辐射4》](https://labuladong.github.io/article/fname.html?fname=转盘)
 - [动态规划帮我通关了《魔塔》](https://labuladong.github.io/article/fname.html?fname=魔塔)
 - [动态规划设计:最长递增子序列](https://labuladong.github.io/article/fname.html?fname=动态规划设计:最长递增子序列)
 - [动态规划问题的两种穷举视角](https://labuladong.github.io/article/fname.html?fname=动归两种视角)
 - [如何运用贪心思想玩跳跃游戏](https://labuladong.github.io/article/fname.html?fname=跳跃游戏)
 - [学习算法和刷题的框架思维](https://labuladong.github.io/article/fname.html?fname=学习数据结构和算法的高效方法)
 - [对动态规划进行降维打击](https://labuladong.github.io/article/fname.html?fname=状态压缩技巧)
 - [归并排序详解及应用](https://labuladong.github.io/article/fname.html?fname=归并排序)
 - [当老司机学会了贪心算法](https://labuladong.github.io/article/fname.html?fname=老司机)
 - [我的刷题心得](https://labuladong.github.io/article/fname.html?fname=算法心得)
 - [旅游省钱大法:加权最短路径](https://labuladong.github.io/article/fname.html?fname=旅行最短路径)
 - [最优子结构原理和 dp 数组遍历方向](https://labuladong.github.io/article/fname.html?fname=最优子结构)
 - [算法学习和心流体验](https://labuladong.github.io/article/fname.html?fname=心流)
 - [算法时空复杂度分析实用指南](https://labuladong.github.io/article/fname.html?fname=时间复杂度)
 - [算法笔试「骗分」套路](https://labuladong.github.io/article/fname.html?fname=刷题技巧)
 - [经典动态规划:0-1 背包问题](https://labuladong.github.io/article/fname.html?fname=背包问题)
 - [经典动态规划:博弈问题](https://labuladong.github.io/article/fname.html?fname=动态规划之博弈问题)
 - [经典动态规划:完全背包问题](https://labuladong.github.io/article/fname.html?fname=背包零钱)
 - [经典动态规划:戳气球](https://labuladong.github.io/article/fname.html?fname=扎气球)
 - [经典动态规划:最长公共子序列](https://labuladong.github.io/article/fname.html?fname=LCS)
 - [经典动态规划:编辑距离](https://labuladong.github.io/article/fname.html?fname=编辑距离)

</details><hr>




<hr>
<details>
<summary><strong>引用本文的题目</strong></summary>

<strong>安装 [我的 Chrome 刷题插件](https://mp.weixin.qq.com/s/X-fE9sR4BLi6T9pn7xP4pg) 点开下列题目可直接查看解题思路:</strong>

| LeetCode | 力扣 |
| :----: | :----: |
| [112. Path Sum](https://leetcode.com/problems/path-sum/?show=1) | [112. 路径总和](https://leetcode.cn/problems/path-sum/?show=1) |
| [115. Distinct Subsequences](https://leetcode.com/problems/distinct-subsequences/?show=1) | [115. 不同的子序列](https://leetcode.cn/problems/distinct-subsequences/?show=1) |
| [139. Word Break](https://leetcode.com/problems/word-break/?show=1) | [139. 单词拆分](https://leetcode.cn/problems/word-break/?show=1) |
| [1696. Jump Game VI](https://leetcode.com/problems/jump-game-vi/?show=1) | [1696. 跳跃游戏 VI](https://leetcode.cn/problems/jump-game-vi/?show=1) |
| [221. Maximal Square](https://leetcode.com/problems/maximal-square/?show=1) | [221. 最大正方形](https://leetcode.cn/problems/maximal-square/?show=1) |
| [240. Search a 2D Matrix II](https://leetcode.com/problems/search-a-2d-matrix-ii/?show=1) | [240. 搜索二维矩阵 II](https://leetcode.cn/problems/search-a-2d-matrix-ii/?show=1) |
| [256. Paint House](https://leetcode.com/problems/paint-house/?show=1)🔒 | [256. 粉刷房子](https://leetcode.cn/problems/paint-house/?show=1)🔒 |
| [62. Unique Paths](https://leetcode.com/problems/unique-paths/?show=1) | [62. 不同路径](https://leetcode.cn/problems/unique-paths/?show=1) |
| [63. Unique Paths II](https://leetcode.com/problems/unique-paths-ii/?show=1) | [63. 不同路径 II](https://leetcode.cn/problems/unique-paths-ii/?show=1) |
| [70. Climbing Stairs](https://leetcode.com/problems/climbing-stairs/?show=1) | [70. 爬楼梯](https://leetcode.cn/problems/climbing-stairs/?show=1) |
| [91. Decode Ways](https://leetcode.com/problems/decode-ways/?show=1) | [91. 解码方法](https://leetcode.cn/problems/decode-ways/?show=1) |
| - | [剑指 Offer 04. 二维数组中的查找](https://leetcode.cn/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/?show=1) |
| - | [剑指 Offer 10- II. 青蛙跳台阶问题](https://leetcode.cn/problems/qing-wa-tiao-tai-jie-wen-ti-lcof/?show=1) |
| - | [剑指 Offer II 091. 粉刷房子](https://leetcode.cn/problems/JEj789/?show=1) |
| - | [剑指 Offer II 097. 子序列的数目](https://leetcode.cn/problems/21dk04/?show=1) |
| - | [剑指 Offer II 098. 路径的数目](https://leetcode.cn/problems/2AoeFn/?show=1) |
| - | [剑指 Offer II 103. 最少的硬币数目](https://leetcode.cn/problems/gaM7Ch/?show=1) |

</details>


L
labuladong 已提交
496

497
**_____________**
L
labuladong 已提交
498

L
labuladong 已提交
499
**《labuladong 的算法小抄》已经出版,关注公众号查看详情;后台回复关键词「**进群**」可加入算法群;回复「**全家桶**」可下载配套 PDF 和刷题全家桶**
L
labuladong 已提交
500 501

![](https://labuladong.github.io/algo/images/souyisou2.png)
L
labuladong 已提交
502 503


R
ruiboliu 已提交
504 505
======其他语言代码======

B
brucecat 已提交
506 507
### python

R
ruiboliu 已提交
508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546
[DapangLiu](https://github.com/DapangLiu) 提供 509. 斐波那契数 Python3 解法代码:

递归写法

```python
class Solution:
    def fib(self, N: int) -> int:
        if N <= 1:
            return N
        return self.fib(N-1) + self.fib(N-2)
```

动态规划写法

```python
class Solution:
    def fib(self, N: int) -> int:
        if N == 0:
            return 0
        # init
        result = [0 for i in range(N+1)]
        result[1] = 1

        # status transition
        for j in range(2, N+1):
            result[j] = result[j-1] + result[j-2]
        return result[-1]
```

动态规划写法 (状态压缩)

```python
class Solution:
    def fib(self, n: int) -> int:
        # current status only depends on two previous status
        dp_0, dp_1 = 0, 1
        for _ in range(n):
            dp_0, dp_1 = dp_1, dp_0 + dp_1
        return dp_0
B
brucecat 已提交
547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719
```



### javascript

#### 一、斐波那契数

**1、暴力递归**

```js
let fib = function (n) {
    if (n === 1 || n === 2) {
        return 1;
    }

    return fib(n - 1) + fib(n - 2);
};
```

**2、带备忘录的递归**

```js
let fib = function (n) {
    if (n < 1) return 0;

    // 备忘录全初始化为 0
    let memo = new Array(n + 1);
    memo.fill(0, 0, n + 1);
    // 进行带备忘录的递归
    return helper(memo, n);
}

let helper = function (memo, n) {
    // base case
    if (n === 1 || n === 2) return 1;
    // 已经计算过
    if (memo[n] !== 0) return memo[n];

    memo[n] = helper(memo, n - 1) + helper(memo, n - 2);
    return memo[n];
}
```

**3、dp 数组的迭代解法**

```js
let fib = function (n) {
    if (n === 0) return 0;
    if (n === 1) return 1;
    let dp = new Array(n + 1);
    dp.fill(0, 0, n + 1);
    // base case
    dp[1] = dp[2] = 1;
    for (let i = 3; i <= n; i++)
        dp[i] = dp[i - 1] + dp[i - 2];
    return dp[n];
}
```

##### 4、dp数组 状态压缩

```js
let fib = function (n) {
    if (n === 2 || n === 1)
        return 1;
    let prev = 1, curr = 1;
    for (let i = 3; i <= n; i++) {
        let sum = prev + curr;
        prev = curr;
        curr = sum;
    }
    return curr;
}
```





#### 二、凑零钱

1、**递归写法**

```js
var coinChange = function (coins, amount) {
    let dp = function (n) {
        // base case
        if (n === 0) {
            return 0
        }
        if (n < 0) {
            return -1
        }

        // 求最小值,所以初始化为正无穷 或者是amount+1
        let res = amount + 1
        for (let coin of coins) {
            let subproblem = dp(n - coin)
            // 子问题无解,跳过
            if (subproblem === -1)
                continue;
            res = Math.min(res, 1 + subproblem)
        }
        return res !== amount + 1 ? res : -1;
    }
    return dp(amount)
};
```

**2、带备忘录的递归写法**

```js
var coinChange = function (coins, amount) {
    let memo = {};

    this.dp = function (amount) {
        if (memo[amount] !== undefined) {
            return memo[amount];
        }
        if (amount === 0) {
            return 0;
        }
        if (amount < 0) {
            return -1;
        }

        let result = Infinity;
        for (let coin of coins) {
            // 计算子问题
            const subResult = dp(amount - coin);
            // 子问题无解
            if (subResult === -1) {
                continue;
            }
            // 个数为 1 + 子问题的解
            result = Math.min(result, 1 + subResult);
        }

        // 备忘录
        memo[amount] = result == Infinity ? -1 : result;
        return memo[amount];
    };

    return dp(amount);
}
```

**3、dp 数组的迭代解法**

```js
var coinChange = function (coins, amount) {
    // 数组大小为 amount + 1,初始值也为 amount + 1
    let dp = new Array(amount + 1);
    dp.fill(amount + 1, 0, amount + 1);

    // base case
    dp[0] = 0;
    // 外层 for 循环在遍历所有状态的所有取值
    for (let i = 0; i < dp.length; i++) {
        // 内层 for 循环在求所有选择的最小值
        for (let coin of coins) {
            // 子问题无解,跳过
            if (i - coin < 0) continue;
            dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
        }
    }
    return (dp[amount] === amount + 1) ? -1 : dp[amount];
}
```