mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
336
极客时间专栏/动态规划面试宝典/初识动态规划/01 | 硬币找零问题:从贪心算法说起.md
Normal file
336
极客时间专栏/动态规划面试宝典/初识动态规划/01 | 硬币找零问题:从贪心算法说起.md
Normal file
@@ -0,0 +1,336 @@
|
||||
<audio id="audio" title="01 | 硬币找零问题:从贪心算法说起" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/0c/c3838d375ba69266f4f1d003356eba0c.mp3"></audio>
|
||||
|
||||
你好,我是卢誉声。
|
||||
|
||||
作为“初识动态规划”模块的第一节课,我会带着你一起从贪心算法开始了解整个知识体系的脉络。现实中,我们往往不愿意承认自己贪婪。事实上,贪婪是渴望而不知满足,它是人的一种基本驱动力。既然是基本驱动力,那它自然就不会太难。
|
||||
|
||||
所以你可能会说贪心算法很简单啊,但其实不然,这里面还真有不少门道值得我们说说。而且,它还跟动态规划问题有着千丝万缕的联系,能够帮助我们理解真正的动归问题。
|
||||
|
||||
接下来我们就从一个简单的算法问题开始探讨,那就是硬币找零。在开始前,我先提出一个问题:**任何算法都有它的局限性,贪心算法也如此,那么贪心算法能解决哪些问题呢?**
|
||||
|
||||
你不妨带着这个问题来学习下面的内容。
|
||||
|
||||
## 硬币找零问题
|
||||
|
||||
移动支付已经成为了我们日常生活当中的主流支付方式,无论是在便利店购买一瓶水,还是在超市或菜市场购买瓜果蔬菜等生活用品,无处不在的二维码让我们的支付操作变得异常便捷。
|
||||
|
||||
但在移动支付成为主流支付方式之前,我们常常需要面对一个简单问题,就是找零的问题。
|
||||
|
||||
虽然说硬币找零在日常生活中越来越少,但它仍然活跃在编程领域和面试问题当中,主要还是因为它极具代表性,也能多方面考察一个开发人员或面试者解决问题的能力。
|
||||
|
||||
既然如此,我们就先来看看这个算法问题的具体描述。
|
||||
|
||||
问题:给定n种不同面值的硬币,分别记为c[0], c[1], c[2], … c[n],同时还有一个总金额k,编写一个函数计算出**最少**需要几枚硬币凑出这个金额k?每种硬币的个数不限,且如果没有任何一种硬币组合能组成总金额时,返回 -1。
|
||||
|
||||
```
|
||||
示例 1:
|
||||
|
||||
输入:c[0]=1, c[1]=2, c[2]=5, k=12
|
||||
输出:3
|
||||
解释:12 = 5 + 5 + 2
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例 2:
|
||||
|
||||
输入:c[0]=5, k=7
|
||||
输出:-1
|
||||
解释:只有一种面值为5的硬币,怎么都无法凑出总价值为7的零钱。
|
||||
|
||||
```
|
||||
|
||||
题目中有一个醒目的提示词,那就是“最少”。嗯,看起来这是一个求最值的问题,其实也好理解,如果题目不在这里设定这一条件,那么所求结果就不唯一了。
|
||||
|
||||
举个简单的例子,按照示例1的题设,有三种不同面值的硬币,分别为c1=1, c2=2, c3=5,在没有“最少”这一前提条件下你能罗列出几种不同的答案?我在这里随意列出几个:
|
||||
|
||||
```
|
||||
解1:输出:5,因为 5 + 2 + 2 + 2 + 1 = 12。
|
||||
解2:输出:6,因为 2 + 2 + 2 + 2 + 2 + 2 = 12。
|
||||
解3:输出:12,因为 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 12。
|
||||
|
||||
```
|
||||
|
||||
所以,这是一个求最值的问题。那么求最值的核心问题是什么呢?嗯,无非就是**穷举**,显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币,那么最少的凑法,就是这道题目的答案。
|
||||
|
||||
在面试中,一般来说穷举从来都不是一个好方法。除非你要的结果就是所有的不同组合,而不是一个最值。但即便是求所有的不同组合,在计算的过程中也仍然会出现重复计算的问题,我们将这种现象称之为**重叠子问题**。
|
||||
|
||||
请你记住这个关键概念,它是动态规划当中的一个重要概念。但现在你只需要知道所谓重叠子问题就是:我们在罗列所有可能答案的过程中,可能存在重复计算的情况。我会在后续课程中与你深入探讨这个概念。
|
||||
|
||||
在尝试解决硬币找零问题前,我们先用较为严谨的定义来回顾一下贪心算法的概念。
|
||||
|
||||
## 贪心算法
|
||||
|
||||
所谓贪心算法,就是指它的每一步计算作出的都是在当前看起来最好的选择,也就是说它所作出的选择只是在某种意义上的局部最优选择,并不从整体最优考虑。在这里,我把这两种选择的思路称作**局部最优解**和**整体最优解**。
|
||||
|
||||
因此,我们可以得到贪心算法的基本思路:
|
||||
|
||||
1. 根据问题来建立数学模型,一般面试题会定义一个简单模型;
|
||||
1. 把待求解问题划分成若干个子问题,对每个子问题进行求解,得到子问题的局部最优解;
|
||||
1. 把子问题的局部最优解进行合并,得到最后基于局部最优解的一个解,即原问题的答案。
|
||||
|
||||
## 解题思路
|
||||
|
||||
现在让我们回到这个问题上来。
|
||||
|
||||
既然这道题问的是**最少**需要几枚硬币凑出金额k,那么是否可以尝试使用贪心的思想来解这个问题呢?从面值最大的硬币开始兑换,最后得出的硬币总数很有可能就是最少的。
|
||||
|
||||
这个想法不错,让我们一起来试一试。
|
||||
|
||||
我用一个例子,带你看下整个贪心算法求解的过程,我们从 c[0]=5, c[1]=3 且k=11 的情况下寻求最少硬币数。按照“贪心原则”,我们先挑选面值最大的,即为5的硬币放入钱包。接着,还有6元待解(即11-5 = 6)。这时,我们再次“贪心”,放入5元面值的硬币。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/12/20e9a277444dfcf9362cf1f22106d812.jpg" alt="">
|
||||
|
||||
这样来看,贪心算法其实不难吧。我在这里把代码贴出来,你可以结合代码再理解一下算法的执行步骤。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int getMinCoinCountHelper(int total, int[] values, int valueCount) {
|
||||
int rest = total;
|
||||
int count = 0;
|
||||
|
||||
// 从大到小遍历所有面值
|
||||
for (int i = 0; i < valueCount; ++ i) {
|
||||
int currentCount = rest / values[i]; // 计算当前面值最多能用多少个
|
||||
rest -= currentCount * values[i]; // 计算使用完当前面值后的余额
|
||||
count += currentCount; // 增加当前面额用量
|
||||
|
||||
if (rest == 0) {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
return -1; // 如果到这里说明无法凑出总价,返回-1
|
||||
}
|
||||
|
||||
int getMinCoinCount() {
|
||||
int[] values = { 5, 3 }; // 硬币面值
|
||||
int total = 11; // 总价
|
||||
return getMinCoinCountHelper(total, values, 2); // 输出结果
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int GetMinCoinCountHelper(int total, int* values, int valueCount) {
|
||||
int rest = total;
|
||||
int count = 0;
|
||||
|
||||
// 从大到小遍历所有面值
|
||||
for (int i = 0; i < valueCount; ++ i) {
|
||||
int currentCount = rest / values[i]; // 计算当前面值最多能用多少个
|
||||
rest -= currentCount * values[i]; // 计算使用完当前面值后的余额
|
||||
count += currentCount; // 增加当前面额用量
|
||||
|
||||
if (rest == 0) {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
return -1; // 如果到这里说明无法凑出总价,返回-1
|
||||
}
|
||||
|
||||
int GetMinCoinCount() {
|
||||
int values[] = { 5, 3 }; // 硬币面值
|
||||
int total = 11; // 总价
|
||||
return GetMinCoinCountHelper(total, values, 2); // 输出结果
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码就是简单地从最大的面值开始尝试,每次都会把当前面值的硬币尽量用光,然后才会尝试下一种面值的货币。
|
||||
|
||||
嗯。。。你有没有发现问题?那就是还剩1元零钱待找,但是我们只有c[0]=5, c[1]=3两种面值的硬币,怎么办?这个问题无解了,该返回-1了吗?显然不是。
|
||||
|
||||
我们把第2步放入的5元硬币取出,放入面值为3元的硬币试试看。这时,你就会发现,我们还剩3元零钱待找。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/85/8fayy6c170b5732431fecb165d53e885.jpg" alt="">
|
||||
|
||||
正好我们还有c[1]=3的硬币可以使用,因此解是c[0]=5, c[1]=3, c[1]=3,即**最少**使用三枚硬币凑出了k=11这个金额。
|
||||
|
||||
我们对贪心算法做了改进,引入了回溯来解决前面碰到的“过于贪心”的问题。同样地,我把改进后的代码贴在这,你可以再看看跟之前算法实现的区别。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int getMinCoinCountOfValue(int total, int[] values, int valueIndex) {
|
||||
int valueCount = values.length;
|
||||
if (valueIndex == valueCount) { return Integer.MAX_VALUE; }
|
||||
|
||||
int minResult = Integer.MAX_VALUE;
|
||||
int currentValue = values[valueIndex];
|
||||
int maxCount = total / currentValue;
|
||||
|
||||
for (int count = maxCount; count >= 0; count --) {
|
||||
int rest = total - count * currentValue;
|
||||
|
||||
// 如果rest为0,表示余额已除尽,组合完成
|
||||
if (rest == 0) {
|
||||
minResult = Math.min(minResult, count);
|
||||
break;
|
||||
}
|
||||
|
||||
// 否则尝试用剩余面值求当前余额的硬币总数
|
||||
int restCount = getMinCoinCountOfValue(rest, values, valueIndex + 1);
|
||||
|
||||
// 如果后续没有可用组合
|
||||
if (restCount == Integer.MAX_VALUE) {
|
||||
// 如果当前面值已经为0,返回-1表示尝试失败
|
||||
if (count == 0) { break; }
|
||||
// 否则尝试把当前面值-1
|
||||
continue;
|
||||
}
|
||||
|
||||
minResult = Math.min(minResult, count + restCount);
|
||||
}
|
||||
|
||||
return minResult;
|
||||
}
|
||||
|
||||
int getMinCoinCountLoop(int total, int[] values, int k) {
|
||||
int minCount = Integer.MAX_VALUE;
|
||||
int valueCount = values.length;
|
||||
|
||||
if (k == valueCount) {
|
||||
return Math.min(minCount, getMinCoinCountOfValue(total, values, 0));
|
||||
}
|
||||
|
||||
for (int i = k; i <= valueCount - 1; i++) {
|
||||
// k位置已经排列好
|
||||
int t = values[k];
|
||||
values[k] = values[i];
|
||||
values[i]=t;
|
||||
minCount = Math.min(minCount, getMinCoinCountLoop(total, values, k + 1)); // 考虑后一位
|
||||
|
||||
// 回溯
|
||||
t = values[k];
|
||||
values[k] = values[i];
|
||||
values[i]=t;
|
||||
}
|
||||
|
||||
return minCount;
|
||||
}
|
||||
|
||||
int getMinCoinCountOfValue() {
|
||||
int[] values = { 5, 3 }; // 硬币面值
|
||||
int total = 11; // 总价
|
||||
int minCoin = getMinCoinCountLoop(total, values, 0);
|
||||
|
||||
return (minCoin == Integer.MAX_VALUE) ? -1 : minCoin; // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int GetMinCoinCountOfValue(int total, int* values, int valueIndex, int valueCount) {
|
||||
if (valueIndex == valueCount) { return INT_MAX; }
|
||||
|
||||
int minResult = INT_MAX;
|
||||
int currentValue = values[valueIndex];
|
||||
int maxCount = total / currentValue;
|
||||
|
||||
for (int count = maxCount; count >= 0; count --) {
|
||||
int rest = total - count * currentValue;
|
||||
|
||||
// 如果rest为0,表示余额已除尽,组合完成
|
||||
if (rest == 0) {
|
||||
minResult = min(minResult, count);
|
||||
break;
|
||||
}
|
||||
|
||||
// 否则尝试用剩余面值求当前余额的硬币总数
|
||||
int restCount = GetMinCoinCountOfValue(rest, values, valueIndex + 1, valueCount);
|
||||
|
||||
// 如果后续没有可用组合
|
||||
if (restCount == INT_MAX) {
|
||||
// 如果当前面值已经为0,返回-1表示尝试失败
|
||||
if (count == 0) { break; }
|
||||
// 否则尝试把当前面值-1
|
||||
continue;
|
||||
}
|
||||
|
||||
minResult = min(minResult, count + restCount);
|
||||
}
|
||||
|
||||
return minResult;
|
||||
}
|
||||
|
||||
int GetMinCoinCountLoop(int total, int* values, int valueCount, int k) {
|
||||
int minCount = INT_MAX;
|
||||
if (k == valueCount) {
|
||||
return min(minCount, GetMinCoinCountOfValue(total, values, 0, valueCount));
|
||||
}
|
||||
|
||||
for (int i = k; i <= valueCount - 1; i++) {
|
||||
// k位置已经排列好
|
||||
int t = values[k];
|
||||
values[k] = values[i];
|
||||
values[i]=t;
|
||||
minCount = min(minCount, GetMinCoinCountOfValue(total, values, 0, valueCount));
|
||||
minCount = min(minCount, GetMinCoinCountLoop(total, values, valueCount, k + 1)); // 考虑后一位
|
||||
|
||||
// 回溯
|
||||
t = values[k];
|
||||
values[k] = values[i];
|
||||
values[i]=t;
|
||||
}
|
||||
|
||||
return minCount;
|
||||
}
|
||||
|
||||
int GetMinCoinCountOfValue() {
|
||||
int values[] = { 5, 3 }; // 硬币面值
|
||||
int total = 11; // 总价
|
||||
int minCoin = GetMinCoinCountLoop(total, values, 2, 0);
|
||||
|
||||
return (minCoin == INT_MAX) ? -1 : minCoin;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
改进后的算法实现在之前的基础上增加上了一个**回溯**过程。简单地说就是多了一个**递归**,不断尝试用更少的当前面值来拼凑。只要有一个组合成功,我们就返回总数,如果所有组合都尝试失败,就返回-1。
|
||||
|
||||
嗯,这样就没问题了,对硬币找零问题来说,我们得到了理想的结果。
|
||||
|
||||
## 贪心算法的局限性
|
||||
|
||||
从上面这个例子我们可以看出,如果只是简单采用贪心的思路,那么到用完2个5元硬币的时候我们就已经黔驴技穷了——因为剩下的1元无论如何都没法用现在的硬币凑出来。这是什么问题导致的呢?
|
||||
|
||||
这就是贪心算法所谓的**局部最优**导致的问题,因为我们每一步都尽量多地使用面值最大的硬币,因为这样数量肯定最小,但是有的时候我们就进入了死胡同,就好比上面这个例子。
|
||||
|
||||
所谓**局部最优**,就是只考虑“当前”的最大利益,既不向前多看一步,也不向后多看一步,导致每次都只用当前阶段的最优解。
|
||||
|
||||
那么如果纯粹采用这种策略我们就永远无法达到**整体最优**,也就无法求得题目的答案了。至于能得到答案的情况那就是我们走狗屎运了。
|
||||
|
||||
虽然纯粹的贪心算法作用有限,但是这种求解**局部最优**的思路在方向上肯定是对的,毕竟所谓的**整体最优**肯定是从很多个**局部最优**中选择出来的,因此所有最优化问题的基础都是贪心算法。
|
||||
|
||||
回到前面的例子,我只不过是在贪心的基础上加入了失败后的回溯,稍微牺牲一点当前利益,仅仅是希望通过下一个硬币面值的**局部最优**达到最终可行的**整体最优**。
|
||||
|
||||
所有贪心的思路就是我们最优化求解的根本思想,所有的方法只不过是针对贪心思路的改进和优化而已。回溯解决的是正确性问题,而动态规划则是解决时间复杂度的问题。
|
||||
|
||||
贪心算法是求解整体最优的真正思路源头,这就是为什么我们要在课程的一开始就从贪心算法讲起。
|
||||
|
||||
## 课程总结
|
||||
|
||||
硬币找零问题本质上是求最值问题。事实上,动态规划问题的一般形式就是求最值,而求最值的核心思想是**穷举**。这是因为只要我们能够找到所有可能的答案,从中挑选出最优的解就是算法问题的结果。
|
||||
|
||||
在没有优化的情况下,穷举从来就不算是一个好方法。所以我带你使用了贪心算法来解题,它是一种使用**局部最优**思想解题的算法(即从问题的某一个初始解出发逐步逼近给定的目标,以尽可能快的速度去求得更好的解,当达到算法中的某一步不能再继续前进时,算法停止)。
|
||||
|
||||
但是通过硬币找零问题,我们也发现了贪心算法本身的局限性:
|
||||
|
||||
1. 不能保证求得的最后解是最佳的;
|
||||
1. 不能用来求最大或最小解问题;
|
||||
1. 只能求满足某些约束条件的可行解的范围。
|
||||
|
||||
我们往往需要使用**回溯**来优化贪心算法,否则就会导致算法失效。因此,在求解最值问题时,我们需要更好的方法来解。在后面课程讲到递归和穷举优化问题的时候,我会讲到解决最值问题的正确思路和方法:考虑**整体最优**的问题。
|
||||
|
||||
## 课后思考
|
||||
|
||||
在递归问题中,回溯是一种经典的优化算法性能的方法。递归对动态规划来说也十分重要。你能否举出使用回溯算法来解的面试问题?并给出你的解。希望你能在课后提出问题,进行练习。
|
||||
|
||||
最后,欢迎留言和我分享你的思考,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!
|
||||
502
极客时间专栏/动态规划面试宝典/初识动态规划/02 | 暴力递归:当贪心失效了怎么办?.md
Normal file
502
极客时间专栏/动态规划面试宝典/初识动态规划/02 | 暴力递归:当贪心失效了怎么办?.md
Normal file
@@ -0,0 +1,502 @@
|
||||
<audio id="audio" title="02 | 暴力递归:当贪心失效了怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/81/yy2ff02a92ce3df2b7e2d47afba69181.mp3"></audio>
|
||||
|
||||
你好,我是卢誉声。
|
||||
|
||||
上一课我们学习了贪心算法,提出了硬币找零的问题,发现了贪心算法的局限性。与此同时,我还提出了一个重要概念,那就是局部最优与整体最优的概念,即最优化问题。今天,我们就从最优化问题开始聊起,引出学习动态规划时的另一重要概念:递归。
|
||||
|
||||
我们之前说过,贪心算法是求解整体最优的真正思路源头,这是为什么我们要在这门课程的一开始从贪心算法讲起。现在,你应该已经意识到贪心算法是有局限性的,它只能在局部最优的思想下工作,**那么当贪心算法失效了怎么办?**
|
||||
|
||||
接下来我们就带着这个问题,开始学习今天的内容:递归!看看它能否更进一步地解决我们遇到的棘手问题,从整体最优的角度来解决算法问题。
|
||||
|
||||
## 从最优化问题到递归
|
||||
|
||||
贪心算法失效的很大一个原因在于它明显的局限性:它几乎只考虑局部最优解。所谓局部最优,就是只考虑当前的最大利益,既不向前多看一步,也不向后多看一步,导致每次都只用当前阶段的最优解。
|
||||
|
||||
因此在绝大多数情况下,贪心算法不能得到整体最优解,但它的解是最优解的一个很好近似。同时,也是所有讨论最优化问题的核心基础。
|
||||
|
||||
既然无法通过贪心算法达到整体最优,我们就得换一个思路了:我们得从整体最优的层面上解决这个难缠的算法问题。那么从何说起呢?我认为你应该先理解最优化问题的本质,然后再把这个思考扩展到递归问题上。话不多说,我们这就开始吧!
|
||||
|
||||
### 最优化问题的本质
|
||||
|
||||
所谓最优化问题,就是指在某些约束条件下,决定可选择的变量应该取何值,使所选定的目标函数达到最优的问题。
|
||||
|
||||
从数学意义上说,最优化方法是一种求极值的方法,即在一组约束为等式或不等式的条件下,使系统的目标函数达到极值,即最大值或最小值。
|
||||
|
||||
如果只是从概念上来看最优化问题真的是玄而又玄,所以在上一课中我用了硬币找零的例子,引出了最优化的概念,以便你理解。
|
||||
|
||||
在数学里一切都是函数,现在我们先把这个问题用函数形式来表示。为了易于理解,下面我们不会使用向量。
|
||||
|
||||
我们假定需要给出$y$元硬币,硬币面额是5元和3元,求出需要的最少硬币数量。所谓的最少硬币数量就是5元硬币和3元硬币的总数,假定5元硬币数量为$x_{0}$,3元硬币数量为$x_{1}$,那么用函数表示就是:
|
||||
|
||||
$$f(x_{0}, x_{1})=x_{0}+x_{1}$$
|
||||
|
||||
这就是所谓的“目标函数”。
|
||||
|
||||
但是这个函数现在是没有任何限制的,我们希望对此进行约束,使得5元硬币和3元硬币的面值综合为$y$。为此我们需要给出一个约束:
|
||||
|
||||
$$5x_{0}+3x_{1}=y$$
|
||||
|
||||
这个时候我们的问题就变成了,当满足这个约束条件的时候,求解函数中的变量$x_{0}$和$x_{1}$,使得目标函数$f(x_{0}, x_{1})$的取值最小。如果用数学的描述方法来说的话,就是下面这样:
|
||||
|
||||
$${\arg\min}_{(x_0,x_1)\in S} (x_0+x_1)$$
|
||||
|
||||
这个就是我们常见的$argmin$表示方式。它的意思是:当$(x_{0}, x_{1})$属于$S$这个集合的时候,希望知道$x_{0} + x_{1}$的最小值是多少。其中$S$集合的条件就是上面的约束。
|
||||
|
||||
所以最优化问题在我们生活中是非常普遍的,只不过大多数问题可能都像硬币找零问题这样看起来普普通通,概念其实是不难理解的。
|
||||
|
||||
回到硬币找零这个问题上。由于$(x_{0}, x_{1})$都是离散的值,因此所有满足上述约束的$(x_{0}, x_{1})$组合,就是我们最终所求的集合!而这个最优化问题的本质就是:从所有满足条件的组合$(x_{0},x_{1})$中找出一个组合,使得$x_{0}+x_{1}$的值最小。
|
||||
|
||||
所以,你会发现在这种离散型的最优化问题中,本质就是从所有满足条件的组合(能够凑出$y$元)中选择出使得我们的目标函数(所有硬币数量之和)最小的那个组合。而这个所谓满足条件的组合不就是$argmin$公式中的那个集合$S$吗?
|
||||
|
||||
因此,这种离散型的最优化问题就是去所有满足条件的组合里找出最优解的组合。我曾多次提到的**局部最优**就是在一定条件下的最优解,而**整体最优**就是我们真正希望得到的最优解。
|
||||
|
||||
那么我们的视角就转到另一边了:如何去找到这个最优解呢?
|
||||
|
||||
### 枚举与递归:最优组合的求解策略
|
||||
|
||||
如果想得到最优组合,那么最简单直接的方法肯定就是**枚举**。枚举就是直接求出所有满足条件的组合,然后看看这些组合是否能得到最大值或者最小值。
|
||||
|
||||
在硬币找零问题中,假设现在需要给出25元的硬币,有两种组合,分别是(5, 0)和(2, 5),也就是5个5元硬币,或者2个5元硬币加上5个3元硬币,那么硬币数量最小的组合肯定就是(5, 0)。
|
||||
|
||||
所以最简单的方法就是找出所有满足条件的组合,也就是上面两个组合,然后去看这些组合中的最优解。
|
||||
|
||||
枚举本身很简单,就是把所有组合都遍历一遍即可。可现在问题就是,**如何得到这些组合呢?**
|
||||
|
||||
这就需要我们通过一些策略来生成所有满足条件的组合。而**递归**正是得到这些组合的方法。在解决问题前,我们先回顾一下递归问题的本质。
|
||||
|
||||
## 递归与问题表达
|
||||
|
||||
我们可以看出,其实最优化问题使用递归来处理是非常清晰的,递归是搜索组合的一种非常直观的思路。
|
||||
|
||||
当我在稍后的课程里讨论动态规划时,你就会发现所有问题都需要被描述成递归的形式来讨论。
|
||||
|
||||
所以我们有必要先巩固一下递归的概念。首先是在数学中我们怎么去用递归描述一个问题,然后是如何用递归描述最优化问题的解法。
|
||||
|
||||
### 从斐波那契数列说起
|
||||
|
||||
严格来说,斐波那契数列问题不是最优化问题,但它能很好地展示递归的概念。我们先来看一下斐波那契数列的问题描述。
|
||||
|
||||
问题:斐波那契数通常用 F(n) 表示,形成的序列称为斐波那契数列。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和:
|
||||
|
||||
$$F(n)=\left\{\begin{array}{c}<br>
|
||||
0,n=0\\\<br>
|
||||
1,n=1\\\<br>
|
||||
F(n-1)+F(n-2),n>1<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
```
|
||||
示例 1:
|
||||
|
||||
输入:2
|
||||
输出:1
|
||||
解释:F(2) = F(1) + F(0) = 1 + 0 = 1。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例 2:
|
||||
|
||||
输入:3
|
||||
输出:2
|
||||
解释:F(3) = F(2) + F(1) = 1 + 1 = 2.
|
||||
|
||||
```
|
||||
|
||||
很多人在解算法面试问题的时候有一种倾向性,那就是使用迭代而非递归来求解问题。我先不说这样的倾向性正确与否,那么我们就按照这个偏好来解一下(即斐波那契数列的循环解法)。
|
||||
|
||||
Java实现:
|
||||
|
||||
```
|
||||
int fibonacci(int n) {
|
||||
int[] resolution = {0, 1}; // 解的数组
|
||||
if(n < 2) { return resolution[n]; }
|
||||
|
||||
int i = 1;
|
||||
int fib1 = 0, fib2 = 1, fib = 0;
|
||||
while(i < n) {
|
||||
fib = fib1 + fib2;
|
||||
fib1 = fib2;
|
||||
fib2 = fib;
|
||||
i++;
|
||||
}
|
||||
|
||||
return fib; // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++实现:
|
||||
|
||||
```
|
||||
int Fibonacci(int n) {
|
||||
std::vector<int> resolution = {0, 1}; // 解的数组
|
||||
if(n < 2) { return resolution[n]; }
|
||||
|
||||
int i = 1;
|
||||
int fib1 = 0, fib2 = 1, fib = 0;
|
||||
while(i < n) {
|
||||
fib = fib1 + fib2;
|
||||
fib1 = fib2;
|
||||
fib2 = fib;
|
||||
i++;
|
||||
}
|
||||
|
||||
return fib; // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
嗯,这样的解法固然没错,但是它几乎脱离了题设的数学表达形式。在这道题目中,出题者“刻意”地写出了求解斐波那契数列的函数表达式,这其中有没有什么别的含义或原因呢?
|
||||
|
||||
当然有了,这个函数表达式很好地反应出了计算机科学中常见的算法形式:递归。 下面,就让我们来看看斐波那契数列与递归之间的关系。
|
||||
|
||||
### 使用递归求解斐波那契数列
|
||||
|
||||
事实上,斐波那契数列的数学形式就是递归的,我在这里直接贴出其递归形式的算法代码,你就能很清楚地看出这一点。
|
||||
|
||||
```
|
||||
int Fibonacci(int n) {
|
||||
if (0 == n || 1 == n) { return n; }
|
||||
if(n > 1) { return Fibonacci(n - 1) + Fibonacci(n - 2); }
|
||||
|
||||
return 0; // 如果输入n有误,则返回默认值
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
递归形式的求解几乎就是简单的把题设中的函数表达式照搬过来,因此我们说从数学意义上讲,递归更直观,且易于理解。
|
||||
|
||||
### 使用递归求解硬币问题
|
||||
|
||||
你可以看出,理解递归并不难,现在我们要把这种思路套用到求解硬币的问题上来。话不多说,我在这里直接贴出使用递归求解硬币问题的代码实现。
|
||||
|
||||
Java实现:
|
||||
|
||||
```
|
||||
void getMinCountsHelper(int total, int[] values, ArrayList<Integer> currentCounts, ArrayList<ArrayList<Integer>> combinations) {
|
||||
if (0 == total) { // 如果余额为0,说明当前组合成立,将组合加入到待选数组中
|
||||
combinations.add(new ArrayList<Integer>(currentCounts));
|
||||
return;
|
||||
}
|
||||
|
||||
int valueLength = values.length;
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
if (currentValue > total) { // 如果面值大于当前总额,直接跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 否则在当前面值数量组合上的对应位置加1
|
||||
ArrayList<Integer> newCounts = new ArrayList<Integer>(currentCounts);
|
||||
newCounts.set(i, newCounts.get(i)+1);
|
||||
int rest = total - currentValue;
|
||||
|
||||
getMinCountsHelper(rest, values, newCounts, combinations); // 求解剩余额度所需硬币数量
|
||||
}
|
||||
}
|
||||
|
||||
int getMinimumHelper(ArrayList<ArrayList<Integer>> combinations) {
|
||||
// 如果没有可用组合,返回-1
|
||||
if (0 == combinations.size()) { return -1; }
|
||||
|
||||
int minCount = Integer.MAX_VALUE;
|
||||
for (ArrayList<Integer> counts : combinations) {
|
||||
int total = 0; // 求当前组合的硬币总数
|
||||
for (int count : counts) { total += count; }
|
||||
|
||||
// 保留最小的
|
||||
if (total < minCount) { minCount = total; }
|
||||
}
|
||||
|
||||
return minCount;
|
||||
}
|
||||
|
||||
int getMinCountOfCoins() {
|
||||
int[] values = { 5, 3 }; // 硬币面值的数组
|
||||
int total = 11; // 总值
|
||||
|
||||
ArrayList<Integer> initialCounts = new ArrayList<>(Collections.nCopies(values.length, 0)); // 初始值(0,0)
|
||||
|
||||
ArrayList<ArrayList<Integer>> coinCombinations = new ArrayList<>(); // 存储所有组合
|
||||
getMinCountsHelper(total, values, initialCounts, coinCombinations); // 求解所有组合(不去重)
|
||||
|
||||
return getMinimumHelper(coinCombinations); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++实现:
|
||||
|
||||
```
|
||||
void GetMinCountsHelper(int total, const std::vector<int>& values, std::vector<int> currentCounts, std::vector<std::vector<int>>& combinations) {
|
||||
if (!total) { // 如果余额为0,说明当前组合成立,将组合加入到待选数组中
|
||||
combinations.push_back(currentCounts);
|
||||
return;
|
||||
}
|
||||
|
||||
int valueLength = values.size();
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
if (currentValue > total) { // 如果面值大于当前总额,直接跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
// 否则在当前面值数量组合上的对应位置加1
|
||||
std::vector<int> newCounts = currentCounts;
|
||||
newCounts[i] ++;
|
||||
int rest = total - currentValue;
|
||||
|
||||
GetMinCountsHelper(rest, values, newCounts, combinations); // 求解剩余额度所需硬币数量
|
||||
}
|
||||
}
|
||||
|
||||
int GetMinimumHelper(const std::vector<std::vector<int>>& combinations) {
|
||||
// 如果没有可用组合,返回-1
|
||||
if (!combinations.size()) { return -1; }
|
||||
|
||||
int minCount = INT_MAX;
|
||||
for (const std::vector<int>& counts : combinations) {
|
||||
int total = 0; // 求当前组合的硬币总数
|
||||
for (int count : counts) { total += count; }
|
||||
|
||||
// 保留最小的
|
||||
if (total < minCount) { minCount = total; }
|
||||
}
|
||||
|
||||
return minCount;
|
||||
}
|
||||
|
||||
int GetMinCountOfCoins() {
|
||||
std::vector<int> values = { 5, 3 }; // 硬币面值的数组
|
||||
int total = 11; // 总值
|
||||
|
||||
std::vector<int> initialCounts(values.size(), 0); // 初始值(0,0)
|
||||
std::vector<std::vector<int>> coinCombinations; // 存储所有组合
|
||||
GetMinCountsHelper(total, values, initialCounts, coinCombinations); // 求解所有组合(不去重)
|
||||
|
||||
return GetMinimumHelper(coinCombinations); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你从代码里可以看出,这里的操作被明确分成了两步:
|
||||
|
||||
1. 求解所有满足条件的组合;
|
||||
1. 从组合中选出总和最小的组合。如果找不到满足条件的组合那么就返回-1。
|
||||
|
||||
我们也可以将这两步合并成一步来解决,就像下面这段代码。
|
||||
|
||||
Java实现:
|
||||
|
||||
```
|
||||
int getMinCountsHelper(int total, int[] values) {
|
||||
// 如果余额为0,说明当前组合成立,将组合加入到待选数组中
|
||||
if (0 == total) { return 0; }
|
||||
|
||||
int valueLength = values.length;
|
||||
int minCount = Integer.MAX_VALUE;
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > total) { continue; }
|
||||
|
||||
int rest = total - currentValue; // 使用当前面值,得到剩余硬币总额
|
||||
int restCount = getMinCountsHelper(rest, values);
|
||||
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
int totalCount = 1 + restCount; // 保留最小总额
|
||||
if (totalCount < minCount) { minCount = totalCount; }
|
||||
}
|
||||
|
||||
// 如果没有可用组合,返回-1
|
||||
if (minCount == Integer.MAX_VALUE) { return -1; }
|
||||
|
||||
return minCount; // 返回最小硬币数量
|
||||
}
|
||||
|
||||
int getMinCountOfCoinsAdvance() {
|
||||
int[] values = { 3, 5 }; // 硬币面值的数组
|
||||
int total = 11; // 总值
|
||||
|
||||
return getMinCountsHelper(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++实现:
|
||||
|
||||
```
|
||||
int GetMinCountsHelper(int total, const std::vector<int>& values) {
|
||||
// 如果余额为0,说明当前组合成立,将组合加入到待选数组中
|
||||
if (!total) { return 0; }
|
||||
|
||||
int valueLength = values.size();
|
||||
int minCount = INT_MAX;
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > total) { continue; }
|
||||
|
||||
int rest = total - currentValue; // 使用当前面值,得到剩余硬币总额
|
||||
int restCount = GetMinCountsHelper(rest, values);
|
||||
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
int totalCount = 1 + restCount; // 保留最小总额
|
||||
if (totalCount < minCount) { minCount = totalCount; }
|
||||
}
|
||||
|
||||
// 如果没有可用组合,返回-1
|
||||
if (minCount == INT_MAX) { return -1; }
|
||||
|
||||
return minCount; // 返回最小硬币数量
|
||||
}
|
||||
|
||||
int GetMinCountOfCoinsAdvance() {
|
||||
std::vector<int> values = { 5, 3 }; // 硬币面值的数组
|
||||
int total = 11; // 总值
|
||||
|
||||
return GetMinCountsHelper(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这段代码中,每一次递归返回的值,都是后续组合之和的最小值。它不再存储所有的组合,直到回退到递归的顶层。
|
||||
|
||||
这样可以极大节省存储空间,这是处理递归问题的通用方法。一般来说,你都应该用这种算法处理方式来解递归问题。
|
||||
|
||||
## 深入理解递归
|
||||
|
||||
在了解了递归的概念、问题的描述方式和解决问题方法后,我想让你来思考这样一个问题:**为什么递归能帮助我们解决最优化问题?**
|
||||
|
||||
### 堆栈与递归的状态存储
|
||||
|
||||
在计算机中,实现递归必须建立在堆栈的基础上,这是因为每次递归调用的时候我们都需要把当前函数调用中的局部变量保存在某个特定的地方,等到函数返回的时候再把这些局部变量取出来。
|
||||
|
||||
而用于保存这些局部变量的地方也就是堆栈了。
|
||||
|
||||
因此,你可以看到递归可以不断保存当前求解状态并进入下一层次的求解,并在得到后续阶段的解之后,将当前求解状态恢复并与后续求解结果进行合并。
|
||||
|
||||
在硬币找零问题中,我们可以放心的在函数中用循环不断遍历,找出当前面值硬币的可能数量。而无需用其它方法来存储当前或之前的数据。
|
||||
|
||||
得益于递归,我们通过堆栈实现了状态存储,这样的代码看起来简单、清晰明了。在本节课稍后的内容中,在我讲到递归树的求解组合空间时,你会更清晰地认识到堆栈和状态存储带来的价值!
|
||||
|
||||
### 递归与回溯
|
||||
|
||||
在求解最优化问题的时候,我们经常会用到**回溯**这个策略。
|
||||
|
||||
上一课中,我们已经提到过回溯的思想。在硬币找零这个问题里,具体说就是如果遇到已经无法求解的组合,那么我们就往回退一步,修改上一个面值的硬币数量,然后再尝试新的组合。
|
||||
|
||||
递归这种形式,正是赋予了回溯这种可以回退一步的能力:它通过堆栈保存了上一步的当前状态。
|
||||
|
||||
因此,如果想要用回溯的策略来解决问题,那么递归应该是你的首选方法。所以说,回溯在最优化问题中有多么重要,递归也就有多么重要。
|
||||
|
||||
### 树形结构与深度优先搜索
|
||||
|
||||
为了理解递归,我在这里用合适的结构来描述递归的求解过程。这种结构正是计算机数据结构中的树。如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/70/154a4b0fdb8e467428fb3e06b14a3d70.jpg" alt="">
|
||||
|
||||
你可以从中看到形象的递归求解过程,每个节点的 /(斜线)左边表示当前节点使用的硬币面值,右边表示使用面值后的余额。图中的蓝色节点就表示我们目前得到的解。
|
||||
|
||||
递归的过程的确就是一个树形结构,而递归也就是一个深度优先搜索的过程,先找到下一步的解,然后再回退,如此往复。
|
||||
|
||||
所以我们可以这样理解递归:作为一个算法解决方案,它采用了深度优先搜索的策略,去搜索所有可能的组合,并得到最优解的最优化问题。
|
||||
|
||||
如果在每个节点上加上当前这个节点求得的组合结果,就可以用递归树表示**求解的组合空间**:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/63/e721d6a000637746a8d0ea223151b563.jpg" alt="">
|
||||
|
||||
### 通过穷举法从所有的解中得到最优解
|
||||
|
||||
从上图中我们可以发现,每个节点都存储了一个当前求解过程中的组合,和后续节点的组合合并到一起形成完整的答案。
|
||||
|
||||
而真正的求解组合,就是把所有余额为0的组合拿出来,经过去重之后得到的结果。
|
||||
|
||||
所以,你可以看到求解的组合就蕴含在这个递归的树形结算的节点空间中,这也就是为什么递归策略是行之有效的:我们可以通过穷举法从所有的解中得到最优解!
|
||||
|
||||
## 暴力递归的问题与优化
|
||||
|
||||
从上一课介绍的贪心算法,到我在这里跟你讲的暴力递归法,看起来硬币找零问题有了一个稳定且行之有效的解题思路。
|
||||
|
||||
但这就足够了吗?哈哈,显然不是。因为这样的穷举法效率实在低下,不仅如此,这样的代码可读性低且调试困难。我在这里给你具体分析一下。
|
||||
|
||||
### 性能问题
|
||||
|
||||
暴力递归的最后一个特点就是穷举(都叫暴力,你说是不是)。如果我们只使用朴素的递归思路解题,就需要通过递归来暴力穷举出所有的组合,而且我们穷举的不只是组合,还是所有可能得到目标组合的组成路径!
|
||||
|
||||
这个在上面的图中我们可以看到,同样是求解(2, 5)这个组合,图中有多少种路径?这还只是25元和两种面值的情况。如果求解的金额和面值数量增加,那么我们可以看到这个树会以非常难以置信的方式增长,那么带来的性能问题就是灾难性的。
|
||||
|
||||
如果你仔细观察一下,就会发现这个树会随着总额的增加呈现指数形式的增长。对于这种事情,我们难以接受。
|
||||
|
||||
因此,递归只是让问题可以求解,但是如果数据规模过大的时候暴力递归会引发极大的性能问题。
|
||||
|
||||
### 可读性与调试问题
|
||||
|
||||
虽然递归在数学意义上非常直观,但是如果问题过于复杂,一般是无法直接画出上面我画的那棵求解树的。
|
||||
|
||||
有画求解树的时候,我们可以想出我们的求解过程是怎么进行的,但如果求解树的分支极多,那么很多人就很难继续在脑海中模拟出整个求解过程了。
|
||||
|
||||
因此,一旦程序出现bug,当你想尝试去调试的时候,就会发现这样的代码几乎没有调试的可能性。这种问题在数据规模很大的情况下尤为明显。
|
||||
|
||||
那么针对性能低下、代码可读性降低和调试问题,我们有什么办法去解决吗?有,听我给你讲下面的内容。
|
||||
|
||||
### 优化暴力递归:剪枝与优化
|
||||
|
||||
你可以从前面的图中看到,这棵树中有很多分支是完全相同的:起码从理论上讲最终只有两个组合。但是这棵树到达同一种组合的路径却非常多,所以优化递归的思路其实就是如何减少搜索的分支数量。
|
||||
|
||||
分支数量减少了,递归效率也就高了。这就是所谓的**剪枝**优化。对于优化方法,这里我提供两种思路给你。
|
||||
|
||||
**1. 参考贪心算法**
|
||||
|
||||
第一种思路是仿照贪心算法,从整个搜索策略上来调整。也就是说,你要考虑这个问题的性质,即面值大的硬币用得足够多,那么这个组合的硬币总数肯定就最小。
|
||||
|
||||
所以在每一次递归时,我们不应该暴力地搜索所有的面值,而应该从面值最大的硬币着手,不断尝试大面值硬币的最大情况。
|
||||
|
||||
如果无法满足条件再减少一个,再递归搜索。最后的代码就跟我在上一课中写给你的回溯代码一样,即通过贪心这种思路结合递归实现一种组合搜索。
|
||||
|
||||
殊途同归啊!我们从递归的角度重新解释了这个算法问题,而且代码实现也是一样的。
|
||||
|
||||
**2. 从解空间图解释**
|
||||
|
||||
除了参考贪心算法的思想,我们还可以从解空间的角度来解释这个问题。
|
||||
|
||||
请你注意观察一下:在解空间的图中,只要是余额相同的情况下,后面的搜索路径是完全一致的!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/d0/776fe3739110beba2a538103775e3ed0.jpg" alt="">
|
||||
|
||||
我在图中圈出的两个部分就是重复的搜索路径。因为余额都是12元,所以后续的求解路径和结果完全相同。
|
||||
|
||||
这是一个重要线索,在这个硬币求解问题中,当余额相同的时候,最优解是确定的。那么你想想看,如果能够避免相同余额下的重复搜索过程,那么算法执行速度是不是可以加快了?
|
||||
|
||||
这就是我在上一课中提到过的**重叠子问题**。
|
||||
|
||||
你可以把求解12元的硬币数量理解成求解25元的硬币数量的一个子问题。在求解25元硬币过程中,会有很多种情况都要求解12元硬币的最优解。我们把这类会出现重复求解的子问题称之为重叠子问题。
|
||||
|
||||
显然,这就是我们可以优化的出发点。至于如何进行优化,则需要用比较多的篇幅讨论,在下一节课中,我会跟你细谈这个问题。
|
||||
|
||||
## 课程总结
|
||||
|
||||
今天我们学习了最优化问题的本质,即从所有满足条件的组合里找出最优解的组合。贪心算法只能解决**局部最优**问题,而我们的最终目标是解决**整体最优**问题(即最优解)。
|
||||
|
||||
自然地,**枚举**是获得最优解的理想方法。而**递归**可以帮助我们获得所有可能答案的组合。递归形式的求解几乎就是简单地把题设中的函数表达式照搬过来,它相较于迭代来说更直观,且易于理解。
|
||||
|
||||
但暴力递归有着十分明显的缺陷,存在性能低下、可读性低和调试困难等问题。为此,我们提出了剪枝与优化这两种方法:
|
||||
|
||||
1. 利用预设条件减少搜索路径,优化最优组合搜索方案(硬币的优化);
|
||||
1. 利用重叠子问题,避免重叠子问题的计算。
|
||||
|
||||
因此,在面试问题中,考虑贪心算法和递归是我们求解问题时思考的重要方向。很多面试问题已经可以使用这两种算法来解决了。
|
||||
|
||||
但在稍复杂的面试问题面前,我们还需要借助于更高级的手段:备忘录和动态规划。而重叠子问题是理解这些高级手段的基础,下节课我会具体来讲。
|
||||
|
||||
## 课后思考
|
||||
|
||||
今天我讲了递归求解最优解问题的思路,并强调了回溯的重要性。那如何通过编程,求出所有有效的括号组合呢?(设输入是有几组括号,输出是所有有效的括号组合)
|
||||
|
||||
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!
|
||||
370
极客时间专栏/动态规划面试宝典/初识动态规划/03 | 备忘录:如何避免递归中的重复计算?.md
Normal file
370
极客时间专栏/动态规划面试宝典/初识动态规划/03 | 备忘录:如何避免递归中的重复计算?.md
Normal file
@@ -0,0 +1,370 @@
|
||||
<audio id="audio" title="03 | 备忘录:如何避免递归中的重复计算?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/6b/65bcc016ecd08a768a095c94f8d38b6b.mp3"></audio>
|
||||
|
||||
你好,我是卢誉声。
|
||||
|
||||
从前面的课程中我们已经看到,动态规划问题的一般形式就是求最值。因此我先讲解了什么是最优解问题,在考虑整体最优的情况下,我们需要找到一种办法获取最优解。那么最简单直接的做法是什么呢?
|
||||
|
||||
其实就是把所有可行的答案**穷举**出来,然后在所有可行的答案中找出满足条件的最值。
|
||||
|
||||
这样的解法看似“天衣无缝”,但它有着重要的缺陷,而且这个缺陷是我们在面试过程中需要极力避免的:它的执行效率极低。
|
||||
|
||||
导致这个问题的罪魁祸首是重叠子问题,我已经不止一次提到这个概念了。那么你该**如何解决重叠子问题并提高算法效率呢?**
|
||||
|
||||
接下来我们就带着这个问题,开始学习今天的内容:备忘录。看看它能否有效解决递归过程中出现的大量重复计算的问题,提高算法效率。
|
||||
|
||||
## 什么是重叠子问题?
|
||||
|
||||
斐波那契数列没有求最值的问题,因此严格来说它不是最优解问题,当然也就不是动态规划问题。但它能帮助你理解什么是重叠子问题。首先,它的数学形式即递归表达是这样的:
|
||||
|
||||
$$F(n)=\left\{\begin{array}{c}<br>
|
||||
0,n=0\\\<br>
|
||||
1,n=1\\\<br>
|
||||
F(n-1)+F(n-2),n>1<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
因此写成代码也极为简洁:
|
||||
|
||||
```
|
||||
int Fibonacci(int n) {
|
||||
if (0 == n || 1 == n) { return n; }
|
||||
if(n > 1) { return Fibonacci(n - 1) + Fibonacci(n - 2); }
|
||||
|
||||
return 0; // 如果输入n有误,则返回默认值
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个代码有问题吗?没问题,但是它效率极低。那么它效率低在哪里呢?假设上面的函数调用输入是10,我把递归树画出来:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/c2/f5b6b9be134109d7f54be59779fdc3c2.png" alt="">
|
||||
|
||||
我们要怎么理解这个递归树呢?如果要计算原问题 F(10),你就需要先计算出子问题 F(9) 和 F(8),如果要计算 F(9),你就需要先计算出子问题 F(8) 和 F(7),以此类推。这个递归的终止条件是当 F(1)=1 或 F(0)=0 时结束。
|
||||
|
||||
我在这里画出整个递归树的目的,在于向你尽可能详细地展示递归的过程,但凡遇到递归问题,你最好都能画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。
|
||||
|
||||
但你也看到了,即便如此简单的递归过程,这个树也十分“巨大”。
|
||||
|
||||
在画求解树的时候,你可以想出求解过程是怎么进行的,但如果求解树的分支极多,那么很多人就很难继续在脑海中模拟出整个求解过程了。因此问题过于复杂,一般是无法直接画出像上面我画的那棵求解树的,即便递归在数学意义上非常直观。
|
||||
|
||||
因此,我给你的建议是画出主要的分支路径,帮助你理解和解决面试问题。
|
||||
|
||||
画完斐波那契数列的求解树之后,你发现问题没有?在我用红色标出的两个区域中,它们的递归计算过程完全相同!
|
||||
|
||||
这意味着,第2个红色区域的计算是“完全没有必要的”,它是重复的计算。因为我们已经在求解 F(7) 的时候把 F(6) 的所有情况计算过了。因此我们把第2个红色区域的计算称为**重叠子问题**。
|
||||
|
||||
这种问题常见吗?答案是肯定的。让我们回到硬币找零的问题上,我们把上一课的求解树再拿出来观察一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/d0/776fe3739110beba2a538103775e3ed0.jpg" alt="">
|
||||
|
||||
从上图中,我们看到了类似的模式,我同样圈选出了两个红色区域,其中第2个即5/12(2, 1)的递归子问题与第1个 3/12(2, 1) 的递归子问题完全相同,因此它们是**重叠子问题**。
|
||||
|
||||
对于我们实现的这个递归算法,它的复杂度是多少呢?
|
||||
|
||||
1. 先考虑子问题的个数(即上图中节点的总数),二叉树节点总数为指数级别,因此子问题个数为指数级 O(2<sup>n</sup>);
|
||||
1. 再考虑求解一个子问题的复杂度:没有计算,因此是 O(1);
|
||||
1. 综上所述,该算法的时间复杂度是 O(2<sup>n</sup>)。
|
||||
|
||||
事实上在面试或工程实践中,指数级别的复杂度已经很难让人接受了,除非情况特殊,我们必须想办法来优化算法的时间复杂度。
|
||||
|
||||
该怎么做呢?到这,其实我们要解决的问题已经十分清晰明了了:只需要将重复计算的部分跳过,复用之前已经计算过的结果,以此来加速算法。
|
||||
|
||||
## 递归中的备忘录:解决重复计算的法宝
|
||||
|
||||
### 备忘录即正义
|
||||
|
||||
现在,我们明确了亟待解决的问题:消除重叠子问题,即消灭重复计算的过程。我们可以创建一个**备忘录**(memorization),在每次计算出某个子问题的答案后,将这个临时的中间结果记录到备忘录里,然后再返回。
|
||||
|
||||
接着,每当遇到一个子问题时,我们不是按照原有的思路开始对子问题进行递归求解,而是先去这个备忘录中查询一下。如果发现之前已经解决过这个子问题了,那么就直接把答案取出来复用,没有必要再递归下去耗时的计算了。
|
||||
|
||||
对于备忘录,你可以考虑使用以下两种数据结构:
|
||||
|
||||
1. 数组(Array),通常对于简单的问题来说,使用一维数组就足够了。在后续的课程中,你会看到更为复杂的状态存储过程,届时我会指导你使用更高维度(二维甚至三维)的数组来存储状态。
|
||||
1. 哈希表(Hash table),如果你存储的状态不能直接通过索引找到需要的值(比如斐波那契数列问题,你就可以直接通过数组的索引确定其对应子问题的解是否存在,如果存在你就拿出来直接使用),比如你使用了更高级的数据结构而非简单的数字索引,那么你还可以考虑使用哈希表,即字典来存储中间状态,来避免重复计算的问题。
|
||||
|
||||
我们先来看看如何使用备忘录来解决斐波那契数列问题,直接上代码。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int fibonacci(int n, int[] memo) {
|
||||
if (0 == n || 1 == n) { return n; }
|
||||
if (memo[n] != 0) { return memo[n]; } // 看来备忘录中找到了之前计算的结果,既然找到了,直接返回,避免重复计算
|
||||
|
||||
if(n > 1) {
|
||||
memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
|
||||
return memo[n];
|
||||
}
|
||||
|
||||
return 0; // 如果数值无效(比如 < 0),则返回0
|
||||
}
|
||||
|
||||
int fibonacciAdvance(int n) {
|
||||
int[] memo = new int[n + 1];
|
||||
return fibonacci(n, memo);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int Fibonacci(int n, std::vector<int>& memo) {
|
||||
if (0 == n || 1 == n) { return n; }
|
||||
if (memo[n] != 0) { return memo[n]; } // 看来备忘录中找到了之前计算的结果,既然找到了,直接返回,避免重复计算
|
||||
|
||||
if(n > 1) {
|
||||
memo[n] = Fibonacci(n - 1, memo) + Fibonacci(n - 2, memo);
|
||||
return memo[n];
|
||||
}
|
||||
|
||||
return 0; // 如果数值无效(比如 < 0),则返回0
|
||||
}
|
||||
|
||||
int FibonacciAdvance(int n) {
|
||||
std::vector<int> memo(n + 1, 0); // 初始化备忘录,在这里我使用数组
|
||||
return Fibonacci(n, memo);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从以上代码可以看出,我在第12行创建了一个基于数组的备忘录,用来存储中间计算状态。第3行代码十分关键,它从我们的备忘录中查询对应索引位置存储的状态是否已经计算过(值 > 0时),如果计算过了,那么就直接返回之前计算过的答案。
|
||||
|
||||
我在下图中,把两个重复计算的分支画了出来,你可以清晰地看到,这些被圈出的子问题,是完全没有必要进行再次计算的,我们可以直接拿之前的计算结果来使用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/f0/b3f1f3bd8e24308a655c309105f89df0.png" alt="">
|
||||
|
||||
实际上,这就是我们所熟知的“剪枝与优化”,在这里我把一棵存在巨量冗余的递归树通过剪枝,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。
|
||||
|
||||
通过这种方式,我们大幅缩减了算法的计算量,所有重复的部分都被跳过了。这时,我们再看看递归的复杂度是多少?
|
||||
|
||||
1. 同样,我们先考虑子问题的个数(即上图中节点的总数)。由于本算法不存在冗余计算,子问题就是 F(1), F(2), F(3) ... F(10),因此为 O(n);
|
||||
1. 再考虑求解一个子问题的复杂度:没有计算,因此是 O(1);
|
||||
1. 综上所述,该算法的时间复杂度是O(n)。
|
||||
|
||||
使用算法解决重复计算的第一步就是需要将问题定义成函数,比如上面的硬币问题,我们可以将满足x的最小硬币总数定义成一个函数f(x):
|
||||
|
||||
$$F(x)=\left\{\begin{array}{c}<br>
|
||||
min(f(x-c)+1), x>0,f(x-c)\ne-1,c\in C\\\<br>
|
||||
0,x=0\\\<br>
|
||||
-1,x<0<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
说明:在这个函数中,C 指的是硬币面值的集合。
|
||||
|
||||
那如果要处理这个问题,我们只需要避免每次都重复计算 F(x) 的结果就行了。如果有一次的路径中已经计算了 F(8),那么如果在其它的路径中再次遇到 F(8) 的时候,我们就不需要再次计算这个路径了。
|
||||
|
||||
所以我们需要一个集合来存储所有F(x)的结果,F(x) 在计算时首先查询集合中是否存储了 F(x) 的结果,如果有则返回,没有再执行整个计算步骤。
|
||||
|
||||
看来,我们已经把指数级 O(2<sup>n</sup>) 时间复杂度的问题进行了“疯狂”的简化。相较于暴力递归,这就是传说中的“降维打击”。
|
||||
|
||||
### 使用备忘录求解硬币找零问题
|
||||
|
||||
现在,让我们再次回到硬币找零问题去实践一遍。首先这是一个可以使用动态规划解决的问题,它存在最优化问题;其次,就像我刚才所说的,该问题存在重叠子问题。
|
||||
|
||||
现在,让我们来看看如何使用备忘录,对硬币找零算法的复杂度进行“大刀阔斧”的简化。
|
||||
|
||||
我们仿照使用备忘录提高斐波那契数列算法效率的方法,对硬币找零算法进行剪枝和优化。为了简单起见,我在这里直接贴出改进后的算法实现。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int getMinCountsHelper(int total, int[] values, int[] memo) {
|
||||
int savedMinCount = memo[total];
|
||||
if (savedMinCount != -2) { return savedMinCount; }
|
||||
|
||||
|
||||
int valueLength = values.length;
|
||||
int minCount = Integer.MAX_VALUE;
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > total) { continue; }
|
||||
|
||||
// 使用当前面值,得到剩余硬币总额
|
||||
int rest = total - currentValue;
|
||||
int restCount = getMinCountsHelper(rest, values, memo);
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
// 保留最小总额
|
||||
int totalCount = 1 + restCount;
|
||||
if (totalCount < minCount) { minCount = totalCount; }
|
||||
}
|
||||
|
||||
// 如果没有可用组合,返回-1
|
||||
if (minCount == Integer.MAX_VALUE) {
|
||||
memo[total] = -1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
memo[total] = minCount; // 记录到备忘录
|
||||
return minCount; // 返回最小硬币数量
|
||||
}
|
||||
|
||||
int getMinCountsSol() { // 入口函数
|
||||
int[] values = { 3, 5 }; // 硬币面值
|
||||
int total = 14; // 总值
|
||||
|
||||
int[] memo = new int[total + 1];// , -2); // 备忘录,没有缓存的元素为-2
|
||||
Arrays.fill(memo, -2);
|
||||
memo[0] = 0; // 其中0对应的结果也是0,首先存在备忘录中
|
||||
|
||||
// 求得最小的硬币数量,并输出结果
|
||||
return getMinCountsHelper(total, values, memo); // 输出结果
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int GetMinCountsHelper(int total, const std::vector<int>& values, std::vector<int>& memo) {
|
||||
auto savedMinCount = memo[total];
|
||||
if (savedMinCount != -2) { return savedMinCount; }
|
||||
|
||||
int valueLength = values.size();
|
||||
int minCount = INT_MAX;
|
||||
for (int i = 0; i < valueLength; i ++) { // 遍历所有面值
|
||||
int currentValue = values[i];
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > total) { continue; }
|
||||
|
||||
// 使用当前面值,得到剩余硬币总额
|
||||
int rest = total - currentValue;
|
||||
int restCount = GetMinCountsHelper(rest, values, memo);
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
// 保留最小总额
|
||||
int totalCount = 1 + restCount;
|
||||
if (totalCount < minCount) { minCount = totalCount; }
|
||||
}
|
||||
|
||||
// 如果没有可用组合,返回-1
|
||||
if (minCount == INT_MAX) {
|
||||
memo[total] = -1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
memo[total] = minCount; // 记录到备忘录
|
||||
return minCount; // 返回最小硬币数量
|
||||
}
|
||||
|
||||
int GetMinCountsSol() { // 入口函数
|
||||
std::vector<int> values = { 3, 5 }; // 硬币面值
|
||||
int total = 11; // 总值
|
||||
|
||||
std::vector<int> memo(total + 1, -2); // 备忘录,没有缓存的元素为-2
|
||||
memo[0] = 0; // 其中0对应的结果也是0,首先存在备忘录中
|
||||
|
||||
// 求得最小的硬币数量,并输出结果
|
||||
return GetMinCountsHelper(total, values, memo); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相较于我在上一课实现的代码,我在函数中加入了一个memo参数。
|
||||
|
||||
因此,对于原来实现的代码,算法时间复杂度可以概括为:
|
||||
|
||||
1. 先考虑子问题的个数,我只画了这颗树的一部分,因此从树上这个比较难看出来,但从斐波那契数列的题目上我们可以推广,得到其个数是 O(n<sup>m</sup>), m=|values|,即指数级别;
|
||||
1. 再考虑求解一个子问题的复杂度:每个子问题中含有一个循环,因此时间复杂度为 O(m), m=|values|;
|
||||
1. 综上所述,该算法的时间复杂度是 O(mn<sup>m</sup>), m=|values|。
|
||||
|
||||
而通过备忘录优化后的算法时间复杂度为:
|
||||
|
||||
1. 先考虑子问题的个数,如果我们求n元总额的硬币最小数量,那么子问题最多就是 0…n 个,一共 n+1 个子问题;
|
||||
1. 因为我们缓存了子问题的数量,所以其实每个子问题都只会被求解一次;
|
||||
1. 针对每个子问题求解,我们都需要通过硬币面额数量知道需要筛选的子问题数量,每个子问题求解时的时间是 O(m), m=|values|;
|
||||
1. 最后我们可以得知,采用备忘录形式时,整个时间复杂度就是子问题的数量乘以需要考虑的面额数量,也就是 O(m*n), m=|values|。
|
||||
|
||||
我在这里创建的备忘录memo其实是一个缓存数组,每次求解函数F(x)之后都会将结果缓存在数组中。数组初始化长度是total+1,也就是可以存储0-total的计算结果。所有元素的初值都是-2,表示没有缓存。然后我们将memo[0] 设置为0,表示公式中如果x为0,那么F(x)的结果也就是0。
|
||||
|
||||
接着在 GetMinCountsHelper 函数中,先查找memo中是否已经缓存了当前total的值。如果有则直接返回,如果没有那么重新计算。
|
||||
|
||||
重新计算完成后,如果结果为-1(即无效组合),那么就将当前total对应的缓存设置为-1,否则设置为我们计算的结果。
|
||||
|
||||
通过备忘录,我们避免了重复计算,即避免重复计算那些已经计算过的子问题。
|
||||
|
||||
## 重叠子问题处理模式
|
||||
|
||||
现在,你已经知道了如何处理具体的问题:比如斐波那契数列和硬币找零问题。但是如果我们遇到了类似新问题该如何处理呢?这里我总结一下对这类问题的处理方法。
|
||||
|
||||
假设面试问题是这样的:当目标为 x,其中x可能是一个任意长度的向量,目标可能包含多个元素,求最优解 F(x)。举个例子,比如在硬币这个问题里,x 就是硬币总额度,F(x)就是最少的硬币数量。
|
||||
|
||||
同时,我们还需要知道问题是求最小值还是最大值,并以此来定义我们的数值函数 G(t)。如果求最小值,那么 G 是 min,如果求最大值,那么 G 就是 max。
|
||||
|
||||
除此之外,我们还需要通过当前的问题获得后续的一系列子问题,假定当前得到子问题的参数为 c,得到后续子问题的函数是 S,那么这个函数就是 S(x, c)。
|
||||
|
||||
接着,我们就可以用 F(S(x, c)) 来求得子问题的结果。
|
||||
|
||||
我们再定义一个函数 V(x),该函数可以聚合当前参数c和当前子问题的结果。最后,我们还要定义每一步如何与子问题进行叠加。定义一个叠加函数 H(x)。
|
||||
|
||||
综上所述,最后得到如下求解公式:
|
||||
|
||||
$$F(x) = H(G(V(F(S(x, c)), c)))$$
|
||||
|
||||
因此,当你解决类似问题时,只需要把问题套用到上面的公式(框架)中,就能用一个递归函数来描述所有的问题。你可以尝试把斐波那契数列和硬币问题分别套入这个模型,就知道后面的问题定义该怎么举一反三了。
|
||||
|
||||
在定义好问题后,你就可以编写基于递归算法的代码了。不过需要注意,上面的公式并不包含边界值的处理。所谓的边界值就是无法再分解为子问题的子问题。
|
||||
|
||||
比如在硬币找零问题中,x 为0的时候就是一个所谓的边界值。只要处理好递归函数和边界值,我们就能一通百通了。
|
||||
|
||||
## 重叠子问题缓存的限制
|
||||
|
||||
我们刚刚学习了重叠子问题的处理模式,提炼出了一个通用的求解公式。你可能会问,这种利用重叠子问题的缓存来提升速度的方法是不是万灵药呢?
|
||||
|
||||
有一句老话,叫计算机中不存在“银弹”,也就是说没有任何一种方法能够解决世界上的所有问题。通过备忘录的思想来处理重叠子问题的方法亦是如此。
|
||||
|
||||
<li>
|
||||
我们回想一下在上一课中提到过的问题,就有不少是不存在重叠子问题的,比如八皇后问题。既然没有重叠子问题,那么通过备忘录来对其优化加速,又从何谈起呢?
|
||||
</li>
|
||||
<li>
|
||||
有些问题虽然看起来像包含“重叠子问题”的子问题,但是这类子问题可能具有后效性,但我们追求的是**无后效性**。所谓无后效性,指的是在通过A阶段的子问题推导B阶段的子问题的时候,我们不需要回过头去再根据B阶段的子问题重新推导A阶段的子问题,即子问题之间的依赖是单向性的。
|
||||
</li>
|
||||
|
||||
所以说,如果一个问题可以通过重叠子问题缓存进行优化,那么它肯定都能被画成一棵树。希望你能牢记这些限制,不然可能抓破头皮都没法解决问题,最后陷入死胡同。
|
||||
|
||||
## 方案弊端
|
||||
|
||||
我们可以看到,通过重叠子问题缓存可以极大加速我们的代码执行效率。但是凡事都有两面性,我们毋庸置疑,这种方案肯定是通过某种牺牲换取了性能的提升。
|
||||
|
||||
在硬币找零问题中,我们在代码里加入了一个memo数组作为备忘录,这个数组的大小是钱币总额+1。如果计算出F(x)的结果,就把F(x)的结果存在数组中x的位置,这样后续再计算相同的子问题时,我们就可以利用缓存来避免重复计算了。
|
||||
|
||||
但这样有个问题,如果我们的钱币总额数量非常巨大,那这个数组的大小就会非常巨大,导致的结果就是会占据大量的内存存储空间,而且有很多的数字其实是不会被求解的,存在很多的“存储空洞”。显然,这是一种浪费。
|
||||
|
||||
同样,如果考虑为了节省空间,那么我们可以使用哈希表,但是哈希表的检索性能肯定不如数组。你可能会说,哈希表的插入和查找的算法复杂度是 O(1) 啊,它怎么可能会慢呢?
|
||||
|
||||
原因在于,哈希表通常都会使用经过设计的数据结构(比如拉链法)来避免记录碰撞,因此实际的速度肯定不如直接访问数组的特定位置。
|
||||
|
||||
因此在这个问题里,我们仍然优先选用数组和指定的索引来快速访问数据。
|
||||
|
||||
话说回来,如果遇到一个目标结果 x 是一个向量的情况下,这个数组就会随着向量维度一起提升,比如如果 x 是二维向量,那么缓存数组就必须是二维数组,以此类推。因此向量维度提升造成的空间压力也可能是巨大的。
|
||||
|
||||
所以在面试中,你需要根据实际情况,在空间和时间中寻求一个平衡,虽然这样的经验需要积累,但更多的时候是需要你在编写代码前,将这个问题考虑在内。
|
||||
|
||||
## 课程总结
|
||||
|
||||
今天我们学习了重叠子问题这一概念,通过斐波那契数列展现了重复计算引发的问题:暴力的递归性能低下。并在此基础上提出了备忘录的思想。
|
||||
|
||||
备忘录的思想极为重要,特别是当求解的问题包含**重叠子问题**时,只要面试的问题包含重复计算,你就应该考虑使用备忘录来对算法时间复杂度进行简化。具体来说,备忘录解法可以归纳为:
|
||||
|
||||
1. 用数组或哈希表来缓存已解的子问题答案,并使用自顶向下的递归顺序递归数据;
|
||||
1. 基于递归实现,与暴力递归的区别在于备忘录为每个求解过的子问题建立了备忘录(缓存);
|
||||
1. 为每个子问题的初始记录存入一个特殊的值,表示该子问题尚未求解(如无此记录,或像求解斐波那契数列题目中那样初始化成0);
|
||||
1. 在求解过程中,从备忘录中查询。如果未找到或是特殊值,表示未求解;否则取出该子问题的答案,直接返回。
|
||||
|
||||
与此同时,在求解最优解问题的时候,画出基本的递归树结构,能极大地降低问题的难度。因此,我建议你在解决此类问题的时候尝试使用这个方法。
|
||||
|
||||
含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。没错!我们又进了一步。备忘录让我们实现了对算法时间复杂度的“降维打击”,这与贪心算法到递归的进步程度不同,这是真正意义上的动态规划思维:
|
||||
|
||||
1. 我们考虑了整体最优;
|
||||
1. 在计算的过程中保存计算当中的状态,并在后续的计算中复用之前保存的状态。
|
||||
|
||||
记住使用备忘录来优化你的算法时间复杂度,它是提高算法效率的高级手段。我们距真正的动态规划咫尺之遥,除了重叠子问题,你还需要了解什么是**最优子结构**和**状态转移方程**,我会从下节课开始讲起。
|
||||
|
||||
## 课后思考
|
||||
|
||||
到目前为止,我在课程中展示算法的代码实现时,使用的递归方式都是自上而下的,那么请你思考一下这个问题:递归的顺序有办法倒转过来吗(变成自底向上)?如果可以,应该怎么做?如果不能,为什么?
|
||||
|
||||
欢迎留言和我分享你的答案,我会第一时间给你反馈。如果今天的内容对你有所启发,也欢迎把它分享给你身边的朋友,邀请他一起学习!
|
||||
411
极客时间专栏/动态规划面试宝典/初识动态规划/04 | 动态规划:完美解决硬币找零.md
Normal file
411
极客时间专栏/动态规划面试宝典/初识动态规划/04 | 动态规划:完美解决硬币找零.md
Normal file
@@ -0,0 +1,411 @@
|
||||
<audio id="audio" title="04 | 动态规划:完美解决硬币找零" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/0a/d41615671f534f06207be003e2b8530a.mp3"></audio>
|
||||
|
||||
你好,我是卢誉声。今天我们来继续学习动态规划。
|
||||
|
||||
在前面的几节课中,我们经历了贪心算法求解硬币找零的问题,并从中发现了贪心算法本身的局限性:它几乎只考虑了局部最优,因此无法应对需要考虑整体最优的算法面试问题。
|
||||
|
||||
针对这一问题,我们重新思考了解决方案,用递归的方法来**穷举**出所有可能的组合,从这些可能组合中找出最优解。虽然这么做考虑了整体最优,而且真的可以解决问题,但效率太低。因此,为了解决这个低效问题,我们又提出了备忘录的概念,并在硬币找零案例中应用它解决了问题。
|
||||
|
||||
你应该发现了,我们在解决硬币找零问题时的思路是一以贯之的:发现问题,找解决方案;如果方案有局限性,那么就看如何扩展视野,找寻更优的方法。
|
||||
|
||||
不知道你还记不记得,我在上一节课的结尾有提到:含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。
|
||||
|
||||
事实上,你已经在使用动态规划的思想解决问题了。但**“真正”的动态规划解法跟备忘录法又有什么区别呢?**
|
||||
|
||||
接下来我们就带着这个问题,一起来学习今天的内容吧!
|
||||
|
||||
## 动态规划的问题描述
|
||||
|
||||
我们曾不止一次提到重叠子问题,并在上一课对其做了深入探讨。其实,重叠子问题是考虑一个问题是否为动态规划问题的先决条件,除此之外,我还提到了无后效性。
|
||||
|
||||
嗯,是时候对这些问题做个总结了,动态规划问题一定具备以下三个特征:
|
||||
|
||||
1. 重叠子问题:在穷举的过程中(比如通过递归),存在重复计算的现象;
|
||||
1. 无后效性:子问题之间的依赖是单向性的,某阶段状态一旦确定,就不受后续决策的影响;
|
||||
1. 最优子结构:子问题之间必须相互独立,或者说后续的计算可以通过前面的状态推导出来。
|
||||
|
||||
### 什么是最优子结构?
|
||||
|
||||
这是我第一次在课程中提出**最优子结构**这一概念,所以咱们先了解一下。这东西乍一听有些玄乎,什么叫子问题之间必须相互独立?我举一个简单的例子,你就明白了。
|
||||
|
||||
比如说,假设你在外卖平台购买5斤苹果和3斤香蕉。由于促销的缘故,这两种水果都有一个互相独立的促销价。如果原问题是让你以最低的价格购买这些水果,你该怎么买?显然,由于这两种水果的促销价格相互独立、互不影响,你只需直接购买就能享受到最低折扣的价格。
|
||||
|
||||
现在我们得到了正确的结果:最低价格就是直接购买这两种折扣水果。因为这个过程符合最优子结构,打折的苹果和香蕉这两个子问题是互相独立、互不干扰的。
|
||||
|
||||
但是,如果平台追加一个条件:折扣不能同时享用。即购买了折扣的苹果就不能享受折扣的香蕉,反之亦然。这样的话,你肯定就不能同时以最低的苹果价格和最低的香蕉价格享受到最低折扣了。
|
||||
|
||||
按刚才那个思路就会得到错误的结果。因为子问题并不独立,苹果和香蕉的折扣价格无法同时达到最优,这时最优子结构被破坏。
|
||||
|
||||
回过头来,我们再读一下最优子结构的定义。首先,你应该已经理解了什么是子问题之间必须相互独立。
|
||||
|
||||
其次,所谓后续的计算可以通过前面的状态推导,是指:如果你准备购买了5斤折扣苹果,那么这个价格(即子问题)就被确定了,继续在购物车追加3斤折扣香蕉的订单,只需要在刚才的价格上追加折扣香蕉的价格,就是最低的总价格(即答案)。
|
||||
|
||||
现在,让我们回到硬币找零的问题上来,它满足最优子结构吗?满足。
|
||||
|
||||
假设有两种面值的硬币 c[0]=5, c[1]=3,目标兑换金额为 k=11。原问题是求这种情况下求最少兑换的硬币数。
|
||||
|
||||
如果你知道凑出 k=6 最少硬币数为 “2”(注意,这是一个子问题),那么你只需要再加 “1” 枚面值为 c[0]=5 的硬币就可以得到原问题的答案,即 2 + 1 = 3。
|
||||
|
||||
原问题并没有限定硬币数量,你应该可以看出这些子问题之间没有互相制约的情况,它们之间是互相独立的。因此,硬币找零问题满足最优子结构,可以使用动态规划思想来进行求解。
|
||||
|
||||
### 使用动态规划求解硬币找零
|
||||
|
||||
当动态规划最终落到实处,就是一个状态转移方程,这同样是一个吓唬人的名词。不过没关系,其实我们已经具备了写出这个方程的所有工具。现在,就让我带你一起看看如何写出这个状态转移方程。
|
||||
|
||||
首先,任何穷举算法(包括递归在内)都需要一个终止条件。那么对于硬币找零问题来说,终止条件是什么呢?当剩余的金额为 0 时结束穷举,因为这时不需要任何硬币就已经凑出目标金额了。在动态规划中,我们将其称之为**初始化状态**。
|
||||
|
||||
接着,我们按照上面提到的凑硬币的思路,找出子问题与原问题之间会发生变化的变量。原问题指定了硬币的面值,同时没有限定硬币的数量,因此它们俩无法作为“变量”。唯独剩余需要兑换的金额是变化的,因此在这个题目中,唯一的变量是目标兑换金额 k。
|
||||
|
||||
在动态规划中,我们将其称之为**状态参数**。同时,你应该注意到了,这个状态在不断逼近初始化状态。而这个不断逼近的过程,叫做状态转移。
|
||||
|
||||
接着,既然我们确定了状态,那么什么操作会改变状态,并让它不断逼近初始化状态呢?每当我们挑一枚硬币,用来凑零钱,就会改变状态。在动态规划中,我们将其称之为**决策**。
|
||||
|
||||
终于,我们构造了一个初始化状态->确定状态参数->设计决策的思路。现在万事俱备,只欠东风,让我们一起来写这个**状态转移方程**。通常情况下,状态转移方程的参数就是状态转移过程中的变量,即状态参数。而函数的返回值就是答案,在这里是最少兑换的硬币数。
|
||||
|
||||
我在这里先用递归形式(伪代码形式)描述一下状态转移的过程,这跟我们在上面讨论的挑硬币的过程是一致的。
|
||||
|
||||
```
|
||||
DP(values, k) {
|
||||
res = MAX
|
||||
for c in values
|
||||
// 作出决策,找到需要硬币最少的那个结果
|
||||
res = min(res, 1 + DP(values, k-c)) // 递归调用
|
||||
|
||||
if res == MAX
|
||||
return -1
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
顺着这个思路,我把状态转移方程给写出来,它是这样的:
|
||||
|
||||
$$F(n)=\left\{\begin{array}{c}<br>
|
||||
0,n=0\\\<br>
|
||||
-1,n<0\\\<br>
|
||||
min\{ 1+DP(n-c) | c \in values \}<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
还记得吗?我们曾在讲斐波那契数列时展示了一个重要发现,那就是:递归形式的求解几乎就是简单的把题设中的函数表达式照搬过来,因此我们说从数学意义上讲,递归更直观,且易于理解。
|
||||
|
||||
## 递归与动态规划
|
||||
|
||||
不知道你有没有发现,上面这个动态规划的状态转移方程,与上一课当中的递归优化方案的方程式几乎一样。
|
||||
|
||||
在上一课中,我们为了优化递归中的重叠子问题,设计了一个缓存用于存储重叠子问题的结果,避免重复计算已经计算过的子问题。而这个缓存其实就是存储了动态规划里的状态信息。
|
||||
|
||||
因此,带备忘录的递归算法与你现在看到的动态规划解法之间,有着密不可分的关系。它们要解决的核心问题是一样的,即消除重叠子问题的重复计算。
|
||||
|
||||
事实上,带备忘录的递归算法也是一种动态规划解法。但是,我们为何不把这种方法作为动态规划面试题的常规解法呢?
|
||||
|
||||
这是递归带来的固有问题。
|
||||
|
||||
首先,从理论上说,虽然带备忘录的递归算法与动态规划解法的时间复杂度是相同规模的(稍后我就会展示新的动态规划解法),但在计算机编程的世界里,递归是依赖于函数调用的,而每一次函数调用的代价非常高昂。
|
||||
|
||||
递归调用是需要基于堆栈才能实现的。而对于基于堆栈的函数调用来说,在每一次调用的时候都会发生环境变量的保存和还原,因此会带来比较高的额外时间成本。这是无法通过时间复杂度分析直接表现出来的。
|
||||
|
||||
更重要的是,即便我们不考虑函数调用带来的开销,递归本身的处理方式是**自顶向下**的。所谓自顶向下,是指访问递归树的顺序是自顶向下的,这么说还是很抽象?我把递归处理斐波那契数列的顺序画出来,你就明白了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2f/87/2f7baa792e7765b66e6a563137d7e187.png" alt="">
|
||||
|
||||
如果从解路径的角度看递归的自顶向下处理,那么它的形式可以用由左至右的链表形式表示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/f8/9aeb84114f90c2f524c95e9a4cb84ff8.png" alt="">
|
||||
|
||||
因此每次都需要查询子问题是否已经被计算过,如果该子问题已经被计算过,则直接返回备忘录中的记录。也就是说,在带备忘录的递归解法中,无论如何都要多处理一个分支逻辑,只不过这个分支的子分支是不需要进行处理的。
|
||||
|
||||
这样的话,我们就可以预想到,如果遇到子问题分支非常多,那么肉眼可见的额外时间开销在所难免。我们不希望把时间浪费在递归本身带来的性能损耗上。
|
||||
|
||||
那么,有什么好的办法来规避这个问题呢?我们需要设计一种新的缓存方式,并考虑使用迭代来替换递归。接下来,让我们来一起看看该如何改造我们的算法。
|
||||
|
||||
## 状态缓存与循环
|
||||
|
||||
在带备忘录的递归算法中,每次都需要查询子问题是否已经被计算过。针对这一问题,我们可以思考一下,是否有方法可以不去检查子问题的处理情况呢?在执行A问题的时候,确保A的所有子问题一定已经计算完毕了。
|
||||
|
||||
仔细想一想,这不就是把处理方向倒过来用**自底向上**嘛!那么我们具体要怎么做呢?
|
||||
|
||||
回顾一下自顶向下的方法,我们的思路是从目标问题开始,不断将大问题拆解成子问题,然后再继续不断拆解子问题,直到子问题不可拆解为止。通过备忘录就可以知道哪些子问题已经被计算过了,从而提升求解速度。
|
||||
|
||||
那么如果要自底向上,我们是不是可以首先求出所有的子问题,然后通过底层的子问题向上求解更大的问题。我还是通过斐波那契数列来画出自底向上的处理方式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/a9/27621b2ba53b5c412e00cc7f29872ea9.png" alt="">
|
||||
|
||||
如果从解路径的角度看动态规划的自底向上处理方式,那么它的形式可以用一个数组来进行表示,而这个数组事实上就是实际的备忘录存储结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/2e/c7a3c00e2611b7da6c84441dfcbe6c2e.png" alt="">
|
||||
|
||||
这样有一个好处,当求解大问题的时候,我们已经可以确保该问题依赖的所有子问题都已经计算过了,那么我们就无需检查子问题是否已经求解,而是直接从缓存中取出子问题的解。
|
||||
|
||||
通过自底向上,我们完美地解决掉了递归中由于“试探”带来的性能损耗。有了思路之后,让我们把上一课中的递归代码做些修改,变成新的迭代实现:
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int getMinCounts(int k, int[] values) {
|
||||
int[] memo = new int[k + 1];
|
||||
Arrays.fill(memo, -1);
|
||||
memo[0] = 0; // 初始化状态
|
||||
|
||||
for (int v = 1; v <= k; v++) {
|
||||
int minCount = k + 1; // 模拟无穷大
|
||||
for (int i = 0; i < values.length; ++i) {
|
||||
int currentValue = values[i];
|
||||
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > v) { continue; }
|
||||
|
||||
// 使用当前面值,得到剩余硬币总额
|
||||
int rest = v - currentValue;
|
||||
int restCount = memo[rest];
|
||||
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
// 保留最小总额
|
||||
int kCount = 1 + restCount;
|
||||
if (kCount < minCount) { minCount = kCount; }
|
||||
}
|
||||
|
||||
// 如果是可用组合,记录结果
|
||||
if (minCount != k + 1) { memo[v] = minCount; }
|
||||
}
|
||||
|
||||
return memo[k];
|
||||
}
|
||||
|
||||
int getMinCountsDPSol() {
|
||||
int[] values = { 3, 5 }; // 硬币面值
|
||||
int total = 22; // 总值
|
||||
|
||||
// 求得最小的硬币数量
|
||||
return getMinCounts(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++ 实现:
|
||||
|
||||
```
|
||||
int GetMinCounts(int k, const std::vector<int>& values) {
|
||||
std::vector<int> memo(k + 1, -1); // 创建备忘录
|
||||
memo[0] = 0; // 初始化状态
|
||||
|
||||
for (int v = 1; v <= k; v++) {
|
||||
int minCount = k + 1; // 模拟无穷大
|
||||
for (int i = 0; i < values.size(); ++i) {
|
||||
int currentValue = values[i];
|
||||
|
||||
// 如果当前面值大于硬币总额,那么跳过
|
||||
if (currentValue > v) { continue; }
|
||||
|
||||
// 使用当前面值,得到剩余硬币总额
|
||||
int rest = v - currentValue;
|
||||
int restCount = memo[rest];
|
||||
|
||||
// 如果返回-1,说明组合不可信,跳过
|
||||
if (restCount == -1) { continue; }
|
||||
|
||||
// 保留最小总额
|
||||
int kCount = 1 + restCount;
|
||||
if (kCount < minCount) { minCount = kCount; }
|
||||
}
|
||||
|
||||
// 如果是可用组合,记录结果
|
||||
if (minCount != k + 1) { memo[v] = minCount; }
|
||||
}
|
||||
|
||||
return memo[k];
|
||||
}
|
||||
|
||||
int GetMinCountsDPSol() {
|
||||
std::vector<int> values = { 3, 5 }; // 硬币面值
|
||||
int total = 11; // 总值
|
||||
|
||||
// 求得最小的硬币数量
|
||||
return GetMinCounts(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们的关注点在GetMinCounts函数上,该函数先定义了一个“新款”状态备忘录,用数组memo来表示(通常将其称之为 DP 数组,DP 是 Dynamic Programming 的缩写即动态规划。你看,是不是高级起来了)。
|
||||
|
||||
这个备忘录由数组构成,其定义是:当目标兑换金额为 i 时,至少需要 memo[i] 枚硬币才能凑出。
|
||||
|
||||
有了备忘录的定义后,我们接下来再依据状态转移方程的指导来**初始化状态**:
|
||||
|
||||
1. 将 F(0) 初始化成 0,即 memo[0]=0;
|
||||
1. 把备忘录中剩余的位置初始化成 k + 1。凑成金额 k 的硬币数至多只可能等于 k (如果硬币的最低面值是 1),因此初始化为 k + 1 就相当于将这些位置初始化成正无穷大,便于后续**决策**时取最小值。
|
||||
|
||||
接着,我们从1开始遍历,求解 F(1) 的结果,直到求解 F(k) 的结果为止。循环结束后我们想要的结果就存储在 memo[k] 中,也就是 F(k) 的解。
|
||||
|
||||
在这个基于原来递归代码上改进得到的代码中,我们来看一下每次循环中做了什么。每一次循环都包含一个小循环,这个小循环会遍历所有的面值。
|
||||
|
||||
<li>先看当前面额总值是否小于当前硬币面额。如果是,说明组合不存在,直接进入下一轮循环。<br>
|
||||
</li>
|
||||
1. 否则,我们就可以认为已经使用了这一枚硬币,那么就求得使用完硬币后的余额 rest,并从备忘录中获取 F(rest) 的结果:
|
||||
|
||||
>
|
||||
<p>a. 如果 F(rest) 为 -1,说明 F(rest) 组合不存在,子问题不成立那么当前问题也就无解,直接进入下一轮循环;<br>
|
||||
b.如果返回的值不是 -1,说明组合存在,那么求 F(rest) + 1,并和当前最小硬币总数比较,取最小值。</p>
|
||||
|
||||
|
||||
1. 内部循环结束后,我们看一下 minCount 的值:
|
||||
|
||||
>
|
||||
<p>a. 如果是 -1,说明 F(v) 不存在,那么不做任何处理,保留 F(v)=-1 即可;<br>
|
||||
b. 否则将最小值存入 memo[v],表示已经求得f(v)的值,准备为后续的问题使用。</p>
|
||||
|
||||
|
||||
这样我们就通过这种自下而上的方法将递归转换成了循环。但是,这段代码还是跟我们常见的动态规划代码有些出入,不过没有关系,经过简单的调整就可以把它变漂亮。我先给出代码,然后再对其进行解释。
|
||||
|
||||
Java 实现:
|
||||
|
||||
```
|
||||
int getMinCounts(int k, int[] values) {
|
||||
int[] memo = new int[k + 1]; // 创建备忘录
|
||||
memo[0] = 0; // 初始化状态
|
||||
for (int i = 1; i < k + 1; i++) { memo[i] = k + 1; }
|
||||
|
||||
for (int i = 1; i < k + 1; i++) {
|
||||
for (int coin : values) {
|
||||
if (i - coin < 0) { continue; }
|
||||
memo[i] = Math.min(memo[i], memo[i - coin] + 1); // 作出决策
|
||||
}
|
||||
}
|
||||
|
||||
return memo[k] == k + 1 ? -1 : memo[k];
|
||||
}
|
||||
|
||||
int getMinCountsDPSolAdvance() {
|
||||
int[] values = { 3, 5 }; // 硬币面值
|
||||
int total = 22; // 总值
|
||||
|
||||
return getMinCounts(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++实现:
|
||||
|
||||
```
|
||||
int GetMinCounts(int k, const std::vector<int>& values) {
|
||||
int memo[k + 1]; // 创建备忘录
|
||||
memo[0] = 0; // 初始化状态
|
||||
for (int i = 1; i < k + 1; i++) { memo[i] = k + 1; }
|
||||
|
||||
for (int i = 1; i < k + 1; i++) {
|
||||
for (auto coin : values) {
|
||||
if (i - coin < 0) { continue; }
|
||||
memo[i] = min(memo[i], memo[i - coin] + 1); // 作出决策
|
||||
}
|
||||
}
|
||||
|
||||
return memo[k] == k + 1 ? -1 : memo[k];
|
||||
}
|
||||
|
||||
int GetMinCountsDPSolAdvance() {
|
||||
std::vector<int> values = { 3, 5 }; // 硬币面值
|
||||
int total = 11; // 总值
|
||||
|
||||
return GetMinCounts(total, values); // 输出答案
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在我们看一下,每一次循环中是如何做**决策**的。每一次循环都包含一个小循环,这个小循环会遍历所有的面值。
|
||||
|
||||
<li>跟之前一样,我们先看当前面额总值是否小于当前硬币面额。如果是,则说明组合不存在,直接进入下一轮循环。<br>
|
||||
</li>
|
||||
1. 否则,就可以认为已经使用了这一枚硬币,这时我们要作出决策:
|
||||
|
||||
>
|
||||
<p>a. 如果采纳了这枚硬币,则凑的硬币数量需要 +1,这时“状态A”是 memo[i - coin] + 1;<br>
|
||||
b. 如果不采纳这枚硬币,则凑的硬币数量不变,这时“状态B”是 memo[i];<br>
|
||||
c. 显然,硬币找零问题是求最值问题(即最少需要几枚硬币凑出总额k)。因此,我们在这里作出决策,在状态A与状态B中谁的硬币数量更少,即取最小值 min(状态A, 状态B)。</p>
|
||||
|
||||
|
||||
1. 当循环结束后,我们看一下备忘录中位置为 k 的值是多少,即 memo[k]:
|
||||
|
||||
>
|
||||
<p>a. 如果是 k + 1,就意味着在初始化状态时的值没有被更新过,是“正无穷大”。这时按照题目要求,返回 -1;<br>
|
||||
b. 否则,我们就找到了最少凑出硬币的数量,返回它,就是我们的答案。</p>
|
||||
|
||||
|
||||
这样一来,借助于自底向上的方法,我们成功的将递归转换成了迭代。
|
||||
|
||||
这段代码的时间复杂度是非常标准的 O(m*n)。它不会有任何额外的性能开销,我们通过动态规划完美地解决了硬币找零问题。
|
||||
|
||||
## 通用的动态规划
|
||||
|
||||
在掌握了如何使用标准的动态规划来解决硬币找零问题后,我们有必要将其推而广之,来看看解决动态规划面试问题的通用框架,或者说套路。
|
||||
|
||||
在这里,我会给出一个经验总结,而非严格的数学推导。
|
||||
|
||||
动态规划问题的核心是写出正确的状态转移方程,为了写出它,我们要先确定以下几点:
|
||||
|
||||
1. 初始化状态:由于动态规划是根据已经计算好的子问题推广到更大问题上去的,因此我们需要一个“原点”作为计算的开端。在硬币找零问题中,这个初始化状态是 memo[0]=0;
|
||||
1. 状态:找出子问题与原问题之间会发生变化的变量。在硬币找零问题中,这个状态只有一个,就是剩余的目标兑换金额 k;
|
||||
1. 决策:改变状态,让状态不断逼近初始化状态的行为。在硬币找零问题中,挑一枚硬币,用来凑零钱,就会改变状态。
|
||||
|
||||
一般来说,状态转移方程的**核心参数**就是**状态**。
|
||||
|
||||
接着,我们需要自底向上地使用备忘录来消除重叠子问题,构造一个备忘录(在硬币找零问题中,它叫 memo。为了通用,我们以后都将其称之为 DP table)。
|
||||
|
||||
最后,我们需要实现决策。在硬币找零问题中,决策是指挑出需要硬币最少的那个结果。通过这样几个简单步骤,我们就能写出状态转移方程:
|
||||
|
||||
$$DP(n)=\left\{\begin{array}{c}<br>
|
||||
0,n=0\\\<br>
|
||||
-1,n<0\\\<br>
|
||||
min(DP(n), 1+DP(n-c)), c \in values<br>
|
||||
\end{array}\right.$$
|
||||
|
||||
由于是经验,因此它在90%以上的情况下都是有效的,而且易于理解。至于严格的数学推导和状态转移方程框架,我会在后续的课程中给出。
|
||||
|
||||
## 从贪心算法到动态规划
|
||||
|
||||
我们从最开始的贪心算法,到暴力递归、带备忘录的递归,通过分析问题最终推导出了动态规划解法。这么做的目的在于,当我们在后续课程中扩展到复杂面试问题时,你仍然能够拥有清晰的核心思路。
|
||||
|
||||
到这儿,我们已经完美地解决了硬币找零问题,是时候做个小小的总结了(基于第1~4课)。
|
||||
|
||||
首先,贪心算法是根据当前阶段得到局部最优解,然后再看下一个阶段,逐个求解。这样导致的问题就是,我们可能永远无法得到真正的最优解:整体最优解。
|
||||
|
||||
为了解决这个问题,我们在贪心算法中加入了回溯的过程。如果无法求解的时候,就会返回,然后重新尝试当前阶段的“局部次优方案”,重新计算这种情况下的解。这样一来,我们至少保证了所有问题都能求得一个解。
|
||||
|
||||
但是如果遇到一些局部最优解前提条件不一定满足全局最优解的情况,这种方法也不一定能让我们找到整体最优解,因为贪心算法里我们找到一个解就结束了,如果约束不足,那么返回可能不一定是整体最优解。
|
||||
|
||||
为了解决贪心算法的问题,真正求得整体最优解,我们就必须得到问题解的所有可能组合。这个时候我们就要利用递归来解决问题。
|
||||
|
||||
递归就是自顶向下求得满足问题条件的所有组合,并计算这些组合的解,最后从这些组合的解中取出最优解,这样暴力计算出来的结果必定是整体最优解。
|
||||
|
||||
但是这样就又出现了效率问题,暴力递归在计算量巨大的情况下,时间复杂度实在太高了,几乎会呈现指数爆炸形式。那么我们就得考虑是否有些问题可以进行剪枝优化。
|
||||
|
||||
我提出了一些剪枝优化的方法,重点介绍的就是利用重叠子问题进行优化。在递归求解过程中我们会把一个大问题分解成多个子问题,那些在求解计算分支中可能被反复求解的子问题就是所谓的重叠子问题。
|
||||
|
||||
如果这些重叠子问题无后效性,那么我们就可以利用缓存的方法,在求得每个子问题的解之后将求解结果存入缓存数组中。如果在后续的计算分支中遇到相同的子问题,就直接从备忘录中取出我们已经计算过的结果。
|
||||
|
||||
这样一来,我们就不需要浪费时间重复求解已经求解的问题,在这种情况下可以将时间复杂度约束在多项式级别。
|
||||
|
||||
但是递归求解最后还是会有性能损耗问题,因此这时我正式引入了动态规划。在经历了这些讨论与探索后,你现在应该能够理解动态规划与贪心、回溯、递归的关系了。
|
||||
|
||||
## 课程总结
|
||||
|
||||
带备忘录的递归解法从传统意义上说已经是动态规划思想的范畴了,但它使用的是自顶向下的处理方式来解题,它离我们日常看到的动态规划还有差距。
|
||||
|
||||
这个差距不仅仅体现在代码的形式上,更重要的是它仍然还不够好:递归本身的性质导致了算法执行时额外的存储开销。
|
||||
|
||||
为此,我们正式引入自底向上的一种处理方式,并用迭代代替了递归,实现了较为简洁的硬币找零动归解法。在多项式级别的算法时间复杂度内,我们用最快的速度得到了我们想要的结果。
|
||||
|
||||
另外,动态规划的关键是状态转移方程,为了写出它,我们需要按照套路找出以下项目:
|
||||
|
||||
1. 初始化状态:由于动态规划是根据已经计算好的子问题推广到更大问题上去的,因此我们需要一个“原点”作为计算的开端;
|
||||
1. 状态:找出子问题与原问题之间会发生变化的变量,这个变量就是状态转移方程中的参数;
|
||||
1. 决策:改变状态,让状态不断逼近初始化状态的操作。这个决策,就是状态转移方程状态转移的方向。
|
||||
|
||||
最后,我们要将以上信息组装起来,一般来说就可以得到动态规划的状态转移方程了。
|
||||
|
||||
## 课后思考
|
||||
|
||||
如果给你一个包含正数和负数的整数数组,你能否找到一个具有最大和的连续子数组,其中子数组最少包含一个元素,返回其最大和。比如输入的子数组是 [-2, 1, -3, 1, -1, 6, 2, -5, 4],输出是 8,因为连续子数组 [1, -1, 6, 2] 的和最大。请你思考一下,并使用动态规划的方法来求解此问题。
|
||||
|
||||
最后,欢迎留言和我分享你的答案,也不妨把问题发给你的好朋友看看,邀请他一起讨论。
|
||||
288
极客时间专栏/动态规划面试宝典/初识动态规划/05|面试即正义第一期:什么样的问题应该使用动态规划?.md
Normal file
288
极客时间专栏/动态规划面试宝典/初识动态规划/05|面试即正义第一期:什么样的问题应该使用动态规划?.md
Normal file
@@ -0,0 +1,288 @@
|
||||
<audio id="audio" title="05|面试即正义第一期:什么样的问题应该使用动态规划?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/31/cc254efe048056654818a724f8dc3231.mp3"></audio>
|
||||
|
||||
你好,我是卢誉声。
|
||||
|
||||
作为“初识动态规划”模块的最后一课,今天我们不谈具体的解决方案了,我们来聊聊面试相关的话题,做个总结,也为我们后面的深入学习打下一个良好的基础。
|
||||
|
||||
那说起动态规划,我不知道你有没有这样的困扰,在掌握了一些基础算法和数据结构之后,碰到一些较为复杂的问题还是无从下手,面试时自然也是胆战心惊。如果我说动态规划是个玄幻的问题其实也不为过。究其原因,我觉得可以归因于这样两点:
|
||||
|
||||
- 你对动态规划相关问题的套路和思想还没有完全掌握;
|
||||
- 你没有系统地总结过究竟有哪些问题可以用动态规划解决。
|
||||
|
||||
知己知彼,你想把动态规划作为你的面试武器之一,就得足够了解它;而应对面试,总结、归类问题其实是个不错的选择,这在我们刷题的时候其实也能感觉得到。
|
||||
|
||||
那么今天,我们就针对以上两点,系统地谈一谈究竟什么样的问题可以用动态规划来解。相信这节课过后,你就能有针对性地攻克难关了,无论是面试还是工程实践都能做到有的放矢。
|
||||
|
||||
## 动态规划是一种思想
|
||||
|
||||
动态规划算法,这种叫法我想你应该经常听说。嗯,从道理上讲这么说我觉得也没错,首先动态规划它不是数据结构,这一点毋庸置疑,并且严格意义上来说它就是一种算法。但更加准确或者更加贴切的提法应该是说动态规划是一种思想。
|
||||
|
||||
**那什么是思想?算法和思想又有什么区别呢?**
|
||||
|
||||
一般来说,我们都会把算法和数据结构放一起来讲,这是因为它们之间密切相关,而算法也往往是在特定数据结构的基础之上对解题方案的一种严谨的总结。
|
||||
|
||||
比如说,在一个乱序数组的基础上进行排序,这里的数据结构指的是什么呢?很显然是数组,而算法则是所谓的排序。至于排序算法,你可以考虑使用简单的冒泡排序或效率更高的快速排序方法等等来解决问题。
|
||||
|
||||
没错,你应该也感觉到了,算法是一种简单的经验总结和套路。那什么是思想呢?相较于算法,思想更多的是指导你我来解决问题。
|
||||
|
||||
比如说,在解决一个复杂问题的时候,我们可以先将问题简化,先解决简单的问题,再解决难的问题,那么这就是一种指导解决问题的思想。另外,我们常说的分治也是一种简单的思想,当然它在诸如归并排序或递归算法当中会常常被提及。
|
||||
|
||||
而动态规划就是这样一个指导我们解决问题的思想:**你需要利用已经计算好的结果来推导你的计算,即大规模问题的结果是由小规模问题的结果运算得来的。**这句话对于你充分理解动态规划的基本原理十分重要,希望你能记下来。
|
||||
|
||||
简单理解的话,你可以这样认为:**算法是一种经验总结,而思想则是用来指导我们解决问题的。**
|
||||
|
||||
既然动态规划是一种思想,那它实际上就是一个比较抽象的概念了,也很难和实际的问题关联起来。所以说,弄清楚什么样的问题可以使用动态规划来解,就显得十分重要了。
|
||||
|
||||
## 动态规划问题的典型特点
|
||||
|
||||
在前几课中,我们已经基本了解了动态规划的基本概念,从贪心算法、暴力递归再到最后的动态规划解法,我们比较完美地解决了提出的问题。在这个过程中,你有没有想过这些问题为什么可以用动态规划来解决?是别人这么做我也要这么做吗?别人的经验又是从何而来?
|
||||
|
||||
事实上,动态规划是运筹学上的一种最优化方法,只不过在算法问题上应用广泛。接下来我们就深挖一层,看看动归问题所具备的一些特点。
|
||||
|
||||
### 求“最”优解问题(最大值和最小值)
|
||||
|
||||
除非你碰到的问题是简单到找出一个数组中最大的值这样,对这种问题来说,你可以对数组进行排序,然后取数组头或尾部的元素,如果觉得麻烦,你也可以直接遍历得到最值。不然的话,你就得考虑使用动态规划来解决这个问题了。这样的问题一般都会让你求最大子数组、求最长递增子数组、求最长递增子序列或求最长公共子串、子序列等等。不知道你发现没有,这些问题里都包含一个“最”字,如果出现了这个字,那么你就该警惕它是否是动归问题。那具体怎么判断呢?
|
||||
|
||||
既然是要求最值,不妨先想一下核心问题是什么。其实在真的解决最值问题的时候,你应该按照这样的思考顺序来解决问题:
|
||||
|
||||
- 优先考虑使用贪心算法的可能性;
|
||||
- 然后是暴力递归进行穷举(但这里的数据规模不大);
|
||||
- 还是不行呢?选择动态规划!
|
||||
|
||||
你也看到了,求解动态规划的核心问题其实就是穷举。那么因为我们要求最值,就肯定要把所有可行的答案穷举出来,然后在其中找最值就好了嘛。你看,动态规划也不过如此,就两个字:**穷举**。
|
||||
|
||||
当然了,动态规划问题也不会这么简单了事,我们还需要考虑待解决的问题是否存在重叠子问题、最优子结构等特性。我们已经在前面的课程中讲清楚了重叠子问题,而对于最优子结构,我会在后续的内容中继续给你讲清楚。
|
||||
|
||||
清楚了特点,那根据我的经验,绝大多数面试者其实还是很难在第一时间针对具体问题采取明确的行动:这个问题到底该不该用动态规划思想来解呢?
|
||||
|
||||
我在这里列出几道常见的经典面试题,如果你遇到它们了,不要犹豫,使用动态规划来解。这样不仅目标明确,而且会在面试时给面试官留下极为深刻的印象(这一讲我们只是分析下题目,后面的课程中会涵盖所有问题的解法,并归纳总结解题套路)。
|
||||
|
||||
**1. 乘积最大子数组**
|
||||
|
||||
问题:给你一个整数数组 numbers,找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),返回该子数组的乘积。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: [2,7,-2,4]
|
||||
输出: 14
|
||||
解释: 子数组 [2,7] 有最大乘积 14。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
输入: [-5,0,3,-1]
|
||||
输出: 3
|
||||
解释: 结果不能为 15, 因为 [-5,3,-1] 不是子数组,是子序列。
|
||||
|
||||
```
|
||||
|
||||
首先,很明显这个题目当中包含一个“最”字,使用动态规划求解的概率就很大。这个问题的目的就是从数组中寻找一个最大的连续区间,确保这个区间的乘积最大。由于每个连续区间可以划分成两个更小的连续区间,而且大的连续区间的结果是两个小连续区间的乘积,因此这个问题还是求解满足条件的最大值,同样可以进行问题分解,而且属于求最值问题。同时,这个问题与求最大连续子序列和比较相似,唯一的区别就是你需要在这个问题里考虑正负号的问题,其它就相同了。
|
||||
|
||||
**2. 最长回文子串**
|
||||
|
||||
问题:给定一个字符串s,找到s中最长的回文子串。你可以假设s的最大长度为1000。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: "babad"
|
||||
输出: "bab"
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
输入: "cbbd"
|
||||
输出: "bb"
|
||||
|
||||
```
|
||||
|
||||
这个问题依然包含一个“最”字,同样由于求解的最长回文子串肯定包含一个更短的回文子串,因此我们依然可以使用动态规划来求解这个问题。
|
||||
|
||||
**3. 最长上升子序列**
|
||||
|
||||
问题:给定一个无序的整数数组,找到其中最长上升子序列的长度。可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
|
||||
|
||||
```
|
||||
示例:
|
||||
输入: [10,9,2,5,3,7,66,18]
|
||||
输出: 4
|
||||
解释: 最长的上升子序列是 [2,3,7,66],它的长度是 4。
|
||||
|
||||
```
|
||||
|
||||
这个问题依然是一个最优解问题,假设我们要求一个长度为5的字符串中的上升自序列,我们只需要知道长度为4的字符串最长上升子序列是多长,就可以根据剩下的数字确定最后的结果。
|
||||
|
||||
### 求可行性(True或False)
|
||||
|
||||
接下来我们再来看另一种可能的动态规划问题。
|
||||
|
||||
如果有这样一个问题,让你判断是否存在一条总和为x的路径(如果找到了,就是True;如果找不到,自然就是False),或者让你判断能否找到一条符合某种条件的路径,那么这类问题都可以归纳为求可行性问题,并且可以使用动态规划来解。比如我们前面课程中提到的找零钱问题,是不是就很好地说明了这一点?
|
||||
|
||||
**1. 凑零兑换问题**
|
||||
|
||||
问题:给你 k 种面值的硬币,面值分别为 c1, c2 … ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: c1=1, c2=2, c3=5, c4=7, amount = 15
|
||||
输出: 3
|
||||
解释: 11 = 7 + 7 + 1。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
输入: c1=3, amount =7
|
||||
输出: -1
|
||||
解释: 3怎么也凑不到7这个值。
|
||||
|
||||
```
|
||||
|
||||
这个问题显而易见,如果不可能凑出我们需要的金额(即amount),最后算法需要返回-1,否则输出可能的硬币数量。这是一个典型的求可行性的动态规划问题。
|
||||
|
||||
**2. 字符串交错组成问题**
|
||||
|
||||
问题:给定三个字符串 s1, s2, s3, 验证 s3 是否是由 s1 和 s2 交错组成的。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: s1="aabcc",s2 ="dbbca",s3="aadbbcbcac"
|
||||
输出: true
|
||||
解释: 可以交错组成。
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
输入: s1="aabcc",s2="dbbca",s3="aadbbbaccc"
|
||||
输出: false
|
||||
解释:无法交错组成。
|
||||
|
||||
```
|
||||
|
||||
这个问题稍微有点复杂,但是我们依然可以通过子问题的视角,首先求解s1中某个长度的子字符串是否由s2和s3的子字符串交错组成,直到求解整个s1的长度为止,也可以看成一个包含子问题的最值问题。
|
||||
|
||||
### 求方案总数
|
||||
|
||||
除了求最值与可行性之外,求方案总数也是比较常见的一类动态规划问题。比如说给定一个数据结构和限定条件,让你计算出一个方案的所有可能的路径,那么这种问题就属于求方案总数的问题。我在这里介绍几个典型例子,帮助你理解。
|
||||
|
||||
**1.硬币组合问题**
|
||||
|
||||
问题:英国的英镑硬币有 1p, 2p, 5p, 10p, 20p, 50p, £1 (100p), 和 £2 (200p)。比如我们可以用以下方式来组成2英镑:1×£1 + 1×50p + 2×20p + 1×5p + 1×2p + 3×1p。问题是一共有多少种方式可以组成n英镑? 注意不能有重复,比如 1英镑+2个50P 和 50P+50P+1英镑是一样的。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: 2
|
||||
输出: 73682
|
||||
|
||||
```
|
||||
|
||||
这个问题本质还是求满足条件的组合,只不过这里不需要求出具体的值或者说组合,只需要计算出组合的数量即可。
|
||||
|
||||
**2.路径规划问题**
|
||||
|
||||
问题:一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角,共有多少路径?
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: 2 2
|
||||
输出: 2
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入: 3 3
|
||||
输出: 6
|
||||
|
||||
```
|
||||
|
||||
这个问题还是一个求满足条件的组合数量的问题,只不过这里的组合变成了路径的组合。我们可以先求出长宽更小的网格中的所有路径,然后再在一个更大的网格内求解更多的组合。这和硬币组合的问题相比没有什么本质区别。
|
||||
|
||||
这里有一个规律或者说现象需要强调,那就是求方案总数的动态规划问题一般都指的是求“一个”方案的所有具体形式。如果是求“所有”方案的具体形式,那这种肯定不是动态规划问题,而是使用传统递归来遍历出所有方案的具体形式。
|
||||
|
||||
为什么这么说呢?因为你需要把所有情况枚举出来,大多情况下根本就没有重叠子问题给你优化。即便有,你也只能使用备忘录对遍历进行一个简单加速。但本质上,这类问题不是动态规划问题。接下来你就会看到这样的例子,你可以找找区别在哪里。
|
||||
|
||||
## 进一步确认是否为动态规划问题
|
||||
|
||||
从前面我所说来看,如果你碰到了求最值、求可行性或者是求方案总数的问题的话,那么这个问题就八九不离十了,你基本可以确定它就需要使用动态规划来解。但这里还有一些极具迷惑性的问题,你需要格外注意。
|
||||
|
||||
### 数据不可排序(Unsortable)
|
||||
|
||||
假设我们有一个无序数列,希望求出这个数列中最大的两个数字之和。很多初学者刚刚学完动态规划会走火入魔到看到最优化问题就想用动态规划来求解,嗯,那么这样应该也是可以的吧……不,等等,这个问题不是简单做一个排序或者做一个遍历就可以求解出来了吗?所以学完动态规划后,你一定要注意,遇到这些简单的问题不要把事情变得更复杂了。先考虑一下能不能通过排序来简化问题,如果不能,才极有可能是动态规划问题。还是看个例子。
|
||||
|
||||
**最小的k个数**
|
||||
|
||||
问题:输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
|
||||
|
||||
```
|
||||
示例1:
|
||||
输入:arr = [3,2,1], k = 2
|
||||
输出:[1,2] 或者 [2,1]
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
示例2:
|
||||
输入:arr = [0,1,2,1], k = 1
|
||||
输出:[0]
|
||||
|
||||
```
|
||||
|
||||
我们发现虽然这个问题也是求“最”值,但其实只要通过排序就能解决,所以我们应该用排序、堆等算法或者数据结构来解决,而不应该用动态规划。
|
||||
|
||||
### 数据不可交换(Non-swapable)
|
||||
|
||||
还有一类问题,可以归类到我们总结的几类问题里去,但是不存在动态规划要求的重叠子问题(比如经典的八皇后问题),那么这类问题就无法通过动态规划求解。这种情况需要避免被套进去。
|
||||
|
||||
**全排列**
|
||||
|
||||
问题:给定一个没有重复数字的序列,返回其所有可能的全排列。
|
||||
|
||||
```
|
||||
示例:
|
||||
输入: [1,2,3]
|
||||
输出:
|
||||
[
|
||||
[1,2,3],
|
||||
[1,3,2],
|
||||
[2,1,3],
|
||||
[2,3,1],
|
||||
[3,1,2],
|
||||
[3,2,1]
|
||||
]
|
||||
|
||||
|
||||
```
|
||||
|
||||
这个问题虽然是求组合,但没有重叠子问题,更不存在最优化的要求,因此可以使用回溯处理,并不是动态规划的用武之地。
|
||||
|
||||
## 课程总结
|
||||
|
||||
今天,我们一起探讨了动态规划问题的本质,更准确或更加严谨地说,动态规划是一种指导我们解决问题的思想。
|
||||
|
||||
接着我们列出了辨别一个算法问题是否该使用动态规划来解的五大特点:
|
||||
|
||||
1. 求最优解问题(最大值和最小值);
|
||||
1. 求可行性(True或False);
|
||||
1. 求方案总数;
|
||||
1. 数据结构不可排序(Unsortable);
|
||||
1. 算法不可使用交换(Non-swappable)。
|
||||
|
||||
如果面试题目出现这些特征,那么在90%的情况下你都能断言它就是一个动归问题。
|
||||
|
||||
当然了,就像我前面所讲的,你还需要考虑这个问题是否包含重叠子问题与最优子结构,在这个基础之上你就可以99%断言它是否为动归问题,并且也顺势找到了大致的解题思路,我会在后面的课程中继续跟你探讨这些问题,彻底解决你的疑惑。
|
||||
|
||||
通过上述这几个鲜明的特点,相信你能够在将来迅速地判断出问题是否为动态规划类问题,并使用对应的思想和套路来应对算法或面试问题。
|
||||
|
||||
## 课后思考
|
||||
|
||||
<li>
|
||||
除了我在这里列出的动态规划特点以外,你觉得还有哪些类别的问题应该进行归纳总结?能否把你见过的或认为是动态规划的算法留在评论区,并分析一下它们又属于哪些类别。
|
||||
</li>
|
||||
<li>
|
||||
我在前面提到过子数组与子序列的问题,请你思考一下,这两种情况有什么区别?
|
||||
</li>
|
||||
|
||||
欢迎留言和我分享,我会第一时间给你反馈。如果今天的内容让你对动态规划的用法有了进一步的了解,也欢迎把它分享给你身边的朋友,邀请他一起学习!
|
||||
Reference in New Issue
Block a user