This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
<audio id="audio" title="05 | 数组为什么很多编程语言中数组都从0开始编号" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/c7/a15bfde0d0e1ef172f24ba1928bfb3c7.mp3"></audio>
提到数组,我想你肯定不陌生,甚至还会自信地说,它很简单啊。
是的,在每一种编程语言中,基本都会有数组这种数据类型。不过,它不仅仅是一种编程语言中的数据类型,还是一种最基础的数据结构。尽管数组看起来非常基础、简单,但是我估计很多人都并没有理解这个基础数据结构的精髓。
在大部分编程语言中数组都是从0开始编号的但你是否下意识地想过**为什么数组要从0开始编号而不是从1开始呢** 从1开始不是更符合人类的思维习惯吗
你可以带着这个问题来学习接下来的内容。
## 如何实现随机访问?
什么是数组?我估计你心中已经有了答案。不过,我还是想用专业的话来给你做下解释。**数组Array是一种线性表数据结构。它用一组连续的内存空间来存储一组具有相同类型的数据。**
这个定义里有几个关键词,理解了这几个关键词,我想你就能彻底掌握数组的概念了。下面就从我的角度分别给你“点拨”一下。
第一是**线性表**Linear List。顾名思义线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组链表、队列、栈等也是线性表结构。
<img src="https://static001.geekbang.org/resource/image/b6/77/b6b71ec46935130dff5c4b62cf273477.jpg" alt="">
而与它相对立的概念是**非线性表**,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系。
<img src="https://static001.geekbang.org/resource/image/6e/69/6ebf42641b5f98f912d36f6bf86f6569.jpg" alt="">
第二个是**连续的内存空间和相同类型的数据**。正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作。
说到数据的访问,那你知道数组是如何实现根据下标随机访问数组元素的吗?
我们拿一个长度为10的int类型的数组int[] a = new int[10]来举例。在我画的这个图中计算机给数组a[10]分配了一块连续内存空间10001039其中内存块的首地址为base_address = 1000。
<img src="https://static001.geekbang.org/resource/image/98/c4/98df8e702b14096e7ee4a5141260cdc4.jpg" alt="">
我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:
```
a[i]_address = base_address + i * data_type_size
```
其中data_type_size表示数组中每个元素的大小。我们举的这个例子里数组中存储的是int类型数据所以data_type_size就为4个字节。这个公式非常简单我就不多做解释了。
这里我要特别纠正一个“错误”。我在面试的时候常常会问数组和链表的区别很多人都回答说“链表适合插入、删除时间复杂度O(1)数组适合查找查找时间复杂度为O(1)”。
实际上这种表述是不准确的。数组是适合查找操作但是查找的时间复杂度并不为O(1)。即便是排好序的数组你用二分查找时间复杂度也是O(logn)。所以正确的表述应该是数组支持随机访问根据下标随机访问的时间复杂度为O(1)。
## 低效的“插入”和“删除”
前面概念部分我们提到,数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。现在我们就来详细说一下,究竟为什么会导致低效?又有哪些改进方法呢?
我们先来看**插入操作**。
假设数组的长度为n现在如果我们需要将一个数据插入到数组中的第k个位置。为了把第k个位置腾出来给新来的数据我们需要将第kn这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢你可以自己先试着分析一下。
如果在数组的末尾插入元素那就不需要移动数据了这时的时间复杂度为O(1)。但如果在数组的开头插入元素那所有的数据都需要依次往后移动一位所以最坏时间复杂度是O(n)。 因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为(1+2+...n)/n=O(n)。
如果数组中的数据是有序的我们在某个位置插入一个新的元素时就必须按照刚才的方法搬移k之后的数据。但是如果数组中存储的数据并没有任何规律数组只是被当作一个存储数据的集合。在这种情况下如果要将某个数据插入到第k个位置为了避免大规模的数据搬移我们还有一个简单的办法就是直接将第k位的数据搬移到数组元素的最后把新的元素直接放入第k个位置。
为了更好地理解我们举一个例子。假设数组a[10]中存储了如下5个元素abcde。
我们现在需要将元素x插入到第3个位置。我们只需要将c放入到a[5]将a[2]赋值为x即可。最后数组中的元素如下 abxdec。
<img src="https://static001.geekbang.org/resource/image/3f/dc/3f70b4ad9069ec568a2caaddc231b7dc.jpg" alt="">
利用这种处理技巧在特定场景下在第k个位置插入一个元素的时间复杂度就会降为O(1)。这个处理思想在快排中也会用到,我会在排序那一节具体来讲,这里就说到这儿。
我们再来看**删除操作**。
跟插入数据类似如果我们要删除第k个位置的数据为了内存的连续性也需要搬移数据不然中间就会出现空洞内存就不连续了。
和插入类似如果删除数组末尾的数据则最好情况时间复杂度为O(1)如果删除开头的数据则最坏情况时间复杂度为O(n)平均情况时间复杂度也为O(n)。
实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。如果我们将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?
我们继续来看例子。数组a[10]中存储了8个元素abcdefgh。现在我们要依次删除abc三个元素。
<img src="https://static001.geekbang.org/resource/image/b6/e5/b69b8c5dbf6248649ddab7d3e7cfd7e5.jpg" alt="">
为了避免defgh这几个数据会被搬移三次我们可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据只是记录数据已经被删除。当数组没有更多空间存储数据时我们再触发执行一次真正的删除操作这样就大大减少了删除操作导致的数据搬移。
如果你了解JVM你会发现这不就是JVM标记清除垃圾回收算法的核心思想吗没错数据结构和算法的魅力就在于此**很多时候我们并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的**。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。
## 警惕数组的访问越界问题
了解了数组的几个基本操作后,我们来聊聊数组访问越界的问题。
首先我请你来分析一下这段C语言代码的运行结果
```
int main(int argc, char* argv[]){
int i = 0;
int arr[3] = {0};
for(; i&lt;=3; i++){
arr[i] = 0;
printf(&quot;hello world\n&quot;);
}
return 0;
}
```
你发现问题了吗这段代码的运行结果并非是打印三行“hello word”而是会无限打印“hello world”这是为什么呢
因为数组大小为3a[0]a[1]a[2]而我们的代码因为书写错误导致for循环的结束条件错写为了i&lt;=3而非i&lt;3所以当i=3时数组a[3]访问越界。
我们知道在C语言中只要不是访问受限的内存所有的内存空间都是可以自由访问的。根据我们前面讲的数组寻址公式a[3]也会被定位到某块不属于数组的内存地址上而这个地址正好是存储变量i的内存地址那么a[3]=0就相当于i=0所以就会导致代码无限循环。
数组越界在C语言中是一种未决行为并没有规定数组访问越界时编译器应该如何处理。因为访问数组的本质就是访问一段连续内存只要数组通过偏移计算得到的内存地址是可用的那么程序就可能不会报任何错误。
这种情况下一般都会出现莫名其妙的逻辑错误就像我们刚刚举的那个例子debug的难度非常的大。而且很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞来攻击系统所以写代码的时候一定要警惕数组越界。
但并非所有的语言都像C一样把数组越界检查的工作丢给程序员来做像Java本身就会做越界检查比如下面这几行Java代码就会抛出java.lang.ArrayIndexOutOfBoundsException。
```
int[] a = new int[3];
a[3] = 10;
```
## 容器能否完全替代数组?
针对数组类型很多语言都提供了容器类比如Java中的ArrayList、C++ STL中的vector。在项目开发中什么时候适合用数组什么时候适合用容器呢
这里我拿Java语言来举例。如果你是Java工程师几乎天天都在用ArrayList对它应该非常熟悉。那它与数组相比到底有哪些优势呢
我个人觉得ArrayList最大的优势就是**可以将很多数组操作的细节封装起来**。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是**支持动态扩容**。
数组本身在定义的时候需要预先指定大小因为需要分配连续的内存空间。如果我们申请了大小为10的数组当第11个数据需要存储到数组中时我们就需要重新分配一块更大的空间将原来的数据复制过去然后再将新的数据插入。
如果使用ArrayList我们就完全不需要关心底层的扩容逻辑ArrayList已经帮我们实现好了。每次存储空间不够的时候它都会将空间自动扩容为1.5倍大小。
不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好**在创建ArrayList的时候事先指定数据大小**。
比如我们要从数据库中取出10000条数据放入ArrayList。我们看下面这几行代码你会发现相比之下事先指定数据大小可以省掉很多次内存申请和数据搬移操作。
```
ArrayList&lt;User&gt; users = new ArrayList(10000);
for (int i = 0; i &lt; 10000; ++i) {
users.add(xxx);
}
```
作为高级语言编程者,是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适些,我总结了几点自己的经验。
1.Java ArrayList无法存储基本类型比如int、long需要封装为Integer、Long类而Autoboxing、Unboxing则有一定的性能消耗所以如果特别关注性能或者希望使用基本类型就可以选用数组。
2.如果数据大小事先已知并且对数据的操作非常简单用不到ArrayList提供的大部分方法也可以直接使用数组。
3.还有一个是我个人的喜好当要表示多维数组时用数组往往会更加直观。比如Object[][] array而用容器的话则需要这样定义ArrayList&lt;ArrayList<object> &gt; array。<p>
我总结一下,对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
## 解答开篇
现在我们来思考开篇的问题为什么大多数编程语言中数组要从0开始编号而不是从1开始呢
从数组存储的内存模型上来看“下标”最确切的定义应该是“偏移offset”。前面也讲到如果用a来表示数组的首地址a[0]就是偏移为0的位置也就是首地址a[k]就表示偏移k个type_size的位置所以计算a[k]的内存地址只需要用这个公式:
```
a[k]_address = base_address + k * type_size
```
但是如果数组从1开始计数那我们计算数组元素a[k]的内存地址就会变为:
```
a[k]_address = base_address + (k-1)*type_size
```
对比两个公式我们不难发现从1开始编号每次随机访问数组元素都多了一次减法运算对于CPU来说就是多了一次减法指令。
数组作为非常基础的数据结构通过下标随机访问数组元素又是其非常基础的编程操作效率的优化就要尽可能做到极致。所以为了减少一次减法操作数组选择了从0开始编号而不是从1开始。
不过我认为上面解释得再多其实都算不上压倒性的证明说数组起始编号非0开始不可。所以我觉得最主要的原因可能是历史原因。
C语言设计者用0开始计数数组下标之后的Java、JavaScript等高级语言都效仿了C语言或者说为了在一定程度上减少C语言程序员学习Java的学习成本因此继续沿用了从0开始计数的习惯。实际上很多语言中数组也并不是从0开始计数的比如Matlab。甚至还有一些语言支持负数下标比如Python。
## 内容小结
我们今天学习了数组。它可以说是最基础、最简单的数据结构了。数组用一块连续的内存空间来存储相同类型的一组数据最大的特点就是支持随机访问但插入、删除操作也因此变得比较低效平均情况时间复杂度为O(n)。在平时的业务开发中,我们可以直接使用编程语言提供的容器类,但是,如果是特别底层的开发,直接使用数组可能会更合适。
## 课后思考
<li>
前面我基于数组的原理引出JVM的标记清除垃圾回收算法的核心理念。我不知道你是否使用Java语言理解JVM如果你熟悉可以在评论区回顾下你理解的标记清除垃圾回收算法。
</li>
<li>
前面我们讲到一维数组的内存寻址公式,那你可以思考一下,类比一下,二维数组的内存寻址公式是怎样的呢?
</li>
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,157 @@
<audio id="audio" title="06 | 链表如何实现LRU缓存淘汰算法?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/02/2e/02fa8c4e41a0749858fb98d4b7c29f2e.mp3"></audio>
今天我们来聊聊“链表Linked list”这个数据结构。学习链表有什么用呢为了回答这个问题我们先来讨论一个经典的链表应用场景那就是LRU缓存淘汰算法。
缓存是一种提高数据读取性能的技术在硬件设计、软件开发中都有着非常广泛的应用比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限当缓存被用满时哪些数据应该被清理出去哪些数据应该被保留这就需要缓存淘汰策略来决定。常见的策略有三种先进先出策略FIFOFirst InFirst Out、最少使用策略LFULeast Frequently Used、最近最少使用策略LRULeast Recently Used
这些策略你不用死记,我打个比方你很容易就明白了。假如说,你买了很多本技术书,但有一天你发现,这些书太多了,太占书房空间了,你要做个大扫除,扔掉一些书籍。那这个时候,你会选择扔掉哪些书呢?对应一下,你的选择标准是不是和上面的三种策略神似呢?
好了,回到正题,我们今天的开篇问题就是:**如何用链表来实现LRU缓存淘汰策略呢** 带着这个问题,我们开始今天的内容吧!
## 五花八门的链表结构
相比数组,链表是一种稍微复杂一点的数据结构。对于初学者来说,掌握起来也要比数组稍难一些。这两个非常基础、非常常用的数据结构,我们常常会放到一块儿来比较。所以我们先来看,这两者有什么区别。
我们先从**底层的存储结构**上来看一看。
为了直观地对比,我画了一张图。从图中我们看到,数组需要一块**连续的内存空间**来存储对内存的要求比较高。如果我们申请一个100MB大小的数组当内存中没有连续的、足够大的存储空间时即便内存的剩余总可用空间大于100MB仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组**零散的内存块**串联起来使用所以如果我们申请的是100MB大小的链表根本不会有问题。
<img src="https://static001.geekbang.org/resource/image/d5/cd/d5d5bee4be28326ba3c28373808a62cd.jpg" alt="">
链表结构五花八门,今天我重点给你介绍三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表。我们首先来看最简单、最常用的**单链表**。
我们刚刚讲到,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的“**结点**”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,我们把这个记录下个结点地址的指针叫作**后继指针next**。
<img src="https://static001.geekbang.org/resource/image/b9/eb/b93e7ade9bb927baad1348d9a806ddeb.jpg" alt="">
从我画的单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作**头结点**,把最后一个结点叫作**尾结点**。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个**空地址NULL**,表示这是链表上最后一个结点。
与数组一样,链表也支持数据的查找、插入和删除操作。
我们知道在进行数组的插入、删除操作时为了保持内存数据的连续性需要做大量的数据搬移所以时间复杂度是O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。
为了方便你理解我画了一张图从图中我们可以看出针对链表的插入和删除操作我们只需要考虑相邻结点的指针改变所以对应的时间复杂度是O(1)。
<img src="https://static001.geekbang.org/resource/image/45/17/452e943788bdeea462d364389bd08a17.jpg" alt="">
但是有利就有弊。链表要想随机访问第k个元素就没有数组那么高效了。因为链表中的数据并非连续存储的所以无法像数组那样根据首地址和下标通过寻址公式就能直接计算出对应的内存地址而是需要根据指针一个结点一个结点地依次遍历直到找到相应的结点。
你可以把链表想象成一个队伍队伍中的每个人都只知道自己后面的人是谁所以当我们希望知道排在第k位的人是谁的时候我们就需要从第一个人开始一个一个地往下数。所以链表随机访问的性能没有数组好需要O(n)的时间复杂度。
好了,单链表我们就简单介绍完了,接着来看另外两个复杂的升级版,**循环链表**和**双向链表**。
**循环链表是一种特殊的单链表**。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。
<img src="https://static001.geekbang.org/resource/image/86/55/86cb7dc331ea958b0a108b911f38d155.jpg" alt="">
和单链表相比,**循环链表**的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的[约瑟夫问题](https://zh.wikipedia.org/wiki/%E7%BA%A6%E7%91%9F%E5%A4%AB%E6%96%AF%E9%97%AE%E9%A2%98)。尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多。
单链表和循环链表是不是都不难?接下来我们再来看一个稍微复杂的,在实际的软件开发中,也更加常用的链表结构:**双向链表**。
单向链表只有一个方向结点只有一个后继指针next指向后面的结点。而双向链表顾名思义它支持两个方向每个结点不止有一个后继指针next指向后面的结点还有一个前驱指针prev指向前面的结点。
<img src="https://static001.geekbang.org/resource/image/cb/0b/cbc8ab20276e2f9312030c313a9ef70b.jpg" alt="">
从我画的图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。那相比单链表,双向链表适合解决哪种问题呢?
从结构上来看双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。
你可能会说我刚讲到单链表的插入、删除操作的时间复杂度已经是O(1)了,双向链表还能再怎么高效呢?别着急,刚刚的分析比较偏理论,很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。我再来带你分析一下链表的两个操作。
我们先来看**删除操作**。
在实际的软件开发中,从链表中删除一个数据无外乎这两种情况:
<li>
删除结点中“值等于某个给定值”的结点;
</li>
<li>
删除给定指针指向的结点。
</li>
对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过我前面讲的指针操作将其删除。
尽管单纯的删除操作时间复杂度是O(1)但遍历查找的时间是主要的耗时点对应的时间复杂度为O(n)。根据时间复杂度分析中的加法法则删除值等于给定值的结点对应的链表操作的总时间复杂度为O(n)。
对于第二种情况我们已经找到了要删除的结点但是删除某个结点q需要知道其前驱结点而单链表并不支持直接获取前驱结点所以为了找到前驱结点我们还是要从头结点开始遍历链表直到p-&gt;next=q说明p是q的前驱结点。
但是对于双向链表来说这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针不需要像单链表那样遍历。所以针对第二种情况单链表删除操作需要O(n)的时间复杂度而双向链表只需要在O(1)的时间复杂度内就搞定了!
同理如果我们希望在链表的某个指定结点前面插入一个结点双向链表比单链表有很大的优势。双向链表可以在O(1)时间复杂度搞定而单向链表需要O(n)的时间复杂度。你可以参照我刚刚讲过的删除操作自己分析一下。
除了插入、删除操作有优势之外对于一个有序链表双向链表的按值查询的效率也要比单链表高一些。因为我们可以记录上次查找的位置p每次查询时根据要查找的值与p的大小关系决定是往前还是往后查找所以平均只需要查找一半的数据。
现在你有没有觉得双向链表要比单链表更加高效呢这就是为什么在实际的软件开发中双向链表尽管比较费内存但还是比单链表的应用更加广泛的原因。如果你熟悉Java语言你肯定用过LinkedHashMap这个容器。如果你深入研究LinkedHashMap的实现原理就会发现其中就用到了双向链表这种数据结构。
实际上,这里有一个更加重要的知识点需要你掌握,那就是**用空间换时间**的设计思想。当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路。
还是开篇缓存的例子。缓存实际上就是利用了空间换时间的设计思想。如果我们把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。
所以我总结一下,对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。你还能想到其他时间换空间或者空间换时间的例子吗?
了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个新的版本:**双向循环链表**。我想不用我多讲,你应该知道双向循环链表长什么样子了吧?你可以自己试着在纸上画一画。
<img src="https://static001.geekbang.org/resource/image/d1/91/d1665043b283ecdf79b157cfc9e5ed91.jpg" alt="">
## 链表VS数组性能大比拼
通过前面内容的学习,你应该已经知道,数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。
<img src="https://static001.geekbang.org/resource/image/4f/68/4f63e92598ec2551069a0eef69db7168.jpg" alt="">
不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。
数组简单易用在实现上使用的是连续的内存空间可以借助CPU的缓存机制预读数组中的数据所以访问效率更高。而链表在内存中并不是连续存储所以对CPU缓存不友好没办法有效预读。
数组的缺点是大小固定一经声明就要占用整块连续内存空间。如果声明的数组过大系统可能没有足够的连续内存空间分配给它导致“内存不足out of memory”。如果声明的数组过小则可能出现不够用的情况。这时只能再申请一个更大的内存空间把原数组拷贝进去非常费时。链表本身没有大小的限制天然地支持动态扩容我觉得这也是它与数组最大的区别。
你可能会说我们Java中的ArrayList容器也可以支持动态扩容啊我们上一节课讲过当我们往支持动态扩容的数组中插入一个数据时如果数组中没有空闲空间了就会申请一个更大的空间将数据拷贝过去而数据拷贝的操作是非常耗时的。
我举一个稍微极端的例子。如果我们用ArrayList存储了了1GB大小的数据这个时候已经没有空闲空间了当我们再插入数据的时候ArrayList会申请一个1.5GB大小的存储空间并且把原来那1GB的数据拷贝到新申请的空间上。听起来是不是就很耗时
除此之外如果你的代码对内存的使用非常苛刻那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针所以内存消耗会翻倍。而且对链表进行频繁的插入、删除操作还会导致频繁的内存申请和释放容易造成内存碎片如果是Java语言就有可能会导致频繁的GCGarbage Collection垃圾回收
所以,在我们实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表。
## 解答开篇
好了关于链表的知识我们就讲完了。我们现在回过头来看下开篇留给你的思考题。如何基于链表实现LRU缓存淘汰算法
我的思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
1.如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
2.如果此数据没有在缓存链表中,又可以分为两种情况:
<li>
如果此时缓存未满,则将此结点直接插入到链表的头部;
</li>
<li>
如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
</li>
这样我们就用链表实现了一个LRU缓存是不是很简单
现在我们来看下缓存访问的时间复杂度是多少。因为不管缓存有没有满我们都需要遍历一遍链表所以这种基于链表的实现思路缓存访问的时间复杂度为O(n)。
实际上,我们可以继续优化这个实现思路,比如引入**散列表**Hash table来记录每个数据的位置将缓存访问的时间复杂度降到O(1)。因为要涉及我们还没有讲到的数据结构,所以这个优化方案,我现在就不详细说了,等讲到散列表的时候,我会再拿出来讲。
除了基于链表的实现思路实际上还可以用数组来实现LRU缓存淘汰策略。如何利用数组实现LRU缓存淘汰策略呢我把这个问题留给你思考。
## 内容小结
今天我们讲了一种跟数组“相反”的数据结构,链表。它跟数组一样,也是非常基础、非常常用的数据结构。不过链表要比数组稍微复杂,从普通的单链表衍生出来好几种链表结构,比如双向链表、循环链表、双向循环链表。
和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。不过,在具体软件开发中,要对数组和链表的各种性能进行对比,综合来选择使用两者中的哪一个。
## 课后思考
如何判断一个字符串是否是回文字符串的问题,我想你应该听过,我们今天的题目就是基于这个问题的改造版本。如果字符串是通过单链表来存储的,那该如何来判断是一个回文串呢?你有什么好的解决思路呢?相应的时间空间复杂度又是多少呢?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="07 | 链表(下):如何轻松写出正确的链表代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/7a/3b15db06c3825429bace496b7319b97a.mp3"></audio>
上一节我讲了链表相关的基础知识。学完之后,我看到有人留言说,基础知识我都掌握了,但是写链表代码还是很费劲。哈哈,的确是这样的!
想要写好链表代码并不是容易的事儿尤其是那些复杂的链表操作比如链表反转、有序链表合并等写的时候非常容易出错。从我上百场面试的经验来看能把“链表反转”这几行代码写对的人不足10%。
为什么链表代码这么难写?究竟怎样才能比较轻松地写出正确的链表代码呢?
只要愿意投入时间我觉得大多数人都是可以学会的。比如说如果你真的能花上一个周末或者一整天的时间就去写链表反转这一个代码多写几遍一直练到能毫不费力地写出Bug free的代码。这个坎还会很难跨吗
当然,自己有决心并且付出精力是成功的先决条件,除此之外,我们还需要一些方法和技巧。我根据自己的学习经历和工作经验,总结了**几个写链表代码技巧**。如果你能熟练掌握这几个技巧,加上你的主动和坚持,轻松拿下链表代码完全没有问题。
## 技巧一:理解指针或引用的含义
事实上,看懂链表的结构并不是很难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。所以,要想写对链表代码,首先就要理解好指针。
我们知道有些语言有“指针”的概念比如C语言有些语言没有指针取而代之的是“引用”比如Java、Python。不管是“指针”还是“引用”实际上它们的意思都是一样的都是存储所指对象的内存地址。
接下来我会拿C语言中的“指针”来讲解如果你用的是Java或者其他没有指针的语言也没关系你把它理解成“引用”就可以了。
实际上,对于指针的理解,你只需要记住下面这句话就可以了:
**将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。**
这句话听起来还挺拗口的,你可以先记住。我们回到链表代码的编写过程中,我来慢慢给你解释。
在编写链表代码的时候我们经常会有这样的代码p-&gt;next=q。这行代码是说p结点中的next指针存储了q结点的内存地址。
还有一个更复杂的也是我们写链表代码经常会用到的p-&gt;next=p-&gt;next-&gt;next。这行代码表示p结点的next指针存储了p结点的下下一个结点的内存地址。
掌握了指针或引用的概念,你应该可以很轻松地看懂链表代码。恭喜你,已经离写出链表代码近了一步!
## 技巧二:警惕指针丢失和内存泄漏
不知道你有没有这样的感觉,写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针。
指针往往都是怎么弄丢的呢?我拿单链表的插入操作为例来给你分析一下。
<img src="https://static001.geekbang.org/resource/image/05/6e/05a4a3b57502968930d517c934347c6e.jpg" alt="">
如图所示我们希望在结点a和相邻的结点b之间插入结点x假设当前指针p指向结点a。如果我们将代码实现变成下面这个样子就会发生指针丢失和内存泄露。
```
p-&gt;next = x; // 将p的next指针指向x结点
x-&gt;next = p-&gt;next; // 将x的结点的next指针指向b结点
```
初学者经常会在这儿犯错。p-&gt;next指针在完成第一步操作之后已经不再指向结点b了而是指向结点x。第2行代码相当于将x赋值给x-&gt;next自己指向自己。因此整个链表也就断成了两半从结点b往后的所有结点都无法访问到了。
对于有些语言来说比如C语言内存管理是由程序员负责的如果没有手动释放结点对应的内存空间就会产生内存泄露。所以我们**插入结点时,一定要注意操作的顺序**要先将结点x的next指针指向结点b再把结点a的next指针指向结点x这样才不会丢失指针导致内存泄漏。所以对于刚刚的插入代码我们只需要把第1行和第2行代码的顺序颠倒一下就可以了。
同理,**删除链表结点时,也一定要记得手动释放内存空间**否则也会出现内存泄漏的问题。当然对于像Java这种虚拟机自动管理内存的编程语言来说就不需要考虑这么多了。
## 技巧三:利用哨兵简化实现难度
首先我们先来回顾一下单链表的插入和删除操作。如果我们在结点p后面插入一个新的结点只需要下面两行代码就可以搞定。
```
new_node-&gt;next = p-&gt;next;
p-&gt;next = new_node;
```
但是当我们要向一个空链表中插入第一个结点刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理其中head表示链表的头结点。所以从这段代码我们可以发现对于单链表的插入操作第一个结点和其他结点的插入逻辑是不一样的。
```
if (head == null) {
head = new_node;
}
```
我们再来看单链表结点删除操作。如果要删除结点p的后继结点我们只需要一行代码就可以搞定。
```
p-&gt;next = p-&gt;next-&gt;next;
```
但是如果我们要删除链表中的最后一个结点前面的删除代码就不work了。跟插入类似我们也需要对于这种情况特殊处理。写成代码是这样子的
```
if (head-&gt;next == null) {
head = null;
}
```
从前面的一步一步分析,我们可以看出,**针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理**。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?
技巧三中提到的哨兵就要登场了。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。
还记得如何表示一个空链表吗head=null表示链表中没有结点了。其中head表示头结点指针指向链表中的第一个结点。
如果我们引入哨兵结点在任何时候不管链表是不是空head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫**带头链表**。相反,没有哨兵结点的链表就叫作**不带头链表**。
我画了一个带头链表,你可以发现,哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。
<img src="https://static001.geekbang.org/resource/image/7d/c7/7d22d9428bdbba96bfe388fe1e3368c7.jpg" alt="">
实际上这种利用哨兵简化编程难度的技巧在很多代码实现中都有用到比如插入排序、归并排序、动态规划等。这些内容我们后面才会讲现在为了让你感受更深我再举一个非常简单的例子。代码我是用C语言实现的不涉及语言方面的高级语法很容易看懂你可以类比到你熟悉的语言。
代码一:
```
// 在数组a中查找key返回key所在的位置
// 其中n表示数组a的长度
int find(char* a, int n, char key) {
// 边界条件处理如果a为空或者n&lt;=0说明数组中没有数据就不用while循环比较了
if(a == null || n &lt;= 0) {
return -1;
}
int i = 0;
// 这里有两个比较操作i&lt;n和a[i]==key.
while (i &lt; n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
```
代码二:
```
// 在数组a中查找key返回key所在的位置
// 其中n表示数组a的长度
// 我举2个例子你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6} n=6 key = 7
// a = {4, 2, 3, 5, 9, 6} n=6 key = 6
int find(char* a, int n, char key) {
if(a == null || n &lt;= 0) {
return -1;
}
// 这里因为要将a[n-1]的值替换成key所以要特殊处理这个值
if (a[n-1] == key) {
return n-1;
}
// 把a[n-1]的值临时保存在变量tmp中以便之后恢复。tmp=6。
// 之所以这样做的目的是希望find()代码不要改变a数组中的内容
char tmp = a[n-1];
// 把key的值放到a[n-1]中此时a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循环比起代码一少了i&lt;n这个比较操作
while (a[i] != key) {
++i;
}
// 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果i == n-1说明在0...n-2之间都没有key所以返回-1
return -1;
} else {
// 否则返回i就是等于key值的元素的下标
return i;
}
}
```
对比两段代码在字符串a很长的时候比如几万、几十万你觉得哪段代码运行得更快点呢答案是代码二因为两段代码中执行次数最多就是while循环那一部分。第二段代码中我们通过一个哨兵a[n-1] = key成功省掉了一个比较语句i&lt;n不要小看这一条语句当累积执行万次、几十万次时累积的时间就很明显了。
当然,这只是为了举例说明哨兵的作用,你写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部分情况下,我们并不需要如此追求极致的性能。
## 技巧四:重点留意边界条件处理
软件开发中代码在一些边界或者异常情况下最容易产生Bug。链表代码也不例外。要实现没有Bug的链表代码一定要在编写的过程中以及编写完成之后检查边界条件是否考虑全面以及代码在边界条件下是否能正确运行。
我经常用来检查链表代码是否正确的边界条件有这样几个:
<li>
如果链表为空时,代码是否能正常工作?
</li>
<li>
如果链表只包含一个结点时,代码是否能正常工作?
</li>
<li>
如果链表只包含两个结点时,代码是否能正常工作?
</li>
<li>
代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
</li>
当你写完链表代码之后,除了看下你写的代码在正常的情况下能否工作,还要看下在上面我列举的几个边界条件下,代码仍然能否正确工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了。
当然,边界条件不止我列举的那些。针对不同的场景,可能还有特定的边界条件,这个需要你自己去思考,不过套路都是一样的。
实际上,不光光是写链表代码,你在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,你的代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!
## 技巧五:举例画图,辅助思考
对于稍微复杂的链表操作,比如前面我们提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。所以这个时候就要使用大招了,**举例法**和**画图法**。
你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,我一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:
<img src="https://static001.geekbang.org/resource/image/4a/f8/4a701dd79b59427be654261805b349f8.jpg" alt="">
看图写代码是不是就简单多啦而且当我们写完代码之后也可以举几个例子画在纸上照着代码走一遍很容易就能发现代码中的Bug。
## 技巧六:多写多练,没有捷径
如果你已经理解并掌握了我前面所讲的方法,但是手写链表代码还是会出现各种各样的错误,也不要着急。因为我最开始学的时候,这种状况也持续了一段时间。
现在我写这些代码,简直就和“玩儿”一样,其实也没有什么技巧,就是把常见的链表操作都自己多写几遍,出问题就一点一点调试,熟能生巧!
所以我精选了5个常见的链表操作。你只要把这几个操作都能写熟练不熟就多写几遍我保证你之后再也不会害怕写链表代码。
<li>
单链表反转
</li>
<li>
链表中环的检测
</li>
<li>
两个有序的链表合并
</li>
<li>
删除链表倒数第n个结点
</li>
<li>
求链表的中间结点
</li>
## 内容小结
这节我主要和你讲了写出正确链表代码的六个技巧。分别是理解指针或引用的含义、警惕指针丢失和内存泄漏、利用哨兵简化实现难度、重点留意边界条件处理,以及举例画图、辅助思考,还有多写多练。
我觉得,**写链表代码是最考验逻辑思维能力的**。因为链表代码到处都是指针的操作、边界条件的处理稍有不慎就容易产生Bug。链表代码写得好坏可以看出一个人写代码是否够细心考虑问题是否全面思维是否缜密。所以这也是很多面试官喜欢让人手写链表代码的原因。所以这一节讲到的东西你一定要自己写代码实现一下才有效果。
## 课后思考
今天我们讲到用哨兵来简化编码实现,你是否还能够想到其他场景,利用哨兵可以大大地简化编码难度?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="08 | 栈:如何实现浏览器的前进和后退功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/dc/7a21252cfdcb543227be4aacee92c0dc.mp3"></audio>
浏览器的前进、后退功能,我想你肯定很熟悉吧?
当你依次访问完一串页面a-b-c之后点击浏览器的后退按钮就可以查看之前浏览过的页面b和a。当你后退到页面a点击前进按钮就可以重新查看页面b和c。但是如果你后退到页面b后点击了新的页面d那就无法再通过前进、后退功能查看页面c了。
**假设你是Chrome浏览器的开发工程师你会如何实现这个功能呢**
这就要用到我们今天要讲的“栈”这种数据结构。带着这个问题,我们来学习今天的内容。
## 如何理解“栈”?
关于“栈”,我有一个非常贴切的例子,就是一摞叠在一起的盘子。我们平时放盘子的时候,都是从下往上一个一个放;取的时候,我们也是从上往下一个一个地依次取,不能从中间任意抽出。**后进者先出,先进者后出,这就是典型的“栈”结构。**
<img src="https://static001.geekbang.org/resource/image/3e/0b/3e20cca032c25168d3cc605fa7a53a0b.jpg" alt="">
从栈的操作特性上来看,**栈是一种“操作受限”的线性表**,只允许在一端插入和删除数据。
我第一次接触这种数据结构的时候,就对它存在的意义产生了很大的疑惑。因为我觉得,相比数组和链表,栈带给我的只有限制,并没有任何优势。那我直接使用数组或者链表不就好了吗?为什么还要用这个“操作受限”的“栈”呢?
事实上,从功能上来说,数组或链表确实可以替代栈,但你要知道,特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。
**当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,这时我们就应该首选“栈”这种数据结构**
## 如何实现一个“栈”?
从刚才栈的定义里,我们可以看出,栈主要包含两个操作,入栈和出栈,也就是在栈顶插入一个数据和从栈顶删除一个数据。理解了栈的定义之后,我们来看一看如何用代码实现一个栈。
实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作**顺序栈**,用链表实现的栈,我们叫作**链式栈**。
我这里实现一个基于数组的顺序栈。基于链表实现的链式栈的代码你可以自己试着写一下。我会将我写好的代码放到GitHub上你可以去看一下自己写的是否正确。
我这段代码是用Java来实现的但是不涉及任何高级语法并且我还用中文做了详细的注释所以你应该是可以看懂的。
```
// 基于数组实现的顺序栈
public class ArrayStack {
private String[] items; // 数组
private int count; // 栈中元素个数
private int n; //栈的大小
// 初始化数组申请一个大小为n的数组空间
public ArrayStack(int n) {
this.items = new String[n];
this.n = n;
this.count = 0;
}
// 入栈操作
public boolean push(String item) {
// 数组空间不够了直接返回false入栈失败。
if (count == n) return false;
// 将item放到下标为count的位置并且count加一
items[count] = item;
++count;
return true;
}
// 出栈操作
public String pop() {
// 栈为空则直接返回null
if (count == 0) return null;
// 返回下标为count-1的数组元素并且栈中元素个数count减一
String tmp = items[count-1];
--count;
return tmp;
}
}
```
了解了定义和基本操作,那它的操作的时间、空间复杂度是多少呢?
不管是顺序栈还是链式栈我们存储数据只需要一个大小为n的数组就够了。在入栈和出栈过程中只需要一两个临时变量存储空间所以空间复杂度是O(1)。
注意这里存储数据需要一个大小为n的数组并不是说空间复杂度就是O(n)。因为这n个空间是必须的无法省掉。所以我们说空间复杂度的时候是指除了原本的数据存储空间外算法运行还需要额外的存储空间。
空间复杂度分析是不是很简单时间复杂度也不难。不管是顺序栈还是链式栈入栈、出栈只涉及栈顶个别数据的操作所以时间复杂度都是O(1)。
## 支持动态扩容的顺序栈
刚才那个基于数组实现的栈是一个固定大小的栈也就是说在初始化栈时需要事先指定栈的大小。当栈满之后就无法再往栈里添加数据了。尽管链式栈的大小不受限但要存储next指针内存消耗相对较多。那我们如何基于数组实现一个可以支持动态扩容的栈呢
你还记得,我们在数组那一节,是如何来实现一个支持动态扩容的数组的吗?当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。这样就实现了一个支持动态扩容的数组。
所以,如果要实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了。当栈满了之后,我们就申请一个更大的数组,将原来的数据搬移到新数组中。我画了一张图,你可以对照着理解一下。
<img src="https://static001.geekbang.org/resource/image/b1/da/b193adf5db4356d8ab35a1d32142b3da.jpg" alt="">
实际上,支持动态扩容的顺序栈,我们平时开发中并不常用到。我讲这一块的目的,主要还是希望带你练习一下前面讲的复杂度分析方法。所以这一小节的重点还是复杂度分析。
你不用死记硬背入栈、出栈的时间复杂度,你需要掌握的是分析方法。能够自己分析才算是真正掌握了。现在我就带你分析一下支持动态扩容的顺序栈的入栈、出栈操作的时间复杂度。
对于出栈操作来说我们不会涉及内存的重新申请和数据的搬移所以出栈的时间复杂度仍然是O(1)。但是对于入栈操作来说情况就不一样了。当栈中有空闲空间时入栈操作的时间复杂度为O(1)。但当空间不够时就需要重新申请内存和数据搬移所以时间复杂度就变成了O(n)。
也就是说对于入栈操作来说最好情况时间复杂度是O(1)最坏情况时间复杂度是O(n)。那平均情况下的时间复杂度又是多少呢?还记得我们在复杂度分析那一节中讲的摊还分析法吗?这个入栈操作的平均情况下的时间复杂度可以用摊还分析法来分析。我们也正好借此来实战一下摊还分析法。
为了分析的方便,我们需要事先做一些假设和定义:
<li>
栈空间不够时,我们重新申请一个是原来大小两倍的数组;
</li>
<li>
为了简化分析,假设只有入栈操作没有出栈操作;
</li>
<li>
定义不涉及内存搬移的入栈操作为simple-push操作时间复杂度为O(1)。
</li>
如果当前栈大小为K并且已满当再有新的数据要入栈时就需要重新申请2倍大小的内存并且做K个数据的搬移操作然后再入栈。但是接下来的K-1次入栈操作我们都不需要再重新申请内存和搬移数据所以这K-1次入栈操作都只需要一个simple-push操作就可以完成。为了让你更加直观地理解这个过程我画了一张图。
<img src="https://static001.geekbang.org/resource/image/c9/bb/c936a39ad54a9fdf526e805dc18cf6bb.jpg" alt="">
你应该可以看出来这K次入栈操作总共涉及了K个数据的搬移以及K次simple-push操作。将K个数据搬移均摊到K次入栈操作那每个入栈操作只需要一个数据搬移和一个simple-push操作。以此类推入栈操作的均摊时间复杂度就为O(1)。
通过这个例子的实战分析也印证了前面讲到的均摊时间复杂度一般都等于最好情况时间复杂度。因为在大部分情况下入栈操作的时间复杂度O都是O(1)只有在个别时刻才会退化为O(n)所以把耗时多的入栈操作的时间均摊到其他入栈操作上平均情况下的耗时就接近O(1)。
## 栈在函数调用中的应用
前面我讲的都比较偏理论,我们现在来看下,栈在软件工程中的实际应用。栈作为一个比较基础的数据结构,应用场景还是蛮多的。其中,比较经典的一个应用场景就是**函数调用栈**。
我们知道,操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。为了让你更好地理解,我们一块来看下这段代码的执行过程。
```
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf(&quot;%d&quot;, res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
```
从代码中我们可以看出main()函数调用了add()函数获取计算结果并且与临时变量a相加最后打印res的值。为了让你清晰地看到这个过程对应的函数栈里出栈、入栈的操作我画了一张图。图中显示的是在执行到add()函数时,函数调用栈的情况。
<img src="https://static001.geekbang.org/resource/image/17/1c/17b6c6711e8d60b61d65fb0df5559a1c.jpg" alt="">
## 栈在表达式求值中的应用
我们再来看栈的另一个常见的应用场景,编译器如何利用栈来实现**表达式求值**。
为了方便解释我将算术表达式简化为只包含加减乘除四则运算比如34+13*9+44-12/3。对于这个四则运算我们人脑可以很快求解出答案但是对于计算机来说理解这个表达式本身就是个挺难的事儿。如果换作你让你来实现这样一个表达式求值的功能你会怎么做呢
实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高就将当前运算符压入栈如果比运算符栈顶元素的优先级低或者相同从运算符栈中取栈顶运算符从操作数栈的栈顶取2个操作数然后进行计算再把计算完的结果压入操作数栈继续比较。
我将3+5*8-6这个表达式的计算过程画成了一张图你可以结合图来理解我刚讲的计算过程。
<img src="https://static001.geekbang.org/resource/image/bc/00/bc77c8d33375750f1700eb7778551600.jpg" alt="">
这样用两个栈来解决的思路是不是非常巧妙?你有没有想到呢?
## 栈在括号匹配中的应用
除了用栈来实现表达式求值,我们还可以借助栈来检查表达式中的括号是否匹配。
我们同样简化一下背景。我们假设表达式中只包含三种括号,圆括号()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?
这里也可以用栈来解决。我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
## 解答开篇
好了,我想现在你已经完全理解了栈的概念。我们再回来看看开篇的思考题,如何实现浏览器的前进、后退功能?其实,用两个栈就可以非常完美地解决这个问题。
我们使用两个栈X和Y我们把首次浏览的页面依次压入栈X当点击后退按钮时再依次从栈X中出栈并将出栈的数据依次放入栈Y。当我们点击前进按钮时我们依次从栈Y中取出数据放入栈X中。当栈X中没有数据时那就说明没有页面可以继续后退浏览了。当栈Y中没有数据那就说明没有页面可以点击前进按钮浏览了。
比如你顺序查看了abc三个页面我们就依次把abc压入栈这个时候两个栈的数据就是这个样子
<img src="https://static001.geekbang.org/resource/image/4b/3d/4b579a76ea7ebfc5abae2ad6ae6a3c3d.jpg" alt="">
当你通过浏览器的后退按钮从页面c后退到页面a之后我们就依次把c和b从栈X中弹出并且依次放入到栈Y。这个时候两个栈的数据就是这个样子
<img src="https://static001.geekbang.org/resource/image/b5/1b/b5e496e2e28fe08f0388958a0e12861b.jpg" alt="">
这个时候你又想看页面b于是你又点击前进按钮回到b页面我们就把b再从栈Y中出栈放入栈X中。此时两个栈的数据是这个样子
<img src="https://static001.geekbang.org/resource/image/ea/bc/ea804125bea25d25ba467a51fb98c4bc.jpg" alt="">
这个时候你通过页面b又跳转到新的页面d了页面c就无法再通过前进、后退按钮重复查看了所以需要清空栈Y。此时两个栈的数据这个样子
<img src="https://static001.geekbang.org/resource/image/a3/2e/a3c926fe3050d9a741f394f20430692e.jpg" alt="">
## 内容小结
我们来回顾一下今天讲的内容。栈是一种操作受限的数据结构只支持入栈和出栈操作。后进先出是它最大的特点。栈既可以通过数组实现也可以通过链表来实现。不管基于数组还是链表入栈、出栈的时间复杂度都为O(1)。除此之外,我们还讲了一种支持动态扩容的顺序栈,你需要重点掌握它的均摊时间复杂度分析方法。
## 课后思考
<li>
我们在讲栈的应用时,讲到用函数调用栈来保存临时变量,为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗?
</li>
<li>
我们都知道JVM内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用堆内存用来存储Java中的对象。那JVM里面的“栈”跟我们这里说的“栈”是不是一回事呢如果不是那它为什么又叫作“栈”呢
</li>
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="09 | 队列:队列在线程池等有限资源池中的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/1c/4c22e87c82f3bd78eaa640900964321c.mp3"></audio>
我们知道CPU资源是有限的任务的处理速度与线程个数并不是线性正相关。相反过多的线程反而会导致CPU频繁切换处理性能下降。所以线程池的大小一般都是综合考虑要处理任务的特点和硬件环境来事先设置的。
**当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?**
实际上这些问题并不复杂其底层的数据结构就是我们今天要学的内容队列queue
## 如何理解“队列”?
队列这个概念非常好理解。你可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。**先进者先出,这就是典型的“<strong><strong>队列**</strong></strong>
我们知道,栈只支持两个基本操作:**入栈push()<strong>和**出栈pop()</strong>。队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:**入队enqueue()**,放一个数据到队列尾部;**出队dequeue()**,从队列头部取一个元素。
<img src="https://static001.geekbang.org/resource/image/9e/3e/9eca53f9b557b1213c5d94b94e9dce3e.jpg" alt="">
所以,队列跟栈一样,也是一种**操作受限的线性表数据结构**。
队列的概念很好理解基本操作也很容易掌握。作为一种非常基础的数据结构队列的应用也非常广泛特别是一些具有某些额外特性的队列比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中起着关键性的作用。比如高性能队列Disruptor、Linux环形缓存都用到了循环并发队列Java concurrent并发包利用ArrayBlockingQueue来实现公平锁等。
## 顺序队列和链式队列
我们知道了,队列跟栈一样,也是一种抽象的数据结构。它具有先进先出的特性,支持在队尾插入元素,在队头删除元素,那究竟该如何实现一个队列呢?
跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作**顺序队列**,用链表实现的队列叫作**链式队列**。
我们先来看下基于数组的实现方法。我用Java语言实现了一下不过并不包含Java语言的高级语法而且我做了比较详细的注释你应该可以看懂。
```
// 用数组实现的队列
public class ArrayQueue {
// 数组items数组大小n
private String[] items;
private int n = 0;
// head表示队头下标tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public ArrayQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 如果tail == n 表示队列已经满了
if (tail == n) return false;
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
// 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
String ret = items[head];
++head;
return ret;
}
}
```
比起栈的数组实现,队列的数组实现稍微有点儿复杂,但是没关系。我稍微解释一下实现思路,你很容易就能明白了。
对于栈来说,我们只需要一个**栈顶指针**就可以了。但是队列需要两个指针一个是head指针指向队头一个是tail指针指向队尾。
你可以结合下面这张图来理解。当a、b、c、d依次入队之后队列中的head指针指向下标为0的位置tail指针指向下标为4的位置。
<img src="https://static001.geekbang.org/resource/image/5c/cb/5c0ec42eb797e8a7d48c9dbe89dc93cb.jpg" alt="">
当我们调用两次出队操作之后队列中head指针指向下标为2的位置tail指针仍然指向下标为4的位置。
<img src="https://static001.geekbang.org/resource/image/de/0d/dea27f2c505dd8d0b6b86e262d03430d.jpg" alt="">
你肯定已经发现了随着不停地进行入队、出队操作head和tail都会持续往后移动。当tail移动到最右边即使数组中还有空闲空间也无法继续往队列中添加数据了。这个问题该如何解决呢
你是否还记得,在数组那一节,我们也遇到过类似的问题,就是数组的删除操作会导致数组中的数据不连续。你还记得我们当时是怎么解决的吗?对,用**数据搬移**但是每次进行出队操作都相当于删除数组下标为0的数据要搬移整个队列中的数据这样出队操作的时间复杂度就会从原来的O(1)变为O(n)。能不能优化一下呢?
实际上我们在出队时可以不用搬移数据。如果没有空闲空间了我们只需要在入队时再集中触发一次数据的搬移操作。借助这个思想出队函数dequeue()保持不变我们稍加改造一下入队函数enqueue()的实现,就可以轻松解决刚才的问题了。下面是具体的代码:
```
// 入队操作将item放入队尾
public boolean enqueue(String item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
// tail ==n &amp;&amp; head==0表示整个队列都占满了
if (head == 0) return false;
// 数据搬移
for (int i = head; i &lt; tail; ++i) {
items[i-head] = items[i];
}
// 搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
```
从代码中我们看到当队列的tail指针移动到数组的最右边后如果有新的数据入队我们可以将head到tail之间的数据整体搬移到数组中0到tail-head的位置。
<img src="https://static001.geekbang.org/resource/image/09/c7/094ba7722eeec46ead58b40c097353c7.jpg" alt="">
这种实现思路中出队操作的时间复杂度仍然是O(1)但入队操作的时间复杂度还是O(1)吗你可以用我们第3节、第4节讲的算法复杂度分析方法自己试着分析一下。
接下来,我们再来看下**基于链表的队列实现方法**。
基于链表的实现我们同样需要两个指针head指针和tail指针。它们分别指向链表的第一个结点和最后一个结点。如图所示入队时tail-&gt;next= new_node, tail = tail-&gt;next出队时head = head-&gt;next。我将具体的代码放到GitHub上你可以自己试着实现一下然后再去GitHub上跟我实现的代码对比下看写得对不对。
<img src="https://static001.geekbang.org/resource/image/c9/93/c916fe2212f8f543ddf539296444d393.jpg" alt="">
## 循环队列
我们刚才用数组来实现队列的时候在tail==n时会有数据搬移操作这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢我们来看看循环队列的解决思路。
循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在我们把首尾相连,扳成了一个环。我画了一张图,你可以直观地感受一下。
<img src="https://static001.geekbang.org/resource/image/58/90/58ba37bb4102b87d66dffe7148b0f990.jpg" alt="">
我们可以发现图中这个队列的大小为8当前head=4tail=7。当有一个新的元素a入队时我们放入下标为7的位置。但这个时候我们并不把tail更新为8而是将其在环中后移一位到下标为0的位置。当再有一个元素b入队时我们将b放入下标为0的位置然后tail加1更新为1。所以在ab依次入队之后循环队列中的元素就变成了下面的样子
<img src="https://static001.geekbang.org/resource/image/71/80/71a41effb54ccea9dd463bde1b6abe80.jpg" alt="">
通过这样的方法我们成功避免了数据搬移操作。看起来不难理解但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有bug的循环队列的实现代码我个人觉得最关键的是**确定好队空和队满的判定条件**。
在用数组实现的非循环队列中队满的判断条件是tail == n队空的判断条件是head == tail。那针对循环队列如何判断队空和队满呢
队列为空的判断条件仍然是head == tail。但队列满的判断条件就稍微有点复杂了。我画了一张队列满的图你可以看一下试着总结一下规律。
<img src="https://static001.geekbang.org/resource/image/3d/ec/3d81a44f8c42b3ceee55605f9aeedcec.jpg" alt="">
就像我图中画的队满的情况tail=3head=4n=8所以总结一下规律就是(3+1)%8=4。多画几张队满的图你就会发现当队满时**(tail+1)%n=head**。
你有没有发现当队列满时图中的tail指向的位置实际上是没有存储数据的。所以循环队列会浪费一个数组的存储空间。
Talk is cheap如果还是没怎么理解那就show you code吧。
```
public class CircularQueue {
// 数组items数组大小n
private String[] items;
private int n = 0;
// head表示队头下标tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public CircularQueue(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head) return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail) return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
```
## 阻塞队列和并发队列
前面讲的内容理论比较多,看起来很难跟实际的项目开发扯上关系。确实,队列这种数据结构很基础,平时的业务开发不大可能从零实现一个队列,甚至都不会直接用到。而一些具有特殊特性的队列应用却比较广泛,比如阻塞队列和并发队列。
**阻塞队列**其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。
<img src="https://static001.geekbang.org/resource/image/5e/eb/5ef3326181907dea0964f612890185eb.jpg" alt="">
你应该已经发现了,上述的定义就是一个“生产者-消费者模型”!是的,我们可以使用阻塞队列,轻松实现一个“生产者-消费者模型”!
这种基于阻塞队列实现的“生产者-消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
而且不仅如此,基于阻塞队列,我们还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。比如前面的例子,我们可以多配置几个“消费者”,来应对一个“生产者”。
<img src="https://static001.geekbang.org/resource/image/9f/67/9f539cc0f1edc20e7fa6559193898067.jpg" alt="">
前面我们讲了阻塞队列,在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题,那如何实现一个线程安全的队列呢?
线程安全的队列我们叫作**并发队列**。最简单直接的实现方式是直接在enqueue()、dequeue()方法上加锁但是锁粒度大并发度会比较低同一时刻仅允许一个存或者取操作。实际上基于数组的循环队列利用CAS原子操作可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因。在实战篇讲Disruptor的时候我会再详细讲并发队列的应用。
## 解答开篇
队列的知识就讲完了,我们现在回过来看下开篇的问题。线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?各种处理策略又是如何实现的呢?
我们一般有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。那如何存储排队的请求呢?
我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。我们前面说过,队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢?
基于链表的实现方式可以实现一个支持无限排队的无界队列unbounded queue但是可能会导致过多的请求排队等待请求处理的响应时间过长。所以针对响应时间比较敏感的系统基于链表实现的无限排队的线程池是不合适的。
而基于数组实现的有界队列bounded queue队列的大小有限所以线程池中排队的请求超过队列大小时接下来的请求就会被拒绝这种方式对响应时间敏感的系统来说就相对更加合理。不过设置一个合理的队列大小也是非常有讲究的。队列太大导致等待的请求太多队列太小会导致无法充分利用系统资源、发挥最大性能。
除了前面讲到队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。**实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。**
## 内容小结
今天我们讲了一种跟栈很相似的数据结构,队列。关于队列,你能掌握下面的内容,这节就没问题了。
队列最大的特点就是先进先出,主要的两个操作是入队和出队。跟栈一样,它既可以用数组来实现,也可以用链表来实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。特别是长得像一个环的循环队列。在数组实现队列的时候,会有数据搬移操作,要想解决数据搬移的问题,我们就需要像环一样的循环队列。
循环队列是我们这节的重点。要想写出没有bug的循环队列实现代码关键要确定好队空和队满的判定条件具体的代码你要能写出来。
除此之外,我们还讲了几种高级的队列结构,阻塞队列、并发队列,底层都还是队列这种数据结构,只不过在之上附加了很多其他功能。阻塞队列就是入队、出队操作可以阻塞,并发队列就是队列的操作多线程安全。
## 课后思考
<li>
除了线程池这种池结构会用到队列排队请求,你还知道有哪些类似的池结构或者场景中会用到队列的排队请求呢?
</li>
<li>
今天讲到并发队列,关于如何实现无锁并发队列,网上有非常多的讨论。对这个问题,你怎么看呢?
</li>
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,261 @@
<audio id="audio" title="10 | 递归:如何用三行代码找到“最终推荐人”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/4a/f1ff90ce650a4b44ac1aefcf0d6a924a.mp3"></audio>
推荐注册返佣金的这个功能我想你应该不陌生吧现在很多App都有这个功能。这个功能中用户A推荐用户B来注册用户B又推荐了用户C来注册。我们可以说用户C的“最终推荐人”为用户A用户B的“最终推荐人”也为用户A而用户A没有“最终推荐人”。
一般来说我们会通过数据库来记录这种推荐关系。在数据库表中我们可以记录两行数据其中actor_id表示用户idreferrer_id表示推荐人id。
<img src="https://static001.geekbang.org/resource/image/29/0e/2984d45578440e9a348144c70d124a0e.jpg" alt="">
基于这个背景,我的问题是,**给定一个用户ID如何查找这个用户的“最终推荐人”** 带着这个问题我们来学习今天的内容递归Recursion
## 如何理解“递归”?
从我自己学习数据结构和算法的经历来看,我个人觉得,有两个最难理解的知识点,一个是**动态规划**,另一个就是**递归**。
递归是一种应用非常广泛的算法或者编程技巧。之后我们要讲的很多数据结构和算法的编码实现都要用到递归比如DFS深度优先搜索、前中后序二叉树遍历等等。所以搞懂递归非常重要否则后面复杂一些的数据结构和算法学起来就会比较吃力。
不过,别看我说了这么多,递归本身可是一点儿都不“高冷”,咱们生活中就有很多用到递归的例子。
周末你带着女朋友去电影院看电影,女朋友问你,咱们现在坐在第几排啊?电影院里面太黑了,看不清,没法数,现在你怎么办?
别忘了你是程序员,这个可难不倒你,递归就开始排上用场了。于是你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,于是你就知道答案了。
这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子,我们用递推公式将它表示出来就是这样的:
```
f(n)=f(n-1)+1 其中f(1)=1
```
f(n)表示你想知道自己在哪一排f(n-1)表示前面一排所在的排数f(1)=1表示第一排的人知道自己在第一排。有了这个递推公式我们就可以很轻松地将它改为递归代码如下
```
int f(int n) {
if (n == 1) return 1;
return f(n-1) + 1;
}
```
## 递归需要满足的三个条件
刚刚这个例子是非常典型的递归,那究竟什么样的问题可以用递归来解决呢?我总结了三个条件,只要同时满足以下三个条件,就可以用递归来解决。
**1.一个问题的解可以分解为几个子问题的解**
何为子问题?子问题就是数据规模更小的问题。比如,前面讲的电影院的例子,你要知道,“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题。
**2.这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样**
比如电影院那个例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的。
**3.存在递归终止条件**
把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。
还是电影院的例子第一排的人不需要再继续询问任何人就知道自己在哪一排也就是f(1)=1这就是递归的终止条件。
## 如何编写递归代码?
刚刚铺垫了这么多,现在我们来看,如何来写递归代码?我个人觉得,写递归代码最关键的是**写出递推公式,找到终止条件**,剩下将递推公式转化为代码就很简单了。
你先记住这个理论。我举一个例子,带你一步一步实现一个递归代码,帮你理解。
假如这里有n个台阶每次你可以跨1个台阶或者2个台阶请问走这n个台阶有多少种走法如果有7个台阶你可以2221这样子上去也可以12112这样子上去总之走法有很多那如何用编程求得总共有多少种走法呢
我们仔细想下实际上可以根据第一步的走法把所有走法分为两类第一类是第一步走了1个台阶另一类是第一步走了2个台阶。所以n个台阶的走法就等于先走1阶后n-1个台阶的走法 加上先走2阶后n-2个台阶的走法。用公式表示就是
```
f(n) = f(n-1)+f(n-2)
```
有了递推公式递归代码基本上就完成了一半。我们再来看下终止条件。当有一个台阶时我们不需要再继续递归就只有一种走法。所以f(1)=1。这个递归终止条件足够吗我们可以用n=2n=3这样比较小的数试验一下。
n=2时f(2)=f(1)+f(0)。如果递归终止条件只有一个f(1)=1那f(2)就无法求解了。所以除了f(1)=1这一个递归终止条件外还要有f(0)=1表示走0个台阶有一种走法不过这样子看起来就不符合正常的逻辑思维了。所以我们可以把f(2)=2作为一种终止条件表示走2个台阶有两种走法一步走完或者分两步来走。
所以递归终止条件就是f(1)=1f(2)=2。这个时候你可以再拿n=3n=4来验证一下这个终止条件是否足够并且正确。
我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:
```
f(1) = 1;
f(2) = 2;
f(n) = f(n-1)+f(n-2)
```
有了这个公式,我们转化成递归代码就简单多了。最终的递归代码是这样的:
```
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
```
我总结一下,**写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码**。
虽然我讲了这么多方法,但是作为初学者的你,现在是不是还是有种想不太清楚的感觉呢?实际上,我刚学递归的时候,也有这种感觉,这也是文章开头我说递归代码比较难理解的地方。
刚讲的电影院的例子,我们的递归调用只有一个分支,也就是说“一个问题只需要分解为一个子问题”,我们很容易能够想清楚“递”和“归”的每一个步骤,所以写起来、理解起来都不难。
但是,当我们面对的是一个问题要分解为多个子问题的情况,递归代码就没那么好理解了。
像我刚刚讲的第二个例子,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。
计算机擅长做重复的事情,所以递归正合它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。
对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?
如果一个问题A可以分解为若干子问题B、C、D你可以假设子问题B、C、D已经解决在此基础上思考如何解决问题A。而且你只需要思考问题A与子问题B、C、D两层之间的关系即可不需要一层一层往下思考子问题与子子问题子子问题与子子子问题之间的关系。屏蔽掉递归细节这样子理解起来就简单多了。
因此,**编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤**。
## 递归代码要警惕堆栈溢出
在实际的软件开发中,编写递归代码时,我们会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。为什么递归代码容易造成堆栈溢出呢?我们又该如何预防堆栈溢出呢?
我在“栈”那一节讲过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。
比如前面的讲到的电影院的例子如果我们将系统栈或者JVM堆栈大小设置为1KB在求解f(19999)时便会出现如下堆栈报错:
```
Exception in thread &quot;main&quot; java.lang.StackOverflowError
```
那么,如何避免出现堆栈溢出呢?
我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度比如1000之后我们就不继续往下再递归了直接返回报错。还是电影院那个例子我们可以改造成下面这样子就可以避免堆栈溢出了。不过我写的代码是伪代码为了代码简洁有些边界条件没有考虑比如x&lt;=0。
```
// 全局变量,表示递归的深度。
int depth = 0;
int f(int n) {
++depth
if (depth &gt; 1000) throw exception;
if (n == 1) return 1;
return f(n-1) + 1;
}
```
但这种做法并不能完全解决问题因为最大允许的递归深度跟当前线程剩余的栈空间大小有关事先无法计算。如果实时计算代码过于复杂就会影响代码的可读性。所以如果最大深度比较小比如10、50就可以用这种方法否则这种方法并不是很实用。
## 递归代码要警惕重复计算
除此之外,使用递归时还会出现重复计算的问题。刚才我讲的第二个递归代码的例子,如果我们把整个递归过程分解一下的话,那就是这样的:
<img src="https://static001.geekbang.org/resource/image/e7/bf/e7e778994e90265344f6ac9da39e01bf.jpg" alt="">
从图中我们可以直观地看到想要计算f(5)需要先计算f(4)和f(3)而计算f(4)还需要计算f(3)因此f(3)就被计算了很多次,这就是重复计算问题。
为了避免重复计算我们可以通过一个数据结构比如散列表来保存已经求解过的f(k)。当递归调用到f(k)时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。
按照上面的思路,我们来改造一下刚才的代码:
```
public int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
// hasSolvedList可以理解成一个Mapkey是nvalue是f(n)
if (hasSolvedList.containsKey(n)) {
return hasSolvedList.get(n);
}
int ret = f(n-1) + f(n-2);
hasSolvedList.put(n, ret);
return ret;
}
```
除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题。
在时间效率上递归代码里多了很多函数调用当这些函数调用的数量较大时就会积聚成一个可观的时间成本。在空间复杂度上因为递归调用一次就会在内存栈中保存一次现场数据所以在分析递归代码空间复杂度时需要额外考虑这部分的开销比如我们前面讲到的电影院递归代码空间复杂度并不是O(1)而是O(n)。
## 怎么将递归代码改写为非递归代码?
我们刚说了,递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。
那我们是否可以把递归代码改写为非递归代码呢比如刚才那个电影院的例子我们抛开场景只看f(x) =f(x-1)+1这个递推公式。我们这样改写看看
```
int f(int n) {
int ret = 1;
for (int i = 2; i &lt;= n; ++i) {
ret = ret + 1;
}
return ret;
}
```
同样,第二个例子也可以改为非递归的实现方式。
```
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;
int pre = 2;
int prepre = 1;
for (int i = 3; i &lt;= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
```
那是不是所有的递归代码都可以改为这种**迭代循环**的非递归写法呢?
笼统地讲,是的。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。
但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。
## 解答开篇
到此为止,递归相关的基础知识已经讲完了,咱们来看一下开篇的问题:如何找到“最终推荐人”?我的解决方案是这样的:
```
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
```
是不是非常简洁?用三行代码就能搞定了,不过在实际项目中,上面的代码并不能工作,为什么呢?这里面有两个问题。
第一,如果递归很深,可能会有堆栈溢出的问题。
第二如果数据库里存在脏数据我们还需要处理由此产生的无限递归问题。比如demo环境下数据库中测试工程师为了方便测试会人为地插入一些数据就会出现脏数据。如果A的推荐人是BB的推荐人是CC的推荐人是A这样就会发生死循环。
第一个问题我前面已经解答过了可以用限制递归深度来解决。第二个问题也可以用限制递归深度来解决。不过还有一个更高级的处理方法就是自动检测A-B-C-A这种“环”的存在。如何来检测环的存在呢这个我暂时不细说你可以自己思考下后面的章节我们还会讲。
## 内容小结
关于递归的知识,到这里就算全部讲完了。我来总结一下。
递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。
不过递归代码也比较难写、难理解。编写递归代码的关键就是不要把自己绕进去,正确姿势是写出递推公式,找出终止条件,然后再翻译成递归代码。
递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。
## 课后思考
我们平时调试代码喜欢使用IDE的单步跟踪功能像规模比较大、递归层次很深的递归代码几乎无法使用这种调试方式。对于递归代码你有什么好的调试方法呢
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,285 @@
<audio id="audio" title="11 | 排序(上):为什么插入排序比冒泡排序更受欢迎?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/06/9fedf83916430e3244c3ba294c075e06.mp3"></audio>
排序对于任何一个程序员来说,可能都不会陌生。你学的第一个算法,可能就是排序。大部分编程语言中,也都提供了排序函数。在平常的项目中,我们也经常会用到排序。排序非常重要,所以我会花多一点时间来详细讲一讲经典的排序算法。
排序算法太多了,有很多可能你连名字都没听说过,比如猴子排序、睡眠排序、面条排序等。我只讲众多排序算法中的一小撮,也是最经典的、最常用的:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。我按照时间复杂度把它们分成了三类,分三节课来讲解。
<img src="https://static001.geekbang.org/resource/image/fb/cd/fb8394a588b12ff6695cfd664afb17cd.jpg" alt="">
带着问题去学习,是最有效的学习方法。所以按照惯例,我还是先给你出一个思考题:**插入排序和冒泡排序的时间复杂度相同都是O(n<sup>2</sup>),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?**
你可以先思考一两分钟,带着这个问题,我们开始今天的内容!
## 如何分析一个“排序算法”?
学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。那分析一个排序算法,要从哪几个方面入手呢?
### 排序算法的执行效率
对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:
**1.最好情况、最坏情况、平均<strong><strong>情况**</strong>时间复杂度</strong>
我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。
为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。
**2.时间复杂度的系数、常数 、低阶**
我们知道时间复杂度反映的是数据规模n很大的时候的一个增长趋势所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中我们排序的可能是10个、100个、1000个这样规模很小的数据所以在对同一阶时间复杂度的排序算法性能对比的时候我们就要把系数、常数、低阶也考虑进来。
**3.比较次数和交换(或移动)次数**
这一节和下一节讲的都是基于比较的排序算法。基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
### 排序算法的内存消耗
我们前面讲过,算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,**原地排序**Sorted in place。原地排序算法就是特指空间复杂度是O(1)的排序算法。我们今天讲的三种排序算法,都是原地排序算法。
### 排序算法的稳定性
仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,我们还有一个重要的度量指标,**稳定性**。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
我通过一个例子来解释一下。比如我们有一组数据293483按照大小排序之后就是233489。
这组数据里有两个3。经过某种排序算法排序之后如果两个3的前后顺序没有改变那我们就把这种排序算法叫作**稳定的排序算法**;如果前后顺序发生变化,那对应的排序算法就叫作**不稳定的排序算法**。
你可能要问了两个3哪个在前哪个在后有什么关系啊稳不稳定又有什么关系呢为什么要考察排序算法的稳定性呢
很多数据结构和算法课程在讲排序的时候都是用整数来举例但在真正软件开发中我们要排序的往往不是单纯的整数而是一组对象我们需要按照对象的某个key来排序。
比如说我们现在要给电商交易系统中的“订单”排序。订单有两个属性一个是下单时间另一个是订单金额。如果我们现在有10万条订单数据我们希望按照金额从小到大对订单数据排序。对于金额相同的订单我们希望按照下单时间从早到晚有序。对于这样一个排序需求我们怎么来做呢
最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。
借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?
**稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变**。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
<img src="https://static001.geekbang.org/resource/image/13/59/1381c1f3f7819ae61ab17455ed7f0b59.jpg" alt="">
## 冒泡排序Bubble Sort
我们从冒泡排序开始,学习今天的三种排序算法。
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置重复n次就完成了n个数据的排序工作。
我用一个例子带你看下冒泡排序的整个过程。我们要对一组数据456321从小到大进行排序。第一次冒泡操作的详细过程就是这样
<img src="https://static001.geekbang.org/resource/image/40/e9/4038f64f47975ab9f519e4f739e464e9.jpg" alt="">
可以看出经过一次冒泡操作之后6这个元素已经存储在正确的位置上。要想完成所有数据的排序我们只要进行6次这样的冒泡操作就行了。
<img src="https://static001.geekbang.org/resource/image/92/09/9246f12cca22e5d872cbfce302ef4d09.jpg" alt="">
实际上刚讲的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时说明已经达到完全有序不用再继续执行后续的冒泡操作。我这里还有另外一个例子这里面给6个元素排序只需要4次冒泡操作就可以了。
<img src="https://static001.geekbang.org/resource/image/a9/e6/a9783a3b13c11a5e064c5306c261e8e6.jpg" alt="">
冒泡排序算法的原理比较容易理解,具体的代码我贴到下面,你可以结合着代码来看我前面讲的原理。
```
// 冒泡排序a表示数组n表示数组大小
public void bubbleSort(int[] a, int n) {
if (n &lt;= 1) return;
for (int i = 0; i &lt; n; ++i) {
// 提前退出冒泡循环的标志位
boolean flag = false;
for (int j = 0; j &lt; n - i - 1; ++j) {
if (a[j] &gt; a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true; // 表示有数据交换
}
}
if (!flag) break; // 没有数据交换,提前退出
}
}
```
现在,结合刚才我分析排序算法的三个方面,我有三个问题要问你。
**第一,冒泡排序是原地排序算法吗?**
冒泡的过程只涉及相邻数据的交换操作只需要常量级的临时空间所以它的空间复杂度为O(1),是一个原地排序算法。
**第二,冒泡排序是稳定的排序算法吗?**
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
**第三,冒泡排序<strong><strong>的时间复杂度**</strong>是多少?</strong>
最好情况下要排序的数据已经是有序的了我们只需要进行一次冒泡操作就可以结束了所以最好情况时间复杂度是O(n)。而最坏的情况是要排序的数据刚好是倒序排列的我们需要进行n次冒泡操作所以最坏情况时间复杂度为O(n<sup>2</sup>)。
<img src="https://static001.geekbang.org/resource/image/fe/0f/fe107c06da8b290fb78fcce4f6774c0f.jpg" alt="">
最好、最坏情况下的时间复杂度很容易分析,那平均情况下的时间复杂是多少呢?我们前面讲过,平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识。
对于包含n个数据的数组这n个数据就有n!种排列方式。不同的排列方式冒泡排序执行的时间肯定是不同的。比如我们前面举的那两个例子其中一个要进行6次冒泡而另一个只需要4次。如果用概率论方法定量分析平均时间复杂度涉及的数学推理和计算就会很复杂。我这里还有一种思路通过“**有序度**”和“**逆序度**”这两个概念来进行分析。
**有序度**是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:
```
有序元素对a[i] &lt;= a[j], 如果i &lt; j。
```
<img src="https://static001.geekbang.org/resource/image/a1/20/a1ef4cc1999d6bd0af08d8417ee55220.jpg" alt="">
同理对于一个倒序排列的数组比如654321有序度是0对于一个完全有序的数组比如123456有序度就是**n*(n-1)/2**也就是15。我们把这种完全有序的数组的有序度叫作**满有序度**。
逆序度的定义正好跟有序度相反(默认从小到大为有序),我想你应该已经想到了。关于逆序度,我就不举例子讲了。你可以对照我讲的有序度的例子自己看下。
```
逆序元素对a[i] &gt; a[j], 如果i &lt; j。
```
关于这三个概念,我们还可以得到一个公式:**逆序度=满有序度-有序度**。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。
我还是拿前面举的那个冒泡排序的例子来说明。要排序的数组的初始状态是456321 ,其中,有序元素对有(45) (46)(56)所以有序度是3。n=6所以排序完成之后终态的满有序度为n*(n-1)/2=15。
<img src="https://static001.geekbang.org/resource/image/88/34/8890cbf63ea80455ce82490a23361134.jpg" alt="">
冒泡排序包含两个操作原子,**比较**和**交换**。每交换一次有序度就加1。不管算法怎么改进交换次数总是确定的即为**逆序度,<strong>也就是**n*(n-1)/2初始有序度</strong>。此例中就是153=12要进行12次交换操作。
对于包含n个数据的数组进行冒泡排序平均交换次数是多少呢最坏情况下初始状态的有序度是0所以要进行n*(n-1)/2次交换。最好情况下初始状态的有序度是n*(n-1)/2就不需要进行交换。我们可以取个中间值n*(n-1)/4来表示初始有序度既不是很高也不是很低的平均情况。
换句话说平均情况下需要n*(n-1)/4次交换操作比较操作肯定要比交换操作多而复杂度的上限是O(n<sup>2</sup>)所以平均情况下的时间复杂度就是O(n<sup>2</sup>)。
这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用。等我们讲到快排的时候,我还会再次用这种“不严格”的方法来分析平均时间复杂度。
## 插入排序Insertion Sort
我们先来看一个问题。一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。
<img src="https://static001.geekbang.org/resource/image/7b/a6/7b257e179787c633d2bd171a764171a6.jpg" alt="">
这是一个动态排序的过程,即动态地往有序集合中添加数据,我们可以通过这种方法保持集合中的数据一直有序。而对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法。
那**插入排序具体是如何借助上面的思想来实现排序的呢**
首先,我们将数组中的数据分为两个区间,**已排序区间**和**未排序区间**。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
如图所示要排序的数据是456132其中左侧为已排序区间右侧是未排序区间。
<img src="https://static001.geekbang.org/resource/image/b6/e1/b60f61ec487358ac037bf2b6974d2de1.jpg" alt="">
插入排序也包含两种操作,一种是**元素的比较**,一种是**元素<strong><strong>的**</strong>移动</strong>。当我们需要将一个数据a插入到已排序区间时需要拿a与已排序区间的元素依次比较大小找到合适的插入位置。找到插入点之后我们还需要将插入点之后的元素顺序往后移动一位这样才能腾出位置给元素a插入。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。
为什么说移动次数就等于逆序度呢我拿刚才的例子画了一个图表你一看就明白了。满有序度是n*(n-1)/2=15初始序列的有序度是5所以逆序度是10。插入排序中数据移动的个数总和也等于10=3+3+4。
<img src="https://static001.geekbang.org/resource/image/fd/01/fd6582d5e5927173ee35d7cc74d9c401.jpg" alt="">
插入排序的原理也很简单吧?我也将代码实现贴在这里,你可以结合着代码再看下。
```
// 插入排序a表示数组n表示数组大小
public void insertionSort(int[] a, int n) {
if (n &lt;= 1) return;
for (int i = 1; i &lt; n; ++i) {
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j &gt;= 0; --j) {
if (a[j] &gt; value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
}
a[j+1] = value; // 插入数据
}
}
```
现在,我们来看点稍微复杂的东西。我这里还是有三个问题要问你。
**第一,插入排序是原地排序算法吗?**
从实现过程可以很明显地看出插入排序算法的运行并不需要额外的存储空间所以空间复杂度是O(1),也就是说,这是一个原地排序算法。
**第二,插入排序是<strong><strong>稳定**</strong>的排序算法吗?</strong>
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
**第三,插入排序<strong><strong>的时间复杂度**</strong>是多少?</strong>
如果要排序的数据已经是有序的我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置每次只需要比较一个数据就能确定插入的位置。所以这种情况下最好是时间复杂度为O(n)。注意,这里是**从尾到头遍历已经有序的数据**。
如果数组是倒序的每次插入都相当于在数组的第一个位置插入新的数据所以需要移动大量的数据所以最坏情况时间复杂度为O(n<sup>2</sup>)。
还记得我们在数组中插入一个数据的平均时间复杂度是多少吗没错是O(n)。所以对于插入排序来说每次插入操作都相当于在数组中插入一个数据循环执行n次插入操作所以平均时间复杂度为O(n<sup>2</sup>)。
## 选择排序Selection Sort
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
<img src="https://static001.geekbang.org/resource/image/32/1d/32371475a0b08f0db9861d102474181d.jpg" alt="">
照例,也有三个问题需要你思考,不过前面两种排序算法我已经分析得很详细了,这里就直接公布答案了。
首先选择排序空间复杂度为O(1)是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n<sup>2</sup>)。你可以自己来分析看看。
那选择排序是稳定的排序算法吗?这个问题我着重来说一下。
答案是否定的,选择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。
比如58529这样一组数据使用选择排序算法来排序的话第一次找到最小元素2与第一个5交换位置那第一个5和中间的5顺序就变了所以就不稳定了。正是因此相对于冒泡排序和插入排序选择排序就稍微逊色了。
## 解答开篇
基本的知识都讲完了我们来看开篇的问题冒泡排序和插入排序的时间复杂度都是O(n<sup>2</sup>),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
我们前面分析冒泡排序和插入排序的时候讲到,冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
但是从代码实现上来看冒泡排序的数据交换要比插入排序的数据移动要复杂冒泡排序需要3个赋值操作而插入排序只需要1个。我们来看这段操作
```
冒泡排序中数据的交换操作:
if (a[j] &gt; a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] &gt; value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
```
我们把执行一个赋值语句的时间粗略地计为单位时间unit_time然后分别用冒泡排序和插入排序对同一个逆序度是K的数组进行排序。用冒泡排序需要K次交换操作每次需要3个赋值语句所以交换操作总耗时就是3*K单位时间。而插入排序中数据移动操作只需要K个单位时间。
这个只是我们非常理论的分析为了实验针对上面的冒泡排序和插入排序的Java代码我写了一个性能对比测试程序随机生成10000个数组每个数组中包含200个数据然后在我的机器上分别用冒泡和插入排序算法来排序冒泡排序算法大约700ms才能执行完成而插入排序只需要100ms左右就能搞定
所以虽然冒泡排序和插入排序在时间复杂度上是一样的都是O(n<sup>2</sup>),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。插入排序的算法思路也有很大的优化空间,我们只是讲了最基础的一种。如果你对插入排序的优化感兴趣,可以自行学习一下[希尔排序](https://zh.wikipedia.org/wiki/%E5%B8%8C%E5%B0%94%E6%8E%92%E5%BA%8F)。
## 内容小结
要想分析、评价一个排序算法需要从执行效率、内存消耗和稳定性三个方面来看。因此这一节我带你分析了三种时间复杂度是O(n<sup>2</sup>)的排序算法,冒泡排序、插入排序、选择排序。你需要重点掌握的是它们的分析方法。
<img src="https://static001.geekbang.org/resource/image/34/50/348604caaf0a1b1d7fee0512822f0e50.jpg" alt="">
这三种时间复杂度为O(n<sup>2</sup>)的排序算法中,冒泡排序、选择排序,可能就纯粹停留在理论的层面了,学习的目的也只是为了开拓思维,实际开发中应用并不多,但是插入排序还是挺有用的。后面讲排序优化的时候,我会讲到,有些编程语言中的排序函数的实现原理会用到插入排序算法。
今天讲的这三种排序算法实现代码都非常简单对于小规模数据的排序用起来非常高效。但是在大规模数据排序的时候这个时间复杂度还是稍微有点高所以我们更倾向于用下一节要讲的时间复杂度为O(nlogn)的排序算法。
## 课后思考
我们讲过,特定算法是依赖特定的数据结构的。我们今天讲的几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,313 @@
<audio id="audio" title="12 | 排序如何用快排思想在O(n)内查找第K大元素" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c2/31/c26554d074671749f7ee7468de5ab831.mp3"></audio>
上一节我讲了冒泡排序、插入排序、选择排序这三种排序算法它们的时间复杂度都是O(n<sup>2</sup>)比较高适合小规模数据的排序。今天我讲两种时间复杂度为O(nlogn)的排序算法,**归并排序**和**快速排序**。这两种排序算法适合大规模的数据排序,比上一节讲的那三种排序算法要更常用。
归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题,比如:**如何在O(n)的时间复杂度内查找一个无序数组中的第K大元素** 这就要用到我们今天要讲的内容。
## 归并排序的原理
我们先来看**归并排序**Merge Sort
归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
<img src="https://static001.geekbang.org/resource/image/db/2b/db7f892d3355ef74da9cd64aa926dc2b.jpg" alt="">
归并排序使用的就是**分治思想**。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
从我刚才的描述,你有没有感觉到,分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。**分治是一种解决问题的处理思想,递归是一种编程技巧**,这两者并不冲突。分治算法的思想我后面会有专门的一节来讲,现在不展开讨论,我们今天的重点还是排序算法。
前面我通过举例让你对归并有了一个感性的认识,又告诉你,归并排序用的是分治思想,可以用递归来实现。我们现在就来看看**如何用递归代码来实现归并排序**。
我在[第10节](https://time.geekbang.org/column/article/41440)讲的递归代码的编写技巧你还记得吗?写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。
```
递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:
p &gt;= r 不用再继续分解
```
我来解释一下这个递推公式。
merge_sort(p…r)表示给下标从p到r之间的数组排序。我们将这个排序问题转化为了两个子问题merge_sort(p…q)和merge_sort(q+1…r)其中下标q等于p和r的中间位置也就是(p+r)/2。当下标从p到q和从q+1到r这两个子数组都排好序之后我们再将两个有序的子数组合并在一起这样下标从p到r之间的数据就也排好序了。
有了递推公式,转化成代码就简单多了。为了阅读方便,我这里只给出伪代码,你可以翻译成你熟悉的编程语言。
```
// 归并排序算法, A是数组n表示数组大小
merge_sort(A, n) {
merge_sort_c(A, 0, n-1)
}
// 递归调用函数
merge_sort_c(A, p, r) {
// 递归终止条件
if p &gt;= r then return
// 取p到r之间的中间位置q
q = (p+r) / 2
// 分治递归
merge_sort_c(A, p, q)
merge_sort_c(A, q+1, r)
// 将A[p...q]和A[q+1...r]合并为A[p...r]
merge(A[p...r], A[p...q], A[q+1...r])
}
```
你可能已经发现了merge(A[p...r], A[p...q], A[q+1...r])这个函数的作用就是将已经有序的A[p...q]和A[q+1....r]合并成一个有序的数组并且放入A[p....r]。那这个过程具体该如何做呢?
如图所示我们申请一个临时数组tmp大小与A[p...r]相同。我们用两个游标i和j分别指向A[p...q]和A[q+1...r]的第一个元素。比较这两个元素A[i]和A[j]如果A[i]&lt;=A[j]我们就把A[i]放入到临时数组tmp并且i后移一位否则将A[j]放入到数组tmpj后移一位。
继续上述比较过程直到其中一个子数组中的所有数据都放入临时数组中再把另一个数组中的数据依次加入到临时数组的末尾这个时候临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p...r]中。
<img src="https://static001.geekbang.org/resource/image/95/2f/95897ade4f7ad5d10af057b1d144a22f.jpg" alt="">
我们把merge()函数写成伪代码,就是下面这样:
```
merge(A[p...r], A[p...q], A[q+1...r]) {
var i := pj := q+1k := 0 // 初始化变量i, j, k
var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组
while i&lt;=q AND j&lt;=r do {
if A[i] &lt;= A[j] {
tmp[k++] = A[i++] // i++等于i:=i+1
} else {
tmp[k++] = A[j++]
}
}
// 判断哪个子数组中有剩余的数据
var start := iend := q
if j&lt;=r then start := j, end:=r
// 将剩余的数据拷贝到临时数组tmp
while start &lt;= end do {
tmp[k++] = A[start++]
}
// 将tmp中的数组拷贝回A[p...r]
for i:=0 to r-p do {
A[p+i] = tmp[i]
}
}
```
你还记得[第7讲](https://time.geekbang.org/column/article/41149)讲过的利用哨兵简化编程的处理技巧吗merge()合并函数如果借助哨兵,代码就会简洁很多,这个问题留给你思考。
## 归并排序的性能分析
这样跟着我一步一步分析,归并排序是不是没那么难啦?还记得上节课我们分析排序算法的三个问题吗?接下来,我们来看归并排序的三个问题。
**第一,归并排序是<strong><strong>稳定**</strong>的排序算法吗?</strong>
结合我前面画的那张图和归并排序的伪代码你应该能发现归并排序稳不稳定关键要看merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代码。
在合并的过程中如果A[p...q]和A[q+1...r]之间有值相同的元素那我们可以像伪代码中那样先把A[p...q]中的元素放入tmp数组。这样就保证了值相同的元素在合并前后的先后顺序不变。所以归并排序是一个稳定的排序算法。
**第二,归并排序的<strong><strong>时间复杂度**</strong>是多少?</strong>
归并排序涉及递归,时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下,如何分析递归代码的时间复杂度。
在递归那一节我们讲过递归的适用场景是一个问题a可以分解为多个子问题b、c那求解问题a就可以分解为求解问题b、c。问题b、c解决之后我们再把b、c的结果合并成a的结果。
如果我们定义求解问题a的时间是T(a)求解问题b、c的时间分别是T(b)和 T( c),那我们就可以得到这样的递推关系式:
```
T(a) = T(b) + T(c) + K
```
其中K等于将两个子问题b、c的结果合并成问题a的结果所消耗的时间。
从刚刚的分析,我们可以得到一个重要的结论:**不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。**
套用这个公式,我们来分析一下归并排序的时间复杂度。
我们假设对n个元素进行归并排序需要的时间是T(n)那分解成两个子数组排序的时间都是T(n/2)。我们知道merge()函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
```
T(1) = C n=1时只需要常量级的执行时间所以表示为C。
T(n) = 2*T(n/2) + n n&gt;1
```
通过这个公式如何来求解T(n)呢?还不够直观?那我们再进一步分解一下计算过程。
```
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......
```
通过这样一步一步分解推导我们可以得到T(n) = 2^k**T(n/2^k)+k**n。当T(n/2^k)=T(1)时也就是n/2^k=1我们得到k=log<sub>2</sub>n 。我们将k值代入上面的公式得到T(n)=C**n+n**log<sub>2</sub>n 。如果我们用大O标记法来表示的话T(n)就等于O(nlogn)。所以归并排序的时间复杂度是O(nlogn)。
从我们的原理分析和伪代码可以看出归并排序的执行效率与要排序的原始数组的有序程度无关所以其时间复杂度是非常稳定的不管是最好情况、最坏情况还是平均情况时间复杂度都是O(nlogn)。
**第三,归并排序的<strong><strong>空间复杂度**</strong>是多少?</strong>
归并排序的时间复杂度任何情况下都是O(nlogn)看起来非常优秀。待会儿你会发现即便是快速排序最坏情况下时间复杂度也是O(n<sup>2</sup>)。)但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。
这是因为归并排序的合并函数在合并两个有序数组为一个有序数组时需要借助额外的存储空间。这一点你应该很容易理解。那我现在问你归并排序的空间复杂度到底是多少呢是O(n)还是O(nlogn),应该如何分析呢?
如果我们继续按照分析递归时间复杂度的方法通过递推公式来求解那整个归并过程需要的空间复杂度就是O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?
实际上递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点那就是尽管每次合并操作都需要申请额外的内存空间但在合并完成之后临时开辟的内存空间就被释放掉了。在任意时刻CPU只会有一个函数在执行也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小所以空间复杂度是O(n)。
## 快速排序的原理
我们再来看快速排序算法Quicksort我们习惯性把它简称为“快排”。快排利用的也是分治思想。乍看起来它有点像归并排序但是思路其实完全不一样。我们待会会讲两者的区别。现在我们先来看下快排的核心思想。
快排的思想是这样的如果要排序数组中下标从p到r之间的一组数据我们选择p到r之间的任意一个数据作为pivot分区点
我们遍历p到r之间的数据将小于pivot的放到左边将大于pivot的放到右边将pivot放到中间。经过这一步骤之后数组p到r之间的数据就被分成了三个部分前面p到q-1之间都是小于pivot的中间是pivot后面的q+1到r之间是大于pivot的。
<img src="https://static001.geekbang.org/resource/image/4d/81/4d892c3a2e08a17f16097d07ea088a81.jpg" alt="">
根据分治、递归的处理思想我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据直到区间缩小为1就说明所有的数据都有序了。
如果我们用递推公式来将上面的过程写出来的话,就是这样:
```
递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)
终止条件:
p &gt;= r
```
我将递推公式转化成递归代码。跟归并排序一样,我还是用伪代码来实现,你可以翻译成你熟悉的任何语言。
```
// 快速排序A是数组n表示数组的大小
quick_sort(A, n) {
quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数p,r为下标
quick_sort_c(A, p, r) {
if p &gt;= r then return
q = partition(A, p, r) // 获取分区点
quick_sort_c(A, p, q-1)
quick_sort_c(A, q+1, r)
}
```
归并排序中有一个merge()合并函数我们这里有一个partition()分区函数。partition()分区函数实际上我们前面已经讲过了就是随机选择一个元素作为pivot一般情况下可以选择p到r区间的最后一个元素然后对A[p...r]分区函数返回pivot的下标。
如果我们不考虑空间消耗的话partition()分区函数可以写得非常简单。我们申请两个临时数组X和Y遍历A[p...r]将小于pivot的元素都拷贝到临时数组X将大于pivot的元素都拷贝到临时数组Y最后再将数组X和数组Y中数据顺序拷贝到A[p....r]。
<img src="https://static001.geekbang.org/resource/image/66/dc/6643bc3cef766f5b3e4526c332c60adc.jpg" alt="">
但是如果按照这种思路实现的话partition()函数就需要很多额外的内存空间所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法那它的空间复杂度得是O(1)那partition()分区函数就不能占用太多额外的内存空间我们就需要在A[p...r]的原地完成分区操作。
原地分区函数的实现思路非常巧妙,我写成了伪代码,我们一起来看一下。
```
partition(A, p, r) {
pivot := A[r]
i := p
for j := p to r-1 do {
if A[j] &lt; pivot {
swap A[i] with A[j]
i := i+1
}
}
swap A[i] with A[r]
return i
```
这里的处理有点类似选择排序。我们通过游标i把A[p...r-1]分成两部分。A[p...i-1]的元素都是小于pivot的我们暂且叫它“已处理区间”A[i...r-1]是“未处理区间”。我们每次都从未处理的区间A[i...r-1]中取一个元素A[j]与pivot对比如果小于pivot则将其加入到已处理区间的尾部也就是A[i]的位置。
数组的插入操作还记得吗在数组某个位置插入元素需要搬移数据非常耗时。当时我们也讲了一种处理技巧就是交换在O(1)的时间复杂度内完成插入操作。这里我们也借助这个思想只需要将A[i]与A[j]交换就可以在O(1)时间复杂度内将A[j]放到下标为i的位置。
文字不如图直观,所以我画了一张图来展示分区的整个过程。
<img src="https://static001.geekbang.org/resource/image/08/e7/086002d67995e4769473b3f50dd96de7.jpg" alt="">
因为分区的过程涉及交换操作如果数组中有两个相同的元素比如序列68763594在经过第一次分区操作之后两个6的相对先后顺序就会改变。所以快速排序并不是一个稳定的排序算法。
到此,快速排序的原理你应该也掌握了。现在,我再来看另外一个问题:快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?
<img src="https://static001.geekbang.org/resource/image/aa/05/aa03ae570dace416127c9ccf9db8ac05.jpg" alt="">
可以发现,归并排序的处理过程是**由下到上**的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是**由上到下**的先分区然后再处理子问题。归并排序虽然是稳定的、时间复杂度为O(nlogn)的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
## 快速排序的性能分析
现在,我们来分析一下快速排序的性能。我在讲解快排的实现原理的时候,已经分析了稳定性和空间复杂度。快排是一种原地、不稳定的排序算法。现在,我们集中精力来看快排的时间复杂度。
快排也是用递归来实现的。对于递归代码的时间复杂度我前面总结的公式这里也还是适用的。如果每次分区操作都能正好把数组分成大小接近相等的两个小区间那快排的时间复杂度递推求解公式跟归并是相同的。所以快排的时间复杂度也是O(nlogn)。
```
T(1) = C n=1时只需要常量级的执行时间所以表示为C。
T(n) = 2*T(n/2) + n n&gt;1
```
但是公式成立的前提是每次分区操作我们选择的pivot都很合适正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。
我举一个比较极端的例子。如果数组中的数据原来已经是有序的了比如13568。如果我们每次选择最后一个元素作为pivot那每次分区得到的两个区间都是不均等的。我们需要进行大约n次分区操作才能完成快排的整个过程。每次分区我们平均要扫描大约n/2个元素这种情况下快排的时间复杂度就从O(nlogn)退化成了O(n<sup>2</sup>)。
我们刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢?
我们假设每次分区操作都将区间分成大小为9:1的两个小区间。我们继续套用递归时间复杂度的递推公式就会变成这样
```
T(1) = C n=1时只需要常量级的执行时间所以表示为C。
T(n) = T(n/10) + T(9*n/10) + n n&gt;1
```
这个公式的递推求解的过程非常复杂虽然可以求解但我不推荐用这种方法。实际上递归的时间复杂度的求解方法除了递推公式之外还有递归树在树那一节我再讲这里暂时不说。我这里直接给你结论T(n)在大部分情况下的时间复杂度都可以做到O(nlogn)只有在极端情况下才会退化到O(n<sup>2</sup>)。而且,我们也有很多方法将这个概率降到很低,如何来做?我们后面章节再讲。
## 解答开篇
快排核心思想就是**分治**和**分区**我们可以利用分区的思想来解答开篇的问题O(n)时间复杂度内求无序数组中的第K大元素。比如4 2 5 12 3这样一组数据第3大元素就是4。
我们选择数组区间A[0...n-1]的最后一个元素A[n-1]作为pivot对数组A[0...n-1]原地分区这样数组就分成了三部分A[0...p-1]、A[p]、A[p+1...n-1]。
如果p+1=K那A[p]就是要求解的元素如果K&gt;p+1, 说明第K大元素出现在A[p+1...n-1]区间我们再按照上面的思路递归地在A[p+1...n-1]这个区间内查找。同理如果K&lt;p+1那我们就在A[0...p-1]区间查找。
<img src="https://static001.geekbang.org/resource/image/89/91/898d94fc32e0a795fd65897293b98791.jpg" alt="">
我们再来看为什么上述解决思路的时间复杂度是O(n)
第一次分区查找我们需要对大小为n的数组执行分区操作需要遍历n个元素。第二次分区查找我们只需要对大小为n/2的数组执行分区操作需要遍历n/2个元素。依次类推分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为1。
如果我们把每次分区遍历的元素个数加起来就是n+n/2+n/4+n/8+...+1。这是一个等比数列求和最后的和等于2n-1。所以上述解决思路的时间复杂度就为O(n)。
你可能会说我有个很笨的办法每次取数组中的最小值将其移动到数组的最前面然后在剩下的数组中继续找最小值以此类推执行K次找到的数据不就是第K大元素了吗
不过时间复杂度就并不是O(n)了而是O(K * n)。你可能会说时间复杂度前面的系数不是可以忽略吗O(K * n)不就等于O(n)吗?
这个可不能这么简单地划等号。当K是比较小的常量时比如1、2那最好时间复杂度确实是O(n)但当K等于n/2或者n时这种最坏情况下的时间复杂度就是O(n<sup>2</sup>)了。
## 内容小结
归并排序和快速排序是两种稍微复杂的排序算法它们用的都是分治的思想代码都通过递归来实现过程非常相似。理解归并排序的重点是理解递推公式和merge()合并函数。同理理解快排的重点也是理解递推公式还有partition()分区函数。
归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法这也使它存在致命的缺点即归并排序不是原地排序算法空间复杂度比较高是O(n)。正因为此,它也没有快排应用广泛。
快速排序算法虽然最坏情况下的时间复杂度是O(n<sup>2</sup>)但是平均情况下时间复杂度都是O(nlogn)。不仅如此快速排序算法时间复杂度退化到O(n<sup>2</sup>)的概率非常小我们可以通过合理地选择pivot来避免这种情况。
## 课后思考
现在你有10个接口访问日志文件每个日志文件大小约300MB每个文件里的日志都是按照时间戳从小到大排序的。你希望将这10个较小的日志文件合并为1个日志文件合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有1GB你有什么好的解决思路能“快速”地将这10个日志文件合并吗
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,172 @@
<audio id="audio" title="13 | 线性排序如何根据年龄给100万用户数据排序" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f0/0e/f086c20cb641aa07c4662a5yy658350e.mp3"></audio>
上两节中我带你着重分析了几种常用排序算法的原理、时间复杂度、空间复杂度、稳定性等。今天我会讲三种时间复杂度是O(n)的排序算法:桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作**线性排序**Linear sort。之所以能做到线性的时间复杂度主要原因是这三个算法是非基于比较的排序算法都不涉及元素之间的比较操作。
这几种排序算法理解起来都不难,时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以我们**今天的学习重点是掌握这些排序算法的适用场景**。
按照惯例,我先给你出一道思考题:**如何根据年龄给100万用户排序** 你可能会说我用上一节课讲的归并、快排就可以搞定啊是的它们也可以完成功能但是时间复杂度最低也是O(nlogn)。有没有更快的排序方法呢?让我们一起进入今天的内容!
## 桶排序Bucket sort
首先,我们来看桶排序。桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
<img src="https://static001.geekbang.org/resource/image/98/ae/987564607b864255f81686829503abae.jpg" alt="">
桶排序的时间复杂度为什么是O(n)呢?我们一块儿来分析一下。
如果要排序的数据有n个我们把它们均匀地划分到m个桶内每个桶里就有k=n/m个元素。每个桶内部使用快速排序时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk)因为k=n/m所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时log(n/m)就是一个非常小的常量这个时候桶排序的时间复杂度接近O(n)。
**桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?**
答案当然是否定的。为了让你轻松理解桶排序的核心思想,我刚才做了很多假设。实际上,桶排序对要排序数据的要求是非常苛刻的。
首先要排序的数据需要很容易就能划分成m个桶并且桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后桶与桶之间的数据不需要再进行排序。
其次数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后有些桶里的数据非常多有些非常少很不平均那桶内数据排序的时间复杂度就不是常量级了。在极端情况下如果数据都被划分到一个桶里那就退化为O(nlogn)的排序算法了。
**桶排序比较适合用在外部排序中**。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
比如说我们有10GB的订单数据我们希望按订单金额假设金额都是正整数进行排序但是我们的内存有限只有几百MB没办法一次性把10GB的数据都加载到内存中。这个时候该怎么办呢
现在我来讲一下,如何借助桶排序的处理思想来解决这个问题。
我们可以先扫描一遍文件看订单金额所处的数据范围。假设经过扫描之后我们得到订单金额最小是1元最大是10万元。我们将所有订单根据金额划分到100个桶里第一个桶我们存储金额在1元到1000元之内的订单第二桶存储金额在1001元到2000元之内的订单以此类推。每一个桶对应一个文件并且按照金额范围的大小顺序编号命名000102...99)。
理想的情况下如果订单金额在1到10万之间均匀分布那订单会被均匀划分到100个文件中每个小文件中存储大约100MB的订单数据我们就可以将这100个小文件依次放到内存中用快排来排序。等所有文件都排好序之后我们只需要按照文件编号从小到大依次读取每个小文件中的订单数据并将其写入到一个文件中那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过你可能也发现了订单按照金额在1元到10万元之间并不一定是均匀分布的 所以10GB订单数据是无法均匀地被划分到100个文件中的。有可能某个金额区间的数据特别多划分之后对应的文件就会很大没法一次性读入内存。这又该怎么办呢
针对这些划分之后还是比较大的文件我们可以继续划分比如订单金额在1元到1000元之间的比较多我们就将这个区间继续划分为10个小区间1元到100元101元到200元201元到300元....901元到1000元。如果划分之后101元到200元之间的订单还是太多无法一次性读入内存那就继续再划分直到所有的文件都能读入内存为止。
## 计数排序Counting sort
我个人觉得,**计数排序其实是桶排序的一种特殊情况**。当要排序的n个数据所处的范围并不大的时候比如最大值是k我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的省掉了桶内排序的时间。
我们都经历过高考高考查分数系统你还记得吗我们查分数的时候系统会显示我们的成绩以及所在省的排名。如果你所在的省有50万考生如何通过成绩快速排序得出名次呢
考生的满分是900分最小是0分这个数据的范围很小所以我们可以分成901个桶对应分数从0分到900分。根据考生的成绩我们将这50万考生划分到这901个桶里。桶内的数据都是分数相同的考生所以并不需要再进行排序。我们只需要依次扫描每个桶将桶内的考生依次输出到一个数组中就实现了50万考生的排序。因为只涉及扫描遍历操作所以时间复杂度是O(n)。
计数排序的算法思想就是这么简单,跟桶排序非常类似,只是桶的大小粒度不一样。**不过,为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?**
想弄明白这个问题我们就要来看计数排序算法的实现方法。我还拿考生那个例子来解释。为了方便说明我对数据规模做了简化。假设只有8个考生分数在0到5分之间。这8个考生的成绩我们放在一个数组A[8]中它们分别是25302303。
考生的成绩从0到5分我们使用大小为6的数组C[6]表示桶其中下标对应分数。不过C[6]内存储的并不是考生而是对应的考生个数。像我刚刚举的那个例子我们只需要遍历一遍考生分数就可以得到C[6]的值。
<img src="https://static001.geekbang.org/resource/image/ad/c9/adc75672ef33fa54b023a040834fcbc9.jpg" alt="">
从图中可以看出分数为3分的考生有3个小于3分的考生有4个所以成绩为3分的考生在排序之后的有序数组R[8]中会保存下标456的位置。
<img src="https://static001.geekbang.org/resource/image/36/29/361f4d781d2a2d144dcbbbb0b9e6db29.jpg" alt="">
那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙,很不容易想到。
思路是这样的我们对C[6]数组顺序求和C[6]存储的数据就变成了下面这样子。C[k]里存储小于等于分数k的考生个数。
<img src="https://static001.geekbang.org/resource/image/dd/1f/dd6c62b12b0dc1b3a294af0fa1ce371f.jpg" alt="">
有了前面的数据准备之后,现在我就要讲计数排序中最复杂、最难理解的一部分了,请集中精力跟着我的思路!
我们从后到前依次扫描数组A。比如当扫描到3时我们可以从数组C中取出下标为3的值7也就是说到目前为止包括自己在内分数小于等于3的考生有7个也就是说3是数组R中的第7个元素也就是数组R中下标为6的位置。当3放入到数组R中后小于等于3的元素就只剩下了6个了所以相应的C[3]要减1变成6。
以此类推当我们扫描到第2个分数为3的考生的时候就会把它放入数组R中的第6个元素的位置也就是下标为5的位置。当我们扫描完整个数组A后数组R内的数据就是按照分数从小到大有序排列的了。
<img src="https://static001.geekbang.org/resource/image/1d/84/1d730cb17249f8e92ef5cab53ae65784.jpg" alt="">
上面的过程有点复杂,我写成了代码,你可以对照着看下。
```
// 计数排序a是数组n是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
if (n &lt;= 1) return;
// 查找数组中数据的范围
int max = a[0];
for (int i = 1; i &lt; n; ++i) {
if (max &lt; a[i]) {
max = a[i];
}
}
int[] c = new int[max + 1]; // 申请一个计数数组c下标大小[0,max]
for (int i = 0; i &lt;= max; ++i) {
c[i] = 0;
}
// 计算每个元素的个数放入c中
for (int i = 0; i &lt; n; ++i) {
c[a[i]]++;
}
// 依次累加
for (int i = 1; i &lt;= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 临时数组r存储排序之后的结果
int[] r = new int[n];
// 计算排序的关键步骤,有点难理解
for (int i = n - 1; i &gt;= 0; --i) {
int index = c[a[i]]-1;
r[index] = a[i];
c[a[i]]--;
}
// 将结果拷贝给a数组
for (int i = 0; i &lt; n; ++i) {
a[i] = r[i];
}
}
```
这种利用另外一个数组来计数的实现方式是不是很巧妙呢?这也是为什么这种排序算法叫计数排序的原因。不过,你千万不要死记硬背上面的排序过程,重要的是理解和会用。
我总结一下,**计数排序只能用在数据范围不大的场景<strong><strong>中**</strong>如果数据范围k比要排序的数据n大很多就**<strong>不**</strong>适合用**<strong>计数**</strong>排序了。而且,**<strong>计数**</strong>排序只能**<strong>给**</strong>非负整数**<strong>排序**</strong>,如果要排序的数据是其他类型的,要**<strong>将**</strong>其在不改变相对大小的情况下,转化为非负整数。</strong>
比如还是拿考生这个例子。如果考生成绩精确到小数后一位我们就需要将所有的分数都先乘以10转化成整数然后再放到9010个桶内。再比如如果要排序的数据中有负数数据的范围是[-1000, 1000]那我们就需要先对每个数据都加1000转化成非负整数。
## 基数排序Radix sort
我们再来看这样一个排序问题。假设我们有10万个手机号码希望将这10万个手机号码从小到大排序你有什么比较快速的排序方法呢
我们之前讲的快排时间复杂度可以做到O(nlogn)还有更高效的排序算法吗桶排序、计数排序能派上用场吗手机号码有11位范围太大显然不适合用这两种排序算法。针对这个排序问题有没有时间复杂度是O(n)的算法呢?现在我就来介绍一种新的排序算法,基数排序。
刚刚这个问题里有这样的规律假设要比较两个手机号码ab的大小如果在前面几位中a手机号码已经比b手机号码大了那后面的几位就不用看了。
借助稳定排序算法这里有一个巧妙的实现思路。还记得我们第11节中在阐述排序算法的稳定性的时候举的订单的例子吗我们这里也可以借助相同的处理思路先按照最后一位来排序手机号码然后再按照倒数第二位重新排序以此类推最后按照第一位重新排序。经过11次排序之后手机号码就都有序了。
手机号码稍微有点长,画图比较不容易看清楚,我用字符串排序的例子,画了一张基数排序的过程分解图,你可以看下。
<img src="https://static001.geekbang.org/resource/image/df/0c/df0cdbb73bd19a2d69a52c54d8b9fc0c.jpg" alt="">
注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。
根据每一位来排序我们可以用刚讲过的桶排序或者计数排序它们的时间复杂度可以做到O(n)。如果要排序的数据有k位那我们就需要k次桶排序或者计数排序总的时间复杂度是O(k*n)。当k不大的时候比如手机号码排序的例子k最大就是11所以基数排序的时间复杂度就近似于O(n)。
实际上有时候要排序的数据并不都是等长的比如我们排序牛津字典中的20万个英文单词最短的只有1个字母最长的我特意去查了下有45个字母中文翻译是尘肺病。对于这种不等长的数据基数排序还适用吗
实际上,**我们可以把所有的单词补齐到相同长度位数不够的可以在后面补“0”**,因为根据[ASCII值](https://zh.wiktionary.org/wiki/US-ASCII)所有字母都大于“0”所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。
我来总结一下,**基数排序对要排序的数据是有要求的需要可以分割出独立的“位”来比较而且位之间有递进的关系如果a数据的高位比b数据大那剩下的低位就不用比较了。除此之外每一位的数据范围不能太大要可以用线性排序算法来排序否则基数排序的时间复杂度就无法做到O(n)了**。
## 解答开篇
今天的内容学完了。我们再回过头来看看开篇的思考题如何根据年龄给100万用户排序现在思考题是不是变得非常简单了呢我来说一下我的解决思路。
实际上根据年龄给100万用户排序就类似按照成绩给50万考生排序。我们假设年龄的范围最小1岁最大不超过120岁。我们可以遍历这100万用户根据年龄将其划分到这120个桶里然后依次顺序遍历这120个桶中的元素。这样就得到了按照年龄排序的100万用户数据。
## 内容小结
今天我们学习了3种线性时间复杂度的排序算法有桶排序、计数排序、基数排序。它们对要排序的数据都有比较苛刻的要求应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求应用这些算法会非常高效线性时间复杂度可以达到O(n)。
桶排序和计数排序的排序思想是非常相似的,都是针对范围不大的数据,将数据划分成不同的桶来实现排序。基数排序要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。而且每一位的数据范围不能太大,因为基数排序算法需要借助桶排序或者计数排序来完成每一个位的排序工作。
## 课后思考
我们今天讲的都是针对特殊数据的排序算法。实际上,还有很多看似是排序但又不需要使用排序算法就能处理的排序问题。
假设我们现在需要对DaFBcAz这个字符串进行排序要求将其中所有小写字母都排在大写字母的前面但小写字母内部和大写字母内部不要求有序。比如经过排序之后为aczDFBA这个如何来实现呢如果字符串中存储的不仅有大小写字母还有数字。要将小写字母的放到前面大写字母放在最后数字放在中间不用排序算法又该怎么解决呢
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,100 @@
<audio id="audio" title="14 | 排序优化:如何实现一个通用的、高性能的排序函数?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d0/89/d01a09c1ae936811bb24412127047289.mp3"></audio>
几乎所有的编程语言都会提供排序函数比如C语言中qsort()C++ STL中的sort()、stable_sort()还有Java语言中的Collections.sort()。在平时的开发中,我们也都是直接使用这些现成的函数来实现业务逻辑中的排序功能。那你知道这些排序函数是如何实现的吗?底层都利用了哪种排序算法呢?
基于这些问题,今天我们就来看排序这部分的最后一块内容:**如何实现一个通用的、高性能的排序函数?**
## 如何选择合适的排序算法?
如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?我们先回顾一下前面讲过的几种排序算法。
<img src="https://static001.geekbang.org/resource/image/1f/fd/1f6ef7e0a5365d6e9d68f0ccc71755fd.jpg" alt="">
我们前面讲过,线性排序算法的时间复杂度比较低,适用场景比较特殊。所以如果要写一个通用的排序函数,不能选择线性排序算法。
如果对小规模数据进行排序可以选择时间复杂度是O(n<sup>2</sup>)的算法如果对大规模数据进行排序时间复杂度是O(nlogn)的算法更加高效。所以为了兼顾任意规模数据的排序一般都会首选时间复杂度是O(nlogn)的排序算法来实现排序函数。
时间复杂度是O(nlogn)的排序算法不止一个我们已经讲过的有归并排序、快速排序后面讲堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用比如Java语言采用堆排序实现排序函数C语言使用快速排序实现排序函数。
不知道你有没有发现使用归并排序的情况其实并不多。我们知道快排在最坏情况下的时间复杂度是O(n<sup>2</sup>)而归并排序可以做到平均情况、最坏情况下的时间复杂度都是O(nlogn),从这点上看起来很诱人,那为什么它还是没能得到“宠信”呢?
还记得我们上一节讲的归并排序的空间复杂度吗归并排序并不是原地排序算法空间复杂度是O(n)。所以粗略点、夸张点讲如果要排序100MB的数据除了数据本身占用的内存之外排序算法还要额外再占用100MB的内存空间空间耗费就翻倍了。
前面我们讲到快速排序比较适合来实现排序函数但是我们也知道快速排序在最坏情况下的时间复杂度是O(n<sup>2</sup>),如何来解决这个“复杂度恶化”的问题呢?
## 如何优化快速排序?
我们先来看下为什么最坏情况下快速排序的时间复杂度是O(n<sup>2</sup>)呢我们前面讲过如果数据原来就是有序的或者接近有序的每次分区点都选择最后一个数据那快速排序算法就会变得非常糟糕时间复杂度就会退化为O(n<sup>2</sup>)。实际上,**这种O(n<sup>2</sup>)时间复杂度出现的主要原因还是因为我们分区点选得不够合理**。
那什么样的分区点是好的分区点呢?或者说如何来选择分区点呢?
最理想的分区点是:**被分区点分开的两个分区中,数据的数量差不多**。
如果很粗暴地直接选择第一个或者最后一个数据作为分区点不考虑数据的特点肯定会出现之前讲的那样在某些情况下排序的最坏情况时间复杂度是O(n<sup>2</sup>)。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均。
我这里介绍两个比较常用、比较简单的分区算法,你可以直观地感受一下。
### 1.三数取中法
我们从区间的首、尾、中间分别取出一个数然后对比大小取这3个数的中间值作为分区点。这样每间隔某个固定的长度取数据出来比较将中间值作为分区点的分区算法肯定要比单纯取某一个数据更好。但是如果要排序的数组比较大那“三数取中”可能就不够了可能要“五数取中”或者“十数取中”。
### 2.随机法
随机法就是每次从要排序的区间中随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好但是从概率的角度来看也不大可能会出现每次分区点都选得很差的情况所以平均情况下这样选的分区点是比较好的。时间复杂度退化为最糟糕的O(n<sup>2</sup>)的情况,出现的可能性不大。
好了,我这里也只是抛砖引玉,如果想了解更多寻找分区点的方法,你可以自己课下深入去学习一下。
我们知道,快速排序是用递归来实现的。我们在递归那一节讲过,递归要警惕堆栈溢出。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。
## 举例分析排序函数
为了让你对如何实现一个排序函数有一个更直观的感受我拿Glibc中的qsort()函数举例说明一下。虽说qsort()从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。
如果你去看源码,你就会发现,**qsort()会优先使用归并排序来排序输入数据**因为归并排序的空间复杂度是O(n)所以对于小数据量的排序比如1KB、2KB等归并排序额外需要1KB、2KB的内存空间这个问题不大。现在计算机的内存都挺大的我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗这就是一个典型的应用。
但如果数据量太大就跟我们前面提到的排序100MB的数据这个时候我们再用归并排序就不合适了。所以**要排序的数据量比较大的时候qsort()会改为用快速排序算法来排序**。
那qsort()是如何选择快速排序算法的分区点的呢如果去看源码你就会发现qsort()选择分区点的方法就是“三数取中法”。是不是也并不复杂?
还有我们前面提到的递归太深会导致堆栈溢出的问题qsort()是通过自己实现一个堆上的栈,手动模拟递归来解决的。我们之前在讲递归那一节也讲过,不知道你还有没有印象?
实际上qsort()并不仅仅用到了归并排序和快速排序它还用到了插入排序。在快速排序的过程中当要排序的区间中元素的个数小于等于4时qsort()就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,**O(n<sup>2</sup>)时间复杂度的算法并不一定比O(nlogn)的算法执行时间长**。我们现在就来分析下这个说法。
我们在讲复杂度分析的时候讲过,算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,如果我们深究的话,实际上时间复杂度并不等于代码实际的运行时间。
时间复杂度代表的是一个增长趋势如果画成增长曲线图你会发现O(n<sup>2</sup>)比O(nlogn)要陡峭也就是说增长趋势要更猛一些。但是我们前面讲过在大O复杂度表示法中我们会省略低阶、系数和常数也就是说O(nlogn)在没有省略低阶、系数、常数之前可能是O(knlogn + c)而且k和c有可能还是一个比较大的数。
假设k=1000c=200当我们对小规模数据比如n=100排序时n<sup>2</sup>的值实际上比knlogn+c还要小。
```
knlogn+c = 1000 * 100 * log100 + 200 远大于10000
n^2 = 100*100 = 10000
```
所以对于小规模数据的排序O(n<sup>2</sup>)的排序算法并不一定比O(nlogn)排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。
还记得我们之前讲到的哨兵来简化代码提高执行效率吗在qsort()插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致。
好了C语言的qsort()我已经分析完了,你有没有觉得其实也不是很难?基本上都是用了我们前面讲到的知识点,有了前面的知识积累,看一些底层的类库的时候是不是也更容易了呢?
## 内容小结
今天我带你分析了一下如何来实现一个工业级的通用的、高效的排序函数内容比较偏实战而且贯穿了一些前面几节的内容你要多看几遍。我们大部分排序函数都是采用O(nlogn)排序算法来实现,但是为了尽可能地提高性能,会做很多优化。
我还着重讲了快速排序的一些优化策略比如合理选择分区点、避免递归太深等等。最后我还带你分析了一个C语言中qsort()的底层实现原理,希望你对此能有一个更加直观的感受。
## 课后思考
在今天的内容中我分析了C语言的中的qsort()的底层排序算法,你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢?都有哪些优化技巧?
欢迎留言和我分享,我会第一时间给你反馈。
**特别说明:**
专栏已经更新一月有余,我在留言区看到很多同学说,希望给出课后思考题的标准答案。鉴于留言区里本身就有很多非常好的答案,之后我会将我认为比较好的答案置顶在留言区,供需要的同学参考。
如果文章发布一周后,留言里依旧没有比较好的答案,我会把我的答案写出来置顶在留言区。
最后,**希望你把思考的过程看得比标准答案更重要。**

View File

@@ -0,0 +1,172 @@
<audio id="audio" title="15 | 二分查找(上):如何用最省内存的方式实现快速查找功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/ba/e90ff904d68d6ce7194aaa5745f78cba.mp3"></audio>
今天我们讲一种针对有序数据集合的查找算法二分查找Binary Search算法也叫折半查找算法。二分查找的思想非常简单很多非计算机专业的同学很容易就能理解但是看似越简单的东西往往越难掌握好想要灵活应用就更加困难。
老规矩,我们还是来看一道思考题。
假设我们有1000万个整数数据每个数据占8个字节**如何设计数据结构和算法快速判断某个整数是否出现在这1000万数据中** 我们希望这个功能不要占用太多的内存空间最多不要超过100MB你会怎么做呢带着这个问题让我们进入今天的内容吧
## 无处不在的二分思想
二分查找是一种非常简单易懂的快速查找算法生活中到处可见。比如说我们现在来做一个猜字游戏。我随机写一个0到99之间的数字然后你来猜我写的是什么。猜的过程中你每猜一次我就会告诉你猜的大了还是小了直到猜中为止。你来想想如何快速猜中我写的数字呢
假设我写的数字是23你可以按照下面的步骤来试一试。如果猜测范围的数字有偶数个中间数有两个就选择较小的那个。
<img src="https://static001.geekbang.org/resource/image/9d/9b/9dadf04cdfa7b3724e0df91da7cacd9b.jpg" alt="">
7次就猜出来了是不是很快这个例子用的就是二分思想按照这个思想即便我让你猜的是0到999的数字最多也只要10次就能猜中。不信的话你可以试一试。
这是一个生活中的例子我们现在回到实际的开发场景中。假设有1000条订单数据已经按照订单金额从小到大排序每个订单金额都不同并且最小单位是元。我们现在想知道是否存在金额等于19元的订单。如果存在则返回订单数据如果不存在则返回null。
最简单的办法当然是从第一个订单开始一个一个遍历这1000个订单直到找到金额等于19元的订单为止。但这样查找会比较慢最坏情况下可能要遍历完这1000条记录才能找到。那用二分查找能不能更快速地解决呢
为了方便讲解我们假设只有10个订单订单金额分别是8111923273345556798。
还是利用二分思想每次都与区间的中间数据比对大小缩小查找区间的范围。为了更加直观我画了一张查找过程的图。其中low和high表示待查找区间的下标mid表示待查找区间的中间元素下标。
<img src="https://static001.geekbang.org/resource/image/8b/29/8bce81259abf0e9a06f115e22586b829.jpg" alt="">
看懂这两个例子,你现在对二分的思想应该掌握得妥妥的了。我这里稍微总结升华一下,**二分查找针对的是一个有序的数据集合查找思想有点类似分治思想。每次都通过跟区间的中间元素对比将待查找的区间缩小为之前的一半直到找到要查找的元素或者区间被缩小为0**。
## O(logn)惊人的查找速度
二分查找是一种非常高效的查找算法,高效到什么程度呢?我们来分析一下它的时间复杂度。
我们假设数据大小是n每次查找后数据都会缩小为原来的一半也就是会除以2。最坏情况下直到查找区间被缩小为空才停止。
<img src="https://static001.geekbang.org/resource/image/d1/94/d1e4fa1542e187184c87c545c2fe4794.jpg" alt="">
可以看出来这是一个等比数列。其中n/2<sup>k</sup>=1时k的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较所以经过了k次区间缩小操作时间复杂度就是O(k)。通过n/2<sup>k</sup>=1我们可以求得k=log<sub>2</sub>n所以时间复杂度就是O(logn)。
二分查找是我们目前为止遇到的第一个时间复杂度为O(logn)的算法。后面章节我们还会讲堆、二叉树的操作等等它们的时间复杂度也是O(logn)。我这里就再深入地讲讲O(logn)这种**对数时间复杂度**。这是一种极其高效的时间复杂度有的时候甚至比时间复杂度是常量级O(1)的算法还要高效。为什么这么说呢?
因为logn是一个非常“恐怖”的数量级即便n非常非常大对应的logn也很小。比如n等于2的32次方这个数很大了吧大约是42亿。也就是说如果我们在42亿个数据中用二分查找一个数据最多需要比较32次。
我们前面讲过用大O标记法表示时间复杂度的时候会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说O(1)有可能表示的是一个非常大的常量值比如O(1000)、O(10000)。所以常量级时间复杂度的算法有时候可能还没有O(logn)的算法执行效率高。
反过来,对数对应的就是指数。有一个非常著名的“阿基米德与国王下棋的故事”,你可以自行搜索一下,感受一下指数的“恐怖”。这也是为什么我们说,指数时间复杂度的算法在大规模数据面前是无效的。
## 二分查找的递归与非递归实现
实际上,简单的二分查找并不难写,注意我这里的“简单”二字。下一节,我们会讲到二分查找的变体问题,那才是真正烧脑的。今天,我们来看如何来写最简单的二分查找。
**最简单的情况**就是**有序数组中不存在重复元素**我们在其中用二分查找值等于给定值的数据。我用Java代码实现了一个最简单的二分查找算法。
```
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = (low + high) / 2;
if (a[mid] == value) {
return mid;
} else if (a[mid] &lt; value) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
```
这个代码我稍微解释一下low、high、mid都是指数组下标其中low和high表示当前查找的区间范围初始low=0 high=n-1。mid表示[low, high]的中间位置。我们通过对比a[mid]与value的大小来更新接下来要查找的区间范围直到找到或者区间缩小为0就退出。如果你有一些编程基础看懂这些应该不成问题。现在我就着重强调一下**容易出错的3个地方**。
### 1.循环退出条件
注意是low&lt;=high而不是low&lt;high。
### 2.mid的取值
实际上mid=(low+high)/2这种写法是有问题的。因为如果low和high比较大的话两者之和就有可能会溢出。改进的方法是将mid的计算方式写成low+(high-low)/2。更进一步如果要将性能优化到极致的话我们可以将这里的除以2操作转化成位运算low+((high-low)&gt;&gt;1)。因为相比除法运算来说,计算机处理位运算要快得多。
### 3.low和high的更新
low=mid+1high=mid-1。注意这里的+1和-1如果直接写成low=mid或者high=mid就可能会发生死循环。比如当high=3low=3时如果a[3]不等于value就会导致一直循环不退出。
如果你留意我刚讲的这三点,我想一个简单的二分查找你已经可以实现了。**实际上,二分查找除了用循环来实现,还可以<strong><strong>用**</strong>递归来实现</strong>,过程也非常简单。
我用Java语言实现了一下这个过程正好你可以借此机会回顾一下写递归代码的技巧。
```
// 二分查找的递归实现
public int bsearch(int[] a, int n, int val) {
return bsearchInternally(a, 0, n - 1, val);
}
private int bsearchInternally(int[] a, int low, int high, int value) {
if (low &gt; high) return -1;
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] &lt; value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
```
## 二分查找应用场景的局限性
前面我们分析过二分查找的时间复杂度是O(logn),查找数据的效率非常高。不过,并不是什么情况下都可以用二分查找,它的应用场景是有很大局限性的。那什么情况下适合用二分查找,什么情况下不适合呢?
**首先,二分查找依赖的是顺序表结构,简单点说就是数组。**
那二分查找能否依赖其他数据结构呢比如链表。答案是不可以的主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过数组按照下标随机访问数据的时间复杂度是O(1)而链表随机访问的时间复杂度是O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。
二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。
**其次,二分查找针对的是有序数据。**
二分查找对这一点的要求比较苛刻数据必须是有序的。如果数据没有序我们需要先排序。前面章节里我们讲到排序的时间复杂度最低是O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。
但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。
所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据呢?别急,等到二叉树那一节我会详细讲。
**再次,数据量太小不适合二分查找。**
如果要处理的数据量很小完全没有必要用二分查找顺序遍历就足够了。比如我们在一个大小为10的数组中查找一个元素不管用二分查找还是顺序遍历查找速度都差不多。只有数据量比较大的时候二分查找的优势才会比较明显。
不过这里有一个例外。如果数据之间的比较操作非常耗时不管数据量大小我都推荐使用二分查找。比如数组中存储的都是长度超过300的字符串如此长的两个字符串之间比对大小就会非常耗时。我们需要尽可能地减少比较次数而比较次数的减少会大大提高性能这个时候二分查找就比顺序遍历更有优势。
**最后,数据量太大也不适合二分查找。**
二分查找的底层需要依赖数组这种数据结构而数组为了支持随机访问的特性要求内存空间连续对内存的要求比较苛刻。比如我们有1GB大小的数据如果希望用数组来存储那就需要1GB的连续内存空间。
注意这里的“连续”二字也就是说即便有2GB的内存空间剩余但是如果这剩余的2GB内存空间都是零散的没有连续的1GB大小的内存空间那照样无法申请一个1GB大小的数组。而我们的二分查找是作用在数组这种数据结构之上的所以太大的数据用数组存储就比较吃力了也就不能用二分查找了。
## 解答开篇
二分查找的理论知识你应该已经掌握了。我们来看下开篇的思考题如何在1000万个整数中快速查找某个整数
这个问题并不难。我们的内存限制是100MB每个数据大小是8字节最简单的办法就是将数据存储在数组中内存占用差不多是80MB符合内存的限制。借助今天讲的内容我们可以先对这1000万数据从小到大排序然后再利用二分查找算法就可以快速地查找想要的数据了。
看起来这个问题并不难,很轻松就能解决。实际上,它暗藏了“玄机”。如果你对数据结构和算法有一定了解,知道散列表、二叉树这些支持快速查找的动态数据结构。你可能会觉得,用散列表和二叉树也可以解决这个问题。实际上是不行的。
虽然大部分情况下用二分查找可以解决的问题用散列表、二叉树都可以解决。但是我们后面会讲不管是散列表还是二叉树都会需要比较多的额外的内存空间。如果用散列表或者二叉树来存储这1000万的数据用100MB的内存肯定是存不下的。而二分查找底层依赖的是数组除了数据本身之外不需要额外存储其他信息是最省内存空间的存储方式所以刚好能在限定的内存大小下解决这个问题。
## 内容小结
今天我们学习了一种针对有序数据的高效查找算法二分查找它的时间复杂度是O(logn)。
二分查找的核心思想理解起来非常简单有点类似分治思想。即每次都通过跟区间中的中间元素对比将待查找的区间缩小为一半直到找到要查找的元素或者区间被缩小为0。但是二分查找的代码实现比较容易写错。你需要着重掌握它的三个容易出错的地方循环退出条件、mid的取值low和high的更新。
二分查找虽然性能比较优秀,但应用场景也比较有限。底层必须依赖数组,并且还要求数据是有序的。对于较小规模的数据查找,我们直接使用顺序遍历就可以了,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。
## 课后思考
<li>
如何编程实现“求一个数的平方根”要求精确到小数点后6位。
</li>
<li>
我刚才说了,如果数据使用链表存储,二分查找的时间复杂就会变得很高,那查找的时间复杂度究竟是多少呢?如果你自己推导一下,你就会深刻地认识到,为何我们会选择用数组而不是链表来实现二分查找了。
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,210 @@
<audio id="audio" title="16 | 二分查找如何快速定位IP对应的省份地址" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0d/yy/0d5aa27bf165c16855991ca12b2c0byy.mp3"></audio>
通过IP地址来查找IP归属地的功能不知道你有没有用过没用过也没关系你现在可以打开百度在搜索框里随便输一个IP地址就会看到它的归属地。
<img src="https://static001.geekbang.org/resource/image/c4/0a/c497770eca94fdf3baf4f813bafcb20a.jpg" alt="">
这个功能并不复杂它是通过维护一个很大的IP地址库来实现的。地址库中包括IP地址范围和归属地的对应关系。
当我们想要查询202.102.133.13这个IP地址的归属地时我们就在地址库中搜索发现这个IP地址落在[202.102.133.0, 202.102.133.255]这个地址范围内那我们就可以将这个IP地址范围对应的归属地“山东东营市”显示给用户了。
```
[202.102.133.0, 202.102.133.255] 山东东营市
[202.102.135.0, 202.102.136.255] 山东烟台
[202.102.156.34, 202.102.157.255] 山东青岛
[202.102.48.0, 202.102.48.255] 江苏宿迁
[202.102.49.15, 202.102.51.251] 江苏泰州
[202.102.56.0, 202.102.56.255] 江苏连云港
```
现在我的问题是在庞大的地址库中逐一比对IP地址所在的区间是非常耗时的。**假设我们有12万条这样的IP区间与归属地的对应关系如何快速定位出一个IP地址的归属地呢**
是不是觉得比较难?不要紧,等学完今天的内容,你就会发现这个问题其实很简单。
上一节我讲了二分查找的原理,并且介绍了最简单的一种二分查找的代码实现。今天我们来讲几种二分查找的变形问题。
不知道你有没有听过这样一个说法“十个二分九个错”。二分查找虽然原理极其简单但是想要写出没有Bug的二分查找并不容易。
唐纳德·克努特Donald E.Knuth在《计算机程序设计艺术》的第3卷《排序和查找》中说到“尽管第一个二分查找算法于1946年出现然而第一个完全正确的二分查找算法实现直到1962年才出现。”
你可能会说,我们上一节学的二分查找的代码实现并不难写啊。那是因为上一节讲的只是二分查找中最简单的一种情况,在不存在重复元素的有序数组中,查找值等于给定值的元素。最简单的二分查找写起来确实不难,但是,二分查找的变形问题就没那么好写了。
二分查找的变形问题很多,我只选择几个典型的来讲解,其他的你可以借助我今天讲的思路自己来分析。
<img src="https://static001.geekbang.org/resource/image/42/36/4221d02a2e88e9053085920f13f9ce36.jpg" alt="">
需要特别说明一点为了简化讲解今天的内容我都以数据是从小到大排列为前提如果你要处理的数据是从大到小排列的解决思路也是一样的。同时我希望你最好先自己动手试着写一下这4个变形问题然后再看我的讲述这样你就会对我说的“二分查找比较难写”有更加深的体会了。
## 变体一:查找第一个值等于给定值的元素
上一节中的二分查找是最简单的一种,即有序数据集合中不存在重复的数据,我们在其中查找值等于某个给定值的数据。如果我们将这个问题稍微修改下,有序数据集合中存在重复的数据,我们希望找到第一个值等于给定值的数据,这样之前的二分查找代码还能继续工作吗?
比如下面这样一个有序数组其中a[5]a[6]a[7]的值都等于8是重复的数据。我们希望查找第一个等于8的数据也就是下标是5的元素。
<img src="https://static001.geekbang.org/resource/image/50/f8/503c572dd0f9d734b55f1bd12765c4f8.jpg" alt="">
如果我们用上一节课讲的二分查找的代码实现首先拿8与区间的中间值a[4]比较8比6大于是在下标5到9之间继续查找。下标5和9的中间位置是下标7a[7]正好等于8所以代码就返回了。
尽管a[7]也等于8但它并不是我们想要找的第一个等于8的元素因为第一个值等于8的元素是数组下标为5的元素。我们上一节讲的二分查找代码就无法处理这种情况了。所以针对这个变形问题我们可以稍微改造一下上一节的代码。
100个人写二分查找就会有100种写法。网上有很多关于变形二分查找的实现方法有很多写得非常简洁比如下面这个写法。但是尽管简洁理解起来却非常烧脑也很容易写错。
```
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] &gt;= value) {
high = mid - 1;
} else {
low = mid + 1;
}
}
if (low &lt; n &amp;&amp; a[low]==value) return low;
else return -1;
}
```
看完这个实现之后你是不是觉得很不好理解如果你只是死记硬背这个写法我敢保证过不了几天你就会全都忘光再让你写90%的可能会写错。所以,我换了一种实现方法,你看看是不是更容易理解呢?
```
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] &gt; value) {
high = mid - 1;
} else if (a[mid] &lt; value) {
low = mid + 1;
} else {
if ((mid == 0) || (a[mid - 1] != value)) return mid;
else high = mid - 1;
}
}
return -1;
}
```
我来稍微解释一下这段代码。a[mid]跟要查找的value的大小关系有三种情况大于、小于、等于。对于a[mid]&gt;value的情况我们需要更新high= mid-1对于a[mid]&lt;value的情况我们需要更新low=mid+1。这两点都很好理解。那当a[mid]=value的时候应该如何处理呢
如果我们查找的是任意一个值等于给定值的元素当a[mid]等于要查找的值时a[mid]就是我们要找的元素。但是如果我们求解的是第一个值等于给定值的元素当a[mid]等于要查找的值时我们就需要确认一下这个a[mid]是不是第一个值等于给定值的元素。
我们重点看第11行代码。如果mid等于0那这个元素已经是数组的第一个元素那它肯定是我们要找的如果mid不等于0但a[mid]的前一个元素a[mid-1]不等于value那也说明a[mid]就是我们要找的第一个值等于给定值的元素。
如果经过检查之后发现a[mid]前面的一个元素a[mid-1]也等于value那说明此时的a[mid]肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新high=mid-1因为要找的元素肯定出现在[low, mid-1]之间。
对比上面的两段代码,是不是下面那种更好理解?实际上,**很多人都觉得变形的二分查找很难写,主要原因是太追求第一种那样完美、简洁的写法**。而对于我们做工程开发的人来说代码易读懂、没Bug其实更重要所以我觉得第二种写法更好。
## 变体二:查找最后一个值等于给定值的元素
前面的问题是查找第一个值等于给定值的元素,我现在把问题稍微改一下,查找最后一个值等于给定值的元素,又该如何做呢?
如果你掌握了前面的写法,那这个问题你应该很轻松就能解决。你可以先试着实现一下,然后跟我写的对比一下。
```
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] &gt; value) {
high = mid - 1;
} else if (a[mid] &lt; value) {
low = mid + 1;
} else {
if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
else low = mid + 1;
}
}
return -1;
}
```
我们还是重点看第11行代码。如果a[mid]这个元素已经是数组中的最后一个元素了那它肯定是我们要找的如果a[mid]的后一个元素a[mid+1]不等于value那也说明a[mid]就是我们要找的最后一个值等于给定值的元素。
如果我们经过检查之后发现a[mid]后面的一个元素a[mid+1]也等于value那说明当前的这个a[mid]并不是最后一个值等于给定值的元素。我们就更新low=mid+1因为要找的元素肯定出现在[mid+1, high]之间。
## 变体三:查找第一个大于等于给定值的元素
现在我们再来看另外一类变形问题。在有序数组中查找第一个大于等于给定值的元素。比如数组中存储的这样一个序列346710。如果查找第一个大于等于5的元素那就是6。
实际上,实现的思路跟前面的那两种变形问题的实现思路类似,代码写起来甚至更简洁。
```
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] &gt;= value) {
if ((mid == 0) || (a[mid - 1] &lt; value)) return mid;
else high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
```
如果a[mid]小于要查找的值value那要查找的值肯定在[mid+1, high]之间所以我们更新low=mid+1。
对于a[mid]大于等于给定值value的情况我们要先看下这个a[mid]是不是我们要找的第一个值大于等于给定值的元素。如果a[mid]前面已经没有元素或者前面一个元素小于要查找的值value那a[mid]就是我们要找的元素。这段逻辑对应的代码是第7行。
如果a[mid-1]也大于等于要查找的值value那说明要查找的元素在[low, mid-1]之间所以我们将high更新为mid-1。
## 变体四:查找最后一个小于等于给定值的元素
现在我们来看最后一种二分查找的变形问题查找最后一个小于等于给定值的元素。比如数组中存储了这样一组数据3568910。最后一个小于等于7的元素就是6。是不是有点类似上面那一种实际上实现思路也是一样的。
有了前面的基础,你完全可以自己写出来了,所以我就不详细分析了。我把代码贴出来,你可以写完之后对比一下。
```
public int bsearch7(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low &lt;= high) {
int mid = low + ((high - low) &gt;&gt; 1);
if (a[mid] &gt; value) {
high = mid - 1;
} else {
if ((mid == n - 1) || (a[mid + 1] &gt; value)) return mid;
else low = mid + 1;
}
}
return -1;
}
```
## 解答开篇
好了现在我们回头来看开篇的问题如何快速定位出一个IP地址的归属地
现在这个问题应该很简单了。如果IP区间与归属地的对应关系不经常更新我们可以先预处理这12万条数据让其按照起始IP从小到大排序。如何来排序呢我们知道IP地址可以转化为32位的整型数。所以我们可以将起始地址按照对应的整型值的大小关系从小到大进行排序。
然后,这个问题就可以转化为我刚讲的第四种变形问题“在有序数组中,查找最后一个小于等于某个给定值的元素”了。
当我们要查询某个IP归属地时我们可以先通过二分查找找到最后一个起始IP小于等于这个IP的IP区间然后检查这个IP是否在这个IP区间内如果在我们就取出对应的归属地显示如果不在就返回未查找到。
## 内容小结
上一节我说过,凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是毕竟内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗?
实际上,上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。
变体的二分查找算法写起来非常烧脑很容易因为细节处理不好而产生Bug这些容易出错的细节有**终止条件、区间上下界更新方法、返回值选择**。所以今天的内容你最好能用自己实现一遍对锻炼编码能力、逻辑思维、写出Bug free代码会很有帮助。
## 课后思考
我们今天讲的都是非常规的二分查找问题今天的思考题也是一个非常规的二分查找问题。如果有序数组是一个循环有序数组比如456123。针对这种情况如何实现一个求“值等于给定值”的二分查找算法呢
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,155 @@
<audio id="audio" title="17 | 跳表为什么Redis一定要用跳表来实现有序集合" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/43/cc9a332ecc0cfc2a7ecb7ea7ce31a243.mp3"></audio>
上两节我们讲了二分查找算法。当时我讲到,因为二分查找底层依赖的是数组随机访问的特性,所以只能用数组来实现。如果数据存储在链表中,就真的没法用二分查找算法了吗?
实际上,我们只需要对链表稍加改造,就可以支持类似“二分”的查找算法。我们把改造之后的数据结构叫做**跳表**Skip list也就是今天要讲的内容。
跳表这种数据结构对你来说,可能会比较陌生,因为一般的数据结构和算法书籍里都不怎么会讲。但是它确实是一种各方面性能都比较优秀的**动态数据结构**,可以支持快速地插入、删除、查找操作,写起来也不复杂,甚至可以替代[红黑树](https://zh.wikipedia.org/wiki/%E7%BA%A2%E9%BB%91%E6%A0%91)Red-black tree
Redis中的有序集合Sorted Set就是用跳表来实现的。如果你有一定基础应该知道红黑树也可以实现快速地插入、删除和查找操作。**那Redis为什么会选择用跳表来实现有序集合呢** 为什么不用红黑树呢?学完今天的内容,你就知道答案了。
## 如何理解“跳表”?
对于一个单链表来讲即便链表中存储的数据是有序的如果我们要想在其中查找某个数据也只能从头到尾遍历链表。这样查找效率就会很低时间复杂度会很高是O(n)。
<img src="https://static001.geekbang.org/resource/image/e1/6d/e18303fcedc068e5a168de04df956f6d.jpg" alt="">
那怎么来提高查找效率呢?如果像图中那样,对链表建立一级“索引”,查找起来是不是就会更快一些呢?每两个结点提取一个结点到上一级,我们把抽出来的那一级叫做**索引**或**索引层**。你可以看我画的图。图中的down表示down指针指向下一级结点。
<img src="https://static001.geekbang.org/resource/image/14/8e/14753c824a5ee4a976ea799727adc78e.jpg" alt="">
如果我们现在要查找某个结点比如16。我们可以先在索引层遍历当遍历到索引层中值为13的结点时我们发现下一个结点是17那要查找的结点16肯定就在这两个结点之间。然后我们通过索引层结点的down指针下降到原始链表这一层继续遍历。这个时候我们只需要再遍历2个结点就可以找到值等于16的这个结点了。这样原来如果要查找16需要遍历10个结点现在只需要遍历7个结点。
从这个例子里,我们看出,**加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了**。那如果我们再加一级索引呢?效率会不会提升更多呢?
跟前面建立第一级索引的方式相似我们在第一级索引的基础之上每两个结点就抽出一个结点到第二级索引。现在我们再来查找16只需要遍历6个结点了需要遍历的结点数量又减少了。
<img src="https://static001.geekbang.org/resource/image/49/65/492206afe5e2fef9f683c7cff83afa65.jpg" alt="">
我举的例子数据量不大所以即便加了两级索引查找效率的提升也并不明显。为了让你能真切地感受索引提升查询效率。我画了一个包含64个结点的链表按照前面讲的这种思路建立了五级索引。
<img src="https://static001.geekbang.org/resource/image/46/a9/46d283cd82c987153b3fe0c76dfba8a9.jpg" alt="">
从图中我们可以看出原来没有索引的时候查找62需要遍历62个结点现在只需要遍历11个结点速度是不是提高了很多所以当链表的长度n比较大时比如1000、10000的时候在构建索引之后查找效率的提升就会非常明显。
前面讲的**这种链表<strong><strong>加**</strong>多**<strong>级**</strong>索引的结构,就是跳表</strong>。我通过例子给你展示了跳表是如何减少查询次数的,现在你应该比较清晰地知道,跳表确实是可以提高查询效率的。接下来,我会定量地分析一下,用跳表查询到底有多快。
## 用跳表查询到底有多快?
前面我讲过算法的执行效率可以通过时间复杂度来度量这里依旧可以用。我们知道在一个单链表中查询某个数据的时间复杂度是O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?
这个时间复杂度的分析方法比较难想到。我把问题分解一下先来看这样一个问题如果链表里有n个结点会有多少级索引呢
按照我们刚才讲的每两个结点会抽出一个结点作为上一级索引的结点那第一级索引的结点个数大约就是n/2第二级索引的结点个数大约就是n/4第三级索引的结点个数大约就是n/8依次类推也就是说**第k级索引的结点个数是第k-1级索引的结点个数的1/2<strong><strong>那**</strong>第k**<strong>级**</strong>索引结点的个数就是n/(2<sup>k</sup>)。</strong>
假设索引有h级最高级的索引有2个结点。通过上面的公式我们可以得到n/(2<sup>h</sup>)=2从而求得h=log<sub>2</sub>n-1。如果包含原始链表这一层整个跳表的高度就是log<sub>2</sub>n。我们在跳表中查询某个数据的时候如果每一层都要遍历m个结点那在跳表中查询一个数据的时间复杂度就是O(m*logn)。
那这个m的值是多少呢按照前面这种索引结构我们每一级索引都最多只需要遍历3个结点也就是说m=3为什么是3呢我来解释一下。
假设我们要查找的数据是x在第k级索引中我们遍历到y结点之后发现x大于y小于后面的结点z所以我们通过y的down指针从第k级索引下降到第k-1级索引。在第k-1级索引中y和z之间只有3个结点包含y和z所以我们在K-1级索引中最多只需要遍历3个结点依次类推每一级索引都最多只需要遍历3个结点。
<img src="https://static001.geekbang.org/resource/image/d0/0c/d03bef9a64a0368e6a0d23ace8bd450c.jpg" alt="">
通过上面的分析我们得到m=3所以在跳表中查询任意数据的时间复杂度就是O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,是不是很神奇?不过,天下没有免费的午餐,这种查询效率的提升,前提是建立了很多级索引,也就是我们在[第6节](https://time.geekbang.org/column/article/41013)讲过的空间换时间的设计思路。
## 跳表是不是很浪费内存?
比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?我们来分析一下跳表的空间复杂度。
跳表的空间复杂度分析并不难我在前面说了假设原始链表大小为n那第一级索引大约有n/2个结点第二级索引大约有n/4个结点以此类推每上升一级就减少一半直到剩下2个结点。如果我们把每层索引的结点数写出来就是一个等比数列。
<img src="https://static001.geekbang.org/resource/image/10/55/100e9d6e5abeaae542cf7841be3f8255.jpg" alt="">
这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2。所以跳表的空间复杂度是O(n)。也就是说如果将包含n个结点的单链表构造成跳表我们需要额外再用接近n个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢
我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?我画了一个每三个结点抽一个的示意图,你可以看下。
<img src="https://static001.geekbang.org/resource/image/0b/f7/0b0680ecf500f9349fc142e1a9eb73f7.jpg" alt="">
从图中可以看出第一级索引需要大约n/3个结点第二级索引需要大约n/9个结点。每往上一级索引结点个数都除以3。为了方便计算我们假设最高一级的索引结点个数是1。我们把每级索引的结点个数都写下来也是一个等比数列。
<img src="https://static001.geekbang.org/resource/image/19/95/192c480664e35591360cee96ff2f8395.jpg" alt="">
通过等比数列求和公式总的索引结点大约就是n/3+n/9+n/27+...+9+3+1=n/2。尽管空间复杂度还是O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。
实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。
## 高效的动态插入和删除
跳表长什么样子我想你应该已经很清楚了它的查找操作我们刚才也讲过了。实际上跳表这个动态数据结构不仅支持查找操作还支持动态的插入、删除操作而且插入、删除操作的时间复杂度也是O(logn)。
我们现在来看下, 如何在跳表中插入一个数据以及它是如何做到O(logn)的时间复杂度的?
我们知道在单链表中一旦定位好要插入的位置插入结点的时间复杂度是很低的就是O(1)。但是,这里为了保证原始链表中数据的有序性,我们需要先找到要插入的位置,这个查找操作就会比较耗时。
对于纯粹的单链表需要遍历每个结点来找到插入的位置。但是对于跳表来说我们讲过查找某个结点的时间复杂度是O(logn)所以这里查找某个数据应该插入的位置方法也是类似的时间复杂度也是O(logn)。我画了一张图,你可以很清晰地看到插入的过程。
<img src="https://static001.geekbang.org/resource/image/65/6c/65379f0651bc3a7cfd13ab8694c4d26c.jpg" alt="">
好了,我们再来看删除操作。
如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的。因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。当然,如果我们用的是双向链表,就不需要考虑这个问题了。
## 跳表索引动态更新
当我们不停地往跳表中插入数据时如果我们不更新索引就有可能出现某2个索引结点之间数据非常多的情况。极端情况下跳表还会退化成单链表。
<img src="https://static001.geekbang.org/resource/image/c8/c5/c863074c01c26538cf0134eaf8dc67c5.jpg" alt="">
作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。
如果你了解红黑树、AVL树这样平衡二叉树你就知道它们是通过左右旋的方式保持左右子树的大小平衡如果不了解也没关系我们后面会讲而跳表是通过随机函数来维护前面提到的“平衡性”。
当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?
我们通过一个随机函数来决定将这个结点插入到哪几级索引中比如随机函数生成了值K那我们就将这个结点添加到第一级到第K级这K级索引中。
<img src="https://static001.geekbang.org/resource/image/a8/a7/a861445d0b53fc842f38919365b004a7.jpg" alt="">
随机函数的选择很有讲究从概率上来讲能够保证跳表的索引大小和数据大小平衡性不至于性能过度退化。至于随机函数的选择我就不展开讲解了。如果你感兴趣的话可以看看我在GitHub上的代码或者Redis中关于有序集合的跳表实现。
跳表的实现还是稍微有点复杂的我将Java实现的代码放到了GitHub中你可以根据我刚刚的讲解对照着代码仔细思考一下。你不用死记硬背代码跳表的实现并不是我们这节的重点。
## 解答开篇
今天的内容到此就讲完了。现在我来讲解一下开篇的思考题为什么Redis要用跳表来实现有序集合而不是红黑树
Redis中的有序集合是通过跳表来实现的严格点讲其实还用到了散列表。不过散列表我们后面才会讲到所以我们现在暂且忽略这部分。如果你去查看Redis的开发手册就会发现Redis中的有序集合支持的核心操作主要有下面这几个
<li>
插入一个数据;
</li>
<li>
删除一个数据;
</li>
<li>
查找一个数据;
</li>
<li>
按照区间查找数据(比如查找值在[100, 356]之间的数据);
</li>
<li>
迭代输出有序序列。
</li>
其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
对于按照区间查找数据这个操作跳表可以做到O(logn)的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
当然Redis之所以用跳表来实现有序集合还有其他原因比如跳表更容易代码实现。虽然跳表的实现也不简单但比起红黑树来说还是好懂、好写多了而简单就意味着可读性好不容易出错。还有跳表更加灵活它可以通过改变索引构建策略有效平衡执行效率和内存消耗。
不过跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些很多编程语言中的Map类型都是通过红黑树来实现的。我们做业务开发的时候直接拿来用就可以了不用费劲自己去实现一个红黑树但是跳表并没有一个现成的实现所以在开发中如果你想使用跳表必须要自己实现。
## 内容小结
今天我们讲了跳表这种数据结构。跳表使用空间换时间的设计思路通过构建多级索引来提高查询的效率实现了基于链表的“二分查找”。跳表是一种动态数据结构支持快速地插入、删除、查找操作时间复杂度都是O(logn)。
跳表的空间复杂度是O(n)。不过,跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单,但是作为一种动态数据结构,比起红黑树来说,实现要简单多了。所以很多时候,我们为了代码的简单、易读,比起红黑树,我们更倾向用跳表。
## 课后思考
在今天的内容中,对于跳表的时间复杂度分析,我分析了每两个结点提取一个结点作为索引的时间复杂度。如果每三个或者五个结点提取一个结点作为上级索引,对应的在跳表中查询数据的时间复杂度是多少呢?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="18 | 散列表Word文档中的单词拼写检查功能是如何实现的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/82/1d74e435997e06416162193e67844e82.mp3"></audio>
Word这种文本编辑器你平时应该经常用吧那你有没有留意过它的拼写检查功能呢一旦我们在Word里输入一个错误的英文单词它就会用标红的方式提示“拼写错误”。**Word的这个单词拼写检查功能虽然很小但却非常实用。你有没有想过这个功能是如何实现的呢**
其实啊,一点儿都不难。只要你学完今天的内容,**散列表**Hash Table。你就能像微软Office的工程师一样轻松实现这个功能。
## 散列思想
散列表的英文叫“Hash Table”我们平时也叫它“哈希表”或者“Hash表”你一定也经常听过它我在前面的文章里也不止一次提到过但是你是不是真的理解这种数据结构呢
**散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。**
我用一个例子来解释一下。假如我们有89名选手参加学校运动会。为了方便记录成绩每个选手胸前都会贴上自己的参赛号码。这89名选手的编号依次是1到89。现在我们希望编程实现这样一个功能通过编号快速找到对应的选手信息。你会怎么做呢
我们可以把这89名选手的信息放在数组里。编号为1的选手我们放到数组中下标为1的位置编号为2的选手我们放到数组中下标为2的位置。以此类推编号为k的选手放到数组中下标为k的位置。
因为参赛编号跟数组下标一一对应当我们需要查询参赛编号为x的选手的时候我们只需要将下标为x的数组元素取出来就可以了时间复杂度就是O(1)。这样按照编号查找选手信息,效率是不是很高?
实际上这个例子已经用到了散列的思想。在这个例子里参赛编号是自然数并且与数组的下标形成一一映射所以利用数组支持根据下标随机访问的时候时间复杂度是O(1)这一特性,就可以实现快速查找编号对应的选手信息。
你可能要说了,这个例子中蕴含的散列思想还不够明显,那我来改造一下这个例子。
假设校长说参赛编号不能设置得这么简单要加上年级、班级这些更详细的信息所以我们把编号的规则稍微修改了一下用6位数字来表示。比如051167其中前两位05表示年级中间两位11表示班级最后两位还是原来的编号1到89。这个时候我们该如何存储选手信息才能够支持通过编号来快速查找选手信息呢
思路还是跟前面类似。尽管我们不能直接把编号作为数组下标,但我们可以截取参赛编号的后两位作为数组下标,来存取选手信息数据。当通过参赛编号查询选手信息的时候,我们用同样的方法,取参赛编号的后两位,作为数组下标,来读取数组中的数据。
这就是典型的散列思想。其中,参赛选手的编号我们叫做**键**key或者**关键字**。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作**散列函数**或“Hash函数”“哈希函数”而散列函数计算得到的值就叫作**散列值**或“Hash值”“哈希值”
<img src="https://static001.geekbang.org/resource/image/92/73/92c89a57e21f49d2f14f4424343a2773.jpg" alt="">
通过这个例子我们可以总结出这样的规律散列表用的就是数组支持按照下标随机访问的时候时间复杂度是O(1)的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。
## 散列函数
从上面的例子我们可以看到,散列函数在散列表中起着非常关键的作用。现在我们就来学习下散列函数。
散列函数,顾名思义,它是一个函数。我们可以把它定义成**hash(key)**其中key表示元素的键值hash(key)的值表示经过散列函数计算得到的散列值。
那第一个例子中编号就是数组下标所以hash(key)就等于key。改造后的例子写成散列函数稍微有点复杂。我用伪代码将它写成函数就是下面这样
```
int hash(String key) {
// 获取后两位字符
string lastTwoChars = key.substr(length-2, length);
// 将后两位字符转换为整数
int hashValue = convert lastTwoChas to int-type;
return hashValue;
}
```
刚刚举的学校运动会的例子散列函数比较简单也比较容易想到。但是如果参赛选手的编号是随机生成的6位数字又或者用的是a到z之间的字符串**该如何构造散列函数呢?<strong>我总结了三点**散列函数**<strong>设计的**</strong>基本要求</strong>
<li>
散列函数计算得到的散列值是一个非负整数;
</li>
<li>
如果key1 = key2那hash(key1) == hash(key2)
</li>
<li>
如果key1 ≠ key2那hash(key1) ≠ hash(key2)。
</li>
我来解释一下这三点。其中第一点理解起来应该没有任何问题。因为数组下标是从0开始的所以散列函数生成的散列值也要是非负整数。第二点也很好理解。相同的key经过散列函数得到的散列值也应该是相同的。
第三点理解起来可能会有问题我着重说一下。这个要求看起来合情合理但是在真实的情况下要想找到一个不同的key对应的散列值都不一样的散列函数几乎是不可能的。即便像业界著名的[MD5](https://zh.wikipedia.org/wiki/MD5)、[SHA](https://zh.wikipedia.org/wiki/SHA%E5%AE%B6%E6%97%8F)、[CRC](https://zh.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E5%86%97%E9%A4%98%E6%A0%A1%E9%A9%97)等哈希算法,也无法完全避免这种**散列冲突**。而且,因为数组的存储空间有限,也会加大散列冲突的概率。
所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决。
## 散列冲突
再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢我们常用的散列冲突解决方法有两类开放寻址法open addressing和链表法chaining
### 1.开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那如何重新探测新的位置呢?我先讲一个比较简单的探测方法,**线性探测**Linear Probing
当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
我说的可能比较抽象,我举一个例子具体给你说明一下。这里面黄色的色块表示空闲位置,橙色的色块表示已经存储了数据。
<img src="https://static001.geekbang.org/resource/image/5c/d5/5c31a3127cbc00f0c63409bbe1fbd0d5.jpg" alt="">
从图中可以看出散列表的大小为10在元素x插入散列表之前已经6个元素插入到散列表中。x经过Hash算法之后被散列到位置下标为7的位置但是这个位置已经有数据了所以就产生了冲突。于是我们就顺序地往后一个一个找看有没有空闲的位置遍历到尾部都没有找到空闲的位置于是我们再从表头开始找直到找到空闲位置2于是将其插入到这个位置。
在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。
<img src="https://static001.geekbang.org/resource/image/91/ff/9126b0d33476777e7371b96e676e90ff.jpg" alt="">
散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是为什么呢?
还记得我们刚讲的查找操作吗?在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?
我们可以将删除的元素特殊标记为deleted。当线性探测查找的时候遇到标记为deleted的空间并不是停下来而是继续往下探测。
<img src="https://static001.geekbang.org/resource/image/fe/1d/fe7482ba09670cbe05a9dfe4dd49bd1d.jpg" alt="">
你可能已经发现了线性探测法其实存在很大问题。当散列表中插入的数据越来越多时散列冲突发生的可能性就会越来越大空闲位置会越来越少线性探测的时间就会越来越久。极端情况下我们可能需要探测整个散列表所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,**二次探测**Quadratic probing和**双重散列**Double hashing
所谓二次探测跟线性探测很像线性探测每次探测的步长是1那它探测的下标序列就是hash(key)+0hash(key)+1hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”也就是说它探测的下标序列就是hash(key)+0hash(key)+1<sup>2</sup>hash(key)+2<sup>2</sup>……
所谓双重散列意思就是不仅要使用一个散列函数。我们使用一组散列函数hash1(key)hash2(key)hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用**装载因子**load factor来表示空位的多少。
装载因子的计算公式是:
```
散列表的装载因子=填入表中的元素个数/散列表的长度
```
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。
### 2.链表法
链表法是一种更加常用的散列冲突解决办法相比开放寻址法它要简单很多。我们来看这个图在散列表中每个“桶bucket”或者“槽slot”会对应一条链表所有散列值相同的元素我们都放到相同槽位对应的链表中。
<img src="https://static001.geekbang.org/resource/image/a4/7f/a4b77d593e4cb76acb2b0689294ec17f.jpg" alt="">
当插入的时候我们只需要通过散列函数计算出对应的散列槽位将其插入到对应链表中即可所以插入的时间复杂度是O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?
实际上这两个操作的时间复杂度跟链表的长度k成正比也就是O(k)。对于散列比较均匀的散列函数来说理论上讲k=n/m其中n表示散列中数据的个数m表示散列表中“槽”的个数。
## 解答开篇
有了前面这些基本知识储备我们来看一下开篇的思考题Word文档中单词拼写检查功能是如何实现的
常用的英文单词有20万个左右假设单词的平均长度是10个字母平均一个单词占用10个字节的内存空间那20万英文单词大约占2MB的存储空间就算放大10倍也就是20MB。对于现在的计算机来说这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。
当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。
## 内容小结
今天我讲了一些比较基础、比较偏理论的散列表知识,包括散列表的由来、散列函数、散列冲突的解决方法。
散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是**散列函数设计**和**散列冲突解决**。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。
针对散列函数和散列冲突,今天我只讲了一些基础的概念、方法,下一节我会更贴近实战、更加深入探讨这两个问题。
## 课后思考
<li>
假设我们有10万条URL访问日志如何按照访问次数给URL排序
</li>
<li>
有两个字符串数组每个数组大约有10万条字符串如何快速找出两个数组中相同的字符串
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,201 @@
<audio id="audio" title="19 | 散列表(中):如何打造一个工业级水平的散列表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/fc/3b6ee19c8c726c56b2ca95053b2db6fc.mp3"></audio>
通过上一节的学习我们知道散列表的查询效率并不能笼统地说成是O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。
在极端情况下有些恶意的攻击者还有可能通过精心构造的数据使得所有的数据经过散列函数之后都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法那这个时候散列表就会退化为链表查询的时间复杂度就从O(1)急剧退化为O(n)。
如果散列表中有10万个数据退化后的散列表查询的效率就下降了10万倍。更直接点说如果之前运行100次查询只需要0.1秒那现在就需要1万秒。这样就有可能因为查询操作消耗大量CPU或者线程资源导致系统无法响应其他请求从而达到拒绝服务攻击DoS的目的。这也就是散列表碰撞攻击的基本原理。
今天,我们就来学习一下,**如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击?**
## 如何设计散列函数?
散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。那什么才是好的散列函数呢?
首先,**散列函数<strong><strong>的**</strong>设计不能太复杂</strong>。过于复杂的散列函数,势必会消耗很多计算时间,也就间接地影响到散列表的性能。其次,**散列函数生成的值要尽可能随机并且均匀分布**,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。
实际工作中,我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样,我举几个常用的、简单的散列函数的设计方法,让你有个直观的感受。
第一个例子就是我们上一节的学生运动会的例子,我们通过分析参赛编号的特征,把编号中的后两位作为散列值。我们还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后面几位就比较随机,我们可以取手机号的后四位作为散列值。这种散列函数的设计方法,我们一般叫做“数据分析法”。
第二个例子就是上一节的开篇思考题如何实现Word拼写检查功能。这里面的散列函数我们就可以这样设计将单词中每个字母的[ASCll码](http://www.96yx.com/tool/ASC2.htm)[](http://www.96yx.com/tool/ASC2.htm)“进位”相加然后再跟散列表的大小求余、取模作为散列值。比如英文单词nice我们转化出来的散列值就是下面这样
```
hash(&quot;nice&quot;)=((&quot;n&quot; - &quot;a&quot;) * 26*26*26 + (&quot;i&quot; - &quot;a&quot;)*26*26 + (&quot;c&quot; - &quot;a&quot;)*26+ (&quot;e&quot;-&quot;a&quot;)) / 78978
```
实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等,这些你只要了解就行了,不需要全都掌握。
## 装载因子过大了怎么办?
我们上一节讲到散列表的装载因子的时候说过,装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。
对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。
对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。这个时候,我们该如何处理呢?
还记得我们前面多次讲的“动态扩容”吗?你可以回想一下,我们是如何做数组、栈、队列的动态扩容的。
针对散列表当装载因子过大时我们也可以进行动态扩容重新申请一个更大的散列表将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是0.8那经过扩容之后新散列表的装载因子就下降为原来的一半变成了0.4。
针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。
你可以看我图里这个例子。在原来的散列表中21这个元素原来存储在下标为0的位置搬移到新的散列表中存储在下标为7的位置。
<img src="https://static001.geekbang.org/resource/image/67/43/67d12e07a7d673a9c1d14354ad029443.jpg" alt="">
对于支持动态扩容的散列表,插入操作的时间复杂度是多少呢?前面章节我已经多次分析过支持动态扩容的数组、栈等数据结构的时间复杂度了。所以,这里我就不啰嗦了,你要是还不清楚的话,可以回去复习一下。
插入一个数据最好情况下不需要扩容最好时间复杂度是O(1)。最坏情况下散列表装载因子过高启动扩容我们需要重新申请内存空间重新计算哈希位置并且搬移数据所以时间复杂度是O(n)。用摊还分析法均摊情况下时间复杂度接近最好情况就是O(1)。
实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了。
我们前面讲到,当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。
装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张对执行效率要求很高可以降低负载因子的阈值相反如果内存空间紧张对执行效率要求又不高可以增加负载因子的值甚至可以大于1。
## 如何避免低效的扩容?
我们刚刚分析得到,大部分情况下,动态扩容的散列表插入一个数据都很快,但是在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。这个时候,插入数据就会变得很慢,甚至会无法接受。
我举一个极端的例子如果散列表当前大小为1GB要想扩容为原来的两倍大小那就需要对1GB的数据重新计算哈希值并且从原来的散列表搬移到新的散列表听起来就很耗时是不是
如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。这个时候,“一次性”扩容的机制就不合适了。
为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。
当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。
<img src="https://static001.geekbang.org/resource/image/6d/cb/6d6736f986ec4b75dabc5472965fb9cb.jpg" alt="">
这期间的查询操作怎么来做呢?对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。
通过这样均摊的方法将一次性扩容的代价均摊到多次插入操作中就避免了一次性扩容耗时过多的情况。这种实现方式任何情况下插入一个数据的时间复杂度都是O(1)。
## 如何选择冲突解决方法?
上一节我们讲了两种主要的散列冲突的解决办法开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常常用。比如Java中LinkedHashMap就采用了链表法解决冲突ThreadLocalMap是通过线性探测的开放寻址法来解决冲突。那你知道这两种冲突解决方法各有什么优势和劣势又各自适用哪些场景吗
### 1.开放寻址法
我们先来看看,开放寻址法的优点有哪些。
开放寻址法不像链表法需要拉很多链表。散列表中的数据都存储在数组中可以有效地利用CPU缓存加快查询速度。而且这种方法实现的散列表序列化起来比较简单。链表法包含指针序列化起来就没那么容易。你可不要小看序列化很多场合都会用到的。我们后面就有一节会讲什么是数据结构序列化、如何序列化以及为什么要序列化。
我们再来看下,开放寻址法有哪些缺点。
上一节我们讲到,用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。
所以,**我总结一下当数据量比较小、装载因子小的时候适合采用开放寻址法。这也是Java中的<strong><strong>ThreadLocalMap**</strong>使用开放寻址法解决散列冲突的原因</strong>
### 2.链表法
首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是我们前面讲过的链表优于数组的地方。
链表法比起开放寻址法对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于1的情况。接近1时就可能会有大量的散列冲突导致大量的探测、再散列等性能会下降很多。但是对于链表法来说只要散列函数的值随机均匀即便装载因子变成10也就是链表的长度变长了而已虽然查找效率有所下降但是比起顺序查找还是快很多。
还记得我们之前在链表那一节讲的吗链表因为要存储指针所以对于比较小的对象的存储是比较消耗内存的还有可能会让内存的消耗翻倍。而且因为链表中的结点是零散分布在内存中的不是连续的所以对CPU缓存是不友好的这方面对于执行效率也有一定的影响。
当然如果我们存储的是大对象也就是说要存储的对象的大小远远大于一个指针的大小4个字节或者8个字节那链表中指针的内存消耗在大对象面前就可以忽略了。
实际上我们对链表法稍加改造可以实现一个更加高效的散列表。那就是我们将链表法中的链表改造为其他高效的动态数据结构比如跳表、红黑树。这样即便出现散列冲突极端情况下所有的数据都散列到同一个桶内那最终退化成的散列表的查找时间也只不过是O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。
<img src="https://static001.geekbang.org/resource/image/10/29/103b84d7173277c5565607b413c40129.jpg" alt="">
所以,**我总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表**。
## 工业级散列表举例分析
刚刚我讲了实现一个工业级散列表需要涉及的一些关键技术现在我就拿一个具体的例子Java中的HashMap这样一个工业级的散列表来具体看下这些技术是怎么应用的。
### 1.初始大小
HashMap默认的初始大小是16当然这个默认值是可以设置的如果事先知道大概的数据量有多大可以通过修改默认初始大小减少动态扩容的次数这样会大大提高HashMap的性能。
### 2.装载因子和动态扩容
最大装载因子默认是0.75当HashMap中元素个数超过0.75*capacitycapacity表示散列表的容量的时候就会启动扩容每次扩容都会扩容为原来的两倍大小。
### 3.散列冲突解决方法
HashMap底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理也免不了会出现拉链过长的情况一旦出现拉链过长则会严重影响HashMap的性能。
于是在JDK1.8版本中为了对HashMap做进一步优化我们引入了红黑树。而当链表长度太长默认超过8链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点提高HashMap的性能。当红黑树结点个数少于8个的时候又会将红黑树转化为链表。因为在数据量较小的情况下红黑树要维护平衡比起链表来性能上的优势并不明显。
### 4.散列函数
散列函数的设计并不复杂,追求的是简单高效、分布均匀。我把它摘抄出来,你可以看看。
```
int hash(Object key) {
int h = key.hashCode()
return (h ^ (h &gt;&gt;&gt; 16)) &amp; (capicity -1); //capicity表示散列表的大小
}
```
其中hashCode()返回的是Java对象的hash code。比如String类型的对象的hashCode()就是下面这样:
```
public int hashCode() {
int var1 = this.hash;
if(var1 == 0 &amp;&amp; this.value.length &gt; 0) {
char[] var2 = this.value;
for(int var3 = 0; var3 &lt; this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}
```
## 解答开篇
今天的内容就讲完了,我现在来分析一下开篇的问题:如何设计一个工业级的散列函数?如果这是一道面试题或者是摆在你面前的实际开发问题,你会从哪几个方面思考呢?
首先,我会思考,**何为一个工业级的散列表?工业级的散列表<strong><strong>应该**</strong>具有哪些特性?</strong>
结合已经学习过的散列知识,我觉得应该有这样几点要求:
<li>
支持快速地查询、插入、删除操作;
</li>
<li>
内存占用合理,不能浪费过多的内存空间;
</li>
<li>
性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。
</li>
**如何实现这样一个散列表呢?**根据前面讲到的知识,我会从这三个方面来考虑设计思路:
<li>
设计一个合适的散列函数;
</li>
<li>
定义装载因子阈值,并且设计动态扩容策略;
</li>
<li>
选择合适的散列冲突解决方法。
</li>
关于散列函数、装载因子、动态扩容策略,还有散列冲突的解决办法,我们前面都讲过了,具体如何选择,还要结合具体的业务场景、具体的业务数据来具体分析。不过只要我们朝这三个方向努力,就离设计出工业级的散列表不远了。
## 内容小结
上一节的内容比较偏理论,今天的内容侧重实战。我主要讲了如何设计一个工业级的散列表,以及如何应对各种异常情况,防止在极端情况下,散列表的性能退化过于严重。我分了三部分来讲解这些内容,分别是:如何设计散列函数,如何根据装载因子动态扩容,以及如何选择散列冲突解决方法。
关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。
关于散列冲突解决方法的选择我对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下链表法更加普适。而且我们还可以通过将链表法中的链表改造成其他动态查找数据结构比如红黑树来避免散列表时间复杂度退化成O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。
对于动态散列表来说,不管我们如何设计散列函数,选择什么样的散列冲突解决方法。随着数据的不断增加,散列表总会出现装载因子过高的情况。这个时候,我们就需要启动动态扩容。
## 课后思考
在你熟悉的编程语言中,哪些数据类型底层是基于散列表实现的?散列函数是如何设计的?散列冲突是通过哪种方法解决的?是否支持动态扩容呢?
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,170 @@
<audio id="audio" title="20 | 散列表(下):为什么散列表和链表经常会一起使用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/3d/ffd1fac2d1ba11d199ed5232afe3ea3d.mp3"></audio>
我们已经学习了20节内容你有没有发现有两种数据结构散列表和链表经常会被放在一起使用。你还记得前面的章节中都有哪些地方讲到散列表和链表的组合使用吗我带你一起回忆一下。
在链表那一节我讲到如何用链表来实现LRU缓存淘汰算法但是链表实现的LRU缓存淘汰算法的时间复杂度是O(n)当时我也提到了通过散列表可以将这个时间复杂度降低到O(1)。
在跳表那一节我提到Redis的有序集合是使用跳表来实现的跳表可以看作一种改进版的链表。当时我们也提到Redis有序集合不仅使用了跳表还用到了散列表。
除此之外如果你熟悉Java编程语言你会发现LinkedHashMap这样一个常用的容器也用到了散列表和链表两种数据结构。
今天,我们就来看看,在这几个问题中,散列表和链表都是如何组合起来使用的,以及为什么散列表和链表会经常放到一块使用。
## LRU缓存淘汰算法
在链表那一节中我提到借助散列表我们可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。现在,我们就来看看它是如何做到的。
首先我们来回顾一下当时我们是如何通过链表实现LRU缓存淘汰算法的。
我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。
当要缓存某个数据的时候先在链表中查找这个数据。如果没有找到则直接将数据放到链表的尾部如果找到了我们就把它移动到链表的尾部。因为查找数据需要遍历链表所以单纯用链表实现的LRU缓存淘汰算法的时间复杂很高是O(n)。
实际上我总结一下一个缓存cache系统主要包含下面这几个操作
<li>
往缓存中添加一个数据;
</li>
<li>
从缓存中删除一个数据;
</li>
<li>
在缓存中查找一个数据。
</li>
这三个操作都要涉及“查找”操作如果单纯地采用链表的话时间复杂度只能是O(n)。如果我们将散列表和链表两种数据结构组合使用可以将这三个操作的时间复杂度都降低到O(1)。具体的结构就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/ea/6e/eaefd5f4028cc7d4cfbb56b24ce8ae6e.jpg" alt="">
我们使用双向链表存储数据链表中的每个结点处理存储数据data、前驱指针prev、后继指针next之外还新增了一个特殊的字段hnext。这个hnext有什么作用呢
因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的**双向链表**,另一个链是散列表中的**拉链**。**前驱和后继指针是为了将结点串在双向链表中hnext指针是为了将结点串在散列表的拉链中**。
了解了这个散列表和双向链表的组合存储结构之后我们再来看前面讲到的缓存的三个操作是如何做到时间复杂度是O(1)的?
首先,我们来看**如何查找一个数据**。我们前面讲过散列表中查找数据的时间复杂度接近O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的尾部。
其次,我们来看**如何删除一个数据**。我们需要找到数据所在的结点然后将结点删除。借助散列表我们可以在O(1)时间复杂度里找到要删除的结点。因为我们的链表是双向链表双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点所以在双向链表中删除结点只需要O(1)的时间复杂度。
最后,我们来看**如何添加一个数据**。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作比如删除头结点、链表尾部插入数据等都可以在O(1)的时间复杂度内完成。所以这三个操作的时间复杂度都是O(1)。至此我们就通过散列表和双向链表的组合使用实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。
## Redis有序集合
在跳表那一节,讲到有序集合的操作时,我稍微做了些简化。实际上,在有序集合中,每个成员对象有两个重要的属性,**key**(键值)和**score**分值。我们不仅会通过score来查找数据还会通过key来查找数据。
举个例子比如用户积分排行榜有这样一个功能我们可以通过用户的ID来查找积分信息也可以通过积分区间来查找用户ID或者姓名信息。这里包含ID、姓名和积分的用户信息就是成员对象用户ID就是key积分就是score。
所以如果我们细化一下Redis有序集合的操作那就是下面这样
<li>
添加一个成员对象;
</li>
<li>
按照键值来删除一个成员对象;
</li>
<li>
按照键值来查找一个成员对象;
</li>
<li>
按照分值区间查找数据,比如查找积分在[100, 356]之间的成员对象;
</li>
<li>
按照分值从小到大排序成员变量;
</li>
如果我们仅仅按照分值将成员对象组织成跳表的结构那按照键值来删除、查询成员对象就会很慢解决方法与LRU缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表这样按照key来删除、查找一个成员对象的时间复杂度就变成了O(1)。同时,借助跳表结构,其他操作也非常高效。
实际上Redis有序集合的操作还有另外一类也就是查找成员对象的排名Rank或者根据排名区间查找成员对象。这个功能单纯用刚刚讲的这种组合结构就无法高效实现了。这块内容我后面的章节再讲。
## Java LinkedHashMap
前面我们讲了两个散列表和链表结合的例子现在我们再来看另外一个Java中的LinkedHashMap这种容器。
如果你熟悉Java那你几乎天天会用到这个容器。我们之前讲过HashMap底层是通过散列表这种数据结构实现的。而LinkedHashMap前面比HashMap多了一个“Linked”这里的“Linked”是不是说LinkedHashMap是一个通过链表法解决散列冲突的散列表呢
实际上LinkedHashMap并没有这么简单其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。关于这一点在我是初学者的时候也误解了很久。
我们先来看一段代码。你觉得这段代码会以什么样的顺序打印3152这几个key呢原因又是什么呢
```
HashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
```
我先告诉你答案上面的代码会按照数据插入的顺序依次来打印也就是说打印的顺序就是3152。你有没有觉得奇怪散列表中数据是经过散列函数打乱之后无规律存储的这里是如何实现按照数据的插入顺序来遍历打印的呢
你可能已经猜到了LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上它不仅支持按照插入顺序遍历数据还支持按照访问顺序来遍历数据。你可以看下面这段代码
```
// 10是初始大小0.75是装载因子true是表示按照访问时间排序
HashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
```
这段代码打印的结果是1235。我来具体分析一下为什么这段代码会按照这样顺序来打印。
每次调用put()函数往LinkedHashMap中添加数据的时候都会将数据添加到链表的尾部所以在前四个操作完成之后链表中的数据是下面这样
<img src="https://static001.geekbang.org/resource/image/17/98/17ac41d9dac454e454dcb289100bf198.jpg" alt="">
在第8行代码中再次将键值为3的数据放入到LinkedHashMap的时候会先查找这个键值是否已经有了然后再将已经存在的(3,11)删除,并且将新的(3,26)放到链表的尾部。所以,这个时候链表中的数据就是下面这样:
<img src="https://static001.geekbang.org/resource/image/fe/8c/fe313ed327bcf234c73ba738d975b18c.jpg" alt="">
当第9行代码访问到key为5的数据的时候我们将被访问到的数据移动到链表的尾部。所以第9行代码之后链表中的数据是下面这样
<img src="https://static001.geekbang.org/resource/image/b5/11/b5e07bb34d532d46d127f4fcc4b78f11.jpg" alt="">
所以最后打印出来的数据是1235。从上面的分析你有没有发现按照访问时间排序的LinkedHashMap本身就是一个支持LRU缓存淘汰策略的缓存系统实际上它们两个的实现原理也是一模一样的。我也就不再啰嗦了。
我现在来总结一下,实际上,**LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表并非指用链表法解决散列冲突**。
## 解答开篇&amp;内容小结
弄懂刚刚我讲的这三个例子,开篇的问题也就不言而喻了。我这里总结一下,为什么散列表和链表经常一块使用?
散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。
因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。
## 课后思考
<li>
今天讲的几个散列表和链表结合使用的例子里,我们用的都是双向链表。如果把双向链表改成单链表,还能否正常工作呢?为什么呢?
</li>
<li>
假设猎聘网有10万名猎头每个猎头都可以通过做任务比如发布职位来积累积分然后通过积分来下载简历。假设你是猎聘网的一名工程师如何在内存中存储这10万个猎头ID和积分信息让它能够支持这样几个操作
</li>
<li>
根据猎头的ID快速查找、删除、更新这个猎头的积分信息
</li>
<li>
查找积分在某个区间的猎头ID列表
</li>
<li>
查找按照积分从小到大排名在第x位到第y位之间的猎头ID列表。
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,141 @@
<audio id="audio" title="21 | 哈希算法(上):如何防止数据库中的用户信息被脱库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/d3/a8a496d681054b6a1f1b02a70892dfd3.mp3"></audio>
还记得2011年CSDN的“脱库”事件吗当时CSDN网站被黑客攻击超过600万用户的注册邮箱和密码明文被泄露很多网友对CSDN明文保存用户密码行为产生了不满。如果你是CSDN的一名工程师**你会如何存储用户密码这么重要的数据吗仅仅MD5加密一下存储就够了吗** 要想搞清楚这个问题,就要先弄明白哈希算法。
哈希算法历史悠久业界著名的哈希算法也有很多比如MD5、SHA等。在我们平时的开发中基本上都是拿现成的直接用。所以我今天不会重点剖析哈希算法的原理也不会教你如何设计一个哈希算法而是从实战的角度告诉你**在实际的开发中,我们该如何用哈希算法解决问题**。
## 什么是哈希算法?
我们前面几节讲到“散列表”“散列函数”,这里又讲到“哈希算法”,你是不是有点一头雾水?实际上,不管是“散列”还是“哈希”,这都是中文翻译的差别,英文其实就是“**Hash**”。所以我们常听到有人把“散列表”叫作“哈希表”“Hash表”把“哈希算法”叫作“Hash算法”或者“散列算法”。那到底什么是哈希算法呢
哈希算法的定义和原理非常简单,基本上一句话就可以概括了。将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是**哈希算法**,而通过原始数据映射之后得到的二进制值串就是**哈希值**。但是,要想设计一个优秀的哈希算法并不容易,根据我的经验,我总结了需要满足的几点要求:
<li>
从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);
</li>
<li>
对输入数据非常敏感哪怕原始数据只修改了一个Bit最后得到的哈希值也大不相同
</li>
<li>
散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
</li>
<li>
哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。
</li>
这些定义和要求都比较理论可能还是不好理解我拿MD5这种哈希算法来具体说明一下。
我们分别对“今天我来讲哈希算法”和“jiajia”这两个文本计算MD5哈希值得到两串看起来毫无规律的字符串MD5的哈希值是128位的Bit长度为了方便表示我把它们转化成了16进制编码。可以看出来无论要哈希的文本有多长、多短通过MD5哈希之后得到的哈希值的长度都是相同的而且得到的哈希值看起来像一堆随机数完全没有规律。
```
MD5(&quot;今天我来讲哈希算法&quot;) = bb4767201ad42c74e650c1b6c03d78fa
MD5(&quot;jiajia&quot;) = cd611a31ea969b908932d44d126d195b
```
我们再来看两个非常相似的文本“我今天讲哈希算法”和“我今天讲哈希算法”。这两个文本只有一个感叹号的区别。如果用MD5哈希算法分别计算它们的哈希值你会发现尽管只有一字之差得到的哈希值也是完全不同的。
```
MD5(&quot;我今天讲哈希算法!&quot;) = 425f0d5a917188d2c3c3dc85b5e4f2cb
MD5(&quot;我今天讲哈希算法&quot;) = a1fb91ac128e6aa37fe42c663971ac3d
```
我在前面也说了通过哈希算法得到的哈希值很难反向推导出原始数据。比如上面的例子中我们就很难通过哈希值“a1fb91ac128e6aa37fe42c663971ac3d”反推出对应的文本“我今天讲哈希算法”。
哈希算法要处理的文本可能是各种各样的。比如对于非常长的文本如果哈希算法的计算时间很长那就只能停留在理论研究的层面很难应用到实际的软件开发中。比如我们把今天这篇包含4000多个汉字的文章用MD5计算哈希值用不了1ms的时间。
哈希算法的应用非常非常多,我选了最常见的七个,分别是安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。这节我们先来看前四个应用。
## 应用一:安全加密
说到哈希算法的应用,最先想到的应该就是安全加密。最常用于加密的哈希算法是**MD5**MD5 Message-Digest AlgorithmMD5消息摘要算法和**SHA**Secure Hash Algorithm安全散列算法
除了这两个之外,当然还有很多其他加密算法,比如**DES**Data Encryption Standard数据加密标准、**AES**Advanced Encryption Standard高级加密标准
前面我讲到的哈希算法四点要求,对用于加密的哈希算法来说,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据,第二点是散列冲突的概率要很小。
第一点很好理解,加密的目的就是防止原始数据泄露,所以很难通过哈希值反向推导原始数据,这是一个最基本的要求。所以我着重讲一下第二点。实际上,不管是什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。为什么这么说呢?
这里就基于组合数学中一个非常基础的理论鸽巢原理也叫抽屉原理。这个原理本身很简单它是说如果有10个鸽巢有11只鸽子那肯定有1个鸽巢中的鸽子数量多于1个换句话说就是肯定有2只鸽子在1个鸽巢内。
有了鸽巢原理的铺垫之后,我们再来看,**为什么哈希算法无法做到零冲突?**
我们知道哈希算法产生的哈希值的长度是固定且有限的。比如前面举的MD5的例子哈希值是固定的128位二进制串能表示的数据是有限的最多能表示2^128个数据而我们要哈希的数据是无穷的。基于鸽巢原理如果我们对2^128+1个数据求哈希值就必然会存在哈希值相同的情况。这里你应该能想到一般情况下哈希值越长的哈希算法散列冲突的概率越低。
```
2^128=340282366920938463463374607431768211456
```
为了让你能有个更加直观的感受我找了两段字符串放在这里。这两段字符串经过MD5哈希算法加密之后产生的哈希值是相同的。
<img src="https://static001.geekbang.org/resource/image/65/d8/65ee084ee47ae9ef6f53f618c65d00d8.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/71/f1/715de12e09843a1c4a5f99ffd00c9ef1.jpg" alt="">
不过即便哈希算法存在散列冲突的情况但是因为哈希值的范围很大冲突的概率极低所以相对来说还是很难破解的。像MD5有2^128个不同的哈希值这个数据已经是一个天文数字了所以散列冲突的概率要小于1/2^128。
如果我们拿到一个MD5哈希值希望通过毫无规律的穷举的方法找到跟这个MD5值相同的另一个数据那耗费的时间应该是个天文数字。所以即便哈希算法存在冲突但是在有限的时间和资源下哈希算法还是很难被破解的。
除此之外没有绝对安全的加密。越复杂、越难破解的加密算法需要的计算时间也越长。比如SHA-256比SHA-1要更复杂、更安全相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。我们在实际的开发过程中也需要权衡破解难度和计算时间来决定究竟使用哪种加密算法。
## 应用二:唯一标识
我先来举一个例子。如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?
我们知道任何文件在计算中都可以表示成二进制码串所以比较笨的办法就是拿要查找的图片的二进制码串与图库中所有图片的二进制码串一一比对。如果相同则说明图片在图库中存在。但是每个图片小则几十KB、大则几MB转化成二进制是一个非常长的串比对起来非常耗时。有没有比较快的方法呢
我们可以给每一个图片取一个唯一标识或者说信息摘要。比如我们可以从图片的二进制码串开头取100个字节从中间取100个字节从最后再取100个字节然后将这300个字节放到一块通过哈希算法比如MD5得到一个哈希字符串用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中这样就可以减少很多工作量。
如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。
如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。
## 应用三:数据校验
电驴这样的BT下载软件你肯定用过吧我们知道BT下载的原理是基于P2P协议的。我们从多个机器上并行下载一个2GB的电影这个电影文件可能会被分割成很多文件块比如可以分成100块每块大约20MB。等所有的文件块都下载完成之后再组装成一个完整的电影文件就行了。
我们知道,网络传输是不安全的,下载的文件块有可能是被宿主机器恶意修改过的,又或者下载过程中出现了错误,所以下载的文件块可能不是完整的。如果我们没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。现在的问题是,如何来校验文件块的安全、正确、完整呢?
具体的BT协议很复杂校验方法也有很多我来说其中的一种思路。
我们通过哈希算法对100个文件块分别取哈希值并且保存在种子文件中。我们在前面讲过哈希算法有一个特点对数据很敏感。只要文件块的内容有一丁点儿的改变最后计算出的哈希值就会完全不同。所以当文件块下载完成之后我们可以通过相同的哈希算法对下载好的文件块逐一求哈希值然后跟种子文件中保存的哈希值比对。如果不同说明这个文件块不完整或者被篡改了需要再重新从其他宿主机器上下载这个文件块。
## 应用四:散列函数
前面讲了很多哈希算法的应用,实际上,散列函数也是哈希算法的一种应用。
我们前两节讲到,散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。
不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。
## 解答开篇
好了,有了前面的基础,现在你有没有发现开篇的问题其实很好解决?
我们可以通过哈希算法对用户密码进行加密之后再存储不过最好选择相对安全的加密算法比如SHA等因为MD5已经号称被破解了。不过仅仅这样加密之后存储就万事大吉了吗
字典攻击你听说过吗如果用户信息被“脱库”黑客虽然拿到是加密之后的密文但可以通过“猜”的方式来破解密码这是因为有些用户的密码太简单。比如很多人习惯用00000、123456这样的简单数字组合做密码很容易就被猜中。
那我们就需要维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。(注意,这里说是的是“基本上可以认为”,因为根据我们前面的学习,哈希算法存在散列冲突,也有可能出现,尽管密文一样,但是明文并不一样的情况。)
针对字典攻击我们可以引入一个盐salt跟用户的密码组合在一起增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密将它存储到数据库中进一步增加破解的难度。不过我这里想多说一句我认为安全和攻击是一种博弈关系不存在绝对的安全。所有的安全措施只是增加攻击的成本而已。
## 内容小结
今天的内容比较偏实战,我讲到了哈希算法的四个应用场景。我带你来回顾一下。
第一个应用是唯一标识,哈希算法可以对大数据做信息摘要,通过一个较短的二进制编码来表示很大的数据。
第二个应用是用于校验数据的完整性和正确性。
第三个应用是安全加密,我们讲到任何哈希算法都会出现散列冲突,但是这个冲突概率非常小。越是复杂哈希算法越难破解,但同样计算时间也就越长。所以,选择哈希算法的时候,要权衡安全性和计算时间来决定用哪种哈希算法。
第四个应用是散列函数,这个我们前面讲散列表的时候已经详细地讲过,它对哈希算法的要求非常特别,更加看重的是散列的平均性和哈希算法的执行效率。
## 课后思考
现在,区块链是一个很火的领域,它被很多人神秘化,不过其底层的实现原理并不复杂。其中,哈希算法就是它的一个非常重要的理论基础。你能讲一讲区块链使用的是哪种哈希算法吗?是为了解决什么问题而使用的呢?
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,88 @@
<audio id="audio" title="22 | 哈希算法(下):哈希算法在分布式系统中有哪些应用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/43/6e35346a93b0e0ba3cdd9751994f5b43.mp3"></audio>
上一节,我讲了哈希算法的四个应用,它们分别是:安全加密、数据校验、唯一标识、散列函数。今天,我们再来看剩余三种应用:**负载均衡、数据分片、分布式存储**。
你可能已经发现,这三个应用都跟分布式系统有关。没错,今天我就带你看下,**哈希算法是如何解决这些分布式问题的**。
## 应用五:负载均衡
我们知道负载均衡算法有很多比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞session sticky的负载均衡算法呢也就是说我们需要在同一个客户端上在一次会话中的所有请求都路由到同一个服务器上。
最直接的方法就是维护一张映射关系表这张表的内容是客户端IP地址或者会话ID与服务器编号的映射关系。客户端发出的每次请求都要先在映射表中查找应该路由到的服务器编号然后再请求编号对应的服务器。这种方法简单直观但也有几个弊端
<li>
如果客户端很多,映射表可能会很大,比较浪费内存空间;
</li>
<li>
客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;
</li>
如果借助哈希算法,这些问题都可以非常完美地解决。**我们可以通过哈希算法对客户端IP地址或者会话ID计算哈希值将取得的哈希值与服务器列表的大小进行取模运算最终得到的值就是应该被路由到的服务器编号。** 这样我们就可以把同一个IP过来的所有请求都路由到同一个后端服务器上。
## 应用六:数据分片
哈希算法还可以用于数据的分片。我这里有两个例子。
### 1.如何统计“搜索关键词”出现的次数?
假如我们有1T的日志文件这里面记录了用户的搜索关键词我们想要快速统计出每个关键词被搜索的次数该怎么做呢
我们来分析一下。这个问题有两个难点,第一个是搜索日志很大,没办法放到一台机器的内存中。第二个难点是,如果只用一台机器来处理这么巨大的数据,处理时间会很长。
针对这两个难点,**我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度**。具体的思路是这样的为了提高处理的速度我们用n台机器并行处理。我们从搜索记录的日志文件中依次读出每个搜索关键词并且通过哈希函数计算哈希值然后再跟n取模最终得到的值就是应该被分配到的机器编号。
这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。
实际上这里的处理过程也是MapReduce的基本设计思想。
### 2.如何快速判断图片是否在图库中?
如何快速判断图片是否在图库中?上一节我们讲过这个例子,不知道你还记得吗?当时我介绍了一种方法,即给每个图片取唯一标识(或者信息摘要),然后构建散列表。
假设现在我们的图库中有1亿张图片很显然在单台机器上构建散列表是行不通的。因为单台机器的内存有限而1亿张图片构建散列表显然远远超过了单台机器的内存上限。
我们同样可以对数据进行分片然后采用多机处理。我们准备n台机器让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片计算唯一标识然后与机器个数n求余取模得到的值就对应要分配的机器编号然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。
当我们要判断一个图片是否在图库中的时候我们通过同样的哈希算法计算这个图片的唯一标识然后与机器个数n求余取模。假设得到的值是k那就去编号k的机器构建的散列表中查找。
现在我们来估算一下给这1亿张图片构建散列表大约需要多少台机器。
散列表中每个数据单元包含两个信息哈希值和图片文件的路径。假设我们通过MD5来计算哈希值那长度就是128比特也就是16字节。文件路径长度的上限是256字节我们可以假设平均长度是128字节。如果我们用链表法来解决冲突那还需要存储指针指针只占用8字节。所以散列表中每个数据单元就占用152字节这里只是估算并不准确
假设一台机器的内存大小为2GB散列表的装载因子为0.75那一台机器可以给大约1000万2GB*0.75/152张图片构建散列表。所以如果要对1亿张图片构建索引需要大约十几台机器。在工程中这种估算还是很重要的能让我们事先对需要投入的资源、资金有个大概的了解能更好地评估解决方案的可行性。
实际上针对这种海量数据的处理问题我们都可以采用多机分布式处理。借助这种分片的思路可以突破单机内存、CPU等资源的限制。
## 应用七:分布式存储
现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。
该如何决定将哪个数据放到哪个机器上呢?我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。
但是如果数据增多原来的10个机器已经无法承受了我们就需要扩容了比如扩到11个机器这时候麻烦就来了。因为这里并不是简单地加个机器就可以了。
原来的数据是通过与10来取模的。比如13这个数据存储在编号为3这台机器上。但是新加了一台机器中我们对数据按照11取模原来13这个数据就被分配到2号这台机器上了。
<img src="https://static001.geekbang.org/resource/image/13/7c/138b060ee522cd2eae83c0c31a16bc7c.jpg" alt="">
因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生[雪崩效应](https://zh.wikipedia.org/wiki/%E9%9B%AA%E5%B4%A9%E6%95%88%E5%BA%94),压垮数据库。
所以,我们需要一种方法,使得在新加入一个机器后,并不需要做大量的数据搬移。这时候,**一致性哈希算法**就要登场了。
假设我们有k个机器数据的哈希值的范围是[0, MAX]。我们将整个范围划分成m个小区间m远大于k每个机器负责m/k个小区间。当有新机器加入的时候我们就将某几个小区间的数据从原来的机器中搬移到新的机器中。这样既不用全部重新哈希、搬移数据也保持了各个机器上数据数量的均衡。
一致性哈希算法的基本思想就是这么简单。除此之外,它还会借助一个虚拟的环和虚拟结点,更加优美地实现出来。这里我就不展开讲了,如果感兴趣,你可以看下这个[介绍](https://en.wikipedia.org/wiki/Consistent_hashing)。
除了我们上面讲到的分布式缓存,实际上,一致性哈希算法的应用非常广泛,在很多分布式存储系统中,都可以见到一致性哈希算法的影子。
## 解答开篇&amp;内容小结
这两节的内容理论不多,比较贴近具体的开发。今天我讲了三种哈希算法在分布式系统中的应用,它们分别是:负载均衡、数据分片、分布式存储。
在负载均衡应用中,利用哈希算法替代映射表,可以实现一个会话粘滞的负载均衡策略。在数据分片应用中,通过哈希算法对处理的海量数据进行分片,多机分布式处理,可以突破单机资源的限制。在分布式存储应用中,利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。
## 课后思考
这两节我总共讲了七个哈希算法的应用。实际上我讲的也只是冰山一角哈希算法还有很多其他的应用比如网络协议中的CRC校验、Git commit id等等。除了这些你还能想到其他用到哈希算法的地方吗
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/8e/704f5aed9fecca945740b5c50713458e.mp3"></audio>
前面我们讲的都是线性表结构,栈、队列等等。今天我们讲一种非线性表结构,树。树这种数据结构比线性表的数据结构要复杂得多,内容也比较多,所以我会分四节来讲解。
<img src="https://static001.geekbang.org/resource/image/6c/c9/6ce8707f43e1a3e7e5368167cca6a4c9.jpg" alt="">
我反复强调过,带着问题学习,是最有效的学习方式之一,所以在正式的内容开始之前,我还是给你出一道思考题:**二叉树有哪几种存储方式?什么样的二叉树适合用数组来存储?**
带着这些问题,我们就来学习今天的内容,树!
## 树Tree
我们首先来看,什么是“树”?再完备的定义,都没有图直观。所以我在图中画了几棵“树”。你来看看,这些“树”都有什么特征?
<img src="https://static001.geekbang.org/resource/image/b7/29/b7043bf29a253bb36221eaec62b2e129.jpg" alt="">
你有没有发现,“树”这种数据结构真的很像我们现实生活中的“树”,这里面每个元素我们叫做“节点”;用来连接相邻节点之间的关系,我们叫做“父子关系”。
比如下面这幅图A节点就是B节点的**父节点**B节点是A节点的**子节点**。B、C、D这三个节点的父节点是同一个节点所以它们之间互称为**兄弟节点**。我们把没有父节点的节点叫做**根节点**也就是图中的节点E。我们把没有子节点的节点叫做**叶子节点**或者**叶节点**比如图中的G、H、I、J、K、L都是叶子节点。
<img src="https://static001.geekbang.org/resource/image/22/ae/220043e683ea33b9912425ef759556ae.jpg" alt="">
除此之外,关于“树”,还有三个比较相似的概念:**高度**Height、**深度**Depth、**层**Level。它们的定义是这样的
<img src="https://static001.geekbang.org/resource/image/40/1e/4094a733986073fedb6b9d03f877d71e.jpg" alt="">
这三个概念的定义比较容易混淆,描述起来也比较空洞。我举个例子说明一下,你一看应该就能明白。
<img src="https://static001.geekbang.org/resource/image/50/b4/50f89510ad1f7570791dd12f4e9adeb4.jpg" alt="">
记这几个概念,我还有一个小窍门,就是类比“高度”“深度”“层”这几个名词在生活中的含义。
在我们的生活中“高度”这个概念其实就是从下往上度量比如我们要度量第10层楼的高度、第13层楼的高度起点都是地面。所以树这种数据结构的高度也是一样从最底层开始计数并且计数的起点是0。
“深度”这个概念在生活中是从上往下度量的比如水中鱼的深度是从水平面开始度量的。所以树这种数据结构的深度也是类似的从根结点开始度量并且计数起点也是0。
“层数”跟深度的计算类似不过计数起点是1也就是说根节点位于第1层。
## 二叉树Binary Tree
树结构多种多样,不过我们最常用还是二叉树。
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是**左子节点**和**右子<strong><strong>节**</strong></strong>。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。我画的这几个都是二叉树。以此类推,你可以想象一下四叉树、八叉树长什么样子。
<img src="https://static001.geekbang.org/resource/image/09/2b/09c2972d56eb0cf67e727deda0e9412b.jpg" alt="">
这个图里面有两个比较特殊的二叉树分别是编号2和编号3这两个。
其中编号2的二叉树中叶子节点全都在最底层除了叶子节点之外每个节点都有左右两个子节点这种二叉树就叫做**满二叉树**。
编号3的二叉树中叶子节点都在最底下两层最后一层的叶子节点都靠左排列并且除了最后一层其他层的节点个数都要达到最大这种二叉树叫做**完全二叉树**。
满二叉树很好理解,也很好识别,但是完全二叉树,有的人可能就分不清了。我画了几个完全二叉树和非完全二叉树的例子,你可以对比着看看。
<img src="https://static001.geekbang.org/resource/image/18/60/18413c6597c2850b75367393b401ad60.jpg" alt="">
你可能会说,满二叉树的特征非常明显,我们把它单独拎出来讲,这个可以理解。但是完全二叉树的特征不怎么明显啊,单从长相上来看,完全二叉树并没有特别特殊的地方啊,更像是“芸芸众树”中的一种。
那我们为什么还要特意把它拎出来讲呢?为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?这个定义的由来或者说目的在哪里?
要理解完全二叉树定义的由来,我们需要先了解,**如何表示(或者存储)一棵二叉树?**
想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。
我们先来看比较简单、直观的**链式存储法**。从图中你应该可以很清楚地看到,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。
<img src="https://static001.geekbang.org/resource/image/12/8e/12cd11b2432ed7c4dfc9a2053cb70b8e.jpg" alt="">
我们再来看,基于数组的**顺序存储法**。我们把根节点存储在下标i = 1的位置那左子节点存储在下标2 * i = 2的位置右子节点存储在2 * i + 1 = 3的位置。以此类推B节点的左子节点存储在2 * i = 2 * 2 = 4的位置右子节点存储在2 * i + 1 = 2 * 2 + 1 = 5的位置。
<img src="https://static001.geekbang.org/resource/image/14/30/14eaa820cb89a17a7303e8847a412330.jpg" alt="">
我来总结一下如果节点X存储在数组中下标为i的位置下标为2 * i 的位置存储的就是左子节点下标为2 * i + 1的位置存储的就是右子节点。反过来下标为i/2的位置存储就是它的父节点。通过这种方式我们只要知道根节点存储的位置一般情况下为了方便计算子节点根节点会存储在下标为1的位置这样就可以通过下标计算把整棵树都串起来。
不过我刚刚举的例子是一棵完全二叉树所以仅仅“浪费”了一个下标为0的存储位置。如果是非完全二叉树其实会浪费比较多的数组存储空间。你可以看我举的下面这个例子。
<img src="https://static001.geekbang.org/resource/image/08/23/08bd43991561ceeb76679fbb77071223.jpg" alt="">
所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。
当我们讲到堆和堆排序的时候,你会发现,堆其实就是一种完全二叉树,最常用的存储方式就是数组。
## 二叉树的遍历
前面我讲了二叉树的基本定义和存储方法,现在我们来看二叉树中非常重要的操作,二叉树的遍历。这也是非常常见的面试题。
如何将所有节点都遍历打印出来呢?经典的方法有三种,**前序遍历**、**中序遍历**和**后序遍历**。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。
<li>
前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
</li>
<li>
中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。
</li>
<li>
后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。
</li>
<img src="https://static001.geekbang.org/resource/image/ab/16/ab103822e75b5b15c615b68560cb2416.jpg" alt="">
**实际上,二叉树的前、中、后序遍历就是一个递归的过程**。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。
写递归代码的关键就是看能不能写出递推公式而写递推公式的关键就是如果要解决问题A就假设子问题B、C已经解决然后再来看如何利用B、C来解决A。所以我们可以把前、中、后序遍历的递推公式都写出来。
```
前序遍历的递推公式:
preOrder(r) = print r-&gt;preOrder(r-&gt;left)-&gt;preOrder(r-&gt;right)
中序遍历的递推公式:
inOrder(r) = inOrder(r-&gt;left)-&gt;print r-&gt;inOrder(r-&gt;right)
后序遍历的递推公式:
postOrder(r) = postOrder(r-&gt;left)-&gt;postOrder(r-&gt;right)-&gt;print r
```
有了递推公式,代码写起来就简单多了。这三种遍历方式的代码,我都写出来了,你可以看看。
```
void preOrder(Node* root) {
if (root == null) return;
print root // 此处为伪代码表示打印root节点
preOrder(root-&gt;left);
preOrder(root-&gt;right);
}
void inOrder(Node* root) {
if (root == null) return;
inOrder(root-&gt;left);
print root // 此处为伪代码表示打印root节点
inOrder(root-&gt;right);
}
void postOrder(Node* root) {
if (root == null) return;
postOrder(root-&gt;left);
postOrder(root-&gt;right);
print root // 此处为伪代码表示打印root节点
}
```
二叉树的前、中、后序遍历的递归实现是不是很简单?你知道**二叉树遍历的时间复杂度是多少**吗?我们一起来看看。
从我前面画的前、中、后序遍历的顺序图可以看出来每个节点最多会被访问两次所以遍历操作的时间复杂度跟节点的个数n成正比也就是说二叉树遍历的时间复杂度是O(n)。
## 解答开篇&amp;内容小结
今天,我讲了一种非线性表数据结构,树。关于树,有几个比较常用的概念你需要掌握,那就是:根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度、层数,以及树的高度。
我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。
二叉树既可以用链式存储也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树其他类型的二叉树用数组存储会比较浪费存储空间。除此之外二叉树里非常重要的操作就是前、中、后序遍历操作遍历的时间复杂度是O(n),你需要理解并能用递归代码来实现。
## 课后思考
<li>
给定一组数据比如1356910。你来算算可以构建出多少种不同的二叉树
</li>
<li>
我们讲了三种二叉树的遍历方式,前、中、后序。实际上,还有另外一种遍历方式,也就是按层遍历,你知道如何实现吗?
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,232 @@
<audio id="audio" title="24 | 二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3c/b1/3cdfbc7fab4f5d264cae3fb5d40be1b1.mp3"></audio>
上一节我们学习了树、二叉树以及二叉树的遍历,今天我们再来学习一种特殊的二叉树,二叉查找树。二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。
我们之前说过散列表也是支持这些操作的并且散列表的这些操作比二叉查找树更高效时间复杂度是O(1)。**既然有了这么高效的散列表,使用二叉树的地方是不是都可以替换成散列表呢?有没有哪些地方是散列表做不了,必须要用二叉树来做的呢?**
带着这些问题,我们就来学习今天的内容,二叉查找树!
## 二叉查找树Binary Search Tree
二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?
这些都依赖于二叉查找树的特殊结构。**二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。** 我画了几个二叉查找树的例子,你一看应该就清楚了。
<img src="https://static001.geekbang.org/resource/image/f3/ae/f3bb11b6d4a18f95aa19e11f22b99bae.jpg" alt="">
前面我们讲到,二叉查找树支持快速查找、插入、删除操作,现在我们就依次来看下,这三个操作是如何实现的。
### 1.二叉查找树的查找操作
首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
<img src="https://static001.geekbang.org/resource/image/96/2a/96b3d86ed9b7c4f399e8357ceed0db2a.jpg" alt="">
这里我把查找的代码实现了一下,贴在下面了,结合代码,理解起来会更加容易。
```
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data &lt; p.data) p = p.left;
else if (data &gt; p.data) p = p.right;
else return p;
}
return null;
}
public static class Node {
private int data;
private Node left;
private Node right;
public Node(int data) {
this.data = data;
}
}
}
```
### 2.二叉查找树的插入操作
二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。
如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
<img src="https://static001.geekbang.org/resource/image/da/c5/daa9fb557726ee6183c5b80222cfc5c5.jpg" alt="">
同样,插入的代码我也实现了一下,贴在下面,你可以看看。
```
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data &gt; p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data &lt; p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}
```
### 3.二叉查找树的删除操作
二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了 。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。
第一种情况是如果要删除的节点没有子节点我们只需要直接将父节点中指向要删除节点的指针置为null。比如图中的删除节点55。
第二种情况是如果要删除的节点只有一个子节点只有左子节点或者右子节点我们只需要更新父节点中指向要删除节点的指针让它指向要删除节点的子节点就可以了。比如图中的删除节点13。
第三种情况是如果要删除的节点有两个子节点这就比较复杂了。我们需要找到这个节点的右子树中的最小节点把它替换到要删除的节点上。然后再删除掉这个最小节点因为最小节点肯定没有左子节点如果有左子结点那就不是最小节点了所以我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点18。
<img src="https://static001.geekbang.org/resource/image/29/2c/299c615bc2e00dc32225f4d9e3490e2c.jpg" alt="">
老规矩,我还是把删除的代码贴在这里。
```
public void delete(int data) {
Node p = tree; // p指向要删除的节点初始化指向根节点
Node pp = null; // pp记录的是p的父节点
while (p != null &amp;&amp; p.data != data) {
pp = p;
if (data &gt; p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 没有找到
// 要删除的节点有两个子节点
if (p.left != null &amp;&amp; p.right != null) { // 查找右子树中最小节点
Node minP = p.right;
Node minPP = p; // minPP表示minP的父节点
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 将minP的数据替换到p中
p = minP; // 下面就变成了删除minP了
pp = minPP;
}
// 删除节点是叶子节点或者仅有一个子节点
Node child; // p的子节点
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 删除的是根节点
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
```
实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。
### 4.二叉查找树的其他操作
除了插入、删除、查找操作之外,二叉查找树中还可以支持**快速地查找最大节点和最小节点、前驱节点和后继节点**。这些操作我就不一一展示了。我会将相应的代码放到GitHub上你可以自己先实现一下然后再去上面看。
二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是**中序遍历二叉查找树可以输出有序的数据序列时间复杂度是O(n),非常高效**。因此,二叉查找树也叫作二叉排序树。
## 支持重复数据的二叉查找树
前面讲二叉查找树的时候我们默认树中节点存储的都是数字。很多时候在实际的软件开发中我们在二叉查找树中存储的是一个包含很多字段的对象。我们利用对象的某个字段作为键值key来构建二叉查找树。我们把对象中的其他字段叫作卫星数据。
前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?我这里有两种解决方法。
第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
第二种方法比较不好理解,不过更加优雅。
每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
<img src="https://static001.geekbang.org/resource/image/3f/5f/3f59a40e3d927f567022918d89590a5f.jpg" alt="">
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
<img src="https://static001.geekbang.org/resource/image/fb/ff/fb7b320efd59a05469d6d6fcf0c98eff.jpg" alt="">
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
<img src="https://static001.geekbang.org/resource/image/25/17/254a4800703d31612c0af63870260517.jpg" alt="">
## 二叉查找树的时间复杂度分析
好了,对于二叉查找树常用操作的实现方式,你应该掌握得差不多了。现在,我们来分析一下,二叉查找树的插入、删除、查找操作的时间复杂度。
实际上二叉查找树的形态各式各样。比如这个图中对于同一组数据我们构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树根节点的左右子树极度不平衡已经退化成了链表所以查找的时间复杂度就变成了O(n)。
<img src="https://static001.geekbang.org/resource/image/e3/d9/e3d9b2977d350526d2156f01960383d9.jpg" alt="">
我刚刚其实分析了一种最糟糕的情况,我们现在来分析一个最理想的情况,二叉查找树是一棵完全二叉树(或满二叉树)。这个时候,插入、删除、查找的时间复杂度是多少呢?
从我前面的例子、图,以及还有代码来看,不管操作是插入、删除还是查找,**时间复杂度<strong><strong>其实**</strong>都跟树的高度成正比也就是O(height)</strong>。既然这样现在问题就转变成另外一个了也就是如何求一棵包含n个节点的完全二叉树的高度
树的高度就等于最大层数减一为了方便计算我们转换成层来表示。从图中可以看出包含n个节点的完全二叉树中第一层包含1个节点第二层包含2个节点第三层包含4个节点依次类推下面一层节点个数是上一层的2倍第K层包含的节点个数就是2^(K-1)。
不过对于完全二叉树来说最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在1个到2^(L-1)个之间我们假设最大层数是L。如果我们把每一层的节点个数加起来就是总的节点个数n。也就是说如果节点的个数是n那么n满足这样一个关系
```
n &gt;= 1+2+4+8+...+2^(L-2)+1
n &lt;= 1+2+4+8+...+2^(L-2)+2^(L-1)
```
借助等比数列的求和公式我们可以计算出L的范围是[log<sub>2</sub>(n+1), log<sub>2</sub>n +1]。完全二叉树的层数小于等于log<sub>2</sub>n +1也就是说完全二叉树的高度小于等于log<sub>2</sub>n。
显然极度不平衡的二叉查找树它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据在任何时候都能保持任意节点左右子树都比较平衡的二叉查找树这就是我们下一节课要详细讲的一种特殊的二叉查找树平衡二叉查找树。平衡二叉查找树的高度接近logn所以插入、删除、查找操作的时间复杂度也比较稳定是O(logn)。
## 解答开篇
我们在散列表那节中讲过散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1)非常高效。而二叉查找树在比较平衡的情况下插入、删除、查找操作时间复杂度才是O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?
我认为有下面几个原因:
第一散列表中的数据是无序存储的如果要输出有序的数据需要先进行排序。而对于二叉查找树来说我们只需要中序遍历就可以在O(n)的时间复杂度内,输出有序的数据序列。
第二散列表扩容耗时很多而且当遇到散列冲突时性能不稳定尽管二叉查找树的性能不稳定但是在工程中我们最常用的平衡二叉查找树的性能非常稳定时间复杂度稳定在O(logn)。
第三笼统地来说尽管散列表的查找等操作的时间复杂度是常量级的但因为哈希冲突的存在这个常量不一定比logn小所以实际的查找速度可能不一定比O(logn)快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。
综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。
## 内容小结
今天我们学习了一种特殊的二叉树,二叉查找树。它支持快速地查找、插入、删除操作。
二叉查找树中,每个节点的值都大于左子树节点的值,小于右子树节点的值。不过,这只是针对没有重复数据的情况。对于存在重复数据的二叉查找树,我介绍了两种构建方法,一种是让每个节点存储多个值相同的数据;另一种是,每个节点中存储一个数据。针对这种情况,我们只需要稍加改造原来的插入、删除、查找操作即可。
在二叉查找树中查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是O(n)和O(logn),分别对应二叉树退化成链表的情况和完全二叉树。
为了避免时间复杂度的退化针对二叉查找树我们又设计了一种更加复杂的树平衡二叉查找树时间复杂度可以做到稳定的O(logn),下一节我们具体来讲。
## 课后思考
今天我讲了二叉树高度的理论分析方法,给出了粗略的数量级。如何通过编程,求出一棵给定二叉树的确切高度呢?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。

View File

@@ -0,0 +1,108 @@
<audio id="audio" title="25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5b/58/5b8ee5da6bc0b1416c7798798751de58.mp3"></audio>
上两节我们依次讲了树、二叉树、二叉查找树。二叉查找树是最常用的一种二叉树它支持快速插入、删除、查找操作各个操作的时间复杂度跟树的高度成正比理想情况下时间复杂度是O(logn)。
不过二叉查找树在频繁的动态更新过程中可能会出现树的高度远大于log<sub>2</sub>n的情况从而导致各个操作的效率下降。极端情况下二叉树会退化为链表时间复杂度会退化到O(n)。我上一节说了,要解决这个复杂度退化的问题,我们需要设计一种平衡二叉查找树,也就是今天要讲的这种数据结构。
很多书籍里,但凡讲到平衡二叉查找树,就会拿红黑树作为例子。不仅如此,如果你有一定的开发经验,你会发现,在工程中,很多用到平衡二叉查找树的地方都会用红黑树。你有没有想过,**为什么工程中都喜欢用红黑树,而不是其他平衡二叉查找树呢?**
带着这个问题,让我们一起来学习今天的内容吧!
## 什么是“平衡二叉查找树”?
平衡二叉树的严格定义是这样的二叉树中任意一个节点的左右子树的高度相差不能大于1。从这个定义来看上一节我们讲的完全二叉树、满二叉树其实都是平衡二叉树但是非完全二叉树也有可能是平衡二叉树。
<img src="https://static001.geekbang.org/resource/image/dd/9b/dd9f5a4525f5029a8339c89ad1c8159b.jpg" alt="">
平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。最先被发明的平衡二叉查找树是[AVL树](https://zh.wikipedia.org/wiki/AVL%E6%A0%91)它严格符合我刚讲到的平衡二叉查找树的定义即任何节点的左右子树高度相差不超过1是一种高度平衡的二叉查找树。
但是很多平衡二叉查找树其实并没有严格符合上面的定义树中任意一个节点的左右子树的高度相差不能大于1比如我们下面要讲的红黑树它从根节点到各个叶子节点的最长路径有可能会比最短路径大一倍。
我们学习数据结构和算法是为了应用到实际的开发中的,所以,我觉得没必去死抠定义。对于平衡二叉查找树这个概念,我觉得我们要从这个数据结构的由来,去理解“平衡”的意思。
发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。
所以,**平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。**
所以如果我们现在设计一个新的平衡二叉查找树只要树的高度不比log<sub>2</sub>n大很多比如树的高度仍然是对数量级的尽管它不符合我们前面讲的严格的平衡二叉查找树的定义但我们仍然可以说这是一个合格的平衡二叉查找树。
## 如何定义一棵“红黑树”?
平衡二叉查找树其实有很多比如Splay Tree伸展树、Treap树堆但是我们提到平衡二叉查找树听到的基本都是红黑树。它的出镜率甚至要高于“平衡二叉查找树”这几个字有时候我们甚至默认平衡二叉查找树就是红黑树那我们现在就来看看这个“明星树”。
红黑树的英文是“Red-Black Tree”简称R-B Tree。它是一种不严格的平衡二叉查找树我前面说了它的定义是不严格符合平衡二叉查找树的定义的。那红黑树究竟是怎么定义的呢
顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:
<li>
根节点是黑色的;
</li>
<li>
每个叶子节点都是黑色的空节点NIL也就是说叶子节点不存储数据
</li>
<li>
任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
</li>
<li>
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
</li>
这里的第二点要求“叶子节点都是黑色的空节点”,稍微有些奇怪,它主要是为了简化红黑树的代码实现而设置的,下一节我们讲红黑树的实现的时候会讲到。**这节我们暂时不考虑这一点,所以,在画图和讲解的时候,我将黑色的、空的叶子节点都省略掉了。**
为了让你更好地理解上面的定义,我画了两个红黑树的图例,你可以对照着看下。
<img src="https://static001.geekbang.org/resource/image/90/9a/903ee0dcb62bce2f5b47819541f9069a.jpg" alt="">
## 为什么说红黑树是“近似平衡”的?
我们前面也讲到,平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,**“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重**。
我们在上一节讲过二叉查找树很多操作的性能都跟树的高度成正比。一棵极其平衡的二叉树满二叉树或完全二叉树的高度大约是log<sub>2</sub>n所以如果要证明红黑树是近似平衡的我们只需要分析红黑树的高度是否比较稳定地趋近log<sub>2</sub>n就好了。
红黑树的高度不是很好分析,我带你一步一步来推导。
**首先,我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?**
红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。
<img src="https://static001.geekbang.org/resource/image/7e/ed/7e6ecc308fe44120f30de809822215ed.jpg" alt="">
前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。
上一节我们说完全二叉树的高度近似log<sub>2</sub>n这里的四叉“黑树”的高度要低于完全二叉树所以去掉红色节点的“黑树”的高度也不会超过log<sub>2</sub>n。
**我们现在知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,高度会变成多少呢?**
从上面我画的红黑树的例子和定义看在红黑树中红色节点不能相邻也就是说有一个红色节点就要至少有一个黑色节点将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过log<sub>2</sub>n所以加入红色节点之后最长路径不会超过2log<sub>2</sub>n也就是说红黑树的高度近似2log<sub>2</sub>n。
所以红黑树的高度只比高度平衡的AVL树的高度log<sub>2</sub>n仅仅大了一倍在性能上下降得并不多。这样推导出来的结果不够精确实际上红黑树的性能更好。
## 解答开篇
我们刚刚提到了很多平衡二叉查找树,现在我们就来看下,为什么在工程中大家都喜欢用红黑树这种平衡二叉查找树?
我们前面提到Treap、Splay Tree绝大部分情况下它们操作的效率都很高但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大但是对于单次操作时间非常敏感的场景来说它们并不适用。
AVL树是一种高度平衡的二叉树所以查找的效率非常高但是有利就有弊AVL树为了维持这种高度的平衡就要付出更多的代价。每次插入、删除都要做调整就比较复杂、耗时。所以对于有频繁的插入、删除操作的数据集合使用AVL树的代价就有点高了。
红黑树只是做到了近似平衡并不是严格的平衡所以在维护平衡的成本上要比AVL树要低。
所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。
## 内容小结
很多同学都觉得红黑树很难,的确,它算是最难掌握的一种数据结构。其实红黑树最难的地方是它的实现,我们今天还没有涉及,下一节我会专门来讲。
不过呢,我认为,我们其实不应该把学习的侧重点,放到它的实现上。那你可能要问了,关于红黑树,我们究竟需要掌握哪些东西呢?
还记得我多次说过的观点吗?**我们学习数据结构和算法,要学习它的由来、特性、适用的场景以及它能解决的问题。对于红黑树,也不例外。你如果能搞懂这几个问题,其实就已经足够了。**
红黑树是一种平衡二叉查找树。它是为了解决普通二叉查找树在数据更新的过程中复杂度退化的问题而产生的。红黑树的高度近似log<sub>2</sub>n所以它是近似平衡插入、删除、查找操作的时间复杂度都是O(logn)。
因为红黑树是一种性能非常稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它。不过,它实现起来比较复杂,如果自己写代码实现,难度会有些高,这个时候,我们其实更倾向用跳表来替代它。
## 课后思考
动态数据结构支持动态的数据插入、删除、查找操作,除了红黑树,我们前面还学习过哪些呢?能对比一下各自的优势、劣势,以及应用场景吗?
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="26 | 红黑树(下):掌握这些技巧,你也可以实现一个红黑树" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/9c/65c92bf96a680d39efbbc7b8edfa049c.mp3"></audio>
红黑树是一个让我又爱又恨的数据结构,“爱”是因为它稳定、高效的性能,“恨”是因为实现起来实在太难了。我今天讲的红黑树的实现,对于基础不太好的同学,理解起来可能会有些困难。但是,我觉得没必要去死磕它。
我为什么这么说呢?因为,即便你将左右旋背得滚瓜烂熟,我保证你过不几天就忘光了。因为,学习红黑树的代码实现,对于你平时做项目开发没有太大帮助。对于绝大部分开发工程师来说,这辈子你可能都用不着亲手写一个红黑树。除此之外,它对于算法面试也几乎没什么用,一般情况下,靠谱的面试官也不会让你手写红黑树的。
如果你对数据结构和算法很感兴趣,想要开拓眼界、训练思维,我还是很推荐你看一看这节的内容。但是如果学完今天的内容你还觉得懵懵懂懂的话,也不要纠结。我们要有的放矢去学习。你先把平时要用的、基础的东西都搞会了,如果有余力了,再来深入地研究这节内容。
好,我们现在就进入正式的内容。**上一节,我们讲到红黑树定义的时候,提到红黑树的叶子节点都是黑色的空节点。当时我只是粗略地解释了,这是为了代码实现方便,那更加确切的原因是什么呢?** 我们这节就来说一说。
## 实现红黑树的基本思想
不知道你有没有玩过魔方?其实魔方的复原解法是有固定算法的:遇到哪几面是什么样子,对应就怎么转几下。你只要跟着这个复原步骤,就肯定能将魔方复原。
实际上,红黑树的平衡过程跟魔方复原非常神似,大致过程就是:**遇到什么样的节点排布,我们就对应怎么去调整**。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。
还记得我们前面讲过的红黑树的定义吗?今天的内容里,我们会频繁用到它,所以,我们现在再来回顾一下。一棵合格的红黑树需要满足这样几个要求:
<li>
根节点是黑色的;
</li>
<li>
每个叶子节点都是黑色的空节点NIL也就是说叶子节点不存储数据
</li>
<li>
任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
</li>
<li>
每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。
</li>
在插入、删除节点的过程中,第三、第四点要求可能会被破坏,而我们今天要讲的“平衡调整”,实际上就是要把被破坏的第三、第四点恢复过来。
在正式开始之前,我先介绍两个非常重要的操作,**左旋rotate left**、**右旋rotate right**。左旋全称其实是叫**围绕某个节点的左旋**,那右旋的全称估计你已经猜到了,就叫**围绕某个节点的右旋**。
我们下面的平衡调整中会一直用到这两个操作所以我这里画了个示意图帮助你彻底理解这两个操作。图中的abr表示子树可以为空。
<img src="https://static001.geekbang.org/resource/image/0e/1e/0e37e597737012593a93105ebbf4591e.jpg" alt="">
前面我说了,红黑树的插入、删除操作会破坏红黑树的定义,具体来说就是会破坏红黑树的平衡,所以,我们现在就来看下,红黑树在插入、删除数据之后,如何调整平衡,继续当一棵合格的红黑树的。
## 插入操作的平衡调整
首先,我们来看插入操作。
**红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上**。所以,关于插入操作的平衡调整,有这样两种特殊情况,但是也都非常好处理。
<li>
如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
</li>
<li>
如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。
</li>
除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:**左右旋转**和**改变颜色**。
红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫做**关注节点**。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。
新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。
我们下面依次来看每种情况的调整过程。提醒你注意下,为了简化描述,我把父节点的兄弟节点叫做叔叔节点,父节点的父节点叫做祖父节点。
**CASE 1如果关注节点是a它的叔叔节点d是红色**,我们就依次执行下面的操作:
<li>
将关注节点a的父节点b、叔叔节点d的颜色都设置成黑色
</li>
<li>
将关注节点a的祖父节点c的颜色设置成红色
</li>
<li>
关注节点变成a的祖父节点c
</li>
<li>
跳到CASE 2或者CASE 3。
</li>
<img src="https://static001.geekbang.org/resource/image/60/40/603cf91f54b5db21bd02c6c5678ecf40.jpg" alt="">
**CASE 2如果关注节点是a它的叔叔节点d是黑色关注节点a是其父节点b的右子节点**,我们就依次执行下面的操作:
<li>
关注节点变成节点a的父节点b
</li>
<li>
围绕新的关注节点b左旋
</li>
<li>
跳到CASE 3。
</li>
<img src="https://static001.geekbang.org/resource/image/44/ad/4480a314f9d83c343b8adbb28b6782ad.jpg" alt="">
**CASE 3如果关注节点是a它的叔叔节点d是黑色关注节点a是其父节点b的左子节点**,我们就依次执行下面的操作:
<li>
围绕关注节点a的祖父节点c右旋
</li>
<li>
将关注节点a的父节点b、兄弟节点c的颜色互换。
</li>
<li>
调整结束。
</li>
<img src="https://static001.geekbang.org/resource/image/04/12/04650d9470b1e67899f5b8b7b8e33212.jpg" alt="">
## 删除操作的平衡调整
红黑树插入操作的平衡调整还不是很难,但是它的删除操作的平衡调整相对就要难多了。不过原理都是类似的,我们依旧只需要根据关注节点与周围节点的排布特点,按照一定的规则去调整就行了。
删除操作的平衡调整分为两步,第一步是**针对删除节点初步调整**。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是**针对关注节点进行二次调整**,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。
### 1.针对删除节点初步调整
这里需要注意一下,红黑树的定义中“只包含红色节点和黑色节点”,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,“红-黑”或者“黑-黑”。如果一个节点被标记为了“黑-黑”,那在计算黑色节点个数的时候,要算成两个黑色节点。
在下面的讲解中,如果一个节点既可以是红色,也可以是黑色,在画图的时候,我会用一半红色一半黑色来表示。如果一个节点是“红-黑”或者“黑-黑”,我会用左上角的一个小黑点来表示额外的黑色。
**CASE 1如果要删除的节点是a它只有一个子节点b**,那我们就依次进行下面的操作:
<li>
删除节点a并且把节点b替换到节点a的位置这一部分操作跟普通的二叉查找树的删除操作一样
</li>
<li>
节点a只能是黑色节点b也只能是红色其他情况均不符合红黑树的定义。这种情况下我们把节点b改为黑色
</li>
<li>
调整结束,不需要进行二次调整。
</li>
<img src="https://static001.geekbang.org/resource/image/a6/c3/a6c4c347b7cbdf57662bab399ed36cc3.jpg" alt="">
**CASE 2如果要删除的节点a有两个非空子节点并且它的后继节点就是节点a的右子节点c**。我们就依次进行下面的操作:
<li>
如果节点a的后继节点就是右子节点c那右子节点c肯定没有左子树。我们把节点a删除并且将节点c替换到节点a的位置。这一部分操作跟普通的二叉查找树的删除操作无异
</li>
<li>
然后把节点c的颜色设置为跟节点a相同的颜色
</li>
<li>
如果节点c是黑色为了不违反红黑树的最后一条定义我们给节点c的右子节点d多加一个黑色这个时候节点d就成了“红-黑”或者“黑-黑”;
</li>
<li>
这个时候关注节点变成了节点d第二步的调整操作就会针对关注节点来做。
</li>
<img src="https://static001.geekbang.org/resource/image/48/4e/48e3bd2cdd66cb635f8a4df8fb8fd64e.jpg" alt="">
**CASE 3如果要删除的是节点a它有两个非空子节点并且节点a的后继节点不是右子节点**,我们就依次进行下面的操作:
<li>
找到后继节点d并将它删除删除后继节点d的过程参照CASE 1
</li>
<li>
将节点a替换成后继节点d
</li>
<li>
把节点d的颜色设置为跟节点a相同的颜色
</li>
<li>
如果节点d是黑色为了不违反红黑树的最后一条定义我们给节点d的右子节点c多加一个黑色这个时候节点c就成了“红-黑”或者“黑-黑”;
</li>
<li>
这个时候关注节点变成了节点c第二步的调整操作就会针对关注节点来做。
</li>
<img src="https://static001.geekbang.org/resource/image/b9/29/b93c1fa4de16aee5482424ddf49f3c29.jpg" alt="">
### 2.针对关注节点进行二次调整
经过初步调整之后,关注节点变成了“红-黑”或者“黑-黑”节点。针对这个关注节点,我们再分四种情况来进行二次调整。二次调整是为了让红黑树中不存在相邻的红色节点。
**CASE 1如果关注节点是a它的兄弟节点c是红色的**,我们就依次进行下面的操作:
<li>
围绕关注节点a的父节点b左旋
</li>
<li>
关注节点a的父节点b和祖父节点c交换颜色
</li>
<li>
关注节点不变;
</li>
<li>
继续从四种情况中选择适合的规则来调整。
</li>
<img src="https://static001.geekbang.org/resource/image/ac/91/ac76d78c064a2486e2a5b4c4903acb91.jpg" alt="">
**CASE 2如果关注节点是a它的兄弟节点c是黑色的并且节点c的左右子节点d、e都是黑色的**,我们就依次进行下面的操作:
<li>
将关注节点a的兄弟节点c的颜色变成红色
</li>
<li>
从关注节点a中去掉一个黑色这个时候节点a就是单纯的红色或者黑色
</li>
<li>
给关注节点a的父节点b添加一个黑色这个时候节点b就变成了“红-黑”或者“黑-黑”;
</li>
<li>
关注节点从a变成其父节点b
</li>
<li>
继续从四种情况中选择符合的规则来调整。
</li>
<img src="https://static001.geekbang.org/resource/image/ec/ec/eca118d673c607eb2b103f3476fb24ec.jpg" alt="">
**CASE 3如果关注节点是a它的兄弟节点c是黑色c的左子节点d是红色c的右子节点e是黑色**,我们就依次进行下面的操作:
<li>
围绕关注节点a的兄弟节点c右旋
</li>
<li>
节点c和节点d交换颜色
</li>
<li>
关注节点不变;
</li>
<li>
跳转到CASE 4继续调整。
</li>
<img src="https://static001.geekbang.org/resource/image/44/af/44075213100edd70315e1492422c92af.jpg" alt="">
**CASE 4如果关注节点a的兄弟节点c是黑色的并且c的右子节点是红色的**,我们就依次进行下面的操作:
<li>
围绕关注节点a的父节点b左旋
</li>
<li>
将关注节点a的兄弟节点c的颜色跟关注节点a的父节点b设置成相同的颜色
</li>
<li>
将关注节点a的父节点b的颜色设置为黑色
</li>
<li>
从关注节点a中去掉一个黑色节点a就变成了单纯的红色或者黑色
</li>
<li>
将关注节点a的叔叔节点e设置为黑色
</li>
<li>
调整结束。
</li>
<img src="https://static001.geekbang.org/resource/image/5f/44/5f73f61bf77a7f2bb75f168cf432ec44.jpg" alt="">
## 解答开篇
红黑树的平衡调整就讲完了,现在,你能回答开篇的问题了吗?为什么红黑树的定义中,要求叶子节点是黑色的空节点?
要我说,之所以有这么奇怪的要求,其实就是为了实现起来方便。只要满足这一条要求,那在任何时刻,红黑树的平衡操作都可以归结为我们刚刚讲的那几种情况。
还是有点不好理解,我通过一个例子来解释一下。假设红黑树的定义中不包含刚刚提到的那一条“叶子节点必须是黑色的空节点”,我们往一棵红黑树中插入一个数据,新插入节点的父节点也是红色的,两个红色的节点相邻,这个时候,红黑树的定义就被破坏了。那我们应该如何调整呢?
<img src="https://static001.geekbang.org/resource/image/d9/c9/d9d1ce7d6bf3da4888f39f9d15be99c9.jpg" alt="">
你会发现这个时候我们前面在讲插入时三种情况下的平衡调整规则没有一种是适用的。但是如果我们把黑色的空节点都给它加上变成下面这样你会发现它满足CASE 2了。
<img src="https://static001.geekbang.org/resource/image/8b/9a/8b1fb8c8004d86f737d829ecbd3a599a.jpg" alt="">
你可能会说你可以调整一下平衡调整规则啊。比如把CASE 2改为“如果关注节点a的叔叔节点b是黑色或者不存在a是父节点的右子节点就进行某某操作”。当然可以但是这样的话规则就没有原来简洁了。
你可能还会说,这样给红黑树添加黑色的空的叶子节点,会不会比较浪费存储空间呢?答案是不会的。虽然我们在讲解或者画图的时候,每个黑色的、空的叶子节点都是独立画出来的。实际上,在具体实现的时候,我们只需要像下面这样,共用一个黑色的、空的叶子节点就行了。
<img src="https://static001.geekbang.org/resource/image/d6/66/d63231acb0e9d54c3469055d8dbdb366.jpg" alt="">
## 内容小结
“红黑树一向都很难学”,有这种想法的人很多。但是我感觉,其实主要原因是,很多人试图去记忆它的平衡调整策略。实际上,你只需要能看懂我讲的过程,没有知识盲点,就算是掌握了这部分内容了。毕竟实际的软件开发并不是闭卷考试,当你真的需要实现一个红黑树的时候,可以对照着我讲的步骤,一点一点去实现。
现在,我就来总结一下,如何比较轻松地看懂我今天讲的操作过程。
第一点,**把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性**。你只需要明白,只要按照固定的操作步骤,保持插入、删除的过程,不破坏平衡树的定义就行了。
第二点,**找准关注节点,不要搞丢、搞错关注节点**。因为每种操作规则,都是基于关注节点来做的,只有弄对了关注节点,才能对应到正确的操作规则中。在迭代的调整过程中,关注节点在不停地改变,所以,这个过程一定要注意,不要弄丢了关注节点。
第三点,**插入操作的平衡调整比较简单,但是删除操作就比较复杂**。针对删除操作,我们有两次调整,第一次是针对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义,“每个节点到可达叶子节点的路径都包含相同个数的黑色节点”。但是这个时候,第三条定义就不满足了,有可能会存在两个红色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。
## 课后思考
如果你以前了解或者学习过红黑树,关于红黑树的实现,你也可以在留言区讲讲,你是怎样来学习的?在学习的过程中,有过什么样的心得体会?有没有什么好的学习方法?
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,189 @@
<audio id="audio" title="27 | 递归树:如何借助树来求解递归算法的时间复杂度?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fa/46/fa697c966301e83f6e68e478e69de846.mp3"></audio>
今天,我们来讲这种数据结构的一种特殊应用,递归树。
我们都知道,递归代码的时间复杂度分析起来很麻烦。我们在[第12节《排序](https://time.geekbang.org/column/article/41913)那里讲过,如何利用递推公式,求解归并排序、快速排序的时间复杂度,但是,有些情况,比如快排的平均时间复杂度的分析,用递推公式的话,会涉及非常复杂的数学推导。
除了用递推公式这种比较复杂的分析方法,有没有更简单的方法呢?今天,我们就来学习另外一种方法,**借助递归树来分析递归算法的时间复杂度**。
## 递归树与时间复杂度分析
我们前面讲过,递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。
如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作**递归树**。我这里画了一棵斐波那契数列的递归树,你可以看看。节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。
<img src="https://static001.geekbang.org/resource/image/1d/a3/1d9648b7f43e430473d76d24803159a3.jpg" alt="">
通过这个例子,你对递归树的样子应该有个感性的认识了,看起来并不复杂。现在,我们就来看,**如何用递归树来求解时间复杂度**。
归并排序算法你还记得吧?它的递归实现代码非常简洁。现在我们就借助归并排序来看看,如何用递归树,来分析递归代码的时间复杂度。
归并排序的原理我就不详细介绍了如果你忘记了可以回看一下第12节的内容。归并排序每次会将数据规模一分为二。我们把归并排序画成递归树就是下面这个样子
<img src="https://static001.geekbang.org/resource/image/c6/d0/c66bfc3d02d3b7b8f64c208bf4c948d0.jpg" alt="">
因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量$1$。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的时间记作$n$。
现在,我们只需要知道这棵树的高度$h$,用高度$h$乘以每一层的时间消耗$n$,就可以得到总的时间复杂度$O(n*h)$。
从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。我们前两节中讲到,满二叉树的高度大约是$\log_{2}n$,所以,归并排序递归实现的时间复杂度就是$O(n\log n)$。我这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。
利用递归树的时间复杂度分析方法并不难理解,关键还是在实战,所以,接下来我会通过三个实际的递归算法,带你实战一下递归的复杂度分析。学完这节课之后,你应该能真正掌握递归代码的复杂度分析。
## 实战一:分析快速排序的时间复杂度
在用递归树推导之前,我们先来回忆一下用递推公式的分析方法。你可以回想一下,当时,我们为什么说用递推公式来求解平均时间复杂度非常复杂?
快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式$T(n)=2T(\frac{n}{2})+n$,很容易就能推导出时间复杂度是$O(n\log n)$。但是,我们并不可能每次分区都这么幸运,正好一分为二。
我们假设平均情况下,每次分区之后,两个分区的大小比例为$1:k$。当$k=9$时,如果用递推公式的方法来求解时间复杂度的话,递推公式就写成$T(n)=T(\frac{n}{10})+T(\frac{9n}{10})+n$。
这个公式可以推导出时间复杂度,但是推导过程非常复杂。那我们来看看,**用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单呢?**
我们还是取$k$等于$9$,也就是说,每次分区都很不平均,一个分区是另一个分区的$9$倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/44/43/44972a3531dae0b7a0ccc935bc13f243.jpg" alt="">
快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是$n$。我们现在只要求出递归树的高度$h$,这个快排过程遍历的数据个数就是 $h * n$ ,也就是说,时间复杂度就是$O(h * n)$。
因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少呢?
我们知道,快速排序结束的条件就是待排序的小区间,大小为$1$,也就是说叶子节点里的数据规模是$1$。从根节点$n$到叶子节点$1$,递归树中最短的一个路径每次都乘以$\frac{1}{10}$,最长的一个路径每次都乘以$\frac{9}{10}$。通过计算,我们可以得到,从根节点到叶子节点的最短路径是$\log_{10}n$,最长的路径是$\log_{\frac{10}{9}}n$。
<img src="https://static001.geekbang.org/resource/image/7c/ed/7cea8607f0d92a901f3152341830d6ed.jpg" alt="">
所以,遍历数据的个数总和就介于$n\log_{10}n$和$n\log_{\frac{10}{9}}n$之间。根据复杂度的大O表示法对数复杂度的底数不管是多少我们统一写成$\log n$,所以,当分区大小比例是$1:9$时,快速排序的时间复杂度仍然是$O(n\log n)$。
刚刚我们假设$k=9$,那如果$k=99$,也就是说,每次分区极其不平均,两个区间大小是$1:99$,这个时候的时间复杂度是多少呢?
我们可以类比上面$k=9$的分析过程。当$k=99$的时候,树的最短路径就是$\log_{100}n$,最长路径是$\log_{\frac{100}{99}}n$,所以总遍历数据个数介于$n\log_{100}n$和$n\log_{\frac{100}{99}}n$之间。尽管底数变了,但是时间复杂度也仍然是$O(n\log n)$。
也就是说,对于$k$等于$9$$99$,甚至是$999$$9999$……,只要$k$的值不随$n$变化,是一个事先确定的常量,那快排的时间复杂度就是$O(n\log n)$。所以,从概率论的角度来说,快排的平均时间复杂度就是$O(n\log n)$。
## 实战二:分析斐波那契数列的时间复杂度
在递归那一节中,我们举了一个跨台阶的例子,你还记得吗?那个例子实际上就是一个斐波那契数列。为了方便你回忆,我把它的代码实现贴在这里。
```
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
```
这样一段代码的时间复杂度是多少呢?你可以先试着分析一下,然后再来看,我是怎么利用递归树来分析的。
我们先把上面的递归代码画成递归树,就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/9c/ce/9ccbce1a70c7e2def52701dcf176a4ce.jpg" alt="">
这棵递归树的高度是多少呢?
$f(n)$分解为$f(n-1)$和$f(n-2)$,每次数据规模都是$-1$或者$-2$,叶子节点的数据规模是$1$或者$2$。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是$-1$,那最长路径大约就是$n$;如果每次都是$-2$,那最短路径大约就是$\frac{n}{2}$。
每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作$1$。所以,从上往下,第一层的总时间消耗是$1$,第二层的总时间消耗是$2$,第三层的总时间消耗就是$2^{2}$。依次类推,第$k$层的时间消耗就是$2^{k-1}$,那整个算法的总的时间消耗就是每一层时间消耗之和。
如果路径长度都为$n$,那这个总和就是$2^{n}-1$。
<img src="https://static001.geekbang.org/resource/image/86/1f/86d301fc5fa3088383fa5b45f01e4d1f.jpg" alt="">
如果路径长度都是$\frac{n}{2}$ ,那整个算法的总的时间消耗就是$2^{\frac{n}{2}}-1$。
<img src="https://static001.geekbang.org/resource/image/55/d4/55fcb1570dfa09e457cdb93ba58777d4.jpg" alt="">
所以,这个算法的时间复杂度就介于$O(2^{n})$和$O(2^{\frac{n}{2}})$之间。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本上知道了上面算法的时间复杂度是指数级的,非常高。
## 实战三:分析全排列的时间复杂度
前面两个复杂度分析都比较简单,我们再来看个稍微复杂的。
我们在高中的时候都学过排列组合。“如何把$n$个数据的所有排列都找出来”,这就是全排列的问题。
我来举个例子。比如,$1 23$这样$3$个数据,有下面这几种不同的排列:
```
1, 2, 3
1, 3, 2
2, 1, 3
2, 3, 1
3, 1, 2
3, 2, 1
```
如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。
如果我们确定了最后一位数据,那就变成了求解剩下$n-1$个数据的排列问题。而最后一位数据可以是$n$个数据中的任意一个,因此它的取值就有$n$种情况。所以,“$n$个数据的排列”问题,就可以分解成$n$个“$n-1$个数据的排列”的子问题。
如果我们把它写成递推公式,就是下面这个样子:
```
假设数组中存储的是12 3...n。
f(1,2,...n) = {最后一位是1, f(n-1)} + {最后一位是2, f(n-1)} +...+{最后一位是n, f(n-1)}。
```
如果我们把递推公式改写成代码,就是下面这个样子:
```
// 调用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
if (k == 1) {
for (int i = 0; i &lt; n; ++i) {
System.out.print(data[i] + &quot; &quot;);
}
System.out.println();
}
for (int i = 0; i &lt; k; ++i) {
int tmp = data[i];
data[i] = data[k-1];
data[k-1] = tmp;
printPermutations(data, n, k - 1);
tmp = data[i];
data[i] = data[k-1];
data[k-1] = tmp;
}
}
```
如果不用我前面讲的递归树分析方法,这个递归代码的时间复杂度会比较难分析。现在,我们来看下,如何借助递归树,轻松分析出这个代码的时间复杂度。
首先,我们还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。
<img src="https://static001.geekbang.org/resource/image/82/9b/82f40bed489cf29b14192b44decf059b.jpg" alt="">
第一层分解有$n$次交换操作,第二层有$n$个节点,每个节点分解需要$n-1$次交换,所以第二层总的交换次数是$n*(n-1)$。第三层有$n*(n-1)$个节点,每个节点分解需要$n-2$次交换,所以第三层总的交换次数是$n*(n-1)*(n-2)$。
以此类推,第$k$层总的交换次数就是$n * (n-1) * (n-2) * ... * (n-k+1)$。最后一层的交换次数就是$n * (n-1) * (n-2) * ... * 2 * 1$。每一层的交换次数之和就是总的交换次数。
```
n + n*(n-1) + n*(n-1)*(n-2) +... + n*(n-1)*(n-2)*...*2*1
```
这个公式的求和比较复杂,我们看最后一个数,$n * (n-1) * (n-2) * ... * 2 * 1$等于$n!$,而前面的$n-1$个数都小于最后一个数,所以,总和肯定小于$n * n!$,也就是说,全排列的递归算法的时间复杂度大于$O(n!)$,小于$O(n * n!)$,虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。
这里我稍微说下,掌握分析的方法很重要,思路是重点,不要纠结于精确的时间复杂度到底是多少。
## 内容小结
今天,我们用递归树分析了递归代码的时间复杂度。加上我们在排序那一节讲到的递推公式的时间复杂度分析方法,我们现在已经学习了两种递归代码的时间复杂度分析方法了。
有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。
时间复杂度分析的理论知识并不多,也不复杂,掌握起来也不难,但是,在我们平时的工作、学习中,面对的代码千差万别,能够灵活应用学到的复杂度分析方法,来分析现有的代码,并不是件简单的事情,所以,你平时要多实战、多分析,只有这样,面对任何代码的时间复杂度分析,你才能做到游刃有余、毫不畏惧。
## 课后思考
$1$个细胞的生命周期是$3$小时,$1$小时分裂一次。求$n$小时后,容器内有多少细胞?请你用已经学过的递归时间复杂度的分析方法,分析一下这个递归问题的时间复杂度。
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,274 @@
<audio id="audio" title="28 | 堆和堆排序:为什么说堆排序没有快速排序快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d9/db/d9fb924804a591498e6beca88cd4dedb.mp3"></audio>
我们今天讲另外一种特殊的树,“堆”($Heap$)。堆这种数据结构的应用场景非常多,最经典的莫过于堆排序了。堆排序是一种原地的、时间复杂度为$O(n\log n)$的排序算法。
前面我们学过快速排序,平均情况下,它的时间复杂度为$O(n\log n)$。尽管这两种排序算法的时间复杂度都是$O(n\log n)$,甚至堆排序比快速排序的时间复杂度还要稳定,但是,**在实际的软件开发中,快速排序的性能要比堆排序好,这是为什么呢?**
现在,你可能还无法回答,甚至对问题本身还有点疑惑。没关系,带着这个问题,我们来学习今天的内容。等你学完之后,或许就能回答出来了。
## 如何理解“堆”?
前面我们提到,堆是一种特殊的树。我们现在就来看看,什么样的树才是堆。我罗列了两点要求,只要满足这两点,它就是一个堆。
<li>
堆是一个完全二叉树;
</li>
<li>
堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
</li>
我分别解释一下这两点。
第一点,堆必须是一个完全二叉树。还记得我们之前讲的完全二叉树的定义吗?完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。
第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做“小顶堆”。
定义解释清楚了,你来看看,下面这几个二叉树是不是堆?
<img src="https://static001.geekbang.org/resource/image/4c/99/4c452a1ad3b2d152daa2727d06097099.jpg" alt="">
其中第$1$个和第$2$个是大顶堆,第$3$个是小顶堆,第$4$个不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。
## 如何实现一个堆?
要实现一个堆,我们先要知道,**堆都支持哪些操作**以及**如何存储一个堆**。
我之前讲过,完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。
我画了一个用数组存储堆的例子,你可以先看下。
<img src="https://static001.geekbang.org/resource/image/4d/1e/4d349f57947df6590a2dd1364c3b0b1e.jpg" alt="">
从图中我们可以看到,数组中下标为$i$的节点的左子节点,就是下标为$i*2$的节点,右子节点就是下标为$i*2+1$的节点,父节点就是下标为$\frac{i}{2}$的节点。
知道了如何存储一个堆,那我们再来看看,堆上的操作有哪些呢?我罗列了几个非常核心的操作,分别是往堆中插入一个元素和删除堆顶元素。(如果没有特殊说明,我下面都是拿大顶堆来讲解)。
### 1.往堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的两个特性。
如果我们把新插入的元素放到堆的最后,你可以看我画的这个图,是不是不符合堆的特性了?于是,我们就需要进行调整,让其重新满足堆的特性,这个过程我们起了一个名字,就叫做**堆化**heapify
堆化实际上有两种,从下往上和从上往下。这里我先讲**从下往上**的堆化方法。
<img src="https://static001.geekbang.org/resource/image/e5/22/e578654f930002a140ebcf72b11eb722.jpg" alt="">
堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。
我这里画了一张堆化的过程分解图。我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。
<img src="https://static001.geekbang.org/resource/image/e3/0e/e3744661e038e4ae570316bc862b2c0e.jpg" alt="">
我将上面讲的往堆中插入数据的过程,翻译成了代码,你可以结合着一块看。
```
public class Heap {
private int[] a; // 数组从下标1开始存储数据
private int n; // 堆可以存储的最大数据个数
private int count; // 堆中已经存储的数据个数
public Heap(int capacity) {
a = new int[capacity + 1];
n = capacity;
count = 0;
}
public void insert(int data) {
if (count &gt;= n) return; // 堆满了
++count;
a[count] = data;
int i = count;
while (i/2 &gt; 0 &amp;&amp; a[i] &gt; a[i/2]) { // 自下往上堆化
swap(a, i, i/2); // swap()函数作用交换下标为i和i/2的两个元素
i = i/2;
}
}
}
```
### 2.删除堆顶元素
从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,我们可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。
假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。
这里我也画了一个分解图。不过这种方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性。
<img src="https://static001.geekbang.org/resource/image/59/81/5916121b08da6fc0636edf1fc24b5a81.jpg" alt="">
实际上,我们稍微改变一下思路,就可以解决这个问题。你看我画的下面这幅图。我们把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是**从上往下的堆化方法**。
因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。
<img src="https://static001.geekbang.org/resource/image/11/60/110d6f442e718f86d2a1d16095513260.jpg" alt="">
我把上面的删除过程同样也翻译成了代码,贴在这里,你可以结合着看。
```
public void removeMax() {
if (count == 0) return -1; // 堆中没有数据
a[1] = a[count];
--count;
heapify(a, count, 1);
}
private void heapify(int[] a, int n, int i) { // 自上往下堆化
while (true) {
int maxPos = i;
if (i*2 &lt;= n &amp;&amp; a[i] &lt; a[i*2]) maxPos = i*2;
if (i*2+1 &lt;= n &amp;&amp; a[maxPos] &lt; a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
```
我们知道,一个包含$n$个节点的完全二叉树,树的高度不会超过$\log_{2}n$。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是$O(\log n)$。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是$O(\log n)$。
## 如何基于堆实现排序?
前面我们讲过好几种排序算法,我们再来回忆一下,有时间复杂度是$O(n^{2})$的冒泡排序、插入排序、选择排序,有时间复杂度是$O(n\log n)$的归并排序、快速排序,还有线性排序。
这里我们借助于堆这种数据结构实现的排序算法,就叫做堆排序。这种排序方法的时间复杂度非常稳定,是$O(n\log n)$,并且它还是原地排序算法。如此优秀,它是怎么做到的呢?
我们可以把堆排序的过程大致分解成两个大的步骤,**建堆**和**排序**。
### 1.建堆
我们首先将数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。
第一种是借助我们前面讲的,在堆中插入一个元素的思路。尽管数组中包含$n$个数据,但是我们可以假设,起初堆中只包含一个数据,就是下标为$1$的数据。然后,我们调用前面讲的插入操作,将下标从$2$到$n$的数据依次插入到堆中。这样我们就将包含$n$个数据的数组,组织成了堆。
第二种实现思路,跟第一种截然相反,也是我这里要详细讲的。第一种建堆思路的处理过程是从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。而第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。
我举了一个例子,并且画了一个第二种实现思路的建堆分解步骤图,你可以看下。因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从最后一个非叶子节点开始,依次堆化就行了。
<img src="https://static001.geekbang.org/resource/image/50/1e/50c1e6bc6fe68378d0a66bdccfff441e.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/aa/9d/aabb8d15b1b92d5e040895589c60419d.jpg" alt="">
对于程序员来说,看代码可能更好理解一些,所以,我将第二种实现思路翻译成了代码,你可以看下。
```
private static void buildHeap(int[] a, int n) {
for (int i = n/2; i &gt;= 1; --i) {
heapify(a, n, i);
}
}
private static void heapify(int[] a, int n, int i) {
while (true) {
int maxPos = i;
if (i*2 &lt;= n &amp;&amp; a[i] &lt; a[i*2]) maxPos = i*2;
if (i*2+1 &lt;= n &amp;&amp; a[maxPos] &lt; a[i*2+1]) maxPos = i*2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
```
你可能已经发现了,在这段代码中,我们对下标从$\frac{n}{2}$ 开始到$1$的数据进行堆化,下标是$\frac{n}{2}+1$到$n$的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从$\frac{n}{2}+1$到$n$的节点都是叶子节点。
现在,我们来看,建堆操作的时间复杂度是多少呢?
每个节点堆化的时间复杂度是$O(\log n)$,那$\frac{n}{2}+1$个节点堆化的总时间复杂度是不是就是$O(n\log n)$呢?这个答案虽然也没错,但是这个值还是不够精确。实际上,堆排序的建堆过程的时间复杂度是$O(n)$。我带你推导一下。
因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度$k$成正比。
我把每一层的节点个数和对应的高度画了出来,你可以看看。我们只需要将每个节点的高度求和,得出的就是建堆的时间复杂度。
<img src="https://static001.geekbang.org/resource/image/89/d5/899b9f1b40302c9bd5a7f77f042542d5.jpg" alt="">
我们将每个非叶子节点的高度求和,就是下面这个公式:
<img src="https://static001.geekbang.org/resource/image/f7/09/f712f8a7baade44c39edde839cefcc09.jpg" alt="">
这个公式的求解稍微有点技巧,不过我们高中应该都学过:把公式左右都乘以$2$,就得到另一个公式$S2$。我们将$S2$错位对齐,并且用$S2$减去$S1$,可以得到$S$。
<img src="https://static001.geekbang.org/resource/image/62/df/629328315decd96e349d8cb3940636df.jpg" alt="">
$S$的中间部分是一个等比数列,所以最后可以用等比数列的求和公式来计算,最终的结果就是下面图中画的这个样子。
<img src="https://static001.geekbang.org/resource/image/46/36/46ca25edc69b556b967d2c62388b7436.jpg" alt="">
因为$h=\log_{2}n$,代入公式$S$,就能得到$S=O(n)$,所以,建堆的时间复杂度就是$O(n)$。
### 2.排序
建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为$n$的位置。
这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为$n$的元素放到堆顶,然后再通过堆化的方法,将剩下的$n-1$个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是$n-1$的位置,一直重复这个过程,直到最后堆中只剩下标为$1$的一个元素,排序工作就完成了。
<img src="https://static001.geekbang.org/resource/image/23/d1/23958f889ca48dbb8373f521708408d1.jpg" alt="">
堆排序的过程,我也翻译成了代码。结合着代码看,你理解起来应该会更加容易。
```
// n表示数据的个数数组a中的数据从下标1到n的位置。
public static void sort(int[] a, int n) {
buildHeap(a, n);
int k = n;
while (k &gt; 1) {
swap(a, 1, k);
--k;
heapify(a, k, 1);
}
}
```
现在,我们再来分析一下堆排序的时间复杂度、空间复杂度以及稳定性。
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是$O(n)$,排序过程的时间复杂度是$O(n\log n)$,所以,堆排序整体的时间复杂度是$O(n\log n)$。
堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
今天的内容到此就讲完了。我这里要稍微解释一下在前面的讲解以及代码中我都假设堆中的数据是从数组下标为1的位置开始存储。那如果从$0$开始存储,实际上处理思路是没有任何变化的,唯一变化的,可能就是,代码实现的时候,计算子节点和父节点的下标的公式改变了。
如果节点的下标是$i$,那左子节点的下标就是$2*i+1$,右子节点的下标就是$2*i+2$,父节点的下标就是$\frac{i-1}{2}$。
## 解答开篇
现在我们来看开篇的问题,在实际开发中,为什么快速排序要比堆排序性能好?
我觉得主要有两方面的原因。
**第一点,堆排序数据访问的方式没有快速排序友好。**
对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是$1248$的元素而不是像快速排序那样局部顺序访问所以这样对CPU缓存是不友好的。
<img src="https://static001.geekbang.org/resource/image/83/ce/838a38286dcace89ca63895b77ae8ece.jpg" alt="">
**第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。**
我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。
但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。
<img src="https://static001.geekbang.org/resource/image/6e/bd/6e81fdde42ec3fd288d32eb866867fbd.jpg" alt="">
对于第二点,你可以自己做个试验看下。我们用一个记录交换次数的变量,在代码中,每次交换的时候,我们就对这个变量加一,排序完成之后,这个变量的值就是总的数据交换次数。这样你就能很直观地理解我刚刚说的,堆排序比快速排序交换次数多。
## 内容小结
今天我们讲了堆这种数据结构。堆是一种完全二叉树。它最大的特性是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成了两类,大顶堆和小顶堆。
堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是$O(\log n)$。
除此之外,我们还讲了堆的一个经典应用,堆排序。堆排序包含两个过程,建堆和排序。我们将下标从$\frac{n}{2}$到$1$的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。
## 课后思考
<li>
在讲堆排序建堆的时候,我说到,对于完全二叉树来说,下标从$\frac{n}{2}+1$到$n$的都是叶子节点,这个结论是怎么推导出来的呢?
</li>
<li>
我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,153 @@
<audio id="audio" title="29 | 堆的应用如何快速获取到Top 10最热门的搜索关键词" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bc/4c/bc87946a12351fe3dyy9ae33d2fb144c.mp3"></audio>
搜索引擎的热门搜索排行榜功能你用过吗你知道这个功能是如何实现的吗实际上它的实现并不复杂。搜索引擎每天会接收大量的用户搜索请求它会把这些用户输入的搜索关键词记录下来然后再离线地统计分析得到最热门的Top 10搜索关键词。
那请你思考下,**假设现在我们有一个包含10亿个搜索关键词的日志文件如何能快速获取到热门榜Top 10的搜索关键词呢**
这个问题就可以用堆来解决这也是堆这种数据结构一个非常典型的应用。上一节我们讲了堆和堆排序的一些理论知识今天我们就来讲一讲堆这种数据结构几个非常重要的应用优先级队列、求Top K和求中位数。
## 堆的应用一:优先级队列
首先,我们来看第一个应用场景:优先级队列。
优先级队列,顾名思义,它首先应该是一个队列。我们前面讲过,队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。
如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。
你可别小看这个优先级队列它的应用场景非常多。我们后面要讲的很多数据结构和算法都要依赖它。比如赫夫曼编码、图的最短路径、最小生成树算法等等。不仅如此很多语言中都提供了优先级队列的实现比如Java的PriorityQueueC++的priority_queue等。
只讲这些应用场景比较空泛,现在,我举两个具体的例子,让你感受一下优先级队列具体是怎么用的。
### 1.合并有序小文件
假设我们有100个小文件每个文件的大小是100MB每个文件中存储的都是有序的字符串。我们希望将这些100个小文件合并成一个有序的大文件。这里就会用到优先级队列。
整体思路有点像归并排序中的合并函数。我们从这100个文件中各取第一个字符串放入数组中然后比较大小把最小的那个字符串放入合并后的大文件中并从数组中删除。
假设这个最小的字符串来自于13.txt这个小文件我们就再从这个小文件取下一个字符串放到数组中重新比较大小并且选择最小的放入合并后的大文件将它从数组中删除。依次类推直到所有的文件中的数据都放入到大文件为止。
这里我们用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,显然,这不是很高效。有没有更加高效方法呢?
这里就可以用到优先级队列也可以说是堆。我们将从小文件中取出来的字符串放入到小顶堆中那堆顶的元素也就是优先级队列队首的元素就是最小的字符串。我们将这个字符串放入到大文件中并将其从堆中删除。然后再从小文件中取出下一个字符串放入到堆中。循环这个过程就可以将100个小文件中的数据依次放入到大文件中。
我们知道删除堆顶数据和往堆中插入数据的时间复杂度都是O(logn)n表示堆中的数据个数这里就是100。是不是比原来数组存储的方式高效了很多呢
### 2.高性能定时器
假设我们有一个定时器定时器中维护了很多定时任务每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间比如1秒就扫描一遍任务看是否有任务到达设定的执行时间。如果到达了就拿出来执行。
<img src="https://static001.geekbang.org/resource/image/b0/e7/b04656d27fd0ba112a38a28c892069e7.jpg" alt="">
但是这样每过1秒就扫描一遍任务列表的做法比较低效主要原因有两点第一任务的约定执行时间离当前时间可能还有很久这样前面很多次扫描其实都是徒劳的第二每次都要扫描整个任务列表如果任务列表很大的话势必会比较耗时。
针对这些问题,我们就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。
这样定时器就不需要每隔1秒就扫描一遍任务列表了。它拿队首任务的执行时间点与当前时间点相减得到一个时间间隔T。
这个时间间隔T就是从当前时间开始需要等待多久才会有第一个任务需要被执行。这样定时器就可以设定在T秒之后再来执行任务。从当前时间点到T-1秒这段时间里定时器都不需要做任何事情。
当T秒时间过去之后定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值把这个值作为定时器执行下一个任务需要等待的时间。
这样定时器既不用间隔1秒就轮询一次也不用遍历整个任务列表性能也就提高了。
## 堆的应用二利用堆求Top K
刚刚我们学习了优先级队列我们现在来看堆的另外一个非常重要的应用场景那就是“求Top K问题”。
我把这种求Top K的问题抽象成两类。一类是针对静态数据集合也就是说数据集合事先确定不会再变。另一类是针对动态数据集合也就是说数据集合事先并不确定有数据动态地加入到集合中。
针对静态数据如何在一个包含n个数据的数组中查找前K大数据呢我们可以维护一个大小为K的小顶堆顺序遍历数组从数组中取出数据与堆顶元素比较。如果比堆顶元素大我们就把堆顶元素删除并且将这个元素插入到堆中如果比堆顶元素小则不做处理继续遍历数组。这样等数组中的数据都遍历完之后堆中的数据就是前K大数据了。
遍历数组需要O(n)的时间复杂度一次堆化操作需要O(logK)的时间复杂度所以最坏情况下n个元素都入堆一次时间复杂度就是O(nlogK)。
针对动态数据求得Top K就是实时Top K。怎么理解呢我举一个例子。一个数据集合中有两个操作一个是添加数据另一个询问当前的前K大数据。
如果每次询问前K大数据我们都基于当前的数据重新计算的话那时间复杂度就是O(nlogK)n表示当前的数据的大小。实际上我们可以一直都维护一个K大小的小顶堆当有数据被添加到集合中时我们就拿它与堆顶的元素对比。如果比堆顶元素大我们就把堆顶元素删除并且将这个元素插入到堆中如果比堆顶元素小则不做处理。这样无论任何时候需要查询当前的前K大数据我们都可以立刻返回给他。
## 堆的应用三:利用堆求中位数
前面我们讲了如何求Top K的问题现在我们来讲下如何求动态数据集合中的中位数。
中位数,顾名思义,就是处在中间位置的那个数。如果数据的个数是奇数,把数据从小到大排列,那第$\frac{n}{2}+1$个数据就是中位数注意假设数据是从0开始编号的如果数据的个数是偶数的话那处于中间位置的数据有两个第$\frac{n}{2}$个和第$\frac{n}{2}+1$个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第$\frac{n}{2}$个数据。
<img src="https://static001.geekbang.org/resource/image/18/b6/1809157fdd804dd40a6a795ec30acbb6.jpg" alt="">
对于一组**静态数据**,中位数是固定的,我们可以先排序,第$\frac{n}{2}$个数据就是中位数。每次询问中位数的时候,我们直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。但是,如果我们面对的是**动态数据**集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。
**借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。我们来看看,它是如何做到的?**
我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。
也就是说如果有n个数据n是偶数我们从小到大排序那前$\frac{n}{2}$个数据存储在大顶堆中,后$\frac{n}{2}$个数据存储在小顶堆中。这样大顶堆中的堆顶元素就是我们要找的中位数。如果n是奇数情况是类似的大顶堆就存储$\frac{n}{2}+1$个数据,小顶堆中就存储$\frac{n}{2}$个数据。
<img src="https://static001.geekbang.org/resource/image/08/99/08c29d3e014a4baf5f8148c2271e6099.jpg" alt="">
我们前面也提到,数据是动态变化的,当新添加一个数据的时候,我们如何调整两个堆,让大顶堆中的堆顶元素继续是中位数呢?
如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。
这个时候就有可能出现两个堆中的数据个数不符合前面约定的情况如果n是偶数两个堆中的数据个数都是$\frac{n}{2}$如果n是奇数大顶堆有$\frac{n}{2}+1$个数据,小顶堆有$\frac{n}{2}$个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。
<img src="https://static001.geekbang.org/resource/image/ae/b1/aee4dcaf9d34111870a1d66a6e109fb1.jpg" alt="">
于是我们就可以利用两个堆一个大顶堆、一个小顶堆实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化所以时间复杂度变成了O(logn)但是求中位数我们只需要返回大顶堆的堆顶元素就可以了所以时间复杂度就是O(1)。
实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。还记得我们在“[为什么要学习数据结构与算法](https://time.geekbang.org/column/article/39972)”里的这个问题吗“如何快速求接口的99%响应时间?”我们现在就来看下,利用两个堆如何来实现。
在开始这个问题的讲解之前我先解释一下什么是“99%响应时间”。
中位数的概念就是将数据从小到大排列处于中间位置就叫中位数这个数据会大于等于前面50%的数据。99百分位数的概念可以类比中位数如果将一组数据从小到大排列这个99百分位数就是大于前面99%数据的那个数据。
如果你还是不太理解我再举个例子。假设有100个数据分别是123……100那99百分位数就是99因为小于等于99的数占总个数的99%。
<img src="https://static001.geekbang.org/resource/image/bb/2d/bbb043d369eeef1bb7feadd28c6ea32d.jpg" alt="">
弄懂了这个概念我们再来看99%响应时间。如果有100个接口访问请求每个接口请求的响应时间都不同比如55毫秒、100毫秒、23毫秒等我们把这100个接口的响应时间按照从小到大排列排在第99的那个数据就是99%响应时间也叫99百分位响应时间。
我们总结一下如果有n个数据将数据从小到大排列之后99百分位数大约就是第n*99%个数据同类80百分位数大约就是第n*80%个数据。
弄懂了这些我们再来看如何求99%响应时间。
我们维护两个堆一个大顶堆一个小顶堆。假设当前总数据的个数是n大顶堆中保存n*99%个数据小顶堆中保存n*1%个数据。大顶堆堆顶的数据就是我们要找的99%响应时间。
每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。
但是为了保持大顶堆中的数据占99%小顶堆中的数据占1%在每次新插入数据之后我们都要重新计算这个时候大顶堆和小顶堆中的数据个数是否还符合99:1这个比例。如果不符合我们就将一个堆中的数据移动到另一个堆直到满足这个比例。移动的方法类似前面求中位数的方法这里我就不啰嗦了。
通过这样的方法每次插入数据可能会涉及几个数据的堆化操作所以时间复杂度是O(logn)。每次求99%响应时间的时候直接返回大顶堆中的堆顶数据即可时间复杂度是O(1)。
## 解答开篇
学懂了上面的一些应用场景的处理思路我想你应该能解决开篇的那个问题了吧。假设现在我们有一个包含10亿个搜索关键词的日志文件如何快速获取到Top 10最热门的搜索关键词呢
处理这个问题有很多高级的解决方法比如使用MapReduce等。但是如果我们将处理的场景限定为单机可以使用的内存为1GB。那这个问题该如何解决呢
因为用户搜索的关键词,有很多可能都是重复的,所以我们首先要统计每个搜索关键词出现的频率。我们可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。
假设我们选用散列表。我们就顺序扫描这10亿个搜索关键词。当扫描到某个关键词时我们去散列表中查询。如果存在我们就将对应的次数加一如果不存在我们就将它插入到散列表并记录次数为1。以此类推等遍历完这10亿个搜索关键词之后散列表中就存储了不重复的搜索关键词以及出现的次数。
然后我们再根据前面讲的用堆求Top K的方法建立一个大小为10的小顶堆遍历散列表依次取出每个搜索关键词及对应出现的次数然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多那就删除堆顶的关键词将这个出现次数更多的关键词加入到堆中。
以此类推当遍历完整个散列表中的搜索关键词之后堆中的搜索关键词就是出现次数最多的Top 10搜索关键词了。
不知道你发现了没有上面的解决思路其实存在漏洞。10亿的关键词还是很多的。我们假设10亿条搜索关键词中不重复的有1亿条如果每个搜索关键词的平均长度是50个字节那存储1亿个关键词起码需要5GB的内存空间而散列表因为要避免频繁冲突不会选择太大的装载因子所以消耗的内存空间就更多了。而我们的机器只有1GB的可用内存空间所以我们无法一次性将所有的搜索关键词加入到内存中。这个时候该怎么办呢
我们在哈希算法那一节讲过相同数据经过哈希算法得到的哈希值是一样的。我们可以根据哈希算法的这个特点将10亿条搜索关键词先通过哈希算法分片到10个文件中。
具体可以这样做我们创建10个空文件000102……09。我们遍历这10亿个关键词并且通过某个哈希算法对其求哈希值然后哈希值同10取模得到的结果就是这个搜索关键词应该被分到的文件编号。
对这10亿个关键词分片之后每个文件都只有1亿的关键词去除掉重复的可能就只有1000万个每个关键词平均50个字节所以总的大小就是500MB。1GB的内存完全可以放得下。
我们针对每个包含1亿条搜索关键词的文件利用散列表和堆分别求出Top 10然后把这个10个Top 10放在一块然后取这100个关键词中出现次数最多的10个关键词这就是这10亿数据中的Top 10最频繁的搜索关键词了。
## 内容小结
我们今天主要讲了堆的几个重要的应用它们分别是优先级队列、求Top K问题和求中位数问题。
优先级队列是一种特殊的队列优先级高的数据先出队而不再像普通的队列那样先进先出。实际上堆就可以看作优先级队列只是称谓不一样罢了。求Top K问题又可以分为针对静态数据和针对动态数据只需要利用一个堆就可以做到非常高效率地查询Top K的数据。求中位数实际上还有很多变形比如求99百分位数据、90百分位数据等处理的思路都是一样的即利用两个堆一个大顶堆一个小顶堆随着数据的动态添加动态调整两个堆中的数据最后大顶堆的堆顶元素就是要求的数据。
## 课后思考
有一个访问量非常大的新闻网站我们希望将点击量排名Top 10的新闻摘要滚动显示在网站首页banner上并且每隔1小时更新一次。如果你是负责开发这个功能的工程师你会如何来实现呢
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,139 @@
<audio id="audio" title="30 | 图的表示:如何存储微博、微信等社交网络中的好友关系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/1b/e4fb6a1b4ee1af7cdbfc50d73e40481b.mp3"></audio>
微博、微信、LinkedIn这些社交软件我想你肯定都玩过吧。在微博中两个人可以互相关注在微信中两个人可以互加好友。那你知道**如何存储微博、微信等这些社交网络的好友关系吗?**
这就要用到我们今天要讲的这种数据结构:图。实际上,涉及图的算法有很多,也非常复杂,比如图的搜索、最短路径、最小生成树、二分图等等。我们今天聚焦在图存储这一方面,后面会分好几节来依次讲解图相关的算法。
## 如何理解“图”?
我们前面讲过了树这种非线性表数据结构,今天我们要讲另一种非线性表数据结构,**图**Graph。和树比起来这是一种更加复杂的非线性表结构。
我们知道,树中的元素我们称为节点,图中的元素我们就叫做**顶点**vertex。从我画的图中可以看出来图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做**边**edge
<img src="https://static001.geekbang.org/resource/image/df/af/df85dc345a9726cab0338e68982fd1af.jpg" alt="">
我们生活中就有很多符合图这种结构的例子。比如,开篇问题中讲到的社交网络,就是一个非常典型的图结构。
我们就拿微信举例子吧。我们可以把每个用户看作一个顶点。如果两个用户之间互加好友,那就在两者之间建立一条边。所以,整个微信的好友关系就可以用一张图来表示。其中,每个用户有多少个好友,对应到图中,就叫做顶点的**度**degree就是跟顶点相连接的边的条数。
实际上微博的社交关系跟微信还有点不一样或者说更加复杂一点。微博允许单向关注也就是说用户A关注了用户B但用户B可以不关注用户A。那我们如何用图来表示这种单向的社交关系呢
我们可以把刚刚讲的图结构稍微改造一下,引入边的“方向”的概念。
如果用户A关注了用户B我们就在图中画一条从A到B的带箭头的边来表示边的方向。如果用户A和用户B互相关注了那我们就画一条从A指向B的边再画一条从B指向A的边。我们把这种边有方向的图叫做“有向图”。以此类推我们把边没有方向的图就叫做“无向图”。
<img src="https://static001.geekbang.org/resource/image/c3/96/c31759a37d8a8719841f347bd479b796.jpg" alt="">
我们刚刚讲过,无向图中有“度”这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为**入度**In-degree和**出度**Out-degree
顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人。
前面讲到了微信、微博、无向图、有向图现在我们再来看另一种社交软件QQ。
QQ中的社交关系要更复杂一点。不知道你有没有留意过QQ亲密度这样一个功能。QQ不仅记录了用户之间的好友关系还记录了两个用户之间的亲密度如果两个用户经常往来那亲密度就比较高如果不经常往来亲密度就比较低。如何在图中记录这种好友关系的亲密度呢
这里就要用到另一种图,**带权图**weighted graph。在带权图中每条边都有一个权重weight我们可以通过这个权重来表示QQ好友间的亲密度。
<img src="https://static001.geekbang.org/resource/image/55/e8/55d7e4806dc47950ae098d959b03ace8.jpg" alt="">
关于图的概念比较多,我今天也只是介绍了几个常用的,理解起来都不复杂,不知道你都掌握了没有?掌握了图的概念之后,我们再来看下,如何在内存中存储图这种数据结构呢?
## 邻接矩阵存储方法
图最直观的一种存储方法就是,**邻接矩阵**Adjacency Matrix
邻接矩阵的底层依赖一个二维数组。对于无向图来说如果顶点i与顶点j之间有边我们就将A[i][j]和A[j][i]标记为1对于有向图来说如果顶点i到顶点j之间有一条箭头从顶点i指向顶点j的边那我们就将A[i][j]标记为1。同理如果有一条箭头从顶点j指向顶点i的边我们就将A[j][i]标记为1。对于带权图数组中就存储相应的权重。
<img src="https://static001.geekbang.org/resource/image/62/d2/625e7493b5470e774b5aa91fb4fdb9d2.jpg" alt="">
用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费存储空间。为什么这么说呢?
对于无向图来说如果A[i][j]等于1那A[j][i]也肯定等于1。实际上我们只需要存储一个就可以了。也就是说无向图的二维数组中如果我们将其用对角线划分为上下两部分那我们只需要利用上面或者下面这样一半的空间就足够了另外一半白白浪费掉了。
还有,如果我们存储的是**稀疏图**Sparse Matrix也就是说顶点很多但每个顶点的边并不多那邻接矩阵的存储方法就更加浪费空间了。比如微信有好几亿的用户对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多一般也就三五百个而已。如果我们用邻接矩阵来存储那绝大部分的存储空间都被浪费了。
但这也并不是说,邻接矩阵的存储方法就完全没有优点。首先,邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵之间的运算。比如求解最短路径问题时会提到一个[Floyd-Warshall算法](https://zh.wikipedia.org/wiki/Floyd-Warshall%E7%AE%97%E6%B3%95),就是利用矩阵循环相乘若干次得到结果。
## 邻接表存储方法
针对上面邻接矩阵比较浪费内存空间的问题,我们来看另外一种图的存储方法,**邻接表**Adjacency List
我画了一张邻接表的图,你可以先看下。乍一看,邻接表是不是有点像散列表?每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。另外我需要说明一下,图中画的是一个有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的,不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点,你可以自己画下。
<img src="https://static001.geekbang.org/resource/image/03/94/039bc254b97bd11670cdc4bf2a8e1394.jpg" alt="">
还记得我们之前讲过的时间、空间复杂度互换的设计思想吗?邻接矩阵存储起来比较浪费空间,但是使用起来比较节省时间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时间。
就像图中的例子如果我们要确定是否存在一条从顶点2到顶点4的边那我们就要遍历顶点2对应的那条链表看链表中是否存在顶点4。而且我们前面也讲过链表的存储方式对缓存不友好。所以比起邻接矩阵的存储方式在邻接表中查询两个顶点之间的关系就没那么高效了。
在散列表那几节里,我讲到,在基于链表法解决冲突的散列表中,如果链过长,为了提高查找效率,我们可以将链表换成其他更加高效的数据结构,比如平衡二叉查找树等。我们刚刚也讲到,邻接表长得很像散列。所以,我们也可以将邻接表同散列表一样进行“改进升级”。
我们可以将邻接表中的链表改成平衡二叉查找树。实际开发中,我们可以选择用红黑树。这样,我们就可以更加快速地查找两个顶点之间是否存在边了。当然,这里的二叉查找树可以换成其他动态数据结构,比如跳表、散列表等。除此之外,我们还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间否是存在边。
## 解答开篇
有了前面讲的理论知识,现在我们回过头来看开篇的问题,如何存储微博、微信等社交网络中的好友关系?
前面我们分析了,微博、微信是两种“图”,前者是有向图,后者是无向图。在这个问题上,两者的解决思路差不多,所以我只拿微博来讲解。
数据结构是为算法服务的,所以具体选择哪种存储方法,与期望支持的操作有关系。针对微博用户关系,假设我们需要支持下面这样几个操作:
<li>
判断用户A是否关注了用户B
</li>
<li>
判断用户A是否是用户B的粉丝
</li>
<li>
用户A关注用户B
</li>
<li>
用户A取消关注用户B
</li>
<li>
根据用户名称的首字母排序,分页获取用户的粉丝列表;
</li>
<li>
根据用户名称的首字母排序,分页获取用户的关注列表。
</li>
关于如何存储一个图,前面我们讲到两种主要的存储方法,邻接矩阵和邻接表。因为社交网络是一张稀疏图,使用邻接矩阵存储比较浪费存储空间。所以,这里我们采用邻接表来存储。
不过,用一个邻接表来存储这种有向图是不够的。我们去查找某个用户关注了哪些用户非常容易,但是如果要想知道某个用户都被哪些用户关注了,也就是用户的粉丝列表,是非常困难的。
基于此,我们需要一个逆邻接表。邻接表中存储了用户的关注关系,逆邻接表中存储的是用户的被关注关系。对应到图上,邻接表中,每个顶点的链表中,存储的就是这个顶点指向的顶点,逆邻接表中,每个顶点的链表中,存储的是指向这个顶点的顶点。如果要查找某个用户关注了哪些用户,我们可以在邻接表中查找;如果要查找某个用户被哪些用户关注了,我们从逆邻接表中查找。
<img src="https://static001.geekbang.org/resource/image/50/a1/501440bcffdcf4e6f9a5ca1117e990a1.jpg" alt="">
基础的邻接表不适合快速判断两个用户之间是否是关注与被关注的关系,所以我们选择改进版本,将邻接表中的链表改为支持快速查找的动态数据结构。选择哪种动态数据结构呢?红黑树、跳表、有序动态数组还是散列表呢?
因为我们需要按照用户名称的首字母排序分页来获取用户的粉丝列表或者关注列表用跳表这种结构再合适不过了。这是因为跳表插入、删除、查找都非常高效时间复杂度是O(logn)空间复杂度上稍高是O(n)。最重要的一点,跳表中存储的数据本来就是有序的了,分页获取粉丝列表或关注列表,就非常高效。
如果对于小规模的数据,比如社交网络中只有几万、几十万个用户,我们可以将整个社交关系存储在内存中,上面的解决思路是没有问题的。但是如果像微博那样有上亿的用户,数据规模太大,我们就无法全部存储在内存中了。这个时候该怎么办呢?
我们可以通过哈希算法等数据分片方式将邻接表存储在不同的机器上。你可以看下面这幅图我们在机器1上存储顶点123的邻接表在机器2上存储顶点45的邻接表。逆邻接表的处理方式也一样。当要查询顶点与顶点关系的时候我们就利用同样的哈希算法先定位顶点所在的机器然后再在相应的机器上查找。
<img src="https://static001.geekbang.org/resource/image/08/2f/08e4f4330a1d88e9fec94b0f2d1bbe2f.jpg" alt="">
除此之外,我们还有另外一种解决思路,就是利用外部存储(比如硬盘),因为外部存储的存储空间要比内存会宽裕很多。数据库是我们经常用来持久化存储关系数据的,所以我这里介绍一种数据库的存储方式。
我用下面这张表来存储这样一个图。为了高效地支持前面定义的操作,我们可以在表上建立多个索引,比如第一列、第二列,给这两列都建立索引。
<img src="https://static001.geekbang.org/resource/image/73/8f/7339595c631660dc87559bec2ddf928f.jpg" alt="">
## 内容小结
今天我们学习了图这种非线性表数据结构,关于图,你需要理解这样几个概念:无向图、有向图、带权图、顶点、边、度、入度、出度。除此之外,我们还学习了图的两个主要的存储方式:邻接矩阵和邻接表。
邻接矩阵存储方法的缺点是比较浪费空间,但是优点是查询效率高,而且方便矩阵运算。邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。
## 课后思考
<li>
关于开篇思考题,我们只讲了微博这种有向图的解决思路,那像微信这种无向图,应该怎么存储呢?你可以照着我的思路,自己做一下练习。
</li>
<li>
除了我今天举的社交网络可以用图来表示之外符合图这种结构特点的例子还有很多比如知识图谱Knowledge Graph。关于图这种数据结构你还能想到其他生活或者工作中的例子吗
</li>
欢迎留言和我分享,我会第一时间给你反馈。

View File

@@ -0,0 +1,184 @@
<audio id="audio" title="31 | 深度和广度优先搜索:如何找出社交网络中的三度好友关系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/dd/1ee276db3427735b231a2fe19e9537dd.mp3"></audio>
上一节我们讲了图的表示方法,讲到如何用有向图、无向图来表示一个社交网络。在社交网络中,有一个[六度分割理论](https://zh.wikipedia.org/wiki/%E5%85%AD%E5%BA%A6%E5%88%86%E9%9A%94%E7%90%86%E8%AE%BA),具体是说,你与世界上的另一个人间隔的关系不会超过六度,也就是说平均只需要六步就可以联系到任何两个互不相识的人。
一个用户的一度连接用户很好理解,就是他的好友,二度连接用户就是他好友的好友,三度连接用户就是他好友的好友的好友。在社交网络中,我们往往通过用户之间的连接关系,来实现推荐“可能认识的人”这么一个功能。今天的开篇问题就是,**给你一个用户,如何找出这个用户的所有三度(其中包含一度、二度和三度)好友关系?**
这就要用到今天要讲的深度优先和广度优先搜索算法。
## 什么是“搜索”算法?
我们知道,算法是作用于具体数据结构之上的,深度优先搜索算法和广度优先搜索算法都是基于“图”这种数据结构的。这是因为,图这种数据结构的表达能力很强,大部分涉及搜索的场景都可以抽象成“图”。
图上的搜索算法最直接的理解就是在图中找出从一个顶点出发到另一个顶点的路径。具体方法有很多比如今天要讲的两种最简单、最“暴力”的深度优先、广度优先搜索还有A*、IDA*等启发式搜索算法。
我们上一节讲过,图有两种主要存储方法,邻接表和邻接矩阵。今天我会用邻接表来存储图。
我这里先给出图的代码实现。需要说明一下,深度优先搜索算法和广度优先搜索算法,既可以用在无向图,也可以用在有向图上。在今天的讲解中,我都针对无向图来讲解。
```
public class Graph { // 无向图
private int v; // 顶点的个数
private LinkedList&lt;Integer&gt; adj[]; // 邻接表
public Graph(int v) {
this.v = v;
adj = new LinkedList[v];
for (int i=0; i&lt;v; ++i) {
adj[i] = new LinkedList&lt;&gt;();
}
}
public void addEdge(int s, int t) { // 无向图一条边存两次
adj[s].add(t);
adj[t].add(s);
}
}
```
## 广度优先搜索BFS
广度优先搜索Breadth-First-Search我们平常都简称BFS。直观地讲它其实就是一种“地毯式”层层推进的搜索策略即先查找离起始顶点最近的然后是次近的依次往外搜索。理解起来并不难所以我画了一张示意图你可以看下。
<img src="https://static001.geekbang.org/resource/image/00/ea/002e9e54fb0d4dbf5462226d946fa1ea.jpg" alt="">
尽管广度优先搜索的原理挺简单,但代码实现还是稍微有点复杂度。所以,我们重点讲一下它的代码实现。
这里面bfs()函数就是基于之前定义的图的广度优先搜索的代码实现。其中s表示起始顶点t表示终止顶点。我们搜索一条从s到t的路径。实际上这样求得的路径就是从s到t的最短路径。
```
public void bfs(int s, int t) {
if (s == t) return;
boolean[] visited = new boolean[v];
visited[s]=true;
Queue&lt;Integer&gt; queue = new LinkedList&lt;&gt;();
queue.add(s);
int[] prev = new int[v];
for (int i = 0; i &lt; v; ++i) {
prev[i] = -1;
}
while (queue.size() != 0) {
int w = queue.poll();
for (int i = 0; i &lt; adj[w].size(); ++i) {
int q = adj[w].get(i);
if (!visited[q]) {
prev[q] = w;
if (q == t) {
print(prev, s, t);
return;
}
visited[q] = true;
queue.add(q);
}
}
}
}
private void print(int[] prev, int s, int t) { // 递归打印s-&gt;t的路径
if (prev[t] != -1 &amp;&amp; t != s) {
print(prev, s, prev[t]);
}
System.out.print(t + &quot; &quot;);
}
```
这段代码不是很好理解里面有三个重要的辅助变量visited、queue、prev。只要理解这三个变量读懂这段代码估计就没什么问题了。
**visited**是用来记录已经被访问的顶点用来避免顶点被重复访问。如果顶点q被访问那相应的visited[q]会被设置为true。
**queue**是一个队列用来存储已经被访问、但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的也就是说我们只有把第k层的顶点都访问完成之后才能访问第k+1层的顶点。当我们访问到第k层的顶点的时候我们需要把第k层的顶点记录下来稍后才能通过第k层的顶点来找第k+1层的顶点。所以我们用这个队列来实现记录的功能。
**prev**用来记录搜索路径。当我们从顶点s开始广度优先搜索到顶点t后prev数组中存储的就是搜索的路径。不过这个路径是反向存储的。prev[w]存储的是顶点w是从哪个前驱顶点遍历过来的。比如我们通过顶点2的邻接表访问到顶点3那prev[3]就等于2。为了正向打印出路径我们需要递归地来打印你可以看下print()函数的实现方式。
为了方便你理解,我画了一个广度优先搜索的分解图,你可以结合着代码以及我的讲解一块儿看。
<img src="https://static001.geekbang.org/resource/image/4f/3a/4fea8c4505b342cfaf8cb0a93a65503a.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/ea/23/ea00f376d445225a304de4531dd82723.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/4c/39/4cd192d4c220cc9ac8049fd3547dba39.jpg" alt="">
掌握了广度优先搜索算法的原理,我们来看下,广度优先搜索的时间、空间复杂度是多少呢?
最坏情况下终止顶点t离起始顶点s很远需要遍历完整个图才能找到。这个时候每个顶点都要进出一遍队列每个边也都会被访问一次所以广度优先搜索的时间复杂度是O(V+E)其中V表示顶点的个数E表示边的个数。当然对于一个连通图来说也就是说一个图中的所有顶点都是连通的E肯定要大于等于V-1所以广度优先搜索的时间复杂度也可以简写为O(E)。
广度优先搜索的空间消耗主要在几个辅助变量visited数组、queue队列、prev数组上。这三个存储空间的大小都不会超过顶点的个数所以空间复杂度是O(V)。
## 深度优先搜索DFS
深度优先搜索Depth-First-Search简称DFS。最直观的例子就是“走迷宫”。
假设你站在迷宫的某个岔路口,然后想找到出口。你随意选择一个岔路口来走,走着走着发现走不通的时候,你就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这种走法就是一种深度优先搜索策略。
走迷宫的例子很容易能看懂,我们现在再来看下,如何在图中应用深度优先搜索,来找某个顶点到另一个顶点的路径。
你可以看我画的这幅图。搜索的起始顶点是s终止顶点是t我们希望在图中寻找一条从顶点s到顶点t的路径。如果映射到迷宫那个例子s就是你起始所在的位置t就是出口。
我用深度递归算法把整个搜索的路径标记出来了。这里面实线箭头表示遍历虚线箭头表示回退。从图中我们可以看出深度优先搜索找出来的路径并不是顶点s到顶点t的最短路径。
<img src="https://static001.geekbang.org/resource/image/87/85/8778201ce6ff7037c0b3f26b83efba85.jpg" alt="">
实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。回溯思想我们后面会有专门的一节来讲,我们现在还回到深度优先搜索算法上。
我把上面的过程用递归来翻译出来就是下面这个样子。我们发现深度优先搜索代码实现也用到了prev、visited变量以及print()函数它们跟广度优先搜索代码实现里的作用是一样的。不过深度优先搜索代码实现里有个比较特殊的变量found它的作用是当我们已经找到终止顶点t之后我们就不再递归地继续查找了。
```
boolean found = false; // 全局变量或者类成员变量
public void dfs(int s, int t) {
found = false;
boolean[] visited = new boolean[v];
int[] prev = new int[v];
for (int i = 0; i &lt; v; ++i) {
prev[i] = -1;
}
recurDfs(s, t, visited, prev);
print(prev, s, t);
}
private void recurDfs(int w, int t, boolean[] visited, int[] prev) {
if (found == true) return;
visited[w] = true;
if (w == t) {
found = true;
return;
}
for (int i = 0; i &lt; adj[w].size(); ++i) {
int q = adj[w].get(i);
if (!visited[q]) {
prev[q] = w;
recurDfs(q, t, visited, prev);
}
}
}
```
理解了深度优先搜索算法之后,我们来看,深度优先搜索的时间、空间复杂度是多少呢?
从我前面画的图可以看出每条边最多会被访问两次一次是遍历一次是回退。所以图上的深度优先搜索算法的时间复杂度是O(E)E表示边的个数。
深度优先搜索算法的消耗内存主要是visited、prev数组和递归调用栈。visited、prev数组的大小跟顶点的个数V成正比递归调用栈的最大深度不会超过顶点的个数所以总的空间复杂度就是O(V)。
## 解答开篇
了解了深度优先搜索和广度优先搜索的原理之后,开篇的问题是不是变得很简单了呢?我们现在来一起看下,如何找出社交网络中某个用户的三度好友关系?
上一节我们讲过社交网络可以用图来表示。这个问题就非常适合用图的广度优先搜索算法来解决因为广度优先搜索是层层往外推进的。首先遍历与起始顶点最近的一层顶点也就是用户的一度好友然后再遍历与用户距离的边数为2的顶点也就是二度好友关系以及与用户距离的边数为3的顶点也就是三度好友关系。
我们只需要稍加改造一下广度优先搜索代码,用一个数组来记录每个顶点与起始顶点的距离,非常容易就可以找出三度好友关系。
## 内容小结
广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法比起其他高级的搜索算法比如A*、IDA*等,要简单粗暴,没有什么优化,所以,也被叫作暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。
广度优先搜索通俗的理解就是地毯式层层推进从起始顶点开始依次往外遍历。广度优先搜索需要借助队列来实现遍历得到的路径就是起始顶点到终止顶点的最短路径。深度优先搜索用的是回溯思想非常适合用递归实现。换种说法深度优先搜索是借助栈来实现的。在执行效率方面深度优先和广度优先搜索的时间复杂度都是O(E)空间复杂度是O(V)。
## 课后思考
<li>
我们通过广度优先搜索算法解决了开篇的问题,你可以思考一下,能否用深度优先搜索来解决呢?
</li>
<li>
学习数据结构最难的不是理解和掌握原理,而是能灵活地将各种场景和问题抽象成对应的数据结构和算法。今天的内容中提到,迷宫可以抽象成图,走迷宫可以抽象成搜索算法,你能具体讲讲,如何将迷宫抽象成一个图吗?或者换个说法,如何在计算机中存储一个迷宫?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,103 @@
<audio id="audio" title="32 | 字符串匹配基础(上):如何借助哈希算法实现高效字符串匹配?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b2/4d/b2933f7399d06d65e3a88208934bc04d.mp3"></audio>
从今天开始我们来学习字符串匹配算法。字符串匹配这样一个功能我想对于任何一个开发工程师来说应该都不会陌生。我们用的最多的就是编程语言提供的字符串查找函数比如Java中的indexOf()Python中的find()函数等,它们底层就是依赖接下来要讲的字符串匹配算法。
字符串匹配算法很多我会分四节来讲解。今天我会讲两种比较简单的、好理解的它们分别是BF算法和RK算法。下一节我会讲两种比较难理解、但更加高效的它们是BM算法和KMP算法。
这两节讲的都是单模式串匹配的算法也就是一个串跟一个串进行匹配。第三节、第四节我会讲两种多模式串匹配算法也就是在一个串中同时查找多个串它们分别是Trie树和AC自动机。
今天讲的两个算法中RK算法是BF算法的改进它巧妙借助了我们前面讲过的哈希算法让匹配的效率有了很大的提升。那**RK算法是如何借助哈希算法来实现高效字符串匹配的呢**?你可以带着这个问题,来学习今天的内容。
## BF算法
BF算法中的BF是Brute Force的缩写中文叫作暴力匹配算法也叫朴素匹配算法。从名字可以看出这种算法的字符串匹配方式很“暴力”当然也就会比较简单、好懂但相应的性能也不高。
在开始讲解这个算法之前,我先定义两个概念,方便我后面讲解。它们分别是**主串**和**模式串**。这俩概念很好理解,我举个例子你就懂了。
比方说我们在字符串A中查找字符串B那字符串A就是主串字符串B就是模式串。我们把主串的长度记作n模式串的长度记作m。因为我们是在主串中查找模式串所以n&gt;m。
作为最简单、最暴力的字符串匹配算法BF算法的思想可以用一句话来概括那就是**我们在主串中检查起始位置分别是0、1、2....n-m且长度为m的n-m+1个子串看有没有跟模式串匹配的**。我举一个例子给你看看,你应该可以理解得更清楚。
<img src="https://static001.geekbang.org/resource/image/f3/a2/f36fed972a5bdc75331d59c36eb15aa2.jpg" alt="">
从上面的算法思想和例子我们可以看出在极端情况下比如主串是“aaaaa....aaaaaa”省略号表示有很多重复的字符a模式串是“aaaaab”。我们每次都比对m个字符要比对n-m+1次所以这种算法的最坏情况时间复杂度是O(n*m)。
尽管理论上BF算法的时间复杂度很高是O(n*m),但在实际的开发中,它却是一个比较常用的字符串匹配算法。为什么这么说呢?原因有两点。
第一实际的软件开发中大部分情况下模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候当中途遇到不能匹配的字符的时候就可以就停止了不需要把m个字符都比对一下。所以尽管理论上的最坏情况时间复杂度是O(n*m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。
第二朴素字符串匹配算法思想简单代码实现也非常简单。简单意味着不容易出错如果有bug也容易暴露和修复。在工程中在满足性能要求的前提下简单是首选。这也是我们常说的[KISSKeep it Simple and Stupid设计原则](https://zh.wikipedia.org/wiki/KISS%E5%8E%9F%E5%88%99)。
所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。
## RK算法
RK算法的全称叫Rabin-Karp算法是由它的两位发明者Rabin和Karp的名字来命名的。这个算法理解起来也不是很难。我个人觉得它其实就是刚刚讲的BF算法的升级版。
我在讲BF算法的时候讲过如果模式串长度为m主串长度为n那在主串中就会有n-m+1个长度为m的子串我们只需要暴力地对比这n-m+1个子串与模式串就可以找出主串与模式串匹配的子串。
但是每次检查主串与子串是否匹配需要依次比对每个字符所以BF算法的时间复杂度就比较高是O(n*m)。我们对朴素的字符串匹配算法稍加改造,引入哈希算法,时间复杂度立刻就会降低。
RK算法的思路是这样的我们通过哈希算法对主串中的n-m+1个子串分别求哈希值然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相等那就说明对应的子串和模式串匹配了这里先不考虑哈希冲突的问题后面我们会讲到。因为哈希值是一个数字数字之间比较是否相等是非常快速的所以模式串和子串比较的效率就提高了。
<img src="https://static001.geekbang.org/resource/image/01/ee/015c85a9c2a4adc11236f9a40c6d57ee.jpg" alt="">
不过,通过哈希算法计算子串的哈希值的时候,我们需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是,算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?
这就需要哈希算法设计的非常有技巧了。我们假设要匹配的字符串的字符集中只包含K个字符我们可以用一个K进制数来表示一个子串这个K进制数转化成十进制数作为子串的哈希值。表述起来有点抽象我举了一个例子看完你应该就能懂了。
比如要处理的字符串只包含az这26个小写字母那我们就用二十六进制来表示一个字符串。我们把az这26个字符映射到025这26个数字a就表示0b就表示1以此类推z表示25。
在十进制的表示法中一个数字的值是通过下面的方式计算出来的。对应到二十六进制一个包含a到z这26个字符的字符串计算哈希的时候我们只需要把进位从10改成26就可以。
<img src="https://static001.geekbang.org/resource/image/d5/04/d5c1cb11d9fc97d0b28513ba7495ab04.jpg" alt="">
这个哈希算法你应该看懂了吧现在为了方便解释在下面的讲解中我假设字符串中只包含az这26个小写字符我们用二十六进制来表示一个字符串对应的哈希值就是二十六进制数转化成十进制的结果。
这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。我这有个例子,你先找一下规律,再来看我后面的讲解。
<img src="https://static001.geekbang.org/resource/image/f9/f5/f99c16f2f899d19935567102c59661f5.jpg" alt="">
从这里例子中我们很容易就能得出这样的规律相邻两个子串s[i-1]和s[i]i表示子串在主串中的起始位置子串的长度都为m对应的哈希值计算公式有交集也就是说我们可以使用s[i-1]的哈希值很快的计算出s[i]的哈希值。如果用公式表示的话,就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/c4/9c/c47b092408ebfddfa96268037d53aa9c.jpg" alt="">
不过这里有一个小细节需要注意那就是26^(m-1)这部分的计算我们可以通过查表的方法来提高效率。我们事先计算好26^0、26^1、26^2……26^(m-1)并且存储在一个长度为m的数组中公式中的“次方”就对应数组的下标。当我们需要计算26的x次方的时候就可以从数组的下标为x的位置取值直接使用省去了计算的时间。
<img src="https://static001.geekbang.org/resource/image/22/2f/224b899c6e82ec54594e2683acc4552f.jpg" alt="">
我们开头的时候提过RK算法的效率要比BF算法高现在我们就来分析一下RK算法的时间复杂度到底是多少呢
整个RK算法包含两部分计算子串哈希值和模式串哈希值与子串哈希值之间的比较。第一部分我们前面也分析了可以通过设计特殊的哈希算法只需要扫描一遍主串就能计算出所有子串的哈希值了所以这部分的时间复杂度是O(n)。
模式串哈希值与每个子串哈希值之间的比较的时间复杂度是O(1)总共需要比较n-m+1个子串的哈希值所以这部分的时间复杂度也是O(n)。所以RK算法整体的时间复杂度就是O(n)。
这里还有一个问题就是,模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机中整型数据可以表示的范围,那该如何解决呢?
刚刚我们设计的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。因为我们是基于进制来表示一个字符串的,你可以类比成十进制、十六进制来思考一下。实际上,我们为了能将哈希值落在整型数据范围内,可以牺牲一下,允许哈希冲突。这个时候哈希算法该如何设计呢?
哈希算法的设计方法有很多我举一个例子说明一下。假设字符串中只包含az这26个英文字母那我们每个字母对应一个数字比如a对应1b对应2以此类推z对应26。我们可以把字符串中每个字母对应的数字相加最后得到的和作为哈希值。这种哈希算法产生的哈希值的数据范围就相对要小很多了。
不过你也应该发现这种哈希算法的哈希冲突概率也是挺高的。当然我只是举了一个最简单的设计方法还有很多更加优化的方法比如将每一个字母从小到大对应一个素数而不是123……这样的自然数这样冲突的概率就会降低一些。
那现在新的问题来了。之前我们只需要比较一下模式串和子串的哈希值,如果两个值相等,那这个子串就一定可以匹配模式串。但是,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。
实际上,解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。当然,如果子串的哈希值与模式串的哈希值不相等,那对应的子串和模式串肯定也是不匹配的,就不需要比对子串和模式串本身了。
所以哈希算法的冲突概率要相对控制得低一些如果存在大量冲突就会导致RK算法的时间复杂度退化效率下降。极端情况下如果存在大量的冲突每次都要再对比子串和模式串本身那时间复杂度就会退化成O(n*m)。但也不要太悲观一般情况下冲突不会很多RK算法的效率还是比BF算法高的。
## 解答开篇&amp;内容小结
今天我们讲了两种字符串匹配算法BF算法和RK算法。
BF算法是最简单、粗暴的字符串匹配算法它的实现思路是拿模式串与主串中是所有子串匹配看是否有能匹配的子串。所以时间复杂度也比较高是O(n*m)n、m表示主串和模式串的长度。不过在实际的软件开发中因为这种算法实现简单对于处理小规模的字符串匹配很好用。
RK算法是借助哈希算法对BF算法进行改造即对每个子串分别求哈希值然后拿子串的哈希值与模式串的哈希值比较减少了比较的时间。所以理想情况下RK算法的时间复杂度是O(n)跟BF算法相比效率提高了很多。不过这样的效率取决于哈希算法的设计方法如果存在冲突的情况下时间复杂度可能会退化。极端情况下哈希算法大量冲突时间复杂度就退化为O(n*m)。
## 课后思考
我们今天讲的都是一维字符串的匹配方法,实际上,这两种算法都可以类比到二维空间。假设有下面这样一个二维字符串矩阵(图中的主串),借助今天讲的处理思路,如何在其中查找另一个二维字符串矩阵(图中的模式串)呢?
<img src="https://static001.geekbang.org/resource/image/00/c9/00c353326466a8ce4e790e36924704c9.jpg" alt="">
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,291 @@
<audio id="audio" title="33 | 字符串匹配基础(中):如何实现文本编辑器中的查找功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/6e/63304a0d90e70eb363f730741c3eaf6e.mp3"></audio>
文本编辑器中的查找替换功能我想你应该不陌生吧比如我们在Word中把一个单词统一替换成另一个用的就是这个功能。你有没有想过它是怎么实现的呢
当然你用上一节讲的BF算法和RK算法也可以实现这个功能但是在某些极端情况下BF算法性能会退化的比较严重而RK算法需要用到哈希算法设计一个可以应对各种类型字符的哈希算法并不简单。
对于工业级的软件开发来说,我们希望算法尽可能的高效,并且在极端情况下,性能也不要退化的太严重。那么,**对于查找功能是重要功能的软件来说比如一些文本编辑器它们的查找功能都是用哪种算法来实现的呢有没有比BF算法和RK算法更加高效的字符串匹配算法呢**
今天我们就来学习BMBoyer-Moore算法。它是一种非常高效的字符串匹配算法有实验统计它的性能是著名的[KMP算法](https://zh.wikipedia.org/wiki/%E5%85%8B%E5%8A%AA%E6%96%AF-%E8%8E%AB%E9%87%8C%E6%96%AF-%E6%99%AE%E6%8B%89%E7%89%B9%E7%AE%97%E6%B3%95)的3到4倍**。**BM算法的原理很复杂比较难懂学起来会比较烧脑我会尽量给你讲清楚同时也希望你做好打硬仗的准备。好现在我们正式开始
## BM算法的核心思想
我们把模式串和主串的匹配过程看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时BF算法和RK算法的做法是模式串往后滑动一位然后从模式串的第一个字符开始重新匹配。我举个例子解释一下你可以看我画的这幅图。
<img src="https://static001.geekbang.org/resource/image/43/f9/4316dd98eac500a01a0fd632bb5e77f9.jpg" alt="">
在这个例子里主串中的c在模式串中是不存在的所以模式串向后滑动的时候只要c与模式串没有重合肯定无法匹配。所以我们可以一次性把模式串往后多滑动几位把模式串移动到c的后面。
<img src="https://static001.geekbang.org/resource/image/cf/15/cf362f9e59c01aaf40a34d2f10e1ef15.jpg" alt="">
由现象找规律,你可以思考一下,当遇到不匹配的字符时,有什么固定的规律,可以将模式串往后多滑动几位呢?这样一次性往后滑动好几位,那匹配的效率岂不是就提高了?
我们今天要讲的BM算法本质上其实就是在寻找这种规律。借助这种规律在模式串与主串匹配的过程中当模式串和主串某个字符不匹配的时候能够跳过一些肯定不会匹配的情况将模式串往后多滑动几位。
## BM算法原理分析
BM算法包含两部分分别是**坏字符规则**bad character rule和**好后缀规则**good suffix shift。我们下面依次来看这两个规则分别都是怎么工作的。
### 1.坏字符规则
前面两节讲的算法在匹配的过程中我们都是按模式串的下标从小到大的顺序依次与主串中的字符进行匹配的。这种匹配顺序比较符合我们的思维习惯而BM算法的匹配顺序比较特别它是按照模式串下标从大到小的顺序倒着匹配的。我画了一张图你可以看下。
<img src="https://static001.geekbang.org/resource/image/29/e1/29521f541dd45e13162013b3364fece1.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/54/9e/540809418354024206d9989cb6cdd89e.jpg" alt="">
从模式串的末尾往前倒着匹配,当发现某个字符没法匹配的时候,我们把这个没有匹配的字符叫作**坏字符**(主串中的字符)。
<img src="https://static001.geekbang.org/resource/image/22/da/220daef736418df84367215647bca5da.jpg" alt="">
我们拿坏字符c在模式串中查找发现模式串中并不存在这个字符也就是说字符c与模式串中的任何字符都不可能匹配。这个时候我们可以将模式串直接往后滑动三位将模式串滑动到c后面的位置再从模式串的末尾字符开始比较。
<img src="https://static001.geekbang.org/resource/image/4e/64/4e36c4d48d1b6c3b499fb021f03c7f64.jpg" alt="">
这个时候我们发现模式串中最后一个字符d还是无法跟主串中的a匹配这个时候还能将模式串往后滑动三位吗答案是不行的。因为这个时候坏字符a在模式串中是存在的模式串中下标是0的位置也是字符a。这种情况下我们可以将模式串往后滑动两位让两个a上下对齐然后再从模式串的末尾字符开始重新匹配。
<img src="https://static001.geekbang.org/resource/image/a8/ca/a8d229aa217a67051fbb31b8aeb2edca.jpg" alt="">
第一次不匹配的时候,我们滑动了三位,第二次不匹配的时候,我们将模式串后移两位,那具体滑动多少位,到底有没有规律呢?
当发生不匹配的时候我们把坏字符对应的模式串中的字符下标记作si。如果坏字符在模式串中存在我们把这个坏字符在模式串中的下标记作xi。如果不存在我们把xi记作-1。那模式串往后移动的位数就等于si-xi。注意我这里说的下标都是字符在模式串的下标
<img src="https://static001.geekbang.org/resource/image/8f/2e/8f520fb9d9cec0f6ea641d4181eb432e.jpg" alt="">
这里我要特别说明一点如果坏字符在模式串里多处出现那我们在计算xi的时候选择最靠后的那个因为这样不会让模式串滑动过多导致本来可能匹配的情况被滑动略过。
利用坏字符规则BM算法在最好情况下的时间复杂度非常低是O(n/m)。比如主串是aaabaaabaaabaaab模式串是aaaa。每次比对模式串都可以直接后移四位所以匹配具有类似特点的模式串和主串的时候BM算法非常高效。
不过单纯使用坏字符规则还是不够的。因为根据si-xi计算出来的移动位数有可能是负数比如主串是aaaaaaaaaaaaaaaa模式串是baaa。不但不会向后滑动模式串还有可能倒退。所以BM算法还需要用到“好后缀规则”。
### 2.好后缀规则
好后缀规则实际上跟坏字符规则的思路很类似。你看我下面这幅图。当模式串滑动到图中的位置的时候模式串和主串有2个字符是匹配的倒数第3个字符发生了不匹配的情况。
<img src="https://static001.geekbang.org/resource/image/d7/8a/d78990dbcb794d1aa2cf4a3c646ae58a.jpg" alt="">
这个时候该如何滑动模式串呢?当然,我们还可以利用坏字符规则来计算模式串的滑动位数,不过,我们也可以使用好后缀处理规则。两种规则到底如何选择,我稍后会讲。抛开这个问题,现在我们来看,好后缀规则是怎么工作的?
我们把已经匹配的bc叫作好后缀记作{u}。我们拿它在模式串中查找,如果找到了另一个跟{u}相匹配的子串{u*},那我们就将模式串滑动到子串{u*}与主串中{u}对齐的位置。
<img src="https://static001.geekbang.org/resource/image/b9/63/b9785be3e91e34bbc23961f67c234b63.jpg" alt="">
如果在模式串中找不到另一个等于{u}的子串,我们就直接将模式串,滑动到主串中{u}的后面,因为之前的任何一次往后滑动,都没有匹配主串中{u}的情况。
<img src="https://static001.geekbang.org/resource/image/de/cd/de97c461b9b9dbc42d35768db59908cd.jpg" alt="">
不过,当模式串中不存在等于{u}的子串时,我们直接将模式串滑动到主串{u}的后面。这样做是否有点太过头呢我们来看下面这个例子。这里面bc是好后缀尽管在模式串中没有另外一个相匹配的子串{u*},但是如果我们将模式串移动到好后缀的后面,如图所示,那就会错过模式串和主串可以匹配的情况。
<img src="https://static001.geekbang.org/resource/image/9b/70/9b3fa3d1cd9c0d0f914a9b1f518ad070.jpg" alt="">
如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中,只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与主串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配的情况。
<img src="https://static001.geekbang.org/resource/image/05/23/0544d2997d8bb57c10e13ccac4015e23.jpg" alt="">
所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
所谓某个字符串s的后缀子串就是最后一个字符跟s对齐的子串比如abc的后缀子串就包括c, bc。所谓前缀子串就是起始字符跟s对齐的子串比如abc的前缀子串有aab。我们从好后缀的后缀子串中找一个最长的并且能跟模式串的前缀子串匹配的假设是{v},然后将模式串滑动到如图所示的位置。
<img src="https://static001.geekbang.org/resource/image/6c/f9/6caa0f61387fd2b3109fe03d803192f9.jpg" alt="">
坏字符和好后缀的基本原理都讲完了,我现在回答一下前面那个问题。当模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的位数?
我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。
## BM算法代码实现
学习完了基本原理我们再来看如何实现BM算法
“坏字符规则”本身不难理解。当遇到坏字符时要计算往后移动的位数si-xi其中xi的计算是重点我们如何求得xi呢或者说如何查找坏字符在模式串中出现的位置呢
如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能。有没有更加高效的方式呢?我们之前学的散列表,这里可以派上用场了。我们可以将模式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下标了。
关于这个散列表我们只实现一种最简单的情况假设字符串的字符集不是很大每个字符长度是1字节我们用大小为256的数组来记录每个字符在模式串中出现的位置。数组的下标对应字符的ASCII码值数组中存储这个字符在模式串中出现的位置。
<img src="https://static001.geekbang.org/resource/image/bf/02/bf78f8a0506e069fa318f36c42a95e02.jpg" alt="">
如果将上面的过程翻译成代码就是下面这个样子。其中变量b是模式串m是模式串的长度bc表示刚刚讲的散列表。
```
private static final int SIZE = 256; // 全局变量或成员变量
private void generateBC(char[] b, int m, int[] bc) {
for (int i = 0; i &lt; SIZE; ++i) {
bc[i] = -1; // 初始化bc
}
for (int i = 0; i &lt; m; ++i) {
int ascii = (int)b[i]; // 计算b[i]的ASCII值
bc[ascii] = i;
}
}
```
掌握了坏字符规则之后我们先把BM算法代码的大框架写好先不考虑好后缀规则仅用坏字符规则并且不考虑si-xi计算得到的移动位数可能会出现负数的情况。
```
public int bm(char[] a, int n, char[] b, int m) {
int[] bc = new int[SIZE]; // 记录模式串中每个字符最后出现的位置
generateBC(b, m, bc); // 构建坏字符哈希表
int i = 0; // i表示主串与模式串对齐的第一个字符
while (i &lt;= n - m) {
int j;
for (j = m - 1; j &gt;= 0; --j) { // 模式串从后往前匹配
if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
}
if (j &lt; 0) {
return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
}
// 这里等同于将模式串往后滑动j-bc[(int)a[i+j]]位
i = i + (j - bc[(int)a[i+j]]);
}
return -1;
}
```
代码里的注释已经很详细了,我就不再赘述了。不过,为了你方便理解,我画了一张图,将其中的一些关键变量标注在上面了,结合着图,代码应该更好理解。
<img src="https://static001.geekbang.org/resource/image/53/c6/5380b6ef906a5210f782fccd044b36c6.jpg" alt="">
至此,我们已经实现了包含坏字符规则的框架代码,只剩下往框架代码中填充好后缀规则了。现在,我们就来看看,如何实现好后缀规则。它的实现要比坏字符规则复杂一些。
在讲实现之前,我们先简单回顾一下,前面讲过好后缀的处理规则中最核心的内容:
<li>
在模式串中,查找跟好后缀匹配的另一个子串;
</li>
<li>
在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;
</li>
在不考虑效率的情况下这两个操作都可以用很“暴力”的匹配查找方式解决。但是如果想要BM算法的效率很高这部分就不能太低效。如何来做呢
因为好后缀也是模式串本身的后缀子串,所以,我们可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。这个预处理过程比较有技巧,很不好懂,应该是这节最难懂的内容了,你要认真多读几遍。
我们先来看,**如何表示模式串中不同的后缀子串呢?**因为后缀子串的最后一个字符的位置是固定的下标为m-1我们只需要记录长度就可以了。通过长度我们可以确定一个唯一的后缀子串。
<img src="https://static001.geekbang.org/resource/image/77/c8/7742f1d02d0940a1ef3760faf4929ec8.jpg" alt="">
现在,我们要**引入最关键的变量suffix数组**。suffix数组的下标k表示后缀子串的长度下标对应的数组值存储的是在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。这句话不好理解,我举一个例子。
<img src="https://static001.geekbang.org/resource/image/99/c2/99a6cfadf2f9a713401ba8feac2484c2.jpg" alt="">
但是如果模式串中有多个大于1个子串跟后缀子串{u}匹配那suffix数组中该存储哪一个子串的起始位置呢为了避免模式串往后滑动得过头了我们肯定要存储模式串中最靠后的那个子串的起始位置也就是下标最大的那个子串的起始位置。不过这样处理就足够了吗
实际上,仅仅是选最靠后的子串片段来存储是不够的。我们再回忆一下好后缀规则。
我们不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。
如果我们只记录刚刚定义的suffix实际上只能处理规则的前半部分也就是在模式串中查找跟好后缀匹配的另一个子串。所以除了suffix数组之外我们还需要另外一个boolean类型的prefix数组来记录模式串的后缀子串是否能匹配模式串的前缀子串。
<img src="https://static001.geekbang.org/resource/image/27/83/279be7d64e6254dac1a32d2f6d1a2383.jpg" alt="">
现在,我们来看下,**如何来计算并填充这两个数组的值**?这个计算过程非常巧妙。
我们拿下标从0到i的子串i可以是0到m-2与整个模式串求公共后缀子串。如果公共后缀子串的长度是k那我们就记录suffix[k]=jj表示公共后缀子串的起始下标。如果j等于0也就是说公共后缀子串也是模式串的前缀子串我们就记录prefix[k]=true。
<img src="https://static001.geekbang.org/resource/image/57/7c/5723be3c77cdbddb64b1f8d6473cea7c.jpg" alt="">
我们把suffix数组和prefix数组的计算过程用代码实现出来就是下面这个样子
```
// b表示模式串m表示长度suffixprefix数组事先申请好了
private void generateGS(char[] b, int m, int[] suffix, boolean[] prefix) {
for (int i = 0; i &lt; m; ++i) { // 初始化
suffix[i] = -1;
prefix[i] = false;
}
for (int i = 0; i &lt; m - 1; ++i) { // b[0, i]
int j = i;
int k = 0; // 公共后缀子串长度
while (j &gt;= 0 &amp;&amp; b[j] == b[m-1-k]) { // 与b[0, m-1]求公共后缀子串
--j;
++k;
suffix[k] = j+1; //j+1表示公共后缀子串在b[0, i]中的起始下标
}
if (j == -1) prefix[k] = true; //如果公共后缀子串也是模式串的前缀子串
}
}
```
有了这两个数组之后,我们现在来看,**在模式串跟主串匹配的过程中,遇到不能匹配的字符时,如何根据好后缀规则,计算模式串往后滑动的位数?**
假设好后缀的长度是k。我们先拿好后缀在suffix数组中查找其匹配的子串。如果suffix[k]不等于-1-1表示不存在匹配的子串那我们就将模式串往后移动j-suffix[k]+1位j表示坏字符对应的模式串中的字符下标。如果suffix[k]等于-1表示模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。
<img src="https://static001.geekbang.org/resource/image/1d/72/1d046df5cc40bc57d3f92ff7c51afb72.jpg" alt="">
好后缀的后缀子串b[r, m-1]其中r取值从j+2到m-1的长度k=m-r如果prefix[k]等于true表示长度为k的后缀子串有可匹配的前缀子串这样我们可以把模式串后移r位。
<img src="https://static001.geekbang.org/resource/image/63/0d/63a357abc9766393a77a9a006a31b10d.jpg" alt="">
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串我们就将整个模式串后移m位。
<img src="https://static001.geekbang.org/resource/image/d9/a1/d982db00467964666de18ed5ac647fa1.jpg" alt="">
至此好后缀规则的代码实现我们也讲完了。我们把好后缀规则加到前面的代码框架里就可以得到BM算法的完整版代码实现。
```
// a,b表示主串和模式串nm表示主串和模式串的长度。
public int bm(char[] a, int n, char[] b, int m) {
int[] bc = new int[SIZE]; // 记录模式串中每个字符最后出现的位置
generateBC(b, m, bc); // 构建坏字符哈希表
int[] suffix = new int[m];
boolean[] prefix = new boolean[m];
generateGS(b, m, suffix, prefix);
int i = 0; // j表示主串与模式串匹配的第一个字符
while (i &lt;= n - m) {
int j;
for (j = m - 1; j &gt;= 0; --j) { // 模式串从后往前匹配
if (a[i+j] != b[j]) break; // 坏字符对应模式串中的下标是j
}
if (j &lt; 0) {
return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
}
int x = j - bc[(int)a[i+j]];
int y = 0;
if (j &lt; m-1) { // 如果有好后缀的话
y = moveByGS(j, m, suffix, prefix);
}
i = i + Math.max(x, y);
}
return -1;
}
// j表示坏字符对应的模式串中的字符下标; m表示模式串长度
private int moveByGS(int j, int m, int[] suffix, boolean[] prefix) {
int k = m - 1 - j; // 好后缀长度
if (suffix[k] != -1) return j - suffix[k] +1;
for (int r = j+2; r &lt;= m-1; ++r) {
if (prefix[m-r] == true) {
return r;
}
}
return m;
}
```
## BM算法的性能分析及优化
我们先来分析BM算法的内存消耗。整个算法用到了额外的3个数组其中bc数组的大小跟字符集大小有关suffix数组和prefix数组的大小跟模式串长度m有关。
如果我们处理字符集很大的字符串匹配问题bc数组对内存的消耗就会比较多。因为好后缀和坏字符规则是独立的如果我们运行的环境对内存要求苛刻可以只使用好后缀规则不使用坏字符规则这样就可以避免bc数组过多的内存消耗。不过单纯使用好后缀规则的BM算法效率就会下降一些了。
对于执行效率来说,我们可以先从时间复杂度的角度来分析。
实际上我前面讲的BM算法是个初级版本。为了让你能更容易理解有些复杂的优化我没有讲。基于我目前讲的这个版本在极端情况下预处理计算suffix数组、prefix数组的性能会比较差。
比如模式串是aaaaaaa这种包含很多重复的字符的模式串预处理的时间复杂度就是O(m^2)。当然,大部分情况下,时间复杂度不会这么差。关于如何优化这种极端情况下的时间复杂度退化,如果感兴趣,你可以自己研究一下。
实际上BM算法的时间复杂度分析起来是非常复杂这篇论文“[A new proof of the linearity of the Boyer-Moore string searching algorithm](http://dl.acm.org/citation.cfm?id=1382431.1382552)”证明了在最坏情况下BM算法的比较次数上限是5n。这篇论文“[Tight bounds on the complexity of the Boyer-Moore string matching algorithm](http://dl.acm.org/citation.cfm?id=127830)”证明了在最坏情况下BM算法的比较次数上限是3n。你可以自己阅读看看。
## 解答开篇&amp;内容小结
今天我们讲了一种比较复杂的字符串匹配算法BM算法。尽管复杂、难懂但匹配的效率却很高在实际的软件开发中特别是一些文本编辑器中应用比较多。如果一遍看不懂的话你就多看几遍。
BM算法核心思想是利用模式串本身的特点在模式串中某个字符与主串不能匹配的时候将模式串往后多滑动几位以此来减少不必要的字符比较提高匹配的效率。BM算法构建的规则有两类坏字符规则和好后缀规则。好后缀规则可以独立于坏字符规则使用。因为坏字符规则的实现比较耗内存为了节省内存我们可以只用好后缀规则来实现BM算法。
## 课后思考
你熟悉的编程语言中的查找函数,或者工具、软件中的查找功能,都是用了哪种字符串匹配算法呢?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,144 @@
<audio id="audio" title="34 | 字符串匹配基础如何借助BM算法轻松理解KMP算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/01/48ea3d931eecbae246d671bc84577101.mp3"></audio>
上一节我们讲了BM算法尽管它很复杂也不好理解但却是工程中非常常用的一种高效字符串匹配算法。有统计说它是最高效、最常用的字符串匹配算法。不过在所有的字符串匹配算法里要说最知名的一种的话那就非KMP算法莫属。很多时候提到字符串匹配我们首先想到的就是KMP算法。
尽管在实际的开发中我们几乎不大可能自己亲手实现一个KMP算法。但是学习这个算法的思想作为让你开拓眼界、锻炼下逻辑思维也是极好的所以我觉得有必要拿出来给你讲一讲。不过KMP算法是出了名的不好懂。我会尽力把它讲清楚但是你自己也要多动动脑子。
实际上KMP算法跟BM算法的本质是一样的。上一节我们讲了好后缀和坏字符规则今天我们就看下如何借助上一节BM算法的讲解思路让你能更好地理解KMP算法
## KMP算法基本原理
KMP算法是根据三位作者D.E.KnuthJ.H.Morris和V.R.Pratt的名字来命名的算法的全称是Knuth Morris Pratt算法简称为KMP算法。
KMP算法的核心思想跟上一节讲的BM算法非常相近。我们假设主串是a模式串是b。在模式串与主串匹配的过程中当遇到不可匹配的字符的时候我们希望找到一些规律可以将模式串往后多滑动几位跳过那些肯定不会匹配的情况。
还记得我们上一节讲到的好后缀和坏字符吗?这里我们可以类比一下,在模式串和主串匹配的过程中,把不能匹配的那个字符仍然叫作**坏字符**,把已经匹配的那段字符串叫作**好前缀**。
<img src="https://static001.geekbang.org/resource/image/17/be/17ae3d55cf140285d1f34481e173aebe.jpg" alt="">
当遇到坏字符的时候,我们就要把模式串往后滑动,在滑动的过程中,只要模式串和好前缀有上下重合,前面几个字符的比较,就相当于拿好前缀的后缀子串,跟模式串的前缀子串在比较。这个比较的过程能否更高效了呢?可以不用一个字符一个字符地比较了吗?
<img src="https://static001.geekbang.org/resource/image/f4/69/f4ef2c1e6ce5915e1c6460c2e26c9469.jpg" alt="">
KMP算法就是在试图寻找一种规律在模式串和主串匹配的过程中当遇到坏字符后对于已经比对过的好前缀能否找到一种规律将模式串一次性滑动很多位
我们只需要拿好前缀本身,在它的后缀子串中,查找最长的那个可以跟好前缀的前缀子串匹配的。假设最长的可匹配的那部分前缀子串是{v}长度是k。我们把模式串一次性往后滑动j-k位相当于每次遇到坏字符的时候我们就把j更新为ki不变然后继续比较。
<img src="https://static001.geekbang.org/resource/image/da/8f/da99c0349f8fac27e193af8d801dbb8f.jpg" alt="">
为了表述起来方便,我把好前缀的所有后缀子串中,最长的可匹配前缀子串的那个后缀子串,叫作**最长可匹配后缀子串**;对应的前缀子串,叫作**最长可匹配前缀子串**。
<img src="https://static001.geekbang.org/resource/image/9e/ad/9e59c0973ffb965abdd3be5eafb492ad.jpg" alt="">
如何来求好前缀的最长可匹配前缀和后缀子串呢?我发现,这个问题其实不涉及主串,只需要通过模式串本身就能求解。所以,我就在想,能不能事先预处理计算好,在模式串和主串匹配的过程中,直接拿过来就用呢?
类似BM算法中的bc、suffix、prefix数组KMP算法也可以提前构建一个数组用来存储模式串中每个前缀这些前缀都有可能是好前缀的最长可匹配前缀子串的结尾字符下标。我们把这个数组定义为**next数组**,很多书中还给这个数组起了一个名字,叫**失效函数**failure function
数组的下标是每个前缀结尾字符下标,数组的值是这个前缀的最长可以匹配前缀子串的结尾字符下标。这句话有点拗口,我举了一个例子,你一看应该就懂了。
<img src="https://static001.geekbang.org/resource/image/16/a8/1661d37cb190cb83d713749ff9feaea8.jpg" alt="">
有了next数组我们很容易就可以实现KMP算法了。我先假设next数组已经计算好了先给出KMP算法的框架代码。
```
// a, b分别是主串和模式串n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
int[] next = getNexts(b, m);
int j = 0;
for (int i = 0; i &lt; n; ++i) {
while (j &gt; 0 &amp;&amp; a[i] != b[j]) { // 一直找到a[i]和b[j]
j = next[j - 1] + 1;
}
if (a[i] == b[j]) {
++j;
}
if (j == m) { // 找到匹配模式串的了
return i - m + 1;
}
}
return -1;
}
```
## 失效函数计算方法
KMP算法的基本原理讲完了我们现在来看最复杂的部分也就是next数组是如何计算出来的
当然我们可以用非常笨的方法比如要计算下面这个模式串b的next[4]我们就把b[0, 4]的所有后缀子串从长到短找出来依次看看是否能跟模式串的前缀子串匹配。很显然这个方法也可以计算得到next数组但是效率非常低。有没有更加高效的方法呢
<img src="https://static001.geekbang.org/resource/image/1e/ec/1ee5bea573abd033a6aa35d15ef0baec.jpg" alt="">
这里的处理非常有技巧,类似于动态规划。不过,动态规划我们在后面才会讲到,所以,我这里换种方法解释,也能让你听懂。
我们按照下标从小到大依次计算next数组的值。当我们要计算next[i]的时候前面的next[0]next[1]……next[i-1]应该已经计算出来了。利用已经计算出来的next值我们是否可以快速推导出next[i]的值呢?
如果next[i-1]=k-1也就是说子串b[0, k-1]是b[0, i-1]的最长可匹配前缀子串。如果子串b[0, k-1]的下一个字符b[k]与b[0, i-1]的下一个字符b[i]匹配那子串b[0, k]就是b[0, i]的最长可匹配前缀子串。所以next[i]等于k。但是如果b[0, k-1]的下一字符b[k]跟b[0, i-1]的下一个字符b[i]不相等呢这个时候就不能简单地通过next[i-1]得到next[i]了。这个时候该怎么办呢?
<img src="https://static001.geekbang.org/resource/image/4c/19/4caa532d03d3b455ca834245935e2819.jpg" alt="">
我们假设b[0, i]的最长可匹配后缀子串是b[r, i]。如果我们把最后一个字符去掉那b[r, i-1]肯定是b[0, i-1]的可匹配后缀子串但不一定是最长可匹配后缀子串。所以既然b[0, i-1]最长可匹配后缀子串对应的模式串的前缀子串的下一个字符并不等于b[i]那么我们就可以考察b[0, i-1]的次长可匹配后缀子串b[x, i-1]对应的可匹配前缀子串b[0, i-1-x]的下一个字符b[i-x]是否等于b[i]。如果等于那b[x, i]就是b[0, i]的最长可匹配后缀子串。
<img src="https://static001.geekbang.org/resource/image/2a/e1/2a1845b494127c7244c82c7c59f2bfe1.jpg" alt="">
可是如何求得b[0, i-1]的次长可匹配后缀子串呢次长可匹配后缀子串肯定被包含在最长可匹配后缀子串中而最长可匹配后缀子串又对应最长可匹配前缀子串b[0, y]。于是查找b[0, i-1]的次长可匹配后缀子串这个问题就变成查找b[0, y]的最长匹配后缀子串的问题了。
<img src="https://static001.geekbang.org/resource/image/13/13/1311d9026cb6e0fd51b7afa47255b813.jpg" alt="">
按照这个思路我们可以考察完所有的b[0, i-1]的可匹配后缀子串b[y, i-1]直到找到一个可匹配的后缀子串它对应的前缀子串的下一个字符等于b[i]那这个b[y, i]就是b[0, i]的最长可匹配后缀子串。
前面我已经给出KMP算法的框架代码了现在我把这部分的代码也写出来了。这两部分代码合在一起就是整个KMP算法的代码实现。
```
// b表示模式串m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
int[] next = new int[m];
next[0] = -1;
int k = -1;
for (int i = 1; i &lt; m; ++i) {
while (k != -1 &amp;&amp; b[k + 1] != b[i]) {
k = next[k];
}
if (b[k + 1] == b[i]) {
++k;
}
next[i] = k;
}
return next;
}
```
## KMP算法复杂度分析
KMP算法的原理和实现我们就讲完了我们现在来分析一下KMP算法的时间、空间复杂度是多少
空间复杂度很容易分析KMP算法只需要一个额外的next数组数组的大小跟模式串相同。所以空间复杂度是O(m)m表示模式串的长度。
KMP算法包含两部分第一部分是构建next数组第二部分才是借助next数组匹配。所以关于时间复杂度我们要分别从这两部分来分析。
我们先来分析第一部分的时间复杂度。
计算next数组的代码中第一层for循环中i从1到m-1也就是说内部的代码被执行了m-1次。for循环内部代码有一个while循环如果我们能知道每次for循环、while循环平均执行的次数假设是k那时间复杂度就是O(k*m)。但是while循环执行的次数不怎么好统计所以我们放弃这种分析方法。
我们可以找一些参照变量i和k。i从1开始一直增加到m而k并不是每次for循环都会增加所以k累积增加的值肯定小于m。而while循环里k=next[k]实际上是在减小k的值k累积都没有增加超过m所以while循环里面k=next[k]总的执行次数也不可能超过m。因此next数组计算的时间复杂度是O(m)。
我们再来分析第二部分的时间复杂度。分析的方法是类似的。
i从0循环增长到n-1j的增长量不可能超过i所以肯定小于n。而while循环中的那条语句j=next[j-1]+1不会让j增长的那有没有可能让j不变呢也没有可能。因为next[j-1]的值肯定小于j-1所以while循环中的这条语句实际上也是在让j的值减少。而j总共增长的量都不会超过n那减少的量也不可能超过n所以while循环中的这条语句总的执行次数也不会超过n所以这部分的时间复杂度是O(n)。
所以综合两部分的时间复杂度KMP算法的时间复杂度就是O(m+n)。
## 解答开篇&amp;内容小结
KMP算法讲完了不知道你理解了没有如果没有建议多看几遍自己多思考思考。KMP算法和上一节讲的BM算法的本质非常类似都是根据规律在遇到坏字符的时候把模式串往后多滑动几位。
BM算法有两个规则坏字符和好后缀。KMP算法借鉴BM算法的思想可以总结成好前缀规则。这里面最难懂的就是next数组的计算。如果用最笨的方法来计算确实不难但是效率会比较低。所以我讲了一种类似动态规划的方法按照下标i从小到大依次计算next[i]并且next[i]的计算通过前面已经计算出来的next[0]next[1]……next[i-1]来推导。
KMP算法的时间复杂度是O(n+m),不过它的分析过程稍微需要一点技巧,不那么直观,你只要看懂就好了,并不需要掌握,在我们平常的开发中,很少会有这么难分析的代码。
## 课后思考
至此我们把经典的单模式串匹配算法全部讲完了它们分别是BF算法、RK算法、BM算法和KMP算法关于这些算法你觉得什么地方最难理解呢
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,204 @@
<audio id="audio" title="35 | Trie树如何实现搜索引擎的搜索关键词提示功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/90/69/90c5ea1dc435a6d92c213756ef194969.mp3"></audio>
搜索引擎的搜索关键词提示功能,我想你应该不陌生吧?为了方便快速输入,当你在搜索引擎的搜索框中,输入要搜索的文字的某一部分的时候,搜索引擎就会自动弹出下拉框,里面是各种关键词提示。你可以直接从下拉框中选择你要搜索的东西,而不用把所有内容都输入进去,一定程度上节省了我们的搜索时间。
<img src="https://static001.geekbang.org/resource/image/ce/9e/ceb8738453401d5fc067acd513b57a9e.png" alt="">
尽管这个功能我们几乎天天在用,作为一名工程师,你是否思考过,它是怎么实现的呢?它底层使用的是哪种数据结构和算法呢?
像Google、百度这样的搜索引擎它们的关键词提示功能非常全面和精准肯定做了很多优化但万变不离其宗底层最基本的原理就是今天要讲的这种数据结构Trie树。
## 什么是“Trie树”
Trie树也叫“字典树”。顾名思义它是一个树形结构。它是一种专门处理字符串匹配的数据结构用来解决在一组字符串集合中快速查找某个字符串的问题。
当然这样一个问题可以有多种解决方法比如散列表、红黑树或者我们前面几节讲到的一些字符串匹配算法但是Trie树在这个问题的解决上有它特有的优点。不仅如此Trie树能解决的问题也不限于此我们一会儿慢慢分析。
现在我们先来看下Trie树到底长什么样子。
我举个简单的例子来说明一下。我们有6个字符串它们分别是howhiherhellososee。我们希望在里面多次查找某个字符串是否存在。如果每次查找都是拿要查找的字符串跟这6个字符串依次进行字符串匹配那效率就比较低有没有更高效的方法呢
这个时候我们就可以先对这6个字符串做一下预处理组织成Trie树的结构之后每次查找都是在Trie树中进行匹配查找。**Trie树的本质就是利用字符串之间的公共前缀将重复的前缀合并在一起**。最后构造出来的就是下面这个图中的样子。
<img src="https://static001.geekbang.org/resource/image/28/32/280fbc0bfdef8380fcb632af39e84b32.jpg" alt="">
其中,根节点不包含任何信息。每个节点表示一个字符串中的字符,从根节点到红色节点的一条路径表示一个字符串(注意:红色节点并不都是叶子节点)。
为了让你更容易理解Trie树是怎么构造出来的我画了一个Trie树构造的分解过程。构造过程的每一步都相当于往Trie树中插入一个字符串。当所有字符串都插入完成之后Trie树就构造好了。
<img src="https://static001.geekbang.org/resource/image/f8/6c/f848a7d8bda3d4f8bb4a7cbfaabab66c.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/06/b6/06b45fde2ca8077465e0c557bc749ab6.jpg" alt="">
当我们在Trie树中查找一个字符串的时候比如查找字符串“her”那我们将要查找的字符串分割成单个的字符her然后从Trie树的根节点开始匹配。如图所示绿色的路径就是在Trie树中匹配的路径。
<img src="https://static001.geekbang.org/resource/image/6d/b9/6dbed0579a60c6d170bd8fde5990bfb9.jpg" alt="">
如果我们要查找的是字符串“he”呢我们还用上面同样的方法从根节点开始沿着某条路径来匹配如图所示绿色的路径是字符串“he”匹配的路径。但是路径的最后一个节点“e”并不是红色的。也就是说“he”是某个字符串的前缀子串但并不能完全匹配任何字符串。
<img src="https://static001.geekbang.org/resource/image/05/f9/05c3c5d534921f00a9ae33e7e65b1bf9.jpg" alt="">
## 如何实现一棵Trie树
知道了Trie树长什么样子我们现在来看下如何用代码来实现一个Trie树。
从刚刚Trie树的介绍来看Trie树主要有两个操作**一个是将字符串集合构造成Trie树**。这个过程分解开来的话就是一个将字符串插入到Trie树的过程。**另一个是在Trie树中查询一个字符串**。
了解了Trie树的两个主要操作之后我们再来看下**如何存储一个Trie树**
从前面的图中我们可以看出Trie树是一个多叉树。我们知道二叉树中一个节点的左右子节点是通过两个指针来存储的如下所示Java代码。那对于多叉树来说我们怎么存储一个节点的所有子节点的指针呢
```
class BinaryTreeNode {
char data;
BinaryTreeNode left;
BinaryTreeNode right;
}
```
我先介绍其中一种存储方式,也是经典的存储方式,大部分数据结构和算法书籍中都是这么讲的。还记得我们前面讲到的散列表吗?借助散列表的思想,我们通过一个下标与字符一一映射的数组,来存储子节点的指针。这句话稍微有点抽象,不怎么好懂,我画了一张图你可以看看。
<img src="https://static001.geekbang.org/resource/image/f5/35/f5a4a9cb7f0fe9dcfbf29eb1e5da6d35.jpg" alt="">
假设我们的字符串中只有从a到z这26个小写字母我们在数组中下标为0的位置存储指向子节点a的指针下标为1的位置存储指向子节点b的指针以此类推下标为25的位置存储的是指向的子节点z的指针。如果某个字符的子节点不存在我们就在对应的下标的位置存储null。
```
class TrieNode {
char data;
TrieNode children[26];
}
```
当我们在Trie树中查找字符串的时候我们就可以通过字符的ASCII码减去“a”的ASCII码迅速找到匹配的子节点的指针。比如d的ASCII码减去a的ASCII码就是3那子节点d的指针就存储在数组中下标为3的位置中。
描述了这么多,有可能你还是有点懵,我把上面的描述翻译成了代码,你可以结合着一块看下,应该有助于你理解。
```
public class Trie {
private TrieNode root = new TrieNode('/'); // 存储无意义字符
// 往Trie树中插入一个字符串
public void insert(char[] text) {
TrieNode p = root;
for (int i = 0; i &lt; text.length; ++i) {
int index = text[i] - 'a';
if (p.children[index] == null) {
TrieNode newNode = new TrieNode(text[i]);
p.children[index] = newNode;
}
p = p.children[index];
}
p.isEndingChar = true;
}
// 在Trie树中查找一个字符串
public boolean find(char[] pattern) {
TrieNode p = root;
for (int i = 0; i &lt; pattern.length; ++i) {
int index = pattern[i] - 'a';
if (p.children[index] == null) {
return false; // 不存在pattern
}
p = p.children[index];
}
if (p.isEndingChar == false) return false; // 不能完全匹配,只是前缀
else return true; // 找到pattern
}
public class TrieNode {
public char data;
public TrieNode[] children = new TrieNode[26];
public boolean isEndingChar = false;
public TrieNode(char data) {
this.data = data;
}
}
}
```
Trie树的实现你现在应该搞懂了。现在我们来看下**在Trie树中查找某个字符串的时间复杂度是多少**
如果要在一组字符串中频繁地查询某些字符串用Trie树会非常高效。构建Trie树的过程需要扫描所有的字符串时间复杂度是O(n)n表示所有字符串的长度和。但是一旦构建成功之后后续的查询操作会非常高效。
每次查询时如果要查询的字符串长度是k那我们只需要比对大约k个节点就能完成查询操作。跟原本那组字符串的长度和个数没有任何关系。所以说构建好Trie树后在其中查找字符串的时间复杂度是O(k)k表示要查找的字符串的长度。
## Trie树真的很耗内存吗
前面我们讲了Trie树的实现也分析了时间复杂度。现在你应该知道Trie树是一种非常独特的、高效的字符串匹配方法。但是关于Trie树你有没有听过这样一种说法“Trie树是非常耗内存的用的是一种空间换时间的思路”。这是什么原因呢
刚刚我们在讲Trie树的实现的时候讲到用数组来存储一个节点的子节点的指针。如果字符串中包含从a到z这26个字符那每个节点都要存储一个长度为26的数组并且每个数组元素要存储一个8字节指针或者是4字节这个大小跟CPU、操作系统、编译器等有关。而且即便一个节点只有很少的子节点远小于26个比如3、4个我们也要维护一个长度为26的数组。
我们前面讲过Trie树的本质是避免重复存储一组字符串的相同前缀子串但是现在每个字符对应一个节点的存储远远大于1个字节。按照我们上面举的例子数组长度为26每个元素是8字节那每个节点就会额外需要26*8=208个字节。而且这还是只包含26个字符的情况。
如果字符串中不仅包含小写字母还包含大写字母、数字、甚至是中文那需要的存储空间就更多了。所以也就是说在某些情况下Trie树不一定会节省存储空间。在重复的前缀并不多的情况下Trie树不但不能节省内存还有可能会浪费更多的内存。
当然我们不可否认Trie树尽管有可能很浪费内存但是确实非常高效。那为了解决这个内存问题我们是否有其他办法呢
我们可以稍微牺牲一点查询的效率,将每个节点中的数组换成其他数据结构,来存储一个节点的子节点指针。用哪种数据结构呢?我们的选择其实有很多,比如有序数组、跳表、散列表、红黑树等。
假设我们用有序数组数组中的指针按照所指向的子节点中的字符的大小顺序排列。查询的时候我们可以通过二分查找的方法快速查找到某个字符应该匹配的子节点的指针。但是在往Trie树中插入一个字符串的时候我们为了维护数组中数据的有序性就会稍微慢了点。
替换成其他数据结构的思路是类似的,这里我就不一一分析了,你可以结合前面学过的内容,自己分析一下。
实际上Trie树的变体有很多都可以在一定程度上解决内存消耗的问题。比如**缩点优化**,就是对只有一个子节点的节点,而且此节点不是一个串的结束节点,可以将此节点与子节点合并。这样可以节省空间,但却增加了编码难度。这里我就不展开详细讲解了,你如果感兴趣,可以自行研究下。
<img src="https://static001.geekbang.org/resource/image/87/11/874d6870e365ec78f57cd1b9d9fbed11.jpg" alt="">
## Trie树与散列表、红黑树的比较
实际上字符串的匹配问题笼统上讲其实就是数据的查找问题。对于支持动态数据高效操作的数据结构我们前面已经讲过好多了比如散列表、红黑树、跳表等等。实际上这些数据结构也可以实现在一组字符串中查找字符串的功能。我们选了两种数据结构散列表和红黑树跟Trie树比较一下看看它们各自的优缺点和应用场景。
在刚刚讲的这个场景在一组字符串中查找字符串Trie树实际上表现得并不好。它对要处理的字符串有极其严苛的要求。
第一,字符串中包含的字符集不能太大。我们前面讲到,如果字符集太大,那存储空间可能就会浪费很多。即便可以优化,但也要付出牺牲查询、插入效率的代价。
第二,要求字符串的前缀重合比较多,不然空间消耗会变大很多。
第三如果要用Trie树解决问题那我们就要自己从零开始实现一个Trie树还要保证没有bug这个在工程上是将简单问题复杂化除非必须一般不建议这样做。
第四我们知道通过指针串起来的数据块是不连续的而Trie树中用到了指针所以对缓存并不友好性能上会打个折扣。
综合这几点,针对在一组字符串中查找字符串的问题,我们在工程中,更倾向于用散列表或者红黑树。因为这两种数据结构,我们都不需要自己去实现,直接利用编程语言中提供的现成类库就行了。
讲到这里你可能要疑惑了讲了半天我对Trie树一通否定还让你用红黑树或者散列表那Trie树是不是就没用了呢是不是今天的内容就白学了呢
实际上Trie树只是不适合精确匹配查找这种问题更适合用散列表或者红黑树来解决。Trie树比较适合的是查找前缀匹配的字符串也就是类似开篇问题的那种场景。
## 解答开篇
Trie树就讲完了我们来看下开篇提到的问题如何利用Trie树实现搜索关键词的提示功能
我们假设关键词库由用户的热门搜索关键词组成。我们将这个词库构建成一个Trie树。当用户输入其中某个单词的时候把这个词作为一个前缀子串在Trie树中匹配。为了讲解方便我们假设词库里只有hello、her、hi、how、so、see这6个关键词。当用户输入了字母h的时候我们就把以h为前缀的hello、her、hi、how展示在搜索提示框内。当用户继续键入字母e的时候我们就把以he为前缀的hello、her展示在搜索提示框内。这就是搜索关键词提示的最基本的算法原理。
<img src="https://static001.geekbang.org/resource/image/4c/0d/4ca9d9f78f2206cad93836a2b1d6d80d.jpg" alt="">
不过,我讲的只是最基本的实现原理,实际上,搜索引擎的搜索关键词提示功能远非我讲的这么简单。如果再稍微深入一点,你就会想到,上面的解决办法遇到下面几个问题:
<li>
我刚讲的思路是针对英文的搜索关键词提示对于更加复杂的中文来说词库中的数据又该如何构建成Trie树呢
</li>
<li>
如果词库中有很多关键词在搜索提示的时候用户输入关键词作为前缀在Trie树中可以匹配的关键词也有很多如何选择展示哪些内容呢
</li>
<li>
像Google这样的搜索引擎用户单词拼写错误的情况下Google还是可以使用正确的拼写来做关键词提示这个又是怎么做到的呢
</li>
你可以先思考一下如何来解决,如果不会也没关系,这些问题,我们会在实战篇里具体来讲解。
实际上Trie树的这个应用可以扩展到更加广泛的一个应用上就是自动输入补全比如输入法自动补全功能、IDE代码编辑器自动补全功能、浏览器网址输入的自动补全功能等等。
## 内容小结
今天我们讲了一种特殊的树Trie树。Trie树是一种解决字符串快速匹配问题的数据结构。如果用来构建Trie树的这一组字符串中前缀重复的情况不是很多那Trie树这种数据结构总体上来讲是比较费内存的是一种空间换时间的解决问题思路。
尽管比较耗费内存但是对内存不敏感或者内存消耗在接受范围内的情况下在Trie树中做字符串匹配还是非常高效的时间复杂度是O(k)k表示要匹配的字符串的长度。
但是Trie树的优势并不在于用它来做动态集合数据的查找因为这个工作完全可以用更加合适的散列表或者红黑树来替代。Trie树最有优势的是查找前缀匹配的字符串比如搜索引擎中的关键词提示功能这个场景就比较适合用它来解决也是Trie树比较经典的应用场景。
## 课后思考
我们今天有讲到Trie树应用场合对数据要求比较苛刻比如字符串的字符集不能太大前缀重合比较多等。如果现在给你一个很大的字符串集合比如包含1万条记录如何通过编程量化分析这组字符串集合是否比较适合用Trie树解决呢也就是如何统计字符串的字符集大小以及前缀重合的程度呢
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="36 | AC自动机如何用多模式串匹配实现敏感词过滤功能" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/34/af2df4ccd908eda7eb8ca0a372982434.mp3"></audio>
很多支持用户发表文本内容的网站比如BBS大都会有敏感词过滤功能用来过滤掉用户输入的一些淫秽、反动、谩骂等内容。你有没有想过这个功能是怎么实现的呢
实际上,这些功能最基本的原理就是字符串匹配算法,也就是通过维护一个敏感词的字典,当用户输入一段文字内容之后,通过字符串匹配算法,来查找用户输入的这段文字,是否包含敏感词。如果有,就用“***”把它替代掉。
我们前面讲过好几种字符串匹配算法了,它们都可以处理这个问题。但是,对于访问量巨大的网站来说,比如淘宝,用户每天的评论数有几亿、甚至几十亿。这时候,我们对敏感词过滤系统的性能要求就要很高。毕竟,我们也不想,用户输入内容之后,要等几秒才能发送出去吧?我们也不想,为了这个功能耗费过多的机器吧?**那如何才能实现一个高性能的敏感词过滤系统呢**?这就要用到今天的**多模式串匹配算法**。
## 基于单模式串和Trie树实现的敏感词过滤
我们前面几节讲了好几种字符串匹配算法有BF算法、RK算法、BM算法、KMP算法还有Trie树。前面四种算法都是单模式串匹配算法只有Trie树是多模式串匹配算法。
我说过,单模式串匹配算法,是在一个模式串和一个主串之间进行匹配,也就是说,在一个主串中查找一个模式串。多模式串匹配算法,就是在多个模式串和一个主串之间做匹配,也就是说,在一个主串中查找多个模式串。
尽管单模式串匹配算法也能完成多模式串的匹配工作。例如开篇的思考题我们可以针对每个敏感词通过单模式串匹配算法比如KMP算法与用户输入的文字内容进行匹配。但是这样做的话每个匹配过程都需要扫描一遍用户输入的内容。整个过程下来就要扫描很多遍用户输入的内容。如果敏感词很多比如几千个并且用户输入的内容很长假如有上千个字符那我们就需要扫描几千遍这样的输入内容。很显然这种处理思路比较低效。
与单模式匹配算法相比多模式匹配算法在这个问题的处理上就很高效了。它只需要扫描一遍主串就能在主串中一次性查找多个模式串是否存在从而大大提高匹配效率。我们知道Trie树就是一种多模式串匹配算法。那如何用Trie树实现敏感词过滤功能呢
我们可以对敏感词字典进行预处理构建成Trie树结构。这个预处理的操作只需要做一次如果敏感词字典动态更新了比如删除、添加了一个敏感词那我们只需要动态更新一下Trie树就可以了。
当用户输入一个文本内容后我们把用户输入的内容作为主串从第一个字符假设是字符C开始在Trie树中匹配。当匹配到Trie树的叶子节点或者中途遇到不匹配字符的时候我们将主串的开始匹配位置后移一位也就是从字符C的下一个字符开始重新在Trie树中匹配。
基于Trie树的这种处理方法有点类似单模式串匹配的BF算法。我们知道单模式串匹配算法中KMP算法对BF算法进行改进引入了next数组让匹配失败时尽可能将模式串往后多滑动几位。借鉴单模式串的优化改进方法能否对多模式串Trie树进行改进进一步提高Trie树的效率呢这就要用到AC自动机算法了。
## 经典的多模式串匹配算法AC自动机
AC自动机算法全称是Aho-Corasick算法。其实Trie树跟AC自动机之间的关系就像单串匹配中朴素的串匹配算法跟KMP算法之间的关系一样只不过前者针对的是多模式串而已。所以**AC自动机实际上就是在Trie树之上加了类似KMP的next数组只不过此处的next数组是构建在树上罢了**。如果代码表示,就是下面这个样子:
```
public class AcNode {
public char data;
public AcNode[] children = new AcNode[26]; // 字符集只包含a~z这26个字符
public boolean isEndingChar = false; // 结尾字符为true
public int length = -1; // 当isEndingChar=true时记录模式串长度
public AcNode fail; // 失败指针
public AcNode(char data) {
this.data = data;
}
}
```
所以AC自动机的构建包含两个操作
<li>
将多个模式串构建成Trie树
</li>
<li>
在Trie树上构建失败指针相当于KMP中的失效函数next数组
</li>
关于如何构建Trie树我们上一节已经讲过了。所以这里我们就重点看下**构建好Trie树之后如何在它之上构建失败指针**
我用一个例子给你讲解。这里有4个模式串分别是cbcbcdabcd主串是abcd。
<img src="https://static001.geekbang.org/resource/image/f8/f1/f80487051d8f44cabf488195de8db1f1.jpg" alt="">
Trie树中的每一个节点都有一个失败指针它的作用和构建过程跟KMP算法中的next数组极其相似。所以**要想看懂这节内容你要先理解KMP算法中next数组的构建过程**。如果你还有点不清楚建议你先回头去弄懂KMP算法。
假设我们沿Trie树走到p节点也就是下图中的紫色节点那p的失败指针就是从root走到紫色节点形成的字符串abc跟所有模式串前缀匹配的最长可匹配后缀子串就是箭头指的bc模式串。
这里的最长可匹配后缀子串我稍微解释一下。字符串abc的后缀子串有两个bcc我们拿它们与其他模式串匹配如果某个后缀子串可以匹配某个模式串的前缀那我们就把这个后缀子串叫作**可匹配后缀子串**。
我们从可匹配后缀子串中找出最长的一个就是刚刚讲到的最长可匹配后缀子串。我们将p节点的失败指针指向那个最长匹配后缀子串对应的模式串的前缀的最后一个节点就是下图中箭头指向的节点。
<img src="https://static001.geekbang.org/resource/image/58/ca/582ec4651948b4cdc1e1b49235e4f8ca.jpg" alt="">
计算每个节点的失败指针这个过程看起来有些复杂。其实,如果我们把树中相同深度的节点放到同一层,那么某个节点的失败指针只有可能出现在它所在层的上一层。
我们可以像KMP算法那样当我们要求某个节点的失败指针的时候我们通过已经求得的、深度更小的那些节点的失败指针来推导。也就是说我们可以逐层依次来求解每个节点的失败指针。所以失败指针的构建过程是一个按层遍历树的过程。
首先root的失败指针为NULL也就是指向自己。**当我们已经求得某个节点p的失败指针之后如何寻找它的子节点的失败指针呢**
我们假设节点p的失败指针指向节点q我们看节点p的子节点pc对应的字符是否也可以在节点q的子节点中找到。如果找到了节点q的一个子节点qc对应的字符跟节点pc对应的字符相同则将节点pc的失败指针指向节点qc。
<img src="https://static001.geekbang.org/resource/image/da/1f/da685b7ac5f7dc41b2db6cf5d9a35a1f.jpg" alt="">
如果节点q中没有子节点的字符等于节点pc包含的字符则令q=q-&gt;failfail表示失败指针这里有没有很像KMP算法里求next的过程继续上面的查找直到q是root为止如果还没有找到相同字符的子节点就让节点pc的失败指针指向root。
<img src="https://static001.geekbang.org/resource/image/91/61/91123d8c38a050d32ca730a93c7aa061.jpg" alt="">
我将构建失败指针的代码贴在这里你可以对照着讲解一块看下应该更容易理解。这里面构建Trie树的代码我并没有贴出来你可以参看上一节的代码自己实现。
```
public void buildFailurePointer() {
Queue&lt;AcNode&gt; queue = new LinkedList&lt;&gt;();
root.fail = null;
queue.add(root);
while (!queue.isEmpty()) {
AcNode p = queue.remove();
for (int i = 0; i &lt; 26; ++i) {
AcNode pc = p.children[i];
if (pc == null) continue;
if (p == root) {
pc.fail = root;
} else {
AcNode q = p.fail;
while (q != null) {
AcNode qc = q.children[pc.data - 'a'];
if (qc != null) {
pc.fail = qc;
break;
}
q = q.fail;
}
if (q == null) {
pc.fail = root;
}
}
queue.add(pc);
}
}
}
```
通过按层来计算每个节点的子节点的失效指针刚刚举的那个例子最后构建完成之后的AC自动机就是下面这个样子
<img src="https://static001.geekbang.org/resource/image/51/3c/5150d176502dda4adfc63e9b2915b23c.jpg" alt="">
AC自动机到此就构建完成了。我们现在来看下**如何在AC自动机上匹配主串**
我们还是拿之前的例子来讲解。在匹配过程中主串从i=0开始AC自动机从指针p=root开始假设模式串是b主串是a。
<li>
如果p指向的节点有一个等于b[i]的子节点x我们就更新p指向x这个时候我们需要通过失败指针检测一系列失败指针为结尾的路径是否是模式串。这一句不好理解你可以结合代码看。处理完之后我们将i加一继续这两个过程
</li>
<li>
如果p指向的节点没有等于b[i]的子节点那失败指针就派上用场了我们让p=p-&gt;fail然后继续这2个过程。
</li>
关于匹配的这部分,文字描述不如代码看得清楚,所以我把代码贴了出来,非常简短,并且添加了详细的注释,你可以对照着看下。这段代码输出的就是,在主串中每个可以匹配的模式串出现的位置。
```
public void match(char[] text) { // text是主串
int n = text.length;
AcNode p = root;
for (int i = 0; i &lt; n; ++i) {
int idx = text[i] - 'a';
while (p.children[idx] == null &amp;&amp; p != root) {
p = p.fail; // 失败指针发挥作用的地方
}
p = p.children[idx];
if (p == null) p = root; // 如果没有匹配的从root开始重新匹配
AcNode tmp = p;
while (tmp != root) { // 打印出可以匹配的模式串
if (tmp.isEndingChar == true) {
int pos = i-tmp.length+1;
System.out.println(&quot;匹配起始下标&quot; + pos + &quot;; 长度&quot; + tmp.length);
}
tmp = tmp.fail;
}
}
}
```
## 解答开篇
AC自动机的内容讲完了关于开篇的问题你应该能解答了吧实际上我上面贴出来的代码已经是一个敏感词过滤的原型代码了。它可以找到所有敏感词出现的位置在用户输入的文本中的起始下标。你只需要稍加改造再遍历一遍文本内容主串就可以将文本中的所有敏感词替换成“***”。
所以我这里着重讲一下,**AC自动机实现的敏感词过滤系统是否比单模式串匹配方法更高效呢**
首先我们需要将敏感词构建成AC自动机包括构建Trie树以及构建失败指针。
我们上一节讲过Trie树构建的时间复杂度是O(m*len)其中len表示敏感词的平均长度m表示敏感词的个数。那构建失败指针的时间复杂度是多少呢我这里给出一个不是很紧确的上界。
假设Trie树中总的节点个数是k每个节点构建失败指针的时候你可以看下代码最耗时的环节是while循环中的q=q-&gt;fail每运行一次这个语句q指向节点的深度都会减少1而树的高度最高也不会超过len所以每个节点构建失败指针的时间复杂度是O(len)。整个失败指针的构建过程就是O(k*len)。
不过AC自动机的构建过程都是预先处理好的构建好之后并不会频繁地更新所以不会影响到敏感词过滤的运行效率。
我们再来看下,**用AC自动机做匹配的时间复杂度是多少**
跟刚刚构建失败指针的分析类似for循环依次遍历主串中的每个字符for循环内部最耗时的部分也是while循环而这一部分的时间复杂度也是O(len)所以总的匹配的时间复杂度就是O(n*len)。因为敏感词并不会很长而且这个时间复杂度只是一个非常宽泛的上限实际情况下可能近似于O(n)所以AC自动机做敏感词过滤性能非常高。
你可以会说从时间复杂度上看AC自动机匹配的效率跟Trie树一样啊。实际上因为失效指针可能大部分情况下都指向root节点所以绝大部分情况下在AC自动机上做匹配的效率要远高于刚刚计算出的比较宽泛的时间复杂度。只有在极端情况下如图所示AC自动机的性能才会退化的跟Trie树一样。
<img src="https://static001.geekbang.org/resource/image/8c/37/8cd064ab3103f9f38b02f298fc01c237.jpg" alt="">
## 内容小结
今天我们讲了多模式串匹配算法AC自动机。单模式串匹配算法是为了快速在主串中查找一个模式串而多模式串匹配算法是为了快速在主串中查找多个模式串。
AC自动机是基于Trie树的一种改进算法它跟Trie树的关系就像单模式串中KMP算法与BF算法的关系一样。KMP算法中有一个非常关键的next数组类比到AC自动机中就是失败指针。而且AC自动机失败指针的构建过程跟KMP算法中计算next数组极其相似。所以要理解AC自动机最好先掌握KMP算法因为AC自动机其实就是KMP算法在多模式串上的改造。
整个AC自动机算法包含两个部分第一部分是将多个模式串构建成AC自动机第二部分是在AC自动机中匹配主串。第一部分又分为两个小的步骤一个是将模式串构建成Trie树另一个是在Trie树上构建失败指针。
## 课后思考
到此为止,字符串匹配算法我们全都讲完了,你能试着分析总结一下,各个字符串匹配算法的特点和比较适合的应用场景吗?
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,133 @@
<audio id="audio" title="37 | 贪心算法如何用贪心算法实现Huffman压缩编码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/02/53/028452c9c6fb12e3199738f61f9c6053.mp3"></audio>
基础的数据结构和算法我们基本上学完了,接下来几节,我会讲几种更加基本的算法。它们分别是贪心算法、分治算法、回溯算法、动态规划。更加确切地说,它们应该是算法思想,并不是具体的算法,常用来指导我们设计具体的算法和编码等。
贪心、分治、回溯、动态规划这4个算法思想原理解释起来都很简单但是要真正掌握且灵活应用并不是件容易的事情。所以接下来的这4个算法思想的讲解我依旧不会长篇大论地去讲理论而是结合具体的问题让你自己感受这些算法是怎么工作的是如何解决问题的带你在问题中体会这些算法的本质。我觉得这比单纯记忆原理和定义要更有价值。
今天我们先来学习一下贪心算法greedy algorithm。贪心算法有很多经典的应用比如霍夫曼编码Huffman Coding、Prim和Kruskal最小生成树算法、还有Dijkstra单源最短路径算法。最小生成树算法和最短路径算法我们后面会讲到所以我们今天讲下霍夫曼编码看看**它是如何利用贪心算法来实现对数据压缩编码,有效节省数据存储空间的**。
## 如何理解“贪心算法”?
关于贪心算法,我们先看一个例子。
假设我们有一个可以容纳100kg物品的背包可以装各种物品。我们有以下5种豆子每种豆子的总量和总价值都各不相同。为了让背包中所装物品的总价值最大我们如何选择在背包中装哪些豆子每种豆子又该装多少呢
<img src="https://static001.geekbang.org/resource/image/f9/c7/f93f4567168d3bc65688a785b76753c7.jpg" alt="">
实际上这个问题很简单我估计你一下子就能想出来没错我们只要先算一算每个物品的单价按照单价由高到低依次来装就好了。单价从高到低排列依次是黑豆、绿豆、红豆、青豆、黄豆所以我们可以往背包里装20kg黑豆、30kg绿豆、50kg红豆。
这个问题的解决思路显而易见,它本质上借助的就是贪心算法。结合这个例子,我总结一下贪心算法解决问题的步骤,我们一起来看看。
**第一步,当我们看到这类问题的时候,首先要联想到贪心算法**:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。
类比到刚刚的例子限制值就是重量不能超过100kg期望值就是物品的总价值。这组数据就是5种豆子。我们从中选出一部分满足重量不超过100kg并且总价值最大。
**第二步,我们尝试看下这个问题是否可以用贪心算法解决**:每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据。
类比到刚刚的例子,我们每次都从剩下的豆子里面,选择单价最高的,也就是重量相同的情况下,对价值贡献最大的豆子。
**第三步,我们举几个例子看下贪心算法产生的结果是否是最优的**。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。
实际上,用贪心算法解决问题的思路,并不总能给出最优解。
我来举一个例子。在一个有权图中我们从顶点S开始找一条到顶点T的最短路径路径中边的权值和最小。贪心算法的解决思路是每次都选择一条跟当前顶点相连的权最小的边直到找到顶点T。按照这种思路我们求出的最短路径是S-&gt;A-&gt;E-&gt;T路径长度是1+4+4=9。
<img src="https://static001.geekbang.org/resource/image/2d/42/2de91c0afb0912378c5acf32a173f642.jpg" alt="">
但是这种贪心的选择方式最终求的路径并不是最短路径因为路径S-&gt;B-&gt;D-&gt;T才是最短路径因为这条路径的长度是2+2+2=6。为什么贪心算法在这个问题上不工作了呢
在这个问题上贪心算法不工作的主要原因是前面的选择会影响后面的选择。如果我们第一步从顶点S走到顶点A那接下来面对的顶点和边跟第一步从顶点S走到顶点B是完全不同的。所以即便我们第一步选择最优的走法边最短但有可能因为这一步选择导致后面每一步的选择都很糟糕最终也就无缘全局最优解了。
## 贪心算法实战分析
对于贪心算法,你是不是还有点懵?如果死抠理论的话,确实很难理解透彻。掌握贪心算法的关键是多练习。只要多练习几道题,自然就有感觉了。所以,我带着你分析几个具体的例子,帮助你深入理解贪心算法。
### 1.分糖果
我们有m个糖果和n个孩子。我们现在要把糖果分给这些孩子吃但是糖果少孩子多m&lt;n所以糖果只能分配给一部分孩子。
每个糖果的大小不等这m个糖果的大小分别是s1s2s3……sm。除此之外每个孩子对糖果大小的需求也是不一样的只有糖果的大小大于等于孩子的对糖果大小的需求的时候孩子才得到满足。假设这n个孩子对糖果大小的需求分别是g1g2g3……gn。
我的问题是,如何分配糖果,能尽可能满足最多数量的孩子?
我们可以把这个问题抽象成从n个孩子中抽取一部分孩子分配糖果让满足的孩子的个数期望值是最大的。这个问题的限制值就是糖果个数m。
我们现在来看看如何用贪心算法来解决。对于一个孩子来说,如果小的糖果可以满足,我们就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。另一方面,对糖果大小需求小的孩子更容易被满足,所以,我们可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对我们期望值的贡献是一样的。
我们每次从剩下的孩子中,找出对糖果大小需求最小的,然后发给他剩下的糖果中能满足他的最小的糖果,这样得到的分配方案,也就是满足的孩子个数最多的方案。
### 2.钱币找零
这个问题在我们的日常生活中更加普遍。假设我们有1元、2元、5元、10元、20元、50元、100元这些面额的纸币它们的张数分别是c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付K元最少要用多少张纸币呢
在生活中我们肯定是先用面值最大的来支付如果不够就继续用更小一点面值的以此类推最后剩下的用1元来补齐。
在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少,这就是一种贪心算法的解决思路。直觉告诉我们,这种处理方法就是最好的。实际上,要严谨地证明这种贪心算法的正确性,需要比较复杂的、有技巧的数学推导,我不建议你花太多时间在上面,不过如果感兴趣的话,可以自己去研究下。
### 3.区间覆盖
假设我们有n个区间区间的起始端点和结束端点分别是[l1, r1][l2, r2][l3, r3],……,[ln, rn]。我们从这n个区间中选出一部分区间这部分区间满足两两不相交端点相交的情况不算相交最多能选出多少个区间呢
<img src="https://static001.geekbang.org/resource/image/f0/cd/f0a1b7978711651d9f084d19a70805cd.jpg" alt="">
这个问题的处理思路稍微不是那么好懂,不过,我建议你最好能弄懂,因为这个处理思想在很多贪心算法问题中都有用到,比如任务调度、教师排课等等问题。
这个问题的解决思路是这样的我们假设这n个区间中最左端点是lmin最右端点是rmax。这个问题就相当于我们选择几个不相交的区间从左到右将[lmin, rmax]覆盖上。我们按照起始端点从小到大的顺序对这n个区间排序。
我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大,就可以放置更多的区间。这实际上就是一种贪心的选择方法。
<img src="https://static001.geekbang.org/resource/image/ef/b5/ef2d0bd8284cb6e69294566a45b0e2b5.jpg" alt="">
## 解答开篇
今天的内容就讲完了,我们现在来看开篇的问题,如何用贪心算法实现霍夫曼编码?
假设我有一个包含1000个字符的文件每个字符占1个byte1byte=8bits存储这1000个字符就一共需要8000bits那有没有更加节省空间的存储方式呢
假设我们通过统计分析发现这1000个字符中只包含6种不同字符假设它们分别是a、b、c、d、e、f。而3个二进制位bit就可以表示8个不同的字符所以为了尽量减少存储空间每个字符我们用3个二进制位来表示。那存储这1000个字符只需要3000bits就可以了比原来的存储方式节省了很多空间。不过还有没有更加节省空间的存储方式呢
```
a(000)、b(001)、c(010)、d(011)、e(100)、f(101)
```
霍夫曼编码就要登场了。霍夫曼编码是一种十分有效的编码方法广泛用于数据压缩中其压缩率通常在20%90%之间。
霍夫曼编码不仅会考察文本中有多少个不同字符,还会考察每个字符出现的频率,根据频率的不同,选择不同长度的编码。霍夫曼编码试图用这种不等长的编码方法,来进一步增加压缩的效率。如何给不同频率的字符选择不同长度的编码呢?根据贪心的思想,我们可以把出现频率比较多的字符,用稍微短一些的编码;出现频率比较少的字符,用稍微长一些的编码。
对于等长的编码来说我们解压缩起来很简单。比如刚才那个例子中我们用3个bit表示一个字符。在解压缩的时候我们每次从文本中读取3位二进制码然后翻译成对应的字符。但是霍夫曼编码是不等长的每次应该读取1位还是2位、3位等等来解压缩呢这个问题就导致霍夫曼编码解压缩起来比较复杂。为了避免解压缩过程中的歧义霍夫曼编码要求各个字符的编码之间不会出现某个编码是另一个编码前缀的情况。
<img src="https://static001.geekbang.org/resource/image/02/29/02ad3e02429b294412fb1cff1b3d3829.jpg" alt="">
假设这6个字符出现的频率从高到低依次是a、b、c、d、e、f。我们把它们编码下面这个样子任何一个字符的编码都不是另一个的前缀在解压缩的时候我们每次会读取尽可能长的可解压的二进制串所以在解压缩的时候也不会歧义。经过这种编码压缩之后这1000个字符只需要2100bits就可以了。
<img src="https://static001.geekbang.org/resource/image/83/45/83921e609c8a4dc81ca5b90c8b4cd745.jpg" alt="">
尽管霍夫曼编码的思想并不难理解,但是如何根据字符出现频率的不同,给不同的字符进行不同长度的编码呢?这里的处理稍微有些技巧。
我们把每个字符看作一个节点并且附带着把频率放到优先级队列中。我们从队列中取出频率最小的两个节点A、B然后新建一个节点C把频率设置为两个节点的频率之和并把这个新节点C作为节点A、B的父节点。最后再把C节点放入到优先级队列中。重复这个过程直到队列中没有数据。
<img src="https://static001.geekbang.org/resource/image/7b/7a/7b6a08e7df45eac66820b959c64f877a.jpg" alt="">
现在我们给每一条边加上画一个权值指向左子节点的边我们统统标记为0指向右子节点的边我们统统标记为1那从根节点到叶节点的路径就是叶节点对应字符的霍夫曼编码。
<img src="https://static001.geekbang.org/resource/image/cc/ed/ccf15d048be005924a409574dce143ed.jpg" alt="">
## 内容小结
今天我们学习了贪心算法。
实际上,贪心算法适用的场景比较有限。这种算法思想更多的是指导设计基础算法。比如最小生成树算法、单源最短路径算法,这些算法都用到了贪心算法。**从我个人的学习经验来讲,不要刻意去记忆贪心算法的原理,多练习才是最有效的学习方法。**
贪心算法的最难的一块是如何将要解决的问题抽象成贪心算法模型,只要这一步搞定之后,贪心算法的编码一般都很简单。贪心算法解决问题的正确性虽然很多时候都看起来是显而易见的,但是要严谨地证明算法能够得到最优解,并不是件容易的事。所以,很多时候,我们只需要多举几个例子,看一下贪心算法的解决方案是否真的能得到最优解就可以了。
## 课后思考
<li>
在一个非负整数a中我们希望从中移除k个数字让剩下的数字值最小如何选择移除哪k个数字呢
</li>
<li>
假设有n个人等待被服务但是服务窗口只有一个每个人需要被服务的时间长度是不同的如何安排被服务的先后顺序才能让这n个人总的等待时间最短
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,158 @@
<audio id="audio" title="38 | 分治算法谈一谈大规模计算框架MapReduce中的分治思想" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3f/96/3f52a287c5f8f9030f3828969a146896.mp3"></audio>
MapReduce是Google大数据处理的三驾马车之一另外两个是GFS和Bigtable。它在倒排索引、PageRank计算、网页分析等搜索引擎相关的技术中都有大量的应用。
尽管开发一个MapReduce看起来很高深感觉跟我们遥不可及。实际上万变不离其宗它的本质就是我们今天要学的这种算法思想分治算法。
## 如何理解分治算法?
为什么说MapRedue的本质就是分治算法呢我们先来看什么是分治算法
分治算法divide and conquer的核心思想其实就是四个字分而治之 也就是将原问题划分成n个规模较小并且结构与原问题相似的子问题递归地解决这些子问题然后再合并其结果就得到原问题的解。
这个定义看起来有点类似递归的定义。关于分治和递归的区别,我们在排序(下)的时候讲过,**分治算法是一种处理问题的思想,递归是一种编程技巧**。实际上,分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:
<li>
分解:将原问题分解成一系列子问题;
</li>
<li>
解决:递归地求解各个子问题,若子问题足够小,则直接求解;
</li>
<li>
合并:将子问题的结果合并成原问题。
</li>
分治算法能解决的问题,一般需要满足下面这几个条件:
<li>
原问题与分解成的小问题具有相同的模式;
</li>
<li>
原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别,等我们讲到动态规划的时候,会详细对比这两种算法;
</li>
<li>
具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
</li>
<li>
可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
</li>
## 分治算法应用举例分析
理解分治算法的原理并不难,但是要想灵活应用并不容易。所以,接下来,我会带你用分治算法解决我们在讲排序的时候涉及的一个问题,加深你对分治算法的理解。
还记得我们在排序算法里讲的数据的有序度、逆序度的概念吗?我当时讲到,我们用有序度来表示一组数据的有序程度,用逆序度表示一组数据的无序程度。
假设我们有n个数据我们期望数据从小到大排列那完全有序的数据的有序度就是n(n-1)/2逆序度等于0相反倒序排列的数据的有序度就是0逆序度是n(n-1)/2。除了这两种极端情况外我们通过计算有序对或者逆序对的个数来表示数据的有序度或逆序度。
<img src="https://static001.geekbang.org/resource/image/f4/20/f41fd0a83bc5c5b059f7d02658179120.jpg" alt="">
我现在的问题是,**如何编程求出一组数据的有序对个数或者逆序对个数呢**?因为有序对个数和逆序对个数的求解方式是类似的,所以你可以只思考逆序对个数的求解方法。
最笨的方法是拿每个数字跟它后面的数字比较看有几个比它小的。我们把比它小的数字个数记作k通过这样的方式把每个数字都考察一遍之后然后对每个数字对应的k值求和最后得到的总和就是逆序对个数。不过这样操作的时间复杂度是O(n^2)。那有没有更加高效的处理方法呢?
我们用分治算法来试试。我们套用分治的思想来求数组A的逆序对个数。我们可以将数组分成前后两半A1和A2分别计算A1和A2的逆序对个数K1和K2然后再计算A1与A2之间的逆序对个数K3。那数组A的逆序对个数就等于K1+K2+K3。
我们前面讲过使用分治算法其中一个要求是子问题合并的代价不能太大否则就起不了降低时间复杂度的效果了。那回到这个问题如何快速计算出两个子问题A1与A2之间的逆序对个数呢
这里就要借助归并排序算法了。你可以先试着想想,如何借助归并排序算法来解决呢?
归并排序中有一个非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,我们就可以计算这两个小数组的逆序对个数了。每次合并操作,我们都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。
<img src="https://static001.geekbang.org/resource/image/e8/32/e835cab502bec3ebebab92381c667532.jpg" alt="">
尽管我画了张图来解释,但是我个人觉得,对于工程师来说,看代码肯定更好理解一些,所以我们把这个过程翻译成了代码,你可以结合着图和文字描述一起看下。
```
private int num = 0; // 全局变量或者成员变量
public int count(int[] a, int n) {
num = 0;
mergeSortCounting(a, 0, n-1);
return num;
}
private void mergeSortCounting(int[] a, int p, int r) {
if (p &gt;= r) return;
int q = (p+r)/2;
mergeSortCounting(a, p, q);
mergeSortCounting(a, q+1, r);
merge(a, p, q, r);
}
private void merge(int[] a, int p, int q, int r) {
int i = p, j = q+1, k = 0;
int[] tmp = new int[r-p+1];
while (i&lt;=q &amp;&amp; j&lt;=r) {
if (a[i] &lt;= a[j]) {
tmp[k++] = a[i++];
} else {
num += (q-i+1); // 统计p-q之间比a[j]大的元素个数
tmp[k++] = a[j++];
}
}
while (i &lt;= q) { // 处理剩下的
tmp[k++] = a[i++];
}
while (j &lt;= r) { // 处理剩下的
tmp[k++] = a[j++];
}
for (i = 0; i &lt;= r-p; ++i) { // 从tmp拷贝回a
a[p+i] = tmp[i];
}
}
```
有很多同学经常说,某某算法思想如此巧妙,我是怎么也想不到的。实际上,确实是的。有些算法确实非常巧妙,并不是每个人短时间都能想到的。比如这个问题,并不是每个人都能想到可以借助归并排序算法来解决,不夸张地说,如果之前没接触过,绝大部分人都想不到。但是,如果我告诉你可以借助归并排序算法来解决,那你就应该要想到如何改造归并排序,来求解这个问题了,只要你能做到这一点,我觉得就很棒了。
关于分治算法,我这还有两道比较经典的问题,你可以自己练习一下。
<li>
二维平面上有n个点如何快速计算出两个距离最近的点对
</li>
<li>
有两个n*n的矩阵AB如何快速求解两个矩阵的乘积C=A*B
</li>
## 分治思想在海量数据处理中的应用
分治算法思想的应用是非常广泛的,并不仅限于指导编程和算法设计。它还经常用在海量数据处理的场景中。我们前面讲的数据结构和算法,大部分都是基于内存存储和单机处理。但是,如果要处理的数据量非常大,没法一次性放到内存中,这个时候,这些数据结构和算法就无法工作了。
比如给10GB的订单文件按照金额排序这样一个需求看似是一个简单的排序问题但是因为数据量大有10GB而我们的机器的内存可能只有2、3GB这样子无法一次性加载到内存也就无法通过单纯地使用快排、归并等基础算法来解决了。
要解决这种数据量大到内存装不下的问题,我们就可以利用分治的思想。我们可以将海量的数据集合根据某种方法,划分为几个小的数据集合,每个小的数据集合单独加载到内存来解决,然后再将小数据集合合并成大数据集合。实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。
比如刚刚举的那个例子给10GB的订单排序我们就可以先扫描一遍订单根据订单的金额将10GB的文件划分为几个金额区间。比如订单金额为1到100元的放到一个小文件101到200之间的放到另一个文件以此类推。这样每个小文件都可以单独加载到内存排序最后将这些有序的小文件合并就是最终有序的10GB订单数据了。
如果订单数据存储在类似GFS这样的分布式系统上当10GB的订单被划分成多个小文件的时候每个文件可以并行加载到多台机器上处理最后再将结果合并在一起这样并行处理的速度也加快了很多。不过这里有一个点要注意就是数据的存储与计算所在的机器是同一个或者在网络中靠的很近比如一个局域网内数据存取速度很快否则就会因为数据访问的速度导致整个处理过程不但不会变快反而有可能变慢。
你可能还有印象,这个就是我在讲线性排序的时候举的例子。实际上,在前面已经学习的课程中,我还讲了很多利用分治思想来解决的问题。
## 解答开篇
分治算法到此就讲完了我们现在来看下开篇的问题为什么说MapReduce的本质就是分治思想
我们刚刚举的订单的例子数据有10GB大小可能给你的感受还不强烈。那如果我们要处理的数据是1T、10T、100T这样子的那一台机器处理的效率肯定是非常低的。而对于谷歌搜索引擎来说网页爬取、清洗、分析、分词、计算权重、倒排索引等等各个环节中都会面对如此海量的数据比如网页。所以利用集群并行处理显然是大势所趋。
一台机器过于低效,那我们就把任务拆分到多台机器上来处理。如果拆分之后的小任务之间互不干扰,独立计算,最后再将结果合并。这不就是分治思想吗?
实际上MapReduce框架只是一个任务调度器底层依赖GFS来存储数据依赖Borg管理机器。它从GFS中拿数据交给Borg中的机器执行并且时刻监控机器执行的进度一旦出现机器宕机、进度卡壳等就重新从Borg中调度一台机器执行。
尽管MapReduce的模型非常简单但是在Google内部应用非常广泛。它除了可以用来处理这种数据与数据之间存在关系的任务比如MapReduce的经典例子统计文件中单词出现的频率。除此之外它还可以用来处理数据与数据之间没有关系的任务比如对网页分析、分词等每个网页可以独立的分析、分词而这两个网页之间并没有关系。网页几十亿、上百亿如果单机处理效率低下我们就可以利用MapReduce提供的高可靠、高性能、高容错的并行计算框架并行地处理这几十亿、上百亿的网页。
## 内容小结
今天我们讲了一种应用非常广泛的算法思想,分治算法。
分治算法用四个字概括就是“分而治之”将原问题划分成n个规模较小而结构与原问题相似的子问题递归地解决这些子问题然后再合并其结果就得到原问题的解。这个思想非常简单、好理解。
今天我们讲了两种分治算法的典型的应用场景一个是用来指导编码降低问题求解的时间复杂度另一个是解决海量数据处理问题。比如MapReduce本质上就是利用了分治思想。
我们也时常感叹Google的创新能力如此之强总是在引领技术的发展。实际上创新并非离我们很远创新的源泉来自对事物本质的认识。无数优秀架构设计的思想来源都是基础的数据结构和算法这本身就是算法的一个魅力所在。
## 课后思考
我们前面讲过的数据结构、算法、解决思路,以及举的例子中,有哪些采用了分治算法的思想呢?除此之外,生活、工作中,还有没有其他用到分治算法的地方呢?你可以自己回忆、总结一下,这对你将零散的知识提炼成体系非常有帮助。
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,171 @@
<audio id="audio" title="39 | 回溯算法:从电影《蝴蝶效应》中学习回溯算法的核心思想" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/6b/56025473cb78c4c6c97587aa7baae56b.mp3"></audio>
我们在[第31节](https://time.geekbang.org/column/article/70891)提到,深度优先搜索算法利用的是回溯算法思想。这个算法思想非常简单,但是应用却非常广泛。它除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,比如正则表达式匹配、编译原理中的语法分析等。
除此之外很多经典的数学问题都可以用回溯算法解决比如数独、八皇后、0-1背包、图的着色、旅行商问题、全排列等等。既然应用如此广泛我们今天就来学习一下这个算法思想看看它是如何指导我们解决问题的。
## 如何理解“回溯算法”?
在我们的一生中,会遇到很多重要的岔路口。在岔路口上,每个选择都会影响我们今后的人生。有的人在每个岔路口都能做出最正确的选择,最后生活、事业都达到了一个很高的高度;而有的人一路选错,最后碌碌无为。如果人生可以量化,那如何才能在岔路口做出最正确的选择,让自己的人生“最优”呢?
我们可以借助前面学过的贪心算法,在每次面对岔路口的时候,都做出看起来最优的选择,期望这一组选择可以使得我们的人生达到“最优”。但是,我们前面也讲过,贪心算法并不一定能得到最优解。那有没有什么办法能得到最优解呢?
2004年上映了一部非常著名的电影《蝴蝶效应》讲的就是主人公为了达到自己的目标一直通过回溯的方法回到童年在关键的岔路口重新做选择。当然这只是科幻电影我们的人生是无法倒退的但是这其中蕴含的思想其实就是回溯算法。
笼统地讲,回溯算法很多时候都应用在“搜索”这类问题上。不过这里说的搜索,并不是狭义的指我们前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。
回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
理论的东西还是过于抽象,老规矩,我还是举例说明一下。我举一个经典的回溯例子,我想你可能已经猜到了,那就是八皇后问题。
我们有一个8x8的棋盘希望往里放8个棋子皇后每个棋子所在的行、列、对角线都不能有另一个棋子。你可以看我画的图第一幅图是满足条件的一种方法第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。
<img src="https://static001.geekbang.org/resource/image/a0/f5/a0e3994319732ca77c81e0f92cc77ff5.jpg" alt="">
我们把这个问题划分成8个阶段依次将8个棋子放到第一行、第二行、第三行……第八行。在放置的过程中我们不停地检查当前放法是否满足要求。如果满足则跳到下一行继续放置棋子如果不满足那就再换一种放法继续尝试。
回溯算法非常适合用递归代码实现,所以,我把八皇后的算法翻译成代码。我在代码里添加了详细的注释,你可以对比着看下。如果你之前没有接触过八皇后问题,建议你自己用熟悉的编程语言实现一遍,这对你理解回溯思想非常有帮助。
```
int[] result = new int[8];//全局或成员变量,下标表示行,值表示queen存储在哪一列
public void cal8queens(int row) { // 调用方式cal8queens(0);
if (row == 8) { // 8个棋子都放置好了打印结果
printQueens(result);
return; // 8行棋子都放好了已经没法再往下递归了所以就return
}
for (int column = 0; column &lt; 8; ++column) { // 每一行都有8中放法
if (isOk(row, column)) { // 有些放法不满足要求
result[row] = column; // 第row行的棋子放到了column列
cal8queens(row+1); // 考察下一行
}
}
}
private boolean isOk(int row, int column) {//判断row行column列放置是否合适
int leftup = column - 1, rightup = column + 1;
for (int i = row-1; i &gt;= 0; --i) { // 逐行往上考察每一行
if (result[i] == column) return false; // 第i行的column列有棋子吗
if (leftup &gt;= 0) { // 考察左上对角线第i行leftup列有棋子吗
if (result[i] == leftup) return false;
}
if (rightup &lt; 8) { // 考察右上对角线第i行rightup列有棋子吗
if (result[i] == rightup) return false;
}
--leftup; ++rightup;
}
return true;
}
private void printQueens(int[] result) { // 打印出一个二维矩阵
for (int row = 0; row &lt; 8; ++row) {
for (int column = 0; column &lt; 8; ++column) {
if (result[row] == column) System.out.print(&quot;Q &quot;);
else System.out.print(&quot;* &quot;);
}
System.out.println();
}
System.out.println();
}
```
## 两个回溯算法的经典应用
回溯算法的理论知识很容易弄懂。不过,对于新手来说,比较难的是用递归来实现。所以,我们再通过两个例子,来练习一下回溯算法的应用和实现。
### 1.0-1背包
0-1背包是非常经典的算法问题很多场景都可以抽象成这个问题模型。这个问题的经典解法是动态规划不过还有一种简单但没有那么高效的解法那就是今天讲的回溯算法。动态规划的解法我下一节再讲我们先来看下如何用回溯法解决这个问题。
0-1背包问题有很多变体我这里介绍一种比较基础的。我们有一个背包背包总的承载重量是Wkg。现在我们有n个物品每个物品的重量不等并且不可分割。我们现在期望选择几件物品装载到背包中。在不超过背包所能装载重量的前提下如何让背包中物品的总重量最大
实际上背包问题我们在贪心算法那一节已经讲过一个了不过那里讲的物品是可以分割的我可以装某个物品的一部分到背包里面。今天讲的这个背包问题物品是不可分割的要么装要么不装所以叫0-1背包问题。显然这个问题已经无法通过贪心算法来解决了。我们现在来看看用回溯算法如何来解决。
对于每个物品来说都有两种选择装进背包或者不装进背包。对于n个物品来说总的装法就有2^n种去掉总重量超过Wkg的从剩下的装法中选择总重量最接近Wkg的。不过我们如何才能不重复地穷举出这2^n种装法呢
这里就可以用回溯的方法。我们可以把物品依次排列整个问题就分解为了n个阶段每个阶段对应一个物品怎么选择。先对第一个物品进行处理选择装进去或者不装进去然后再递归地处理剩下的物品。描述起来很费劲我们直接看代码反而会更加清晰一些。
这里还稍微用到了一点搜索剪枝的技巧就是当发现已经选择的物品的重量超过Wkg之后我们就停止继续探测剩下的物品。你可以看我写的具体的代码。
```
public int maxW = Integer.MIN_VALUE; //存储背包中物品总重量的最大值
// cw表示当前已经装进去的物品的重量和i表示考察到哪个物品了
// w背包重量items表示每个物品的重量n表示物品个数
// 假设背包可承受重量100物品个数10物品重量存储在数组a中那可以这样调用函数
// f(0, 0, a, 10, 100)
public void f(int i, int cw, int[] items, int n, int w) {
if (cw == w || i == n) { // cw==w表示装满了;i==n表示已经考察完所有的物品
if (cw &gt; maxW) maxW = cw;
return;
}
f(i+1, cw, items, n, w);
if (cw + items[i] &lt;= w) {// 已经超过可以背包承受的重量的时候,就不要再装了
f(i+1,cw + items[i], items, n, w);
}
}
```
### 2.正则表达式
看懂了0-1背包问题我们再来看另外一个例子正则表达式匹配。
对于一个开发工程师来说,正则表达式你应该不陌生吧?在平时的开发中,或多或少都应该用过。实际上,正则表达式里最重要的一种算法思想就是回溯。
正则表达式中,最重要的就是通配符,通配符结合在一起,可以表达非常丰富的语义。为了方便讲解,我假设正则表达式中只包含“*”和“?”这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“*”匹配任意多个大于等于0个任意字符“?”匹配零个或者一个任意字符。基于以上背景假设,我们看下,如何用回溯算法,判断一个给定的文本,能否跟给定的正则表达式匹配?
我们依次考察正则表达式中的每个字符,当是非通配符时,我们就直接跟文本的字符进行匹配,如果相同,则继续往下处理;如果不同,则回溯。
如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如“*”有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。
有了前面的基础,是不是这个问题就好懂多了呢?我把这个过程翻译成了代码,你可以结合着一块看下,应该有助于你理解。
```
public class Pattern {
private boolean matched = false;
private char[] pattern; // 正则表达式
private int plen; // 正则表达式长度
public Pattern(char[] pattern, int plen) {
this.pattern = pattern;
this.plen = plen;
}
public boolean match(char[] text, int tlen) { // 文本串及长度
matched = false;
rmatch(0, 0, text, tlen);
return matched;
}
private void rmatch(int ti, int pj, char[] text, int tlen) {
if (matched) return; // 如果已经匹配了,就不要继续递归了
if (pj == plen) { // 正则表达式到结尾了
if (ti == tlen) matched = true; // 文本串也到结尾了
return;
}
if (pattern[pj] == '*') { // *匹配任意个字符
for (int k = 0; k &lt;= tlen-ti; ++k) {
rmatch(ti+k, pj+1, text, tlen);
}
} else if (pattern[pj] == '?') { // ?匹配0个或者1个字符
rmatch(ti, pj+1, text, tlen);
rmatch(ti+1, pj+1, text, tlen);
} else if (ti &lt; tlen &amp;&amp; pattern[pj] == text[ti]) { // 纯字符匹配才行
rmatch(ti+1, pj+1, text, tlen);
}
}
}
```
## 内容小结
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。
尽管回溯算法的原理非常简单但是却可以解决很多问题比如我们开头提到的深度优先搜索、八皇后、0-1背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。如果感兴趣的话你可以自己搜索研究一下最好还能用代码实现一下。如果这几个问题都能实现的话你基本就掌握了回溯算法。
## 课后思考
现在我们对今天讲到的0-1背包问题稍加改造如果每个物品不仅重量不同价值也不同。如何在不超过背包重量的情况下让背包中的总价值最大
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,303 @@
<audio id="audio" title="40 | 初识动态规划:如何巧妙解决“双十一”购物时的凑单问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/06/b8/06c836809160092e73f878a79983bdb8.mp3"></audio>
淘宝的“双十一”购物节有各种促销活动比如“满200元减50元”。假设你女朋友的购物车中有n个n&gt;100想买的商品她希望从里面选几个在凑够满减条件的前提下让选出来的商品价格总和最大程度地接近满减条件200元这样就可以极大限度地“薅羊毛”。作为程序员的你能不能编个代码来帮她搞定呢
要想高效地解决这个问题就要用到我们今天讲的动态规划Dynamic Programming
## 动态规划学习路线
动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。不过,它也是出了名的难学。它的主要学习难点跟递归类似,那就是,求解问题的过程不太符合人类常规的思维方式。对于新手来说,要想入门确实不容易。不过,等你掌握了之后,你会发现,实际上并没有想象中那么难。
为了让你更容易理解动态规划,我分了三节给你讲解。这三节分别是,初识动态规划、动态规划理论、动态规划实战。
第一节,我会通过两个非常经典的动态规划问题模型,向你展示我们为什么需要动态规划,以及动态规划解题方法是如何演化出来的。实际上,你只要掌握了这两个例子的解决思路,对于其他很多动态规划问题,你都可以套用类似的思路来解决。
第二节,我会总结动态规划适合解决的问题的特征,以及动态规划解题思路。除此之外,我还会将贪心、分治、回溯、动态规划这四种算法思想放在一起,对比分析它们各自的特点以及适用的场景。
第三节,我会教你应用第二节讲的动态规划理论知识,实战解决三个非常经典的动态规划问题,加深你对理论的理解。弄懂了这三节中的例子,对于动态规划这个知识点,你就算是入门了。
## 0-1背包问题
我在讲贪心算法、回溯算法的时候,多次讲到背包问题。今天,我们依旧拿这个问题来举例。
对于一组不同重量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
关于这个问题,我们上一节讲了回溯的解决方法,也就是穷举搜索所有可能的装法,然后找出满足条件的最大值。不过,回溯算法的复杂度比较高,是指数级别的。那有没有什么规律,可以有效降低时间复杂度呢?我们一起来看看。
```
// 回溯算法实现。注意:我把输入的变量都定义成了成员变量。
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {22463}; // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw) { // 调用f(0, 0)
if (cw == w || i == n) { // cw==w表示装满了i==n表示物品都考察完了
if (cw &gt; maxW) maxW = cw;
return;
}
f(i+1, cw); // 选择不装第i个物品
if (cw + weight[i] &lt;= w) {
f(i+1,cw + weight[i]); // 选择装第i个物品
}
}
```
规律是不是不好找那我们就举个例子、画个图看看。我们假设背包的最大承载重量是9。我们有5个不同的物品每个物品的重量分别是22463。如果我们把这个例子的回溯求解过程用递归树画出来就是下面这个样子
<img src="https://static001.geekbang.org/resource/image/42/ea/42ca6cec4ad034fc3e5c0605fbacecea.jpg" alt="">
递归树中的每个节点表示一种状态我们用i, cw来表示。其中i表示将要决策第几个物品是否装入背包cw表示当前背包中物品的总重量。比如22表示我们将要决策第2个物品是否装入背包在决策前背包中物品的总重量是2。
从递归树中你应该能会发现有些子问题的求解是重复的比如图中f(2, 2)和f(3,4)都被重复计算了两次。我们可以借助[递归](https://time.geekbang.org/column/article/41440)那一节讲的“备忘录”的解决方式记录已经计算好的f(i, cw)当再次计算到重复的f(i, cw)的时候,可以直接从备忘录中取出来用,就不用再递归计算了,这样就可以避免冗余计算。
```
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {22463}; // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; // 备忘录默认值false
public void f(int i, int cw) { // 调用f(0, 0)
if (cw == w || i == n) { // cw==w表示装满了i==n表示物品都考察完了
if (cw &gt; maxW) maxW = cw;
return;
}
if (mem[i][cw]) return; // 重复状态
mem[i][cw] = true; // 记录(i, cw)这个状态
f(i+1, cw); // 选择不装第i个物品
if (cw + weight[i] &lt;= w) {
f(i+1,cw + weight[i]); // 选择装第i个物品
}
}
```
这种解决方法非常好。实际上,它已经跟动态规划的执行效率基本上没有差别。但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。
我们把整个求解过程分为n个阶段每个阶段会决策一个物品是否放到背包中。每个物品决策放入或者不放入背包完之后背包中的物品的重量会有多种情况也就是说会达到多种不同的状态对应到递归树中就是有很多不同的节点。
我们把每一层重复的状态节点合并只记录不同的状态然后基于上一层的状态集合来推导下一层的状态集合。我们可以通过合并每一层重复的状态这样就保证每一层不同状态的个数都不会超过w个w表示背包的承载重量也就是例子中的9。于是我们就成功避免了每层状态个数的指数级增长。
我们用一个二维数组states[n][w+1],来记录每层可以达到的不同状态。
第0个下标从0开始编号物品的重量是2要么装入背包要么不装入背包决策完之后会对应背包的两种状态背包中物品的总重量是0或者2。我们用states[0][0]=true和states[0][2]=true来表示这两种状态。
第1个物品的重量也是2基于之前的背包状态在这个物品决策完之后不同的状态有3个背包中物品总重量分别是0(0+0)2(0+2 or 2+0)4(2+2)。我们用states[1][0]=truestates[1][2]=truestates[1][4]=true来表示这三种状态。
以此类推直到考察完所有的物品后整个states状态数组就都计算好了。我把整个计算的过程画了出来你可以看看。图中0表示false1表示true。我们只需要在最后一层找一个值为true的最接近w这里是9的值就是背包中物品总重量的最大值。
<img src="https://static001.geekbang.org/resource/image/aa/b5/aaf51df520ea6b8056f4e62aed81a5b5.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/bb/7e/bbbb934247219db8299bd46dba9dd47e.jpg" alt="">
文字描述可能还不够清楚。我把上面的过程,翻译成代码,你可以结合着一块看下。
```
weight:物品重量n:物品个数w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
boolean[][] states = new boolean[n][w+1]; // 默认值false
states[0][0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (weight[0] &lt;= w) {
states[0][weight[0]] = true;
}
for (int i = 1; i &lt; n; ++i) { // 动态规划状态转移
for (int j = 0; j &lt;= w; ++j) {// 不把第i个物品放入背包
if (states[i-1][j] == true) states[i][j] = states[i-1][j];
}
for (int j = 0; j &lt;= w-weight[i]; ++j) {//把第i个物品放入背包
if (states[i-1][j]==true) states[i][j+weight[i]] = true;
}
}
for (int i = w; i &gt;= 0; --i) { // 输出结果
if (states[n-1][i] == true) return i;
}
return 0;
}
```
实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由来,你可以自己体会一下,是不是还挺形象的?
前面我们讲到用回溯算法解决这个问题的时间复杂度O(2^n),是指数级的。那动态规划解决方案的时间复杂度是多少呢?我来分析一下。
这个代码的时间复杂度非常好分析耗时最多的部分就是代码中的两层for循环所以时间复杂度是O(n*w)。n表示物品个数w表示背包可以承载的总重量。
从理论上讲指数级的时间复杂度肯定要比O(n*w)高很多,但是为了让你有更加深刻的感受,我来举一个例子给你比较一下。
我们假设有10000个物品重量分布在1到15000之间背包可以承载的总重量是30000。如果我们用回溯算法解决用具体的数值表示出时间复杂度就是2^10000这是一个相当大的一个数字。如果我们用动态规划解决用具体的数值表示出时间复杂度就是10000*30000。虽然看起来也很大但是和2^10000比起来要小太多了。
尽管动态规划的执行效率比较高但是就刚刚的代码实现来说我们需要额外申请一个n乘以w+1的二维数组对空间的消耗比较多。所以有时候我们会说动态规划是一种空间换时间的解决思路。你可能要问了有什么办法可以降低空间消耗吗
实际上我们只需要一个大小为w+1的一维数组就可以解决这个问题。动态规划状态转移的过程都可以基于这个一维数组来操作。具体的代码实现我贴在这里你可以仔细看下。
```
public static int knapsack2(int[] items, int n, int w) {
boolean[] states = new boolean[w+1]; // 默认值false
states[0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (items[0] &lt;= w) {
states[items[0]] = true;
}
for (int i = 1; i &lt; n; ++i) { // 动态规划
for (int j = w-items[i]; j &gt;= 0; --j) {//把第i个物品放入背包
if (states[j]==true) states[j+items[i]] = true;
}
}
for (int i = w; i &gt;= 0; --i) { // 输出结果
if (states[i] == true) return i;
}
return 0;
}
```
这里我特别强调一下代码中的第8行j需要从大到小来处理。如果我们按照j从小到大处理的话会出现for循环重复计算的问题。你可以自己想一想这里我就不详细说了。
## 0-1背包问题升级版
我们继续升级难度。我改造了一下刚刚的背包问题。你看这个问题又该如何用动态规划解决?
我们刚刚讲的背包问题,只涉及背包重量和物品重量。我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?
这个问题依旧可以用回溯算法来解决。这个问题并不复杂,所以具体的实现思路,我就不用文字描述了,直接给你看代码。
```
private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] items = {22463}; // 物品的重量
private int[] value = {34896}; // 物品的价值
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
if (cw == w || i == n) { // cw==w表示装满了i==n表示物品都考察完了
if (cv &gt; maxV) maxV = cv;
return;
}
f(i+1, cw, cv); // 选择不装第i个物品
if (cw + weight[i] &lt;= w) {
f(i+1,cw+weight[i], cv+value[i]); // 选择装第i个物品
}
}
```
针对上面的代码我们还是照例画出递归树。在递归树中每个节点表示一个状态。现在我们需要3个变量i, cw, cv来表示一个状态。其中i表示即将要决策第i个物品是否装入背包cw表示当前背包中物品的总重量cv表示当前背包中物品的总价值。
<img src="https://static001.geekbang.org/resource/image/bf/3f/bf0aa18f367db1b8dfd392906cb5693f.jpg" alt="">
我们发现在递归树中有几个节点的i和cw是完全相同的比如f(2,2,4)和f(2,2,3)。在背包中物品总重量一样的情况下f(2,2,4)这种状态对应的物品总价值更大我们可以舍弃f(2,2,3)这种状态只需要沿着f(2,2,4)这条决策路线继续往下决策就可以。
也就是说,对于(i, cw)相同的不同状态那我们只需要保留cv值最大的那个继续递归处理其他状态不予考虑。
思路说完了,但是代码如何实现呢?如果用回溯算法,这个问题就没法再用“备忘录”解决了。所以,我们就需要换一种思路,看看动态规划是不是更容易解决这个问题?
我们还是把整个求解过程分为n个阶段每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后背包中的物品的总重量以及总价值会有多种情况也就是会达到多种不同的状态。
我们用一个二维数组states[n][w+1]来记录每层可以达到的不同状态。不过这里数组存储的值不再是boolean类型的了而是当前状态对应的最大总价值。我们把每一层中(i, cw)重复的状态节点合并只记录cv值最大的那个状态然后基于这些状态来推导下一层的状态。
我们把这个动态规划的过程翻译成代码,就是下面这个样子:
```
public static int knapsack3(int[] weight, int[] value, int n, int w) {
int[][] states = new int[n][w+1];
for (int i = 0; i &lt; n; ++i) { // 初始化states
for (int j = 0; j &lt; w+1; ++j) {
states[i][j] = -1;
}
}
states[0][0] = 0;
if (weight[0] &lt;= w) {
states[0][weight[0]] = value[0];
}
for (int i = 1; i &lt; n; ++i) { //动态规划,状态转移
for (int j = 0; j &lt;= w; ++j) { // 不选择第i个物品
if (states[i-1][j] &gt;= 0) states[i][j] = states[i-1][j];
}
for (int j = 0; j &lt;= w-weight[i]; ++j) { // 选择第i个物品
if (states[i-1][j] &gt;= 0) {
int v = states[i-1][j] + value[i];
if (v &gt; states[i][j+weight[i]]) {
states[i][j+weight[i]] = v;
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j &lt;= w; ++j) {
if (states[n-1][j] &gt; maxvalue) maxvalue = states[n-1][j];
}
return maxvalue;
}
```
关于这个问题的时间、空间复杂度的分析跟上一个例子大同小异所以我就不赘述了。我直接给出答案时间复杂度是O(n*w)空间复杂度也是O(n*w)。跟上一个例子类似,空间复杂度也是可以优化的,你可以自己写一下。
## 解答开篇
掌握了今天讲的两个问题之后,你是不是觉得,开篇的问题很简单?
对于这个问题你当然可以利用回溯算法穷举所有的排列组合看大于等于200并且最接近200的组合是哪一个但是这样效率太低了点时间复杂度非常高是指数级的。当n很大的时候可能“双十一”已经结束了你的代码还没有运行出结果这显然会让你在女朋友心中的形象大大减分。
实际上它跟第一个例子中讲的0-1背包问题很像只不过是把“重量”换成了“价格”而已。购物车中有n个商品。我们针对每个商品都决策是否购买。每次决策之后对应不同的状态集合。我们还是用一个二维数组states[n][x]来记录每次决策之后所有可达的状态。不过这里的x值是多少呢
0-1背包问题中我们找的是小于等于w的最大值x就是背包的最大承载重量w+1。对于这个问题来说我们要找的是大于等于200满减条件的值中最小的所以就不能设置为200加1了。就这个实际的问题而言如果要购买的物品的总价格超过200太多比如1000那这个羊毛“薅”得就没有太大意义了。所以我们可以限定x值为1001。
不过这个问题不仅要求大于等于200的总价格中的最小的我们还要找出这个最小总价格对应都要购买哪些商品。实际上我们可以利用states数组倒推出这个被选择的商品序列。我先把代码写出来待会再照着代码给你解释。
```
// items商品价格n商品个数, w表示满减条件比如200
public static void double11advance(int[] items, int n, int w) {
boolean[][] states = new boolean[n][3*w+1];//超过3倍就没有薅羊毛的价值了
states[0][0] = true; // 第一行的数据要特殊处理
if (items[0] &lt;= 3*w) {
states[0][items[0]] = true;
}
for (int i = 1; i &lt; n; ++i) { // 动态规划
for (int j = 0; j &lt;= 3*w; ++j) {// 不购买第i个商品
if (states[i-1][j] == true) states[i][j] = states[i-1][j];
}
for (int j = 0; j &lt;= 3*w-items[i]; ++j) {//购买第i个商品
if (states[i-1][j]==true) states[i][j+items[i]] = true;
}
}
int j;
for (j = w; j &lt; 3*w+1; ++j) {
if (states[n-1][j] == true) break; // 输出结果大于等于w的最小值
}
if (j == 3*w+1) return; // 没有可行解
for (int i = n-1; i &gt;= 1; --i) { // i表示二维数组中的行j表示列
if(j-items[i] &gt;= 0 &amp;&amp; states[i-1][j-items[i]] == true) {
System.out.print(items[i] + &quot; &quot;); // 购买这个商品
j = j - items[i];
} // else 没有购买这个商品j不变。
}
if (j != 0) System.out.print(items[0]);
}
```
代码的前半部分跟0-1背包问题没有什么不同我们着重看后半部分看它是如何打印出选择购买哪些商品的。
状态(i, j)只有可能从(i-1, j)或者(i-1, j-value[i])两个状态推导过来。所以我们就检查这两个状态是否是可达的也就是states[i-1][j]或者states[i-1][j-value[i]]是否是true。
如果states[i-1][j]可达就说明我们没有选择购买第i个商品如果states[i-1][j-value[i]]可达那就说明我们选择了购买第i个商品。我们从中选择一个可达的状态如果两个都可达就随意选择一个然后继续迭代地考察其他商品是否有选择购买。
## 内容小结
动态规划的第一节到此就讲完了。内容比较多,你可能需要多一点时间来消化。为了帮助你有的放矢地学习,我来强调一下,今天你应该掌握的重点内容。
今天的内容不涉及动态规划的理论,我通过两个例子,给你展示了动态规划是如何解决问题的,并且一点一点详细给你讲解了动态规划解决问题的思路。这两个例子都是非常经典的动态规划问题,只要你真正搞懂这两个问题,基本上动态规划已经入门一半了。所以,你要多花点时间,真正弄懂这两个问题。
从例子中,你应该能发现,大部分动态规划能解决的问题,都可以通过回溯算法来解决,只不过回溯算法解决起来效率比较低,时间复杂度是指数级的。动态规划算法,在执行效率方面,要高很多。尽管执行效率提高了,但是动态规划的空间复杂度也提高了,所以,很多时候,我们会说,动态规划是一种空间换时间的算法思想。
我前面也说了,今天的内容并不涉及理论的知识。这两个例子的分析过程,我并没有涉及任何高深的理论方面的东西。而且,我个人觉得,贪心、分治、回溯、动态规划,这四个算法思想有关的理论知识,大部分都是“后验性”的,也就是说,在解决问题的过程中,我们往往是先想到如何用某个算法思想解决问题,然后才用算法理论知识,去验证这个算法思想解决问题的正确性。所以,你大可不必过于急于寻求动态规划的理论知识。
## 课后思考
“杨辉三角”不知道你听说过吗?我们现在对它进行一些改造。每个位置的数字可以随意填写,经过某个数字只能到达下面一层相邻的两个数字。
假设你站在第一层,往下移动,我们把移动到最底层所经过的所有数字之和,定义为路径的长度。请你编程求出从最高层移动到最底层的最短路径长度。
<img src="https://static001.geekbang.org/resource/image/f7/cc/f756eade65a5da08e7c0f1e93f9f20cc.jpg" alt="">
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,212 @@
<audio id="audio" title="41 | 动态规划理论:一篇文章带你彻底搞懂最优子结构、无后效性和重复子问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/00/161a951644ea4c4d91faf6b5798cf500.mp3"></audio>
上一节,我通过两个非常经典的问题,向你展示了用动态规划解决问题的过程。现在你对动态规划应该有了一个初步的认识。
今天,我主要讲动态规划的一些理论知识。学完这节内容,可以帮你解决这样几个问题:什么样的问题可以用动态规划解决?解决动态规划问题的一般思考过程是什么样的?贪心、分治、回溯、动态规划这四种算法思想又有什么区别和联系?
理论的东西都比较抽象,不过你不用担心,我会结合具体的例子来讲解,争取让你这次就能真正理解这些知识点,也为后面的应用和实战做好准备。
## “一个模型三个特征”理论讲解
什么样的问题适合用动态规划来解决呢?换句话说,动态规划能解决的问题有什么规律可循呢?实际上,动态规划作为一个非常成熟的算法思想,很多人对此已经做了非常全面的总结。我把这部分理论总结为“一个模型三个特征”。
首先,我们来看,什么是“**一个模型**”?它指的是动态规划适合解决的问题的模型。我把这个模型定义为“**多阶段决策最优解模型**”。下面我具体来给你讲讲。
我们一般是用动态规划来解决最优问题。而解决问题的过程,需要经历多个决策阶段。每个决策阶段都对应着一组状态。然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值。
现在,我们再来看,什么是“**三个特征**”?它们分别是**最优子结构**、**无后效性**和**重复子问题**。这三个概念比较抽象,我来逐一详细解释一下。
### 1.最优子结构
最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来。
### 2.无后效性
无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。
### 3.重复子问题
这个概念比较好理解。前面一节,我已经多次提过。如果用一句话概括一下,那就是,不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。
## “一个模型三个特征”实例剖析
“一个模型三个特征”这部分是理论知识,比较抽象,你看了之后可能还是有点懵,有种似懂非懂的感觉,没关系,这个很正常。接下来,我结合一个具体的动态规划问题,来给你详细解释。
假设我们有一个n乘以n的矩阵w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?
<img src="https://static001.geekbang.org/resource/image/65/9f/652dff86c5dcc6a0e2a0de9a814b079f.jpg" alt="">
我们先看看,这个问题是否符合“一个模型”?
从(0, 0)走到(n-1, n-1)总共要走2*(n-1)步也就对应着2*(n-1)个阶段。每个阶段都有向右走或者向下走两种决策,并且每个阶段都会对应一个状态集合。
我们把状态定义为min_dist(i, j)其中i表示行j表示列。min_dist表达式的值表示从(0, 0)到达(i, j)的最短路径长度。所以,这个问题是一个多阶段决策最优解问题,符合动态规划的模型。
<img src="https://static001.geekbang.org/resource/image/b0/69/b0da245a38fafbfcc590782486b85269.jpg" alt="">
我们再来看,这个问题是否符合“三个特征”?
我们可以用回溯算法来解决这个问题。如果你自己写一下代码,画一下递归树,就会发现,递归树中有重复的节点。重复的节点表示,从左上角到节点对应的位置,有多种路线,这也能说明这个问题中存在重复子问题。
<img src="https://static001.geekbang.org/resource/image/64/65/64403695861da87f41f7b2ec83d44365.jpg" alt="">
如果我们走到(i, j)这个位置,我们只能通过(i-1, j)(i, j-1)这两个位置移动过来,也就是说,我们想要计算(i, j)位置对应的状态,只需要关心(i-1, j)(i, j-1)两个位置对应的状态,并不关心棋子是通过什么样的路线到达这两个位置的。而且,我们仅仅允许往下和往右移动,不允许后退,所以,前面阶段的状态确定之后,不会被后面阶段的决策所改变,所以,这个问题符合“无后效性”这一特征。
刚刚定义状态的时候,我们把从起始位置(0, 0)到(i, j)的最小路径记作min_dist(i, j)。因为我们只能往右或往下移动,所以,我们只有可能从(i, j-1)或者(i-1, j)两个位置到达(i, j)。也就是说,到达(i, j)的最短路径要么经过(i, j-1),要么经过(i-1, j),而且到达(i, j)的最短路径肯定包含到达这两个位置的最短路径之一。换句话说就是min_dist(i, j)可以通过min_dist(i, j-1)和min_dist(i-1, j)两个状态推导出来。这就说明,这个问题符合“最优子结构”。
```
min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
```
## 两种动态规划解题思路总结
刚刚我讲了,如何鉴别一个问题是否可以用动态规划来解决。现在,我再总结一下,动态规划解题的一般思路,让你面对动态规划问题的时候,能够有章可循,不至于束手无策。
我个人觉得,解决动态规划问题,一般有两种思路。我把它们分别叫作,状态转移表法和状态转移方程法。
### 1.状态转移表法
一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。
找到重复子问题之后,接下来,我们有两种处理思路,第一种是直接用**回溯加“备忘录”**的方法,来避免重复子问题。从执行效率上来讲,这跟动态规划的解决思路没有差别。第二种是使用动态规划的解决方法,**状态转移表法**。第一种思路,我就不讲了,你可以看看上一节的两个例子。我们重点来看状态转移表法是如何工作的。
我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。
尽管大部分状态表都是二维的,但是如果问题的状态比较复杂,需要很多变量来表示,那对应的状态表可能就是高维的,比如三维、四维。那这个时候,我们就不适合用状态转移表法来解决了。一方面是因为高维状态转移表不好画图表示,另一方面是因为人脑确实很不擅长思考高维的东西。
现在,我们来看一下,如何套用这个状态转移表法,来解决之前那个矩阵最短路径的问题?
从起点到终点,我们有很多种不同的走法。我们可以穷举所有走法,然后对比找出一个最短走法。不过如何才能无重复又不遗漏地穷举出所有走法呢?我们可以用回溯算法这个比较有规律的穷举算法。
回溯算法的代码实现如下所示。代码很短,而且我前面也分析过很多回溯算法的例题,这里我就不多做解释了,你自己来看看。
```
private int minDist = Integer.MAX_VALUE; // 全局变量或者成员变量
// 调用方式minDistBacktracing(0, 0, 0, w, n);
public void minDistBT(int i, int j, int dist, int[][] w, int n) {
// 到达了n-1, n-1这个位置了这里看着有点奇怪哈你自己举个例子看下
if (i == n &amp;&amp; j == n) {
if (dist &lt; minDist) minDist = dist;
return;
}
if (i &lt; n) { // 往下走更新i=i+1, j=j
minDistBT(i + 1, j, dist+w[i][j], w, n);
}
if (j &lt; n) { // 往右走更新i=i, j=j+1
minDistBT(i, j+1, dist+w[i][j], w, n);
}
}
```
有了回溯代码之后,接下来,我们要画出递归树,以此来寻找重复子问题。在递归树中,一个状态(也就是一个节点)包含三个变量(i, j, dist)其中ij分别表示行和列dist表示从起点到达(i, j)的路径长度。从图中,我们看出,尽管(i, j, dist)不存在重复的,但是(i, j)重复的有很多。对于(i, j)重复的节点我们只需要选择dist最小的节点继续递归求解其他节点就可以舍弃了。
<img src="https://static001.geekbang.org/resource/image/2c/e2/2c3ec820fa8f8cc7df838c0304b030e2.jpg" alt="">
既然存在重复子问题,我们就可以尝试看下,是否可以用动态规划来解决呢?
我们画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。我们按照决策过程,通过不断状态递推演进,将状态表填好。为了方便代码实现,我们按行来进行依次填充。
<img src="https://static001.geekbang.org/resource/image/b3/ca/b3f0de1c81533a0d24c43426eaf09aca.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/05/7d/05a48baf7fb4d251bf5078840079107d.jpg" alt="">
弄懂了填表的过程,代码实现就简单多了。我们将上面的过程,翻译成代码,就是下面这个样子。结合着代码、图和文字描述,应该更容易理解我讲的内容。
```
public int minDistDP(int[][] matrix, int n) {
int[][] states = new int[n][n];
int sum = 0;
for (int j = 0; j &lt; n; ++j) { // 初始化states的第一行数据
sum += matrix[0][j];
states[0][j] = sum;
}
sum = 0;
for (int i = 0; i &lt; n; ++i) { // 初始化states的第一列数据
sum += matrix[i][0];
states[i][0] = sum;
}
for (int i = 1; i &lt; n; ++i) {
for (int j = 1; j &lt; n; ++j) {
states[i][j] =
matrix[i][j] + Math.min(states[i][j-1], states[i-1][j]);
}
}
return states[n-1][n-1];
}
```
### 2.状态转移方程法
状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。有了状态转移方程,代码实现就非常简单了。一般情况下,我们有两种代码实现方法,一种是**递归加“备忘录”**,另一种是**迭代递推**。
我们还是拿刚才的例子来举例。最优子结构前面已经分析过了,你可以回过头去再看下。为了方便你查看,我把状态转移方程放到这里。
```
min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
```
这里我强调一下,**状态转移方程是解决动态规划的关键。**如果我们能写出状态转移方程,那动态规划问题基本上就解决一大半了,而翻译成代码非常简单。但是很多动态规划问题的状态本身就不好定义,状态转移方程也就更不好想到。
下面我用递归加“备忘录”的方式,将状态转移方程翻译成来代码,你可以看看。对于另一种实现方式,跟状态转移表法的代码实现是一样的,只是思路不同。
```
private int[][] matrix =
{{1359}, {2134}{5267}{6843}};
private int n = 4;
private int[][] mem = new int[4][4];
public int minDist(int i, int j) { // 调用minDist(n-1, n-1);
if (i == 0 &amp;&amp; j == 0) return matrix[0][0];
if (mem[i][j] &gt; 0) return mem[i][j];
int minLeft = Integer.MAX_VALUE;
if (j-1 &gt;= 0) {
minLeft = minDist(i, j-1);
}
int minUp = Integer.MAX_VALUE;
if (i-1 &gt;= 0) {
minUp = minDist(i-1, j);
}
int currMinDist = matrix[i][j] + Math.min(minLeft, minUp);
mem[i][j] = currMinDist;
return currMinDist;
}
```
两种动态规划解题思路到这里就讲完了。我要强调一点,不是每个问题都同时适合这两种解题思路。有的问题可能用第一种思路更清晰,而有的问题可能用第二种思路更清晰,所以,你要结合具体的题目来看,到底选择用哪种解题思路。
## 四种算法思想比较分析
到今天为止,我们已经学习了四种算法思想,贪心、分治、回溯和动态规划。今天的内容主要讲些理论知识,我正好一块儿也分析一下这四种算法,看看它们之间有什么区别和联系。
如果我们将这四种算法思想分一下类,那贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类,因为它跟其他三个都不大一样。为什么这么说呢?前三个算法解决问题的模型,都可以抽象成我们今天讲的那个多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。
回溯算法是个“万金油”。基本上能用的动态规划、贪心解决的问题,我们都可以用回溯算法解决。回溯算法相当于穷举搜索。穷举所有的情况,然后对比得到最优解。不过,回溯算法的时间复杂度非常高,是指数级别的,只能用来解决小规模数据的问题。对于大规模数据的问题,用回溯算法解决的执行效率就很低了。
尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。
其中,最优子结构、无后效性跟动态规划中的无异。“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
## 内容小结
今天的内容到此就讲完了,我带你来复习一下。
我首先讲了什么样的问题适合用动态规划解决。这些问题可以总结概括为“一个模型三个特征”。其中,“一个模型”指的是,问题可以抽象成分阶段决策最优解模型。“三个特征”指的是最优子结构、无后效性和重复子问题。
然后,我讲了两种动态规划的解题思路。它们分别是状态转移表法和状态转移方程法。其中,状态转移表法解题思路大致可以概括为,**回溯算法实现-定义状态-画递归树-找重复子问题-画状态转移表-根据递推关系填表-将填表过程翻译成代码**。状态转移方程法的大致思路可以概括为,**找最优子结构-写状态转移方程-将状态转移方程翻译成代码**。
最后,我们对比了之前讲过的四种算法思想。贪心、回溯、动态规划可以解决的问题模型类似,都可以抽象成多阶段决策最优解模型。尽管分治算法也能解决最优问题,但是大部分问题的背景都不适合抽象成多阶段决策模型。
今天的内容比较偏理论,可能会不好理解。很多理论知识的学习,单纯的填鸭式讲给你听,实际上效果并不好。要想真的把这些理论知识理解透,化为己用,还是需要你自己多思考,多练习。等你做了足够多的题目之后,自然就能自己悟出一些东西,这样再回过头来看理论,就会非常容易看懂。
所以,在今天的内容中,如果有哪些地方你还不能理解,那也没关系,先放一放。下一节,我会运用今天讲到的理论,再解决几个动态规划的问题。等你学完下一节,可以再回过头来看下今天的理论知识,可能就会有一种顿悟的感觉。
## 课后思考
硬币找零问题我们在贪心算法那一节中讲过一次。我们今天来看一个新的硬币找零问题。假设我们有几种不同币值的硬币v1v2……vn单位是元。如果我们要支付w元求最少需要多少个硬币。比如我们有3种不同的硬币1元、3元、5元我们要支付9元最少需要3个硬币3个3元的硬币
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。

View File

@@ -0,0 +1,281 @@
<audio id="audio" title="42 | 动态规划实战:如何实现搜索引擎中的拼写纠错功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/a2/eddf200166804d9e476d78f47aa9efa2.mp3"></audio>
在[Trie树](https://time.geekbang.org/column/article/72414)那节我们讲过利用Trie树可以实现搜索引擎的关键词提示功能这样可以节省用户输入搜索关键词的时间。实际上搜索引擎在用户体验方面的优化还有很多比如你可能经常会用的拼写纠错功能。
当你在搜索框中,一不小心输错单词时,搜索引擎会非常智能地检测出你的拼写错误,并且用对应的正确单词来进行搜索。作为一名软件开发工程师,你是否想过,这个功能是怎么实现的呢?
<img src="https://static001.geekbang.org/resource/image/c1/6d/c18a9c785206754f9f1ff74c1b8f6c6d.png" alt="">
## 如何量化两个字符串的相似度?
计算机只认识数字所以要解答开篇的问题我们就要先来看如何量化两个字符串之间的相似程度呢有一个非常著名的量化方法那就是编辑距离Edit Distance
顾名思义,**编辑距离**指的就是将一个字符串转化成另一个字符串需要的最少编辑操作次数比如增加一个字符、删除一个字符、替换一个字符。编辑距离越大说明两个字符串的相似程度越小相反编辑距离就越小说明两个字符串的相似程度越大。对于两个完全相同的字符串来说编辑距离就是0。
根据所包含的编辑操作种类的不同,编辑距离有多种不同的计算方式,比较著名的有**莱文斯坦距离**Levenshtein distance和**最长公共子串长度**Longest common substring length。其中莱文斯坦距离允许增加、删除、替换字符这三个编辑操作最长公共子串长度只允许增加、删除字符这两个编辑操作。
而且,莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。
关于这两个计算方法我举个例子给你说明一下。这里面两个字符串mitcmu和mtacnu的莱文斯坦距离是3最长公共子串长度是4。
<img src="https://static001.geekbang.org/resource/image/f0/0f/f0e72008ce8451609abed7e368ac420f.jpg" alt="">
了解了编辑距离的概念之后,我们来看,如何快速计算两个字符串之间的编辑距离?
## 如何编程计算莱文斯坦距离?
之前我反复强调过,思考过程比结论更重要,所以,我现在就给你展示一下,解决这个问题,我的完整的思考过程。
这个问题是求把一个字符串变成另一个字符串,需要的最少编辑次数。整个求解过程,涉及多个决策阶段,我们需要依次考察一个字符串中的每个字符,跟另一个字符串中的字符是否匹配,匹配的话如何处理,不匹配的话又如何处理。所以,这个问题符合**多阶段决策最优解模型**。
我们前面讲了,贪心、回溯、动态规划可以解决的问题,都可以抽象成这样一个模型。要解决这个问题,我们可以先看一看,用最简单的回溯算法,该如何来解决。
回溯是一个递归处理的过程。如果a[i]与b[j]匹配我们递归考察a[i+1]和b[j+1]。如果a[i]与b[j]不匹配,那我们有多种处理方式可选:
<li>
可以删除a[i]然后递归考察a[i+1]和b[j]
</li>
<li>
可以删除b[j]然后递归考察a[i]和b[j+1]
</li>
<li>
可以在a[i]前面添加一个跟b[j]相同的字符然后递归考察a[i]和b[j+1];
</li>
<li>
可以在b[j]前面添加一个跟a[i]相同的字符然后递归考察a[i+1]和b[j]
</li>
<li>
可以将a[i]替换成b[j]或者将b[j]替换成a[i]然后递归考察a[i+1]和b[j+1]。
</li>
我们将上面的回溯算法的处理思路,翻译成代码,就是下面这个样子:
```
private char[] a = &quot;mitcmu&quot;.toCharArray();
private char[] b = &quot;mtacnu&quot;.toCharArray();
private int n = 6;
private int m = 6;
private int minDist = Integer.MAX_VALUE; // 存储结果
// 调用方式 lwstBT(0, 0, 0);
public lwstBT(int i, int j, int edist) {
if (i == n || j == m) {
if (i &lt; n) edist += (n-i);
if (j &lt; m) edist += (m - j);
if (edist &lt; minDist) minDist = edist;
return;
}
if (a[i] == b[j]) { // 两个字符匹配
lwstBT(i+1, j+1, edist);
} else { // 两个字符不匹配
lwstBT(i + 1, j, edist + 1); // 删除a[i]或者b[j]前添加一个字符
lwstBT(i, j + 1, edist + 1); // 删除b[j]或者a[i]前添加一个字符
lwstBT(i + 1, j + 1, edist + 1); // 将a[i]和b[j]替换为相同字符
}
}
```
根据回溯算法的代码实现,我们可以画出递归树,看是否存在重复子问题。如果存在重复子问题,那我们就可以考虑能否用动态规划来解决;如果不存在重复子问题,那回溯就是最好的解决方法。
<img src="https://static001.geekbang.org/resource/image/86/89/864f25506eb3db427377bde7bb4c9589.jpg" alt="">
在递归树中,每个节点代表一个状态,状态包含三个变量(i, j, edist)其中edist表示处理到a[i]和b[j]时,已经执行的编辑操作的次数。
在递归树中,(i, j)两个变量重复的节点很多,比如(3, 2)和(2, 3)。对于(i, j)相同的节点我们只需要保留edist最小的继续递归处理就可以了剩下的节点都可以舍弃。所以状态就从(i, j, edist)变成了(i, j, min_edist)其中min_edist表示处理到a[i]和b[j],已经执行的最少编辑次数。
看到这里,你有没有觉得,这个问题跟上两节讲的动态规划例子非常相似?不过,这个问题的状态转移方式,要比之前两节课中讲到的例子都要复杂很多。上一节我们讲的矩阵最短路径问题中,到达状态(i, j)只能通过(i-1, j)或(i, j-1)两个状态转移过来,而今天这个问题,状态(i, j)可能从(i-1, j)(i, j-1)(i-1, j-1)三个状态中的任意一个转移过来。
<img src="https://static001.geekbang.org/resource/image/11/89/11ffcba9b3c722c5487de7df5a0d6c89.jpg" alt="">
基于刚刚的分析,我们可以尝试着将把状态转移的过程,用公式写出来。这就是我们前面讲的状态转移方程。
```
如果a[i]!=b[j]那么min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1, min_edist(i-1,j-1)+1)
如果a[i]==b[j]那么min_edist(i, j)就等于:
min(min_edist(i-1,j)+1, min_edist(i,j-1)+1min_edist(i-1,j-1))
其中min表示求三数中的最小值。
```
了解了状态与状态之间的递推关系,我们画出一个二维的状态表,按行依次来填充状态表中的每个值。
<img src="https://static001.geekbang.org/resource/image/ab/2d/ab44eb53fad2601c19f73604747d652d.jpg" alt="">
我们现在既有状态转移方程,又理清了完整的填表过程,代码实现就非常简单了。我将代码贴在下面,你可以对比着文字解释,一起看下。
```
public int lwstDP(char[] a, int n, char[] b, int m) {
int[][] minDist = new int[n][m];
for (int j = 0; j &lt; m; ++j) { // 初始化第0行:a[0..0]与b[0..j]的编辑距离
if (a[0] == b[j]) minDist[0][j] = j;
else if (j != 0) minDist[0][j] = minDist[0][j-1]+1;
else minDist[0][j] = 1;
}
for (int i = 0; i &lt; n; ++i) { // 初始化第0列:a[0..i]与b[0..0]的编辑距离
if (a[i] == b[0]) minDist[i][0] = i;
else if (i != 0) minDist[i][0] = minDist[i-1][0]+1;
else minDist[i][0] = 1;
}
for (int i = 1; i &lt; n; ++i) { // 按行填表
for (int j = 1; j &lt; m; ++j) {
if (a[i] == b[j]) minDist[i][j] = min(
minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]);
else minDist[i][j] = min(
minDist[i-1][j]+1, minDist[i][j-1]+1, minDist[i-1][j-1]+1);
}
}
return minDist[n-1][m-1];
}
private int min(int x, int y, int z) {
int minv = Integer.MAX_VALUE;
if (x &lt; minv) minv = x;
if (y &lt; minv) minv = y;
if (z &lt; minv) minv = z;
return minv;
}
```
你可能会说,我虽然能看懂你讲的思路,但是遇到新的问题的时候,我还是会感觉到无从下手。这种感觉是非常正常的。关于复杂算法问题的解决思路,我还有一些经验、小技巧,可以分享给你。
当我们拿到一个问题的时候,**我们可以先不思考,计算机会如何实现这个问题,而是单纯考虑“人脑”会如何去解决这个问题**。人脑比较倾向于思考具象化的、摸得着看得见的东西,不适合思考过于抽象的问题。所以,我们需要把抽象问题具象化。那如何具象化呢?我们可以实例化几个测试数据,通过人脑去分析具体实例的解,然后总结规律,再尝试套用学过的算法,看是否能够解决。
除此之外,我还有一个非常有效、但也算不上技巧的东西,我也反复强调过,那就是**多练**。实际上,等你做多了题目之后,自然就会有感觉,看到问题,立马就能想到能否用动态规划解决,然后直接就可以寻找最优子结构,写出动态规划方程,然后将状态转移方程翻译成代码。
## 如何编程计算最长公共子串长度?
前面我们讲到,最长公共子串作为编辑距离中的一种,只允许增加、删除字符两种编辑操作。从名字上,你可能觉得它看起来跟编辑距离没什么关系。实际上,从本质上来说,它表征的也是两个字符串之间的相似程度。
这个问题的解决思路,跟莱文斯坦距离的解决思路非常相似,也可以用动态规划解决。我刚刚已经详细讲解了莱文斯坦距离的动态规划解决思路,所以,针对这个问题,我直接定义状态,然后写状态转移方程。
每个状态还是包括三个变量(i, j, max_lcs)max_lcs表示a[0...i]和b[0...j]的最长公共子串长度。那(i, j)这个状态都是由哪些状态转移过来的呢?
我们先来看回溯的处理思路。我们从a[0]和b[0]开始,依次考察两个字符串中的字符是否匹配。
<li>
如果a[i]与b[j]互相匹配我们将最大公共子串长度加一并且继续考察a[i+1]和b[j+1]。
</li>
<li>
如果a[i]与b[j]不匹配,最长公共子串长度不变,这个时候,有两个不同的决策路线:
</li>
<li>
删除a[i]或者在b[j]前面加上一个字符a[i]然后继续考察a[i+1]和b[j]
</li>
<li>
删除b[j]或者在a[i]前面加上一个字符b[j]然后继续考察a[i]和b[j+1]。
</li>
反过来也就是说如果我们要求a[0...i]和b[0...j]的最长公共长度max_lcs(i, j),我们只有可能通过下面三个状态转移过来:
<li>
(i-1, j-1, max_lcs)其中max_lcs表示a[0...i-1]和b[0...j-1]的最长公共子串长度;
</li>
<li>
(i-1, j, max_lcs)其中max_lcs表示a[0...i-1]和b[0...j]的最长公共子串长度;
</li>
<li>
(i, j-1, max_lcs)其中max_lcs表示a[0...i]和b[0...j-1]的最长公共子串长度。
</li>
如果我们把这个转移过程,用状态转移方程写出来,就是下面这个样子:
```
如果a[i]==b[j]那么max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1)+1, max_lcs(i-1, j), max_lcs(i, j-1))
如果a[i]!=b[j]那么max_lcs(i, j)就等于:
max(max_lcs(i-1,j-1), max_lcs(i-1, j), max_lcs(i, j-1))
其中max表示求三数中的最大值。
```
有了状态转移方程,代码实现就简单多了。我把代码贴到了下面,你可以对比着文字一块儿看。
```
public int lcs(char[] a, int n, char[] b, int m) {
int[][] maxlcs = new int[n][m];
for (int j = 0; j &lt; m; ++j) {//初始化第0行a[0..0]与b[0..j]的maxlcs
if (a[0] == b[j]) maxlcs[0][j] = 1;
else if (j != 0) maxlcs[0][j] = maxlcs[0][j-1];
else maxlcs[0][j] = 0;
}
for (int i = 0; i &lt; n; ++i) {//初始化第0列a[0..i]与b[0..0]的maxlcs
if (a[i] == b[0]) maxlcs[i][0] = 1;
else if (i != 0) maxlcs[i][0] = maxlcs[i-1][0];
else maxlcs[i][0] = 0;
}
for (int i = 1; i &lt; n; ++i) { // 填表
for (int j = 1; j &lt; m; ++j) {
if (a[i] == b[j]) maxlcs[i][j] = max(
maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]+1);
else maxlcs[i][j] = max(
maxlcs[i-1][j], maxlcs[i][j-1], maxlcs[i-1][j-1]);
}
}
return maxlcs[n-1][m-1];
}
private int max(int x, int y, int z) {
int maxv = Integer.MIN_VALUE;
if (x &gt; maxv) maxv = x;
if (y &gt; maxv) maxv = y;
if (z &gt; maxv) maxv = z;
return maxv;
}
```
## 解答开篇
今天的内容到此就讲完了,我们来看下开篇的问题。
当用户在搜索框内,输入一个拼写错误的单词时,我们就拿这个单词跟词库中的单词一一进行比较,计算编辑距离,将编辑距离最小的单词,作为纠正之后的单词,提示给用户。
这就是拼写纠错最基本的原理。不过,真正用于商用的搜索引擎,拼写纠错功能显然不会就这么简单。一方面,单纯利用编辑距离来纠错,效果并不一定好;另一方面,词库中的数据量可能很大,搜索引擎每天要支持海量的搜索,所以对纠错的性能要求很高。
针对纠错效果不好的问题,我们有很多种优化思路,我这里介绍几种。
<li>
我们并不仅仅取出编辑距离最小的那个单词而是取出编辑距离最小的TOP 10然后根据其他参数决策选择哪个单词作为拼写纠错单词。比如使用搜索热门程度来决定哪个单词作为拼写纠错单词。
</li>
<li>
我们还可以用多种编辑距离计算方法比如今天讲到的两种然后分别编辑距离最小的TOP 10然后求交集用交集的结果再继续优化处理。
</li>
<li>
我们还可以通过统计用户的搜索日志,得到最常被拼错的单词列表,以及对应的拼写正确的单词。搜索引擎在拼写纠错的时候,首先在这个最常被拼错单词列表中查找。如果一旦找到,直接返回对应的正确的单词。这样纠错的效果非常好。
</li>
<li>
我们还有更加高级一点的做法,引入个性化因素。针对每个用户,维护这个用户特有的搜索喜好,也就是常用的搜索关键词。当用户输入错误的单词的时候,我们首先在这个用户常用的搜索关键词中,计算编辑距离,查找编辑距离最小的单词。
</li>
针对纠错性能方面,我们也有相应的优化方式。我讲两种分治的优化思路。
<li>
如果纠错功能的TPS不高我们可以部署多台机器每台机器运行一个独立的纠错功能。当有一个纠错请求的时候我们通过负载均衡分配到其中一台机器来计算编辑距离得到纠错单词。
</li>
<li>
如果纠错系统的响应时间太长,也就是,每个纠错请求处理时间过长,我们可以将纠错的词库,分割到很多台机器。当有一个纠错请求的时候,我们就将这个拼写错误的单词,同时发送到这多台机器,让多台机器并行处理,分别得到编辑距离最小的单词,然后再比对合并,最终决定出一个最优的纠错单词。
</li>
真正的搜索引擎的拼写纠错优化,肯定不止我讲的这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法,剩下就靠你自己的灵活运用和实战操练了。
## 内容小结
动态规划的三节内容到此就全部讲完了,不知道你掌握得如何呢?
动态规划的理论尽管并不复杂,总结起来就是“一个模型三个特征”。但是,要想灵活应用并不简单。要想能真正理解、掌握动态规划,你只有多练习。
这三节中加上课后思考题总共有8个动态规划问题。这8个问题都非常经典是我精心筛选出来的。很多动态规划问题其实都可以抽象成这几个问题模型所以你一定要多看几遍多思考一下争取真正搞懂它们。
只要弄懂了这几个问题,一般的动态规划问题,你应该都可以应付。对于动态规划这个知识点,你就算是入门了,再学习更加复杂的就会简单很多。
## 课后思考
我们有一个数字序列包含n个不同的数字如何求出这个序列中的最长递增子序列长度比如2, 9, 3, 6, 5, 1, 7这样一组数字序列它的最长递增子序列就是2, 3, 5, 7所以最长递增子序列的长度是4。
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。