mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
91
极客时间专栏/检索技术核心20讲/基础技术篇/01 | 线性结构检索:从数组和链表的原理初窥检索本质.md
Normal file
91
极客时间专栏/检索技术核心20讲/基础技术篇/01 | 线性结构检索:从数组和链表的原理初窥检索本质.md
Normal file
@@ -0,0 +1,91 @@
|
||||
<audio id="audio" title="01 | 线性结构检索:从数组和链表的原理初窥检索本质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/39/e6f6433c3547520a8dd783fbdd709539.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到专栏的第一节,今天我们主要探讨的是,对于数组和链表这样的线性结构,我们是怎么检索的。希望通过这个探讨的过程,你能深入理解检索到底是什么。
|
||||
|
||||
你可以先思考一个问题:什么是检索?从字面上来理解,检索其实就是将我们所需要的信息,从存储数据的地方高效取出的一种技术。所以,检索效率和数据存储的方式是紧密联系的。具体来说,就是不同的存储方式,会导致不同的检索效率。那么,研究数据结构的存储特点对检索效率的影响就很有必要了。
|
||||
|
||||
那今天,我们就从数组和链表的存储特点入手,先来看一看它们是如何进行检索的。
|
||||
|
||||
## 数组和链表有哪些存储特点?
|
||||
|
||||
数组的特点相信你已经很熟悉了,就是用一块连续的内存空间来存储数据。那如果我申请不到连续的内存空间怎么办?这时候链表就可以派上用场了。链表可以申请不连续的空间,通过一个指针按顺序将这些空间串起来,形成一条链,**链表**也正是因此得名。不过,严格意义上来说,这个叫**单链表**。如果没有特别说明,下面我所提到的链表,指的都是只有一个后续指针的单链表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/fc/fffe3e8a77e14f253078727b06e1cafc.jpeg" alt="">
|
||||
|
||||
从图片中我们可以看出,**数组和链表分别代表了连续空间和不连续空间的最基础的存储方式,它们是线性表(Linear List)的典型代表。其他所有的数据结构,比如栈、队列、二叉树、B+树等,都不外乎是这两者的结合和变化**。以栈为例,它本质就是一个限制了读写位置的数组,特点是只允许后进先出。
|
||||
|
||||
因此,**我们只需要从最基础的数组和链表入手,结合实际应用中遇到的问题去思考解决方案,就能逐步地学习和了解更多的数据结构和检索技术。**
|
||||
|
||||
那么,数组和链表这两种线性的数据结构的检索效率究竟如何呢?我们来具体看一下。
|
||||
|
||||
## 如何使用二分查找提升数组的检索效率?
|
||||
|
||||
首先,如果数据是无序存储的话,无论是数组还是链表,想要查找一个指定元素是否存在,在缺乏数据分布信息的情况下,我们只能从头到尾遍历一遍,才能知道其是否存在。这样的检索效率就是O(n)。当然,如果数据集不大的话,其实直接遍历就可以了。但如果数据集规模较大的话,我们就需要考虑更高效的检索方式。
|
||||
|
||||
对于规模较大的数据集,我们往往是先将它通过排序算法转为有序的数据集,然后通过一些检索算法,比如**二分查找算法**来完成高效的检索。
|
||||
|
||||
二分查找也叫折半查找,它的思路很直观,就是将有序数组二分为左右两个部分,通过只在半边进行查找来提升检索效率。那二分查找具体是怎么实现的呢?让我们一起来看看具体的实现步骤。
|
||||
|
||||
我们首先会从中间的元素查起,这就会有三种查询结果。
|
||||
|
||||
第一种,是中间元素的值等于我们要查询的值。也就是,查到了,那直接返回即可。
|
||||
|
||||
如果中间元素的值小于我们想查询的值,那接下来该怎么查呢?这就是第二种情况了。数组是有序的,所以我们以中间元素为分隔,左半边的数组元素一定都小于中间元素,也就是小于我们想查询的值。因此,我们想查询的值只可能存在于右半边的数组中。
|
||||
|
||||
对于右半边的数组,我们还是可以继续使用二分查找的思路,再从它的中间查起,重复上面的过程。这样不停地“二分”下去,每次的检索空间都能减少一半,整体的平均查询效率就是O(log n),远远小于遍历整个数组的代价O(n)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/a5/6bc7fb93746164ab1deccdda35d5d1a5.jpeg" alt="">
|
||||
|
||||
同理,对于第三种情况,如果中间元素的值大于我们想查询的值,那么我们就只在左边的数组元素查找即可。
|
||||
|
||||
由此可见,合理地组织数据的存储可以提高检索效率。**检索的核心思路,其实就是通过合理组织数据,尽可能地快速减少查询范围。**在专栏后面的章节中,我们会看到更多的检索算法和技术,其实它们的本质都是通过灵活应用各种数据结构的特点来组织数据,从而达到快速减少查询范围的目的。
|
||||
|
||||
## 链表在检索和动态调整上的优缺点
|
||||
|
||||
前面我们说了,数据无序存储的话,链表的检索效率很低。那你可能要问了,有序的链表好像也没法儿提高检索效率啊,这是为什么呢?你可以先停下来自己思考一下,然后再看我下面的讲解。
|
||||
|
||||
数组的“连续空间存储”带来了可随机访问的特点。在有序数组应用二分查找时,它以O(1)的时间代价就可以直接访问到位于中间的数值,然后以中间的数值为分界线,只选择左边或右边继续查找,从而能快速缩小查询范围。
|
||||
|
||||
而链表并不具备“随机访问”的特点。当链表想要访问中间的元素时,我们必须从链表头开始,沿着链一步一步遍历过去,才能访问到期望的数值。如果要访问到中间的节点,我们就需要遍历一半的节点,时间代价已经是O(n/2)了。从这个方面来看,由于少了“随机访问位置”的特性,链表的检索能力是偏弱的。
|
||||
|
||||
但是,任何事情都有两面性,**链表的检索能力偏弱,作为弥补,它在动态调整上会更容易。**我们可以以O(1)的时间代价完成节点的插入和删除,这是“连续空间”的数组所难以做到的。毕竟如果我们要在有序的数组中插入一个元素,为了保证“数组有序”,我们就需要将数组中排在这个元素后面的元素,全部顺序后移一位,这其实是一个O(n)的时间代价了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/22/0491248d8fdbd4ed8c72e44d864b6222.jpeg" alt="">
|
||||
|
||||
因此,在一些需要频繁插入删除数据的场合,有序数组不见得是最合适的选择。另一方面,在数据量非常大的场合,我们也很难保证能申请到连续空间来构建有序数组。因此,学会合理高效地使用链表,也是非常重要的。
|
||||
|
||||
## 如何灵活改造链表提升检索效率?
|
||||
|
||||
**本质上,我们学习链表,就是在学习“非连续存储空间”的组织方案。**我们知道,对于“非连续空间”,可以用指针将它串联成一个整体。只要掌握了这个思想,我们就可以在不同的应用场景中,设计出适用的数据结构,而不需要拘泥于链表自身的结构限制。
|
||||
|
||||
我们可以来看一个简单的改造例子。
|
||||
|
||||
比如说,如果我们觉得链表一个节点一个节点遍历太慢,那么我们是不是可以对它做一个简单的改造呢?在掌握了链表的核心思想后,我们很容易就能想到一个改进方案,那就是让链表每个节点不再只是存储一个元素,而是存储一个小的数组。这样我们就能大幅减少节点的数量,从而减少依次遍历节点带来的“低寻址效率”。
|
||||
|
||||
比如说,我的链表就只有两个节点,每个节点都存储了一个小的有序数组。这样在检索的时候,我可以用二分查找的思想,先查询第一个节点存储的小数组的末尾元素,看看是否是我们要查询的数字。如果不是,我们要么在第一个节点存储的小数组里,继续二分查找;要么在第二个节点存储的小数组里,继续二分查找。这样的结构就能同时兼顾数组和链表的特点了,而且时间代价也是O(log n)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/89/36bea4dfd90c5fa94fa7067b8b193789.jpg" alt="">
|
||||
|
||||
可见,尽管常规的链表只能遍历检索,但是只要我们掌握了“非连续存储空间可以灵活调整”的特性,就可以设计更高效的数据结构和检索算法了。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,这一讲的内容差不多了,我们一起回顾一下这一讲的主要内容:以数组和链表为代表的线性结构的检索技术和效率分析。
|
||||
|
||||
首先,我们学习了具体的检索方法。对于无序数组,我们可以遍历检索。对于有序数组,我们可以用二分查找。链表具有灵活调整能力,适合用在数据频繁修改的场合。
|
||||
|
||||
其次,你应该也开始体会到了检索的一些核心思想:合理组织数据,尽可能快速减少查询范围,可以提升检索效率。
|
||||
|
||||
今天的内容其实不难,涉及的核心思想看起来也很简单,但是对于我们掌握检索这门技术非常重要,你一定要好好理解。
|
||||
|
||||
随着咱们的课程深入,后面我们会一一解锁更多高级的检索技术和复杂系统,但是核心思路都离不开我们今天所学的内容。
|
||||
|
||||
因此,从最基础的数组和链表入手,之后结合具体的问题去思考解决方案,这样可以帮助你一步一步建立起你的知识体系,从而更好地掌握检索原理,达到提高代码效率,提高系统设计能力的目的。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
结合今天学习的数组和链表的检索技术和效率分析,你可以思考一下这两个问题。
|
||||
|
||||
1. 对于有序数组的高效检索,我们为什么使用二分查找算法,而不是3-7分查找算法,或4-6分查找算法?
|
||||
1. 对于单个查询值k,我们已经熟悉了如何使用二分查找。那给出两个查询值x和y作为查询范围,如果要在有序数组中查找出大于x和小于y之间的所有元素,我们应该怎么做呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
106
极客时间专栏/检索技术核心20讲/基础技术篇/02 | 非线性结构检索:数据频繁变化的情况下,如何高效检索?.md
Normal file
106
极客时间专栏/检索技术核心20讲/基础技术篇/02 | 非线性结构检索:数据频繁变化的情况下,如何高效检索?.md
Normal file
@@ -0,0 +1,106 @@
|
||||
<audio id="audio" title="02 | 非线性结构检索:数据频繁变化的情况下,如何高效检索?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f4/45/f45275a57e8e134810821cfc4b509f45.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
当我们在电脑中查找文件的时候,我们一般习惯先打开相应的磁盘,再打开文件夹以及子文件夹,最后找到我们需要的文件。这其实就是一个检索路径。如果把所有的文件展开,这个查找路径其实是一个树状结构,也就是一个非线性结构,而不是一个所有文件平铺排列的线性结构。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/18/46/1859310bd112d5479eac9c097db8b946.jpeg" alt="">
|
||||
|
||||
我们都知道,有层次的文件组织肯定比散乱平铺的文件更容易找到。这样熟悉的一个场景,是不是会给你一个启发:对于零散的数据,非线性的树状结构是否可以帮我们提高检索效率呢?
|
||||
|
||||
另一方面,我们也知道,在数据频繁更新的场景中,连续存储的有序数组并不是最合适的存储方案。因为数组为了保持有序必须不停地重建和排序,系统检索性能就会急剧下降。但是,非连续存储的有序链表倒是具有高效插入新数据的能力。因此,我们能否结合上面的例子,使用非线性的树状结构来改造有序链表,让链表也具有二分查找的能力呢?今天,我们就来讨论一下这个问题。
|
||||
|
||||
## 树结构是如何进行二分查找的?
|
||||
|
||||
上一讲我们讲了,因为链表并不具备“随机访问”的特点,所以二分查找无法生效。当链表想要访问中间的元素时,我们必须从链表头开始,沿着指针一步一步遍历,需要遍历一半的节点才能到达中间节点,时间代价是O(n/2)。而有序数组由于可以“随机访问”,因此只需要O(1)的时间代价就可以访问到中间节点了。
|
||||
|
||||
那如果我们能在链表中以O(1)的时间代价快速访问到中间节点,是不是就可以和有序数组一样使用二分查找了?你先想想看该怎么做,然后我们一起来试着改造一下。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/ca/2c61d26ed919411dd9be1a94cefb30ca.jpg" alt="">
|
||||
|
||||
既然我们希望能以O(1)的时间代价访问中间节点,那将这个节点直接记录下来是不是就可以了?因此,如果我们把中间节点M拎出来单独记录,那我们的第一步操作就是直接访问这个中间节点,然后判断这个节点和要查找的元素是否相等。如果相等,则返回查询结果。如果节点元素大于要查找的元素,那我们就到左边的部分继续查找;反之,则在右边部分继续查找。
|
||||
|
||||
对于左边或者右边的部分,我们可以将它们视为两个独立的子链表,依然沿用这个逻辑。如果想用O(1)的时间代价就能访问这两个子链表的中间节点,我们就应该把左边的中间节点L和右边的中间节点R,单独拎出来记录。
|
||||
|
||||
并且,由于我们是在访问完了M节点以后,才决定接下来该去访问左边的L还是右边的R。因此,我们需要将L和M,R和M连接起来。我们可以让M带有两个指针,一个左指针指向L,一个右指针指向R。这样,在访问M以后,一旦发现M不是我们要查找的节点,那么,我们接下来就可以通过指针快速访问到L或者R了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/f4/d274bfacd98b00d82746cfeb838ec1f4.jpeg" alt="">
|
||||
|
||||
对于其余的节点,我们也可以进行同样的处理。下面这个结构,你是不是很熟悉?没错,这就是我们常见的二叉树。你可以再观察一下,这个二叉树和普通的二叉树有什么不一样?<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/bb/bf8df69285c21e28b493bd2f7a0c1abb.jpeg" alt="">
|
||||
|
||||
没错,这个二叉树是有序的。它的左子树的所有节点的值都小于根节点,同时右子树所有节点的值都大于等于根节点。这样的有序结构,使得它能使用二分查找算法,快速地过滤掉一半的数据。具备了这样特点的二叉树,就是二叉检索树(Binary Search Tree),或者叫二叉排序树(Binary Sorted Tree)。
|
||||
|
||||
讲到这里,不知道你有没有发现,**尽管有序数组和二叉检索树,在数据结构形态上看起来差异很大,但是在提高检索效率上,它们的核心原理都是一致的。**那么,它们是如何提高检索效率的呢?核心原理又一致在哪里呢?接下来,我们就从两个主要方面来看。
|
||||
|
||||
- 将数据有序化,并且根据数据存储的特点进行不同的组织。对于连续存储空间的数组而言,由于它具有“随机访问”的特性,因此直接存储即可;对于非连续存储空间的有序链表而言,由于它不具备“随机访问”的特性,因此,需要将它改造为可以快速访问到中间节点的树状结构。
|
||||
- 在进行检索的时候,它们都是通过二分查找的思想从中间节点开始查起。如果不命中,会快速缩小一半的查询空间。这样不停迭代的查询方式,让检索的时间代价能达到O(log n)这个级别。
|
||||
|
||||
说到这里,你可能会问,二叉检索树的检索时间代价一定是O(log n)吗?其实不一定。
|
||||
|
||||
## 二叉检索树的检索空间平衡方案
|
||||
|
||||
我们先来看一个例子。假设,一个二叉树的每一个节点的左指针都是空的,右子树的值都大于根节点。那么它满足二叉检索树的特性,是一颗二叉检索树。但是,如果我们把左边的空指针忽略,你会发现它其实就是一个单链表!单链表的检索效率如何呢?其实是O(n),而不是O(log n)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/eb/a9f61debcc5a5502f810b1c84ca682eb.jpeg" alt="">
|
||||
|
||||
为什么会出现这样的情况呢?
|
||||
|
||||
**最根本的原因是,这样的结构造成了检索空间不平衡。在当前节点不满足查询条件的时候,它无法把“一半的数据”过滤掉,而是只能过滤掉当前检索的这个节点。因此无法达到“快速减小查询范围”的目的。**
|
||||
|
||||
因此,为了提升检索效率,我们应该尽可能地保证二叉检索树的平衡性,让左右子树尽可能差距不要太大。这样无论我们是继续往左边还是右边检索,都可以过滤掉一半左右的数据。
|
||||
|
||||
也正是为了解决这个问题,有更多的数据结构被发明了出来。比如:AVL树(平衡二叉树)和红黑树,其实它们本质上都是二叉检索树,但它们都在保证左右子树差距不要太大上做了特殊的处理,保证了检索效率,让二叉检索树可以被广泛地使用。比如,我们常见的C++中的Set和Map等数据结构,底层就是用红黑树实现的。
|
||||
|
||||
这里,我就不再详细介绍AVL树和红黑树的具体实现了。为了保证检索效率,我们其实只需要在数据的组织上考虑检索空间的平衡划分就好了,这一点都是一样的。
|
||||
|
||||
## 跳表是如何进行二分查找的?
|
||||
|
||||
除了二叉检索树,有序链表还有其他快速访问中间节点的改造方案吗?我们知道,链表之所以访问中间节点的效率低,就是因为每个节点只存储了下一个节点的指针,要沿着这个指针遍历每个后续节点才能到达中间节点。那如果我们在节点上增加一个指针,指向更远的节点,比如说跳过后一个节点,直接指向后面第二个节点,那么沿着这个指针遍历,是不是遍历速度就翻倍了呢?
|
||||
|
||||
同理,如果我们能增加更多的指针,提供不同步长的遍历能力,比如一次跳过4个节点,甚至一半的节点,那我们是不是就可以更快速地访问到中间节点了呢?
|
||||
|
||||
这当然是可以实现的。我们可以为链表的某些节点增加更多的指针。这些指针都指向不同距离的后续节点。这样一来,链表就具备了更高效的检索能力。这样的数据结构就是**跳表**(Skip List)。
|
||||
|
||||
一个理想的跳表,就是从链表头开始,用多个不同的步长,每隔2^n个节点做一次直接链接(n取值为0,1,2……)。跳表中的每个节点都拥有多个不同步长的指针,我们可以在每个节点里,用一个数组next来记录这些指针。next数组的大小就是这个节点的层数,next[0]就是第0层的步长为1的指针,next[1]就是第1层的步长为2的指针,next[2]就是第2层的步长为4的指针,依此类推。你会发现,不同步长的指针,在链表中的分布是非常均匀的,这使得整个链表具有非常平衡的检索结构。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/77/bbae24216d975a014b6112dbce45ae77.jpg" alt="">
|
||||
|
||||
举个例子,当我们要检索k=a<sub>6</sub>时,从第一个节点a<sub>1</sub>开始,用最大步长的指针开始遍历,直接就可以访问到中间节点a<sub>5</sub>。但是,如果沿着这个最大步长指针继续访问下去,下一个节点是大于k的a<sub>9</sub>,这说明k在a<sub>5</sub>和a<sub>9</sub>之间。那么,我们就在a<sub>5</sub>和a<sub>9</sub>之间,用小一个级别的步长继续查询。这时候,a<sub>5</sub>的下一个元素是a<sub>7</sub>,a<sub>7</sub>依然大于k的值,因此,我们会继续在a<sub>5</sub>和a<sub>7</sub>之间,用再小一个级别的步长查找,这样就找到a<sub>6</sub>了。这个过程其实就是二分查找。时间代价是O(log n)。
|
||||
|
||||
## 跳表的检索空间平衡方案
|
||||
|
||||
不知道你有没有注意到,我在前面强调了一个词,那就是“理想的跳表”。为什么要叫它“理想”的跳表呢?难道在实际情况下,跳表不是这样实现的吗?的确不是。当我们要在跳表中插入元素时,节点之间的间隔距离就被改变了。如果要保证理想链表的每隔2^n个节点做一次链接的特性,我们就需要重新修改许多节点的后续指针,这会带来很大的开销。
|
||||
|
||||
所以,在实际情况下,我们会在检索性能和修改指针代价之间做一个权衡。为了保证检索性能,我们不需要保证跳表是一个“理想”的平衡状态,只需要保证它在大概率上是平衡的就可以了。因此,当新节点插入时,我们不去修改已有的全部指针,而是仅针对新加入的节点为它建立相应的各级别的跳表指针。具体的操作过程,我们一起来看看。
|
||||
|
||||
首先,我们需要确认新加入的节点需要具有几层的指针。我们通过随机函数来生成层数,比如说,我们可以写一个函数RandomLevel(),以(1/2)^n的概率决定是否生成第n层。这样,通过简单的随机生成指针层数的方式,我们就可以保证指针的分布,在大概率上是平衡的。
|
||||
|
||||
在确认了新节点的层数n以后,接下来,我们需要将新节点和前后的节点连接起来,也就是为每一层的指针建立前后连接关系。其实每一层的指针链接,你都可以看作是一个独立的单链表的修改,因此我们只需要用单链表插入节点的方式完成指针连接即可。
|
||||
|
||||
这么说,可能你理解起来不是很直观,接下来,我通过一个具体的例子进一步给你解释一下。
|
||||
|
||||
我们要在一个最高有3层指针的跳表中插入一个新元素k,这个跳表的结构如下图所示。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/42/dd4a8d2cfc40d4825dc5951ddcce2442.jpg" alt=""><br>
|
||||
假设我们通过跳表的检索已经确认了,k应该插入到a<sub>6</sub>和a<sub>7</sub>两个节点之间。那接下来,我们要先为新节点随机生成一个层数。假设生成的层数为2,那我们就要修改第0层和第1层的指针关系。对于第0层的链表,k需要插入到a<sub>6</sub>和a<sub>7</sub>之间,我们只需要修改a<sub>6</sub>和a<sub>7</sub>的第0层指针;对于第1层的链表,k需要插入到a<sub>5</sub>和a<sub>7</sub>之间,我们只需要修改a<sub>5</sub>和a<sub>7</sub>的第1层指针。这样,我们就完成了将k插入到跳表中的动作。
|
||||
|
||||
通过这样一种方式,我们可以大大减少修改指针的代价。当然,由于新加入节点的层数是随机生成的,因此在节点数目较少的情况下,如果指针分布的不合理,检索性能依然可能不高。但是当节点数较多的时候,指针会趋向均匀分布,查找空间会比较平衡,检索性能会趋向于理想跳表的检索效率,接近O(log n)。
|
||||
|
||||
因此,相比于复杂的平衡二叉检索树,如红黑树,跳表用一种更简单的方式实现了检索空间的平衡。并且,由于跳表保持了链表顺序遍历的能力,在需要遍历功能的场景中,跳表会比红黑树用起来更方便。这也就是为什么,在Redis这样的系统中,我们经常会利用跳表来代替红黑树作为底层的数据结构。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,关于非线性结构的检索技术,我们就先讲到这里。我们一起回顾一下今天的重点内容。
|
||||
|
||||
首先,对于数据频繁变化的应用场景,有序数组并不是最适合的解决方案。我们一般要考虑采用非连续存储的数据结构来灵活调整。同时,为了提高检索效率,我们还要采取合理的组织方式,让这些非连续存储的数据结构能够使用二分查找算法。
|
||||
|
||||
数据组织的方式有两种,一种是二叉检索树。一个平衡的二叉检索树使用二分查找的检索效率是O(log n),但如果我们不做额外的平衡控制的话,二叉检索树的检索性能最差会退化到O(n),也就和单链表一样了。所以,AVL树和红黑树这样平衡性更强的二叉检索树,在实际工作中应用更多。
|
||||
|
||||
除了树结构以外,另一种数据组织方式是跳表。跳表也具备二分查找的能力,理想跳表的检索效率是O(log n)。为了保证跳表的检索空间平衡,跳表为每个节点随机生成层级,这样的实现方式比AVL树和红黑树更简单。
|
||||
|
||||
无论是二叉检索树还是跳表,它们都是通过将数据进行合理组织,然后尽可能地平衡划分检索空间,使得我们能采用二分查找的思路快速地缩减查找范围,达到O(log n)的检索效率。
|
||||
|
||||
除此之外,我们还能发现,当我们从实际问题出发,去思考每个数据结构的特点以及解决方案时,我们就会更好地理解一些高级数据结构和算法的来龙去脉,从而达到更深入地理解和吸收知识的目的。并且,这种思考方式,会在不知不觉中提升你的设计能力以及解决问题的能力。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
今天的内容比较多,你可以结合我留的课堂讨论题,加深理解。
|
||||
|
||||
二叉检索树和跳表都能做到O(log n)的查询时间代价,还拥有灵活的调整能力,并且调整代价也是O(log n)(包括了寻找插入位置的时间代价)。而有序数组的查询时间代价也是O(log n),调整代价是O(n),那这是不是意味着二叉检索树或者跳表可以用来替代有序数组呢?有序数组自己的优势又是什么呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
102
极客时间专栏/检索技术核心20讲/基础技术篇/03 | 哈希检索:如何根据用户ID快速查询用户信息?.md
Normal file
102
极客时间专栏/检索技术核心20讲/基础技术篇/03 | 哈希检索:如何根据用户ID快速查询用户信息?.md
Normal file
@@ -0,0 +1,102 @@
|
||||
<audio id="audio" title="03 | 哈希检索:如何根据用户ID快速查询用户信息?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/c0/315a6f1c5030e9adc742c24fde24d3c0.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在实际应用中,我们经常会面临需要根据键(Key)来查询数据的问题。比如说,给你一个用户ID,要求你查出该用户的具体信息。这样的需求我们应该如何实现呢?你可能会想到,使用有序数组和二叉检索树都可以来实现。具体来说,我们可以将用户ID和用户信息作为一个整体的元素,然后以用户ID作为Key来排序,存入有序数组或者二叉检索树中,这样我们就能通过二分查找算法快速查询到用户信息了。
|
||||
|
||||
但是,不管是有序数组、二叉检索树还是跳表,它们的检索效率都是O(log n)。那有没有更高效的检索方案呢?也就是说,有没有能实现O(1)级别的查询方案呢?今天,我们就一起来探讨一下这个问题。
|
||||
|
||||
## 使用Hash函数将Key转换为数组下标
|
||||
|
||||
在第1讲中我们说过,数组具有随机访问的特性。那给定一个用户ID,想要查询对应的用户信息,我们能否利用数组的随机访问特性来实现呢?
|
||||
|
||||
我们先来看一个例子。假设系统中的用户ID是从1开始的整数,并且随着注册数的增加而增加。如果系统中的用户数是有限的,不会大于10万。那么用户的ID范围就会被固定在1到10万之间。在数字范围有限的情况下,我们完全可以申请一个长度为10万的数组,然后将用户ID作为数组下标,从而实现O(1)级别的查询能力。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/cf/bb7ac50d85287e55dde85490a02080cf.jpg" alt="">
|
||||
|
||||
注意,刚才我们举的这个例子中有一个假设:用户的ID是一个数字,并且范围有限。符合这种假设的用户ID才能作为数组下标,使用数组的随机访问特性,达到O(1)时间代价的高效检索能力。那如果用户的ID数字范围很大,数组无法申请这么大的空间该怎么办呢?或者,用户的ID不是数字而是字符串,还能作为数组下标吗?
|
||||
|
||||
我们假设有一个系统使用字符串作为用户ID。如果有一个用户的ID是“tom”,我们该怎么处理呢?我们能否将它转换为一个数字来表示呢?你可以先想一想解决方案,再和我继续往下分析。
|
||||
|
||||
我们来考虑这样一种方案:字母表是有限的,只有26个,我们可以用字母在字母表中的位置顺序作为数值。于是,就有:“t” = 20,“o” = 15,“m” = 13。我们可以把这个ID看作是26进制的数字,那么对于“tom”这个字符串,把它转为对应的数值就是20 * 26^2 + 15*26 + 13 =149123,这是一个小于26^4 = 456976的数。
|
||||
|
||||
如果所有用户的ID都不超过3个字符,使用这个方法,我们用一个可以存储456976个元素的数组就可以存储所有用户的信息了。实际上,工业界有许多更复杂的将字符串转为整数的哈希算法,但核心思想都是利用每个字符的编码和位置信息进行计算,这里我就不展开了。
|
||||
|
||||
那如果内存空间有限,我们只能开辟一个存储10000个元素的数组该怎么办呢?这个时候,我们可以使用“tom”对应的数值149123除以数组长度10000,得到余数9123,用这个余数作为数组下标。
|
||||
|
||||
这种将对象转为有限范围的正整数的表示方法,就叫作**Hash**,翻译过来叫**散列**,也可以直接音译为**哈希**。而我们常说的Hash函数就是指具体转换方法的函数。我们将对象进行Hash,用得到的Hash值作为数组下标,将对应元素存在数组中。这个数组就叫作**哈希表**。这样我们就可以利用数组的随机访问特性,达到O(1)级别的查询性能。
|
||||
|
||||
说到这里,你可能会有疑问了,Hash函数真的这么神奇吗?如果两个对象的哈希值是相同的怎么办?事实上,任何Hash函数都有可能造成对象不同,但Hash值相同的冲突。而且,数组空间是有限的,只要被Hash的元素个数大于数组上限,就一定会产生冲突。
|
||||
|
||||
对于哈希冲突这个问题,我们有两类解决方案: 一类是构造尽可能理想的Hash函数,使得Hash以后得到的数值尽可能平均分布,从而减少冲突发生的概率;另一类是在冲突发生以后,通过“提供冲突解决方案”来完成存储和查找。最常用的两种冲突解决方案是“开放寻址法”和“链表法”。下面,我就来介绍一下这两种方法,并且重点看看它们对检索效率的影响。
|
||||
|
||||
## 如何利用开放寻址法解决Hash冲突?
|
||||
|
||||
所谓“开放寻址法”,就是在冲突发生以后,最新的元素需要寻找新空闲的数组位置完成插入。那我们该如何寻找新空闲的位置呢?我们可以使用一种叫作“线性探查”(Linear Probing)的方案来进行查找。
|
||||
|
||||
“线性探查”的插入逻辑很简单:在当前位置发现有冲突以后,就顺序去查看数组的下一个位置,看看是否空闲。如果有空闲,就插入;如果不是空闲,再顺序去看下一个位置,直到找到空闲位置插入为止。
|
||||
|
||||
查询逻辑也和插入逻辑相似。我们先根据Hash值去查找相应数组下标的元素,如果该位置不为空,但是存储元素的Key和查询的Key不一致,那就顺序到数组下一个位置去检索,就这样依次比较Key。如果访问元素的Key和查询Key相等,我们就在哈希表中找到了对应元素;如果遍历到空闲处,依然没有元素的Key和查询Key相等,则说明哈希表中不存在该元素。
|
||||
|
||||
为了帮助你更好地理解,我们来看一个例子。
|
||||
|
||||
假设一个哈希表中已经插入了两个Key,key1和key2。其中Hash(key1) = 1, Hash(key2) = 2。这时,如果我们要插入一个Hash值为1的key3。根据线性探查的插入逻辑,通过3步,我们就可以将key3插入到哈希表下标为3的位置中。插入的过程如下:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8b/d0/8b0de808f6485bde014019e9d158b0d0.jpg" alt=""><br>
|
||||
在查找key3的时候,因为Hash(key3)= 1,我们会从哈希表下标为1的位置开始顺序查找,经过3步找到key3,查询结束。
|
||||
|
||||
讲到这里,你可能已经发现了一个问题:当我们插入一个Key时,如果哈希表已经比较满了,这个Key就会沿着数组一直顺序遍历,直到遇到空位置才会成功插入。查询的时候也一样。但是,顺序遍历的代价是O(n),这样的检索性能很差。
|
||||
|
||||
更糟糕的是,如果我们在插入key1后,先插入key3再插入key2,那key3就会抢占key2的位置,影响key2的插入和查询效率。**因此,“线性探查”会影响哈希表的整体性能,而不只是Hash值冲突的Key**。
|
||||
|
||||
为了解决这个问题,我们可以使用“二次探查”(Quadratic Probing)和“双散列”(Double Hash)这两个方法进行优化。下面,我来分别解释一下这两个方法的优化原理。
|
||||
|
||||
二次探查就是将线性探查的步长从i改为i^2:第一次探查,位置为Hash(key) + 1^2;第二次探查,位置为Hash(key) +2^2;第三次探查,位置为Hash(key) + 3^2,依此类推。
|
||||
|
||||
双散列就是使用多个Hash函数来求下标位置,当第一个Hash函数求出来的位置冲突时,启用第二个Hash函数,算出第二次探查的位置;如果还冲突,则启用第三个Hash函数,算出第三次探查的位置,依此类推。
|
||||
|
||||
无论是二次探查还是双散列,核心思路其实都是在发生冲突的情况下,将下个位置尽可能地岔开,让数据尽可能地随机分散存储,来降低对不相干Key的干扰,从而提高整体的检索效率。
|
||||
|
||||
但是,对于开放寻址法来说,无论使用什么优化方案,随着插入的元素越多、哈希表越满,插入和检索的性能也就下降得越厉害。在极端情况下,当哈希表被写满的时候,为了保证能插入新元素,我们只能重新生成一个更大的哈希表,将旧哈希表中的所有数据重新Hash一次写入新的哈希表,也就是**Re-Hash**,这样会造成非常大的额外开销。因此,在数据动态变化的场景下,使用开放寻址法并不是最合适的方案。
|
||||
|
||||
## 如何利用链表法解决Hash冲突?
|
||||
|
||||
相比开放寻址法,还有一种更常见的冲突解决方案,链表法。所谓“链表法”,就是在数组中不存储一个具体元素,而是存储一个链表头。如果一个Key经过Hash函数计算,得到了对应的数组下标,那么我们就将它加入该位置所存的链表的尾部。
|
||||
|
||||
这样做的好处是,如果key3和key1发生了冲突,那么key3会通过链表的方式链接在key1的后面,而不是去占据key2的位置。这样当key2插入时,就不会有冲突了。最终效果如下图。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b9/07/b91d6e394af24935f67dc21293bc0c07.jpg" alt="">
|
||||
|
||||
讲到这里,你可能已经发现了,其实链表法就是将我们前面讲过的数组和链表进行结合,既利用了数组的随机访问特性,又利用了链表的动态修改特性,同时提供了快速查询和动态修改的能力。
|
||||
|
||||
想要查询时,我们会先根据查询Key的Hash值,去查找相应数组下标的链表。如果链表为空,则表示不存在该元素;如果链表不为空,则遍历链表,直到找到Key相等的对应元素为止。
|
||||
|
||||
但是,如果链表很长,遍历代价还是会很高。那我们有没有更好的检索方案呢?你可以回想一下,在上一讲中我们就是用二叉检索树或跳表代替链表,来提高检索效率的。
|
||||
|
||||
实际上,在JDK1.8 之后,Java中HashMap的实现就是在链表到了一定的长度时,将它转为红黑树;而当红黑树中的节点低于一定阈值时,就将它退化为链表。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/f2/8c5c5054e92ec24de3bde1ca15946af2.jpg" alt="">
|
||||
|
||||
第一个阶段,通过Hash函数将要查询的Key转为数组下标,去查询对应的位置。这个阶段的查询代价是O(1)级别。
|
||||
|
||||
第二阶段,将数组下标所存的链表头或树根取出。如果是链表,就使用遍历的方式查找,这部分查询的时间代价是O(n)。由于链表长度是有上限的,因此实际开销并不会很大,可以视为常数级别时间。如果是红黑树,则用二分查找的方式去查询,时间代价是O(log n)。如果哈希表中冲突的元素不多,那么落入红黑树的数据规模也不会太大,红黑树中的检索代价也可以视为常数级别时间。
|
||||
|
||||
## 哈希表有什么缺点?
|
||||
|
||||
哈希表既有接近O(1)的检索效率,又能支持动态数据的场景,看起来非常好,那是不是在任何场景下,我们都可以用它来代替有序数组和二叉检索树呢?答案是否定的。前面我们说了这么多哈希表的优点,下面我们就来讲讲它的缺点。
|
||||
|
||||
首先,哈希表接近O(1)的检索效率是有前提条件的,就是哈希表要足够大和有足够的空闲位置,否则就会非常容易发生冲突。我们一般用**装载因子(load factor)**来表示哈希表的填充率。装载因子 = 哈希表中元素个数/哈希表的长度。
|
||||
|
||||
如果频繁发生冲突,大部分的数据会被持续地添加到链表或二叉检索树中,检索也会发生在链表或者二叉检索树中,这样检索效率就会退化。因此,为了保证哈希表的检索效率,我们需要预估哈希表中的数据量,提前生成足够大的哈希表。按经验来说,我们一般要预留一半以上的空闲位置,哈希表才会有足够优秀的检索效率。这就让哈希表和有序数组、二叉检索树相比,需要的存储空间更多了。
|
||||
|
||||
另一方面,尽管哈希表使用Hash值直接进行下标访问,带来了O(1)级别的查询能力,但是也失去了“有序存储”这个特点。因此,如果我们的查询场景需要遍历数据,或者需要进行范围查询,那么哈希表本身是没有什么加速办法的。比如说,如果我们在一个很大的哈希表中存储了少数的几个元素,为了知道存储了哪些元素,我们只能从哈希表的第一个位置开始遍历,直到结尾,这样性能并不好。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,关于哈希检索我们就讲到这里。你会看到,哈希表的本质是一个数组,它通过Hash函数将查询的Key转为数组下标,利用数组的随机访问特性,使得我们能在O(1)的时间代价内完成检索。
|
||||
|
||||
尽管哈希检索没有使用二分查找,但无论是设计理想的哈希函数,还是保证哈希表有足够的空闲位置,包括解决冲突的“二次探查”和“双散列”方案,本质上都是希望数据插入哈希表的时候,分布能均衡,这样检索才能更高效。从这个角度来看,其实哈希检索提高检索效率的原理,和二叉检索树需要平衡左右子树深度的原理是一样的,也就是说,高效的检索需要均匀划分检索空间。
|
||||
|
||||
另一方面,你会看到,复杂的数据结构和检索算法其实都是由最基础的数据结构和算法组成的。比如说JDK1.8中哈希表的实现,就是使用了数组、链表、红黑树这三种数据结构和相应的检索算法。因此,对于这些基础的数据结构,我们需要深刻地理解它们的检索原理和适用场景,这也为我们未来学习更复杂的系统打下了扎实的基础。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
假设一个哈希表是使用开放寻址法实现的,如果我们需要删除其中一个元素,可以直接删除吗?为什么呢?如果这个哈希表是使用链表法实现的会有不同吗?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
78
极客时间专栏/检索技术核心20讲/基础技术篇/04 | 状态检索:如何快速判断一个用户是否存在?.md
Normal file
78
极客时间专栏/检索技术核心20讲/基础技术篇/04 | 状态检索:如何快速判断一个用户是否存在?.md
Normal file
@@ -0,0 +1,78 @@
|
||||
<audio id="audio" title="04 | 状态检索:如何快速判断一个用户是否存在?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/4c/fe11ae7ec5050fbf9f809fbd17ef8f4c.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在实际工作中,我们经常需要判断一个对象是否存在。比如说,在注册新用户时,我们需要先快速判断这个用户ID是否被注册过;再比如说,在爬虫系统抓取网页之前,我们要判断一个URL是否已经被抓取过,从而避免无谓的、重复的抓取工作。
|
||||
|
||||
那么,对于这一类是否存在的状态检索需求,如果直接使用我们之前学习过的检索技术,有序数组、二叉检索树以及哈希表来实现的话,它们的检索性能如何呢?是否还有优化的方案呢?今天,我们就一起来讨论一下这些问题。
|
||||
|
||||
## 如何使用数组的随机访问特性提高查询效率?
|
||||
|
||||
以注册新用户时查询用户ID是否存在为例,我们可以直接使用有序数组、二叉检索树或者哈希表来存储所有的用户ID。
|
||||
|
||||
我们知道,无论是有序数组还是二叉检索树,它们都是使用二分查找的思想从中间元素开始查起的。所以,在查询用户ID是否存在时,它们的平均检索时间代价都是O(log n),而哈希表的平均检索时间代价是O(1)。因此,如果我们希望能快速查询出元素是否存在,那哈希表无疑是最合适的选择。不过,如果从工程实现的角度来看的话,哈希表的查询过程还是可以优化的。
|
||||
|
||||
比如说,如果我们要查询的对象ID本身是正整数类型,而且ID范围有上限的话。我们就可以申请一个足够大的数组,让数组的长度超过ID的上限。然后,把数组中所有位置的值都初始化为0。对于存在的用户,我们**直接将用户ID的值作为数组下标**,将该位置的值从0设为1就可以了。
|
||||
|
||||
这种情况下,当我们查询一个用户ID是否存在时,会直接以该ID为数组下标去访问数组,如果该位置为1,说明该ID存在;如果为0,就说明该ID不存在。和哈希表的查找流程相比,这个流程就节省了计算哈希值得到数组下标的环节,并且直接利用数组随机访问的特性,在O(1)的时间内就能判断出元素是否存在,查询效率是最高的。
|
||||
|
||||
但是,直接使用ID作为数组下标会有一个问题:如果ID的范围比较广,比如说在10万之内,那我们就需要保证数组的长度大于10万。所以,这种方案的占用空间会很大。
|
||||
|
||||
而且,如果这个数组是一个int 32类型的整型数组,那么每个元素就会占据4个字节,用4个字节来存储0和1会是一个巨大的空间浪费。那我们该如何优化呢?你可以先想一想,然后我们一起来讨论。
|
||||
|
||||
## 如何使用位图来减少存储空间?
|
||||
|
||||
最直观的一个想法就是,使用最少字节的类型来定义数组。比如说,使用1个字节的char类型数组,或者使用bool类型的数组(在许多系统中,一个bool类型的元素也是1个字节)。它们和4个字节的int 32数组相比,空间使用效率提升了4倍,这已经算是不错的改善了。
|
||||
|
||||
但是,使用char类型的数组,依然是一个非常“浪费空间”的方案。因为表示0或者1,理论上只需要一个bit。所以,如果我们能以bit为单位来构建这个数组,那使用空间就是int 32数组的1/32,从而大幅减少了存储使用的内存空间。这种以bit为单位构建数组的方案,就叫作**Bitmap**,翻译为**位图**。
|
||||
|
||||
位图的优势非常明显,但许多系统中并没有以bit为单位的数据类型。因此,我们往往需要对其他类型的数组进行一些转换设计,使其能对相应的bit位的位置进行访问,从而实现位图。
|
||||
|
||||
我们以char类型的数组为例子。假设我们申请了一个1000个元素的char类型数组,每个char元素有8个bit,如果一个bit表示一个用户,那么1000个元素的char类型数组就能表示8*1000 = 8000个用户。如果一个用户的ID是11,那么位图中的第11个bit就表示这个用户是否存在的信息。
|
||||
|
||||
这种情况下,我们怎么才能快速访问到第11个bit呢?
|
||||
|
||||
首先,数组是以char类型的元素为一个单位的,因此,我们的第一步,就是要找到第11个bit在数组的第几个元素里。具体的计算过程:一个元素占8个bit,我们用11除以8,得到的结果是1,余数是3。这就代表着,第11个bit存在于第2个元素里,并且在第2个元素里的位置是第3个。
|
||||
|
||||
对于第2个元素的访问,我们直接使用数组下标[1]就可以在O(1)的时间内访问到。对于第2个元素中的第3个bit的访问,我们可以通过位运算,先构造一个二进制为00100000的字节(字节的第3位为1),然后和第2个元素做and运算,就能得知该元素的第3位是1还是0。这也是一个时间代价为O(1)的操作。这样一来,通过两次O(1)时间代价的查找,我们就可以知道第11个bit的值是0还是1了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/70/85/7003f942bc4626ae74fd66badbb21f85.jpg" alt="">
|
||||
|
||||
尽管位图相对于原始数组来说,在元素存储上已经有了很大的优化,但如果我们还想进一步优化存储空间,是否还有其他的优化方案呢?我们知道,**一个数组所占的空间其实就是“数组元素个数*每个元素大小”**。我们已经将每个元素大小压缩到了最小单位1个bit,如果还要进行优化,那么自然会想到优化“数组元素个数”。
|
||||
|
||||
没错,限制数组的长度是一个可行的方案。不过前面我们也说了,数组长度必须大于ID的上限。因此,如果我们希望将数组长度压缩到一个足够小的值之内,我们就需要使用哈希函数将大于数组长度的用户ID,转换为一个小于数组长度的数值作为下标。除此以外,使用哈希函数也带来了另一个优点,那就是我们不需要把用户ID限制为正整数了,它也可以是字符串。这样一来,压缩数组长度,并使用哈希函数,就是一个更加通用的解决方案。
|
||||
|
||||
但是我们也知道,数组压缩得越小,发生哈希冲突的可能性就会越大,如果两个元素A和B的哈希值冲突了,映射到了同一个位置。那么,如果我们查询A时,该位置的结果为1,其实并不能说明元素A一定存在。因此,如何在数组压缩的情况下缓解哈希冲突,保证一定的查询正确率,是我们面临的主要问题。
|
||||
|
||||
在第3讲中,我们讲了哈希表解决哈希冲突的两种常用方法:开放寻址法和链表法。开放寻址法中有一个优化方案叫“双散列”,它的原理是使用多个哈希函数来解决冲突问题。我们能否借鉴这个思想,在位图的场景下使用多个哈希函数来降低冲突概率呢?没错,这其实就是布隆过滤器(Bloom Filter)的设计思想。
|
||||
|
||||
布隆过滤器最大的特点,就是对一个对象使用多个哈希函数。如果我们使用了k个哈希函数,就会得到k个哈希值,也就是k个下标,我们会把数组中对应下标位置的值都置为1。布隆过滤器和位图最大的区别就在于,我们不再使用一位来表示一个对象,而是使用k位来表示一个对象。这样两个对象的k位都相同的概率就会大大降低,从而能够解决哈希冲突的问题了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/08/cb/089de1531a75731a657ae2c6e55c55cb.jpg" alt="">
|
||||
|
||||
但是,布隆过滤器的查询有一个特点,就是即使任何两个元素的哈希值不冲突,而且我们查询对象的k个位置的值都是1,查询结果为存在,这个结果也可能是错误的。这就叫作**布隆过滤器的错误率**。
|
||||
|
||||
我在下图给出了一个例子。我们可以看到,布隆过滤器中存储了x和y两个对象,它们对应的bit位被置为1。当我们查询一个不存在的对象z时,如果z的k个哈希值的对应位置的值正好都是1,z就会被错误地认定为存在。而且,这个时候,z和x,以及z和y,两两之间也并没有发生哈希冲突。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/26/7f9a98a2e877b298c0be5b5c7b8a5626.jpg" alt="">
|
||||
|
||||
那遇到“可能存在”这样的情况,我们该怎么办呢?不要忘了我们的使用场景:我们希望用更小的代价快速判断ID是否已经被注册了。在这个使用场景中,就算我们无法确认ID是否已经被注册了,让用户再换一个ID注册,这也不会损害新用户的体验。在系统不要求结果100%准确的情况下,我们可以直接当作这个用户ID已经被注册了就可以了。这样,我们使用布隆过滤器就可以快速完成“是否存在”的检索。
|
||||
|
||||
除此之外,对于布隆过滤器而言,如果哈希函数的个数不合理,比如哈希函数特别多,布隆过滤器的错误率就会变大。因此,除了使用多个哈希函数避免哈希冲突以外,我们还要控制布隆过滤器中哈希函数的个数。有这样一个**计算最优哈希函数个数的数学公式: 哈希函数个数k = (m/n) * ln(2)**。其中m为bit数组长度,n为要存入的对象的个数。实际上,如果哈希函数个数为1,且数组长度足够,布隆过滤器就可以退化成一个位图。所以,我们可以认为“**位图是只有一个特殊的哈希函数,且没有被压缩长度的布隆过滤器**”。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,状态检索的内容我们就讲到这里。我们一起来总结一下,这一讲你要掌握的重点内容。
|
||||
|
||||
今天,我们主要解决了快速判断一个对象是否存在的问题。相比于有序数组、二叉检索树和哈希表这三种方案,位图和布隆过滤器其实更适合解决这类状态检索的问题。这是因为,在不要求100%判断正确的情况下,使用位图和布隆过滤器可以达到O(1)时间代价的检索效率,同时空间使用率也非常高效。
|
||||
|
||||
虽然位图和布隆过滤器的原理和实现都非常简单,但是在许多复杂的大型系统中都可以见到它们的身影。
|
||||
|
||||
比如,存储系统中的数据是存储在磁盘中的,而磁盘中的检索效率非常低,因此,我们往往会先使用内存中的布隆过滤器来快速判断数据是否存在,不存在就直接返回,只有可能存在才会去磁盘检索,这样就避免了为无效数据读取磁盘的额外开销。
|
||||
|
||||
再比如,在搜索引擎中,我们也需要使用布隆过滤器快速判断网站是否已经被抓取过,如果一定不存在,我们就直接去抓取;如果可能存在,那我们可以根据需要,直接放弃抓取或者再次确认是否需要抓取。**你会发现,这种快速预判断的思想,也是提高应用整体检索性能的一种常见设计思路。**
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
这节课的内容,你可以结合这道讨论题进一步加深理解:
|
||||
|
||||
如果位图中一个元素被删除了,我们可以将对应bit位置为0。但如果布隆过滤器中一个元素被删除了,我们直接将对应的k个bit位置为0,会产生什么样的问题呢?为什么?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
@@ -0,0 +1,71 @@
|
||||
<audio id="audio" title="05 | 倒排索引:如何从海量数据中查询同时带有“极”和“客”的唐诗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/98/8b8d0c984acc1758e6f367683678c998.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
试想这样一个场景:假设你已经熟读唐诗300首了。这个时候,如果我给你一首诗的题目,你可以马上背出这首诗的内容吗?相信你一定可以的。但是如果我问你,有哪些诗中同时包含了“极”字和“客”字?你就不见得能立刻回答出来了。你需要在头脑中一首诗一首诗地回忆,并判断每一首诗的内容是否同时包含了“极”字和“客”字。很显然,第二个问题的难度比第一个问题大得多。
|
||||
|
||||
那从程序设计的角度来看,这两个问题对应的检索过程又有什么不同呢?今天,我们就一起来聊一聊,两个非常常见又非常重要的检索技术:正排索引和倒排索引。
|
||||
|
||||
## 什么是倒排索引?
|
||||
|
||||
我们先来看比较简单的那个问题:给出一首诗的题目,马上背出内容。这其实就是一个典型的键值查询场景。针对这个场景,我们可以给每首诗一个唯一的编号作为ID,然后使用哈希表将诗的ID作为键(Key),把诗的内容作为键对应的值(Value)。这样,我们就能在O(1)的时间代价内,完成对指定key的检索。这样一个以对象的唯一ID为key的哈希索引结构,叫作**正排索引**(Forward Index)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/f1/4b5e88addf89120aba176671c53d25f1.jpeg" alt="">
|
||||
|
||||
一般来说,我们会遍历哈希表,遍历的时间代价是O(n)。在遍历过程中,对于遇到的每一个元素也就是每一首诗,我们需要遍历这首诗中的每一个字符,才能判断是否包含“极”字和“客”字。假设每首诗的平均长度是k,那遍历一首诗的时间代价就是O(k)。从这个分析中我们可以发现,这个检索过程全部都是遍历,因此时间代价非常高。对此,有什么优化方法吗?
|
||||
|
||||
我们先来分析一下这两个场景。我们会发现,“根据题目查找内容”和“根据关键字查找题目”,这两个问题其实是完全相反的。既然完全相反,那我们能否“反着”建立一个哈希表来帮助我们查找呢?也就是说,如果我们以关键字作为key建立哈希表,是不是问题就解决了呢?接下来,我们就试着操作一下。
|
||||
|
||||
我们将每个关键字当作key,将包含了这个关键字的诗的列表当作存储的内容。这样,我们就建立了一个哈希表,根据关键字来查询这个哈希表,在O(1)的时间内,我们就能得到包含该关键字的文档列表。这种根据具体内容或属性反过来索引文档标题的结构,我们就叫它**倒排索引**(Inverted Index)。在倒排索引中,key的集合叫作**字典**(Dictionary),一个key后面对应的记录集合叫作**记录列表**(Posting List)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/8b/8e602ab79d98380c8c258a30a1e2108b.jpg" alt="">
|
||||
|
||||
## 如何创建倒排索索引?
|
||||
|
||||
前面我们介绍了倒排索引的概念,那创建一个倒排索引的过程究竟是怎样的呢?我把这个过程总结成了以下步骤。
|
||||
|
||||
1. 给每个文档编号,作为其唯一的标识,并且排好序,然后开始遍历文档(为什么要先排序,然后再遍历文档呢?你可以先想一下,后面我们会解释)。
|
||||
1. 解析当前文档中的每个关键字,生成<关键字,文档ID,关键字位置>这样的数据对。为什么要记录关键字位置这个信息呢?因为在许多检索场景中,都需要显示关键字前后的内容,比如,在组合查询时,我们要判断多个关键字之间是否足够近。所以我们需要记录位置信息,以方便提取相应关键字的位置。
|
||||
1. 将关键字作为key插入哈希表。如果哈希表中已经有这个key了,我们就在对应的posting list后面追加节点,记录该文档ID(关键字的位置信息如果需要,也可以一并记录在节点中);如果哈希表中还没有这个key,我们就直接插入该key,并创建posting list和对应节点。
|
||||
<li>重复第2步和第3步,处理完所有文档,完成倒排索引的创建。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/0d/2ccc78df6ebbd4d716318d5113fa090d.jpg" alt=""></li>
|
||||
|
||||
## 如何查询同时含有“极”字和“客”字两个key的文档?
|
||||
|
||||
如果只是查询包含“极”或者“客”这样单个字的文档,我们直接以查询的字作为key去倒排索引表中检索,得到的posting list就是结果了。但是,如果我们的目的是要查询同时包含“极”和“客”这两个字的文档,那我们该如何操作呢?
|
||||
|
||||
我们可以先分别用两个key去倒排索引中检索,这样会得到两个不同的posting list:A和B。A中的文档都包含了“极”字,B中文档都包含了“客”字。那么,如果一个文档既出现在A中,又出现在B中,它是不是就同时包含了这两个字呢?按照这个思路,我们只需查找出A和B的公共元素即可。
|
||||
|
||||
那么问题来了,我们该如何在A和B这两个链表中查找出公共元素呢?如果A和B都是无序链表,那我们只能将A链表和B链表中的每个元素分别比对一次,这个时间代价是O(m*n)。但是,如果两个链表都是有序的,我们就可以用归并排序的方法来遍历A和B两个链表,时间代价会降低为O(m + n),其中m是链表A的长度,n是链表B的长度。
|
||||
|
||||
我把链表归并的过程总结成了3个步骤,你可以看一看。
|
||||
|
||||
第1步,使用指针p1和p2分别指向有序链表A和B的第一个元素。
|
||||
|
||||
第2步,对比p1和p2指向的节点是否相同,这时会出现3种情况:
|
||||
|
||||
- 两者的id相同,说明该节点为公共元素,直接将该节点加入归并结果。然后,p1和p2要同时后移,指向下一个元素;
|
||||
- p1元素的id小于p2元素的id,p1后移,指向A链表中下一个元素;
|
||||
- p1元素的id大于p2元素的id,p2后移,指向B链表中下一个元素。
|
||||
|
||||
第3步,重复第2步,直到p1或p2移动到链表尾为止。
|
||||
|
||||
为了帮助你理解,我把一个链表归并的完整例子画在了一张图中,你可以结合这张图进一步理解上面的3个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/5f/a377f626bbfc1de2f98f199ed0ad585f.jpg" alt="">
|
||||
|
||||
那对于**两个key**的联合查询来说,除了有“同时存在”这样的场景以外,其实还有很多联合查询的实际例子。比如说,我们可以查询包含“极”**或**“客”字的诗,也可以查询包含“极”**且不包含**“客”的诗。这些场景分别对应着集合合并中的交集、并集和差集问题。它们的具体实现方法和“同时存在”的实现方法差不多,也是通过遍历链表对比的方式来完成的。如果感兴趣的话,你可以自己来实现看看,这里我就不再多做阐述了。
|
||||
|
||||
此外,在实际应用中,我们可能还需要对**多个key**进行联合查询。比如说,要查询同时包含“极”“客”“时”“间”四个字的诗。这个时候,我们利用多路归并的方法,同时遍历这四个关键词对应的posting list即可。实现过程如下图所示。<img src="https://static001.geekbang.org/resource/image/c9/96/c91ce2f3cff16b20b0cca52a57336b96.jpeg" alt="">
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就先讲到这里。你会发现,倒排索引的核心其实并不复杂,它的具体实现其实是哈希表,只是它不是将文档ID或者题目作为key,而是反过来,通过将内容或者属性作为key来存储对应的文档列表,使得我们能在O(1)的时间代价内完成查询。
|
||||
|
||||
尽管原理并不复杂,但是倒排索引是许多检索引擎的核心。比如说,数据库的全文索引功能、搜索引擎的索引、广告引擎和推荐引擎,都使用了倒排索引技术来实现检索功能。因此,这一讲的内容我也希望你能好好理解消化,打好扎实的基础。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
今天的内容实践性比较强,你可以结合下面这道课堂讨论题,动手试一试,加深理解。
|
||||
|
||||
对于一个检索系统而言,除了根据关键字查询文档,还可能有其他的查询需求。比如说,我们希望查询李白都写了哪些诗。也就是说,如何在“根据内容查询”的基础上,同时支持“根据作者查询”,我们该怎么做呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
23
极客时间专栏/检索技术核心20讲/基础技术篇/测一测 | 检索算法基础,你掌握了多少?.md
Normal file
23
极客时间专栏/检索技术核心20讲/基础技术篇/测一测 | 检索算法基础,你掌握了多少?.md
Normal file
@@ -0,0 +1,23 @@
|
||||
<audio id="audio" title="测一测 | 检索算法基础,你掌握了多少?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/e5/0b6abbc64797228db3fa85dd7a0aa6e5.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到基础技术篇的测试环节!
|
||||
|
||||
经过这几篇的学习,检索相关的基础数据结构和算法,你掌握了多少呢?为了帮助你巩固和复习之前讲到的知识,我精心设计了一套测试题,希望能帮你巩固所学,温故知新。
|
||||
|
||||
在这套测试题中,有20道选择题,每道题5分,满分为100。这是我们这套测试题最核心的部分。建议你花上30分钟,好好完成这套题目。
|
||||
|
||||
最后呢,我还为你准备了一道主观题,这道题为选做。 如果你对自己有更高的要求,我希望你可以认真思考一下,然后把你的思考过程和最终答案都写在留言区,我们一起探讨。因为主观题考察的是你的设计能力,所以你可以多思考几天。我会在下周三把解题思路放到评论区置顶,到时,记得来看啊!
|
||||
|
||||
还等什么,点击下面按钮开始测试吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=93&exam_id=182)
|
||||
|
||||
## 主观题
|
||||
|
||||
假设有一个员工管理系统,它存储了用户的ID、姓名、所属部门等信息。如果我们需要它支持以下查询能力:
|
||||
|
||||
1.根据员工ID查找员工信息,并支持ID的范围查询;<br>
|
||||
2.根据姓名查询员工信息;<br>
|
||||
3.根据部门查询部门里有哪些员工。
|
||||
|
||||
那使用我们在基础篇中学习到的知识,你会怎么设计和实现这些功能呢?(小提示:你可以先想一下,这个员工管理系统是怎么存储员工信息的,然后再来设计这些功能)
|
||||
139
极客时间专栏/检索技术核心20讲/基础技术篇/特别加餐 | 倒排检索加速(一):工业界如何利用跳表、哈希表、位图进行加速?.md
Normal file
139
极客时间专栏/检索技术核心20讲/基础技术篇/特别加餐 | 倒排检索加速(一):工业界如何利用跳表、哈希表、位图进行加速?.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="特别加餐 | 倒排检索加速(一):工业界如何利用跳表、哈希表、位图进行加速?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/01/e6ba71b0b61d96e95dc9098b770f0701.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到检索专栏的第一次加餐时间。
|
||||
|
||||
很多同学在留言区提问,说基础篇讲了这么多检索的基础数据结构和算法,那它们在工业界的实际系统中是怎么应用的呢?真正的检索系统和算法又是什么样的呢?
|
||||
|
||||
为了帮助你把这些基础的知识,更好地和实际应用结合。我特别准备了两篇加餐,来和你一起聊一聊,这些看似简单的基础技术是怎样在工业界的实际系统中发挥重要作用的。
|
||||
|
||||
在许多大型系统中,倒排索引是最常用的检索技术,搜索引擎、广告引擎、推荐引擎等都是基于倒排索引技术实现的。而在倒排索引的检索过程中,两个posting list求交集是一个最重要、最耗时的操作。
|
||||
|
||||
所以,今天我们就先来看一看,倒排索引在求交集的过程中,是如何借助跳表、哈希表和位图,这些基础数据结构进行加速的。
|
||||
|
||||
## 跳表法加速倒排索引
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/219268)中我们讲过,倒排索引中的posting list一般是用链表来实现的。当两个posting list A和B需要合并求交集时,如果我们用归并法来合并的话,时间代价是O(m+n)。其中,m为posting list A的长度,n为posting list B的长度。
|
||||
|
||||
那对于这个归并过程,工业界是如何优化的呢?接下来,我们就通过一个例子来看一下。
|
||||
|
||||
假设posting list A中的元素为<1,2,3,4,5,6,7,8……,1000>,这1000个元素是按照从1到1000的顺序递增的。而posting list B中的元素,只有<1,500,1000>3个。那按照我们之前讲过的归并方法,它们的合并过程就是,在找到相同元素1以后,还需要再遍历498次链表,才能找到第二个相同元素500。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/41/2c/41a18c5bf060dba4ff15fddc0646412c.jpg" alt="">
|
||||
|
||||
很显然,为了找到一个元素,遍历这么多次是很不划算的。那对于链表遍历,我们可以怎么优化呢?实际上,在许多工业界的实践中,比如搜索引擎,还有Lucene和Elasticsearch等应用中,都是使用跳表来实现posting list的。
|
||||
|
||||
在上面这个例子中,我们可以将链表改为跳表。这样,在posting list A中,我们从第2个元素遍历到第500个元素,只需要log(498)次的量级,会比链表快得多。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/d5/0efedfaf5754dd2e7bfcce3c3e624cd5.jpg" alt="">
|
||||
|
||||
这个时候你可能就会问了,我们只能用B中的每一个元素去A中二分查找吗?那在解答这个问题之前,我们先来看下图这个例子。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/3e/9b92ef9a864cb11ca29a1db3df68ef3e.jpg" alt="">
|
||||
|
||||
你会发现,在寻找500这个公共元素的过程中,我们是拿着链表B中的500作为key,在链表A中进行跳表二分查找的。但是,在查找1000这个公共元素的过程中,我们是拿着链表A中的元素1000,在链表B中进行跳表二分查找的。
|
||||
|
||||
我们把这种方法定义为**相互二分查找**。那啥叫相互二分查找呢?
|
||||
|
||||
你可以这么理解:如果A中的当前元素小于B中的当前元素,我们就以B中的当前元素为key,在A中快速往前跳;如果B中的当前元素小于A中的当前元素,我们就以A中的当前元素为key,在B中快速往前跳。这样一来,整体的检索效率就提升了。
|
||||
|
||||
在实际的系统中,如果posting list可以都存储在内存中,并且变化不太频繁的话,那我们还可以利用**可变长数组**来代替链表。
|
||||
|
||||
可变长数组的数组的长度可以随着数据的增加而增加。一种简单的可变长数组实现方案就是当数组被写满时,我们直接重新申请一个2倍于原数组的新数组,将原数组数据直接导入新数组中。这样,我们就可以应对数据动态增长的场景。
|
||||
|
||||
那对于两个posting list求交集,我们同样可以先使用可变长数组,再利用**相互二分查找**进行归并。而且,由于数组的数据在内存的物理空间中是紧凑的,因此CPU还可以利用内存的局部性原理来提高检索效率。
|
||||
|
||||
## 哈希表法加速倒排索引
|
||||
|
||||
说到高效查询,哈希表O(1)级别的查找能力令人印象深刻。那我们有没有能利用哈希表来加速的方法呢?别说,还真有。
|
||||
|
||||
哈希表加速的思路其实很简单,就是当两个集合要求交集时,如果一个集合特别大,另一个集合相对比较小,那我们就可以用哈希表来存储大集合。这样,我们就可以拿着小集合中的每一个元素去哈希表中对比:如果查找为存在,那查找的元素就是公共元素;否则,就放弃。
|
||||
|
||||
我们还是以前面说的posting list A和B为例,来进一步解释一下这个过程。由于Posting list A有1000个元素,而B中只有3个元素,因此,我们可以将posting list A中的元素提前存入哈希表。这样,我们利用B中的3个元素来查询的时候,每次查询代价都是O(1)。如果B有m个元素,那查询代价就是O(m)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/25/e4/257cc516587a2772df3684254a1ab3e4.jpg" alt="">
|
||||
|
||||
但是,使用哈希表法加速倒排索引有一个前提,就是我们要在查询发生之前,就把posting list转为哈希表。这就需要我们提前分析好,哪些posting list经常会被拿来求交集,针对这一批posting list,我们将它们提前存入哈希表。这样,我们就能实现检索加速了。
|
||||
|
||||
这里还有一点需要你注意,原始的posting list我们也要保留。这是为什么呢?
|
||||
|
||||
我们假设有这样一种情况:当我们要给两个posting list求交集时,发现这两个posting list都已经转为哈希表了。这个时候,由于哈希表没有遍历能力,反而会导致我们无法合并这两个posting list。因此,在哈希表法的最终改造中,一个key后面会有两个指针,一个指向posting list,另一个指向哈希表(如果哈希表存在)。
|
||||
|
||||
除此之外,哈希表法还需要在有很多短posting list存在的前提下,才能更好地发挥作用。这是因为哈希表法的查询代价是O(m),如果m的值很大,那它的性能就不一定会比跳表法更优了。
|
||||
|
||||
## 位图法加速倒排索引
|
||||
|
||||
我们知道,位图其实也可以看作是一种特殊的哈希,所以除了哈希表,我们还可以使用位图法来改造链表。如果我们使用位图法,就需要将所有的posting list全部改造为位图,这样才能使用位图的位运算来进行检索加速。那具体应该怎么做呢?我们一起来看一下。
|
||||
|
||||
首先,我们需要为每个key生成同样长度的位图,表示所有的对象空间。然后,如果一个元素出现在该posting list中,我们就将位图中该元素对应的位置置为1。这样就完成了posting list的位图改造。
|
||||
|
||||
接下来,我们来看一下位图法的查询过程。
|
||||
|
||||
如果要查找posting list A和B的公共元素,我们可以将A、B两个位图中对应的位置直接做and 位运算(复习一下and位运算:0 and 0 = 0; 0 and 1 = 0; 1 and 1 = 1)。由于位图的长度是固定的,因此两个位图的合并运算时间代价也是固定的。并且由于CPU执行位运算的效率非常快,因此,在位图总长度不是特别长的情况下,位图法的检索效率还是非常高的。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/2f/b18d701815e9347ab145d3794331c52f.jpg" alt="">
|
||||
|
||||
和哈希表法一样,位图法也有自己的局限性。我总结了以下3点,你可以感受一下。
|
||||
|
||||
1. 位图法仅适用于只存储ID的简单的posting list。如果posting list中需要存储复杂的对象,就不适合用位图来表示posting list了。
|
||||
1. 位图法仅适用于posting list中元素稠密的场景。对于posting list中元素稀疏的场景,使用位图的运算和存储开销反而会比使用链表更大。
|
||||
1. 位图法会占用大量的空间。尽管位图仅用1个bit就能表示元素是否存在,但每个posting list都需要表示完整的对象空间。如果ID范围是用int32类型的数组表示的,那一个位图的大小就约为512M字节。如果我们有1万个key,每个key都存一个这样的位图,那就需要5120G的空间了。这是非常可怕的空间开销啊!
|
||||
|
||||
在很多成熟的工业界系统中,为了解决位图的空间消耗问题,我们经常会使用一种压缩位图的技术Roaring Bitmap来代替位图。在数据库、全文检索Lucene、数据分析Druid等系统中,你都能看到Roaring Bitmap的身影。
|
||||
|
||||
## 升级版位图:Roaring Bitmap
|
||||
|
||||
下面我们来学习一下Roaring Bitmap的设计思想。
|
||||
|
||||
首先,Roaring Bitmap将一个32位的整数分为两部分,一部分是高16位,另一部分是低16位。对于高16位,Roaring Bitmap将它存储到一个有序数组中,这个有序数组中的每一个值都是一个“桶”;而对于低16位,Roaring Bitmap则将它存储在一个2^16的位图中,将相应位置置为1。这样,每个桶都会对应一个2^16的位图。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/b8/7a98d3f464c1e233c2082c626067cdb8.jpeg" alt="">
|
||||
|
||||
接下来,如果我们要确认一个元素是否在Roaring Bitmap中存在,通过两次查找就能确认了。第一步是以高16位在有序数组中二分查找,看对应的桶是否存在。如果存在,第二步就是将桶中的位图取出,拿着低16位在位图中查找,判断相应位置是否为1。第一步查找由于是数组二分查找,因此时间代价是O(log n);第二步是位图查找,因此时间代价是O(1)。
|
||||
|
||||
所以你看,这种将**有序数组和位图用倒排索引结合起来的设计思路,是**能够保证高效检索的。那它到底是怎么节省存储空间的呢?
|
||||
|
||||
我们来看一个极端的例子。
|
||||
|
||||
如果一个posting list中,所有元素的高16位都是相同的,那在有序数组部分,我们只需要一个2个字节的桶(注:每个桶都是一个short型的整数,因此只有2个字节。如果数组提前分配好了2^16个桶,那就需要128K字节的空间,因此使用可变长数组更节省空间)。在低16位部分,因为位图长度是固定的,都是2^16个bit,那所占空间就是8K个字节。
|
||||
|
||||
同样都是32位的整数,这样的空间消耗相比于我们在位图法中计算的512M字节来说,大大地节省了空间!
|
||||
|
||||
你会发现,相比于位图法,这种设计方案就是通过,**将不存在的桶的位图空间全部省去这样的方式,来节省存储空间的**。而代价就是将高16位的查找,从位图的O(1)的查找转为有序数组的log(n)查找。
|
||||
|
||||
那每个桶对应的位图空间,我们是否还能优化呢?
|
||||
|
||||
前面我们说过,当位图中的元素太稀疏时,其实我们还不如使用链表。这个时候,链表的计算更快速,存储空间也更节省。Roaring Bitmap就基于这个思路,对低16位的位图部分进行了优化:如果一个桶中存储的数据少于4096个,我们就不使用位图,而是直接使用short型的有序数组存储数据。同时,我们使用可变长数组机制,让数组的初始化长度是4,随着元素的增加再逐步调整数组长度,上限是4096。这样一来,存储空间就会低于8K,也就小于使用位图所占用的存储空间了。
|
||||
|
||||
总结来说,一个桶对应的存储容器有两种,分别是数组容器和位图容器(其实还有一种压缩的runContainer,它是对连续元素通过只记录初始元素和后续个数。由于它不是基础类型,需要手动调用runOptimize()函数才会启用,这里就不展开说了)。那在实际应用的过程中,数组容器和位图容器是如何转换的呢?这里有三种情况。
|
||||
|
||||
第一种,在一个桶中刚插入数据时,因为数据量少,所以我们就默认使用**数组容器**;
|
||||
|
||||
第二种,随着数据插入,桶中的数据不断增多,当数组容器中的元素个数大于4096个时,就从数组容器转为**位图容器**;
|
||||
|
||||
第三种,随着数据的删除,如果位图容器中的元素个数小于4096个,就退化回**数组容器**。
|
||||
|
||||
这个过程是不是很熟悉?没错,这很像[第3](https://time.geekbang.org/column/article/215324)[节](https://time.geekbang.org/column/article/215324)中的Hashmap的处理方法。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/ef/f8ce419c31151ff57f606fb6aafb63ef.jpg" alt="">
|
||||
|
||||
好了,前面我们说了这么多Roaring Bitmap的压缩位图空间的设计思路。下面,我们回到两个集合A和B快速求交集的例子中,一起来看一看Roaring Bitmap是怎么做的。假设,这里有Roaring Bitmap表示的两个集合A和B,那我们求它们交集的过程可以分为2步。
|
||||
|
||||
第1步,比较高16位的所有桶,也就是对这两个有序数组求交集,所有相同的桶会被留下来。
|
||||
|
||||
第2步,对相同的桶里面的元素求交集。这个时候会出现3种情况,分别是位图和位图求交集、数组和数组求交集、位图和数组求交集。
|
||||
|
||||
其中,位图和位图求交集,我们可以直接使用位运算;数组和数组求交集,我们可以使用相互二分查找(类似跳表法);位图和数组求交集,我们可以通过遍历数组,在位图中查找数组中的每个元素是否存在(类似哈希表法)。这些方法我们前面都讲过了,那知道了方法,具体怎么操作就是很容易的事情了,你可以再自己尝试一下。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容讲完了。我们来总结一下,你要掌握的重点内容。
|
||||
|
||||
在工业界,我们会利用跳表法、哈希表法和位图法,对倒排索引进行检索加速。
|
||||
|
||||
其中,跳表法是将实现倒排索引中的posting list的链表改为了跳表,并且使用相互二分查找来提升检索效率;哈希表法就是在有很多短posting list存在的前提下,将大的posting list转为哈希表,减少查询的时间代价;位图法是在位图总长度不是特别长的情况下,将所有的posting list都转为位图,它们进行合并运算的时间代价由位图的长度决定。
|
||||
|
||||
并且我们还介绍了位图的升级版本,Roaring Bitmap。很有趣的是,你会发现Roaring Bitmap求交集过程的设计实现,本身就是跳表法、哈希表法和位图法的一个综合应用案例。
|
||||
|
||||
最后呢,我还想再多说两句。实际上,我写这篇文章就是想告诉你,基础的数据结构和算法组合在一起,就能提供更强大的检索能力,而且这也是大量的工程系统中广泛使用的设计方案。因此,深入理解每一种基础数据结构和算法的特点和适用场景,并且能将它们灵活应用,这能帮助你更好地学习和理解复杂的数据结构和算法,以及更好地学会如何设计复杂的高性能检索系统。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
最后,我们还是来看一道讨论题。
|
||||
|
||||
在Roaring Bitmap的求交集过程中,有位图和位图求交集、数组和数组求交集、位图和数组求交集这3种场景。那它们求交集以后的结果,我们是应该用位图来存储,还是用数组来存储呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
113
极客时间专栏/检索技术核心20讲/基础技术篇/特别加餐 | 倒排检索加速(二):如何对联合查询进行加速?.md
Normal file
113
极客时间专栏/检索技术核心20讲/基础技术篇/特别加餐 | 倒排检索加速(二):如何对联合查询进行加速?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="特别加餐 | 倒排检索加速(二):如何对联合查询进行加速?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/dc/b5caa42a0a54521a13b2eea4f4333edc.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到检索专栏的第二次加餐时间。
|
||||
|
||||
在上一篇加餐中,我们讲了工业界中,倒排索引是怎么利用基础的数据结构来加速“求交集”过程的。现在,相信你已经对跳表、哈希表和位图的实际使用,有了更深刻的理解和认识了。然而,在日常的检索中,我们往往会面临更复杂的联合查询需求。这个时候,又该如何加速呢?
|
||||
|
||||
我们先来看一个例子:在一个系统的倒排索引中,有4个不同的key,分别记录着“北京”“上海”“安卓”“学生”,这些标签分别对应着4种人群列表。如果想分析用户的特点,我们需要根据不同的标签来选择不同的人群。这个时候,我们可能会有以下的联合查询方式:
|
||||
|
||||
1. 在“北京”或在“上海”,并且使用“安卓”的用户集合。抽象成联合查询表达式就是,(“北京”∪“上海”)∩“安卓”;
|
||||
1. 在“北京”使用“安卓”,并且是“学生”的用户集合。抽象成联合查询表达式就是,“北京”∩“安卓”∩“学生”。
|
||||
|
||||
这只是2个比较有代表性的联合查询方式,实际上,联合查询的组合表达可以更长、更复杂。对于联合查询,在工业界中有许多加速检索的研究和方法,比如,调整次序法、快速多路归并法、预先组合法和缓存法。今天,我们就来聊一聊这四种加速方法。
|
||||
|
||||
## 方法一:调整次序法
|
||||
|
||||
首先,我们来看调整次序法。那什么是调整次序法呢?接下来,我们就以三个集合的联合查询为例,来一起分析一下。这里我再多说一句,虽然这次讲的是三个集合,但是对于多个集合,我们也是采用同样的处理方法。
|
||||
|
||||
假设,这里有A、B、C三个集合,集合中的元素个数分别为2、20、40,而且A包含在B内,B包含在C内。这里我先补充一点,如果两个集合分别有m个元素和n个元素,那使用普通的遍历归并合并它们的时间代价为O(m+n)。接着,如果我们要对A、B、C求交集,这个时候,会有几种不同的求交集次序,比如,A∩(B∩C)、(A∩B)∩C等。那我们该如何选择求交集的次序呢?下面,我们就以这两种求交集次序为例来分析一下,不同的求交集次序对检索效率的影响。
|
||||
|
||||
当求交集次序是A∩(B∩C)时,我们要先对B和C求交集,时间代价就是20+40 = 60,得到的结果集是B,然后B再和A求交集,时间代价是2 + 20 = 22。因此,最终一共的时间代价就是60 + 22 = 82。
|
||||
|
||||
那当求交集次序是(A∩B)∩C时,我们要先对A和B求交集,时间代价是2 + 20 = 22,得到的结果集是A,然后A再和C求交集,时间代价是2 + 40 = 42。因此,最终的时间代价就是22 + 42 = 64。这比之前的代价要小得多。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/4d/c04b2ecadc46759012e2022c66c3c54d.jpeg" alt="">
|
||||
|
||||
除了对A、B、C这三个集合同时取交集以外,还有一种常见的联合查询方式,就是对其中两个集合取并集之后,再和第三个集合取交集,比如A ∩(B∪C),你可以看我开头举的第一个例子。在这种情况下,如果我们不做任何优化,查询代价是怎么样的呢?让我们一起来看一下。
|
||||
|
||||
首先是执行B∪C的操作,时间代价是20 + 40 = 60,结果是C。然后再和A求交集,时间代价是2+40 = 42。一共是102。
|
||||
|
||||
这样的时间代价非常大,那针对这个查询过程我们还可以怎么优化呢?这种情况下,我们可以尝试使用数学公式,对先求并集再求交集的次序进行改造,我们先来复习一个集合分配律公式:
|
||||
|
||||
A∩(B∪C)=(A∩B)∪(A∩C)
|
||||
|
||||
然后,我们就可以把先求并集再求交集的操作,转为先求交集再求并集的操作了。那这个时候,查询的时间代价是多少呢?我们一起来看一下。
|
||||
|
||||
首先,我们要执行A∩B操作,时间代价是2+20 = 22,结果是A。然后,我们执行A∩C操作,时间代价是2+40=42,结果也是A。最后,我们对两个A求并集,时间代价是2+2=4。因此,最终总的时间代价是22 + 42 + 4 = 68。这比没有优化前的102要低得多。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/ef/f5509f56323fcd8ff63b3a1f185563ef.jpeg" alt="">
|
||||
|
||||
这里有一点需要特别注意,如果求并集的元素很多,比如说(B∪C∪D∪E∪F),那我们用分配律改写的时候,A就需要分别和B到F求5次交集,再将5个结果求并集。这样一来,操作的次数会多很多,性能就有可能下降。因此,我们需要先检查B到F每个集合的大小,比如说,如果集合中元素个数都明显大于A,我们预测它们分别和A求交集能有提速的效果,那我们就可以使用集合分配律公式来加速检索。
|
||||
|
||||
不知道你有没有注意到,在一开始讲这两个例子的时候,我们假设了A、B、C有相互包含的关系,这是为了方便你更好地理解调整操作次序带来的效率差异。那在真实情况中,集合中的关系不会这么理想,但是我们分析得到的结论,依然是有效的。
|
||||
|
||||
## 方法二:快速多路归并法
|
||||
|
||||
**但是,**调整次序法有一个前提,就是集合的大小要有一定的差异,这样的调整效果才会更明显。那如果我们要对多个posting list求交集,但是它们的长度差异并不大,这又该如何优化呢?这个时候,我们可以使用跳表法来优化。
|
||||
|
||||
在对多个posting list求交集的过程中,我们可以**利用跳表的性质,快速跳过多个元素,加快多路归并的效率。**这种方法,我叫它"**快速多路归并法**"。在一些搜索引擎和广告引擎中,包括在Elastic Search这类框架里,就都使用了这样的技术。那具体是怎么做的呢?我们一起来看一下。
|
||||
|
||||
其实,快速多路归并法的思路和实现都非常简单,就是将n个链表的当前元素看作一个**有序循环数组**list[n]。并且,对有序循环数组从小到大依次处理,当有序循环数组中的最小值等于最大值,也就是所有元素都相等时,就说明我们找到了公共元素。
|
||||
|
||||
这么说可能比较抽象,下面,我们就以4个链表A、B、C、D求交集为例,来讲一讲具体的实现步骤。
|
||||
|
||||
第1步,将4个链表的当前第一个元素取出,让它们按照由小到大的顺序进行排序。然后,将链表也按照由小到大有序排列;
|
||||
|
||||
第2步,用一个变量max记录当前4个链表头中最大的一个元素的值;
|
||||
|
||||
第3步,从第一个链表开始,判断当前位置的值是否和max相等。如果等于max,则说明此时所有链表的当前元素都相等,该元素为公共元素,那我们就将该元素取出,然后回到第一步;如果当前位置的值小于max,则用**跳表法**快速调整到该链表中**第一个大于等于max的元素位置**;如果新位置元素的值大于max,则更新max的值。
|
||||
|
||||
第4步,对下一个链表重复第3步,就这样依次处理每个链表(处理完第四个链表后循环回到第一个链表,用循环数组实现),直到链表全部遍历完。
|
||||
|
||||
为了帮助你加深理解,我在下面的过程图中加了一个具体的例子,你可以对照前面的文字描述一起消化吸收。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/0c/3ba6d7717f18ff7de99b5a617b894d0c.jpg" alt=""><br>
|
||||
上图的例子中,我们通过以上4个步骤,找到了公共元素6。接下来,你可以试着继续用这个方法,去找下一个公共元素11。这里,我就不再继续举例了。
|
||||
|
||||
## 方法三:预先组合法
|
||||
|
||||
接下来,我们来说一说第三种方法,预先组合法。其实预先组合法的核心原理,和我们熟悉的一个系统实现理念一样,就是能提前计算好的,就不要临时计算。换一句话说,对于常见的联合查询,我们可以提前将结果算好,并将该联合查询定义一个key。那具体该怎么操作呢?
|
||||
|
||||
假设,key1、key2和key3分别的查询结果是A、B、C三个集合。如果我们经常会计算A∩B∩C,那我们就可以将key1+key2+key3这个查询定义为一个新的组合key,然后对应的posting list就是提前计算好的结果。之后,当我们要计算A∩B∩C时,直接去查询这个组合key,取出对应的posting list就可以了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/a9/8d989adeb03da29a5f07bf1036bddaa9.jpg" alt="">
|
||||
|
||||
## 方法四:缓存法加速联合查询
|
||||
|
||||
预先组合的方法非常实用,但是在搜索引擎以及一些具有热搜功能的平台中,经常会出现一些最新的查询组合。这些查询组合请求量也很大,但是由于之前没有出现过,因此我们无法使用预先组合的方案来优化。这个时候,我们会使用**缓存技术**来优化。
|
||||
|
||||
那什么是缓存技术呢?缓存技术就是指将之前的联合查询结果保存下来。这样再出现同样的查询时,我们就不需要重复计算了,而是直接取出之前缓存的结果即可。这里,我们可以借助预先组合法的优化思路,为每一个联合查询定义一个新的key,将结果作为这个key的posting list保存下来。
|
||||
|
||||
但是,我们还要考虑一个问题:内存空间是有限的,不可能无限缓存所有出现过的查询组合。因此,对于缓存,我们需要进行内容替换管理。一种常用的缓存管理技术是**LRU**(Least Recently Used),也叫作**最近最少使用替换机制**。所谓最近最少使用替换机制,就是如果一个对象长期未被访问,那当缓存满时,它将会被替换。
|
||||
|
||||
对于最近最少使用替换机制,一个合适的实现方案是使用双向链表:当一个元素被访问时,将它提到链表头。这个简单的机制能起到的效果是:如果一个元素经常被访问,它就会经常被往前提;如果一个元素长时间未被访问,它渐渐就会被排到链表尾。这样一来,当缓存满时,我们直接删除链表尾的元素即可。
|
||||
|
||||
不过,我们希望能快速查询缓存,那链表的访问速度就不满足我们的需求了。因此,我们可以使用O(1)查询代价的哈希表来优化。我们向链表中插入元素时,同时向哈希表中插入该元素的key,然后这个key对应的value则是链表中这个节点的地址。这样,我们在查询这个key的时候,就可以通过查询哈希表,快速找到链表中的对应节点了。因此,使用“**双向链表+哈希表**”是一种常见的实现LRU机制的方案。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/82/85/8243a7be13f1c78330e0109f10fd1a85.jpg" alt="">
|
||||
|
||||
通过使用LRU缓存机制,我们就可以将临时的查询组合缓存起来,快速查询出结果,而不需要重复计算了。一旦这个查询组合不是热点了,那它就会被LRU机制替换出缓存区,让位给新的热点查询组合。
|
||||
|
||||
缓存法在许多高并发的查询场景中,会起到相当大的作用。比如说在搜索引擎中,对于一些特定时段的热门查询,缓存命中率能达到60%以上甚至更高,会大大加速系统的检索效率。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就讲到这里。今天我们学习了联合查询的4种优化方法,我们一起来回顾一下。
|
||||
|
||||
第1种方法是调整次序法,它是通过从小到大求交集,以及使用集合分配律改写查询,使得检索效率得到提升。
|
||||
|
||||
第2种方法是快速多路归并法,它是利用跳表快速跳过多个元素的能力,结合优化的多路归并方案,提升多个posting list归并性能的。
|
||||
|
||||
第3种方法是预先组合法,也就是将热门的查询组合提前处理好,作为一个单独的key,保存提前计算好的posting list。
|
||||
|
||||
第4种则是使用缓存法,将临时的热点查询组合进行结果缓存处理,避免重复查询每次都要重复计算。
|
||||
|
||||
你会看到,这4种方法分别从数学、算法、线下工程和线上工程,这四种不同的方向对联合查询进行了优化。同时使用它们,能让我们从多个维度对联合查询进行加速。
|
||||
|
||||
通过上一篇加餐,我相信你也体会到了,如果我们能合理组合和灵活应用简单的数据结构和算法,就能构建出复杂的架构,让它们在工业界的大规模系统中发挥重要的作用。那通过这一篇加餐,你还可以体会到,基础知识的全面性能帮助你从不同的维度去思考,教你用更多的手段去优化系统。
|
||||
|
||||
因此,在学习的金字塔中,扎实和稳健的基础知识永远是最重要。多花点时间,打好基础,我相信你一定会有巨大的收获!
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
最后,我们还是来看一道讨论题。
|
||||
|
||||
对于今天介绍的四种方案,你觉得哪一种给你的印象最深刻?你会尝试在怎么样的场景中使用它?为什么?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
Reference in New Issue
Block a user