This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
<audio id="audio" title="11动态规划新问题1攻破最长递增子序列问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/93/42828d65d2ab01733bff50e183d90393.mp3"></audio>
你好,我是卢誉声。
还记得我们在上个模块中讲解的子数组和子序列问题吗?相较于较为复杂的子序列问题,它的答案不一定连续;我们还讲解了子数组问题,这类问题的答案是连续的。因此,这两者之间最大的区别,其实就在于答案是否连续。
随着时间的推移,面试官们也往往不再满足于考察传统的动态规划问题了,即便涉及了子序列和子数组问题。所以,在这一课中,我将带着你一起掌握最长递增序列的问题。
在本课的最后,我还会给出完整的攻破子序列的解题模板。还是那句话,由于是经验总结,因此在 90% 以上的情况下这个模板(套路)都是工作的,它足以应对你可能遇到的所有面试问题。
既然准备要解决的问题是最长递增序列,这就会涉及到子数组和子序列两种情况。你也无需担心,今天我会为你讲解这两种情况。那么按照惯例,在开始前,我先提出一个简单的问题:**在处理递增序列时,连续和不连续的答案会对状态转移方程产生什么影响?**
接下来就让我们带着这个问题,开始今天的学习之旅吧。
## 最长连续递增序列
我们先从一个较为简单的递增序列问题说起,从题目本身就可以看出,这是一个基于子数组的递增序列问题。我们看到这样的题目时,首先就要有一个意识,那就是所求答案肯定是连续的。既然如此,我们先看看问题的描述。
问题:给定一个未经排序的整数数组 $nums$,找到最长且连续的的递增序列,并返回该序列的长度。
```
示例1
输入: nums = [6, 6, 6, 6, 6]
输出: 1
解释: 最长连续递增序列是 [6], 长度为 1。
```
```
示例2
输入: nums = [1, 3, 5, 0, 7]
输出: 3
解释: 最长连续递增序列是 [1, 3, 5], 长度为 3。你会发现 [1, 3, 5, 7] 也是升序的子序列, 但它不是连续的。因为 5 和 7 在原数组中被 0 隔开。因此,这不是原问题的答案。
```
### 算法问题分析
事实上,这个问题没有复杂到必须使用动态规划来求解。但是,从原问题可以看出这其中一定存在重复计算的问题,它类似于穷举的操作了。
没错,你可以尝试用穷举来解决问题。但在你下手之前,先让我们回顾一下曾在[第5课](https://time.geekbang.org/column/article/289310)讲到的内容,即“进一步确认是否为动态规划问题”:
1. 数据不可排序Unsortable
1. 数据不可交换Non-swapable
再读一下原问题的提法,显然我们不可能对输入的数组进行排序:如果进行了排序,求最长上升序列这句话就无从谈起了。这违背了题目的本意,也就失去了求解的意义。因此,对于该问题来说,数据不可交换。
与此同时,如果我们使用之前分析算法问题是否满足动态规划特征的方法,来对该问题进行判断。它一定是满足重叠子问题、无后效性和最优子结构的。
在继续下面的内容前,你不妨参照之前的方法做一下判断。既然该问题可以通过动态规划来大幅优化算法的时间复杂度,那就让我们来看看如何写出状态转移方程吧。
### 写出状态转移方程
我们根据最平凡的动态规划求解模板,来看看如何解决这个问题。
首先,我们先来确定**初始化状态**。考虑一下,如果只考虑某个特定位置的数字,从开头到它为止的最长上升序列一定 ≥ 1。因此我们可以将即将设计的备忘录的每一个位置都初始化成 1。这就是针对这个简单问题的初始化状态。
接着,再来确定**状态参数**。在这个问题的计算过程中,不断变化的变量是什么呢?显然,就是移动数组的索引。因此,我们只需要一个变量,就足以描述整个状态转移过程了。如果我们设状态存储(即备忘录)为 $DP[i]$,那么它所对应的值表达的含义是什么呢?
这与我们在[第8课](https://time.geekbang.org/column/article/292667)中讲解“最大子数组之和”问题有些类似。如果你遗忘了那部分内容,我建议你再阅读一次以加深理解。
动态规划是数学归纳法的一种很好的体现,即如何从已知的答案推导出未知的部分。基于这个理论,我们该如何定义 $DP[i]$ 的含义呢?有几种可以考虑的选项:
1. $DP[i]$ 表示从位置 $i$ 开始到结束位置的最长连续递增序列的长度;
1. $DP[i]$ 表示从位置 0 到位置 $i$ 的最长连续递增序列的长度。
你觉得以上两个论述,哪个是可行的?
如果从原问题出发,我们的最终答案应该包含在从 0 ... n-1 的序列上。因此,第一种表述不合适,它没有体现出数学归纳法的思想,我们很难从中提取出这样的概念,$DP[i]$ 无法从 $DP[i-1]$ 决策中求出。
综上所述,第二种表述是合理的,即 **$DP[i]$ 表示从位置 0 到位置 $i$ 中以 $i$ 为结尾的最长连续递增序列的长度**。基于这个定义,我们显然可以通过 $DP[i-1]$ 推导出 $DP[i]$,因为这两个状态是连续的,可以通过状态转移实现子问题的求解。
最后,我们来看一看**决策**是什么。考虑一下,在什么情况下,当前子问题的解需要根据子问题的子问题计算得出呢?原问题问的是最长连续递增序列。因此,当 $nums[i] &gt; nums[i-1]$ 时,我们需要更新当前子问题的答案,这就是该问题的决策。
基于以上分析,我们就可以写出状态转移方程了。
$$DP[i]=\left\{\begin{array}{c}<br>
1 +DP[i-1] \ , DP[i] &gt; DP[i-1] \\\<br>
1 \ , otherwise<br>
\end{array}\right.$$
### 编写代码进行求解
由于这个问题比较简单,我先给出求解代码,然后再做一些解释。
Java 实现:
```
public int findLengthOfLCIS(int[] nums) {
int n = nums.length; if (n == 0) { return 0; }
int[] dp = new int[n];
Arrays.fill(dp, 1); // 初始化状态
int res = 1; // 记录答案的变量
for (int i = 1; i &lt; n; i++) {
if (nums[i] &gt; nums[i-1]) { // 决策
dp[i] = dp[i-1] + 1;
res = Math.max(res, dp[i]);
}
}
return res; // 输出答案
}
```
C++ 实现:
```
int FindLengthOfLCIS(std::vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (n == 0) { return 0; }
int dp[n]; for (int i = 0; i &lt; n; i++) { dp[i] = 1; } // 初始化状态
int res = 1; // 记录答案的变量
for (int i = 1; i &lt; n; i++) {
if (nums[i] &gt; nums[i-1]) { // 决策
dp[i] = dp[i-1] + 1;
res = max(res, dp[i]);
}
}
return res; // 输出答案
}
```
在代码中有一个值得一提的点,就是创建了一个名为 $res$ 的变量用于记录最终需要输出的答案。我们通过 $max$ 函数,比较了当前求解的子问题与上一次记录下来的最长连续递增序列的长度,并取更大的值作为当前的最优解。
最后,输出 res 作为原问题的答案。
## 最长上升子序列的长度
在开始解决子序列问题前,让我们回顾一下动态规划中子序列问题的模型。
所谓动态规划领域中的子序列问题,其实就是指从给定字符序列中随意地(不一定连续)去掉若干个字符(可能一个也不去掉)后,形成的满足题设的字符序列。
因此,该问题会比上面的连续序列复杂那么一点点,不过嘛,我们都学到这里了,其实这个问题并不算难题。在讲解该问题前,你不妨关注一下**该问题的状态转移方程与上面的问题区别在哪里?**我们先来看问题描述。
问题:给定一个无序的整数数组 $nums$找到其中最长上升子序列的长度Longest Increasing SubsequenceLIS。附加条件是
1. 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可;
1. 你算法的时间复杂度应该为 O(n<sup>2</sup>) 。
```
示例:
输入: nums = [10, 9, 1, 5, 2, 6, 66, 18]
输出: 4
解释: 其中一个最长的上升子序列是 [1, 2, 6, 66],它的长度是 4。
```
### 分析并写出状态转移方程
该问题同样满足动态规划的三大特征,即存在重叠子问题、无后效性以及最优子结构。你可以尝试用上一模块中,我频繁使用的方法来对其做一个基本的判断和分析。
事实上,我们可以几乎照搬“最长连续递增序列”问题的状态存储(备忘录)的定义,即 **$DP[i]$ 表示从位置 0 到位置 $i$ 的最长连续递增序列的长度**。
基于以上判断,针对该问题的**初始化状态**也是相似的。如果我们只考虑某个特定位置的数字,从开头到它为止的最长上升序列一定 ≥ 1。因此我们可以将即将设计的备忘录的每一个位置都初始化成 1。
接着,再来确定**状态参数**。我们只需要一个当前遍历的索引位置作为变量,就足以描述整个状态转移过程了。
最后,我们来看看**决策**。毕竟子序列问题和子数组问题是不一样的:它们求解的答案,一个不一定连续;而另一个必定连续。所以,我们要好好分析一下最长上升子序列的决策过程(状态转移过程)。在我画图解释前,你考虑一下:由于子序列问题的子问题答案不一定是连续的,为此,我们不就需要一个额外的循环,来遍历出子序列中能够供当前子问题推导的那个解么?
还是不太理解?没关系,我画出图后你就明白了。
<img src="https://static001.geekbang.org/resource/image/cc/44/cc75418f83ac515bbd0ebcf1b429f344.png" alt="">
按照图示,如果我们用解决“最长连续递增序列”的思路设计一个 $res$ 变量来求解状态转移过程,那就是 $res = max(DP[7], DP[6])$,答案是 4。另外我们考察一下 $DP[7]$ 的解是怎么计算出来的?其实,就是从 0 ... 7-1 即 0 ... 6 进行一个遍历,找出比 $nums[7]$ 位置小的数字,并形成上升序列,以此为基准计算出 $DP[7]$ 的长度。
接下来的图示,则展示了整个计算和决策的过程,来帮助你加深理解。
<img src="https://static001.geekbang.org/resource/image/42/6a/420795f07b8fddacc39a641103faae6a.png" alt="">
通过图示,我们可以清晰地看到,这种上升序列问题的决策是通过 $res = max(res, DP[i])$ 来实现的。这个 $max$ 函数就是决策以及状态转移的核心。这个问题跟我们之前遇到的有些区别,它更简单一些,但同时也不太好直接套用在解题模板上。为此,我多做一些解释。
你应该已经清楚的是,动态规划不仅是运筹学的重要分支,同时也是数学归纳法这种思想中很好的工程实践的体现。我们说,所谓数学归纳法就是从已知的答案推导出未知的部分。那么,按照图示来说,我们已经知道了 $DP[3]$ 的结果,我们该如何推导出 $DP[4]$ 呢?
根据我们对状态存储(备忘录)的定义,$DP[i]$ 中的 $i$ 表示的是从开始位置 0 到位置 $i$ 的的最长上升子序列的长度。既然我们要求的是上升子序列,$nums[4] = 2$。因此,我们只需找到前面那些结尾比 2 小的子序列,然后将 $nums[4] = 2$ 接在其后,就构成了一个新的上升子序列,而这个上升子序列的长度比前面的子问题 1。
对于那些结尾比 2 小的子序列,我们要找出最长的那一个,因为原问题要我们求“最长”上升子序列嘛。这也就是 $res = max(res, DP[i])$ 真正的由来。
同时,需要注意的是,从 $DP[3]$ 的计算开始往后,最长上升子序列的可能性就不唯一了。比如说,[1, 5, 6, 66]、[1, 5, 6, 18]、[1, 2, 6, 66] 和 [1, 2, 6, 18] 其实都是满足计算规则的。但是,原问题只需要我们求出最长上升子序列的长度,因此在上面的状态转移过程中,我没有给出其余可能性的计算过程。
经过分析后,我们就可以利用初始化状态、状态参数(决定了备忘录的设计)和决策来写出状态转移方程了。
$$DP[j]=\left\{\begin{array}{c}<br>
max \{ \ 1 + DP[i] \ , i &lt; j, j = |nums| - 1 \} \\\<br>
1 \ , otherwise<br>
\end{array}\right.$$
### 编写代码进行求解
接着就是代码实现。
Java 实现:
```
public int getLengthOfLIS(int[] nums) {
int n = nums.length; if (0 == n) { return 0; }
int[] dp = new int[n];
Arrays.fill(dp, 1); // 初始化状态
int res = 1; // 记录答案的变量
for (int j = 0; j &lt; n; j++) { // 决策
for (int i = 0; i &lt; j; i++) {
if (nums[i] &lt; nums[j]) {
dp[j] = Math.max(dp[j], dp[i] + 1);
res = Math.max(dp[j], res);
}
}
}
return res; // 输出答案
}
```
C++ 实现:
```
int GetLengthOfLIS(std::vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (0 == n) { return 0; }
int dp[n]; for (int i = 0; i &lt; n; i++) { dp[i] = 1; } // 初始化状态
int res = 1; // 记录答案的变量
for (int j = 0; j &lt; n; j++) { // 决策
for (int i = 0; i &lt; j; i++) {
if (nums[i] &lt; nums[j]) {
dp[j] = max(dp[j], dp[i] + 1);
res = max(dp[j], res);
}
}
}
return res; // 输出答案
}
```
好了,问题得到了较好的解决。通过这一系列的讲解,你应该已经发现,如果我们没有定义好状态存储(备忘录)的定义,那么就会在写状态转移方程时造成极大影响。
这个解法的算法复杂度是多少呢?
1. 首先,算法的时间复杂度是 O(n<sup>2</sup>),其中 n 为数组 $nums$ 的长度。动态规划的状态数为 n计算状态 $dp[j]$ 时,需要 O(n) 的时间遍历 $dp[0 ... j-1]$的所有状态,所以总时间复杂度为 O(n<sup>2</sup>)
1. 其次,算法的空间复杂度比较简单,是 O(n),需要额外使用长度为 n 的 $dp$ 数组。
因此,如果发现状态转移方程无法找出,那么就倒退一步,回去再思考一下备忘录的定义是否恰当,同时是否缺少了必要的状态参数(即备忘录的维度是否不足)。这就像“回溯算法”一样,倒退一步,可能就能更快地得到问题的思路和答案。
## 最长上升子序列的数量
现在,我们已经知道如何求解最长上升子序列的长度了。
但是,如果把问题稍微扩展一下,问你最长上升子序列共有几个,你该怎么解呢?事实上,这种问题比较普遍,我们就拿前面示例中的输入(即 $nums = [10, 9, 1, 5, 2, 6, 66, 18]$)作为例子,一起看下这张图。
<img src="https://static001.geekbang.org/resource/image/4c/55/4cd68740e4d2da7c8e2f00ac3c41c655.png" alt="">
从图示中我们可以看出,有四种到达 $DP[7]$ 状态的最长上升子序列的长度均为 4。这意味着由 [1, 5, 6, 66]、[1, 5, 6, 18]、[1, 2, 6, 66] 和 [1, 2, 6, 18] 构成的四个子序列的长度均为 4它们都符合题设的要求。因此对于这样的输入最长上升子序列的数量是 4。
现在,问题清楚了,我们来看一下这道面试问题的具体描述。
问题:给定一个未排序的整数数组 $nums$,找到最长递增子序列的个数。注意: 给定的数组长度不超过 2000 并且结果一定是 32 位有符号整数。
```
示例1:
输入: nums = [10, 9, 1, 5, 2, 6, 66, 18]
输出: 4
解释: 最长的上升子序列的长度是 4有以下几种组合
1) [1, 5, 6, 66]
2) [1, 5, 6, 18]
3) [1, 2, 6, 66]
4) [1, 2, 6, 18]
因此,原问题的答案是 4。
```
```
示例2:
输入: [2, 2, 2, 2, 2]
输出: 5
解释: 最长递增子序列的长度是 1并且存在 5 个子序列的长度为 1 ,因此原问题的答案是 5。
```
### 分析并写出状态转移方程
其实,这个问题本质上和上一个问题是一样的。只不过在上一个问题中,求解的是最长上升子序列的长度;而在这个问题中,求解的则是最长上升子序列的个数。因此,如果说上一个问题满足动态规划问题的几个特征,那这个问题肯定也是满足动态规划的问题特征的。
现在的问题是,怎么求解呢?我们能否在上一题思路的基础上做些调整而得到答案?
首先,这个问题肯定依然需要准备一个备忘录 $DP$。我将这里的状态存储定义跟上一个问题保持一致,即 **$DP[i]$ 表示以第 $i$ 个数字结束的序列的上升子序列的最长长度**。这么做的原因在于,如果我们要计算最长上升子序列的个数,首先需要知道最长上升子序列有多长。
现在,假定我们知道了最长上升子序列的长度。那么,最简单的方案肯定是从这个序列里把所有符合该长度的上升子序列全部暴力枚举出来。既然存在穷举,我们肯定不希望使用暴力法进行枚举,因为那么做效率实在太低了,即便写出求解代码也一定不是面试官想看到的。因此,我们来看看如何通过动态规划来解决这个问题。
根据原问题的描述,我们需要计算出最长上升子序列的数量。为此,我们需要创建一个新的备忘录 $count$,其中 **$count[i]$ 表示以第 $i$ 个数字结尾的序列的最长上升子序列的数量**。现在,我们考虑一下**初始化状态**。其实跟 $DP$ 数组一样,每个以自身结尾的初始序列长度肯定是 1同样序列的数量肯定也是 1。因此这个数组的初始状态就是将每一个位置都赋值成 1。
接着,再来确定**状态参数**。和上一个问题一样,我们只需要把一个当前遍历的索引位置作为变量,就足以描述整个状态转移过程了。
最后,我们来看看如何进行**决策**。这里的关键问题是:寻找当前问题 $count[i]$ 和它的子问题之间的关系,到底如何在子问题的决策上做出新的决策?
其实,我们不必单独去计算最长上升子序列的数量,而完全可以在计算最长上升子序列长度的同时计算数量。另**外层循环**的数字下标为 $j$**内部循环**的数字下标为 $i$,那么我们可以按照下面的思路来作出进一步决策:
1. 在内部循环中,如果 $nums[i] &gt; nums[j]$,那么 $count[j]$ 的值就不需要变化。这意味着,在最终求解的序列组合中,肯定没有同时包含 $nums[i]$ 和 $nums[j]$ 的上升子序列;
1. 如果 $DP[i]+1 &gt; DP[j]$,那么说明我们要更新 $DP[j]$ 的长度。同时(重点来了),要将 $count[j]$ 更新为 $count[i]$,这是因为 $count[j]$ 代表的是 $DP[j]$ 为最长上升子序列的个数。由于这时的 $DP[i]$ 是 $DP[j]$ 的子问题的解,因此它的个数就是 $DP[j]$ 长度的个数;
1. 与此同时,这里比普通的最长上升子序列问题复杂的地方在于:同为 $DP[j]$ 这个长度的上升子序列,可能不止一个!因此,我们要在 $DP[j]==DP[i]+1$ 时,继续追加 $count[j]$ 的个数;
1. 如果 $DP[i]+1 &lt; DP[j]$,则说明以 $nums[i]$ 结尾的序列加上 $nums[j]$ 形成的序列肯定不是当前的最长上升子序列,$count[j]$ 就不需要变化。
最后,我们从 $DP$ 数组中找出最长的那个 $maxLength$。然后,再遍历 $count$ 数组,将所有 $DP[i] == maxLength$ 的对应的 $count[i]$ 加起来,就是最终答案。其实,该问题的解法与上面一个求最长上升子序列的问题差不多,唯一区别就是:多了一个数组(即 $count$)来存储特定索引位置为结尾的最长子序列的个数。
这个问题比普通最长上升子序列问题稍微复杂一些,因此也更难理解一些,可以稍微放慢脚步。经过一些思考后,我相信你能理解这个问题的特别之处。
这个问题的状态转移方程需要分成几个部分来写,首先和上一个问题一样,我们先定义 $DP$ 的状态转移方程:
$$DP[j]=\left\{\begin{array}{c}<br>
max { \ 1 + DP[i] \ , i &lt; j, j = |nums| - 1 \ } \\\<br>
1 \ , otherwise<br>
\end{array}\right.$$
接着定义状态 $count$ 的状态转移方程:
$$count[j]=\left\{\begin{array}{c}<br>
sum\{\ count[i] \ , i &lt; j, DP[i]+1 = DP[j], j = |nums| - 1 \} \\\<br>
1 \ , otherwise<br>
\end{array}\right.$$
这个状态转移方程怎么理解呢?其实,就是求所有序列长度加 1 后 与当前数字结尾的“最长上升子序列的长度”相同的上升子序列数量的和。
最后的最后,我们还要定义一下结果函数:
$$result=sum\{\ count[i] \ , DP[i] = max(DP), i &lt; |nums|\ \}$$
这里,我们简单地将所有子序列长度,与最长上升子序列长度相同的序列数量,进行了相加。
### 编写代码进行求解
接着看代码实现。
Java 实现:
```
public int findLengthOfLISCount(int[] nums) {
int n = nums.length; if (n==0) { return 0; }
// 初始化状态
int[] dp = new int[n];
Arrays.fill(dp, 1);
int[] count = new int[n];
Arrays.fill(count, 1);
for (int j = 0; j &lt; n; j++) {
for (int i = 0; i &lt; j; i++) {
if (nums[i] &lt; nums[j]) {
if (dp[i]+1 &gt; dp[j]) {
dp[j] = dp[i]+1;
count[j] = count[i];
} else if (dp[i]+1==dp[j]) {
count[j] += count[i];
}
}
}
}
int maxLength = 0; // 求出 maxLength
for (int it : dp) { maxLength = Math.max(maxLength, it); }
int res = 0; // 定义备选答案的变量
for (int i = 0; i &lt; n; i++) {
if (maxLength == dp[i]) {
res+=count[i];
}
}
return res; // 输出答案
}
```
C++ 实现:
```
int FindNumberOfLIS(std::vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (n==0) return 0;
int dp[n], count[n];
for (int i = 0; i &lt; n; i++) { dp[i] = count[i] = 1; } // 初始化状态
for (int j = 0; j &lt; n; j++) {
for (int i = 0; i &lt; j; i++) {
if (nums[i] &lt; nums[j]) {
if (dp[i]+1 &gt; dp[j]) {
dp[j] = dp[i]+1;
count[j] = count[i];
} else if (dp[i]+1==dp[j]) {
count[j] += count[i];
}
}
}
}
int maxLength = 0; // 求出 maxLength
for (auto it : dp) { maxLength = max(maxLength, it); }
int res = 0; // 定义备选答案的变量
for (int i = 0; i &lt; n; i++) {
if (maxLength == dp[i]) {
res+=count[i];
}
}
return res; // 输出答案
}
```
现在,我们分析一下这个解法的算法复杂度:
1. 首先,算法的时间复杂度是 O(n<sup>2</sup>)。其中 n 是 $nums$ 的长度。与此同时,另外还有两个 for 循环是 O(1)。因此,总的算法时间复杂度为 O(n<sup>2</sup>)
1. 其次,我们创建了两个长度为 n 的备忘录(分别是 $dp$ 和 $count$),因此算法的空间复杂度为 O(n)。
## 攻破子序列问题的解题模板
从最长上升子序列的数量问题,你应该感觉到问题的难度提升了。我们甚至不惜动用第二个状态转移方程,来描述 $count$ 的状态转移过程。
但其实经过反复思考后,这个问题仍然没有脱离动态规划解题套路的适用范畴。不过不得不承认这个问题变复杂了,希望你能反复阅读这部分内容,加深理解,弄懂、弄通。
讲到这里,我们其实已经涵盖了大多数常见的子序列相关的动态规划问题。所以,是时候对动归中子序列问题的求解,进行一次较为全面的总结了。
就像我在本课开头说的那样,由于这里给出的是经验总结,因此在 90% 以上的情况下这个模板(套路)都是工作的,它足以应对你可能遇到的所有面试问题。
### 用一维备忘录求解子序列问题
在本课中,我讲到的“最长上升子序列”问题,就属于可以用一维备忘录来求解的动归问题。我们曾在上一个模块中讲解子序列问题时就提到过,但凡一个面试问题涉及子序列,那么它离动态规划就八九不离十了。
动态规划是数学归纳法的一种实践。因此,当状态存储(备忘录)的定义类似于 **$DP[i]$ 表示数组 $A[0 ... i]$ 中子序列的长度**,那么这个问题你可以考虑使用一维备忘录来进行求解。
我们需要根据原问题的特性,来确定初始化状态、状态参数(其实不用确定了,就是索引 $i$)和决策。至于决策,是这个解题模板的关键。你可以直接照搬下面的代码块来实现你的题解。
```
int Solution(std::vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (n == 0) { return 0; }
int dp[n]; // 注意,需要初始化状态
for (int j = 0; j &lt; n; j++) { // 决策
for (int i = 0; i &lt; j; i++) {
dp[j] = 最值函数(dp[j], dp[i] + ...);
}
}
}
```
其中,最值函数指的是像 $min$、$max$ 这样的函数,下同。
### 用高维备忘录求解子序列问题
当原问题涉及两个数组或字符串(甚至多个时),就需要考虑使用高维备忘录来求解子序列问题。比如说,我们在之前讲解“最长公共子序列”“最长回文子序列”时,就用到了这个解题模板。
但这里需要注意的是,针对原问题的特性,有两种不同的情况决定了备忘录的具体含义:
1. 如果原问题只涉及一个字符串或数组时,比如“最长回文子序列”问题。那么,**$DP[i][j]$ 表示的是数组 $A[i ... j]$ 中要求的子序列的长度**
1. 如果原问题涉及两个(或多个)字符串或数组时,比如“最长公共子序列”问题。那么,**$DP[i][j]$ 表示的是在数组 $A[0 ... i]$ 和 $B[0 ... j]$ 中要求的子序列的长度**。
考虑好备忘录的具体定义后,就可以确定初始化状态和决策了。至于决策,同样是该解题模板的关键。你可以参照下面的代码块外加一些特定的调整,来实现你的题解。
```
int Solution(std::vector&lt;int&gt;&amp; text1, std::vector&lt;int&gt;&amp; text2) {
int m = text1.size(), n = text2.size();
int dp[m+1][n+1]; memset(dp, 0, sizeof(dp)); // 注意,需要初始化状态
for (int j = 1; j &lt;= n; j++) { // 决策
for (int i = 1; i &lt;= m; i++) {
if (text1[i-1] == text[j-1]) {
dp[i][j] = dp[i-1][j-1] + ...
} else {
dp[i][j] = 最值函数(..., ...);
}
}
}
}
```
## 课程总结
求解动归领域中的子序列问题,其难度的跨越比较大,有比较简单的问题,也有比较复杂的问题。但是,这些问题都脱离不开本课结尾提到的解题模板。
对于解决子序列问题来说,只有两种情况需要我们考虑:
1. 当原问题的输入是一个字符串或数组时,要求解子序列。那么,你可以优先考虑使用一维备忘录的解题模板和套路来寻求问题的解;
1. 但如果原问题的输入是两个或以上的字符串或数组时,你就需要考虑使用高维备忘录的解题模板来解题了。
除了这两个解题模板以外,还有一些技巧需要掌握,比如在解决“最长上升子序列的数量”问题时,我们就不惜引入一个新的备忘录,来解决问题。希望你能在课后进行练习,充分并且灵活地利用解题模板,来攻破子序列问题。
## 课后思考
在本课中我讲解了如何求解最长上升子序列的问题当时给出的解法的算法时间复杂度是O(n<sup>2</sup>)。那么请你思考一下如何将该问题的算法时间复杂度优化为O(nlgn)
除此之外,我们在最长上升子序列的数量问题中,引入了更多的空间来辅助问题的求解。那么,我们该如何优化算法空间复杂度呢?
欢迎留言和我分享你的想法,我们一同交流!

View File

@@ -0,0 +1,433 @@
<audio id="audio" title="12动态规划新问题2攻破最大子数组问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ac/29/ac489044886ef47b1343d9c9ce5ddb29.mp3"></audio>
你好,我是卢誉声。
在“动态规划的套路”模块和上一课中,我们已经讨论了最典型的简单子数组问题,这其中包括:
1. 回文子串个数;
1. 最大子数组之和;
1. 最长连续递增序列。
但是,在实际的技术面试环节,如果涉及到动态规划的子数组问题,那么面试官往往会根据经典问题,给出一些有所变化的问题。和上节课类似,为了能够熟练解决所有常见的子数组问题及其各类变化,在本课中,我将会为你讲解一些子数组问题的变种,作出问题的扩展,深挖该类型面试问题的解法。
最后,我还会给出攻破子数组的解题模板。由于是经验总结,因此在 90% 以上的情况下这个模板(套路)都是可行的,它足以应对你可能遇到的这类面试问题。
按照惯例,在开始今天的内容前,你可以关注一下:**相较于简单的动归子数组问题(如“最长连续递增序列”问题),接下来的题目有何区别。有哪些东西是可以提取出来成为解题模板的?**
现在,就让我们带着这个关注点,来开始今天的学习吧。
## 不重叠的子数组之和
还记得什么是动态规划问题中的子数组问题吧!我先简单概括一下。所谓子数组模型,一般就是从一个序列中寻找满足条件的子数组或者相关的扩展。而这类问题的特点就是答案是连续的子串,而非上一课中的子序列。
对于子数组问题,你应该已经跨过了基本解题的门槛。现在,让我们先来看第一个“面试级别”的子数组问题——不重叠的子数组之和,先看一下问题描述。
问题:给定一个整数数组 $nums$ 和一个整数 $k$,找出 $k$ 个不重叠子数组使得它们的和最大。每个子数组的数字在数组中的位置应该是连续的。返回最大的和。
```
示例1
输入: nums = [1, 2, 3, 4]k = 1
输出: 6
解释: 1 + 2 + 3 + 4 = 10
```
```
示例2
输入: nums = [-1, 4, -2, 3, -2, 3]k = 2
输出: 8
解释: 4 + (3 + -2 + 3) = 8
```
### 算法问题分析
其实,这个问题相当于[第8课](https://time.geekbang.org/column/article/292667)中“最大子数组之和”的威力加强版。在之前讲解的求最大子数组之和问题时,我们只需要简单地求出和最大的子数组;而这里需要求解的是,找出 $k$ 个不重叠的子数组,使得整体的和最大。因此,从思路上看这两个问题之间肯定存在某种关系。
首先,我们可以初步判断这个问题是一个最优化的问题,而且一定是满足重叠子问题、无后效性和最优子结构,我们就不在这里做具体分析了。希望你可以根据我们之前分析问题的方法,来分析一下该问题,看它是否符合动态规划问题的特征。
现在,我们直接开始讲到底如何使用动态规划来解决这个问题。
### 分析写出状态转移方程
解决动态规划问题早已成为套路,我们直接拿套路来解题吧!
第一步,分析**初始化状态**。首先,如果这个问题里,数组的长度 &lt; 子数组的数量 $k$。那么,由于数组无法被分解为 $k$ 个子数组(每个子数组要至少包含一个元素吧)。因此,这种情况是没有结果的。
而如果数组长度 = 子数组的数量 $k$。那么,在这种情况下,我们只能将整个数组分解为 $k$ 个子数组,其中每个元素单独组成一个子数组。此时,最大之和其实就是数组中所有元素之和。这就是我们的初始化状态,也就是边界条件。
第二步,我们来看一下**状态参数**。首先,我们要记住的是,**但凡是子数组问题,数组的索引肯定是我们的一个状态参数!**这是因为,我们需要不断移动数组的索引,在更小的数组的基础上求解出更大数组的解。
但是只有这个参数就足够了吗?恐怕还不够,因为我们还有另一个需要衡量的因素,就是子数组的数量 $k$。因此,我们可以先假定状态参数中包含:数组的索引 $i$ 和子数组的数量 $k$。
第三步,我们需要来看怎么定义状态存储(备忘录)$DP$ 的格式。在[第8课](https://time.geekbang.org/column/article/292667)最大子数组之和问题中,我们曾定义了一个备忘录 $DP[i]$,表示以 $i$ 为结束位置的最大子数组之和。但在不重叠的子数组之和问题中,有两个状态参数,分别是数组的索引 $i$ 和子数组的数量 $k$。因此,这个类似的问题就需要定义一个数组 $DP[i][j]$ 表示将数组的前 $i$ 个元素划分为 $j$ 个子数组时的最优解。
这个时候我们需要再思考一下。对于原问题来说,其真正的最优解中最后一个子数组的最后一个元素,并不一定是 $i$ 这个元素,有这么几种情况:
1. 舍弃第 $i$ 个元素,将前 $i-1$ 个元素划分为 $j$个数组;
1. 选取第 $i$ 个元素,将前 $i-1$ 个元素划分为 $j$ 个数组;而当前元素加入第 $j$ 个数组。在这种情况下有一个特殊要求,即第 $i-1$ 个元素必须在第 $j$ 个数组中,这样第 $i$ 个元素才能加入进去;否则,不连续的元素不能放在一个子数组中(我们在计算子数组问题,前提就是要“连续”);
1. 选取第 $i$ 个元素,将前 $i-1$ 个元素划分为 $j-1$ 个数组;而当前元素自己成为第 $j$ 个数组。
我们需要特别注意上面的第二点,由于无法确保 $DP[i][j]$ 中的第 $i$ 个元素一定在第 $j$ 个数组中。因此,我们需要再定义一个备忘录 **$M[i][j]$,表示将数组的前 $i$ 个元素划分为 $j$ 个子数组,并且第 i 个元素一定在第 j 个数组中时的最优解。**
对于整个求解过程,你可以参考以下**计算方向**示意图。
<img src="https://static001.geekbang.org/resource/image/a0/2c/a0e76ccfeca1ce37de52b180f80eb82c.png" alt="">
从示意图中可以看出,这里有两个状态备忘录,分别是 $dp$(对应$DP[i][j]$)和 $m$(对应$M[i][j]$)。从这个案例可以看出,当动态规划问题稍微复杂一些的时候,我们会创建多个备忘录,而且备忘录之间的求解过程是相互关联的。
好了,万事俱备。有了以上分析之后,现在我们可以写出状态转移方程了。
$$DP[i][j]=\left\{\begin{array}{c}<br>
DP[i-1][j-1]+nums[i] \ , i ==j \\\<br>
max(DP[i-1][j], M[i][j]) \ , i &gt; j<br>
\end{array}\right.$$
$$M[i][j]=\left\{\begin{array}{c}<br>
M[i-1][j-1]+nums[i] \ , i == j \\\<br>
max(M[i-1][j], DP[i-1][j-1]) + nums[i] \ , i &gt; j<br>
\end{array}\right.$$
我们定义了两个状态转移方程,首先定义了 $DP[i][j]$ 的状态转移方程,然后定义了 $M[i][j]$ 的状态转移方程。这两个备忘录相互依赖、缺一不可。
### 编写代码进行求解
现在,我先给出该问题的算法求解代码,然后再做一些解释。
Java 实现:
```
public int maxSubArray(int[] nums, int k) {
int n = nums.length;
int[][] m = new int[n+1][k+1];
int[][] dp = new int[n+1][k+1];
for (int i = 0; i &lt;= n; i ++) { // 初始化状态
for (int j = 0; j &lt;= k; j ++) {
m[i][j] = 0;
dp[i][j] = 0;
}
}
for (int i = 1; i &lt;= n; i++) { // 决策过程
for (int j = Math.min(i, k); j &gt; 0; j--){
if(i == j){
m[i][j] = m[i-1][j-1] + nums[i-1];
dp[i][j] = dp[i-1][j-1] + nums[i-1];
} else{
m[i][j] = Math.max(m[i-1][j], dp[i-1][j-1]) + nums[i-1];
dp[i][j] = Math.max(dp[i-1][j], m[i][j]);
}
}
}
return dp[n][k]; // 输出答案
}
```
C++ 实现:
```
int MaxSubArray(std::vector&lt;int&gt; nums, int k) {
int n = static_cast&lt;int&gt;(nums.size());
int m[n+1][k+1];
int dp[n+1][k+1];
for (int i = 0; i &lt;= n; i ++) { // 初始化状态
for (int j = 0; j &lt;= k; j ++) {
m[i][j] = 0;
dp[i][j] = 0;
}
}
for (int i = 1; i &lt;= n; i++) { // 决策过程
for (int j = min(i, k); j &gt; 0; j--){
if(i == j){
m[i][j] = m[i-1][j-1] + nums[i-1];
dp[i][j] = dp[i-1][j-1] + nums[i-1];
} else{
m[i][j] = max(m[i-1][j], dp[i-1][j-1]) + nums[i-1];
dp[i][j] = max(dp[i-1][j], m[i][j]);
}
}
}
return dp[n][k]; // 输出答案
}
```
在代码中,为了代码的统一,我定义的缓冲区长度是 (n+1) * (k+1),这么做便于处理边界情况。接着,我们将所有备忘录的值都初始化为 0。
接下来,就开始循环操作即决策过程。这里需要注意的是,$j$ 是从最大到 0 倒推的。然后,在每次循环的时候,检查 $i$ 和 $j$ 的大小关系。由于 $j$ 的初始值是 $min(i, k)$,必然有 $j ≤ i$。所以,这里无需处理 $j &gt; i$ 的情况。
当 $i == j$ 时,说明子数组的数量等于整个数组的长度。因此,每个元素都是一个单独的数组,所以状态存储(备忘录) $dp$ 和 $m$ 的值都是 $i-1$ 个数的结果 当前数字。
由于我们的备忘录长度是 $n+1$,循环开始的时候下标是 1。所以这里需要用 $i-1$ 来从 $nums$ 数组中取对应的元素。细节是魔鬼!你一定要注意。
当 $i != j$ 时,通过 $m[i-1][j]$ 和 $dp[i-1][j-1]$ 得到前 $i-1$ 个数字的最优解。 然后,将当前数字放入子数组中,因此,需要再加上当前元素得到前 $n$ 个元素的最优解。而这个解是存放在 $m$ 数组中的。
最后,我们需要考虑前 $i$ 个数字的最优解是否会包含第 $i$ 个数字:
1. 如果包含,那么 $m[i][j]$ 就是前 $i$ 个数字的最优解;
1. 如果不包含,那么 $dp[i-1][j]$ 就是前 $i$ 个数字的最优解。
因此,这里我们用 $max$ 函数取了一下两者最大值,作为前 $i$ 个元素的最优解。
## 最大子数组之积
我们再来看一个问题,这个问题其实也“最大子数组之和”问题的一个变种。先看一下问题的具体描述。
问题:给定一个整数数组 $nums$(由正整数和负整数组成),请你找出数组中乘积最大的子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
```
示例1
输入: nums = [2, 8, -2, 4]
输出: 16
解释: 子数组 [2, 8] 有最大乘积 16。
```
```
示例2
输入: nums = [-2, 0, -1]
输出: 0
解释: 结果只能为 0不能为 2。因为 [-2,-1] 不是子数组,是子序列,它们不是连续的。
```
### 分析并写出状态转移方程
这个问题的基本模型,与求最大子数组之和的问题非常类似,只不过将求和变成了求乘积。因此,我们可以初步判断这个问题是一个最优化的问题,而且一定是满足重叠子问题、无后效性和最优子结构的。
同样的,你可以自己根据我们之前分析问题的套路,来分析一下该问题是否满足使用动态规划求解的特征。
现在,我们直接用动归解题模板,来讲解该如何使用动态规划来解决这个问题,你可以关注一下该问题的求解与最大子数组之和的区别在哪里。
第一步,分析**初始化状态**。我们考察一下原问题中的边界条件,当数组索引为 0 的时候,这个时候 $dp[0] = nums[0]$。这是因为,当 $i = 0$ 时这个子数组只能包含数组的第 0 项。
第二步,确定**状态参数**。就像前面我说的那样:只要是子数组问题,数组的索引肯定是我们的一个状态参数。因为我们需要不断移动数组的索引,不断在原来的最大数组的基础上和当前第 $i$ 个元素相乘,在更小的数组的基础上求解出更大数组的解,因此数组的位置 $i$ 肯定是一个参数。
Hmmm… 看起来这个问题跟求最大子数组之和的问题没什么区别嘛。显然,这里有坑,问题没有表面上看起来那么容易。我们仔细思考一下,求乘积与求和不一样的地方是什么?**如果某次乘上的数字是负数**,那么得到的结果很有可能会从最大变成最小,或者从最小变成最大!
因此,我们需要考虑正负数的问题,创建两个 $DP$ 数组,作为存储状态以做状态转移,分别为 dp_max[n] 和 dp_min[n]。当迭代到当前的数字为负数时,需要对调 dp_max[i-1] 和 dp_min[i-1],即 swap(dp_max[i-1], dp_min[i-1])。这是因为,当前这个负数 nums[i] 乘以上一个数后,最大的会变成最小的,而最小的会变成最大的。那么就在乘之前将它们俩对调。
这样一来求乘积之后的结果就仍然是正确的dp_max[i] 维护的仍然是当前最大值dp_min[i] 维护的是当前最小值。
最后,由于原问题要求的是最大值。因此,每次迭代将 res 与 dp_max[i] 做比较,用 max 函数取最大值。最终res 就是原问题所需的答案。
<img src="https://static001.geekbang.org/resource/image/9d/a9/9d1193621c5580208afe2eabf213cfa9.png" alt="">
以上状态转移图中,有一个现象值得关注。那就是在第三轮迭代时, dp_max[1] 和 dp_min[1] 的值做了交换。
这是因为 nums[2] 对应的数字 -2 为负数,因此在迭代前做了数字的交换。当前这个负数乘以上一个数后,最大的会变成最小的,而最小的会变成最大的。那么就在乘之前将它们俩对调。
$$DP_{max}[i] = max \{ nums[i], \ DP_{max}[i-1] * nums[i] \}$$
### 编写代码进行求解
这个问题最后的状态转移比较简单,我直接给出求解代码。
Java 实现:
```
public int getMaxProduct(int[] nums) {
int n = nums.length; if (0 == n) { return 0; }
int[] dp_max = new int[n]; Arrays.fill(dp_max, 0);
int[] dp_min = new int[n]; Arrays.fill(dp_min, 0);
dp_max[0] = nums[0]; // 初始化状态
dp_min[0] = nums[0];
int res = nums[0];
for (int i = 1; i &lt; n; i++) { // 决策过程
if (nums[i] &lt; 0) {
int temp = dp_max[i-1];
dp_max[i-1] = dp_min[i-1];
dp_min[i-1] = temp;
}
dp_max[i] = Math.max(nums[i], dp_max[i-1] * nums[i]);
dp_min[i] = Math.min(nums[i], dp_min[i-1] * nums[i]);
res = Math.max(res, dp_max[i]);
}
return res; // 输出答案
}
```
C++ 实现:
```
int GetMaxProduct(std::vector&lt;int&gt;&amp; nums) {
int n = static_cast&lt;int&gt;(nums.size()); if (0 == n) { return 0; }
int dp_max[n], dp_min[n];
memset(dp_max, 0, sizeof(dp_max));
memset(dp_min, 0, sizeof(dp_min));
dp_max[0] = nums[0]; // 初始化状态
dp_min[0] = nums[0];
int res = nums[0];
for (int i = 1; i &lt; n; i++) { // 决策过程
if (nums[i] &lt; 0) { std::swap(dp_max[i-1], dp_min[i-1]); }
dp_max[i] = max(nums[i], dp_max[i-1] * nums[i]);
dp_min[i] = min(nums[i], dp_min[i-1] * nums[i]);
res = max(res, dp_max[i]);
}
return res; // 输出答案
}
```
### 另一种求解方法
在上面的方法中,我们充分利用了原问题的特点,用了一个“交换”的技巧实现了问题的求解。但事实上,这个问题还能用别的方法求解,也就是更加贴近于解题模板的方法。接下来,我们就来看看重新设计备忘录后的另一种解法。
现在按照套路来解决这个动态规划问题。
首先,这种解法的**初始化状态**和**状态参数**跟上面的解法完全相同。我们从“负数”这个问题开始重新进行分析。
我们需要仔细思考一下,求乘积和求和不一样的地方是,**如果某次乘上的数字是负数**,那么得到的结果很有可能会从最大变成最小,或者从最小变成最大!
因此我们需要考虑,如果第 $i$ 个数字为负数,而到第 $i-1$ 个位置的最小值也是负数,那么相乘之后很有可能变成最大值。所以我们的状态参数还要加上一个 $j$1 或 2**$DP[i][0]$ 表示数组前 $i$ 个元素的最大乘积,$DP[i][1]$ 表示数组前 $i$ 个元素的最小乘积**。
接着,我们需要来看怎么定义状态转移方程和备忘录的格式。根据我们的状态参数,我们的备忘录 $DP[i][j]$ 是一个二维数组。其中 $j$ 的维度是 2$DP[i][0]$ 表示数组前 $i$ 个元素的最大乘积;$DP[i][1]$ 表示数组前 $i$ 个元素的最小乘积。
在这种情况下,$DP[i][0]$ 可能有下面三种情况:
1. 结果为 $nums[i]$ 自身,不和其它元素相乘;
1. 正数,则要乘以 $DP[i-1][0]$,也就是前 $i-1$ 个元素的乘积最大值,这样才能得到**最大值**
1. 负数,则要乘以 $DP[i-1][1]$,也就是前 $i-1$ 个元素的乘积最小值,这样才能得到**最大值**。
最后,从这三个值中取最大值即可。同理,$DP[i][1]$ 也可能有三种情况:
1. 结果为 $nums[i]$ 自身,不和其它元素相乘;
1. 正数,肯定要乘以 $DP[i-1][1]$,也就是前 $i-1$ 个元素的乘积最小值,这样才能得到**最小值;**
1. 负数,肯定要乘以 $dp[i-1][0]$,也就是前 $i-1$ 个元素的乘积最大值,这样才能得到**最小值。**
最后,从这三个值中取最小值即可。整个状态转移过程就如下图所示。
<img src="https://static001.geekbang.org/resource/image/1b/5d/1b93a244d66e3013308ced57c0d9db5d.png" alt="">
从图中可以看到,$DP[3][0]$ 需要根据 $DP[2][0] * nums[3]$、$DP[2][1] * nums[3]$ 和 $nums[3]$ 综合判定,最后取最大值。
基于以上分析,我们就可以写出状态转移方程了。
$$DP[i][j]=\left\{\begin{array}{c}<br>
max(dp[i1][0]nums[i],dp[i1][1]nums[i],nums[i]) \ , j = 0 \\\<br>
min(dp[i1][0]nums[i],dp[i1][1]nums[i],nums[i]) \ , j = 1 \\\<br>
\end{array}\right.$$
从这个解法和状态转移方程,我们可以看出,其实我们在状态存储(备忘录)上多创建了一个维度来记录下来数字是正数还是负数。本质上,跟前一种解法的思路是相同的,只不过具体求解方法不同。希望你在求解动归问题的时候,通过练习实现灵活运用。
按照惯例,下面给出第二种解法的算法求解代码,然后稍作解释。
Java 实现:
```
public int getMaxProduct(int[] nums) {
int n = nums.length;
int[][] dp = new int[n][2];
for (int i = 0; i &lt; n; i ++) { // 初始化状态
dp[i][0] = nums[i];
dp[i][1] = nums[i];
}
for (int i = 1; i &lt; n; i ++) { // 决策求解
dp[i][0] = Math.max(dp[i - 1][0] * nums[i], Math.max(nums[i], dp[i - 1][1] * nums[i]));
dp[i][1] = Math.min(dp[i - 1][1] * nums[i], Math.min(nums[i], dp[i - 1][0] * nums[i]));
}
int ans = dp[0][0];
for (int i = 1; i &lt; n; i ++) { ans = Math.max(ans, dp[i][0]); }
return ans; // 输出答案
}
```
C++ 实现:
```
int GetMaxProduct(vector&lt;int&gt; nums) {
int n = static_cast&lt;int&gt;(nums.size());
int dp[n][2];
for (int i = 0; i &lt; n; i ++) { // 初始化状态
dp[i][0] = nums[i];
dp[i][1] = nums[i];
}
for (int i = 1; i &lt; n; i ++) { // 决策求解
dp[i][0] = max(dp[i-1][0] * nums[i], max(nums[i], dp[i-1][1] * nums[i]));
dp[i][1] = min(dp[i-1][1] * nums[i], min(nums[i], dp[i-1][0] * nums[i]));
}
int ans = dp[0][0];
for (int i = 1; i &lt; n; i ++) { ans = max(ans, dp[i][0]); }
return ans; // 输出答案
}
```
以上代码中,我们首先创建了一个二维数组作为该动态规划解法的备忘录。然后,把最大值数组和最小值数组的各个位置赋予初值,也就是第 $i$ 个元素,处理边界情况。
接下来,执行循环。我们每次都会处理最小值数组和最大值数组,求以 $i$ 结尾的数组的最小乘积和最大乘积。最后,从所有的最大乘积中返回最大值。
## 攻破子数组问题的解题模板
一般人会说,子数组问题并没有一个统一的模板,很多问题还是需要具体问题具体分析。但是,我们已经做过这么多题目了,其实已经隐约发现了其中的套路。
首先,所有动态规划领域中的子数组问题,基本都需要遍历原来的数组,使用数组元素下标作为子问题的**状态参数**。除此之外,在更复杂的问题中,我们可能还会使用更多的状态参数。一般来说,如果不考虑空间复杂度优化,那么一般有几个状态参数,备忘录就要用几维数组。
举个例子,如果只有数组元素的下标作为状态参数,那么我们只需要使用**一维数组 $DP[i]$** 作为备忘录;如果除了数组元素下标,还需要第二个状态参数 $j$(假设有这么一个参数),那么就需要使用**二维数组 $DP[i][j]$** 作为备忘录;如果再不济碰到三个参数(技术面试一般不会到这个程度)就需要三维数组 … 以此类推。
按照解题套路,确定了初始化状态、状态参数,就需要写出状态转移方程,也就是决策代码,基本模板如下所示:
```
int Solution(std::vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (n == 0) { return 0; }
int dp[n];
// 请你注意,这里需要根据具体问题,做初始化状态
for (int i = 0; i &lt; n; i ++) {
initialize(dp, i);
}
// 状态转移与决策
for (int i = 0; i &lt; n; i++) { // 决策
dp[i] = 最值函数(dp[i], dp[i - 1] + ...);
}
return get_result(dp);
}
```
最值函数指的是像 $min$、 $max$ 这样的求最值函数。在复杂的问题中,这个最值函数也会变得非常复杂,一般如果有一个状态参数就需要一层循环,有两个状态参数就需要两层循环。
## 课程总结
动态规划中的子数组问题看起来比较类似,但其实很多题目需要我们举一反三、灵活处理。当然这些问题都脱离不开本课结尾提到的解题模板。
解决这些问题的关键在于分析出除了数组索引之外还存在什么状态参数,一旦能够找到合适的状态参数,所有的子数组问题就迎刃而解了。所以我们需要多做练习,才能熟练解决类似的子数组问题。
## 课后思考
在本课中,我讲解了如何处理乘积最大子数组问题。但其实这个问题无论是时间复杂度还是空间复杂度都有可以提升的空间,请思考一下如何降低这个问题的时间复杂度和空间复杂度,并给出解决方案。
欢迎留言和我分享你的想法,我们一同交流!

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="13动态规划算法设计的关键最优子结构与状态依赖" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/20/bf7f7cc71ed57ecd6bf855927fcca320.mp3"></audio>
你好,我是卢誉声。
还记得我们曾经讨论过的吗?动态规划是运筹学上的一种最优化方法,常出现在数学、管理科学、计算机科学、经济学和生物信息学中,特别是在算法问题上应用广泛。当我们求解一个复杂问题时,会考虑把原问题分解为相对简单的子问题,再进行求解。
从这个意义上说,动态规划是一种思想,而非传统意义上的算法:如果我们要求解原问题,就需要求解其不同部分(即子问题),再根据子问题的解推导计算出原问题的解。
在专栏中,我们曾反复提及动态规划三大特征,即重叠子问题、无后效性和最优子结构。只有当原问题满足以上特征时,我们才能使用动态规划思想来进行求解。动态规划对子问题与原问题的关系、子问题之间的依赖关系这两方面有一些要求,它们分别对应了最优子结构和重叠子问题。
相较于重叠子问题和无后效性来说,理解最优子结构要稍微困难一些。最优子结构最终决定了我们求解动态规划问题的状态转移过程,甚至是动态规划算法的计算方向。因此,充分理解最优子结构的概念至关重要。
今天,就让我们深入挖掘最优子结构这个概念,以及它与计算方向之间的关系。
## 深入理解最优子结构
动态规划思想在求解包含重叠子问题情况的最优解时特别有效。它将问题重新组合成子问题,为了避免重复计算,我们会设计一个状态存储,即备忘录来保存中间计算状态。
备忘录使得我们在后续计算过程中出现重复计算时,可以直接拿出之前计算好的结果。整个计算过程从简单的问题直到整个问题都被解决为止,并通过备忘录,我们戏剧性地加快了算法执行的速度。
当重叠子问题的数目关于输入的规模呈指数增长时,这种做法特别有用。因此,我们说动态规划适用于求解含有重叠子问题和最优子结构性质的问题,其算法时间复杂度往往远少于朴素解法(比如穷举)。
说着说着,我们就把重叠子问题和最优子结构联系在一起了。当然了,作为动态规划问题的三大特征,它们之间即便有关系也正常。有关于重叠子问题和重复计算的概念比较简单,我曾用斐波那契数列作为示例,并通过求解树的形式讲解了其概念,有关这部分的内容你可以参见[第3课](https://time.geekbang.org/column/article/287199)。
### 特征与问题的关系
动态规划的三大特征(重叠子问题、无后效性和最优子结构)约束了动态规划中问题的关系。
1. 重叠子问题:规定了经过拆分后的原问题中,子问题与子问题之间的关系。即更大子问题可能包含更小子问题的重复计算部分,而子问题之间也可能存在完全相同的情况。
1. 无后效性规定了子问题与子问题之间的关系。即如果A子问题的结果依赖于B子问题那么B子问题的结果一定不能直接或者间接依赖于A。
1. 最优子结构:规定了子问题与原问题之间的关系。原问题的最优解一定是由子问题的最优解组成的,如果无法通过子问题的最优解求得原问题的最优解,那么就不存在最优子结构。
可以看出,最优子结构比较特殊,它是连接子问题与原问题之间的重要桥梁。接下来,让我们看看最优子结构在动态规划中是如何解释的。
### 最优子结构
在动态规划能解决的三大类问题中:
1. 求“最”优解问题(最大值和最小值);
1. 求可行性True或False
1. 求方案总数。
动态规划首要解决的是“最”优解问题(求最大值和最小值),即从很多解决问题的方案中找到最优的那一个。而求最优解问题的核心其实就是穷举,把一个大问题分解成多个子问题,然后递归找到每个子问题的最优解。最后,通过算法将每个子问题的最优解进行组合,得出原问题的答案。
因此,我们要求的原问题的最优解,是由它的各个子问题的最优解决定的。而动态规划能否成功实施,就取决于我们能否将子问题的答案,通过某种方法进行组合,进而得到原问题的答案。
我们拿[第8课](https://time.geekbang.org/column/article/292667)的“最长回文子串个数”问题为例,来看一下最优子结构与状态转移方程的关系。另原问题的解(即动态规划最终要到达的状态)为 $DP(i, j)$,其与子问题之间的关系定义为 $DP(i, j) = DP(i+1, j-1)$, …conditions。
这就是状态转移方程,它描述了一种原问题与子问题的组合关系 。在原问题上的不同选择,可能对应不同的子问题或不同的组合方式:
$$DP(i, j)=\left\{\begin{array}{c}<br>
DP[i+1][j-1],s[i]==s[j]\\\<br>
False, s[i] \ne s[j]<br>
\end{array}\right.$$
比如上述状态转移方程中的 $s[i] == s[j]$ 或 $s[i] != s[j]$,就对应了原问题 $i$ 和 $j$ 上不同的选择,分别对应了不同的子问题和组合方式。找到了最优子结构,就能推导出状态转移方程,有了状态转移方程,我们能很快地写出原问题的递归实现。
<img src="https://static001.geekbang.org/resource/image/c9/5a/c966f5yy5byybec152da566cbd13955a.png" alt="">
还记得在早期的课程中我都会给出原问题求解的递归形式么,只不过后来随着我们逐渐熟悉了动态规划求解模板,我渐渐省去了这一步骤。
那么,我们再思考一个问题,即对于每个子问题,它拥有最优解的条件又是什么呢?这就要继续考察子问题是否具有无后效性,即子问题与子问题之间没有前后依赖关系,它们是**相互独立**的。
综上所述,**所谓最优子结构,就是指原问题中各个子问题是否存在最优解,而子问题是否存在最优解的关键是它们之间相互独立**。通过子问题求得最终答案的过程,我们用**状态转移方程**来进行描述。
在之前的课程里,我们反复强调过只有三个特征同时满足时,原问题才可以使用动态规划思想来解。这么说是有原因的,因为动态规划的最核心思想就是消除穷举过程中的重复计算,如果没有重叠子问题,我们其实也没有必要再去分析原问题是否包含最优子结构了。
所以说,最优子结构是某些问题的一种特定性质,并不是动态规划专有的特征。其实,很多问题都具有最优子结构,只是绝大多数问题都不具备重叠子问题,所以我们不把它们归为动态规划求解的范畴。这么说可能比较抽象,那么接下来我们举个例子。
假设有这样一个问题,有 n 个需要在同一天使用相同教室的活动 $a_{1}$, $a_{2}$, … , $a_{n}$,教室同一时刻只能供一个活动使用。每个活动 $a_{i}$ 都有一个开始时间 $s_{i}$ 和结束时间 $f_{i}$。一旦被选择后,活动 $a_{i}$ 就占据半开时间区间 [$s_{i}$,$f_{i}$)。如果 [$s_{i}$,$f_{i}$] 和 [$s_{j}$,$f_{j}$] 互不重叠,$a_{i}$ 和 $a_{j}$ 两个活动就可以被安排在这一天。该问题就是要安排这些活动,使得尽量多的活动能不冲突的举行。
我们如果认真分析一下,就会发现这个问题只需要每次选取结束时间最早的活动就行了。虽然,我们也会将原问题划分成子问题来处理,但是子问题是不包含重复计算的,因此不必使用动态规划来处理。
从以上问题可以看出,最优子结构不是动态规划问题的充分条件,而是其必要条件。绝大多数求最值的问题具有最优子结构,但并不是说这些问题都能使用动态规划思想来进行求解。
在我们使用解题模板前,我们总会优先判定原问题是否符合动态规划三大特征,这么做是有必要的,特别是确定原问题是否存在最优子结构。这是因为,动态规划解法总是从初始化状态向更复杂的子问题进行推导的,而最优子结构正是证明这种推导关系的重要证据。
寻找最优子结构的过程,就是证明状态转移方程正确性的过程。只要写出状态转移方程,我们求解的目标就实现了一大半。
## 分析计算方向
我们在本课中将全程围绕最优子结构这一问题展开。现在,我们知道了最优子结构是确定状态转移方向的充分必要条件,最终决定了状态转移方程。
最优子结构的定义决定了子问题依赖的方向,即动态规划算法的**计算方向**。我们在[第9课](https://time.geekbang.org/column/article/293232)中讲到“最长回文子序列”问题时,第一次提出计算方向这一重要概念。现在,是时候对它进行深入剖析了。
在讲解计算方向前,我们先深入挖掘一下什么是无后效性。
### 关于无后效性
所谓无后效性,简要一句话概括就是子问题之间的依赖是单向性的,某阶段状态一旦确定,就不受后续决策的影响。怎么理解这句话呢?
我们都知道其实动态规划是一种用空间换时间的方法,通过备忘录记录重叠子问题的最优解,确保在需要获得子问题结果的时候,不需要重复计算。所以这就需要确保一个子问题在计算完成之后,就不应该再因为其它任何因素产生变化了,不然牵一发而动全身,其它依赖于这个子问题的问题就都需要重新计算。
而动态规划需要确保效率的另一个关键在于计算方向是单向的比如很多问题都是0到n那么我们假设在计算第n个子问题的时候会影响第n-1个子问题的结果哪怕是有条件的那岂不是所有依赖于第n-1个子问题的问题都需要重新计算那么动态规划对时间复杂度的优化保证也就无从谈起了。
所以动态规划这种计算的单向性也就强调了子问题之前的依赖必须是单向的换言之如果问题A的最优解需要通过问题B的最优解直接或者间接计算出来那么问题B的最优解必定不能直接或者间接需要通过问题A的最优解来计算。这也就是在前文中提到的动态规划的实际计算一定是一棵树因为树可以确保这种性质如下图所示。
<img src="https://static001.geekbang.org/resource/image/be/f6/bec578a6283d4a89836b36974c0181f6.png" alt="">
所以通过这个性质我们可以发现计算方向在动态规划里是非常重要的,而无后效性也确保了单调的计算方向是一定存在的!那么我们接下来就讨论一下计算方向。
### 分析计算方向
在刚开始接触动态规划问题的时候,有一个关键细节往往是大家会忽略的:在设计好了备忘录后,我们凭直觉从 0 到 n 进行计算并不断填充备忘录。
```
...
int dp[n]; memset(dp, 0, sizeof(dp));
for (int i = 0; i &lt; n; i++) {
dp[i] = ... // 计算 dp[i]
}
...
```
而如果碰到高维备忘录,比如说二维数组,就会按照这种方法遍历:
```
...
int dp[m][n]; memset(dp, 0, sizeof(dp));
for (int j = 0; j &lt; n; j++) {
for (int i = 0; i &lt; n; i++) {
dp[i][j] = ... // 计算 dp[i][j]
}
}
...
```
这看起来很正常嘛,很多人会因此得出一个“结论”:在处理动态规划问题时,只要从左上角到右下角(即从左到右,从上到下)计算就可以了 。但是,当我们学习到[第9课](https://time.geekbang.org/column/article/293232)中的“最长回文子序列”问题时,情况就有些不对了。想想当时我们是怎么进行遍历的?
为了方便起见,我直接给出当时编写的代码:
```
int GetLongestPalindromeSubseq(string s) {
int n = static_cast&lt;int&gt;(s.size());
if (0 == n) return 0;
int dp[n][n]; memset(dp, 0, sizeof(dp));
for (int i = 0; i &lt; n; i++) dp[i][i] = 1; // 初始化状态
for (int i = n-1; i &gt;= 0; i--) {
for (int j = i+1; j &lt; n; j++) {
if (s[i]==s[j]) {
dp[i][j] = 2 + dp[i+1][j-1];
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j-1]); // 作出进一步决策
}
}
}
return dp[0][n-1]; // 输出答案
}
```
我们仔细看一下两个 for 循环的具体处理,就会发现,这里我们不仅没有从左上角到右下角计算,抑或是从右下角到左上角计算。反而,我们在“斜着”进行状态转移和执行计算过程!
通过以下状态转移图,你就能清晰地看出这个特别的计算方向。
<img src="https://static001.geekbang.org/resource/image/12/78/124def159a0b5e478b2b6fc798916178.png" alt="">
由于待求解问题、最优子结构和状态转移方程设计的特殊性,原问题的答案最终存储在了 $DP[0][n-1]$,即 $DP[0][7]$ 的位置上。
因此,当最优子结构和状态转移方程要求我们按照别的计算方向进行求解时,问题变得愈发扑朔迷离了,极具迷惑性。我们曾不止一次提到动态规划是算法领域采用求解模板的典范,所以说这个计算方向的问题,也一定是有规可循的。
其实,我们只要把握住动态规划的核心概念,这个迷惑行为就能迎刃而解了。那么核心问题是什么?其实就是子问题之间的状态依赖,即当前子问题的计算,是要依赖于其它子问题计算得出的。
让我们再仔细分析一下这个“最长回文子序列”问题。根据当时的备忘录定义,由于最终需要的答案存放在 $DP[0][n-1]$ 中,因此需要从最右下角反向推导:$DP[i][j]$ 需要的是其左侧 $DP[i][j-1]$、左下角 $DP[i+1][j-1]$ 以及正下方 $DP[i+1][j]$ 的值来满足上述状态转移方程。
以下红色虚线框,就是最后一次子问题求解的计算过程,$DP[0][7]$ 依赖于 $DP[0][6]$、左下角 $DP[1][6]$ 和正下方 $DP[1][7]$ 这几个子问题,求得了最后问题的答案。但是从代码上可以看出,我们的计算是从右下角斜向左上角计算的,具体参见下图:
<img src="https://static001.geekbang.org/resource/image/b1/16/b127667d89e44cd5316e2d9b93ba3916.png" alt="">
从图示也可以看出,我们的计算的确是斜向左上角计算的。当然,这只是解决“最长回文子序列”问题时的计算方向。如果遇到新问题时,我们该怎么办呢?把握住以下几点即可:
1. 遍历的过程中,所需的状态必须是已经计算出来的。比如在上面这个问题里,$DP[6][7]$ 未知,如果要求它,就需要依赖已经计算出来的子问题答案:$DP[6][6]$、$DP[7][6]$ 和 $DP[7][7]$
1. 遍历的终点必须是存储结果的那个位置。比如在上面这个问题里,最终计算方向要到达的终点就是 $DP[0][7]$。
其实,我们主要就是看需要依赖哪些子问题,以及最终结果的存储位置。
现在,你应该理解了最优子结构与计算方向之间的重要关系了。对于计算方向的最终确定,它与最优子结构和状态转移方程的设计有着直接关系,依据每个人的求解习惯不同而不同,因此需要通过练习来灵活掌握。
## 课程总结
动态规划只能应用于有最优子结构的问题。所谓最优子结构,就是指局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,就是问题能够分解成子问题来解决。
最优子结构规定了子问题与原问题的关系,最终指导我们写出状态转移方程。与此同时,它还指导了具体代码实现的计算方向。
在本专栏中,计算方向是最后一个提及的内容,但它却十分重要。考虑好正确的计算方向,才能写出正确的循环迭代代码。而对于计算方向的确定,我们主要看需要依赖哪些子问题,以及最终结果的存储位置就可以了。
## 课后思考
这是一个叫做“编辑距离”的动态规划问题:给你两个单词 text1 和 text2计算出将 text1 转换成 text2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:
1. 插入一个字符;
1. 删除一个字符;
1. 替换一个字符。
```
示例:
输入text1 = &quot;giraffe&quot;, text2 = &quot;irbff&quot;
输出3
解释1giraffe -&gt; iraffe (删除 'g')
2iraffe -&gt; irbffe (将 'a' 替换成 'b')
3) irbffe -&gt; irbff (删除 'e')
```
请你看一下这个问题,然后分析一下里面的最优子结构。
欢迎留言和我分享,我会第一时间给你反馈。如果今天的内容让你对动态规划算法设计的关键有了进一步的了解,也欢迎把它分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,87 @@
<audio id="audio" title="14面试即正义第三期刷题指南熟能生巧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/f1/eb5yy66df1453c9yy9e2b493ec2aebf1.mp3"></audio>
你好,我是卢誉声。
自从给出了动态规划的解题模板后,我们就一直沿着其既定的套路在处理各式各样的动归问题。这其实印证了我们在专栏开头所说的一句话:动态规划问题简直就是模板、套路届的典范。
学到今天,其实我们已经对动态规划进行了较为全面的经验式总结,也对技术面试会重点考察的题型进行了深入分析和讲解。这些经验总结在 90% 以上的情况下都是有效的,易于理解,而且十分适合用来应对面试。
诚然,我们在整个专栏中所掌握的解题思路、技巧和最重要的解题模板十分有效而且实用。但是,一定程度的练习还是有必要的,正所谓熟能生巧。而且,动态规划问题总共就那么多,只要稍加练习,相信你就能够轻松攻破技术面试中的动归难关。
在今天的课程里,我将对动态规划题目进行了分类,从难度、类型等维度进行总结。同时,给出刷题建议。话不多说,我们开始吧。
动态规划题目总结表格见链接(请关注题目链接的同学点击此处进行查看):[https://shimo.im/sheets/hrHvGxvRD3xxvvGD/SZhqW](https://shimo.im/sheets/hrHvGxvRD3xxvvGD/SZhqW)
## 线性问题
一般来说,线性问题是动态规划中最为基础的一类动态规划问题。这是因为其主要特点就是按照我们常说的动态规划概念,即按照问题的规模从小到达进行推导,较大的子问题依赖于较小的子问题 当前决策得出。因此,这是一类非常凭直觉且容易求解的动归问题。
虽然我们这么说,但是基础不意味着容易,我在文稿中的表格里列出了常考的题目。这些题目来源于互联网,主要是 LeetCode。对于题目难度的标注是根据我个人刷题经验总结而来的因此可能会跟网络上标注的难度有些出入希望这份表格能对你有所帮助。
<img src="https://static001.geekbang.org/resource/image/05/91/05fceed4a847031011fa4yy89a5f3e91.jpg" alt="">
在初学动态规划时,通过不断练习线性类型的动态规划问题可以不断加深理解,之后再学习其它类型的动态规划问题就会变得容易许多。
## 区间问题
除了线性问题以外,还有一种特别容易在技术面试环节考察的题目类型:区间类型问题。事实上,我们在专栏的课程中已经对这类问题做了深入探讨,比如“最长回文子序列”问题就属于这类区间类型的问题。
那么什么是区间呢?从“最长回文子序列”问题就可以看出,当时我们使用了状态参数 $i$ 和 $j$ 共同定义了字符串或数组上的一个区间,通过算法计算游走于数组之上,最后根据状态转移方程完成整个问题的推导。因此,所谓区间问题,就是使用多个状态参数来约束数据结构访问的范围,其中区间用两个端点表示。
现在,让我们来看看有哪些区间类型的题目值得练习。
<img src="https://static001.geekbang.org/resource/image/9f/45/9fafaf6b5dc17774208138abebdfaf45.jpg" alt="">
到这里你可能会有疑问,那就是单个字符串或数组的问题不也存在区间吗?比如在最长上升子序列问题中,我们明明就可以用一个 $DP[i]$ 来存储计算的子问题答案。其实,这里面是有显著区别的。
对于单个字符串或数组的问题来说,它其实隐含了区间的起始位置,因为每个子问题的起始位置都是 0因此我们通过降维实现了只需要一个状态参数的计算。在这种情况下我们可以考虑它是一个线性问题。
但对于区间类型问题来说,一般我们会定义 $DP[i][j]$,表示考虑 $[i…j]$ 范围内的元素,原问题的解增加 $i$、减小 $j$ 都可以得到更小规模的子问题,状态转移是按照区间长度由短到长进行推导的。比如“最长回文子序列问题”,其原问题的最终答案可能不是存储在以 0 为起始位置的子序列当中的,正因为我们需要明确的计算出该问题的起始位置,因此状态参数 $i$ 不能被忽略。在这种情况下,我们需要将其考虑成区间类型的动态规划问题。
由于状态参数的增多,导致了状态存储,即备忘录的维度的增加,势必会提高写出算法代码的难度。通常来说,在填充高维备忘录时,你都需要小心**计算方向**这个东西。
## 背包问题
在专栏中,我曾耗费了不少篇幅深入讲解了背包问题,如果你对其有所遗忘,不妨回看[第6课](https://time.geekbang.org/column/article/290092)和[第7课](https://time.geekbang.org/column/article/291638)的内容。简单地说,背包问题是一种组合优化的 NP 完全问题。简单的背包问题包括硬币找零而稍微复杂一些的就包括0-1背包问题、完全背包问题和多重背包问题。
几乎所有的背包问题都可以概括成这样一句话:给定一系列物品,每种物品都有自己的重量和价值两个参数。此时,给定一个有重量上限的背包,求如何选择才能使得物品的总价值最高。
只不过,在技术面试环节,即便考察了背包问题,往往考察的也是其变种。我们曾在[第6课](https://time.geekbang.org/column/article/290092)就提出过一个粉碎石头的问题,那就是一个背包问题的变种。
现在,我给出你值得关注和练习的背包问题。
<img src="https://static001.geekbang.org/resource/image/d4/92/d4c31c473988620ba033297c96cc2992.jpg" alt="">
对于0-1背包、完全背包和多重背包的问题在 LintCode 上有所体现。我的建议是根据专栏的课程多加练习,如果你能在遇到这类问题时轻松写出状态转移方程,就更好了。
## 方案总数问题
现在,让我们来看看什么是方案数类型的问题。其实最容易想得到的就是我们曾在[第10课](https://time.geekbang.org/column/article/293536)中讲解的问题。其中“简单的路径规划”和“带障碍的路径规划”就属于方案数的问题,其原问题要求解的答案就可以是有多少种可行路径。
求方案总数的问题和求可行性True或False的问题是可以相互转化的。举个例子在早期讨论的硬币找零问题当时我们要求的是最少需要几枚硬币凑出总金额。
但那个问题显然存在无法用硬币凑出解的情况(至于这种情况,原问题要求返回 -1。因此如果我们把原问题修改成你能否用最少的硬币凑出总金额这样就变成了一个求可行性问题了。
现在,我给出你值得关注和练习的问题。
<img src="https://static001.geekbang.org/resource/image/e9/52/e98b88732b86187beed4544742235952.jpg" alt="">
## 复杂问题
最后,我列出了常考的较为复杂的动态规划面试问题,这些问题确实比较困难,但却常常出现在技术面试环节中。因此,你应该花一些时间仔细研读这几道题目,就足以应对复杂的动归技术面试了。
<img src="https://static001.geekbang.org/resource/image/f2/49/f210ac588945452192a4c1aa3393e949.jpg" alt="">
## 课程总结
在本课中,我列出了在技术面试环节常考的高频动态规划问题。你可以根据题目的类型,难易程度,逐步推进你的“刷题”进程。就像我在开头说的那样,虽然动态规划解题模板非常管用,但一定的练习还是有必要的。
这里列出的题目确实比较多,但我也不希望你去把每道题都解一遍。最重要的还是要掌握以及运用我们从专栏开课就给出的动态规划解题模板。学习并培养解题思路,养成思考的习惯,这才是本课最重要的目的。同时,你也可以将这节课列出的题目当作一个参考文档,它几乎就是你能看到的最全面的动态规划面试问题总结了。
我相信你能够通过这些练习,进一步加深对动态规划的理解,彻底攻破最难技术面试问题这一关!
## 课后思考
你是否遇到过本课中尚未列出的动态规划技术面试问题?如果有,请列出它,不妨与大家一起分享,共同进步。
期待你的留言。如果今天的内容对你有所帮助,也欢迎把文章分享给你身边的朋友,邀请他一起练习!

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="15课程回顾与总结" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/df/6588630e3a64283c39a03c3afa7e91df.mp3"></audio>
你好,我是卢誉声。
当你看到这里的时候,说明你已基本学习完了我们的整个专栏。在经过一系列的课程之后,你应该已经对最难技术面试问题——动态规划,有了较为全面的认识,并且知道该如何去解决一些经典的问题和这些问题的变种。
话说回来,无论你是按部就班学习完整个专栏,还是有所挑选地进行阅读,我都建议你认真读完本节课和下节课的内容。就如标题所写,这两节课主要是对整个专栏的内容进行回顾与总结,会涵盖有关动态规划的重要内容。
如果你认真学完了本专栏的所有内容,掌握了动态规划的基本概念,能够熟练解决动态规划的经典问题,你仍然可以通过这两节课来进行查漏补缺;而如果你在之前的课程中还是有所疑惑,那么你还可以利用总结中的梳理来掌握动态规划最核心的知识点和内容,再回去认真学习相关内容,我相信你会有更多收获。
好了,现在就让我们开始吧。
## 从贪心算法到动态规划
### 1. 贪心算法
在详细讲解动态规划之前,我们势必要从贪心算法说起。
无论是使用动态规划还是其它的任何算法,核心目的就是求出问题的最优解,只不过具体的思路和方法技巧会有所区别。在解决任何最优解问题的时候我们基本都绕不开一组概念,就是**局部最优解**和**整体最优解**。
- 局部最优解:针对一定条件或环境下的一个问题或目标,是部分解决该问题决策中的最优决策,也就是一个问题的解在一定范围或区域内最优,或者说解决问题或达成目标的手段在一定范围或限制内最优;
- 整体最优解:针对一定条件或环境下的一个问题或目标,是所有解决该问题决策中的最优决策。
也就是说,整体最优解一定是一个局部最优解,而局部最优解则不一定是整体最优解。同时,由于局部最优解追加了一些限定的条件,可以降低解决整个问题消耗的空间和时间上的资源。而我们熟知的**贪心算法**就是一种经典的求解“局部最优解”的算法。
现在,我们回顾一下之前所学的硬币找零问题。
问题:给定 n 种不同面值的硬币,分别记为 c[0], c[1], c[2], … c[n],同时还有一个总金额 k编写一个函数计算出**最少**需要几枚硬币凑出这个金额k每种硬币的个数不限且如果没有任何一种硬币组合能组成总金额时返回 -1。
```
示例:
输入c[0]=1, c[1]=2, c[2]=5, k=12
输出3
解释12 = 5 + 5 + 2
```
在这个问题中,有一个显而易见的思路,硬币中大面值的数量越多,那么最后所需的硬币肯定数量越少。因此,我们应该从面值最大的硬币开始尝试,尽量多地使用面值更大的硬币。只有当面值更大的硬币无法凑出所需总额时,我们才使用面值更小的硬币。下面是一个例子:
<img src="https://static001.geekbang.org/resource/image/ce/c1/ceb72949a6442d62f70557d2500455c1.jpg" alt="">
我们从 c[0]=5, c[1]=3 且 k=13 的情况下寻求最少硬币数。按照“贪心原则”,我们先挑选面值最大的,即为 5 的硬币放入钱包。接着,还有 8 元待解即13-5 = 8。这时我们再次“贪心”放入 5 元面值的硬币。这个时候我们就只剩下 3 元了,我们再放入 3 就可以凑足 13 元了。而且这肯定是面值最少的解法。
但是这种贪心是无法解决所有问题的,比如假设我们现在需要在 c[0]=5, c[1]=3 且 k=11 的情况下寻求最少硬币数。如果按照同样的原则,选择了两枚 5 元硬币后就只剩下 1 元了,如下图所示:
<img src="https://static001.geekbang.org/resource/image/dd/42/dd8c895ba29a85745c342902f1c19a42.jpg" alt="">
如果这样,岂不就没有解了!
但是这个问题其实是有解决方案的,之所以会出问题是因为我们“太贪心”了,因此我们需要通过引入“回溯”的方法来解决这个问题。
如果我们把第 2 步放入的 5 元硬币取出,放入面值为 3 元的硬币试试看。这时,你就会发现,我们还剩 3 元零钱待找。这个时候如果再放入 3 元硬币,那么问题是不是就迎刃而解了?
<img src="https://static001.geekbang.org/resource/image/d7/19/d79357449a12893b4bb8ae682d3c6619.jpg" alt="">
所以,纯粹的贪心算法是一种通过**既定贪心策略**寻找局部最优解的方法。虽然贪心方法是一种简单直接、易于理解的方法,但贪心本身其实只能得到局部条件下的最优解,如果想要找到真正的最优解——整体最优解,我们势必要加入**回溯**和**递归**的过程。具体的解题代码可以参见[第1课](https://time.geekbang.org/column/article/285230),这里就不再赘述了。
但是,如果我们仔细一想就会发现,如果使用了回溯和递归,这个问题本质上就会变成一个**枚举**的问题,我们就是枚举出所有的可能解,然后从中选择一个满足要求的最优解。
比如我们看一下 c[0]=5, c[1]=3 且 k=25 这种情况,整个求解过程我们可以画成下面这棵树。
<img src="https://static001.geekbang.org/resource/image/72/52/72ebd3b1c6e415ce90efc44dcde8e752.jpg" alt="">
比如,我们先尝试放入 5 元的硬币,这个时候余额就剩下了 20 元。然后再放入 5 元,变成 15 元……以此类推,如果我们使用递归算法把所有叶子节点全部计算出来,也就求得了这个问题的所有解。然后再从这些解中求出最小的硬币数量即可。
我们可以想象到,如果要求解整棵树,需要的**时间复杂度则非常高**,假设一共有 C 种面值,综合为 K那么最坏情况下的时间复杂度就是 **C<sup>K</sup>**,这种指数级别的时间复杂度在处理实际问题的时候肯定是会出问题的!
所以我们需要想方设法优化整个求解过程,减少求解的时间复杂度。我们的基本思路是如何减少这颗树的分支数量。如果分支数量减少了,递归效率也就高了。这就是所谓的**剪枝优化**。
贪心就是我们的一种剪枝优化思路,比如假设我们遵从优先使用最大面值的原则,那么这个问题的求解路径就会变成如下图所示:
<img src="https://static001.geekbang.org/resource/image/38/4b/387775a810499702810f3b4dd9ba284b.jpg" alt="">
这样我们通过四步就得到了最优解!
但是我们也能看到,贪心算法**只能解决特定条件下的剪枝问题。**针对一些情况,贪心可能并不能起到剪枝的作用。比如如果 c[0]=5, c[1]=3 且 k=12这种情况下显而易见只能使用 4 枚 3 元硬币,如果我们继续采用贪心的思路,完全无法起到任何剪枝的作用,我们只能在尝试完最大的路径之后,不得不去使用最小的路径,这样时间复杂度无法有任何降低。
### 2. 重叠子问题与备忘录
因此,为了解决普遍情况下的剪枝问题,我们必须采用另外的思路来进行优化。这个思路就是**备忘录**,后来我还常用**状态存储**来指代备忘录。
我们可以仔细分析一下上面的求解图和求解路径,你会发现在余额相同的情况下,后面的搜索路径是完全一致的!
<img src="https://static001.geekbang.org/resource/image/b9/8f/b9a639f1644956e3f37723e043667e8f.jpg" alt="">
比如,我们看到图中圈出来的两个部分,因为余额都是 12 元,所有后续的求解路径和结果是完全相同的。既然余额确定的时候后续的求解路径和结果是完全相同的,那么最优解也是确定的。
所以我们可以将一个大问题划分成多个子问题。比如可以把求解 12 元的硬币数量理解成求解 25 元的硬币数量的一个子问题。在求解 25 元硬币过程中,会有很多种情况都要求解 12 元硬币的最优解。
这里我们就可以提出一个概念——**重叠子问题**。所谓重叠子问题,就是在大问题的求解过程中会重复求解的小问题。既然重叠子问题是在求解过程中会重复计算的,那么我们是否可以**消除这些重复计算的过程**呢?显然,这些就是我们可以优化的出发点,然后再通过这个思路进行优化。这个时候我们就可以采用备忘录的方法来进行优化。
首先,我们需要明确一下求解硬币问题时的子问题,假定求解硬币问题的函数是 F(x),表示拼凑 x 元硬币所需的最少硬币数量。这样我们现在要求解的问题是 F(25),然后 F(25) 中包含了需要重复求解的子问题 F(12)。子问题的定义如下所示:
$$F(x)=\left\{\begin{array}{c}<br>
min(f(x-c)+1), x&gt;0,f(x-c)\ne-1,c\in C\\\<br>
0,x=0\\\<br>
-1,x&lt;0<br>
\end{array}\right.$$
明确了需要求解的问题和子问题后,我们就可以根据参数来缓存重叠子问题的解。这是什么意思呢?
我们可以创建一个数组 memo使用 memo[i] 存储子问题 F(i) 的解。然后我们自顶向下求解。以上图为例,首先求解 F(25),然后求解 F(15) 和 F(17)。在子问题 F(15) 中再求解子问题 F(12) 和 F(10),在子问题 F(17) 中求解子问题 F(14) 和 F(12)。
每次计算完一个子问题后,就将 F(i) 存储在 memo 数组的第 i 个位置,在计算子问题 F(i) 之前,检查一下备忘录,如果备忘录中有结果,就直接返回;否则就重新计算这个子问题。如下图所示:
<img src="https://static001.geekbang.org/resource/image/d1/6d/d170yy76527b748fdd376a77d85a766d.jpg" alt="">
我们可以看到,在红色线框 1 这一步,由于我们已经计算了 F(12) 的解。因此,在红色线框 2 这一步,再计算 F(12) 解的时候就可以使用在红色线框 1 中缓存下来的计算结果,而不需要再次重复计算了。具体代码可以看[第3课](https://time.geekbang.org/column/article/287199)中使用备忘录实现的硬币找零问题的代码。这样一来我们就通过备忘录解决了计算重叠子问题的重复计算问题,极大提升了计算的速度。
### 3. 迭代与动态规划
当我们使用自顶向下的方法求解的时候,我们需要采取的方法就是**递归**。众所周知,递归是一种比较直观的方法,比如在硬币找零问题中,由于我们子问题的定义形式就是递归函数,因此如果采用递归实现与问题定义对照的时候会非常容易理解。比如硬币找零的递归求解过程如下图所示:
<img src="https://static001.geekbang.org/resource/image/0e/fc/0e7cf5f55a9fc461b51036b01245f6fc.png" alt="">
但是,递归也存在着自身的问题。第一个问题就是**性能**,每次递归必定会产生函数调用,而如果我们学过函数调用的实现方式,就会知道大部分现代语言的函数调用是基于栈的,基于栈的函数调用肯定会产生额外的时间开销和空间开销,因此在复杂问题中,由于递归树的分支众多,而且递归的调用层次非常深,会带来的额外消耗其实是需要我们考量的。
第二个问题就是**调试**问题,复杂问题的递归代码一旦出错其实是很难调试的,这个只要有编程经验的人应该都会知道这一点。
为了避免这些问题,我们就可以考虑从求解顺序上来解决这个问题。在自顶向下的方法中,我们是将大问题不断拆解成多个小问题,然后再对各个小问题依次求解。那么如果我们可以预知在处理每个大问题之前,必须要求解哪些小问题,我们是不是就可以先求解所有的小问题的解,然后再求解大问题的解。如下图所示:
<img src="https://static001.geekbang.org/resource/image/2c/00/2cf1ee93d94166yy97a3eb1yy6ff1c00.png" alt="">
于是,我们思索一下这个过程,在问题具备什么性质的前提下,我们可以这样**自底向上**求解呢?
其实很简单,如果子问题之间的**依赖关系是单向的**,也就是如果子问题 A 直接或者间接依赖于子问题 B 的时候,子问题 B 不会直接或间接依赖于子问题 A而且每一个子问题的参数的顺序是可以通过既定规则定义的那么我们就可以直接自底向上进行求解。
比如对于硬币找零问题F(25) 由 F(20) 和 F(22) 的最优解组成,那么 F(22) 和 F(20) 不会依赖于 F(25)(这个问题里是显而易见的),那么这种情况我们就可以先计算 F(20) 和 F(22),然后再计算 F(25)。
只不过由于我们是通过备忘录来存储计算结果,因此在计算 F(25) 之前需要先计算完 F(0) 到 F(24),然后才能计算 F(25)。这是因为,虽然我们求解的是 F(25),但是在求解 F(25) 之前,其实我们并不知道 F(25) 依赖于哪些子问题。但是我们可以知道的是F(25) 依赖的子问题肯定在 F(0) 到 F(24) 之中,因此可以直接先求解 F(0) 到 F(24),最后求解 F(25)。如下图所示:
<img src="https://static001.geekbang.org/resource/image/d5/fa/d5b99655a7507524b096d90523dc85fa.jpg" alt="">
这个时候我们就会发现,这个数组其实就是我们的备忘录数组,我们只需要按照备忘录数组的顺序依次求解每个子问题,而且每个子问题依赖的子问题肯定会在遇到这个子问题之前求解完毕。
现在,之前的递归问题被我们转换成了一个迭代的问题,现在我们只需要通过循环求解 F(0) 到 F(25) 即可。具体可以参见[第4课](https://time.geekbang.org/column/article/287406)的相关代码。
我们看看从一开始分析问题到现在,我们干了哪些事情:
- 定义问题模型;
- 分析依赖关系;
- 定义备忘录结构;
- 定义计算顺序;
- 编写代码。
而这几步其实就是我们通过**动态规划**方法来求解的过程。没错,这种通过循环实现的自下向上的求解过程就是所谓的动态规划。
在这种解题思路中,如果面值的数量是 C需要求解的金额是 K那么我们的计算复杂度就是O(C * K)K 是因为我们需要从 F(0) 求解到 F(K)C 是因为在每次求解的时候我们都需要求解C 种面值产生的子问题,并求出最大值。除此之外不会有任何的性能开销。
这样一来,我们就成功地将指数级别的时间复杂度,降低成了多项式级的时间复杂度。这个就是动态规划带来的性能提升,秒啊!
## 动态规划详解
在掌握了如何使用标准的动态规划来解决硬币找零问题后,我们有必要来详细分析一下动态规划的一些特性,也就是可以通过动态规划求解的问题具备哪些特征。接着看看解决动态规划面试问题的通用框架。
### 1. 动态规划问题特征
首先,标准的动态规划问题一般包含下面三个特征,分别是:
- 重叠子问题:在穷举的过程中(比如通过递归),存在重复计算的现象;
- 无后效性:子问题之间的依赖是单向性的,某阶段状态一旦确定,就不受后续决策的影响;
- 最优子结构:子问题之间必须相互独立,或者说后续的计算可以通过前面的状态推导出来。
首先看一下**重叠子问题**。在硬币找零问题的递归求解过程中,我们发现一个子问题可能会被同时求解很多遍,这种会被重复计算的子问题就是重叠子问题。如果一个问题可以被分解成子问题,但是没有会重复计算的子问题,那么也就没有必要使用动态规划了。
然后看一下**无后效性**。我们在解决硬币问题时看到了我们之所以能够使用循环来求解目标问题,就是因为子问题之间的依赖是单向的,也就是如果子问题 A 被求解后,在求解后续的子问题的时候永远不会影响子问题 A 的解。换言之如果子问题A直接或者间接依赖于子问题 B 的时候,子问题 B 不会直接或间接依赖于子问题 A。这样一来我们根据备忘录的定义顺序依次计算完整个备忘录后就可以确保能够计算出正确的最优解。
最后需要讨论一下**最优子结构**。
动态规划首要解决的是“最”优解问题(最大值和最小值),即从很多解决问题的方案中找到最优的那一个。而求最优解问题的核心其实就是穷举,把一个大问题分解成多个子问题,然后递归地找到每个子问题的最优解。最后,通过算法将每个子问题的最优解进行组合,得出原问题的答案。
因此,我们要求的原问题的最优解,是由它的各个子问题的最优解决定的。而动态规划能否成功实施,就取决于我们能否将子问题的答案,通过某种方法进行组合,进而得到原问题的答案。
那么,我们再思考一个问题,即对于每个子问题,它拥有最优解的条件又是什么呢?这就要继续考察子问题是否具有无后效性,即子问题与子问题之间没有前后依赖关系,它们是相互独立的。
综上所述,所谓最优子结构,就是指原问题中各个子问题是否存在最优解,而子问题是否存在最优解的关键是它们之间相互独立。通过子问题求得最终答案的过程,我们用状态转移方程来进行描述。
所以说,无论是重叠子问题、无后效性还是最优子结构都是某些问题的一些特定性质,并不是动态规划专有的特征。其实,很多问题可能具备了其中的某些特征,但是如果不同时具备其它特征的时候,我们就不把它们归为动态规划求解的范畴。
在我们求解动态规划之前,总会优先判定原问题是否符合动态规划三大特征,这么做是有必要的,特别是确定原问题是否存在最优子结构。这是因为,动态规划解法总是从初始化状态向更复杂的子问题进行推导的,而最优子结构正是证明这种推导关系的重要证据。明确最优子结构后,无论是重叠子问题还是无后效性看起来都迎刃而解了。
因此寻找最优子结构的过程,就是证明状态转移方程正确性的过程。只要写出状态转移方程,我们实现求解的目标就完成了一大半。接着我们就可以分析一下重叠子问题,并且判定计算方向,确定子问题求解是无后效性的。
重新整理了动态规划的问题特征后,我们接着复习一下动态规划的解题框架。
### 2. 动态规划解题框架
动态规划的解题步骤是非常套路化的,虽然每一步可能都需要依靠经验和灵感,但是步骤本身是固定的。下面我们再来看一下动态规划的解题步骤,巩固已经学习的知识。
根据之前学习的动态规划的知识,我们知道动态规划的关键在于写出状态转移方程,只要我们能够写出状态转移方程,编写代码就不是什么难事了。当然编写代码的时候一些技巧还是要依靠练习来积累的。但是,只要写出状态转移方程了,动态规划的 80% 的解题工作也就完成了。
为了写出状态转移方程,这里我以硬币找零问题作为例子,我们需要确定以下几点:
- 初始化状态:由于动态规划是根据已经计算好的子问题推广到更大问题上去的,因此我们需要一个“原点”作为计算的开端。在硬币找零问题中,这个初始化状态是 memo[0]=0
- 状态参数:找出子问题与原问题之间会发生变化的变量。在硬币找零问题中,这个状态只有一个,就是剩余的目标兑换金额 k
- 状态存储因为状态参数只有一个参数k因此我们需要一个备忘录 memo[k+1],其中 memo[k]表示兑换k元所需的最小硬币数
- 决策与状态转移:改变状态,让状态不断逼近初始化状态的行为。在硬币找零问题中,挑一枚硬币,用来凑零钱,就会改变状态。
最后,我们需要实现决策。在硬币找零问题中,决策是指挑出需要硬币最少的那个结果。接着就是状态转移方程:
$$DP(n)=\left\{\begin{array}{c}<br>
0,n=0\\\<br>
-1,n&lt;0\\\<br>
min(DP(n), 1+DP(n-c)), c \in values<br>
\end{array}\right.$$
基本上,通过这几步模板化的神操作,我们就能写出状态转移方程了。这几个特定步骤就是解题的经验,对于大部分的问题基本都是有效的。剩下的就是需要多看几类经典的动态规划问题并且尝试解决这些问题了,通过练习积累每一步的解题经验,以做到熟能生巧。
到这,总结与回顾还未结束,下节课我们就会具体总结几类经典的动态规划问题了。

View File

@@ -0,0 +1,367 @@
<audio id="audio" title="16课程回顾与总结" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/54/afc6a78c5867af0c1b7efc654d3d3d54.mp3"></audio>
你好,我是卢誉声。今天我们来继续课程总结,重点回顾几类经典的动态规划问题,并尝试使用我们的解题框架去解决它们。这几类问题我们前面都详细讲过,再带你巩固一遍。
## 经典的动态规划问题
动态规划的问题主要分为三类:
1. 求最优解(最大值和最小值):从一系列方案中寻找最优解决方案;
1. 求方案总数:计算满足要求的解决方案的数量;
1. 求可行性True或False确定提出的问题是否存在可行方案。
下面我们分别来看看这几类问题的代表性问题。
### 1. 背包问题
首先我们来看下背包问题,背包问题是一类非常经典的最优化问题,一般都是希望得到背包可以容纳的最大物品价值,是**求最优解**的问题代表。
背包问题有很多类,常见的背包问题有**0-1背包**、**完全背包**、**多重背包**等,再多就是在这些问题上的变种和延伸。但是无论是什么问题,基本都逃不过一个标准的题目描述模板。
问题:给你一个可放总重量为 W 的背包和 N 个物品,对每个物品,有重量 w 、价值 v 和数量3个属性那么第 i 个物品的重量为 w[i],价值为 v[i]数量为k[i]k≥1。现在让你用这个背包装物品问这个背包最多能装的价值是多少
让我们来将几类背包问题对号入座,套入这个模板里面。
首先是**0-1背包**所谓0-1背包就是每个物品最多只能选择1个所以要不选择 0 个,要不选择 1 个因此我们称之为0-1背包。所以0-1背包也就是说所有物品的数量 k[i] 都为 1。
接着是**完全背包**,所谓完全背包就是每个物品的数量都是无限的,这种情况下我们可以认为物品的数量 k[i] 都是正无穷,或者认为 k[i] 最大值为 W/w[i],其中 W 是背包的容量w[i] 是物品的重量,背包里的物品数量是不可能大于 W/w[i] 的。
最后就是**多重背包**,所谓多重背包就是给予每个物品固定的数量,也就是指定了 k[i]。这也就是问题模板本身。
所以无论是0-1背包还是完全背包其实都是**多重背包的特例**,只要我们知道背包问题的模板是什么样子,然后就可以知道解决所有背包问题的套路。
下面我们根据动态规划解题框架来给出背包问题的解题框架。
首先,我们确定**初始化状态**。当背包的容量为 0 时肯定无法选择物品,此时物品数量为 0同时如果压根儿就没有物品可选那么自然背包的重量也为 0。也就是当没有物品时重量为 0而重量为 0 时显然物品数量也为 0。
接着,我们确定**状态参数**,也就是会影响我们进行决策的变量:
- 背包内物品的数量 N 在增加,它是一个变量;
- 同时,背包还能装下的重量 W 在减少,它也是一个变量。
因此,当前背包内的物品数量 N 和背包还能装下的重量 W 就是这个动态规划问题的状态参数。
然后,我们再来看如何进行**决策**。这里的区别由于每种物品的数量为k[i],因此我们可以将同一种物品多次放入背包。
因此,对于第 tn 种物品,我们有 k 种选择(其中 0 ≤ k[tn] * w[tn] ≤ W我们可以从 0 开始,拿第 0 件、第 1 件、第 2 件……直到第 (W / w[tn]) 件物品为止。然后在这么多子问题下,选择最优的那一种情况。
所以,我们可以看出,背包问题决策的核心在于,针对一种物品,它需要考察拿不同数量的情况下的最优解。也就是针对当前物品,应放入多少件当前物品,价值最大。
最后,动态规划是需要一个**备忘录**来加速算法的。由于有两个状态参数,因此我们考虑使用二维数组来存储子问题的答案。跟之前一样,为了通用起见,我将其命名为 DP[tn][rw],它的含义是:背包容量还剩 rw 时,放入前 tn 种物品时的最大价值。
现在,我们根据解题分析写出通用的背包状态转移方程。它是这样的:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max\{ DP(tn-1,rw-k*w[tn])+k*v[tn] \},(0\leqq k \leqq min(k[tn], rw/w[tn]))<br>
\end{array}\right.$$
我们对号入座根据这个通用的状态转移方程来看看0-1背包和完全背包的状态转移方程。
首先是0-1背包在0-1背包中 k[tn] 固定为 1因此如果将 k[tn] 替换为 1方程可以简化为
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max\{ DP(tn-1,rw-k*w[tn])+k*v[tn] \},(0\leqq k \leqq min(1, rw/w[tn])) \\\<br>
\end{array}\right.$$
同时,由于 k 其实只有两种取值,一种是 0一种是 1。如果 k 为 0表示不放入该物品因此 DP(tn,rw) 的结果为 DP(tn-1,rw),如果 k 为 1表示放入该物品因此 DP(tn,rw) 的结果为DP(tn-1, rw-w[tn])+v[tn],我们需要从中取最大值,因此 DP(tn,rw) 的最终结果就是两者最大值,所以最后方程可以被简化成如下所示:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, &amp; tn&lt;=0\\\<br>
0, &amp; rw&lt;=0\\\<br>
DP(tn-1,rw), &amp; rw&lt;w[tn]\\\<br>
max(DP(tn-1,rw-w[tn])+v[tn], DP(tn-1, rw)), &amp; otherwise \\\<br>
\end{array}\right.$$
接下来是完全背包问题。完全背包问题中只不过将k[i]变成了无穷大,对整个方程并没有什么其它影响,因此我们可以直接将状态转移方程改写为:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max\{ DP(tn-1,rw-k*w[tn])+k*v[tn] \},(0\leqq k \leqq rw/w[tn])<br>
\end{array}\right.$$
这里只是将方程中k的最大值调整为 rw/w[tn],这是因为每个物品有无穷多个,所以到底最多能放下多少物品取决于背包的剩余重量除以物品重量。背包的模板代码如下所示。
Java 实现:
```
int bag(int[] w, int[] v, int[] k, int N, int W) {
// 创建备忘录
int[][] dp = new int[N+1][W+1];
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
int maxK = Math.min(k[tn], rw / w[tn]);
// 根据rw尝试放入多次物品从中找出最大值作为当前子问题的最优解
for (int k = 0; k &lt;= maxK; k++) {
dp[tn][rw] = Math.max(dp[tn][rw], dp[tn-1][rw-k*w[tn]] + k*v[tn]);
}
}
}
return dp[N][W];
}
```
C++ 实现:
```
int DP(const std::vector&lt;int&gt;&amp; w, const std::vector&lt;int&gt;&amp; v, const std::vector&lt;int&gt;&amp; k, int N, int W) {
int dp[N+1][W+1]; // 创建备忘录
memset(dp, 0, sizeof(dp));
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
int maxK = min(k[tn], rw / w[tn]);
// 根据rw尝试放入多次物品从中找出最大值作为当前子问题的最优解
for (int k = 0; k &lt;= maxK; k++) {
dp[tn][rw] = max(dp[tn][rw], dp[tn-1][rw-k*w[tn]] + k*v[tn]);
}
}
}
return dp[N][W];
}
```
至于如何通过这个模板得到0-1背包和完全背包代码就要看你能不能理解整个模板的思路举一反三自己写出实际的代码了。
### 2. 路径问题
在[第10课](https://time.geekbang.org/column/article/293536)中介绍的路径问题是求解总方案数量的经典代表问题。我们回顾一下这个问题的内容和描述。
问题:一个机器人位于一个 m * n 网格的左上角 (起始点在下图中标记为“开始” ),机器人每次只能向下或者向右移动一步,现在机器人试图达到网格的右下角(在下图中标记为“结束”)。问总共有多少条不同的路径?
<img src="https://static001.geekbang.org/resource/image/fe/7d/febf34cda01dc250ce99796855f0a87d.png" alt="">
上图是一个7 * 3的网格我们希望知道从起点到终点能有多少条不同的路径。
```
示例:
输入m = 3, n = 2
输出: 3
解释: 从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 -&gt; 向右 -&gt; 向下
2. 向右 -&gt; 向下 -&gt; 向右
3. 向下 -&gt; 向右 -&gt; 向右
```
首先确定**初始化状态**。从原问题可以看出,初始化状态是网格的第一行和第一列。网格的第一行永远只能从左侧的格子往前走,第一列永远只能从上方的格子向下走。由于我们只能向右或向下走,因此,第一行和第一列的格子永远只能存在 1 条路径。
接着确定**状态参数**。原问题的状态参数其实就是格子的行数和列数,只要知道行数 i 和列数 j 就能知道这个格子的路径数量。因此,我们得到两个状态参数,分别是代表行数的 i 和代表列数的 j。
然后就要进行**状态存储**。这里我们的状态存储空间是一个二维数组 **DP[i][j],表示第 i 行、第 j 列的路径数量**。你可以通过以下图示加深理解。
<img src="https://static001.geekbang.org/resource/image/9c/4d/9c29ce7cd99036b2d1960c46ba5d064d.png" alt="">
从表格中我们可以看出,第一行和第一列是格子的序号。其中填写了 1 的格子就是初始状态深色的格子就是DP[i][j],表示第 i 行、第 j 列的路径数量。我们可以计算一下,这个格子的路径数量是 DP[i-1][j] + DP[i][j-1]。
现在一切就绪,我们来写**状态转移方程**。
$$DP(i, j)=\left\{\begin{array}{c}<br>
DP[i-1][j] + DP[i][j-1], &amp; if \ i\ne0\ or\ j\ne0 \\\<br>
1, &amp; i=0\ and\ j=0<br>
\end{array}\right.$$
这个状态转移方程由初始状态和后续的状态转移构成。当 i 和 j 为 0 时(假定格子从 0 开始而不是 1 开始),函数结果就是 1否则就是 DP[i- 1][j] + DP[i][j-1]。
现在可以根据状态转移方程写出代码。
Java 实现:
```
int getPathCount(int m, int n) {
int[][] dp = new int[m][n];
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
C++ 实现:
```
int GetPathCount(int m, int n) {
int dp[m][n];
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
在[第10课](https://time.geekbang.org/column/article/293536)中,我还提到了考虑障碍物的路径规划问题,大家可以去回顾一下,和不带障碍物的问题的解决方案其实没有本质区别。
### 3. 跳跃游戏
前面我们回顾了最优解和方案总数的代表性问题现在我们再回顾一下可行性问题True或False。这类问题其实一般涵盖在第一类和第二类的问题解决思路中我们完全可以通过问题的转化将其它类型的问题转化成求可行性问题。
现在我们看一下学习过的代表性问题——跳跃游戏。
题目:给出一个非负整数数组 A你最初定位在数组的第一个位置。数组中的每个元素代表你在那个位置可以跳跃的最大长度。判断你是否能到达数组的最后一个位置。
```
示例1
输入A = [2, 3, 1, 1, 6]
输出: True
解释: 我们可以先跳 1 步,从位置 0 到达位置 1, 然后再从位置 1 跳 3 步到达最后一个位置。
```
首先确定**初始化状态**。这个问题的初始化状态就是 0 这个位置。因为这个位置是出发点,因此肯定可以到达,所以我们可以将其初始化成 True。而对其它位置则可以根据状态转移方程来计算得出。
接着确定**状态参数**,只有数组的位置是变化的,因此状态参数就是当前位置 i。
然后是状态存储,由于只有一个状态参数,因此我们可以使用一维数组 **DP[i] 来表示能否从出发点到达位置 i**
最后确定**状态转移与决策**。如果我们想要知道能否到达位置 i就需要逐个看前面的位置判定能否从位置 i-1、i-2、i-3 ... 跳到位置 i 上。然后,再看 i-1 这个位置是否能够到达。
通过以上分析,我们就可以给出状态转移方程的定义了。
$$DP[i]=\left\{\begin{array}{c}<br>
True, &amp; i = 0 \\\<br>
(DP[j] = true)\ and\ (max(A[j]+j) \geq i), &amp; i \ne 0\ and\ j &lt; i \\\<br>
\end{array}\right.$$
代码如下所示,具体代码以及解释可以参见[第10课](https://time.geekbang.org/column/article/293536)。
Java 实现:
```
public boolean canJump(int[] nums) {
int n = nums.length;
if (n &lt;= 1) { return true; }
boolean[] dp = new boolean[n];
// 初始化状态
for (int i = 0; i &lt; n; i++) { dp[i] = false; }
dp[0] = true;
for (int i = 1; i &lt; n; i++) {
for (int j = 0; j &lt; i; j++) { // j &lt; i
if (dp[j] &amp;&amp; j + nums[j] &gt;= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1]; // 输出答案
}
```
C++ 实现:
```
bool canJump(vector&lt;int&gt;&amp; nums) {
int n = nums.size();
if (n &lt;= 1) { return true; }
bool dp[n]; memset(dp, 0, sizeof(dp));
dp[0] = true; // 初始化状态
for (int i = 1; i &lt; n; i++) {
for (int j = 0; j &lt; i; j++) { // j &lt; i
if (dp[j] &amp;&amp; j + nums[j] &gt;= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1]; // 输出答案
}
```
### 4. 其它问题
除了上面提到的几类代表性问题,还有两类问题是需要你重点关注的,分别是子数组问题和子序列问题。
所谓子数组问题,就是从一个数组中寻找满足条件,并可以得到最优结果的一个最长的子数组序列的问题,子数组序列一定是求一个连续的序列。
而子序列问题要更复杂一些,因为子数组问题是连续的,而子序列问题是不连续的。比如说字符串 "I wanna keep a giraffe in my backyard" 的一种子序列就可以是 "Igbackd"。因此子序列问题往往比子数组问题更加难以找到头绪。
关于子数组问题我们在[第8课](https://time.geekbang.org/column/article/292667)和[第12课](https://time.geekbang.org/column/article/295396)有非常详细的讲解,子序列问题我们在[第9课](https://time.geekbang.org/column/article/293232)和[第11课](https://time.geekbang.org/column/article/294300)中做了详细的陈述,希望你可以重点学习一下这几节课的内容,牢固掌握这些常见的动态规划题型。
剩下还有各种各样的动态规划问题就需要大家自己在各类 OJOnline Judge网站上多多练习然后积累经验让自己可以更加熟练地解决面试中可能出现的动态规划问题了。
## 动态规划的优化
我们现在回归并总结一下动态规划的优化问题。
动态规划的最终目的是降低问题解决方案的时间复杂度,动态规划在缩小问题规模的同时,通过备忘录记录已经求解过的子问题的解,利用备忘录避免对同一个子问题的重复计算,从而减少了冗余。但动态规划往往仍然存在冗余。主要包括求解无用的子问题,对结果无意义的引用等等。
根据动态规划的状态转移方程分析套路,动态规划的时间复杂度受到三个因素影响:
- 状态总数;
- 每个状态转移的状态数;
- 每次状态转移的时间。
时间复杂度 = 状态总数 * 每个状态转移的状态数 * 每次状态转移的时间。
因此,我们通常就会通过减少状态总数、减少每个状态转移的状态数或者减少每次状态转移的时间,来进一步优化动态规划的时间复杂度。
另一方面,我们知道动态规划是一种利用空间换取时间的方法,所以对空间复杂度的优化也是重中之重。影响空间复杂度的因素主要包括状态总数和每个状态需要存储的数据数量。因此我们一般会从状态总数和存储数据数量这两方面进行优化。
这里我们会发现,如果我们能够减少状态总数,一般可以同时减少时间复杂度和空间复杂度,就可以一箭双雕,这就是我们常说的状态压缩。因此我们在初步解决一个问题后会进一步分析问题的状态参数是否冗余,一旦有冗余就需要进行处理,最后得到比较低的时间复杂度和空间复杂度。
除了这些方法,还有一些常用的技巧会用在空间复杂度优化上,比如常见的就是滚动数组,简而言之,一个问题求解的时候可能只会依赖于其前 k 个子问题,这种情况下我们的备忘录就只需要永远保留 k+1 项的结果,然后通过滚动数组的方式利用备忘录。我们在做很多问题优化的时候都使用了这种技巧,你可以回顾一下。
但是无论如何,动态规划的优化是比写出基础的动态规划方程更难的能力,因此需要大家做每一道动态规划题目的时候都尽量去思考问题是否适用于动态规划,是否有多余的状态参数,状态存储空间是否能压缩等等,只有遇到过更多的问题,积累了更多经验,才能在面试中游刃有余。
## 课程总结
动态规划思想是如今技术面试当中特别爱考察的一类重要问题,它出现在技术面中的比重与日俱增。因此,我们有必要有针对性地攻破这一难关,特别是系统的经验总结,外加适当强度的练习。
我在[第14课](https://time.geekbang.org/column/article/296625)总结了刷题指南,希望你能够沿着本专栏的整体思路,去尝试解决那些问题。你最终一定会发现,所有的问题都能够追溯到我为你总结的解题模板上去。刷题的目的在于灵活运用动归解题模板,养成解题思路和习惯,那么你的目标、我的目标,就都达成了。
## 课后思考
除了我今天为你勾勒出的动态规划知识点总结外,你觉得还有什么知识点是值得关注的?不妨谈谈你的心得体会。
期待你的留言。如果今天的内容对你有所帮助,也欢迎把文章分享给你身边的朋友,邀请他一起练习!

View File

@@ -0,0 +1,336 @@
<audio id="audio" title="01 | 硬币找零问题:从贪心算法说起" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/0c/c3838d375ba69266f4f1d003356eba0c.mp3"></audio>
你好,我是卢誉声。
作为“初识动态规划”模块的第一节课,我会带着你一起从贪心算法开始了解整个知识体系的脉络。现实中,我们往往不愿意承认自己贪婪。事实上,贪婪是渴望而不知满足,它是人的一种基本驱动力。既然是基本驱动力,那它自然就不会太难。
所以你可能会说贪心算法很简单啊,但其实不然,这里面还真有不少门道值得我们说说。而且,它还跟动态规划问题有着千丝万缕的联系,能够帮助我们理解真正的动归问题。
接下来我们就从一个简单的算法问题开始探讨,那就是硬币找零。在开始前,我先提出一个问题:**任何算法都有它的局限性,贪心算法也如此,那么贪心算法能解决哪些问题呢?**
你不妨带着这个问题来学习下面的内容。
## 硬币找零问题
移动支付已经成为了我们日常生活当中的主流支付方式,无论是在便利店购买一瓶水,还是在超市或菜市场购买瓜果蔬菜等生活用品,无处不在的二维码让我们的支付操作变得异常便捷。
但在移动支付成为主流支付方式之前,我们常常需要面对一个简单问题,就是找零的问题。
虽然说硬币找零在日常生活中越来越少,但它仍然活跃在编程领域和面试问题当中,主要还是因为它极具代表性,也能多方面考察一个开发人员或面试者解决问题的能力。
既然如此,我们就先来看看这个算法问题的具体描述。
问题给定n种不同面值的硬币分别记为c[0], c[1], c[2], … c[n]同时还有一个总金额k编写一个函数计算出**最少**需要几枚硬币凑出这个金额k每种硬币的个数不限且如果没有任何一种硬币组合能组成总金额时返回 -1。
```
示例 1
输入c[0]=1, c[1]=2, c[2]=5, k=12
输出3
解释12 = 5 + 5 + 2
```
```
示例 2
输入c[0]=5, k=7
输出:-1
解释只有一种面值为5的硬币怎么都无法凑出总价值为7的零钱。
```
题目中有一个醒目的提示词,那就是“最少”。嗯,看起来这是一个求最值的问题,其实也好理解,如果题目不在这里设定这一条件,那么所求结果就不唯一了。
举个简单的例子按照示例1的题设有三种不同面值的硬币分别为c1=1, c2=2, c3=5在没有“最少”这一前提条件下你能罗列出几种不同的答案我在这里随意列出几个
```
解1输出5因为 5 + 2 + 2 + 2 + 1 = 12。
解2输出6因为 2 + 2 + 2 + 2 + 2 + 2 = 12。
解3输出12因为 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 12。
```
所以,这是一个求最值的问题。那么求最值的核心问题是什么呢?嗯,无非就是**穷举**,显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币,那么最少的凑法,就是这道题目的答案。
在面试中,一般来说穷举从来都不是一个好方法。除非你要的结果就是所有的不同组合,而不是一个最值。但即便是求所有的不同组合,在计算的过程中也仍然会出现重复计算的问题,我们将这种现象称之为**重叠子问题**。
请你记住这个关键概念,它是动态规划当中的一个重要概念。但现在你只需要知道所谓重叠子问题就是:我们在罗列所有可能答案的过程中,可能存在重复计算的情况。我会在后续课程中与你深入探讨这个概念。
在尝试解决硬币找零问题前,我们先用较为严谨的定义来回顾一下贪心算法的概念。
## 贪心算法
所谓贪心算法,就是指它的每一步计算作出的都是在当前看起来最好的选择,也就是说它所作出的选择只是在某种意义上的局部最优选择,并不从整体最优考虑。在这里,我把这两种选择的思路称作**局部最优解**和**整体最优解**。
因此,我们可以得到贪心算法的基本思路:
1. 根据问题来建立数学模型,一般面试题会定义一个简单模型;
1. 把待求解问题划分成若干个子问题,对每个子问题进行求解,得到子问题的局部最优解;
1. 把子问题的局部最优解进行合并,得到最后基于局部最优解的一个解,即原问题的答案。
## 解题思路
现在让我们回到这个问题上来。
既然这道题问的是**最少**需要几枚硬币凑出金额k那么是否可以尝试使用贪心的思想来解这个问题呢从面值最大的硬币开始兑换最后得出的硬币总数很有可能就是最少的。
这个想法不错,让我们一起来试一试。
我用一个例子,带你看下整个贪心算法求解的过程,我们从 c[0]=5, c[1]=3 且k=11 的情况下寻求最少硬币数。按照“贪心原则”我们先挑选面值最大的即为5的硬币放入钱包。接着还有6元待解即11-5 = 6。这时我们再次“贪心”放入5元面值的硬币。
<img src="https://static001.geekbang.org/resource/image/20/12/20e9a277444dfcf9362cf1f22106d812.jpg" alt="">
这样来看,贪心算法其实不难吧。我在这里把代码贴出来,你可以结合代码再理解一下算法的执行步骤。
Java 实现:
```
int getMinCoinCountHelper(int total, int[] values, int valueCount) {
int rest = total;
int count = 0;
// 从大到小遍历所有面值
for (int i = 0; i &lt; valueCount; ++ i) {
int currentCount = rest / values[i]; // 计算当前面值最多能用多少个
rest -= currentCount * values[i]; // 计算使用完当前面值后的余额
count += currentCount; // 增加当前面额用量
if (rest == 0) {
return count;
}
}
return -1; // 如果到这里说明无法凑出总价,返回-1
}
int getMinCoinCount() {
int[] values = { 5, 3 }; // 硬币面值
int total = 11; // 总价
return getMinCoinCountHelper(total, values, 2); // 输出结果
}
```
C++ 实现:
```
int GetMinCoinCountHelper(int total, int* values, int valueCount) {
int rest = total;
int count = 0;
// 从大到小遍历所有面值
for (int i = 0; i &lt; valueCount; ++ i) {
int currentCount = rest / values[i]; // 计算当前面值最多能用多少个
rest -= currentCount * values[i]; // 计算使用完当前面值后的余额
count += currentCount; // 增加当前面额用量
if (rest == 0) {
return count;
}
}
return -1; // 如果到这里说明无法凑出总价,返回-1
}
int GetMinCoinCount() {
int values[] = { 5, 3 }; // 硬币面值
int total = 11; // 总价
return GetMinCoinCountHelper(total, values, 2); // 输出结果
}
```
这段代码就是简单地从最大的面值开始尝试,每次都会把当前面值的硬币尽量用光,然后才会尝试下一种面值的货币。
嗯。。。你有没有发现问题那就是还剩1元零钱待找但是我们只有c[0]=5, c[1]=3两种面值的硬币怎么办这个问题无解了该返回-1了吗显然不是。
我们把第2步放入的5元硬币取出放入面值为3元的硬币试试看。这时你就会发现我们还剩3元零钱待找。
<img src="https://static001.geekbang.org/resource/image/8f/85/8fayy6c170b5732431fecb165d53e885.jpg" alt="">
正好我们还有c[1]=3的硬币可以使用因此解是c[0]=5, c[1]=3, c[1]=3即**最少**使用三枚硬币凑出了k=11这个金额。
我们对贪心算法做了改进,引入了回溯来解决前面碰到的“过于贪心”的问题。同样地,我把改进后的代码贴在这,你可以再看看跟之前算法实现的区别。
Java 实现:
```
int getMinCoinCountOfValue(int total, int[] values, int valueIndex) {
int valueCount = values.length;
if (valueIndex == valueCount) { return Integer.MAX_VALUE; }
int minResult = Integer.MAX_VALUE;
int currentValue = values[valueIndex];
int maxCount = total / currentValue;
for (int count = maxCount; count &gt;= 0; count --) {
int rest = total - count * currentValue;
// 如果rest为0表示余额已除尽组合完成
if (rest == 0) {
minResult = Math.min(minResult, count);
break;
}
// 否则尝试用剩余面值求当前余额的硬币总数
int restCount = getMinCoinCountOfValue(rest, values, valueIndex + 1);
// 如果后续没有可用组合
if (restCount == Integer.MAX_VALUE) {
// 如果当前面值已经为0返回-1表示尝试失败
if (count == 0) { break; }
// 否则尝试把当前面值-1
continue;
}
minResult = Math.min(minResult, count + restCount);
}
return minResult;
}
int getMinCoinCountLoop(int total, int[] values, int k) {
int minCount = Integer.MAX_VALUE;
int valueCount = values.length;
if (k == valueCount) {
return Math.min(minCount, getMinCoinCountOfValue(total, values, 0));
}
for (int i = k; i &lt;= valueCount - 1; i++) {
// k位置已经排列好
int t = values[k];
values[k] = values[i];
values[i]=t;
minCount = Math.min(minCount, getMinCoinCountLoop(total, values, k + 1)); // 考虑后一位
// 回溯
t = values[k];
values[k] = values[i];
values[i]=t;
}
return minCount;
}
int getMinCoinCountOfValue() {
int[] values = { 5, 3 }; // 硬币面值
int total = 11; // 总价
int minCoin = getMinCoinCountLoop(total, values, 0);
return (minCoin == Integer.MAX_VALUE) ? -1 : minCoin; // 输出答案
}
```
C++ 实现:
```
int GetMinCoinCountOfValue(int total, int* values, int valueIndex, int valueCount) {
if (valueIndex == valueCount) { return INT_MAX; }
int minResult = INT_MAX;
int currentValue = values[valueIndex];
int maxCount = total / currentValue;
for (int count = maxCount; count &gt;= 0; count --) {
int rest = total - count * currentValue;
// 如果rest为0表示余额已除尽组合完成
if (rest == 0) {
minResult = min(minResult, count);
break;
}
// 否则尝试用剩余面值求当前余额的硬币总数
int restCount = GetMinCoinCountOfValue(rest, values, valueIndex + 1, valueCount);
// 如果后续没有可用组合
if (restCount == INT_MAX) {
// 如果当前面值已经为0返回-1表示尝试失败
if (count == 0) { break; }
// 否则尝试把当前面值-1
continue;
}
minResult = min(minResult, count + restCount);
}
return minResult;
}
int GetMinCoinCountLoop(int total, int* values, int valueCount, int k) {
int minCount = INT_MAX;
if (k == valueCount) {
return min(minCount, GetMinCoinCountOfValue(total, values, 0, valueCount));
}
for (int i = k; i &lt;= valueCount - 1; i++) {
// k位置已经排列好
int t = values[k];
values[k] = values[i];
values[i]=t;
minCount = min(minCount, GetMinCoinCountOfValue(total, values, 0, valueCount));
minCount = min(minCount, GetMinCoinCountLoop(total, values, valueCount, k + 1)); // 考虑后一位
// 回溯
t = values[k];
values[k] = values[i];
values[i]=t;
}
return minCount;
}
int GetMinCoinCountOfValue() {
int values[] = { 5, 3 }; // 硬币面值
int total = 11; // 总价
int minCoin = GetMinCoinCountLoop(total, values, 2, 0);
return (minCoin == INT_MAX) ? -1 : minCoin;
}
```
改进后的算法实现在之前的基础上增加上了一个**回溯**过程。简单地说就是多了一个**递归**,不断尝试用更少的当前面值来拼凑。只要有一个组合成功,我们就返回总数,如果所有组合都尝试失败,就返回-1。
嗯,这样就没问题了,对硬币找零问题来说,我们得到了理想的结果。
## 贪心算法的局限性
从上面这个例子我们可以看出如果只是简单采用贪心的思路那么到用完2个5元硬币的时候我们就已经黔驴技穷了——因为剩下的1元无论如何都没法用现在的硬币凑出来。这是什么问题导致的呢
这就是贪心算法所谓的**局部最优**导致的问题,因为我们每一步都尽量多地使用面值最大的硬币,因为这样数量肯定最小,但是有的时候我们就进入了死胡同,就好比上面这个例子。
所谓**局部最优**,就是只考虑“当前”的最大利益,既不向前多看一步,也不向后多看一步,导致每次都只用当前阶段的最优解。
那么如果纯粹采用这种策略我们就永远无法达到**整体最优**,也就无法求得题目的答案了。至于能得到答案的情况那就是我们走狗屎运了。
虽然纯粹的贪心算法作用有限,但是这种求解**局部最优**的思路在方向上肯定是对的,毕竟所谓的**整体最优**肯定是从很多个**局部最优**中选择出来的,因此所有最优化问题的基础都是贪心算法。
回到前面的例子,我只不过是在贪心的基础上加入了失败后的回溯,稍微牺牲一点当前利益,仅仅是希望通过下一个硬币面值的**局部最优**达到最终可行的**整体最优**。
所有贪心的思路就是我们最优化求解的根本思想,所有的方法只不过是针对贪心思路的改进和优化而已。回溯解决的是正确性问题,而动态规划则是解决时间复杂度的问题。
贪心算法是求解整体最优的真正思路源头,这就是为什么我们要在课程的一开始就从贪心算法讲起。
## 课程总结
硬币找零问题本质上是求最值问题。事实上,动态规划问题的一般形式就是求最值,而求最值的核心思想是**穷举**。这是因为只要我们能够找到所有可能的答案,从中挑选出最优的解就是算法问题的结果。
在没有优化的情况下,穷举从来就不算是一个好方法。所以我带你使用了贪心算法来解题,它是一种使用**局部最优**思想解题的算法(即从问题的某一个初始解出发逐步逼近给定的目标,以尽可能快的速度去求得更好的解,当达到算法中的某一步不能再继续前进时,算法停止)。
但是通过硬币找零问题,我们也发现了贪心算法本身的局限性:
1. 不能保证求得的最后解是最佳的;
1. 不能用来求最大或最小解问题;
1. 只能求满足某些约束条件的可行解的范围。
我们往往需要使用**回溯**来优化贪心算法,否则就会导致算法失效。因此,在求解最值问题时,我们需要更好的方法来解。在后面课程讲到递归和穷举优化问题的时候,我会讲到解决最值问题的正确思路和方法:考虑**整体最优**的问题。
## 课后思考
在递归问题中,回溯是一种经典的优化算法性能的方法。递归对动态规划来说也十分重要。你能否举出使用回溯算法来解的面试问题?并给出你的解。希望你能在课后提出问题,进行练习。
最后,欢迎留言和我分享你的思考,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,502 @@
<audio id="audio" title="02 | 暴力递归:当贪心失效了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/81/yy2ff02a92ce3df2b7e2d47afba69181.mp3"></audio>
你好,我是卢誉声。
上一课我们学习了贪心算法,提出了硬币找零的问题,发现了贪心算法的局限性。与此同时,我还提出了一个重要概念,那就是局部最优与整体最优的概念,即最优化问题。今天,我们就从最优化问题开始聊起,引出学习动态规划时的另一重要概念:递归。
我们之前说过,贪心算法是求解整体最优的真正思路源头,这是为什么我们要在这门课程的一开始从贪心算法讲起。现在,你应该已经意识到贪心算法是有局限性的,它只能在局部最优的思想下工作,**那么当贪心算法失效了怎么办?**
接下来我们就带着这个问题,开始学习今天的内容:递归!看看它能否更进一步地解决我们遇到的棘手问题,从整体最优的角度来解决算法问题。
## 从最优化问题到递归
贪心算法失效的很大一个原因在于它明显的局限性:它几乎只考虑局部最优解。所谓局部最优,就是只考虑当前的最大利益,既不向前多看一步,也不向后多看一步,导致每次都只用当前阶段的最优解。
因此在绝大多数情况下,贪心算法不能得到整体最优解,但它的解是最优解的一个很好近似。同时,也是所有讨论最优化问题的核心基础。
既然无法通过贪心算法达到整体最优,我们就得换一个思路了:我们得从整体最优的层面上解决这个难缠的算法问题。那么从何说起呢?我认为你应该先理解最优化问题的本质,然后再把这个思考扩展到递归问题上。话不多说,我们这就开始吧!
### 最优化问题的本质
所谓最优化问题,就是指在某些约束条件下,决定可选择的变量应该取何值,使所选定的目标函数达到最优的问题。
从数学意义上说,最优化方法是一种求极值的方法,即在一组约束为等式或不等式的条件下,使系统的目标函数达到极值,即最大值或最小值。
如果只是从概念上来看最优化问题真的是玄而又玄,所以在上一课中我用了硬币找零的例子,引出了最优化的概念,以便你理解。
在数学里一切都是函数,现在我们先把这个问题用函数形式来表示。为了易于理解,下面我们不会使用向量。
我们假定需要给出$y$元硬币硬币面额是5元和3元求出需要的最少硬币数量。所谓的最少硬币数量就是5元硬币和3元硬币的总数假定5元硬币数量为$x_{0}$3元硬币数量为$x_{1}$,那么用函数表示就是:
$$f(x_{0}, x_{1})=x_{0}+x_{1}$$
这就是所谓的“目标函数”。
但是这个函数现在是没有任何限制的我们希望对此进行约束使得5元硬币和3元硬币的面值综合为$y$。为此我们需要给出一个约束:
$$5x_{0}+3x_{1}=y$$
这个时候我们的问题就变成了,当满足这个约束条件的时候,求解函数中的变量$x_{0}$和$x_{1}$,使得目标函数$f(x_{0}, x_{1})$的取值最小。如果用数学的描述方法来说的话,就是下面这样:
$${\arg\min}_{(x_0,x_1)\in S} (x_0+x_1)$$
这个就是我们常见的$argmin$表示方式。它的意思是:当$(x_{0}, x_{1})$属于$S$这个集合的时候,希望知道$x_{0} + x_{1}$的最小值是多少。其中$S$集合的条件就是上面的约束。
所以最优化问题在我们生活中是非常普遍的,只不过大多数问题可能都像硬币找零问题这样看起来普普通通,概念其实是不难理解的。
回到硬币找零这个问题上。由于$(x_{0}, x_{1})$都是离散的值,因此所有满足上述约束的$(x_{0}, x_{1})$组合,就是我们最终所求的集合!而这个最优化问题的本质就是:从所有满足条件的组合$(x_{0},x_{1})$中找出一个组合,使得$x_{0}+x_{1}$的值最小。
所以,你会发现在这种离散型的最优化问题中,本质就是从所有满足条件的组合(能够凑出$y$元)中选择出使得我们的目标函数(所有硬币数量之和)最小的那个组合。而这个所谓满足条件的组合不就是$argmin$公式中的那个集合$S$吗?
因此,这种离散型的最优化问题就是去所有满足条件的组合里找出最优解的组合。我曾多次提到的**局部最优**就是在一定条件下的最优解,而**整体最优**就是我们真正希望得到的最优解。
那么我们的视角就转到另一边了:如何去找到这个最优解呢?
### 枚举与递归:最优组合的求解策略
如果想得到最优组合,那么最简单直接的方法肯定就是**枚举**。枚举就是直接求出所有满足条件的组合,然后看看这些组合是否能得到最大值或者最小值。
在硬币找零问题中假设现在需要给出25元的硬币有两种组合分别是(5, 0)和(2, 5)也就是5个5元硬币或者2个5元硬币加上5个3元硬币那么硬币数量最小的组合肯定就是(5, 0)。
所以最简单的方法就是找出所有满足条件的组合,也就是上面两个组合,然后去看这些组合中的最优解。
枚举本身很简单,就是把所有组合都遍历一遍即可。可现在问题就是,**如何得到这些组合呢?**
这就需要我们通过一些策略来生成所有满足条件的组合。而**递归**正是得到这些组合的方法。在解决问题前,我们先回顾一下递归问题的本质。
## 递归与问题表达
我们可以看出,其实最优化问题使用递归来处理是非常清晰的,递归是搜索组合的一种非常直观的思路。
当我在稍后的课程里讨论动态规划时,你就会发现所有问题都需要被描述成递归的形式来讨论。
所以我们有必要先巩固一下递归的概念。首先是在数学中我们怎么去用递归描述一个问题,然后是如何用递归描述最优化问题的解法。
### 从斐波那契数列说起
严格来说,斐波那契数列问题不是最优化问题,但它能很好地展示递归的概念。我们先来看一下斐波那契数列的问题描述。
问题:斐波那契数通常用 F(n) 表示,形成的序列称为斐波那契数列。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和:
$$F(n)=\left\{\begin{array}{c}<br>
0,n=0\\\<br>
1,n=1\\\<br>
F(n-1)+F(n-2),n&gt;1<br>
\end{array}\right.$$
```
示例 1
输入2
输出1
解释F(2) = F(1) + F(0) = 1 + 0 = 1。
```
```
示例 2
输入3
输出2
解释F(3) = F(2) + F(1) = 1 + 1 = 2.
```
很多人在解算法面试问题的时候有一种倾向性,那就是使用迭代而非递归来求解问题。我先不说这样的倾向性正确与否,那么我们就按照这个偏好来解一下(即斐波那契数列的循环解法)。
Java实现
```
int fibonacci(int n) {
int[] resolution = {0, 1}; // 解的数组
if(n &lt; 2) { return resolution[n]; }
int i = 1;
int fib1 = 0, fib2 = 1, fib = 0;
while(i &lt; n) {
fib = fib1 + fib2;
fib1 = fib2;
fib2 = fib;
i++;
}
return fib; // 输出答案
}
```
C++实现:
```
int Fibonacci(int n) {
std::vector&lt;int&gt; resolution = {0, 1}; // 解的数组
if(n &lt; 2) { return resolution[n]; }
int i = 1;
int fib1 = 0, fib2 = 1, fib = 0;
while(i &lt; n) {
fib = fib1 + fib2;
fib1 = fib2;
fib2 = fib;
i++;
}
return fib; // 输出答案
}
```
嗯,这样的解法固然没错,但是它几乎脱离了题设的数学表达形式。在这道题目中,出题者“刻意”地写出了求解斐波那契数列的函数表达式,这其中有没有什么别的含义或原因呢?
当然有了,这个函数表达式很好地反应出了计算机科学中常见的算法形式:递归。 下面,就让我们来看看斐波那契数列与递归之间的关系。
### 使用递归求解斐波那契数列
事实上,斐波那契数列的数学形式就是递归的,我在这里直接贴出其递归形式的算法代码,你就能很清楚地看出这一点。
```
int Fibonacci(int n) {
if (0 == n || 1 == n) { return n; }
if(n &gt; 1) { return Fibonacci(n - 1) + Fibonacci(n - 2); }
return 0; // 如果输入n有误则返回默认值
}
```
递归形式的求解几乎就是简单的把题设中的函数表达式照搬过来,因此我们说从数学意义上讲,递归更直观,且易于理解。
### 使用递归求解硬币问题
你可以看出,理解递归并不难,现在我们要把这种思路套用到求解硬币的问题上来。话不多说,我在这里直接贴出使用递归求解硬币问题的代码实现。
Java实现
```
void getMinCountsHelper(int total, int[] values, ArrayList&lt;Integer&gt; currentCounts, ArrayList&lt;ArrayList&lt;Integer&gt;&gt; combinations) {
if (0 == total) { // 如果余额为0说明当前组合成立将组合加入到待选数组中
combinations.add(new ArrayList&lt;Integer&gt;(currentCounts));
return;
}
int valueLength = values.length;
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
if (currentValue &gt; total) { // 如果面值大于当前总额,直接跳过
continue;
}
// 否则在当前面值数量组合上的对应位置加1
ArrayList&lt;Integer&gt; newCounts = new ArrayList&lt;Integer&gt;(currentCounts);
newCounts.set(i, newCounts.get(i)+1);
int rest = total - currentValue;
getMinCountsHelper(rest, values, newCounts, combinations); // 求解剩余额度所需硬币数量
}
}
int getMinimumHelper(ArrayList&lt;ArrayList&lt;Integer&gt;&gt; combinations) {
// 如果没有可用组合,返回-1
if (0 == combinations.size()) { return -1; }
int minCount = Integer.MAX_VALUE;
for (ArrayList&lt;Integer&gt; counts : combinations) {
int total = 0; // 求当前组合的硬币总数
for (int count : counts) { total += count; }
// 保留最小的
if (total &lt; minCount) { minCount = total; }
}
return minCount;
}
int getMinCountOfCoins() {
int[] values = { 5, 3 }; // 硬币面值的数组
int total = 11; // 总值
ArrayList&lt;Integer&gt; initialCounts = new ArrayList&lt;&gt;(Collections.nCopies(values.length, 0)); // 初始值(0,0)
ArrayList&lt;ArrayList&lt;Integer&gt;&gt; coinCombinations = new ArrayList&lt;&gt;(); // 存储所有组合
getMinCountsHelper(total, values, initialCounts, coinCombinations); // 求解所有组合(不去重)
return getMinimumHelper(coinCombinations); // 输出答案
}
```
C++实现:
```
void GetMinCountsHelper(int total, const std::vector&lt;int&gt;&amp; values, std::vector&lt;int&gt; currentCounts, std::vector&lt;std::vector&lt;int&gt;&gt;&amp; combinations) {
if (!total) { // 如果余额为0说明当前组合成立将组合加入到待选数组中
combinations.push_back(currentCounts);
return;
}
int valueLength = values.size();
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
if (currentValue &gt; total) { // 如果面值大于当前总额,直接跳过
continue;
}
// 否则在当前面值数量组合上的对应位置加1
std::vector&lt;int&gt; newCounts = currentCounts;
newCounts[i] ++;
int rest = total - currentValue;
GetMinCountsHelper(rest, values, newCounts, combinations); // 求解剩余额度所需硬币数量
}
}
int GetMinimumHelper(const std::vector&lt;std::vector&lt;int&gt;&gt;&amp; combinations) {
// 如果没有可用组合,返回-1
if (!combinations.size()) { return -1; }
int minCount = INT_MAX;
for (const std::vector&lt;int&gt;&amp; counts : combinations) {
int total = 0; // 求当前组合的硬币总数
for (int count : counts) { total += count; }
// 保留最小的
if (total &lt; minCount) { minCount = total; }
}
return minCount;
}
int GetMinCountOfCoins() {
std::vector&lt;int&gt; values = { 5, 3 }; // 硬币面值的数组
int total = 11; // 总值
std::vector&lt;int&gt; initialCounts(values.size(), 0); // 初始值(0,0)
std::vector&lt;std::vector&lt;int&gt;&gt; coinCombinations; // 存储所有组合
GetMinCountsHelper(total, values, initialCounts, coinCombinations); // 求解所有组合(不去重)
return GetMinimumHelper(coinCombinations); // 输出答案
}
```
你从代码里可以看出,这里的操作被明确分成了两步:
1. 求解所有满足条件的组合;
1. 从组合中选出总和最小的组合。如果找不到满足条件的组合那么就返回-1。
我们也可以将这两步合并成一步来解决,就像下面这段代码。
Java实现
```
int getMinCountsHelper(int total, int[] values) {
// 如果余额为0说明当前组合成立将组合加入到待选数组中
if (0 == total) { return 0; }
int valueLength = values.length;
int minCount = Integer.MAX_VALUE;
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; total) { continue; }
int rest = total - currentValue; // 使用当前面值,得到剩余硬币总额
int restCount = getMinCountsHelper(rest, values);
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
int totalCount = 1 + restCount; // 保留最小总额
if (totalCount &lt; minCount) { minCount = totalCount; }
}
// 如果没有可用组合,返回-1
if (minCount == Integer.MAX_VALUE) { return -1; }
return minCount; // 返回最小硬币数量
}
int getMinCountOfCoinsAdvance() {
int[] values = { 3, 5 }; // 硬币面值的数组
int total = 11; // 总值
return getMinCountsHelper(total, values); // 输出答案
}
```
C++实现:
```
int GetMinCountsHelper(int total, const std::vector&lt;int&gt;&amp; values) {
// 如果余额为0说明当前组合成立将组合加入到待选数组中
if (!total) { return 0; }
int valueLength = values.size();
int minCount = INT_MAX;
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; total) { continue; }
int rest = total - currentValue; // 使用当前面值,得到剩余硬币总额
int restCount = GetMinCountsHelper(rest, values);
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
int totalCount = 1 + restCount; // 保留最小总额
if (totalCount &lt; minCount) { minCount = totalCount; }
}
// 如果没有可用组合,返回-1
if (minCount == INT_MAX) { return -1; }
return minCount; // 返回最小硬币数量
}
int GetMinCountOfCoinsAdvance() {
std::vector&lt;int&gt; values = { 5, 3 }; // 硬币面值的数组
int total = 11; // 总值
return GetMinCountsHelper(total, values); // 输出答案
}
```
在这段代码中,每一次递归返回的值,都是后续组合之和的最小值。它不再存储所有的组合,直到回退到递归的顶层。
这样可以极大节省存储空间,这是处理递归问题的通用方法。一般来说,你都应该用这种算法处理方式来解递归问题。
## 深入理解递归
在了解了递归的概念、问题的描述方式和解决问题方法后,我想让你来思考这样一个问题:**为什么递归能帮助我们解决最优化问题?**
### 堆栈与递归的状态存储
在计算机中,实现递归必须建立在堆栈的基础上,这是因为每次递归调用的时候我们都需要把当前函数调用中的局部变量保存在某个特定的地方,等到函数返回的时候再把这些局部变量取出来。
而用于保存这些局部变量的地方也就是堆栈了。
因此,你可以看到递归可以不断保存当前求解状态并进入下一层次的求解,并在得到后续阶段的解之后,将当前求解状态恢复并与后续求解结果进行合并。
在硬币找零问题中,我们可以放心的在函数中用循环不断遍历,找出当前面值硬币的可能数量。而无需用其它方法来存储当前或之前的数据。
得益于递归,我们通过堆栈实现了状态存储,这样的代码看起来简单、清晰明了。在本节课稍后的内容中,在我讲到递归树的求解组合空间时,你会更清晰地认识到堆栈和状态存储带来的价值!
### 递归与回溯
在求解最优化问题的时候,我们经常会用到**回溯**这个策略。
上一课中,我们已经提到过回溯的思想。在硬币找零这个问题里,具体说就是如果遇到已经无法求解的组合,那么我们就往回退一步,修改上一个面值的硬币数量,然后再尝试新的组合。
递归这种形式,正是赋予了回溯这种可以回退一步的能力:它通过堆栈保存了上一步的当前状态。
因此,如果想要用回溯的策略来解决问题,那么递归应该是你的首选方法。所以说,回溯在最优化问题中有多么重要,递归也就有多么重要。
### 树形结构与深度优先搜索
为了理解递归,我在这里用合适的结构来描述递归的求解过程。这种结构正是计算机数据结构中的树。如下图所示:
<img src="https://static001.geekbang.org/resource/image/15/70/154a4b0fdb8e467428fb3e06b14a3d70.jpg" alt="">
你可以从中看到形象的递归求解过程,每个节点的 /(斜线)左边表示当前节点使用的硬币面值,右边表示使用面值后的余额。图中的蓝色节点就表示我们目前得到的解。
递归的过程的确就是一个树形结构,而递归也就是一个深度优先搜索的过程,先找到下一步的解,然后再回退,如此往复。
所以我们可以这样理解递归:作为一个算法解决方案,它采用了深度优先搜索的策略,去搜索所有可能的组合,并得到最优解的最优化问题。
如果在每个节点上加上当前这个节点求得的组合结果,就可以用递归树表示**求解的组合空间**
<img src="https://static001.geekbang.org/resource/image/e7/63/e721d6a000637746a8d0ea223151b563.jpg" alt="">
### 通过穷举法从所有的解中得到最优解
从上图中我们可以发现,每个节点都存储了一个当前求解过程中的组合,和后续节点的组合合并到一起形成完整的答案。
而真正的求解组合就是把所有余额为0的组合拿出来经过去重之后得到的结果。
所以,你可以看到求解的组合就蕴含在这个递归的树形结算的节点空间中,这也就是为什么递归策略是行之有效的:我们可以通过穷举法从所有的解中得到最优解!
## 暴力递归的问题与优化
从上一课介绍的贪心算法,到我在这里跟你讲的暴力递归法,看起来硬币找零问题有了一个稳定且行之有效的解题思路。
但这就足够了吗?哈哈,显然不是。因为这样的穷举法效率实在低下,不仅如此,这样的代码可读性低且调试困难。我在这里给你具体分析一下。
### 性能问题
暴力递归的最后一个特点就是穷举(都叫暴力,你说是不是)。如果我们只使用朴素的递归思路解题,就需要通过递归来暴力穷举出所有的组合,而且我们穷举的不只是组合,还是所有可能得到目标组合的组成路径!
这个在上面的图中我们可以看到,同样是求解(2, 5)这个组合图中有多少种路径这还只是25元和两种面值的情况。如果求解的金额和面值数量增加那么我们可以看到这个树会以非常难以置信的方式增长那么带来的性能问题就是灾难性的。
如果你仔细观察一下,就会发现这个树会随着总额的增加呈现指数形式的增长。对于这种事情,我们难以接受。
因此,递归只是让问题可以求解,但是如果数据规模过大的时候暴力递归会引发极大的性能问题。
### 可读性与调试问题
虽然递归在数学意义上非常直观,但是如果问题过于复杂,一般是无法直接画出上面我画的那棵求解树的。
有画求解树的时候,我们可以想出我们的求解过程是怎么进行的,但如果求解树的分支极多,那么很多人就很难继续在脑海中模拟出整个求解过程了。
因此一旦程序出现bug当你想尝试去调试的时候就会发现这样的代码几乎没有调试的可能性。这种问题在数据规模很大的情况下尤为明显。
那么针对性能低下、代码可读性降低和调试问题,我们有什么办法去解决吗?有,听我给你讲下面的内容。
### 优化暴力递归:剪枝与优化
你可以从前面的图中看到,这棵树中有很多分支是完全相同的:起码从理论上讲最终只有两个组合。但是这棵树到达同一种组合的路径却非常多,所以优化递归的思路其实就是如何减少搜索的分支数量。
分支数量减少了,递归效率也就高了。这就是所谓的**剪枝**优化。对于优化方法,这里我提供两种思路给你。
**1. 参考贪心算法**
第一种思路是仿照贪心算法,从整个搜索策略上来调整。也就是说,你要考虑这个问题的性质,即面值大的硬币用得足够多,那么这个组合的硬币总数肯定就最小。
所以在每一次递归时,我们不应该暴力地搜索所有的面值,而应该从面值最大的硬币着手,不断尝试大面值硬币的最大情况。
如果无法满足条件再减少一个,再递归搜索。最后的代码就跟我在上一课中写给你的回溯代码一样,即通过贪心这种思路结合递归实现一种组合搜索。
殊途同归啊!我们从递归的角度重新解释了这个算法问题,而且代码实现也是一样的。
**2. 从解空间图解释**
除了参考贪心算法的思想,我们还可以从解空间的角度来解释这个问题。
请你注意观察一下:在解空间的图中,只要是余额相同的情况下,后面的搜索路径是完全一致的!
<img src="https://static001.geekbang.org/resource/image/77/d0/776fe3739110beba2a538103775e3ed0.jpg" alt="">
我在图中圈出的两个部分就是重复的搜索路径。因为余额都是12元所以后续的求解路径和结果完全相同。
这是一个重要线索,在这个硬币求解问题中,当余额相同的时候,最优解是确定的。那么你想想看,如果能够避免相同余额下的重复搜索过程,那么算法执行速度是不是可以加快了?
这就是我在上一课中提到过的**重叠子问题**。
你可以把求解12元的硬币数量理解成求解25元的硬币数量的一个子问题。在求解25元硬币过程中会有很多种情况都要求解12元硬币的最优解。我们把这类会出现重复求解的子问题称之为重叠子问题。
显然,这就是我们可以优化的出发点。至于如何进行优化,则需要用比较多的篇幅讨论,在下一节课中,我会跟你细谈这个问题。
## 课程总结
今天我们学习了最优化问题的本质,即从所有满足条件的组合里找出最优解的组合。贪心算法只能解决**局部最优**问题,而我们的最终目标是解决**整体最优**问题(即最优解)。
自然地,**枚举**是获得最优解的理想方法。而**递归**可以帮助我们获得所有可能答案的组合。递归形式的求解几乎就是简单地把题设中的函数表达式照搬过来,它相较于迭代来说更直观,且易于理解。
但暴力递归有着十分明显的缺陷,存在性能低下、可读性低和调试困难等问题。为此,我们提出了剪枝与优化这两种方法:
1. 利用预设条件减少搜索路径,优化最优组合搜索方案(硬币的优化);
1. 利用重叠子问题,避免重叠子问题的计算。
因此,在面试问题中,考虑贪心算法和递归是我们求解问题时思考的重要方向。很多面试问题已经可以使用这两种算法来解决了。
但在稍复杂的面试问题面前,我们还需要借助于更高级的手段:备忘录和动态规划。而重叠子问题是理解这些高级手段的基础,下节课我会具体来讲。
## 课后思考
今天我讲了递归求解最优解问题的思路,并强调了回溯的重要性。那如何通过编程,求出所有有效的括号组合呢?(设输入是有几组括号,输出是所有有效的括号组合)
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,370 @@
<audio id="audio" title="03 | 备忘录:如何避免递归中的重复计算?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/6b/65bcc016ecd08a768a095c94f8d38b6b.mp3"></audio>
你好,我是卢誉声。
从前面的课程中我们已经看到,动态规划问题的一般形式就是求最值。因此我先讲解了什么是最优解问题,在考虑整体最优的情况下,我们需要找到一种办法获取最优解。那么最简单直接的做法是什么呢?
其实就是把所有可行的答案**穷举**出来,然后在所有可行的答案中找出满足条件的最值。
这样的解法看似“天衣无缝”,但它有着重要的缺陷,而且这个缺陷是我们在面试过程中需要极力避免的:它的执行效率极低。
导致这个问题的罪魁祸首是重叠子问题,我已经不止一次提到这个概念了。那么你该**如何解决重叠子问题并提高算法效率呢?**
接下来我们就带着这个问题,开始学习今天的内容:备忘录。看看它能否有效解决递归过程中出现的大量重复计算的问题,提高算法效率。
## 什么是重叠子问题?
斐波那契数列没有求最值的问题,因此严格来说它不是最优解问题,当然也就不是动态规划问题。但它能帮助你理解什么是重叠子问题。首先,它的数学形式即递归表达是这样的:
$$F(n)=\left\{\begin{array}{c}<br>
0,n=0\\\<br>
1,n=1\\\<br>
F(n-1)+F(n-2),n&gt;1<br>
\end{array}\right.$$
因此写成代码也极为简洁:
```
int Fibonacci(int n) {
if (0 == n || 1 == n) { return n; }
if(n &gt; 1) { return Fibonacci(n - 1) + Fibonacci(n - 2); }
return 0; // 如果输入n有误则返回默认值
}
```
这个代码有问题吗没问题但是它效率极低。那么它效率低在哪里呢假设上面的函数调用输入是10我把递归树画出来
<img src="https://static001.geekbang.org/resource/image/f5/c2/f5b6b9be134109d7f54be59779fdc3c2.png" alt="">
我们要怎么理解这个递归树呢?如果要计算原问题 F(10),你就需要先计算出子问题 F(9) 和 F(8),如果要计算 F(9),你就需要先计算出子问题 F(8) 和 F(7),以此类推。这个递归的终止条件是当 F(1)=1 或 F(0)=0 时结束。
我在这里画出整个递归树的目的,在于向你尽可能详细地展示递归的过程,但凡遇到递归问题,你最好都能画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
但你也看到了,即便如此简单的递归过程,这个树也十分“巨大”。
在画求解树的时候,你可以想出求解过程是怎么进行的,但如果求解树的分支极多,那么很多人就很难继续在脑海中模拟出整个求解过程了。因此问题过于复杂,一般是无法直接画出像上面我画的那棵求解树的,即便递归在数学意义上非常直观。
因此,我给你的建议是画出主要的分支路径,帮助你理解和解决面试问题。
画完斐波那契数列的求解树之后,你发现问题没有?在我用红色标出的两个区域中,它们的递归计算过程完全相同!
这意味着第2个红色区域的计算是“完全没有必要的”它是重复的计算。因为我们已经在求解 F(7) 的时候把 F(6) 的所有情况计算过了。因此我们把第2个红色区域的计算称为**重叠子问题**。
这种问题常见吗?答案是肯定的。让我们回到硬币找零的问题上,我们把上一课的求解树再拿出来观察一下:
<img src="https://static001.geekbang.org/resource/image/77/d0/776fe3739110beba2a538103775e3ed0.jpg" alt="">
从上图中我们看到了类似的模式我同样圈选出了两个红色区域其中第2个即5/12(2, 1)的递归子问题与第1个 3/12(2, 1) 的递归子问题完全相同,因此它们是**重叠子问题**。
对于我们实现的这个递归算法,它的复杂度是多少呢?
1. 先考虑子问题的个数(即上图中节点的总数),二叉树节点总数为指数级别,因此子问题个数为指数级 O(2<sup>n</sup>)
1. 再考虑求解一个子问题的复杂度:没有计算,因此是 O(1)
1. 综上所述,该算法的时间复杂度是 O(2<sup>n</sup>)。
事实上在面试或工程实践中,指数级别的复杂度已经很难让人接受了,除非情况特殊,我们必须想办法来优化算法的时间复杂度。
该怎么做呢?到这,其实我们要解决的问题已经十分清晰明了了:只需要将重复计算的部分跳过,复用之前已经计算过的结果,以此来加速算法。
## 递归中的备忘录:解决重复计算的法宝
### 备忘录即正义
现在,我们明确了亟待解决的问题:消除重叠子问题,即消灭重复计算的过程。我们可以创建一个**备忘录**memorization在每次计算出某个子问题的答案后将这个临时的中间结果记录到备忘录里然后再返回。
接着,每当遇到一个子问题时,我们不是按照原有的思路开始对子问题进行递归求解,而是先去这个备忘录中查询一下。如果发现之前已经解决过这个子问题了,那么就直接把答案取出来复用,没有必要再递归下去耗时的计算了。
对于备忘录,你可以考虑使用以下两种数据结构:
1. 数组Array通常对于简单的问题来说使用一维数组就足够了。在后续的课程中你会看到更为复杂的状态存储过程届时我会指导你使用更高维度二维甚至三维的数组来存储状态。
1. 哈希表Hash table如果你存储的状态不能直接通过索引找到需要的值比如斐波那契数列问题你就可以直接通过数组的索引确定其对应子问题的解是否存在如果存在你就拿出来直接使用比如你使用了更高级的数据结构而非简单的数字索引那么你还可以考虑使用哈希表即字典来存储中间状态来避免重复计算的问题。
我们先来看看如何使用备忘录来解决斐波那契数列问题,直接上代码。
Java 实现:
```
int fibonacci(int n, int[] memo) {
if (0 == n || 1 == n) { return n; }
if (memo[n] != 0) { return memo[n]; } // 看来备忘录中找到了之前计算的结果,既然找到了,直接返回,避免重复计算
if(n &gt; 1) {
memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
return memo[n];
}
return 0; // 如果数值无效(比如 &lt; 0)则返回0
}
int fibonacciAdvance(int n) {
int[] memo = new int[n + 1];
return fibonacci(n, memo);
}
```
C++ 实现:
```
int Fibonacci(int n, std::vector&lt;int&gt;&amp; memo) {
if (0 == n || 1 == n) { return n; }
if (memo[n] != 0) { return memo[n]; } // 看来备忘录中找到了之前计算的结果,既然找到了,直接返回,避免重复计算
if(n &gt; 1) {
memo[n] = Fibonacci(n - 1, memo) + Fibonacci(n - 2, memo);
return memo[n];
}
return 0; // 如果数值无效(比如 &lt; 0)则返回0
}
int FibonacciAdvance(int n) {
std::vector&lt;int&gt; memo(n + 1, 0); // 初始化备忘录,在这里我使用数组
return Fibonacci(n, memo);
}
```
从以上代码可以看出我在第12行创建了一个基于数组的备忘录用来存储中间计算状态。第3行代码十分关键它从我们的备忘录中查询对应索引位置存储的状态是否已经计算过&gt; 0时如果计算过了那么就直接返回之前计算过的答案。
我在下图中,把两个重复计算的分支画了出来,你可以清晰地看到,这些被圈出的子问题,是完全没有必要进行再次计算的,我们可以直接拿之前的计算结果来使用。
<img src="https://static001.geekbang.org/resource/image/b3/f0/b3f1f3bd8e24308a655c309105f89df0.png" alt="">
实际上,这就是我们所熟知的“剪枝与优化”,在这里我把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。
通过这种方式,我们大幅缩减了算法的计算量,所有重复的部分都被跳过了。这时,我们再看看递归的复杂度是多少?
1. 同样,我们先考虑子问题的个数(即上图中节点的总数)。由于本算法不存在冗余计算,子问题就是 F(1), F(2), F(3) ... F(10),因此为 O(n)
1. 再考虑求解一个子问题的复杂度:没有计算,因此是 O(1)
1. 综上所述该算法的时间复杂度是O(n)。
使用算法解决重复计算的第一步就是需要将问题定义成函数比如上面的硬币问题我们可以将满足x的最小硬币总数定义成一个函数f(x)
$$F(x)=\left\{\begin{array}{c}<br>
min(f(x-c)+1), x&gt;0,f(x-c)\ne-1,c\in C\\\<br>
0,x=0\\\<br>
-1,x&lt;0<br>
\end{array}\right.$$
说明在这个函数中C 指的是硬币面值的集合。
那如果要处理这个问题,我们只需要避免每次都重复计算 F(x) 的结果就行了。如果有一次的路径中已经计算了 F(8),那么如果在其它的路径中再次遇到 F(8) 的时候,我们就不需要再次计算这个路径了。
所以我们需要一个集合来存储所有F(x)的结果F(x) 在计算时首先查询集合中是否存储了 F(x) 的结果,如果有则返回,没有再执行整个计算步骤。
看来,我们已经把指数级 O(2<sup>n</sup>) 时间复杂度的问题进行了“疯狂”的简化。相较于暴力递归,这就是传说中的“降维打击”。
### 使用备忘录求解硬币找零问题
现在,让我们再次回到硬币找零问题去实践一遍。首先这是一个可以使用动态规划解决的问题,它存在最优化问题;其次,就像我刚才所说的,该问题存在重叠子问题。
现在,让我们来看看如何使用备忘录,对硬币找零算法的复杂度进行“大刀阔斧”的简化。
我们仿照使用备忘录提高斐波那契数列算法效率的方法,对硬币找零算法进行剪枝和优化。为了简单起见,我在这里直接贴出改进后的算法实现。
Java 实现:
```
int getMinCountsHelper(int total, int[] values, int[] memo) {
int savedMinCount = memo[total];
if (savedMinCount != -2) { return savedMinCount; }
int valueLength = values.length;
int minCount = Integer.MAX_VALUE;
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; total) { continue; }
// 使用当前面值,得到剩余硬币总额
int rest = total - currentValue;
int restCount = getMinCountsHelper(rest, values, memo);
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
// 保留最小总额
int totalCount = 1 + restCount;
if (totalCount &lt; minCount) { minCount = totalCount; }
}
// 如果没有可用组合,返回-1
if (minCount == Integer.MAX_VALUE) {
memo[total] = -1;
return -1;
}
memo[total] = minCount; // 记录到备忘录
return minCount; // 返回最小硬币数量
}
int getMinCountsSol() { // 入口函数
int[] values = { 3, 5 }; // 硬币面值
int total = 14; // 总值
int[] memo = new int[total + 1];// , -2); // 备忘录,没有缓存的元素为-2
Arrays.fill(memo, -2);
memo[0] = 0; // 其中0对应的结果也是0首先存在备忘录中
// 求得最小的硬币数量,并输出结果
return getMinCountsHelper(total, values, memo); // 输出结果
}
```
C++ 实现:
```
int GetMinCountsHelper(int total, const std::vector&lt;int&gt;&amp; values, std::vector&lt;int&gt;&amp; memo) {
auto savedMinCount = memo[total];
if (savedMinCount != -2) { return savedMinCount; }
int valueLength = values.size();
int minCount = INT_MAX;
for (int i = 0; i &lt; valueLength; i ++) { // 遍历所有面值
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; total) { continue; }
// 使用当前面值,得到剩余硬币总额
int rest = total - currentValue;
int restCount = GetMinCountsHelper(rest, values, memo);
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
// 保留最小总额
int totalCount = 1 + restCount;
if (totalCount &lt; minCount) { minCount = totalCount; }
}
// 如果没有可用组合,返回-1
if (minCount == INT_MAX) {
memo[total] = -1;
return -1;
}
memo[total] = minCount; // 记录到备忘录
return minCount; // 返回最小硬币数量
}
int GetMinCountsSol() { // 入口函数
std::vector&lt;int&gt; values = { 3, 5 }; // 硬币面值
int total = 11; // 总值
std::vector&lt;int&gt; memo(total + 1, -2); // 备忘录,没有缓存的元素为-2
memo[0] = 0; // 其中0对应的结果也是0首先存在备忘录中
// 求得最小的硬币数量,并输出结果
return GetMinCountsHelper(total, values, memo); // 输出答案
}
```
相较于我在上一课实现的代码我在函数中加入了一个memo参数。
因此,对于原来实现的代码,算法时间复杂度可以概括为:
1. 先考虑子问题的个数,我只画了这颗树的一部分,因此从树上这个比较难看出来,但从斐波那契数列的题目上我们可以推广,得到其个数是 O(n<sup>m</sup>), m=|values|,即指数级别;
1. 再考虑求解一个子问题的复杂度:每个子问题中含有一个循环,因此时间复杂度为 O(m), m=|values|
1. 综上所述,该算法的时间复杂度是 O(mn<sup>m</sup>), m=|values|。
而通过备忘录优化后的算法时间复杂度为:
1. 先考虑子问题的个数如果我们求n元总额的硬币最小数量那么子问题最多就是 0…n 个,一共 n+1 个子问题;
1. 因为我们缓存了子问题的数量,所以其实每个子问题都只会被求解一次;
1. 针对每个子问题求解,我们都需要通过硬币面额数量知道需要筛选的子问题数量,每个子问题求解时的时间是 O(m), m=|values|
1. 最后我们可以得知,采用备忘录形式时,整个时间复杂度就是子问题的数量乘以需要考虑的面额数量,也就是 O(m*n), m=|values|。
我在这里创建的备忘录memo其实是一个缓存数组每次求解函数F(x)之后都会将结果缓存在数组中。数组初始化长度是total+1也就是可以存储0-total的计算结果。所有元素的初值都是-2表示没有缓存。然后我们将memo[0] 设置为0表示公式中如果x为0那么F(x)的结果也就是0。
接着在 GetMinCountsHelper 函数中先查找memo中是否已经缓存了当前total的值。如果有则直接返回如果没有那么重新计算。
重新计算完成后,如果结果为-1即无效组合那么就将当前total对应的缓存设置为-1否则设置为我们计算的结果。
通过备忘录,我们避免了重复计算,即避免重复计算那些已经计算过的子问题。
## 重叠子问题处理模式
现在,你已经知道了如何处理具体的问题:比如斐波那契数列和硬币找零问题。但是如果我们遇到了类似新问题该如何处理呢?这里我总结一下对这类问题的处理方法。
假设面试问题是这样的:当目标为 x其中x可能是一个任意长度的向量目标可能包含多个元素求最优解 F(x)。举个例子比如在硬币这个问题里x 就是硬币总额度F(x)就是最少的硬币数量。
同时,我们还需要知道问题是求最小值还是最大值,并以此来定义我们的数值函数 G(t)。如果求最小值,那么 G 是 min如果求最大值那么 G 就是 max。
除此之外,我们还需要通过当前的问题获得后续的一系列子问题,假定当前得到子问题的参数为 c得到后续子问题的函数是 S那么这个函数就是 S(x, c)。
接着,我们就可以用 F(S(x, c)) 来求得子问题的结果。
我们再定义一个函数 V(x)该函数可以聚合当前参数c和当前子问题的结果。最后我们还要定义每一步如何与子问题进行叠加。定义一个叠加函数 H(x)。
综上所述,最后得到如下求解公式:
$$F(x) = H(G(V(F(S(x, c)), c)))$$
因此,当你解决类似问题时,只需要把问题套用到上面的公式(框架)中,就能用一个递归函数来描述所有的问题。你可以尝试把斐波那契数列和硬币问题分别套入这个模型,就知道后面的问题定义该怎么举一反三了。
在定义好问题后,你就可以编写基于递归算法的代码了。不过需要注意,上面的公式并不包含边界值的处理。所谓的边界值就是无法再分解为子问题的子问题。
比如在硬币找零问题中x 为0的时候就是一个所谓的边界值。只要处理好递归函数和边界值我们就能一通百通了。
## 重叠子问题缓存的限制
我们刚刚学习了重叠子问题的处理模式,提炼出了一个通用的求解公式。你可能会问,这种利用重叠子问题的缓存来提升速度的方法是不是万灵药呢?
有一句老话,叫计算机中不存在“银弹”,也就是说没有任何一种方法能够解决世界上的所有问题。通过备忘录的思想来处理重叠子问题的方法亦是如此。
<li>
我们回想一下在上一课中提到过的问题,就有不少是不存在重叠子问题的,比如八皇后问题。既然没有重叠子问题,那么通过备忘录来对其优化加速,又从何谈起呢?
</li>
<li>
有些问题虽然看起来像包含“重叠子问题”的子问题,但是这类子问题可能具有后效性,但我们追求的是**无后效性**。所谓无后效性指的是在通过A阶段的子问题推导B阶段的子问题的时候我们不需要回过头去再根据B阶段的子问题重新推导A阶段的子问题即子问题之间的依赖是单向性的。
</li>
所以说,如果一个问题可以通过重叠子问题缓存进行优化,那么它肯定都能被画成一棵树。希望你能牢记这些限制,不然可能抓破头皮都没法解决问题,最后陷入死胡同。
## 方案弊端
我们可以看到,通过重叠子问题缓存可以极大加速我们的代码执行效率。但是凡事都有两面性,我们毋庸置疑,这种方案肯定是通过某种牺牲换取了性能的提升。
在硬币找零问题中我们在代码里加入了一个memo数组作为备忘录这个数组的大小是钱币总额+1。如果计算出F(x)的结果就把F(x)的结果存在数组中x的位置这样后续再计算相同的子问题时我们就可以利用缓存来避免重复计算了。
但这样有个问题,如果我们的钱币总额数量非常巨大,那这个数组的大小就会非常巨大,导致的结果就是会占据大量的内存存储空间,而且有很多的数字其实是不会被求解的,存在很多的“存储空洞”。显然,这是一种浪费。
同样,如果考虑为了节省空间,那么我们可以使用哈希表,但是哈希表的检索性能肯定不如数组。你可能会说,哈希表的插入和查找的算法复杂度是 O(1) 啊,它怎么可能会慢呢?
原因在于,哈希表通常都会使用经过设计的数据结构(比如拉链法)来避免记录碰撞,因此实际的速度肯定不如直接访问数组的特定位置。
因此在这个问题里,我们仍然优先选用数组和指定的索引来快速访问数据。
话说回来,如果遇到一个目标结果 x 是一个向量的情况下,这个数组就会随着向量维度一起提升,比如如果 x 是二维向量,那么缓存数组就必须是二维数组,以此类推。因此向量维度提升造成的空间压力也可能是巨大的。
所以在面试中,你需要根据实际情况,在空间和时间中寻求一个平衡,虽然这样的经验需要积累,但更多的时候是需要你在编写代码前,将这个问题考虑在内。
## 课程总结
今天我们学习了重叠子问题这一概念,通过斐波那契数列展现了重复计算引发的问题:暴力的递归性能低下。并在此基础上提出了备忘录的思想。
备忘录的思想极为重要,特别是当求解的问题包含**重叠子问题**时,只要面试的问题包含重复计算,你就应该考虑使用备忘录来对算法时间复杂度进行简化。具体来说,备忘录解法可以归纳为:
1. 用数组或哈希表来缓存已解的子问题答案,并使用自顶向下的递归顺序递归数据;
1. 基于递归实现,与暴力递归的区别在于备忘录为每个求解过的子问题建立了备忘录(缓存);
1. 为每个子问题的初始记录存入一个特殊的值表示该子问题尚未求解如无此记录或像求解斐波那契数列题目中那样初始化成0
1. 在求解过程中,从备忘录中查询。如果未找到或是特殊值,表示未求解;否则取出该子问题的答案,直接返回。
与此同时,在求解最优解问题的时候,画出基本的递归树结构,能极大地降低问题的难度。因此,我建议你在解决此类问题的时候尝试使用这个方法。
含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。没错!我们又进了一步。备忘录让我们实现了对算法时间复杂度的“降维打击”,这与贪心算法到递归的进步程度不同,这是真正意义上的动态规划思维:
1. 我们考虑了整体最优;
1. 在计算的过程中保存计算当中的状态,并在后续的计算中复用之前保存的状态。
记住使用备忘录来优化你的算法时间复杂度,它是提高算法效率的高级手段。我们距真正的动态规划咫尺之遥,除了重叠子问题,你还需要了解什么是**最优子结构**和**状态转移方程**,我会从下节课开始讲起。
## 课后思考
到目前为止,我在课程中展示算法的代码实现时,使用的递归方式都是自上而下的,那么请你思考一下这个问题:递归的顺序有办法倒转过来吗(变成自底向上)?如果可以,应该怎么做?如果不能,为什么?
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,411 @@
<audio id="audio" title="04 | 动态规划:完美解决硬币找零" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/0a/d41615671f534f06207be003e2b8530a.mp3"></audio>
你好,我是卢誉声。今天我们来继续学习动态规划。
在前面的几节课中,我们经历了贪心算法求解硬币找零的问题,并从中发现了贪心算法本身的局限性:它几乎只考虑了局部最优,因此无法应对需要考虑整体最优的算法面试问题。
针对这一问题,我们重新思考了解决方案,用递归的方法来**穷举**出所有可能的组合,从这些可能组合中找出最优解。虽然这么做考虑了整体最优,而且真的可以解决问题,但效率太低。因此,为了解决这个低效问题,我们又提出了备忘录的概念,并在硬币找零案例中应用它解决了问题。
你应该发现了,我们在解决硬币找零问题时的思路是一以贯之的:发现问题,找解决方案;如果方案有局限性,那么就看如何扩展视野,找寻更优的方法。
不知道你还记不记得,我在上一节课的结尾有提到:含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。
事实上,你已经在使用动态规划的思想解决问题了。但**“真正”的动态规划解法跟备忘录法又有什么区别呢?**
接下来我们就带着这个问题,一起来学习今天的内容吧!
## 动态规划的问题描述
我们曾不止一次提到重叠子问题,并在上一课对其做了深入探讨。其实,重叠子问题是考虑一个问题是否为动态规划问题的先决条件,除此之外,我还提到了无后效性。
嗯,是时候对这些问题做个总结了,动态规划问题一定具备以下三个特征:
1. 重叠子问题:在穷举的过程中(比如通过递归),存在重复计算的现象;
1. 无后效性:子问题之间的依赖是单向性的,某阶段状态一旦确定,就不受后续决策的影响;
1. 最优子结构:子问题之间必须相互独立,或者说后续的计算可以通过前面的状态推导出来。
### 什么是最优子结构?
这是我第一次在课程中提出**最优子结构**这一概念,所以咱们先了解一下。这东西乍一听有些玄乎,什么叫子问题之间必须相互独立?我举一个简单的例子,你就明白了。
比如说假设你在外卖平台购买5斤苹果和3斤香蕉。由于促销的缘故这两种水果都有一个互相独立的促销价。如果原问题是让你以最低的价格购买这些水果你该怎么买显然由于这两种水果的促销价格相互独立、互不影响你只需直接购买就能享受到最低折扣的价格。
现在我们得到了正确的结果:最低价格就是直接购买这两种折扣水果。因为这个过程符合最优子结构,打折的苹果和香蕉这两个子问题是互相独立、互不干扰的。
但是,如果平台追加一个条件:折扣不能同时享用。即购买了折扣的苹果就不能享受折扣的香蕉,反之亦然。这样的话,你肯定就不能同时以最低的苹果价格和最低的香蕉价格享受到最低折扣了。
按刚才那个思路就会得到错误的结果。因为子问题并不独立,苹果和香蕉的折扣价格无法同时达到最优,这时最优子结构被破坏。
回过头来,我们再读一下最优子结构的定义。首先,你应该已经理解了什么是子问题之间必须相互独立。
其次所谓后续的计算可以通过前面的状态推导是指如果你准备购买了5斤折扣苹果那么这个价格即子问题就被确定了继续在购物车追加3斤折扣香蕉的订单只需要在刚才的价格上追加折扣香蕉的价格就是最低的总价格即答案
现在,让我们回到硬币找零的问题上来,它满足最优子结构吗?满足。
假设有两种面值的硬币 c[0]=5, c[1]=3目标兑换金额为 k=11。原问题是求这种情况下求最少兑换的硬币数。
如果你知道凑出 k=6 最少硬币数为 “2”注意这是一个子问题那么你只需要再加 “1” 枚面值为 c[0]=5 的硬币就可以得到原问题的答案,即 2 + 1 = 3。
原问题并没有限定硬币数量,你应该可以看出这些子问题之间没有互相制约的情况,它们之间是互相独立的。因此,硬币找零问题满足最优子结构,可以使用动态规划思想来进行求解。
### 使用动态规划求解硬币找零
当动态规划最终落到实处,就是一个状态转移方程,这同样是一个吓唬人的名词。不过没关系,其实我们已经具备了写出这个方程的所有工具。现在,就让我带你一起看看如何写出这个状态转移方程。
首先,任何穷举算法(包括递归在内)都需要一个终止条件。那么对于硬币找零问题来说,终止条件是什么呢?当剩余的金额为 0 时结束穷举,因为这时不需要任何硬币就已经凑出目标金额了。在动态规划中,我们将其称之为**初始化状态**。
接着,我们按照上面提到的凑硬币的思路,找出子问题与原问题之间会发生变化的变量。原问题指定了硬币的面值,同时没有限定硬币的数量,因此它们俩无法作为“变量”。唯独剩余需要兑换的金额是变化的,因此在这个题目中,唯一的变量是目标兑换金额 k。
在动态规划中,我们将其称之为**状态参数**。同时,你应该注意到了,这个状态在不断逼近初始化状态。而这个不断逼近的过程,叫做状态转移。
接着,既然我们确定了状态,那么什么操作会改变状态,并让它不断逼近初始化状态呢?每当我们挑一枚硬币,用来凑零钱,就会改变状态。在动态规划中,我们将其称之为**决策**。
终于,我们构造了一个初始化状态-&gt;确定状态参数-&gt;设计决策的思路。现在万事俱备,只欠东风,让我们一起来写这个**状态转移方程**。通常情况下,状态转移方程的参数就是状态转移过程中的变量,即状态参数。而函数的返回值就是答案,在这里是最少兑换的硬币数。
我在这里先用递归形式(伪代码形式)描述一下状态转移的过程,这跟我们在上面讨论的挑硬币的过程是一致的。
```
DP(values, k) {
res = MAX
for c in values
// 作出决策,找到需要硬币最少的那个结果
res = min(res, 1 + DP(values, k-c)) // 递归调用
if res == MAX
return -1
return res
}
```
顺着这个思路,我把状态转移方程给写出来,它是这样的:
$$F(n)=\left\{\begin{array}{c}<br>
0,n=0\\\<br>
-1,n&lt;0\\\<br>
min\{ 1+DP(n-c) | c \in values \}<br>
\end{array}\right.$$
还记得吗?我们曾在讲斐波那契数列时展示了一个重要发现,那就是:递归形式的求解几乎就是简单的把题设中的函数表达式照搬过来,因此我们说从数学意义上讲,递归更直观,且易于理解。
## 递归与动态规划
不知道你有没有发现,上面这个动态规划的状态转移方程,与上一课当中的递归优化方案的方程式几乎一样。
在上一课中,我们为了优化递归中的重叠子问题,设计了一个缓存用于存储重叠子问题的结果,避免重复计算已经计算过的子问题。而这个缓存其实就是存储了动态规划里的状态信息。
因此,带备忘录的递归算法与你现在看到的动态规划解法之间,有着密不可分的关系。它们要解决的核心问题是一样的,即消除重叠子问题的重复计算。
事实上,带备忘录的递归算法也是一种动态规划解法。但是,我们为何不把这种方法作为动态规划面试题的常规解法呢?
这是递归带来的固有问题。
首先,从理论上说,虽然带备忘录的递归算法与动态规划解法的时间复杂度是相同规模的(稍后我就会展示新的动态规划解法),但在计算机编程的世界里,递归是依赖于函数调用的,而每一次函数调用的代价非常高昂。
递归调用是需要基于堆栈才能实现的。而对于基于堆栈的函数调用来说,在每一次调用的时候都会发生环境变量的保存和还原,因此会带来比较高的额外时间成本。这是无法通过时间复杂度分析直接表现出来的。
更重要的是,即便我们不考虑函数调用带来的开销,递归本身的处理方式是**自顶向下**的。所谓自顶向下,是指访问递归树的顺序是自顶向下的,这么说还是很抽象?我把递归处理斐波那契数列的顺序画出来,你就明白了:
<img src="https://static001.geekbang.org/resource/image/2f/87/2f7baa792e7765b66e6a563137d7e187.png" alt="">
如果从解路径的角度看递归的自顶向下处理,那么它的形式可以用由左至右的链表形式表示。
<img src="https://static001.geekbang.org/resource/image/9a/f8/9aeb84114f90c2f524c95e9a4cb84ff8.png" alt="">
因此每次都需要查询子问题是否已经被计算过,如果该子问题已经被计算过,则直接返回备忘录中的记录。也就是说,在带备忘录的递归解法中,无论如何都要多处理一个分支逻辑,只不过这个分支的子分支是不需要进行处理的。
这样的话,我们就可以预想到,如果遇到子问题分支非常多,那么肉眼可见的额外时间开销在所难免。我们不希望把时间浪费在递归本身带来的性能损耗上。
那么,有什么好的办法来规避这个问题呢?我们需要设计一种新的缓存方式,并考虑使用迭代来替换递归。接下来,让我们来一起看看该如何改造我们的算法。
## 状态缓存与循环
在带备忘录的递归算法中每次都需要查询子问题是否已经被计算过。针对这一问题我们可以思考一下是否有方法可以不去检查子问题的处理情况呢在执行A问题的时候确保A的所有子问题一定已经计算完毕了。
仔细想一想,这不就是把处理方向倒过来用**自底向上**嘛!那么我们具体要怎么做呢?
回顾一下自顶向下的方法,我们的思路是从目标问题开始,不断将大问题拆解成子问题,然后再继续不断拆解子问题,直到子问题不可拆解为止。通过备忘录就可以知道哪些子问题已经被计算过了,从而提升求解速度。
那么如果要自底向上,我们是不是可以首先求出所有的子问题,然后通过底层的子问题向上求解更大的问题。我还是通过斐波那契数列来画出自底向上的处理方式:
<img src="https://static001.geekbang.org/resource/image/27/a9/27621b2ba53b5c412e00cc7f29872ea9.png" alt="">
如果从解路径的角度看动态规划的自底向上处理方式,那么它的形式可以用一个数组来进行表示,而这个数组事实上就是实际的备忘录存储结构。
<img src="https://static001.geekbang.org/resource/image/c7/2e/c7a3c00e2611b7da6c84441dfcbe6c2e.png" alt="">
这样有一个好处,当求解大问题的时候,我们已经可以确保该问题依赖的所有子问题都已经计算过了,那么我们就无需检查子问题是否已经求解,而是直接从缓存中取出子问题的解。
通过自底向上,我们完美地解决掉了递归中由于“试探”带来的性能损耗。有了思路之后,让我们把上一课中的递归代码做些修改,变成新的迭代实现:
Java 实现:
```
int getMinCounts(int k, int[] values) {
int[] memo = new int[k + 1];
Arrays.fill(memo, -1);
memo[0] = 0; // 初始化状态
for (int v = 1; v &lt;= k; v++) {
int minCount = k + 1; // 模拟无穷大
for (int i = 0; i &lt; values.length; ++i) {
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; v) { continue; }
// 使用当前面值,得到剩余硬币总额
int rest = v - currentValue;
int restCount = memo[rest];
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
// 保留最小总额
int kCount = 1 + restCount;
if (kCount &lt; minCount) { minCount = kCount; }
}
// 如果是可用组合,记录结果
if (minCount != k + 1) { memo[v] = minCount; }
}
return memo[k];
}
int getMinCountsDPSol() {
int[] values = { 3, 5 }; // 硬币面值
int total = 22; // 总值
// 求得最小的硬币数量
return getMinCounts(total, values); // 输出答案
}
```
C++ 实现:
```
int GetMinCounts(int k, const std::vector&lt;int&gt;&amp; values) {
std::vector&lt;int&gt; memo(k + 1, -1); // 创建备忘录
memo[0] = 0; // 初始化状态
for (int v = 1; v &lt;= k; v++) {
int minCount = k + 1; // 模拟无穷大
for (int i = 0; i &lt; values.size(); ++i) {
int currentValue = values[i];
// 如果当前面值大于硬币总额,那么跳过
if (currentValue &gt; v) { continue; }
// 使用当前面值,得到剩余硬币总额
int rest = v - currentValue;
int restCount = memo[rest];
// 如果返回-1说明组合不可信跳过
if (restCount == -1) { continue; }
// 保留最小总额
int kCount = 1 + restCount;
if (kCount &lt; minCount) { minCount = kCount; }
}
// 如果是可用组合,记录结果
if (minCount != k + 1) { memo[v] = minCount; }
}
return memo[k];
}
int GetMinCountsDPSol() {
std::vector&lt;int&gt; values = { 3, 5 }; // 硬币面值
int total = 11; // 总值
// 求得最小的硬币数量
return GetMinCounts(total, values); // 输出答案
}
```
我们的关注点在GetMinCounts函数上该函数先定义了一个“新款”状态备忘录用数组memo来表示通常将其称之为 DP 数组DP 是 Dynamic Programming 的缩写即动态规划。你看,是不是高级起来了)。
这个备忘录由数组构成,其定义是:当目标兑换金额为 i 时,至少需要 memo[i] 枚硬币才能凑出。
有了备忘录的定义后,我们接下来再依据状态转移方程的指导来**初始化状态**
1. 将 F(0) 初始化成 0即 memo[0]=0
1. 把备忘录中剩余的位置初始化成 k + 1。凑成金额 k 的硬币数至多只可能等于 k (如果硬币的最低面值是 1因此初始化为 k + 1 就相当于将这些位置初始化成正无穷大,便于后续**决策**时取最小值。
接着我们从1开始遍历求解 F(1) 的结果,直到求解 F(k) 的结果为止。循环结束后我们想要的结果就存储在 memo[k] 中,也就是 F(k) 的解。
在这个基于原来递归代码上改进得到的代码中,我们来看一下每次循环中做了什么。每一次循环都包含一个小循环,这个小循环会遍历所有的面值。
<li>先看当前面额总值是否小于当前硬币面额。如果是,说明组合不存在,直接进入下一轮循环。<br>
&nbsp;</li>
1. 否则,我们就可以认为已经使用了这一枚硬币,那么就求得使用完硬币后的余额 rest并从备忘录中获取 F(rest) 的结果:
>
<p>a. 如果 F(rest) 为 -1说明 F(rest) 组合不存在,子问题不成立那么当前问题也就无解,直接进入下一轮循环;<br>
b.如果返回的值不是 -1说明组合存在那么求 F(rest) + 1并和当前最小硬币总数比较取最小值。</p>
1. 内部循环结束后,我们看一下 minCount 的值:
>
<p>a. 如果是 -1说明 F(v) 不存在,那么不做任何处理,保留 F(v)=-1 即可;<br>
b. 否则将最小值存入 memo[v]表示已经求得f(v)的值,准备为后续的问题使用。</p>
这样我们就通过这种自下而上的方法将递归转换成了循环。但是,这段代码还是跟我们常见的动态规划代码有些出入,不过没有关系,经过简单的调整就可以把它变漂亮。我先给出代码,然后再对其进行解释。
Java 实现:
```
int getMinCounts(int k, int[] values) {
int[] memo = new int[k + 1]; // 创建备忘录
memo[0] = 0; // 初始化状态
for (int i = 1; i &lt; k + 1; i++) { memo[i] = k + 1; }
for (int i = 1; i &lt; k + 1; i++) {
for (int coin : values) {
if (i - coin &lt; 0) { continue; }
memo[i] = Math.min(memo[i], memo[i - coin] + 1); // 作出决策
}
}
return memo[k] == k + 1 ? -1 : memo[k];
}
int getMinCountsDPSolAdvance() {
int[] values = { 3, 5 }; // 硬币面值
int total = 22; // 总值
return getMinCounts(total, values); // 输出答案
}
```
C++实现:
```
int GetMinCounts(int k, const std::vector&lt;int&gt;&amp; values) {
int memo[k + 1]; // 创建备忘录
memo[0] = 0; // 初始化状态
for (int i = 1; i &lt; k + 1; i++) { memo[i] = k + 1; }
for (int i = 1; i &lt; k + 1; i++) {
for (auto coin : values) {
if (i - coin &lt; 0) { continue; }
memo[i] = min(memo[i], memo[i - coin] + 1); // 作出决策
}
}
return memo[k] == k + 1 ? -1 : memo[k];
}
int GetMinCountsDPSolAdvance() {
std::vector&lt;int&gt; values = { 3, 5 }; // 硬币面值
int total = 11; // 总值
return GetMinCounts(total, values); // 输出答案
}
```
现在我们看一下,每一次循环中是如何做**决策**的。每一次循环都包含一个小循环,这个小循环会遍历所有的面值。
<li>跟之前一样,我们先看当前面额总值是否小于当前硬币面额。如果是,则说明组合不存在,直接进入下一轮循环。<br>
&nbsp;</li>
1. 否则,就可以认为已经使用了这一枚硬币,这时我们要作出决策:
>
<p>a. 如果采纳了这枚硬币,则凑的硬币数量需要 +1这时“状态A”是 memo[i - coin] + 1<br>
b. 如果不采纳这枚硬币则凑的硬币数量不变这时“状态B”是 memo[i]<br>
c. 显然硬币找零问题是求最值问题即最少需要几枚硬币凑出总额k。因此我们在这里作出决策在状态A与状态B中谁的硬币数量更少即取最小值 min(状态A, 状态B)。</p>
1. 当循环结束后,我们看一下备忘录中位置为 k 的值是多少,即 memo[k]
>
<p>a. 如果是 k + 1就意味着在初始化状态时的值没有被更新过是“正无穷大”。这时按照题目要求返回 -1<br>
b. 否则,我们就找到了最少凑出硬币的数量,返回它,就是我们的答案。</p>
这样一来,借助于自底向上的方法,我们成功的将递归转换成了迭代。
这段代码的时间复杂度是非常标准的 O(m*n)。它不会有任何额外的性能开销,我们通过动态规划完美地解决了硬币找零问题。
## 通用的动态规划
在掌握了如何使用标准的动态规划来解决硬币找零问题后,我们有必要将其推而广之,来看看解决动态规划面试问题的通用框架,或者说套路。
在这里,我会给出一个经验总结,而非严格的数学推导。
动态规划问题的核心是写出正确的状态转移方程,为了写出它,我们要先确定以下几点:
1. 初始化状态:由于动态规划是根据已经计算好的子问题推广到更大问题上去的,因此我们需要一个“原点”作为计算的开端。在硬币找零问题中,这个初始化状态是 memo[0]=0
1. 状态:找出子问题与原问题之间会发生变化的变量。在硬币找零问题中,这个状态只有一个,就是剩余的目标兑换金额 k
1. 决策:改变状态,让状态不断逼近初始化状态的行为。在硬币找零问题中,挑一枚硬币,用来凑零钱,就会改变状态。
一般来说,状态转移方程的**核心参数**就是**状态**。
接着,我们需要自底向上地使用备忘录来消除重叠子问题,构造一个备忘录(在硬币找零问题中,它叫 memo。为了通用我们以后都将其称之为 DP table
最后,我们需要实现决策。在硬币找零问题中,决策是指挑出需要硬币最少的那个结果。通过这样几个简单步骤,我们就能写出状态转移方程:
$$DP(n)=\left\{\begin{array}{c}<br>
0,n=0\\\<br>
-1,n&lt;0\\\<br>
min(DP(n), 1+DP(n-c)), c \in values<br>
\end{array}\right.$$
由于是经验因此它在90%以上的情况下都是有效的,而且易于理解。至于严格的数学推导和状态转移方程框架,我会在后续的课程中给出。
## 从贪心算法到动态规划
我们从最开始的贪心算法,到暴力递归、带备忘录的递归,通过分析问题最终推导出了动态规划解法。这么做的目的在于,当我们在后续课程中扩展到复杂面试问题时,你仍然能够拥有清晰的核心思路。
到这儿我们已经完美地解决了硬币找零问题是时候做个小小的总结了基于第14课
首先,贪心算法是根据当前阶段得到局部最优解,然后再看下一个阶段,逐个求解。这样导致的问题就是,我们可能永远无法得到真正的最优解:整体最优解。
为了解决这个问题,我们在贪心算法中加入了回溯的过程。如果无法求解的时候,就会返回,然后重新尝试当前阶段的“局部次优方案”,重新计算这种情况下的解。这样一来,我们至少保证了所有问题都能求得一个解。
但是如果遇到一些局部最优解前提条件不一定满足全局最优解的情况,这种方法也不一定能让我们找到整体最优解,因为贪心算法里我们找到一个解就结束了,如果约束不足,那么返回可能不一定是整体最优解。
为了解决贪心算法的问题,真正求得整体最优解,我们就必须得到问题解的所有可能组合。这个时候我们就要利用递归来解决问题。
递归就是自顶向下求得满足问题条件的所有组合,并计算这些组合的解,最后从这些组合的解中取出最优解,这样暴力计算出来的结果必定是整体最优解。
但是这样就又出现了效率问题,暴力递归在计算量巨大的情况下,时间复杂度实在太高了,几乎会呈现指数爆炸形式。那么我们就得考虑是否有些问题可以进行剪枝优化。
我提出了一些剪枝优化的方法,重点介绍的就是利用重叠子问题进行优化。在递归求解过程中我们会把一个大问题分解成多个子问题,那些在求解计算分支中可能被反复求解的子问题就是所谓的重叠子问题。
如果这些重叠子问题无后效性,那么我们就可以利用缓存的方法,在求得每个子问题的解之后将求解结果存入缓存数组中。如果在后续的计算分支中遇到相同的子问题,就直接从备忘录中取出我们已经计算过的结果。
这样一来,我们就不需要浪费时间重复求解已经求解的问题,在这种情况下可以将时间复杂度约束在多项式级别。
但是递归求解最后还是会有性能损耗问题,因此这时我正式引入了动态规划。在经历了这些讨论与探索后,你现在应该能够理解动态规划与贪心、回溯、递归的关系了。
## 课程总结
带备忘录的递归解法从传统意义上说已经是动态规划思想的范畴了,但它使用的是自顶向下的处理方式来解题,它离我们日常看到的动态规划还有差距。
这个差距不仅仅体现在代码的形式上,更重要的是它仍然还不够好:递归本身的性质导致了算法执行时额外的存储开销。
为此,我们正式引入自底向上的一种处理方式,并用迭代代替了递归,实现了较为简洁的硬币找零动归解法。在多项式级别的算法时间复杂度内,我们用最快的速度得到了我们想要的结果。
另外,动态规划的关键是状态转移方程,为了写出它,我们需要按照套路找出以下项目:
1. 初始化状态:由于动态规划是根据已经计算好的子问题推广到更大问题上去的,因此我们需要一个“原点”作为计算的开端;
1. 状态:找出子问题与原问题之间会发生变化的变量,这个变量就是状态转移方程中的参数;
1. 决策:改变状态,让状态不断逼近初始化状态的操作。这个决策,就是状态转移方程状态转移的方向。
最后,我们要将以上信息组装起来,一般来说就可以得到动态规划的状态转移方程了。
## 课后思考
如果给你一个包含正数和负数的整数数组,你能否找到一个具有最大和的连续子数组,其中子数组最少包含一个元素,返回其最大和。比如输入的子数组是 [-2, 1, -3, 1, -1, 6, 2, -5, 4],输出是 8因为连续子数组 [1, -1, 6, 2] 的和最大。请你思考一下,并使用动态规划的方法来求解此问题。
最后,欢迎留言和我分享你的答案,也不妨把问题发给你的好朋友看看,邀请他一起讨论。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="05面试即正义第一期什么样的问题应该使用动态规划" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/31/cc254efe048056654818a724f8dc3231.mp3"></audio>
你好,我是卢誉声。
作为“初识动态规划”模块的最后一课,今天我们不谈具体的解决方案了,我们来聊聊面试相关的话题,做个总结,也为我们后面的深入学习打下一个良好的基础。
那说起动态规划,我不知道你有没有这样的困扰,在掌握了一些基础算法和数据结构之后,碰到一些较为复杂的问题还是无从下手,面试时自然也是胆战心惊。如果我说动态规划是个玄幻的问题其实也不为过。究其原因,我觉得可以归因于这样两点:
- 你对动态规划相关问题的套路和思想还没有完全掌握;
- 你没有系统地总结过究竟有哪些问题可以用动态规划解决。
知己知彼,你想把动态规划作为你的面试武器之一,就得足够了解它;而应对面试,总结、归类问题其实是个不错的选择,这在我们刷题的时候其实也能感觉得到。
那么今天,我们就针对以上两点,系统地谈一谈究竟什么样的问题可以用动态规划来解。相信这节课过后,你就能有针对性地攻克难关了,无论是面试还是工程实践都能做到有的放矢。
## 动态规划是一种思想
动态规划算法,这种叫法我想你应该经常听说。嗯,从道理上讲这么说我觉得也没错,首先动态规划它不是数据结构,这一点毋庸置疑,并且严格意义上来说它就是一种算法。但更加准确或者更加贴切的提法应该是说动态规划是一种思想。
**那什么是思想?算法和思想又有什么区别呢?**
一般来说,我们都会把算法和数据结构放一起来讲,这是因为它们之间密切相关,而算法也往往是在特定数据结构的基础之上对解题方案的一种严谨的总结。
比如说,在一个乱序数组的基础上进行排序,这里的数据结构指的是什么呢?很显然是数组,而算法则是所谓的排序。至于排序算法,你可以考虑使用简单的冒泡排序或效率更高的快速排序方法等等来解决问题。
没错,你应该也感觉到了,算法是一种简单的经验总结和套路。那什么是思想呢?相较于算法,思想更多的是指导你我来解决问题。
比如说,在解决一个复杂问题的时候,我们可以先将问题简化,先解决简单的问题,再解决难的问题,那么这就是一种指导解决问题的思想。另外,我们常说的分治也是一种简单的思想,当然它在诸如归并排序或递归算法当中会常常被提及。
而动态规划就是这样一个指导我们解决问题的思想:**你需要利用已经计算好的结果来推导你的计算,即大规模问题的结果是由小规模问题的结果运算得来的。**这句话对于你充分理解动态规划的基本原理十分重要,希望你能记下来。
简单理解的话,你可以这样认为:**算法是一种经验总结,而思想则是用来指导我们解决问题的。**
既然动态规划是一种思想,那它实际上就是一个比较抽象的概念了,也很难和实际的问题关联起来。所以说,弄清楚什么样的问题可以使用动态规划来解,就显得十分重要了。
## 动态规划问题的典型特点
在前几课中,我们已经基本了解了动态规划的基本概念,从贪心算法、暴力递归再到最后的动态规划解法,我们比较完美地解决了提出的问题。在这个过程中,你有没有想过这些问题为什么可以用动态规划来解决?是别人这么做我也要这么做吗?别人的经验又是从何而来?
事实上,动态规划是运筹学上的一种最优化方法,只不过在算法问题上应用广泛。接下来我们就深挖一层,看看动归问题所具备的一些特点。
### 求“最”优解问题(最大值和最小值)
除非你碰到的问题是简单到找出一个数组中最大的值这样,对这种问题来说,你可以对数组进行排序,然后取数组头或尾部的元素,如果觉得麻烦,你也可以直接遍历得到最值。不然的话,你就得考虑使用动态规划来解决这个问题了。这样的问题一般都会让你求最大子数组、求最长递增子数组、求最长递增子序列或求最长公共子串、子序列等等。不知道你发现没有,这些问题里都包含一个“最”字,如果出现了这个字,那么你就该警惕它是否是动归问题。那具体怎么判断呢?
既然是要求最值,不妨先想一下核心问题是什么。其实在真的解决最值问题的时候,你应该按照这样的思考顺序来解决问题:
- 优先考虑使用贪心算法的可能性;
- 然后是暴力递归进行穷举(但这里的数据规模不大);
- 还是不行呢?选择动态规划!
你也看到了,求解动态规划的核心问题其实就是穷举。那么因为我们要求最值,就肯定要把所有可行的答案穷举出来,然后在其中找最值就好了嘛。你看,动态规划也不过如此,就两个字:**穷举**。
当然了,动态规划问题也不会这么简单了事,我们还需要考虑待解决的问题是否存在重叠子问题、最优子结构等特性。我们已经在前面的课程中讲清楚了重叠子问题,而对于最优子结构,我会在后续的内容中继续给你讲清楚。
清楚了特点,那根据我的经验,绝大多数面试者其实还是很难在第一时间针对具体问题采取明确的行动:这个问题到底该不该用动态规划思想来解呢?
我在这里列出几道常见的经典面试题,如果你遇到它们了,不要犹豫,使用动态规划来解。这样不仅目标明确,而且会在面试时给面试官留下极为深刻的印象(这一讲我们只是分析下题目,后面的课程中会涵盖所有问题的解法,并归纳总结解题套路)。
**1. 乘积最大子数组**
问题:给你一个整数数组 numbers找出数组中乘积最大的连续子数组该子数组中至少包含一个数字返回该子数组的乘积。
```
示例1
输入: [2,7,-2,4]
输出: 14
解释: 子数组 [2,7] 有最大乘积 14。
```
```
示例2
输入: [-5,0,3,-1]
输出: 3
解释: 结果不能为 15, 因为 [-5,3,-1] 不是子数组,是子序列。
```
首先,很明显这个题目当中包含一个“最”字,使用动态规划求解的概率就很大。这个问题的目的就是从数组中寻找一个最大的连续区间,确保这个区间的乘积最大。由于每个连续区间可以划分成两个更小的连续区间,而且大的连续区间的结果是两个小连续区间的乘积,因此这个问题还是求解满足条件的最大值,同样可以进行问题分解,而且属于求最值问题。同时,这个问题与求最大连续子序列和比较相似,唯一的区别就是你需要在这个问题里考虑正负号的问题,其它就相同了。
**2. 最长回文子串**
问题给定一个字符串s找到s中最长的回文子串。你可以假设s的最大长度为1000。
```
示例1
输入: &quot;babad&quot;
输出: &quot;bab&quot;
```
```
示例2
输入: &quot;cbbd&quot;
输出: &quot;bb&quot;
```
这个问题依然包含一个“最”字,同样由于求解的最长回文子串肯定包含一个更短的回文子串,因此我们依然可以使用动态规划来求解这个问题。
**3. 最长上升子序列**
问题:给定一个无序的整数数组,找到其中最长上升子序列的长度。可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
```
示例:
输入: [10,9,2,5,3,7,66,18]
输出: 4
解释: 最长的上升子序列是 [2,3,7,66],它的长度是 4。
```
这个问题依然是一个最优解问题假设我们要求一个长度为5的字符串中的上升自序列我们只需要知道长度为4的字符串最长上升子序列是多长就可以根据剩下的数字确定最后的结果。
### 求可行性True或False
接下来我们再来看另一种可能的动态规划问题。
如果有这样一个问题让你判断是否存在一条总和为x的路径如果找到了就是True如果找不到自然就是False或者让你判断能否找到一条符合某种条件的路径那么这类问题都可以归纳为求可行性问题并且可以使用动态规划来解。比如我们前面课程中提到的找零钱问题是不是就很好地说明了这一点
**1. 凑零兑换问题**
问题:给你 k 种面值的硬币,面值分别为 c1, c2 … ck每种硬币的数量无限再给一个总金额 amount问你最少需要几枚硬币凑出这个金额如果不可能凑出算法返回 -1 。
```
示例1
输入: c1=1, c2=2, c3=5, c4=7, amount = 15
输出: 3
解释: 11 = 7 + 7 + 1。
```
```
示例2
输入: c1=3, amount =7
输出: -1
解释: 3怎么也凑不到7这个值。
```
这个问题显而易见如果不可能凑出我们需要的金额即amount最后算法需要返回-1否则输出可能的硬币数量。这是一个典型的求可行性的动态规划问题。
**2. 字符串交错组成问题**
问题:给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交错组成的。
```
示例1
输入: s1=&quot;aabcc&quot;,s2 =&quot;dbbca&quot;,s3=&quot;aadbbcbcac&quot;
输出: true
解释: 可以交错组成。
```
```
示例2
输入: s1=&quot;aabcc&quot;,s2=&quot;dbbca&quot;,s3=&quot;aadbbbaccc&quot;
输出: false
解释:无法交错组成。
```
这个问题稍微有点复杂但是我们依然可以通过子问题的视角首先求解s1中某个长度的子字符串是否由s2和s3的子字符串交错组成直到求解整个s1的长度为止也可以看成一个包含子问题的最值问题。
### 求方案总数
除了求最值与可行性之外,求方案总数也是比较常见的一类动态规划问题。比如说给定一个数据结构和限定条件,让你计算出一个方案的所有可能的路径,那么这种问题就属于求方案总数的问题。我在这里介绍几个典型例子,帮助你理解。
**1.硬币组合问题**
问题:英国的英镑硬币有 1p, 2p, 5p, 10p, 20p, 50p, £1 (100p), 和 £2 (200p)。比如我们可以用以下方式来组成2英镑1×£1 + 1×50p + 2×20p + 1×5p + 1×2p + 3×1p。问题是一共有多少种方式可以组成n英镑? 注意不能有重复,比如 1英镑+2个50P 和 50P+50P+1英镑是一样的。
```
示例1
输入: 2
输出: 73682
```
这个问题本质还是求满足条件的组合,只不过这里不需要求出具体的值或者说组合,只需要计算出组合的数量即可。
**2.路径规划问题**
问题:一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角,共有多少路径?
```
示例1
输入: 2 2
输出: 2
```
```
示例1
输入: 3 3
输出: 6
```
这个问题还是一个求满足条件的组合数量的问题,只不过这里的组合变成了路径的组合。我们可以先求出长宽更小的网格中的所有路径,然后再在一个更大的网格内求解更多的组合。这和硬币组合的问题相比没有什么本质区别。
这里有一个规律或者说现象需要强调,那就是求方案总数的动态规划问题一般都指的是求“一个”方案的所有具体形式。如果是求“所有”方案的具体形式,那这种肯定不是动态规划问题,而是使用传统递归来遍历出所有方案的具体形式。
为什么这么说呢?因为你需要把所有情况枚举出来,大多情况下根本就没有重叠子问题给你优化。即便有,你也只能使用备忘录对遍历进行一个简单加速。但本质上,这类问题不是动态规划问题。接下来你就会看到这样的例子,你可以找找区别在哪里。
## 进一步确认是否为动态规划问题
从前面我所说来看,如果你碰到了求最值、求可行性或者是求方案总数的问题的话,那么这个问题就八九不离十了,你基本可以确定它就需要使用动态规划来解。但这里还有一些极具迷惑性的问题,你需要格外注意。
### 数据不可排序Unsortable
假设我们有一个无序数列,希望求出这个数列中最大的两个数字之和。很多初学者刚刚学完动态规划会走火入魔到看到最优化问题就想用动态规划来求解,嗯,那么这样应该也是可以的吧……不,等等,这个问题不是简单做一个排序或者做一个遍历就可以求解出来了吗?所以学完动态规划后,你一定要注意,遇到这些简单的问题不要把事情变得更复杂了。先考虑一下能不能通过排序来简化问题,如果不能,才极有可能是动态规划问题。还是看个例子。
**最小的k个数**
问题:输入整数数组 arr ,找出其中最小的 k 个数。例如输入4、5、1、6、2、7、3、8这8个数字则最小的4个数字是1、2、3、4。
```
示例1
输入arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]
```
```
示例2
输入arr = [0,1,2,1], k = 1
输出:[0]
```
我们发现虽然这个问题也是求“最”值,但其实只要通过排序就能解决,所以我们应该用排序、堆等算法或者数据结构来解决,而不应该用动态规划。
### 数据不可交换Non-swapable
还有一类问题,可以归类到我们总结的几类问题里去,但是不存在动态规划要求的重叠子问题(比如经典的八皇后问题),那么这类问题就无法通过动态规划求解。这种情况需要避免被套进去。
**全排列**
问题:给定一个没有重复数字的序列,返回其所有可能的全排列。
```
示例:
输入: [1,2,3]
输出:
[
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
```
这个问题虽然是求组合,但没有重叠子问题,更不存在最优化的要求,因此可以使用回溯处理,并不是动态规划的用武之地。
## 课程总结
今天,我们一起探讨了动态规划问题的本质,更准确或更加严谨地说,动态规划是一种指导我们解决问题的思想。
接着我们列出了辨别一个算法问题是否该使用动态规划来解的五大特点:
1. 求最优解问题(最大值和最小值);
1. 求可行性True或False
1. 求方案总数;
1. 数据结构不可排序Unsortable
1. 算法不可使用交换Non-swappable
如果面试题目出现这些特征那么在90%的情况下你都能断言它就是一个动归问题。
当然了就像我前面所讲的你还需要考虑这个问题是否包含重叠子问题与最优子结构在这个基础之上你就可以99%断言它是否为动归问题,并且也顺势找到了大致的解题思路,我会在后面的课程中继续跟你探讨这些问题,彻底解决你的疑惑。
通过上述这几个鲜明的特点,相信你能够在将来迅速地判断出问题是否为动态规划类问题,并使用对应的思想和套路来应对算法或面试问题。
## 课后思考
<li>
除了我在这里列出的动态规划特点以外,你觉得还有哪些类别的问题应该进行归纳总结?能否把你见过的或认为是动态规划的算法留在评论区,并分析一下它们又属于哪些类别。
</li>
<li>
我在前面提到过子数组与子序列的问题,请你思考一下,这两种情况有什么区别?
</li>
欢迎留言和我分享,我会第一时间给你反馈。如果今天的内容让你对动态规划的用法有了进一步的了解,也欢迎把它分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,326 @@
<audio id="audio" title="06 | 0-1背包动态规划的Hello World" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/eb/c80bf33dd694a8d8a6d845327bb80beb.mp3"></audio>
你好,我是卢誉声。从今天开始,我们正式进入动态规划套路模块。
不知道你是否跟我有过相似的经历那就是提起动态规划最先想到的就是背包问题。事实上背包问题分很多种大多数人首先遇到的一般是背包中的0-1背包问题。
因此,我把这个问题称作 Hello World这跟我们学习一门新的编程语言十分相似。它很经典又极具代表性能很好地展示动态规划思想对于你掌握动态规划面试题来说也十分有帮助。
在“初识动态规划”模块中相信你已经对动态规划问题有了一个比较全面的认识和了解。今天就让我们用一用前面所学的解题思路其实就是把总结出来的套路套用在0-1背包问题上看看能不能解决这道题。
那在开始前呢,我还是先提出一个简单的问题,那就是:**为什么将它称作0-1背包问题0-1代表什么**你不妨带着这个小问题,来学习今天的内容。
## 0-1 背包问题
我们先来看看0-1背包问题的描述。
问题:给你一个可放总重量为 $W$ 的背包和 $N$ 个物品,对每个物品,有重量 $w$ 和价值 $v$ 两个属性,那么第 $i$ 个物品的重量为 $w[i]$,价值为 $v[i]$。现在让你用这个背包装物品,问最多能装的价值是多少?
示例:
```
示例:
输入W = 5, N = 3
w = [3, 2, 1], v = [5, 2, 3]
输出8
解释:选择 i=0 和 i=2 这两件物品装进背包。它们的总重量 4 小于 W同时可以获得最大价值 8。
```
### 算法问题分析
这个问题的描述和示例都比较简单,而且容易理解。当遇到这样一个问题时,你该从哪里下手呢?
如果你是一个动态规划老手,当然就能一眼看出这是个动态规划问题。但如果你是第一次接触,也不用担心,接下来我就带着你判断一下。
按照我之前给你说过的思路,先看问题是怎么问的:“最多能装的价值的多少?”注意这里有一个“最”字,遇到这种问题我们应该最先想到什么呢?没错,贪心算法。那么贪心算法的局部最优能解决我们的问题吗?
事实上不太能,因为如果按照贪心算法来解的话,我们很难得到整体最优解。举个简单的例子,按照示例给出的输入,如果我们先选择 $i=0$ 和 $ i=1$ 的物品,那么总重量正好是$W=5$,但这不是最优解,因为总价值才 $7$。因此,为了获得整体最优解,我们该怎么办呢?显然就是穷举。
在后续的课程中,我会与你分享更多面试实战题目。届时你就会发现,当问题复杂到一定程度后,穷举真的不是一件容易的事。因此,我们优先考虑使用动态规划来解决这个问题。那么该问题满足动态规划的特征吗?我在这列举出来,你对照看一下:
1. 重叠子问题对于0-1背包问题来说即便我们不画出求解树也能很容易看出在穷举的过程中存在重复计算的问题。这是因为各种排列组合间肯定存在重叠子问题的情况
1. 无后效性:当我们选定了一个物品后,它的重量与价值就随即确定了,后续选择的物品不会对当前这个选择产生副作用。因此,该问题无后效性;
1. 最优子结构:当我们选定了一个物品后,继续做决策时,我们是可以使用之前计算的重量和价值的,也就是说后续的计算可以通过前面的状态推导出来。因此,该问题存在最优子结构。
### 写出状态转移方程
现在,我们确定了这是一个动态规划问题。接下来,让我们一起看看如何写出动态规划算法的核心,即状态转移方程。还记得之前总结的动态规划求解框架(或者说套路)吗?
首先,我们先来确定初始化状态。任何穷举算法(包括递归在内)都需要一个终止条件,这个所谓的终止条件,就是我们在动态规划解法当中的最初子问题,因此我们将其称作**初始化状态**。
在0-1背包中这个终止条件是什么呢显然当背包的容量为 0 或者物品的数量为 0 时要终止执行。如果体现在代码上,就是当物品总数为 0 时重量为 0而重量为 0 时显然物品数量也为 0。
接着,在什么情况下,会导致计算过程中不断逼近上面提到的初始化状态呢?其实题目中已经给出了答案。我们从背包的角度看待这个问题,将物品放入背包时:
1. 背包内物品的数量 $N$ 在增加,它是一个变量;
1. 同时,背包还能装下的重量 $W$ 在减少,它也是一个变量。
因此,当前背包内的物品数量 $N$ 和背包还能装下的重量 $W$ 就是这个动态规划问题的**状态参数**。
然后,我们再来看如何进行**决策**。在0-1背包问题中我们的决策无非就是该不该把当前这个物品放入背包中如果将该物品放入背包子问题的答案是多少如果没有放入子问题的答案又是多少。
我们曾说过,通常情况下,状态转移方程的参数就是状态转移过程中的变量,即状态参数。而函数的返回值就是答案,在这里就是最大价值。因此,我们从上面两种决策情况中取最优解,即 max (放入该物品, 不放入该物品)。
在确定了初始化状态、状态参数和决策后,我们就可以开始尝试写状态转移方程了。由于这是我们第一次正式面对动归问题,我会先把递归形式的状态转移过程描述出来,代码如下:
```
/*
* tn: traversed n即已经遍历过的物品
* rw: reserved w即背包还能容量的重量。
*/
DP(int tn, int rw) {
// 当遍历完所有物品时,就该返回 0 了,因为没有物品也就没有价值了
if tn &lt; 0
return 0
// 当背包还能容纳的重量已经小于当前物品的重量时,显然这个物品不能放入背包
if rw &lt; w[tn]
return DP(tn - 1, rw)
// 作出决策,该不该放入物品:
// 1. 放入:那么价值是 DP(tn - 1, rw - w[tn])
// 2. 不放入:那么价值是 DP(tn - 1, rw)。
return max(DP(tn - 1, rw), DP(tn - 1, rw - w[tn]) + v[tn])
}
```
顺着这个思路,我把状态转移方程给写出来,它是这样的:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max(DP(tn-1,rw), DP(tn-1,rw-w[tn])+v[tn])),rw&gt;=w[tn]<br>
\end{array}\right.$$
现在我们有了针对0-1背包问题的完整状态转移方程可以开始编写代码了。
### 编写代码进行求解
但在编写代码前,还有一个小问题需要解决,就是我们需要为动态规划代码准备一个备忘录,来存储计算过的子问题答案。那么这个备忘录的数据结构应该是什么样的呢?
从前面的分析可以看出,状态转移方程中有两个状态参数,并通过这两个状态参数确定了一个子问题的答案。因此,我们可以使用一个二维数组作为备忘录。
为了通用起见,我将其命名为$DP[tn][rw]$,其中行代表的是 $tn$,表示第几个物品;列代表的是$rw$,表示背包还能容纳的重量。这个索引组合(比如$DP[2][3]$)对应位置的值,就是这个子问题的答案,表示当背包还能容纳 3 的重量时,放入前 2 件物品的最大价值。
所有先决条件都解决了,现在来看一下如何用标准的动归解法来求解此问题,我直接给出代码。
Java 实现:
```
int dp(int[] w, int[] v, int N, int W) {
// 创建备忘录
int[][] dp = new int[N+1][W+1];
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
for (int tn = 1; tn &lt; N + 1; tn++) { // 遍历每一件物品
for (int rw = 1; rw &lt; W + 1; rw++) { // 背包容量有多大就还要计算多少次
if (rw &lt; w[tn]) {
// 当背包容量小于第tn件物品重量时只能放入前tn-1件
dp[tn][rw] = dp[tn-1][rw];
} else {
// 当背包容量还大于第tn件物品重量时进一步作出决策
dp[tn][rw] = Math.max(dp[tn-1][rw], dp[tn-1][rw-w[tn]] + v[tn]);
}
}
}
return dp[N][W];
}
int solveDP() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
int[] w = {0, 3, 2, 1}; // 物品的重量
int[] v = {0, 5, 2, 3}; // 物品的价值
return dp(w, v, N, W); // 输出答案
}
```
C++ 实现:
```
int DP(const std::vector&lt;int&gt;&amp; w, const std::vector&lt;int&gt;&amp; v, int N, int W) {
int dp[N+1][W+1]; memset(dp, 0, sizeof(dp)); // 创建备忘录
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
for (int tn = 1; tn &lt; N + 1; tn++) { // 遍历每一件物品
for (int rw = 1; rw &lt; W + 1; rw++) { // 背包容量有多大就还要计算多少次
if (rw &lt; w[tn]) {
// 当背包容量小于第tn件物品重量时只能放入前tn-1件
dp[tn][rw] = dp[tn-1][rw];
} else {
// 当背包容量还大于第tn件物品重量时进一步作出决策
dp[tn][rw] = max(dp[tn-1][rw], dp[tn-1][rw-w[tn]] + v[tn]);
}
}
}
return dp[N][W];
}
int DPSol() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
std::vector&lt;int&gt; w = {0, 3, 2, 1}; // 物品的重量
std::vector&lt;int&gt; v = {0, 5, 2, 3}; // 物品的价值
return DP(w, v, N, W); // 输出答案
}
```
我们几乎照搬了状态转移方程描述的内容到代码里,因此这段代码通俗易懂。
首先,我们定义了两个数组,其中 $w$ 用来表示物品的重量,而 $v$ 用来表示物品的价值。这里需要注意的是,每个数组的第 0 项都是 0。由于小于 0 的值对应的都应该是 0因此我们可以通过这个方法来省去冗余的 if 判断逻辑。
我们已经定义了备忘录即 $DP[tn][rw]$ 数组的含义:当背包还能装 $rw$ 重量的物品,放入了前 $tn$ 件物品时的最大价值。接下来,我们再依据状态转移方程的定义来**初始化状态**
1. 创建一个大小为 N+1 / W+1 的二维数组并将所有位置初始化为0
1. 初始化状态,即前面提到的穷举的终止条件,把所有的 $dp[0][i]$ 和 $dp[j][0]$ 全部都设置为 0。
接着,进入编写函数主体循环的阶段,让我们看看每一次循环中是如何做**决策**的:
1. 主循环分为两层,第 1 层遍历所有物品,也就是尝试放入每个物品;第 2 层遍历背包容量,也就是假定当前背包容量是 $rw$ 的时候,求在背包容量为$rw$时,放入当前物品的最大价值;
1. 如果背包容量小于当前物品价值,那么这个时候最大价值也就是当前容量不变,使用上一个物品的最大价值即可;
1. 如果背包容量大于当前物品价值,那么这个时候最大价值也就是从以下两个决策中挑选:
>
<p>a. 放入这个物品前的最大价值 + 当前物品价值和作为答案;<br>
b. 不放入这个物品时,当前容量的最大价值作为答案。</p>
我在下面的表格中,用箭头画出了容量为 5 时的求解路径。你可以参照这个求解路径来加深对代码的理解。
<img src="https://static001.geekbang.org/resource/image/af/d0/afbe718a68b8a1f89c42c259a75ca7d0.png" alt="">
在面试过程中,如果能养成对编写代码重审的习惯,也是可以加分的。因此,在我们实现完决策逻辑后,再对代码做些基本的检查,就可以“交卷”了。
## 0-1 背包问题的延伸
事实上由于0-1背包问题过于经典在真正的算法面试环节如果涉及动态规划问题时基本不会让你直接解决这个问题而是让你解决这个问题的变种。
因此我们有必要对0-1背包问题做一个延伸来看看如何把一个看似陌生的动态规划问题转化成0-1背包问题来进行求解。
### 算法问题分析
我们先来看看问题的描述。
问题:有一堆石头,每块石头的重量都是正整数。每次从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 $x$ 和 $y$,且 $x ≤ y$。那么粉碎的可能结果如下:
1. 如果 $x$ 与 $y$ 相等,那么两块石头都会被完全粉碎;
1. 否则,重量为 $x$ 的石头将会完全粉碎,而重量为 $y$ 的石头的新重量为 $y - x$。
最后,最多只会剩下一块石头。返回此时石头最小的可能重量。如果没有石头剩下,就返回 0。
示例:
```
示例:
输入:[1, 2, 1, 7, 9, 4]
输出:
解释Round 1: (2, 4) -&gt; 2, 数组变成 [1, 1, 7, 9, 2]
Round 2: (7, 9) -&gt; 2, 数组变成 [1, 1, 2, 2]
Round 3: (2, 2) -&gt; 0, 数组变成 [1, 1]
Round 4: (1, 1) -&gt; 0, 数组为空,返回 0
```
如果你是第一次遇见这个问题,那么你很可能跟绝大多数人(包括我在内)一样一脸茫然,一上来就没有什么思路。这其实很正常,事实上动态规划的面试题有规可循,总共就那么几种,见过了,以后就知道了。
我们先来读一下题目,最后的问题中包含了“最”字,这时你就应该小心了。同时,这个题目显然需要通过排列组合的方式从所有可能组合中找到最优解,因此会涉及穷举,如果涉及穷举,就很有可能涉及重叠子问题。
我刚才在0-1背包中使用了一个模版化的分析方法我建议你在这里对此问题进行类似的分析。分析后你就会发现这应该是一个动态规划问题。
### 转化成动态规划问题
现在,我们就来讲一下到底如何将其转化为动态规划问题。
首先,请你观察一下上面提供的示例。在示例中,第一步组合 2 和 4求出 (4 - 2) = 2第二步组合 7 和 9求出 (9 - 7) = 2第三步组合 2 和 2求出 (2 - 2) = 0最后第四步组合 1 和 1同样得 0。我们把这个过程组合成一个式子它看起来是这样的
$$1-(1-((4-2)-(9-7)))$$
如果解开这些括号,就可以得到 1 - 4 + 2 + 9 - 7 - 1。再做一下简单的变换就可以得到如下式子
$$1 + 2 + 9 - 1 - 4 - 7$$
这个时候,我们可以把这个公式分成两组,一组是从数组中挑选出几个数字相加;然后,将另外几个数字相减,求两个数字的差。最后确保这个差最小。
从直觉上来说,如何确保两组数字之差最小呢?
我们可以看到如果一组数字接近所有数字之和的 1/2那么两组数字之差肯定越小比如上面的示例中所有数字之和是 24所以一组数字是 12另一组数字也是 12最后肯定能得到最小值0。
现在,假设有一个背包,背包的容量是 1224/2。接着我们有一堆的物品重量分别是 [1, 2, 1, 7, 9, 4]注意我们设它的价值与重量相同。现在我们希望选出的物品放到背包里的价值最大这样一来我们就可以把这个题目转化成0-1背包问题了。
### 写出状态转移方程
那么动态规划部分的状态转移方程就和0-1背包问题中的一样如下所示
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max=(DP(tn-1,rw), DP(tn-1,rw-w[tn])+v[tn])),rw&gt;=w[tn]<br>
\end{array}\right.$$
看到了吧我们巧妙地把这个看似让人蒙圈的问题成功转化成了一个标准的0-1背包问题而且能够直接复用我们所学的内容。
万事俱备后就是编写代码由于状态转移方程与0-1背包问题如出一辙因此我们这里就省略编码这一环节了。
## 通用的动态规划
在上一个模块“初识动态规划”中,我们曾经介绍了一种经过经验总结的动态规划解题框架(或者说是套路)。其实当时,我并未给出比较严格的框架,作为补充完善动态规划理论的重要一环,我们很有必要学习、掌握通用的动态规划的框架。
我们已经知道,一个动态规划问题是指它可以从大问题中找到无后效性的重叠子问题。所谓无后效行是指,其子问题不会双向依赖,只会单向依赖。否则,我们就无法确保子问题处理后,更大的问题一定能取到子问题的解。
现在,我们准备对动态规划问题进行泛化统一建模,如果用数学语言描述就如下公式所示:
$$f(x)=\left\{\begin{array}{c}<br>
d(x), x \in V_{I}\\\<br>
g(\{v(f(s(x,c)),c)\}),c \in values(x)<br>
\end{array}\right.$$
我们该怎么理解这个公式呢?首先,我们需要考虑一些边界情况,如果输入向量 $x$,那么在边界组合 $V_{I}$ 中,用一个边界函数 $d(x)$ 直接返回 $f(x)$ 的值就不需要再划分子问题了。比如在0-1背包问题中当 $tn$ 或 $rw$ 小于等于 0 时,这个值就是 0。
否则,说明这是一个可以划分子问题的问题,那么我们就需要从可选组合 $values$ 中取出用于划分子问题的备选值。需要牢记的是,在复杂问题中这个 $values$ 可能不是一个一成不变的组合,它会随着当前状态 $x$ 变化而变化。
接着,我们对每一个备选值 $c$(与上面的 $x$ 类似,同样可能是一个向量),通过函数 $s(x, c)$ 求得当前备选值的子问题的 $x$, $c$。然后,通过 $f(s(x, c))$ 得到这个子问题的结果。
再接着,我们通过子问题 $v(f(s(x, c)), c)$ 的结果和当前备选值 $c$,来求得当前问题的解。因为我们有一系列的备选值 $c$,因此会得到一个当前问题的求解集合。
最后,我们通过最优化函数 $g(t)$ 进行求解。比如原问题是求最小值,那么 $g(t)$ 就是 $min(t)$;如果是求最大值,那么就是 $max(t)$。这两种是最为常见的函数,我们在前面的例题当中也都见过了。
这样一来,我们就可以把所有的问题都套入这个框架,写出对应的状态转移方程了。
## 课程总结
现在让我们回到这节课开头提出的那个问题那就是0-1背包问题中的 0 和 1 代表的到底是什么呢?
其实,你可以看到在整个算法计算过程中,每次我们只能做两种选择:
1. 放入当前物品;
1. 不放入当前物品。
如果我们对这个问题稍作修改:每个物品有一定的数量(注意不止一个),同时还允许在背包中反复放入多个相同的物品,那么这个问题就变成了每个物品应该放几个。
我们可以看到0-1背包就是这种问题的一个子集相当于每个物品都只有 1 个的背包问题!如果从放入数量的角度来看,放入当前物品就相当于当前的物品放入了 1 个,不放入当前物品就相当于放入了 0 个。
所以这就是为什么这个背包问题被称为0-1背包的根本原因。
充分理解0-1背包的解题思路对全面掌握背包问题来说至关重要。我会在下一节课为你讲解泛化的背包问题并给出衍生的面试问题讨论帮助你攻破背包问题难关。
## 课后思考
在这节课中我们介绍了0-1背包问题的延伸提出了一个“粉碎石头”的问题。现在请你按照求解0-1背包问题的思路全面分析一下这个问题然后写出求解的代码。
不知道你今天的收获如何呢?如果感觉已经掌握了解题思路,不妨也去考考你们的同事或者朋友吧,刚好也有机会复述一遍今天所学。

View File

@@ -0,0 +1,456 @@
<audio id="audio" title="07完全背包深入理解背包问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/c7/b69206f9ddcebe499b242a491431cdc7.mp3"></audio>
你好,我是卢誉声。
在上节课中,我们用动态规划解法,成功解决了动态规划领域中的 Hello World 问题。这个问题虽然比较初级,但却很有代表性,它比较全面地展示了动归解题的套路。
但光解决一个0-1背包问题显然不够过瘾。如果你觉得应用动态规划的解题套路还不太熟练没关系。现在我们就趁热打铁继续刨根问底讨论背包问题。
首当其冲的就是完全背包问题。它仍然是动态规划领域的经典问题但是比0-1背包问题要复杂一些。不过嘛我们之前总结的解题套路还是比较具有普适性的因此我们仍然可以将其套用在完全背包问题上。
在开始今天的课程前,请你思考这样一个问题:**既然都是背包问题那么完全背包跟0-1背包问题会如何影响状态转移方程呢**
你不妨带着这个问题,有针对性地学习今天的内容。
## 完全背包问题
我们先来看看完全背包问题的描述。
问题:给你一个可放总重量为 $W$ 的背包和 $N$ 个物品,对每个物品,有重量 $w$ 和价值 $v$ 两个属性,那么第 $i$ 个物品的重量为 $w[i]$,价值为 $v[i]$。现在让你用这个背包装物品,每种物品都可以选择任意多个,问这个背包最多能装的价值是多少?
示例:
```
示例:
输入W = 5, N = 3
w = [3, 2, 1], v = [5, 2, 3]
输出15
解释:当 i = 2 时,选取 5 次,总价值为 5 * 3 = 15。
```
问题描述还是这么简单如果你回过头去看上一课的0-1背包的问题描述你会发现完全背包问题只在原来的基础上多加了一句话那就是“每种物品都可以选择任意多个”。除此之外完全相同。
可不要小看这一句话,它的出现让我们的问题复杂度上了一个台阶。
### 算法问题分析
不同于0-1背包问题每件物品只能拿一次在完全背包问题中每件物品可以拿任意多件只要背包装得下就行。
如果从每件物品的角度来看与之相关的决策已经不再是选拿1或者不拿0而是拿 0 件、拿 1 件、拿 2 件……直到拿到 ($W / w[i]$) 件物品为止。
我曾在上一课中对0-1背包问题做了较为全面的分析最后得出的结论就是它是一个动态规划问题。那么为了起到对照的作用我在这里再次给出分析步骤不过比之前的稍微简化一些。
首先,题设中出现了“最多能装的价值是多少”这样的论断。既然有“最”字,那么我们需要先考虑贪心算法,这里我直接给出一个反例:按照示例中的提示,虽然 $i = 1$ 的物品价值最高,但最后得到的解不是真正的答案。
因此,为了获得整体最优解,我们需要考虑穷举。为了高效地进行穷举操作,我们需要考虑使用动态规划来解。仿照上一课的做法,我们对该问题做一个分析,看看它是否满足求解动态规划的特征。
1. 重叠子问题:在穷举的过程中肯定存在重复计算的问题。这是因为各种排列组合间肯定存在重叠子问题的情况;
1. 无后效性:选择了一个物品后,背包还能容纳的重量与总价值是确定的,后续选择的物品(即便重复选择相同的物品)不会对当前这个选择产生副作用。因此,该问题无后效性;
1. 最优子结构:在选定了一个物品后,继续做决策时,我们是可以使用之前计算的重量和价值,也就是说后续的计算可以通过前面的状态推导出来。因此,该问题存在最优子结构。
这个分析算法问题的方法特别有效,希望你能够养成这个基本分析的习惯。这样一来,你不仅能少走弯路,而且能有目的性地解决面试问题。
### 写出状态转移方程
既然我们已经确定了这是个动态规划问题,那么就拿出我们的法宝:动态规划解题框架。现在,就让我们沿着解题框架的顺序,来写出状态转移方程。
首先,我们先来确定动态规划解法当中的最初子问题,即**初始化状态**。这跟0-1背包问题有些类似由于物品的数量没有限制因此只有当背包的容量为 0 时要终止执行,但如果压根儿就没有物品可选,那么自然背包的重量也为 0。如果体现在代码上就是当没有物品时重量为 0而重量为 0 时显然物品数量也为 0。
接着,我们来确定动态规划问题中的**状态参数**这与0-1背包问题几乎一样
1. 背包内物品的数量 $N$ 在增加,它是一个变量;
1. 同时,背包还能装下的重量 $W$ 在减少,它也是一个变量。
因此,当前背包内的物品数量 $N$ 和背包还能装下的重量 $W$ 就是这个动态规划问题的状态参数。
然后,我们再来看如何进行**决策**。这里的区别跟0-1背包问题中的决策差别就比较大了。由于每种物品的数量是无限制的因此就像前面给出的示例那样我们可以将同一种物品多次放入背包。
因此,对于第 $tn$ 种物品,我们有 k 种选择(其中 0 ≤ k * $w[tn]$ ≤ W我们可以从 0 开始,拿第 0 件、第 1 件、第 2 件……直到第 ($W / w[tn]$) 件物品为止。然后在这么多子问题下,选择最优的那一种情况。
所以我们可以看出完全背包问题决策的核心在于针对一种物品它需要考察拿不同数量的情况下的最优解。这显然与0-1背包问题的决策完全不同总结来说就是
1. 0-1背包问题针对当前物品是放入背包还是不放入背包时的价值最大
1. 完全背包问题:针对当前物品,应放入多少件当前物品,价值最大。
最后,动态规划是需要一个**备忘录**来加速算法的。由于有两个状态参数,因此我们考虑使用二维数组来存储子问题的答案。跟之前一样,为了通用起见,我将其命名为 $DP[tn][rw]$,它的含义是:背包容量还剩 $rw$ 时,放入前 $tn$ 种物品时的最大价值。
由于这个问题跟0-1背包问题有些相似因此今天我们做一个新的尝试那就是在不写出递归代码的情况下直接根据上面的信息写出状态转移方程。它是这样的
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max\{ DP(tn-1,rw-k*w[tn])+k*v[tn] \},(0\leqq k \leqq rw)<br>
\end{array}\right.$$
我们有了完整的状态转移方程,就可以开始编写代码了。
### 编写代码进行求解
现在,所有的先决条件都解决了,因此我直接给出以下代码,你可以参考一下。
Java 实现:
```
int bag(int[] w, int[] v, int N, int W) {
// 创建备忘录
int[][] dp = new int[N+1][W+1];
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
// 根据rw尝试放入多次物品从中找出最大值作为当前子问题的最优解
for (int k = 0; k &lt;= rw / w[tn]; k++) {
dp[tn][rw] = Math.max(dp[tn][rw], dp[tn-1][rw-k*w[tn]] + k*v[tn]);
}
}
}
return dp[N][W];
}
int solveBag() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
int[] w = {0, 3, 2, 1}; // 物品的重量
int[] v = {0, 5, 2, 3}; // 物品的价值
return bag(w, v, N, W); // 输出答案
}
```
C++ 实现:
```
int DP(const std::vector&lt;int&gt;&amp; w, const std::vector&lt;int&gt;&amp; v, int N, int W) {
int dp[N+1][W+1]; // 创建备忘录
memset(dp, 0, sizeof(dp));
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
// 根据rw尝试放入多次物品从中找出最大值作为当前子问题的最优解
for (int k = 0; k &lt;= rw / w[tn]; k++) {
dp[tn][rw] = max(dp[tn][rw], dp[tn-1][rw-k*w[tn]] + k*v[tn]);
}
}
}
return dp[N][W];
}
int DPSol() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
std::vector&lt;int&gt; w = {0, 3, 2, 1}; // 物品的重量
std::vector&lt;int&gt; v = {0, 5, 2, 3}; // 物品的价值
return DP(w, v, N, W); // 输出答案
}
```
## 时间复杂度优化
如果我们认真分析上面的代码,就可以发现代码中使用了三重循环:
1. 首先是遍历物品;
1. 然后是遍历剩余容量;
1. 最后是遍历物品数量。
那么这个解法的算法时间复杂度是多少呢?如果我们假定物品数量是 k容量是 v那么最后的时间复杂度就是 O(kv<sup>2</sup>)。
我们如果回顾一下0-1背包问题就会发现0-1背包的时间复杂度是 O(kv)。虽然完全背包问题比0-1背包问题更复杂一些但是出现指数级别的复杂度可不是一件好事。我们得比一般人做得更好。那么我们能够通过某种方式降低完全背包的时间复杂度吗
在回答这个问题前,我们来进行一些简单的探讨。
### 为何时间复杂度会增加?
现在,按照题设和上面的状态转移方程的定义,我们来思考一下:假如要拿第 $tn $个物品,当前物品重量为 $w[tn]$,我们会考察放入第 0 件、第 1 件、第 2 件…… k 件该物品时的价值,并取最大值。
因此,要求剩余容量为 $rw$ (即 $rw$ - 0*$w[tn]$ )时的最优解,就需要遍历求出 $rw$ - 0*$w[tn]$、$rw$ - 1*$w[tn]$、$rw$ - 2*$w[tn]$ … $rw$ - k*$w[tn]$,然后在其中挑出最大的那个,作为当前子问题的解。这导致了算法执行时多了一层循环。
让我们仔细考虑一下这个求解过程,如果我们求解剩余容量为 $rw$ - 1*$w[tn]$ 时的最优解,就需要遍历求出 $rw$ - 1*$w[tn]$、$rw$ - 2*$w[tn]$ … $rw$ - k*$w[tn]$,因此我们肯定会再次求解 $rw$ - 2*$w[tn]$。所以,在完全背包问题中,依然存在重复计算。
针对这一问题我们是否可以避免这个重复计算呢答案是肯定的。至于方法其实很简单我们只需要把问题转换成一种新的0-1背包问题就行了。
### 改进状态转移方程
回忆一下在0-1背包问题中当我们求第 $tn$ 个物品的最优解时,是从“放入该物品”和“不放入该物品”两种情况中作出决策的。也就是说,第 $tn$ 个物品状态下的最优解,是第 $tn - 1$ 个物品的最优解(子问题) 当前的决策推导出来的。
0-1背包问题解决方案的关键在于当剩余容量 $rw$ 确定,处理第 $tn$ 件物品的时候,我们只需要考虑拿或不拿第 $tn$ 件物品,而不需要考虑放入几个第 $tn$ 件物品。
根据上述思路,在解决完全背包问题时,我们可以把之前的重叠子问题等价地转化成一个新的重叠子问题来解决,以消除上面提到的重复计算(多出来的那个子循环)。另 $rw$ 确定时,在处理第 $tn$ 件物品的时候,也只需要考虑拿或不拿第 $tn$ 件物品。怎么做呢?我们只需要从以下两种情况里作出决策:
1. 不拿第 $tn$ 个物品,那么价值就是 $DP[tn-1][rw]$(状态 A
1. 拿第 $tn$ 个物品,那么价值就是 $DP[tn][rw-w[tn]] + v[tn]$(状态 B
在剩余容量为 $rw$ 的时候,其最大价值就是 max(状态 A, 状态 B)。也就是说,此时处理第 $tn$ 件物品的最优解,就是从上面两个状态的结果中取最大值。
因此,每一次我们只需考虑,当前是否要把第 $tn$ 个物品放入背包就行了。至于之前有没有放过第 $tn$ 件物品,以及放了几件进入背包,已经在容量更小的时候计算过了(需要注意的是,动态规划的计算过程是自底向上的)。
如果你还是觉得有点晕没关系我们再换一种说法。在0-1背包问题里因为一个物品只能放入一次所以我们是以上一个物品的最优解为基础进行决策推导的。而在完全背包问题里因为一个物品可以放入 0 到多次,所以我们必须以“当前物品 $tn$ 在容量更小时,计算出的最优解”为基础进行决策推导。
这样可以隐含一个过程:我们在当前物品 $tn$ 状态下,当容量 $rw$ 更小的时候,就已经选择过 0 到多次当前物品了,而且得到的最优解存储在缓存中,这部分不需要每次都重复求解。
通过以上分析,我们得到了优化后的状态转移方程:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max( DP(tn-1,rw),DP(tn,rw-w[tn])+v[tn] )<br>
\end{array}\right.$$
方程中,$tn$表示当前物品序号,$rw$表示目前背包剩余容量。$DP(tn,rw)$ 也就是在目前背包剩余$rw$容量的情况下,放入第$tn$个物品的最大价值。$w[tn]$就是第$tn$个物品的重量,$v[tn]$就是第$tn$个物品的价值。
### 改进代码的时间复杂度
接着,按照状态转移方程的指导,给出相应的算法代码。你可以参考以下代码,看看跟之前的解法有何不同。
Java 实现:
```
int bag(int[] w, int[] v, int N, int W) {
// 创建备忘录
int[][] dp = new int[N+1][W+1];
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
// 如果可以放入则尝试放入第tn件物品
if (w[tn] &lt;= rw) {
dp[tn][rw] = Math.max(dp[tn][rw], dp[tn][rw-w[tn]] + v[tn]);
}
}
}
return dp[N][W];
}
int solveBag() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
int[] w = {0, 3, 2, 1}; // 物品的重量
int[] v = {0, 5, 2, 3}; // 物品的价值
return bag(w, v, N, W); // 输出答案
}
```
C++ 实现:
```
int DP(const std::vector&lt;int&gt;&amp; w, const std::vector&lt;int&gt;&amp; v, int N, int W) {
int dp[N+1][W+1]; // 创建备忘录
memset(dp, 0, sizeof(dp));
// 初始化状态
for (int i = 0; i &lt; N + 1; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
dp[tn][rw] = dp[tn-1][rw];
// 如果可以放入则尝试放入第tn件物品
if (w[tn] &lt;= rw) {
dp[tn][rw] = max(dp[tn][rw], dp[tn][rw-w[tn]] + v[tn]);
}
}
}
return dp[N][W];
}
int DPSol() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
std::vector&lt;int&gt; w = {0, 3, 2, 1}; // 物品的重量
std::vector&lt;int&gt; v = {0, 5, 2, 3}; // 物品的价值
return DP(w, v, N, W); // 输出答案
}
```
我在下面的表格中,用箭头画出了容量为 5 时的求解路径。你可以参照这个求解路径来加深对代码的理解。
<img src="https://static001.geekbang.org/resource/image/25/3e/2535d01f5c3c3ac1952e72378de5c43e.png" alt="">
不知道你发现了没有,在改进后的代码中没有 k 参与计算了,那么这个由 0 到 k 的循环过程去哪了呢?其实,它隐含在了新的重叠子问题的计算过程中,这一过程可以用下图描述:
<img src="https://static001.geekbang.org/resource/image/8a/24/8ac92debaa452d83656454e9d51e9a24.png" alt="">
从图中我们可以看出,虚线框就是我们所说的会包含重叠子问题的部分内容(并非意味着虚线框里的内容是重叠子问题)。在计算$DP(3, 5)$时 $k = 5$,因此循环从 6 个值中求解最优解这6个值就是$DP(2,5-0*1)+0*3$到$DP(2,5-5*1)+5*3$,也就是$DP(2,5 - k*1)+k*3$,此时背包剩余容量$rw$为5第2件物品的重量为1价值为3所以$k$可以取0到5。我们只需要求出中的最大值即可。
但是我们可以看到其中的前五步所依赖的子问题,在 $DP(3, 4)$ 这个问题中也会被计算到,此时 $k = 4$,只不过在$k=5$的时候需要在$k=4$的求解基础上加上1个物品的价值。因此$DP(3, 4)$ 和 $DP(3, 5)$ 之间只相差了这一步循环和1个物品的价值但我们的确没必要把$DP(3,4)$中求解过的子问题在$DP(3,5)$中重复求解一遍,而是通过这种换算关系直接复用$DP(3,4)$的结果即可。
然后我们再看 $DP(3, 5)$ 和 $DP(3, 3)$ 两个子问题前四步依赖的子问题是完全相同的都相差2个物品的价值因此这两个子问题之间状态只相差了两次循环步骤然后再加上2个物品的价值。以此类推原本方程中的 $k$ 次循环,其实是在其它子问题中被重复计算了。
## 空间复杂度优化
我们刚刚讲解了如何优化动归解法下完全背包问题的时间复杂度。现在,再让我们看看如何优化它的空间复杂度。
### 动态规划对内存要求高
还记得备忘录这个词吧,在我们解动态规划问题时,总会用到它。名字确实比较高端、上档次,但说白了,它无非就是一块事先开辟好的缓存区域。我们总是要对计算结果进行缓存,而缓存可以避免对结果进行重复计算。
但是,鱼与熊掌不可兼得,当状态数量非常多的时候,缓存的占用空间也会变得非常非常大。因此,如果我们要优化动态规划的空间复杂度,就必须想办法减少缓存的大小,毕竟其它的空间相对于缓存都是九牛一毛。
### 寻找优化空间复杂度的方法
我们先来回顾一下时间复杂度优化一节的状态转移方程:
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
0, tn&lt;=0\\\<br>
0, rw&lt;=0\\\<br>
DP(tn-1,rw), rw&lt;w[tn]\\\<br>
max( DP(tn-1,rw),DP(tn,rw-w[tn])+v[tn] )<br>
\end{array}\right.$$
从状态转移方程中,我们可以知道:如果想求 $DP(tn, rw)$,那么我们只依赖于$DP(tn-1, rw)$和$DP(tn, 0)$。
如果从状态备忘录的角度上来说,就是我们只关心 $tn - 1$ 时的结果和 $tn$ 相同时的结果。也就是说,当前的计算只使用缓存中当前这一行和上一行的计算结果。
既然如此,我们就可以采用滚动数组的方式,定义一个只有两行的数组。
- 在计算第 1 个物品时,用第 0 行做 $tn - 1$ 的缓存,用第 1 行做 $tn$ 的缓存;
- 在计算第 2 个物品时,用第 1 行做 $tn - 1$ 的缓存,用第 0 行做 $tn$ 的缓存;
- 在计算第 3个物品时用第 0 行做 $tn - 1$ 的缓存,而用第 1 行做 $tn$ 的缓存……以此类推。
这个过程,可以用下面的图展示出来。
<img src="https://static001.geekbang.org/resource/image/5d/f3/5d35fd0198952959626c4963bb0b28f3.jpg" alt="">
通过上述方法,我们把那张庞大的状态转移表,优化成了只有两行的数组。可以预见的是,无论输入的数据多么庞大,改进后的算法占用的空间都会十分稳定,妙哉!
### 改进代码的空间复杂度
现在,我们有了明确的优化思路,那就是用一个只有两行的数组来代替原来的状态转移表(即备忘录)。在这种情况下,状态转移方程不会有什么变化,我们只需要对代码中的备忘录稍作修改即可。
Java 实现:
```
int bag(int[] w, int[] v, int N, int W) {
// 创建备忘录
int[][] dp = new int[2][W+1];
// 初始化状态
for (int i = 0; i &lt; 2; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
// tn % 2代表当前行的缓存索引
int ctn = tn % 2;
// 1 - ctn代表上一行的缓存索引
int ptn = 1 - ctn;
dp[ctn][rw] = dp[ptn][rw];
// 如果可以放入则尝试放入第tn件物品
if (w[tn] &lt;= rw) {
dp[ctn][rw] = Math.max(dp[ctn][rw], dp[ctn][rw-w[tn]] + v[tn]);
}
}
}
return dp[N % 2][W];
}
int solveBag() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
int[] w = {0, 3, 2, 1}; // 物品的重量
int[] v = {0, 5, 2, 3}; // 物品的价值
return bag(w, v, N, W); // 输出答案
}
```
C++ 实现:
```
int DP(const std::vector&lt;int&gt;&amp; w, const std::vector&lt;int&gt;&amp; v, int N, int W) {
int dp[2][W+1]; // 创建备忘录
memset(dp, 0, sizeof(dp));
// 初始化状态
for (int i = 0; i &lt; 2; i++) { dp[i][0] = 0; }
for (int j = 0; j &lt; W + 1; j++) { dp[0][j] = 0; }
// 遍历每一件物品
for (int tn = 1; tn &lt; N + 1; tn++) {
// 背包容量有多大就还要计算多少次
for (int rw = 1; rw &lt; W + 1; rw++) {
// tn % 2代表当前行的缓存索引
int ctn = tn % 2;
// tn % 1代表上一行的缓存索引
int ptn = tn % 1;
dp[ctn][rw] = dp[ptn][rw];
// 如果可以放入则尝试放入第tn件物品
if (w[tn] &lt;= rw) {
dp[ctn][rw] = max(dp[ctn][rw], dp[ctn][rw-w[tn]] + v[tn]);
}
}
}
return dp[N % 2][W];
}
int DPSol() {
int N = 3, W = 5; // 物品的总数,背包能容纳的总重量
std::vector&lt;int&gt; w = {0, 3, 2, 1}; // 物品的重量
std::vector&lt;int&gt; v = {0, 5, 2, 3}; // 物品的价值
return DP(w, v, N, W); // 输出答案
}
```
从代码中,我们可以看到,其唯一变化的就是缓存的定义和使用方法。
我们将缓存定义成只有 2 行。在使用的时候,我们利用求余的操作控制到底哪一行是当前行,哪一行是上一行,交替使用两部分缓存。通过这个巧妙的方式,我们大幅减少了缓存空间的使用,尤其在物品数量很多的时候效果会非常好。
至此,我们较为完美地解决了整个完全背包问题,无论是从时间复杂度,还是从空间复杂度角度上看,这段代码都称得上是 a master piece
虽然完全背包问题已经在之前的0-1背包问题上复杂了许多不过关于背包的故事还没有结束。我会在后续的课程中结合完全背包的衍生面试问题与你进行探讨。不过你还是要把本节课中提到的技巧和方法多加练习一下就目前来说这更为重要。
## 课程总结
让我们回到本课开篇的那个问题上来:**完全背包会如何影响状态转移方程呢?**
显然,完全背包把问题复杂化了,曾经的我们,只需要决策当前物品放还是不放;但现在,我们需要考虑当前物品到底要放几个,才能到达最后的最优解。
从状态转移方程的角度上看在原有0-1背包问题的基础上它多了一层循环遍历。我们要通过这个循环找到一个答案那就是到底该拿多少件当前物品。因此上述问题的结论就是**完全背包问题让状态转移方程多了一层循环迭代**。
如果你已经理解到这个层面,那么恭喜你,面试这一关你已经达标了,面试官应该会很满意。因为根据我的经验,真就是有很多面试者会栽在这一类动归问题的复杂度上,更别提写出代码了。
但我们追求的不仅是弄懂,还要弄通。因为只有弄通了,才能解决咱们后续课程的动态规划问题。因此,我们还要考虑,如何从时间复杂度和空间复杂度上来进一步优化算法。
1. 优化算法的时间复杂度:动态规划的重叠子问题并不一定是唯一的,不同的重叠子问题可能会带来不同的计算消耗。因此,我们要尽量将问题转换成时间复杂度最低的重叠子问题;
1. 优化算法的空间复杂度:动态规划的核心在于状态存储(即备忘录),而状态存储必定带来消耗,也就是以空间换时间。但是在实际应用中,实际的存储条件并不一定能满足动态规划的标准状态存储方式。此时,我们要考虑如何压缩状态存储数,降低空间复杂度。
## 课后思考
我们已经学习了0-1背包和完全背包问题。特别的在完全背包问题中每一种物品的数量是无限的。现在给你这样一个问题如果每种物品不像0-1背包问题中那样只有一个也不像完全背包问题中那样无限制即每种物品有个数的限制≥ 1。那么在这种题设下该如何使用动态规划来化解此问题呢
在解决问题后,你是否能找到降低时间复杂度和空间复杂度的方法呢?
十分期待你的答案,欢迎你在留言区中与我交流!如果乍一看感觉解决不了,不妨再次复习下这节课的内容,或者考考你身边的同事或朋友呀。

View File

@@ -0,0 +1,363 @@
<audio id="audio" title="08子数组问题从解决动归问题套路到实践解题思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/cd/7cb67269c184dfdf39c06acff3a243cd.mp3"></audio>
你好,我是卢誉声。
如果你已经通过前面的课程,掌握了背包问题的奥义,那么恭喜你已经正式跨过动态规划的门槛了。除了背包问题以外,我们还需要掌握剩下几个类型的动态规划问题。
其中有一个是子数组问题,另一个是子序列问题。今天,我们就从子数组问题开始讲起,这类问题很容易在技术面试中出现,让我们来看一看如何用动归问题的套路来应对面试中的常见问题。
在前面的课程中,我们根据直觉设计了备忘录的定义。但事实上,这个备忘录的定义也是有讲究的。因此,在开始今天的课程前,有这样一个问题值得你关注:**备忘录的定义会对编写代码产生什么影响呢?**
让我们带着这个疑问,来学习今天的内容吧。
## 什么是子数组问题?
首先,我们要明确一下什么是动态规划中的子数组问题。如果一道题目给定的输入是一个数组,那么满足以下条件的问题就是动归子数组问题:
1. 问题符合动归典型特征:
>
<p>a. 求“最”优解问题(最大值和最小值);<br>
b. 求可行性True 或 False<br>
c. 求方案总数。</p>
1. 题目的答案是题设数组的子数组,或者来源于子数组。
所谓答案来源于子数组,举个简单例子。比如这节课要讲到的最大子数组之和的问题,我们要求的答案就是子数组每个数字相加得到的。这个答案来源于子数组,只是对子数组多做了一步加法而已。
我在这里给出的定义同样是经验总结,所以它在 90% 以上的情况下是工作的,它足以应对面试中遇到的问题。
了解了什么是子数组问题后,现在让我们来看一看典型的面试问题。
## 回文子串个数
我们先来看一看回文子串问题的描述。
问题:给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
```
示例1
输入:&quot;dp&quot;
输出2
解释:共有两个回文子串,分别为 &quot;d&quot;, &quot;p&quot;。
```
```
示例2
输入:&quot;aaa&quot;
输出6
解释:共有六个回文子串,分别为 &quot;a&quot;, &quot;a&quot;, &quot;a&quot;, &quot;aa&quot;, &quot;aa&quot;, &quot;aaa&quot;。注意题设,具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串,因此像 &quot;aa&quot; 和 &quot;aa&quot; 就是两个不同的回文子串。
```
### 算法问题分析
字符串当然可以理解成数组。从数据结构上讲,它也是连续的,也可以通过索引访问特定位置字符。
除此之外,我们还需要注意一个子数组问题的特征,即答案也必须是连续的。举个例子,如果输入的字符串是"abca",那么"aca"是原问题的答案吗?不是,因为"aca"是一个子序列,它不连续。有关于子序列的问题会比子数组稍微复杂一些,我会在下一课进行讲解。
<img src="https://static001.geekbang.org/resource/image/91/ab/91ea712a764eb021dcfe765393355bab.png" alt="">
那么,这是一个动态规划问题吗?显然,最笨拙的一种方法是穷举,然后再编写一个 Helper 函数来判断穷举出的子字符串是否是回文。但这样效率太低了,我们需要考虑更高效的方法。
为了高效地进行穷举操作,我们需要考虑使用动态规划来解。仿照之前的做法,我们对该问题做一个分析,看看它是否满足求解动态规划的特征。
1. 重叠子问题:在穷举的过程中肯定存在重复计算的问题。这是因为各种排列组合间肯定存在重叠子问题的情况;
1. 无后效性:对不是最长的回文子数组,一定包含在更长的回文子数组中,而更长的回文子数组不会包含在较短的回文子数组中,依赖是单项的;
1. 最优子结构:对整个字符串,其最长的回文子串肯定包含了更短长度字符串中的回文子串,子问题可以递归求解。
既然是动归问题,接下来我们看看该如何写出状态转移方程吧。
### 写出状态转移方程
我们根据之前总结的动态规划求解模板,来看看如何解决这个问题。
首先,我们先来确定**初始化状态**。从问题的示例就可以看出(当然也很容易想到),单个字符一定是它自己的回文。
接着,再来确定**状态参数**。由于我们需要在整个字符串(数组)中确定子串(子数组)的位置,因此需要两个变量来约束和确定子串,一个是子串的起始位置,另一个是结束位置。在算法的执行过程中,起始和结束位置是变化的,因此它们是状态参数。
既然有两个状态参数,因此我们考虑使用二维数组作为动归解法的备忘录。设 $DP[i][j]$,其中 $i$ 是子数组的起始位置,$j$ 是结束位置,而 $DP[i][j]$ 又代表什么含义呢?
这里我们需要分析一下。我们说,动态规划的当前问题是根据它的子问题 当前决策推导出来的。从数组的角度上看,无非就是:一个范围较小的回文子数组 额外元素后,再看它是不是回文子数组。这么说有些抽象,我画了一张图,你看一看就明白了。
<img src="https://static001.geekbang.org/resource/image/5a/12/5ac8c53d7a86eyy75e84ac0db8379812.png" alt="">
从图中可以看到,更大范围的问题是由前面的子问题 当前决策推导出来的,当前的**决策**就是如果向子问题的两边分别扩充一个元素,那么当前问题是否还是回文呢?
在上图给出的示例中,当前问题仍然是回文,如果设 $DP[4][6]$ 为子问题,那么当前问题 $DP[3][7]$ = $DP[4][6]$ + 决策。现在问题已经很明显了,这个决策就是 True 或者 False。
因此, **$DP[i][j]$ 所对应的值是子串 $i…j$ 是否为回文True 或 False**。
一切就绪了,现在给出回文子串问题的状态转移方程。你会发现,相较我前面的背包问题来说,这里的方程比较简单。我们把字符串当作数组来访问,当 $s[i] == s[j]$ 时,当前子问题的答案就是 $DP[i+1][j-1]$ &amp;&amp; $s[i] == s[j]$(其中 $s[i] == s[j]$ 即为 True因此在状态转移方程中没有写出来而当 $s[i] != s[j]$ 时,显然当前子问题的答案就是 False。
$$DP(i, j)=\left\{\begin{array}{c}<br>
DP[i+1][j-1],s[i]==s[j]\\\<br>
False, s[i] \ne s[j]<br>
\end{array}\right.$$
### 编写代码进行求解
所有先决条件都解决了,现在我们来看一下如何用标准的动归解法来求解此问题,我直接给出代码。
Java 实现:
```
int countSubstrings(String s) {
int n = s.length();
if (0 == n) return 0;
int ans = 0;
boolean[][] dp = new boolean[n][n];
for (int i = 0; i &lt; n; i++) {
dp[i][i] = true;
ans++;
}
for (int j = 1; j &lt; n; j++) {
for (int i = 0; i &lt; j; i++) {
dp[i][j] = (s.charAt(i) == s.charAt(j)) &amp;&amp; (j-i &lt;3 || dp[i+1][j-1]);
if (dp[i][j]) { ans++; }
}
}
return ans;
}
```
C++ 实现:
```
int CountSubstrings(string s) {
int n = static_cast&lt;int&gt;(s.size());
if (0 == n) return 0;
int ans = 0;
bool dp[n][n]; memset(dp, 0, sizeof(dp));
for (int i = 0; i &lt; n; i++) {
dp[i][i] = true;
ans++;
}
for (int j = 1; j &lt; n; j++) {
for (int i = 0; i &lt; j; i++) {
dp[i][j] = s[i]==s[j] &amp;&amp; (j-i &lt;3 || dp[i+1][j-1]);
if (dp[i][j]) { ans++; }
}
}
return ans;
}
```
我们在第 2 行到第 10 行创建了备忘录,并进行了初始化状态的操作,即每一个单个字符都是回文,即每个 $dp[i][i]$ 对应的值都是 True。同时原问题问的是有多少个回文子串因此我们创建了 ans 变量用来存储答案,并在初始化状态时就对其进行了自增(这是因为这些单字符的子问题都是答案,它们对应的值为 True
接下来,我们从起始位置 0 到结束位置 1起始位置 0 到结束位置 2 … 起始位置 n-1 到结束位置 n 进行遍历,并按照状态转移方程的“指示”来进行子问题的计算。
这看起来没有什么问题,无非就是穷举所有可能,并自底向上地用备忘录加速我们的计算。但如果你仔细阅读了代码的第 14 行,你就会发现,我们的处理方法跟上面的状态转移方程有些区别。
事实上,在编写这个问题的状态转移方程时,有技巧可以利用。我们仍然分析以下回文的特征:
1. 当子问题局限于单字符时,它一定是回文(如 “a”因此子问题的答案是 True
1. 当子问题是由相同的两个字符构成的,它一定是回文(如 “aa”因此子问题的答案是 True
1. 当子问题是由左右两个相同字符外加一个任意字符,共三个字符构成时,它一定是回文(如 “aba”因此子问题的答案是 True。
综上所述,只要 $s[i] == s[j]$ 且 $j - i$ &lt; $3$ 的时候,那个子问题一定是回文,其对应的 $dp[i][j]$ 一定是 True。因此我们对状态转移方程做一个调整
$$DP(i, j)=\left\{\begin{array}{c}<br>
DP[i+1][j-1],s[i]==s[j]\\\<br>
s[i]==s[j] \&amp;\&amp; j-i&lt;3, s[i] \stackrel{?}{=} s[j]<br>
\end{array}\right.$$
这样一来,我们就用比较优雅的方式解决了回文子串问题。算法的时间复杂度为 O(n<sup>2</sup>),空间复杂度为 O(n<sup>2</sup>)。
从题目的标题我们可以看出,最长回文子串问题属于动态规划当中的求方案个数的问题。但没有题目的时候你能判断出来它的类型吗?
这里有些迷惑性,因为我们在处理子问题的时候,其对应存储在备忘录中的值是 True 或 False。我们只是在备忘录中用 True 或 False 存储了中间计算的状态,这个缓存的值只是中间计算结果,这跟我们前面遇到的问题中存储数字是一个思路(你甚至可以不用 True 或 False而使用 0 和 1 来表示中间计算的结果)。
同时我们可以发现DP 数组的定义在这个问题下比较特别,虽然是一个数组的问题,但是我们需要两个变量来定义(约束)子串,而子串的位置是跟随算法的执行来回漂移的。
所以,记住这个关键点:如果问题涉及位置,考虑增加备忘录的维度来记录下会发生变化的这些变量。这些变量就是状态转移方程中最为关键的状态参数,一般每一个维度都会对应一个状态参数,只有正确定义了状态参数,我们才能用决策来正确地进行状态转移。
## 最大子数组之和
除了回文子串问题以外,接下来,让我们来看一个求最大子数组之和的问题。
问题:给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
```
示例:
输入:[-2, 1, -3, 4, -1, 3, -5, 1, 2]
输出6
解释:连续子数组 [4,-1, 3] 的和最大为 6。
```
### 算法问题分析
按照解题模板,先来确认**初始化状态**。我们试着用回文子串中的方法来定义备忘录,即 $DP[i][j]$ 对应的值是起始位置为 $i$ 结束位置为 $j$ 构成的最大子的子数组和。
按照这个思路,那么原问题的答案应该存放在 $DP[0][n]$ 当中。但是这样设计备忘录,问题就复杂了。由于我们要求的只是一个最值,所有子问题最终要规约到从索引 0 到 n因此没有必要同时记录子数组的起始和结束位置。
在这里我们对备忘录存储的状态进行简化,将 $DP[i][j]$ 简化成 $DP[i]$,其对应值表示的是 $nums[0…i]$ 中的最大子数组之和。 接着,**状态参数**就清晰明了,即 n。
我们再来看看如何进行**决策**。由于动态规划的当前子问题需要由当前子问题的子问题 当前决策来决定,同时,这又是一个求最值的动态规划问题(请你尝试使用之前讲到的方法来判断一下这个问题是不是动归问题)。
因此,我们要决策的就是是否要将当前子问题中额外的数字放入整个计算当中,以获得“更大”的子数组之和:
1. 如果放入额外的数字得到状态A
1. 如果不让入额外的数字得到状态B。
综上所述,我们可以得到经过决策的状态转移,即 max(状态A, 状态B)。现在,我们是不是可以开始写状态转移方程了?
等一下,在写之前我们再多思考几秒钟。假设我们知道了 $DP[i-1]$,我们真的可以推导出 $DP[i]$ 吗?如果按照这样的备忘录定义($DP[i]$ 是 $nums[0…i]$ 中的最大子数组之和)是不能的。我根据示例中的输入画出了下面这张图,你看一下就明白了。
<img src="https://static001.geekbang.org/resource/image/75/f1/753887a8dedb7683f49087c5c9c3dff1.png" alt="">
仔细想一下,子问题 $DP[8]$ 是不能根据子问题 $DP[7]$ 决策推导出来的。这是因为在子数组问题中有一个强加属性,即子数组要连续。
按照之前的备忘录定义,并不能保证 $nums[0…i-1]$ 中的最大子数组与 $nums[i]$ 是连续的(在示例中,$i = 6$ 的位置的 -5 就是一个极大的副作用),也就没办法从 $DP[i-1]$ 推导出 $DP[i]$了。
所以说这样的备忘录定义是错误的,无法得到合适的状态转移方程。
### 写出状态转移方程
对于这类子数组问题,我们需要重新定义备忘录的含义,即**$DP[i]$ 表示的是以 $i$ 为结束位置的最大子数组之和**。
这样一来,以结束位置作为导向,就一定能跟后续子问题相连。现在,我们可以写出状态转移方程了。
$$DP(i, j)=\left\{\begin{array}{c}<br>
0, i = 0\\\<br>
DP[i] = max(nums[i], nums[i]+dp[i-1]), i &gt; 0<br>
\end{array}\right.$$
### 编写代码进行求解
我直接给出代码,然后再做解释。
Java 实现:
```
int maxSubArray(int[] nums) {
int n = nums.length; if (0 == n) return 0;
int[] dp = new int[n];
for (int i = 0; i &lt; n; i++) dp[i] = Integer.MIN_VALUE; // 初始化状态
dp[0] = nums[0];
int res = dp[0];
for (int i = 1; i &lt; n; i++) {
dp[i] = Math.max(nums[i], dp[i-1] + nums[i]);
res = Math.max(res, dp[i]);
}
return res;
}
```
C++ 实现:
```
int MaxSubArray(vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (0 == n) return 0;
int dp[n];
for (int i = 0; i &lt; n; i++) dp[i] = INT_MIN; // 初始化状态
dp[0] = nums[0];
int res = dp[0];
for (int i = 1; i &lt; n; i++) {
dp[i] = max(nums[i], dp[i-1] + nums[i]);
res = max(res, dp[i]);
}
return res;
}
```
代码的第 1 行中我们首先处理了边界情况。如果数组长度为0不包含任何元素那么结果肯定为0。
接着定义了备忘录数组dp并通过循环将数组的值全部初始化为 INT_MIN这样就能确保每次求出来的有效值可以直接当作最大值使用。然后我们令 $dp[0]$ 为 $nums[0]$,也就是以 0 这个位置结尾的数组其最大子数组之和就是dp的第 1 个元素。
到了算法的主要计算部分,我们不断遍历整个数组。每次遍历时,首先确定是需要开始一个新的连续子数组,还是扩展之前的连续子数组。如果当前位置的元素大于前面最优解子数组与当前元素之和,说明应该以当前位置开始一个新的子数组;否则说明当前元素应该是前一个最优解的扩展,得到一个更大的连续子数组。
接着,我们将当前连续子数组的和与之前遍历过程中保存的最大子数组之和进行比较,如果更大则替换掉之前保存的结果,这说明相对于之前保存的结果,我们遇到了求和更大的一个子串;否则说明当前子串之和小于之前找到过的最大值,因此依然保留之前的结果。
最后我们返回存储的最大值即可。这也就是我们整个数组的最大连续子数组之和。
### 空间复杂度优化
由于这个问题不太复杂,其实你可以使用暴力法求出,不过那样做效率还是太低,且不会得到面试官的认同。以上解法的时间复杂度为 O(N),空间复杂度为 O(N),相较暴力解法的 O(n<sup>2</sup>) 来说已经很优秀了。
那么现在,如果面试官问你是否还有优化的余地,你会从哪个角度下手呢?根据前面的分析,我们知道 $DP[i]$ 仅和 $DP[i-1]$ 的状态有关,因此可以进行状态压缩,即降低备忘录的空间复杂度。
我们无需保存一个长度为 n 的数组来存储状态,只需要两个整数型变量就够了。
Java 实现:
```
int maxSubArrayAdvanced(int[] nums) {
int n = nums.length; if (0 == n) return 0;
int dp_0 = nums[0], dp_1 = 0; // 初始化状态
int res = dp_0;
for (int i = 1; i &lt; n; i++) {
dp_1 = Math.max(nums[i], dp_0 + nums[i]);
dp_0 = dp_1;
res = Math.max(res, dp_1);
}
return res;
}
```
C++ 实现:
```
int MaxSubArrayAdvanced(vector&lt;int&gt;&amp; nums) {
int n = nums.size(); if (0 == n) return 0;
int dp_0 = nums[0], dp_1 = 0; // 初始化状态
int res = dp_0;
for (int i = 1; i &lt; n; i++) {
dp_1 = max(nums[i], dp_0 + nums[i]);
dp_0 = dp_1;
res = max(res, dp_1);
}
return res;
}
```
这样一来,我们就完美地解决了最大子数组之和这个动态规划问题。从这个问题可以看出,备忘录的定义十分重要。
在这个题目中,我们需要将 $DP[i]$ 定义为以 $i$ 结尾的子问题答案,因为只有这样才能建立起 $DP[i]$ 与 $DP[i-1]$ 之间的关系,通过决策写出状态转移方程。
## 课程总结
所谓动态规划的子数组问题,就是从一个数组中寻找满足条件,并可以得到最优结果的一个最长的子数组序列的问题。
在设计备忘录时,我们根据实际情况缩减了问题中的状态数量,虽然缩减的方法不是非常套路,但是因为大多数缩减状态的方法大同小异,所以你可以根据这种思路去思考如何对类似问题的状态数量进行控制。状态数量会直接影响问题的空间复杂度和时间复杂度,状态越少,空间和时间复杂度肯定也就越小,求解方法也就越优秀。
最后,我们还讲解了如何在状态数量不变的情况下,根据状态的依赖关系,进一步缩减备忘录的空间,进一步降低空间复杂度。
由于实际的动态规划问题的状态数量肯定比较大,会带来较多的空间消耗。因此,在解决实际问题的时候,这种缩减备忘录空间的做法是非常常见的,我们有必要学习和掌握。
我们通过本课的两个子数组问题,认识了子数组这类动态规划问题的形式,了解了如何寻找状态和子问题、构建状态转移方程,并对其进行优化。在后续的课程中,你还会看到更复杂的子数组问题。到时候,希望你能够利用这节课所学到的内容,做到举一反三,百尺竿头更进一步。
## 课后思考
事实上,子串问题在很多情况下是可以使用滑动窗口来解决的。那对于子数组问题来说,我们该如何区分是该使用滑动窗口等传统算法来解决,还是该用动态规划来解决呢?请你给出能使用滑动窗口解决的子数组问题,并比较它与动态规划问题之间的区别。
不知道你今天的收获如何呢?如果感觉已经掌握了解题思路,不妨也去考考你的同事或者朋友吧,刚好也有机会复述一遍今天所学。

View File

@@ -0,0 +1,317 @@
<audio id="audio" title="09子序列问题详解重要的一大类动态规划问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/51/f2/51232673f209785723bef4d877612df2.mp3"></audio>
你好,我是卢誉声。
我们曾在上一课中提到,有两类重要的动态规划问题需要掌握,其中一个是子数组问题,另一个是子序列问题。今天,我们将深入讲解动态规划中的另一个经典问题,即子序列问题。
相较于子数组问题而言,子序列问题要更复杂一些,这是由子序列的特性决定的。不过有一点比较类似,那就是我们仍然需要小心定义备忘录结构和其对应值的含义。
你应该注意到了,我们把子数组问题和子序列问题放在一块儿讲,这意味着它们之间是有联系的。因此,在开始今天的课程前,我提出这样一个问题:**子数组和子序列问题在求解时有什么异同呢?**
接下来就让我们带着这个问题,开始今天的学习之旅吧。
## 什么是子序列问题?
类似的,我们要明确一下什么是动态规划中的子序列问题。首先,相较于子数组问题而言,子序列问题要更复杂一些。这是因为,子数组问题是连续的,而子序列问题是不连续的。比如说字符串 “I wanna keep a giraffe in my backyard” 的一种子序列就可以是 “Igbackd”。
因此,你可以看到,子序列不再要求答案是一个连续的串。即便用穷举的思路求解问题,我们都不一定知道该从何下手解决。特别的,当涉及到两个数组或字符串作为输入的情况时,如果没有处理经验,真的不容易想到解法。
其次一个字符串的子序列是由原字符串在不改变字符的相对顺序的情况下删除某些字符也可以不删除任何字符后组成的新字符串。举个例子“ace” 是 “abcde” 的子序列,但是 “aec” 就不是 “abcde” 的子序列。
再次,如果一个问题涉及以下特征,那么它大概率需要使用动态规划来进行求解:
1. 题目涉及子序列;
1. 问题符合动归典型特征,特别是求“最”优解问题(最大值和最小值);
1. 题目的答案是题设数组的子序列,或者来源于子序列。
其实,一旦技术面试问题涉及子序列,你都几乎不需要考虑动态规划以外的解法了。为什么这么说呢?你考虑一下,一个数组或字符串子序列的组合数肯定是指数级别的,如果想依赖纯粹的穷举来进行求解,从时间复杂度上看,几乎没有求解的可能性。
所以啊我们虽然说动态规划中的子序列问题是经典动归问题但它不同于0-1背包这种经典问题事实上它并不好解决。不过我们都学到这了你应该坚信再难的动归问题都应该有模板可以应对。
没错,今天就让我们用两个经典的案例,来找出解决子序列问题的思路。
## 最长回文子序列
如果问题含有最长子序列这样的提法,那么它一定是动态规划问题。现在,先让我们一起来看一看最长“回文”子序列问题的描述。
问题:给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000。
```
示例1
输入:&quot;asssasms&quot;
输出5
解释:一个可能的最长回文子序列为 &quot;sssss&quot;,另一种可能的答案是 &quot;asssa&quot;。
```
```
示例2
输入:&quot;abba&quot;
输出4
解释:有三个子序列满足题设要求:&quot;aa&quot;、&quot;bb&quot; 和 &quot;abba&quot;,因此答案为 4。
```
### 算法问题分析
还记得在解决回文子串时给出的那个简单例子么?我们当时有提到过 “子数组问题的特征是答案也必须是连续的”。显然,子序列问题的特征发生了变化,它的答案可以是连续的,也可以是不连续的。
<img src="https://static001.geekbang.org/resource/image/bb/b2/bbec56f66358506ba5952efc9c0293b2.png" alt="">
我现在输入的字符串是 “abca”那么 “aca” 是原问题的答案吗在子数组问题中不是但现在“aca” 是原问题的答案了。
我在前面曾提到过涉及子序列的问题基本上全部都是动态规划问题。那么这个问题符合动态规划问题的特征吗?我们来看一下:
1. 重叠子问题:在穷举的过程中肯定存在重复计算的问题。这是因为各种排列组合间肯定存在重叠子问题的情况;
1. 无后效性:对不是最长的回文子序列来说,它一定被包含在更长的回文子序列中。而更长的回文子序列不会包含在较短的回文子序列中,依赖是单向的;
1. 最优子结构:对整个字符串,其最长的回文子序列肯定包含了更短长度字符串中的回文子序列,因此子问题可以递归求解。
既然是动归问题,接下来我们看看该如何写出状态转移方程吧。
### 写出状态转移方程
首先,我们先来确定**初始化状态**。从问题的示例就可以看出(当然也很容易想到),单个字符一定是它自己的回文。
接着,再来确定**状态参数**。跟回文子串问题类似,我们需要确定子序列的位置:一个是起始位置,另一个是结束位置。在算法的执行过程中,起始和结束位置是变化的,因此它们是状态参数。
既然有两个状态,我们用二维数组来定义备忘录。设 **$DP[i][j]$,其对应的值是字符串 $i…j$ 中最长回文子序列的长度**。你可能会问,为何要这样设计备忘录的定义呢?我们曾在讲解子数组问题时讨论了 “备忘录定义对编写代码的影响”,这里的影响其实并不直接是代码,主要影响的是状态转移方程的设计(因为有了状态转移方程,才能编写代码嘛)。
现在让我们回到动态规划问题的本质问题上来。动态规划是数学归纳法的一种很好的体现,即如何从已知的答案推导出未知的部分。回到最长回文子序列问题上来,如果知道了 $s[i+1 … j-1]$ 中最长回文子序列的长度(即 $DP[i+1][j-1]$),我们能通过它推导出 $s[i … j]$ 中最长回文子序列的长度(即 $DP[i][j]$)吗?
<img src="https://static001.geekbang.org/resource/image/03/46/03732428e6aa1e2880d7c2b50dcf7546.png" alt="">
根据以上决策示意图,我们显然可以通过 $DP[i+1][j-1]$ 求出 $DP[i][j]$。这是因为状态转移是连续的,我们可以通过向左移动一位或向右移动一位,得到更大规模子问题的答案。
那么让状态转移的**决策**是什么呢?其实这里的决策跟回文子串问题类似,当前子问题的答案就是通过前面的子问题 当前的决策推导出来的。
而当前的**决策**就是:计算出向子问题的两边分别扩充一个元素后得到的答案。你可以参考示意图来更好地理解这个状态转移过程。
一切就绪了,现在就可以给出回文子串问题的状态转移方程了。我们仍然把字符串当作数组来访问,并考虑当 $s[i] == s[j]$ 和 $s[i] != s[j]$ 两种情况进行讨论:
1. 如果 $s[i] == s[j]$(示意图是相等的),那么 $DP[i][j] = 2 + DP[i+1][j-1]$
1. 如果 $s[i] != s[j]$,就意味着 $s[i]$ 和 $s[j]$ 是不可能同时出现在 $s[i … j]$ 的最⻓回文子序列中的。这时我们该怎么做?这里需要进一步作出决策。
既然 $s[i] != s[j]$,我们可以考虑把 $s[i]$ 和 $s[j]$ 分别放入 $s[i+1 … j-1]$ 中试试这样就会产生两个子状态其中状态A$s[i … j-1]$状态B$s[i+1 … j]$。接着,再看看哪个子串产生的回文子序列更⻓,即 max(状态A, 状态B)。
<img src="https://static001.geekbang.org/resource/image/27/ea/276bf4ed8234cef82cd2125a5e7955ea.png" alt="">
这个过程可以用以上示意图进行描述。在示意图中状态A$DP[0][4] = 5$状态B$DP[1][5] = 4$。因此,这里通过决策后得到的状态应该是 max(状态A, 状态B) = 5。
$$DP(i, j)=\left\{\begin{array}{c}<br>
2 + DP[i+1][j-1],\ s[i]==s[j]\\\<br>
max(DP[i+1][j], DP[i][j-1]),\ s[i] \ne s[j]<br>
\end{array}\right.$$
### 编写代码进行求解
所有先决条件都解决了,现在我们来看一下如何用动归来求解此问题,我直接给出代码。
Java 实现:
```
int getLongestPalindromeSubseq(String s) {
int n = s.length(); if (0 == n) return 0;
int[][] dp = new int[n][n];
for (int[] row : dp) { Arrays.fill(row, 0); }
for (int i = 0; i &lt; n; i++) dp[i][i] = 1; // 初始化状态
for (int i = n-1; i &gt;= 0; i--) {
for (int j = i+1; j &lt; n; j++) {
if (s.charAt(i)==s.charAt(j)) {
dp[i][j] = 2 + dp[i+1][j-1];
} else {
dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1]); // 作出进一步决策
}
}
}
return dp[0][n-1]; // 输出答案
}
```
C++ 实现:
```
int GetLongestPalindromeSubseq(string s) {
int n = static_cast&lt;int&gt;(s.size());
if (0 == n) return 0;
int dp[n][n]; memset(dp, 0, sizeof(dp));
for (int i = 0; i &lt; n; i++) dp[i][i] = 1; // 初始化状态
for (int i = n-1; i &gt;= 0; i--) {
for (int j = i+1; j &lt; n; j++) {
if (s[i]==s[j]) {
dp[i][j] = 2 + dp[i+1][j-1];
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j-1]); // 作出进一步决策
}
}
}
return dp[0][n-1]; // 输出答案
}
```
在代码实现中,我们先进行了初始化状态的操作,将备忘录初始化为全 0 (编程习惯)。此外,单个字符一定是它自己的回文,因此备忘录中的对角线都是 1。
除此之外,你有没有发现这里对字符串迭代的顺序作了特别处理?没错,这是刻意为之的。为什么我们需要这样迭代字符串呢?这就涉及到了动态规划的计算方向问题了。
这是我们第一次在专栏中提出**计算方向**的概念,这是彻底理解动态规划问题的重中之重。如果你仔细阅读了之前课程中的状态转移图,你就会发现,我曾多次在图中用红色的箭头标出状态转移的方向。
事实上,那就是计算方向了,只不过对于我们之前遇到的问题来说,都是从左上到右下进行计算的。
对于回文子序列问题来说,根据备忘录的定义,由于我们最终需要的答案存放在 $DP[0][n-1]$中,因此需要从最右下角反向推导:$DP[i][j]$ 需要的是其左侧 $DP[i][j-1]$、左下角 $DP[i+1][j-1]$ 以及正下方 $DP[i+1][j]$ 的值来满足上述状态转移方程。
<img src="https://static001.geekbang.org/resource/image/db/c5/dbdedd4d624cb7aa6bb734c15a2f0bc5.png" alt="">
按照图片所展示的,$DP[0][7]$ 是根据 $DP[0][6]$、$DP[1][6]$ 和 $DP[1][7]$ 推导出来的。
当前子问题的计算,需要依赖于哪些更小的子问题推导出来呢?寻找这个线索,你应该能够找出备忘录上的计算方向。
如果你还没有完全理解计算方向这个问题,也不需要担心,在后续的课程中我们还会遇到这个问题,而且还有专题去彻底讲清楚计算方向这个概念。
## 最长公共子序列
刚才我们有提到过当涉及到两个数组或字符串作为输入的情况时问题会变得比较复杂。而最长公共子序列Longest Common SubsequenceLCS问题就需要从两个字符串中寻找公共子序列。让我们来看看这个问题的描述。
问题:给定两个字符串 $text1$ 和 $text2$,返回这两个字符串的最长公共子序列的长度。若这两个字符串没有公共子序列,则返回 0。其中
- 1 ≤ text1.length ≤ 1000
- 1 ≤ text2.length ≤ 1000
- 输入的字符串只含有小写英文字符。
```
示例1
输入text1 = &quot;abcde&quot;, text2 = &quot;ade&quot;
输出3
解释:最长公共子序列是 &quot;ade&quot;,它的长度为 3。
```
```
示例2:
输入text1 = &quot;abc&quot;, text2 = &quot;def&quot;
输出0
解释:显然,两个字符串没有公共子序列,返回 0。
```
### 算法问题分析
一般来说,在处理多个字符串的动态规划问题时,如果用数组作为备忘录的基本数据结构,那么它的维度则跟字符串的个数是线性相关的,即有几个字符串待解决,我们就创建几维的数组。当然也有例外,有些问题可能是将多个字符串进行合并,然后达到降维的目的。
一个数组或字符串子序列的组合数肯定是指数级别的。如果想依赖纯粹的穷举来进行求解,从时间复杂度上看,几乎没有求解的可能性。因此我们几乎可以“武断”地判断该问题需使用动态规划来进行求解。
你可以根据我们多次提到的判断方法,来判断该问题是否满足重叠子问题、无后效性和最优子结构这几个特征。然后,再接着往下看。
在这个问题中,我们需要创建一个二维数组作为我们的备忘录来存储中间计算的状态。既然已经确定了是二维数组,那么我们该如何理解备忘录的定义呢?
由于这个问题较为复杂,需要一些技巧,因此我在这里先给出备忘录的定义,解完题目之后我们再倒回来理解为何要这样设计。
### 写出状态转移方程
现在,我直接给出最长公共子序列问题的备忘录定义。**$DP[i][j]$ 表示的是 $text1[0 … i]$ 和 $text2[0 … j]$ 的最长公共子序列的长度**。
如果我们以示例 1 中的输入作为例子,就可以画出备忘录。
<img src="https://static001.geekbang.org/resource/image/1e/64/1e4f673ac02e7c69e97f0b5dde46f564.png" alt="">
其中红色箭头展示了状态转移的**计算方向**。从图中可以得知,$DP[2][4] = 2$,其含义是 “ad”即 $text2[0 … 2]$)和 “abcd”即 $text1[0 … 4]$)的最长公共子序列的长度;$DP[3][5] = 3$ 即 “ade”即 $text2[0 … 3]$)和 “abcde”即 $text1[0 … 5]$)的最长公共子序列的长度,即原问题的答案。
通过上图的展示,你可能会产生一个疑问,那就是为何要设计一个空字符作为计算开始的位置。这其实是**初始化状态**的一部分。当两个字符的其中一个为空串,或同时为空串时,原问题的答案肯定是 0。显然一个字符串与空串的公共子序列肯定是空的。与此同时这样的设计还能让真正的字符串迭代拥有能够推导计算的初始化值。
接着,我们再来看**状态参数**。根据前面的描述,我们用变量 $i$ 和变量 $j$ 描述了整个问题的求解空间,备忘录是基于二维数组构建的。因此,我们的状态参数就是变量 $i$ 和变量 $j$。
最后,我们该如何**决策**状态的转移?对于 $text1$ 和 $text2$ 这两个字符串中的每个字符 $text1[i]$ 和 $text2[j]$,其实只有两种选择:
1. $text1[i-1] == text2[j-1]$,即当前遍历的两个字符在最长公共子序列中,此时 $DP[i][j] = 1 + DP[i-1][j-1]$
1. $text1[i-1] != text2[j-1]$,即当前遍历的两个字符**至少有一个不在**最长公共子序列中。仿照最长回文子序列的处理方法,由于两个字符至少有一个不在,因此我们需要丢弃一个。因此在不等的情况下,需要进一步作出决策。
由于我们要求的是最长公共子序列,因此哪个子问题的答案比较长,就留下谁:$max(DP[i-1][j]$, $DP[i][j-1]$)。通过以上线索,我们得出了求解该问题的状态转移方程。
$$DP(i, j)=\left\{\begin{array}{c}<br>
dp[i][j] = 1 + dp[i-1][j-1],\ text1[i]==text2[j]\\\<br>
max(dp[i-1][j], dp[i][j-1]),\ text1[i] \ne text2[j]<br>
\end{array}\right.$$
### 编写代码进行求解
我直接给出状态转移方程对应的求解代码。
Java 实现:
```
int getLongestCommonSubsequence(String text1, String text2) {
int m = text1.length(), n = text2.length();
int[][] dp = new int[m + 1][n + 1];
for (int[] row: dp) { Arrays.fill(row, 0); }
for (int j = 1; j &lt;= n; j++) {
for (int i = 1; i &lt;= m; i++) {
if (text2.charAt(j - 1) == text1.charAt(i - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
```
C++ 实现:
```
int GetLongestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
int dp[m+1][n+1]; memset(dp, 0, sizeof(dp)); // 多一行一列为了 i, j=1 时有 base value
for (int j = 1; j &lt;= n; j++) {
for (int i = 1; i &lt;= m; i++) {
if (text2[j-1]==text1[i-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[m][n];
}
```
在代码中,我们先进行了初始化状态的操作,将备忘录初始化为全 0 (编程习惯)。
接着,我们分别遍历两个字符串,外层循环遍历第一个字符串,从 1 开始到 n第一个字符串长度为止内层循环遍历第二个字符串从 1 开始到 m第二个字符串长度为止。
每次先比较一下两个字符串的当前字符:
1. 如果当前字符相同,那么说明当前遍历的两个字符均在最长公共子序列中,因此需要将当前序列长度加 1。由于之前的序列长度在 $dp[i-1][j-1]$,因此结果是 $dp[i-1][j-1] + 1$
1. 如果当前字符不同,我们需要从之前的序列中选取一个最大的,也就是从 $dp[i-1][j]$ 和 $dp[i][j-1]$ 中取最大值。
求解结束后,原问题的答案存储在 $dp[m][n]$ 中。
## 课程总结
动态规划领域中,所谓子序列问题,就是从给定字符序列中随意地(不一定连续)去掉若干个字符(可能一个也不去掉)后所形成的字符序列;而对子数组问题,则是从给定的序列中提取连续的序列。
所以,子序列问题往往比子数组问题要复杂一些,因为求解的子问题可能不是连续的字符串。但是,对于动态规划来说,处理问题的思路往往是不变的,其实只是换一种思路去寻找子问题罢了。
从我们分析的两个问题中基本可以看出子序列问题的处理模板,在子序列问题中由于暴力求解的代价更高,因此动态规划带来的价值也就更高。对于全面总结的处理子序列问题的动归套路,我会在下一个模块中进行讲解。
## 课后思考
对于这一课讲解的最长公共子序列问题,我们对最直接的解决方案进行了讲解。但实际上,代码还可以作出进一步优化。请你思考一下,如何优化这个方案的时间复杂度和空间复杂度?并给出改进后的算法代码。
欢迎留言和我分享你的想法,我们一同交流!

View File

@@ -0,0 +1,358 @@
<audio id="audio" title="10面试即正义第二期常见的动态规划面试题串烧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/84/81/84a4b475e6954eb0e13860ec65fef181.mp3"></audio>
你好,我是卢誉声。
在前面的课程中,我们使用动态规划解题模板(套路),解决了多种类型的动态规划算法问题。这其中包括背包问题、子数组问题和子序列问题等,它们绝大多数都属于求最优解(最大值和最小值)类型的问题。
除此之外我们还需要掌握另外两大类型的动归问题它们分别是求方案总数以及求可行性True或False。虽然这两类动归问题的提法不同但我们仍然可以使用之前总结的动态规划解题模板套路只需稍作调整就可以了。
那这样的话,我们今天的课程目标也就非常清晰了,就是把这两类典型的动态规划问题弄明白。现在,就让从最简单的题目开始吧!
## 简单的路径规划
路径规划问题是十分常见的动态规划面试问题,这类问题通常都是模拟现实中的路径规划。一般来说,它会给你一个指定的图,以及与图相对应的约定条件,然后让你计算出路径的总数或最优路径等。我们一般把这种问题归类到求方案总数这一类别中。
现在,我们来看下最简单的路径规划问题。
### 算法问题分析
问题:一个机器人位于一个 m * n 网格的左上角 (起始点在下图中标记为“开始” ),机器人每次只能向下或者向右移动一步,现在机器人试图达到网格的右下角(在下图中标记为“结束”)。问总共有多少条不同的路径?
<img src="https://static001.geekbang.org/resource/image/10/51/105a1f11f948e5d61d7d61c01184a251.png" alt="">
比如上图是一个7 * 3的网格我们希望知道从起点到终点能有多少条不同的路径。
```
示例:
输入m = 3, n = 2
输出: 3
解释: 从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 -&gt; 向右 -&gt; 向下
2. 向右 -&gt; 向下 -&gt; 向右
3. 向下 -&gt; 向右 -&gt; 向右
```
当遇到一个问题时,我们先要确定这个问题能否使用动态规划来进行求解,那就看一下该问题是否满足动归问题的三大特征吧。这个你应该非常熟悉了!
1. 重叠子问题:显然,求方案总数必定涉及穷举,那么在穷举过程中就难以避免出现重叠子问题的计算。比如说,格子 (4, 3) 的路径数量和格子 (3, 4) 的路径数量肯定都会依赖于格子 (3, 3) 的路径数量。因此,如果我们要求格子 (3, 3) 的路径数量,那么在求解格子 (4, 3) 和格子 (3, 4) 的时候,就不需要进行重复计算了;
1. 无后效性:然后判断该问题是否是无后效性的。由于我们的机器人只能向下或者向右走,因此格子的路径数量求解是单向的,所以子问题肯定是无后效性的;
1. 最优子结构:由于这个问题中机器人每次只能向右或者向下走一步,因此如果要产生不同的路径肯定从当前格子的上方下来,或者从当前格子的左侧过来,所以第 (m, n) 个格子的路径数量就是第 (m-1, n) 个格子的路径数量加上第 (m, n-1) 个格子的路径数量,所以这里存在所谓的最优子结构。
通过以上判断,我们得知该问题可以利用动态规划思想来进行求解。
### 写出状态转移方程
现在,我们来看看如何写出该问题的状态转移方程。
参照我们已经反复使用过的解题模板,先要确定**初始化状态**。从原问题可以看出,初始化状态是网格的第一行和第一列。网格的第一行永远只能从左侧的格子往前走,第一列永远只能从上方的格子向下走。由于我们只能向右或向下走,因此,第一行和第一列的格子永远只能存在 1 条路径。
接着,确定**状态参数**。原问题的状态参数其实就是格子的行数和列数,只要知道行数 $i$ 和列数 $j$ 就能知道这个格子的路径数量。因此,我们得到两个状态参数,分别是代表行数的 $i$ 和代表列数的 $j$。
那么,在确定了初始化状态和状态参数后,就要进行状态存储。这里我们的状态存储空间是一个二维数组 **$DP[i][j]$,表示第 $i$ 行、第 $j$ 列的路径数量**。你可以通过以下图示加深理解。
<img src="https://static001.geekbang.org/resource/image/6b/42/6b72fc1c072beac66904c7ebbd864542.png" alt="">
从表格中我们可以看出,第一行和第一列是格子的序号。其中填写了 1 的格子,就是初始状态,深色的格子就是$DP[i][j]$,表示第 $i$ 行、第 $j$ 列的路径数量。我们可以计算一下,这个格子的路径数量是 $DP[i-1][j] + DP[i][j-1]$。
现在一切就绪,我们来写**状态转移方程**。
$$DP(i, j)=\left\{\begin{array}{c}<br>
DP[i-1][j] + DP[i][j-1], &amp; if \ i\ne0\ or\ j\ne0 \\\<br>
1, &amp; i=0\ and\ j=0<br>
\end{array}\right.$$
我们可以这么理解这个状态转移方程:它由初始状态和后续的状态转移构成。当 $i$ 和 $j$ 为 0 时(假定格子从 0 开始而不是 1 开始),函数结果就是 1否则就是 $DP[i- 1][j] + DP[i][j-1]$。
### 编写代码进行求解
对于这个简单的路径规划问题,我们可以用状态转移方程直接写出算法代码。
Java 实现:
```
int getPathCount(int m, int n) {
int[][] dp = new int[m][n];
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
C++ 实现:
```
int GetPathCount(int m, int n) {
int dp[m][n];
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
## 带障碍的路径规划
刚才讲到的路径规划问题,属于“简单”的路径规划,那在实际的面试中呢,往往不会那么简单。或者说,面试官会在你给出上述解答后,提高问题难度,然后再考察你的应变能力。
为了防止这种情况发生,同时也是学习动归必修的内容。现在,我们来看一看带障碍的路径规划问题该如何求解。
### 算法问题分析
问题:一个机器人位于一个 m * n 网格的左上角 (起始点在下图中标记为“开始” )。机器人每次只能向下或者向右移动一步,现在机器人试图达到网格的右下角(在下图中标记为“结束”)。考虑网格中有障碍物,那么从左上角到右下角将会有多少条不同的路径?
<img src="https://static001.geekbang.org/resource/image/6b/72/6b23f87fedec91b2e27ca6935576ab72.png" alt="">
网格中的障碍物和空位置分别用 1 和 0 来表示。
```
示例:
输入:
[
[0, 0, 0],
[0, 1, 0],
[0, 0, 0]
]
输出: 2
解释3 * 3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -&gt; 向右 -&gt; 向下 -&gt; 向下
2. 向下 -&gt; 向下 -&gt; 向右 -&gt; 向右
```
显然,这个问题要比前面的问题复杂一些,在网格中加入了障碍物这一要素,也就是说有些格子是无法通行的,那么这个时候应该如何求解呢?
我们静下心来仔细想想,其实这个问题并没有产生本质变化。待求的子问题还是第 (m, n) 格子的不同路径总数。唯一的区别在于,其中一些格子有障碍物,无法通行。因此,对这些格子来说,其路径总数就是 0。
所以,重叠子问题、无后效性和最优子结构跟上一个问题是相同的。
### 写出状态转移方程
虽然基本的子问题没变,但是由于问题产生了变化,因此状态转移方程肯定会有所改变。
首先,我们还是确定**初始化状态**。依然先考虑网格的第一行和第一列,第一行永远只能从左侧的格子往前走;第一列永远只能从上方的格子往下走。由于我们只能向右或向下走,所以第一行和第一列的格子永远只能存在 1 条路径。但是,我们还需要再考虑那些有障碍的格子,对这些格子来说,它们的路径总数应该是 0 而不是 1。
由于该问题的基本计算规则没有发生变化。因此,**状态参数**依然是格子的行数和列数,我们只要知道了行数 $i$ 和列数 $j$ 就能知道这个格子的路径数量。
同理可得,状态存储空间依然是一个二维数组 **$DP[i][j]$,表示第 $i$ 行、第 $j$ 列的路径数量**。你可以参考上一个问题的表格,来加深对备忘录的理解。
好了,现在就是重头戏了,写出我们的**状态转移方程**。这个时候我们需要注意:对这些存在障碍的格子,它们的状态需要设为 0。为此我们得到了以下状态转移方程
$$DP(i, j)=\left\{\begin{array}{c}<br>
1, &amp; i=0\ and\ j=0\ and\ u(i,j)=0 \\\<br>
0, &amp; u(i,j)=1 \\\<br>
DP[i-1][j] + DP[i][j-1] &amp; otherwise<br>
\end{array}\right.$$
从公式中可以得知u(i, j) 表示格子 (i, j) 的编码1 为有障碍物0 为无障碍物。因此,当 u(i, j) 为 1 的时候,$DP[i][j] = 0$;否则的话,状态转移函数就跟上一个问题没有区别。
### 编写代码进行求解
写好状态转移方程后,我直接给出算法代码的实现。
Java实现
```
int getPathCountWithBlocks(int[][] v) {
int m = v.length;
int n = v[0].length;
int[][] dp = new int[m][n];
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = v[i][0] == 1 ? 0 : 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = v[0][j] == 1 ? 0 : 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
if (v[i][j] == 1) {
dp[i][j] = 0;
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
C++实现:
```
int GetPathCount(const vector&lt;vector&lt;int&gt;&gt;&amp; v) {
int m = v.size();
int n = v[0].size();
int dp[m][n]; memset(dp, 0, sizeof(dp));
// 初始化状态
for (int i = 0; i &lt; m; i ++) { dp[i][0] = v[i][0] ? 0 : 1; }
for (int j = 0; j &lt; n; j ++) { dp[0][j] = v[0][j] ? 0 : 1; }
for (int i = 1; i &lt; m; i ++) { // 状态转移过程
for (int j = 1; j &lt; n; j ++) {
if (v[i][j]) {
dp[i][j] = 0;
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1]; // 输出答案
}
```
通过以上讲解,你会发现:即便原问题增加了障碍物,我们求解问题的基本思路也没怎么发生变化,只是在初始化状态时多考虑了一下有障碍物这种情况。
## 跳跃游戏
接下来我们看看求可行性问题True或False这是最后一类我们“可能”还未触及的动归问题。我为什么要说可能呢这是因为我们完全可以通过问题的转化将其他类型的问题转化成求可行性问题。
比如我们在早期讨论的硬币找零问题,当时我们要求的是:最少需要几枚硬币凑出总金额。但那个问题显然存在无法用硬币凑出解的情况(至于这种情况,原问题要求返回 -1。因此如果我们把原问题修改成你能否用用最少的硬币凑出总金额这样就变成了一个求可行性问题了。
当然,除了上述的情况外,有些面试题目也会直接提出求可行性的问题。对于这类问题,**我们只需要知道某个问题是否可以被解决即可。**既然说到这了,就让我们来看一下跳跃游戏这个常考的题目吧。
题目:给出一个非负整数数组 A你最初定位在数组的第一个位置。数组中的每个元素代表你在那个位置可以跳跃的最大长度。判断你是否能到达数组的最后一个位置。
```
示例1
输入A = [2, 3, 1, 1, 6]
输出: True
解释: 我们可以先跳 1 步,从位置 0 到达位置 1, 然后再从位置 1 跳 3 步到达最后一个位置。
```
```
示例2
输入A = [4, 2, 1, 0, 0, 6]
输出: False
解释: 无论从开头怎么跳跃,你最多都只能到达位置 3 或位置 4但这两个位置的最大跳跃长度是 0所以你永远不可能到达最后一个位置。
```
### 算法问题分析
这个问题其实很简单,每个数字表示了跳跃距离的可能性,这就涉及到了排列组合的问题。因此,我们可以考虑使用穷举来解该问题。但显然穷举不是一个好的面试问题的答案,因此,我们考虑使用动态规划来进行求解。
我们就按照动态规划的解题套路来分析一下,先来判断该问题是否满足动态规划特征。
1. 重叠子问题:这个问题肯定存在重叠子问题,比如求能否到达 $i$ 和 $i-1$ 这两个位置,那么肯定都需要确定 $i-2$ 这个位置的答案。因此,必定存在重叠子问题;
1. 无后效性:该问题明显也无后效性,只有后续的子问题依赖于前面的子问题;
1. 最优子结构:该问题是否存在最优子结构呢?当我们在一个位置的时候,我们可以知道最远可以跳到什么位置。因此,如果我们想要知道能否到达位置 $i$,就需要逐个看前面的位置,判定能否从 $i-1$、$i-2$、$i-3$ … 的位置跳到位置 $i$ 上。然后,再看 $i-1$ 这个位置是否能够到达,因此肯定存在最优子结构。
好了,判断过后我们知道这个问题可以使用动态规划的状态转移方程来进行求解。现在,让我们来看一看如何写出这个状态转移方程吧。
### 写出状态转移方程
首先,我们要考虑**初始化状态**。这个问题的初始化状态就是 0 这个位置。因为这个位置是出发点,因此肯定可以到达,所以我们可以将其初始化成 True。而对其他位置则可以根据状态转移方程来计算得出。
接着,**状态参数**也比较容易看出,只有数组的位置是变化的,因此状态参数就是当前位置 $i$。
由于只有一个状态参数,因此我们可以使用一维数组 **$DP[i]$ 来表示能否从出发点到达位置 $i$**。
那么,状态转移方程中的**决策**是什么呢?就像前面所说的,如果我们想要知道能否到达位置 $i$,就需要逐个看前面的位置,判定能否从位置 $i-1$、$i-2$、$i-3$ … 跳到位置 $i$ 上。然后,再看 $i-1$ 这个位置是否能够到达。
通过以上分析,我们就可以给出状态转移方程的定义了。
$$DP[i]=\left\{\begin{array}{c}<br>
True, &amp; i = 0 \\\<br>
(DP[j] = true)\ and\ (max(A[j]+j) \geq i), &amp; i \ne 0\ and\ j &lt; i \\\<br>
\end{array}\right.$$
### 编写代码进行求解
有了状态转移方程,求解代码也就不难了。按照惯例,我直接给出求解代码。
Java 实现:
```
public boolean canJump(int[] nums) {
int n = nums.length;
if (n &lt;= 1) { return true; }
boolean[] dp = new boolean[n];
// 初始化状态
for (int i = 0; i &lt; n; i++) { dp[i] = false; }
dp[0] = true;
for (int i = 1; i &lt; n; i++) {
for (int j = 0; j &lt; i; j++) { // j &lt; i
if (dp[j] &amp;&amp; j + nums[j] &gt;= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1]; // 输出答案
}
```
C++ 实现:
```
bool canJump(vector&lt;int&gt;&amp; nums) {
int n = nums.size();
if (n &lt;= 1) { return true; }
bool dp[n]; memset(dp, 0, sizeof(dp));
dp[0] = true; // 初始化状态
for (int i = 1; i &lt; n; i++) {
for (int j = 0; j &lt; i; j++) { // j &lt; i
if (dp[j] &amp;&amp; j + nums[j] &gt;= i) {
dp[i] = true;
break;
}
}
}
return dp[n - 1]; // 输出答案
}
```
## 课程总结
以上就是求方案总数类型问题,以及求可行性类型问题的解法了。
事实上,我们完全可以通过转化其它类型的问题,来得到求可行性类型的动态规划问题。比如说,在带障碍的路径规划中,我们求的是路径总数。但是,当我将题目改一下:问能否从起点移动到终点?这样就变成了求可行性的问题。
我们依然在遵循之前给出的动态规划解题模板来求解问题。首先,根据重叠子问题、无后向性和最优子结构来确定能否用动态规划解题。然后,再确定初始化状态、状态参数,接着确定状态存储数组(即备忘录),最终写出关键的状态转移方程。
一旦写出状态转移方程,我们的问题就解决掉一大半了。你可以按照这种思路,自己去尝试解决一些实际的面试问题,从而加深你对解题模板的利用和理解。过程中如果遇到困难,欢迎随时在留言区中提出。
## 课后思考
这节课我们学习了路径规划的解法,那么在带障碍的路径规划问题的基础上,我再为每条边设定一个长度(也就是不同格子之间行进的距离是不同的),此时该如何求出从起点到终点的最大长度呢?
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把文章分享给你身边的朋友,邀请他一起学习!

View File

@@ -0,0 +1,337 @@
<audio id="audio" title="加餐|买卖股票:常见且必考的动态规划面试题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/4c/5e040610bf56ca04d2f252615a2bf94c.mp3"></audio>
你好,我是卢誉声。
上一课我们介绍了动态规划面试问题中求方案总数和求可行性这两大类问题的通用解法,解题模版如下:
1. 根据特征判断是否用动态规划来解;
1. 确定初始化状态和状态参数;
1. 确定状态存储数组(即备忘录);
1. 写出关键的状态转移方程;
1. 编写代码进行求解。
这样的解题模版(套路)是可以复用的,希望你能牢牢记住。今天,作为一节加餐课,我想给你介绍另一种常考的面试问题:买卖股票。这种问题的变种比较多,但依然可以用上述解题模版来解决所有买卖股票的问题,从而做到一通百通。
## 买卖股票问题
在技术面试环节,如果考察动态规划问题的话,那么买卖股票就是一类常考且经典的问题。这类问题一般来说属于求最优解(最大值和最小值)的范畴,下面我们看看这个问题到底是怎样的。
### 算法问题分析
问题:给定一个数组,它的第 $i$ 个元素是一支给定的股票在第 $i$ 天的价格。请你设计一个算法来计算你所能获取的最大利润,你最多可以完成两笔交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
```
示例1
输入:[3, 3, 5, 0, 0, 3, 1, 4]
输出6
解释:在第 4 天(股票价格 = 0的时候买入在第 6 天(股票价格 = 3的时候卖出这笔交易所能获得利润 = 3 - 0 = 3 。随后,在第 7 天(股票价格 = 1的时候买入在第 8 天 (股票价格 = 4的时候卖出这笔交易所能获得利润 = 4 - 1 = 3 。
```
```
示例2
输入:[1, 2, 3, 4, 5]
输出4
解释: 在第 1 天(股票价格 = 1的时候买入在第 5 天 (股票价格 = 5的时候卖出, 这笔交易所能获得利润 = 5 - 1 = 4。需要注意的是你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
```
```
示例3
输入:[7, 6, 4, 3, 1]
输出0
解释:在这个情况下, 没有交易完成, 所以最大利润为 0。
```
可能对大部分人来说,第一眼看到这道题都没有什么思路——这个问题里存在什么可以提取的最优子结构吗?我来给你分析一下。
我们假设,一支股票某天在某种条件下(在前一天赚取的利润确定的情况下,这些条件肯定会影响我们的决策,因此暂时不考虑具体的条件到底是什么)已经赚取了利润。那么当天的利润是依赖于其前一天某些条件下的利润,所以这里存在重复计算问题,也就是会有**重叠子问题**。
而对于这支股票,前一天的利润会影响后一天的利润,反之是不会有影响的,那么这里**无后效性**的条件也满足了。
最后考虑一下,原问题要求解的是:最后一天结束时,一共赚取了多少利润。每天结束时,赚得的总利润 = 前一天赚取的总利润 当天的决策(是否卖出或者买入股票),到这里我们终于嗅到了**最优子结构**的味道。
现在,我们有了一个大致的思路:这是一个可以使用动态规划求解的问题。现在,再来看一下这个问题的状态转移方程到底是什么?
### 写出状态转移方程
首先,我们要确定**初始化状态**,思考一下有哪些边界情况。
1. 第一种边界情况是:如果当天结束时没有持股,而且到当天结束时从未卖出过股票,这种情况利润肯定为 0
1. 第二种边界情况是:当天持股,而且卖出过两次股票,这种情况是不存在的,这也就是我们的终止条件。
然后,可以看出这个问题的**状态参数**之一是天数(变量),也就是当前是第几天,毕竟没有天数也就没有我们的子问题。
根据前面的分析,得知问题的形式是前一天赚取的总利润 当天的决策——也就是在当天结束时是否持有这支股票,以及我们当天买卖当前这支股票(每支股票最多只能买卖两次),进而确定当天结束时赚取的总利润。
现在我们得知,在每一天结束时的总利润取决于以下三个因素:
1. 前一天赚取的总利润;
1. 当天结束时是否持有股票;
1. 当天是否买进或者卖出股票。
此时由于买卖股票是有次数限制的即只能有2次。因此对于第三个因素我们需要进一步具体化才能做出决策——未卖出过股票、卖出过一次股票和卖出过两次股票。
所以,这里我们就找出了三个状态参数,它们决定了某一天结束时得到的总利润,分别是:
1. 天数;
1. 当天结束时是否持有股票;
1. 股票卖出的次数。
根据这三个状态参数(因素),再结合前一天赚取的总利润,就可以得到当前这一天这个状态下的最优解了。只不过前一天赚取的总利润肯定也会受到这三个参数的影响。为此,还需要根据当天的参数来思考前一天的参数有哪些可能性,才能知道使用前一天哪种状态下的总利润(毕竟有三个参数)。
经过上面对状态参数的分析,我们可以知道状态存储空间,即备忘录是一个三维数组 **$DP[i][j][k]$,表示在第 $i$ 天,是否持有(其中 $j$ 为 0 表示未持有1 表示持有)以及卖出了几次(其中 $k$ 为 0 表示卖出 0 次1 表示卖出 1 次2 表示卖出 2 次)股票的情况下,最大利润是多少。**
基于以上分析,我们就可以写出**状态转移方程**了。
$$DP(i, j, k)=\left\{\begin{array}{c}<br>
0, &amp; case1: j=0\ and\ k=0 \\\<br>
max(DP[i-1][1][0]+p[i], DP[i-1][0][1]), &amp; case2: j=0\ and\ k=1 \\\<br>
max(DP[i-1][1][1]+p[i], DP[i-1][0][2]), &amp; case3: j=0\ and\ k=2 \\\<br>
max(DP[i-1][0][0]-p[i], DP[i-1][1][0]), &amp; case4: j=1\ and\ k=0 \\\<br>
max(DP[i-1][0][1]-p[i], DP[i-1][1][1]), &amp; case5: j=1\ and\ k=1 \\\<br>
-INF, &amp; case5: j=1\ and\ k=2 \<br>
\end{array}\right.$$
这个状态转移方程比较复杂,需要进一步解释。
<li>
初始化状态,如果当天未持股,而且到当天结束时从未卖出过股票,利润必定为 0。
</li>
<li>
如果当日结束时未持股,卖出过 1 次股票。那么在这种情况下,可能是今天卖出;也可能是之前卖出的,所以当天的利润可能存在两种情况。同时,我们需要从这两种情况中取最大值作为当天的最大利润:
</li>
>
<p>a. 股票是当天卖出的;<br>
b. 股票是前一天卖出的(前一天肯定是未持股状态,而且已经卖出过 1 次股票)。</p>
1. 如果当日结束时未持股,卖出过 2 次股票。那么在这种情况下,可能是今天卖出;也可能是之前卖出的,所以当天的利润可能存在两种情况。同时,我们需要从这两种情况中取最大值作为当天的最大利润:
>
<p>a. 股票是当天卖的;<br>
b. 股票是前一天已经卖出的(前一天肯定是未持股状态,而且已经卖出过 2 次股票)。</p>
1. 如果当日结束时持股,未卖出过股票。那么在这种情况下,可能是今天买进;也可能是之前买进的,所以当天的利润可能存在两种情况:
>
<p>a. 股票是当天买进的;<br>
b. 股票是前一天已经买进的(前一天肯定是持股状态,而且从未卖出过股票);<br>
因此,当天的最大利润就是从这两种情况中取最大值。需要注意的是,由于这里是买进股票的情况,所以如果当日买进了股票,那么利润需要减去当天的股票价值。</p>
1. 如果当日结束时持股,卖出过 1 次股票。那么在这种情况下,可能是今天买进;也可能是之前买进的,所以当天的利润可能存在两种情况:
>
<p>a. 股票是当天买进的;<br>
b. 股票是前一天已经买进的(前一天肯定是持股状态,而且卖出过 1 次股票);<br>
因此,当天的最大利润就是从这两种情况中取最大值。需要注意的是,由于这里是买进股票的情况,所以如果当日买进了股票,那么利润需要减去当天的股票价值。</p>
1. 如果当日结束后持有股票,卖出过 2 次股票。这种情况不存在,直接设置为 -INF (代码中要做对应的处理)。
分析完毕,需要注意的情况比较多。你也会发现考察这类问题时,需要细心,不要遗漏掉原问题给出的条件,同时要注意卖出和买进之间的关系。
### 编写代码进行求解
写出该问题的状态转移方程,我们的工程就完成一大半了。现在,我给出求解该问题的代码实现。
Java实现
```
int getMaxStock(int[] prices) {
int m = prices.length;
int dp[][][] = new int[m][2][3];
// 处理第一天
// 假设第一天没有买入
dp[0][0][0] = 0;
dp[0][0][1] = 0;
dp[0][0][2] = 0;
// 第一天不可能已卖出
dp[0][1][0] = -prices[0];
dp[0][1][1] = -prices[0];
dp[0][1][2] = -prices[0];
// 处理后续日期
for (int i = 1; i &lt; m; i ++) {
dp[i][0][0] = 0;
dp[i][0][1] = Math.max(dp[i - 1][1][0] + prices[i], dp[i - 1][0][1]);
dp[i][0][2] = Math.max(dp[i - 1][1][1] + prices[i], dp[i - 1][0][2]);
dp[i][1][0] = Math.max(dp[i - 1][0][0] - prices[i], dp[i - 1][1][0]);
dp[i][1][1] = Math.max(dp[i - 1][0][1] - prices[i], dp[i - 1][1][1]);
dp[i][1][2] = 0;
}
return Math.max(dp[m - 1][0][1], dp[m - 1][0][2]); // 输出答案
}
```
C++实现:
```
int GetMaxStock(const vector&lt;int&gt;&amp; prices) {
int m = prices.size();
int dp[m][2][3];
// 处理第一天
// 假设第一天没有买入
dp[0][0][0] = 0;
dp[0][0][1] = 0;
dp[0][0][2] = 0;
// 第一天不可能已卖出
dp[0][1][0] = -prices[0];
dp[0][1][1] = -prices[0];
dp[0][1][2] = -prices[0];
// 处理后续日期
for (int i = 1; i &lt; m; i ++) {
dp[i][0][0] = 0;
dp[i][0][1] = max(dp[i - 1][1][0] + prices[i], dp[i - 1][0][1]);
dp[i][0][2] = max(dp[i - 1][1][1] + prices[i], dp[i - 1][0][2]);
dp[i][1][0] = max(dp[i - 1][0][0] - prices[i], dp[i - 1][1][0]);
dp[i][1][1] = max(dp[i - 1][0][1] - prices[i], dp[i - 1][1][1]);
dp[i][1][2] = 0;
}
return max(dp[m - 1][0][1], dp[m - 1][0][2]); // 输出答案
}
```
通过比较状态转移方程和代码实现,我们发现实现股票买卖问题的代码还是比较容易的。基本上,就是照搬状态转移方程中的状态转移定义。
## 攻破买卖股票问题的解题模板
在讨论了具体的买卖股票问题之后,你就会发现,买卖股票问题的条件设定比较灵活多变(比如问题中可能限定只能买卖一次,卖出一次之后可能需要等待一定时间才能买入),也就是有交易冷冻期,每次交易需要支付手续费等。稍作修改就可以变成另一道题。
因此,我们说买卖股票问题是一类容易考察的问题,我们很有必要提炼出攻破该类问题的解题模板(套路)。
### 经过经验总结的解题模板
我们可以这样描述买卖股票类型的问题。
给定一个数组,它的第 $i$ 个元素是一支给定的股票在**第 $i$ 天**的价格。设计一个算法来计算你所能获取的最大利润,你最多可以**完成 $k$ 笔交易**。附加条件是:
1. 每次卖出股票**之后 $t$ 天内**你无法进行任何交易,同时买入股票的时候会**收取 $c$ 元的交易手续费**
1. 你不能同时参与多笔交易,即你必须在再次购买前出售掉之前的股票。
### 对解题模板进行分析
相比于前面我讲的具体的买卖股票问题,这个解题模板里多了这么几个要素:
1. 最多 2 笔交易变成了 $k$ 笔交易;
1. 多了一个交易冻结期限制,即 $t$ 天之内无法进行任何交易;
1. 买入股票可能需要交易手续费,即卖出股票的时候需要支付额外的费用。
这几个因素产生的影响有:
1. 原本需要计算的是 2 次交易的最优解,现在需要求 $k$ 次交易的最优解;
1. 原本只需要在前一天的基础上进行决策,现在由于存在冻结期 $t$。因此,卖出或买进股票时需要在冻结期之前进行决策,而不是前一天;
1. 由于多了手续费 $c$,因此买入股票的时候需要扣掉手续费。
从表面上看,解题模板比上面的问题更复杂。但如果仔细思考一下,其实整个问题的框架并没有什么实质性变化。
待解的问题依然是:确定每天结束时的最大利润。但是,由于原问题里多了一个交易冻结期 $t$ 的限制。因此,我们需要考虑的问题就变多了:不仅要在前一天的基础上做出决策,还需要考虑冻结期的时间。
至于 $k$ 笔交易和手续费 $c$,则不影响整个问题的解题框架。
在解题模板中,由于待解问题的核心不变,所以重叠子问题、无后效性和最优子结构,则与之前的问题没有变化,因此不再赘述。
### 写出解题模板的状态转移方程
对于解题模板中多出来的这些因素,都不会影响状态参数。因此状态参数没有发生变化,分别是:
1. 天数;
1. 当天结束时是否持有股票;
1. 股票卖出的次数。
接着,我们来考虑状态存储,即备忘录的设计问题。由于现在交易次数上限从 2 次变成了 $k$ 次,因此状态存储空间需要改变。
在前面的具体买卖股票问题中,交易次数的上限是 2 次。那时,状态存储空间是三维数组 $DP[i][2][3]$,其中第三个维度表示股票卖出次数。那么,如果交易上限变成 $k$ 次,状态转移数组就变成了 **$DP[i][j][k+1]$,表示在第 $i$ 天,是否持有(其中 $j$ 为 0 表示未持有1 表示持有)以及卖出了几次(其中 $k$ 为 0 表示卖出 0 次1 表示卖出 1 次2 表示卖出 2 次 … 以此类推)股票的情况下,最大利润是多少。**
此外,我们还要考虑一下,求解这个问题存在哪些边界情况:
1. 第一种边界情况没有变化如果当天结束时没有持股而且到当天结束时从未卖出过股票这种情况利润肯定为0
1. 第二种边界情况发生了变化:由于交易次数限制从 2 次变成了 $k$ 次,因此这里边界变成:当天持股,而且卖出过 $k$ 次股票,而对于情况不存在的,利润设定为负无穷(实际情况下可能需要在编写代码时进行调整)。
我们发现这个问题的状态参数基本没有发生改变,只有交易上限 $k$ 影响了状态存储和初始化参数。现在,给出状态转移方程。
$$DP(i, j, k)=\left\{\begin{array}{c}<br>
0, &amp; case1: j=0\ and\ k=0 \\\<br>
max(DP[i-1][1][k-1]+p[i], DP[i-1][0][k]), &amp; case2: j=0\ and\ k&lt;=k_{max} \\\<br>
max(DP[i-1-t][0][k]-p[i]-c, DP[i-1][1][k]), &amp; case4: j=1\ and\ k&lt;k_{max} \\\<br>
-INF, &amp; case5: j=1\ and\ k=k_{max} \\\<br>
\end{array}\right.$$
同理,这个状态转移方程比较复杂。因此,我这里对其作出解释。
<li>
初始化状态如果当天未持股而且到当天结束时从未卖出过股票利润必定为0。
</li>
<li>
如果当日结束时未持股,卖出过 $k$ 次股票。那么在这种情况下,可能是今天卖出;也可能是之前卖出的,所以当天的利润可能存在两种情况。同时,我们需要从这两种情况中取最大值作为当天的最大利润:
</li>
>
<p>a. 股票是当前卖出的(前一天肯定是持股状态,而且已经卖出过 $k-1$ 次股票);<br>
b. 股票是前一天已经卖出的(前一天肯定是未持股状态,而且已经卖出过 $k$ 次股票)。</p>
1. 如果当日结束时持股,卖出过 $k$ 次股票。那么在这种情况下,可能是今天买进;也可能是之前买进的,所以当天的利润可能存在两种情况。同时,我们需要从这两种情况中取最大值作为当天的最大利润:
>
<p>a. 股票是当天买进的(前 $t+1$ 天肯定是持股状态,而且已经卖出过 $k-1$ 次股票),这里需要考虑 $t$ 天的冻结期,$t$ 天之内无法交易的,所以上一个状态是 $(1+t)$ 天之前,而不是 1 天前;<br>
b. 股票是前一天已经买进的前一天肯定是持股状态而且卖出过1次股票<br>
因此,当天的最大利润就是从这两种情况中取最大值。需要注意的是,由于这里是买进股票的情况。所以,如果当日买进了股票,那么利润需要减去当天的股票价值。另外,由于我们可能涉及 $c$ 元的手续费,因此这里买入的时候需要扣去 $c$ 元的手续费,相当于股票的购入价格上升。</p>
1. 如果当日结束后持有股票,卖出过 $K_{max}$ 次股票,这种情况不存在,直接设置为 INF编码时需要考虑这个怎么处理
这样我们就能求出最后一天的最优解了。其实,冻结期 $t$ 和 $c$ 元手续费只影响了问题中的部分参数,比如冻结期 $t$ 影响了在买入股票时的状态转移参数(从 -1 变成了$-(1+t)$);而手续费 $c$ 则影响了买入股票时的成本(多减去了 $c$ 元);而最大售出次数则影响了边界条件。
### 实例化解题模板
现在,我们看一个实例化解题模板后的具体问题。
问题是这样的:给定一个数组,它的第 $i$ 个元素是一支给定的股票在**第 $i$ 天**的价格。请你设计一个算法来计算你所能获取的最大利润。你最多可以**完成 3 笔交易**。附加条件是:
1. 每次买入股票的时候会**收取 2 元的交易手续费**
1. 你不能同时参与多笔交易,即你必须在再次购买前出售掉之前的股票。
根据前面的分析得知,**状态参数**有三个:天数、当天结束时是否持有股票、股票卖出的次数。对状态存储,即备忘录来说 **$DP[i][2][4]$ 表示在第 $i$ 天,是否持有以及卖出了几次股票(最多 3 笔交易)的情况下,最大利润是多少。**
在写出状态转移方程前,再考虑一下初始化状态:
1. 第一种边界情况:如果当天结束时没有持股而且到当天结束时从未卖出过股票,这种情况利润肯定为 0
1. 第二种边界情况当天持股而且卖出过3次股票这种情况不存在的利润设定为 -INF实际情况下可能需要编码时调整
最后,我们根据以上信息给出了状态转移方程:
$$DP(i, j, k)=\left\{\begin{array}{c}<br>
0, &amp; case1: j=0\ and\ k=0 \\\<br>
max(DP(i-1,1,k-1)+p[i], DP(i-1,0,k)), &amp; case2: j=0\ and\ k&lt;=3 \\\<br>
max(DP(i-1,0,k)-p[i]-2, DP(i-1,1,k)), &amp; case4: j=1\ and\ k&lt;3 \\\<br>
-INF, &amp; case5: j=1\ and\ k=3 \\\<br>
\end{array}\right.$$
这里,我们把最大次数 $K_{max}$ 替换成了 3把冻结期 $t$ 替换成 0把手续费 $c$ 替换成 2。通过买卖股票的解题模板我们就能非常轻松地解决这些问题了。
## 课程总结
鉴于我们刚刚已经总结了解题模版,这里就不再赘述了。最后再啰嗦一句吧,其实很多动态规划问题就像我们处理股票问题的框架一样,很多类似的题目都可以通过总结分析,直接套用模板,效果会非常好!你不妨多去试试。
## 课后思考
你能否写出通用的买卖股票的代码实现。另外,请你思考一下是否存在时间或空间复杂度优化的可能性?
期待你的分享,任何问题欢迎来留言区一起讨论!

View File

@@ -0,0 +1,8 @@
你好,我是卢誉声。
《动态规划面试宝典》这个专栏到今天就结课了,很感谢大家的支持,也非常开心能与各位交流技术。为认真学习的你点赞!
为了让你更好地检测自己的学习成果我特意做了一套期末测试题。题目共有20道其中单选3道、多选17道满分100分快来挑战一下吧
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=227&amp;exam_id=745)

View File

@@ -0,0 +1,10 @@
你好,我是卢誉声。
截至今天,本专栏结课已经快两周了,十分感谢你的支持。
那学习了1个多月不知道你有哪些特别想和我说的话呢对于本专栏的内容你还有什么建议或意见为了能更好地了解你的想法以便我后面酌情安排加餐在此希望能得到更多的反馈听到更多的声音。
所以我特别邀请你填写毕业问卷另外这也是有奖征集的最后3天啦欢迎大家畅所欲言
[<img src="https://static001.geekbang.org/resource/image/0b/8e/0b11018fef9b1175ca9faef7072cf58e.jpg" alt="">](https://jinshuju.net/f/IOnQ7V)

View File

@@ -0,0 +1,42 @@
<audio id="audio" title="结束语|在我家的后院养长颈鹿" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f2/07/f212d466d9ef74562512442a78b46007.mp3"></audio>
你好,我是卢誉声。
时光流逝,专栏到这里真的就要结束了。就像我在开课时曾说的,动态规划是一种解决问题的高级技巧,这不仅体现在它那高深莫测的命名上,还体现在解决实际的算法问题上。我想这趟学习之旅可能有些“烧脑”,那么作为专栏的最后一课,我们就聊一聊轻松的话题,谈一谈有关“长颈鹿”的问题。
Hmmm… 其实是我对职业发展和个人成长的一点感悟。
身边的朋友经常问我这样一个问题:“遇到困难和问题的时候,我的压力总是很大,你觉得怎样才能快速提高自己的技术水平,独当一面呢?”
这其实是一个老生常谈的问题了,如果套用解题模板,就是首先你要有梦想,不要轻言放弃;接着是打好基础,掌握学习方法,慢慢积累经验等等。这些说得似乎没错,但真正推动你我成长,在技术问题面前独当一面的真就只是这些方法论吗?
我对导演克里斯托弗·诺兰 2015 年在普林斯顿大学的演讲的印象至今极为深刻,他曾指导电影《盗梦空间》和《信条》等。在这里,我摘选出了其中几段话,与你分享:
>
<p>我想强调的是“尊重现实”。在一定程度上,我感觉到在过去的几十年里,我们开始认为现实是梦想的可怜陪衬。<br>
&nbsp;<br>
但我想告诉你的是,我们的梦想其实是虚拟的现实。在我的电影中,常常提出这个问题,其答案对人们来说意义重大,而这也是现实的意义——现实举足轻重,你无法超越现实。<br>
&nbsp;<br>
奥斯卡·王尔德曾说:老人相信一切,中年人怀疑一切,年轻人知晓一切。我显然处在怀疑一切的年龄,我想把自己的一些怀疑分享给你:我不相信所谓的追逐梦想的话,也不劝你去追求梦想,我希望你能把握现实,即当下。把握现实并非以梦想为代价,而是以梦想为基础,你要竭尽所能去改善现实。</p>
那从我们专栏的核心应用场景——“大厂算法面试”来说,对于大多数人而言,拥有在大厂工作的经历,无论是对个人成长还是发展来说都是利大于弊的。大厂或者跨国公司带给你的不仅仅是自身的光环,更多的是实实在在的标准工作流程,解决问题的方法,与多个团队协作解决一个大项目的能力的提升。无论你梦想进入大厂工作,还是希望能在日常工作中独当一面。**最重要的其实不是要有梦想,而是把握现实和当下,以梦想为基础,竭尽所能去改善现实。**
<img src="https://static001.geekbang.org/resource/image/e0/97/e0e23d3ce6e908aa20f0841101b52997.jpg" alt="">
有很多大厂惯用动物来命名旗下的公司或产品代号,我在标题中用长颈鹿这种动物指代了所谓的大厂或我们想要达到的职业高度。说到这,你应该发现我从一开始就提到了在我家的后院养长颈鹿,而非在我心中常驻它。这是因为我觉得把握现实(即当下)更重要,把你心中的“长颈鹿”也带回现实,并把握现实,最后竭尽所能去改善现实。
如果你已经阅读到这里,说明你已经学习和掌握了《动态规划面试宝典》这个专栏的大部分内容了。我觉得这是为“把握现实”迈出的很好的一步,遇到问题,发现不足,即刻行动,寻求解决方案。这就跟当产品趋于稳定,而你积极主动挖掘产品代码不足、改善系统架构或提出解决方案,而非追随一样,这很重要。我认为,持有这样的状态,能让一个人更快成长,实现人生目标的飞跃。
在写这篇专栏结束语时,我的心情复杂,我觉得还有不少“干货”没来得及跟你分享。但我还是努力把我内心最想分享给你的记录在了这里。如果你也有想对我说的话,不妨通过问卷告知,大家的每一条反馈我都会认真去看。
最后,我就借用电影《信条》中的经典台词来结束我们的专栏吧:
>
<p>我在整个专栏中为你穿针引线<br>
对我来说,这是一段美好友谊的休止<br>
但对你来说,这是一个新的开始<br>
你我的这段经历将永存 Youll love it, youll see</p>
[<img src="https://static001.geekbang.org/resource/image/0b/8e/0b11018fef9b1175ca9faef7072cf58e.jpg" alt="">](https://jinshuju.net/f/IOnQ7V)

View File

@@ -0,0 +1,8 @@
你好!十分感谢你的参与,现在我们来公布一下获奖用户名单。
在这里,我首先要感谢各位同学给我们的反馈,你们的声音可以促使我们精益求精。在这些反馈中,我们看到了很多非常有价值的信息,也收获了很多的支持与肯定。在此,我们精选出了反馈最为具体、丰富,最有实际价值的 5 位用户,送出奖品。中奖名单如下:
<img src="https://static001.geekbang.org/resource/image/1f/12/1f3edf1d0258b950735ceed25bcb0512.jpg" alt="">
恭喜这 5 位同学,也再次感谢所有参与调研的同学。希望大家今后还能多多支持,给予宝贵意见。

View File

@@ -0,0 +1,104 @@
<audio id="audio" title="导读|动态规划问题纷繁复杂,如何系统学习和掌握它?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/b5/ae7yyc6f97ce7ca86f8d256651de21b5.mp3"></audio>
你好,我是卢誉声。
你是否曾经有过,或者正在经历这样的体验,那就是在学习和掌握了一些数据结构和算法后,面对一个较为复杂的面试题,仍然无从下手?
>
<p>那个问题看起来好像可以使用递归,但是我该怎么遍历整个数据结构呢?<br>
&nbsp;<br>
这个问题看起来需要穷举,但排列组合好像挺难的……<br>
&nbsp;<br>
这里的排列组合情况实在太多了,我到底该怎么优化时间复杂度?</p>
其实,几乎所有人在初学算法和动态规划时都会有这种感受,特别是当待解决的问题步入“穷举”这个不得了的领域时。穷举从来都不是一个好的解决方案,因此针对这类问题的求解方法真是八仙过海、各显神通,我们很难直接从这些解法中找到规律,同时这些解法又晦涩难懂。
正因如此,在面试中如果发现问题需要使用穷举或动态规划,很多人就会变得胆战心惊,无从下手。
但我想告诉你的是,数据结构和算法虽然从表面上看纷繁复杂,但常用的基本思想和方法还真的就不多。这同样适用于动态规划问题,它简直就是模板、套路届的典范。因此,只要我们掌握了正确的学习方法,形成经验式总结,那么当我们再去面对看似“玄幻”的动态规划问题时,就再也不是什么难事了。
所以说,动态规划作为算法面试问题中的一项重要议题,从表面上看似纷繁复杂,但有规可循。今天,我会把自己这些年来总结的学习窍门、遇到的问题和解题思路,进行归纳总结,梳理出一条清晰的路径给你,即**如何系统学习和掌握动态规划**。我期待这个专栏能让你产生全新的认识,收获清晰的解题思路,轻松跨过大厂算法面试这道坎。
### 建立扎实的基础知识体系
首先,我想强调的是,**先掌握基础数据结构和算法,再来谈动态规划。**
动态规划不仅名字听起来十分高级,它也的确是一种高级的解决问题的思想。为了更好地理解这个思想,掌握基础数据结构就显得尤为重要了,比如高维数组这样的数据结构,就经常出现在动态规划解法当中。其次是算法,像是递归、搜索和迭代这些常见的算法,都会作为工具在动态规划解法中使用。
这里我再次拿出了开篇词中那张“基石”的图片。没错,你完全可以这样理解:掌握基础数据结构和算法,就是学习动态规划的基石,怎样强调其重要性都不过分。
<img src="https://static001.geekbang.org/resource/image/8c/f4/8c69611454f088e9458741f55ce138f4.jpg" alt="">
接着,还有一个值得强调的问题,就是**锻炼算法编码能力**,请不要忽视实践的力量。我曾不止一次在面试环节中,看到面试者在白板上纠结于这样的问题:我是否该在循环上加上等号这个条件?
1. 加等号?
```
for (int i = 0; i &lt;= MAX_COUNT; i++) { ... }
```
1. 还是不加等号?
```
for (int i = 0; i &lt; MAX_COUNT; i++) { ... }
```
1. 还是换个方法?
```
for (int i = 0; i &lt; MAX_COUNT + 1; i++) { ... }
```
不知道你看到这里,是否会心一笑?是的,在面试过程中,白板是没办法拿给我们做现场调试的。因此,能否快速地写出干净漂亮的代码,不仅能让自己的思路愈发清晰,还能尽量避免错误。
正所谓细节是魔鬼,在平时学习、练习的过程中,你要**重视细节、重视细节、重视细节**,重要的事情说三遍。
### 透彻理解动态规划的基本方法论
我刚才有提到,动态规划是一种思想,是一种高级方法。我们说**算法是一种简单的经验总结和套路**。那什么是思想呢?相较于算法,**思想更多的是指导你我来解决问题的**。既然是思想,那这个东西就比较难落实到实践上来。
为此,我们必须找到一些规律,来指导我们解决动态规划问题。这些规律或特征包括:寻找子问题、递归求解、重叠子问题与无后效性、状态存储。
如果你完全没有接触过动态规划,你可能会觉得这几个词已经够头疼了,但其实它们都很简单,在接下来的课程里,我会带着你弄清楚这里的每一个概念,同时也要让它们落到实处,看在一个具体动归问题下它们是如何发挥作用的。
那么在理解这些概念及其背后的深意之后,我们需要对其进行归纳总结。这么做的主要目的在于,你可以拥有一个清晰的判断标准:**哪些问题应该使用动态规划来解,而哪些不应该或不能使用动态规划来解**。避免盲目地使用动态规划来解题,弄清楚这个问题后,我们才能有的放矢地解决算法难题。
因此,你完全不需要担心,我们的“车”很稳,且车速适中。
### 掌握经典问题,总结解题思路
掌握经典的动态规划问题特别重要,因为很多问题都是从这些经典问题延伸出来的,在后面的课程中,你就会看到这一点。
在你掌握了诸如背包问题、子序列问题或子数组问题之后,你就会发现这些问题都可以进行归纳总结。当然了,作为专栏,我会给出详细的经验总结,并在后续课程中的恰当时机给出严格的推导。
这些经验总结在 90% 以上的情况下都是有效的,易于理解,而且十分适合用来应对面试。所以,我希望你也能在接下来的学习过程中,建立自己的经验总结,这些总结可以是基于这个专栏的经验总结之上的,也可以是你在学习的过程中发现的。
既然我们主要想解决的问题是面试,那么在学习过程中辅以刷题就十分重要了。首先,在专栏中提到的算法问题,你都应该上手自己去实现一遍,这样比单纯的读效果要好上很多!
其次,刷题也要讲究一个度,我当然希望你能够轻松应对国内大厂或国际大厂的算法面试环节,但是你还是应该循序渐进,慢慢提升刷题数量和刷题的题目难度。正所谓欲速则不达!
最后,把每一道题目吃透,记录下编写代码时的思考,以便于在未来复习的过程中加深和巩固。
话说回来,如果你能认真吃透本专栏讲解的题目和经验总结,那么就足以应对这些面试了。
### 及时总结,举一反三
其实吧,我们常说人工智能是人工智障,这么说并不过分,因为计算机真的很笨,它唯一能解决的问题就是穷举。对,你没有听错,它只能穷举所有可能性。
动态规划的思想是从一系列算法中演进而来的。贪心算法是求解整体最优的真正思路源头,我们从那里开始,考虑穷举的问题,最终通过优化形成了一个比较完美的总结。而这个总结,正是动态规划思想。
所以你看,即便是高级如动态规划这样的思想,也是通过不断的总结而得到的。而经过我们前面这一系列的总结,便形成了下面这幅脑图,你可以通过这幅图对学习动态规划有一个全面的了解。
<img src="https://static001.geekbang.org/resource/image/56/0f/5605585d75b8ef128285bea9a7c86d0f.jpg" alt="">
我刚才就提到了总结解题思路,那么除了本专栏为你总结的经验和解题模板之外,我建议你也在学完每一课之后记录下自己的理解。
我有一个习惯,就是在刷题之后会把关键信息做一个总结,然后分享到刷题网站上。随着时间的推移,有些分享会被人点赞。虽然只是一个不起眼的点赞,但真落到你身上时,它还是会成为一个积极的推动力,会莫名地鼓励你在以后的刷题过程中继续分享观点。就算观点不对,有人给你指了出来,岂不也是一次免费的学习机会?
而这个专栏就为大家提供了一个很好的交流平台,我期待着你能把经验总结和思考,抑或是问题分享出来,让大家一起学习探讨。让总结、分享成为一个习惯,形成正向刺激。
那不如就从今天开始,先分享分享你的学习计划吧?让我们一起开启这趟有趣的动态规划学习之旅。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="开篇词|为什么大厂都爱考动态规划?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/64/d7/6454c3ffffab4fec188ab0836eb5d5d7.mp3"></audio>
你好,我是卢誉声,很高兴能在这个专栏与你见面,和你一起搞定动态规划。
开门见山我先做一个自我介绍。最开始我在思科系统Cisco Systems工作曾参与设计和开发了下一代视频会议系统的核心数据交换服务。我的工作涵盖了协议栈开发、微服务设计、分布式系统编配以及弹性算法设计。
这段经历让我形成了一个认知:算法对设计关键服务来说十分重要,它决定了系统的稳定性、弹性以及可扩展性。
后来,我加入了 Autodesk成为了一款三维设计旗舰软件的框架和平台软件工程师。负责开发了基于大规模结构化数据的高性能搜索引擎首次将灵活的多线程和异步框架带入产品框架层面在原有的底层内存模型上采用了改进后的检索引擎相较于原有的搜索功能实现了超过 300 倍的性能提升。除此之外,我还改进并维护了用于改进用户体验的数据处理系统,在平台框架层面的工作,让我积累了大量的工程实践经验。
现在,我在 Autodesk 数据平台就职,负责设计和开发大规模数据的分析、丰富化以及流化分布式服务。
我发现自己的职业发展一直围绕着数据在不断前进。基于此,我常说的一句话是:“**数据即是正义**”。
那直到今天,我的态度依然没有变。数据为媒,算法为介,而在极其重要的算法中,动态规划其实占了很大的比重。
<img src="https://static001.geekbang.org/resource/image/c4/a3/c43bc5fyy622d4f5d3240173b28bb5a3.png" alt="">
事实上,如果你平常关注大厂面试的话,你会发现,但凡是研发岗位,无论是招聘初级还是高级工程师,大厂都倾向于安排一轮或多轮专门的算法面试环节,而且在面试环节提出动态规划相关问题的这种趋势已经愈发明显。
这是为什么呢?我来谈谈我的看法。
先说算法这件事吧。我想请你回想一下,当处理数据结构相关的问题时,你有没有这样的经历?
1. 你本能地到工具函数或者库函数中寻找有没有现成的工具。如果问题得到快速解决,它是不是迅速就成了过眼云烟?
1. 如果这个问题看起来比较棘手,它不是一个典型的算法问题,那么就寻求搜索引擎的帮助,或者干脆访问 Stack Overflow 这样的“智库”寻找前人留下的解决方案?
1. 虽然平时工作中表现优异,但当你想换工作参加大厂面试时,又发现自己难以解决面试官提出的算法问题,无从下手,面对白板“望洋兴叹”?
相信我,你不是一个人!这种现象很普遍。
其实,对于开发人员来说,算法和数据结构就是我们的基本功。我们常常自嘲软件研发人员的工作就是复制粘贴,搬砖就是日常工作的全部。但当公司或部门要求你去研究一个全新的技术,或者快速阅读一份开发多年且成熟的开源项目代码,并对其改造来服务于自己的产品功能时,你的压力会让你明白基本功到底有多重要!
关于基本功这事儿,我要插个故事进来,再多说几句。我曾有幸与 C++ 之父 Bjarne Stroustrup 先生进行过面对面的交流。我问了他一个问题:“如今新生代技术人员倾向于学习 Java、Go 或 Python 这些更容易上手的编程语言您是如何看待这个现象的”Stroustrup 先生的回答大概是这样的:“如果一个人只了解一种编程语言,那么他不能称自己是专业人士,而从我的角度上看,将 C++ 作为基础,能让你深入洞察各种各样编程语言背后的思想和设计思路。”
我觉得这个回答特别好。首先,众所周知 C++ 不是一门易学的编程语言因此基础不代表简单或容易。其次C++ 能够极度自由地操纵内存资源,如果你有 C++ 的编程基础,那么在学习 Java 时就会对内存管理和控制有更深的见地。
和你分享这个小故事,当然不是强调 C++ 的重要性了,但如果你有精力学习 C++ 的话肯定能给你带来数不清的好处。其实,我这里真正想表达的就一点:**掌握好基础,能极大地拓宽我们学习更多新事物、新技术的能力。**
<img src="https://static001.geekbang.org/resource/image/8c/f4/8c69611454f088e9458741f55ce138f4.jpg" alt="">
而算法就像技术领域的基石,它的稳定与否直接决定了大楼最终的高度。那动态规划又起到了什么作用呢?
我作为面试官曾接触过许多优秀的候选人,他们有着各种各样的背景,既有潜力又非常努力,但在面对算法问题和解决问题时没有太多思路,始终无法更上一层楼,十分遗憾。
而动态规划恰恰是解决问题的重要方法论,面对很多数据处理的应用场景,它在降低时间复杂度上极具优势,因此成为了考察重点。不仅如此,动态规划问题还能很好地考察面试者的数学模型抽象能力和逻辑思维能力,可以反应个人在算法上的综合能力。
所以我觉得,大厂之所以如此看中一个面试者的算法基础,**特别是动态规划问题的解决能力,是因为他们更加看中一位面试者解决问题的思路与逻辑思维能力**,而不只是工具与技能的熟练程度。
讲到这儿,可能有同学会想:虽然大厂爱考,但是这东西会不会就是个绣花枕头?只是在面试中有用,实际工作中用得上吗?
不同于普通算法,如排序或递归,动态规划从名字上看就显得很特别,有些“高端、大气、上档次”的味道在里面。但其实它离我们很近。我举个例子你就明白了,在云计算平台上一个解决方案的计算能力(容量)肯定是有限的,那么为了高效服务那些重要程度或优先级最高的客户,同时又不想浪费计算资源(说白了为了省钱),我们该怎么办?
这个问题其实可以通过队列这样的分发方式来进行一个简单的编配。但是这不够好,如果我们能够事先知道一个计算任务的重要程度和所需的计算时长,就可以通过动态规划算法来进行预演算,从数学角度推导出一个严谨的编排结果,实现有限资源的最大化利用。
你看,似乎遥不可及的动态规划问题,其实就是求最优解问题,它无时无刻都在我们身边,**总是戏剧般地提高了最优化问题的性能!**这再一次凸显出大厂为何青睐于动态规划问题,而且成为了区别面试者的一个隐形门槛。甚至可以说,掌握动态规划思想,在工作面试、技术等级晋升上都扮演了核心角色。总之一句话,动归必学。
说到这儿,估计又有同学会问:我现在知道动态规划很重要了,面试会考,工程实践要用,但问题是这玩意儿真的难啊,怎么学?
确实,如果你尝试去搜索引擎上搜动态规划的话,你会发现,检索出来的内容往往比较凌乱,很难有一个系统的方法带你从入门到精通。而像“算法面试”这样的传统书籍,对动态规划问题的描述也比较匮乏,缺乏实战经验,阅读和学习起来枯燥无味,过目就忘。
说实话,我以前也有过这种困扰。不过,近些年来在工作中用到动态规划的场景越来越多,在积累了大量的实战经验后,再结合上面试经历,我发现还真的可以做一个较为系统的专题,针对动态规划面试题做一次深入的探讨,也就有了这门课。
我希望这个专栏,能够为你提供一个较为全面的**动态规划知识库**,兼顾理论基础和有效的经验总结,而非照本宣科的理论描述。同时,我也希望它能够为你提供一条捷径,帮助你更快地掌握动态规划问题,从容地应对面试。
为此,我精心打磨了以下三个模块。
**模块一:初识动态规划**
我会为你讲解复杂面试题的思考和解决方式。从贪心算法开始,一步步阐述动态规划的由来,并通过一个贯穿全篇的例子来展现动态规划的强大之处。学习和掌握这些经典的处理方法,能够为你后续掌握动态规划打下一个坚实基础。
通过这部分内容,你会系统了解到动态规划问题的特点和解题经验。
**模块二:动态规划的套路**
我会为你讲解动态规划问题的解题框架和套路,你可以把这个套路理解成是解决动归问题的模板。在此模板的基础上,我会向你讲解面试真题,有针对性地套用解题框架。而应对面试题的纷繁复杂,我会为你进行有效的分类,并针对每一种动态规划问题进行深入而全面的讲解。
通过这部分内容,你会快速掌握常见面试题的解题套路。
**模块三:举一反三,突破套路**
我会针对几种特别易考的动态规划面试题进行总结,帮助你攻破套路。并在这些高级话题的基础上,提出设计动态规划算法的关键问题。另外,还有刷题指南,所谓孰能生巧,必要的练习我们还是要的。
通过这部分内容,你会快速掌握动态规划面试题的进阶法门。
最后,我十分理解,动态规划是绝大多数人在面试中的一个老大难问题。有很多人因为它停了下来,错失了机会。掌握动态规划算法,无论是从工程实践还是面试上来说,都充满必要性。所谓大厂,带给我们的不仅仅是品牌光环那么简单,更多的是人生的一个全新阶段和崭新的台阶。
我希望,这个专栏不仅能帮你跨过大厂算法面试这道坎,还能帮你掌握一套学习复杂知识理论的思维方法,陪你度过职业发展甚至人生的重要时刻!
关于动态规划,在这里你尽可以畅所欲言,提出你的困惑和问题,非常欢迎你能与我同乘一辆车。一起加油吧!