This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 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)总结了刷题指南,希望你能够沿着本专栏的整体思路,去尝试解决那些问题。你最终一定会发现,所有的问题都能够追溯到我为你总结的解题模板上去。刷题的目的在于灵活运用动归解题模板,养成解题思路和习惯,那么你的目标、我的目标,就都达成了。
## 课后思考
除了我今天为你勾勒出的动态规划知识点总结外,你觉得还有什么知识点是值得关注的?不妨谈谈你的心得体会。
期待你的留言。如果今天的内容对你有所帮助,也欢迎把文章分享给你身边的朋友,邀请他一起练习!