mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
326
极客时间专栏/动态规划面试宝典/动态规划的套路/06 | 0-1背包:动态规划的Hello World.md
Normal file
326
极客时间专栏/动态规划面试宝典/动态规划的套路/06 | 0-1背包:动态规划的Hello World.md
Normal 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 < 0
|
||||
return 0
|
||||
|
||||
// 当背包还能容纳的重量已经小于当前物品的重量时,显然这个物品不能放入背包
|
||||
if rw < 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<=0\\\<br>
|
||||
0, rw<=0\\\<br>
|
||||
DP(tn-1,rw), rw<w[tn]\\\<br>
|
||||
max(DP(tn-1,rw), DP(tn-1,rw-w[tn])+v[tn])),rw>=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 < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
for (int tn = 1; tn < N + 1; tn++) { // 遍历每一件物品
|
||||
for (int rw = 1; rw < W + 1; rw++) { // 背包容量有多大就还要计算多少次
|
||||
if (rw < 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<int>& w, const std::vector<int>& v, int N, int W) {
|
||||
int dp[N+1][W+1]; memset(dp, 0, sizeof(dp)); // 创建备忘录
|
||||
|
||||
// 初始化状态
|
||||
for (int i = 0; i < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
for (int tn = 1; tn < N + 1; tn++) { // 遍历每一件物品
|
||||
for (int rw = 1; rw < W + 1; rw++) { // 背包容量有多大就还要计算多少次
|
||||
if (rw < 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<int> w = {0, 3, 2, 1}; // 物品的重量
|
||||
std::vector<int> 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) -> 2, 数组变成 [1, 1, 7, 9, 2]
|
||||
Round 2: (7, 9) -> 2, 数组变成 [1, 1, 2, 2]
|
||||
Round 3: (2, 2) -> 0, 数组变成 [1, 1]
|
||||
Round 4: (1, 1) -> 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。
|
||||
|
||||
现在,假设有一个背包,背包的容量是 12(24/2)。接着,我们有一堆的物品,重量分别是 [1, 2, 1, 7, 9, 4],注意我们设它的价值与重量相同。现在我们希望选出的物品放到背包里的价值最大,这样一来,我们就可以把这个题目转化成0-1背包问题了。
|
||||
|
||||
### 写出状态转移方程
|
||||
|
||||
那么,动态规划部分的状态转移方程就和0-1背包问题中的一样,如下所示:
|
||||
|
||||
$$DP(tn, rw)=\left\{\begin{array}{c}<br>
|
||||
0, tn<=0\\\<br>
|
||||
0, rw<=0\\\<br>
|
||||
DP(tn-1,rw), rw<w[tn]\\\<br>
|
||||
max=(DP(tn-1,rw), DP(tn-1,rw-w[tn])+v[tn])),rw>=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背包问题的思路,全面分析一下这个问题,然后写出求解的代码。
|
||||
|
||||
不知道你今天的收获如何呢?如果感觉已经掌握了解题思路,不妨也去考考你们的同事或者朋友吧,刚好也有机会复述一遍今天所学。
|
||||
456
极客时间专栏/动态规划面试宝典/动态规划的套路/07|完全背包:深入理解背包问题.md
Normal file
456
极客时间专栏/动态规划面试宝典/动态规划的套路/07|完全背包:深入理解背包问题.md
Normal 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<=0\\\<br>
|
||||
0, rw<=0\\\<br>
|
||||
DP(tn-1,rw), rw<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 < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < W + 1; rw++) {
|
||||
dp[tn][rw] = dp[tn-1][rw];
|
||||
// 根据rw尝试放入多次物品,从中找出最大值,作为当前子问题的最优解
|
||||
for (int k = 0; k <= 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<int>& w, const std::vector<int>& v, int N, int W) {
|
||||
int dp[N+1][W+1]; // 创建备忘录
|
||||
memset(dp, 0, sizeof(dp));
|
||||
|
||||
// 初始化状态
|
||||
for (int i = 0; i < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < W + 1; rw++) {
|
||||
dp[tn][rw] = dp[tn-1][rw];
|
||||
// 根据rw尝试放入多次物品,从中找出最大值,作为当前子问题的最优解
|
||||
for (int k = 0; k <= 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<int> w = {0, 3, 2, 1}; // 物品的重量
|
||||
std::vector<int> 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<=0\\\<br>
|
||||
0, rw<=0\\\<br>
|
||||
DP(tn-1,rw), rw<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 < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < W + 1; rw++) {
|
||||
dp[tn][rw] = dp[tn-1][rw];
|
||||
// 如果可以放入,则尝试放入第tn件物品
|
||||
if (w[tn] <= 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<int>& w, const std::vector<int>& v, int N, int W) {
|
||||
int dp[N+1][W+1]; // 创建备忘录
|
||||
memset(dp, 0, sizeof(dp));
|
||||
|
||||
// 初始化状态
|
||||
for (int i = 0; i < N + 1; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < W + 1; rw++) {
|
||||
dp[tn][rw] = dp[tn-1][rw];
|
||||
// 如果可以放入,则尝试放入第tn件物品
|
||||
if (w[tn] <= 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<int> w = {0, 3, 2, 1}; // 物品的重量
|
||||
std::vector<int> 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<=0\\\<br>
|
||||
0, rw<=0\\\<br>
|
||||
DP(tn-1,rw), rw<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 < 2; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < 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] <= 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<int>& w, const std::vector<int>& v, int N, int W) {
|
||||
int dp[2][W+1]; // 创建备忘录
|
||||
memset(dp, 0, sizeof(dp));
|
||||
|
||||
// 初始化状态
|
||||
for (int i = 0; i < 2; i++) { dp[i][0] = 0; }
|
||||
for (int j = 0; j < W + 1; j++) { dp[0][j] = 0; }
|
||||
|
||||
// 遍历每一件物品
|
||||
for (int tn = 1; tn < N + 1; tn++) {
|
||||
// 背包容量有多大就还要计算多少次
|
||||
for (int rw = 1; rw < 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] <= 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<int> w = {0, 3, 2, 1}; // 物品的重量
|
||||
std::vector<int> 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)。那么在这种题设下,该如何使用动态规划来化解此问题呢?
|
||||
|
||||
在解决问题后,你是否能找到降低时间复杂度和空间复杂度的方法呢?
|
||||
|
||||
十分期待你的答案,欢迎你在留言区中与我交流!如果乍一看感觉解决不了,不妨再次复习下这节课的内容,或者考考你身边的同事或朋友呀。
|
||||
363
极客时间专栏/动态规划面试宝典/动态规划的套路/08|子数组问题:从解决动归问题套路到实践解题思路.md
Normal file
363
极客时间专栏/动态规划面试宝典/动态规划的套路/08|子数组问题:从解决动归问题套路到实践解题思路.md
Normal 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:
|
||||
|
||||
输入:"dp"
|
||||
输出:2
|
||||
解释:共有两个回文子串,分别为 "d", "p"。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
|
||||
输入:"aaa"
|
||||
输出:6
|
||||
解释:共有六个回文子串,分别为 "a", "a", "a", "aa", "aa", "aaa"。注意题设,具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串,因此像 "aa" 和 "aa" 就是两个不同的回文子串。
|
||||
|
||||
```
|
||||
|
||||
### 算法问题分析
|
||||
|
||||
字符串当然可以理解成数组。从数据结构上讲,它也是连续的,也可以通过索引访问特定位置字符。
|
||||
|
||||
除此之外,我们还需要注意一个子数组问题的特征,即答案也必须是连续的。举个例子,如果输入的字符串是"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]$ && $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 < n; i++) {
|
||||
dp[i][i] = true;
|
||||
ans++;
|
||||
}
|
||||
|
||||
for (int j = 1; j < n; j++) {
|
||||
for (int i = 0; i < j; i++) {
|
||||
dp[i][j] = (s.charAt(i) == s.charAt(j)) && (j-i <3 || dp[i+1][j-1]);
|
||||
if (dp[i][j]) { ans++; }
|
||||
}
|
||||
}
|
||||
|
||||
return ans;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int CountSubstrings(string s) {
|
||||
int n = static_cast<int>(s.size());
|
||||
if (0 == n) return 0;
|
||||
|
||||
int ans = 0;
|
||||
bool dp[n][n]; memset(dp, 0, sizeof(dp));
|
||||
for (int i = 0; i < n; i++) {
|
||||
dp[i][i] = true;
|
||||
ans++;
|
||||
}
|
||||
|
||||
for (int j = 1; j < n; j++) {
|
||||
for (int i = 0; i < j; i++) {
|
||||
dp[i][j] = s[i]==s[j] && (j-i <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$ < $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] \&\& j-i<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 > 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 < n; i++) dp[i] = Integer.MIN_VALUE; // 初始化状态
|
||||
|
||||
dp[0] = nums[0];
|
||||
|
||||
int res = dp[0];
|
||||
for (int i = 1; i < 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<int>& nums) {
|
||||
int n = nums.size(); if (0 == n) return 0;
|
||||
int dp[n];
|
||||
for (int i = 0; i < n; i++) dp[i] = INT_MIN; // 初始化状态
|
||||
|
||||
dp[0] = nums[0];
|
||||
|
||||
int res = dp[0];
|
||||
for (int i = 1; i < 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 < 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<int>& 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 < 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]$ 之间的关系,通过决策写出状态转移方程。
|
||||
|
||||
## 课程总结
|
||||
|
||||
所谓动态规划的子数组问题,就是从一个数组中寻找满足条件,并可以得到最优结果的一个最长的子数组序列的问题。
|
||||
|
||||
在设计备忘录时,我们根据实际情况缩减了问题中的状态数量,虽然缩减的方法不是非常套路,但是因为大多数缩减状态的方法大同小异,所以你可以根据这种思路去思考如何对类似问题的状态数量进行控制。状态数量会直接影响问题的空间复杂度和时间复杂度,状态越少,空间和时间复杂度肯定也就越小,求解方法也就越优秀。
|
||||
|
||||
最后,我们还讲解了如何在状态数量不变的情况下,根据状态的依赖关系,进一步缩减备忘录的空间,进一步降低空间复杂度。
|
||||
|
||||
由于实际的动态规划问题的状态数量肯定比较大,会带来较多的空间消耗。因此,在解决实际问题的时候,这种缩减备忘录空间的做法是非常常见的,我们有必要学习和掌握。
|
||||
|
||||
我们通过本课的两个子数组问题,认识了子数组这类动态规划问题的形式,了解了如何寻找状态和子问题、构建状态转移方程,并对其进行优化。在后续的课程中,你还会看到更复杂的子数组问题。到时候,希望你能够利用这节课所学到的内容,做到举一反三,百尺竿头更进一步。
|
||||
|
||||
## 课后思考
|
||||
|
||||
事实上,子串问题在很多情况下是可以使用滑动窗口来解决的。那对于子数组问题来说,我们该如何区分是该使用滑动窗口等传统算法来解决,还是该用动态规划来解决呢?请你给出能使用滑动窗口解决的子数组问题,并比较它与动态规划问题之间的区别。
|
||||
|
||||
不知道你今天的收获如何呢?如果感觉已经掌握了解题思路,不妨也去考考你的同事或者朋友吧,刚好也有机会复述一遍今天所学。
|
||||
317
极客时间专栏/动态规划面试宝典/动态规划的套路/09|子序列问题:详解重要的一大类动态规划问题.md
Normal file
317
极客时间专栏/动态规划面试宝典/动态规划的套路/09|子序列问题:详解重要的一大类动态规划问题.md
Normal 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:
|
||||
|
||||
输入:"asssasms"
|
||||
输出:5
|
||||
解释:一个可能的最长回文子序列为 "sssss",另一种可能的答案是 "asssa"。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
|
||||
输入:"abba"
|
||||
输出:4
|
||||
解释:有三个子序列满足题设要求:"aa"、"bb" 和 "abba",因此答案为 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 < n; i++) dp[i][i] = 1; // 初始化状态
|
||||
|
||||
for (int i = n-1; i >= 0; i--) {
|
||||
for (int j = i+1; j < 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<int>(s.size());
|
||||
if (0 == n) return 0;
|
||||
|
||||
int dp[n][n]; memset(dp, 0, sizeof(dp));
|
||||
for (int i = 0; i < n; i++) dp[i][i] = 1; // 初始化状态
|
||||
|
||||
for (int i = n-1; i >= 0; i--) {
|
||||
for (int j = i+1; j < 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 Subsequence,LCS)问题就需要从两个字符串中寻找公共子序列。让我们来看看这个问题的描述。
|
||||
|
||||
问题:给定两个字符串 $text1$ 和 $text2$,返回这两个字符串的最长公共子序列的长度。若这两个字符串没有公共子序列,则返回 0。其中:
|
||||
|
||||
- 1 ≤ text1.length ≤ 1000;
|
||||
- 1 ≤ text2.length ≤ 1000;
|
||||
- 输入的字符串只含有小写英文字符。
|
||||
|
||||
```
|
||||
示例1:
|
||||
|
||||
输入:text1 = "abcde", text2 = "ade"
|
||||
输出:3
|
||||
解释:最长公共子序列是 "ade",它的长度为 3。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
|
||||
输入:text1 = "abc", text2 = "def"
|
||||
输出: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 <= n; j++) {
|
||||
for (int i = 1; i <= 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 <= n; j++) {
|
||||
for (int i = 1; i <= 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]$ 中。
|
||||
|
||||
## 课程总结
|
||||
|
||||
动态规划领域中,所谓子序列问题,就是从给定字符序列中随意地(不一定连续)去掉若干个字符(可能一个也不去掉)后所形成的字符序列;而对子数组问题,则是从给定的序列中提取连续的序列。
|
||||
|
||||
所以,子序列问题往往比子数组问题要复杂一些,因为求解的子问题可能不是连续的字符串。但是,对于动态规划来说,处理问题的思路往往是不变的,其实只是换一种思路去寻找子问题罢了。
|
||||
|
||||
从我们分析的两个问题中基本可以看出子序列问题的处理模板,在子序列问题中由于暴力求解的代价更高,因此动态规划带来的价值也就更高。对于全面总结的处理子序列问题的动归套路,我会在下一个模块中进行讲解。
|
||||
|
||||
## 课后思考
|
||||
|
||||
对于这一课讲解的最长公共子序列问题,我们对最直接的解决方案进行了讲解。但实际上,代码还可以作出进一步优化。请你思考一下,如何优化这个方案的时间复杂度和空间复杂度?并给出改进后的算法代码。
|
||||
|
||||
欢迎留言和我分享你的想法,我们一同交流!
|
||||
358
极客时间专栏/动态规划面试宝典/动态规划的套路/10|面试即正义第二期:常见的动态规划面试题串烧.md
Normal file
358
极客时间专栏/动态规划面试宝典/动态规划的套路/10|面试即正义第二期:常见的动态规划面试题串烧.md
Normal 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. 向右 -> 向右 -> 向下
|
||||
2. 向右 -> 向下 -> 向右
|
||||
3. 向下 -> 向右 -> 向右
|
||||
|
||||
```
|
||||
|
||||
当遇到一个问题时,我们先要确定这个问题能否使用动态规划来进行求解,那就看一下该问题是否满足动归问题的三大特征吧。这个你应该非常熟悉了!
|
||||
|
||||
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], & if \ i\ne0\ or\ j\ne0 \\\<br>
|
||||
1, & 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 < m; i ++) { dp[i][0] = 1; }
|
||||
for (int j = 0; j < n; j ++) { dp[0][j] = 1; }
|
||||
|
||||
for (int i = 1; i < m; i ++) { // 状态转移过程
|
||||
for (int j = 1; j < 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 < m; i ++) { dp[i][0] = 1; }
|
||||
for (int j = 0; j < n; j ++) { dp[0][j] = 1; }
|
||||
|
||||
for (int i = 1; i < m; i ++) { // 状态转移过程
|
||||
for (int j = 1; j < 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. 向右 -> 向右 -> 向下 -> 向下
|
||||
2. 向下 -> 向下 -> 向右 -> 向右
|
||||
|
||||
```
|
||||
|
||||
显然,这个问题要比前面的问题复杂一些,在网格中加入了障碍物这一要素,也就是说有些格子是无法通行的,那么这个时候应该如何求解呢?
|
||||
|
||||
我们静下心来仔细想想,其实这个问题并没有产生本质变化。待求的子问题还是第 (m, n) 格子的不同路径总数。唯一的区别在于,其中一些格子有障碍物,无法通行。因此,对这些格子来说,其路径总数就是 0。
|
||||
|
||||
所以,重叠子问题、无后效性和最优子结构跟上一个问题是相同的。
|
||||
|
||||
### 写出状态转移方程
|
||||
|
||||
虽然基本的子问题没变,但是由于问题产生了变化,因此状态转移方程肯定会有所改变。
|
||||
|
||||
首先,我们还是确定**初始化状态**。依然先考虑网格的第一行和第一列,第一行永远只能从左侧的格子往前走;第一列永远只能从上方的格子往下走。由于我们只能向右或向下走,所以第一行和第一列的格子永远只能存在 1 条路径。但是,我们还需要再考虑那些有障碍的格子,对这些格子来说,它们的路径总数应该是 0 而不是 1。
|
||||
|
||||
由于该问题的基本计算规则没有发生变化。因此,**状态参数**依然是格子的行数和列数,我们只要知道了行数 $i$ 和列数 $j$ 就能知道这个格子的路径数量。
|
||||
|
||||
同理可得,状态存储空间依然是一个二维数组 **$DP[i][j]$,表示第 $i$ 行、第 $j$ 列的路径数量**。你可以参考上一个问题的表格,来加深对备忘录的理解。
|
||||
|
||||
好了,现在就是重头戏了,写出我们的**状态转移方程**。这个时候我们需要注意:对这些存在障碍的格子,它们的状态需要设为 0。为此,我们得到了以下状态转移方程:
|
||||
|
||||
$$DP(i, j)=\left\{\begin{array}{c}<br>
|
||||
1, & i=0\ and\ j=0\ and\ u(i,j)=0 \\\<br>
|
||||
0, & u(i,j)=1 \\\<br>
|
||||
DP[i-1][j] + DP[i][j-1] & 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 < m; i ++) { dp[i][0] = v[i][0] == 1 ? 0 : 1; }
|
||||
for (int j = 0; j < n; j ++) { dp[0][j] = v[0][j] == 1 ? 0 : 1; }
|
||||
|
||||
for (int i = 1; i < m; i ++) { // 状态转移过程
|
||||
for (int j = 1; j < 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<vector<int>>& v) {
|
||||
int m = v.size();
|
||||
int n = v[0].size();
|
||||
|
||||
int dp[m][n]; memset(dp, 0, sizeof(dp));
|
||||
|
||||
// 初始化状态
|
||||
for (int i = 0; i < m; i ++) { dp[i][0] = v[i][0] ? 0 : 1; }
|
||||
for (int j = 0; j < n; j ++) { dp[0][j] = v[0][j] ? 0 : 1; }
|
||||
|
||||
for (int i = 1; i < m; i ++) { // 状态转移过程
|
||||
for (int j = 1; j < 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, & i = 0 \\\<br>
|
||||
(DP[j] = true)\ and\ (max(A[j]+j) \geq i), & i \ne 0\ and\ j < i \\\<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
### 编写代码进行求解
|
||||
|
||||
有了状态转移方程,求解代码也就不难了。按照惯例,我直接给出求解代码。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
public boolean canJump(int[] nums) {
|
||||
int n = nums.length;
|
||||
if (n <= 1) { return true; }
|
||||
|
||||
boolean[] dp = new boolean[n];
|
||||
// 初始化状态
|
||||
for (int i = 0; i < n; i++) { dp[i] = false; }
|
||||
dp[0] = true;
|
||||
|
||||
for (int i = 1; i < n; i++) {
|
||||
for (int j = 0; j < i; j++) { // j < i
|
||||
if (dp[j] && j + nums[j] >= i) {
|
||||
dp[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[n - 1]; // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
bool canJump(vector<int>& nums) {
|
||||
int n = nums.size();
|
||||
if (n <= 1) { return true; }
|
||||
|
||||
bool dp[n]; memset(dp, 0, sizeof(dp));
|
||||
dp[0] = true; // 初始化状态
|
||||
|
||||
for (int i = 1; i < n; i++) {
|
||||
for (int j = 0; j < i; j++) { // j < i
|
||||
if (dp[j] && j + nums[j] >= i) {
|
||||
dp[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[n - 1]; // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 课程总结
|
||||
|
||||
以上就是求方案总数类型问题,以及求可行性类型问题的解法了。
|
||||
|
||||
事实上,我们完全可以通过转化其它类型的问题,来得到求可行性类型的动态规划问题。比如说,在带障碍的路径规划中,我们求的是路径总数。但是,当我将题目改一下:问能否从起点移动到终点?这样就变成了求可行性的问题。
|
||||
|
||||
我们依然在遵循之前给出的动态规划解题模板来求解问题。首先,根据重叠子问题、无后向性和最优子结构来确定能否用动态规划解题。然后,再确定初始化状态、状态参数,接着确定状态存储数组(即备忘录),最终写出关键的状态转移方程。
|
||||
|
||||
一旦写出状态转移方程,我们的问题就解决掉一大半了。你可以按照这种思路,自己去尝试解决一些实际的面试问题,从而加深你对解题模板的利用和理解。过程中如果遇到困难,欢迎随时在留言区中提出。
|
||||
|
||||
## 课后思考
|
||||
|
||||
这节课我们学习了路径规划的解法,那么在带障碍的路径规划问题的基础上,我再为每条边设定一个长度(也就是不同格子之间行进的距离是不同的),此时该如何求出从起点到终点的最大长度呢?
|
||||
|
||||
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把文章分享给你身边的朋友,邀请他一起学习!
|
||||
337
极客时间专栏/动态规划面试宝典/动态规划的套路/加餐|买卖股票:常见且必考的动态规划面试题.md
Normal file
337
极客时间专栏/动态规划面试宝典/动态规划的套路/加餐|买卖股票:常见且必考的动态规划面试题.md
Normal 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, & case1: j=0\ and\ k=0 \\\<br>
|
||||
max(DP[i-1][1][0]+p[i], DP[i-1][0][1]), & case2: j=0\ and\ k=1 \\\<br>
|
||||
max(DP[i-1][1][1]+p[i], DP[i-1][0][2]), & case3: j=0\ and\ k=2 \\\<br>
|
||||
max(DP[i-1][0][0]-p[i], DP[i-1][1][0]), & case4: j=1\ and\ k=0 \\\<br>
|
||||
max(DP[i-1][0][1]-p[i], DP[i-1][1][1]), & case5: j=1\ and\ k=1 \\\<br>
|
||||
-INF, & 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 < 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<int>& 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 < 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, & case1: j=0\ and\ k=0 \\\<br>
|
||||
max(DP[i-1][1][k-1]+p[i], DP[i-1][0][k]), & case2: j=0\ and\ k<=k_{max} \\\<br>
|
||||
max(DP[i-1-t][0][k]-p[i]-c, DP[i-1][1][k]), & case4: j=1\ and\ k<k_{max} \\\<br>
|
||||
-INF, & 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, & case1: j=0\ and\ k=0 \\\<br>
|
||||
max(DP(i-1,1,k-1)+p[i], DP(i-1,0,k)), & case2: j=0\ and\ k<=3 \\\<br>
|
||||
max(DP(i-1,0,k)-p[i]-2, DP(i-1,1,k)), & case4: j=1\ and\ k<3 \\\<br>
|
||||
-INF, & case5: j=1\ and\ k=3 \\\<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
这里,我们把最大次数 $K_{max}$ 替换成了 3,把冻结期 $t$ 替换成 0,把手续费 $c$ 替换成 2。通过买卖股票的解题模板,我们就能非常轻松地解决这些问题了。
|
||||
|
||||
## 课程总结
|
||||
|
||||
鉴于我们刚刚已经总结了解题模版,这里就不再赘述了。最后再啰嗦一句吧,其实很多动态规划问题就像我们处理股票问题的框架一样,很多类似的题目都可以通过总结分析,直接套用模板,效果会非常好!你不妨多去试试。
|
||||
|
||||
## 课后思考
|
||||
|
||||
你能否写出通用的买卖股票的代码实现。另外,请你思考一下是否存在时间或空间复杂度优化的可能性?
|
||||
|
||||
期待你的分享,任何问题欢迎来留言区一起讨论!
|
||||
Reference in New Issue
Block a user