mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-10-19 16:33:46 +08:00
mod
This commit is contained in:
138
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/18 | 重新认识数据结构(上):初识链表结构.md
Normal file
138
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/18 | 重新认识数据结构(上):初识链表结构.md
Normal file
@@ -0,0 +1,138 @@
|
||||
<audio id="audio" title="18 | 重新认识数据结构(上):初识链表结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/96/375e60ea7f1451645741c56acb674e96.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎来到“算法数据结构篇”的第一课。
|
||||
|
||||
在之前的学习中,我们对数据结构的认识,主要集中在它是用来做数据的表示,更具体地讲,就是数据结构所讨论的问题,是将现实中的数据如何合理地表示在程序中,以使程序完成既定的目标任务。
|
||||
|
||||
不知道你还记不记得,在上节课 Shift-And 算法的学习中,我们发现不同的数据,或者说信息表示方式,会给解决问题的效率带来很大的影响。因此,基本确定了数据的表示,在程序设计过程中发挥着非常重要的作用,也就意味着我们必须对数据结构的学习重视起来。
|
||||
|
||||
之前我们所讨论的数据结构呢,其实都只是停留在程序内部的数据结构,这是一种具体的,可见的数据结构,并对我们设计的程序产生重要影响。我们也认识到,这种具体的数据结构的重要作用,会对我们设计的程序产生重要的影响。今天呢,我将带你重新认识数据结构,发现它的另一面,那就是数据结构对我们思维方式的影响,这种影响更抽象,也更重要。
|
||||
|
||||
接下来的两次课程内容呢,我将通过链表结构的讲解,让你认识这种思维层面的数据结构。
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
今天我们将要学习的链表,是一种常见的基础数据结构,属于数据结构中线性结构的一种。在讲如何学习链表之前,我们先来看一看通常情况下,如何学习数据结构的相关的知识。
|
||||
|
||||
#### 1.数据结构:结构定义+结构操作
|
||||
|
||||
你应该玩过拼装式的玩具吧,类似于高达机器人之类的。面对这样的玩具,我一般在拼装之前看看说明书,知道这个玩具包含哪几部分,然后对这些部分进行拼装,等把各部分拼好了后,再把它们组合起来,最终的成品就完成了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/59/32/590065eaa40bacd12f44af281b272f32.jpg" alt="" title="图1:高达机器人">
|
||||
|
||||
其实学习某样知识也是一样的,要先搞清楚这门知识的组成部分,从组成部分开始入手学习,最后把所有的知识碎片整合到一起,就是知识的全貌了。
|
||||
|
||||
回到如何理解数据结构这个问题,我先给你列出重要的两句话:
|
||||
|
||||
1. **数据结构 = 结构定义 + 结构操作**
|
||||
1. **数据结构,就是定义一种性质,并且维护这种性质**
|
||||
|
||||
其实这两句话,说的是一回事儿。结构定义,指的是就是说明这种数据结构长成什么样子,具备什么性质。结构操作,就是确定这种数据结构都支持哪些操作,同时结构操作的结果,不能破坏这类结构原本的性质。这也就到了第二句话中说的内容,维护这种性质。
|
||||
|
||||
这就好像刚才我说到的高达机器人,结构定义类比高达机器人的样子,结构操作就是这个机器人都支持什么功能,比如抬手、伸腿之类的。但是无论是哪种结构操作,你都不能把机器人玩坏掉(也就是不能破坏结构定义),这就是我们所说的:操作归操作,但是你要维护这种性质。
|
||||
|
||||
接下来呢,我将会通过这两句话,带你学习**链表**这种数据结构。
|
||||
|
||||
#### 2.链表的结构定义
|
||||
|
||||
**链表的结构定义中,包含了两个信息,一个是数据信息,用来存储数据的,也叫做数据域;另外一个是地址信息,用来存储下一个节点地址的,也叫做指针域。**<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/18/5f/1848443e25f6494b85b9064fc1b3d85f.jpg" alt="" title="图2:链表结构定义">
|
||||
|
||||
记住,链表结构是由一个一个链表节点组成的,在应用这种结构的时候,你无需对这种结构本身做改变,你只需要按照自己的需求,把自己想要的数据,放在链表结构定义的数据域中即可。比如说,整型是你想存储在链表中的数据,那么数据域的类型就是整型,如果字符串类型是你想存储的数据,那么数据域的类型就是字符串类型。
|
||||
|
||||
在示意图中可以看到,链表节点以整型作为数据域的类型,其中第一个链表节点,存储了 763 这个数据,指针域中呢,存储了一个 0x56432地址,这个地址而 0x56432正是第二个链表节点的地址。我们可以说,第一个节点指向第二个节点,因此这两个节点之间,在逻辑上构成了一个指向关系。
|
||||
|
||||
在第二个节点的指针域中呢,存储了一个地址,是0x0,这个地址值所对应的整型值就是 0。这是一个特殊的地址,我们称它为空地址,在 C 语言中用 NULL 宏表示这个空地址,读作nào。我们让第二个链表节点指向空地址,就意味着它就是这个链表结构的最后一个节点。
|
||||
|
||||
看完了链表的结构示意图以后,就来让我们看一下在代码中,如何定义链表节点结构吧:
|
||||
|
||||
```
|
||||
struct Node {
|
||||
int data;
|
||||
struct Node *next;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
正如这段代码所示,我们使用结构体,定义一种新的类型,叫做 struct Node 类型,来表示链表的节点结构。链表的每个节点内部,有一个数据域,一个指针域,对应到代码中,就是一个整型的 data 字段,和一个指向 struct Node 类型本身的指针字段 next。
|
||||
|
||||
值得注意的是,链表结构的指针域只有一个 next 变量,这就说明每一个链表节点,只能唯一地指向后续的一个节点,这是链表结构非常重要的一个性质,后续我们也会用到这个性质。
|
||||
|
||||
总地来说,链表结构中,数据域是留出来让我们实现自我需求的,就是想存整型,就改成整型,想存浮点型,就改成浮点型。而 next 指针域,是用来维护链表这个结构的,这里一般不需要你自由发挥,记住怎么回事儿,直接用就行了。**记住,要想修改内存中的链表结构,就一定要修改节点内部 next 指针域中存储的地址值。**
|
||||
|
||||
#### 3.链表的结构操作
|
||||
|
||||
接下来呢,我会给你介绍一种链表的基础操作,就是向链表中插入节点的操作。
|
||||
|
||||
在讲解链表的插入和删除方法之前呢,我们先来对齐一个概念,就是**链表节点的位置**。当你把链表结构画出来以后,你会发现链表结构和数组结构很类似,只不过数组结构在内存中存储是连续的,链表结构由于有指针域的存在,它的每一个节点在内存中存储的位置未必连续。
|
||||
|
||||
我们也可以参考数组下标的编号规则,给每个链表节点编一个号,从第一个开始依次是0、1、2,具体如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/dd/8c7c7e5628911d108ea871360657b7dd.jpg" alt="" title="图3:链表节点位置定义">
|
||||
|
||||
明白了什么是链表的节点位置以后呢,我们定义一个向链表中插入节点的函数方法:
|
||||
|
||||
```
|
||||
struct Node *insert(struct Node *head, int ind, struct Node *a);
|
||||
|
||||
```
|
||||
|
||||
这个插入方法呢,传入三个参数,第一个是待操作的链表的头结点地址,也就是链表中第一个节点的地址;第二个参数代表插入位置;第三个参数是一个指针变量,指向要插入的新节点。
|
||||
|
||||
简单来说,就是向 head 所指向的链表的 ind 位置插入一个由 a 所指向的节点,返回值代表插入新节点以后的链表头结点地址。为什么要返回插入以后的链表头结点地址呢?因为新节点有可能插入到链表的第 0 位,插入以后,链表的头结点地址就发生了改变,我们必须把这个信息返回。
|
||||
|
||||
由于插入操作,会改变链表结构,刚刚我们说了,只有修改链表节点中的 next 指针域的值,才算是修改了链表的结构。为了完成插入操作,我们都需要修改哪些节点的 next 指针域的值呢?
|
||||
|
||||
首先是让 ind - 1 位置的节点指向 a 节点,然后是 a 节点指向原 ind 位置的节点,也就是说,涉及到两个节点的 next 指针域的值的修改,一个是 ind - 1 位置的节点,一个是 a 节点自身。我们就可以先找到 ind - 1位置的节点,然后再进行相关操作即可。写成代码,如下所示:
|
||||
|
||||
```
|
||||
struct Node *insert(struct Node *head, int ind, struct Node *a) {
|
||||
struct Node ret, *p = &ret;
|
||||
ret.next = head;
|
||||
// 从【虚拟头节点】开始向后走 ind 步
|
||||
while (ind--) p = p->next;
|
||||
// 完成节点的插入操作
|
||||
a->next = p->next;
|
||||
p->next = a;
|
||||
// 返回真正的链表头节点地址
|
||||
return ret.next;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中,涉及到一个很重要的技巧,就是 “虚拟头结点” 这个链表操作技巧。所谓虚拟头结点,就是在原有的链表头结点前面,加上另外一个节点,这个额外增加的头结点,就是虚拟头结点。增加虚拟头结点的目的,是为了让我们操作链表更方便,实际上,如果在某个操作中,头结点地址有可能发生改变,我们也可以使用虚拟头结点这个技巧。
|
||||
|
||||
我们来分析一下,对于插入操作,虚拟头结点有什么重要的作用。首先如果我们要在第 5 个位置插入新节点,那我们就要找到 4 号位的节点,也就是从头结点开始,向后走 4 步,确定了 4 号节点以后,再修改相关节点的 next 指针域即可。
|
||||
|
||||
也就是说,如果我们想插入到 ind 位,就需要从头结点向后走 ind - 1 步,定位到 ind - 1 号节点。如果插入的位置为 0 呢?我们总不能走 -1 步吧?这个时候,在程序中我们就只能对 ind 等于 0 的情况进行特殊判断了。这确实是一种可行的实现方法,可不够优美,因为这种做法没有统一 ind 在等于 0 和 不等于 0 时候的处理情况。
|
||||
|
||||
可是当我们在原链表前面,加入了一个虚拟头结点以后,这一切的操作就变得自然了许多!一开始 p 指向虚拟头结点,由于链表头部增加了一个节点,原先我们要定位链表 ind - 1 位置,要走 ind - 1步,现在就是走 ind 步。
|
||||
|
||||
也就是说,在有虚拟头结点的情况下,如果我们插入到 5 号位,就从虚拟头结点向后走 5 步就行,同样的,想要插入到 0 号位呢,就向后走 0 步即可,即 p 指针指向虚拟头结点不动,直接将新的节点,插入到虚拟头结点后面即可。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/77/f3c19fb1a46d0917509bdac33e0a4577.jpg" alt="" title="图4:虚拟节点示意图">
|
||||
|
||||
其实,对于链表的相关操作,无论是插入还是删除,只要是有可能改变原有链表头结点的操作,增加虚拟头结点都是一个很实用的处理技巧。
|
||||
|
||||
## 一起动手,搞事情
|
||||
|
||||
今天给你留的作业呢,与链表的操作有关系,请看如下函数接口定义:
|
||||
|
||||
```
|
||||
struct Node *erase(struct Node *head, int ind);
|
||||
|
||||
```
|
||||
|
||||
请你参照文中的链表插入操作,实现一个链表节点删除的操作,删除函数传入两个参数,分别代表指向链表头结点的指针变量 head,以及要删除的节点位置 ind,返回值代表删除节点以后的链表头结点地址。
|
||||
|
||||
由于删除操作,有可能改变链表的头结点,所以你可以尝试使用前面我们讲到的虚拟头结点的编码技巧。仔细分析,你可以的!
|
||||
|
||||
## 课程小结
|
||||
|
||||
我们今天介绍的链表呢,其实真实姓名叫做“单向链表”,这是一种很有代表性的链表结构。实际上,你学会了单向链表,也就很容易掌握其他形式的链表了。比如说:单向循环链表、双向链表、双向循环链表、块状链表、跳跃表。
|
||||
|
||||
尤其是块状链表和跳跃表, 在工程中应用最广泛,C++ STL 中的 vector 底层用的就是块状链表。关于这些概念,如果你感兴趣,可以自行上网搜索相关资料。篇幅有限,我们就不一个个展开介绍了。
|
||||
|
||||
最后,我们来做一下今天课程的总结,今天我希望你记住如下几点:
|
||||
|
||||
1. 数据结构 = 结构定义 + 结构操作,这个等式说明了我们学习数据结构的方法顺序。
|
||||
1. 单向链表节点中,存在数据域和指针域,指针域控制了链表的结构,一般不会根据应用场景的变化而变化,而数据域是根据应用场景的需求而设计的。
|
||||
|
||||
下节课呢,我将给你讲几种更有意思的链表操作。好了,今天就到这里了,我是胡光,咱们下期见。
|
95
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/19 | 重新认识数据结构(下):有趣的“链表思维”.md
Normal file
95
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/19 | 重新认识数据结构(下):有趣的“链表思维”.md
Normal file
@@ -0,0 +1,95 @@
|
||||
<audio id="audio" title="19 | 重新认识数据结构(下):有趣的“链表思维”" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/68/c7a1ebe0cbdf1f7a53b9e96937c40a68.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课,我们着重介绍了数据结构的学习方法,就是把数据结构分成两部分进行学习:**结构定义**和**结构操作**。其中,结构定义是定义数据结构的样子和性质,结构操作就是数据结构的相关功能,并且在操作过程中需要维护相关结构的性质。在这个基础上,我们详细讲了链表的基础结构。
|
||||
|
||||
我们经常听到,算法中最有价值的是“算法思维”,其实在数据结构中,最有价值的也是“数据结构思维”。今天呢,我们就看看链表这种具体的数据结构,如何变成一种思维层面的数据结构,辅助我们进行思考。
|
||||
|
||||
## 今日任务
|
||||
|
||||
先来看一下今天这 10 分钟的任务吧。首先,我们定义一种数字名称,叫做“快乐数”。所谓快乐数就是经过有限次变换以后,等于 1 的数字。这个变换规则,给出一个非1的数字a ,把它的位数拎出来,求各个位数的平方和,得到一个数字b,如果数字b不是1,那就对数字b的每一位数再做平方和,得到数字c……经过不停的变换,确定最后能否得到 1。
|
||||
|
||||
例如,一开始的数字是19,经过变换规则 $1^2 + 9^2 = 82$,得到数字82;因为不是 1 ,所以接着做变换,就是 $8^2 + 2^2 = 68$,再做一次变换 $6^2 + 8^2 = 100$,最后一次做变换 $1^2 + 0^2 + 0^2 = 1$,得到了 1 以后,停止。
|
||||
|
||||
由于从 19 开始,通过有限次变换以后,能够到达数字 1,所以数字 19 是“快乐数”。在这个过程中,你应该明白,后面得到的82、68、100其实也是快乐数。所以今天我们要做的,就是给出一个正整数,你来判断这个数字是否是快乐数。
|
||||
|
||||
这个任务的难点,不在于判定哪些数字是快乐数,而在于如何判定一个数字不是快乐数。为什么这么说呢?因为不是快乐数,就说明原数字没有办法通过有限次变换,到达数字 1,那么经过多少次,才算是有限次呢?1 千次,1 万次还是10万次呢?你会发现,很难确定这个转换次数的上限。
|
||||
|
||||
你可能已经感觉到了这是一个有趣,但似乎又有点难度的任务。那就请你带着这份好奇和困惑,让我们用链表思维解决它!
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
先忘了上面这个任务,我们先从几个具体的例子,来学习链表相关的进阶操作。
|
||||
|
||||
#### 1.直观操作法:用数组判断链表中是否有环
|
||||
|
||||
我要带你学习的第一个链表相关的问题呢,就是如何判断一个链表中有环。链表中为什么会有环呢?通过上一节课的学习,我们知道单向链表的最后一个节点,原本应该指向一个叫做 NULL 的空地址,代表整个链表结束。可你有没有想过,如果链表的最后一个节点,指向的不是一个空地址,而是链表中的一个点,那是不是就形成了链表内部的一个环?如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/25/fb/25e8197144387605db2862eecf68dffb.jpg" alt="" title="图1: 链表成环示意图"><br>
|
||||
就如你所看到的,图中的8号节点,本应该是链表的最后一个节点,可它却指向了3号节点,这样就形成了一个以3、4、5、6、7、8号节点为循环状态的环形结构。当你使用指针遍历这个链表的时候,将会永无尽头。
|
||||
|
||||
那有没有什么办法,能够帮助我们判断在一个单向链表中,是否存在环呢?面对这个问题,你可能想要说,很简单啊,我只需要使用一个数组,记录出现过的节点信息,之后每次遍历到新节点,就判断这个节点是否在数组中有记录。如果有的话,说明链表中有环,如果遍历到了 NULL 地址,那就说明链表中无环。
|
||||
|
||||
上面这个方法看似可行,但数组会占用与链表等量的额外存储空间,并且效率太差了。假设链表有 n 个节点,当你经过第 1 个节点的时候,你需要在数组中查找 0 次;第 2 个节点的时候,需要在数组中查找 1 次;第 3 个节点需要查找 2 次。
|
||||
|
||||
依次类推,第 i 个节点,你需要在数组中查找 i - 1 次,可直到你遍历到第 n + 1个节点的时候,才会发现有重复。此时在数组中查找的总次数将会是 (n + 1) * n / 2 次,接近于 $n^2$ 次,这种时间复杂度,写作 $O(n^2)$。关于时间复杂度,简单来理解,它反映的其实是问题规模与运算次数之间的关系。
|
||||
|
||||
#### 2.快慢指针法:让判断链表是否有环变得简单
|
||||
|
||||
接下来呢,我们来假设一种场景,在操场上有两个运动员从跑道起点出发跑步,一个速度比较快,一个速度比较慢。同时操场的能见度特别低,他们根本不知道跑道是不是环形的,可过了一段时间以后,两个人就确定了这个跑道是否环形,他俩是怎么做到的?
|
||||
|
||||
你稍微想一想,就会明白这里面的道理:如果跑道是环形的,那么速度快的运动员,在足够的时间里,终究会从速度慢的人后面追上来了,形成两人相遇的情况。如果跑到不是环形的,那速度快的运动员会最先跑到终点,两人不会相遇!这就是今天我们要讲的链表判环的方法,叫做:**快慢指针法**。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/18/9d/18e27e3f96ab152c4ead158a0a85d59d.jpg" alt="" title="图2: 快慢指针法示意图"><br>
|
||||
简单来说,就是我们把链表当成跑道,放上两个指针,一个指针每次走一步,另一个指针每次走两步,如果快的指针,先跑到了终点,说明链表中没有环,如果两个指针相遇了,则说明链表中有环。并且,我们很容易知道两个指针一定是在环内部相遇的。
|
||||
|
||||
如果把上述过程写成代码的话,如下所示:
|
||||
|
||||
```
|
||||
int hasCycle(struct Node *head) {
|
||||
if (head == NULL) return 0;
|
||||
// p 是慢指针,q 是快指针
|
||||
struct Node *p = head, *q = head;
|
||||
// 每次循环,p 走1步,q 走2步
|
||||
do {
|
||||
p = p->next;
|
||||
q = q->next;
|
||||
if (q == NULL) return 0;
|
||||
q = q->next;
|
||||
} while (p != q && q);
|
||||
return p == q;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码比较简单,你可以自行阅读并学习。其中需要注意的是几个指针判空的语句,一定要保证指针非空的前提下,再用指针间接访问结构体字段,否则你的程序会瞬间崩溃。
|
||||
|
||||
## 一起动手,搞事情
|
||||
|
||||
前面讲了,如何判断链表有环。那么今天的“一起动手,搞事情”环节呢,我就给你留两个相关的子问题:
|
||||
|
||||
1. 如何求解环的长度,如图1中,环的长度就是 5。
|
||||
1. 如何找到环的起点,如图1中,3号点,就是环的起始点。
|
||||
|
||||
## 快乐数判定问题
|
||||
|
||||
准备完了前面这些基础知识以后,你可能还是摸不着头绪,不知道如何解决快乐数判定问题。那你可要跟住节奏了,下面就要进入将链表数据结构提升成为思维的阶段了。
|
||||
|
||||
首先,我们知道,整型表示的最大值是${2}^{31} - 1$,大约是 20 亿左右。如果就在这个整型范围内解决快乐数判定的问题,我们可以得到哪些有用的结论呢?下面,让我们分析一下。
|
||||
|
||||
由本节开头的例子可知,从19开始,依次得到的是:82、68、100、1这些数字。也就是说,从一个数字开始,按照快乐数的计算规则,会得到一串数字序列。这其中就蕴含着链表重要的结构思维:从当前节点,唯一映射到下一个节点。快乐数序列中的数字,就是链表中的节点,如果当前数字确定了,下一个数字也就是确定了的,就像数字19,下一个肯定是数字82,这种映射规则,就是链表节点之间的指向关系。
|
||||
|
||||
最后,我们做一个思维映射,一切就豁然开朗了。所谓快乐数序列,最终的目标是能到 1,这个数字 1,其实就可以看成是链表中的空地址。这样,我们就把快乐数问题,用链表思维做了一番改造,并且这种改造,一点儿违和感都没有。当你把这个思考过程搞清楚后,恭喜你,在这个过程中,你正在将你自己看待问题的方式,变得更加结构化,更加计算机化。
|
||||
|
||||
接下来我们分析一下,这个快乐数链表,最长能有多长?这个问题其实比较好分析,主要是思考究竟哪个数字,按照快乐数的计算规则,得到的下一个数字是最大的,这个最大的数字是多少。
|
||||
|
||||
稍加思索,你就知道,如果在整型范围内解决快乐数问题的话,1999999999 这个数字,按照各位平方和来进行计算,得到的下一个数字应该是 $(9*9^2 + 1) = 730$,也就是说,这个快乐数链表中,节点数量绝对不会超过 731 个。一个不超过 731 个节点的链表,还总也走不到末尾,说明什么?说明这个链表中有环!
|
||||
|
||||
至此,你会发现,**判断一个数字是否是快乐数,等价于判断链表中是否有环**。剩下的台词,我就不和你抢了,舞台留给你来发挥吧,代码我就不替你写了。加油!我相信,你一定写得出来。
|
||||
|
||||
## 课程小结
|
||||
|
||||
关于链表的内容,到这里就结束了。通过这两节,只是想让你记住一点:数据结构,不仅仅是计算机中的代码,更是我们思维的逻辑结构在计算机科学中的具体体现。
|
||||
|
||||
这种有趣的思维变换,才是算法和数据结构的真正魅力所在。有些技术的酷炫,是长在脸上的,让人一眼就能看到;而算法和数据结构的酷炫,从来都是那样的含蓄,那样的内敛,你只有深入到里面,才能感受到它的巨大魅力。我已经竭尽所能,试图将它的内在美描述出来,不知道你有没有 get 到,如果你没有 get 到,那一定是我的语言太过苍白,而不是算法数据结构没有魅力!
|
||||
|
||||
好了,今天先到这里了,我是胡光,我们下期见。
|
144
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/20 | 二分查找:提升程序的查找效率.md
Normal file
144
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/20 | 二分查找:提升程序的查找效率.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<audio id="audio" title="20 | 二分查找:提升程序的查找效率" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/4a/9fb38c77ce36ab4788cc635bc25ff34a.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课,我们讲了链表的基础结构,以及体会了一把链表结构在思维逻辑层面的作用,就是面对看似复杂的问题,当我们把它转换成链表结构思维去解决的时候,这些问题和困难都迎刃而解。
|
||||
|
||||
今天呢,我将带你学习一种简单、有趣且高效的算法,叫做二分查找。在学习二分查找之前呢,有一个关于二分查找的笑话,你必须知道。
|
||||
|
||||
话说,在学校图书馆的计算机科学相关书籍借阅区里面,有一个女生抱着 40 本书往外走,经过图书馆安检机器的时候,安检机器发出了警报声。这时候,女生很无奈,就把书放到了地上,准备一本一本地去试,看看究竟是哪一本书没有消磁。
|
||||
|
||||
女生的举动,被旁边图书馆管理员阿姨看到了,阿姨看不下去了,叫住了她,说:这么一本一本的尝试,多慢啊!我教你一种方法。眼看阿姨将书分成两摞,拿起其中一摞的书,过了一下安检,安检机器响了,阿姨就又将这摞书分成了两部分,拿出其中一部分又过了一次安检……就这样,阿姨每次将书的数量减少一半,没几次就找到了那本没有消磁的书。阿姨得意洋洋地说:小姑娘,这就是书中讲的二分查找算法,你这专业知识不过关啊!次日,图书馆发现,丢了 39 本书。
|
||||
|
||||
上面这个故事中的阿姨,虽然知道二分查找算法,可明显是对使用算法的前提条件没有搞清楚。今天,我教给你的不仅是二分查找算法本身,还希望你能准确搞清楚二分查找算法的使用场景。
|
||||
|
||||
## 今日任务
|
||||
|
||||
在正式开始课程之前呢,先来看一下我们今天这 10 分钟的任务吧。
|
||||
|
||||
假设你手上有 n 段长度不等的绳子,你现在想将这些绳子进行裁剪,裁剪出 k 条长度相等的绳子,注意,只能剪断绳子,不能拼接绳子。问题就是,你能得到的这 k 段绳子的最长长度是多长?<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/72/41/72e13fc8a526d82a6f700892ea294741.jpg" alt="" title="图1: 切绳子任务示意图">
|
||||
|
||||
如图所示,如果你手中有 3 条绳子,分别是 4米、6米 和 5米,想要切出等长的4段,你会发现,每段最长就是 3 米。
|
||||
|
||||
那么我们是如何得到“每段最长就是3米”这个答案的呢?当然,你可以采用枚举法,就是先尝试能不能切出至少 4 段的 1 米长绳子,如果可以的话,再去尝试每段长度 2 米是否可行,依次尝试下去,直到尝试不下去为止。最后一次尝试可行的长度,就是每段绳子的最长长度了。
|
||||
|
||||
这种做法,就像前面故事中想要一本一本进行尝试的女生,显得低效且繁琐。而今天,我将扮演图书馆阿姨的角色,当然,不会像故事中的阿姨一样,犯了前提条件没有搞清的错误,去给你讲一种更高效的方法!
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
#### 1.二分查找算法基础
|
||||
|
||||
最简单的二分算法的形式,就是在一个有序数组中,查找一个数字 x 是否存在。而二分算法,就是要基于这种有序性,才能对原问题进行加速求解。
|
||||
|
||||
我们先来思考,如何在一个数组中查找一个数字 x,最直接的方法,就是从头到尾一个一个找,找到了就是有数字x,找不到就是没有数字x。
|
||||
|
||||
而二分算法呢,是确定一个查找区间,然后从查找区间的一半处,与 x 进行比较,如果中间的数字比 x 大,说明x 在前半段,就把后半段扔掉;如果比 x 小,就把前半段扔掉,继续在后半段区间内查找。你会发现,二分查找的过程,每一次比较,都会使区间减少一半,对于一个大小为 n 的区间,我们只需要 ${log_2}{n}$ 次比较操作即可确定结果。
|
||||
|
||||
具体的过程呢,如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/80/9afee42d2620c807f2dfc1507e349480.jpg" alt="" title="图2: 二分查找算法示意图">
|
||||
|
||||
图中呢,我们以查找 17 这个数字为例,L 和 R所圈定的,就是当前的查找区间,一开始 L = 0,R = 6,mid 所指向的就是数组的中间位置,根据 L 和 R 计算得到 mid 的值是 3。查看数组第 3 位的值是 12,比待查找值 17 要小,说明如果 17 在这个有序数组中,那它一定在 mid 所指向位置的后面,而 mid 本身所指向的数字已经确定不是 17 了,所以下一次我们可以将查找区间,定位到 mid + 1 到 R,也就是将 L 调整到 mid + 1 (即数组第4位)的位置。
|
||||
|
||||
理解二分查找的过程,首先要理解二分查找是怎么保证查找过程正确性的。中心思想就一个:**不管如何调整区间,都要保证待查找数字,总是落在我们的由 L 和 R 标记的查找区间内部**。而二分查找,实际上“二分”的就是查找范围。这个过程,就像警察排查犯罪嫌疑人一样,通过一些特定的条件,快速地缩小范围,并锁定真正的罪犯。
|
||||
|
||||
下面是一份二分查找的示例代码:
|
||||
|
||||
```
|
||||
int binary_search(int *arr, int n, int x) {
|
||||
int l = 0, r = n - 1, mid;
|
||||
while (l <= r) {
|
||||
mid = (l + r) >> 1;
|
||||
if (arr[mid] == x) return mid;
|
||||
if (arr[mid] > x) r = mid - 1;
|
||||
else l = mid + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如代码所示,binary_search 函数传入三个参数,分别代表有序数组 arr,数组长度 n 和待查找数字 x。如果在数组中存在数字 x,函数将返回 x 数字的下标,否则就会返回 -1,代表数组中不存在数字 x。
|
||||
|
||||
你会看到,函数中有一个 while 循环,循环的执行条件是 l <= r,意味着待查找区间不为空。每次循环开始的时候,都是先通过 l 和 r 的值,计算得到一个中间位置的下标 mid 值,然后比较 mid 位置的值与 x 的大小关系,从而确定区间调整策略。
|
||||
|
||||
如果 arr[mid] 大于 x,说明 x 值在区间的前半段,那么mid 及 mid 位置以后的值,就不在下一次查找的范围之内了,我们就把区间的尾部位置 r 向前移动,移动到 mid - 1 位。arr[mid] 小于 x 时候的调整策略与之类似。
|
||||
|
||||
至此,我们就讲完了基础的二分查找算法。
|
||||
|
||||
#### 2.二分答案的基本思路
|
||||
|
||||
其实二分查找的算法思想,最有价值的部分,不是刚才讲的有序数组查找问题。而是由其衍生出来的叫做“二分答案”的思想。
|
||||
|
||||
关于二分答案的思想,你可以先回想一下,我们是如何介绍数组与函数的关系的?我们说:数组和函数本质上做的都是映射,函数是压缩的数组,数组是展开的函数。
|
||||
|
||||
我们沿着函数和数组的关系,回头再看看二分查找的代码,你会发现二分查找是在一个有序数组中,确定某一位为待查找值的位置,也就是 arr[x] = y,给定待查找 y 值,确定 x 值。对于这个有序数组,我们可以看成是一个单调函数,而对于这个arr[x] = y中去确定 x 值的过程,可以看成是对单调函数 f(x) = y 进行求解的过程。
|
||||
|
||||
如何判断,什么场景下需要使用二分思想对问题求解呢?其实,二分能解决的问题,还是比较有代表性的,基本需要满足如下两点:
|
||||
|
||||
1. f(x) 是一个单调函数。
|
||||
1. f(x) = y 函数,由 x 确定 y 值比较简单,而由 y 值确定 x 就比较困难。
|
||||
|
||||
关于第一点,我就不做过多解释了。至于第二点,你可以参考有序数组那个例子,对于数组来说,arr[x] = y,给定数组下标 x,确定值 y,这个过程对于计算机来说很容易,可给定 y 值,确定 y 值所在的下标 x,这个过程就不太容易了。所以,我们使用了二分查找算法。
|
||||
|
||||
总地来说,**二分答案就是把二分查找过程中的数组换成了函数**。关于二分答案的知识,你先理解思想,接下来我会用具体例子来给你展示。
|
||||
|
||||
## 一起动手,搞事情
|
||||
|
||||
今天给你留的作业题呢,是一个我们大家普遍都比较关心的问题,与计算工资有关系。下表是“个人所得税缴纳税率表”的一部分:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/da/6a/dac4a6d4427008ac750418fa79e3d06a.jpg" alt="">
|
||||
|
||||
按照表格所示,如果一个人的每月工资是 18600,首先扣除不超过3000部分的3% 的所得税 90 元,然后扣除3000 到 12000 部分的 10%,就是(12000 - 3000) * 10% = 900,最后是扣除 12000 到 18600 的部分的 20%,也就是 (18600 - 12000) * 20% = 1320。所以,此人每月到手工资应该是:18600 - 90 - 900 - 1320 = 16290 元。
|
||||
|
||||
如果要是让你通过上表,计算一个人的税后工资,那这个任务可太容易了,我不会将这么简单的任务交给你的。
|
||||
|
||||
你今天要做的,是通过一个人的税后工资,反推出他/她的税前工资,也就是针对于上面这个例子,我给出税后工资 16290,你的程序应该能够计算得到税前工资 18600。想一想,怎么做吧,加油!
|
||||
|
||||
## 切出最长的绳子
|
||||
|
||||
最后,我们回到今天的任务。我们将每一段绳子的长度 x,与能切出来的绳子段数之间,看成一个映射关系,用函数 f(x) = y 来表示,代表每一段长度为 x 的情况下,最多能切出来 y 段绳子。你很容易发现,f 函数是一个单调函数,随着每一段长度的增加,能切出来的段数 y 是在减少的,而对于我们来说,就是要确定 y = k 时的 x 的最大值。
|
||||
|
||||
让我们总结以下 f(x) 函数的性质,首先f(x) 函数是单调函数,x 越大,y 值越小。其次,你应该可以感受出来,当我给你每一段长度 x 的时候,你很容易确定 f(x) = y 的 y 值,而如果让你通过 y 值求解 x,就没那么容易了!
|
||||
|
||||
至此,我们从这个任务出现的问题中,看到了能够使用二分思想的两个最重要的性质,下面我们就用二分思想的思路,来解决这个问题,下面是我给出的参考代码:
|
||||
|
||||
```
|
||||
#define EPS 1e-7
|
||||
double l[100], n;
|
||||
|
||||
int f(double x) {
|
||||
int cnt = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
cnt += (int)floor(l[i] / x);
|
||||
}
|
||||
return cnt;
|
||||
}
|
||||
|
||||
double binary_search(double *l, double *r, int k) {
|
||||
if (r - l <= EPS) return r;
|
||||
double mid = (l + r) / 2.0;
|
||||
if (f(mid) < k) return bs(l, mid, k);
|
||||
return bs(mid, r, k);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中的 binary_search 就是二分答案的过程,函数 f 传入每一段的长度 x,返回最多能切多少段,变量 n 记录的是原始绳子的数量,l 数组记录的是每一段原始绳子的长度。
|
||||
|
||||
让我们把目光集中到 binary_search 函数过程,这一段二分答案的程序,使用递归的程序设计技巧,其实和之前给你演示的循环程序本质思想都是一样的。其中 l 和 r 表示待查找区间范围,也就是每一段绳子的长度范围。
|
||||
|
||||
递归程序的边界条件,是当 r - l 小于等于一个极小值的时候,就终止递归。这里需要特殊的说明一下,根据浮点数在我们计算机中的表示方法,我们很难用判等操作来判断两个浮点值相等,取而代之的,就是当这两个浮点值已经很接近的时候,我们就认为它俩是一个值。代码中的 EPS 是一个宏,就是我们控制的精度,一般控制在 $10^{-7}$ 范围,两个值相差不到 $10^{-7}$ 的时候,我们就认为这两个浮点值相等。
|
||||
|
||||
关于调整搜索范围的代码,我就不再赘述了,剩下的你自己就可以看明白了。至此,我们就解决了今天的任务。
|
||||
|
||||
## 课程小结
|
||||
|
||||
最后,我们来做一下课程小结。今天我们学习了二分查找算法,以及由二分思想延伸出来的二分答案相关算法思想。关于这些知识,你只需要记住如下几点即可:
|
||||
|
||||
1. 二分算法框架,是求解具有单调性问题的利器。
|
||||
1. 二分算法,通常用来求解那些f(x) = y 问题中,给定 y,求解 x 的问题。
|
||||
1. 再次强调,数组和函数在思维层面,没有什么本质差别。
|
||||
|
||||
总结完了以后呢,我们再回顾那个笑话故事,图书馆阿姨用二分算法为什么会导致图书馆丢了 39 本书呢?如果我们将书的编号和是否是图书馆的书之间,做一个函数映射的话,你会发现这种映射出来的函数,本质上没有单调性。所以,原因就是阿姨将二分算法思想用错场景了。
|
||||
|
||||
好了,今天就到这里了,我是胡光,我们下期见。
|
123
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/21 | 队列与单调队列:滑动区间最大值.md
Normal file
123
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/21 | 队列与单调队列:滑动区间最大值.md
Normal file
@@ -0,0 +1,123 @@
|
||||
<audio id="audio" title="21 | 队列与单调队列:滑动区间最大值" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/10/70038a308d66cfd64a4dbd3d08292310.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课呢,我们学习了二分查找的基本思想,以及明确了二分答案所使用的问题模型,你会发现,正因为问题具有单调性,我们才可以使用二分查找算法对问题求解过程进行加速。
|
||||
|
||||
今天呢,我将带你学习一种性质有趣、简单且高效的数据结构,叫做:单调队列。学习这个数据结构的时候呢,我们还是要强调一下那句话:数据结构,就是定义一种性质,并且维护这种性质。
|
||||
|
||||
## 今日任务
|
||||
|
||||
在正式开始学习之前呢,先来看一下今天这 10 分钟的任务吧。
|
||||
|
||||
滑动区间最大值,就是指在固定区间长度的前提下,在一个序列上,从前到后滑动这个区间窗口,每次窗口内部的最大值,就组成了滑动区间最大值。
|
||||
|
||||
例如,给你如下包含 8 个数字的序列,区间长度设置为 3:
|
||||
|
||||
```
|
||||
[6 4 2] 10 3 8 5 9 -> 6
|
||||
6 [4 2 10] 3 8 5 9 -> 10
|
||||
6 4 [2 10 3] 8 5 9 -> 10
|
||||
6 4 2 [10 3 8] 5 9 -> 10
|
||||
6 4 2 10 [3 8 5] 9 -> 8
|
||||
6 4 2 10 3 [8 5 9] -> 9
|
||||
|
||||
```
|
||||
|
||||
滑动区间从数字6开始出发,每次向右移动一个数字,同时把左边的一个数字丢出去,保持区间长度为3,最后移动到数字9停止。可以看到,这个序列共包含8个数字,所以最后形成的滑动区间最大值共有6个,依次是 6、10、10、10、8、9。
|
||||
|
||||
面对这个问题,你很容易采用 $O(nm)$ 的算法来完成,n 是区间长度,m 是窗口长度,就是枚举区间的终止位置,每次扫描区间内部,获得最大值。
|
||||
|
||||
而我今天要给你讲的这种方法,能让时间复杂度降低到 $O(n)$,你可以认为是对原序列扫描一遍,就能得到问题的答案。这究竟是什么样神奇的方法呢?带着这份好奇,我们开始今天的课程吧!
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
想要完成今天这个任务呢,你必须掌握今天我将要教给你的一种新的结构,就是:单调队列。
|
||||
|
||||
#### 1.初识队列
|
||||
|
||||
首先让我们来认识一下最简单的队列结构,举一个生活中最常见的例子:火车站排队买票,你应该都经历过吧?售票员坐在窗口里面,每次只能服务队列中排在最前面的那个人,每当有人买完票,都会从队列的头部离开,后面的人上前一步,接替离开的人向售票员购票,当有其他人想要来买票的时候,必须从队列的末尾开始排队。这种结构就是典型的队列结构。
|
||||
|
||||
我们计算机中的队列,和买票的这个队列是一样的,先到先得,先入先出,每个元素都是从队列尾部入队,在头部被处理完后再出队。如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/12/31/1223c53b5bd1e0e4d2bc50f18244ce31.jpg" alt="" title="队列结构示意图">
|
||||
|
||||
如图所示,队列就像一个数组一样,每个元素从数组尾部进入数组,从头部出数组。这种结构很简单,你应该很容易理解它的工作顺序。任何事物,往往就是看起来越简单,想要掌握其真谛就越难。就像是我给你一把锤子,你知道这东西大概可以干什么,而我要是给你一块铁,你可能就懵了。其实队列就是这种表面简单,可作用却不简单的数据结构。
|
||||
|
||||
想要理解队列,你就必须理解一句话,叫做:计算机是很专注的。什么意思呢,我们回忆一下之前讲到的链表判环,作为人类的你和我,可以一眼就看出来链表中是否有环。而对于计算机程序来说,只有指针指向的地方,才是它能看得到的地方。所以,我们才费了很大的力气,为计算机设计了一个快慢指针的算法,来判断链表中是否有环。
|
||||
|
||||
实际上,当我们在实现程序的时候,我们不仅要把数据存储在计算机中,我们还要规定计算机处理这些数据的顺序。想一想,我们之前设计的所有的循环程序,不就是在规定计算机的处理顺序么?
|
||||
|
||||
而今天我们学习的这个队列,你可以把其中的元素,看成是计算机要处理的一个个的任务,那么队列结构,其实就是规定了这些任务的处理顺序,程序只从队列头部取任务,先到先处理,后到的任务,需要在队列后面排着,直到轮到它,这样就可以把计算机的专注与高效发挥到极致。
|
||||
|
||||
学习编程,与其说是将我们的思维转换成代码,不如说是将我们的思维,锻炼成计算机的思维。注意,计算机的处理逻辑,是有顺序的。今天,我们所说的队列,就代表了一种顺序。后面,我们还会介绍另外一种顺序的代表,就是“栈”,到时候我再详细讲解。
|
||||
|
||||
#### 2.队列升级:单调队列
|
||||
|
||||
讲完了最简单的队列以后,下面就来让我们学习一种队列的升级产物:单调队列。在正式讲解单调队列之前,让我们来讲一个现实中的单调队列的例子。
|
||||
|
||||
假设你的学长张三,作为乒乓球体育生,很幸运地进了高中学校的校队。学校规定当前校队中,能力最强的人,才有可能代表学校去参加比赛。那么在校的这三年,张三都有机会代表学校参加国家赛。
|
||||
|
||||
高一的时候张三战斗力 85,比他能力强的有两个人,一个是高二的孔令辉,战斗力 93,一个是高三的刘国梁,战斗力 98。那么此时,能代表学校参加比赛的只有最高战力的刘国梁。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/88/440a0401bc65a31d9002a77163abad88.jpg" alt="">
|
||||
|
||||
过了一年,张三上了高二,原高二的孔令辉上了高三,刘国梁毕业了,如果张三的战力比后面上来的新生要强,那么他再等一年,有可能在高三的时候代表学校参赛。然而这时作为高一乒乓球体育生的你也进入了校队,战斗力 88。悲剧出现了,因为只要你在学校,张三永远不可能参加国家赛了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/4b/c51f381f501c6d0b18b493fafd11a54b.jpg" alt=""><br>
|
||||
这个时候你欣喜若狂,因为再熬一年,如果新学弟战斗力没你高的话,你就能代表学校去参加国家赛了。很快一年又过去了,你终于熬到了高二,同时也迎来了一名新学弟张继科,战斗力 90。悲剧再次上演,你和张三一样,也失去了代表学校参赛的机会了,此时你的心情,是不是五味杂陈?<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/66/62/66a3931ad85a5386795847a92e893562.jpg" alt=""><br>
|
||||
上面的几个校队名单呢,就是我们所谓的单调队列,如果把学生从高年级到低年级排列,随着时间的流逝,这本身就是一个队列结构,高年级的同学从队列头部毕业,低年级的同学从队列尾部进入。
|
||||
|
||||
而这个校队名单,记录的是最有可能代表学校参加比赛队员的名字。刘国梁毕业了,最有可能接班的是孔令辉,孔令辉毕业了,最有可能接班的是张三。而当你进入队列的那一刻,张三尽管比你入队早,但战力没你高,所以张三就永远失去了机会。后来张继科进入队列,你遭遇了和张三一样的悲剧。
|
||||
|
||||
如果你要是仔细观察校队名单,你会发现校队名单上,永远是按照能力值的从高到低,来记录学校里面的种子选手。这个名单,既有队列的样子,又有单调的性质,所以称为“单调队列”。
|
||||
|
||||
单调队列的作用,就是用来维护在**队列处理顺序**中的区间最大值。就像上面所说的校队名单,维护的就是区间长度为3时候的最大值。当一个新的元素入队的时候,它会把其前面违反单调性的元素,都从队列中踢掉,就像张继科的入学,把你踢出了校队名单,最终他成为了队列里的最大值。
|
||||
|
||||
## 滑动区间最大值
|
||||
|
||||
让我们回到开始的求“滑动窗口最大值”的任务。其实,滑动窗口每次向后滑动一位,会有一个元素从队首出队,同时也会有一个元素从队尾入队,所以滑动窗口的过程,就遵照了我们所谓的队列处理顺序。
|
||||
|
||||
而这个任务,本身就是求区间最大值的,所以也符合了单调队列应用的场景:维护在**队列处理顺序**中的区间最大值。下面呢,我们就来看一下具体代码:
|
||||
|
||||
```
|
||||
#define MAX_N 1000
|
||||
int q[MAX_N + 5], head, tail;
|
||||
void interval_max_number(int *a, int n, int m) {
|
||||
head = tail = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
// a[i] 入队,将违反单调性的从队列 q 中踢出
|
||||
while (head < tail && a[q[tail - 1]] < a[i]) tail--;
|
||||
q[tail++] = i; // i 入队
|
||||
// 判断队列头部元素是否出了窗口范围
|
||||
if (i - m == q[head]) head++;
|
||||
// 输出区间内最大值
|
||||
if (i + 1 >= m) {
|
||||
printf("interval(%d, %d)", i - m + 1, i);
|
||||
printf(" = %d\n", a[q[head]]);
|
||||
}
|
||||
}
|
||||
return ;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如代码所示,interval_max_number 函数,传入三个参数,数组首地址 a,元素数量 n 以及 区间长度 m。代码中的 q 数组,后续的作用就是模拟单调队列,head 与 tail 代表了队列的头尾下标,这里我们采用左闭右开式的表示方法,也就是 head 和 tail 所指示的区间范围内,包含 head 所指位置,但不包含 tail 所指位置。
|
||||
|
||||
函数内部,依次处理数组中的每个元素,每次处理相应元素的时候,涉及到两个过程:
|
||||
|
||||
- 第一个过程,是将当前元素入队。在入队之前,将队列尾部违反单调性的元素都从队列中踢出,这个就是第 7 行 while 过程的作用,之后就是将编号 i 入队即可。这里注意,单调队列里面,存储的是 a 数组的下标,而不是 a 数组的值。其实存储了下标,我们就可以索引到值,而在上一节二分查找的课里面,我们也见识过了,要是存储了值,想要反向索引下标是比较困难的。
|
||||
- 第二个过程呢,就是判断单调队列头部的元素是否超出了窗口范围,也就是前面我们例子中你的学长毕业的过程,如果元素下标已经超出了窗口范围,就将队列头部元素出队。
|
||||
|
||||
这样我们就可以保证,我们每次输出的,就都是滑动窗口内部的区间最大值了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
以上就是我们今天要学习的单调队列的内容,关于单调队列的知识,你在理解其处理过程的时候,更应该记住单调队列应用的场景:**就是维护队列处理顺序中的区间最大值**。
|
||||
|
||||
这个里面,需要重点强调一个**队列处理顺序** 。也就是说,如果你可以把一个问题的求解顺序,抽象成队列求解顺序,并且在这个过程中,你还需要维护区间最大值,那么翻出“单调队列”,准能帮助你大幅度提升处理速度!而单调队列,无论是入队,还是出队,操作完以后,一定要保证队列内部满足单调性,这就是开头我们说的:定义一种性质,并且维护这种性质。单调队列,维护的就是单调性。
|
||||
|
||||
最后,我们来简单说一下单调队列处理单个元素的平均时间复杂度为什么是 $O(1)$ 的。假设我们要处理 n 个元素,从整体上来看,每个元素会入队列 1 次,出队列最多也是 1 次,那么n 个元素的总操作次数不会超过 $2 \times n$ 次,平均到一个元素上就是 2 次,也就是常数次,记作 $O(1)$ 时间复杂度。由此得知,处理 n 个元素的总时间复杂度,就是 $O(n)$。
|
||||
|
||||
今天没有思考题,因为这节课的内容只是作为一个铺垫,下节课关于“栈”的知识才是重头戏。我也希望你对这节课的内容认真学习体会,可以的话,在留言区说说你的看法和思考。
|
||||
|
||||
好了,单调队列的知识,就讲到这里了,我是胡光,我们下期见。
|
137
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/22 | 栈与单调栈:最大矩形面积.md
Normal file
137
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/22 | 栈与单调栈:最大矩形面积.md
Normal file
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="22 | 栈与单调栈:最大矩形面积" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/79/14e1f960ec2c5edfc97b2908f4ed0379.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课我们讲了单调队列这种具有单调性的结构,并且说明了单调队列适合:维护**队列处理顺序**中的区间最大值,并且我还提到单调队列只是一个铺垫,搞清楚了单调队列的内容,才能更好地学习新的数据结构。
|
||||
|
||||
今天我将带你学习一种队列和单调队列的兄弟数据结构,它的性质也很有趣,就是:栈与单调栈。学习这个数据结构的时候呢,我还是要再次强调一下那句话:数据结构,就是定义一种性质,并且维护这种性质。
|
||||
|
||||
## 今日任务
|
||||
|
||||
在正式开始学习之前呢,先来看一下今天这 10 分钟的任务吧。
|
||||
|
||||
假设有一面木板墙,每块木板的宽度都是 1,你现在想在木板墙上,沿着平行于地面的方向,切割出一块矩形区域。问题来了,如果给出了每一块木板的高度,那么如何切出面积最大的矩形区域?矩形木板墙如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/13/84e7e0de54973648e444b780d245ae13.jpg" alt="" title="图1: 木板墙示意图"><br>
|
||||
如你所见,图中有 7 块木板,每块木板的高度分别为:2、1、4、5、1、3、3。经过尝试,我们发现最大矩形就是红色阴影部分所示,也就是切割了高度为 4和5 两块木板,形成了一个高度为4,宽度为2的矩形区域,这个最大面积为 8。
|
||||
|
||||
显而易见的结论:就是**切下来的最大的矩形,一定是以最大矩形所在区域最短那块木板作为其高度值**。如果不是这样的话,我们就可以提升一点点高度,让切下来的部分更大一点儿。
|
||||
|
||||
有了如上这个结论,我们就可以枚举每一块木板,每次都以当前木板作为高度,就是把当前这块木板,当成是切出来的矩形区域中的最矮的木板,然后向左边和右边分别做延伸,切出此时的最大矩形区域。当把所有木板都试过一遍后,我们在所有枚举结果中比较出最大值,这个最大值就是我们要求的最大矩形面积。如果木板的个数为 n,那这种做法的时间复杂度接近于 $O(n^2)$。
|
||||
|
||||
而今天,我要给你讲的方法,能将这个时间复杂度降低到 $O(n)$,这种结构方法就像我们上次讲的单调队列一样有趣。接下来,就让我们一点点地揭开这个结构神秘的面纱。
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
想要完成今天这个任务呢,你必须掌握接下来我要教给你的一个新的数据结构:单调栈。
|
||||
|
||||
#### 1.栈:维护一种完全包含关系的结构
|
||||
|
||||
首先让我们来认识一下最简单的栈结构,所谓栈结构,你可以想象成只有一个口的羽毛球桶,羽毛球只能从唯一的一个口放入和取出。我们把编号1、2、3三个羽毛球按顺序放进球桶后,如果想取出来,那么这些球被取出来的顺序一定是编号3、2、1。也就是说后放入的羽毛球,在取出的时候,会最先被取出来,它们放入和取出的顺序是相反的。
|
||||
|
||||
如果说,上一节我们学习的队列结构是**先进先出**的结构,那么今天我们学习的栈就是一种**后进先出**的结构。栈和队列一样,都是计算机中,用来规范处理顺序的基础结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/6e/ddd9b4e82dd7e48ffdc57b08644cb26e.jpg" alt="" title="图2: 栈结构示意图"><br>
|
||||
图中所示,入栈顺序分别是 蓝、绿、红,那么出栈顺序就一定是红、绿、蓝。图中每一个颜色的方块上标注的数字,就是每一个方块入栈及出栈的顺序。
|
||||
|
||||
从示意图中,我们还可以观察到一个有趣的事情,在顺序上而言,红色方块被绿色方块包裹着,绿色方块被蓝色方块包裹着。这种结构,像是程序的调用过程,如果把蓝色方块,看成是主函数的话,那么绿色方块就是主函数中调用的一个函数 A,红色方块就是 A 函数中调用的另外一个函数 B,三个函数调用的顺序是主函数、函数 A、函数 B。
|
||||
|
||||
而它们的执行结束顺序恰恰是相反的,首先是 函数 B 结束,然后是 函数 A 结束,最后是主函数结束。实际上,我们计算机用来维护函数执行的底层系统,就是用的这种栈结构。
|
||||
|
||||
你可以认为,栈结构本身维护的是一种完全包含关系。其实函数之间的运行,就是一种完全包含关系,只要在主函数中调用函数 B,那么函数 B 一定在主函数结束之前结束,这就可以视为是主函数包含函数 B。
|
||||
|
||||
#### 2.单调栈:**维护最近大于或小于关系的结构**
|
||||
|
||||
我们了解了最简单的栈结构以后,接下来,就来让我们学习一种栈的升级产物:单调栈。理解单调栈的最简单方法,就是基于对单调队列的理解去学习它。如果你单调队列还没有掌握,那我建议你再好好看一看上节课中关于单调队列的相关内容。
|
||||
|
||||
我先问你一个问题,队列结构和栈结构到底有什么区别?你可能会说,它们唯一的区别就是,队列是从一端进另外一端出,栈是在同一端进出。
|
||||
|
||||
那我再问你一个问题,堵住出口的单调队列,和栈有什么区别?你会发现,好像没什么区别了,单调队列为了维护单调性,在入队列的时候,也会将违反单调性的元素弹出,所以,这就相当于栈的从同一端进出。
|
||||
|
||||
好了,如果你明白这些问题,我可以明确地告诉你:堵住出口的单调队列,就是我们今天要学习的“单调栈”。
|
||||
|
||||
既然堵住了单调队列的出口,那么这种所谓单调栈的结构,就再也维护不了区间最大值了。那它维护的是什么呢?让我们以单调递减栈为例。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/37/a6/372c236ecc02ccff691a300785fc35a6.jpg" alt="" title="图3: 单调栈示意图"><br>
|
||||
如图所示,这是我假设的一种单调栈中元素的情况:序列第 12 号元素入栈以后,单调栈中只剩下了 4 个元素,从栈底到栈顶,值分别为23、18、15 和 9,分别对应了原序列的第 2 号、第 5 号,第 9 号 以及 第 12 号。
|
||||
|
||||
关于单调栈性质的思考,我们只需要重点关注栈顶的 12 号元素和 9 号元素之间的关系即可。如果 12 号元素入栈以后,为了保持栈中的单调递减性,它最终放在了 9 号元素上面,那说明什么呢?是不是说明从 9 号元素到 12 号之间的元素值,均小于 12 号元素值呢?也就是说,10号、11 号这两个元素的值,我们虽然不知道具体是多少,可这两个元素的值,肯定比 9 号元素小,甚至也比 12 号元素小。否则,按照单调栈的入栈规则,12 号元素和 9 号元素就不可能在栈中相邻。
|
||||
|
||||
其实,说到这里,你应该已经对单调栈的性质有所感觉了。如果我们将一个元素压入单调递减栈,那么这个元素会落在离它最近,且比它大的元素上面。就像上面的例子中,当12号元素入栈以后,它落在了9号元素上面,说明从 12 号元素向前找,9 号元素是第一个比 12 号元素值大的元素。
|
||||
|
||||
**如果说单调队列是维护区间最值的高效结构,单调栈就是维护最近大于或小于关系的高效结构**。如果想要维护最近大于关系,就建立一个单调递减栈,然后将每个元素依次入栈,在这个过程中,我们就可以统计得到每一个元素之前离它最近的,且大于它的元素。那要是想维护最近小于关系呢?就建立一个单调递增栈就好了!
|
||||
|
||||
至此,我们就掌握了单调栈的基本性质了。
|
||||
|
||||
## 一起动手,搞事情
|
||||
|
||||
今天的思考题呢,跟括号匹配有关系。任务很简单,就是给你一串括号序列,括号序列中可能包含小括号(),中括号[] 或者 大括号{},你需要写程序,判断这个括号序列是否合法。只要括号之间,没有交错重叠的情况,就是合法的括号序列。
|
||||
|
||||
下列给出了一些合法的序列的示例:
|
||||
|
||||
```
|
||||
({})
|
||||
[]([]){()}
|
||||
|
||||
```
|
||||
|
||||
下面是一些非法的括号序列的示例:
|
||||
|
||||
```
|
||||
([)]
|
||||
(((){}
|
||||
|
||||
```
|
||||
|
||||
通过观察括号序列,你会发现合法的括号序列,其实就是一种完全包含的结构,关于这种结构合法性的判断,和我们今天讲的栈结构有什么关系呢?开动你聪明的大脑,思考一下吧!
|
||||
|
||||
## 最大矩形面积
|
||||
|
||||
最后我们回到今天的任务,先来回顾一下之前所说的解题过程:我们通过枚举每一块木板作为切割出的木板墙的高度,每次都需要向左边和右边分别做查找,一直找到一块高度小于当前木板高度的位置,这样就确定了切割木板墙的长度。
|
||||
|
||||
以图1中高度为4的木板为例,我们通过向左延伸查找,发现左边第一块就比它短,这样就确定了向左延伸的长度是0;往右延伸查找,发现第二块木板比它短,也就是向右延伸的长度是1。说到这里,你会发现,上面这个过程,不就是我们之前所说的,维护最近小于关系么!只需要建立一个单调递增栈,就可以完成这个任务!
|
||||
|
||||
下面,是一份我给出的示例代码:
|
||||
|
||||
```
|
||||
#define MAX_N 1000
|
||||
#define max(a, b) ((a) > (b) ? (a) : (b))
|
||||
int s[MAX_N + 5], top;
|
||||
int l[MAX_N + 5], r[MAX_N + 5];
|
||||
int max_matrix_area(int *h, int n) {
|
||||
h[0] = h[n + 1] = -1;
|
||||
top = -1, s[++top] = 0;
|
||||
// 找到每一块木板,左边第一块比其矮的木板编号
|
||||
for (int i = 1; i <= n; i++) {
|
||||
while (top >= 0 && h[s[top]] >= h[i]) --top;
|
||||
l[i] = s[top];
|
||||
s[++top] = i;
|
||||
}
|
||||
// 找到每一块木板,右边第一块比其矮的木板编号
|
||||
top = -1, s[++top] = n + 1;
|
||||
for (int i = n; i >= 1; i--) {
|
||||
while (top >= 0 && h[s[top]] >= h[i]) --top;
|
||||
r[i] = s[top];
|
||||
s[++top] = i;
|
||||
}
|
||||
// 在所有木板中,找到面积最大的矩形
|
||||
int ans = 0;
|
||||
for (int i = 1; i <= n; i++) {
|
||||
ans = max(ans, (r[i] - l[r] - 1) * h[i]);
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如上代码所示,max_matrix_area 函数传入两个参数,木板高度数组首地址 h,和木板数量 n。代码中的 s 数组,后续的作用就是模拟单调栈,top 代表了栈顶元素的下标。
|
||||
|
||||
你需要注意的是,代码中假设木板的编号是从 1 到 n 的,然后,在数组的 0 位 及 n + 1 位分别加入两块高度为 -1 的虚拟木板,这是边界控制的一种技巧。也就是说,在每块木板向左搜索的时候,最远也就搜索到 0 号位就停止了,向右搜索的时候呢,最远搜索到 n + 1 位也就停止了。通过加入虚拟木板,代码中就少了相关的边界条件判断,这是一种很实用的技巧,你一定要理解和掌握。
|
||||
|
||||
## 课程小结
|
||||
|
||||
以上就是我们今天要学习的单调栈的内容,关于单调栈,其实你只需要对比着单调队列进行学习和记忆即可,记住以下两点:
|
||||
|
||||
1. 单调栈是用来维护最近大于或小于关系的数据结构。
|
||||
1. 单调栈就是堵住出口的单调队列,所以其时间复杂度与单调队列一致,平均到每个处理元素上,都是 $O(1)$ 的时间复杂度。
|
||||
|
||||
好了,单调栈的知识,就讲到这里了。我是胡光,我们下期见。
|
139
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/23 | 深入理解:容斥原理与递推算法.md
Normal file
139
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/23 | 深入理解:容斥原理与递推算法.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="23 | 深入理解:容斥原理与递推算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b3/9d/b3ef4c46f74f03718b88a2760305dd9d.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上两节呢,我们学习了两个具有单调性的数据结构:**单调队列**和**单调栈**。其中,单调队列是用来维护滑动窗口内的区间最值的单调结构,单调栈是用来维护最近大于或小于关系的单调结构。这两种单调结构的均摊时间复杂度都是 $O(1)$,每个元素的操作次数最多是 2 次,足以看到这两种结构的高效。如果想彻底掌握这两种结构,我建议你在课下时间不断地练习。
|
||||
|
||||
从今天开始呢,我们将从数据结构,跳跃到算法的学习上。将要带你认识一类比较偏重于思维的算法,大类叫做递推算法,以及其中的一个重要的组成部分,动态规划类算法。
|
||||
|
||||
关于递推算法,在“语言基础篇”的第05讲[《数组:一秒钟,定义1000个变量》](https://time.geekbang.org/column/article/188612)中,我们其实就使用了递推算法的思想。如果你已经忘了的话,我建议你可以先回去看看,复习一下之前的内容,为今天的课程做好充足的准备。
|
||||
|
||||
## 今日任务
|
||||
|
||||
咱们今天这10分钟的任务呢,和钱有关系。众所周知,在不计算小于 1 元钱的面额的前提下,我国的纸币系统中,曾经拥有如下面值:1元、2元、5元、10元、20元、50元 和 100元。假设,每一种面值的纸币,我们都有无限张,现在想用这些钱凑出1000元,请问你有多少种不同的方案?
|
||||
|
||||
这里说的不同方案,是不关注钱币之间的顺序的,例如要凑 7 元钱,可以是1元、5元、1元,也可以是1元、1元、5元,这两种方案我们视为同一种。
|
||||
|
||||
好了,面对这个问题,你要怎么解决呢?让我们开始今天的学习吧。
|
||||
|
||||
## 必知必会,查缺补漏
|
||||
|
||||
#### 温故知新:容斥原理
|
||||
|
||||
在讲今天的递推问题之前呢,先来和你解释一个与递推思维相关的数学原理:容斥原理。
|
||||
|
||||
我们知道,一般在计数问题中,为了保证计数准确,必须注意两个事情:一是没有重复,二是没有遗漏。保证没有遗漏,这一点比较好做到,就像对某片地区采取地毯式轰炸,只要炸弹足够多,你可以很容易地保证,没有任何一个被漏掉的地方。可你要保证任何一片土地都仅被轰炸一次,这就很难做到。计数类问题往往也是这样的,想要保证没有遗漏的计数,比较简单,可要是想保证没有重复的计数,可能就困难那么一点儿了。
|
||||
|
||||
容斥原理就是为了解决计数类问题中的重复问题,其基本思想是:先不考虑重叠的情况,把包含于某内容中的所有对象的数目先计算出来,然后再把计数时重复计算的数目排除出去,使得计算的结果既无遗漏又无重复。简单来说,就是在计算过程中,如果加多了,就把加多的部分减掉,如果发现又减多了,就再加回来一部分,一直到不多不少为止。
|
||||
|
||||
这么说,你可能还是有点儿懵。没关系,回想一下,我们之前在第13节课[《程序设计原则:把计算过程交给计算机》](https://time.geekbang.org/column/article/197583)中,提到的求1000以内,3或者5倍数的所有数字和的问题。
|
||||
|
||||
原问题解决方法如图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/9d/0b903e61a086497d128d0fe6197ec19d.jpg" alt="" title="图1: 倍数问题的集合表示"><br>
|
||||
当时,我们提出了基于等差数列求和的一种做法,就是用 3 的倍数,加上 5 的倍数,然而我们发现,有一部分加多了,就又减掉了 15 的倍数,这样才是我们真正想要求的值。你仔细想一想,这种做法本质上,其实就是容斥原理的一种体现。
|
||||
|
||||
关于理解容斥原理,你需要理解问题的集合思维,关于这点呢,我在下面还会带着你做详细的解释。
|
||||
|
||||
#### 一个递推算法例子:兔子繁殖问题
|
||||
|
||||
我们先从一个简单的问题开始,逐步熟悉容斥原理与递推算法。假设在一片草原上,莫名其妙来了一只外星兔子,这种外星兔子呢,第一个月的时候是幼体,第二个月成长为成体,从第三个月开始,成体兔子每个月都会产生出一只克隆体的幼体兔子,而且这种兔子不会衰老,一旦成体以后,就会一直生下去。按照这种情况,请你计算出第 n 个月,草原上有多少只兔子?
|
||||
|
||||
这里我给出了前6个月,草原上兔子数量的情况:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/73/a4d7c79cfaf01acc956e5f461a8e1773.jpg" alt="" title="图2: 兔子前6个月繁殖情况"><br>
|
||||
我们看到,从第1个月到第6个月,草原上兔子的数量分别是:1、1、2、3、5、8,我们主要来分析第6个月兔子的组成情况。
|
||||
|
||||
第6个月共有 8 只兔子,其中5 只成兔,3 只幼兔。可以看到,之所以有 5 只成兔,是因为上个月总共有5只兔子,毕竟根据兔子的成长周期,不管它们是否成年,到下个月都会成长为成兔,所以**第6个月的成兔数量等于第5个月的兔子总数**。
|
||||
|
||||
而第6个月的另外的3 只幼兔,是由第 5 个月的 3 只成兔产生的,根据前面的推论,我们知道第5个月的3只成兔,是源自第4个月的兔子总数。所以,**第6个月的幼兔数量等于第4个月的兔子总数**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/bf/56a7b86ac2b48050b715fe4622d5d2bf.jpg" alt="" title="图3:第6个月的兔子数量与前两个月兔子数量关系"><br>
|
||||
因此,我们可以得出这样的一个结论:从第3个月开始,**第 n 个月的兔子总数,等于该月的成兔数量与幼兔数量之和,也就等于第 n - 1 个月的兔子数量与第n - 2个月的兔子数量之和**。
|
||||
|
||||
在这个兔子繁殖问题中,我们把当前月份的兔子分成两类,一类是成年兔子,一类是幼年兔子。这种分类方法,就保证了我们对两类分别统计的时候,它们之间没有交集,也就不需要考虑容斥原理中剔除重复部分的过程了。
|
||||
|
||||
之后,我们重点分析本月的成年兔子数量、幼年兔子数量与之前的哪个量有关系,最终得到了一个只使用某个月兔子数量,对本月兔子数量进行表示的数学关系。**这种使用序列中的前项的值,来计算当前项值的做法,就是我们今天要讲的递推算法**。
|
||||
|
||||
那么这个算法具体如何求解呢?下面我们就来看看递推问题的求解过程。
|
||||
|
||||
#### 递推问题求解步骤
|
||||
|
||||
**递推问题,通常分成三步进行求解。第一步,确定递推状态,也叫做状态定义;第二步,推导递推公式;最后一步,程序设计与编写。**
|
||||
|
||||
接下来,我将用前面的兔子繁殖问题为例,说明递推问题求解的前两个步骤。最后一步的程序设计与编写,将作为作业题,留给你来完成。
|
||||
|
||||
#### 1. 确定递推状态
|
||||
|
||||
所谓确定递推状态,就是确定一个有明确含义的数学符号,这里重要的是这个明确含义,而非那个数学符号。
|
||||
|
||||
什么意思呢?就以兔子繁殖问题为例,当我们分析完问题以后,就可以定义出具有明确语义的数学符号,例如:f(n) 。如果我们仅仅列出这么一个数学符号,其实是没有多大意义的,可是当我们定义了它的语义,即 f(n) 代表第 n 个月兔子的数量,这才算是完成了状态定义。因为只有确定了状态定义,我们才能进行下一步的递推公式推导。
|
||||
|
||||
说到这里,在继续讲下面递推公式推导之前,你可能会有疑问了:这个状态定义中的数学符号虽然不重要,可一般是怎么确定出来的呢?
|
||||
|
||||
针对这类问题,那就不得不提到我的思考技巧了:我一般会把递推类问题,看成是初中的代数问题,先分析问题中的**自变量**和**因变量**。自变量,就是问题中那些不受控制的量,就像兔子繁殖问题中的月份。而因变量就是那些随自变量改变而改变的量,就像兔子繁殖问题中兔子的数量,是随着月份而改变的。
|
||||
|
||||
所以,我的技巧就是把和问题求解量相关的自变量,都作为数学符号中的参数,然后将相关问题求解量作为数学符号映射值的含义。就像我们刚刚所说的,f(n) 代表第 n 个月兔子的数量,在这个状态定义中,将问题求解量,也就是兔子数量,作为函数映射值的含义;而与问题求解量,即兔子数量相关的自变量只有一个,那就是月份,所以我们将月份作为函数的参数。
|
||||
|
||||
这样,我们就完成了状态定义。
|
||||
|
||||
#### 2.推导递推公式
|
||||
|
||||
接下来,就是递推问题求解的第二步了,推导递推公式。在推导递推公式的时候,这里需要用到前面我们定义的递推状态,并且,使用时一定要严格遵守递推状态的语义信息。
|
||||
|
||||
例如,在兔子繁殖问题中,如果你想用状态 f(n) 做公式推导的时候,那么 f(n - 1) 就代表了第 n - 1 个月兔子的数量,而 f(n - 2) 就代表第 n - 2 个月兔子的数量。这就是我刚刚所说的“要严格遵守递推状态的语义信息”的意思。
|
||||
|
||||
一般做递推公式推导的时候,我们主要思考的事情是,当前递推状态和前几项递推状态之间的关系。例如,在兔子繁殖问题中,当我们确定了递推状态 f(n) 以后,通过分析可以得到如下递推公式:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/2a/a9a9ab49024a11c0a3eb34337f33232a.jpg" alt="" title="图4: 兔子繁殖问题递推公式"><br>
|
||||
根据前面对兔子繁殖问题的分析,你应该很容易理解这个递推公式吧?就是前两个月(n=1,2),兔子的数量都是1只;到第三个月以及之后的月份(n>=3),本月的兔子数量等于上两个月的兔子数量之和。其中 f(n - 1) 等于本月中成兔的数量,f(n - 2) 实际代表的是 2 个月前的兔子数量,它也等于本月中幼兔的数量。套用集合的思想就是,成兔与幼兔这两部分互为补集,加在一起就正好等于全集。
|
||||
|
||||
细心的你肯定发现了,这个公式是不是在哪里见过?没错,在咱们的第12节课[《数学归纳法:搞定循环与递归的钥匙》](https://time.geekbang.org/column/article/197058)中,我们提到了菲波那契数列递推公式,就跟这个兔子繁殖公式一模一样。
|
||||
|
||||
## 一起动手,搞事情
|
||||
|
||||
请你来完成兔子繁殖问题的第三步:程序设计与编写。要求是用两种方式完成:
|
||||
|
||||
1. 请使用循环的程序实现方式
|
||||
1. 请使用递归的程序实现方式
|
||||
|
||||
在你用递归实现了兔子繁殖问题的求解过程以后,我希望你可以计算下80个月后,兔子的数量。你的程序将发生一些奇奇怪怪的现象,试着自己去理解这个程序现象,并且想一想,如何解决出现的问题吧。
|
||||
|
||||
在这里呢,再跟你多说一句,在进行程序实践的时候,一定要注意总结我们之前讲过的数学归纳法和递推算法与程序之间的关系。
|
||||
|
||||
## 凑钱币问题
|
||||
|
||||
最后,让我们回到今天的任务,也就是用1元、2元、5元、10元、20元、50元和100元凑成1000元钱,总共有多少种方案。
|
||||
|
||||
第一步,让我们来确定递推状态。确定递推状态之前,我们需要分析清楚题目中的自变量与因变量。因变量比较好分析,就是方案总数,那这个方案总数都受什么影响呢?很明显,是钱币的种类和拼凑目标金额。也就是说,钱币种类发生变化,方案总数就会发生变化;同理,如果拼凑的目标金额发生变化,方案总数也一定会发生变化。所以,自变量是 2 个,钱币种类和拼凑的钱币数量。因变量是 1 个,就是方案总数。
|
||||
|
||||
通过上面的分析,我们就可以列出状态定义:f(i, j) ,代表使用前 i 种钱币,拼凑 j 元钱的方案总数。例如,f[3][10] 就代表使用前3种钱币,也就是只使用1元、2元、5元,凑10元钱的方案总数。
|
||||
|
||||
第二步,就是用这个状态定义,进行递推公式推导,关键就是分析当前项与前几项的关系。核心思想其实就是容斥原理,也就是用某几项表示 f(i, j) ,如果发现这些表示 f(i, j) 的项之间存在交集,就将交集部分减去,如果减多了再加回来一些,直到正好表示 f(i, j) 为止。
|
||||
|
||||
好在这道题目还算是一道简单的递推问题,我们可以将 f(i, j) 划分成性质不同且互为补集的两部分。在 f(i, j) 所代表的所有方案中,一部分方案是使用了第 i 种钱币的,另外一部分方案中是没有使用第 i 种钱币的,我们就用这个性质,将 f(i, j) 表示成两项相加之和的形式。
|
||||
|
||||
例如,在用前三种钱币,拼凑 10 元钱的所有方案中,可以按照方案中是否使用第 3 种钱币,也就是是否使用了 5 元钱,将所有方案划分成两类。
|
||||
|
||||
其中一类方案不包含第 3 种钱币,也就是不用 5 元这个钱币,这些方案的数量,等价于使用前 2 种钱币拼凑 10 元钱的方案总数,也就是 f[2][10] 的值。另外一类方案中,使用了至少 1 张 5 块钱,那么我们可以在这些方案中,都拿掉一张 5 元钱,剩余的部分组成的方案数量,就等于 f[3][5],也就是用前 3 种钱币凑 5 元钱的方案总数。
|
||||
|
||||
这样我们就推导出了递推公式:f[3][10] = f[2][10] + f[3][5]。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/cc/45e07e8c765a5dd9ab50767d54705ccc.jpg" alt="" title="图5: 凑钱币问题示意图"><br>
|
||||
回到我们的任务,就是在 f(i, j) 代表的所有方案中,没有使用第 i 种钱币,拼凑 j 元钱的方案数量,就是 f(i - 1, j),代表使用前 i - 1 种钱币拼凑 j 元钱的方案总数。剩下的使用了第 i 种钱币的方案中,由于都存在第 i 种钱币至少 1 张,假设第 i 种钱币的面额是 val[i],也就意味着,我们可以使用前 i 种钱币,凑 j - val[i] 的钱数,给第 i 种钱币留出一个位置,这么做所对应的方案总数就是 f(i, j - val[i])。
|
||||
|
||||
最终,我们推导出了递推公式:**f(i, j) = f(i - 1, j) + f(i, j - val[i])**。其中,边界条件是 f(1, k * val[1]) = 1,也就是用在只使用第 1 种钱币的条件下,想要凑第 1 种钱币的整数倍面额的方案总数都是 1。
|
||||
|
||||
至此,我们就完成了凑钱币问题的递推求解过程。最后,还剩一个程序实现,试着自己完成一下吧,加油!你可以的!
|
||||
|
||||
## 课程小结
|
||||
|
||||
最后呢,我们来总结一下今天的内容,今天的内容主要想让你记住三点:
|
||||
|
||||
1. 递推问题第一步是要确定递推状态,也就是给出一个数学符号,以及数学符号的相关描述。
|
||||
1. 在设计递推状态的时候,主要分析自变量与因变量的关系,一般因变量都是问题求解的那个量。
|
||||
1. 递推问题的第二步是推导递推公式,而容斥原理的思想,对于这一步的求解,十分重要。
|
||||
|
||||
递推问题的求解过程,不是一朝一夕就能掌握的,今天的课程呢,只是让你拥有这种感觉,以及掌握求解递推问题的重要思考过程。我相信,只要你沿着今天讲的递推问题求解过程,去学习每一个递推问题,总有一天,你会对递推问题理解得更加透彻。
|
||||
|
||||
对于学有余力的小伙伴们,如果想更深入地了解一下容斥原理,可以通过学习莫比乌斯函数、狄利克雷卷积与莫比乌斯反演等内容,进一步感受一下这个思想所绽放出的光芒。
|
||||
|
||||
好了,关于递推的知识今天就讲到这里了,我是胡光,咱们下期见。
|
126
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/24 | 动态规划(上):只需四步,搞定动态规划算法设计.md
Normal file
126
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/24 | 动态规划(上):只需四步,搞定动态规划算法设计.md
Normal file
@@ -0,0 +1,126 @@
|
||||
<audio id="audio" title="24 | 动态规划(上):只需四步,搞定动态规划算法设计" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/88/199f9e3b22f89bb673dc25a5ed27f288.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课呢,我们学习了递推算法的一般求解步骤:先是定义递推状态,然后推导递推公式,最后是程序设计与实现。并且为了顺利完成递推算法,还介绍了在推导递推公式中的重要指导思想“容斥原理”的相关内容。
|
||||
|
||||
递推算法解决的主要类型问题之一,就是计数类问题。就像上节课我们提到的,求 n 个月以后的小兔子数量,求拼凑钱币的方法总数,还有更早之前学习的,求前 n 个数字二进制表示中 1 的个数,等等,这些都是计数类问题。
|
||||
|
||||
而在递推算法中,还有一类不同于计数类问题,它是求解最优化解的问题的算法,这类算法有一个专有名称,叫做:动态规划。这就是我们今天要学习的,递推算法中的一个子集算法,动态规划算法。
|
||||
|
||||
## 初识:数字三角形问题
|
||||
|
||||
想了解什么是动态规划算法,咱们得先从一个叫做“数字三角形”的简单的动态规划问题开始。数字三角形这个问题很简单,这里我给出了一个由数字组成的 6 层三角形,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/9b/89d94f71545022620d8df9ba2b01e69b.jpg" alt="" title="图1: 数字三角形结构示意图"><br>
|
||||
由上到下,第 i 层由 i 个数字组成,目标从第 1 层开始,每次只能向下走到相邻的两个节点,求走到最后一层路径上面数字的最大和值是多少。就像图中标红的一条线路,就是路径和值最大的一条路线,和值为 39。如果给你的是一个 n 层的数字三角形,你该如何解决这个问题呢?
|
||||
|
||||
从数学归纳法思想出发,如果我们已知到第三层所有点的最大值,那么我们就可以计算得到起始点到第四层每一个的路径最大和值。如图2所示:所有绿色节点和蓝色节点,就是已经求出来的,起始点到其路径最大和值的点。其中的数字是根据 图1 中的数字三角形计算所得,比如第2层的12,是由图1中第1层的3与原所在位置的9,相加之和的结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/7f/6a11950d13803194cb39fba4ea1a057f.jpg" alt="" title="图2: 数学归纳法求解示意图"><br>
|
||||
从图2中可知,如果想求从起始点到红色的点,也就是第 4 行数字 9 点的路径最大和值,那么根据数字三角形的规则,我们只能从图中的两个蓝色点转移到红色点。那究竟选择从哪个点走到红色点呢?当然是选择其中和值较大的了,也就是从和值为 14 的点转移到红色点,得到的就是起始点到红色点的路径最大和值。
|
||||
|
||||
我们来总结一下上述这个过程,若我们已知从起始点到第 i - 1 层上每个点的路径最大和值,那我们又是怎么得到从起始点到第 i 层上每个点的路径最大和值呢?请看下图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/53/3e4f257ec18cda8171ea004c71a1ca53.jpg" alt="" title="图3: 由第 i-1层推导第 i 层示意图"><br>
|
||||
如图所示,我们给每一层的节点,从左向右,从 0 开始依次编号,那么第 i 行的第3个点对应的坐标就是 (i, 2) 点。从第 1 层的点想要到达红色 (i, 2) 点,可以通过(i - 1, 1)点到达,或者通过(i - 1, 2) 点到达。在已知从起始点到第 i-1层上每个点的路径最大和值的前提下,从第 1 层到 (i, 2) 点的最大和值,就是在 (i - 1, 1) 和 (i - 1, 2) 这两个值中,选择一个路径和值最大的,然后转移到 (i, 2) 点,即为第 1 层到 (i, 2) 点的路径最大和值。
|
||||
|
||||
所以,我们基本可以确定一件事情了,如果我们要是知道第 1 层 到 i - 1 层的每个点的路径最大和值,那就很容易求得到第 i 层每个点的路径最大和值,从而推导出 i + 1 层、i + 2 层等等的路径最大和值,直到最后一层。
|
||||
|
||||
又因为,我们已知第 1 层到第 1 层每一个点的路径最大和值,就是起始点原本的值,所以沿着上面这个思路,就可以按照层序,来求解第一层到每一层的每个节点的路径最大和值了。
|
||||
|
||||
仔细体会一下,上面这个题目的推导过程,有没有点儿我们前面说的数学归纳法思想以及递推算法的意思?你会发现,岂止是有点儿,简直如出一辙。这就是我们所说的,递推算法中那类求解最优化问题的方法,动态规划。
|
||||
|
||||
下面我们就正式来介绍一下动态规划问题的求解步骤。
|
||||
|
||||
## 动态规划算法的四步走
|
||||
|
||||
关于动态规划,也被简称为:DP(dynamic programming),它的问题类型非常的庞杂。如果按照问题类型来进行划分,可以分成:线性 DP、区间 DP,树型 DP,数位 DP,概率 DP 等等。说到动态规划中的概念呢,又有什么:最优子结构,重叠子问题,无后效性等等。这些都是让新手听起来特别摸不到头脑的总结性词汇。
|
||||
|
||||
但是你也不用着急犯晕,我们知道,任何总结,都来源于观察。所以今天,我想让你掌握的,不是这些前人总结的词汇概念,而是一套观察、学习动态规划的方法。
|
||||
|
||||
这套方法分为四个步骤,它会使得你学习动态规划算法的过程事半功倍。如果你按照我的方法,进行了若干种动态规划问题学习以后,再找来一些其他资料,看看今天我跟你说的动态规划中的概念名词,你会对动态规划有一个更具体的理解。
|
||||
|
||||
那这个方法到底是哪四个步骤呢?其实就是:**状态定义,状态转移方程,正确性证明,以及程序设计与实现**。同时,它们也分别代表了学会一个动态规划问题的四个方面。
|
||||
|
||||
#### 1.状态定义
|
||||
|
||||
首先我们从状态定义讲起,提到状态定义,你应该不会陌生,上节课我们已经说过递推问题的确定递推状态,其实二者是一样的,都是一个有明确语义信息的数学符号。
|
||||
|
||||
理解一个动态归划问题的状态定义,是理解其解法的第一步,也是最重要的一步。如果你在往下进行推导的时候,发现进行不下去了,那往往就是状态定义有问题,这时你就需要回到这个第一步,琢磨琢磨新的状态定义了。
|
||||
|
||||
并且,我们一直在强调,对于动态规划的状态定义,不仅仅是要一个数学符号,还要一个明确的语义信息,你的理解可能是:不同的语义信息,对应的不就是不同的数学符号么?那今天,我们就用同一个数学符号,表示不同的语义信息,在接下来的求解过程中,你会发现这两种不同的语义信息,所衍生出来的后续步骤过程,是完全不同的。
|
||||
|
||||
回到前面说的数字三角形问题,我们可以作出两种状态定义:
|
||||
|
||||
**第一种状态定义**:dp[i][j] 代表从起始点,到 (i, j) 点的路径最大值。
|
||||
|
||||
**第二种状态定义**:dp[i][j] 代表从底边的某个点出发,到 (i, j) 点的路径最大值。
|
||||
|
||||
为了后续讲解方便,我们假设所有坐标都是从 1 开始的,也就是第一行第一个点的坐标是 (1, 1)。你会发现,这两种状态定义,数学符号都是 dp[i][j],而含义却完全相反,一个是从顶向下走,一个是从底向上走。对于第一种状态定义,如果数字三角形有 n 层的话,问题所求的最大值,就是在最后一层 dp[n] 中的某个值。而第二种状态定义,问题所求的最大值最终会存储在 dp[1][1] 这个状态值中。
|
||||
|
||||
#### 2.状态转移方程
|
||||
|
||||
看完了数字三角形问题的两种状态定义以后,下面就来讲讲状态转移方程。动态规划的状态转移方程,其实就是递推问题中所说的递推公式,只是从名字上更符合动态规划问题的情况。
|
||||
|
||||
状态转移,就是状态之间的转移,每一个状态的含义,在状态定义中规定的明明白白,而状态与状态之间的转移方式,是需要根据具体的问题以及具体的状态定义,进行具体分析。
|
||||
|
||||
根据刚才作的两种状态定义,我们可以分别画出来这样两种状态转移的方向:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ed/2c/edcd31a9dfb27f8deeffe471a5b18b2c.jpg" alt="" title="图4: 两种状态转移示意图"><br>
|
||||
如图所示,我以左边是第一种状态定义下的状态转移方向为例,来说明它是如何转移的。首先,它是自上向下转移的,所以想要求得 dp[i][j] 的值,我们需要知道 dp[i - 1][j - 1] 和 dp[i - 1][j] 的值。因为按照“走向下个相邻两点”的规则,只有(i - 1, j - 1) 和 (i - 1, j) 这两个点,才能能走到 (i, j),也就是我们讲到的转移到 (i, j) 点。右边的第二种状态定义转移过程和左边的一样,只是移动方向不一样而已。
|
||||
|
||||
所以,根据两种状态定义,我们可以分别列出这两种状态转移方程:
|
||||
|
||||
**第一种状态转移方程**:dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + val[i][j]
|
||||
|
||||
**第二种状态转移方程**:dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + val[i][j]
|
||||
|
||||
两种转移方程,都是在能够转移到 (i, j) 点的状态值中选择一个较大值,再加上 (i, j) 原本的数值val[i][j],就是各自起始点到达 (i, j) 点的路径最大值,也就是两种状态定义下的 dp[i][j] 的值。
|
||||
|
||||
到这里,你可以看出,**状态定义不一样,直接导致我们的状态转移方程就不一样。所以,虽然是相同的数学符号,定义的含义不同,就会造成后续的解法不同,同时也意味着解决问题的难度不同。**
|
||||
|
||||
这也就是很多同学在一开始学习动态规划算法的时候,总喊着不明白状态转移方程,而我会告诉他们的是,你不是不明白状态转移方程,你是不明白状态定义。要解决一个动态规划问题,要从状态定义着手,要学习动态规划算法,也要从状态定义开始学起。
|
||||
|
||||
关于状态转移方程这里,我们再来讲一个转移方向的问题。根据数字三角形这个问题的两种状态转移方程,我们可知这代表了两种不同的状态转移方向:第一种是从第一层开始,计算出第二行的所有值,再计算出第三行所有值;而第二种状态转移方向与第一种正好相反。这里我们就要引出动态规划算法中一个最重要的概念“阶段”。
|
||||
|
||||
什么是“阶段”呢,可以这样说,状态转移就是从一个阶段转移到下一个阶段。像数字三角形问题中,在第一种转移方式中,起始点的第一层,就是整个转移的第一个阶段,第二层就是整个转移的第二个“阶段”,你会发现转移的时候,只有一个阶段计算完了,才能计算下一个阶段中的状态值。
|
||||
|
||||
而在第二种转移方式中,作为起始点的最后一层,才是我们转移的第一个阶段,然后依次由下向上转移,一个阶段接着下一个阶段。
|
||||
|
||||
弄清什么是阶段,对于接下来我们证明算法的正确性,有决定性作用。
|
||||
|
||||
#### 3. 正确性证明
|
||||
|
||||
动态规划算法的第三步,就是证明你推导出的状态转移方程的正确性。关于状态转移方程的正确性证明,借助的就是之前学习中,我们提到过的程序设计中最重要的数学思维:数学归纳法。
|
||||
|
||||
根据数学归纳法的三步走,我们试着证明一下第一种状态转移方程是正确的,也就是自上而下的状态转移方式。
|
||||
|
||||
第一步,我们已知在这种状态转移方式中,第一个阶段中的所有 dp 值都可以轻松获得,也就是可以很轻松的初始化 dp[1][1] 的值,应该等于 val[1][1] 的值。
|
||||
|
||||
第二步,我们假设如果第 i-1 阶段中的所有状态值,我们都正确的得到了。也就是正确的得到了从起始点到 i-1 层中每个点的路径最大和值。那根据状态转移方程:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j + 1]) + val[i][j] 来说,就可以正确的计算得到第 i 个阶段中的所有状态值。
|
||||
|
||||
第三步,两步联立,就可得出结论,所有阶段中的状态值计算均正确。那么,从起始点到底边的路径最大和值,就在最后一个阶段的若干个状态值中。
|
||||
|
||||
以上就是我们使用数学归纳法,证明数字三角形问题的第一种状态转移方程正确性的过程。这个过程呢,比较简单,那是因为数字三角形问题本身就不难。当面对更难一些的动态规划问题的时候,将这种证明方法,加入到你学习动态规划算法的过程中,你会收获奇效的。
|
||||
|
||||
#### 4. 程序设计与实现
|
||||
|
||||
动态规划解题的最后一步,就是程序的设计与实现了。关于数字三角形问题的两种解题方法的代码实现,就作为今天给你留的课后作业题了。
|
||||
|
||||
在上一篇递推算法的作业题中,你应该体会到了,对于同样的递推公式,我们不仅可以用循环实现,还可以用递归实现。今天的这两种状态定义方法呢,我只要求你用循环的程序方式实现即可。
|
||||
|
||||
当然,我还希望,在你实现出了这两种状态定义方法的程序以后,可以从程序的角度,对两种方法加以评价,并在留言区说说它们的优点和缺点。
|
||||
|
||||
## 课程小结
|
||||
|
||||
至此,我们就说完了动态规划算法的完整解题步骤,关于今天的课程呢,希望你记住如下几点:
|
||||
|
||||
1. 状态定义,是动态规划算法的重点,无论是解题还是学习,都要从这一步开始。
|
||||
1. 不同的状态定义,决定了不同的状态转移方程,同时也可能代表了不同的解题难度,所以,学习如何定义优秀的状态很重要。
|
||||
1. 动态规划中的状态转移顺序,是建立在“阶段”概念之上的,只有本阶段的状态值计算完了,下一个阶段的状态值才能得以计算。
|
||||
1. 数学归纳法,是证明动态规划状态转移方程正确性的利器,掌握了它,会让你的动态规划学习过程事半功倍!
|
||||
|
||||
好了,关于动态规划算法,今天我们就先讲到这里。下一期我们将会使用这两期文章中学习到的技巧,来学习一个稍微有点儿难度的动态规划问题,也算是对我们近期学习效果的一个验证。
|
||||
|
||||
再好好看看这两期的内容吧,我是胡光,你要准备好,我们下期见。
|
174
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/25 | 动态规划(下):背包问题与动态规划算法优化.md
Normal file
174
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/25 | 动态规划(下):背包问题与动态规划算法优化.md
Normal file
@@ -0,0 +1,174 @@
|
||||
<audio id="audio" title="25 | 动态规划(下):背包问题与动态规划算法优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/f9/4833ba6b3a0bad6d4e115043151f31f9.mp3"></audio>
|
||||
|
||||
你好,我是胡光,欢迎回来。
|
||||
|
||||
上节课呢,我们学习了动态规划算法的一般求解步骤:状态定义,推导状态转移方程、正确性证明,以及程序设计与实现。在这个过程中,我们又一次用到了之前我们讲的重要数学思维:数学归纳法 。
|
||||
|
||||
今天这节课,将是我们“算法数据结构篇”的最后一篇,其实从这个章节开始,我都在试图用我的语言和有序的课程设计,让你感受算法数据结构在思维层面的魅力。我还希望,你能通过这部分知识的学习,能对算法和数据结构产生兴趣,并且消除对算法学习的畏难心理。
|
||||
|
||||
好了,进入正题,今天我将借由动态规划算法,向你展示算法中追求极致的那一部分基因:算法优化。
|
||||
|
||||
## 初识:0/1 背包问题
|
||||
|
||||
想要感受算法优化,我们先从一类经典的动态规划问题,背包类问题开始。
|
||||
|
||||
简单的背包类问题,可以分成三类:0/1背包问题,完全背包问题与多重背包问题。我们今天要讲的就是 0/1背包与多重背包这两个问题。
|
||||
|
||||
0/1背包问题可以说是所有背包问题的基础,它描述的场景是这样的:假设你有一个背包,载重上限是 W,你面前有 n 个物品,第 i 个物品的重量是 w<sub>i</sub>,价值是 v<sub>i</sub>,那么,在不超过背包重量上限的前提下,你能获得的最大物品价值总和是多少?
|
||||
|
||||
按照动态规划问题的四步走,咱们来分析一下这个问题。
|
||||
|
||||
#### 1. 状态定义
|
||||
|
||||
关于状态定义,我们首先来分析0/1背包问题中的**自变量**和**因变量**。
|
||||
|
||||
因变量比较好确定,就是问题中所求的最大价值总和。自变量呢?经过分析你会发现,物品种类和背包承重上限就是自变量,因为它们都能够影响价值总和的最大值。这样我们就可以设置一个二维的状态,状态定义如下:
|
||||
|
||||
**0/1背包状态定义**:dp[i][j] 代表使用前 i 个物品,背包最大载重为 j 的情况下的最大价值总和。
|
||||
|
||||
#### 2. 推导状态转移方程
|
||||
|
||||
推导状态转移方程,也就是推导 dp[i][j] 的表达式。根据 dp[i][j] 的含义,我们可以将 dp[i][j] 可能达到最大值时的方案分成两类:一类是方案中不选择第 i 个物品的最大价值和,另一类是方案中选择了第 i 个物品的最大价值和。只需要在这两类方案的最大值中,选择一个价值和较大的方案,转移到 dp[i][j] 即可。下面,我们就分别表示一下这两种方案的公式。
|
||||
|
||||
不选择第 i 个物品的最大价值和,就是 dp[i - 1][j]。也就是说,在背包最大载重为 j 的情况下,前 i 个物品中,不选择第 i 个物品的最大价值和,就等于在前 i - 1 个物品中选择的最大价值和。
|
||||
|
||||
选择第 i 个物品的最大价值和就是 dp[i - 1][j - w<sub>i</sub>] + v<sub>i</sub>。关于这个公式的理解,可以参考我们前面讲的凑钱币问题,既然要求一定选择了第 i 个物品,那我们就可以先给第 i 个物品预留出来一个位置,然后给剩余的 i - 1 个物品留的载重空间就只剩下 j - w<sub>i</sub> 了,那么 i - 1 个物品选择的最大价值和是 dp[i - 1][j - w<sub>i</sub>],再加上 v<sub>i</sub> 就是选择第 i 个物品时,我们能够获得最大价值和。
|
||||
|
||||
最终,我们得到 dp[i][j] 的状态转移方程,如下所示:
|
||||
|
||||
```
|
||||
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
|
||||
|
||||
```
|
||||
|
||||
#### 3. 正确性证明
|
||||
|
||||
动规算法的正确性证明,还是需要依赖于数学归纳法,下面我们开始数学归纳法的三步走。
|
||||
|
||||
首先,dp[0][j] = 0,就是当没有物品的时候,无论背包限重是多少,能得到的最大价值和都是0,这也就是已知 k<sub>0</sub> 正确。
|
||||
|
||||
其次,假设我们已经正确计算得到了,在 i - 1 个物品的任意一种背包容量下的价值最大和值,也就是所有 dp[i - 1] 中的值。那么根据状态转移方程,我们也肯定可以正确的得到所有 dp[i] 中的值。
|
||||
|
||||
最后两步联立,整个求解过程对于任意 dp[i][j],均正确。
|
||||
|
||||
请你认真理解这个证明过程,因为接下来的程序处理过程,其实和这个证明过程是一致的。
|
||||
|
||||
#### 4. 程序设计与实现
|
||||
|
||||
完成了关于0/1背包问题的求解过程后,最后我们来看看程序的设计与实现。下面呢,是我给出的一段参考代码:
|
||||
|
||||
```
|
||||
#define MAX_N 100
|
||||
#define MAX_V 10000
|
||||
int v[MAX_N + 5], w[MAX_N + 5];
|
||||
int dp[MAX_N + 5][MAX_V + 5];
|
||||
|
||||
int get_dp(int n, int W) {
|
||||
// 初始化 dp[0] 阶段
|
||||
for (int i = 0; i <= W; i++) dp[0][i] = 0;
|
||||
// 假设 dp[i - 1] 成立,计算得到 dp[i]
|
||||
// 状态转移过程,i 代表物品,j 代表背包限重
|
||||
for (int i = 1; i <= n; i++) {
|
||||
for (int j = 0; j <= W; j++) {
|
||||
// 不选择第 i 种物品时的最大值
|
||||
dp[i][j] = dp[i - 1][j];
|
||||
// 与选择第 i 种物品的最大值作比较,并更新
|
||||
if (j >= w[i] && dp[i][j] < dp[i - 1][j - w[i]] + v[i]) {
|
||||
dp[i][j] = dp[i - 1][j - w[i]] + v[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp[n][W];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,get_dp 函数就是求解0/1背包问题的过程,函数传入两个整形参数 n 和 W,分别代表了物品数量与背包最大限重。程序中有三个数组:v、w 与 dp,v[i] 代表第 i 个物品的价值,w[i]代表第 i 个物品的重量,dp[i][j] 代表背包问题相关的状态。
|
||||
|
||||
这一段代码,采用了正向递推的程序实现。而且,如果你注意观察 get_dp 函数的实现过程,你会惊奇地发现,这就是数学归纳法的证明过程。
|
||||
|
||||
首先,初始化 dp[0] 阶段的所有值,也就是保证了 k<sub>0</sub> 成立;然后从 dp[1] 开始迭代计算到 dp[n] 中所有值,每一次 dp[i]依赖的就是 dp[i - 1] 中的值,只有 dp[i - 1] 中所有值是正确的,才能保证 dp[i]中所有值是正确的,这就是数学归纳法的第二步。最后,两步联立,就证明了以 dp 数组的第一维作为阶段,进行状态转移,计算得到的所有 dp 值均是正确的。
|
||||
|
||||
面对这种更加广义的数学归纳法,我们也有一个名称,叫做“**结构归纳法**”。
|
||||
|
||||
## 进阶:多重背包问题
|
||||
|
||||
接下来我们提高一下问题的复杂度,说说多重背包问题。
|
||||
|
||||
其实这个问题,整体和0/1背包问题类似,只不过从 n 个物品变成了 n 种物品,且每种物品都有不同的数量,我们可以设定第 i 种物品的数量是 c<sub>i</sub>。
|
||||
|
||||
现在你有一个载重上限为 15kg 的背包,有如下 4 件物品:
|
||||
|
||||
- 镀金极客币,每个4kg,每个价值 10 块钱,一共有 5 个;
|
||||
- 胡船长手办,每个3kg ,每个价值 7 块钱,一共有 4 个;
|
||||
- 西瓜,每个12kg ,每个价值 12 块钱,一共有 2 个;
|
||||
- 哈密瓜,每个9kg ,每个价值 8 块钱,一共有 7 个。
|
||||
|
||||
经过分析,在不超过背包载重上限的情况下,你可以选择3个镀金极客币和1个胡船长手办装到背包里面,这种选择方案能获得最大价值为:37 块钱。
|
||||
|
||||
回到我们说的这个多重背包问题,你想如何求解呢?
|
||||
|
||||
其实最简单的解决办法,就是把 n 种物品中的每一个,都看成是0/1背包中的一个物品,然后按照 0/1背包问题的求解过程来做即可。这也就是说,如果一种物品有 12 件,就相当于0/1背包中多了 12 件物品,我们就多做 12 轮运算,要是有 120 件呢,那就是多做 120 轮运算。
|
||||
|
||||
这种做法虽然可行,可显然太浪费我们计算机的计算资源了。下面就让我们看看怎么做,才能更优化。
|
||||
|
||||
#### 1. 二进制拆分法
|
||||
|
||||
我们先来想象另外一个场景,假设你是一个卖白菜的老农,手上有23斤白菜和若干个筐,出于某种不知名的原因,你今天不能把称重器带到菜市场,只能提前把白菜称好装入不同的筐里贩卖给顾客。问题来了,白菜要如何分到这些筐里面,才能使得第一个顾客无论要多少斤白菜,你都能通过挑选其中的几筐白菜,从而满足顾客的需求呢?
|
||||
|
||||
一种最直接的装筐方法,就是每个筐里面装 1 斤白菜,共需要23个筐。这样,第一个顾客要多少斤白菜,你就给他多少筐就行。这种方法简单粗暴,可是用的筐太多了。
|
||||
|
||||
我们转换一个思路去想这件事:当你准备挑几个筐满足第一个顾客需求的时候,对于每个筐来说,都有两种状态,选或者不选,这不就是二进制每位上的数字么?我们就可以把每个筐,看成是二进制相应的位权。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/a2/d0d279b5a1b56347dede04e3007c32a2.jpg" alt="" title="图1: 二进制拆分法分白菜"><br>
|
||||
可以看到,从第一个筐开始,我们依次装上 1 斤、2 斤、4斤、8 斤,第五个筐应该装16斤的,可剩下的白菜不够 16 斤,所以就一起放到最后一个筐里面。这样,我们只需要 5 个筐,就装了 23 斤白菜,并且可以保证无论第一个客人要几斤白菜,都能满足他的需求。
|
||||
|
||||
以上,就是我讲的二进制拆分法。
|
||||
|
||||
#### 2. 多重背包的拆分优化
|
||||
|
||||
看完二进制拆分法以后,我们再看看多重背包转0/1背包的这种解题思路有什么问题。
|
||||
|
||||
假设多重背包中,某一种物品有 23 件,转换到0/1背包问题中,就是 23 个物品,就跟前面一斤白菜装一筐的做法是一样的。我们虽然不知道,在0/1背包问题的最优方案中,这种物品被具体选择了多少件,可是只要我们通过一种合理的拆分方法,使得无论最优方案中选择了多少件这种商品,我们都可以组合出来。
|
||||
|
||||
简单粗暴地拆分成 23 份,是一种拆分方法,而二进制拆分法也是一种拆分方法,并且二进制拆分法只需要拆成 5 份物品,作为0/1背包问题中的 5 个单独的物品即可,这么做可以达到和拆分成 23 件物品等价的效果,并且节省了大量的计算资源。
|
||||
|
||||
例如,前面多重背包问题的那个例子中,按照原本简单粗暴的方式,我们是把 5 个镀金极客币、4 个胡船长手办、2 个西瓜、7 个哈密瓜,当作 18 个物品的0/1背包问题来求解的。但如果采用二进制拆分法,我们就会得到如下拆分方案:
|
||||
|
||||
```
|
||||
1个镀金极客币,4kg每个,价值 10 块钱
|
||||
2个镀金极客币,8kg每个,价值 20 块钱
|
||||
2个镀金极客币,8kg每个,价值 20 块钱
|
||||
|
||||
1个胡船长手办,3kg每个,价值 7 块钱
|
||||
2个胡船长手办,6kg每个,价值 14 块钱
|
||||
1个胡船长手办,3kg每个,价值 7 块钱
|
||||
|
||||
1个西瓜,12kg每个,价值 12 块钱
|
||||
1个西瓜,12kg每个,价值 12 块钱
|
||||
|
||||
1个哈密瓜,9kg每个,价值 8 块钱
|
||||
2个哈密瓜,18kg每个,价值 16 块钱
|
||||
4个哈密瓜,36kg每个,价值 32
|
||||
|
||||
```
|
||||
|
||||
这种拆分方案等价于求解 11 个物品的0/1背包问题,比之前求解的18个物品的 0/1背包问题显然要优秀。
|
||||
|
||||
实际上,随着某个物品数量的增加,二进制拆分法的优势会愈加地明显。想一想 32 个二进制位能表示的数字大小,你就明白了。
|
||||
|
||||
#### 3. 程序设计与实现
|
||||
|
||||
关于多重背包优化版本的程序,给你留作一个作业题,请你根据本节所讲的内容,用二进制拆分法实现多重背包的优化程序。相信你没问题的!
|
||||
|
||||
## 课程小结
|
||||
|
||||
按照惯例,最后我来做一下这节课的知识点总结:
|
||||
|
||||
1. 0/1背包问题中的自变量是物品的种类和背包限重,所以我们把这两维设计到了状态定义中。
|
||||
1. 多重背包问题可以转换成 0/1背包进行求解,转换过程不同,效率也就不同。
|
||||
1. 二进制拆分法,本质思想就是二进制的数字表示法,0/1 表示两种状态,表示选或不选。
|
||||
|
||||
好了,“算法数据结构篇”到这里就结束了,日后若有机会,我希望跟你分享更多编程中的美妙思维过程。
|
||||
|
||||
我是胡光,我们最后一章见。
|
176
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/做好闭环(四):二分答案算法的代码统一结构.md
Normal file
176
极客时间专栏/人人都能学会的编程入门课/算法数据结构篇/做好闭环(四):二分答案算法的代码统一结构.md
Normal file
@@ -0,0 +1,176 @@
|
||||
<audio id="audio" title="做好闭环(四):二分答案算法的代码统一结构" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/7b/8012607ab799fc4c9cfb85d7c508597b.mp3"></audio>
|
||||
|
||||
你好,我是胡光。
|
||||
|
||||
不知不觉,我们已经讲完了“算法数据结构篇”的全部内容。说是“讲完”,其实更意味着你的算法数据结构的学习之路才刚刚开始,因为编程的核心与灵魂就是算法和数据结构。但这毕竟是一个入门课,所以,整个这部分的内容,我更多是侧重说说那些你可能比较陌生的,且有趣的思维与结构。
|
||||
|
||||
我希望通过这个过程,能够激起你对于算法数据结构的学习热情。时至今日,我相信你应该更能深刻地理解我在开篇词里说到的,“学编程,不等于学语言“这句话的含义。
|
||||
|
||||
我也非常高兴,看到很多同学都在紧跟着专栏更新节奏,坚持学习。经常在专栏上线的第一时间,这些同学就给我留言,提出自己的疑惑。大部分留言,我都在相对应的课程中回复过了,而对于每节课中的思考题呢,由于要给你充足的思考时间,所以我选择在今天这样一节课中,给你进行一一的解答。
|
||||
|
||||
看一看我的参考答案,和你的思考结果之间,有什么不同吧。也欢迎你在留言区中,给出一些你感兴趣的题目的思考结果,我希望我们能在这个过程中,碰撞出更多智慧的火花。
|
||||
|
||||
## [重新认识数据结构(上):初识链表结构](https://time.geekbang.org/column/article/205583)
|
||||
|
||||
在这一节里,我们学习了基本的链表结构,并且演示了链表结构的插入操作。最后呢,给你留了一个题目,就是实现链表的删除操作。留言区中很多人实现的代码,我也都看过了,总的来说,很多用户对“虚拟头结点”的掌握还是很不错的,下面是我给出的参考代码:
|
||||
|
||||
```
|
||||
struct Node *erase(strcut Node *head, int ind) {
|
||||
struct Node ret, *p = &ret, *q;
|
||||
ret.next = head;
|
||||
while (ind--) p = p->next;
|
||||
q = p->next;
|
||||
p->next = p->next->next;
|
||||
return ret.next;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于删除操作,有可能删除的是 head 所指向链表的头结点,所以代码中使用了虚拟头结点的技巧来实现。其中,细心的你可能会发现一个致命的问题:删除节点的操作中,我们只是改变了链表节点的指向关系,跳过了那个待删除节点的位置,那原先那个待删除节点呢?这个节点的空间呢?
|
||||
|
||||
这就涉及到操作系统中的内存管理相关的知识了,由于这里不影响编程逻辑的理解,所以,我们就不展开说了。如果你感兴趣,可以自行搜索:内存泄漏、malloc、free 等关键字进行学习。
|
||||
|
||||
## [重新认识数据结构(下):有趣的 “链表” 思维](https://time.geekbang.org/column/article/206200)
|
||||
|
||||
这一节是上一节链表知识的升华,我们将一个快乐数序列,在思维层面映射成了链表结构,之后就将快乐数的判定问题,很自然的转换成了链表判环问题,算是彻彻底底的体验了一把链表思维。最后呢,我留了两个思考题,下面我给你一一解答。
|
||||
|
||||
#### 1. 计算环的长度
|
||||
|
||||
第一个问题,如果链表中有环的话,那么这个环的长度是多少?这个问题比较简单,我看到留言区中很多用户都能自行想出来,在这里我就简单说一说。
|
||||
|
||||
我们可以肯定,如果链表中有环,那么采用快慢指针的方法,两个指针一定会在环中相遇。此时,可以让其中一个指针不动,另外一个指针再沿着环走一圈,直到两个指针再次相遇,这样,就能得到环的长度了。
|
||||
|
||||
#### 2. 找到环的起始位置
|
||||
|
||||
第二个问题,如果链表中有环,请求出环的起始点。如下图所示,环的起始点为 3 号点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/74/caf1c157963e86d929d28b9d6b747674.jpg" alt="" title="图1: 链表成环示意图"><br>
|
||||
这里呢,我将用图跟你感性地解答这个问题,请你注意,以下我所要讲的不是一个严格的证明过程,如果想要更准确地理解这个问题,你可以试着借助“同余式”来理解。下面,就开始我们的非严谨图例演示。
|
||||
|
||||
首先,假设从链表起始点到环的起点距离为 x,那么当快慢指针中的慢指针 p 刚刚走到环的起始点位置的时候,q 指针应该在环内部距离环起始点 x 的位置上,如图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e6/e6/e60d36ddb8d437c776d73e54e01252e6.jpg" alt="" title="图2: p 节点刚刚进入环时刻"><br>
|
||||
图中,q 指针距离环起始点 x 步,q 指针沿着链表向前走 y 步,就又可以到达环的起始点位置,如图所示 x + y 等于环长。也就是说,q 指针想要遇到 p 指针,就必须要追上 y 步的距离,又因为 p 指针每次走 1 步,q 指针每轮走 2 步,所以 q 指针每轮追上1步,也就是说,从此刻开始,当 q 指针追上 p 指针的时候,p 指针正好向前走了y 步,如图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/56/23c57e116b438cb6774f8420e1d86156.jpg" alt="" title="图3: p、q环中相遇时刻"><br>
|
||||
此时,你会发现 p 点在环中走了 y 步以后,p 和 q 相遇了,也就意味着 p 点再走 x 步就到环的起始点了。而恰巧,从链表头结点开始到环的起始点也是 x 步,所以此时只需要让 p 站在相遇点,q 指针回到链表的起始点,然后两个指针以相同的速度,一起往后走,直到二者再次相遇的时候,相遇点就是环的起始点了。
|
||||
|
||||
至此,我们就看完了求解环起始点位置的方法,至于代码么,就不是难题了,你可以自行发挥了。
|
||||
|
||||
## [二分查找:提升程序的查找效率](https://time.geekbang.org/column/article/206937)
|
||||
|
||||
这一节中呢,我们学习了简单的二分查找算法,由此我们引申出了二分答案的相关算法。二分答案算法的应用场景呢,就是有一个函数 f(x) = y,如果它具有单调性,并且通过 x 求 y 很好求,而通过 y 确定 x 就很麻烦,这时,二分答案算法就该登场了。
|
||||
|
||||
最后的思考题中呢,是一道通过税后工资,计算税前工资的题目。我们可以发现,根据个人所得税缴纳的规则,肯定是税前工资越高,税后工资就越高,所以我们把税前工资 x 和税后工资 y 之间,看成是一个映射关系 f 的话,那么 f(x) = y 的函数,就是单调函数。
|
||||
|
||||
而这个 f 函数呢,我们也可以看到,通过税前工资 x 确定税后工资 y 的过程很简单,而通过税后工资 y 计算税前工资 x 的过程就不那么容易了。因此,我们当然要搬出二分答案算法,来解决这个问题了。下面是我给出的示例代码:
|
||||
|
||||
```
|
||||
#define EPS 1e-7
|
||||
#define min(a, b) ((a) < (b) ? (a) : (b))
|
||||
|
||||
double f(double x) {
|
||||
double xx = min(3000, x) * 0.03;
|
||||
if (x > 3000) {
|
||||
xx += (min(12000, x) - 3000) * 0.1;
|
||||
}
|
||||
if (x > 12000) {
|
||||
xx += (min(25000, x) - 12000) * 0.2;
|
||||
}
|
||||
if (xx > 25000) {
|
||||
xx += (min(35000, x) - 25000) * 0.25;
|
||||
}
|
||||
return x - xx;
|
||||
}
|
||||
|
||||
double binary_search(double l, double r, double y) {
|
||||
if (r - l <= EPS) return l;
|
||||
double mid = (l + r) / 2.0;
|
||||
if (f(mid) < y) return binary_search(mid, r, y);
|
||||
return binary_search(l, mid, y);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你会发现,代码中的 binary_search 函数,和我们那一讲中所给的切绳子问题的代码几乎一模一样,唯一不同的就是 f 函数换了样子。
|
||||
|
||||
其实对于二分答案的算法实现,代码真的不是什么难点,难点在于发现问题可以采用二分算法的过程。也就是看到那两条性质判断:f(x)=y是不是具有单调性;是不是通过x求y比较容易,通过y求x比较困难。
|
||||
|
||||
## [栈与单调栈:最大矩形面积](https://time.geekbang.org/column/article/209084)
|
||||
|
||||
本节呢,我们学习了栈和单调栈的基本知识,并且知道了单调栈是用来维护最近大于或小于关系的数据结构。最后的思考题呢,是判断一个括号序列是否是合法的,所谓合法的括号序列,也就是括号之间要么是完全包含,要么是并列无关。
|
||||
|
||||
根据栈的基础知识,如果我们把一个元素入栈动作看成是左括号,出栈看成是对应的右括号,那么一组元素的入栈及出栈操作,就可以唯一对应到一个合法的括号序列。例如,如下操作序列:
|
||||
|
||||
```
|
||||
1 2 3 4 5 6 7 8 9 10
|
||||
push push pop pop push push pop push pop pop
|
||||
|
||||
```
|
||||
|
||||
其中 push 是入栈操作,pop 是出栈操作。显然,3号的 pop 操作,弹出的应该是 2 号push 进去的元素,也就是 2 号和 3 号操作是一对操作。那么把 push 写成左括号,pop 写成右括号,如上操作序列,就可以对应如下的括号序列:
|
||||
|
||||
```
|
||||
【()】{【】【】}
|
||||
|
||||
```
|
||||
|
||||
你会发现,相对应的左右括号,就对应了相匹配的 push 和 pop 操作。那么判断一个括号序列是否合法,就可以把这个括号序列看成是一组入栈和出栈操作。
|
||||
|
||||
我们依次处理括号序列中的每一位,碰到左括号就入栈;碰到右括号,我们就弹出栈顶的一个左括号,看看是否和当前右括号是匹配的,如果不匹配就说明括号序列非法,如果匹配,就继续处理下一个括号序列中的字符。直到最后,如果栈中为空,就说明原括号序列合法。
|
||||
|
||||
好了,至此我们就讲完了这道题目的解题思路,接下来就是请你把我说的解题思路,转换成代码了,加油!如果实在想不出来,也可以参考用户 @胖胖胖 、@Hunter Liu 在留言区中的代码和解题思路。
|
||||
|
||||
## [动态规划(下):动态规划之背包问题与优化](https://time.geekbang.org/column/article/211549)
|
||||
|
||||
在这一节课,我们认识了背包类动态规划算法,讲了0/1背包问题,以及多重背包问题转0/1背包问题的转换技巧。其中我们提到了用二进制拆分法对多重背包拆分过程进行优化,这样不但可以大大减少拆分出来的物品数量,并且还不影响转换成等价的 0/1背包问题。
|
||||
|
||||
关于动态规划状态定义的相关理解,这里给用户 @徐洲更 点赞,大家可以在[《动态规划(上):只需四步,搞定动态规划算法设计》](https://time.geekbang.org/column/article/210842)当中看到他的留言。
|
||||
|
||||
下面呢,我就给出多重背包转 0/1背包的示例代码:
|
||||
|
||||
```
|
||||
#define MAX_N 100
|
||||
#define MAX_W 10000
|
||||
int v[MAX_N + 5], w[MAX_N + 5], c[MAX_N + 5];
|
||||
int v1[MAX_N + 5], w1[MAX_N + 5], n2 = 0;
|
||||
int dp[MAX_N + 5][MAX_W + 5];
|
||||
|
||||
// 添加一个0/1背包中的物品
|
||||
void add_item(int v_value, int w_value) {
|
||||
n2++;
|
||||
v1[n2] = v_value;
|
||||
w1[n2] = w_value;
|
||||
return ;
|
||||
}
|
||||
|
||||
int get_dp(int n, int W) {
|
||||
// 对多重背包中的每一种物品进行拆分
|
||||
for (int i = 1; i <= n; i++) {
|
||||
// 采用二进制拆分法
|
||||
for (int k = 1; k <= c[i]; c[i] -= k, k <<= 1) {
|
||||
add_item(k * v[i], k * w[i]);
|
||||
}
|
||||
if (c[i]) add_item(c[i] * v[i], c[i] * w[i]);
|
||||
}
|
||||
// 按照0/1背包的方式进行求解
|
||||
for (int i = 1; i <= n2; i++) {
|
||||
for (int j = 1; j <= W; j++) {
|
||||
dp[i][j] = dp[i - 1][j];
|
||||
if (j < w1[i]) continue;
|
||||
if (dp[i - 1][j - w1[i]] + v1[i] < dp[i][j]) continue;
|
||||
dp[i][j] = dp[i - 1][j - w1[i]] + v1[i];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
代码中,v、w、c 数组存储的是多重背包中第 i 种物品的价值、重量和数量,v1、w1 数组用来存储拆分出来的0/1背包中的物品价值和重量信息,get_dp 函数就是求解多重背包问题的过程。
|
||||
|
||||
其中,分成两步进行求解,首先对多重背包中的每种物品,按照二进制拆分法,打包拆分成0/1背包中的若干个物品。拆分完成后,再按照0/1背包的算法流程进行求解,需要注意的是,代码中的循环变量 k,枚举的就是拆分的每一堆的物品数量,从数量 1 开始,每次扩大一倍。
|
||||
|
||||
对于多重背包中的每种物品,经过二进制拆分以后,最后剩下的几个,要单独算作一个物品,这就是代码第 22 行的含义。理解了二进制拆分的过程以后,后面的0/1背包的求解过程,就不需要我来解释了,都是老生常谈了。
|
||||
|
||||
好了,今天的思考题答疑就结束了,如果你还有什么不清楚,或者有更好的想法,欢迎告诉我,我们留言区见!
|
Reference in New Issue
Block a user