01|动态数组:按需分配的vector为什么要二倍扩容?

作者: 黄清昊

你好,我是微扰君。今天我们进入第一章基础数据结构的学习。

计算机程序一直以来最根本的作用就是处理数据。即使在早期的计算机中,计算就已经不仅仅是几个数字之间的加减乘除那么简单了,经常需要处理大量线性存储的数据,一个很好的例子就是向量乘法。显然,我们需要找到一种合适的方式在计算机中存储这些信息,并能让我们可以快速地进行向量运算。

再举一个更工程化的例子。假设有个需求,我们希望只借助内存实现一个简易的银行账户管理系统,每个账号里包括两个基本信息:账户ID及余额。用户首次开户的时候,会被分配一个账户ID;系统要支持用户通过ID快速查询余额,也可以存款/取款改变自己的余额。

你可能会觉得这有什么难的?用数组就可以解决。建立一个整型动态数组,每来一个用户就给存到数组的某个位置,用该位置的数组下标来当用户的ID就行。查询起来更快,数组大小是动态的,也不用考虑用户数量超过容量上限的问题。

但是,基于下标随机访问数组元素为什么这么高效?动态数组是怎么做到看起来可以有无限容量?扩容机制的时间复杂度是多少,是不是会带来额外的内存浪费呢?不知道你有没有思考过这些问题。

今天,我们就带着这些问题一起学习第一种序列式容器vector,也就是动态数组。相信你学完之后,对这些问题的理解就深刻啦。

数组和内存

讲解动态数组的实现之前,首先要回顾一下数组是什么,不过为了和动态数组区分开来,我们常常也称之为静态数组。可以这样定义:静态数组是由相同类型的元素线性排列的数据结构,在计算机上会分配一段连续的内存,对元素进行顺序存储。

其中有三个关键词,相同类型、连续内存、顺序存储。之所以这样设计,本质就是为了能做到基于下标,对数组进行O(1)时间复杂度的快速随机访问。

存储数组时,会事先分配一段连续的内存空间,将数组元素依次存入内存。因为数组元素的类型都是一样的,所以每个元素占用的空间大小也是一样的,这样我们就很容易用“数组的开始地址+index*元素大小”的计算方式,快速定位到指定索引位置的元素,这也是数组基于下标随机访问的复杂度为O(1)的原因。

图片

为什么要事先分配一段内存呢?答案也很简单,因为内存空间并不是无限的。一段程序里可能有很多地方都需要分配内存,我们必然要为分配的连续内存寻找一个边界。

事先确定数组大小并分配相应的内存给数组,就是告诉程序,这块地方已经是某个数组的地盘了,就不要再来使用了。同样,访问该数组的时候,下标也不应该超过地盘的范围,在大部分语言里这样的非法操作都会引起越界的错误,但在一些没有越界保护实现的语言(比如C语言)中,这就是一个很大的问题,需要开发者非常谨慎。有时这甚至会成为软件被黑客攻击的漏洞。

静态数组的特性

当然,在内存里这样的顺序存储也不是没有代价,这直接导致了数组的插入和删除会低效很多,平均的复杂度是O(N)。因为数组,和集合不同,元素在数组中的位置是我们关心的。

在长度为N的数组中,要在下标为T的位置插入数据时,原数组中下标为T到N-1的元素都需要向后顺移一位,这需要遍历数组中共计N-T个元素,当然,如果希望插入到数组的末端,只需要做插入而不需要做任何移动操作。但同样,如果我们希望将新元素插入到数组最开始的位置,就要将原数组所有元素都向后移动了,这需要移动共计N个元素。

图片

所以平均而言,数组的插入操作的时间复杂度为O(N)。删除操作基于类似的原因,复杂度当然也是O(N)。

总的来说,静态数组的特性就是数组中元素的个数是事先确定的,每个元素都有对应的索引,第一个元素的索引为0。因为每个元素在内存占用的空间是一样的,我们可以基于首元素的地址和目标元素的下标,直接定位到目标元素的位置。

动态数组

很显然,使用静态数组的时候需要事先指定空间大小,这并不是很让人们满意。因为静态数组的使用者分配完内存之后,数组空间就不再能扩展(或收缩)了。比如在开头的简易银行系统中,确定固定的数组大小带来的风险就是:当用户数不断上涨直至超过数组容量范围时,我们的系统就不能继续工作了。

唯一的解决方案只能是重新申请一个更大的数组。这个过程,如果自己手动实现,有相当多琐碎的操作,至少包括配置一整块更大的连续内存空间、将元素逐一拷贝至新空间,以及释放原本的空间。

而动态数组的意义就在于将这些繁琐的细节封装起来,给用户良好使用体验的同时,也兼顾效率。这就是为什么我们在大部分业务开发场景下,更多地采用动态数组容器,而不是原生的静态数组。

STL的Vector就是这样一种经过严格测试和实战检验的动态数组容器,我们下面来分析一下它的原理和实现。其他高级语言的动态数组容器的实现思路其实也是类似的,比如Java中的ArrayList等(后续我们也会用Java中的实现来讲解)。你搞清楚一个,其他的就很好理解了。

动态数组源码分析

当然,STL的源码涉及了许多高深的C++技巧,我们并不会展开讨论,会对源码做一些简单的调整方便你理解原理。这里也给你一个看源码的小建议,不要死抠细节。我个人看源码比较喜欢自顶而下的方式,先从大的模块暴露的方法和接口看起,而不是上来就开始研究小模块的实现细节

比如学习STL源码中vector实现的时候,你会经常发现allocator相关的方法,如果你揪着它不放一路溯源,会发现allocator的底层实现也非常复杂,但是,大多数时候我们不需要这么做,只要理解清楚了allocator的哪些方法用来申请内存、哪些方法用于释放内存,具体实现细节暂时当作黑箱,把精力集中在当下要搞清楚的问题上就可以了。

首先,来看vector在内存中的表示。它有两个指标,大小和容量。

大小,表示现在存了多少数据。存数据的部分其实和静态数组是一样的,都是一段连续的内存空间顺序排列这若干类型相同、大小一致的元素,但不同的地方在于,数组的大小是可以动态调整的。

我们知道,向计算机申请空间连续的内存空间是一个成本比较高的操作,不只需要扫描出堆区内存的空闲内存块,可能还需要向操作系统申请更大的堆空间,并产生用户态-内核态的切换成本。

所以为了减少二次分配的次数,初次配置空间的时候,可以分配比vector目前所需空间更多一些,后续的若干次插入就不再需要触发昂贵的扩容操作了。这样的可用空间,我们称为vector的容量,是vector在创建时需要的第二个可选参数。

图片

所以我们可以用三个指针来标记vector空间的使用情况,分别是:

  1. _start 指针,指向vector第一个元素
  2. _finish 指针,指向vector最后一个元素
  3. _end 指针,指向vector预留容量的边界

当然,动态数组的两个核心指标就很容易计算出来了:

  1. 容量,capacity = _end - _first,表示目前的数组最多能存储多少个元素
  2. 大小,size = _finish - _first,表示数组当前已经存储的元素个数

对应的代码如下:

1
2
3
4
5
6
7
8
9
10
template <class _Tp, class _Alloc = __STL_DEFAULT_ALLOCATOR(_Tp) >
class vector : protected _Vector_base<_Tp, _Alloc>
{
...
protected:
_Tp* _M_start; //表示目前使用空间的头
_Tp* _M_finish; //表示目前使用空间的尾
_Tp* _M_end_of_storage; //表示目前可用空间的尾
...
};

你不用太关注模版相关的语法,只需要知道这里protected的三个变量就是前面提到的3个指针。

有了这些,我们就可以判断什么时候需要触发扩容操作,以及扩容的方式。因为vector创建的时候会给一个容量,但随着我们不断往数组中插入元素,数组的大小终究会超过当前分配的容量,于是需要重新分配更大的内存,那具体分配多少是一个比较合理的值呢?

STL的扩容方法

来看一下STL怎么做的。除了查询已有资料之外,我个人比较推荐动手实验,不仅能随时检验自己脑海里的想法,通过动手实践对原理的理解和印象也更深刻一些。所以我们来编写一些测试方法,观察Vector在测试过程中的行为,再和官方文档及资料进行对比验证。

要做的实验也很简单,就是往一个数组里不断的插入元素,并观察size和capacity的变化。完整的代码可以在这里找到。

1
2
3
4
5
6
vector<int> v;

for (int i = 0; i < 20; i++) {
cout << "size: " << v.size() << " capacity " << v.capacity() << endl;
v.push_back(i);
}

图片

通过实验我们能发现一个很明显的规律:如果每次只插入一个元素,当vector的大小小于容量时,容量不会发生变化,数组大小不断递增。而当vector的大小即将超过容量的时候,插入之后,容量大小会翻番。

所以,倍增就是vector的扩容方式,这种类似倍增的策略也会出现在许多其他使用场景中。

这里很自然会有一个问题,为什么每次扩容时候都是以倍增的方式扩容,而不是增加固定大小的容量呢?

在回答这个问题之前,我们先看一看STL扩容逻辑的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void push_back(const _Tp& __x) {//在最尾端插入元素
if (_M_finish != _M_end_of_storage) {//若有可用的内存空间
construct(_M_finish, __x);//构造对象
++_M_finish;
}
else//若没有可用的内存空间,调用以下函数,把x插入到指定位置
_M_insert_aux(end(), __x);
}

template <class _Tp, class _Alloc>
void
vector<_Tp, _Alloc>::_M_insert_aux(iterator __position, const _Tp& __x)
{
if (_M_finish != _M_end_of_storage) {
construct(_M_finish, *(_M_finish - 1));
++_M_finish;
_Tp __x_copy = __x;
copy_backward(__position, _M_finish - 2, _M_finish - 1);
*__position = __x_copy;
}
else {
const size_type __old_size = size();
const size_type __len = __old_size != 0 ? 2 * __old_size : 1;
iterator __new_start = _M_allocate(__len);
iterator __new_finish = __new_start;
__STL_TRY {
__new_finish = uninitialized_copy(_M_start, __position, __new_start);
construct(__new_finish, __x);
++__new_finish;
__new_finish = uninitialized_copy(__position, _M_finish, __new_finish);
}
__STL_UNWIND((destroy(__new_start,__new_finish),
_M_deallocate(__new_start,__len)));
destroy(begin(), end());
_M_deallocate(_M_start, _M_end_of_storage - _M_start);
_M_start = __new_start;
_M_finish = __new_finish;
_M_end_of_storage = __new_start + __len;
}
}

这段扩容操作在push_back和insert操作中都会触发,我们以简单一点的push_back,也就是往数组尾部插入元素的操作为例,来解释扩容的逻辑。

可以看到,push_back的时候会先做一个判断,看当前的容量是不是不够用了。如果够用,我们只要直接往后插入一个元素;不够用,才进行_M_insert_aux扩容并插入的操作,插入后需要把finish指针往后移动。这里在容量够用的时候,插入逻辑用的是construct函数,是STL容器中通用的构造方法。

我们来重点分析扩容逻辑所在的_M_insert_aux方法:

  • 13-20行,实际上是因为还有其他函数会调用这个方法,我们已经确定容量不足,所以不会进入这段逻辑。
  • 22-25行,主要做的事情就是读取原有的vector大小old_size,再从内存里申请一段新的空间,大小为2*old_size,创建新的首尾指针并指向新的空间。
  • 26-31行,将老空间里的数据逐一搬到新的空间里,并在最后添加新的元素。这样就完成了扩容的主要目的,这是一个O(n)复杂度的操作,因为你需要对原数组进行逐一的深拷贝。
  • 最后,在32-38行,我们需要做一些清理和收尾工作,释放掉老的数组空间和指针,将容器的首尾及容量指针都更新到对应的位置。

这样Vector就完成了对扩容操作的封装,是不是其实并不复杂呢?

现在清楚扩容的具体实现之后,来解答前面的问题:为什么扩容是采用倍增的方式,而不是每次扩展固定大小?这背后其实是有严密数学依据的,非常有趣,我们一起来探索一下。

用极端法来考虑这个问题。

先假设是不是可以不倍增,而是每次只扩展一个元素呢?直觉上这当然是不合理的,这会导致每一次插入都会触发扩容,而每次扩容都会进行所有元素的复制操作。所以如果我们要插入n个元素,需要进行的拷贝次数:

$1+2+3+… +n=n^{2}$

复杂度为O(n^2),均摊下来每次操作时间复杂度就是O(n)。

那如果我们不是每次只拓展一个元素,而是每次扩展C的容量,对复杂度的计算会产生多大的影响呢?同样来计算一下,每插入C次就需要进行一次扩展操作,每次扩展仍然需要复制全部元素,所以总的拷贝次数是:

$C + 2C + 3C + … + ceiling(n/C)*C$

复杂度同样为O(n^2) 。均摊下来每次操作时间复杂度还是O(n)。 虽然次数少了C倍,但仍然不令人满意。

更好的做法就是和STL一样采用倍增的思想,每次都将容量扩展为当前的一倍,它往往能让我们的时间复杂度下降很多。

算一下倍增这种策略下需要拷贝的次数:假设一共还是插入N次,那总拷贝次数,就是从1加到2的X次,其中x是logn向上取整;这是因为容量每次都在翻番,所以每次触发拷贝的时候,容量分别是1、2、4、8 … 一直到logn向上取整。

$1+2+4+8+… +2^{x}=2^{(x+1)}-1$

这样插入N个元素的复杂度就一下减少为O(N)了。均摊到每次插入的扩容复杂度就为O(1),这当然是一个令人满意的结果啦。

总结

相信经过今天的学习,你一定已经对开头的几个问题有答案了吧,简单总结一下。数组,是支持O(1)基于下标随机访问的数据结构,在内存中是连续存储的。基于下标高效访问元素的核心就在于“相同类型”和“连续存储”的特性,当然,也带来了高昂的插入和删除的时间复杂度。

动态数组之所以能看起来像是无限容量,也仅仅是因为它内置了倍增的扩容策略,每次数组大小超过容量的时候,就会触发数组的扩容机制,封装了繁琐的拷贝细节。

也正是因为上述特性,动态数组广泛应用在需要经常查询、变更,但是很少插入/删除的场景,比如我们在实现一个简单的Web服务器的时候,可以用vector来存储handler的线程,达到线程复用的效果。

你应该感受到了今天的内容比上一讲的文本差分要简单一些,是的,接下来我们会先把数据结构实现的基础打好,了解清楚背后的实现原理,这样在日常开发中,不同的数据结构可能造成什么样的性能瓶颈,你都能烂熟于心。

课后作业

我们已经细致地分析了在vector中插入元素的方法,如果要删除一个元素应该怎么实现呢?在删除元素的时候需不需要缩容呢?如果需要的话,你会怎么做。

这是一个开放问题,欢迎你在留言区与我讨论。我们下节课见。

02|双向链表:list如何实现高效地插入与删除?

作者: 黄清昊

你好,我是微扰君。

在上一讲实现的一个简易银行账户管理系统中,每个账号都对应了一个余额,系统支持用户的开通、存/取款和查询余额。我们使用动态数组容器满足了频繁随机访问查询的需求。

但是如果要在系统里支持删除的功能,就会有一个问题:我们为了不进行整体的数组移动操作,通常就只能保留这个用户在数组里占用的内存,用将元素标记为特殊值的方式来模拟“删除”;而因为数组是连续存储的,不能单独释放掉数组中间某些区域的内存,所以这段内存空间我们实际上就是浪费的。

如果还有个需求,比如现在某个不讲道理的领导来到这个银行,要求自己在数组中排在最前面;那么我们不得不将所有人的账户信息往后挪动一位来满足他奇怪的自尊心,这也会带来高昂的时间复杂度。

那么有没有办法让我们不再需要连续的存储空间去存储一个序列,同时又可以在序列中快速进行插入/删除操作而不用波及之后的所有元素呢?

这就需要另一种常见的序列式数据结构——链表登场了,这同样是几乎所有高级语言都会原生支持的数据结构。

链表

链表这个数据结构的发明也是很久之前的事了,最早在1955年,它就是IPL这一古老语言的内置数据结构,用于开发当时的人工智能程序。

类似数组,链表同样是一种序列式的数据结构,但存储元素时并不需要使用连续的内存空间,而是采用一系列通过指针相连的节点来存储,因为有了指针来关联节点的地址,就不需要连续存储了

在每个节点内,我们都会同时存储元素的数据信息和一个指针,存储元素信息的部分是数据域,也就是 data field,存储指针的地方称为引用域,也就是 reference field;其中指针指向该节点的后继节点,也就是记录着链表中存储下一个元素节点的地址。

下图就是一个典型长度为3的链表的示例,我们可以通过指针很容易地从第一个节点遍历完整个链表。

图片

从内存布局能看出来,链表比连续存储的数组,有更灵活的内存使用方式和更高的内存使用率。

因为数组要求事先分配内存,而链表是每次插入新节点的时候,才申请该节点所需的内存空间,灵活得多,也就不会有分配空间没有被使用的浪费问题,自然内存使用率高。

链表存储元素采用的是通过指针的串联方式,而非数组的连续排列方式。我们在任何位置插入或者删除节点,不再需要为了保持元素的连续存储而进行O(n)的整体移动操作,只需要进行O(1)的指针改写,加申请或者释放内存就行。这显然比数组的插入删除要高效得多。

但是毕竟鱼和熊掌不能兼得。也正是因为这样非连续的存储方式,我们需要访问链表中第n个元素的时候就不得不从头节点遍历,导致访问第i个元素的均摊时间复杂度为O(N),而不能像数组那样直接基于下标和元素大小,计算出指定元素的偏移量。

所以,我们并不能简单地说链表和数组哪个更好,而是要根据使用的场景做出合适的选择。毕竟如果两个相似的数据结构其中一个各个方面都好于另一个的话,另一个数据结构可能也不会存在至今了。

基于上面的特性对比,我们可以得出一个大致的结论:链表更适用于删除、插入、遍历操作频繁的场景,而不适用于随机访问索引频繁的场景。比如在内存池、操作系统进程管理、最常用的缓存替换算法LRU中都有应用,之后讲解到相关专题的时候也会提到。

单链表vs双链表vs循环链表

在实际使用过程中,根据不同的需求,大致有3种常见的链表形式,单链表、双链表和循环链表,它们都需要支持几种最基本的链表操作,包括插入节点、删除节点、修改节点信息,以及访问遍历节点信息。

可以看图直观感受三者的区别。三种链表节点都包含引用域和数据域。

图片

其中,单链表和双链表最大的区别就在于,单链表的引用域,只存了一个后继指针。

图片

而双链表有两个引用域,不只存有后继节点的地址,也存储了前驱节点的信息,这使得我们可以双向遍历链表,拥有了在遍历中回退的能力。在链表首尾的前驱和后继指针,可以设为NULL或者指向一个特殊的虚拟节点,来标记链表的终结。

图片

而循环链表则是一个没有边界的环,既可以用双链表来实现也可以用单链表实现。相比于前两者的主要区别在于,在链表的边界,比如尾部,不再设为NULL或者一个虚拟节点,而是直接将尾部指向链表的头部。这在许多需要循环遍历的场景下非常有用,比如可以用于模拟约瑟夫环。

STL中List的实现

好了,讲解完链表的基础概念和分类,进入今天的重头戏,我们看看如何实现一个链表。

为了契合专栏真实世界的语义,我们继续以STL这一久经考验的C++标准库中的实现来讲解。List就是STL中的链表容器,它实现的是前面提到的双向、循环链表。如果你想要使用更节约空间的单链表,STL中也提供了相应的实现forward_list,有兴趣的话,你可以自己去了解背后的实现。

和上节课一样,因为STL的实现背后涉及了许多繁琐的C++高级语法,我们会对代码做一定的简化方便理解,你对C++不感兴趣的话也不用深究。

链表节点的实现

首先来看一下 list 的主要组成部分node ,也就是链表节点,它是整个链表的关键,存储着元素信息本身和连接链表的前后指针。

Node节点的实现如下:

1
2
3
4
5
6
template <class T>
struct __list_node {
__list_node<T>* next; // 前驱节点指针
__list_node<T>* prev; // 后继节点指针
T data; //存储数据
};

可以看到,链表节点的定义,除了为了支持各种元素类型而用到的泛型语法。其他的内容和前面说的双链表的节点完全一致,指针域同时包含了前驱和后继节点的地址,成员变量data用于存储元素信息本身。

链表迭代器的实现

所有的STL容器都需要实现迭代器,这也是后面所有操作的基础。

迭代器提供用于遍历的最重要的接口,它本身也是一种重要的设计模式,支持的操作就是自增++和自减--。上一讲的vector,因为内存是连续存储的,可以直接通过地址的++和–操作;但在内存不连续存储的List中,我们需要基于节点引用域中的前驱后继节点信息,来实现自己的迭代方法。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
template<typename T>
struct __list_iterator{
typedef __list_iterator<T> self;
typedef __list_node<T>* link_type;
link_type ptr; //成员
__list_iterator(link_type p = nullptr):ptr(p){}
}

T& operator *(){return ptr->data;}
T* operator ->(){return &(operator*());}
// 类似 ++x 返回next节点
self& operator++(){
ptr = ptr->next;
return *this;
}
// 类似 x++ 返回当前节点
self operator++(int){
self tmp = *this;
++*this;
return tmp;
}
// 类似 --x 返回prev节点
self& operator--(){
ptr = ptr->prev;
return *this;
}
// 类似 x-- 返回当前节点
self operator--(int){
self tmp = *this;
--*this;
return tmp;
}
bool operator==(const __list_iterator& rhs){
return ptr == rhs.ptr;
}
bool operator!=(const __list_iterator& rhs){
return !(*this==rhs);
}

其实就是将迭代器的方法都实现一遍。我们重点需要关注的操作是++和–,对应实现也非常直观。

在迭代器中,ptr是我们的主要成员变量,它指向的就是迭代器当前遍历的节点地址。所以++就是返回一个指向 ptr-&gt;next 的指针;而–对应的,就是返回一个指向prt-&gt;prev 的指针;同时我们需要把内置的ptr 也指向prt->next 或者 ptr->prev。这样我们就可以自如地用迭代器在链表上进行遍历了。

链表数据结构的实现

有了迭代器和节点,我们要做的就是将STL中双向循环链表的结构,用C++语言描述出来,并将一些链表相关的基本操作实现出来。

所谓链表,就是要将节点链接起来。由于链表节点本身已经存了前置后继节点的地址,所以链表数据结构主要的内涵,其实只需要使用一个节点即可表示出来,用这一个节点,我们就可以找到所有其他的节点。

所以数据结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<typename T>
class list{
protected:
typedef __list_node<T> list_node; // 显示定义list_node类型
typedef allocator<list_node> nodeAllocator; // 定义allocator类型
public:
typedef T value_type;
typedef T& reference;
typedef value_type* pointer;
typedef list_node* link_type;
typedef const value_type* const_pointer;
typedef size_t size_type;
public:
typedef __list_iterator<value_type> iterator; // 迭代器类型重写
private:
link_type node; // 只要一个指针,便可表示整个环状双向链表
// ......
}

这段代码看起来比较长,其实大部分是一些类型定义,比如简写了带泛型的节点类型,更多是为了保证语义清晰和可读。我们真正要关注的只有private的成员变量 node,事实上,这一个节点就可以表示整个环状双向链表。

在内存中的排布方式如下图所示:

图片

每个链表数据结构,都会有一个虚拟节点的成员变量node,用于标记这整个循环链表的首尾连接处,它既是整个链表的开始,也是整个链表的结尾;也就是说,这个虚拟节点的pre 指向链表的最后一个节点,它的next指向链表的第一个节点。

所以链表初始化容量为零的时候,显然只有一个前后指针都指向自己的虚拟节点。

这里还有一个非常巧妙的设计,我们会让end()迭代器指向这个虚拟节点,begin()则会指向虚拟节点的下一个节点,这完美符合迭代器前闭后开的语义。

因为end()节点指向的是一个并不真实存储数据的元素,是永远取不到值的;而对应的begin(),在链表不空的时候,指向正是链表中的第一个节点。因此要遍历链表所有的元素的时候,就会写出这样的代码:

1
2
for (std::list<int>::iterator it=mylist.begin(); it != mylist.end(); ++it)
std::cout << ' ' << *it;

我们在判断是否遍历到终点的代码,就和vector一样,写的是 it != mylist.end() 。

链表基本操作的实现

好了,终于来到最激动人心的部分:链表的基本操作。

有了刚才的数据结构和迭代器,很容易访问到链表的节点了,我们开始实现链表的另外几个主要操作:初始化链表、插入节点、删除节点。

先来看一切的开始,链表是如何初始化的。

相比数组其实要简单一些,因为前面说了,一个空的链表,就只包含了一个虚拟节点,STL内置的空间配置器很容易处理这个节点的内存申请。初始化代码如下:

1
2
3
4
5
6
7
void empty_initialize() {
node = get_node(0);
node->next = node; // next 指针指向自身
node->prev = node; // prev 指针指向自身
}

link_type get_node() { return list_node_allocator:allocate(); }

当链表为空时,虚拟节点前、后指针都指向自身,代码就是如此简洁直观。

那怎么往链表里插入数据呢,我们主要看 insert 方法是如何实现的,它用来在链表中的任何一个节点后面插入数据。有了insert,我们当然也能很容易实现 push_back 等常用方法。

那 insert 需要传入哪些参数呢?

前面也说了,相比数组,链表的最大优势之一就是它的插入和删除效率会高效得多。这正是因为内存空间不是线性排列的,所以想要插入数据,我们只需要修改指定位置的前、后指针的指向,把新的节点在逻辑上插入某个位置就可以了。

所以 insert需要两个入参,一个是插入位置,类型是一个迭代器;另一个是插入的值。其实现方法如下:

1
2
3
4
5
6
7
8
iterator insert(iterator position, const T& x) {
lik_type tmp = create_node(x); // 创建一个临时节点
tmp->next = position.node; // 将该节点的后继指针指向当前位置的节点
tmp->prev = position.node->prev; // 将该节点的前驱指针指向当前位置的前驱节点
(link_type(position.node->prev))->next = tmp; // 将前驱节点本来指向当前节点的后继指针改为指向该临时节点
position.node->prev = tmp; // 同样,当前位置的前驱指针也要修改为指向该临时节点
return tmp;
}

代码其实并不复杂,但许多新手还是需要花一些时间理解一下的,整个过程有点像“穿针引线”。

图片

先创建一个节点tmp,将该节点的前驱后继分别指向当前position的前驱和position本身;再将当前position的后继和position->prev的前驱指向这个新创建的节点。这样我们就完成了链表节点的插入。

图片

这些操作的顺序是非常重要的。你可以理解成要先把新的节点全接上去,才能把旧指针一一改过来

如果调换了操作的顺序,比如先将当前position的后继指向临时节点,那么我们就访问不到插入节点的后继节点了。建议你用纸笔多模拟几遍整个插入的过程,多练习几遍自然就能掌握了。

而链表的删除操作是类似的,基本上就是进行一组和插入相反的操作。找到某个需要删除的节点位置,将该节点的后继和前驱直接关联在一起,最后释放掉待删除节点的空间即可。代码如下:

1
2
3
4
5
6
7
8
iterator erase(iterator position) {
link_type next_node = link_type(position.node->next);
link_type prev_node = link_type(position.node->prev);
prev_node->next = next_node;
next_node->prev = prev_node;
destroy_node(position.node);
return iterator(next_node);
}

有了 insert 和 erase操作,其他一些基础操作当然也很容易衍生出来。比如pop_front/pop_back/push_back,我们只需要在指定的位置调用 erase 和 insert 即可,首尾的位置都可以通过begin、end等迭代器接口快速取到:

1
2
3
4
5
6
7
void pop_front() { erase(begin()) };
void pop_back() {
iterator tmp = end();
erase(--tmp);
}

push_back(const T& x) { insert(end(), x); }

这三个操作都是在O(1)时间复杂度内可以完成的,比vector对应的操作O(n)的时间复杂度要高效很多。

list其实还支持sort等操作,借助内部实现的transfer方法和归并排序的思想,同样可以做到O(n*logn)的复杂度。但实现比较复杂,如果你有兴趣,可以去看自己搜索一下相关的资料,力扣上有一道关于链表排序的题目也可以练习。

总结

链表,相比于数组,有更好的灵活性和更低的插入、删除的复杂度,更加适用于查询索引较少、遍历、插入、删除操作较多的场景,所以要频繁在容器中间某个位置插入元素的时候,就经常用到,比如在LRU和操作系统进程调度的场景下就都会用到。

链表操作在实现的过程中主要需要注意指针之间的变换顺序,你可以在脑海里多模拟几遍这样的过程,并尝试在不借助参考资料的前提下自己实现几次,这也是面试官在算法面试中经常会考察的点。

课后作业

今天没有讲链表中find方法的实现,这也是STL在各种容器中都会提供的一个通用方法。该方法用于寻找容器中某个值的迭代器,比如链表 1->5->3->4 中,调用find(3),你应该返回的就是链表中的第三个节点的迭代器。你可以来实现一下find方法吗?时间复杂度又是多少呢?

欢迎留言与我讨论。如果你觉得文章有帮助的话呢,也欢迎你点赞转发,我们下节课见~

03|双端队列:并行计算中的工作窃取算法如何实现?

作者: 黄清昊

你好,我是微扰君。

目前我们已经学习了 vector 动态数组和 list 双向链表两种STL中的序列式容器了,今天我们继续学习另一种常见的序列式数据结构,双端队列。

在并行计算中,我们常常会用多进程处理一些复杂的计算任务。为了能够通过多进程加速计算,我们除了需要对任务进行合理的切分,也需要将任务合理公平地分配到每一个进程。简单来说就是,我们希望每个进程都不至于闲着。那怎么样能做到这件事呢?

其实有一种非常常用的算法,工作窃取算法,就可以用来达成这个目标,它就需要用到我们今天的主角——双端队列。

队列

要介绍双端队列,我们先来聊一聊队列,queue。什么是队列呢?

从概念上来说其实非常好理解,因为它的特性和“队列”这个词在现实生活中的意思是一致的,那就是FIFO先进先出。简单来说就是排队。

比如说现在到很多餐厅就餐,服务员都会给你发一个号码让你排队,等有空位的时候,服务员叫号是按照取号的顺序来的,肯定是先来取号的人结束排队去入座;这样的约束就是先进先出。

显然这种先进先出的队列也是一种典型的序列式数据结构;和数组最大的区别就在于,它是一个有约束的序列式数据结构,因为先进先出的特性要求我们,所有的插入操作必须在队列的尾部进行,而所有的删除操作则必须在队列的头部进行。

图片

上图就是一个对队列入队、出队操作的示例。我们注意到先入队的元素一定会比后入队的元素更早出队。这一特性和思想在许多业务系统或者基础软件、操作系统、计算机网络中都有应用,比如在操作系统中的CPU调度中,进程资源使用CPU的顺序就用队列来排序。

双端队列

队列和链表一样也会延展出更多种类的队列,比如带权重的优先队列、或者只能一边进一边出的单端队列。

我们今天要实现的double ended queue,双端队列是其中一种,相比于普通队列而言,双端队列是两端开口的,在队列的头尾两端都可以进行进队和出队操作,让我们在使用队列时有了更大的灵活性。

你肯定想问,数组也可以在两边插入数据呀,那双端队列和数组有什么区别呢?

首先,数组头部的插入操作复杂度很高,如果我们并不需要快速随机访问,这种操作的复杂度是完全可以避免的,这是双端队列和数组的一个很大区别。更本质的地方在于,双端队列仅仅是一个两端都支持FIFO插入删除操作的队列,语义上来说并不支持数组基于下标在指定位置的修改、插入和删除的操作

图片

当然,我们是可以用数组或者链表来模拟实现双端循环队列的,只要暴露出经过剪裁的且满足FIFO的语义方法就可以了。

比如可以开一个大小为N的数组array,用两个数字 rear 和 front 代表队列的前端和尾端。在前端插入 target,只需要 array[(--front+N)%N] = target,这样既扩展了前端的边界,也达到了插入target的效果。%N也就是要对N取模,主要也就是为了处理越界的问题,这样当数组的前端read到达小于0的位置时,就会马上变成N-1,也就实现了一个循环队列。

Deque实现

虽然说,可以用数组或者链表来实现队列,但C++并没有选择依赖已有的序列式容器vector或者list来实现,原因是什么呢?你可以先想一想。

带着这个问题,我们一起来学习后面的内容,看看STL中的deque是如何实现一个高效好用的双端队列的。

我个人认为,在 STL 序列化容器的空间分配中,deque 可能是最复杂的,这也可能会对你阅读源码造成一定的障碍,但是不要害怕,如果只是为了搞清deque设计的大致思想,我们完全可以将内存分配的部分当成黑盒来看,这对搞清楚deque的原理并没有什么影响。

Deque的内存布局

deque的内存布局,可以说同时具备了list和vector的特点。

deque的内存布局是由一段段连续的空间、用另一个类似数组的东西将这些空间的地址信息拼接在一起组成的,真实存放数据的就是那一段段连续的空间。在首尾两端插入和删除的时间复杂度是O(1)。以插入为例,每次一段连续的空间元素被用完的时候,会直接申请一段新的空间并链接到deque的分段空间末尾。

所以deque既不像 vector 那样每次扩容都需要付出复制和拷贝的高昂代价,也不会像链表那样每次插入一个新的节点都需要申请一次内存。

当然这也导致了非常复杂的控制流程,deque的代码量也远远多于vector和list。

为了维护一段段连续的内存空间,deque需要维护一个被称为map的成员变量;这个map数据结构起到了管理真正用于存储队列元素的一段段连续线性空间的作用。那一段段连续的线性空间,我们称为缓冲区。

map的示意图如下:

图片

可以认为map是一个数组,每个元素指向了一段缓冲区的地址。而缓冲区对应了一段指定大小的连续内存空间,默认大小为 512 bytes。

1
2
3
4
5
6
7
8
9
10
template <class _Tp, class _Alloc>
class _Deque_base {
...
protected:
_Tp** _M_map;
size_t _M_map_size;
iterator _M_start;
iterator _M_finish;
...
}

因此 _M_map 在数据结构中的表现就是一个二级指针。_M_map_size指的就是 deque 中 map 的空间大小,即在map中最多能存储多少个指针。如果map的空间已经被用满了,我们也会对map进行一次重新分配迁移的操作,核心思想和vector的重分配其实是一样的,我们马上具体讲。

Deque的迭代器

介绍完内存布局和基本数据结构,下一个重点就是STL的通用访问模式,迭代器的实现了。

正是因为 deque 底层实质是分段连续空间,operator++ 和 operator-- 的实现也变得更困难一些,迭代器既要能找到与当前缓冲区相邻的缓冲区在哪;也需要知道目前访问的地方是否已经到当前缓冲区的边缘,只有这样到边缘时,才能正确跳转。

为了方便达到这一目标,我们需要在迭代器的数据结构中记录一下迭代器在当前缓冲区的位置,同时记录当前缓冲区的开始位置和结束位置,以及缓冲区的map指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <class _Tp, class _Ref, class _Ptr>
struct _Deque_iterator {
typedef _Deque_iterator<_Tp, _Tp&, _Tp*> iterator;
typedef _Deque_iterator<_Tp, const _Tp&, const _Tp*> const_iterator;
static size_t _S_buffer_size() { return __deque_buf_size(sizeof(_Tp)); }
...
typedef _Tp** _Map_pointer; // 缓冲区指针
...
_Tp* _M_cur; // 当前缓冲区的位置
_Tp* _M_first; // 缓冲区的左边界线
_Tp* _M_last; // 缓冲区的右边界
_Map_pointer _M_node;
_Deque_iterator(_Tp* __x, _Map_pointer __y)
: _M_cur(__x), _M_first(*__y),
_M_last(*__y + _S_buffer_size()), _M_node(__y) {}
}

有了位置的记录,operator++ 可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
_Self& operator++() {
++_M_cur;
if (_M_cur == _M_last) {
_M_set_node(_M_node + 1);
_M_cur = _M_first;
}
return *this;
}
void _M_set_node(_Map_pointer __new_node) {
_M_node = __new_node;
_M_first = *__new_node;
_M_last = _M_first + difference_type(_S_buffer_size());
}

核心的就是_M_set_node方法,如果我们发现M_cur已经达到了当前缓冲区的尾部,就将它移动到下一段缓冲区的头部,更新迭代器中当前map的位置。另外,也需要将_M_first和_M_last更新为新的缓冲区的左确界和右虚界。

-- 的操作类似:

1
2
3
4
5
6
7
8
_Self& operator--() {
if (_M_cur == _M_first) {
_M_set_node(_M_node - 1);
_M_cur = _M_last;
}
--_M_cur;
return *this;
}

我们发现M_cur达到缓冲区头部的时候,就要将它移动到当前缓冲区的前一段缓冲区了,调用set_node方法即可。

到这里就完成了迭代器的主要接口,这让我们将内存实质不连续的真相隐藏了起来,取而代之地提供了一个非常简洁好用的遍历deque的接口。

好啦,学完deque 的内存布局和迭代器如何实现,你知道它的基础操作该怎么写了吗?

Deque的基础操作

相比于vector和list来说,deque支持的操作要少得多,只有基本的push和pop实现,因为队列语义保证了我们不会在队列中间进行插入删除操作,也就不用支持insert和erase这样的操作了。

不过正因为内存布局复杂,deque的内存管理扩缩容的逻辑也比较复杂,我们了解大概思想就可以了。如果你感兴趣可以自行查阅deque源码。

push操作

Deque的第一个操作当然是push_front和push_back,因为我们实现的是双端队列,所以头部尾部都有可能插入数据。

遇到内存不足的时候,deque会按照下图的逻辑进行扩容,有几个检查点,首先判断是不是能在当前缓冲区插入元素,如果可以,直接插入就行;如果不能,就要检查缓冲区map两端是否有足够的空间;如果有的话,也很简单,直接创建一个新的缓冲区并存入map。

关键是在map空间不足的时候,也就是插入的数据已经达到map头部或者尾部缓冲区的边界时,我们可以分两种情况讨论:

  1. 如果 map使用率已经超过一半,我们就可以重新申请更大的空间,把老的map上的数据拷贝到新的区域。这里注意,map中指向的那些缓冲区里的数据并不用变化,只是需要一个更大的map去放那些缓冲区的指针,和动态数组扩容的方式如出一辙。
  2. map使用率没有超过一半,这时候我们认为申请新的空间可能是浪费的,所以只是将数据重新调整到map中间的位置,当然也要进行一次拷贝。这可能会帮我们节约大量的空间。

翻译成代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void push_back(const value_type& __t) {
if (_M_finish._M_cur != _M_finish._M_last - 1) {
construct(_M_finish._M_cur, __t);
++_M_finish._M_cur;
}
else
_M_push_back_aux(__t);
}
template <class _Tp, class _Alloc>
void deque<_Tp,_Alloc>::_M_push_back_aux()
{
_M_reserve_map_at_back();
*(_M_finish._M_node + 1) = _M_allocate_node();
__STL_TRY {
construct(_M_finish._M_cur);
_M_finish._M_set_node(_M_finish._M_node + 1);
_M_finish._M_cur = _M_finish._M_first;
}
__STL_UNWIND(_M_deallocate_node(*(_M_finish._M_node + 1)));
}

pop操作

pop操作不再需要处理插入导致的扩容拷贝问题, 相对来说就显得简单很多。以pop_back为例,我们只需要关注是否已经pop到某一段缓冲区的边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void pop_back() {
if (_M_finish._M_cur != _M_finish._M_first) {
--_M_finish._M_cur;
destroy(_M_finish._M_cur);
}
else
_M_pop_back_aux();
}
// Called only if _M_finish._M_cur == _M_finish._M_first.
template <class _Tp, class _Alloc>
void deque<_Tp,_Alloc>::_M_pop_back_aux()
{
_M_deallocate_node(_M_finish._M_first);
_M_finish._M_set_node(_M_finish._M_node - 1);
_M_finish._M_cur = _M_finish._M_last - 1;
destroy(_M_finish._M_cur);
}

如果发现当前迭代器已经和缓冲区的首位置相同,除了释放掉当前的内存,还需要释放掉整段缓冲区的内存,并且将迭代器的缓冲区指针,指向当前缓冲区前一段的位置,这可以通过_M_set_node方法达成。当然,由于我们还需要pop一个节点,所以会将_M_cur指向_M_finish._M_last-1的位置。

C++的选择

现在掌握了deque的实现和基本操作,我们来回答一下为什么C++不选择依赖已有的序列式容器来实现deque?

其实我们已有的容器就两个,一个是vector,另外一种就是list。

显然,基于vector实现,不能真的在头部插入元素,会产生O(N)的时间开销,我们只能用一个固定大小的vector来模拟循环队列,具体实现方式前面说过。但这样就导致我们必须事先确定数组的最大容量,让它的大小是实现分配好的,这就和数组一样,也会产生内存浪费和无法动态扩容的问题

不过在最大容量能确定的场景下,用vector也是一种非常常见的循环队列实现方式。

而基于list,看起来首尾都可以O(1)的时间插入,但对数据的随机读取性能会很差;且每次插入元素都需要申请内存,相比于deque一次申请一段内存的方式也会带来额外的性能开销。而list的最大优势,任意位置的快速插入/删除能力,我们却用不上

所以基于deque的使用场景,C++设计了基于map分段存储的双端队列的数据结构,能同时具备list和vector的特点。

总结

队列的基本特性是FIFO,也就是先进先出,它能衍生出几种不同的形式,包括循环队列、双端队列,既可以通过数组实现,也可以通过链表实现。

STL的deque是一种双端队列的实现,内存布局是由一段段连续内存串联起来的,在队列两端都可以pop和push数据。因为复杂的内存分配,代码实现的难度要高很多。但更多的复杂性还是体现在内存管理中,只要我们通过迭代器等模式,将底层的逻辑封装起来,相信你也看到了,pop和push操作的思路其实是非常清晰好懂的。

现在你知道为什么说工作窃取算法需要用到双端队列了吗?

我们一起看看。为了更公平也更高效地分配每个进程负责的任务,我们可能会多开很多个队列去存储任务,每个进程就去消费一个队列中的任务,这样就可以有效避免进程间的竞争。因为任务先进先出,用一个普通的单向队列就可以完成了。

但是你可能很难保证任务划分得非常均匀,使得每个进程完成所有任务的时间都差不多。这不是一个很好解决的问题。但是如果我们换一个思路,不再费心让任务分配得均匀,只是简单地允许先完成任务的进程,去其他进程的队列盗取任务,是不是就不会有进程闲置了呢

不过怎么盗取,可以让我们仍然尽量规避进程间的竞争问题呢? 相信你已经想到答案了,没错,就是双端队列。我们让盗取任务的进程,从队列的另一端盗取就行了,这样只有队列长度为1的时候才会出现竞争。当然还有很多实现细节,你感兴趣的话可以去看一下Java中ForkJoinPool的实现。

课后作业

最后,同样给你留一个课后作业。我们讲解了如何用数组实现队列,也提到队列同样可以通过链表来实现?你可以试着实现一下吗?

欢迎你留言与我讨论交流~

04|栈:函数调用的秘密究竟是什么?

作者: 黄清昊

你好,我是微扰君。

目前为止,我们已经介绍了STL里的大部分序列式容器,包括vector、deque和list,也对应着数组、队列和链表这几种基础数据结构;今天我们来学习最后一种常用的线性数据结构,栈。

栈这个词,相信每一个研发同学在学习编程的过程中都会经常听到。不仅仅是因为栈本身就是一种基础的、常见的数据结构,更因为栈在计算机世界里起着举足轻重的作用。

在编程语言中,栈除了作为一种常用的数据结构,也常常用来表示一种叫做“调用栈”的概念,它是编程语言之所以能实现函数调用的关键所在。而在内存分配中,栈也表示一种内存分配的区域,和内存中的堆区是一种相对的概念。

栈区是有结构和固定大小的,区块按照次序存放,每个线程独占一个栈区,总的大小也是事先确定的;而堆区则没有固定的大小,数据可以随意存放。我们常常听到的 stack overflow 错误,也就是栈溢出错误,就是指程序在运行时,存放在栈上的数据已经多于栈区的容量,产生了容量不足的错误。相信说到这,你就更加明白为什么说栈相比于其他数据结构更经常被听到了吧。

其实无论是调用栈还是内存中的栈区,这两种含义都和栈数据结构的LIFO特性有关。如果你已经有了充分的背景知识,可以先想想这是为什么?

栈的特性:LIFO

和队列一样,我们也可以将栈理解成一种有约束的序列式数据结构,但是不同于FIFO的队列,栈的约束在于,插入和移除元素的操作,都只能在该数据结构的一端进行。栈对外暴露的插入和删除接口,我们一般称为push和pop,操作的那一侧,也称为栈顶,不能操作的那一侧则叫做栈底

图片

我们只能从栈顶删除或者插入元素,这直接保证了LIFO,也就是先进后出的特性。

栈有一个很好理解的生活中的例子就是坐电梯。显然电梯就是这样一种有着单侧开口性质的容器,把电梯里的乘客看成是容器中的元素,我们发现先进入的人肯定不太好直接越过后进来的人们先下电梯,因为出口和入口是一样的,并且只有一个。

这也就是为什么我们坐电梯到目标楼层准备出来的时候,经常会需要前面的人让一让。相信你只要想象一下这样的场景,就可以理解栈这个数据结构的精髓所在啦。

STL 中 stack 的实现

stack 就是 STL 中对栈这一数据结构的实现。其实,相比上一讲内存管理逻辑复杂的队列,stack的代码实现非常简单。

前面说了 stack 有单侧开口、后进先出的特性,有没有想到之前讲过的哪个容器也能实现类似的效果呢?

图片

其实不只一个可以实现。比如 vector 就可以通过在一侧 push_back 和 pop_back 的操作模拟栈的 push 和 pop;同理,deque也可以通过在同一侧(比如头部)的 push_front 和 pop_front 操作进行栈的模拟,这也正是 STL 的做法。也正是因为这个原因,stack 的实现就非常简单了。

图片

不过 stack 并没有像前几讲介绍的数据结构那样真正实现底层接口的逻辑,而仅仅基于 deque 现有的能力去改造出符合 stack 语义的接口,是不是就像一个接口转换器呢?就好像是把一个三口的插头转成了两口的插头。

事实上,这样的封装方式,也正是一种设计模式:“适配器”模式。所以也有人认为 stack 不是一种 container,容器,而是一种 container adapter,适配器容器。

数据结构定义

好了,来看一下 stack 在 STL 中的具体实现,我们同样先来看看 stack 的数据结构定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename _Tp, typename _Sequence = deque<_Tp> >
class stack
{
public:
typedef typename _Sequence::value_type value_type;
typedef typename _Sequence::reference reference;
typedef typename _Sequence::const_reference const_reference;
typedef typename _Sequence::size_type size_type;
typedef _Sequence container_type;
protected:
_Sequence c; // stack 底层容器; 默认为 deque
public:
reference
top()
{
__glibcxx_requires_nonempty();
return c.back();
}
void push(const value_type& __x) { c.push_back(__x); }
void pop() {c.pop_back();}
...
}

这里有一行 __glibcxx_requires_nonempty ,这实际上是一个宏而不是函数,你不用太过关心,主要是用于帮助你debug的,对代码的运行没有任何影响。

可以看到 stack 下有一个受保护的成员变量 _Sequence,它就是我们说stack作为适配器所适配的容器,在默认的情况下,_Sequence 的选择是用 deque 作为它底层的存储容器,所以其扩容机制,当然也就是依赖了deque的实现。

这也是适配器的魅力所在,给我们所需要的类型提供了一套统一的接口,但底层所适配的容器依旧是可配置的;我们可以根据策略随时改变底层容器的选择,不会对外界造成任何影响。

现在既然已经有了一个功能强大的底层容器“deque”,我们当然可以基于它快速实现 stack 所需要的所有接口。在 STL 的实现中,我们封闭了 deque 的前端,不再有任何地方可以进行 push_front 或者 pop_front 操作了。 而stack最关键的两个方法 push 和 pop ,只需要简单地调用 c.push_back() 和 c.pop_back() ,就可以达到模拟单侧开口的栈的效果。

除了可以利用其他数据结构,stack 实现起来非常简单还有另一个原因,它不需要暴露迭代器。在标准的栈“后进先出”的语义下,我们并不需要对stack做随机访问和遍历的操作,加上只有栈顶的元素才会被外界访问,又省去了实现迭代器的很多逻辑。而栈顶元素top也只需要调一下内置容器的back方法即可取得。

当然,这也让我们失去了一些属于deque的能力,但我认为这正是面向对象6大设计原则之一的接口隔离原则的体现,当我们使用栈的时候,就不应该去关心如何迭代它。

到这里stack 在 STL 中的实现我们就已经全部学完了,是不是非常简单呢?

stack的应用 - 调用栈

掌握了栈作为数据结构的基本特性和实现,我们来回答开头的问题,在编程语言中,函数调用栈里的栈到底是什么,和栈的LIFO特性有什么关系,为什么它也被命名为栈呢?

函数调用

先来简单复习一下什么是函数调用,以及函数调用背后有哪些过程。我们就以JavaScript语言为例来学习,因为现代的Web应用都是跑在Google的V8引擎之上的,这能避免我们涉及太多寄存器和汇编语言相关的细节,毕竟那些细节对理解函数调用栈并没有太直接的帮助。

来看一下这段代码,这里面就包含一个典型的函数调用:

1
2
3
4
5
6
7
8
9
10
11
function add(a, b) {
console.trace();
return a + b;
}
function avg(a, b) {
console.trace();
let res = add(a, b) / 2;
console.trace();
return res;
}
let x = avg(0, 100);

代码中一共包含了两个函数,add用于求两数之和,avg用于求两数均值。其中avg的逻辑里就调用了add函数,而对新变量x的赋值过程,也调用了avg函数。

整个调用链路是:对x赋值 -> call avg -> call add -> return add -> return avg。

这个调用的结构是不是天然看起来就像是前面举的“进电梯-出电梯”的例子呢? 函数的call和return仿佛就像是进电梯和出电梯的过程,后call的先return,完美符合了 LIFO 的原则。事实上也正是如此,但call和return的过程究竟是什么呢?下面我们就来一探究竟。

因为Chrome浏览器的运行时提供了很好用的打印调用栈信息的功能,我们就调用一下,直观地感受调用栈的状态变化:

图片

可以看到,第一次打印的时候,调用栈中包括avg函数和匿名函数。第二次打印的时候在调用栈的顶部又增加了一个add函数。第三次打印的时候add又一次消失在了栈顶。

这是因为在代码的第2、6和8行,打印了三次调用栈的信息。由于avg函数第一个执行trace命令,是在调用add之前,所以首先打印在console中的调用信息属于avg函数,随后是add函数,最后是avg调用完add之后的第二次打印。

如果将调用栈的信息做一个完整的展示,大概是这个样子:

图片

看起来显然更像一个栈了。现在,调用栈之所以叫调用“栈”,也就不言而喻了,它记录程序正式运行过程中函数调用的后call先return情况。

那么调用栈里的一个个函数到底是什么呢?

我们知道,每个函数都有一个自己的作用域,但不同作用域下的变量可以有相同的变量名。比如在刚才的例子中,avg和add函数中的入参变量名都是a和b,它们互相不影响。这就是因为每个函数都会有一个自己的上下文,而上下文中存放着变量名和值的绑定,不同的上下文是彼此隔离的。

当程序每次执行到一个函数调用的时候,操作系统或者虚拟机就会在栈上分配一块区域,我们称之为栈帧。

简单来说,栈帧中就存放着函数执行的上下文。当前计算完成之后,我们就会将执行结果返回,并绑定到上一个栈帧内的变量里,当前栈帧的所有资源也就可以释放了。

这个释放过程,在操作系统或者虚拟机底层,最后都会转化成几个寄存器值的变化,成本非常低廉。事实上,各个语言在实现函数调用的时候都是不约而同地依赖调用栈去实现的,只不过js建立在V8引擎之上,栈帧里存的就可以是执行上下文,包括了变量和值的绑定等信息;而在C语言里可能就直接操作的几个寄存器的值,如EAX、ESP等,和具体的CPU指令集架构有关。

比如前文中js的例子,当average函数调用add函数时,我们就创建了一个属于add的执行上下文,其中a和b绑定着从average中传递过来的变量。因为上下文是隔离的,在add的执行过程中,无论我们怎么修改a和b的值,对average上下文中的变量其实都是没有影响的。

假设用一个数组来表示执行上下文栈,其过程大概如下,average和add有各自的上下文也就对应着各自的活动对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
EXE_STACK = [];

EXE_STACK.push(<main> functionContext);
EXE_STACK.push(<average> functionContext);
Average_AO = {
arguments: {
0: 5,
1: 100,
length: 2
},
a: 5,
b: 100,
}
EXE_STACK.push(<add> functionContext);
Add_AO = {
arguments: {
0: 5,
1: 100,
length: 2
},
a: 5,
b: 100,
}

EXE_STACK.pop(); // add 执行完毕
EXE_STACK.pop(); // 执行 average
EXE_STACK.pop(); // 执行 main

等add执行完毕之后,我们会将add的结果返回,并继续执行average后续的操作。返回后,add的上下文也就没有必要再保留了,直接释放掉即可。所以整个代码执行过程看到的调用栈才会是上图中展现的样子。

至此,整个上下文从创建到销毁的过程完美契合了栈LIFO的原则。正是因为每次调用前,我们都有将上下文的信息保留在栈中,调用的计算过程并不会影响到调用前的内存空间,完美地做到了函数调用保留现场的作用;调用完成之后,我们可以延续调用前上下文中的状态,继续进行后续的计算。

在栈上连续的内存分配,以及函数调用完,不会再有其他地方需要函数上下文内变量的特点,让在栈上的内存管理变得简洁而高效。所以可以说,通过使用栈,我们优雅地实现了函数调用这一编程语言中最基础的核心能力。

在栈上连续的内存分配,以及函数调用完函数生命周期就结束,也就是不会再有其他地方需要函数上下文内变量的特点,让在栈上的内存管理变得简洁而高效;释放内存的操作和堆上完全不同,我们只需要直接改变函数栈帧指针的指向即可。所以可以说,通过使用栈,我们优雅地实现了函数调用这一编程语言中最基础的核心能力。

总结

栈的主要特点就是先进后出,其实list、vector、deque都可以用来做stack的底层容器,只需要利用适配器模式封装,屏蔽一部分接口,只保留在容器一端的插入/删除操作即可,对应到stack上也就是push和pop两个操作。

通过今天的学习,相信你就能理解在函数里,调用栈为什么也叫栈了吧?就是因为函数调用的过程中,函数上下文的产生与销毁天然符合栈后进先出的特点。这在各个语言的编译器中都有体现,有兴趣的话你可以看看你熟悉的语言中调用栈实现的细节。

课后作业

最后留一个小问题给你,既然stack可以有很多种实现方式,为什么STL选择了用 deque 来实现栈呢?通过 vector 实现会有什么好处或弊端吗? 时间复杂度又有什么差异?

欢迎你在留言区留言,一起交流今天的学习感悟。我们下节课见~

05|HashMap:一个优秀的散列表是怎么来的?

作者: 黄清昊

你好,我是微扰君。

过去四讲我们学习了STL中全部的序列式容器,数组、链表、队列、栈;今天来谈一谈另一类容器,关联式容器。所谓“关联式”,就是存储数据的时候,不只是存储元素的值本身,同时对要存储的元素关联一个键,形成一组键值对。这样在访问的时候,我们就可以基于键,访问到容器内的元素。

关联式容器本身其实是STL中的概念,其他高级语言中也有类似的概念。我们这次就以JDK为例,讲解几种关联式容器的原理和实现。

统计单词次数

我们就从一个实际需求讲起。现在有一篇很长的英文文档,如果希望统计每个单词在文档中出现了多少次,应该怎么做呢?

如果熟悉HashMap的小伙伴一定会很快说出来,我们开一个HashMap,以string类型为key,int类型为value;遍历文档中的每个单词 word ,找到键值对中key为 word 的项,并对相关的value进行自增操作。如果该key= word 的项在 HashMap中不存在,我们就插入一个(word,1)的项表示新增。

这样每组键值对表示的就是某个单词对应的数量,等整个文档遍历完成,我们就可以得到每个单词的数量了。用Java语言实现这个逻辑也不难。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.HashMap;
import java.util.Map;
public class Test {
public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
String doc = "aaa bbb ccc aaa bbb ccc ccc bbb ccc ddd";
String[] words = doc.split(" ");
for (String s : words) {
if (!map.containsKey(s)) {
map.put(s, 1);
} else {
map.put(s, map.get(s) + 1);
}
}
System.out.println(map);
}
}

但是HashMap是怎么做到高效统计单词对应数量的?它设计思路的核心究竟是什么呢?这个问题非常有意思,我们一起来思考一下。

一个单词

要统计每个单词的数量有点复杂,如果只统计某一个单词的数量呢,是不是就很好做了?

只需要开一个变量,同样遍历所有单词,遇到和目标单词一样的,才对这个变量进行自增操作;等遍历完成,我们就可以得到该单词的数量了。

按这个思路,一种很简单的想法当然是直接对每一个单词都统计一遍数量,我们把能想到的所有可能出现的单词都列出来,每个单词,单独用一个变量去统计它出现的数量,遍历所有单词,写一堆if-else来判断当前单词应该被累计到哪个变量中

下面的代码是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.util.HashMap;
import java.util.Map;
public class Main {
public static void main(String[] args) {
int[] cnt = new int[20000];
String doc = "aaa bbb ccc aaa bbb ccc ccc bbb ccc ddd";
String[] words = doc.split(" ");
int aaa = 0;
int bbb = 0;
int ccc = 0;
int ddd = 0;

for (String s : words) {
if (s == "aaa") aaa++;
if (s == "bbb") bbb++;
if (s == "ccc") ccc++;
if (s == "ddd") ddd++;
}
}
}

在代码中就对目标文本统计了aaa、bbb、ccc、ddd这四个单词出现的次数。

但这样的代码显然有两个很大的问题:

  1. 对单词和计数器的映射关系是通过一堆if-else写死的,维护性很差;
  2. 必须已知所有可能出现的单词,如果遇到一个新的单词,就没有办法处理它了。

解决办法有没有呢?这个时候我们不禁想到了老朋友——数组。

我们可以开一个数组去维护计数器。具体做法就是,给每个单词编个号,直接用编号对应下标的数组元素作为它的计数器就好啦。唯一麻烦的地方在于,如何能把单词对应到一个数字,并且可以不同的单词有不同的编号,且每个单词都可以通过计算或者查找等手段对应到一个唯一的编号上

图片

解决思路

一种思路就是把文档中出现的字符串,也放在数组里,按照单词出现的顺序对应从0开始连续编号。

所以,一共要建立两个数组,第一个数组用于存放所有单词,数组下标就是单词编号了,我们称之为字典数组;第二个数组用于存放每个单词对应的计数器,我们称之为计数数组。这样,单词的下标和计数器的下标是一一对应的

图片

每遇到一个新的单词,都遍历一遍字典数组,如果没有出现过,我们就将当前单词插入到字典数组结尾。显然,通过遍历字典数组,可以获得当前单词的序号,而有了序号之后,计数数组对应位置的统计计数也非常简单。这样,我们就可以非常方便地对任意文本的任意单词进行计数了,再也不用提前知道文档中有哪些单词了。

至于数组开多大,可以根据英文词典的常用单词数来考虑,相信大部分文档中出现的单词很难超过1w个,那么绝大部分时候,开一个长度为1w的数组肯定就可以满足我们的需求。这样的字典数组,也有人叫做符号表,比如Haskell中的内置map就是基于这个思路实现的。

但很显然,这样的编号方式代价还是非常高,因为基于这么大的数组判断每个单词是否出现过,显然是一个O(D)的操作,其中D代表整个字典空间的大小,也就是一个文档中有多少个不同的单词。整体的时间复杂度是O(D*N),这并不令人满意。

图片

那这里的本质问题是什么呢?这个问题,其实可以抽象成一个给字符串动态编码的问题,为了让我们不需要遍历整个符号表来完成指定键的查找操作,我们需要找到一个更高效的给字符串编码的方式

优化思路

整体的优化方式大概分成两类。

  • 一种是我们维护一个有序的数据结构,让比较和插入的过程更加高效,而不是需要遍历每一个元素判断逐一判断,下一讲会介绍的关联式容器TreeMap就是这样实现的。
  • 另一种思路就是我们是否能寻找到一种直接基于字符串快速计算出编号的方式,并将这个编号“映射”到一个可以在O(1)时间内基于下标访问的数组中呢?

当然是有的,并且方式很多。

以单词为例,英文单词的每个字母只可能是 a-z,那如果想用数字表示单词,最简单的方式莫过于用26进制来表示这个单词了。具体来说就是,我们用0表示a、1表示b,以此类推,用25表示z,然后将一个单词看成一个26进制的数字即可。

图片

基于前面的思路,我们可以开一个比较大的数组来统计每个单词的数量,单词对应的计数器就是计数数组中下标为字符串所对应的二十六进制数的元素。翻译成代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.util.HashMap;
import java.util.Map;
public class Main {
&nbsp; &nbsp; public static void main(String[] args) {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; int[] cnt = new int[20000];
&nbsp; &nbsp; &nbsp; &nbsp; String doc = "aaa bbb ccc aaa bbb ccc ccc bbb ccc ddd";
&nbsp; &nbsp; &nbsp; &nbsp; String[] words = doc.split(" ");
&nbsp; &nbsp; &nbsp; &nbsp; for (String s : words) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; int tmp = 0;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for (char c: s.toCharArray()) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tmp *= 26;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tmp += (c - 'a');
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cnt[tmp]++;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; String target = "aaa";
&nbsp; &nbsp; &nbsp; &nbsp; int hash = 0;
&nbsp; &nbsp; &nbsp; &nbsp; for (char c: target.toCharArray()) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; hash *= 26;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; hash += c - 'a';
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; System.out.println(cnt[hash]);
&nbsp; &nbsp; }
}

这样,我们就得到了统计单词数的非常优秀的时间复杂度了,在近似认为单词26进制计算复杂度为O(1)的前提下,我们统计N个单词出现数量的时候,整体甚至只需要O(N)的复杂度,相比于原来的需要遍历字典O(D*N)的做法就明显高效的多。

这其实就是散列的思想。

图片

散列

在散列表中,我们所做的也就是为每一个key找到一种类似于上述26进制的函数,使得key可以映射到一个数字中,这样就可以利用数组基于下标随机访问的高效性,迅速在散列表中找到所关联的键值对。

所以散列函数的本质,就是将一个更大且可能不连续空间(比如所有的单词),映射到一个空间有限的数组里,从而借用数组基于下标O(1)快速随机访问数组元素的能力

但设计一个合理的散列函数是一个非常有挑战的事情。比如,26进制的散列函数就有一个巨大的缺陷,就是它所需要的数组空间太大了,在刚刚的示例代码中,仅表示长度为3位的、只有a-z构成的字符串,就需要开一个接近20000(26^3)大小的计数数组。假设我们有一个单词是有10个字母,那所需要的26^10的计数数组,其下标甚至不能用一个长整型表示出来。

图片

这种时候我们不得不做的事情可能是,对26进制的哈希值再进行一次对大质数取mod的运算,只有这样才能用比较有限的计数数组空间去表示整个哈希表。

然而,取了mod之后,我们很快就会发现,现在可能出现一种情况,把两个不同的单词用26进制表示并取模之后,得到的值很可能是一样的。这个问题被称之为哈希碰撞,当然也是一个需要处理的问题。

比如如果我们设置的数组大小只有16,那么AA和Q这两个字符串在26进制的哈希函数作用下就是,所对应的哈希表的数组下标就都是0。

好吧,计算机的世界问题总是这样接踵而至。就让我们来逐一解决吧。

JDK的实现

现在,我们来好好考虑一下散列函数到底需要怎么设计。

  • 整个散列表是建立在数组上的,显然首先要保证散列函数输出的数值是一个非负整数,因为这个整数将是散列表底层数组的下标。
  • 其次,底层数组的空间不可能是无限的。我们应该要让散列函数在使用有限数组空间的前提下,导致的哈希冲突尽量少
  • 最后,我们当然也希望散列函数本身的计算不过于复杂。计算哈希虽然是一个常数的开销,但是反复执行一个复杂的散列函数显然也会拖慢整个程序。

带着这些思考,一起来看看JDK中对散列函数的选择吧。

在JDK(以JDK14为例)中Map的实现非常多,我们讲解的HashMap主要实现在 java.util 下的 HashMap 中,这是一个最简单的不考虑并发的、基于散列的Map实现。

找到用于计算哈希值的hash方法:

1
2
3
4
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

可以发现非常简短,就是对key.hashCode()进行了一次特别的位运算。你可能会对这里的,key.hashcode 和 h>>>16,有一些疑惑,我们来看一看。

hashcode

而这里的hashCode,其实是Java一个非常不错的设计。在Java中每个对象生成时都会产生一个对应的hashcode。当然数据类型不同,hashcode的计算方式是不一样的,但一定会保证的是两个一样的对象,对应的hashcode也是一样的

所以在比较两个对象是否相等时,我们可以先比较hashcode是否一致,如果不一致,就不需要继续调用equals,从统计意义上来说就大大降低了比较对象相等的代价。当然equals和hashcode的方法也是支持用户重写的。

既然今天要解决的问题是如何统计文本单词数量,我们就一起来看看JDK中对String类型的hashcode是怎么计算的吧,我们进入 java.lang 包查看String类型的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int hashCode() {
// The hash or hashIsZero fields are subject to a benign data race,
// making it crucial to ensure that any observable result of the
// calculation in this method stays correct under any possible read of
// these fields. Necessary restrictions to allow this to be correct
// without explicit memory fences or similar concurrency primitives is
// that we can ever only write to one of these two fields for a given
// String instance, and that the computation is idempotent and derived
// from immutable state
int h = hash;
if (h == 0 && !hashIsZero) {
h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
if (h == 0) {
hashIsZero = true;
} else {
hash = h;
}
}
return h;
}

Latin和UTF16是两种字符串的编码格式,实现思路其实差不多,我们就来看StringUTF16中hashcode的实现:

1
2
3
4
5
6
7
8
public static int hashCode(byte[] value) {
int h = 0;
int length = value.length >> 1;
for (int i = 0; i < length; i++) {
h = 31 * h + getChar(value, i);
}
return h;
}

啊哈!我们发现也没有多么高深嘛,就是对字符串逐位按照下面的方式进行计算,和展开成26进制的想法本质上是相似的。

1
s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

不过一个非常有趣的问题是为什么选择了31?

答案并不是那么直观。首先在各种哈希计算中,我们比较倾向使用奇素数进行乘法运算,而不是用偶数。因为用偶数,尤其是2的幂次,进行乘法,相当于直接对原来的数据进行移位运算;这样溢出的时候,部分位的信息就完全丢失了,可能增加哈希冲突的概率。

而为什么选择了31这个奇怪的数,这是因为计算机在进行移位运算要比普通乘法运算快得多,而31*i可以直接转化为(i &lt;&lt; 5)- i ,这是一个性能比较好的乘法计算方式,现代的编译器都可以推理并自动完成相关的优化。StackOverflow上有一个相关的讨论非常不错,也可以参考《effective Java》中的相关章节。

h>>>16

好,我们现在来看 ^ h &gt;&gt;&gt; 16 又是一个什么样的作用呢?它的意思是就是将h右移16位并进行异或操作,不熟悉相关概念的同学可以参考百度百科。为什么要这么做呢?

哦,这里要先跟你解释一下,刚刚那个hash值计算出来这么大,怎么把它连续地映射到一个小一点的连续数组空间呢?想必你已经猜到了,就是前面说的取模,我们需要将hash值对数组的大小进行一次取模。

那数组大小是多少呢?在 HashMap 中,用于存储所有的{Key,Value}对的真实数组 table ,有一个初始化的容量,但随着插入的元素越来越多,数组的resize机制会被触发,而扩容时,容量永远是2的幂次,这也是为了保证取模运算的高效。马上讲具体实现的时候会展开讲解。

总而言之,我们需要对2的幂次大小的数组进行一次取模计算。但前面也说了对二的幂次取模相当于直接截取数字比较低的若干位,这在数组元素较少的时候,相当于只使用了数字比较低位的信息,而放弃了高位的信息,可能会增加冲突的概率。

所以,JDK的代码引入了^ h &gt;&gt;&gt; 16 这样的位运算,其实就是把高16位的信息叠加到了低16位,这样我们在取模的时候就可以用到高位的信息了。

当然,无论我们选择多优秀的hash函数,只要是把一个更大的空间映射到一个更小的连续数组空间上,那哈希冲突一定是无可避免的。那如何处理冲突呢?

JDK中采用的是开链法。

图片

哈希表内置数组中的每个槽位,存储的是一个链表,链表节点的值存放的就是需要存储的键值对。如果碰到哈希冲突,也就是两个不同的key映射到了数组中的同一个槽位,我们就将该元素直接放到槽位对应链表的尾部。

JDK代码实现

现在,在JDK中具体实现的代码就很好理解啦,table 就是经过散列之后映射到的内部连续数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
transient Node<K,V>[] table;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//在tab尚未初始化、或者对应槽位链表未初始化时,进行相应的初始化操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 查找 key 对应的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历所有节点 依次查找
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

通过hash函数的计算,我们可以基于这个数组的下标快速访问到key对应的元素,元素存储的是Node类型。

估计你会注意到第21行进行了一个treeifyBin的操作,就是说当哈希冲突产生的链表足够长时,我们就会把它转化成有序的红黑树,以提高对同样hash值的不同key的查找速度。

这是因为在HashMap中Node具体的实现可以是链表或者红黑树。用红黑树的整体思想和开链是一样的,这只是在冲突比较多、链表比较长的情况下的一个优化,具体结构和JDK中另一种典型的Map实现TreeMap一致,我们会在下一讲详细介绍。

好,我们回头看整体的逻辑。

开始的5-8行主要是为了在tab尚未初始化、或者对应槽位链表未初始化时进行相应的初始化操作。从16行开始,就进入了和待插入key的hash值所对应的链表逐一对比的阶段,目标是找到一个合适的槽位,找到当前链表中的key和待插入的key相同的节点,或者直到遍历到链表的尾部;而如果节点类型是红黑树的话,底层就直接调用了红黑树的查找方法。

这里还有一个比较重要的操作就是第40行的resize函数,帮助动态调整数组所占用的空间,也就是底层的连续数组table的大小。

1
2
if (++size > threshold)
resize();

随着插入的数据越来越多,如果保持table大小不变,一定会遇到更多的哈希冲突,这会让哈希表性能大大下降。所以我们有必要在插入数据越来越多的时候进行哈希表的扩容,也就是resize操作。

这里的threshold就是我们触发扩容机制的阈值,每次插入数据时,如果发现表内的元素多于threshold之后,就会进行resize操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 翻倍扩容
}
else if (oldThr > 0) // 初始化的时候 capacity 设置为初始化阈值
newCap = oldThr;
else { // 没有初始化 采用默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor; // 用容量乘负载因子表示扩容阈值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 新扩容部分,标识为hi,原来的部分标识为lo
// JDK 1.8 之后引入用于解决多线程死循环问题 可参考:https://stackoverflow.com/questions/35534906/java-hashmap-getobject-infinite-loop
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 整体操作就是将j所对应的链表拆成两个部分
// 分别放到 j 和 j + oldCap 的槽位
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

看起来 resize 的代码比较复杂,但核心在做的事情很简单,就是要将哈希桶也就是内置的table数组,搬到一个更大的数组中去。主要有两块逻辑我们需要关注一下。

第一部分在第6-26行,主要的工作就是计算当前扩容的数组大小和触发下一次扩容的阈值threshold。

可以看到命中扩容条件的分支都会进入13行的逻辑,也就是每次扩容我们都会扩容一倍的容量。这和c++中STL动态数组的扩容逻辑是相似的,都是为了平衡扩容带来的时间复杂度和占用空间大小的权衡;当然这也是因为我们仍然需要保持数组大小为2的幂次,以提高取模运算的速度。其他行主要是为了处理一些默认参数和初始化的逻辑。

在第22行中,我们还会看到一个很重要的变量loadfactor,也就是负载因子。这是创建HashMap时的一个可选参数,用来帮助我们计算下一次触发扩容的阈值。假设 length 是table的长度,threshold = length * Load factor。在内置数组大小一定的时候,负载因子越高,触发resize的阈值也就越高;

负载因子默认值0.75,是基于经验对空间和时间效率的平衡,如果没有特殊的需求可以不用修改。loadfactor越高,随着插入的元素越来越多,可能导致冲突的概率也会变高;当然也会让我们有机会使用更小的内存,避免更多次的扩容操作。

总结

好,今天关于HashMap源码的分析就到这里啦。红黑树的部分我们下一节讲解TreeMap的时候再好好讨论,到时候你就会知道红黑树和链表之间的性能差距,也能体会到构造可快速访问键值对集合的另一种思路。

图片

现在,如果不借助系统自带的HashMap,相信你应该也可以手写数据结构统计单词的数量了吧?正确的思路就是,根据全文长度大概预估一下会有多少个单词,开一个数倍于它的数组,再设计一个合理的hash函数,把每个单词映射到数组的某个下标,用这个数组计数统计就好啦。

当然在实际工程中,我们不会为每个场景都单独写一个这样的散列表实现,也不用自己去处理复杂的扩容场景。JDK的HashMap或者STL的unordered_map都是非常优秀的散列表实现,你可以好好学习一下相关源码。当然,你也可能会注意到我们在代码中没有任何处理并发的逻辑,这当然导致了线程不安全,在需要保证线程安全的场合可以用ConcurrentHashMap替换。

课后作业

前面我们有提到loadfactor,建议不要修改,那你知道什么时候需要修改吗?欢迎你在评论区和我一起讨论。

拓展资料

关于HashMap的put操作,美团总结了一个非常不错的流程图,可以参考。

图片

06|TreeMap:红黑树真的有那么难吗?

作者: 黄清昊

你好,我是微扰君。

上一讲,我们讲到如何利用散列表解决类似“文档中不同单词计数”的问题,并以JDK中HashMap的实现为例讲解了散列表背后的思想。

单词计数这个问题最基本的解决思路就是建立一个线性的符号表,每次计数的时候,遍历符号表就可以找到对应单词的计数器,做相应的累计计数操作就可以了。

为了更快地查找到单词的计数器,有两种优化思路,一种是我们上一讲学习的基于哈希表的思想,直接将符号表映射到一个连续线性的数组空间,从而获得O(1)的访问时间复杂度;另外一种思路就需要维护一个有序排列的符号表,JDK中的TreeMap就是基于这种思路

试想,如果能够让符号表是有序排列的,我们查找的时候是不是就不用遍历每一个元素,而可以采用二分查找之类的手段了呢?当然也要尽量降低维护这个有序排列的数据结构所花费的代价。

那一种常见的用于实现有序集的数据结构就是红黑树,这也是JDK中TreeMap中Tree的意思。如果你有一定的Java开发经验,相信你一定会知道相比于HashMap,基于红黑树的TreeMap的一个显著特点就是其维护的键值对是有序排列的

如果你一听到红黑树这个词,就有点慌张,觉得这不是自己能驾驭的,今天这节课就来帮你打消这个顾虑。

很多人觉得红黑树很难理解,其实很大程度是因为无论是本科的教学还是网上流传广泛的资料,大部分只是描述了红黑树的一些规则和结论,这就让学习者往往只能死记硬背红黑树是“由红色黑色节点构成的一种近似平衡树”、“不能有连续的两个红色节点”之类的规则,而不知道为什么有这样的规则,当然会非常复杂,很难理解。

但是如果我们追本溯源,从红黑树为什么被发明出来讲起,让你了解红黑树的本质,你就会发现它相当简单好理解了

而且相信大部分程序员应该不会有什么机会要手写红黑树,在正经面试中也绝对不会碰到手写红黑树这样的题目,所以我们掌握红黑树的本质和特性就足够了。当然,红黑树的部分实现细节,我们在最后也会讲到,如果你学有余力可以尝试自己实现一下。

好啦,我们开始今天的学习。

二分查找树

要想轻松理解红黑树,我们需要先来简单复习一下二分查找树和平衡二分查找树的概念,因为红黑树就是一种近似平衡的二分查找树实现。

我们知道,在一个线性的链表里,如果要查找某个特定节点,唯一能做的事情就是从头到尾遍历节点,这带来了平均为O(N)的查找时间复杂度。但是如果数据存储在有序的二分查找树上,情况就大有不同。

二分查找树的二分是指什么呢?

举个非常简单的例子,你在便利店购物,有一个商品忘记扫码触发门禁警报,怎么从一堆商品里迅速定位呢?一个一个扫显然很慢,聪明的方法是将商品先分为两堆过门禁,找到触发的那堆再二分,继续下去,就能迅速找到啦。本质就在于二分每次可以排除掉一半的查询范围

二分查找也是非常常见的算法。

比如在有序排列的数组中,因为数组访问任意下标元素的时间复杂度都是O(1),我们就可以通过二分查找法,在O(logN)的时间复杂度里,快速定位任意元素的位置。我们之后有一讲会详细学习数组上的二分搜索算法。

而二分查找树,则是另外一种可以用来实现二分搜索的数据结构。具体来说,我们在一棵普通的二叉树上放置需要存储的符号表,并保证所有节点和其左右子节点满足这样的关系:每个节点的左节点要不为空,要不为比当前节点小的元素(符号);每个节点的右节点要不为空,要不为比当前节点大的元素(符号)。

在这样的约束下,我们的查找只需要从根节点出发,比较当前节点和目标元素之间的大小,要么往左走,要么往右走;这是因为比当前节点大的元素一定在当前节点右边,反之则在当前节点左边,所以我们每次比较总可以排除左右子树中的一颗

反复进行这样的搜索过程,直到当前节点已经是叶子结点,或者当前结点和目标元素相等为止。需要比较的次数最多为树的最大高度h,因此整个搜索过程的时间复杂度就是O(h),显然,O(h)在大部分时候是一个比O(n)小得多的数。

图片

比如要查找值等于6的结点,在原始的链表中我们可能需要遍历完整个链表,需要6次,而在二分搜索树中,我们只需要沿着根节点一路往右子节点遍历,一共比较3次即可。

但是这样的二叉树在极端的情况下也会退化成链表。

平衡二分查找树

比如下图也是一个满足约束的二分查找树,但所有的结点都在树的一侧排列。在这样带有极度偏向性的树中,我们查找节点的效率其实和链表没有什么区别,反而还用了更多的空间。

图片

所以理想中,如果要实现具备良好查找特性的OrderedMap,我们需要同时保证树的有序性和平衡性,这里平衡性指的是,树上每个节点的左右子树的高度差要尽量小,最好不要超过一。比如前面的图就是一个很好的平衡二分查找树,这里的图就是一个退化成单链表的情况。

对于一个平衡的有N个元素的二分查找树,其高度可以近似认为是logN,所以查找的时间复杂度就是O(logN)。 这意味着一张有10000个元素的符号表,我们想要查询出任意一个元素,最多也只需要进行大约13次左右的比较即可。这显然是一个非常令人满意的结果。

哦,为什么高度是O(logN)呢?我们近似计算一下。

以满二叉树为例,也就是一颗除了叶子结点之外的结点都有两个子节点的二叉树。相信你很容易发现每一层能承载的容量范围都是上一层的一倍,那么,最多在第logN层,只把这一层的节点加起来,就足够承载需要存储的所有N个元素了

而平衡树相比于满二叉树的最大高度显然是偏差有限的,高度应该也就是O(logN)这个数量级了。

那如何才能兼顾有序性和平衡性呢?这就是我们今天的主角红黑树出场的时候了。

红黑树

没错,红黑树就是这样一种兼顾了有序性、平衡性的自平衡二叉树的实现。因为它查找高效,成为许多语言实现内置ordered_map的首选,另外在Nginx的Timer管理、Linux虚拟内存管理等场景下,红黑树也都承担着重要的角色。

其实之前还有人提出了一些不同的平衡二分搜索树的实现,比如 AVL-Tree、2-3-4树等,但因为这些实现保持绝对平衡的代价比较高且往往实现复杂,并没有像红黑树这样遍布于各大需要有序键值对存储的场景。

那么红黑树到底是如何实现的呢?

要更好地理解红黑树的设计,首先你要理解红黑树的由来——红黑树本质上是对“2-3树”的一种实现

那就让我们先一起来学习一下“2-3树”的实现,相信你学完一定会恍然大悟,红黑树这么奇怪的红色黑色的节点设计原来是这样来的。

2-3树

2-3树,也是一种平衡查找树的实现,思想很简单,为了让树能更好地平衡自己,我们除了普通的2节点之外,还引入了一种3节点,这让我们在平衡树高度的时候增大了很大的灵活性。看一个典型的2-3树例子。

图片

在2-3树中,2节点,和普通二叉树的节点其实没有什么太大的区别,有一个键和两条链,分别链向左子树和右边子树。

而3节点,则在2节点的基础上增加了一个键,构成了一个有两个键和三条链的结构。下图是3节点的示意图,左链链向了小于a的节点,右链链向了大于b的节点,中间的区域则是a和b之间的节点。

图片

在2-3树中搜索的过程和二叉树并没有太多的区别,只是遇到3节点的时候,多判断一次是否介于a、b之间即可。

设计有了, 我们看插入新元素的时候会发生什么。

在插入过程中,当我们查找到了某个叶子结点发现并不存在该键时,如果遇到了2节点,非常好办,直接加个键将该节点升级为3节点即可。比较麻烦的是遇到了3节点,因为我们已经不能再在该节点中直接多加一个键创造一个4节点了,怎么办呢?

其实办法也不难想,我们把当前的4节点多出的键向上转移。看图理解,比如要对下图中的2-3 树插入26节点,那首先会沿着根节点一路查询到“19 24”子结点,发现该节点为一个3节点。

那么我们首先将26放入该子节点,使之成为一个4节点,然后将4节点的中间键也就是24,提升到上一层,将其父节点替换成一个包含24的3节点

图片

如果原父节点也是一个3节点的话,我们就递归进行同样的操作直至根节点。最后,如果根节点也是一个3节点,我们就将根节点的中键提升到第一层,然后左右链分别链向原来根节点的左键和右键。以下图为例,b键就被独立地提升为新的根节点,左右节点指向a和c,而原4节点的中间两个链也分别成为a的右链和c的左链。

图片

这样的操作可以保证整个2-3 Tree是一个真正意义上的平衡树。但是,因为它的实现引入了两种异构的节点,导致代码写起来相当复杂,并没有被广泛使用。

而红黑树,正是采用标准的二叉查找树节点附着上额外的颜色信息来表示2-3树的实现,每一个红色节点都和它的父亲节点一起,构成了一个3节点的模拟,这就是红黑树设计的本质

红黑树

所以,我们再把红黑树的定义拿出来,红黑树是一个满足下述几个约束且所有节点要么为红色要么为黑色的二分有序查找树:

  1. 根节点为黑色
  2. 相邻的节点不能同时为红色
  3. 每个节点从自身到各个能到达的叶子结点的所有路径中的黑色节点数量相等
  4. 红节点只能作为左子节点存在(这是左偏红黑树特有的要求,我们以左偏红黑树为例讲解)

所有这些约束,都是为了保证每一颗红黑树和2-3 Tree是一一对应的,相信你看下面这颗“展平”的红黑树就能理解我在说什么了。

图片

我们一起顺一下。

一个3节点有两个键、三条链,那我们完全可以把一个以红节点为左子节点的黑节点和子节点一起看成一个3节点。在下图中,上下两个图其实就可以认为是等价的。

图片

我们再来看红黑树的3个普遍约束,你会发现很好理解:

  1. 因为红色节点只是3节点的一部分,那对应到红黑树上,显然不会出现两个连续的红色结点;
  2. 2-3树上,每个节点到叶子节点的数量一定是一样的,且每个节点对应到红黑树上一定包含且只包含一个黑色节点,所以红黑树每个节点到叶子结点路径中的黑色节点数量也必然是一样的。

唯一不一样的就是“根节点为黑色”。事实上,如果只是为了让红黑树保持平衡,我们完全可以抛弃这条规则。因为在2-3树中,我们也完全是可以用3节点作为根节点的。

对应到红黑树中,当根节点为红色,插入新节点后很可能为了使根节点到每条路径上的黑色节点数量相等,进行变色和旋转操作,最终根节点还是会变成黑色;既然如此,我们何不直接约束根节点必须调整成黑色,方便进行插入操作呢。

这样,我们就可以将每一个红黑树都映射成一个2-3 树,也因此就获得了2-3 树高效的平衡插入的能力,并保留了二叉树查找的简洁性。之后在理解红黑树的时候,如果你能时刻展平成2-3 树看待,一定会觉得,哦,红黑树的实现其实也没有想象中的那么困难。

最后,我们来看一些红黑树的基本操作,帮助你更好地理解红黑树和2-3树之间的关系。

旋转操作的实现

红黑树的所有实现细节,其实也都是围绕着2-3树的2、3节点的诞生和转移展开的,我们就以“插入方法”的实现来具体讨论(仍以左偏红黑树为例)。

红黑树中最基本的自平衡操作就是“旋转”,分为“左旋”和“右旋”两种。这两种操作主要用于处理在插入和删除时产生的右偏红节点或者连续的两个红色节点,通过调整红节点的位置,我们可以修复这些不满足约束的情况。

看“左旋”和“右旋”的具体操作。以左旋为例,本质上就是将某个3节点从以较小的键为根转移成较大的键为根,也就是从a为根转到b为根,当然同时需要把介于a和b之间的节点挂到a的右节点下。这样得到的新树就是以b为根结点的结构,并且在整个过程中,树的平衡性和有序性都没有被破坏,而原来不符合约束的右偏红节点已经被转移成“正确”的左偏红节点。

图片

现在,我们根据插入时是在“2节点”还是“3节点”分开讨论,看看旋转操作具体是如何用于保证约束正确的。

2节点插入

首先我们来看针对“2节点”的插入,对应到红黑树的语境中,也就是针对普通黑色节点的插入。那显然只有两种情况,要么插入在左边,要么插入在右边。

如果插入在左边,非常简单,我们可以直接将2节点提升为3节点,由于新增的是左侧的红节点,完全不会破坏树的平衡性。对应到红黑树上,就是简单地将新节点放到查找到的最后一个节点的左边。

图片

如果插入在右边,其实是一样的,我们也将2节点提升成一个不符合“左偏”规则的3节点,然后进行一次左旋转即可。由于插入的也是红节点,并不影响树的平衡性。

3节点插入

再看插入“3节点”,情况和操作都会复杂一些,我们根据插入的结点在3节点中左键的左侧、右键的右侧和两者之间分开讨论(还是左偏红黑树)。

最简单的情况就是插入在右键的右侧。和3节点分解的方式一样,我们只需要把中间的结点提升到上一层,并左右节点变成黑色即可。

图片

而另外两种情况单独看这一个节点的变化,也并不复杂,只需要插入后进行一到两次旋转操作即可。

图片

但这样是不是就完成了所有操作呢? 并不是。

还有一个很大的问题我们没有处理,就是对3节点的操作中,我们虽然保证了“插入操作”对当前子树的“平衡性”没有被破坏,但由于将红色节点变成了黑色,就有可能导致当前子树的黑色节点高度比其他子树高了。

所以我们还需要进行一种叫做“颜色反转”的操作。

每次插入时,最后一步,除了将3节点的左右节点都变成黑色,同时要将3节点的中间键变成红色,这样当前子树到各个子节点路径中的黑色节点数量就不会有变化啦。

图片

当然这个操作是需要递归进行的。因为父节点如果变成红色,也同样可能造成右偏红节点或者连续红节点这样不符合约束的情况,这其实等价于在父节点的父节点下插入了一个新的红节点。我们用类似的逻辑自下而上递归即可,递归的终点就是,遇到根节点我们将其拆分成3个“2节点”,或者遇到某个2节点我们将其升级为“3节点”时,我们就可以结束递归。

讨论好这些case,我们就可以在整个插入节点的过程中保证不破坏“有序性”和“平衡性”了。

删除的操作和插入类似,感兴趣的话,你可以课后自己通过纸笔模拟一下。整个过程确实比较复杂,许多细节过一段时间可能也会有所遗忘,不过只要你理解了红黑树的本质,是在二叉树基础上对“2-3 Tree”的实现,一定能迅速回忆起红黑树的约束条件。到这里,无论是准备面试,还是帮助你了解许多用到红黑树的中间件源码来说,都已经绰绰有余了。

操作红黑树的复杂度

最后我们来稍微计算一下红黑树上检索数据的复杂度。

前面我们提到,二叉搜索树检索键的最差时间复杂度取决于树的最大高度,是O(logN),那求红黑树上检索数据的时间复杂度就等同于求红黑树的最大高度了。

我们将一个红黑树展平,并对应到2-3树。

2-3树是一颗平衡的树,由于每个节点只可能比二叉树多出一个键,在相同的高度下只会承载更多的元素,所以其高度不可能高于二叉平衡树的高度logN。

而红黑树只是把2-3树的每个“3-节点”拆成了一黑一红两个节点,其高度不可能比2-3树的最大高度翻倍还多;当然你也可以从“红色节点不能相邻”这一约束得出类似的结论,毕竟本质这两者是等价的。

所以,满足这样约束的红黑树的最大高度最多也就是2*logN,因此可以得到良好的O(logN)的查询复杂度。插入、删除复杂度显然也只和高度有关,我们最多需要进行常数倍于树的最大深度次旋转和颜色反转操作,所以复杂度也是O(logN)。

统计单词数量

好了,有了红黑树这样一个可用于动态、高效查找/删除/插入数据的数据结构,维护一个符号表也当然就很简单啦。如果依旧让你统计某文档中不同的单词出现的数量,我们就可以维护一颗以不同单词为键,出现次数为值的红黑树,在比较树的有序关系时只比较键的关系即可。

这样,每次遇到一个新的单词就在红黑树中查找相应的键是否存在,如果有存在就将对应节点的出现次数自增,如果没有存在就插入一个新的节点,让其值为1。

写成Java代码,你会发现和上一讲HashMap也没有有什么区别,只是将HashMap换成了TreeMap而已,底层机制就我们刚刚梳理的一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.TreeMap;
import java.util.Map;
public class Test {
public static void main(String[] args) {
Map<String, Integer> map = new TreeMap<>();
String doc = "aaa bbb ccc aaa bbb ccc ccc bbb ccc ddd";
String[] words = doc.split(" ");
for (String s : words) {
if (!map.containsKey(s)) {
map.put(s, 1);
} else {
map.put(s, map.get(s) + 1);
}
}
System.out.println(map);
}
}

假设有N个单词,则红黑树最大高度为logN,所以单词查询的最大时间复杂度为O(logN),这样的查询一共会进行N次,所以整体的时间复杂度为O(NlogN),从时间效率上来说确实是低于HashMap的。

总结

红黑树的查询、插入、删除的时间复杂度都非常良好且稳定,广泛运用于各种中间件里,还是非常值得掌握的。

当然红黑树是一个比较复杂的数据结构,如果直接从定义和几条绕口令一样的约束入手,很不好学。不过只要掌握它的本质,其实很简单。只要搞清楚,红黑树本质是2-3树在二叉树上的一种模拟,通过旋转操作完成2-3节点的合并和分裂,从而在不改变二叉树节点结构的前提下,保证二叉树的有序性和平衡性

相信你理解了这一层关系,就可以对红黑树有一个比较不错的掌握了;相比于只是死记硬背红黑树的各种特性和复杂约束,追本溯源显然是一个更好的选择,这在你学习技术知识乃至其他领域,都是值得推荐的学习方式。

课后作业

那什么时候我们该用TreeMap什么时候该用HashMap呢?欢迎你留言与我一起讨论哦!

拓展阅读

给感兴趣的同学推荐一下我觉得最好的学习材料《Algorithms》,作者是左偏红黑树的发明者。

07|堆:如何实现一个高效的优先队列?

作者: 黄清昊

你好,我是微扰君。

上一讲学习了基于红黑树的ordered_map的实现,今天我们来介绍另外一种有趣的树,heap,也就是堆。堆的应用非常广泛,我们常说的堆排序的堆就是指这种树状数据结构,除此之外还可以用来解决诸如TopK,或者合并多个有序小文件之类的问题。

堆也是我们最后一个基础数据结构容器-优先队列的常见底层实现方式。

优先队列

你一定还记得之前讲过的线性数据结构queue吧,也就是队列,一种先进先出的数据结构,生活中这种先进先出的场景也很常见,我们当时举了一个在餐厅排队取号的例子,先来的人一定会先取到号,也先被叫到号,这完美地符合队列的语义。

图片

那如果我们现在希望允许一部分人插队呢?比如在医院中,大部分病人都有序的挂号排队,但这时候如果来了一个重症病患,我们就很可能需要破坏先进先出的规则,让医生优先诊断治疗这位病患。这种场景中的队列,我们就可以定义为“优先队列”。

优先队列中的每个元素,我们会赋予它一个优先级,优先级相同的元素我们还是遵循先进先出的原则,但一定会保证队列中优先级更高的元素先出队,即使它进队时间更晚。比如例子中的重症病患来的并不早,但依旧得到了医生的优先治疗。

对于这种优先队列,我们应该如何高效地实现呢?

如何实现

其实也很容易想到不止一种方案。

比如,一种比较暴力的思路可以是:我们依旧用线性容器存储元素。在入队的时候,我们不关心优先级的影响直接按顺序存入容器中,出队的时候,则遍历容器找到最高优先级的元素出队

由于入队的时候没有对优先级做任何处理,所以出队的元素显然可能在线性容器中任意一个位置,基于之前所学的知识,遇到节点删除的场景,用链表显然比用动态数组有更好的时间复杂度。但即使如此,每次出队时我们也需要遍历链表,所以时间复杂度为O(N)。

那与之相对的,另一种同样基于链表的思路也可以是,我们每次在入队的时候进行一些额外的调整,使得整个队列一直满足优先级更高的元素在更前面的约束,这样出队的时候就比较简单。当然这样会导致入队的时候都需要进行一次类似于插入排序的操作,最差情况下也会要遍历完整个链表,时间复杂度同样为O(N)。

假设出队和入队操作数量相等,均摊下来,每一次操作的时间复杂度就是O(N)。

看示意图辅助你理解,队列里的数字代表权重,a、b、c、d、e 代表着入队的值,我们假设入队顺序和值的字典序是一致的。上面的队列画的就是入队的时候就按照优先级排好序的情况,所以直接从队尾出队即可;下面的队列是入队的时候直接放到队尾,出队的时候要按照优先级取出元素。

图片

那有没有一种入队和出队都相对来说比较高效的方式呢?答案是肯定的,只是我们需要抛弃线性的存储结构

不知道你是不是也想到了上一章讲的红黑树。其实你的想法是对的,红黑树当然是可以用来实现优先级队列的一种方式,我们建红黑树的时候以优先级为key作为排序依据即可。入队的时候可以直接push入队,出队pop的时候先从树中找到优先级高的,也就是树的最右节点,然后移除即可。

这些操作的复杂度都是O(logN),所以出队和入队的复杂度自然也就是O(logN)。所以,基于红黑树的优先队列复杂度均摊下来,相比于之前基于线性表的O(N)复杂度,显然更胜一筹。

但是由于我们不会进行类似“找出优先级第3高的元素出队”这样的操作,其实并不需要一直维护完全的顺序信息,只是需要能在每次出队时,找到优先级最高的元素即可。那有没有更合适的选择呢

相比复杂的红黑树,简明的“二叉堆”就是这样一种特别适合用来动态维护一组元素中最大或者最小值的数据结构,它也是各大语言实现优先级队列的首选。

二叉堆

二叉堆,这个数据结构是1964年斯坦福大学教授Robert W.Floyd和J.Williams在发明堆排序的时候提出,之所以想介绍一下它的历史,主要是因为Robert W.Floyd是一个文科出生,后来自学计算机科学,逆袭成为斯坦福终身教授的大佬。我想这多多少少能证明热爱的力量,也能给可能是非科班出身的你我一些信心吧。

好啦回归正题,“二叉堆”,也就是binary heap,顾名思义,这个数据结构也是建立在一种特别的二叉树上的。

它主要有两个约束:

  1. 二叉堆是一颗满二叉树。也就是说,除了最后一层外的每一层都没有空节点,且最后一层所有节点靠左排列,不存在从左到右中间有某些节点为空。
  2. 二叉堆中的每个节点和其子节点都有一样的偏序关系,要么大于要么小于,这两种情况分别对应大顶堆和小顶堆。所以大顶堆就要求堆中所有节点的值,一定大于其左右子树中的任何一个节点的值,也就是说顶部的节点一定是最大的,故称为大顶堆。小顶堆就正好相反。

有了这样的约束,可以保证根节点要么是最大的要么是最小的,也让我们在出队入队的操作里调整的成本很小,整个过程有点像冒泡排序的感觉,我们马上讲解具体的细节。

比如下图中左边就是一个二叉堆,但中间因为不满足满二叉树的约束,就不是一个二叉堆;右边因为不满足完全的有序关系也不是一个二叉堆。

图片

为什么这么设计

那现在我们来看看,这样的数据结构设计对于维护优先队列有什么帮助呢?和红黑树一样,二叉堆这样的树状结构同样暗含了关键的顺序信息,而我们核心就是利用这样的信息,在插入时进行更少的操作,而避免像线性表那样做从头到尾的顺序遍历。

为了实现优先级队列,我们会用优先级,priority,来作为二叉堆节点间大小比较的依据。假设我们始终希望出队的是优先级更高的元素,那可以采用大顶堆作为优先队列的底层实现,这样每次只需要从顶部取出元素即可获得优先级最高的元素。

当然很重要的一点是,取出元素后显然会在树的顶部产生一个空位,我们需要进行一定的操作使得大顶堆的性质得以保全。

同样,为了享受直接从顶部取出优先级最高元素的便利,我们在插入元素时也要让二叉堆的性质得以保持。

幸运的是,正是因为二叉树是一个满二叉树,其高度约等于LogN,其中N为优先队列中元素的个数。而第二条约束父子节点之间的有序关系,让我们每次做pop和push操作,只需要经过最多二叉树高度次的交换调整即可保持堆的所有特性。

这样,我们就得到了一个入队和出队操作复杂度都为O(LogN)的数据结构,虽然均摊的时间复杂度和红黑树是一样的,但实现的难度却要小很多,所以今天我们就结合源码来讲解。

PriorityQueue的实现

以JDK14中的PriorityQueue为例(后面简称PQ),我们来分析一个生产环境中的优先队列要怎么基于堆实现。

先来看看JDK中优先队列数据结构的基本定义,我们会讲一些重要的成员变量和方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class PriorityQueue<E> extends AbstractQueue<E>
implements java.io.Serializable {
/**
* Priority queue represented as a balanced binary heap: the two
* children of queue[n] are queue[2*n+1] and queue[2*(n+1)]. The
* priority queue is ordered by comparator, or by the elements'
* natural ordering, if comparator is null: For each node n in the
* heap and each descendant d of n, n <= d. The element with the
* lowest value is in queue[0], assuming the queue is nonempty.
*/
transient Object[] queue; // non-private to simplify nested class access

/**
* The number of elements in the priority queue.
*/
int size;

/**
* Inserts the specified element into this priority queue.
*
* @return {@code true} (as specified by {@link Queue#offer})
* @throws ClassCastException if the specified element cannot be
* compared with elements currently in this priority queue
* according to the priority queue's ordering
* @throws NullPointerException if the specified element is null
*/
public boolean offer(E e) { ... }

/**
* Increases the capacity of the array.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) { ... }

public E peek() {
return (E) queue[0];
}

/**
* Inserts item x at position k, maintaining heap invariant by
* promoting x up the tree until it is greater than or equal to
* its parent, or is the root.
*
* To simplify and speed up coercions and comparisons, the
* Comparable and Comparator versions are separated into different
* methods that are otherwise identical. (Similarly for siftDown.)
*
* @param k the position to fill
* @param x the item to insert
*/
private void siftUp(int k, E x) { ... }

public E poll() { ... }
}
}

首先要看堆元素在内存中到底是怎么存储的。可以看到:

  • 成员变量size,它显然用于表示优先队列中元素的大小。
  • 成员变量中有一个叫queue的数组,这就是堆中元素存放的地方。

你这个时候可能就会有些疑惑了?诶?那我们堆的树状结构到哪里去了,为什么还是一个线性的存储结构呢?

其实,在JDK中的PQ实现里,并没有和我们想象中一般的树那样采用节点+指针来实现树状数据结构,而是用了内存上连续的数组来模拟。代码中的queue数组就是用来表示堆这一树状结构的成员变量。

这么讲可能你不是很理解,我们看下面的图。图中画的就是一个大顶堆的示例,上下分别代表堆的树状结构本身和存储到queue数组中的样子。你可以通过元素的值来判断两种表示间的对应关系。

相信你也发现了,图中的数组就是按照对树做层序遍历的方式依次排列的,所以数组中下标为0的元素就是堆顶的元素,我们也用箭头将数组元素中的父子关系都标记出来了。由于二叉树每一层元素个数都是上一层两倍的特性,你会发现,queue[k]节点,它的左子节点为queue[2k+1],右子节点为queue[2k+2]。

事实上,不止二叉堆,其实一般的二叉树也是可以用数组表示的,如果你做过LeetCode上相关试题的话,也会经常碰到类似的表示方式。大部分时候,用数组表示树写起来比较简单,不需要引入类似指针的概念,也不用定义树节点的类或者结构体。

但如果二叉树不是满的,数组中会有大量的空值,非常浪费空间。而堆本身满二叉树的特性,则让我们可以选择用数组作为底层二叉树实现而不至于浪费大量的内存,这就是JDK中为什么可以使用数组作为底层存储的原因。

堆的操作

有了底层的数据表示,下面我们来了解一下堆的两个重要操作,插入和删除。

首先看堆的插入操作,也就是 offer 方法,对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* Inserts the specified element into this priority queue.
*
* @return {@code true} (as specified by {@link Queue#offer})
* @throws ClassCastException if the specified element cannot be
* compared with elements currently in this priority queue
* according to the priority queue's ordering
* @throws NullPointerException if the specified element is null
*/
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length)
grow(i + 1);
siftUp(i, e);
size = i + 1;
return true;
}

该方法接收一个待插入的元素e,在18行中将堆的大小增加1,这个非常直观。有两个函数详细解释一下,grow和siftUp。

grow函数

grow函数用于扩展数组的大小。

我们用数组模拟堆,就必然要给出一个数组的大小,这也就限制了二叉堆的最大大小。但这在使用过程中并不方便,我们没有办法在各种场景下都准确预估堆所需使用的最大元素个数。所以和STL的vector一样,JDK中的PriorityQueue,通过内置的grow函数和扩容机制解决了堆动态大小的问题。grow函数具体做的事情我们最后再讲。

所以在offer方法里,如果发现当前插入的元素已经超过了内置queue数组的容量,我们需要进行扩容操作,这就是第15-16行代码所做的事情。

siftUp函数

现在我们来看siftUp的过程,具体的插入操作其实就隐藏在了siftUp这个函数中。

之所以叫siftUp,就是因为这个插入的过程是自下而上的。我们结合具体例子,来讲解siftUp的过程,先搞清楚思路,再来看对应的代码就很好理解了。

假设有这样的一个二叉堆,我们想要插入一个新的元素。

图片

可以先将该元素插入到数组的尾部,也就是堆中的最后一个位置,这是一个O(1)的操作,因为不涉及任何元素的移位。然后要做的,就是将这个元素一路往上交换使得二叉堆可以保持自身的2条特性。

由于我们是将新的元素放到堆的尾部,没有空开任何一个子节点,所以只是进行元素的交换,并不会破坏堆是满二叉树的特性,因而我们只需要关注父节点比所有子节点大这一特性

所以将该节点不断和其父节点比较,如果比父节点大,将该节点和父节点交换,并继续和该节点新的父节点进行比较。由于原来的父亲节点一定比其左右子节点(其中一个是这次调整的节点,另一个是他的兄弟节点)都大,所以将其和父亲节点交换位置,一定也能保证该节点比其兄弟节点大。

而只要发现该节点已经比父节点小了,我们就可以结束这次的比较、交换之旅,堆已经重新满足了特性。

结合示意图理解,比如在堆的最后一个位置插入92。

图片

我们要先把92和47比较,发现92比47大的时候,互换位置;然后继续比较,会交换92和76的位置,关注父子节点的大小要求特性,因为76本身比52大,所以交换后一定也能保证92比52。这就是堆siftUp的过程。

这里再稍微补充两句,堆和红黑树不一样,我们在整个过程中只关心父子节点之间的大小关系,而不用在意兄弟节点之间的大小关系,比如原本4(47)节点是比5(52)节点小的,现在4节点(76)比5节点(52)大了,这并不会破坏堆的性质。这也是堆之所以调整比红黑树简单很多的地方。

了解这个过程,对应的代码就非常好懂了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void siftUp(int k, E x) {
if (comparator != null)
siftUpUsingComparator(k, x, queue, comparator);
else
siftUpComparable(k, x, queue);
}



private static <T> void siftUpComparable(int k, T x, Object[] es) {
Comparable<? super T> key = (Comparable<? super T>) x;
while (k > 0) {
// 计算父节点的下标
int parent = (k - 1) >>> 1;
Object e = es[parent];
// 比较当前节点和父节点的关系 如果当前节点优先级更高,我们可以直接结束比较
if (key.compareTo((T) e) >= 0)
break;
// 交换节点
es[k] = e;
k = parent;
}
es[k] = key;
}

因为PriorityQueue里存放的可以是任何元素,用户也可以自定义比较关系,所以Java的PQ实现里引入了比较符和泛型的概念。就是Java中泛型相关的语法,siftUpUsingComparator 则给了用户自定义比较符的自由;不熟悉的同学可以自行搜索相关资料了解。

我们直接看siftUpComparable方法,也就是元素类型自带比较方法的情况。

我们要比较这个新节点和父节点的值,那怎么定位父节点呢?回忆一下前面讲queue底层存储的时候讲过的父子节点关系,对queue[k]来说,它的父节点一定是queue[(k-1)/2]。当然k需要大于0,否则k就已经是根节点了

所以11-18行的循环就是在做siftUp中和父节点比较并交换的操作,第14行就是对应key比e大,也就是子节点比父节点大的情况,此时我们就可以退出循环了。这也说明PQ默认情况下实现的是小顶堆。

删除的poll操作

好,下面看PQ中第二个主要操作,poll操作,这是我们返回并删除堆顶元素的操作,也就是从优先队列中取出最高优先级元素的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public E poll() {
final Object[] es;
final E result;
// 取出堆顶元素
if ((result = (E) ((es = queue)[0])) != null) {
modCount++;
final int n;
// 其实就是要将最后一个元素放到顶部
final E x = (E) es[(n = --size)];
// 将最后一个元素置空
es[n] = null;
// 进行siftdown操作
if (n > 0) {
final Comparator<? super E> cmp;
if ((cmp = comparator) == null)
siftDownComparable(0, x, es, n);
else
siftDownUsingComparator(0, x, es, n, cmp);
}
}
return result;
}

重点理解第7行和关键的siftDown函数。

要取出堆顶元素,数组第一个元素就会有所空缺。基于siftUp的想法(还是用刚才的大顶堆),我们第一反应很容易想到,直接从空缺的根节点开始,找其左右子节点中大的一个提拔到当前空缺的位置,然后依次找新的空位左右子节点哪个位置可以提拔上来,直到没有子节点为止。

这个思路确实很自然,但有一个比较大的问题就是,如果我们在第一次比较的时候选择了左节点提升上来,当右节点并不为空时,最后得到的树一定不是一个满二叉树了,这就破坏了堆的基本性质。

图片

那怎么做呢?一个比较巧妙的想法就是和代码里第7行一样,将根节点删除后,我们把二叉堆中最后的元素提到根节点的位置,这样又可以保证新的二叉树是一颗满二叉树了,然后要做的就和前面所说的一样,比较+交换。

看大顶堆的例子,现在要删掉最大的元素92,怎么做呢?首先把堆中最后一个节点也就是47提到堆顶的空位,然后依次比较左右节点;47比90、79都小,但90更大,所以我们用47和90进行交换。同理47和76也需要交换。这样我们就完成了优先队列优先级最高元素出队的操作。

图片

siftDown函数

回到代码,这里说的比较+交换的过程就是由siftDown这个函数完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static <T> void siftDownComparable(int k, T x, Object[] es, int n) {
// assert n > 0;
Comparable<? super T> key = (Comparable<? super T>)x;
int half = n >>> 1; // loop while a non-leaf
while (k < half) {
int child = (k << 1) + 1; // assume left child is least
Object c = es[child];
int right = child + 1;
if (right < n &&
((Comparable<? super T>) c).compareTo((T) es[right]) > 0)
c = es[child = right];
if (key.compareTo((T) c) <= 0)
break;
es[k] = c;
k = child;
}
es[k] = key;
}

5-14行就是之前所说的父亲节点和左右子节点循环比较并交换的过程。k的左子节点下标为(k<<1)+1,右子节点下标为左子节点+1,这就是6行和8行代码的意义。在发现左右子节点都比根节点小之后,同样可以退出循环,否则和左右节点中小的一个交换即可。

嗯,如果只是要查看优先级最高的节点而不用出队,当然是非常简单了,我们直接取堆顶元素即可。也就是之前PQ定义的这几行代码:

1
2
3
public E peek() {
return (E) queue[0];
}

扩容机制

那,最后我们来讲讲PQ的扩容机制,这是PQ虽然用数组作为底层存储,却不用限制优先队列大小的核心。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Increases the capacity of the array.
*
* @param minCapacity the desired minimum capacity
*/
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
int newCapacity = ArraysSupport.newLength(oldCapacity,
minCapacity - oldCapacity, /* minimum growth */
oldCapacity < 64 ? oldCapacity + 2 : oldCapacity >> 1
/* preferred growth */);
queue = Arrays.copyOf(queue, newCapacity);
}

本质和之前讲的STL中的vector扩容思想如出一辙,就是将当前的数组搬到一段更大的连续数组中,新的数组容量为newCapacity,旧的数组容量为oldCapacity。

它俩的关系在第11行中可以看出来:如果原来的数组已经比较大了,那新数组的大小是旧数组大小的1.5倍,否则是2倍再+2。至于为什么要成倍增长,你可以回看vector的章节,都是为了保证良好的均摊时间复杂度。

用于计算容量的newLength的方法还有个参数用于保证最小的扩容大小,如果你感兴趣可以自己研究一下。

总结

学完了JDK中PriorityQueue的主要实现机制和源码,现在你有没有掌握用数组模拟树的技巧呢?如果让你手写一个堆应该也不难了吧,这也是面试中算法的常考题目。

核心就是数组中父子节点的下标关系。下标为k的节点queue[k],它的左子节点为queue[2k+1],右子节点为queue[2k+2]。理解了这一点,去实现siftDown和siftUp操作,相信对你来说不在话下。

当然JDK为了提供一个更通用的优先队列,也引入了泛型,也提供了动态扩容的能力,这些内容和动态数组的实现非常相似的,你可以回去复习并尝试手写实现一下。

课后作业

有了堆,实现优先队列的语义其实并不难。但有个问题想问问你,你觉得Java的优先队列是否真正保证了优先队列语义呢?比如优先队列中相同优先级的元素,是否应该要保证先进先出的特性?如果,JDK中的没有这个保证,但我们却有这样的需求,你觉得应该如何改造呢?

这是一个开放问题,欢迎你在留言区参与讨论。如果你觉得这篇文章对你有帮助,也欢迎你转发给身边的朋友一起学习。我们下节课见~

08|外部排序:如何为TB级数据排序?

作者: 黄清昊

你好,我是微扰君。

之前已经学习了常用数据结构的工业级实现(包括动态数组、双向链表、双端队列、栈、哈希表、红黑树、堆),从今天开始,我们来讲讲一些经典的算法思想在工程实践中的应用。

那讲哪些算法呢?我们都知道算法是一个很大的命题,也有很多分类的方式,比如就有人总结过非常经典的五类常用算法:贪婪算法、动态规划算法、分治算法、回溯算法以及分支限界算法。力扣上的每一道算法题也有相应标签,你感兴趣的话可以到题库看一下。

不过有些算法可能只会在特定的场景下被特定的中间件所使用,比如布隆过滤器、前缀树等等,我们在后面的章节结合实际的系统或中间件来讲解;有一些算法思想应用更为广泛,我们会在这个部分学习,所以基础算法篇主要包括了贪心、分治、二分的算法思想,也会涵盖排序、搜索、字符串匹配这些更为常见的应用场景。

今天就让我们从经典的排序问题开始讲起吧。

排序

排序,应该是我们学习算法和数据结构时最早就会学习到的几个算法问题,按时间复杂度这个标准大体可以分为O(n^2)、 O(nlogn) 、O(n) 三大类。

图片

O(n^2)的选择排序、冒泡排序、插入排序,这些常用的算法相信你应该非常熟练了;几种O(n)的算法在工程中其实也都有实际应用,你也可以自己在网上搜索资料了解学习,最好再找几道相关算法题做一做加深印象。

O(nlogn) 的三个常见算法从概念上看也不难理解,但细节实现起来还是有一些复杂度,需要花点时间刻意练习,是面试中相对重要的算法考点。

其中归并排序和快速排序的常用写法都可以采用递归的方式实现,背后是分治的算法思想,也就是分而治之,把大问题递归拆小然后递归得出结果。

而堆排序的思路和实现,在上一讲优先队列中我们详细讲过,相信你现在应该很容易用Java的PriorityQueue实现堆排序,主要思路其实就是建立一个堆,借助堆动态调整的能力,只需要将所有待排序元素依次入队,再依次出队直到堆元素全部出队为止;比如在小顶堆中,每次出队的都是当前最小的元素。

但只是能写出这样的排序代码,往往并不足以让你解决真实世界的工程问题。

举个例子

比如有这样一个基于真实场景的经典面试题:假设现在有1TB的任意文本,请问如何能将其中出现的单词按照字母序排列,得到一个新的文本?

这个问题,你可以回答好吗?

你也许会觉得很简单呀。我们就直接用堆排序,建立一个小顶堆,然后遍历整个文本进行分词,将每个单词都依次push进堆,最后再逐一出队输出到一个文本,最后就可以得到一个按字典序升序排列的文本了。

这当然是一个正确的思路,但是,你忽略了一个至关重要的问题,就是我们的内存可能没有这么大

这也是面试官考察这个问题的核心知识点,1TB的数据量级,意味着绝对不可能一次性将所有的数据都放入到内存中。而大部分单纯考察算法的面试题,其实都是在一个比较理想的环境下的,比如和排序相关的问题,绝大部分题目肯定会给你一个数组去存放需要排序的元素,隐含了内存可以一次性将所有数据读入的条件。

但在实际工作中,我们经常会遇到内存中放不下所有数据的排序场景。

早期可能因为内存的容量确实很小,而现在更多是因为我们需要存储的数据越来越大了,甚至不只是内存放不下,单机的硬盘可能也不够了,需要考虑分布式环境下的排序问题,比如在一个分布式数据库中进行超大表的order by操作,这往往需要花费几分钟甚至几小时的运算才能完成。

好言归正传,我们就借助这道经典面试题一起来看看如何解决大量数据的排序问题。

TB级数据排序

这个经典的算法问题我们一般称之为外部排序,这里的“外”指的其实就是外部存储的意思。

读写较慢的外存,相比快速但昂贵的内存而言,有着更低廉的成本,通常是硬盘,它可以存放更大的数据。当我们不能直接在内存中进行排序,而需要借助外存去处理极大量数据的排序时,就需要使用外部排序算法了

如果遇到这样的面试题,首先可以来向面试官确认一下已有的硬件环境,比如面试官可能会告诉你,你现在有1GB的内存可用。那么我们知道整个1TB的文件,至少要读1024次才能遍历一遍,所以直接在内存里排序显然是不现实的。

但是文件其实是可以一部分一部分读的,如果内存中一次放不下全部的数据,也许我们可以将文件分成若干段,分别读入内存中,并采用常见的内排序算法(比如堆排序),对这段可以在内存中存储的段落进行排序;得到若干个有序的文件段后,最后通过一些合并的方式,得到整体有序的文件。

当然在这个过程里会有大量的中间结果,比如那些有序的文件片段,这些我们都需要借助外存存储,这个思路就是最常见的一种外部排序的方式。

其实你会发现我们刚刚描述的想法和归并排序如出一辙,归并排序也是常用的外排实现方式,只不过我们在学习它的时候,一般都是针对数组,也就是在内存中排序的场景。

外部排序

好我们用严谨的语言来重新描述一下基于归并思想的外排过程,整体分为两个阶段:

  • 部分排序阶段

我们根据内存大小,将待排序的文件拆成多个部分,使得每个部分都是足以存入内存中的。然后选择合适的内排序算法,将多个文件部分排序,并输出到容量可以更大的外存临时文件中,每个临时文件都是有序排列的,我们将其称之为一个“顺段”。

  • 归并阶段

我们对前面的多个“顺段”进行合并,思想和归并排序其实是一样的。以2路归并为例,每次都将两个连续的顺段合并成一个更大的顺段。

因为内存限制,每次可能只能读入两个顺段的部分内容,所以我们需要一部分一部分读入,在内存里将可以确定顺序的部分排列,并输出到外存里的文件中,不断重复这个过程,直至两个顺段被完整遍历。这样经过多层的归并之后,最终会得到一个完整的顺序文件。

举一个简化的例子,配合示意图理解。

图片

假设现在有含有1000个记录的文件,而内存最多只能读取100个记录。那么我们要做的第一步就是先把1000个记录拆成十个文件,每个文件有100个记录,读入后在内存中排序得到10个有序的临时文件,并输出到外存也就是硬盘中。

然后我们进行多次归并操作,每次都把相邻的文件合并。在这个例子中可以看到只需要进行4轮归并,就得到了一个最终有序的文件。

运行时间

那整个过程里,运行时间主要和哪些因素有关呢?

在第一个阶段部分排序中,由于内存可以装下每个顺段的所有元素,所以几种主流的O(nlogn)的算法都是可以的,其中快速排序在大部分场景下是最快的,因此我们可以首选快速排序。

比较复杂的是归并阶段。因为内存不足以装下所有需要排序的元素,所以O(nlogn)的堆排和快排都已经没办法被应用在外排的场景中了,但基于分治思想的归并排序却依然可以很好地发挥作用。

而且相比很多其他排序方式比如选择排序、冒泡排序,归并排序O(nlogn)的复杂度已经是理论上相当好的复杂度了。当然在一些特定场景下我们也可以用一些线性排序算法比如桶排序来解决外部排序问题,感兴趣的同学可以自己搜索了解一下。

但是和内排中的归并排序不同,外部排序场景下,我们还有个非常大的时间消耗就是IO,也就是输入输出

相比内存中的读写操作,在磁盘中的读写是一个慢得多的过程,两者之间可能有千倍以上的时间开销差距。所以考虑外排效率时,非常重要的一点就是我们要尽量减少从磁盘中读取数据的耗时,而这主要关系要访问多少次外存。

那我们在外存中需要读取多少次数据呢?从图中其实可以看出来,每一层我们读取外存的数据总量其实是一样的,本质上就是将所有的数据都遍历一遍。

图片

而内存大小是一样的,所以每一层中读取外存的次数也就是一样的,那么显然关系我们读取次数的多少主要就取决于所需归并的层数了。因此,我们要做的事情就是让归并的层数越低越好

怎么样做到这件事呢?答案很简单也很直观,就是增加更多的归并路数或者降低初始的顺段数量。

如何降低归并层数

我们先算出归并层数,以2路归并为例,每次合并两个连续的顺段,如果上一层有n个顺段,到下一层就会有n/2个顺段,每一层的顺段都会减少一半,直至只剩一个顺段,也就是需要的排序结果。因而,假设初始一共有n个顺段,那么我们大致需要log2n层。

同样的道理,如果进行k路归并,每一层的顺段数量都会变成上一层的1/k,所以就大概只需要logk(n)层即可完成整个归并。比如一个5路归并的例子。

图片

所以,为了增加归并路数,也就是尽量增加k。

另外为了降低初始n个顺段的数量,我们会做的事情也很简单,就是在第一次进行逐段内排序的时候尽可能多地将数据读入内存中并进行内排。

但是增加k的大小,其实也会导致每次归并的时候合并的成本变大,一个显著的问题就是在k路归并中,我们需要从k个元素中选择出最小的元素,代价比2路归并的更高。如果用最暴力的方式,遍历k个元素,每次选择最小的元素的过程将产生O(k)的时间复杂度,这一定程度上会抵消前面通过增加k减少磁盘IO所带来的时间提升。

但是我们仔细想想这个问题,选择k个元素中的最小元素,显然有优于暴力遍历O(k)复杂度的算法。比如,上一讲介绍的堆就可以解决这个问题。

而败者树,则是解决从k个元素中选取最小元素并可以动态更新的另一种方法,也是更广泛运用于多路归并中的算法,我们来学习一下它的思路。

败者树

败者树也被称为,淘汰赛树,也就是Tournament Tree,思想来自体育比赛。

我们知道在淘汰赛中,每一场比赛都有两个参与者,其中胜者可以晋级下一轮。整体可以画成一颗树的形状,如果看过足球比赛,相信你对这个图一点也不陌生。

图片

败者树算法就是基于这一思想实现的,我们用叶子节点存储所有待比较的元素,对叶子结点两两比赛,在它们共同的父节点中存储失败者;然后对获胜者节点再两两比较,得到更上一层的败者,取出胜者继续往上比较,这个过程和归并的思路其实也是比较相似的;这样一层一层往上比较,最后就可以得到一颗锦标赛状的树。

因为除了叶子结点外的每一层的父节点存储的都是其子节点中的失败者,所以我们称其为败者树。根节点我们会稍微做一点特别的处理,除了在根结点存储失败者,同时,我们在根节点之上会悬挂上整棵树的最终获胜者。

我们可以给刚刚的例子画出对应的败者树,大概就是下面这个图的样子。其中根节点上的方框存储的就是整个树的胜者,也就是1,它一定是所有元素中的最小值,和锦标赛的冠军是一个意思。

图片

至于为什么在节点中存储的是失败者,而不是获胜者呢?就留给你做课后思考题啦。事实上,在父节点中存储获胜者的树也是存在的,和你想的一样,我们也称之为胜者树。

和堆一样,我们也会需要对败者树进行类似于出队的操作;在上面的例子中,就是我们需要将1从败者树中取出,寻找下一个最小的元素。

这里有两种情况,一种是我们取出1之后,用一个新的元素替代1,另一种就是取出1之后不再添加新的元素,分别对应某一路元素被取出之后仍有元素未取完,和该路元素已经全部取出的情况。在这两种情况中,我们其实都只需要对整个树重新比赛一次即可,只是在第二种情况里,我们会用一个无限大的数字替换1,因为无限大的数字一定不会在这次重赛中胜出。

我们用一个具体的例子来讲解一下这个过程,假设取出1之后,我们用8来替换1原来的位置。


由于每个父节点存储的都是两个子节点中的失败者,所以我们只要用更新的值和父节点的值比较,也就是和之前两个子节点中的失败者比较。

因为原来的获胜者已经被取走了,这里的父节点现在存储的其实就是这颗子树中原来的亚军,如果我们要看这次新来的选手能否能成为新的冠军,只需要和原来的亚军进行一次比较即可;当然这次比较结束后,两者中的胜者还需要到更上一层继续比较。

这个过程和运动员从市队、省队一路选拔到国家队参加奥运会的过程也是颇为神似的,希望这个例子能帮助你更好地理解败者树的工作机制。

整个过程写成伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 定义节点的value为具体的值;index为每一路数据的索引
// 用index.next可以取到每一路某个元素的后继元素。

function merge(L1, …, Ln)
buildTree(heads of L1, …, Ln)
while tree has elements
winner&nbsp;:= tree.winner
output winner.value
new&nbsp;:= winner.index.next
replayGames(winner, new) // Replacement selection

function replayGames(node, new)
loser, winner&nbsp;:= playGame(node, new) // 比较新节点和老节点的大小
node.value&nbsp;:= loser.value // 当前节点记录为比较中失败的节点
node.index&nbsp;:= loser.index
if node&nbsp;!= root
replayGames(node.parent, winner) // 胜者跟父节点继续比较

function buildTree(elements)
nextLayer&nbsp;:= new Array()
while elements not empty // 自下而上而上建树
el1&nbsp;:= elements.take()
el2&nbsp;:= elements.take()
loser, winner&nbsp;:= playGame(el1, el2) // 两两比较
parent&nbsp;:= new Node(el1, el2, loser)
nextLayer.add(parent) // 将获胜者放入上一层 继续
if nextLayer.size == 1 // 只有根节点 直接返回即可
return nextLayer
else
return buildTree(nextLayer)

时间复杂度

最后我们来分析一下败者树的整体时间复杂度,我们假设一共有k个节点。首先,初始化的过程需要花费 O(k) 的时间,因为对于k个子节点,一共只需要进行k-1场比赛即可完成淘汰赛。

然后在归并排序的每一次合并中,只需要进行replay操作,从新元素到根路径上逐一重赛。在每一层中,只需要进行一次比较。由于树是平衡的,从叶子结点到根路径仅包含 O(logk) 元素(这里高度的计算和之前讲解堆的推导是差不多的,你可以复习之前的章节)。所以,总的时间复杂度为 O(klog k) 。

现在有了败者树的加持,多路归并排序就可以比较高效地解决外部排序的问题了。对于1TB任意文本的排序问题,大致思路就是:

  1. 先用内排序算法,尽可能多的加载源文件,将其变成n个有序顺段。
  2. 在内存有限的前提下每k个文件为一组,每次流式地从各个文件中读取一个单词,借助败者树选出字典序最低的一个,输出到文件中,这样就可以将k个顺段合并到一个顺段中了;反复执行这样的操作,直至所有顺段被归并到同一个顺段。

这里稍微补充一下,看起来我们每次从文件中只读取了一个单词,但操作系统在读文件的时候是会按页为单位读取并缓存下来的,所以某一次磁盘访问之后的若干次访问,其实都会直接命中cache,也就是说,并不是每次从败者树中取出元素时都会真的产生磁盘IO,请不用担心。

当然在工业级实现中肯定还是有很多优化空间的。比如待合并的文件比较大的时候,我们可以利用二分搜索对文件进行分段,并行地合并,相关研究也比较多,感兴趣你可以自行搜索了解。

总结

随着互联网的发展,数据量一直在稳步的提升,许多算法问题都不能只简单地考虑内存中可以存储,甚至单机磁盘可以存储的情况了。相信我们今天学习的外排算法思想,一定会给你一些解决此类问题的启发,希望你可以举一反三在实际生产中也能将算法更好地运用在有各种限制的真实环境中。

借鉴内排中归并排序的想法,我们可以实现一个多路归并的外排算法,解决内存空间不足的问题。但也因为涉及外部存储,需要重点考虑IO的成本。通过尽可能多地利用内存中的排序,得到尽量少的初始顺段,以及选择合适的多路归并参数,我们就可以做到外存访问次数尽量少了。

多路归并,可以通过堆或者败者树实现,这里我也给你贴一道力扣上的算法题供你练习。

课后作业

最后留两个思考题。

  1. 既然,我们已经有了堆,为什么还要用败者树这样的数据结构呢?还有前面提到的胜者树,它不是一个更直观的表示方式吗?所以相比堆和胜者树,败者树在解决求多个元素中最小元素的问题时,又有什么样的优势呢?
  2. 除了基于O(n*logn)的归并排序的思想,有没有可能基于线性排列的几种算法来实现外部排序呢?

欢迎你留言与我讨论。如果有收获也欢迎你转发给身边的朋友,邀他一起学习。我们下节课见~

09|二分:如何高效查询Kafka中的消息?

作者: 黄清昊

你好,我是微扰君。

今天我们来学习另一个常用的算法思想,二分法。这个算法思想相信即使你没有什么开发经验也不会感到陌生,而且之前讲红黑树的时候我们也简单聊过。

不知道你有没有玩过“猜数字”的游戏。大家规定一个范围,一个人在心里想一个这个范围内的具体数字,比如一个1-100的自然数,然后另几个人来猜数字;每次猜错,这个人都会提示他们的猜测是大了还是小了,看谁最快猜到数字。

如果你做这个游戏会怎么猜呢?从1开始顺次猜吗?我反正不会这么猜,出于一个很简单的直觉,如果1猜错了,那么出题的同学给你的提示对可选范围的缩小非常有限,也就是从1-100变成了2-100。

我想很多人第一反应也都会是从比较中间的位置,比如50,开始猜起。毕竟如果50猜错了,因为要提示是大了还是小了,范围就要么缩小到1-49,要么缩小到51-100,这样猜测范围就可以成倍的缩小。

所以,如果每一次我们都猜测可能范围内的中间值,那么即使猜错了也能成倍的缩小范围,这样的策略其实就是二分查找算法

有了二分查找算法,即使更大的范围内进行游戏,比如在1-1,000,000的范围内,我们按照二分的策略,最多也只需要20次即可完成任意数字的猜测,这是遍历数字猜测所远远做不到的。可以看下图有一个直观的认知。

不过这样凭感觉的分析肯定是不行的,我们来严谨地讨论一下二分查找相比于线性查找,到底有多大的优势吧。

二分查找

在具体比较它俩的复杂度和实现之前,首先,我们要知道二分查找相比于线性查找更快是有先决条件的,就是查找的范围内的元素一定是有序排列的

比如在刚刚说的猜数字游戏里,我们之所以每次能排除一半的搜索空间,就是因为数字整体是有序排列的,如果某次猜测的数x,比目标值target大,那么当然比x更大的数就没有必要猜测了。

那什么是无序的排列呢?我们换个例子,假设要从一个由字母构成的数组中寻找某个目标字母“G”是否存在。

如果字母数组本身是按照字母序排序的,那么显然可以用二分查找法,和翻字典的过程其实差不多,如果我们打开的当前页比目标字母要字母序更靠前,那么我们肯定会往后翻,反之则会往前翻。

但是如果字母数组并不是按照字母序排列的,而是随机排列没有规律可言,这样唯一的做法就只有遍历数组的每个元素逐一对比了,因为在乱序的数组中的任意位置和目标字母进行比较,不会有任何有用的信息可以告诉我们应该要往前找还是往后找

这也是为什么有序的结构在很多情况下是更受偏爱的,我们在许多场景下,会先对数组元素进行排序预处理,再进行后续的其他操作;哪怕我们知道,排序,即使是内排序,也会花费不菲的代价,但它带来的收益可能是更高的。

在算法面试中,你可能会经常碰到这样要先进行排序预处理的题目,比如力扣上经典的两数之和题,有一种基于双指针的做法就是要先进行排序预处理的;而采用快排这样的O(nlgn)时间复杂度的算法,即使多出了预处理的步骤,也可以让我们获得比暴力法更好的时间复杂度的算法。如果你感兴趣可以课后尝试解决一下相关的题目。

二分查找

所以我们也来看一下数组上的二分查找算法在维基百科上的严格定义:

二分查找是一种在有序数组中查找某一特定元素的搜索算法。

搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。

这种搜索算法每一次比较都使搜索范围缩小一半。

核心就是查找元素需要可比较且有序的排列

好,我们来简单分析一下二分查找的时间复杂度。也很简单,假设我们搜索的空间内一共有N个元素,它们可以根据某种比较函数升序排列,那基于二分查找的策略,均摊下来需要比较多少次呢?

比较理想的情况,比如查找的元素正好在序列正中间,搜索一次就可以返回了。但我们需要考虑最差的情况。由于每次检索至少能排除一半的空间,假设一开始的搜索空间大小是N,那么我们的搜索空间在最差情况下会构成一个等比数列,下图是一个N=16时具体的例子:

当搜索空间里只剩一个可能元素时,也就是最后一次猜测,我们要么猜到了答案,要么就是答案不存在。这样最坏的搜索次数就是最大搜索空间折半多少次可以变成1。所以二分搜索的时间复杂度就是O(logn)了。

二分查找的实现

用代码实现的时候,往往会用数组来存储有序排列的待搜索元素,这里假设整个数组元素是升序排列的。我们用left和right两个整型作为数组的下标,代表搜索空间的左右边界,进行循环猜测。

每次猜测我们都会选择可选范围内最中间的元素去和目标值比较,最中间元素的数组下标为(left+right)/2。如果和目标值一样,我们就猜中了答案;如果比目标值大,说明比当前元素大的元素都不可能了,我们应该把可能范围的右边界移到当前位置之前;反之,就应该把左边界移到当前位置之后。

以猜数字为例,力扣上的374题描述的就是这个游戏,对应的二分查找代码可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Forward declaration of guess API.
// @param num, your guess
// @return -1 if my number is lower, 1 if my number is higher, otherwise return 0
int guess(int num); // num比答案高返回-1; 否则返回1

class Solution {
public:
int guessNumber(int n) {
int l = 1;
int r = n;

while(l < r) {
int mid = l + (r-l)/2; // 每次用左右边界的中点作为猜测值

if (guess(mid) == 0) return mid; //猜中直接返回

if (guess(mid) < 0) { // 猜的数大了
r = mid - 1;
} else { // 猜的数小了
l = mid+1;
}
}

return l;
}
};

你只要记住我们始终让l和r表示可能的范围,再根据中间值比较的结果,进行边界的缩放;代码是很容易实现的。

这里我们也对比线性搜索的代码看一看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Forward declaration of guess API.
// @param num, your guess
// @return -1 if my number is lower, 1 if my number is higher, otherwise return 0
int guess(int num);

class Solution {
public:
int guessNumber(int n) {
for (int i = 1; i <= n; i++) {
if (guess[i] == 0) return i;
}
return -1;
for
}
};

可以看到,在线性搜索的代码里,其实没有用到guess[i]为-1或者1的信息,也就没有利用到数字本身是有序的特点。当目标值为n的时候,我们需要比较n次才能得到答案,所以均摊的整体时间复杂度为O(N)。在N很大的时候,线性查找会比二分查找慢很多。

二分查找的应用

那二分查找在工程中常用吗? 可太常用了,下面我们就以Kafka的索引查询为例,学习一下二分查找在工程实战中可以发挥的巨大威力。

当然你可能会说,平时写业务代码的时候好像也没怎么写过二分查找。这其实也很正常,一是因为大部分时候,业务代码很少会在内存中存储大量的线性有序数据,在需要比较大量数据的检索时,我们往往会依赖底层的中间件;而数据量比较小时,线性查找和二分查找可能也差别不大了;另外我们也常常会用一些如红黑树这样的结构去存储有序集合,检索的时候也不会用到二分搜索这样在线性容器内的操作。

不过作为有追求的程序员,这种非常基础的算法思想我们还是很有必要掌握的,不止是能帮助你通过面试,更能帮助你更好地理解像Kafka这样的中间件的部分底层实现原理。

Kafka

我们知道Kafka是一款性能强大且相当常用的分布式消息队列,常常用于对流量进行消峰、解耦系统和异步处理部分逻辑以提高性能的场景,不太了解的同学可以去看一下官网的介绍

在Kafka中,所有的消息都是以“日志”的形式存储的。这里的“日志”不是说一般业务代码中用于debug的日志,而是一种存储的范式,这种范式只允许我们在文件尾部追加新数据,而不允许修改文件之前的任何内容。

简单理解,你可以认为Kafka的海量消息就是按照写入的时间顺序,依次追加在许多日志文件中。那在某个日志文件中,每条消息自然会距离第一条消息有一个对应的offset,不过这里的offset更像是一个消息的自增ID,而不是一个消息在文件中的偏移量。

为什么说是许多日志文件,而不是一个巨型的日志文件呢?这也是一个常用的计算机思想:分片。在这里,分片可以让我们更快速、更方便地删除部分无用文件,提高磁盘的利用率。

Kafka日志文件具体的存储方式可以参考这张图。Kafka的每个topic会有多个partition,每个partition下的日志,都按照顺序分成一个个有序的日志段,顺次排列

怎么找到消息

我们知道,Kafka虽然不允许从尾部以外的地方插入或者修改数据,但我们在Kafka中还是很可能需要从某个时间点开始读数据的,这就意味着我们要通过一个offset,快速查找到某条消息在日志文件的什么位置。这里再强调一下,kafka中的offset实际上是类似于消息自增ID的存在,并不是真的在磁盘上的偏移量。

但由于每条消息的消息体不同,每条消息所占用的磁盘大小都是不同的,只有offset,没有办法直接定位到文件的位置。所以我们要么遍历日志文件进行查找,要么我们为日志文件建立一套索引系统,将消息offset和在文件中的position关联起来,这样我们就可以利用消息offset的有序性,通过二分法加速查找了

下面是一个典型的某个topic的某个partition下file(日志文件)的存储情况。

1
2
3
4
5
6
00000000000000000000.log
00000000000000000000.index
00000000000000000000.timeindex
00000000000000000035.log
00000000000000000035.index
00000000000000000035.timeindex
  • .log文件就是存储了消息体本身的日志文件;
  • .index文件就是用于帮我们快速检索消息在文件中位置的索引文件;
  • 这里还有个.timeindex后缀的文件,它和index其实差不多都是索引文件,只不过在这个文件中关联position的变成了时间戳。

所有的文件名都是在这个file下的第一条消息,距离Kafka整体的第一条消息的offset,也就是绝对偏移量,那么在一个index文件内,我们就只需要用更小的空间存储相对偏移量即可

而index文件的内容也很简单,就是用固定大小的记录来标记一对“消息offset”和“消息在log文件中的位置position”的关系,当然我们会保证消息offset是递增的。下图是一个简单的示意。有了这个索引文件,就可以快速地进行二分查找了。

当然这里还有个小细节不知道你有没有注意到,在index文件中,文件中的offset并不是连续存储的。这会导致我们拿着offset,在index中查询时,只能大致查找到一段可能的范围;之后在.log文件中,我们还需要在查找的最接近的消息的位置往后顺序遍历,才可以找到真正的offset所在精确位置。

比如要查询offset为29的消息,在索引表中只能定位到offset为26的位置在838,然后我们还要从838的位置开始,在.log文件中往后遍历查询,直到找到offset为29的消息。

这其实是一个时空效率的权衡,为了使用更少的内存空间,Kafka采用的是稀疏不连续的索引,在实战中起到了非常好的效果。

好说到这,相信让你基于Kafka的存储体系,去实现指定offset消息的查询也可以轻松实现了吧。不过这里要稍微再多说一点,很可能你所面对的不是一个可以在内存中放得下的简单索引文件,而是一个比内存大得多的存放在磁盘上的东西。怎么办?

Kafka的做法是基于mmap技术,将硬盘上的文件和内存进行映射;当然由于硬盘的空间可能比内存大很多,所以并不能够直接将内存在物理层面上与磁盘进行一一映射,这里我们需要引入虚拟内存的手段。这点我们会在操作系统篇讲解LRU缓存置换算法的时候进一步讨论。

那最后我们来看一下kafka中的代码到底是怎么写的吧。

kafka源码实现

你可以对比一下自己想的实现看看有没有什么差别。当然Kafka用的是scala语言,你可能需要花一点时间理解一下基础的语法。

在整个Kafka中,二分搜索的核心作用就是用于加速索引指定offset的消息,所以相应的代码都在 core/src/main/scala/kafka/log/AbstractIndex.scala 中。 indexSlogRangeFor 就是用于检索目标值的函数,其返回值就代表可能范围的上下界,我们会不断的递归搜索,如果最终返回的下界和上界相等,就说明我们找到了目标值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* Lookup lower and upper bounds for the given target.
*/
private def indexSlotRangeFor(idx: ByteBuffer, target: Long, searchEntity: IndexSearchEntity): (Int, Int) = {
// 检查index是否为空
if(_entries == 0)
return (-1, -1)

// 二分搜索
def binarySearch(begin: Int, end: Int) : (Int, Int) = {
var lo = begin
var hi = end
while(lo < hi) {
val mid = ceil(hi/2.0 + lo/2.0).toInt
val found = parseEntry(idx, mid)
val compareResult = compareIndexEntry(found, target, searchEntity)
if(compareResult > 0)
hi = mid - 1
else if(compareResult < 0)
lo = mid
else
return (mid, mid)
}
(lo, if (lo == _entries - 1) -1 else lo + 1)
}

val firstHotEntry = Math.max(0, _entries - 1 - _warmEntries)
// 查询的目标offset是否在热区
if(compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
return binarySearch(firstHotEntry, _entries - 1)
}

// 查询的目标offset是否小于最小的offset
if(compareIndexEntry(parseEntry(idx, 0), target, searchEntity) > 0)
return (-1, 0)

return binarySearch(0, firstHotEntry)
}

看第9-25行代码,和前面写的“猜数字”代码看起来是不是如出一辙呢?就是简单的二分搜索,相信你应该没有什么问题了。

我们稍微解释一下27行到37行的代码。你可能会很疑惑,有了二分搜索函数,我们直接检索 binarySearch(0, _entries - 1) 不就可以了吗?为什么还要分两段检索呢?

这其实也涉及到操作系统、内存和mmap的工作机制。

前面我们提到Kafka利用mmap,将更大的磁盘文件映射到了一个虚拟内存空间,但底层的内存存储其实是相对小的;所以很多时候,我们需要将一部分暂时不用的空间,从内存中置换出去,把需要访问但此时不在内存中的文件,置换进来,这个方法叫做内存置换,每次内存置换都会触发一次“缺页中断”,之后我们会在LRU的章节里展开讲解,现在你只需要知道这个操作显然是需要比较高昂的成本就可以了。

而Kafka消息队列的特性也决定了,我们大部分的索引查询其实都是在日志比较靠近尾部的区域(数据比较新)。

那么,如果我们将索引中最后的 8KB 认定为“热区”,是大部分查询所会命中的区域,剩余的区域认定为是“冷区”,但每次查询的中间位置随着日志的增长就很容易变成冷区,就很容易触发缺页中断。一个典型查询在内存中产生的冷热页面的对比例子可以参见下图:

如果我们在查询的时候分成两段,优先查询热区,没有命中时再查询冷区,是不是就能大大减少“缺页中断”的次数了呢?是的,对于查询常在尾部出现的情况下,采用冷热分区的二分查询算法能很好地优化性能。具体issue可以参考这个

可以看到,相比简单的内存中的二分查询,Kafka中的二分查询考虑了更多的“现实”问题,这也是我们在工程中遇到算法问题和平时做算法题的算法的一个很大的差异。所以如果我们想要真的把算法很好的应用于工程中,除了对算法本身的掌握需要过硬;也需要真正打好计算机基础知识;对程序和操作系统的运行了如指掌,才能真正写出高性能的代码。

总结

相信通过今天的学习,你已经学会了如何给一个基于“日志”存储的消息队列,建立消息的索引查询了吧。通过线性有序的索引文件,我们其实可以为任何需要查询的系统,进行基于二分法的查询,以优化查询效率。二分查找的核心就在于,相比于线性查找,我们可以在每次查询中成倍地缩小可能的查询范围,达到O(logN)的时间复杂度。

所以在工程中,建立索引文件,或者是对业务数据进行排序,都是常用的预处理手段。通过一次计算,就可以帮助我们在之后的查询操作中获得更好的效率,是典型的空间换时间的手段。希望你在工作生活中可以灵活运用今天学习的知识。

课后作业

那最后提一个小问题。 既然建立线性的索引文件就可以帮助我们加速查询的过程,那为什么在许多情况下,我们还需要使用诸如红黑树、B+树这样的复杂索引结构呢?比如InnoDB的索引文件就采用了B+Tree,它和Kafka所选择的顺序稀疏索引文件各有什么优劣呢?

欢迎你在留言区和我一起讨论。如果有收获也欢迎你转发给身边的朋友,邀他一起学习。我们下节课见~

10|搜索算法: 一起来写一个简单的爬虫?

作者: 黄清昊

你好,我是微扰君。

你玩过井字棋的游戏吗?在一个九宫格中,双方轮流用X和O占领一个格子,某一方的O或者X三个连成一线时即可获胜。

这样一个简单井字棋的游戏,如果要让你自己写代码实现一个AI,你会怎么做呢?怎么把博弈过程清晰地表示出来呢?

实际上,许多博弈类游戏的过程,我们都可以用树来表示。根节点就是棋盘为空的状态,终点就是各个棋下完的状态,这样的树也被称为博弈树。下图是井字棋某个局面3步内的树状展示:

一般来说,对弈双方在做的事情,其实就是找到这棵树上对于自己最优的一种落子方式,使得之后的每条路径,自己都有必胜或者必不败的策略。如果你想要找出一个AI策略,最暴力的方式就是直接遍历每一种情况,找到最优的下法,这就是一个典型的搜索问题了

事实上,这类博弈的游戏要么是先手必不败,要么是后手必不败,所以对全空间的搜索一定是可以写出一个无敌AI的,对证明感兴趣的同学可以去搜索“策梅洛定理”了解。

如果暴力遍历,有多少种情况呢?相信你也发现了,就是这么一个简单的井字棋小游戏,终局的数量非常多,达到了255168种。我们可以这样来简单地估计它,第一步有9种下法,第二步有8种下法,显然通过排列组合的知识,占满棋盘一共有9!=362880种下法,当然还需要去掉一些中间获胜不应该继续进行对弈的情况。

这本身是一道挺有意思的数学问题,也可以通过写代码更快地计算出来,留给你作为课后作业。当然这个数字还不算天文数字,尚且在计算机的处理范围之内,如果我们稍微把游戏的复杂度提升一下,比如围棋,还能通过暴力搜索的方式得到一个优秀的AI吗?

我们知道,围棋盘有19*19个落子点,所以刚开始的每一步可能都有接近361个选择,那整体的情况可能接近361!种。这是一个天文数字,在现在的计算机架构下,直接计算和存储这样的问题是不可能的。所以我们想要写出一个靠谱的围棋AI,就需要采取一些新的策略,只选择部分分支进行遍历,从中找出一个比较好的方案

对于人类而言这个过程就是依靠经验,对于AI来说,就是依托于数据,你从AlphaGO核心算法的名字“蒙特卡洛搜索树”中,就可以看出来,这本质上还是一个搜索的问题,只不过人类棋手和AI都采用了比较高明的搜索策略。

我们今天就不讲那么进阶的内容了,就讲一讲平时常用的广度优先搜索算法BFS和深度优先搜索算法DFS。

它们是两种最常见的暴力搜索算法,在面试中也相当常见,前者的实现需要用到我们之前讲解的队列这一数据结构,后者则是递归思想最常用的场景之一。在工程中它们也发挥着巨大的作用。比如,DFS在前端开发中DOM树相关的操作里就非常常见,我们可以用它来实现对DOM树的遍历,从而对比两颗DOM树的差异,这就是React中虚拟DOM树算法的关键点之一。

BFS和DFS

BFS和DFS,作为两种最暴力、也相当常用的搜索策略,最大的特点就是无差别地去遍历搜索空间的每一种情况,因此但凡是可以抽象成图上的问题,基本上都可以考虑用BFS、DFS去做。只不过效率可能不是最优的,所以我们也常常称之为暴力搜索算法,在各大刷题网站题解区中,你应该常常能见到“暴搜”这样的关键词,说的一般就是DFS和BFS这两种算法。

所以,在爬虫这样本来就需要无差别遍历全部空间的场景下可以说是非常合适的了。至于DFS和BFS具体选择哪一种,我们可以结合一个具体的爬虫场景来分析。

如果让你手写一个爬虫,从豆瓣上爬取一个用户关注的所有用户,是不是很简单?只要直接遍历某个用户的关注者列表就可以了,除了需要处理一些鉴权和页面解析的问题,没有什么复杂的地方。

那我们升级一下挑战,爬取这个用户关注的人的所有关注的人,也就是和这个用户有二度关系的所有用户,你要怎么实现呢?如果不是二度,而是让你查找三度关系,也就是找出需要三跳的所有用户,你的代码能否很简单地通过配置就完成这件事呢?

这其实就是一个非常适合用DFS和BFS解决的问题,因为它天然就是一个需要无差别遍历所有图上节点的问题。


你可以把豆瓣用户看成节点,用户之间的关注关系就是边,它们一起构成了一个复杂的社交网络。相信你也听过社交网络中的“六度分割理论”,说的就是世界上任何一个人和你之间的距离不会超过6度,描述了社交网络的小世界特性。这种网络关系也是许多人在研究的。

BFS实现思路

好我们先来使用BFS解决这一问题。我们优先去遍历所有到源用户距离为一度的用户,然后再遍历这些用户的邻居,用层层深入的方式进行搜索。广度优先搜索,其实是一个很直观的定义,我们把对应到图上的搜索顺序画出来,就很清晰了。

看简化的情况,假设我们搜索的源用户为图的0节点,一度关系包含3个节点,二度关系包含了6个节点,每条连边都是一个关注关系。那我们基于广度优先搜索策略遍历时,就会按照标号顺序进行遍历。

为了在代码中实现这样的遍历效果,求出所有和0节点构成两度以内关系的用户,我们就需要借助之前学习的“队列”了。

因为广度优先搜索是由源点向外逐层推进的,每遍历下一层的时候,我们都需要用到上一层的节点,所以我们需要一个容器记录每一层的元素,并依次遍历,先进先出的队列就可以帮助我们很好地解决这个问题。

我们首先把遍历的初始节点加入queue中,然后循环读取queue中的元素,每次读出一个元素,就把它的所有相邻节点都放入新队列中,直到目前队列为空,就代表我们遍历完了所有的元素。队列FIFO的特性保证了,下一层的元素一定会比上层的元素更晚出现。

当然我们经常需要设置自己的搜索退出条件,比如在最短路径问题中,我们并不需要遍历所有的路径,当搜索到终点的时候其实就可以退出了;在我们的例子中,我们的退出条件也不是遍历完整个社交网络,遍历到第二度关系就可以结束了,因此我们还需要在代码中记录当前遍历的层数。

另外,有时候需要对插入队列中的元素做一些判重,防止重复的搜索。

在搜索最短路径或者求几度关系所有用户的情况下就很有用,因为重复的节点已经没有必要再搜索了;如果不这样做,甚至可能导致你的搜索永远无法结束,比如在图有环的情况下。

实现

下面我们试着写一下,就用爬虫常用的脚本语言Python来实现这次的代码。

解释一下几个关键的变量。

degree就是广度优先搜索中我们用于记录搜索层数的变量。为了每一次出队的时候,把一层的元素全部出队,在遍历中我们采用了两层while循环,这样内层循环结束后,我们就可以保证当前层的元素已经全部被访问,也可以将degree进行自增操作。

之所以开了一个新的变量next_degree_urls也是同样的道理,当前层的邻居不能干扰到这一层的出队操作,所以我们将邻居们放到一个新的队列中;内层循环结束后,再将已经为空的队列更新为next_degree_urls。

而类型为set的变量res,除了记录所有的用户主页,也起到了判重的作用;如果已经出现在res集中,说明我们已经遍历过这个用户主页了,不需要再遍历一次了,所以在循环中直接通过continue跳过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 这里我们需要一个合适的 douban html parser
&nbsp; &nbsp; def crawl(self, startUrl: str) -> List[str]:&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; urls = deque()
&nbsp; &nbsp; &nbsp; &nbsp; urls.append(startUrl)
&nbsp; &nbsp; &nbsp; &nbsp; res = set()
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;
degree = 0
N = 2

&nbsp; &nbsp; &nbsp; &nbsp; while urls:
# 遍历层数超过N层,停止遍历
if degree > N: break
# 用于记录下一层的节点
next_degree_urls = deque()
# 遍历当前层
while urls:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; u = urls.popleft()
if u in set: continue
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for url in doubanHtmlParser.getFollowings(u):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; next_degree_urls.append(url)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; res.add(u)

urls = next_degree_urls
# 当前层元素全部出队;进入下一层遍历,记录遍历层数的变量加1
degree = degree + 1

当然,既然是爬虫,我们肯定是需要对网页进行解析的,就用一个getFollowing函数表示这个过程,做的事情就是输入一个用户主页的URL,返回该用户关注的其他好友的个人主页。

大致实现思路就是,通过网络请求库获取指定URL的HTML文本信息,从中解析出表示用户关注好友列表的部分,一般列表中的每个元素都会指向该好友的个人主页,我们把相关的href标签里的URL解析出来即可。

好了,这样我们就用基于队列的广度优先搜索策略完成了一个简易的爬虫,感兴趣你可以自己补全HTML解析器的代码,完整实现一下。

DFS实现思路

再来用DFS解决这个问题。深度优先搜索,就不会再像广度优先搜索那样严格由内而外逐层推进了,它和棋手下棋的思路其实会更像一点,我们就用下棋举个例子。

假设下棋的时候,当前局面可以有若干个落子点,棋手一般会先顺着其中一个落子点在脑海中模拟若干步,发现某一步不行,我们回溯到分叉点,再看一下其他选择;最终遍历完当前选择的落子点的各种局面之后,再依次进行其他落子点的判断,直到选出一种比较优的策略。

画成图对比一下,你会更直观地感受到两者的区别,同样用刚刚假想的豆瓣用户关注关系图来举例:

上图的数字,表示深度优先搜索在同样的关系图中的遍历顺序;可以看到相比于BFS的逐层推进,在DFS中,是一条条分支顺次遍历到终点再进行下一种尝试的,这也是深度优先搜索命名的由来。

实现

这种遍历方式也天然符合回溯法的适用场景,所以常规做法就是通过递归来实现。写成代码如下,关键就是11-13行的for循环,递归地对当前节点的每个子节点进行同样的DFS过程,细节你可以参考代码中的注释来理解,网页解析的逻辑和前面所说的是一样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 结果集 用于存放所有N度关系以内的用户
res = set()
N = 2 # 记录找N度关系以内的所有用户;N=2即找2度关系以内的用户
def crawl(startUrl, degree):
# 如果已经超过N度关系,我们不用继续遍历
if degree > N : return
# 如果已经搜索过,我们也不用继续搜索
if startUrl in res : return
# 将当前搜索的用户主页加入结果集
res.add(startUrl)
for url in doubanHtmlParser.getFollowings(startUrl):
# 遍历关注的所有用户,注意需要将度数增加1
crawl(url, degree+1)

DFS的代码看起来明显要简短很多,这就是递归的威力。通过对自身的调用,很多时候,我们可以让代码变得非常简单。

时空复杂度

两种方法的时空复杂度都非常容易分析。

先说时间复杂度。我们做的事情就是遍历一次网络或者图中的每个节点,因为借助结果集判重后,即使在图中多次出现的节点,也只会入队一次或者被递归一次。那么假设图中有V个顶点,E条边。

在BFS中,图中所有顶点入队一次、出队一次,每条边都会在边起点出队的时候被遍历一次,所以整体的复杂度为O(V+E)。而在实际的复杂网络中,E一般是远大于V的,所以可以近似认为复杂度为O(E)。

在DFS中,是从起点出发递归遍历图,通过结果集判重,保证重复的节点不会被递归两次,从而每条边只会被遍历一次,整体复杂度为O(E)。

所以时间复杂度上两者没有太大的差别。但是在一些求最短路径的场景下,比如求从当前位置走出迷宫的最短路径,就会有一定差异。这是因为BFS是从内到外逐层搜索,所以最早搜索到终点的时候,就对应了最短路径;因此找到终点我们就可以提前结束搜索过程。

而在DFS中,由于我们优先遍历每条路径的最大深度,即使找到了终点也只能说明找到了一条路径,这并不能保证这条路径是最短的,所以哪怕找到终点也不能结束搜索过程,需要遍历完整个搜索空间,找到所有可能的路径之后再从中选择一条最短的。

在搜索空间很大,但已知搜索路径不会特别长的情况下,DFS可能会比BFS要慢很多,所以你要根据实际情况选择一种合适的算法。

当然在今天豆瓣爬虫的场景下,我们需要的就是遍历整个空间找到所有构成N度关系的用户,所以两种算法的时间复杂度其实没什么区别。

空间复杂度

再来看空间复杂度。

在BFS中,主要的空间复杂度就是queue和res所占用的大小。那这里的,res本身并不是所有的BFS场景下都会需要的,因为我们并不一定需要返回所有遍历过的节点,可能只需要记录一个距离之类的值。

但是,在大部分的BFS下,我们是不希望重复遍历节点的,所以仍然需要一个类似于res的集合去标记所有经过的点。它所需要的最大空间和图中总结点数量V一致。而queue存储的就是图上的节点,其所占用的空间最大也不会超过V。所以BFS的空间复杂度是O(V)。

在DFS中,所消耗的内存同样主要与res相关。因为虽然相比于BFS,我们少了queue的内存消耗,但是也多了隐含递归中调用栈所消耗的空间。由于调用栈最多就是描述一条经过了所有节点的路径,其最大空间大小也不会超过顶点数量V。因而DFS的空间复杂度同样是O(V)。

总结

作为两个相当常用的暴力搜索算法,BFS和DFS比较适合用来解决图规模不大,或者本身就需要无差别遍历搜索空间的每一种情况的问题;这两者的时间空间复杂度是相当的。

而至于DFS和BFS具体选择哪一种,我也总结出一些自己的经验,供你参考。

BFS因为是由内向外地毯式地搜索,所以首次搜索到目标位置的时候一定是源点到目标位置的最短路径,所以求最短路径类的问题往往可以用BFS解决。当然,这里的“最短路径”是有条件的,只有在图中所有边权重相等时首次搜索到的才是最短路径;另一类边权不等的图上的最短路径求解问题我们之后会单独讲解。

而DFS实现起来比BFS更简单,且由于递归栈的存在,让我们可以很方便地在递归函数的参数中记录路径,所以需要输出路径的题目用DFS会比较合适。毕竟想用BFS实现相同的路径记录,除了需要在queue中记录节点,还需要关联到此节点的路径才可以,占用的空间比DFS高得多。

一般情况下我们都可以优先使用DFS实现,但这完全建立在我个人觉得DFS写起来更简单的前提下。而在需要求解路径本身的问题中,强烈建议你采用DFS作为搜索算法的实现。

课后作业

最后,我再给你留三个小作业。

  1. 既然说是可以用DFS或者BFS写一个爬虫,希望你尝试补全一下爬虫中解析HTML和HTTP请求的逻辑。或者动手写一个你自己想写的爬虫感受一下,体会搜索算法在实战中的应用。
  2. 在搜索N度关系的所有用户时,如果我们希望同时把源用户和这些用户的关注关系记录下来,比如A->B->C就表示A关注了B,B关注了C;更广泛地意义上来说就是让你记录搜索过程中的路径。你会怎么实现这个逻辑呢?
  3. 前面提到的井字棋中,尝试用代码计算一共有多少种最终合法的局面呢?

欢迎你在留言区与我讨论。如果有收获也欢迎你转发给身边的朋友,邀他一起学习。我们下节课见~

11|字符串匹配:如何实现最快的grep工具

作者: 黄清昊

你好,我是微扰君。

grep命令,相信使用过Linux的同学都会非常熟悉,我们常常用它在Linux上进行文本搜索操作,具体来说就是从一段文本中查找某个字符串存在的行。下面一个典型的grep的使用例子,比如我可以用它来看看自己在LeetCode上用Java做了多少题:

图片

GNU Grep 则是 grep 命令的一个工业级实现,在项目官方 Readme 中作者是这样介绍它的:

This is GNU grep, the “fastest grep in the west” (we hope).

其实就是在说这是世界上最快的grep程序。当然,这款从上世纪就诞生的软件,敢这么说自己也是因为它有着十足的底气。

GNU Grep 确实是将“文本搜索”这一简单的功能做到了极致。作者 Mike Haertel 自己写了一封邮件解释 GNU Grep 为什么这么快,主要有两点:

  1. 它避免了检查每一个byte
  2. 对于被检查的byte,只需要执行非常少的指令

第一点的主要优化就在于 GNU Grep 用到了非常知名的字符串匹配算法:Boyer Moore 算法,也就是我们常说的 BM 算法,它是目前已知的在大多数工业级应用场景中最快的字符串匹配算法,因而被广泛应用在各种需要搜索关键词的软件中,许多文档编辑器快捷键 ctrl+f 对应的搜索功能都是基于这个算法实现的。

那第二点呢,就是当你发现查询的速度已经优化到足够好时,也需要让IO的速度更快一些,查询所需的指令也更少一些,这里可以优化的地方就更多了。

比如由于 grep 是按行查找的,许多版本的 grep 实现都会去遍历查找\n 换行符先进行分行,但 GNU Grep 则是将搜索文本直接读入一个缓冲区优先查找目标字符串,只有命中时才会在命中位置的前后进行换行符的查找;又比如,GNU Grep提供了基于mmap映射内存到文件的参数,可以减少一些内存拷贝的时间开销。具体的细节还有很多,比较繁琐,有兴趣的同学可以自行查阅 Mike Haertel 的邮件

这个例子也再次说明了一件事情,要写出真正高性能的程序,不只要懂算法,也要懂计算机底层原理;只有这样,才能真正了解程序在运行时可能存在的各种性能瓶颈,找到不同场景下的最优解。

好我们回到今天的主题,字符串匹配。这也是一个经典问题了,相关算法非常多种,比如最暴力的 Brute-Force 算法、将前缀信息运用到极致理论性能极佳的KMP算法,还有利用哈希思想和滑动窗口思想的Rabin-Karp算法等等。

那为什么BM算法的性能在工程实战中最好呢?

别急,老规矩,我们还是先来严谨地定义一下字符串匹配问题,方便展开后面的讨论。

字符串匹配问题

假设给定长度为n的主串 s[0…n-1] 和长度为m的模式串 p[0…m-1],一般n远大于m,请实现一个函数 match(string s, string p) 用于找出所有的p在s中出现的位置。

那如何解决这个问题呢?

容易理解、复杂度也相对差的方法就是,遍历主串的每一个位置,看当前位置是否能和模式串匹配上;能否匹配的判断方式也很简单,从主串的当前位置开始,逐一对比主串对应字符是否和模式串相等。如果可以匹配,说明找到了一个匹配的位点,记录下来;如果不可以匹配,我们就继续尝试下一个位置,直到整个主串遍历完全。这也是最暴力的Brute-Force算法的思路。

写成代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
&nbsp;* s: 主串
&nbsp;* p:模式串
&nbsp;*/
std::vector<intstring> match(string s, string p) {
&nbsp; std::vector<int> ans;
int n = s.size();
int m = p.size();
&nbsp; int i, j;
&nbsp; for (i = 0; i < n - m + 1; i++) {
&nbsp; &nbsp; for (j = 0; j < m; j++) {
&nbsp; &nbsp; &nbsp; if (s[i + j] != p[j]) break;
&nbsp; &nbsp; }
&nbsp; &nbsp; if (j == m) ans.push_back(i);
&nbsp; }
&nbsp; return ans;
}

代码非常清晰易懂,相信你看懂没什么压力。

通常在字符串不长的时候,不同的匹配算法之间的效率差异不大。Brute-Force算法的实现和理解都非常简单,不容易出错,完美地符合了KISS(Keep it simple, stupid)原则,也就是让代码尽量简单从而避免出错。所以BF算法在真实开发的环境中出镜率很高,在日常工作中如果有手写字符串匹配的需求,你也可以考虑这种方式。

但这个算法在最坏的情况下时间复杂度确实不是很理想。

比如s = AAAAAAAA、p = AAAB时,在每个位置匹配p最终都会失败,但是都需要匹配到p的最后一个字母“B”才能发现匹配失败;这就导致我们总共需要匹配 mn 次,其时间复杂度就是 O(mn)。

那有没有办法优化它呢?我们再来认真观察一下BF算法,首先会从主串和模式串的头开始遍历匹配,看第一次匹配的情况,BF算法之所以慢,就在于匹配p[3]失败后,我们又从模式串的第一个字符p[0]和主串的下一个位置s[1]开始比较,而s[1]这个位置其实在之前的搜索过程中出现过了。

所以,我们有没有办法通过一些预处理的手段,利用p[0…2]和当前正在匹配的主串中s[0…2]相等的已知信息,跳过一些肯定不可能的匹配,从失配处s[3]继续匹配呢? KMP和BM算法其实都是这样做的,只不过手段有些差别。

KMP算法将前缀的信息利用到了极致,用匹配串自身的信息建立了一张部分匹配表,在每次失配的时候可以用来加速模式串,而不是每次都只向后移动一位。其算法逻辑整体比较复杂,感兴趣的同学可以网上搜索一下相关资料自行学习。

而GNU Grep 中用到的BM(Boyer Moore)算法,不仅理解起来容易很多,实际应用时性能也更好,它同样是基于预处理来避免不必要的重复匹配。但BM算法引入了两条很好懂的规则,“坏字符”和“好后缀”规则,并采用从后往前的匹配顺序进行匹配,构思非常巧妙。

后面的内容我们就用 moore 教授本人提供的例子来讲解。

图片

其中模式串p是EXAMPLE,主串s是 HERE IS A SIMPLE EXAMPLE。

坏字符规则

先来看第一条规则:“坏字符”规则,描述的是主串上的失配字符,目的就是为了跳过一些肯定不可能成立的匹配位置。

在BM算法中,我们同样将s和p对齐,开始遍历匹配,但匹配的顺序和BF算法不同,采用从后往前匹配的方式。这其实是一种非常巧妙的设计,你马上可以看到它配合坏字符规则使用时有着绝佳的效果。

所以在例子中,第一次尝试匹配,首先会把p[6]的“E”和s[6]的“S”匹配,发现它们不匹配,所以这里的“S”就是一个坏字符。

那此时我们有两种选择,一种就是直接将模式串往后移动一位尝试继续匹配,这就和之前BF算法的想法差不多,没有利用到模式串中任何先验的信息。

而另一种呢,就是BM的做法了。

我们先看失配的坏字符“S”在模式串p中是否有出现,如果没有出现,那说明模式串其实不可能和这个位置有重叠,可以直接跳过这段位置,从主串的下一个位置开始匹配。在例子中,“S”显然不属于模式串EXAMPLE,我们就应该跳过“S”继续匹配,这样就大大加速了匹配的过程。

同样在下一步匹配时,因为主串的“P”和模式串的末尾“E”不匹配,但失配的“P”在模式串中就有出现,我们可以将模式串中最后一次出现的“P”和主串中的“P”对齐,同样从模式串尾开始匹配。

至此,坏字符的主要内涵就全部展示出来了,也就是,每次失配的时候,我们需要将匹配串往后移动 (失配位置下标 - 失配字符最右出现的位置下标) 位;如果失配字符不存在,则位置为-1。

这里你可能会有个疑问,为什么是最右的位置呢,不应该是记录上一次出现的位置吗?我的理解是,如果在每个位置都存储相比于当前位置的上一次失配字符出现的位置,存储开销会大得多;而如果只存每个字符最右出现的位置,我们所需要的只是一个字符集大小的哈希表,用一个长度为256的数组即可实现。

当然,这个公式会导致我们有时候求出的移动值可能是负的,让模式串反而向前移动了。比如在 BBBBBB 和 ABB 匹配时,第一次失配的坏字符B,会让匹配串往后移动(0-2=)-2位,导致前移。

那往前移显然是没有意义的,因为当前位置之前的匹配可能我们已经全部排除了;所以当移动位数出现负数时,我们也要让模式串至少往后移动一位,这点通过对基于坏字符的移动值和1取max操作即可实现。

而在这种时候,我们另一条规则“好后缀”也就可以发挥作用了。

好后缀规则

我们继续来看刚刚的例子。

在SIMPLE和EXAMPLE的匹配中,我们发现“MPLE”都匹配得上,但主串中的“I”和模式串中的“A”出现了失配。那这里的“MPLE”,我们就会称之为好后缀;同样“PLE”、“LE”、“E”其实也都是好后缀。

此时如果应用之前的坏字符规则,我们应该将模式串往后移动(2-(-1)=)3位,因为“I”在模式串中不存在。

但是,有没有办法利用已经匹配上的好后缀“MPLE”的信息,往后移动更多位呢?

当然是可以的,我们只要看匹配上的好后缀“MPLE”及它的子串“PLE”、“LE”、“E”是否之前也出现在模式串中即可。这里只有子串“E”之前也出现在了模式串中,所以我们可以直接把模式串移动至和这里主串的“E”对齐即可,这样我们向后移动了6位,显然比坏字符规则跳过了更多不可能的情况

总结起来,好后缀规则移动的方式就是,找到好后缀在模式串中最右的匹配位置,总计向后移动(模式串字符长度 - 1 - 好后缀在模式串上次出现的最右位置)位。以EXAMPLE为例,好后缀“E”在模式串中上一次出现的下标是0,整个字符串长度为7,所以向后移动(7-1-0)6个位置。

这里还需要注意一点,好后缀匹配的时候,只有最长的好后缀被允许出现在模式串的中间位置;其余子串只能匹配在模式串的前缀中。比如下面的例子,主串中的“A”和模式串中的“C”失配,“MABC”是最长好后缀,但之前并没有出现在模式串中。

  • 我们不能直接将模式串直接移到“MABC”之后,因为这样会错过好后缀子串“ABC”的匹配点。
  • 但同样我们也不用匹配红色虚线框中的“ABC”,因为“MABC”没有匹配上,后面所有的MABC的子后缀匹配肯定只能发生在模式串的前缀中。

好后缀和坏字符规则其实都是可以单独使用的;BM算法,为了尽可能多地跳过不可能匹配的字符,会选择两条规则中的较大移动值来往后移动。而且这两个规则和主串都没有关系,只和模式串自身有关,我们显然可以通过预处理得到两个规则的偏移表,来加速整个模式匹配的过程

好了,现在讲完了BM算法“好后缀”、“坏字符”的两个规则和从后往前匹配的策略,我们一起来把例子匹配完成吧。

在查表发现好后缀的规则能跳过更多的位置后,我们选择将模式串往后移动了6位。这时“P”和 “E”没有匹配成功,我们采用坏字符规则,拿着坏字符“P”,找到模式串中出现的“P”位于p[4],向后移动(6-4=) 2位和主串的“P”对齐。从尾部往前遍历匹配,此时,我们发现所有的字符都匹配上了,因而找到了一个完全匹配的位置。

具体实现

相信你现在已经大体理解整个BM算法的思路了,但正所谓,“细节是魔鬼”,BM算法从概念上理解其实并不是很难,但真要手写实现还是比较困难的,不熟练的时候debug很容易花费很多的时间。为了方便起见,我们就用Python来实现这个算法。

具体实现我们可以分为三个大块:“坏字符”最右位置计算、“好后缀”偏移表计算、在主串上的搜索实现。

坏字符最右位置计算

“坏字符”的部分是最简单的,只需要开一个dict,遍历一次模式串,找到每个字符出现在模式串中的最右侧的那个位置即可。事实上,我们可以用一个[0,256]的数组来替代HashMap以提高性能,大部分工业级实现也都是这样做的。

1
2
3
4
5
6
def get_bc(pattern):
bc = dict() # 记录每个badchar最右出现的位置
for i in range(len(pattern) - 1):
char = pattern[i]
bc[char] = i + 1
return bc

由于遍历的时候我们会不断地覆写dict,所以最后遍历完成,就能得到每个badchar在模式串中最右侧的位置。

好后缀偏移表计算

“好后缀”的部分相对来说比较复杂,尤其是工业级的实现对性能要求很高,代码有很多trick,非常不易于理解,这里我们做一些简化的处理;而且在大部分时候,由于模式串比主串要短的多,即使预处理时间复杂度稍微高一些,问题也不是很大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_gs(pattern):
gs = dict()
gs[''] = len(pattern)

# suf_len 用于标记后缀长度
for suf_len in range(len(pattern)):
suffix = pattern[len(pattern) - suf_len - 1:]
# j 用于标记可用于匹配的位置
for j in range(len(pattern) - suf_len - 1):
substr = pattern[j:j + suf_len + 1]
if suffix == substr:
gs[suffix] = len(pattern) - j - suf_len - 1

for suf_len in range(len(pattern)):
suffix = pattern[len(pattern) - suf_len - 1:]
if suffix in gs: continue
gs[suffix] = gs[suffix[1:]]

gs[''] = 0
return gs

我们同样开一个dict,用于标记失配时每个字符串应该往后移动多少,也就是对应的好后缀应该和之前哪个子串或者前缀匹配。怎么做呢?

一种比较暴力的做法就是遍历所有可能的后缀,然后从前往后看这个后缀是否在模式串中的其他位置也出现了,后面遍历的会覆盖之前的记录,所以记录下来的就是最右的匹配位置。

记得前面说过如果一个后缀在模式串中不存在,我们不能直接跳过整个字符串,因为该后缀的子串还可能和模式串中的前缀重合。比如例子中的“MPLE”后缀虽然不再存在于“EXAMPLE”中,但是其子串“E”与“EXAMPLE”的前缀“E”是重叠的。

所以在后缀不存在的时候,还需要检查一下其子后缀是否在dict有对应的匹配,如果有的话,也应该采用;这个通过一次循环赋值即可实现,对应到代码里就是第14到17行。

我这里实现的时间复杂度为O(m^3),你可以自己推导一下,也欢迎去留言区讨论。

匹配过程

有了好后缀的偏移表和坏字符的最右位置,我们就可以来实现整个匹配的过程了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def bm(string, pattern, bc, gs):
# i 用于标记当前模式串和主串哪个位置左对齐。
i = 0
# j 用于标记当前模式串匹配到哪个位置;从右往左遍历匹配。
j = len(pattern)

while i < len(string) - len(pattern) and (j > 0):
# 从右往左匹配每个位置
a = string[i + j - 1]
b = pattern[j - 1]
if a == b: # 匹配的上,继续匹配前一位
j = j - 1
else: # 匹配不上,根据两个规则的预处理结果进行快速移动
i = i + max(gs.setdefault(pattern[j:], len(pattern)), j - bc.setdefault(a, 0))
j = len(pattern)
# 匹配成功返回匹配位置
if j == 0:
return i
# 匹配失败返回 None
return -1

if __name__ == '__main__':
string = 'here is a simple example '
pattern = 'example'

bc = get_bc(pattern) # 坏字符表
gs = get_gs(pattern) # 好后缀表

print(gs)

x = bm(string, pattern, bc, gs)

print(x)

参照详细的注释,整个过程和前面讲解的原理是一一对应的,你可以配合代码一起理解。 完整的代码我放到了GitHub上。

时间复杂度

Boyer-Moore 算法,在最好情况下复杂度可以达到 O(n/m),在字符集比较大的时候,坏字符和好后缀规则可以帮助我们快速跳过大部分不必要的查询,达到接近最好的时间复杂度的概率是比较大。

但BM算法的最坏时间复杂度估计就是一个很难的数学问题了,许多学者都尝试做过相关的证明,目前我知道相对精细的比较上限次数的估计是Guibas和Odlyzko给出的3n,你感兴趣的话可以阅读原始论文了解。

因而和KMP一样,BM算法的理论时间复杂度也在O(m+n)之内,但由于字符集比较大的时候,BM常常能达到更好的时间复杂度,所以在实际应用中得到了更广泛的使用。

总结

我们来总结一下BM算法的特性。

BM算法,最大的特点就是利用了对目标串的预处理,用空间换时间,避免了许多不必要的比较,预处理的方式主要来自于对“坏字符”和“好后缀”两条规则的观察,因为这两个规则和主串都没有关系,只和模式串自身有关,显然可以通过预处理得到两个规则的偏移表,来加速整个模式匹配的过程。

总的来说,BM算法不难理解但实现起来有一定复杂度,感兴趣的同学可以自行练习。不过这一个特定的字符串匹配算法的学习其实还是次要的,空间换时间和预处理的思想你可以好好感受。

课后作业

相信通过今天的学习,你已经知道了如何基于Boyer-Moore算法实现一个高效的grep命令了吧。这里我也把grep源码中BM算法出现的地方分享给你,代码中运用了许多不同的技巧,可读性其实并不是很好,作为今天的课后作业,留给你课后研究。

如果你在阅读代码的时候有什么问题欢迎留言和我一起讨论。如果你觉得有收获,也欢迎分享给身边的朋友一起学习,我们下节课见~

12|拓扑排序:Webpack是如何确定构建顺序的?

作者: 黄清昊

你好,我是微扰君。

Webpack是现在最流行的前端构建工具,见证了前端工程化在过去十年里的繁荣发展。如果你是前端工程师,相信你在日常工作中应该会经常使用到。

Webpack让我们可以模块化地进行现代Web应用的前端开发,并基于我们的简单配置,从入口开始,递归地自动构建出复杂的依赖关系图,最终把所有的模块打包成若干个浏览器可理解的bundle。

整个过程比较复杂,但是其中显然会有一个构建顺序的问题需要处理。以 html-webpack-plugin v3.2.0 为例,我们用Webpack打包HTML文件的时候,文件之间会有一定的引用依赖关系,因而所构建的chunk之间也会有相应的依赖关系。

那问题就来了,打包的时候,按照什么样的顺序去打包才更合理呢?这就是拓扑排序需要解决

我的第一份工作就是前端工程师,对前端开发感情挺深,也一直觉得其实前端里用到算法的地方绝对不在少数。所以今天我们就以Webpack为例,一起来学习拓扑排序在实战中所发挥的威力。

当然,如果你对Webpack了解不多,也不用担心,完全可以把这个问题看成如何在一堆有依赖关系的源文件中找到合适的加载或者编译的顺序,和你常用的maven、makefile这类编译和依赖管理工具,去编译项目的原理并无二致。

好,我们马上开始今天的学习,首先我们得来讲解一下拓扑排序的依据——拓扑序是什么。

拓扑排序

“拓扑”序,光听名字你是不是感觉很抽象不好懂,我们看一个生活中的例子。

在大学,学生往往有更大的选课自由,可以按照自己的兴趣选择不同的课程、在不同的时间修读。但是,显然许多课程,和代码之间的依赖一样,也有着修读顺序的先后要求;有些课程还可能有不止一门先修课程,比如电磁场就要求先修读大学物理和数理方法两门课。

所以请问如果给了你一张这样的必修课表,每一行包含了课程本身的信息和它所依赖的其他课,你应该怎么安排你的课程学习顺序呢?这也是力扣上出镜率非常高的一道面试题课程表

图片

看表格不太清晰,这些课程之间的修读关系,显然可以用有向图来抽象,节点就是课程本身,边代表了两门课程的依赖关系。把课程表的信息用图的方式表示出来就会是这个样子:

图片

照着图去选择修读顺序就会容易很多,你会优先选择没有入边也就是没有先修要求的课程,所以,等修读完线性代数和高等数学之后你就会发现,诶这个时候数理方法和大学物理的先修课程已经全部被你解锁了;继而你就可以继续修读这两门课程;最后再修读电磁场这门。

当然你会发现修读顺序依旧不只一种,比如:

1
2
线性代数、高等数学、数理方法、大学物理、电磁场
高等数学、线性代数、大学物理、数理方法、电磁场

这两种修读顺序的都是合理的,但它们一定保证了,要修读的课程的先修课程一定在当前课程的前面。

讲到这里,拓扑序的概念就清晰了,我们给出一个序列使得每个节点只出现一次,且保证如果存在路径P从A指向B,那么A在序列中一定出现在B之前;满足这个条件的序列就被称为满足拓扑序,所以它常常被用来解决有依赖关系任务的排序问题。

对于一个有向图而言,通常存在不止一种拓扑排序的方式。那有没有什么样的图是不能被拓扑排序的呢?当然是有的。假设有这样一张课表:

图片

乍一看,可能和其他课表也没什么区别,但如果你把依赖关系同样表示在图上就会发现一些问题了。

图片

它们之间构成了循环依赖的关系,你无法找到第一门修读的课程,所以这种先修课程的安排关系在现实世界中也是不存在的,这样的情况其实和我们常说的死锁有点像。你永远没有办法在一个有向有环的图中找到可以被拓扑排序的方案。

所以,所有拓扑排序都是建立在有向无环图(DAG)上的,DAG这个词相信很多同学都很眼熟,在Flink和Spark这类可以用来做数据批计算或者流计算的框架中,就常常可以见到DAG这样的概念,用来做计算任务的调度。

好,现在我们已经知道拓扑排序是什么了,也知道它可以用来解决有依赖关系任务的排序问题。那拓扑排序的具体实现步骤到底是什么样的呢?

拓扑排序如何实现

有两种经典的算法可以实现,一种叫Khan算法,基于贪心和广度优先算法BFS的思想来解决这个问题,另一种则是基于深度优先算法DFS的思想(如果你对DFS和BFS的概念还不够熟悉,可以再复习一下之前的章节)。

khan算法

Khan算法是更符合我们直觉的一种方法,也更容易理解,我们就先来讲解基于BFS的Khan算法如何解决课程表问题,也就是LeetCode 210,感兴趣的话你可以课后可以去网站上提交一下这道题。

题目是这样的:假设你总共有 numCourses 门课需要选,记为从 0 到 numCourses - 1,给你一个数组 prerequisites 存储课程之间的依赖关系,其中 prerequisites[i] = [ai, bi] ,表示在选修课程 ai 前必须先选修 bi 。

图片

可以看到,输入的是课程间两两的依赖关系,事实上,这也是一种图的存储方式,prerequisites就是图上所有边的集合,我们一般称为边表。但是边表的存图方式不能让我们快速找到某个节点的后继节点,所以我们需要一种新的表示方式。

因为每个课程都是用 0 到 numCourses - 1 的数字作为课程标识,我们可以直接用一个二维数组next来表示图,next[i]里存储的是所有需要i作为先修课程的课程。这也是一种图的表示方法,我们一般叫做邻接表,通常可以用HashMap、数组、链表等数据结构实现。

图片

相比于邻接矩阵存储所有节点之间是否存在边,在邻接表中,我们只用存储实际存在的边,所以在稀疏图中比较节约空间,实际应用和算法面试中都非常常见。

1
2
3
4
5
6
7
vector<vector<int>> next(numCourses, vector<int>());
for (auto edge: prerequisites) {
int n = edge[0];
int p = edge[1];

next[p].push_back(n);
}

为了构建这样一张邻接表,我们要做的事就是遍历所有的先修关系,把edge[0]推入edge[1]所对应的课程列表也就是邻接表数组中。

输入处理完成,所有课程的先修关系图我们就有了。下一步就是选择合适的修读顺序。

相信你很自然就会想到,诶,那我们把所有不用先修课程的课都修完,然后从剩下的课程里找出接下来可修读的课程是不是就行了?之后按这样的顺序反复进行,直到所有课程都修读完毕。其实基于贪心思想的Khan算法就是这样做的。而这样一轮一轮遍历的感觉是不是也让你想到之前讲过的地毯式搜索的BFS呢?

我们一起来看整个题目Khan算法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Solution {
public:
vector<int> findOrder(int numCourses, vector<vector<int>>& prerequisites) {
// 邻接表数组
vector<vector<int>> next(numCourses, vector<int>());
// 入度表
vector<int> pre(numCourses, 0);
// 标记是否遍历过
vector<int> visited(numCourses, 0);
// 记录最终修读顺序
vector<int> ans;

// 构图
for (auto edge: prerequisites) {
int n = edge[0];
int p = edge[1];

next[p].push_back(n);
pre[n]++;
}

queue<int> q;

// 所有没有先修课程的课程入队
for (int i = 0; i < numCourses; i++) {
if (pre[i] == 0) {
q.push(i);
}
}

// BFS搜索
while(!q.empty()) {
int p = q.front();
q.pop();
ans.push_back(p);

visited[p] = 1;
// 遍历所有以队首课程为先修课程的课程
for (auto n: next[p]) {
// 由于队首课程已经被修读,所以当前课程入度-1
pre[n]--;
// 如果该课程所有先修课程已经修完;将该课程入队
if (pre[n] == 0) {q.push(n);}
}
}

// 环路检测: 如果仍有课程没有修读;说明环路存在
for (int i = 0; i < numCourses; i++) {
if (!visited[i]) return vector<int>();
}

return ans;
}
};

我们用一个队列来表示所有满足修读条件的课程,一开始把所有没有先修课程的节点加入其中;再遍历出队,每次出队说明该课程已经修读过了,然后我们需要再遍历一下这个课程的所有后继课程,看看哪些在修完这门课程之后就可以修读了。

那哪些课程可以呢?这里我们非常聪明地用一个图的入度数组就解决了这个问题,入度,其实就是有向图上每个节点入边的数量,我们在代码里用一个计数数组就可以记录。

具体来说,我们给每个节点增加一个先修课程计数器pre,表示该课程有多少入度,也就是有多少先修课程,这一步操作,在遍历所有先修关系建图的时候就会完成。

图片

所以之后每修读完一门课,也就是在出队的时候,会把这门课程的先修课计数器pre进行自减操作,如果发现哪个课程的pre已经为0了,那我们就可以把这个课程加入queue中了;因为这个时候这门课已经没有没修过的先修课程了。

按照这个方法遍历,所有课程的出队顺序显然就是一个合理的“拓扑序”的排列。

当然,我们还需要检查一下是不是所有课程都修读了,也就是代码的47-50行。如果有课程没修读完,其实就说明课表里有环存在,这不是一个合理的课表,按题目意思我们需要输出一个空集合。

Khan算法的全部思路和具体实现就学习到这里,如果你想更好地掌握该算法,建议你可以在力扣上找一些相关题目多做练习。

dfs算法

通过Khan算法的学习,相信你对拓扑排序已经有了非常具体的认知,我们来看看第二种基于DFS的拓扑排序算法,这也是html-webpack-plugin v3.2.0 所采用的策略,它又是如何解决Webpack打包HTML chunk的排序问题的呢?

先上代码,注释很清晰易懂,不过由于是前端框架,你可能需要稍微花一点时间熟悉一下JavaScript的语法,不做前端的同学不用太深究具体语义,就像我们前面说的,把chunks当作有依赖关系的节点就行,和课程表中的课程其实是很像的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
Sorts dependencies between chunks by their "parents" attribute.

This function sorts chunks based on their dependencies with each other.
The parent relation between chunks as generated by Webpack for each chunk
is used to define a directed (and hopefully acyclic) graph, which is then
topologically sorted in order to retrieve the correct order in which
chunks need to be embedded into HTML. A directed edge in this graph is
describing a "is parent of" relationship from a chunk to another (distinct)
chunk. Thus topological sorting orders chunks from bottom-layer chunks to
highest level chunks that use the lower-level chunks.

@param {Array} chunks an array of chunks as generated by the html-webpack-plugin.
- For webpack < 4, It is assumed that each entry contains at least the properties
"id" (containing the chunk id) and "parents" (array containing the ids of the
parent chunks).
- For webpack 4+ the see the chunkGroups param for parent-child relationships

@param {Array} chunks an array of ChunkGroups that has a getParents method.
Each ChunkGroup contains a list of chunks in order.

@return {Array} A topologically sorted version of the input chunks
*/
module.exports.dependency = (chunks, options, compilation) => {
const chunkGroups = compilation.chunkGroups;
if (!chunks) {
return chunks;
}

// We build a map (chunk-id -> chunk) for faster access during graph building.
const nodeMap = {};

chunks.forEach(chunk => {
nodeMap[chunk.id] = chunk;
});

// Next, we add an edge for each parent relationship into the graph
let edges = [];

if (chunkGroups) {
// Add an edge for each parent (parent -> child)
edges = chunkGroups.reduce((result, chunkGroup) => result.concat(
Array.from(chunkGroup.parentsIterable, parentGroup => [parentGroup, chunkGroup])
), []);
const sortedGroups = toposort.array(chunkGroups, edges);
// flatten chunkGroup into chunks
const sortedChunks = sortedGroups
.reduce((result, chunkGroup) => result.concat(chunkGroup.chunks), [])
.map(chunk => // use the chunk from the list passed in, since it may be a filtered list
nodeMap[chunk.id])
.filter((chunk, index, self) => {
// make sure exists (ie excluded chunks not in nodeMap)
const exists = !!chunk;
// make sure we have a unique list
const unique = self.indexOf(chunk) === index;
return exists && unique;
});
return sortedChunks;
} else {
// before webpack 4 there was no chunkGroups
chunks.forEach(chunk => {
if (chunk.parents) {
// Add an edge for each parent (parent -> child)
chunk.parents.forEach(parentId => {
// webpack2 chunk.parents are chunks instead of string id(s)
const parentChunk = _.isObject(parentId) ? parentId : nodeMap[parentId];
// If the parent chunk does not exist (e.g. because of an excluded chunk)
// we ignore that parent
if (parentChunk) {
edges.push([parentChunk, chunk]);
}
});
}
});
// We now perform a topological sorting on the input chunks and built edges
return toposort.array(chunks, edges);
}
};

在Webpack4之前,所有的chunks是有父子关系的,你可以认为父节点是需要依赖子节点的。Webpack在生成HTML的chunks时,需要按照拓扑序的方式一次遍历打包并嵌入到最终的HTML中,这样我们就可以把后面父chunk所用到的子chunk放在更前面的位置。

所以,59-73行,这一段代码其实就是用来把这个问题的边表提取出来的,我们通过forEach方法遍历了所有的chunks,然后把父子关系全部用edges变量记录下来,这和课程表的先修数组是一样的存图方式,也就是边表。

有了边表,我们就要对它排序了。真正的拓扑排序代码其实在第76行,调用了toposort.array方法,这个方法来自一个js的包toposort,它提供了非常干净的接口,输入一个DAG的所有节点和边作为参数,返回一个合法的拓扑序。

具体是怎么实现的呢?我们看一下toposort包的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/**
* Topological sorting function
*
* @param {Array} edges
* @returns {Array}
*/

module.exports = function(edges) {
return toposort(uniqueNodes(edges), edges)
}

module.exports.array = toposort

function toposort(nodes, edges) {
var cursor = nodes.length
, sorted = new Array(cursor)
, visited = {}
, i = cursor
// Better data structures make algorithm much faster.
, outgoingEdges = makeOutgoingEdges(edges)
, nodesHash = makeNodesHash(nodes)

// check for unknown nodes
edges.forEach(function(edge) {
if (!nodesHash.has(edge[0]) || !nodesHash.has(edge[1])) {
throw new Error('Unknown node. There is an unknown node in the supplied edges.')
}
})

while (i--) {
if (!visited[i]) visit(nodes[i], i, new Set())
}

return sorted

function visit(node, i, predecessors) {
if(predecessors.has(node)) {
var nodeRep
try {
nodeRep = ", node was:" + JSON.stringify(node)
} catch(e) {
nodeRep = ""
}
throw new Error('Cyclic dependency' + nodeRep)
}

if (!nodesHash.has(node)) {
throw new Error('Found unknown node. Make sure to provided all involved nodes. Unknown node: '+JSON.stringify(node))
}

if (visited[i]) return;
visited[i] = true

var outgoing = outgoingEdges.get(node) || new Set()
outgoing = Array.from(outgoing)

if (i = outgoing.length) {
predecessors.add(node)
do {
var child = outgoing[--i]
visit(child, nodesHash.get(child), predecessors)
} while (i)
predecessors.delete(node)
}

sorted[--cursor] = node
}
}

function uniqueNodes(arr){
var res = new Set()
for (var i = 0, len = arr.length; i < len; i++) {
var edge = arr[i]
res.add(edge[0])
res.add(edge[1])
}
return Array.from(res)
}

function makeOutgoingEdges(arr){
var edges = new Map()
for (var i = 0, len = arr.length; i < len; i++) {
var edge = arr[i]
if (!edges.has(edge[0])) edges.set(edge[0], new Set())
if (!edges.has(edge[1])) edges.set(edge[1], new Set())
edges.get(edge[0]).add(edge[1])
}
return edges
}

function makeNodesHash(arr){
var res = new Map()
for (var i = 0, len = arr.length; i < len; i++) {
res.set(arr[i], i)
}
return res
}

这个代码虽然不是很长,但还是没有那么好理解,我会把一些核心的逻辑讲解一下,你再对着看应该就很清晰了。

基于DFS的思路,和BFS优先选择所有可修读课的策略,就是完全不一样的两个极端。在DFS策略中,我们优先找出没有后继课程的节点,把它作为最后修读的课程,这样一定可以保证在DAG中的拓扑序中它是安全的,因为所有它依赖的节点一定在它之前。

那如何找呢?在DFS的策略中,我们需要几个关键变量,对应在toposort的代码里分别是:

  1. 数组visited:用来标记某个节点是不是已经遍历过;只要是遍历过的节点,就不再重复遍历。
  2. 数组sorted:用于存放最后拓扑序的结果集。

在DFS拓扑排序算法的传统讲解中可能会用一个栈来表示sorted这个结构。这里toposort包用了数组,让插入的顺序是从数组尾部开始,其实就是模拟了栈的结构。

之所以用栈,就是为了在DFS过程中把我们碰到的所有没有子节点的元素,按照先进后出的顺序维护,让这些节点在拓扑序中更靠前

  1. outgoingEdges数组:这也是一个基于数组实现的邻接表的实例。
  2. predecessors集合:这也是一个非常重要的变量,用于在DFS的过程里帮助我们判断是否有环。

怎么判断有环呢,其实很简单,从某个节点出发,如果发现在这次遍历的某个路径中能遍历到已经出现过的节点,就说明有环路存在了。具体来说,我们用predecessors把DFS过程中出现的元素记录下来就可以了。

当然DFS结束之后还需要注意恢复现场,就是说在一条路径调用完成之后,要把predecessors之前加进去的节点都移除,这是因为在有向图中两条路径可能有交集,但并不一定构成环。比如这个例子,红色路径和黄色路径都经过最下面的节点,如果不恢复现场在某次DFS中就会出现重叠。

图片

整个拓扑排序的过程,也就非常清晰,我们遍历所有节点,按照什么顺序都可以;然后从每个节点开始,沿着其有向边进行DFS的图遍历,遍历过的节点都会被visited记录遍历状态,保证不会重复遍历。

如果某个节点的子节点全部被visit函数DFS完毕,我们就会把当前节点加入栈中。因为当前节点子节点全部DFS完毕就意味着,所有依赖当前节点的节点都已经在栈中放到了该节点之后,于是只要能保证如果图中没有环,整个过程结束后,把栈里的元素自顶而下排列就是满足“拓扑序”的。

对着详细的说明,你重点看代码的36-68行,多看几遍代码应该就很好理解啦。

这就是第二种基于DFS的拓扑排序算法的思路,递归处理所有节点,采用先输出依赖自己的节点、再输出自己的策略来保证拓扑序。在早期的Webpack中就是这样处理chunk之间的打包顺序的。

复杂度

那么这两种算法的复杂度是多少呢?

首先khan算法,BFS的过程中把所有节点一一入队、再一一出队,搜索过程中每条边也会被遍历一次,所以对于有V个顶点E条边的图,整体的时间复杂度是O(V+E)。空间复杂度主要引入了额外的队列、各个顶点是否被访问的标记,以及记录了所有边信息的邻接表,所以空间复杂度同样和顶点数还有边数成线性关系,为O(V+E)。

基于DFS的算法,时间复杂度上其实和BFS差不多,在DFS过程中每个顶点、每条边也都会被遍历到,整体时间复杂度同样为O(V+E),空间上也有邻接表和访问记录等信息为O(V+E)。

总结

相信通过今天的学习,你已经了解了Webpack在打包HTML的时候是如何对chunk排序了吧,本质上就是一个对有向无环图输出节点“拓扑序”排列的问题。

在计算机的世界里,这样输出拓扑序的需求其实随处可见,不信你仔细想一想平时用brew或者apt-get装包的时候计算机都会做哪些事情呢?一个包可能依赖了许许多多不同的包,计算机是从哪个包开始装起的呢?相信你学了今天拓扑排序的两个算法,应该就知道怎么做了吧。

利用拓扑排序的算法,我们也可以来确定整个图中是否有环。在khan算法中,我们通过贪心算法把所有能输出到拓扑序的节点都输出,如果发现图中还有节点没有被输出的话,就可以说明它们一定在某个环路中了。而在DFS中如何做呢,前面其实我们也提到过,如果在DFS的过程中访问到一个已经被访问过且不是上一步之前被访问的节点,就说明环存在。

课后作业

如果只是要判断环路而不需要输出拓扑序的话,我们是否有效率更高的办法?你可以实现一下吗?

欢迎你在评论区留言和我讨论。如果有收获也欢迎你转发给身边的朋友,邀他一起学习。我们下节课见~

参考资料

你可能会疑惑,为什么选择了v3.2.0这个相对较老的版本?主要是因为Webpack4.0之后引入了chunkGroup的概念,用 SplitChunksPlugin 替换了 CommonsChunkPlugin,代码中不再需要对chunk进行拓扑排序了。

13|哈夫曼树:HTTP2.0是如何更快传输协议头的?

作者: 黄清昊

你好,我是微扰君。

HTTP 是当今最广为使用的互联网传输协议,我们都听说过HTTP/1.0、HTTP/2.0、SPDY、HTTP/3.0等概念,但是对这几者之间的区别能如数家珍的同学却不多,比如 HTTP/2.0 在编码方面做了什么样的改进,比HTTP/1.1 的传输更快呢?

我们今天就来学习一下HTTP/2.0 为了提高传输效率而引入的用于头部压缩的杀招:HPACK

HPACK应用了静态表、动态表和哈夫曼编码三种技术,把冗余的HTTP头大大压缩,常常可以达到50%以上的压缩率。其中的哈夫曼编码,底层主要就依赖了我们今天会重点学习的哈夫曼树,这也是广泛运用在各大压缩场景里的算法。

在展开讲解HTTP/2.0中的HPACK到底是怎么工作的,我们首先要来思考一下为什么要压缩HTTP的头,或者说,压缩到底又是什么呢?

压缩技术

我们都知道压缩技术诞生已久,在各种文件尤其是多媒体文件里,应用非常广泛,能帮助节约信息的存储空间和网络传输时间。

之所以能压缩,主要原因就是我们存储的信息往往是有模式和冗余的。以文本为例,大量单词的重复或者大量的空格,都是我们可以压缩的空间。原文件大小与压缩后文件大小的比值,我们就叫做压缩比,是衡量压缩算法有效性非常直观的指标。

压缩技术也分为有损压缩和无损压缩两种。

有损压缩,我们允许数据一定程度上的丢失,它在多媒体文件里更加流行,比如JPEG、MP3就是非常典型的两种数据有损压缩的方式。

压缩多媒体数据时,我们允许一定程度的丢失。主要是因为对图像或者音频文件来说,数据一定程度上的丢失并不一定会很影响用户的主观感受。比如压缩图片时,有一种方式会把颜色的种类减少,让图片每个像素的编码位数降低,从而就实现了图片的压缩,但是从人的视觉上说影响可能并不是特别大。所以有损压缩的衡量指标就不止压缩比,还需要考虑人的主观感受了。

但是互联网大部分应用中所用的通信协议,都不应该关心业务数据本身,要做的只是保证数据可以按照一定的方式准确、无误,并且尽量高效地从发送端传输到接收端,有损压缩显然是不可接受的。比如最常用的HTTP,就不会关心具体传输的内容是什么,自然不可能对数据做有损压缩。

所以在HPACK里,我们采用的当然也是无损压缩策略。

现在搞清楚了压缩技术的背景,在那HTTP里引入压缩技术是否有意义呢?毕竟如果压缩比不是很高,引入这样的设计,只会导致相关协议的客户端和服务端实现的复杂性提高,得不偿失。

引入HPACK的价值

早期采用HTTP的互联网应用,只涉及数据的展示,所以我们最初设计HTTP的时候没有引入状态,但是后期随着Web2.0的繁荣发展,网站不再只是展示这么简单,会和用户产生更多的交互,让用户产生内容,于是也引入了“登录”等有状态的功能。

要基于HTTP实现相关应用,我们常用的做法就是把用于鉴权的令牌或者其他“状态”携带在HTTP头里,在客户端和服务端之间来回传递。

出于安全需要,这种鉴权的令牌往往非常冗长,http header常常比http body还要大,这就带来了很大的开销。所以 HTTP/2.0 通过引入HPACK压缩协议头,就带来了很大的价值。

而且 HTTP/1.1 之前的 HTTP 协议传输的内容很简单,可以认为就是一串文本在互联网上直接传递,没有任何编码,这也给我们的压缩算法带来了很大的压缩空间

HPACK的压缩效果

既然HTTP中引入压缩技术很有意义,那我们就先来看看压缩之后的效果到底有多大吧。

h2load 是网上开源的一个对 HTTP/2.0 做 benchmark 的工具,我们可以在系统上安装它,来访问某些网站,直观地感受一下HPACK技术带来的HTTP头的大小变化:

图片

可以看到,采用了HTTP/2.0之后,直接压缩了HTTP头33%的空间,效果显著。

那HPACK具体是如何做的呢?我们现在就来一探究竟。

1.HPACK中的静态表

首先我们来看一下HPACK的第一个手段:静态表,它其实就是对HTTP头报文里最常见的文本进行了一种编码。静态表也是非常常用的压缩手段。

HTTP/2.0 一共对61个常用的头,以及头和值的组合做了编码(图片来源)。

图片

比如HTTP的几种请求方法,GET、POST等,都编码到了一张范围为1-61的索引表里,这样原来的”:method GET”等字符串需要的空间就小多了。

2.HPACK中的动态表

但是只是如此的话,我们能压缩的报文就非常有限了,怎么办呢?

我们应该还允许客户端和服务端,通过通信的方式,维护一张动态的“字典”,这样用索引号就可以代表一串很长的文本,减少在这次HTTP/2连接里反复出现的一些自定义字段的载荷。

比如,这些字段就是很好的例子:

1
2
:authority wfnuser
:cookies xxxxxx

尤其是常常用来保留用户身份凭证的cookies,因为安全性和加密算法的需要,它们往往设计的比较长,很多时候甚至导致header的长度比body还要长。

在http/1.1之前的协议里,每次通信都需要传递冗长且重复的信息,显然会带来巨大的开销。动态表就很好地解决了这个问题。

3.HPACK中的哈夫曼编码

其实只用动态表和静态表已经可以做到很好的压缩header了,但是受限于静态表和动态表的大小,我们并不能用它们压缩任意字符。

哈夫曼编码,就是对静态表和动态表能力的一种补充,HPACK在引入了哈夫曼编码之后,可以达到对HTTP报文高达30%-80%的压缩率。

那我们首先来了解一下哈夫曼编码是什么。

哈夫曼编码

其实,哈夫曼思想非常简单,就是让出现概率更高的字符用更短的编码表示,出现概率低一些的字符则用更长的编码表示。

这句话乍一听可能不太好理解,什么叫更短的编码呢,或者说什么是编码呢?

我们日常在用的ASCII编码就是对字符串的一种编码,每个字符都被编码到0-127的范围里,这也是在绝大部分编程语言里,一个char类型的字符只占用一个字节的原因;当然,一个字节实际可以表示0-255种可能,ASCII编码规范本身没有定义128-255的范围,所以各大厂商都可以有自己的扩展定义去表示更多的字符。

所以ASCII作为一种典型的每个字符都等长编码的编码方法,有没有办法被压缩呢?是可以的。

比如,在自然语言场景里,我们知道字母e可能是最常出现的字符,如果用更短的编码去表示e,而用其他更长的编码表示其他字符,就可以达到压缩文本编码长度的效果。

不过这里还有个问题,用等长编码比如ASCII编码,我们解码的时候直接8位、8位的读,就很容易解出编码前的字符串,但如果用变长编码,我们就需要处理解码歧义的问题

比如在字符串“ABCD”里,我们假设把A用0编码、B用1编码、C用10编码、D用11编码。“ABCD”可以编码成二进制编码为“011011”,没问题,但如果中间不加任何分隔符,你并不能知道这个编码结果是由“ABCD”产生的还是由“ABBABB”产生的。

如果加了分隔符,分隔符本身也会引入额外的编码成本,甚至可能导致一个负向的优化。

为了解决这个问题,学生时代的哈夫曼(huffman)在 1952 年提出了最优前缀码的算法,也就是广泛应用在压缩领域的哈夫曼编码,它除了用更短的编码表示出现概率更高的字母,还引入了一个约束:不同的字符编码间不能彼此成为对方的前缀。

这条约束在解码的时候完美地避免了歧义的问题。比如刚刚如果把A编码成0、B编码成10、C编码成110、D编码成111,这就是一种符合约束的前缀码编码方式。ABCD就会编码成 010110111,一定只有一种解码方式。

那在不能成为对方前缀的约束下,具体如何根据出现频率选择合适的编码方式呢?

哈夫曼采用了贪心的算法思想:用一棵二叉树来标记每个字符的编码方式,左分支代表0、右分支代表1,所有需要编码的字符都对应二叉树的叶子节点,根结点到该叶子结点的路径就代表着该字符的编码方式。由于各节点是独立的不可能重复,每个字符又都唯一对应着一个叶子节点,所以它们一定不会互相成为对方的前缀。

下面我们要做的就是找到这样一个可以达到最大压缩效率的二叉树。

贪心的哈夫曼树

让我们举一个例子来理解哈夫曼树的编码方式。假设要对 a b c d e f 进行编码,它们在需要编码的文本中出现的频率分别是 5 9 12 13 16 45。

1
2
3
4
5
6
a           5
b 9
c 12
d 13
e 16
f 45

那么如何编码可以让整个文本编码出来的二进制所占空间最少呢?

最开始,我们先把每个字符都看成一个独立的二叉树节点,节点中同时包含了字符信息和频率信息。

然后,从中选两个出现频率最少的节点,a和b,我们把这两个节点合并成一棵子树,也就是用一个父节点的左右节点指针分别链向a和b两个节点,把两者的频率之和作为父节点的频率。

之后,我们把这个频率为14的树放回所有的节点里(14 12 13 16 45),再从中继续选择最小的两个节点c和d合并成一棵新的树。

更新之后的所有节点就是下面这些,其中a、b、c、d节点都被替换成了新的合成节点:

1
2
3
4
(a, b)      14
(c, d) 25
e 16
f 45

不断进行这样的操作,最终就可以得到这样一棵树:

假设树的左链代表0,右链代表1,a b c d e f 对应的编码为:

1
2
3
4
5
6
f          0
c 100
d 101
a 1100
b 1101
e 111

看完这个过程,相信你对为什么这样编码也有一些想法了,它非常直观,我们来一起研究一下。

  • 首先为了不用额外的分隔符,一种让解码不产生歧义的办法就是引入前缀码规则,对应到树上就是每个字符都编码成根节点到某个叶子节点的路径。
  • 然后为了“出现频率最高的字符用最短的方式编码”的策略,显然,我们需要让出现频率最高的树出现在最短的路径里,出现频率最低的树则放到更长的路径里。

因为每次将两颗树合并到一起时,都会导致这两颗树里所有叶子节点的高度加1,也就是其中所有的字符编码长度都会+1,所以,为了达到最优编码的目标,我们会从出现频率最低的节点开始合并。最后我们合并完新的树,也要把新树的频率变成这两颗树的频数之和,它代表了这颗树下所有字符出现的频率。

这样每次找出最低的两个树合并,就必然能得到一个整体最优的编码方式,也就是哈夫曼编码的思路了

这背后的思想其实就是贪心算法,也就是在每一次决策时都采取在当前状态下最优的选择,从而得到整体最优解的算法。当然,也不是所有的场景都能使用贪心算法的,比如经典的背包问题,采用贪心算法虽然能得到局部最优解,但就不能得到全局最优解。而哈夫曼树则是一个贪心算法发挥作用的很好的例子。

哈夫曼树实现

现在有了思路,相关的实现其实就非常简单了。

先说编码的部分,实质就是要建立这样一棵基于贪心算法的哈夫曼树。二叉树的相关概念相信你已经非常熟悉了,所以这里的核心就是如何做到每次都可以快速选出频率最低的两棵树。

怎么做呢?相信你已经想到了,我们之前学的堆就是用来维护动态序列中最小值的利器。

假设一共要对N个节点进行编码,堆中最多有N个节点,每次合并涉及两次取出元素和一次放回元素,时间复杂度都是O(logN),整体时间复杂度为O(N*logN)。翻译成C++代码,我写了比较详细的注释供你参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void buildHuffmanTree(string text)
{
// 利用hashmap对字符串进行频率计数
unordered_map<char, int> freq;
for (char ch: text) {
freq[ch]++;
}

// 用堆去动态维护所有树中最小的两颗
priority_queue<Node*, vector<Node*>, comp> pq;

// 将所有的字符都初始化成为哈夫曼树的一个叶子节点
// 并推入优先队列
for (auto pair: freq) {
pq.push(getNode(pair.first, pair.second, nullptr, nullptr));
}

// 每次取出最小的两个合并 直至优先队列只剩一个节点
while (pq.size() != 1)
{
// 最小的两个节点出队
Node *left = pq.top(); pq.pop();
Node *right = pq.top(); pq.pop();

// 建立一个内部节点,以这两个最小的树为左右节点
int sum = left->freq + right->freq;
pq.push(getNode('\0', sum, left, right));
}

// 优先队列中最后一个元素为整棵树的根节点
Node* root = pq.top();
}

好,到这里我们就构建好了一棵哈夫曼树了。基于它,我们可以很方便地通过对哈夫曼树的遍历做到对字符串的编码和解码,进而实现压缩的效果。具体的实现都贴在GitHub仓库中了。

学习完哈夫曼编码,HPACK中用于压缩的最后一个杀招你也就学会了。

HPACK采用的是静态huffman编码,HTTP/2.0 协议制定者利用一个很大的HTTP Header的sample,统计了所有字符出现的频率,并基于此构建了一个huffman编码表,需要内置在服务端和客户端里,最多能带给我们大约37.5%的压缩率。

总结

今天关于HPACK的讲解到这里就结束了,我们做个简单的小结。

HPACK是HTTP/2.0 为了降低HTTP payload大小从而提高传输效率的杀招,应用了静态表、动态表和哈夫曼编码三种技术,把冗余的HTTP头信息大大压缩,常常可以达到50%以上的压缩率。

前两招静态表和动态表的思想其实非常常见。

比如在设计消息系统时,微服务架构下经常涉及消息在不同系统间传递的需求,如果只是为了定位消息而不用真的读取消息体,我们完全可以把消息编码成“消息ID +消息体”的格式,存储在数据库或者其他缓存系统中,这样,在系统间传递的时候只需要传递ID即可,等真的需要取出消息体的时候,再到数据库等系统里读取具体内容。这可以大大减少系统通信的开销,背后其实就是类似动态表的思想,你可以举一反三。

第三招哈夫曼编码,引入不同的字符编码间不能彼此成为对方前缀的约束下,使用哈夫曼树来编码。哈夫曼树基于贪心的思想,以及用树对编码进行抽象的想法,也非常精巧,也值得你好好学习一下。

课后作业

最后再给你留个课后小问题,哈夫曼树在HTTP/2.0 中一定可以获得更优的编码方式吗?为什么?欢迎你在留言区与我一起讨论。

参考资料

HPACK 主要由静态表、动态表和哈夫曼编码三个部分组成,具体可以参见 rfc7541。相信如果是对计算机网络熟悉的同学一定会了解 RFC 文档,主流的网络协议比如 WebSocket、MQTT、TCP 等等都有对应的RFC文档,它也是你学习相关协议最权威的参考资料之一。

14|调度算法:操作系统中的进程是如何调度的?

作者: 黄清昊

你好,我是微扰君。

之前我们已经学习了大部分常用的数据结构和一些经典的算法思想,从今天开始,我们将正式迈入算法在真实世界的应用,感受计算机先辈们在解决实际问题时天马行空的智慧之光。希望带给你思维乐趣的同时,也能给你解决实际工作里的问题带来一些启示。

就让我们从操作系统开始说起,作为计算机软件的基石,它是计算机软硬件交汇的关键所在。

当然,操作系统同样是一个非常大的话题,不同历史时期的操作系统都背负着不同的使命。发展至今,随便一个可用的操作系统都有几千万行的代码,上上下下用到的算法肯定也非常多,我们不可能全部涉及,这次会挑出几个关键的算法或者设计来讲解,包括:计算机进程调度算法、内存页面置换算法和日志文件系统。

我们今天要学习的就是进程调度算法,也就是 Process Scheduling Algorithms。

在许多中间件、语言设计甚至日常开发的业务系统中遇到问题时,我们常常会参考操作系统中成熟的解决办法,进程调度就是这样一种常常被借鉴的场景,在不少语言的线程或者协程机制的设计里都有应用。

那操作系统的进程调度到底是如何设计的呢?话不多说,我们开始今天的学习。

进程是什么?

在聊进程调度算法之前,我们先简单复习一些操作系统相关的基础概念。

首先,我们要明白进程是什么?

我想“Process”最早被翻译成“进程”,应该指的就是“正在进行的程序”的意思。我们知道计算机是可以同时进行很多任务的,比如你现在可能就边开着浏览器阅读这篇文章,边打开着微信软件随时可以处理好友的消息。你的计算机就像一个真正的时间管理大师一样,并发而有条不紊地处理着各种复杂的任务。

但事实上,每个CPU核在同一时间只能同时运行一个程序,那计算机是如何做到看起来可以同时执行很多任务的呢?

这里就需要用到进程、线程之类的抽象了,这也是早期计算机引入多进程的主要目的,让你的计算机看起来可以同时执行不同的任务

我们通常会把不同的程序分配给不同的独立进程去执行,让计算机基于一定的策略,把CPU的计算时间调度给不同的进程使用;但因为进程间切换的时间一般比较短,并不能达到人们能感知到的阈值,所以用户在使用计算机的时候就会觉得多个程序或者任务是同时,也就是并发,执行的。

如果你在Linux系统上运行一下ps命令,就可以看到你的计算机当前正在运行的许多进程了:

图片

可以看到进程都会被分配一个PID,也就是进程的标识符。

而每个进程在执行程序的时候显然也要访问内存,也需要自己的程序计数器等资源,操作系统都会给每个进程独立分配这些资源。

如果把操作系统比作一家公司的CEO,进程就像这家公司的员工,每个员工当然需要被分配有自己独立的办公设备,而许多任务,就像是客户的需求。为了让这些员工可以有条不紊地完成这些需求,当然也就需要一定的调度算法。

图片

怎么实现调度呢?我们首先要介绍进程状态这个概念。其实就相当于每个员工当前的工作状态,我们只有知道各员工是空闲还是正在工作,才能科学分配需求,以高效完成更多任务。

进程状态

以Linux内核为例,进程的状态还是比较多的,它们都被定义在 include/linux/sched.h 下,#define 是C语言宏相关的语法,你不熟悉的话,简单理解成左边的是变量名,右边的是变量名对应的值就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define TASK_RUNNING&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 0
#define TASK_INTERRUPTIBLE&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 1
#define TASK_UNINTERRUPTIBLE&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 2
#define __TASK_STOPPED&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 4
#define __TASK_TRACED&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;8

#define EXIT_DEAD&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;16
#define EXIT_ZOMBIE&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;32
#define EXIT_TRACE&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; (EXIT_ZOMBIE | EXIT_DEAD)

#define TASK_DEAD&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;64
#define TASK_WAKEKILL&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;128
#define TASK_WAKING&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;256
#define TASK_PARKED&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;512
#define TASK_NOLOAD&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;1024
#define TASK_NEW&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 2048
#define TASK_STATE_MAX&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 4096

注意这里的state都是一个可以被表示成2的幂次的数字,这其实是一种常见的bitset的表示方式,方便用位运算判断状态,之后讲解布隆过滤器的时候我们再讨论。

进程状态,本质上就是为了用有限的计算机资源合理且高效地完成更多的任务。我们就看一种简化的模型来学习,把操作系统进程的状态分为3类:READY (就绪的) 、 RUNNING(运行的)、BLOCK(阻塞的)。

图片

RUNNING就是程序正在执行的状态,非常好理解,READY和BLOCK要涉及程序执行中一块比较重要的耗时来源 IO。因为程序运行除了计算之外,也经常需要与外界进行交互,比如等待用户输入一串文本、或者往显示器上输出一副画面,或者从网卡接受一些数据等等,这些操作,我们一般称为IO,也就是输入输出。

计算机执行程序的时候是单进程的,如果需要等待一个IO操作才能执行后续指令,那在IO数据返回前,整个CPU就不会执行任何有意义的计算了,也就是只能放在那边空跑。用公司-员工的例子就是某个员工被一个任务阻塞了,其他员工也都只能闲着,什么都干不了,这显然不是一个好的策略。

如果有了多进程就不一样了。一个正在运行的进程,如果需要等待一个IO操作才能执行后续命令,我们就让这个进程的状态变成阻塞的。操作系统就会把当前阻塞的进程调度开,换一个可以被执行的也就是就绪的进程去运行,被调度执行的新进程现在就成为一个运行中的进程了,而那个被调度到一边的进程I/O结束后,也就会重新进入就绪状态。

过程切换就像这样:

图片

这是进程的第二个意义:可以提高程序的性能,让我们不必再空等IO的耗时,尽可能多地利用CPU的计算资源

好,复习完进程相关的一些基本概念,我们进入今天的主题,调度算法。

调度算法

一个合理的调度算法对CPU的利用率、程序的总体运行效率、不同任务间的公平性起着决定性的作用,这并不是一件容易的事情,因为CPU的算力是各进程所需的资源,但它非常有限,于是人们发明了许多不同的调度策略。

考虑到不同任务的耗时和优先级两项指标,一般可以分为两大类策略:

  • 非抢占式调度
  • 抢占式调度

我们还是用公司-员工的例子来简单解释一下这两大类调度策略。

公司有许多客户的需求待处理,每个员工负责一个客户的需求,但都需要用到计算机来处理自己的需求,当然不同需求的解决时间可能不同;但是公司现在只有一台计算机,这时某个员工使用这台计算机,就好像操作系统用CPU执行某个进程。

我们的本质问题“如何用有限的计算机资源合理且高效地完成更多的任务”,现在其实就变成了如何在耗时不同的任务间合理切换进程。

图片

一个非常简单的想法就是让所有员工排队用这台计算机,轮到的这个员工一直使用到自己的所有工作都处理完,才让给下一个同事。这就是非抢占式调度的思路,操作系统调度到某个进程之后,不会对进程做任何干预,直到该进程阻塞或者结束,才会切换到其他就绪的进程。

但如果轮到的这个员工处理完自己的工作需要2小时,但后几名员工都只需要几分钟,这个排序效率就不够好了。

考虑到这种问题,就有了抢占式调度的策略,操作系统调度到某个进程之后会给它分配一个时间片,如果超过时间片还没有结束或者中途被阻塞,该进程会被操作系统挂起,调度其他进程来执行其他程序。这就好比在公司里加了一个协调者,如果有员工用电脑时间太长,就让他先暂停一会重新排队,先把计算机分配给其他同事。

这里进程的切换主要依赖操作系统的时钟中断,是一个比较复杂的机制,涉及计算机硬件,感兴趣的同学可以搜索时钟中断了解相关知识。

显然,抢占式调度会有更好的公平性,不容易让资源永远被个别耗时长的程序长期霸占,而让其他任务迟迟得不到运行的机会,被饿死;但抢占式调度也带来了更多的切换次数,这会造成更高的上下文切换的成本

就好像不同员工如果用同一个电脑工作,那每次员工被调度开的时候,肯定要保存自己的工作状态,比如保存自己操作的一些数据并关闭文档;下一个员工来的时候,也要恢复自己之前的工作状态。这些都会产生成本。

进程的切换也是一样的,我们需要保留程序运行的状态,然后重新恢复另一个进程的运行状态,像虚拟地址空间映射也需要做相应的转换以保证进程间的隔离性。如果频繁切换就会让CPU真正用于计算的时间比例降低。

所以我们很难一概而论说哪种调度方式就是更好的,一般来说:

  • 非抢占式调度,更适合调度可以忍受延迟执行的普通进程。
  • 抢占式调度,更适合调度交互性要求高的实时进程。

操作系统的应用场景和任务类型很多,有些场景实时性要求就更高。像在自动驾驶场景中,一些碰撞检测或者视觉信号的检测关乎驾驶员和行人的生命安全,显然不能让它们随意被其他播放音乐之类的任务阻塞。我们一定要让这些高优先级的任务可以随时抢占优先使用CPU,而一些批处理之类的后台任务可以按照先来先到的顺序慢慢执行。

接下来,我们就以Linux中进程调度的实现为例,讲一讲基于这两类调度策略的一些常用调度算法;当然,由于操作系统中的真实源码实现涉及Linux对进程的管理和存储方式,不是一两节课能讲完的需要你自己研究,所以我们会用伪代码来讲解核心思路。

Linux的进程

按相同的思路,Linux进程其实也分为两类,一类是有实时交互需要的,它们需要尽快返回结果,不能一直得不到执行;另一类则是普通进程,大部分优先级要求不高,可以忍受更长时间的得不到执行。

图片

我们先来看实时交互进程中的调度算法。

Round-Robin 算法

一种最经典的实现就是 Round-Robin 调度算法,这种算法也常常作为服务器负载均衡的算法,其主要特点就是比较简单且比较公平。

具体做法非常好理解,Round-Robin 本身从字面意义上来说就带有循环的意思,所以顾名思义,我们固定时间片段的长度,然后把所有的进程用一个队列维护,每个进程只能最多执行时间片的最大长度,比如50ms,如果还没执行完或者因为IO等原因阻塞,就得换下一个进程执行了。

实时进程调度的算法衡量指标之一就是平衡性,因为有实时交互需要的进程不能一直得不到执行,需要雨露均沾。比如在图形化的交互任务中,平衡性比较好的调度算法,往往就不会出现有一些计算密集型的任务过多占用CPU导致用户体验到卡顿的情况。

所以Round-Robin算法最大的优势就是不会存在某个进程执行时间太长,每个程序都可以有机会得到较早的执行。

看 Round-Robin 的例子,一共有ABCDE5个进程,arrival time代表进程产生的时间,service time代表进程总共需要执行的时间,单位就是时间片的长度。

图片

可以看到,在整个操作系统运行的时间里,这些进程都是轮流执行的,不会一直等待。

那我们选择的时间片是不是越短越好呢?

当然不是。前面说过进程切换是有开销的,每次切换都需要保存程序运行的状态,并将新的状态装载进寄存器中,这些都需要时间,这个时间大约需要1ms。

如果我们极端一点假设每个时间片只有2ms,那么每次切换到新的进程,大约需要花费1ms恢复现场和保留现场,那真正留给计算机计算的时间只占了总CPU运行时间的50%,这显然是一个极大的浪费,可能直接导致系统上所有的程序运行速度直接拖慢一倍!一般来说,为了平衡公平性和效率,在目前的硬件架构下,常见的时间片长度为30-50ms

Round-Robin算法的相应逻辑翻译成伪代码也非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
time_slot = 50
cur_time = 0 // 用于表示运行的时间
tasks = new queue() // 用于存储所有的进程
while (!tasks.empty()) {
task = tasks.pop() // 选出队列前的进程运行
if (task.time > 50) { // 如果运行时间超过时间片长度,需要挂起当前进程
tastk.time -= 50
tasks.insert(task) // 并将该进程重新放回队列中重新排队等待下一次调度
cur_time += time_slot
} else {
cur_time += task.time
}
}

当然真实的上下文切换是由时钟中断所触发的,并且如果出现阻塞,当前进程也会直接被调度走,就不在代码中演示了。

在实时进程调度算法中,常用的还有高响应比优先调度 HRRN 算法和多级反馈队列调度 MFQ 算法等,简单介绍一下感兴趣可以自己搜索相关资料学习:

  • HRRN 算法是一个非抢占式调度算法,按照“等待时间/执行时间”作为优先级排列,每次选择优先级最高的进程执行,直至完成。
  • MFQ 算法比较复杂,建立了多个等级的队列,优先级高的队列中的进程总是优先得到调度且时间片短;优先级低的队列则不太容易调度,但调度到可以运行的时间片也更长一些。

普通进程调度

我们再来看看实时性要求没有那么强的普通进程是如何被调度的。

在这种场景下,我们通常关注的指标主要有两个:

  • 吞吐量,系统单位时间内完成的任务数量。
  • 周转时间,每个任务从提交到完成的时间。

常见的算法也都比较简单,主要有三种:FCFS先到先服务算法、SJF最短任务优先算法、SRTF最短剩余时间优先算法,这三种算法都是非抢占式的。

FCFS

FCFS(First Come First Serve)是最简单也最直接的,其实它和我们学过的队列很像。

按照进程产生的顺序将它们放到一个队列中,每次调度的时候,直接取队列中第一个进程执行,这是一个非抢占式算法,所以直到这个任务完成或者被阻塞前,我们都会一直执行这个任务;如果这个任务被阻塞了,就重新将它加回队尾重新排队。

图片

但不好的地方就是对短任务不是很公平,如果短任务之前有长任务,长任务就会一直执行,这样一来短任务的周转时间就被拉长了,即使完成它的时间其实很短。整体的平均周转时间也就变得比较差。

SJF

有了FCFS的基础,我们自然想到,让短任务更优先执行,是不是就能降低平均周转时间了呢?这就是SJF(Shortest Job First)的思路,

SJF在从队列取出任务的时候,按照作业时间把待作业的任务排序,优先调度最短可以完成的任务。

图片

SJF因为按各任务的需要时长排序,可能导致长任务一直得到不到执行,会被饿死,而因为最短可完成时长没有把有IO的情况纳入计算,也就出现了下一个SRTF算法。

SRTF

SRTF(Shortest Remaining Time First)从思想上来说和SJF差不多,只不过放回队列的时候按照作业剩余时间排序,优先调度剩余完成时间最短的任务。

图片

因为都是非抢占式的调度,在没有IO的时候,SRTF其实和SJF的机制是一样的,只不过它可以把有IO的情况也纳入到考虑范畴中,如果任务因为阻塞主动调度开,我们再次出队的时候不会再傻傻的按照任务总时长进行排序,而是按照剩余需要的时长进行排序,尽量提高调度整体的吞吐量。

同样,SRTF基于时长的排序策略也一定程度上放弃了公平性,和SJF一样,可能导致长任务一直得到不到执行。

当然,其实还有许多特定场景的调度算法。比如有些系统中,我们会关心某些任务的截止时间,如果任务快到截止时间了,我们需要优先完成接近截止时间的任务。

总结

我们讲了几个主要的CPU调度算法,大致可以分为抢占式调度和非抢占式调度两大类,分别更加适合调度交互性要求高的实时进程和可以忍受延迟执行的普通进程。在实时交互进程中,有简单且较公平的Round-Robin 调度算法,在普通进程调度时,有非抢占式的FCFS先到先服务算法、SJF最短任务优先算法、SRTF最短剩余时间优先算法。

不同的调度算法,有不同的使用场景,很难说哪个算法一定比另一个更好,不同的算法只是在公平性、效率、吞吐量、等待时间等因素间做了不同的取舍,我们要根据实际的需要选择合适的调度算法。

而在许多操作系统之外的场景,相关的调度思想也有许多应用。比如服务器的负载均衡等场景下,我们就可以采用公平的 Round-Robin 算法进行类似的轮训请求;甚至在前端领域也有应用;比如,React的fiber机制也是源于操作系统的进程调度,它很好地解决了React网页应用可能因为一些diff等需要cpu密集计算的操作所带来的卡顿现象,让单线程的JS运行时有了“多线程”般的神奇能力。

课后作业

最后,我也留给你一个小思考题。如果让你设计一个对DDL,也就是任务最晚执行完毕时间,敏感的调度算法,你会怎么样设计呢?

欢迎你留言与我讨论,如果觉得有帮助,也欢迎你转发给你的朋友一起学习。我们下节课见~

15|LRU:在虚拟内存中页面是如何置换的?

作者: 黄清昊

你好,我是微扰君。

今天我们继续讲解操作系统中另一个常用的算法, LRU算法(Least recently used),也就是最近最少使用页面置换算法。这是操作系统中常用的内存置换策略之一,在内存有限的情况下,需要有一种策略帮助我们把此刻要用到的外存中的数据置换到内存里。该算法也同样适用于许多类似的缓存淘汰场景,比如数据库缓存页置换、Redis缓存置换等。

在开始讲解LRU算法本身之前,我们先来了解一下这个算法在操作系统中到底解决了什么问题。

操作系统的缓存淘汰

我们知道,计算机是建立在物理世界上的,底层的存储计算需要依赖许多复杂的硬件:比如内存、磁盘、纷繁的逻辑电路等。所以操作系统的一大作用就是,通过虚拟和抽象为应用开发者提供了一套操作硬件的统一接口,而分页机制的发明,就是为了不需要让用户过度操心物理内存的管理和容量。

通过虚拟内存和分页机制,用户可以在一个大而连续的逻辑地址和非连续的物理地址之间,建立起映射。其中,物理地址既可以真的指向物理内存,也可以指向硬盘或者其他可以被寻址的外部存储介质。

用户的程序可以使用比物理内存容量大得多的连续地址空间;而计算机在运行程序的时候,也不再需要把进程所有信息都加载到内存里,只加载几个当前需要的页就可以了。

图片

但是内存容量并不是无限的,访问到不在内存中的其他页,硬件会触发“缺页”中断,操作系统会在内存中选出一个页,把它替换为需要访问的目标页。这样我们才能访问到需要的数据。如果你对操作系统的内存管理机制感兴趣,推荐阅读CSAPP

这种场景在各种需要缓存的系统中也很常见。比如知名的缓存中间件Redis,就是利用内存读取数据的高效性,去缓存其他可能更慢的数据源的数据,以达到更快的IO速度,也用到了缓存置换算法。毕竟任何系统的存储空间都不是无限的,当我们缓存的数据越来越多,必然需要置换掉其中一部分数据。

而如何选择一个合适的页面(或缓存内容)来替换,就是我们今天的重点LRU算法主要讨论的内容。带着这个问题,我们开始今天的学习。

置换策略

具体怎么样的置换策略是更合理的呢?

我们主要观察的指标是缓存命中率:在整个系统的生命周期里对比数据访问时,可以直接从缓存中读到的次数和数据访问的总次数。

命中率越高,就代表越多数据可以直接从缓存中获取到,系统更少访问成本更高的存储,系统的整体时延就会降低。以操作系统为例,命中率高,就意味着我们发生缺页中断和从外存中获取数据的次数会减少,而访问内存的速度比访问外存要快得多,CPU利用率当然也就会更高。

在操作系统中,页面置换策略其实有很多种,你可能也知道一些,比较常见的包括FIFO(先进先出)、LFU(最不经常使用)、LRU(最近最少使用)等。页面置换算法,在上世纪六七十年代曾经是学术界讨论的热点。

其中LRU是实际应用最广的策略,因为它有着比较高的命中率并且实现非常简单,在虚拟内存系统中效果非常好。主要思想就是,当我们需要置换内存的时候,首先去替换最久没有被访问过的数据,这能很好利用数据的时间局部性,因为我们倾向认为最近被访问过的数据,在整个系统的生命周期里,有更大机会被访问到。

当然,LRU也不都是最优的,比如在特定负载的网络代理缓存场景下,很可能使用LRU就并不是一个最佳选择,因为网络负载很可能在不同的时候变化很大。但是毫无疑问,LRU在内存管理上有着绝佳的应用。

下面我们结合具体例子来看看这几个页面置换策略的区别。

时间局限性与页面置换算法

刚才提到的,时间局部性,是一个比较抽象的描述,为了更直观地讨论这些策略帮助你理解这个概念,这里用一个序列表示操作系统依次访问的页面,序列里的每个元素代表需要访问的页码。假设整个物理内存最多只能放3页,当页数超过3,并访问内存中不存在的数据,就会触发缺页中断。

我们把页面的访问序列叫做“引用序列”,之后的讨论都会建立在下面这个广为流传的引用序列例子上来展开,s表示这个序列,s[i]表示第i次访问的页码:

1
7 0 1 2 0 3 0 4 2 3 0 3 2&nbsp;1 2 0 1 7 0 1

图片

随机页面置换算法

既然发生缺页中断时,我们需要确定一个主存中需要被替换的页,那么一种很自然而然的想法就是通过软硬件的随机发生器选择一个页面替换。

也就是第一种策略,随机页面置换算法。

这种思想非常简单也易于实现,但是很显然,这种算法并不令人很满意。因为它没有用到任何历史访问记录的信息,而历史信息是很有用的,也是我们唯一能用于优化命中率的依据。

另外,这个算法导致同一个引用序列的产生的缺页中断次数是不稳定的,这会导致系统的性能不稳定,所以我们也不太会在实际系统中见到这样的策略。

最优页面置换算法

现在来看第二种策略,最优页面置换算法。这能让我们更好地理解为什么需要使用历史访问信息来帮助优化命中率。

从这个名字也能看出来,这个算法是一种最优的解法,但只是理论上存在的“上帝”算法,因为它的工作方式是,在替换页面的时候,永远优先替换内存中最久不被访问的那个页面,尽可能晚地触发缺页中断

第一行就是引用序列,从高到低顺次排列的三个方块就代表三个缓存页,其中绿色的块代表新替换上的页面。

图片

在例子中,我们访问s[10] = 0时,就可以把内存中的4替换掉,因为4在之后的访问中没有出现过。按照类似的策略,观察后续少出现的页码,尽量少触发缺页中断。所以一共只需要触发9次缺页中断。

很明显,这并不是一个真的能被实现的算法,因为当运行程序时,并没有很好的办法去预测之后访问的页码是哪些,唯一能做的就是尽量从历史的访问记录里推测出,哪些页码可能会很长一段时间不被访问。

总的来说,这个仅存在于理论上的算法主要的意义就是可以为我们衡量其他算法的好坏做一个参考。

FIFO算法

既然要利用历史记录,你是不是很自然想到放得久的数据先置换出去,也就是First In, First out。这也就是我们要介绍的第三种策略,FIFO,先进先出算法。

先进先出,这个词相信你一点也不陌生,我们在第3讲介绍队列数据结构的时候也有提到,队列的基本特征就是先进先出。

在页面置换中,先进先出的策略是这样工作的:在每次缺页中断时,替换当前物理内存中最早被加入缓存的页。实现也很简单,可以通过一个循环链表来存储所有页。

这看似比较符合直觉,但在操作系统的实际应用中表现很差。我们结合刚才引用序列的例子来看,可以画出这样的示意图:

图片

可以看到,我们进行了15次缺页中断,和最优解相比多了很多次置换。比如在序列是s[4…6] = 0 3 0的时候,这里的0因为出现的比较早,在s[5]时被替换成3后,又遇到马上要读取0的情况,又要做一次缺页中断的操作。

所以对于符合直觉的FIFO算法,先加入的页面可能会被多次访问,如果经常让更早被加入但访问频繁的页面被淘汰,显然不是一个很好的策略。这意味着我们不但要用历史数据,还要更好地设计利用的方式,让策略更接近于最优算法

LRU算法

今天的主角LRU最近最少使用算法,就是这样一种直观简单、实际检验效果也非常好的页面置换策略。

通过刚才的几个例子,结合你实际使用体验,会发现在操作系统的场景下,引用序列有明显的局部相关性,每个出现的页码有比较高的概率会在相邻的一段时间里反复出现

上一个FIFO算法的一大失误就在于没有考虑局部性,当一个页码多次出现时,FIFO并没有将这个信息记录并反映到淘汰页面的选择策略里,所以可能就会淘汰了一个近期出现过,但是之后又很快就会再次出现的页码。

既然我们不能预测未来,简单替换最早的页码也不好用,那么一种很自然的想法就是,如果某个页码在过去访问过,就尽量晚点去淘汰它。我们可以选择内存中最近最少使用的页码进行替换,这也正是LRU的策略。

图片

比如在获取数据3时,我们在LRU中替换的是最久没有被访问的1,而在FIFO中我们替换了0。但是,0刚访问过,理论上来说之后访问的概率也会更高,不过在FIFO策略下,因为0是最早进入的被替换了,就导致了后面访问0时产生了一次缺页中断。

相比于FIFO,同样的例子我们只进行了12次缺页中断。采用LRU算法,大多数时候会比FIFO和随机策略有更好的性能。

实现思路

既然有了基本的想法,我们就要想办法高效地实现它了。

Linux源码比较艰深且涉及的背景知识比较多,这里我们选择自己动手实现简单LRU的方式来进行源码级讲解。

对于从指定页获取数据的操作,可以用一个HashMap来模拟,可以用key代表页面号,用value代表页面中具体的数据。

所以问题可以更通用地抽象为设计一个数据结构,提供get和put两个接口。get的时候输入一个key,我们可以快速地访问key所对应的value;put的时候设置某个key对应的value。同时这个数据结构初始化时需要设定一个capaticy,当数据结构中的key数量超过capacity时,按照淘汰最近最少使用元素的策略进行替换,使得数据结构中最多只有 capacity 个 key-value 对。

之所以说是一种更通用的抽象,就是因为这不止适用于页面置换场景,也适用于许多其他缓存场景,比如在Redis中你就可以看到类似的数据结构

对应在页面置换场景下,每次缺页中断就相当于,对该数据结构进行了key为指定页码的put操作,而capacity,自然就是物理内存能存放的最多页数啦。

为了高效地实现内存置换算法,我们大致有两个需求:

  1. 找到一种数据结构,使得我们可以随时快速地找到最近最少访问的页码。
  2. 在每次缺页中断替换页面的时候,维护这个数据结构不会带来太多额外的成本。

幸运的是,LRU的get和put的操作都是可以在O(1)的时间复杂度内实现的。下面我们就来看看用什么数据结构可以满足这样的需求。

数据结构选择

先说get的部分,想在O(1)时间内根据key获取value,很自然就会想到之前提到的哈希表。通过维护哈希表,就可以在O(1)时间内判断某个key是否存在LRU中,或者访问到该key对应的value

但我们还要保证最近最少使用的替换策略,要想办法记录下内存中数据访问的先后关系,才可以保证最近访问过的,要比更早之前访问过的后淘汰。一种很自然的想法就是维护一个基于最近访问时间的有序列表。

这当然有很多种实现方式。比如我们可以维护一个数组,从前到后依次存放最近访问过到最久没有访问过的key。可是这样每次get的时候,我们就需要把数组中某个位置的key移动到数组的开始位置,并把后续的元素全部顺移一位。根据我们之前学到的知识,这样整体移动数组的操作的复杂度是O(N)。

那么应该怎么做呢?

相信好好学习前面专栏的同学已经想到了,双链表就可以实现,在O(1)内,删除节点并移动到指定位置的操作。我们可以构建一个双链表,让链表元素按照访问时间顺序从前到后依次排列。

通过双链表+哈希表,就可以完美实现LRU基于最近访问时间排序的有序列表,这两种数据结构的组合非常常见,也有人称之为LinkedHashMap。

代码实现

下面我们来看具体的代码,这次选择语法简明、性能优秀的Golang作为实现语言。

首先是基础的数据结构的定义。我们声明一个LRU的结构体,它包括以下三个成员标量:

  1. size 是LRU的容量。
  2. innerList 是一个Golang内置的双链表,来表示基于最近访问时间排序的序列。
  3. innerMap 是一个hashmap,Golang同样提供了内置实现,我们主要用它来存储key-value对。

再定义一个 entry,表示在innerList中链表节点的数据结构,它同时记录了key和value的信息。

1
2
3
4
5
6
7
8
9
10
type LRU struct {
size int
innerList *list.List
innerMap map[int]*list.Element
}

type entry struct {
key int
value int
}

然后实现get,我们可以从map中基于key获取元素的信息,如果不存在,就直接返回不存在。

因为要保证LRU的链表始终按照最近访问时间排序,get之后,我们当然需要把当前key对应的链表节点移动到链表的最开始,所以,在hashmap中,可以选择直接记录链表中的节点元素。借助于Golang内置的双链表,只需要调用MoveToFront就可以简短地实现这一逻辑。

1
2
3
4
5
6
7
func (c *LRU) Get(key int) (int, bool) {
if e, ok := c.innerMap[key]; ok {
c.innerList.MoveToFront(e)
return e.Value.(*entry).value, true
}
return -1, false
}

最后来看一下put操作。相比于get操作,代码逻辑会稍显复杂一些,同样代码会有两个支路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (c *LRU) Put(key int, value int) (evicted bool) {
if e, ok := c.innerMap[key]; ok {
c.innerList.MoveToFront(e)
e.Value.(*entry).value = value
return false
} else {
e := &entry{key, value}
ent := c.innerList.PushFront(e)
c.innerMap[key] = ent

if c.innerList.Len() > c.size {
last := c.innerList.Back()
c.innerList.Remove(last)
delete(c.innerMap, last.Value.(*entry).key)
return true
}
return false
}
}

我顺着代码简单说明一下逻辑。如果put的元素在LRU中已经存在,首先根据innerMap找到链表中的节点,移动到最前,并修改其中的value值就可以了。同样,这种情况在页面置换的场景下并不会出现。

而如果LRU中不存在指定key对应的记录,我们就需要在链表开头插入该节点,并在容量不足的时候,淘汰一个最近最少使用的节点。这段逻辑其实也非常简单,由于链表已经是基于访问时间从近到远有序排列的了,我们直接删除链表尾部元素就行。

当然也需要同步在innerMap中删除对应的记录,否则就会有类似于内存泄漏的问题,innerMap中的内存占用就会越来越多且永远没有机会释放。而我们需要做的也仅仅是在删除链表末尾节点前,记录下该节点对应的key的值,并调用Golang内置的delete方法。

到这里我们就实现了最近最少访问算法所需的数据结构,它广泛运用于在实际系统里,我自己在网络组件的开发中就用到了类似的数据结构,去主动关闭超过一定数量的闲置链接,节约了大量的系统资源。

总结

通过分页和虚拟内存的抽象,操作系统解放了用户对内存管理和容量的心智负担。当缓存的数据越来越多,如何选择一个合适的页面或缓存内容来替换,就是缓存置换算法的用武之地。

页面置换策略有多种,包括随机置换、FIFO、LRU等,非常重要且常见的LRU通过利用引用列表的局部相关性,提高了页面的命中率。 LRU的实现也并不是非常复杂,但需要对链表和哈希表有很好的理解才行,所以我们一定要认真打好数据结构和算法的基础。

LRU不但是面试的常见考点,实际开发也相当常用。我在工作中就有手写过类似的数据结构,用于清理最久没有数据包上下行的非活跃链接。建议你用自己熟悉的语言实现一遍,在实现的时候,要记得多考虑一些并发场景下可能会产生的问题。

课后作业

留一个小小的问题给你:为什么在LRU的数据结构中,我们选择了双链表而不是单链表呢?欢迎你在留言区与我讨论。

16|日志型文件系统:写入文件的时候断电了会发生什么?

作者: 黄清昊

你好,我是微扰君。

今天我们就来聊一聊操作系统最常见的外存——磁盘的问题。我们知道计算机的内存一旦断电,数据就会全部丢失,所以如果需要持久化一些数据,磁盘就是必不可少的硬件,甚至在计算机上运行的整个操作系统的大部分代码逻辑,其实也是存储在磁盘中的。

计算机要和磁盘打交道,就需要用到文件系统。

文件系统,其实就是操作系统中用于管理文件的子系统,它通过建立一系列诸如文件、目录,以及许多类似于inode这样的元数据的数据结构,把基础的文件访问能力抽象出来,给用户提供了一套好用的文件接口。

和一般的数据结构和算法主要考虑性能不同,文件系统还需要考虑一件非常重要的事情——数据的可持久化。因为文件系统一定要保证,计算机断电之后整个文件系统还可以正常运作,只要磁盘没有损坏,上面的数据在重新开机之后都可以正常访问。

这件事听起来感觉很简单,但是真正实践起来可要难得多,在过去几十年里为了解决各种各样不同的问题,文件系统层出不穷,今天我们就来讨论其中一个问题:写文件写到一半断电了,或者因为各种各样的原因系统崩溃了,系统重启之后文件是否还能被正常地读写呢?如果不能的话,我们应该怎么办呢

这个问题,我们一般叫崩溃一致性问题(crash-consistent problem)。目前最流行的解决方案是Linux中的Ext3和Ext4文件系统所采用的日志方案,也就是journaling,而Ext3和Ext4自然也就是所谓的日志型文件系统。

崩溃一致性

在讲解问题的具体解决方案之前,我们还是得先来认真审视一下崩溃一致性问题的本质:写文件的时候我们具体做了哪些事情呢?崩溃后为什么可能会产生一致性问题?

这自然也就关系到文件在系统中到底是如何存储的了。不过,不同的文件系统对文件的组织方式千差万别,我们会以Ext4中的存储方式为例,带你简单了解演化了许多年之后现在主流的文件系统是如何存储数据的。

目前最主流的持久化存储介质还是磁盘。限于磁盘的物理结构,它读写的最小单位是扇区,大小是512B,但是每次都只读一个扇区,不利于读写效率的提升;所以文件系统普遍会把多个扇区组成一个块,也就是block。在Ext4中,逻辑块的大小是4KB,包含8个扇区。

我们的数据自然也就是放在这一个个数据块上的。不过和内存一样,磁盘空间大小虽然大的多,但仍然是有限的,我们需要为不同的文件划分出自己的区域,也就是数据具体要存储在哪些块上的。

为了更灵活地存储文件、更高效地利用磁盘空间、更快速地访问到每个文件的数据存储在哪些块上,Linux的做法是把文件分成几块区域:至少包括超级块、索引节点区、数据块区。

图片

  • 超级块,是文件系统中的第一个块,用来存放文件系统本身的信息,比如可以用于记录每块区域的大小;
  • 索引节点区,每个文件对应索引节点区中的一个块,我们称为索引节点,也就是Inode,存放每个文件中所用到的数据块的地址,Inode也是元数据主要存储的地方;
  • 数据块区,也就是Data Blocks,这里是真实数据存放的区域,一个文件的inode可能存有多个指向数据块的指针。

另外,为了标记哪些Inodes和Data Blocks可以被使用,操作系统还建立了两块存放Bitmap的区域。

这样我们就可以非连续地表示各种大小的文件了,因为在索引节点上就会有很多指针链到数据区的不同区块;我们也就可以快速地获取文件所需要的数据内容了,只要在访问文件的时候根据索引节点中的指针和操作系统的磁盘调度算法,读取文件中数据块中的内容即可。

引入元数据的问题

但是在文件系统中引入了元数据带来了灵活性的同时,也带来了问题。

现在每个文件都对应一个Inode,它记录了所有数据块的位置,可以预想到,之后在修改、创建文件的时候,除了修改数据块区的内容,也需要修改文件的元数据和Bitmaps。比如,当我们往某个文件里追加数据,很可能就需要创建新的数据块把数据写入其中,并且在Inode上追加一个指向这个数据块的指针。

总之这么看,每次写文件的操作其实都是一个操作序列,而不是一个单一的操作。但是,磁盘在同一时间里肯定只能接受一次读写的请求,因此文件系统就引入了崩溃一致性问题。

这很好理解,我们看一个具体的例子。

图片

已知我们写一个文件至少会碰到 Bitmaps、Inodes 和 Data Blocks 三块数据的修改,在不会遇到崩溃的时候,我们可能就像这张图一样顺利修改了每个部分,此时文件系统没有任何问题。

但假设,修改完Inodes了,我们把Inodes中某个指针指向了一段即将要存放数据内容的数据块,此时如果遭遇了断电,Data Blocks 上的内容是未知的,很可能是很早之前别的程序写过的数据,我们可以认为此时上面的数据是脏的垃圾数据。等系统恢复,我们重新去读取文件数据的时候,会发现也没有什么有效的依据供我们检验文件是否正常。

图片

在这种情况下,Inodes指向的文件内容和我们的预期就会不一致,从而产生文件损坏的情况。其实和我们平时说的事务性、原子性是类似的场景。

这也只是不一致的一种情况。事实上,因为操作系统会对磁盘读写的顺序做调度,以提高读写的效率,我们其实不能知道这几个写磁盘的独立步骤确切的执行顺序。

不过可以想见,在其他几种写Inodes、Data Blocks和Bitmaps的顺序下,如果没有执行完全部步骤就遭遇了断电等情况,文件系统在大部分时候仍然都会进入不正确的状态,这就是崩溃一致性问题,如果你感兴趣可以模拟其他几种情况。

如何解决

现在,我们搞清楚了崩溃一致性问题的本质,自然就要尝试解决它。历史上比较流行的解决方案有两种:一种是早期操作系统普遍采用的FSCK机制(file system check),另一种就是我们今天主要学习的日志机制(journaling file system)。

我们先从早期的FSCK机制学起。

解决方案1:FSCK

FSCK机制的策略很简单:错误会发生,没关系,我们挂载磁盘的时候检查这些错误并修复就行。

比如,检查发现Inodes和Bitmaps不一致的时候,我们选择相信Inodes,而更新Bitmaps的状态;或者当两个Inodes都指向同一个block时,我们会把其中明显是异常的一个Inodes移除。

但因为崩溃,毕竟有一部分信息是丢失了的,所以很多时候我们也没有办法智能地解决所有问题,尤其是前面说的Inodes指向脏数据的情况,事实上这种情况下,FSCK没有办法做任何事情,因为它本质上只是让文件系统元数据的状态内部保持一致而已。但是,这还不是FSCK最大的问题。

FSCK真正的问题是,每次出现问题需要执行FSCK的时候的时间非常久

因为需要扫描全部磁盘空间,并对每种损坏的情况都做校验,才能让磁盘恢复到一个合法的状态,对普通的家用电脑来说,很可能需要长达几十分钟甚至几小时的时间。所以,现在FSCK基本上已经不再流行,取而代之的就是日志型文件系统。

解决方案2:Journaling

日志型文件系统这个方案,其实是从DBMS也就是数据库系统中借鉴而来的。

journaling file system 的核心思想是鼎鼎大名的预写日志 WAL 也就是 write-ahead logging,这个也正是数据库系统中用于实现原子事务的主要机制,也很好理解,毕竟事务和文件系统一样,都需要保证一致性。

那具体是怎么做的呢?

思想也很简单,就是每次在真正更新磁盘中的数据结构之前,我们把要做的操作先记录下来,然后再执行真正的操作,这也就是先写日志,WAL中write-ahead的意思。这样做的好处在于,如果真的发生错误的时候,没有关系,我们回去查阅一下日志,按照日志记录的操作从头到尾重新做一遍就可以了。

这样我们通过每次写操作时增加一点额外的操作,就可以做到任何时候遭遇崩溃,都可以有一个修复系统状态的依据,从而永远能恢复到一个正确的状态。

对于文件系统的布局,我们也只是需要增加一块区域用于存放日志就行,改动不是很大,这块区域我们叫做日志区(Journal)。

图片

那Journal区里到底要存放点什么样的内容呢?

这里会涉及一次完整的写文件对磁盘的一系列操作,你不熟悉的话也没有关系,就当这是几个独立的操作就行。我们核心就是要实现:希望可以通过某种记录日志的方式,让这些操作一旦决定被提交,即使后续对磁盘上元数据和数据块上数据结构的改动进行到一半,系统断电了,仍然可以根据这个日志恢复出来

那要怎么做呢?和数据库一样,我们为了让一系列操作看起来具有原子性,需要引入“事务”的概念。

我们每次进行一次对文件的写操作,除了会先在预写日志中,记录对Inodes的修改记录、对Bitmaps的修改记录,以及对具体数据块的修改记录之外,还会同时在这几条记录的前后,分别引入一个事务开始记录和一个事务结束记录。

图片

第一个写入的记录是TxB,也就是Transaction Begin记录,最后一个写入的记录就是TxE也就是Transaction End记录,在TxE记录完成后,就意味着整个写文件的操作全过程都被记录在案了,我们把这个步骤叫做Journal Write

从而,日志的组织形式就是由这样一个个事务拼接而成,日志的首尾是TxB和TxE块,中间是具体对元数据和数据块修改的记录块。

当我们完成Journal Write操作之后,就可以放心大胆地把这些实际的元数据或文件数据覆写到磁盘上对应的数据结构中了,这个步骤我们叫做 checkpointing。

当这个步骤也成功完成,我们就可以说整个写文件的操作被完成了,在全部成功的情况下当然没什么特别的,但整个设计的关键之处就在于,面对任意时刻崩溃的情况,我们也能把文件系统恢复到某一合法状态的能力

图片

我们具体看看崩溃出现的时候,引入了journaling的文件系统会有什么不同吧。

  • 如果崩溃出现在journal write步骤中

假设崩溃是出现在TxE块完成写操作之前,那其实对系统也没有任何影响。因为相当于事务没有被成功提交,而我们写的是日志,对文件本身也没有任何实际影响。

当系统断电又恢复之后,只要发现某个事务ID没有对应的TxE块,说明这个事务没有提交成功,不可能进入checkpointing阶段,丢弃它们对文件系统没有任何不良影响,只是相当于上次写文件的操作失败了而已。

  • 如果崩溃刚好发生在journal write结束之后

不管是刚刚写完TxE,还是已经进入了checkpointing的某一步,我们的处理也都是一样的。既然事务已经被提交,系统断电恢复之后,我们也不用关心之前到底checkpointing执行到了哪一步,比如是已经更新了inodes?还是bitmaps?都没有任何影响,直接按照日志重做一遍就可以,最坏的下场也不过就是重新执行了一遍执行过的操作。

顾名思义,这种重做一遍的日志我们也把它称为 redo logging,它也是一种最基本、最常见的日志记录方式。不只在文件系统中,在数据库等场景下也使用非常广泛。

总结

我们就通过引入预写日志的手段,完美解决了操作系统文件状态在崩溃后可能不一致的问题,相比于从头到尾扫描检查的FSCK机制。预写日志,在每次写操作的时候引入一些额外的写成本,让文件系统始终得以始终处于一种可以恢复到一致的状态,如果崩溃,只需要按照日志重放即可。

当然我们其实还有很多优化可以做。比如,可以进行批量日志的更新,把多个独立的文件写操作放到一个事务里提交,提高吞吐量;或者记录日志的时候只记录元数据,而不记录文件写操作的大头数据块等等。如果你仔细看Linux文件系统的实现就会发现,做的优化非常多,集结了许多前人的智慧,我们可以从中领略到很多思想,也许有一天在你的工作中,这些想法就会成为你解决一些问题的关键。

以我们今天学习的“日志”思想为例。我曾经在一家做安全硬件的公司实习,当时就有个需求要写一段代码用单片机往一个类似闪存的芯片里写一些状态,包含好几个独立的字段,每个字段都需要顺序地写,也就是说要分好几次独立的操作去写。

但是,单片机是可能随时可以掉电的,我们如何保证这个闪存中的状态是正确的,而不是状态的某几个字段是上次写的,某几个字段是这次写的呢?其实这个问题我们就可以用类似日志的思想去解决。

一种可行的方式就是,在闪存中开辟一段额外的空间,先预写日志,用TxB和TxE来标记一次完整的状态,每次启动时候先检查日志,把最新的状态覆写到指定的地址区域即可。当然事实上解决的办法要更简单一些,也更省空间,欢迎在留言区和我一起讨论。

课后练习

最后布置一个思考题,文件系统为了提高读写吞吐,实际在写日志的时候也可以通过调度,调整写不同块的先后顺序。那我们在记录日志的时候是不是也能利用这个特性,进一步提高写文件的性能呢?如果可以的话,需要做什么限制吗?

欢迎你留言和我一起讨论。如果你觉得这篇文章对你有帮助的话,也欢迎你转发给你的好朋友一起学习。我们下节课见~

17|选路算法:Dijkstra是如何解决最短路问题的?

作者: 黄清昊

你好,我是微扰君。

在掌握操作系统中的一些经典算法之后,我们来学习计算机的另一大基础课——计算机网络中的算法。计算机网络,当然也是一个历史悠久的科研方向,可以说之所以现在计算机世界如此繁荣,计算机网络发挥着巨大的作用,是整个互联网世界的基石。

复杂的计算机网络中自然也产生了许多算法问题,比如许多经典的图论算法都是在计算机网络的研究背景中诞生的。在这一章我们会挑选几个有趣的问题一起讨论,主要涉及两种场景,计算机网络网络层的选路算法、传输层协议TCP中的滑动窗口思想。

今天我们先来学习选路算法,有时它也被称为路由算法,“路由”这个词相信你应该很熟悉,没错,说的就是路由器里的路由。

路由

我们知道,计算机网络的作用,就是通过把不同的节点连接在一起从而交换信息、共享资源,而各个节点之间也就通过网络形成了一张拓扑关系网。

比如在一个局域网下,节点A要给节点B发送一条消息,如果A和B并没有直接通过网络相连,可能就需要经过其他路由设备的几次转发,这时我们需要在整个网络拓扑图中找到一条可到达的路径,才能把消息发送到目的地。

每台路由器都是一台网络设备,也就是网络中的一个节点,在其中就保存有一张路由表,每次网卡收到包含目标地址的数据包(packet)时,就会根据路由表的内容决定如何转发数据

你的电脑也是一个网络上的一个节点,我们在Mac上通过命令就可以看到自己节点的路由表:

1
netstat -nr

我本地获取到的路由表如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Routing tables

Internet:
Destination&nbsp; &nbsp; &nbsp; &nbsp; Gateway&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Flags&nbsp; &nbsp; &nbsp; &nbsp; Netif Expire
default&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 192.168.1.1&nbsp; &nbsp; &nbsp; &nbsp; UGSc&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0
127&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 127.0.0.1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; lo0
127.0.0.1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; 127.0.0.1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; UH&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;lo0
169.254&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp; &nbsp; !
192.168.1&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp; &nbsp; !
192.168.1.1/32&nbsp; &nbsp; &nbsp;link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp; &nbsp; !
192.168.1.1&nbsp; &nbsp; &nbsp; &nbsp; f4:1c:95:6d:c0:e8&nbsp; UHLWIir&nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp;1125
192.168.1.7/32&nbsp; &nbsp; &nbsp;link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp; &nbsp; !
192.168.1.7&nbsp; &nbsp; &nbsp; &nbsp; 3c:22:fb:94:7:cf&nbsp; &nbsp;UHLWI&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; lo0
192.168.1.8&nbsp; &nbsp; &nbsp; &nbsp; 22:6:ba:99:db:c5&nbsp; &nbsp;UHLWIi&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0&nbsp; &nbsp; 847
192.168.1.11&nbsp; &nbsp; &nbsp; &nbsp;f6:f0:14:1b:9f:68&nbsp; UHLWIi&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0&nbsp; &nbsp;1002
192.168.1.12&nbsp; &nbsp; &nbsp; &nbsp;ae:ea:e4:f2:a4:69&nbsp; UHLWI&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp;1063
224.0.0/4&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UmCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0&nbsp; &nbsp; &nbsp; !
224.0.0.251&nbsp; &nbsp; &nbsp; &nbsp; 1:0:5e:0:0:fb&nbsp; &nbsp; &nbsp; UHmLWI&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0
239.255.255.250&nbsp; &nbsp; 1:0:5e:7f:ff:fa&nbsp; &nbsp; UHmLWI&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;en0
255.255.255.255/32 link#6&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;UCS&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; en0&nbsp; &nbsp; &nbsp; !

路由表的每一行都代表一条路由规则,至少会包括两个信息,也就是路由表的前2列:

  1. 目标网络地址(Destination):标示IP包要去往的目标网络
  2. 下一跳地址(Gateway):与当前路由器相邻的路由器,命中这条规则的数据包应该经由这个路由器转发去往最终目的地

这里的后3列我也顺带简单介绍一下,flag是路由的一些信息,netif指的是网络物理接口,expire代表过期时间,你感兴趣的话可以去查阅Linux手册详细了解。

因为每个数据包里包含了目标地址,所以路由器工作的基本原理就是,网卡基于路由表匹配数据包对应的规则,转发到下一跳的路由器直至抵达终点就可以了。

路由表

那这个路由表怎么来呢?主要有两种方式。

一种就是我们手动管理配置。Linux提供了简单的配置命令,既可以根据路由IP配置路由表,也可以基于一定的策略配置,查阅Linux手册即可。这种方式也被称为静态路由表,最早期的网络就是这样由网络管理员手动配置的。但如果网络结构发生变化,手工修改的成本就非常高。

为了解决这种问题,第二种方式——动态路由表就应运而生,它可以根据协议在网络中通过节点间的通信自主地生成,网络结构变化时,也会自动调整。

而生成动态路由表的算法,就是我们的选路算法。所以,选路算法所做的事情就是,构建一个动态路由表,帮每个数据包都选择一条去目标IP最快的路径

那在路由选路问题中,什么是最快路径呢?

图片

我们知道信息在网络上传输肯定是需要经过物理传输的,各设备之间的距离以及不同设备本身的网络连接情况都是不同的,都会影响节点间传输的时间。如果我们把不同节点之间的通信时间当作距离,整个拓扑图上搜索最快路径的过程,其实就等价于求图上的最短路问题。

求解最短路的算法,相信你也学过不少了,比如基于BFS的SPFA、基于贪心思想的Dijkstra、基于动态规划思想的Bellman-Ford等算法。这些算法在选路问题中也有应用,最经典的两种就是基于Dijkstra实现的链路状态算法和基于Bellman-Ford实现的距离矢量算法。

今天我们就来先学习鼎鼎大名的Dijkstra算法,看看它是如何解决最短路问题的(链路状态算法和距离矢量算法在后两讲学习)。

Dijkstra算法

Dijkstra算法是一个非常经典的求解单源最短路(Single Source Shortest Path)问题的算法,但它有一个巨大的限制:只能用于没有权重为负的边的图。

在分析这一限制之前,我们还是先来严谨地定义一下最短路问题。

假设我们有一张图G=(V,E),图中共有v个节点,它们之间有e条无向边。其中,各节点的集合用V表示,边的集合用E表示,边权weight就代表该边两点之间的距离。

图片

单源最短路问题就是要在这张图上求出从源点s到图上任意其他点的距离最短的路径,一条路径的长度/距离大小就是这条路径上所有边的权重和。具体怎么做呢?

估计你也想到了,一个比较直觉的思路就是贪心思想,我们从离s最近的点开始记录,然后找次之的点、再次之点,逐步推进。

  • 我们先找出距离源点s最近的节点

它一定是和s直接相连的节点中距离最近的一个,这是因为所有和s构成二度关系的节点都会经过一个和s直接相连的节点,距离不会短于这个直接相连的节点,所以这个节点一定是所有节点中到s距离最近的节点,我们把这第一个节点记录为v1。

  • 然后再找出距离s次近的节点

这时刚找到的v1就有可能成为次短路径的一部分了,我们需要在和s、v1直接相邻的节点中,再次找出除了v1之外到s距离最短的节点,它一定是剩余节点中到s最近的节点。

  • 依次类推,就可以求出s到所有节点的最短路径了

Dijkstra算法其实就是这样做的,它引入了一种叫做最短路径树的构造方法。按照刚才说的基于贪心的思想逐步找出距源点s最近、次近的点,就能得到一个G的子图,里面包含了s及所有从s出发能到达的节点,它们以s为根一起构成了一颗树,就是最短路径树。找到了这颗树,我们自然也就求出了s到所有节点的最短距离和路径了。

思路

为了把我们刚才直觉的想法用编程语言更精确的描述出来,需要引入一种叫做“松弛(relax)”的操作,结合例子来讲解这个过程。

图片

假设现在有了一张有向图G,其中包含了0、1、2、3、4这5个节点,节点之间的边权代表距离,都标在图上了,比如节点0和节点1之间的边权/距离是2。

假设0节点是源点s,我们如何构造出这棵以s为根的最短距离树T呢?

图片

整个构造过程是一步步从原点向外扩张的,我们可以用一个数组dis标记源点s到其他节点的距离。由于刚开始树T中只有根节点s,此时:

  • 大部分不和s直接相邻的节点到s的距离都是未知的,我们可以暂时记录为Inf,代表无限大;
  • 和T直接相邻的节点就是我们的候选节点,在最开始时也就是s的所有邻节点。我们每次从中选择距离s最短的一个节点加入树T中,只需要遍历所有节点到s的距离就可以得到这个节点,我们记作u

比如对于节点0而言,1就是目前候选集里到s最近的节点。

那每次挑出最短节点u加入T中之后,T的候选集显然就多了一些选择,u的所有相邻的节点以及它们到树的距离都可以被发现了。

但u的邻节点v,到源点s的距离有两种可能。

  • 第一种情况dis[v] = Inf,代表这个节点v 还没有被加入过候选集中,也就是之前和源点s不直接相邻。

比如图中从1节点搜索4节点的时候就是这种情况。我们可以把v加入T中,并记录dis[v] = dis[u]+edge[u][v],这很显然是目前发现的、能到v的最短距离,但它依旧有可能在后续遍历过程时被更新,我们叫做“松弛操作”

图片

  • 第二种情况dis[v]!=inf,这说明v已经被加入到候选集中了,也意味着之前有其他路径可以到达v。

这个时候,我们要比较一下经由u到达v是不是一条更短的路径,判断 dis[u]+edge[u][v] 是否小于 dis[v],如果小于就要更新 dis[v] = dis[u] + edge[u][v]。比如图中从1节点搜索3节点的时候就是这种情况。

更新的操作其实就是“松弛”,不过我个人觉得“松弛”不是一个很好理解的说法,因为松弛操作实际上是让这条路径变得更短,不过因为Dijkstra是用“relax”来描述这个更新操作的;所以我们也翻译成松弛操作。

我们再来一起结合例子梳理一遍搜索的全过程。

图片

在整个构造过程中会依次把0、1、3、2、4节点入队,入队时,它们都是候选集到s中距离最短的节点。

在没有负边的情况下,这就保证了剩余的节点距离一定长于这个节点,也就不会出现入队之后节点距离仍然需要更新的情况,每个加入树中节点的距离在加入的那一刻就已经被固定了。

入队的时候,我们也探索到了一些可能和树相接且到s更近的节点,需要对它们进行“松弛”,并加入候选集合

比如0-3节点的距离开始是7:但在1节点加入候选集之后,我们就可以经由1去往3,这时3到0的距离就会被更新为5,而不再是7了;这个时候3节点(0-1-3)已经是所有剩余节点中到s最近的节点了,我们把它加入树中,dis[3]=5也不会有机会再被其他节点更新了。

代码

理解了这个过程,翻译成代码也就比较简单了,力扣上的743网络延迟时间就是一道典型的最短路应用题,有多种解法。

这个题正好是建立在网络的场景下的,求从源点s出发把消息广播到所有节点的时间,节点之间的边就代表着网络传输的延时,也就是求s到图上所有节点最短距离的最大值。

用我们今天学的Dijkstra算法就可以求解,实现代码贴在这里供你参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public:
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
// 标记未被探索的节点距离
const int inf = INT_MAX / 2;
// 邻接表
vector<vector<int>> g(n, vector<int>(n, inf));
// 构图
for (auto time: times) {
g[time[0] - 1][time[1] - 1] = time[2];
}

vector<int> dist(n, inf); // 所有节点未被探索时距离都初始化为无穷
vector<bool> used(n, false); // 标记是否已经被加入树中
dist[k - 1] = 0; // 记录原点距离为0

for (int i = 0; i < n; ++i) {
int x = -1;
// 找出候选集中到S距离最短的节点
for (int y = 0; y < n; ++y) {
if (!used[y] && (x == -1 || dist[y] < dist[x])) {
x = y;
}
}
// 加入树中
used[x] = true;
// 基于x 对所有x的邻节点进行松弛操作
for (int y = 0; y < n; ++y) {
dist[y] = min(dist[y], dist[x] + g[x][y]);
}
}

// 取出最短路中的最大值
int ans = *max_element(dist.begin(), dist.end());
return ans == inf ? -1 : ans;
}
};

代码中的dist用于标记距离,used用于标记树中的节点,每次我们都会从候选节点中挑选出到s最短的节点,并基于它对其邻节点进行“松弛”操作;等整个最短路问题求解完毕,最后再从所有距离中取出最大值就可以了。

时间复杂度也很好分析,整个代码中一共有两层循环,外层循环就是每次把一个节点加入树中,一共进行n次;内层循环有两段,分别用于找出最短节点和对所有邻居进行“松弛”操作,最多也不会超过2*n次计算。所以,整体时间复杂度为O(n^2)。

总结

网络路由算法,核心就是在动态变化的网络中,基于探测和寻找最快传输路径的想法,帮助路由器建立路由表,让每个数据包都可以快速且正确地传播到正确目的地。

首先我们需要想办法解决最短路的问题,Dijkstra就是这样一种在没有负边的图中求解单源最短路的算法,基于贪心的思想,我们构造一颗最短路径树就可以求出从源点到网络中所有节点的最短路径了。核心的就是松弛操作,每次加入一个最短节点之后,我们还需要基于它去探索一遍和它相临的节点是否距离更短,比如从不可达变成可达,或者从一条更长的路变成一条更短的路。

Dijkstra算法实现起来还是有一定难度的,你可以多去力扣上找几道题目练手检验一下学习效果;另一个有效的检验方式就是参考费曼学习法,你可以试着给你的朋友讲一下为什么Dijkstra算法不支持负边,这也是Dijkstra算法非常重要的一个约束,如果能讲清楚你也就理解精髓了。

有了Dijkstra算法,我们也就可以求解网络路由中的最短路问题了。后两讲学习我们将学习最经典两种路由的算法:基于Dijkstra算法实现的链路状态算法、基于Bellman-Ford实现的距离矢量算法。

课后作业

最后也给你留一个简单的课后思考题。我们分析了Dijkstra算法的时间复杂度为O(N^2),你觉得是不是可以有更快的写法呢?

欢迎你在留言区与我讨论,如果你觉得本文有帮助,也欢迎你分享给你的朋友一起学习。我们下节课见~

参考资料

每个数据包里包含了目标地址,具体还有哪些内容,你可以去补习一下计算机网络的基础内容,推荐学习UW的计算机网络课。

18|选路算法:链路状态算法是如何分发全局信息的

作者: 黄清昊

你好,我是微扰君。

上一讲,我们介绍了网络中选路算法的背景和单源最短路问题的经典算法Dijkstra算法,还记得为什么网络中需要选路算法吗?

计算机网络很复杂,但核心作用就是把不同的节点连接在一起,交换信息、共享资源,每个节点自己会维护一张路由表,选路算法所做的事情就是:构建出一张路由表,选择出到目标节点成本最低通常也是最快的路径。

而Dijkstra算法是求解单源最短路问题的经典算法,基于贪心的思想,我们从源点开始,一步步搜索最近路径,构造一颗最短路径树。它在网络路由问题中的应用就是我们今天要学习的链路状态算法。

具体如何解决网络路由问题呢?带着这个问题,我们马上开始今天的学习。

网络路由问题

我们知道路由器最大的作用就是转发决策,动态路由算法的作用就是,帮助路由节点在动态变化的网络环境下建立动态变化的路由表,而每个路由表记录,本质就是当前节点到目标节点的最短路。

链路状态算法的思路就是:先在每个节点上都通过通信构建出网络全局信息,再利用Dijkstra算法,计算出在当前网络中从当前节点到每个其他节点的最短路,从而把下一跳记录在路由表中

对于最短路问题,我们可以用之前学过的转化为图问题的思路,把网络抽象成一个有向图,也就是网络拓扑图。

图中每个节点就是一台台路由设备,而节点之间的边的权重(边权)就代表着某种通信成本,我们一般叫链路成本,它有很多种定义方式,比如:

  • 网络通信时间,最常用的成本衡量标准,选出了最短路也就意味着选出了网络当前时刻下,从源节点到目标节点延时最低的数据传输路线。
  • 带宽或者链路负载,有时候也会作为成本的度量,带宽大负载低的路径成本就低,反之成本更高;在这种构建方式下,选出的是带宽比较充裕的路线,用户往往可以享受到更快的带宽速度。

后面我们会以网络通信时间也就是链路延时作为链路成本的策略来讨论,其他指标核心的问题解决思想是类似的。

这里你可能会问了,为什么网络是一个有向图呢?道理很简单,我们以延时作为边权的场景为例,两个节点双向通信的速度很可能是不一样的,所以自然是一个单向图。

有了拓扑图的定义,我们如何在每个节点中都构建出这样一张带有整个网络信息的图呢?这个问题还是没有解决。

在这个动态路由问题里,所有的节点其实都只是网络中的一部分,不同于静态路由的管理员直接有全局的上帝视角,动态路由下的每个节点能真正触达的信息,也只有和自己直接相邻的节点传来的0和1。所以,要构建网络,我们自然也只有通过通信的方式了

图片

在各种网络选路协议中,OSPF协议采用的就是链路状态算法,它把链路状态信息的获取分成了4个主要步骤:发现节点、测量链路成本、封装链路状态包、发送链路状态包。

发现节点

节点想要获取全局的链路信息,显然只能通过和邻居间交换自己知道的信息,才有可能构建出全局的网络图。那第一步当然是要发现和自己相邻的所有节点,并在本地维护这个邻居信息。

发现节点具体怎么做呢?

其实也很简单,就是直接向网络广播一条hello消息,我们称为hello包。所有能直接收到这条消息的一定都是一跳的邻居。协议规定,收到这条消息的节点必须回应一条Response消息,告知自己是谁。

图片

所以每个节点只要统计自己发出hello后收到的回应数量,就可以知道自己和哪几个节点相邻,也知道了它们的地址之类的信息,保存在本地就可以了。

测量链路成本

现在每个路由器都有了自己的邻居信息,接下来要做的就是衡量边权也就是链路成本。

每个节点想要衡量自己和邻居之间的传输成本,没什么别的办法,试一下就行了。

协议规定,每个节点向自己的邻居发送一个特殊的echo包,邻居收到之后,必须原封不动地把echo再返回给发出echo的节点,这样,每个节点只需要统计一下自己从发出echo到收到echo的时间差,就可以用它来估计和邻居之间的网络传输时延了,从而也就可以计算出链路状态算法所需要的链路成本了。

图片

当然由于网络传输是不稳定的,我们会多次测量,取出均值,这样的时间我们有时也叫RTT,round-trip-time。

如果你经常打游戏,可能会在测速工具或者游戏界面中看到过这个词,RTT是最常见的用于衡量网络时延情况的指标,在许多系统里都会用到。我在工作中维护过的长连接网关就有一个这样的需求,要求采样统计消息时延作为监控指标,我们当时就会定期在应用层面上发送echo包,统计来回时间,以达到监控网络情况的效果。

封装链路状态包

现在,每个路由器都知道自己到所有邻居节点的链路成本了。要让每个节点都能构建出整个网络图,显然需要让自己知道的信息尽快扩散出去,也尽快收集别人的信息来拼接出整个路由的拓扑图。

这就要求我们把每个节点已知的信息封装成一个数据包,然后在网络中广而告之。这个数据包我们就叫做链路状态包,

链路状态包中至少要包含几个字段呢?

首先是本机ID,指出链路状态包的发送方,说明当前节点是谁;其次,我们前两步获得的已有链路信息当然也要写上,也就是找的到邻居列表和当前节点到每个邻居的链路成本(前面测出来的通信时延)。

另外我们知道网络是在时刻动态变化的,考虑到包的有效性问题,每个包不可能是永久有效的,过了一段时间之后就应该让这个包自动失效。所以还需要一项生存期,标记这个包中的成本记录有效的时间窗口。

除此之外,OSPF协议还引入了一个关键字段:序号,标示当前状态包是发送方发出的第几个包。因为在网络中传输内容时,出于各种原因可能会产生错序的情况,这个序号就能帮助接收方衡量这个包是老的包还是新的包。其实,序号这种思想贯穿了计算机网络各个层次协议的设计,在许多应用场景下也会通过序号,帮助我们进行消息传递的排序或者去重。

在OSPF协议中4项内容是这样组织的,本机ID、序号、生存期、邻居|成本,你可以看这张图:

图片

发送链路状态包

有了链路状态包,那最后一个步骤自然是发送这些包。为了确保所有的包都能被可靠地传输到每个节点,避免出现各个节点路由构建不一致等问题,我们采用泛洪的方式进行传输。

泛洪,也是在计算机网络中常用的一种传播消息的机制,类似广播,每个节点都会把自己封装好的包和收到的包,发送或转发给所有除了该包发送方的节点

这样,经过一小段时间的传播,每个节点就可以收到整个网络内所有其他节点的邻居信息,从而也就相当于有了一个拓扑图中邻接表的全部信息,自然就可以在内存中构建出一张完整的带有边权的有向图了。

构造方式和我们之前讲解拓扑排序中构造图的过程是很类似的,你不熟悉的话也可以回去复习一下。

图片

计算路由

现在每个节点都有了这样一张有向图,每个节点自然就可以利用之前我们讲解的Dijkstra算法,在有向图中计算出自己到网络中任何其他所有节点的最短路径。

1
2
3
4
5
6
A的最短路
A->B
A->B->D
A->B->D->E
A->B->D->E->F
A->C

以拓扑图中A节点到其他节点的最短路计算为例,我们可以很容易得到每个节点的路由表:

1
2
3
4
5
6
7
A的路由表
Destination Gateway&nbsp;
B B
C C
D B
E B
F B

比如从A到E的最短路径是A、B、D、E,那么在路由表中,只需要记录到E的下一跳是B就可以了。每个节点都进行类似的过程,数据包就可以在这些节点各自构建的路由表的基础上正确地传输了。

总的来说,OSPF协议中的链路状态算法通过4步,先在每个节点上都通过通信构建出网络全局信息,再利用Dijkstra算法,计算出当前网络中从当前节点到每个其他节点的最短路,把下一跳记录在路由表中。

但到目前为止,我们还没有看到链路状态算法路由动态性的体现。

链路状态的动态性

链路状态算法之所以是动态路由算法,还有很重要一个点就是链路状态是可以根据网络的变化自动调整的。这就要涉及今天要重点学习的最后的一个知识点了:链路状态包是什么时候发送的?

链路状态发送主要有两个时机:

  • 一是我们会指定一个周期,让每个路由器都定时向外泛洪地发送链路状态包,比如30s一次。有点像心跳机制,如果长时间没有收到某个节点的链路状态包,这个节点随着之前的包中的生存期到期,就会被认为是失效节点,不会再被路由算法选作传输路线了。
  • 另一个就是当每次发生重大变化,比如节点上下线、网络情况变动等等,相关节点有可能的话也会主动向外快速扩散这些消息,让网络尽快得到动态的修正。

这样简单的策略是非常强大的,以链路延时为成本的链路状态算法甚至可以非常智能地避免网络的阻塞。

我们看个例子。构建网络拓扑图之后,t0时刻,发现A和许多其他节点去往H的路由都是通过G转发最快,那这个时候,大量的信息都会发送到G路由节点中待转发。

图片

但计算机网络中有个“拥塞”的情况,每个节点在单位时间里能处理的信息是有限的,剩余的信息转发就需要排队,总传输时间也就变得更长了。所以,当G节点处理的消息越来越多时,G节点就很容易进入拥塞的状态,经过G转发的链路成本也都会飙升。

但是没有关系,我们的动态路由算法很快就会发现这件事情,G自己就会更新到H的链路成本,比如从4变成7,那再稍后的t1时刻,路由A到H的路由选择就从AGH变成了ABEFH,不再经过G转发了。

总结

至此,整个链路状态动态路由算法我们就学完了。动态路由算法中基于Dijkstra算法的链路状态算法,核心思路就是通过节点间的通信,获得每个节点到邻居的链路成本信息,进而在每个节点里都各自独立地绘制出全局路由图,之后就可以基于我们上一讲学过的Dijkstra算法构建出路由表了。

每个节点虽然有了全局的信息,但在路由表中我们依然只需要管好自己就行,只要每个节点都履行好自己的转发义务,数据包就可以正确有效地在动态变化的网络中传输了。

链路状态中为了解决不同的问题引入了许多手段。

比如,状态包通过周期和发生变化时的发送,可以让整个路由表动态地被更新、给包加序列号进行消息传递的排序或者去重,避免过期的信息因为延迟导致误更新、通过定期发送echo包统计来回时间,来测量网络时延监控网络情况等等。

这些思想在许多场景下也多有应用,你可以好好体会。

课后作业

最后给你留一个小问题,链路状态算法可以动态避免网络拥塞,那它有没有什么不好的地方呢?

欢迎你留言与我一起讨论,如果觉得这篇文章对你有帮助,也欢迎你转发给身边的朋友一起学习。我们下节课见~

19|选路算法:距离矢量算法为什么会产生无穷计算问题?

作者: 黄清昊

你好,我是微扰君。今天,我们一起来学习一种新的解决最短路问题的思路——Bellman-Ford算法,以及基于它发展出来的距离矢量算法。

动态路由问题相信你已经理解了,上两讲我们也一起学习了解决这个问题的一种经典选路算法——基于Dijkstra算法思想的链路状态算法,核心就是每个节点,通过通信收集全部的网络路由信息,再各自计算。

如果说链路状态算法的思想是全局的、中心化的,我们今天要学习的距离矢量算法就是本地的、非中心化的,交换信息的数据量会比链路状态少很多。因为在基于距离矢量算法下的选路协议下,节点之间只用交换到网络中每个其他节点的距离信息,不用关心具体链路,也就是我们所说的距离矢量,而不是泛洪地转发整个网络中每条边的信息。

具体是如何做到的呢?这背后计算最短路的核心思想就是Bellman-Ford算法。

Bellman-Ford算法

我们就先来学习Bellman-Ford算法,它同样是一种反复执行“松弛”操作去计算源点S到网络中其他节点距离最短路径的算法,所以学过Dijkstra算法的思想,我们再理解BellmanFord算法是比较简单的。

不过,和Dijkstra用到的贪心思想不同,Bellman-Ford算法采用的是动态规划(dynamic programming)的思想。

首先用同样的数学语言来描述整个图,图G=(V,E)包含V个顶点E条边,源点是s,weight表示节点之间的距离,weight[u][v]表示节点u和节点v之间的距离,distance[u]表示从s到u的最短距离,在整个算法过程中我们会不断地更新也就是松弛这个距离distance[u]的值。

图片

Bellman-Ford的核心思路就是我们遍历所有的边 e=(u,v) ,并进行松弛操作,也就是判断distance[v]是否小于distance[u]+weight[u][v],如果是的话,就把distance[v]设为distance[u]+weight[u][v],这也标志着在这次遍历中,我们为v找到了一条从s出发抵达v更近的路线。

为了保证正确计算出从源点s到每个其他顶点的最短路径,怎么做呢?

其实也很简单,我们只要把这个遍历松弛的过程重复(V-1)次,也就是图上除了自己之外的顶点数量次。这样,在每次遍历所有边松弛的过程中,distance[v]被计算正确的节点都会增加,最终,我们就可以得到所有节点的最短路径(如果你对这个方法有疑惑,我们稍后会梳理证明过程)。

相比Dijkstra算法每次贪心地找最短节点进行松弛的方式,Bellman-Ford直接多轮遍历所有边的松弛方式显然可以适应更广的应用场景。

比如现在负边就不再是一个困扰了,我们不再需要考虑每个节点加入最短距离树之后,可能会因为存在负边而被重新更新距离的情况。和Dijkstra算法一样,Bellman-Ford中第i轮松弛结束之后,可以确定至少有i个节点到原点的最短路被确定了,但我们不再知道(当然也没有必要知道)是哪一个了。

这个代码其实比Dijkstra算法要好实现许多,这里我写了一个版本(伪代码)供你参考,我们一起来梳理一下思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function BellmanFord(list vertices, list edges, vertex source) is
// This implementation takes in a graph, represented as
// lists of vertices (represented as integers [0..n-1]) and edges,
// and fills two arrays (distance and predecessor) holding
// the shortest path from the source to each vertex
distance&nbsp;:= list of size n
predecessor&nbsp;:= list of size n

// Step 1: initialize graph
for each vertex v in vertices do
distance[v]&nbsp;:= inf // Initialize the distance to all vertices to infinity
predecessor[v]&nbsp;:= null // And having a null predecessor

distance[source]&nbsp;:= 0 // The distance from the source to itself is, of course, zero
// Step 2: relax edges repeatedly

repeat |V|−1 times:
for each edge (u, v) with weight w in edges do
if distance[u] + w < distance[v] then
distance[v]&nbsp;:= distance[u] + w
predecessor[v]&nbsp;:= u

return distance, predecessor

整体就是两步:

  • 第一步,对图的初始化。和Dijkstra算法一样,我们需要用distance数组去记录每个节点的距离,用predecessor记录每个节点最短路中的前驱节点,方便输出最短路径。在刚开始还没有进行遍历松弛的时候,把距离都设为无限大,前驱节点设为空就行。
  • 第二步,循环松弛操作。就是我们刚刚说的,一共进行V-1次,每次循环中都遍历所有的边,进行松弛操作。不过注意,每次松弛成功,也需要更新前置节点。

可以看到,代码写起来其实比Dijkstra要简单很多,这也是建立在更高的时间复杂度代价下的,Bellman-Ford的整体时间复杂度是O(V*E),大部分实际场景下,边的数量比节点数量大的多,所以时间复杂度要比Dijkstra算法差很多。当然好处在于可以处理图中有负边的情况。

代码的部分还是比较好理解的。Bellman-Ford算法正确性的证明还是要稍微花一点功夫,会涉及一点数学的证明,如果你觉得理解困难的话,可以多找几个例子好好模拟几遍遍历松弛的过程,观察一下每一轮遍历之后,距离变化的情况,相信还是可以掌握的。

Bellman-Ford算法正确性证明

我们来严格证明一下为什么只要对进行V-1轮的所有边的松弛操作,就一定可以得到所有节点到原点的最短路径。

整体证明可以通过数学归纳法实现。数学归纳法我们简单复习一下,核心就是两步,第一步要证明i=1的时候某个结论成立;第二步要证明如果结论在i=n时成立,那么i=n+1的情况也成立,这样就能证明整个结论的正确性。

首先,在Bellman-Ford算法中我们知道进行完第一轮遍历之后,一定能得到从源点出发到其他任意节点通过长度最多为1的(1跳)路径中最短的距离。

那我们假设在进行完第i轮遍历之后,可以得到从原点出发到其他任意节点通过长度最多为i的(i跳)路径中最短的距离,判断进行第 i+1 轮松弛时,是否能得到从原点出发到其他任意节点通过长度最多为i+1的(i+1跳)路径中最短的距离呢?

答案是肯定的。因为长度为i+1的路径只能从长度为i的路径演化而来,假设从s到某个节点v的路径中,存在长度为i+1的路径比长度小于等于i的路径更近,假设这条路径的第i跳是u,那遍历所有边,一定能基于此前到u最短的路径,加上u->v这条边,得到s->v的最短路径。

图片

负权回路问题

在一个没有负权回路的图中,也就是不存在某个回路中边的权重之和是负值的情况,显然,从s出发到任意节点v的最短路径,经过的边数量最多就是V-1,因为最短路径不可能经过同一个点两次。

所以,我们通过V-1轮松弛,可以得到从s出发到任意节点的边数量小于等于V-1的路径中最短的路径,自然也就得到了s到任意节点的最短路径。

讲到这里,也就引出了在Bellman-Ford算法中的一个限制:没法处理存在负权回路的情况

有时候Bellman-Ford的这一特性也可用来检测负权回路在图中是否存在,做法就是进行第V次循环,正常情况下这第V次循环不会再有任何边的距离被更新,但是如果有边的距离被更新了,就说明在图里一定有负权回路。

1
2
3
4
// Step 3: check for negative-weight cycles
for each edge (u, v) with weight w in edges do
if distance[u] + w < distance[v] then
error "Graph contains a negative-weight cycle"

当然,在网络选路算法的场景下,我们肯定是没有负边的,也就没有必要担心负权回路的问题了。

距离矢量算法

好,现在我们已经了解了Bellman-Ford的思想,如何用它来解决选路算法中的最短路问题呢?

类似链路状态算法的通信,网络中各节点间同样是需要通过彼此的信息交换获得最短路径的信息,但这次我们只关心网络中各节点和自己的邻居们的路径长度,不用获取全局的网络拓扑结构信息。

举个例子帮助你理解。现在我们希望从上海坐汽车去北京,但没有全局的地图(来源百度地图)不知道怎么走更短,只能打电话问相邻城市的好朋友。如果我们要找出一条最短的路径有两种办法。

图片

一种就是让所有人都问自己邻居城市的朋友,收集好所有的公路信息,然后传播给自己邻居城市的朋友;这样经过一段时间,我们就可以从邻居那里获得整个地图各站间的全部信息,从而可以自己研究出一条最短路径,这个思想就是链路状态法。

而另一种就是我们只是问邻居,你那有汽车能到北京吗?

假设到上海距离一样的两个城市常州和苏州都可以抵达北京,一个到北京700km,另一个到北京800km,那我们可能就会选择短的那条经过常州的线路。而常州和苏州怎么知道自己可以到达北京呢,也是基于类似的方式从自己邻居城市的朋友那知道的。这个思路其实和距离矢量法本质上是一样的。

所谓的“距离矢量”其实就是在每个节点都维护这样一张距离表:它是一个矩阵,每一行都可以代表一个目标节点,每一列是经过每个邻居到达这个目标节点的最短距离。

图片

选路的时候,我们就会从每行中选择一个经过邻居节点成本最低的邻居,作为路由表的下一跳

比如选择从E到达D的路径,我们对比E经过A到D、E直接到达D的路径,距离分别是第四行的第一列和第四行的第三列,显然E直接到达D是一条更短的路径,所以路由表下一跳的选择自然也会是D。

整个算法也是迭代进行的,每个节点都会不断地从邻居那里获得最新的距离信息,然后尝试更新自己的距离矩阵,如果发现自己的距离矩阵有变化,才会通知邻居。这样也能避免许多不必要的通信成本。

图片

参考这个体现算法逻辑的流程图,相信你也一定能意识到为什么我们说这个算法是建立在Bellman-Ford算法思想上的了,其实节点间彼此传递信息的时候,在做的就是松弛操作,等所有的节点都稳定下来,也就相当于进行了V-1轮松弛操作,这个时候所有节点的距离矢量就会进入稳定没有变化的状态,整个算法也就进入了收敛状态。

无限计算问题

但是因为每个节点都没有全局的拓扑结构,距离矢量有一个巨大的问题,就是在一些情况下会产生无限计算的可能

比如图中的例子,假设 A、B、C、D 四个节点已经在某一时刻建立了稳定的距离矢量,ABC三个节点到D都会经过C节点。

图片

此时如果C->D节点突然中断了,会发生什么呢?

C发现自己到D的路径走不通了,就会问自己的邻居B:你那边可以到D吗?

这个时候B的距离表是没有变化的,结果B发现自己可以到D距离为2,就会告诉C:可以从我这走距离是2。

但是因为距离矢量算法没有任何信息告诉B其实它到D的路径就需要经过C,于是,C就会把自己到D的路径信息更新为新的,B到D的距离加上C到B的距离,也就是2+1。

图片

而更新之后,B又会收到消息,你的邻居C距离矩阵变化了,从而把自己B到D的距离更新为3+1。

这样的过程会反复执行,于是通往D的距离会无限增加。

这个问题就是路由环路问题,也被称为无限计算问题。解决思路也比较多,比较常见的做法就是设定一个跳数上限。

比如在RIP协议中16跳就是一个常用的上限,如果路径跳数多于16,我们就会把这个路径看成不可达的,这个时候我们可以让发现某个节点不可达的节点,暂时不要相信其他节点发来的距离矢量,从而避免路由环路问题的无限计算问题。当然,如果有节点和网络断开连接,但在跳数没有到达上限之前,还是会进行大量无谓的计算。

总结

好距离矢量算法到这里就学完了,我们结合链路状态算法简单对比一下。

首先,距离矢量算法和链路状态算法背后分别是基于Bellman-Ford算法和Dijkstra算法实现的。

距离矢量算法背后的Bellman-Ford本质就是对所有边无差别的松弛操作,迭代地进行很多轮,是本地的、非中心化的算法。

节点之间不用交换全部的路由拓扑信息,只需要交换到其他节点的最短距离,就可以让距离矢量算法逐步正确选出最短的路径,直至收敛;节点之间的通信也不需要是同步的,邻居节点的距离矢量在什么时间更新、以什么次序抵达都可以,不会影响选路的正确性。

但是在状态链路算法中完全不同,每个节点都需要通过信息交换获取全部的路由信息,然后各自独立地计算最短路径。虽然带来了更大通信开销,但同时也更加保证了计算的健壮性,不会出现环路计算这样的问题。

这两种基础选路算法值得你好好体会其中的思想,可以说现在绝大部分选路算法都是在它们的基础上改进的。另外背后的Dijkstra和Bellman-Ford算法也是算法竞赛中的常考题,在各大互联网公司的笔试题中也逐渐开始出现,你可以到力扣上找一些题目练习。

课后作业

今天留给你的思考题就是前面提到的环路问题,在跳数没有到上限之前,还是会进行大量无谓的计算。有什么更好的解决办法吗?

欢迎你留言与我一起讨论,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见~

20|滑动窗口:TCP是如何进行流量控制和拥塞控制的?

作者: 黄清昊

你好,我是微扰君。

过去几讲,我们一起讨论了最短路算法在网络中的应用,学习了从Dijkstra算法思想发展而来的链路状态选路算法,以及从Bellman-Ford算法思想发展而来的距离矢量算法。

链路状态算法的每个节点,通过通信,都构建了完整的网络拓扑图,然后根据Dijkstra算法独立地计算最短路径,并依据计算结果维护动态路由表;距离矢量算法,则是通过节点间的通信了解邻居到每个不同节点的距离,以此作为选路依据,所以链路上传输的压力比链路状态算法小了很多,但也因为没有全局的信息,网络出现故障时很容易陷入无穷计算问题。

在计算机网络发展以来,类似单源最短路问题的图论算法应用,除了这两大经典算法,其实还有很多,比如最小生成树问题、网络流问题等等,它们都在不同的场景下发挥着巨大的作用,但我们要知道,图论算法也只是解决了网络传输中和“拓扑结构”相关的一小部分问题。

这些算法并不足以让我们的数据在环境复杂的网络上稳定传输,也并没有办法去控制流量传输的快慢,来避免接受方对数据处理不过来,或者网络上数据包太多产生拥塞的情况。为了解决传输本身的问题,自然也有一些经典的算法思想和协议被提出来,TCP中的滑动窗口、拥塞窗口就是经典的例子。

在学习具体算法之前,我们先简单复习一下TCP协议做到了什么。

TCP协议和UDP协议

TCP作为最常用的两大传输层协议之一,无疑是久经生产环境检验的。传输层有两个我们广泛使用的协议:UDP协议、TCP协议,我们一般会说前者是面向无连接的,后者是面向连接的。

这里的“连接”具体是什么意思呢?

简单来说,UDP协议是一种没有状态的协议,节点之间如果采用UDP协议通信,两个节点能做的就是在UDP协议上发送一个个数据包,协议本身不会关心这些包之间的关系,所以在复杂的网络下,包的顺序和可达性都是没有保证的,应用层需要自己处理这些包的丢失和乱序问题。

当然,UDP协议也因此可以设计的非常轻量,所以在网络传输本来就比较稳定的内网环境下,或者对丢包可以容忍但对时延要求较高的场景下,UDP协议有许多应用

而TCP很不一样,它在节点之间建立了真正的连接的概念。相信你肯定听过TCP的三次握手吧,UDP就没有这个过程,三次握手完成时,TCP连接就建立了;结束通信的时候,通信双方往往也需要主动断开连接。

TCP的传输基于字节流,也引入了状态,明确记录着每个包是否发送、是否被接受到、包本身的序列号等状态;所以,TCP为我们提供了可靠有序的传输能力,也被设计的相当复杂。TCP协议不止考虑了包的可靠传输,同时也兼顾了效率,更提供了对流量控制和拥塞避免的能力,滑动窗口和拥塞窗口就是为了这两个目的而设计的。

那TCP的具体是如何做到让网络中的包传输可靠且效率高的呢?

TCP中包的发送

一个包在网络中发送出去,其实就像古时候你给别人用信鸽寄了一封信一样,在外界环境非常复杂的情况下,你完全没有把握这封信能不能真的送达收信人那边,除非有一天你收到了收信人的回信。

类似的,在复杂网络环境下,TCP为了能保证每个包真的送达了,并且接收端收到包的顺序和发送端是一致的,我们每发出一个包,自然也需要一个类似回信的机制。

图片

这个回信也就是ACK包,每个包发送的时候会有一个序列号,接收端回ACK包的时候会把序列号+1发送回来,发送端如果没有收到某个包的ACK包,会在一段时间之后尝试重新发送,直到收到ACK为止。这其实也是在网络和各种分布式系统中能确保消息可达的唯一方式。

那问题来了,我们为了确保消息保序可达,难道每次发送一个新的包,都等待上一个包的ACK回来之后才能发送吗?这样一来一回的效率显然是很低的,也就是每经过一个RTT的时间,我们只能发送一个包,假设一个RTT是100ms,那在一秒中我们甚至只能发送10个包,这完全是不可接受的。

其实我们在等待ACK的时候没有必要停止后续包的发送,因为网络传输虽然不稳定,但大部分包往往还是可达的,这样我们就可以获得数倍的传输效率提升。如果真的不幸遇到了丢包,接收端ACK姗姗来迟的时候,也就告诉了我们某个序列号之前的所有包全部收到,我们再根据一定的策略,尝试重新发送对应丢失的包就可以了。

图片

所以自然而然的,发送方需要缓存已发出但尚未收到ACK的包,接收方收到包但没有被用户进程消费之前也得把收到的包留着。

但是,缓存是有大小限制的,程序消费数据和链路传输数据的能力也是有限的,发送端和接受端都需要一种机制来限制可发送或者可接收数据的最大范围。

于是,滑动窗口和拥塞窗口应运而生。

这两个算法核心都是为了防止像网络中发送的包太多。不同的是两者的目的,滑动窗口机制,可以用来控制流量,防止接收方处理不过来消息;同样基于窗口机制的拥塞控制算法,则用来处理网络上数据包太多的情况,以避免网络中出现拥塞

流量控制

我们先来看看如何用滑动窗口控制流量。

这里说流量控制,主要就是为了防止接收方处理数据的速度跟不上发送方,避免随着时间推移,数据自然溢出接收方的缓冲区。

虽然协议可以保证发送方没有收到ACK,最终会重试重新发送,但如果需要大量反复发送冗余的数据,所占用的网络资源就被白白浪费了,在网络资源很紧缺的时候,这也会造成网络环境的恶化。

TCP控制流量的方式也很简单,就是滑动窗口机制。

接收端会建立一个滑动窗口,由接收方向发送方通告,TCP首部里的window字段就是用来表示窗口大小的,窗口表示的就是接收方目前能接收的缓冲区的剩余大小。

图片

但是发送方也会根据这个通告窗口的大小建立自己的滑动窗口。为了兼顾效率和可靠性,在发送方,所有未收到ACK的消息虽然可以发送,但是在收到ACK之前是一定要在缓冲区中保存的。

我们一起来看一下。

发送端的窗口

发送窗口根据三个标准来划分:是否发送、是否收到ACK、是否在接收方通告处理范围内,分成了四个部分。

图片

  • 第一部分就是已经发送且收到ACK的部分,这一块我们知道已经成功发送,所以不需要在缓冲区保留了。
  • 第二部分是已发送但尚未收到ACK的部分。
  • 第三部分是还没有发送,但是还在接收方通告窗口也就是处理范围内的数据,这块我们也可以称为可用窗口;第二、第三部分一起构成了我们的整个发送窗口。
  • 最后一部分则是我们需要发送,但已经超过接收方通告窗口范围的部分,这一部分在没有收到新的ACK之前,发送方是不会发送这些数据的。通过这个限制,发送的数据就一定不会超过接收方的缓冲区了。

但如果发送方一直没有收到ACK,随着数据不断被发送,很快可用窗口就会被耗尽。在这种情况下,发送方也就不会继续发送数据了,这种发送端可用窗口为零的情况我们也称为“零窗口”。

图片

正常来说,等接收端处理了一部分数据,又有了新的可用窗口之后,就会再次发送ACK报文通告发送端自己有新的可用窗口(因为发送端的可用窗口是受接收端控制的)。

但是,万一要是ACK消息在网络传输中正好丢包了,那发送端还能感知到接收端窗口的变化吗?其实是不会的,在这个情况下,接收端就会一直等着发送端发送数据,而发送端也还会以为接收端仍然处于零窗口的状态,这样一直互相等待,就好像进入了死锁状态。

解决办法也很简单,我们可以再引入一个零窗口定时器,如果发送端陷入零窗口的状态,就会启动这个定时器,去定时地询问接收端窗口是否可用了,这也是在分布式系统中常见的处理丢包的方式之一。

接收端的窗口

相对发送端来说,接收端要简单的多,主要就分为已经接收并确认的数据和未收到但可以接收的数据,这一部分也就是接收窗口;剩下的就是缓冲区放不下的区域,也就是不可接收的区域。

图片

如果进程读取缓冲区速度有所变化,接收端可能也会改变接收窗口的大小,每次通告给发送端,就可以控制发送端的发送速度了。这就是所谓的滑动窗口,也就是流量控制机制。

而之所以是滑动窗口,也很好理解,随着ACK或者进程读取数据,窗口也会顺次往后移动。比如在发送端的窗口中,如果我们在某次通信中收到了一条ACK消息,表示36之前的消息都已经被收到了,那么整个可用的窗口就会顺次往右移动。

图片

总的来说,滑动窗口(流量控制机制)解决了发送端消息可能淹没接收端,导致处理跟不上的情况。

流量拥塞

那TCP协议如何解决流量拥塞的情况呢?也就是网络中由于大量包传输,导致吞吐量下降甚至为0的情况。这和我们的道路交通很像,当车流越来越大的时候,整体的行车速度可能会不断下降,导致拥堵,最后吞吐量反而不如车少的时候。

图片

在实际网络中,因为大量的包传输,可能导致中间某些节点的缓冲区满载,从而多余的包被丢弃,需要重新发送,情况越发恶化,最差的时候,网络上的包都是重传的包并且反复地丢弃;整个网络传输能力甚至可以降低为0。

这当然是一个很严重的问题,TCP协议同样提出了另外一个叫拥塞窗口的机制,很好地解决了这个问题。具体是怎么做的呢?我们一起来看一下。

拥塞控制

网络中每个节点不会有全局的网络通信情况,唯一能发现的就是自己的部分包丢了,这种时候它就有理由怀疑网络环境劣化,可能产生了拥塞。

TCP是一个比较无私的协议,在这种情况下,会选择减少自己发送的包。当网络上大部分通信协议传输层都采用的是TCP协议时,在出现拥塞的情况下,大部分节点都会不约而同地减少自己传输的包,这样网络拥塞情况就会得到极大的缓解,一直处于比较好的网络状态。

所以我们就需要在发送端定义一个窗口CWND(congestion window),也就是拥塞窗口;发送端能发送的最多没有收到ACK的包,也不会超过拥塞窗口的范围。

引入拥塞控制机制的TCP协议,发送端最大的发送范围是拥塞窗口和滑动窗口中小的一个。拥塞窗口会动态地随着网络情况的变化而进行调整,大体上的策略是如果没有出现拥塞,我们扩大窗口大小,否则就减少窗口大小。

具体是如何实现的呢?经典拥塞控制算法主要包括四个部分:

  • 慢启动
  • 拥塞避免
  • 拥塞发生
  • 快速恢复

我们一个个来看。首先是慢启动,在不确定拥塞是否会发生的时候,我们不会一上来就发送大量的包,而是会采用倍增的方式缓慢增加窗口的大小,窗口大小从1开始尝试,然后尝试2、4、8、16等越来越大的窗口。

图片

整个慢启动的过程看起来就像图中这样,指数型的增加拥塞窗口的大小。

这样,倍增的方式窗口就会很快扩大;我们会在窗口大到一定程度时,减慢增加的速度,转成线性扩大窗口的方式,也就是每次收到新的ACK没有丢包的话只比上次窗口增大1。整个过程看起来就像这样:

图片

这两个慢启动阶段和拥塞避免阶段的分界点,我们就叫“慢启动门限(ssthresh)”。

随着窗口进一步缓慢增加,终于有一天,网络还是遇到了丢包的情况,我们就会假定这是拥塞造成的。

这个时候我们一方面会进行超时重传或者快速重传,另一方面也会把窗口调整到更小的范围。

  • 超时重传,往往意味着拥塞情况更严重,我们的策略也会更激进一些,会直接将ssthresh设置为重传发生时窗口大小的一半,而窗口大小直接重置为0,再进入慢启动阶段。像这样:

    图片

  • 快速重传,如果我们连续3次收到同样序号的ACK,包还能回传,说明这个时候可能只是碰到了部分丢包,网络阻塞还没有很严重,我们就会采用柔和一点的策略,也就是快速恢复策略。

图片

我们会先把拥塞窗口变成原来的一半,ssthresh也就设置成当前的窗口大小,然后开始执行拥塞避免算法。有些实现也会把拥塞窗口直接设置为ssthresh+3,本质上区别不大。

总结

总体而言,TCP就是通过滑动窗口、拥塞窗口这两个简单的窗口实现了流量控制和拥塞控制。

滑动窗口由接收端控制,向发送端通告,这样就可以保证发送端发出的包数量上限是明确的,也就不会存在淹没接收端导致来不及处理的情况。

拥塞窗口由发送端控制,它会根据网络中的情况动态的调整,通过慢启动、拥塞避免、拥塞发生、快速恢复四个算法,就可以很好地调整窗口的大小。和滑动窗口一起限制了发送端最大的发送范围,从而保证了拥塞在网络上不会发生。

思考题

最后也给你留一个思考题。前面提到了一个叫快速重传的机制,也就是连续3次收到相同的ACK,发送端就会意识到丢包的产生,我们没有详细讨论,你能说说看为什么这样比直接超时重传的方式更好吗?它有没有什么其他问题呢,会不会有更好的方式解决呢?

欢迎你留言与我一起讨论,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见~

21|分而治之:MapReduce如何解决大规模分布式计算问题

作者: 黄清昊

你好,我是微扰君。

从今天开始,我们就真正开始学习算法在工业界应用了。和前面的章节不同,分布式系统篇的很多算法,一般都是由工程师们提出来的,为了解决一些大规模网络应用中的实际问题,比如为了解决海量网页排名而发明的pagerank算法、为了解决分布式系统中共识问题的Raft算法、常用的负载均衡算法一致性哈希等等。

因为都是在实际的工程场景下被发明出来的,这些算法,在现在的互联网架构中也经常能看到它们的身影,所以学习这些算法以及其背后解决的问题对我们的实际工作是有很大益处的。

话不多说,我们开始今天的学习——谷歌提出的MapReduce算法,知名的开源项目Hadoop其实就是对MapReduce的工业级实现之一。

为什么发明MapReduce算法

想要掌握一个解决实际生产环境中问题的算法或者框架,我们当然应该先来了解一下相关算法的诞生背景。

MapReduce算法,作为谷歌知名的三驾马车之一,是早期谷歌对大规模分布式计算的最佳实践。他们当时(2004年)发表了相关的论文,很清晰地描述了提出这个算法的目的。

当时的谷歌已经有很大的业务量了,每天都需要处理海量的数据,也有许多不同的业务场景,所以工程师们实现了数以百计的数据处理程序,用来实现网页抓取、日志汇总分析、计算倒排索引等任务。

这些任务大部分本身倒也不是很复杂,但因为需要面对巨大的数据量,单机的程序显然没有办法应对谷歌的数据规模,这些程序只能是分布式的运行。

我们知道,在分布式环境上运行的程序都会比较复杂,需要考虑各种问题,比如不同环境下可能出现的异常、数据如何分发到各个机器上、计算完成之后又如何汇总等等。

要是每个业务方都针对这些雷同的问题,各自实现一遍处理这些问题的逻辑,显然是非常低效的。所以为了解决这个问题,谷歌的工程师提出了一种新的、通用的、抽象模型 MapReduce,让业务开发人员不再需要关心并行计算、数据冗余、负载均衡等和分布式系统本身相关的细节了。

Map和Reduce

那为什么起名叫MapReduce呢?其实map和reduce,在一些函数式编程语言中,是十分常用的概念,比如在Lisp里map和reduce都是作为原语存在的,历史非常悠久。谷歌的分布式计算框架也只是借鉴了其中的思想。

我们就以js为例子,先来看看在一般编程语言中的map和reduce都是什么样的操作。

假设一个number数组中,我们希望统计出数值大于5的那些数向上取整的和。这个问题很简单,常规的写法自然是遍历整个数组,写一个if-else判断出大于5的数,然后用一个变量做累计求和。但是这个写法引入了状态,在循环里你既需要关心filter的逻辑,又要关心累计求和的逻辑,不够清晰。

而比较函数式的写法是这样的:

1
2
3
4
5
6
function(arr) {
arr
.filter(a => a > 5)
.map(a => Math.ceil(a))
.reduce((acc, i) => acc + i);
}

通过filter \ map \ reduce等原语,我们把控制逻辑和计算逻辑分离地非常清楚。

这虽然是一个单机且简单的程序,但是大多数的运算和业务逻辑,其实都是可以通过这样简单的map和reduce函数来实现的。因为一个很复杂的计算,也无非就是对某些数据据进行一系列规则的变换,转化成另一些数据

如果我们把输入数据表示成一个 key\value 对的集合,输出数据也表示成一个 key\value 对的集合。 那么我们只需要通过两种操作就可以完成绝大部分转换。

一是对输入的每个(key, value)进行某种变换,得到和输入集合规模一样的新集合,但每个值都按照指定的规则进行变换。这个新集合,既可以作为最终的输出结果,也可以作为中间的结果供后续转换操作使用。这个就是map。

二是对(key, value)进行一些合并的计算。通常来说就是把某个key的不同value,按照某种规则合并起来,从而得到一个比输入规模更小的 (key, value) 集合。这样的操作就是reduce。

以刚才统计和的程序为例:

  1. 向上取整,需要通过map来实现(每个值都按规则变换);
  2. 过滤5以上的数字是通过filter来实现的,这本质上也是一种reduce的操作,输入一个集合,合并的时候返回一个列表,但reduce计算的时候,只有符合>5条件的元素才会被加入累计的值里;
  3. 最后的求和,也是通过reduce实现。传入的函数就是一个普通的加法运算,对应的reduce操作就会自动对整个集合进行求和了。

除了这个例子,还有很多适用于互联网应用的场景,谷歌的同学当时在论文里就给出了一些例子。比如:

  • 分布式Grep程序,可以用于对大量数据的模式匹配,比如查询日志中某些模式出现的数量。
  • 统计URL访问频数,把所有的访问记录用map函数处理成 (url, 1),再用reduce函数对url相同的记录,累计计数。
  • 网络连接逆向,把 (source, target) map为 (target, source) ,再用 reduce 把target相同的记录中的source,合并成列表,得到 (target, list(source))。
  • 倒排索引,把文本分词,得到 (word, document) 的集合,并对同样的词进行reduce操作,得到(word, list(document)),这样就可以用来给搜索引擎加速查询。

这些例子,本身都不是特别复杂。如果我们就用单机系统去实现,相信你用传统的循环控制、开全局变量的方式,去实现这些逻辑都是没有问题的。

当然如果用map\reduce这样的原语去实现,你的代码逻辑会更清晰,我个人也更推荐你这么写。而且现在各大语言也都开始引入函数式编程的特性,比如Java 8的stream就是一种对函数式编程能力的部分释放,如果感兴趣你可以系统学习一下相关API的使用和背后函数式编程的思想。

分布式实现

但是,谷歌的MapReduce当然不只是一种编程思想,而是一个真正意义上的分布式计算的框架和系统。毕竟对于谷歌来说,那些问题虽然简单,但所需要应对的数据量是单机远远存储不下,也计算不了的。

所以我们需要把整个map和reduce的过程搬运到一个分布式的系统中来实现。所用的机器也都是普通的商用机器,不一定非常稳定,集群规模到达一定程度的时候,机器或者网络出现异常是在所难免的。这些问题自然也都是谷歌需要考虑的。

那么,MapReduce到底是如何运作的呢?

想要任务在MapReduce机器上顺利执行,大体上来说就是要做到把数据分区,交给不同的机器执行,在要汇总的时候也需要有办法进行数据的汇总,并且要有一定应对故障的能力

当然了,整个MapReduce系统是要有一套全局共享的存储系统的,这就是谷歌鼎鼎大名的三驾马车中的另一架GFS(Google File System)的作用,MapReduce也是建立在这个存储系统之上的,感兴趣的同学可以自行查阅相关资料了解。

我们一起来看当用户发起MapReduce任务时,会发生什么。

执行过程

任务发起后,程序首先会把输入文件分成M个数据段,每个数据段大小可控,通常为16MB;然后,整个集群就会快速地分发需要执行的计算任务给到各个节点,让这些节点可以进行计算任务。


在这里有一个很常见的设计,我们会让众多进程中的一个成为master进程,由它来进行任务的调度,其他进程都是worker进程,进行实际的计算任务。master会把M个map任务和R个reduce任务分配给空闲的进程。

对于map任务,worker在读取输入数据之后,根据任务内容进行相应的map计算,由map函数输出中间的结果缓存在内存中;然后worker会通过分区函数,把中间结果定期落盘,分离在r个区域;这些区域的信息会传递给master,以待后续reduce使用。

对于reduce任务, worker 会收到 master 传来的中间结果的位置信息,通过RPC读取相应节点中间结果。由于reduce是按照不同的key进行聚合操作,所以读取完数据之后会做排序,再按照reduce函数进行结果的计算。这里计算出的结果同样会被分区追加到对应的分区文件里。

当map和reduce程序全部执行完成,用户程序会收到通知,读取最终的计算结果。

容错

整个执行过程是比较复杂的,在分布式系统下,一个工业级的应用,必须要考虑容错问题。

一是因为和超级计算机不同,互联网应用通常使用比较廉价的机器,然后通过大规模的部署来提高计算能力和稳定性;二是我们的服务也需要长时间在线,不能每次出现故障都由管理员手动恢复。所以容错灾备是互联网应用的基础能力,MapReduce需要很好地处理机器的故障。

在MapReduce场景下,故障主要包括worker故障和master故障。

worker故障

worker是实际负责计算的节点。

在计算场景下,如果任意一个worker正在处理的任务失败,不进行任何处理,整个计算任务就会因为部分失败而全部失败,所有worker全部完成任务才能得到完整的数据处理结果。

所幸,我们有一个全局的控制节点master,它能很好发现worker的故障,并适时地进行任务的重新调度。具体做法也是在网络中很常见的定时ping操作。master会周期性地给worker发送ping请求,如果worker正常就会回复,所以如果master一段时间没有收到回复,会把这个worker标记为失效worker,相关的任务也会被设置为空闲状态,进而分配给其他空闲的worker。

如果Map的任务异常,由于Map任务的中间结果都存储在本地节点中,当节点异常时,我们就需要重新执行该节点上已经完成的Map任务;而reduce worker,因为完成任务之后会直接输出到全局文件系统中,不需要重新执行已经完成的reduce的任务,只需要重新执行这次失败的任务即可。

引入master节点,对worker节点进行监控和重新调度的机制,在分布式系统中是非常常见的,你可以好好掌握。

master故障

现在master能很好地帮助我们解决worker的故障了,那如果master出现了故障又该怎么办呢?

一种最简单的方式是周期性的把master相关的状态信息保存到磁盘中,形成一个个检查点。如果master任务失败了,我们就从最近的一个检查点恢复当时的执行状态,全部重新执行。

另外一些比较常用的手段,比如可以对master引入backup节点,如果一个节点挂了,我们马上把备份的节点拿来当新的主节点使用,这样恢复的速度就会快很多。但谷歌的工程师当时的选择更简单一些,就是直接终止这个程序,让用户感知到并重新提交任务,其实不能说是最好的解决方案。

总结

MapReduce在整个互联网世界取得了巨大的成功,第一次将大规模的分布式计算用简洁易用的API抽象出来,封装了并行处理、数据分发、容错、负载均衡等繁琐的技术细节,把面对海量数据的应用开发人员解放出来,解决了许多不同类型的问题。

可以说后面的所有分布式计算框架比如Hadoop、Spark、Flink等等都是建立在Google MapReduce工作的基础上的。

系统设计,非常重要的一点就是对容灾能力的支持,主要就要分为故障检测和故障恢复两个步骤。

对于检测来说,引入一个控制节点对其他节点进行监控是一个非常有效的手段,通过定时的心跳,控制节点就很容易发现其他节点的异常;而故障恢复的一个有效手段就是定时设置checkpoint,定期记录下运行正确时系统的状态,这样异常发生的时候就可以快速恢复,重新执行需要的计算。

课后作业

MapReduce毕竟是一个非常古老的系统了,学习它能带给你很多启发,但你也要了解其中设计不足的地方。说一说你觉得MapReduce哪些机制设计的不好,为什么后面又产生了许多新的分布式计算框架呢?

欢迎在留言区写下你的思考,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友。我们下节课见~

22|PageRank:谷歌是如何计算网页排名的

作者: 黄清昊

你好,我是微扰君。

上一讲我们学习了谷歌三架马车之一 MapReduce。建立在Google File System的基础上,MapReduce很好地解决了谷歌当时的大规模分布式计算问题,让业务工程师不再需要处理和分布式计算相关的容错、数据分发、计算调度等复杂的技术细节,而把精力放在业务问题本身。

不过谷歌作为一家搜索引擎公司,搜索自然是谷歌重中之重的核心业务。今天我们就来学习谷歌三架马车之二——PageRank算法。

早期的搜索引擎一般只是基于关键字进行匹配,按照匹配情况,把爬虫爬到的全部内容,再基于时间顺序进行排列,如果对搜索质量的把控高一点,可能最多也就是做到基于关键词出现频次的排序。但是,这样搜索出来的质量往往不是很让我们满意,而且容易出现站点作弊的情况,比如通过大量在网页内容中填充某些关键词,以提高自己的网页排名。

为了让用户获得更好的搜索体验从而打败竞争对手,谷歌是如何设计自己计算网页排名的算法的呢?

这就要提到PageRank算法,由谷歌创始人也是斯坦福大学的博士生 Larry Page 提出的,算法既以Larry Page本人名字来命名,同时也包含了网页排名的意义。 PageRank 算法不止可以让用户搜索到自己关心的内容,也往往能让质量更高的网页得以排到更前的位置,同时它也是一个典型的 MapReduce 的应用场景。

那 PageRank 具体是怎么做网页排名的呢?

基于引用的排名

其实这里面的想法主要受到了论文影响力因子的启发;在学术网络中论文的影响力因子往往是基于论文被引用次数来衡量的,这是一种最简单也非常有效的评价指标。

一篇影响广泛的论文,往往会成为许多其他工作的基础,从而收获大量的引用。以1998年发布的PageRank这篇论文本身为例,到现在一共收获了16102次引用,足以说明这是一篇非常有影响力的工作。

图片

有相当多的学者在用图的方法研究学术网络中的问题,如果你把论文看成图,那么论文之间的引用关系就是一条条有向边,入边越多的节点,影响力一般来说也越高。

网页和学术论文其实在有些方面是很像的。如果把网页看成图上的节点,由于网页之间有一些超链接指向,谷歌所能爬到的所有网页就会构成一张类似于学术网络的图。

PageRank 对网页的排名,本质上也是这样一种基于引用情况和影响力的排名。背后的逻辑很简单,被更多超链接指向的网页,可以推断它往往会有更好的质量,因为当时许多HomePage类的导航网站都会链接到一些提供优质服务的网站,如果一个网站质量很差,自然也不会被太多链接所指向。

那简单统计引用次数,也就是我们把爬到的那些在网络中被链接次数更多的网页排到更前面,行不行呢?

这样当然也一定程度上可以反映出网页的排名情况,但不同网站的链接所代表的权重应该是不同的。比如雅虎链接的网页和某个个人主页所链接的网页,显然代表的意义是不同的,如果把所有的引用都看成权重一样的,并不令人满意,而且也很容易作弊。只要建立大量的网页,链接向某个想要在搜索引擎中提高权重的网页,在只看引用次数的算法下,很容易就把网页排名提高了。

PageRank

Larry Page 的PageRank算法考虑到了不同链接的权重,整个过程也非常简单容易理解。

我们先按照他论文中的数学语言描述一下整个网页集合,方便后面分析。假设u代表了某一个网页;$F_u$代表u所指向的网页集合,$N_u$代表$F_u$集合的大小;$B_u$代表指向u的网页集合;c是一个因子,用来保持所有网页权重之和是一个常数。

如果我们用 R(u) 表示网页u的权重,拉里佩奇是这样计算权重的:

$$R(u)=\sum_{v\subset B_u}R(v)/(N_v)$$

光看数学公式不是很好理解,我们对照这个图片来看:

图片

每个网页都有不同的权重用来排名,如何计算这个权重呢?拉里佩奇的做法就是对指向当前页面的所有页面,我们直接它们的权重做某种程度上的加权平均。

每个页面的总体权重会被平均分散到它所指向的页面中去,比如图中权重为100的网页,有两条出边,那么每条边的权重就会记录为50。那么对于被指向的网页权重如何计算呢?比如权重为53的网页,就是用指向它的两条链接的权重,进行累计求和,也就是50+3=53。

这样的分配是简单而有效的。因为我们可以简化地认为用户从某个网页跳转到另一个网页的概率,就是在当前网页的所有超链接中,随机选择一个,进行跳转,那当前网页的影响力被平均地转移到它所指向的网页,也是很符合直觉的。

如何初始化权重?

但是这样的计算方式显然是动态的、迭代的,每一轮迭代计算的结果都依赖上一轮迭代的结果,所以它们看起来都会依赖整个图的初始状态,我们如何初始化权重呢?

其实无论怎么初始化权重,最终都会趋于平稳。数学上这个问题被称为马氏链平稳状态定理,我们大致了解一下,只要所有状态之间都是互相可达,且整个转移过程没有周期性。那么无论如何初始化,只要状态转移矩阵是确定的,最终整个马氏链一定会趋于稳定。背后的数学就不仔细展开讲解了,你感兴趣的话可以自己搜索了解一下。

我们用一个论文中具体的例子来看一看这个过程,假设现在有三个网页A、B、C,之间的链接关系就是下面的图:

图片

如果按照前面说的方式进行权重的转移,我们可以得到一个类似于马氏链的状态转移矩阵,矩阵的每一列都代表某个网页的权重如何传递到其他网页上(A的50%到B,50%到C;B的100%到C;C的100%到A):

$$

\left[\begin{array}{ccc}

0 & 0 & 1 \<br>

0.5 & 0 & 0 \<br>

0.5 & 1 & 0

\end{array}\right]

$$

所以如果用这个矩阵乘以表示每个页面权重的向量,得到的新的向量,自然代表一轮计算之后的网页权重。

假设我们初始化三个网页的权重都是 1/3 ,那么:

$$

\left[\begin{array}{ccc}

0 & 0 & 1 \<br>

0.5 & 0 & 0 \<br>

0.5 & 1 & 0

\end{array}\right] *\left[\begin{array}{c}

1 / 3 \<br>

1 / 3 \<br>

1 / 3

\end{array}\right]=\left[\begin{array}{c}

1 / 3 \<br>

1 / 6 \<br>

1 / 2

\end{array}\right]

$$

经过一次权重分配计算之后,得到的新的权重是 1/3、1/6、1/2。

我们继续反复进行这样的转移和分配,进行12次计算之后,得到的新的权重就是 (77/192 19/96 77/192) 写成小数是 (0.401 0.198 0.401),很容易发现,权重会渐渐收敛到 0.4 0.2 0.4。

这其实就是所谓的马氏平稳状态。如果你用 0.4 0.2 0.4 作为网页权重再做一次计算,会发现整个网页的权重不会再有任何变化。

$$

\left[\begin{array}{ccc}

0 & 0 & 1 \<br>

0.5 & 0 & 0 \<br>

0.5 & 1 & 0

\end{array}\right] *\left[\begin{array}{c}

0.4 \<br>

0.2 \<br>

0.4

\end{array}\right]=\left[\begin{array}{c}

0.4 \<br>

0.2 \<br>

0.4

\end{array}\right]

$$

总的来说,在这个简单例子里,无论如何初始化网页权重,在这样的链接情况下,所有网页的权重都是固定可以计算出来的。A和C的权重高一些也很容易理解,因为相比于B,有更多的链接指向A和C。

在更大的图上,这样的收敛性质,在所有节点强连通的情况下依然是可以得到保障的。所以在有限次的计算中,一旦给定了网页链接图,所有网页的权重都是收敛到一个稳定值的。那么在搜索的时候我们基于这个权重来做排名,就可以得到一个比较优的搜索结果了。

但是,在推导问题时我们用的例子是一种特殊的网络链接图,所有的节点都是既有出边也有入边的,对于真实的网页链接来说,考虑到边界情况,还有两个问题需要处理一下:

  • Dangling links
  • Spider Traps

第一个就是Dangling links,指的是那些有入边但是没有出边的节点,在网页链接图中,有这样的节点存在会导致很大的问题。

我们仔细思考一下刚刚的计算过程,很容易发现,整个过程的所有页面权重之和是没有变化的,这是因为每个被转移到某个节点的权重,都会在下一轮计算中被平均地分配到其他页面上。

但是如果出现了Dangling links,情况会大有不同,所有分配到那些只有入边,但没有出边的节点上的权重,都不会再有机会转移到其他节点了。这些节点就像一个个黑洞,吸收着网页上所有的权重,随着不断迭代,整个图上的权重仍然会收敛,但是会全部收敛为0。这显然是不可接受的。

那要如何解决这个问题呢?Larry Page 认为,这样的节点对其他节点权重的计算没有任何贡献,既然让它们参与计算会导致所有权重收敛为0,不如直接把它们都移除。

不过这个移除过程可能会导致新的 Dangling links 产生,所以整个过程需要迭代地进行,直到剩余的网页中不再存在 Dangling links。当然,被移除的额外节点会对整个马氏链的计算产生一定影响,但是Larry Page 认为这个影响在谷歌搜索引擎收纳的巨量网页前是可以接受的。

移除后,等新的网页链接图上的计算收敛,我们再把被移除的节点加入图中,进行权重计算,但是不去修改所有已经收敛的权重,这样就可以得到全部网页的权重了。

平滑性和蜘蛛陷阱

第二个要处理的问题是 Spider Traps。这个问题和Dangling links的情况有点像,说的是图中另一种节点,虽然有出链,但是出链只指向自己。

图片

比如图中的C就是这样一个节点,感兴趣的话,你可以自己列一下转移矩阵,做一做权重的计算感受一下。如果图中存在这样的节点,运行PageRank算法之后,权重仍然会收敛,不过这次就是所有的权重都集中在C这一个节点上了。

不过即使没有这样的节点存在,因为网页数量众多,而链接相对有限,整个网页链接图是比较稀疏的。

可以想像运行PageRank之后,很可能节点之间的权重分布很不均匀,有一部分节点处于网络的边缘且入边很少,所以权重几乎为零,而另一部分权重会比较大。那我们怎样才能做到尽量地共同富裕,让权重结果更加均衡一些呢?

思路很简单,就是添加一个跳转因子$\beta$,让每个网页都雨露均沾,相当于增加到所有其他节点的一个链接,只不过权重很小。但是只要增加了,就足以让每个网页的权重不至于接近于零,可以平滑整个图上权重的计算。

新的计算公式是:$$R=(1-\beta)MR’+e*\beta/N$$

R是权重,M是转移矩阵,e是单位矩阵。添加了跳转因子$\beta$后,每个新的权重计算就不只依赖原图中的链接,还考虑了雨露均沾的跳转因子。这样,Spider Traps就会被抑制了,至少不会出现某些节点上几乎没有权重的情况,可以得到一个比较合理的网页排名权重。

利用Spark进行实现

好啦学习了这么多原理性的东西,现在你是不是迫不及待想要实现一下呢?

思路也很简单,无非就是建图,然后迭代地计算权重,重点就是判断一下收敛条件。

比如上一轮结果和这一轮结果的差距是否小于一个阈值,是的话,就停止计算;或者更简单直接规定一个迭代轮次,因为即使在很大的图上,估计也只需要百次左右的迭代,整个网络就会趋于收敛了。如果你是数据分析高手,可能直接用Python套个科学计算的库,利用矩阵计算,10几行代码就可以完成这项工作了。

嗯,听起来很完美是吧?但是不要忘了,我们面对的是海量的网站数据,单机的环境是远远搞不定的,这个时候就是我们上一节课学习的MapReduce的用武之地了。

不过MapReduce和Hadoop确实已经是上一代的大数据计算引擎了,我们这次就用Spark来实现一下(如果你对Spark不熟也没有关系,整个实现非常简单,相信你看代码也很容易理解)。主要是想说明一下MapReduce这样的计算框架的高度抽象和泛化能力,让它可以解决绝大部分分布式计算问题,而PageRank正是绝佳的使用场景。

废话不多说,我们直接写代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def main(args: Array[String]) {
if (args.length < 1) {
System.err.println("Usage: SparkPageRank <file> <iter>")
System.exit(1)
}

showWarning()

// spark 初始化
val spark = SparkSession
.builder
.appName("SparkPageRank")
.getOrCreate()

// 迭代轮次
val iters = if (args.length > 1) args(1).toInt else 10
val lines = spark.read.textFile(args(0)).rdd
// 转化为邻接表
val links = lines.map{ s =>
val parts = s.split("\\s+")
(parts(0), parts(1))
}.distinct().groupByKey().cache()

// 初始化所有节点的权重为1
var ranks = links.mapValues(v => 1.0)

for (i <- 1 to iters) {
// 将所有的处边对应的权重分配计算出来
val contribs = links
.join(ranks)
.values
.flatMap{ case (urls, rank) =>
val size = urls.size
urls.map(url => (url, rank / size))
}
// 进行累积求和
ranks = contribs.reduceByKey(_ + _).mapValues(0.15 + 0.85 * _)
}

val output = ranks.collect()
output.foreach(tup => println(tup._1 + " has rank: " + tup._2 + "."))

spark.stop()
}

假设我们输入的是网页之间的边表:

1
2
3
4
5
6
url_1 url_4
url_2 url_1
url_3 url_2
url_3 url_1
url_4 url_3
url_4 url_1

代码会先把边表转化为邻接表,相关的概念我们之前学搜索和网络的时候都有提过,你不熟悉的话可以看这里回顾。

图片

有了邻接表之后主要就是两步:

  • 第一步是把当前轮次的所有网页权重分配出去,就按照权重除以出边数量进行分配,映射为 <被链接网页,被分配权重> 的 key-value 对。
  • 第二步,汇聚这些key-value对,通过reduce进行求和。

整个过程以及中间结果都是在Spark计算集群中分布式计算、分布式存储的。而且写出来的核心代码非常简洁,这就是MapReduce的威力了。

经过20轮的迭代,在上面的图中,我们可以得到这样的计算结果:

1
2
3
4
url_4 has rank: 1.3705281840649928.
url_2 has rank: 0.4613200524321036.
url_3 has rank: 0.7323900229505396.
url_1 has rank: 1.4357617405523626.

感兴趣你也可以自己部署一个单机版的Spark试一试。

总结

今天我们学习了谷歌三驾马车之二 PageRank 算法,核心是利用网页之间的链接关系,通过迭代的方式计算网页的权重,帮助谷歌获得了更好的搜索质量,打败了竞争对手。

PageRank 的应用非常多,比如一个很常见的,它可以用来帮助微博挖掘平台上有影响力的大V。这些应用往往都需要大量的计算资源,MapReduce或者Spark这样的分布式计算平台,可以很好地帮助我们屏蔽底层的技术细节而将研发人员的精力都放在业务开发之上。

其中为了解决Spider Traps,增加跳转因子的思想也很常见。比如,各种广告系统或者推荐系统,在广告或者内容没有历史数据的时候,我们就会为这些内容提供一些试探流量。这背后的思想其实和跳转因子也是类似的。相信当类似业务需要出现时,现在你可以想到解决方案了。

课后作业

最后也给你再留一个开放式思考题,这样的PageRank算法有什么可以进一步优化的地方吗?

很期待在评论区看到你的想法。如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。下节课见~

23|Raft:分布式系统间如何达成共识?

作者: 黄清昊

你好,我是微扰君。

今天我们要来谈一谈分布式系统中一个非常重要的问题:分布式共识问题,也就是一致性问题。

我们知道,分布式系统的诞生,主要是为了提供单机无法进行的计算和存储、提高吞吐量、增加容错性等。而在现在的互联网架构下,分布式系统由于大量使用廉价的商用机器,节点故障是不可避免的。

在这种情况下,多个机器如何像一个整体一样工作,是件很困难的事情,这里一致性算法就起到了至关重要的作用。

以分布式KV存储系统为例,我们来先搞清楚分布式一致性到底解决的是什么问题。

复制状态机

之前在操作系统章节中讲过,数据库常用 redo-log 来实现事务等能力,那当这样的存储系统不再是单机节点,我们通常也需要采用多台机器来存储日志,把同样的日志在不同的节点都存储一份。这样如果有一台节点挂了,整个系统还可以用备份节点来提供日志的能力。

让多台服务器存储相同顺序的多条相同指令,也就是“日志”,可以帮助我们实现复制状态机。

图片

每一个状态的变更记录都会先在日志中存储并commit,之后才apply到状态机中修改对应的状态,这个设计能在分布式系统中解决许多和容错性相关的问题。

既然涉及多个节点存储同样的一份东西,怎样才能保证多个独立的节点所存储的内容是一致的呢?这就是我们常说的“一致性问题”了。

日志一致性问题

客户端通常只会向分布式系统中的某个服务器发起请求,然后由这个服务器的一致性模块,在多个复制状态机之间进行消息的同步,正常情况下,多个节点同步都会成功,这样不同节点的日志自然也都是一致的,所有的节点都会以相同的顺序包含相同的请求,从外界看起来,行为也就像是一台机器一样。

但是在服务器发生故障时,依然要保持正确的复制变成了困难。

一种最暴力的做法可能是:定义一个主节点,每一次写请求都请求到主节点,等主节点一致性模块向集群中的每个节点都成功写入了同样一份数据,再返回给客户端成功的消息,进行消息的commit。只要有一个失败,就不进行消息的commit。

但是我们看这个方案,显然不是很高效。无论是存在慢节点,会导致整体响应被拖的很慢,还是一台节点挂了,会导致整个集群不可用,都是不可接受的。

那我们分析一下在实际系统中,一致性算法通常会存在哪些问题:

  1. 在分区、网络延迟、乱序等情况下都需要保证安全性,也就是说需要数据一旦返回,一定是正确的。
  2. 可用性问题。部分节点故障,但在集群中大部分节点可运行且能互相通信的情况下,要保证整个系统是可以工作的。
  3. 不依赖时钟保证一致性。因为物理时钟在分布式系统中是不可信的,不同节点间的时间并不同步的,而且随时可能因为时钟同步而导致时钟回跳等等。
  4. 慢节点,要求不能影响系统整体性能。

接下来我们要学习的Raft算法就很好地考虑了这些问题。

Raft与Paxos

Raft提出之前,在非拜占庭条件下,分布式一致性领域里,Paxos算法,一直占据着统治性的地位,但 Paxos 是出了名的难理解,工程实践也比较困难。

拜占庭条件源自一篇论文,也是Paxos的作者写的,他用一组将军围困一座城市的假想问题,描述当点对点通信的节点中出现了恶意节点传播不正确消息时,达成共识的困难处境。非拜占庭条件指的就是所有节点都是正常工作,只可能出现消息不可达,不会有消息错误的情况。拜占庭将军问题本身也非常有趣,你感兴趣的话可以自行搜索了解一下。

Paxos的艰深难懂其实也是Raft算法提出的主要动机。Raft和Paxos都是只要有超过一半的服务器可以运行并互相可通信,就可以保证整个系统可用。

和Paxos不同的是,一致性问题,被Raft明确拆解成了三个比较独立的、更好理解的子问题,并且团队在许多实现细节上做了很多努力和权衡,也增强了系统里的许多限制,简化了需要考虑的状态,尽量让过程和接口的设计变得清晰易懂。比如,Raft中就引入了Leader,由Leader进行全局消息的把控,也不允许日志中存在空洞的情况,都是一些比较常见的权衡。

接下来我们就来逐一学习Raft拆解出来用于达成分布式共识的三个子问题:领导人选举、日志复制、安全性。

子问题一:领导人选举

Raft引入Leader的概念,也就是领导人节点的思路,和两阶段提交里的协调者性质其实差不多的。

本质上,都是因为在分布式的系统中,各个节点不具备全局的信息,那为了感知到不同节点对请求的响应情况,我们通常就会引入一个主节点,由它进行统一的控制和调度,这样整个分布式的处理逻辑就会变得比较简单。

在Raft中,Leader就负责接收客户端的请求,由它统一向其他节点同步消息,等收到半数的节点Commmit日志的响应后,就会把状态应用到状态机,并返回。

思路很简单,但是Leader节点如何被选出呢?

Raft设计了一套节点状态机制,每个节点永远处于三个状态之一:

  • Follower 追随者:所有节点初始化或重启的时候都处于Follower状态。它不会接受请求,也不会发起请求,只响应由Leader发起的AppendEntries和Candidate发起的RequestVote请求。

  • Candidate 候选人:是 Follower 晋升为 Leader 的中间状态,从语义上就能看出来这个阶段是需要投票的。Follower 如果在一段时间没有收到领导人的消息,就会变成 Candidate 并发起选举,也就是向集群中所有节点发出 RequestVote 请求,如果收到半数以上也就是 (n/2+1) 的通过,就可以成功晋升为新的 Leader 。

    图片

  • Leader 领导人: 系统大部分时候只有一个节点处于Leader状态,如果有两个节点同时处于Leader状态,也最多只有一个是真正有效的。Leader会不断的向Follower发起请求,告知它们自己还在正常工作。这里的请求就是后面用于复制日志的AppendEntries请求,我们马上展开讲解。

每一任新的领导人出现,都会带有一个任期(term)。任期时长不确定,只要网络不发生大面积分区,而且超过半数的节点和Leader一直可以正常工作,这届任期可能就会非常长。

任期编号是单调增的,1、2、3……,在每一次选举发起的时候产生。

图片

候选人发起选举的时候会把当前的任期加1,再发给其他节点投票,如果其他Follower收到的请求带的是过期的任期,就会直接拒绝这次请求,对应的Leader和Follower发现后也会立刻变成Follower状态,因为这意味着此时已经出现过一个任期更高的合法Leader了。

大致设计就是这样,但是我们把这套规则应用到真实系统中就会存在一些问题。你可以先暂停思考一下预计会出现哪些问题,如何解决,我们再一起讨论。

首先当同时有多个Candidate产生,发起票选,每个Candidate的票数都不足n/2+1的时候会发生什么呢

这个很简单,平局收场,Term再加1,我们重新选举一轮就可以。所以Candidate也会维护一个定时器,用于处理这种超时的情况。网络分区中的Candidate可能会不断的提高自己的Term,但是因为它没有任何新的数据被写入,网络恢复的时候它自己也能很快感知到这点,从而恢复到Follower的身份。

看到这里你可能又想到新问题了:如果碰到了这样的情况,多个Candidate一起超时,又会触发下一轮票选瓜分,岂不是永远选不出Leader了

解决这个问题的方法,也很简单常用。我们可以在选举超时时间中引入一定的随机性,而不是一个固定值,比如150-300ms,这样多个Candidate在下一轮超时的时候,肯定就会错开发起选举请求的时间了。

当然,这也需要我们保证有良好的网络环境,选取发出-被收到的时间一定要比较短才行。

1
广播时间(broadcastTime) << 选举超时时间(electionTimeout)

不过这里还有一个问题不知道你有没有思考,为什么我们一定要要求半数以上的票选才能晋升呢

这其实也是在分布式系统中非常常见的做法。容斥原理我们知道,能获得半数以上票选的候选人只可能有一个。所以半数机制,如果遇到网络分区,网络分区少数的那一方就肯定不会产生Leader;同样,同一个Term下全局也只会有一个Leader,不会出现脑裂也就是同时有多个leader的情况。

日志复制

现在Leader选举完成,它就要扛起接受客户端请求并复制日志的大旗了,主要职责就是发起AppendEntries请求,这里的Entries主要指的就是日志记录,它可以做两件事。

  • 第一就是我们前面说的,Leader需要周期地告知其他Follower节点,自己还在正常工作。Raft的实现,就是让Leader不断地向其他节点发起空的AppendEntries请求。

当Follower收到这样的请求时,只要请求的任期没有过期,Follower就会接受这个请求,知道Leader还正常工作,自己也就没有必要揭竿而起,成为Candidate了。这个和很多系统中的心跳机制是一样的。

  • 第二,也是AppendEntries的主要用途——复制日志,和字面意思一样,就是由Leader发起,要求Follower在日志中追加记录的意思。

当Leader接收了客户端的请求,它就会并行地请求其他节点,带上客户端请求的指令,要求其他节点进行复制并返回结果。当Leader觉得日志被安全地复制了之后,才会将指令应用到状态机中并返回客户端

什么是安全的复制呢?就是指一旦决定这个日志可以被应用到状态机(我们也叫“已提交的日志CommittedEntries”),即使之后任何节点出现不可用的情况,已提交的日志一定不会丢失,且最终会被集群中所有正常工作的节点应用到状态机。

图片

而Raft对“已提交”的条件定义也很简单有效,如果一个日志被Leader复制到大多数节点,日志就算被提交了,反之则没有,还有可能被其他更新的任期的日志所覆盖。这一点约束我们稍后马上会展开讨论。

另外,Raft在记录日志的时候,除了会记录日志任期、具体操作,也会给每条记录都赋予一个日志索引,这样可以帮助节点定位自己所持有的日志具体是哪些。

Log Matching

有了index和term的概念,Raft通过引入一些约束,使得所有的日志始终拥有着“日志匹配”的特性,主要是两条规则:

  1. 不同日志中的两个记录,如果拥有相同的任期和索引,它们的内容相同。
  2. 不同日志中的两个记录,如果拥有相同的任期和索引,它们之前的内容也相同。

前面我们已经说了,Raft同一个任期里肯定只有一个Leader,如果这个Leader写了某条日志,它在不同节点上日志的索引一定是相同的。

这是因为AppendEntries被接收时,会执行一致性检查,Leader提交请求的时候会带上自己的prevLogIndex和prevLogTerm,表示上一个日志条目的索引和任期。如果Follower发现自己的日志里找不到这个任期和索引对应的条目,会拒绝此次AppendEntries请求,这个就是Raft协议很关键的一个约束;所以当AppendEntrires成功时,Leader能保证prevLogIndex之前所有的记录都是相同的。

这个逻辑你仔细顺一下就很清晰了,具体的证明有点像数学归纳法,初始状态是满足日志匹配的,如果执行了一致性检查,那么后续的所有状态,日志匹配特性也都是满足的。

如果Leader和Follower由于崩溃,出现日志记录不同的时候,Leader就会要求Follower,按照自己的日志覆写。Leader为每一个Follower都记录了一个nextIndex的字段,表示下次应该发给Follower的日志,在Leader刚刚晋升的时候,Leader就会将这个值初始化为自己的最后一条日志的索引+1。

如果Follower日志和Leader日志有所冲突,Leader会尝试减小nextIndex的值,直至两者nextIndex所对应的日志相同;此时,LeaderAppend的记录就包含了从nextIndex开始的全部日志,Follower收到之后就会把不同的部分覆写;如果成功,Leader也会修正nextIndex的值。

基于这样的约束,整个覆写的过程一定是单向的,只会发生在Follower节点上,Leader从来不会修改自己的日志。

安全性

但到目前为止,Raft协议还有一个重要的特性没有得到保证,就是“领导人完整性”,也就是需要保证:如果某个日志条目在某个任期号中已经被提交,该记录必定出现在更大任期号的所有领导人中。

这是一个非常重要的特性,不然会无法保证某个领导人在任期内提交的日志不会被后来者所覆盖。Raft对这个问题作出了另一个简单的限制,相比于一些其他的一致性算法,显得更加清晰。限制Candidate提交选举请求的时候,必须至少和Follower的日志一样新,才可以获得选票

这就意味着,如果Candidate获得了超过半数的选票,说明至少有半数的Follower节点,日志条目和自己一样新,而所有commit了的记录,也一定在半数的节点中出现了。

根据容斥原理,半数同意的节点一定会和commit了某个日志的节点有所重叠,新的Leader至少拥有了所有被commit日志一样新的日志。这样,被commit的日志,一定会被更大任期的领导所包含。

但是这里还有一个比较重要的约束,Raft要求每个节点进行提交的时候只能提交自己任期的日志而不能提交之前任期的日志,也就是说需要通过提交自己任期日志的方式顺带提交之前任期的日志

为什么呢?这里就需要考虑一种比较极端的情况,在论文中的figure8讨论的就是这个问题,我把图片贴在了文稿中。

假设一开始,S1成为了任期为2的领导者,并开始发送任期2所对应的日志,随后在b时刻,S1挂了,此时,S5拿到了S3、S4的选票成为了领导者,任期为3,进入c时刻。

如果S5又宕机且S1又再次获得了选票,成为了任期4的领导者。这个时候,S1可以复制之前任期为2的日志至S3,任期2的日志其实已经被大部分节点所持有了,但是我们可以提交吗?

  • 如果允许提交。假设S1在d时刻中又崩溃了,S5再次获得更高任期的选票并当选,S5就可以像d那样覆写所有之前任期的日志,就会出现已经提交的日志被覆写的情况。所以我们不能允许提交。

  • 而另一种情况,如果在崩溃之前,S1就对多数节点当前任期的日志进行了复制并提交,e时刻S5就不再有被选举上的可能性,因为多数节点都拥有更新的日志。这个时候任期2的日志自然也被一起提交了。

总的来说,只要我们只允许领导提交任期内的日志,且必须确保被大部分节点所复制,Raft的数据安全性就是有保证的,被提交的日志一定是不会被覆写的。

原论文中还提到了一些简单的优化,比如日志压缩、采用Chubby和Zookeeper用的快照技术等,减少因为日志增长越来越多空间被占用和对应的同步成本问题等等,如果你感兴趣可以看看原论文

总结

Raft协议,除了各种协议细节,今天学习的几个比较有价值的技巧对你工作也很有帮助。

我们为了避免票选被多个同时称为cadidate的节点平分,进入无限循环,可以在选举超时时间里引入随机性,避免多个节点继续在同一时间发起票选请求。分布式系统多个节点存在时,往往会采用奇数的节点,这样就可以通过少数服从多数的机制,在集群中保证同一时间只会有一个主节点了。

Raft将复杂问题拆解成多个明确清晰的子问题,分而治之,也是一种系统设计的哲学,你可以好好体会。

Raft算法逻辑还是比较清晰的,但是有很多细节和边界问题需要我们反复琢磨,不自己动手实践一遍,理解程度一定还是比较有限,所以这里也推荐MIT 6.824,供你练手学习,Lab就是基于Raft和Golang语言实现一个简单的分布式KV存储(18年我做过一次,当时有几个case没有跑过,就弃坑了,但整体还是收获很大,而且过程颇为有趣,祝你也能研究顺利)。

课后作业

最后也留一个思考题给你。Raft在现实的工程实践中还有许许多多的优化,不知道你听完了今天的讲解之后有什么觉得可以优化的想法吗?

这里给你抛砖引玉一下,Leader给Follower同步日志的时候,nextIndex是一步步向下尝试的,如果中间缺失了很多日志,效率其实可能比较低?你有什么办法吗?

如果你还有想法也欢迎在留言区和我讨论。如果觉得有帮助的话,也请转发给你的朋友一起学习。我们下节课见。

24|UUID:如何高效生成全局的唯一ID?

作者: 黄清昊

你好,我是微扰君。

今天我们来聊一聊在生产环境中非常常用的一个算法——全局唯一ID生成算法,也就是我们通常说的UUID。

就和我们在社会中都有自己的身份证号作为自己的唯一标示一样,在互联网的应用中,很多时候,我们需要能生成一个全局唯一的ID,去区别不同业务场景下的不同数据,比如消息ID、用户ID、微博内容ID等等。

因为我们往往需要通过这个ID去索引某个业务数据,所以一定要保证生成的ID在全局范围内是唯一的,这也是identifier的本意,在部分情况下,冲突概率很小可能也是可以接受的。另外,这个ID通常需要按照某种规则有序排列,最常用的就是基于时间进行排序。

所以全局唯一ID的两个核心需求就是:

  1. 全局唯一性
  2. 粗略有序性

那业界是如何生成满足这两大需求的ID,又有哪些方案呢?我们开始今天的学习。

单体环境

在单体的应用中,保证ID的全局唯一,其实不是一个很大的问题,我们只需要提供一个在内存中的计数器,就可以完成对ID的颁发。

当然这样的ID可能会带有明确的含义,并被暴露出去了,比如在票务系统中,如果这样设计,我们能根据电子票ID判断出自己买的是第几张票。这对安全性要求更高的业务来说,是不可接受的,但通过一些简单的加密算法混淆,我们就能解决这个问题。

总的来说,单节点的应用,因为所有产生新业务数据,而需要产生新ID的地方,都是同一个地方,复杂性是很可控的。

分布式环境

但在现在的分布式环境下,每一个简单的问题都变得更复杂了一些,我们来举一个具体的例子。

假设,现在有一个票务系统,每次出票请求的产生,都会产生一个对应的电子票ID,毫无疑问这个ID需要是全局唯一的,否则会出现多个同学的票无法区分的情况。那假设我们的出票服务TPS比较高,为了同时让多台服务器都可以颁发不重复的ID,我们自然需要一种机制进行多台服务器之间的协调或者分配

这个问题其实历史已久,解决方法也已经有很多了,我们一起来看看主流的解决方案是如何考虑的。

引入单点ID生成器

先来看一个最简单的,也非常容易DIY的思路——单点ID生成器。

通过这个方案,我们可以快速了解这个问题的解决思路,在接下来学习的过程中,你也可以边看边思考,对于这个全局唯一ID的生成,你有什么更好的改进主意。

我们在前面说了,单机里生成ID不是一个问题,在多节点中,我们仍然可以尝试自己手动打造一个单点的ID生成器,通常可以是一个独立部署的服务,这样的服务,我们一般也称为 ID generate service。

也就是说,所有其他需要生成ID的服务,在需要生成ID的时候都不自己生成,而是全部访问这个单点的服务。因为单点的服务只有一台机器,我们很容易通过本地时钟和计数器来保证ID的唯一性和有序性。

当然这里要重点注意的是,我们必须要能应对时钟回拨,或者服务器异常重启之后计数器不会重复的问题。

如何解决

首先看第一个问题:时钟为什么会回拨呢?

如果你了解计算机如何计时的话就知道,计算机底层的计时主要依靠石英钟,它本身是有一定误差,所以计算机会定期地通过NTP服务,来同步更加接近真实时间的时间(仍然有一定的误差),这个时候就可能会产生时钟的一些跳跃。这里我们就不展开讲了,感兴趣的话。你可以自己去搜索一下NTP协议了解。

真正影响更大的问题其实是第二个,如果计数器只是在内存中保存,一旦发生机器故障或者断电等情况,我们就无法知道之前的ID生成到什么位置了,怎么办?

我们需要想办法有一定的持久化机制,也需要有一定的容灾备份的机制,要考虑的问题还是不少的。比如,对于单点服务挂了的情况下,首先想到可以用之前讲Raft和MapReduce的时候也提到过类似的提供一个备用服务的方案,来提高整个服务的高可用性,但这样,备用服务和主服务之间又如何同步状态,又成了新的问题,所以我们往往需要引入数据库等外部组件来解决。

哪怕解决了这两大问题,就这个设计本身来说,单点服务的一大限制是性能不佳,如果每个请求都需要将状态持久化一下,并发量很容易遇到瓶颈

所以这种方案在实际生产中并不常用,具体实现就留给你做课后的思考题,你可以想想,不借助任何外部组件,自己如何独立实现一个单点的ID生成器服务。

基于数据库实现ID生成器

好我们继续想,既然直接自己写还是有许多问题需要考虑,那能不能利用现有的组件来实现呢?

我首先想到的方案就是数据库,还记得数据库中的主键吗,我们往往可以把主键设置成auto_increment,这样在往数据库里插入一个元素的时候,就不需要我们提供ID,而是数据库自动给我们生成一个呢?而且有了auto_increment,我们也自然能保证字段的有序性。

其实这正是天然的全局ID生成器。利用了外部组件自身的能力,我们基于数据库自增ID直接实现的ID generate非常简单,既可以保证唯一性,也可以保证有序性,ID的步长也是可调的;而且数据库本身有非常好的可用性,能解决了我们对服务可靠性的顾虑。

但是同样有一个很大的限制,单点数据库的写入性能可能不是特别好,作为ID生成器,可能成为整个系统的性能瓶颈。

如何优化呢?我们一起来想一想。

水平扩展

既然单点写性能不高,我们如果扩展多个库,平均分摊流量是不是就可以了呢?这也是非常常用的提高系统吞吐量的办法。接下来的问题就是,多个库之间如何分配ID呢?

图片

为了让每个库都能有独立的ID范围不至于产生冲突,我们可以为它们设置比数据库数量更高的值,作为auto_increment的步长,而且每个库采用不同的初始值,这样自然就可以保证每个库所能分配的ID是错开的。比如两个数据库,一个持有所有偶数ID,一个持有所有奇数ID。其他业务系统只需要轮询两个数据库,就可以得到粗略有序的全局唯一ID了。

为什么只是粗略有序,因为我们没办法保证所有依赖于此的服务,能按照时序轮流访问多个服务,但随着时间推移,只要负载均衡算法比较合理,整体ID还是在递增的。

但这样的系统也往往有一个问题:一旦数据库的数量定好了,就不太好再随意增加,必须重新划分每个数据库的初始值和步长。不过通常来说,这个问题也比较好处理,可以一开始就根据业务规模,设置足够多个数据库作为ID生成器,来避免扩展的需要。

但是如果直接用数据库来产生序号,会面临数据库写入瓶颈的问题。不过估计你也想到了刚才单点服务的思路,如果我们把生成ID的响应服务和存储服务拆开,还是用单点对外提供ID发生服务,但是将ID状态记录在数据库中呢?两者结合应该能获得更好的效果。

利用单点服务

具体做法就是在需要产生新的全局ID的时候,每次单点服务都向数据库批量申请n个ID,在本地用内存维护这个号段,并把数据库中的ID修改为当前值+n,直到这n个ID被耗尽;下次需要产生新的全局ID的时候,再次到数据库申请一段新的号段。

如果ID被耗尽之前,单点服务就挂了,也没关系,我们重启的时候直接向数据库申请下一次批次的ID就行,最多也就导致继续生成的ID和之前的批次不连续,这在大部分场景中都是可以接受的。

这样批量处理的设计,能大大减少数据库写的次数,把压力变成了原来的1/n,性能大大提升,往往可以承载10w级的QPS。这也是非常常见的减少服务压力的策略。

UUID

UUID(universally unique identifier) 这个词我们一开始就提到了,相信你不会很陌生,它本身就可以翻译成全局唯一ID,但同时它也是一种常见的生成全局唯一ID的算法和协议。

和前面我们思考的两种方案不同,这次的ID不再需要通过远程调用一个独立的服务产生,而是直接在业务侧的服务本地产生,所以UUID通常也被实现为一个库,供业务方直接调用。UUID有很多个不同的版本,网络上不同库的实现也可能会略有区别。

UUID一共包含32位16进制数,也就是相当于128位二进制数,显示的时候被分为8-4-4-4-12几个部分,看一个例子:

1
0725f9ac-8cc1-11ec-a8a3-0242ac120002

我们就用JDK中自带的UUID,来讲解一下第三和第四个版本的使用和主要思想,背后的逻辑主要是一些复杂的位运算,解释起来比较麻烦,对我们实际业务开发帮助不大,你感兴趣的话可以自己去看看相关的源代码

第三个版本的方法是基于名字计算的,名字由用户传入,它保证了不同空间不同名字下的UUID都具有唯一性,而相同空间相同名字下的UUID则是相同的:

1
public static UUID nameUUIDFromBytes(byte[] name)

name是用户自行传入的一段二进制,UUID包会对其进行MD5计算以及一些位运算,最终得到一个UUID。

第四个版本更加常用也更加直接一点,就是直接基于随机性进行计算,因为UUID非常长,所以其重复概率可以忽略不计。

1
public static UUID nameUUIDFromBytes(byte[] name)

两个版本的使用都很简单:

1
2
UUID uuid = UUID.randomUUID(); 
UUID uuid_ = UUID.nameUUIDFromBytes(nbyte);

但UUID过于冗长,且主流版本完全无序,对数据库存储非常不利,这点我们之后介绍B+树的时候也会展开讨论。

Snowflake

除了用户自己传入name来计算UUID,UUID其他几个版本里也有用到MAC地址,利用全球唯一性来标识不同的机器以及利用时间来保证有序性。不过Mac地址属于用户隐私,暴露出去不太好,也没有被广泛使用,但是思想还是可以被借鉴的。

Snowflake就是这样一种引入了机器编号和时间信息的分布式ID生成算法,也是由业务方本地执行,由twitter开源,国内的美团和百度也都开源了基于各自业务场景的类似算法,感兴趣的同学可以搜索leaf和UUID-generator,性能都很不错。

整个Snowflake生成的UUID都是64位的长整型,分为四个部分。

  • 第一位是位保留位,置0。
  • 后面连续41位存储时间戳,可到毫秒级精度。
  • 再后面10位代表机器ID,由用户指定,相当于最多可以支持1024台机器。
  • 最后12位表示序列号,是一个自增的序号,在时间相同的情况下,也就是1ms内可以支持4096个不同的序号,也就是在理论上来说Snowflake每秒可以产生400w+个序号,这对于大部分业务场景来说都是绰绰有余的了。

Twitter官方开源的版本是用Scala写的(网上也有人翻译了一个Java版本),因为思路其实很简单,所以代码也非常简洁,我这里写了点简单的注释,供你参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
package com.callicoder.snowflake;

import java.net.NetworkInterface;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Enumeration;

/**
&nbsp;* Distributed Sequence Generator.
&nbsp;* Inspired by Twitter snowflake: https://github.com/twitter/snowflake/tree/snowflake-2010
&nbsp;*
&nbsp;* This class should be used as a Singleton.
&nbsp;* Make sure that you create and reuse a Single instance of Snowflake per node in your distributed system cluster.
&nbsp;*/
public class Snowflake {
&nbsp; &nbsp; private static final int UNUSED_BITS = 1; // Sign bit, Unused (always set to 0)
&nbsp; &nbsp; private static final int EPOCH_BITS = 41;
&nbsp; &nbsp; private static final int NODE_ID_BITS = 10;
&nbsp; &nbsp; private static final int SEQUENCE_BITS = 12;

&nbsp; &nbsp; private static final long maxNodeId = (1L << NODE_ID_BITS) - 1;
&nbsp; &nbsp; private static final long maxSequence = (1L << SEQUENCE_BITS) - 1;

&nbsp; &nbsp; // Custom Epoch (January 1, 2015 Midnight UTC = 2015-01-01T00:00:00Z)
&nbsp; &nbsp; private static final long DEFAULT_CUSTOM_EPOCH = 1420070400000L;

&nbsp; &nbsp; private final long nodeId;
&nbsp; &nbsp; private final long customEpoch;

&nbsp; &nbsp; private volatile long lastTimestamp = -1L;
&nbsp; &nbsp; private volatile long sequence = 0L;

&nbsp; &nbsp; // Create Snowflake with a nodeId and custom epoch
// 初始化需要传入节点ID和年代
&nbsp; &nbsp; public Snowflake(long nodeId, long customEpoch) {
&nbsp; &nbsp; &nbsp; &nbsp; if(nodeId < 0 || nodeId > maxNodeId) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; throw new IllegalArgumentException(String.format("NodeId must be between %d and %d", 0, maxNodeId));
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; this.nodeId = nodeId;
&nbsp; &nbsp; &nbsp; &nbsp; this.customEpoch = customEpoch;
&nbsp; &nbsp; }

&nbsp; &nbsp; // Create Snowflake with a nodeId
&nbsp; &nbsp; public Snowflake(long nodeId) {
&nbsp; &nbsp; &nbsp; &nbsp; this(nodeId, DEFAULT_CUSTOM_EPOCH);
&nbsp; &nbsp; }

&nbsp; &nbsp; // Let Snowflake generate a nodeId
&nbsp; &nbsp; public Snowflake() {
&nbsp; &nbsp; &nbsp; &nbsp; this.nodeId = createNodeId();
&nbsp; &nbsp; &nbsp; &nbsp; this.customEpoch = DEFAULT_CUSTOM_EPOCH;
&nbsp; &nbsp; }

// 这个函数用于获取下一个ID
&nbsp; &nbsp; public synchronized long nextId() {
&nbsp; &nbsp; &nbsp; &nbsp; long currentTimestamp = timestamp();

&nbsp; &nbsp; &nbsp; &nbsp; if(currentTimestamp < lastTimestamp) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; throw new IllegalStateException("Invalid System Clock!");
&nbsp; &nbsp; &nbsp; &nbsp; }

// 同一个时间戳,我们需要递增序号
&nbsp; &nbsp; &nbsp; &nbsp; if (currentTimestamp == lastTimestamp) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sequence = (sequence + 1) & maxSequence;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if(sequence == 0) {
// 如果序号耗尽,则需要等待到下一秒继续执行
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // Sequence Exhausted, wait till next millisecond.
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; currentTimestamp = waitNextMillis(currentTimestamp);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; } else {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // reset sequence to start with zero for the next millisecond
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sequence = 0;
&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; lastTimestamp = currentTimestamp;

&nbsp; &nbsp; &nbsp; &nbsp; long id = currentTimestamp << (NODE_ID_BITS + SEQUENCE_BITS)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | (nodeId << SEQUENCE_BITS)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; | sequence;

&nbsp; &nbsp; &nbsp; &nbsp; return id;
&nbsp; &nbsp; }



&nbsp; &nbsp; // Get current timestamp in milliseconds, adjust for the custom epoch.
&nbsp; &nbsp; private long timestamp() {
&nbsp; &nbsp; &nbsp; &nbsp; return Instant.now().toEpochMilli() - customEpoch;
&nbsp; &nbsp; }

// 由于这样被耗尽的情况不多,且需要等待的时间也只有1ms;所以我们选择死循环进行阻塞
&nbsp; &nbsp; // Block and wait till next millisecond
&nbsp; &nbsp; private long waitNextMillis(long currentTimestamp) {
&nbsp; &nbsp; &nbsp; &nbsp; while (currentTimestamp == lastTimestamp) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; currentTimestamp = timestamp();
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; return currentTimestamp;
&nbsp; &nbsp; }

// 默认基于mac地址生成节点ID
&nbsp; &nbsp; private long createNodeId() {
&nbsp; &nbsp; &nbsp; &nbsp; long nodeId;
&nbsp; &nbsp; &nbsp; &nbsp; try {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; StringBuilder sb = new StringBuilder();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; while (networkInterfaces.hasMoreElements()) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; NetworkInterface networkInterface = networkInterfaces.nextElement();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; byte[] mac = networkInterface.getHardwareAddress();
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if (mac != null) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for(byte macPort: mac) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sb.append(String.format("%02X", macPort));
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; nodeId = sb.toString().hashCode();
&nbsp; &nbsp; &nbsp; &nbsp; } catch (Exception ex) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; nodeId = (new SecureRandom().nextInt());
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; nodeId = nodeId & maxNodeId;
&nbsp; &nbsp; &nbsp; &nbsp; return nodeId;
&nbsp; &nbsp; }

&nbsp; &nbsp; public long[] parse(long id) {
&nbsp; &nbsp; &nbsp; &nbsp; long maskNodeId = ((1L << NODE_ID_BITS) - 1) << SEQUENCE_BITS;
&nbsp; &nbsp; &nbsp; &nbsp; long maskSequence = (1L << SEQUENCE_BITS) - 1;

&nbsp; &nbsp; &nbsp; &nbsp; long timestamp = (id >> (NODE_ID_BITS + SEQUENCE_BITS)) + customEpoch;
&nbsp; &nbsp; &nbsp; &nbsp; long nodeId = (id & maskNodeId) >> SEQUENCE_BITS;
&nbsp; &nbsp; &nbsp; &nbsp; long sequence = id & maskSequence;

&nbsp; &nbsp; &nbsp; &nbsp; return new long[]{timestamp, nodeId, sequence};
&nbsp; &nbsp; }

&nbsp; &nbsp; @Override
&nbsp; &nbsp; public String toString() {
&nbsp; &nbsp; &nbsp; &nbsp; return "Snowflake Settings [EPOCH_BITS=" + EPOCH_BITS + ", NODE_ID_BITS=" + NODE_ID_BITS
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; + ", SEQUENCE_BITS=" + SEQUENCE_BITS + ", CUSTOM_EPOCH=" + customEpoch
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; + ", NodeId=" + nodeId + "]";
&nbsp; &nbsp; }
}

主要思路就是先根据name,初始化Snowflake generator的实例,开发者需要保证name的唯一性;然后在需要生成新的ID的时候,用当前时间戳加上当前时间戳内(也就是某一毫秒内)的计数器,拼接得到UUID,如果某一毫秒内的计数器被耗尽达到上限,会死循环直至这1ms过去。代码很简单,你看懂了吗。

再次说明一下,你千万不用太担心源码艰深复杂而不敢看,其实很多项目的源码还是很简单的,推荐你从这种小而美的代码开始看起,其实你看完之后,往往也会信心倍增,觉得自己也能写出来。等你之后看得多了,自然也能更好地掌握背后的编程技巧啦。

总结

主流的几种生成分布式唯一ID的方案我们今天就都学习完了,思路基本上都比较直接,大体分为两种思路:需要引入额外的系统生成ID、在业务侧本地通过规则约束独立生成ID。

单点生成器和基于数据库的实现都是第一种,UUID和Snowflake则都是在本地根据规则约束独立生成ID,一般来说也应用更加广泛。

你可以好好回顾感受学到的几个问题解决思想,备份节点来提高可用性、批量读写来提高系统性能、本地计算来避免性能瓶颈。之后,你自己引入外部数据库或者其他系统的时候,也要多多考虑是否会在引入的系统上发生问题和性能瓶颈。

课后作业

今天思考题就是前面说的,如果让你自己不借助外部组件实现一个单点的ID发生器,你会怎么做呢?

欢迎你在评论区留言与我一起讨论,如果觉得本文对你有帮助的话,也欢迎转发给你的朋友一起学习,我们下节课见~

25|一致性哈希:如何在集群上合理分配流量?

作者: 黄清昊

你好,我是微扰君。

上一讲我们学习了在分布式系统中,生成全局唯一ID的两种方式,既可以通过引入独立组件远程调用申请ID,也可以通过约定的方式让各个节点独立生成唯一ID。

那对于有多个节点的服务,其他服务或者客户端在访问这个服务的时候,具体应该访问哪一个节点呢?

负载均衡问题

大部分情况下,我们都希望集群在分配流量时,能够比较均衡或者按照某种预期的权重比例,这样每个机器都可以得到比较充分的使用,也不容易出现单点服务过载的情况,能够发挥集群的最大价值。

如何分配流量的问题,也通常被称为负载均衡问题,根据不同的业务需要,解决的方式也很多。

比如最直接的,我们可以引入一个中间的负载均衡层,集中记录收到的请求序号;然后按照Round-Robin的轮询方式,轮流将外界的请求转发给内部的服务集群,或者直接用随机转发的方式也可以。当然你也可以引入权重,让这两种算法对流量的分配不是均匀的,而是按照一定比重分配在不同的机器上。这两种算法也被称为加权轮询和加权随机

其实,不止可以通过引入中间层实现,如果整个系统完全可信、可控,你也可以让客户端自己按照随机或轮询的策略,直接调用需要负载均衡的服务,同样可以达到负载均衡的效果。

除了加权轮询、加权随机,负载均衡算法还有许多。这里我们可以看下 Dubbo 官方中文文档中的列出的算法,Dubbo作为一款知名的RPC服务框架,是典型的分布式应用,自然需要支持集群负载均衡,以保证请求可以正确地发送到Dubbo实例上。

一共支持了5种负载均衡算法,提供的都是客户端负载均衡。这里就不一一讲解了,第三、第四种主要是通过在客户端记录服务集群中不同实例的请求响应情况,以此为依据来判断哪台服务器更适合访问。

图片

这些策略比较简单,但都有比较大的共性问题,无法应对带有状态的请求或服务。这时候就需要我们的一致性哈希算法登场了。

有状态的请求

先来了解一下,什么样的请求或者服务是带有状态的呢?

比如一个分布式KV缓存系统,为了提高整个系统的容量,我们往往会把数据水平切分到不同的节点来存储,当然为了提供更好的系统可用性,在部分不同节点上存储时,我们会让数据产生一定的冗余。对于这样的系统,某个key应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。这样的服务,我们就可以认为是有状态的

再比如,假设某个请求,需要在某个节点上进行一系列连续操作才能完成,也就是构成了一个流程,或者想进行某个操作,会受到在被请求的节点之前请求的影响,在这种的情况下,请求也是有状态的。

在本地,服务器一定会存储和这次请求相关的上下文,这样下次同一个客户端或者会话内发生的请求,就仍然需要打到这台特定的服务器上,才能保证整个服务正常的工作。

这两个例子可能还是有点抽象不太好理解,我们看一个工作中实际的例子。

之前我维护过一个长连接网关,一般主要就是用来做消息推送。某个设备连接到我们的服务器上时,服务器就会去存储里,拉取该设备需要收到的消息进行推送。一个类似场景就是QQ登陆时会去服务端拉取消息。所以拉取消息的请求就是一个有状态的请求。

由于需要推送的消息比较多,服务器会以流的形式推送,也会需要随时保留服务器推送消息的位置。一个比较合理的设计就是,

  • 当连接失败,客户端准备重连的时候,一定需要连接到之前连过的服务器,因为只有这台服务器才保留了之前推送消息的位置,可以从之前断连的位置继续推送消息;
  • 如果连接到其他没有保留这样上下文信息的服务器中,唯一能做的就是直接再去存储里拉一下要推送的全部消息,但是这样肯定就包含了之前已推送到一半的消息了。

这个时候我们可以想一想,如果负载均衡采用的是随机或者轮询策略,客户端下次请求的时候,大概率就不会再打到上一次请求的节点了,所以,面对许多有状态的服务和请求,这是有很大问题的。那我们如何解决这种情况下的负载均衡呢?

方案一

可能你首先想到的方案是,我们直接在负载均衡服务器上记录一下,每个会话或者客户端上次请求到的服务器是哪一台不就好了,这样如果我们发现这个客户端之前已经有访问记录,那下次还继续打到上一次的机器,不是就可以了?

这个思路当然是理论可行的,但这会对负载均衡系统本身带来巨大的开销。

还记得我们为什么要引入复杂的分布式系统吗?就是因为请求和访问数量太高了,而在负载均衡系统里,如果记录每个请求或者参数对应应该访问哪个机器,这就在负载均衡层引入了状态,本身就是另一个需要负载均衡的应用了。所以即使得以实现,付出的性能开销和代价也是不可接受的。

那怎么做呢?重新思考一下本质想要的目标,我们无非就是希望某些访问的参数或者客户端,在请求的时候,都能指向指定的机器,并且也能起到均衡的效果,那说到这里,不知道你有没有想到我们之前讲过的哈希表也就是散列表呢?我们来看看是否可行。

方案二哈希算法

假设一个集群有20个可以对外服务的节点,有很多的客户端同时在请求这些服务,我们希望每次从同一个客户端访问的请求,下次再请求集群的时候,也能打到和这次一样的节点上。这不就类似散列表的需求嘛:对任意key映射到一段连续数组空间,且同一个key每次映射都会映射到数组的同一个位置

我们就还是用长连网关举例子。

在业务场景中,每个不同的客户端都会有不同的client-ID作为唯一客户端标识,有没有想到上一节课学的UUID,其实差不多就是这样的东西。在有很多同时请求的客户端时,我们可以认为,正在请求的所有客户端ID,在整个UUID的空间里是均匀分布的。

把集群里的20个节点连续标号为0~19,想要让每个节点接受差不多的流量,并保证每个相同的客户端在不同的时候都会请求到同一个节点,我们就只需要把clientID哈希到空间为20以内的数字,根据这个数字请求对应标号的节点就可以了。最简单的做法就是进行取MOD运算。

图片

这样的话,无论采用客户端的负载均衡算法,还是添加一层负载均衡层,我们都只需要告知客户端或者负载均衡服务,现在可用的服务器是哪些,再根据计算而非存储的方式分配流量,既避免了状态的产生,又能完美地解决负载均衡问题。

但是,分布式系统当然没有这么简单了。一旦引入了分布式,我们首先没有办法保证所有节点都能一直正常工作,其次也要考虑可能会经常扩容的情况。还记得哈希表怎么处理扩容的吗,需要申请两倍的空间,然后把原始数据全部重新哈希再次分配。但是如果在分布式的环境中用这个方案,会带来很大的麻烦。

节点数量变化问题

我们来看一看,对于负载均衡背后的系统来说,节点数量变化会导致什么样的问题呢?

用一个简化的分布式缓存系统来举例,一共有3个节点,每个节点存储一系列 (key, value) 对,假设我们一开始存储了6个KV pair,由于key分布均匀,取MOD的哈希算法也均匀,它们被均匀地分配在了三个节点上。

图片

此时,如果2节点突然异常需要下线,整个系统只剩1、3两个节点,我们就需要和JDK的HashMap一样,做重哈希的工作,这次就需要对所有的key进行MOD2而不是MOD3的操作了。

你会发现,除了需要把2节点的数据搬移到1、3节点上,为了满足MOD2的条件,还需要移动1和3中本来正常存储可以对外提供服务的两个KV对,也就是(4,emqx)和(3,peach)。

更重要的是,分布式应用,数据存储量比单机更大,节点之间的数据拷贝复制需要经过不可靠的网络,不止时延会高,也可能会需要更多次的重传,因此这样大量不必要数据的搬迁,我们是一定要想办法避免的。

而且工业的分布式缓存系统,其实一般不会真的进行数据的搬移,因为需要一直对外提供服务,这个时候一旦大量的请求和存储数据节点失配,会导致同一时间大部分缓存值失效,转而请求源数据,这就会导致被缓存的服务比如数据库,请求激增,出现宕机等情况。这也被称为缓存雪崩。

所以理论上来说,如果某些节点挂了,我们应该尽量保持其他节点上的数据不要移动,这样就不会出现大量缓存数据失效的情况了。有没有办法做到呢?

一致性哈希

一致性哈希就很好地解决了这个问题。

最常见的一致性哈希算法同样会采用哈希的思想,但是会把请求,按照标识,比如请求的某些参数、客户端ID、会话ID等等,映射到一个很大的数字空间里,比如2^32次方,让它们自然溢出,2^32 在这样的空间里就会被表示为0,于是整个空间可以看成一个首尾相接的数字环,我们称为项(item)。

而一个个节点,也会按照标识,比如机器IP或者编号等等,映射到这个环上,我们称为桶(bucket)。整个环看起来就像这样:

图片

这里的A、B、C节点就是三个桶,在负载均衡场景下也就是服务器;而1、2、3、4、5、6则是项,可以是一个个不同标识的请求。

看这个环的图,我们如何决定哪个请求应该被分配到哪个服务器上呢?

现在就很简单了,找到每个请求在环上的位置之后,按照某个方向,比如数字增大的方向,找到和当前请求最近的桶,桶所对应的值就是我们一次性哈希的位置,在负载均衡下也就是对应的服务器了。

有可能你有疑问了,这样的策略可以保证负载真的是均衡的吗? 假设出现这个情况,A、B、C三个桶集中分布在环的一侧,而请求在环上相对均匀分布,因为我们是按照某个方向寻找最近的,就发现绝大部分请求都被分配到了C节点上,而A节点一个请求都没有。

图片

一致性哈希的作者当然想到了这个问题,解决办法也非常巧妙。既然负载均衡的节点不是那么多,容易出现分配不均匀的情况,我们给这些bucket增加一些副本不就好了,数量比较多的话会更均匀。

一种简单好用的策略就是在某个bucket用于哈希的标识之后,拼接上一些字母或者数字,把它们也映射到环上,当作自己的副本,只要item在环上顺次找到了副本中的一个,也都认为指向的是对应的bucket。

图片

这样,桶和副本在环上就不太容易出现集中在一侧的情况了。而且在业务中,请求数量比较大,在用于Hash的key或者ID生成合理的前提下,分布应该天然就是比较均匀的。

实现

现在有了思路,动手实现是非常简单的。我之前换工作准备从前端转基础架构的时候,写了一个玩具的分布式缓存,就用到了一致性哈希。这里我简单说明一下相关的代码逻辑,里面耦合了部分和存储相关的逻辑。如果感兴趣,你也可以直接到我的GitHub上了解这个项目,能很好地帮助你练习LRU。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package consistent

import (
"hash/crc32"
"sort"
)

// 哈希环 用于存放节点和副本以及需要存储的key
type HashRing struct {
nodes map[uint32]string
replicates int
keys []uint32
}

// 初始化哈希环 需要传入创建的副本数量
func New(replicates int) *HashRing {
hashRing := &HashRing{
replicates: replicates,
nodes: make(map[uint32]string),
}

return hashRing
}

// 在哈希环上添加节点 需要传入节点名称
// 根据副本数,在节点名称后添加数字后缀后进行哈希计算,并放置节点
func (hashRing *HashRing) Add(key string) {
for i := 0; i < hashRing.replicates; i++ {
hash := crc32.ChecksumIEEE([]byte(key + "-" + string(i)))
hashRing.keys = append(hashRing.keys, hash)
hashRing.nodes[hash] = key
}
// 为了方便查找节点;我们需要将环上节点进行排序
sort.Slice(hashRing.keys, func(i, j int) bool { return hashRing.keys[i] < hashRing.keys[j] })
}

// 基于key在环上二分查找最近的节点
func (hashRing *HashRing) Get(key string) string {
hash := crc32.ChecksumIEEE([]byte(key))
idx := sort.Search(len(hashRing.keys), func(i int) bool { return hashRing.keys[i] >= hash })
if idx == len(hashRing.keys) {
idx = 0
}

return hashRing.nodes[hashRing.keys[idx]]
}

可以看到,利用Golang内置的数据结构和方法,代码不超过50行,就非常好地解决了这个问题。而且在工作中我也实际用到过这个算法,很值得你手写练习一下。

总结

我们今天学习了负载均衡的问题和常用的策略。

对于有多个节点的服务,其他服务或者客户端在访问这个服务的时候,我们希望能够比较均衡地分配流量,发挥集群的最大价值,也不容易出现单点服务过载。最直接的思路就是轮询和随机分配。

但在请求和服务有状态的时候,简单基于轮询和随机的策略就失效了,这个时候我们就需要想办法把有状态的请求稳定的指向同一台机器,保证上下文的连续性,当然,同时也需要能起到均衡的效果。

这个时候,我们可以采用一致性哈希算法,利用请求标识,比如请求的参数或者客户端ID等等,把请求稳定的分配到同一台节点,保持上下文的连续性;而相比于直接进行哈希的方式,把请求和节点都映射到同一个哈希环,并顺次寻找最近的节点,可以让我们尽可能少的减少不必要的重哈希,只是把失效节点所负责的请求,较为平均地分配到其他节点之上。

课后作业

实现一致性哈希的代码并不困难,你可以自己动手实践一下,有什么问题,欢迎你在评论区留言和我一起讨论。

如果你觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见~

26|B+ Tree:PostgreSQL 的索引是如何建立的?

作者: 黄清昊

你好,我是微扰君。

过去几讲我们学习了一些经典的分布式算法,主要涉及多个节点之间的协作方式,在现在的业务场景下,它们更多被封装在各种中间件或者类库中,直接供我们使用,不过背后的很多思想还是很值得好好学习体悟的。

从今天开始,我们将更加贴近日常业务开发,剖析常用中间件里用到的、单机上的一些算法,帮助自己更好地分析和优化系统性能。比如在使用数据库的时候,如果我们不清楚底层索引的原理,写出来的查询语句可能性能会很差,甚至不见得能正确建立索引;再比如有时候我们希望不引入额外的网关组件,直接在业务代码里实现一个简单的限流模块,如何设计更合适……

类似场景还有很多,话不多说,今天我们一起来了解这些中间件的秘密。

数据库

我们先从数据库聊起。数据库,应该是我们广大程序员日常开发中必不可少的组件了,作为数据持久化的基石,在互联网应用中,大部分的业务数据都以结构化的方式存储在数据库中,而数据库为我们提供了良好的数据查询、管理、持久化、事务的能力。

在数据库场景下,我们存储的都是大量的数据,显然没有办法一次性把所有的数据全部加载到内存中,用内存高效的数据结构或者算法进行搜索。那数据库是如何快速查询数据的呢?比如:

1
select * from student where id = 5130309492

如果我们逐一遍历数据库的每个记录逐个对比,也就是常说的“全表扫描”,查找速度肯定很慢。如何提高查找指定ID记录的速度呢?

通常为了提高查找速度,我们都会设计一种特殊的数据结构,在内存中如此,在磁盘中也不例外。我们需要设计一种适合磁盘场景的数据结构,对业务数据进行某种有序性的维护,在磁盘读写次数不多的情况下,结合内存,就能快速找到需要查询的记录在磁盘中所在的位置。这就是我们常说的“索引”

那索引一般是用什么样的数据结构实现的呢?

其实在之前学习Kafka的时候,我们学过线性稀疏的索引,适用于 append only 的存储模式,利用有序性带来的二分搜索,帮助我们加速查找指定offset的日志内容,你可以再回顾一下理解索引背后用空间换时间的思想。

图片

但是kafka的索引,在数据库的场景下并不好用,因为数据库中,我们随时可能需要删除或者修改某个字段的值,如果要保持索引的线性有序性的要求,就要不断调整索引文件的前后顺序,在磁盘上,这个代价是非常高的

所以,为了能适配需要随机修改插入的数据库场景,我们的索引结构不能是线性的了。

树状索引

如果你熟悉第一章的内容,估计很快就会想到红黑树这样的结构

在内存中,红黑树任意字段的查询可以做到logN的复杂度,而且相比于二分搜索所需具备的有序性,在红黑树上做元素的调整和增删效率要高得多。我们之前也提到了,对于百万量级的存储场景,红黑树也只需要20层这个数量级的高度就可以容纳全部元素。查询效率当然是很有保证的。

图片

那可不可以在数据库的索引中采用红黑树呢?

因为在数据库中,不同于内存的场景,磁盘读写比内存慢的多,所以相比于查询的计算成本,IO成本可能要显著的多。

数据库里存储的数据比较多,如果我们采用二叉树来存储的话,层数必然不会很少,且层和层之间的数据在物理上基本上是不连续的,即使前几层的元素可以被预加载至内存中,我们仍然可能需要在树上进行10余次的跳转查询,也就对应着10余次的磁盘IO,这是不可接受的。

有没有什么办法利用磁盘读写的特性,既可以保持树状结构的灵活性,又同时降低查询的IO次数呢?这就是B+树的用武之地了,核心就是通过引入更多的分叉,在节点同样数量级的范围内,显著地降低树状索引的层数

B-树B+树

B+树是传统关系型数据库索引的标配,在MySQL、PostgreSQL等主流DBMS中,B+树都是索引的底层实现。

B+树是由B-树演化而来,这里的B一般被解读为balance,也就是平衡树,和之前介绍的2-3树差不多,B-树、B+树每个节点也包含多个键和多条链。

我们先看B-树,这个数据结构就是为大量数据的存储和快速访问而设计的。B-树的每个节点都包含若干个键和若干个指针域,指针域就用于指向存储的数据本身。m阶B-树的主要约束还包括:

  1. 所有叶子结点处于同一高度;
  2. 除了根结点和叶子结点之外,每个节点最少包含 m/2 个键;
  3. 每个节点最多包含m-1个键和m条链,如果某个节点有k-1个键,则对应k条链;
  4. 每个节点内部的键有序排列,每个链指向的节点中的键,都在链左右节点的确定范围之间;
  5. 根节点在不为叶子节点的时候至少有两个子节点。

下图就是一个典型3阶B-树的例子,可以看出,2-3树是一种特殊的B-树。

图片

在数据库场景下,毫无疑问,我们会把建立索引的字段作为key,每个key后面也会跟上对应记录的指针。

为什么磁盘上的B-树会比对应的二叉平衡树快很多呢?主要就是利用了磁盘访问的局部性原理。

之前讲LRU的时候也提到了相关概念,计算机在读取磁盘的时候,往往是以页为单位读取的,读取某一页中的部分内容和读取该中的全部内容,所花费的代价其实是一样的。如果我们把B-树的每个节点中存储的大小设成一个页的大小,利用磁盘预读的能力,就可以做到仅通过一次IO就将整个节点的全部内容加载到内存中

一个页的大小通常是4K~16K,能包含的键数可以高达几千条。以InnoDB通常采用的16K大小的页为例,如果我们的索引字段和指针域大小为8B,B-树上的每个节点能包含的键数高达2048个,这就意味着用4层的高度,就可以存储接近10亿级别的记录,在索引字段大小更大的时候,我们通常也只需要5层以内,就可以构造大部分表的索引。

这就是多叉的B树的主要优点了,利用磁盘的预读能力和树状结构,我们通过3~5次磁盘IO就可以在10亿级的数据表中进行快速检索了。

检索过程的伪代码如下:

图片

基本上和2-3树或者普通二叉树的检索思路是一致的:从根节点出发进行遍历,如果在某个节点中查到了目标键,直接返回;如果查到的目标值在两个键之间,就进入两键之间的链所指向的节点进行下一层的查找。

因为每个节点中的键是有序存储的,当我们加载到内存中后,通常也会直接采用二分搜索,整个搜索过程仍然是logN这样非常低的复杂度。所以主要耗时还是花费在IO中。

B+树

通常数据库中采用的索引结构都是B+树,B+树和B-树的区别主要在于树节点的组织形式,包括两点:

  1. B+树的所有的叶子节点之间会通过双向指针串联在一起,构成一个双向链表;
  2. B+树的中间节点不会存储数据指针,而只有叶子节点才会存储,中间节点只用于存储到叶子节点的路由信息。

图片

这个图就是一个典型的3阶B+树了。可以看到,红色的非叶子节点和绿色的叶子结点的键会有一定的重合,这就是因为非叶子结点不再存储实际的数据信息,所以叶子结点实际上需要存储整张表的信息,但因为树状结构的特性,两者的层数预期仍然都是logN。

同时,因为非叶子节点中不再需要存储数据本身相关的信息,每个节点能存储的键的数量也会有所增加,所以B-树和B+树的层数期望是差不多的,在大部分业务场景下,3~5次的IO查询就可以让我们查询到任意目标值。

那为什么要引入这样额外的指针和约束呢?主要原因在于在数据库中我们经常需要范围查询,比如这样的查询语句,查询所有年龄小于20的学生:

1
select * from students where age < 20

因为B+树的特性,叶子节点之间也一定是有序排列的,我们只需要找到比20小的第一个元素,借助双向链表,我们从链表头遍历到这个元素,就能快速获得所有比20小的元素。这样高效的范围查询能力是B-树所没有的。

当然了,如果能让叶子结点的指向数据,能在磁盘上连续存储,当然可以获得更好的查询能力,不过这件事情非常困难,似乎没有什么太好的办法。

好有了B+树这样的结构,我们已经可以做到快速查询了,但是数据库中的元素是会被时刻修改的,如何在增删改操作中维持B+树的性质呢?我们一起来看一下。

插入操作

为了方便演示和讨论,我们用比较简单的3阶B+树来讲解,和2-3树的过程非常相似我们重点看如何通过节点的合并和分裂操作,应对树结构的变化。

假设我们的树一开始只有 1、2 两个键,此时键的数量没有超过单节点能容纳的范围,我们用一个叶子结点就可以表示。

接下来要加入一个3节点,和2-3树一样,我们就需要对原来的叶子节点做分裂操作,因为3阶B+树,每个节点最多能承载的元素就是2个。现在多出来一个键,我们就需要把节点中键的中位数取出来,提高到上一层,然后分成左右两个部分,每个部分都包括 m/2 个节点。

图片

这里,对于3阶B+树而言,左子节点就分到一个键,右子节点则分到两个键。叶子节点间,我们同样需要用双链表的方式进行串联。

我们尝试继续添加键,现在添加键4到树里,首先要做的就是进行前面提到的B树的查询,我们会查询到最右侧的叶子结点,从而试图将键4放入(2,3)构成的节点中,但同样由于每个节点最多存放2个键,所以我们需要把3提高到上一层,把原来的节点拆成2和(3,4)两个节点。

图片

后面的添加就是类似的过程,如果继续添加键5,同样会先放入(3,4)节点中,把4提高到根节点,而根节点也已经“满载”了,所以会把中间值3提到更上一层,此时我们就得到了一个三层的树。

图片

整个分裂节点的过程自底向上递归进行,可以注意到所有叶子节点的高度其实是始终不变的。

删除操作

删除操作看起来要稍微复杂一点,不能简单理解成插入过程的逆过程。我们用刚才构造出的树来演示几种不同的删除操作。

首先我们尝试删除键2。因为存储2的节点本身只存储了一个键,如果删去,2节点所存储的键数量不满足约束条件了,这个时候我们有两种选择:

  • 一种是如果本身左侧的兄弟节点存储有多余的键,比如存储了2个键,我们就可以很简单地直接借用一个键;
  • 另一种则需要让父节点下移,并合并子节点。

在这个例子中,左侧存放key1的节点也只有一个键,所以不满足可以借用的情况,我们只能考虑让父节点3下沉,和4节点合并。

图片

这样我们就可以重新得到一颗平衡的B+树了。每次让父节点下沉,也可能重新破坏父节点的约束条件,我们同样要递归地找左侧兄弟节点借用键或者考虑让更上层的父亲节点下沉,直到整颗树满足约束为止。

第二种我们继续在这棵树上删除键5,这个情况比较简单,可以直接删除键5,并不会破坏原来的约束。

图片

在数据库中数据有插入或者删除的时候,我们就可以及时地调整索引内部的结构了。当然可以想见这其中会有很多并发的问题,比较复杂,通常需要加锁解决,也是研究的热点之一,这次就不展开讨论了。

总结

今天我们一起学习了非常经典的索引实现方式,利用空间换时间的思路,通过为数据表建立额外的有序索引结构,做到大大加速查询的效果。

由于数据库需要经常对数据进行增删改,我们的索引数据结构要能高效地变动,而且数据库本身海量的数据也意味着,索引结构不会只存在内存中,需要在二级存储中存储。相比于传统的二叉搜索树,通过B+树,我们可以让整个树状结构变得更加矮胖,而磁盘的预读特性每次都可以加载一整个节点中全部的键,到内存进行二分查找,这样我们只需要通过3~5次的磁盘IO就可以查询加了索引的字段,非常高效。

B+树,相比于B-树的主要特点就是,只在叶子结点存储数据,而且叶子节点间用首尾相连的指针串联成双向链表,可以获得良好的范围查询的效果,背后的本质就是索引和数据的分离,这同样是非常值得好好体会的一种思想。

思考题

我们说平衡树的约束之一是每个节点的键不能少于 m/2 也不能多于 m,这里 m/2 是怎么来的呢? 不知道你有没有深入思考过,如果能回答清楚这个问题,我想你对B+树索引的分裂策略就理解地很深刻了。

欢迎你在评论区留下你的思考,如果觉得有帮助的话,也欢迎你把这篇文章转给你身边的好朋友,一起学习。下节课见。

27|LSM Tree:LevelDB的索引是如何建立的?

作者: 黄清昊

你好,我是微扰君。

上一节我们学习了数据库中非常常用的索引数据结构——B+树,在过去很多年里它都是数据库索引的首选实现方式,但是这种数据结构也并不是很完美。

因为,每次修改数据都很有可能破坏B+树的约束,我们需要对整棵树进行递归的合并、分裂等调整操作,而不同节点在磁盘上的位置很可能并不是连续的,这就导致我们需要不断地做随机写入的操作。

众所周知,随机写入的性能是比较差的。这个问题在写多读少的场景下会更加明显,而且现在很多非关系型数据库就是为了适用写多读少的场景而设计的,比如时序数据库常常面对的IOT也就是物联网场景,数据会大量的产生。所以,如果用B+树作为索引的实现方式,就会产生大量的随机读写,这会成为系统吞吐量的瓶颈。

但是考虑到非关系型数据库的检索,往往都是针对近期的数据进行的。不知道你会不会又一次想到Kafka的线性索引呢?不过很可惜,非关系型数据库的workload也不是完全append only的,我们仍然需要面对索引结构变动的需求。

那在写多读少的场景下,如何降低IO的开销呢?

LSM Tree(Log Structure Merge Tree)就是这样比B+树更适合写多读少场景的索引结构,也广泛应用在各大NoSQL中。比如基于LSM树实现底层索引结构的RocksDB,就是Facebook用C++对LevelDB的实现,RocksDB本身是一个KV存储引擎,现在被很多分布式数据库拿来做单机存储引擎,其中LSM树对性能的贡献功不可没。

通过批量读写提高性能

那LSM Tree的秘密到底是什么呢?

其实说起来也不复杂,还记得我们当时讲UUID的时候提到过的“批量生成”策略吗,很多时候,如果批量地去做一些事情,就能获得更好的效率。

在读写磁盘的场景中也是一样,既然B+树的多次随机写入性能不佳,我们有没有办法把多次写入合并成一次写入,从而减少磁盘寻道的开销呢?LSM Tree正是这样做的。

早期LSM Tree

早期,LSM Tree中包含了多个树状结构,C0-tree存储在内存,而C1-tree存储在磁盘中,实质就是利用内存,延迟写入磁盘的时机。

图片

C0-tree 由于常驻内存,检索起来不会产生IO,所以理论上,我们可以使用各种可用于高效索引的数据结构来存储数据,比如红黑树、跳表等等。但是因为内存成本高昂,能存储的数据必然有限,更大量的数据仍然需要存储在磁盘里。而磁盘中的C1-tree一般被实现为特殊的B+树。

数据的存储也会分为两个阶段,我们会一直先在内存中存储元素,直到内存中的数据到达一个阈值,我们会开始和C1-tree中的节点进行合并和覆写,过程和多路归并有点相似。因为我们可以决定写入磁盘的时机,所以完全可以保证B+树的所有节点是满的,也就避免了许多单次的随机写操作。

实现细节我们不用掌握,只需要明白设计实质就可以了,感兴趣的话你可以翻阅最早的LSM tree的论文了解。

现代的LSM-tree已经抛弃了这样繁琐的结构,但核心仍然是一致的,都是通过内存维护有序的结构,延迟写入磁盘的时机,通过合并多次随机写操作,降低磁盘臂移动的开销,在多写少读的场景下能获得比B+树好许多的性能。

现代LSM Tree

整个LSM树包含了三个部分,memtable、immutable memtable、SSTable,前两个在内存中,最后一个在磁盘中。同样,我们会先临时地把数据写在memtable中,然后在合适的时机刷入磁盘上的SSTable中。

看到这里,不知道你会不会有一个疑问,这个过程听起来好像很不靠谱呀?众所周知,内存是非持久化的存储介质,如果写入内容写到一半的时候断电了,考虑到延迟刷盘的机制,岂不是之前的数据都丢失了,而且很多可能是我们已经认为提交了的修改记录?

如果你还记得我们之前在操作系统篇学习的WAL机制,就能想到这个问题的解决方式了吧?没错,在LSM-Tree中我们正是通过预写日志的方式,来保证数据的安全性。

每次提交记录的时候,都会先把操作同步到磁盘上的WAL中做备份,如果断电,我们也可以从WAL中恢复所有的修改记录。而且WAL是典型的Append Only的日志存储格式,并不是随机读写,虽然引入了额外成本,但是能明显避免许多随机写的操作,还是能带来巨大的性能提升。

好解决这个困惑,我们来看LSM tree的三大组成部分,搞清楚它们是如何工作的。

  1. Memtable

Memtable显然是内存中的数据结构,存储的是近期更新的记录值,类似原始的LSM tree,可以用各种有序高效的数据结构来实现,比如HBase中采用的跳跃表,我们之后讲Redis的时候也会着重介绍这一数据结构,当然用之前介绍的红黑树也是可以的。

所谓近期的更新的记录值呢,在KV存储的场景下,就是你最近提交的对某个key的插入或者更新的记录,你可以简单的理解成一个Map中的key,value对就可以了。

  1. Immutable Table

在Memtable存储的元素到达一个数量级之后,我们就会把它固化成immutable table,从字面上理解,就是不可变表。

很明显这就是memtable的拷贝操作,那我们为什么要引入这样一个memtable的不可变副本呢?虽然现在还没学习具体的落盘过程,但是我们可以先猜测一下,拷贝过程是需要时间的,但同时我们的系统很可能仍然在对外工作,所以创建副本,可以很好的地帮助我们避免读写冲突竞争,从而避免阻塞,提高系统性能。

  1. SSTable

现在,我们拥有的是内存中的有序结构,存储了近期的记录变更,如何把这样的数据存储在磁盘上,既利用磁盘顺序读写的优势,也能保证所写的格式便于改动也便于查询呢?

SSTable就是一种很巧妙的设计,它是整个LSM Tree的核心,毕竟我们的大部分数据都是存储在磁盘上的,SSTable就是在磁盘上做持久化的部分。本质其实很简单,就是一段段按照key有序排列的键值对(最早出自Google的bigtable论文,后来在工程实践中加了很多优化):

图片

原始的SSTable,key和value可以是任意大小的,所以直接在磁盘上查询不是特别靠谱,但是SSTable本身的有序性,让我们可以采用类似Kafka的线性索引来加速查询过程,所以SSTable一般也会带上一个索引文件,值存储的是key所对应的offset,加载到内存后,我们利用二分搜索可以很快查找出要访问的key的值。

好,我们知道内存中的数据一定是有序的,而持久化数据到磁盘最高效的方式就是顺序写一遍,每次内存中的数据,我们都一次性dump成磁盘上的一段自然是比较快的,这样一段段的数据,我们就称为一个个segment。所以最简单的持久化方式就是我们在磁盘上把内存中有序的键值对直接dump成一个个段,也就是segment。

当然,后面存储的段和前面存储的段,key可能是重复的,因为后面的段新一些,所以在有重复的时候,最靠后的段中的记录值,就是某个key最新的状态。

整个持久化的过程就像这样,我们把内存中有序的数据结构比如红黑树中的记录,dump到一段磁盘上的空间,然后按segment一段一段往后叠加

图片

那在这样的存储下,检索数据的时候需要怎么做呢?很简单,就是从后面的段开始,往前遍历,看看是否有查找到目标key,有的话就返回。由于从后往前遍历,我们第一次查询到key的时候,一定就是这个key对应的最新状态。

但很显然,这样的存储会有很多问题。

  • 首先数据冗余很大,随着时间推移,磁盘上就会有大量重复的键;
  • 其次我们需要遍历每个有序的segment,查看数据是否存在。随着数据量增大,最坏情况下,要遍历的segment会非常多,整个系统的查询效率显然是惨不忍睹的。当然这个问题,我们可以通过布隆过滤器进行一定的缓解,之后介绍Redis的时候再介绍。

总而言之,虽然说在写多读少的情况下,我们可以稍微降低一些读的速度,来换取更快的写的速度,但是这样无止尽的读性能劣化显然是不可接受的。怎么解决呢?

压缩数据

我们需要合并segment。

每个segment都是有序的,那我们显然可以比较高效地对多段数据进行合并操作,之前讲外排的时候也有提到,就是“多路归并”的思路,一般,多路归并的程序我们会在后台不断运行,我们会不断地把多个老的segment合并成一个更长的、同样有序的segment。

合并前老的segment长度都是一样的,在SSTable的主流实现里,我们会把不同的阶段被合并的segment放到不同的层中,并限制每一层数量,当某层segment超过一定数量,我们就会把它们删除,合并出一个更大的segment放入下一层

低层中的segment显然是更新的记录值,更高层的则是更老的记录值。

图片

在图的例子中可以看出来,我们合并segment1、2、3之后,在得到的segment4里,dog的记录就只剩更新的segment2中的记录84了。这样我们的整个存储空间就不会无尽地膨胀,最高的一层,最多也就是占用历史以来所有出现过的key和对应的记录值这样数量级的空间,而存储这些是数据库本应做到的。

检索的时候,我们只需要按照“内存->level0->level1”这样的顺序,去遍历每层中不同段是否包含目标key。每个段内都是有序存储的,所以整体读的时间复杂度也是可以接受的,

确实可能会比B+树的查询效率低一些,不过辅以布隆过滤器等手段,劣化也不会非常明显,在许多读写比不到1:10的场景下,顺序写带来的写性能提升是非常令人满意的。

删除数据

我们了解了如何存储数据、如何检索数据,那如何删除数据呢?

和B+树直接在本地进行删除的策略不同,LevelDB其实不会真的把某个数据移除,因为一旦移除,就可能需要去不同的层进行数据的清理,代价比较高昂。

一个聪明的做法就是我们用和写入一样的手段,将数据标记成一种特殊的状态。这种通过标记而不是真实移除数据的方法,在业务开发中其实也很常见,有时候我们称为soft delete。在有些ORM库中会直接通过deleteAt字段,标记删除时间,来表示这个数据被删除了,想恢复这个数据的时候也很简单,直接将deleteAt置空即可。

在LSM tree中也是一样,我们把这个特殊的状态称为tombstone,墓碑,看图就非常清楚了。

图片

查询的时候,如果我们先查到了tombstone,就可以认为数据已经不复存在了。

总结

今天我们学习了一个相对简化的modern LSM tree的实现,分为内存和磁盘上的数据结构两部分:

  • 内存上的部分,memtable、immutable memtable,比较简单,用通用的有序集合存储即可,跳表、红黑树都是非常不错的选择;
  • 磁盘上的数据结构,SSTable,也不复杂,就是一段段连续按key有序存储的段,唯一需要做的就是后台启动一个程序不断地进行多路归并,得到分层的有序存储结构。

为了提高查询效率,我们引入了稀疏索引和布隆过滤器。其中稀疏线性索引,在Kafka的章节我们已经学习了,布隆过滤器很快也会介绍,核心就是可以帮助我们快速过滤掉一些肯定在数据库中不存在的字段。

整个LSM Tree的实现还是比较复杂的,重点体会批量写对性能的提高,在你的工作中有一天也许会做出类似的优化。

另外相信你也能感受到,从本篇开始常常提到之前学过的一些思想和算法,这也是这些大型系统之所以难以掌握的原因之一,涉及很多基础算法知识。不过当你能把它们串联起来灵活运用,也就不会觉得特别难啦;相信这些思想对你工作中的系统设计也会有很大的帮助。

课后讨论

前面说segment都是一段段的,如果让你来实现一个基于LSM索引结构的数据库,最小的segment应该设置成多大呢?

欢迎你在留言区留下你的思考,觉得这篇文章对你有帮助的话,也欢迎你转发给你的朋友一起学习。我们下节课见。

28|MVCC:如何突破数据库并发读写性能瓶颈?

作者: 黄清昊

你好,我是微扰君。

过去两讲,我们学习了数据库中查询优化的一个重要手段——索引,通过空间换时间的思想,从数据结构查询本身的时间复杂度和IO开销两个角度,去提高查询的速度。除此之外,查询能做的优化其实还有很多,比如同样的语句在采用不同查询计划的情况下,查询效率可能也是差距很大的。

今天我们就从业务开发非常常见的一个角度,并发,来聊一聊数据库可能的性能优化。首先来看并发场景下,我们在数据库中会碰到什么样的问题。

为什么需要事务

我们知道,主流的关系型数据库都能做到在高并发的场景下支持事务,比如MySQL的InnoDB引擎就支持事务,从而取代了并不支持事务的MyISAM引擎。但为了保证事务性,其实需要付出一定的性能代价。那事务是什么,我们来简单复习一下。

简单来说,事务就是指一系列操作,这些操作要么全部执行成功并提交,要么有一个失败然后全部回滚像什么都没发生一样,绝对不会存在中间有一部分操作得以执行,一部分没有执行。

为什么数据库中需要事务呢,一个非常经典的例子就是银行转账,比如说我们需要从A账户给B账户转200元。整个过程要分为两个步骤,分别是:对A的账户余额减去200、对B的账户余额加上200,如果这两个操作一个成功一个失败,显然会导致业务数据完整性出现问题。

为了保证数据完整性,我们就需要让事务支持原子性。这也是我们通常说的事务需要支持的ACID(原子性、一致性、隔离性和持久性)的特性之一,相信大部分研发同学都听说过,网上对这些性质的讨论有很多,这里就不逐一展开了,我们接下来重点讨论隔离性。

隔离性

数据库通常是并发访问的,也就是说我们很可能会同时执行多个事务,而一个事务又会包含多个读写操作,当两个事务同时进行,并对数据库中同一条数据进行了读写,会发生什么呢?如果有冲突了要怎么办呢?

在很多业务场景中,我们都碰到过这种情况,也非常常见。看学生数据表的例子,我们会反复修正学生最近考试的成绩。假设id=1的学生,成绩一开始是50,现在有两个事务A、B,分别执行语句:

图片

我们先花一分钟思考一下,在熟悉的数据库中,事务A在T2和T4两次查询的结果是多少呢?

其实在不同的事务隔离等级下,我们会有不同的结果。比如有一种可能性是,T2事务A查询的结果是50,T4查询的结果是100,这样的查询结果在很多业务场景下是会产生问题的,我们一般称为脏读问题,也就是在事务开始时,读到了尚未提交的其他并发事务对数据的修改值。

为什么我们称为脏值,主要因为这个值是可能会回滚的,比如如果B事务失败了,100这个值并没有真的被写入成功,会被撤销掉,但是我们竟然在A事务里看到了,这种情况我们称为脏,很好理解。

除脏读,数据库中常见的“有问题的”查询结果还有2种情况:不可重复读、幻读。

  • 不可重复读,是指在事务的过程中对同一个数据,读到了两次不同的值,即使别的事务在当前事务的生命周期里对该数据做了修改。
  • 幻读,在事务的过程里读取符合某个查询条件的数据,第一次没有读到某个记录,而第二次读竟然读到了这个记录,像发生了幻觉一样,这也是它被称为幻读的原因。

因为存在这三种问题,脏读、不可重复读、幻读,业务很可能会产生错误,所以我们就需要根据不同的业务场景,提供不同的事务隔离等级,你可以理解成某个事务对其他事务修改数据结果的可见性情况

事务隔离等级

SQL标准定义了四种不同的事务隔离等级的,相信你也一定有所听闻,按照隔离级别由弱到强,分为:读未提交、读已提交、可重复读和串行化。

图片

许多数据库是允许我们设置事务隔离级别的。比如在采用InnoDB为引擎的MySQL中,默认采用的就是可重复读的事务隔离级别。

在这个隔离级别下,可以从表格里看出来,不会出现脏读和不可重复读的情况,幻读可能发生。不过在InnoDB中,幻读这个情况有点特殊,不一定会发生,我们稍后讲MVCC机制的时候再聊。

现在既然有不同的隔离等级,我们当然要想办法实现它们。

如何实现不同的隔离等级

首先看两个极端情况:串行化、读未提交。

最高等级的串行化,比较好理解,既然问题来自于事务的并发,我们就让它们不要并发,如果涉及同一表的读写,我们就加锁,读的时候用共享锁,写的时候用排他锁。这样,幻读问题自然也不复存在了,但这样完全的串行执行,让我们失去了并发的优势,性能不太好,其实不是很常见。

那最低等级的读未提交,也很好懂,它是性能最好的,策略就是不做任何处理。事务中所有的写都立刻作用到表中,并且对所有其他正在执行中的事务可见,自然会产生脏读问题。在前面学生成绩的例子里,T4时刻A事务读到id=1的学生的分数就已经被更新成了100,即使B事务的修改尚未提交。这种事务的隔离等级,在我们的实际开发中也是非常少见的。

中间的两个等级,读已提交、可重复读,同时兼顾了性能和隔离性,也是许多主流数据库的首选之一,Oracle的默认隔离等级就是读已提交。

对于读已提交而言,主要要避免的就是读到尚未提交的数据,也就是脏值。我们把例子修改一下看看在这个等级下会发生什么,A事务一共会进行3次读数据:

图片

在读提交的隔离等级下,T2还是读到50,这次在T4的时候我们不再会读到脏值100,但在T5事务B已经提交的时候,T6再去读同一个记录,会读到事务B提交之后更新的值100。这个时候,在同一个事务里,两次读到的数据就出现了不一致的情况,也就是仍然会出现不可重复读,但已经不会出现脏读的情况了。

读提交如何实现呢?

一种比较悲观的方式还是通过加锁,每次读数据的时候,对该行加共享锁,读完立刻释放,每次写数据的时候对该行加排他锁,直到事务提交才释放。

比如在上面的例子中,T4的读会被阻塞,直到T5完成之后才会读取,此时如果事务B回滚了,我们在T4进行的记录读到的就仍然是50,如果事务B成功提交,则读到的值是100。虽然与T2读到的内容不同,但至少读到的数据不再是脏的了,它满足了读已提交的语意约束。

当然也有比较乐观的方式,也就和接下来要讲的MVCC相关了。所以接下来我们一起来看看InnoDB是如何利用MVCC机制,来实现数据库的可重复读的隔离等级。

利用MVCC实现可重复读

MVCC,全称 Multi-Version Concurrency Control,多版本并发控制,最大的作用是帮助我们实现可重复读的同时,避免了读的时候加锁,只有在写的时候才进行加锁,从而提高了系统的性能。核心是通过引入版本或者视图来实现的,这是一个非常巧妙的设计,在业务开发中很有用的,希望你可以好好体会。

我们首先要看几个基本概念:事务ID、隐藏列、undo log、快照读、当前读。

  • 事务ID

我们想要维护不同事务之间的可见性,首先当然要给事务一个标识,也就是事务ID,它是一个自增的序列号,每个事务开始前就会申请一个这样的ID,更大的事务ID一定更晚开始,但不一定更晚结束。

那有了事务ID,在InnoDB中就是trx_id,我们就可以开始为数据维护不同的版本了。

  • 隐藏列

想要维护不同的版本,数据表的每一行中除了我们定义的列之外,还有需要至少包括trx_id、roll_pointer,也就是隐藏列。

每一行数据中的trx_id,代表该行数据是在哪个trx_id中被修改的,这样在每个事务中看访问到表中的数据时,我们就可以对比是在当前事务之前的事务里被修改的,还是在之后的事务里被修改的。

但只有这个信息是没有用的,毕竟如果我们想要让并发时,一些尚未结束的事务的修改,对当前事务不可见,还得知道在此之前这个数据是什么样的吧?这就是roll_pointer的作用了,它指向的更早之前的数据记录,也就是一个指针,指向更早的记录值

记录值具体是怎么维护的呢?就要提到 undo log 了。

  • undo Log

undo log,也就是回滚日志,不知道你有没有一点耳熟,还记得我们之前提到的 redo log 吗?和 redo log 的预写(用来在宕机未持久化的时候恢复数据的机制)正好相反,undo log 记录了事务开始前的状态,用于事务失败时回滚。不过undo log和 redo log 可以说是一体两面了,都用于处理事务相关的问题。

除了用于恢复事务,undo log 的另一大作用就是用于实现MVCC,我们的 roll_pointer 指向的其实就是undo log的记录

图片

你可以看到,由于 roll_pointer 的存在,整个数据库中的每行数据,背后都可能有不止一条数据,每个transaction的修改都会在表中留下痕迹,而它们通过 roll_pointer 形成了一个类似于单向链表的数据结构,我们称为版本链。所以每次新插入一条数据,除了插入数据本身和申请事务ID,我们也要记得把pointer指向此前数据的undo_log。

MVCC 就是在这样的版本链上,通过事务ID和链上不同版本的对比,找到一个合适的可见版本的。快照读就是MVCC发挥作用的方式。

  • 快照读和当前读

在 select 数据的时候,我们会按照一定的规则,而不一定会读出表中最新的数据,有可能从版本链中选择一个合适的版本读出来,就像一个快照一样,我们称为快照读

在 InnoDB 中,默认的、可重复读的事务隔离等级下,使用的select都是快照读:

1
select * from student where id < 10

而当前读,读的就是记录的最新值,在InnoDB下我们会进行显示的加锁操作,比如for update

1
select * from student where id < 10 for update

所以如果本质上严格遵循MVCC的要求,幻读是不会发生的,但是InnoDB里的读分为快照度和当前读两种。如果你对MySQL中的 for update 原语有印象就会知道,在select的时候如果没有加 for update 的话,就不会发生幻读的现象,反之则会有幻读的现象。

读视图

现在在可重复读的隔离性下,MVCC是如何工作的呢?

核心的可见性保证来自于读视图的建立,本质就是每个事务开始前,会记录下当前仍在活跃也就是开始但未提交的所有事务,保存在一个数组中,我们称为视图数组,然后会根据这个数组,基于一定的规则判断应该读取每个数据的哪个快照。

来配合这张示意图看规则是什么:

图片

首先,我们会记录视图数组中最小的事务ID和最大的事务ID+1,分别称为低水位和高水位。

这两个ID其实就可以从当前执行的事务的视角,将所有的事务分为三个部分,小于低水位的部分一定是当前事务开始前就提交了的部分,大于等于高水位的则一定是还未提交的事务,我们一定不可见。

处于中间的部分就要分类讨论了:

  • 如果在视图数组中,说明当前事务开始时,这些事务仍在活跃,所以应该是不可见的;
  • 如果不在数组中,说明在仍活跃着的事务范围内,但其中有一些事务虽然不是开始最早的,但是结束的却比活跃数组中的事务早,以至于当前事务开始时,这些事务已经结束,所以就应该是可见的。

简单总结一下,如果我们记录低水位为low_id,高水位为high_id,活跃事务数组为trx_list。可见的trx_id就需要满足 trx_id < low_id 或者 trx_id < high_id 且 !trx_list.contains(trx_id) 的条件,也就是要么比低水位更早,要么比高水位的id小但是不能出现在活跃事物数组中

那读视图的规则其实就是根据可见性的约束,在查询数据的时候从版本链从最新往前遍历,直至找到第一个可见的版本返回。

这么说可能还是比较抽象,我们还是用学生成绩的例子,分析一下事务A这次的执行情况,假设在A之前id=1的记录隐藏列中的事务ID为1,且已经提交。

图片

在事务A启动的时候,由于晚于事务B、早于事务C,申请到的trx_id=3,而视图数组里活跃的事务只有trx_id=2的事务B,也就是长这样 [trx_id=2]

看T4时,事务A的访问情况:

  • trx_id=4的事务C,其实无论有没有提交,由于trx_id大于视图数组中的高水位,所以对我们来说是不可见的,这就避免了脏读。
  • 对于事务B,不管是在T6的时候事务B已经提交,还是T4的时候事务B没有提交,由于其存在于视图数组中,也就是事务A开始时已经在活跃的事务,所以也是不可见的。

所以,T6的时候,事务A访问的值和T4也是一样的,这样也就保证了可重复读的语意。

相信现在你应该理解了,本质上就是要通过多版本的快照读,在实现隔离性的同时,帮助我们避免读的时候加锁的操作。

总结

数据库的事务和其对应的隔离等级,是目前主流数据库的基本性质,我们在工作中用到的机会相当多。首先我们要理解清楚事务的基本概念,包括不同隔离等级下出现的幻读、脏读等等的问题,才能帮助你正确地使用数据库,在合适的时候选择加锁保证业务的正确性。

MVCC的多版本控制策略也是今天的重点学习内容,相比于悲观的加锁实现隔离性的方式,MVCC基于undo_log和版本链的乐观控制并发的方式,可以为我们提供更好的性能,本质是通过快照读,完全不加锁而满足隔离性。

MVCC可见性的判断规则,也不要死记硬背,你可以借助最后的例子仔细琢磨,多问自己几个问题检验一下,比如在T1和T2之间假设还有一个事务D,也对数据进行了修改,并在事务A开始之前就结束了,会对事务A的读操作产生什么样的影响呢?事务B和事务C又会发生什么样的情况呢?它们两个都会修改成功吗?如果你想清楚了这些问题,相信很快就能理解MVCC的工作机制。

思考题

今天简单介绍了RR隔离等级基于乐观的MVCC的实现,那RC隔离等级是否也可以通过MVCC来实现提高性能呢?我们说MVCC相比于加锁的方式提高了性能,但是在所有的场景下都如此吗?

欢迎在留言区写下你的思考,如果觉得有帮助的话,也可以把这篇文章转发给你的朋友一起学习,我们下节课见~

29|位图:如何用更少空间对大量数据进行去重和排序?

作者: 黄清昊

你好,我是微扰君。

今天我们从一道非常经典的面试题开始说起,看看你能否用之前学过的知识回答出来,题目是这样的:QQ,相信你肯定用过,假设QQ号(也就是用户的ID)是一个10位以内的数字,用一个长整型是可以存储得下的。

现在,有一个文件里存储了很多个QQ号,但可能会有一定的重复,如果让你遍历一边文件,把其中重复的QQ号都过滤掉,然后把结果输出到一个新的文件中。你会怎么做?如果QQ号多达40亿个,但是你的内存又比较有限(比如1GB),又会怎么做呢?

你可以先暂停,思考一下这个问题,如果有了初步思路,我们一起进入今天的学习。

直接基于内存进行去重

先来说说常规的思路。假设我们的数据可以被内存装下,这个问题其实就有很多种方式可以解决。

比如,对于去重,直接采用基于散列思想的hashset,或者基于树状结构的set就可以了,前者可以在O(1)的时间复杂度内,判断某个元素是否存在于集合中,后者虽然需要O(logN)的时间复杂度,但是在十亿的数量级下,其实也就是比较30次左右,代价也并不高;然后我们遍历一遍整个文件,存入set中,再输出到另一个文件。总的时间复杂度,前者是O(N),后者是O(N*logN)。

当然还有一种思路,我们先用数组把所有QQ号存储下来,进行排序;然后顺次遍历,跳过所有和前个QQ号相同的QQ号,就能实现去重,采用快排同样可以达到O(N*logN)的时间复杂度。

所以总的来说,基于哈希算法的时间复杂度,理论上已经是最优的了,耗时也是可接受的,毕竟无论如何,想要去重,每个元素至少要遍历一遍,不可能存在更优的时间复杂度了。我们唯一还能优化的点就是,在像QQ号这种以数字为key的特殊情况下,直接利用数组来充当hashmap,避免hash的开销

但是这个题真正的问题是,从空间上来说,我们真的能开着这么大的一个数组吗?因为存储的是10位数的QQ号,这意味着我们的数组至少要有10位数以上的index,假设最高位以1、2、3、4开头,也就是说数组至少要存放40亿级别的数据。

假设数组类型是bool,表示对应index的QQ号是否存在,那我们所需要的内存空间大约是4GB。这对目前的硬件来说不是一个特别高的内存要求,许多个人电脑所采用的内存条都足以支撑,但是如果存11位的QQ号或者其他更大的数据量,显然就不够用了。而且这道面试题的原始版本要求我们用1GB的内存实现去重。

总之,如果有办法用更低的内存,我们应该想办法去发掘。

对文件进行分割

现在,内排序、直接使用hashmap或者数组计数去重的方式肯定是不行了。

不知道你有没有想到之前学过的外排,本质上就是对文件的分割和逐步排序。在我们的QQ号场景下,不需要真的做排序,只是需要去重,所以完全可以逐行读入大文件,根据QQ号的范围,切分成多个可以一次性加载进内存的小文件。

不过因为不同范围内QQ号重复的数量可能不同,分割范围可能不是特别好把握,保守一点的话,我们可以把QQ号按照1000W的大小进行分段,这样大约需要分为400个文件。这样基本上,算上重复的QQ号,也不太可能超过1G内存的容量,每个文件再用hashmap之类的手段去重,最后合并就可以了。

外排序是一个可行的解决办法,而且理论上来说,利用类似的思路,我们还可以实现更大数量级的去重任务,但是代价是要进行更多次的IO。性能比较差

事实上,40亿级的数据范围,在1GB的内存下,我们是有办法直接在内存中处理去重的,这就是我们今天要学习的Bitmap,它非常有用,在计算机的世界里无处不在,从文件系统、数据库,还有Redis中都有广泛应用。我们来看设计思路。

Bitmap

40亿的数据直接放在内存里是不行的,但去重,必须获得所有的信息,如果我们想要只利用内存进行去重,也仍然需要把每个数字是否出现在文件中的信息,通过某种方式记录下来。

前面通过数组存bool值的方式,显然不是最经济的一种存储方式,因为每个bool类型在数组中占据了一个字节的数据,也就是8个bit。

但是存储一个数字是否出现,其实我们只需要用一个bit来记录。Bitmap的核心就是用数字二进制的每一位去标记某个二值的状态,比如是否存在,用0表示不存在,用1表示存在,所以可以在非常高的空间利用率下保存大量二值的状态。

一个基本的Bitmap的图示,相信你一看就能明白:

图片

比如可以用char类型来存储8个不同元素是否出现的情况,char的范围在各大主流语言中一般为0~255,一共包含8位二进制,我们可以用下标的每一位来表示一个元素是否出现。在这张图中Bitmap的值为254,表示下标1~7的元素都有出现,而下标0的元素没有出现。

这样仅仅用了一个字节,就表示了8个元素是否出现的情况,而如果用map或者数组,至少需要8个bool值也就是8个字节大小的空间,这样我们就节约了8倍的空间。

同样如果我们用别的类型来存储Bitmap,比如unsigned int类型,每一个数字就可以表示32个元素的存在与否,采用多个unsigned int类型数据级联,就可以标识更多的元素是否存在。

在QQ号的场景下,要表示40亿的元素,采用Bitmap,最少只需要40亿个bits,所占据的空间大约是500M左右,这样,我们就大大压缩了内存的使用空间,在1GB之内就可以完成去重的工作。

这里插句题外话,不知道你有没有想过为什么bool类型,在大部分语言中,都需要一个byte去存储呢?bool本身语意上就只是二值,我们不应该用一个bit来实现吗,这样不是效率高得多?

这个问题,需要我们有比较好的计算机组成原理相关的知识了。本质原因是在大部分的计算机架构中,最小的内存操作单元就是一个byte,直接采用一个byte作为bool类型的存储,在一次读内存的操作内即可完成,如果存储为一个bit,我们还需要像Bitmap那样,从若干位中通过位运算进行一次提取操作,反而更慢。

当然Bitmap的本质,实质上就是更好地利用空间来做二值的标记,相比于一般的hash算法,它能获得更好的空间成本,从计算上来说,其实也是更高效的,在去重和排序中有比较良好的应用。

具体实现

具体的代码实现,我们需要开辟一个unsigned char的数组,记作flags。数组中的每个元素都记录了8个QQ号是否存在,可以简单地从0开始往后计数,虽然位数很低的QQ号其实并不存在。

flags[index] 代表着第 index*8 ~ index*8+7 这8个QQ号是否存在的情况,最低位表示 index*8 是否存在,而最高位就代表 index*8+7 这个QQ号是否存在。

建立好Bitmap数组之后,遍历文件中的QQ号,进行对应Bitmap标记的更新,根据QQ号计算出对应的flags的下标和二进制的哪一位,进行和1的或运算即可。

遍历完成后,我们只需要顺次遍历Bitmap的每一位,如果为1,说明QQ号存在,输出到新文件,为0的位直接跳过即可。整个过程完成后,其实我们不止做到了去重,也做到了排序

整个过程可以很简单地用C++进行实现。首先要实现一个基本的Bitmap,对外提供初始化、设置标记、获取标记3个功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class BitMap{
private:
char *flags;
int size;
public:
&nbsp; &nbsp; BitMap(){
&nbsp; &nbsp; &nbsp; &nbsp; flags = NULL;
&nbsp; &nbsp; &nbsp; &nbsp; size = 0;
&nbsp; &nbsp; }

&nbsp; &nbsp; BitMap(int size){
// 声明bitmap数组
&nbsp; &nbsp; &nbsp; &nbsp; flags = NULL;
&nbsp; &nbsp; &nbsp; &nbsp; flags = new char[size];
&nbsp; &nbsp; &nbsp; &nbsp; memset(flags, 0x0, size * sizeof(char));
&nbsp; &nbsp; &nbsp; &nbsp; this->size = size;

&nbsp; &nbsp; }

// 根据index设置元素是否出现过
&nbsp; &nbsp; int bitmapSet(int index){
&nbsp; &nbsp; &nbsp; &nbsp; int addr = index/8;
&nbsp; &nbsp; &nbsp; &nbsp; int offset = index%8;
&nbsp; &nbsp; &nbsp; &nbsp; unsigned char b = 0x1 << offset;
&nbsp; &nbsp; &nbsp; &nbsp; if (addr > (size+1)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return 0;
&nbsp; &nbsp; &nbsp; &nbsp; }else{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; flags[addr] |= b;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return 1;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

// 根据index查看元素是否出现过
&nbsp; &nbsp; int bitmapGet(int index){
&nbsp; &nbsp; &nbsp; &nbsp; int addr = index/8;
&nbsp; &nbsp; &nbsp; &nbsp; int offset = index%8;
&nbsp; &nbsp; &nbsp; &nbsp; unsigned char temp = 0x1 << offset;
&nbsp; &nbsp; &nbsp; &nbsp; if (addr > (size + 1)) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return 0;
&nbsp; &nbsp; &nbsp; &nbsp; }else{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return (flags[addr] & temp) > 0 ? 1 : 0;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
};

有了这样的基本能力,我们需要做的就是遍历文件的部分,由于文件IO和解析的部分不是今天的重点,我们直接处理一个在内存中的QQ号数组。

1
2
3
4
5
6
7
8
9
10
int remove_dup(vector<unsigned int> qqs) {
BitMap b = new BitMap(4000000000);
for (int i = 0; i < qqs.size(); i++) {
b.bitmapSet(qqs[i]);
}
for (int i = 0; i < 4000000000; i++) {
if (b.bitmapGet(i)) cout << i << endl;
}
return 0;
}

如果封装好了基本的Bitmap逻辑之后,使用过程和hashmap看起来没有什么太大区别,你可以认为Bitmap就是一种对数字的散列方式,和数组用于去重的场景相似,适用于下标比较密集的情况,否则仍然会浪费大量的空间,在QQ号去重的场景下就非常好用。

数据库中的Bitmap索引

Bitmap既然可以适用于排序,当然也可以用来做索引。在数据库中就有一类索引被称为Bitmap索引,位图索引,比较适用于某个字段只有部分可选值的情况,比如性别的男、女,或者所在城市之类。

采用位图索引,不止可以降低空间成本,在多条件查询中,我们也可以基于位运算提高索引的利用率。看个具体例子:

图片

现在我们有一张北京市某所学校的学生信息表,其中有两列,分别记录了性别和所在区,这两列显然都属于有固定枚举值的情况。这里为了讲解方便,我们简单假设所包含的区只有海淀、朝阳、东城、西城这四个。

如果采用传统的B+树建立索引,在性别这一栏上区分度其实很低,因为很大的概率我们任意一个筛选条件都要筛选出接近半数的元素,所以,基本上先利用性别的红黑树检索就没有什么查询优势了,事实上在大部分的数据库中也会直接选择全局遍历。同样的在区这一维度看,由于我们假设只有4个可选项,采用B+树也没有什么特别大的好处。

而位图索引在这样固定枚举值的场景下非常合适,具体做法就是我们会为性别男、性别女,海淀区、朝阳区、东城区、西城区这样几个独立的选项,都建立一个位图向量,作为索引。比如:

1
2
3
4
性别_男= 10010101
性别_女= 01101010

区_朝阳= 00000100

就意味着id=7\4\3\0的学生性别为男,id=1\2\5\6 的学生为女。

当然由于数据库存储的数据量要大的多,我们会采用更长的Bitmap来存储所有学生在某个key为不同取值的情况。

使用位图索引,最大的好处就在于多查询条件的时候,我们可以直接通过对Bitmap的位运算来获得结果集的向量。比如想获得朝阳区的女生,只需要对区_朝阳性别_女 这两个Bitmap做与运算,就可以得到同时符合两个条件的结果集向量,比如在这个例子里,两者与得结果为0,说明不存在这样的学生。相比于B+树,位图索引效率就会高很多。

总结

Bitmap位图,本质上就是一种通过二进制位来记录状态的数据结构。比基于硬件特性设计、用一个字节来存储bool类型的方式,提高了8倍的存储效率,可以用更少的空间来表示状态。

Bitmap在大量数据去重和排序的场景下很有用,比如大量QQ号的去重问题;在内存资源敏感、需要标记状态的场景下也很常见,比如文件系统中存储某个block是否被占用的状态,用到的就是Bitmap。

在数据库中,我们也可以在枚举类型的属性上建立位图索引,为属性的每个取值建立一个位图,从而可以大大提高多条件过滤查询的效率。事实上,之后会讲到的布隆过滤器,底层也是基于位图的思想,如果你的工作中有去重的需要,也不妨考虑一下采用位图实现的方式,说不定就能大大提高系统的性能。

课后练习

今天主要讲的就是QQ号面试题,课后你可以尝试自己动手实现一下,另外有时我们的Bitmap也会需要把设置为1的状态清除,目前我们没有提供这样的接口,欢迎把你的代码贴在留言区,一起讨论。

如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见。

30|布隆过滤器:如何解决Redis缓存穿透问题?

作者: 黄清昊

你好,我是微扰君。

上一讲我们学习了如何基于bitmap,使用少量内存,对大量密集数据高效地去重和排序,本质就是通过一个长长的二进制01序列,来维护每个元素是否出现过这样的二值状态,这个数据结构非常重要,日常工作中有很多应用,比如我们今天要学习的布隆过滤器,就建立在相似的数据结构之上。

那什么是布隆过滤器,用来解决什么样的问题呢?我们先从缓存说起。

缓存穿透

还记得之前介绍LRU的时候,我们提过的缓存思想吗,用一些访问成本更低的存储,来存一些更加高频的访问数据,提高系统整体的访问速度。比如在业务开发中,我们经常会用Redis来缓存数据。

这就是因为Redis主要把数据存储在内存中,访问速度比数据库快得多,引入缓存层之后,能大大减少数据访问的延时,也能降低数据库系统本身的压力。

我们的用法通常是这样:每次请求某个数据的时候,假设都是基于某个key去访问数据库,比如用户ID之类的字段,我们会先试图访问Redis,查看是否有key相关的缓存记录,有的话就可以直接返回了;如果没有,再去数据库里查询,查到结果后会把数据缓存在Redis中;如果数据库中也没有,返回没有相关记录,这样Redis中自然也不会有缓存数据。

另外,大部分系统访问数据的时候都会有时空局部性,也就是说最近访问过的记录很有可能会被再次访问到,所以加了Redis之后,数据库的压力就会小很多。当然,这里我们需要处理缓存数据过期或者缓存和数据库数据不一致之类的问题。

所以总的来说,如果我们想要通过Redis避免数据库的压力太大,就得保证查询的数据很大一部分是在Redis中有缓存的

大部分场景下,由于时空局部性,都是可以满足这个条件的,但是如果有人恶意反复去系统访问那些已知不存在的key呢?

由于数据库中一定不会存在这样的key,Redis中也不可能有这样的key,我们的每次请求都会访问一次Redis再访问一次数据库,就好像穿透了Redis层一样,这个问题我们就称为“缓存穿透”,恶意攻击者往往可以通过这样的手段,让我们的数据库的压力很大,甚至崩溃。

但是即使在正常的workload下,有时候也会产生大量穿透的请求,有什么办法减少这些不必要的请求呢?

布隆过滤器

布隆过滤器,也就是 bloom filter,可以很好地帮助我们解决这个问题。

顾名思义,布隆过滤器起到的是过滤的作用,在缓存穿透的场景下,过滤掉肯定不在系统中key的相关请求。所以,布隆过滤器,核心就是要维护一个数据结构,我们通过它来快速判断某个key是否存在于某个集合中。

看到这里,你是不是马上想到了一种最简单的filter策略:既然Redis做的就是一个缓存系统,如果存在key,我们把数据缓存至Redis中,不存在key的时候,我们一样缓存到系统中,然后记录一个特殊的值来表示这个数据不存在,这样不就可以了嘛,就像我们之前学的LSM-tree中的墓碑标记一样。

这个方案当然是可以工作的,但是,不存在的key,显然取值范围是很大的,我们也可以预想在大部分场景下,要比存在于系统中的key的取值范围多得多。Redis存储空间有限,所以,这个方案无疑会大大减少有效数据的缓存空间。在恶意攻击者的攻击下,甚至可能造成Redis中存储的大部分数据都是标记为不存在的记录,所以这显然不是一个很好的办法。

我们有没有办法利用更少的空间,快速判断某些记录是否存在于系统中呢?

这就是布隆过滤器的主要优势了,当然,软件没有银弹,布隆过滤器也有两个比较大的限制:

  1. 判断并不是完全准确的。布隆过滤器可以保证自己判断出不在系统中的key一定不在,但是剩下的部分不一定都在系统中存在key,有一定的误判风险;
  2. 布隆过滤器的记录删除比较困难。

为什么会这样呢,接下来我们就来看一看布隆过滤器的实现原理。

实现原理

布隆过滤器本质上可以理解成一种散列的算法,这也是为什么我们说它和上一讲学到的Bitmap关系很大。

不过布隆过滤器不是一个简单的映射,更像是一个散射,它包含K个不同的散列函数,每个散列函数都会把某个key映射到一个数字h,然后我们会把一个M位二进制对应的第h位二进制,置为1。这样,每个key,我们就映射到m位二进制中k位为1的数字上了,记录的方式其实和bitmap的定义如出一辙。

在整个布隆过滤器的使用过程中,我们会维护一个全局的Bitmap,并且把每个出现过的记录值都进行这样的散射,Bitmap的每个散射到的位置都置为1

看这个例子,我们把x、y、z分别散射到了绿色、橙色、紫色指针的位置:

图片

之后再查询不同的key,比如x、y、z,我们首先会进行同样的Hash计算,判断出每个对应的位置是否为1,如果有一位不为1,说明这个key肯定在系统中不存在,我们也就不需要进行后续的查询了。这样我们能减少很大一部分缓存穿透的情况,而且付出的存储空间也非常有限。

这样多重Hash方式有什么好处呢?

优势与劣势

比如之前提到的直接用HashMap的实现方式,因为我们完全可以直接对xyz进行某种hash算法,将它们映射到一个数组空间里,再判断对应的key是否存在呀?多重Hash有什么优势呢?

主要的问题是HashMap所占用的存储空间要高得多,在传统的HashMap中,我们会存储key的引用,这是一个很大的开销。而在布隆过滤器中,我们所需要的只是一个不大的Bitmap(至于到底选择多大的bitmap合适,我们稍后具体的计算)。

当然,我们去掉了引用所占用的空间,自然也就少了应对冲突的能力,这就是布隆过滤器不完美的地方之一,它的重复判断是有“误差的”

根据前面的原理,我们来详细分析一下,只要确定布隆过滤器中不存在的key,则该key在系统中一定是不存在的,因为但凡有一个Hash值对应的Bitmap位不为1,说明这个key一定没有被添加到过bloom filter中。

但是反过来,如果每一位都为1,其实仍然有一定的概率这个key没有出现在布隆过滤器中。比如例子中的w:

图片

w映射的每个Bitmap的位正好都是x、y、z中某个key映射的一位,从布隆过滤器中看来,w的每一位也都是1,我们并不能排除它在系统中存在的可能性,但是如果w不存在,就会产生一次不必要的穿透。

但是缓存系统是空间敏感的应用,这也是我们没有直接用Redis存储不存在记录的原因,为了使用更小的空间,付出小概率的“误差”成本完全是可接受的。

第二个劣势记录删除困难,根本原因和上一点是一致的,因为我们重叠了Hash的位数,所以无法判断每一位的1到底是由哪一次Hash计算贡献的,同一个1可能被多次Hash计算更新,所以我们不好在想要移除某个字段的时候,把通过Hash计算得到的位直接置0。不过这个问题在许多需要使用布隆过滤器的场景下影响也不大。

误判率推导

好现在明白了布隆过滤器的设计思路与优劣势,工作应用时到底选择多大的bitmap合适呢,我们来根据误判概率算一算。

假设Bitmap由m位二进制组成,包含k个哈希函数。判断某个key是否存在的概率怎么算呢?

首先,判断是否存在,就是看这个key对应的k个位置是否都为1,那每个位置是否为1的概率从何而得呢?假设之前已经插入了n个key。

先考虑每次插入key的时候某一位为1的概率。如果哈希函数均匀,每个key一共进行k次哈希,每次哈希到特定某一位的概率为$\frac{1}{m}$,那么这一位不为1的概率是:$(1-\frac{1}{m})^{k}$。

再考虑这样的操作之前已经进行过n次,那么这一位不为1的概率是:$(1-\frac{1}{m})^{k*n}$。

最后反推,这一位为1的概率是:$1-(1-\frac{1}{m})^{k*n}$。

所以什么时候会误判呢?也就是说对于某个新的key,虽然这个key不存在,但是它k次Hash出来的每个位置都为1。这个概率的计算,我们随便找k个1,然后对每一位都为1的概率做累积,在m比较大的时候,通过高等数学的知识可以得到近似:

$$ (1-(1-\frac{1}{m})^{k*n})^{k} ={(1-e^{-\frac{kn}{m}})}^{k} $$

可以看出,误判概率大致和n成正比,和k、m成反比,我们可以根据自己的业务场景选择合适的位数和哈希函数数量。不同的m、n、k取值下,可以参考误判率的表格

经验值是对于100w的数据量,如果希望通过5次Hash得到5%以内的误判率,我们大概需要700万位Bitmap,内存只需要不到1M即可完成,效果非常不错。而且5%的误判率,我们也是完全可以接受的,以数据库-缓存构成的系统为例,这足以帮我们降低95%的穿透请求,效果显著。

实现逻辑

在理解布隆过滤器实现原理的基础上,我们动手实现就非常简单了,比Bitmap的实现多不了几行代码。

Google提供的经典Java工具库Guava中,就有一个对 bloom filter 的实现,它提供了很好的泛化能力,通过自己重新封装bitset获得了更佳的性能,同时也支持多种不同的策略。我们来看一下核心的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
MURMUR128_MITZ_64() {
@Override
public <T> boolean put(
T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
long bitSize = bits.bitSize();
// 获得带有随机性的hash种子
byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
long hash1 = lowerEight(bytes);
long hash2 = upperEight(bytes);
boolean bitsChanged = false;
long combinedHash = hash1;
// 进行k次hash计算
for (int i = 0; i < numHashFunctions; i++) {
// Make the combined hash positive and indexable
bitsChanged |= bits.set((combinedHash & Long.MAX_VALUE) % bitSize);
combinedHash += hash2;
}
return bitsChanged;
}
@Override
public <T> boolean mightContain(
T object, Funnel<? super T> funnel, int numHashFunctions, BitArray bits) {
long bitSize = bits.bitSize();
byte[] bytes = Hashing.murmur3_128().hashObject(object, funnel).getBytesInternal();
long hash1 = lowerEight(bytes);
long hash2 = upperEight(bytes);
long combinedHash = hash1;
for (int i = 0; i < numHashFunctions; i++) {
// Make the combined hash positive and indexable
if (!bits.get((combinedHash & Long.MAX_VALUE) % bitSize)) {
return false;
}
combinedHash += hash2;
}
return true;
}
}

MURMUR128_MITZ_64 是其中一种比较常见的策略,核心方法就是put和mightContain方法。

从mightContain方法名中,相信你也可以再一次意识到bloom filter误判的特性,可以说Google的库命名非常确切。

put方法,核心逻辑就是通过murmur3_128进行Hash计算,得到一个128位带有随机性的byte,取其中的高位和低位作为种子,再通过简单的位运算叠加k次,等同了Hash k次的效果,最后把对应的位置都置1即可。mightContain的逻辑,基本上就是相反的过程。

Guava库的代码质量很高,也不是特别难懂,如果你是Java爱好者,不妨用这个库开始你的源码学习之旅。

总结

今天我们一起学习了非常知名且常用的数据结构,bloom filter。在Bitmap和HashMap的基础上,布隆过滤器,通过对每个元素进行某种散射式的Hash,把状态记录在Bitmap中,高效且成本低地为我们提供了在大量数据中判断某个元素是否存在的神奇能力。

当然,相比于朴素的HashMap,省来的空间也不是完全没有代价,在布隆过滤器中,我们只能断言一个元素一定不存在,但没有断言时,元素可能存在也可能不存在。

不过在选取合适的Bitmap数组大小和Hash计算次数之后,可以很容易地把误判率控制在低于5%的水平,依旧可以给我们带来很大的性能提升。在Redis中,就常常用来缓解缓存穿透的现象,从而提高系统的吞吐和稳定性。

思考题

留一个有点难度的思考题。我们提到了布隆过滤器清除状态比较困难,但有些缓存数据是有生命周期的,比如一周或者一个月之后就大概率过期了,你有没有什么好办法可以帮助我们更好地清理过期数据呢?

欢迎你在留言区留下你的思考。如果觉得这篇文章对你有帮助的话,也欢迎你转发给你的小伙伴一起学习。我们下节课见~

31|跳表:Redis是如何存储有序集合的?

作者: 黄清昊

你好,我是微扰君。

上一讲我们一起学习了布隆过滤器,它可以帮助我们用更低的存储成本、更高效地判断某个元素是否在一个集合中出现,当然代价是一定的误判率。总的来说,布隆过滤器特别适合用来解决Redis中缓存穿透的问题。

今天,我们同样来讨论一个在Redis中发挥巨大作用的数据结构:跳表。如果你有一定的Redis使用经验,常用的ZSET底层实现就是基于跳表的。

跳表这个数据结构,其实在之前介绍红黑树的时候我们简单提到过,和红黑树一样,它可以非常高效地维护有序键值对,插入、查询和删除的平均时间复杂度都是O(logN),所以被Redis用来存储有序集合。但在时空复杂度差不多的情况下,跳表比红黑树实现起来要简洁优雅得多。

我个人认为,跳表几乎在每个方面都比红黑树更好,当然红黑树由于发明更早,得到了更广泛的应用,所以很多TreeMap之类的语言原生的数据结构还是常常采用红黑树。但是跳表作为一种非常高效的有序集合的实现,背后的原理很值得我们学习。

那跳表是如何设计、实现的呢,我们开始今天的学习之旅。

跳表为什么诞生

故事的开头,还是要从链表说起。

之前讲红黑树(点这里复习)我们也提到,如果要实现一个字典这样的数据结构,其实可以直接用一个线性数据结构,来存储所有的元素,至于每次插入元素前如何判断元素是否存在,也很简单,遍历一遍就可以了。

但是这样的时间复杂度不是特别好,一种优化思路就是尽量让这个线性的数据结构有序,方便快速二分查找,更高效,因此我们也就延伸出了基于树状结构的二叉搜索树,以及对平衡性进一步优化的平衡二叉搜索树。

除此之外,是否还有其他高效的、可以加快搜索速度的优化方式呢?

可能你会想到哈希表,当然了哈希表也是一个思路,事实上,在Redis对有序集合的实现里,我们同时维护了跳表和哈希表,为的就是利用单值查询时哈希表的高效性,但哈希表的存储是无序的,这意味着当我们想要使用范围查询的时候,相比于红黑树或者跳表这样有序的数据结构,哈希表就会产生一定的劣势(点这里复习)。

其实,不采用树状结构,仍然采用链表,再通过一些数据结构上的调整,我们也是可以实现类似二分查询这样跳跃查询效果的。这就是跳表主要被发明出来的动机。

跳表具体解决什么问题

先来仔细回顾一下如果用链表存储有序集合,在查询的时候会碰到什么样的问题?看这张典型的链表结构图(出自跳表原始的论文):

图片

这里我们讨论的是有序集合,所以会让整个链表是有序排列的。

要存储有序集合,链表相比数组这样容器的好处,我们之前已经仔细讨论过了(点这里复习),主要就是在插入的时候,由于链表本身不要求内存连续,所以插入和删除的时间复杂度是O(1),而数组为了保持内存空间的连续性,需要花费O(n)的成本做插入和删除的操作。

但同样的,也正是因为链表内存不连续,我们在基于key查询链表节点时,即使整个链表已经是按照key有序排列了,我们仍然需要顺次遍历进行查询,不能像在有序数组中那样二分地跳跃查询。O(n)的查询复杂度,显然没有有效地利用有序集合的有序特性。因此在这一点上,红黑树完胜。

那链表真的没有办法得到接近二分查找的时间复杂度了吗?只通过链表本身肯定是不行了,我们找到的本质问题是:链表不依次遍历就没有办法寻址到每一个节点,但是如果我们有办法在链表上增加一些捷径,跳着走呢?

链表是怎么设计的

这个想法有点抽象,我们找生活中的场景来类比联想。

请你回想自己每次从一个城市去往另一个城市,是怎么换乘公共交通的?是不是先到本地的机场或者火车站,乘坐站距比较大、速度比较快的飞机或者火车,再换成市内的、站距小的交通工具比如公交车、地铁。

这样的换乘选择就是考虑到,直接坐地铁的耗时,比先坐火车再换地铁长得多,因为地铁站比较密集,速度也比较慢,而高铁则快得多,一站相当于地铁的很多站。

那回到链表上,我们把一个个遍历链表节点比作是一站站地铁,如果在链表上能加一些间距更大的火车站,自然就快得多

这就是跳表的思想本质,具体来说就像图里这样:

图片

我们会给链表上增加一些额外的层和指针,越高的层,指针指向的下一个节点会跳跃更大的距离,越低的层,指针间距越小;所有的节点都会出现在最低层,也就是第一层,这一层就是一个包含了所有有序集合中元素的有序链表。

这样我们寻找某个元素的时候,就可以像换乘公共交通一样,先坐站距更远的交通工具,再换站距更小的交通工具,最后一段可能就是徒步,整个搜索效率就会高很多。比如这个具体的例子,假设我们想搜索的是71这个节点(例子来源于CMU的课件):

图片

在原始的链表中,我们需要逐个遍历,需要进行6次跳跃。如果采用了跳表这样分层链表的存储方式,沿着指针移动的过程,可以减少为了2次。

当然,我们还是需要做一些next指针大小的判断,具体的搜索过程从高到低,每次比较包括三条分支:

  1. 如果当前指针指向的值为target,说明找到,返回即可;
  2. 如果当前指针指向的右节点的值大于target,我们进入更低的一层;
  3. 如果当前指针指向的右节点的值小于等于target,我们将指针移动到右节点。

整个过程直至搜索到最低一层,如果仍然没有搜索到,说明元素不存在。

图中你会看到整个链表的左侧和右侧还有一个绿色和红色的节点,我们一般称为哨兵,和之前讲的链表的哑节点起到一样的作用,帮助我们用更统一、更简短的逻辑来处理边界条件。

看到这里,你应该很容易直观地感受到采用分层链表在检索上的优势吧。本质上来说,高层的链表和线性索引的原理是很像的,我们就是通过为原始的链表增加了不同层的索引,起到了和平衡二分搜索树一样的快速搜索的效果。

跳表实现原理

设计想法很好,现在真正的问题来了:我们如何维护这样的多层链表结构?如何在合适的时机里加入新的层,以保证既可以高效查询,又不至于带来太高的维护成本呢?

跳表节点定义

我们先回答第一个问题,跳表节点的基本数据结构。

由于搜索是从高层往底层进行的,基本上就是一个从左上到右下的过程,所以跳表的每个节点,至少需要一个右向指针和一个可以表示层的数据结构。

如果简单地用一个链表表示,那就还需要引入一个下向的指针。当然,跳表需要存储元素,通常是一组键值,键是任意可比较的类型。为了方便实现,我们就假设元素只存储键,且必须是int类型,直接用val来表示。

写成C++的话,整个跳表节点的定义如下:

1
2
3
4
5
6
7
8
9
struct Node
{
// 至少需要向右、向下指针
Node* right;
Node* down;
int val;
Node(Node *right, Node *down, int val)
: right(right), down(down), val(val){}
};

有了这样向下和向右的指针,我们就可以维护整个多层的跳表结构了,也可以顺着指针进行前面说的查找过程了。

整个从左上到右下的搜索过程翻译成代码如下,我写了比较详细的注释供你参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool search(int target)
{
Node *p = head;
while(p)
{
// 左右寻找目标区间
while(p->right && p->right->val < target)
{
p = p->right;
}
// 没找到目标值,则继续往下走
if(!p->right || target < p->right->val)
{
p = p->down;
}
else
{
//找到目标值,结束
return true;
}
}
return false;
}

完美跳表

继续看第二个问题,跳表每一层间距到底是多少合适呢?

其实最理想状态下,跳表所用的存储空间和查询过程,应该和二叉树是非常像的,我们会要求每一层都包含下一层一半的节点,且同一层指针跨越的节点数量是一样的。

所以基于和二叉树一样的原因,层数一共是logN层,在每一层中,我们最多只会进行一次跳跃,这是因为如果需要跳跃两次的话,我们在上一层判断的时候就会选择直接右跳,而不是下跳。因此每一层我们最多访问两个节点。整体搜索时间复杂度为O(logN),我们上面举的例子其实就是一个完美跳表。

但是完美跳表有一个非常显著的问题:在有序集合动态插入和删除的过程中,我们很难高效地维护这样的结构。比如下图也是一个完美跳表,满足每一层的节点数量是下一层的一半,且中间的每个元素都被上一个例子所包含。

图片

所以我们可以认为,这个状态是上一个例子的前置状态之一,也就是说在这个跳表中,只需要顺次添加76、87、91三个元素,理论上就应该得到上一个例子的跳表。

但是事实上你会发现,如果要维持完美列表,每一层的间距是一样的,我们就需要不断地调整每一个节点的层数,因为这个层数完全取决于该节点处于链表中的第几个位置。比如上图中的96的层数就有所变化。

随着不同的插入顺序,我们最差可能需要在某次插入中重置大部分节点的指针关系,这样的更新的维护成本显然不满足我们的期望,在引入了完美跳表的约束后,链表的插入、删除优势荡然无存。那怎么办呢?

引入随机性

关键就是我们需要放弃不同层数里严格倍增的节点数量约束,而只是让每一层的节点数量,在期望上,满足均匀分配和倍增的关系即可。这样从时间复杂度和空间复杂度上来说,我们的期望值其实不会有变化,只是会有一定的小波动。

还记得快排吗?其实也是有一定随机性的算法,虽然最差的时间复杂度是O(n*n),但可以认为这样极度劣化的情况基本不会发生,所以我们认为快排的时间复杂度仍然为O(n*logn)。这里引入随机性的跳表也是一样的情况。

跳表和随机性相关的地方主要体现在插入过程。假设需要插入的节点值为val,具体过程我们先梳理一下,后面会结合具体例子加强理解。

首先,我们进行一遍查找过程,也就是根据三个分支条件判断要么返回,要么向下移动,要么向右移动,直到找到某个次小于且最接近于val的节点。

其次,在搜索过程中,我们需要记录一下搜索路径,这个和DFS中记录路径的方式是一样的,每进到下一层前,把当前节点推入一个数组即可。

最后,随着搜索结束,我们一定会停留在跳表的最底层,且搜索指针指向的是最接近于目标值的节点,这个时候就需要进行真正的插入操作了。

为了保证每一层的节点数量从期望上来说是上一层的两倍,每次插入一个节点的时候,我们可以采用抛硬币的策略,通过50%的概率决策,决定是否需要继续将这个插入到更高的一层。由于我们记录了整个路径,插入上一层的实现,也就是简单将一个新的节点插入到路径里上一层节点的右侧。简单算一下你就可以发现每个节点插入时在每一层的概率分别是:

  • 第一层时100%会被插入(所有节点都出现在第一层)
  • 第二层只有1/2的概率会被插入
  • 第三层是1/4的概率会被插入

基于这样的策略,从期望上来说,层与层之间的节点数量自然就会满足期望倍增约束,且每个节点都不会有任何优待,每一层节点间的间距也比较均匀。到这里,多层的跳跃表结构也就完成了,在实际应用中达到了不输于红黑树的查询、插入、删除的效率。

看个例子

我们来看一个具体例子理解一下这个过程,比如我们希望插入val=87的元素:

图片

第一步当然就是要做搜索,找到链表中离87最近的86,并记录路径上每一层的节点:

图片

其次,需要将新建的87节点插入到86的右侧。按照抛硬币的策略,我们决定要不要往上一层继续添加该节点。比如我们连续抛了两次正面的硬币,第三次抛了反面,最终就只在1、2、3层中添加了87节点

图片

整个过程写成代码也不难,思路和前面说的完全一致,代码和注释供你参考。这里唯一需要注意的点就是,如果已经超过了目前的层数,但是抛硬币的结果还是正面,我们会在跳表中新建一层,其右节点为空,也就是该层只有这一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void add(int num) {
// 从上至下记录搜索路径
pathList.clear();
Node *p = head;
// 从上到下去搜索 次小于num的数字
while(p)
{
// 向右找到次小于num的p
while (p->right && p->right->val < num)
{
p = p->right;
}
pathList.push_back(p);
p = p->down;
}

bool insertUp = true;
Node* downNode = NULL;
// 从下至上搜索路径回溯,50%概率
// 这里实现是会保证不会超过当前的层数的,然后靠头结点去额外加层, 即每次新增一层
while (insertUp && pathList.size() > 0)
{
Node *insert = pathList.back();
pathList.pop_back();
// add新结点
insert->right = new Node(insert->right,downNode,num);
// 把新结点赋值为downNode
downNode = insert->right;
// 50%概率
insertUp = (rand()&1)==0;
// cout << " while new node " << num << " insertUp " << insertUp << endl;
}
// 插入新的头结点,加层
if(insertUp)
{
// cout << " insertUp new node " << num << endl;
head = new Node(new Node(NULL,downNode,num), head, -1);
}
}

删除的过程呢?相比于插入就简单很多。我们同样需要先找到该节点,但指针还是始终指向目标节点的左侧节点。在每层中,发现目标节点存在后,用当前节点指向右侧节点的右侧节点,和删除链表节点的写法是一致的,然后在后面的每一层都进行同样的操作,直到遍历完成。

总结

今天我们一起学习了Redis中有序集合的底层实现:跳表,作为字典类数据结构,它有着和红黑树、哈希表都不同的实现方式。

跳表,和红黑树一样,都提供了O(N)的空间复杂度,O(logN)的插入、查询、删除的时间复杂度,但实现起来比红黑树简单很多,通过引入随机性,我们只需要搜索并记录路径,就可以在保持跳表查询效率的同时,快捷地插入元素。这个操作比红黑树的旋转操作要简单很多。

虽然单点查询的效率确实不如哈希表,但跳表可以很好地支持范围查询,只要找到对应的范围节点,然后顺次在链表上遍历就可以了,这一点比红黑树也有明显优势。

可以认为在大部分方面,跳表都是非常有优势的有序集合实现方式,引入随机性从期望上保证效率、降低维护成本的思想也值得你好好体味。在力扣上的1206.设计跳表就是一个实现跳表的题目,你可以去上面试一试,平台给你提供了丰富的用例,能帮助你快速判断实现的正确性。

课后作业

今天的作业就是尝试实现一下跳表的删除操作,当然直接实现整个跳表是更好的,能帮助你复习一下链表的许多操作。

欢迎你在留言区留下你的实现,我们可以一起讨论。如果觉得这篇文章对你有帮助的话,也欢迎把这篇文章转发给你的朋友一起学习,我们下节课见~

32|时间轮:Kafka是如何实现定时任务的?

作者: 黄清昊

你好,我是微扰君。

今天我们来聊一聊日常开发中非常常见的技术需求:延时队列。

之前在学Kafka二分搜索的时候,我们已经学过了消息队列,它是一个用于传递消息的组件,大部分场景下,我们都希望消息尽快送达,并且消息之间要严格遵循先进先出的约束。但在有一些时候,我们也会希望消息不要立刻送达,而是在一段时间之后才会被接收方收到,也就是延后消息被处理的时间,像这样的场景就是“延时队列”。

常见业务场景

延时队列的应用非常多。我们回想一下有哪些业务场景,比如一个网上售卖电影票的平台,用户在买票的时候肯定要先选好位置,也就是说用户下单一张电影票有两个动作:选位置、付费。

我们不希望用户在付费的时候发现,自己选好的位置被别人买了,所以往往会在用户选定座位之后,就把这个位置锁定;但这个时候用户还没有付费,我们肯定不能让锁定一直持续下去,所以也会想要有一种定时机制,在用户超过一定时间没有付费时,在系统中自动取消这个订单,将对应的座位释放。

类似的场景还有许多,对应需要的定时周期跨度也很大。比如在云平台上如果用户资源过期,一般不会立刻清理所有数据,而会在超过一段时间之后再进行资源回收;再比如外卖平台上订单,如果快超过送餐时限了,就需要提醒外卖小哥加紧配送等等。

在这些业务场景下,一个好用的延时队列应该具备什么样的功能呢:我们只需要把任务和期望的执行时间存储到队列中,等到指定的时候,任务消息就会通过队列被发送给需要执行任务的主体,比如某个订单服务,让主体执行

当然,我们也可以把队列直接实现在业务里,但是延时特性和具体业务无关,其实是一个完全通用的技术方案,所以一般会用通用的中间件来处理这样的问题。

Kafka就是一个非常好用的选择,作为一款高性能的消息队列,Kafka天然支持了延时消息的能力,可以帮助我们处理所有的延时场景下的问题。其实上一讲我们介绍的Redis中的ZSET,也是一种实现延时队列的常见手段。

不过在学习基于ZSET的实现之前,我们先从更简单的实现学起,边学边思考这些常见实现的场景和原理差异是什么。

JDK中的DelayedQueue

除了上面说的业务场景,在一些纯技术的领域,定时任务的需求也非常普遍,比如Linux下就支持了定时任务调度的功能。如果你熟悉Java,估计会想到JDK也默认支持了DelayedQueue的数据结构。

DelayedQueue,作为JDK原生支持的数据结构,能非常方便地帮助我们支持单机、数据规模不大的延时队列的场景。它的实现思路也是一种非常典型的延时队列实现思路,事实上也经常是面试官常考的八股文之一,值得我们好好掌握。

DelayedQueue实现延时队列的本质,是在内存中维护一个有序的数据结构,按任务应该被执行的时间来排序。对外提供了offer和take两个主要的接口,分别用于从队列中插入元素和请求元素。

  • 在插入元素时,既然是所谓的延时队列,我们会插入一个带执行时间的任务,底层会对这些任务进行排序,保证队列最前的任务是最快到期的;
  • 调用take接口后,会有一个线程检查头部元素,如果队列头的任务没有到期,我们就阻塞这个线程,直到任务到期,再唤醒这个线程;如果检查头部的时候任务已经到期,我们就会让这个消费进程真的从队列取出该元素,并执行。

图片

为了提高效率,DelayedQueue底层还采用了一种leader-follower的线程模型,也非常常用,你可以理解成任务的执行会有多个线程进行,参考示意图,这样任务的具体执行和到期时间的检查就不会产生冲突,可以并行地进行。

分析清楚了设计思路,那DelayedQueue底层是如何对任务做有序排列的,用的是什么数据结构呢?你可以先猜想一下。

链表?数组?事实上,这里的有序排列并不会像你想的那样从头到尾维护一个线性的序列。我们之前也讲过,如果维护一个线性的序列,不管是链表还是数组,排序的时候都需要O(n*logn)的时间复杂度;而在这里我们所需要的其实只是判断整个队列中,最接近到期的那个任务的执行时间,是否已经被当前系统时间所超过。也就是说并不需要整个队列有序,只需要最值

这不正是堆这个数据结构的长处嘛?所以DelayedQueue底层的存储结构正是堆。

由于整个数据结构都维护在内存上,也没有线性扩展性,空间上会受一定的制约,但从时间效率上来说,DelayedQueue还是一个非常不错的延时队列实现,特别适合在业务层面上直接解决一些规模不大、比较简单的延时队列场景。具体的代码可以直接在JDK中找到,感兴趣你可以自己研究。

学完DelayedQueue, 我们再来看Redis中的ZSET是如何实现延时队列的,对比理解。

Redis中的ZSET

底层基于跳表(上节课讲过),Redis的有序集合性能非常不错,而且Redis本身是一个稳定、性能良好且能支持大量数据的KV存储引擎,用来实现延时队列自然比基于DelayedQueue的本地实现适用场景更大。

借助ZSET来实现延时队列,本质思想和DelayedQueue是类似的,主要就是我们会用ZSET来维护按任务执行时间排列的数据结构。

在使用ZSET做延时队列的时候,一般会用任务ID作为key,任务详情作为value,任务执行时间作为score,这样所有的待执行任务,在ZSET中,就会按任务执行时间score有序排列。

图片

在需要被调度的延时任务执行主体上,我们可以开启一个线程定时轮询 ZRANGEBYSCORE KEY -inf +inf limit 0 1 WITHSCORES 查询ZSET中最近可执行的任务:

  • 如果发现任务时间戳仍然大于当前时间戳,说明没有任务过期,什么都不执行;
  • 如果发现任务时间戳已经小于当前时间戳,说明任务已经可以执行,我们按照约定的协议执行就可以了。

当然,这里Redis里存储的任务详情其实就是个值,我们需要按照自己的场景序列化和反序列化。写成Java代码大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void poll() {
while (true) {
Set<Tuple> set = jedis.zrangeWithScores(DELAY_QUEUE, 0, 0);
String value = ((Tuple) set.toArray()[0]).getElement();
int score = (int) ((Tuple) set.toArray()[0]).getScore();

Calendar cal = Calendar.getInstance();
int nowSecond = (int) (cal.getTimeInMillis() / 1000);
// 任务已经过期;可以执行
if (nowSecond >= score) {
jedis.zrem(DELAY_QUEUE, value);
// TODO:执行任务
}
// 队列为空
if (jedis.zcard(DELAY_QUEUE) <= 0) {
return;
}
Thread.sleep(1000);
}
}

不过在这种方案中我们需要主动轮询,这会带来一定的开销,也有一定的精度问题,毕竟最小的粒度就是轮训的时间间隔。

既然引入了精度的问题,那我们有没有什么更好的方式呢,尤其是在有大量超时任务的场景下,有什么办法可以进一步优化超时任务的调度呢?

时间轮

这就是时间轮算法的用武之地了。在Kafka、Netty、ZooKeeper等知名组件中都有用到时间轮算法,可以说是久经考验。

思路其实很简单,如果用排序来类比的话,刚才JDK中基于堆的实现当然就是堆排序,永远可以拿到最快要过期的任务;那为了维护有序性,我们是不是也可以用类似桶排序的思想呢?

这正是时间轮的本质。

  • 把任务按时间分成不同的槽(bucket),每个槽位里放着任务的列表,通常采用一个双链表来实现;
  • 把槽位加在一起,构成一个循环队列,底层用数组实现;
  • 一个槽代表一个时间跨度,每个槽内队列中存储的任务就都是在这个跨度内应该被执行的任务。

这样整个时间轮看起来就像一个时钟。

图片

我们还会有一个类似于秒针的指针,以槽位时间跨度为周期固定地转动,就像秒针一样,永远指向当前时间所应该对应的槽位,当然在这里精度不是秒,而是槽位的时间跨度。比如我们期待任务调度的精度是一分钟,在图中就可以让每个槽位代表的时间为60s,这样我们就可以很确定的在时间轮里表示600s也就是10分钟内的任务,每隔1分钟,就将当前的槽位指针+1,指向下一个槽位,并判断槽位中是否有任务需要执行

槽位编号更小的任务,自然就会得到更先的执行,从而就实现了在某个精度下定时任务或者延时任务的需求。

这里的精度也可以调整,时间轮的整个时间周期除以刻度数量,就是我们最小的任务调度的精度,在不同的场景下,可以设计不同的时间轮刻度。比如以24小时、以秒,甚至毫秒为刻度都是可以的,当然精度越高,我们所需要的成本也就更高。

对比思考之前的实现,时间轮方案很大的提升就在于,我们大大减少了任务插入和取出时的锁竞争。相比于只维护一个堆,让所有的线程并发修改,在时间轮中,我们可以将锁的粒度减少到以刻度为单位,大大减少了锁冲突的可能性,取出任务时也只要从槽位中直接遍历,避免了从堆或者其他有序结构中取出元素和调整的开销。

当然这里还有个问题需要处理。比如在600s的时间轮中,我们不难发现601s的任务和9s的任务在同一个时间轮的槽位里,因为601s已经超过600s了,由于循环队列的特性,它会又一次被加入到第一个槽中。

不过这个问题也很好处理,只需要多判断一次当前时间和槽位中时间的关系就行,如果发现是601s或者更后期的任务,直接跳过即可。我们可以类比Hashmap冲突的情况,相信你很容易想明白其中的思想,无非就是遍历链表进行判断。

总结

今天我们一起学习了延时队列的底层实现方式和应用场景。

JDK中的DelayedQueue,以及借助Redis中ZSET的实现方式,两者总体思路比较相似,都是通过某种数据结构,来维护按任务执行时间排列的任务集合,然后定时或者轮询地去判断最接近过期的任务是否已经过期,选择执行或者继续等待。

当然单机的JDK可以更好地利用系统内置的定时机制,避免轮询的成本,不过也因为单机本身的限制,不能很好的扩展来支持海量的数据场景。

第三种实现方式,时间轮,是一个巧妙又高效的设计。牺牲了一定精度,但通过在内存中以循环队列的方式维护任务,降低了任务并行插入的锁竞争,也减少了取出任务的时间复杂度,特别适用于大量定时任务存在的场景,也因此成为Kafka实现延时队列的一种常用方式。

总体来说,这几种方式各有利弊,你可以好好体会一下其中的差异,结合自己的业务场景做一些选型的思考。

课后作业

今天的课后作业是时间轮的实现,整体思路不难,不考虑并发的场景下100行左右的代码就可以完成了,这也是面试官常考的题目之一,值得好好练习。

欢迎你在评论区留下你的代码作业,一起讨论。如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习,我们下节课见~

33|限流算法:如何防止系统过载?

作者: 黄清昊

你好,我是微扰君。

上一讲我们学习了业务场景中频繁会使用到的延时队列,能帮助处理很多业务上的定时任务问题,因为这个组件的功能和具体业务往往没有关系,我们通常会利用各种中间件来实现延时队列的能力。

今天我们来探讨另外一个算法的原理和实现,它也和业务本身没有强关联,但是在各个业务场景下都非常常见,那就是限流算法。

限流算法,也被我们常称为流控算法,顾名思义就是对流量的控制。日常生活中有很多例子,比如地铁站在早高峰的时候,会利用围栏让乘客们有序排队,限制队伍行进的速度,避免大家一拥而上,这就是一种常见的限流思路;再比如在疫情期间,很多景点会按时段限制售卖的门票数量,避免同一时间在景区的游客太多等等。

这些真实生活中的方案本质都是因为在某段时间里资源有限,我们需要对流量施以控制。其实同样的,在互联网的世界里,很多服务,单位时间内能承载的请求也是存在容量上限的,我们也需要通过一些策略,控制请求数量多少,实现对流量的控制

当然在工程中,“流量”的定义也是不同的,可以是每秒请求的数量、网络数据传输的流量等等,所以在不同的场景下,我们也需要用不同的方式限制流量,以保证系统不至于被过多的流量压垮。虽然,限流一定会导致部分请求响应速度下降或者直接被拒绝,但是相比于系统直接崩溃的情况,限流还是要好得多。

业务中的限流场景

好,现在相信你明白限流非常重要了,那在我们的开发中有哪些场景需要考虑限流呢?

一个非常典型的例子就是之前在TCP中提到的拥塞控制算法,这可以被认为是一种经典的限流算法,其中借助窗口控制流量的思路也是限流算法中非常常用的。

但是对于我们大部分业务开发工程师来说,肯定不会去修改TCP协议了,还有哪些典型的业务中的限流场景呢?

最常见的就是三种情况:

  • 突发流量
  • 恶意流量
  • 业务本身需要

突发流量

先放两张图,相信你看一眼就知道突发流量要说什么了。

图片

没错,我们最经常听到的服务器大规模崩溃的场景就是两个:“某个突发新闻出现,微博平台又崩了”、“双十一来了,买买买,诶,某电商平台出现故障”。

这正是我们需要限流的主要场景之一。面对业务流量突然激增,因为后端服务处理能力有限,遇到突发流量时,很容易出现服务器被打垮的情况。

在这个情况下,除了提供更好的弹性伸缩的能力,以及在已经能预测的前提下提前准备更多的资源,我们还能做的一件事就是利用限流来保护服务,即使拒绝了一部分请求,至少也让剩下的请求可以正常被响应。

恶意流量

除了突发流量,限流有的时候也是出于安全性的考虑。网络世界有其凶险的地方,所有暴露出去的API都有可能面对非正常业务的请求。

比如各种各样的爬虫,或者更直接的恶意攻击,都可能会在很短的时间里,大规模的疯狂调用我们服务对外暴露的接口,这同样可能导致服务崩溃,在很多时候也会导致我们需要的计算成本飙升,比如云计算的场景下。

业务本身需要

第三种情况也很常见,就是业务本身的需要。

最典型的,现在很多云产品都会推出不同等级的服务:

图片

云产品业务,本身就会对每个客户使用产品流量的情况进行限制,客户采购等级越低的服务,支持的TPS数量就会相对来说低一些。这不只是出于成本的考虑,也出于商业利益的考虑。

总的来说,这三种情况,都需要有一个有效的流量控制算法,来支持我们的需求。那如何才能准确的限制流量呢?这就是我们的限流算法要考虑的问题了。

如何准确限流

接下来,我们就一起来看一看常见的限流算法,主要包括四种:基于计数的限流算法、基于滑动窗口的限流算法、漏桶算法、令牌桶算法。其实大部分都比较好理解,后面会着重讲解令牌桶算法及其代码实现。

基于计数的限流器

一说到限流,以限制请求次数为例来考虑,我想你一定有一个非常直观的想法,就是直接用计数器来维护一段时间内服务被请求的数量。

比如希望限制系统每分钟处理的请求数量,在100次,那我们只需要在内存中维护一个计数器,每次来一个请求,就对这个计数器+1,另外每过一分钟,也会定时把内存中的计数器清零。

图片

当请求来的时候,如果发现计数器的值已经超过100次,就会直接拒绝这次请求。显然,这样我们就可以严格地控制每分钟内的请求都不会超过100次了。

当然这样只能做到单机的限流,如果希望对某个集群限流,我们自然需要引入一个外部的存储。Redis就很适合这个场景,每次修改访问计数器的时候,就去修改Redis,这样我们就可以做到基于计数的分布式限流了。

这个方法,当然是既简单又直观,但有一个很大的问题:我们没法细粒度地控制流量在定时区间内的平滑性,流量依旧可以出现尖刺。比如同样是1s内请求100次,在这1秒的前10ms就请求100次,和这1秒内均匀请求100次,两者带来的服务器的瞬时压力是截然不同的。而100次每分钟的约束,在流量不均匀分布的时候也很容易遭到破坏。

我们把这个问题称为临界问题,看一个典型的例子:

图片

在每分钟100次请求的限流器中,如果在59秒和1分整这两个时刻内,用户快速请求了200次请求,那么在这2秒内,用户其实就进行了200次请求,而我们建设这个限流器的预期是每分钟最多接受100次请求,这显然和我们的预期有着巨大的鸿沟。

怎么办呢?我们分析一下问题,本质其实就在于这样固定周期清空计数器的方式精度太低,不能区分1分钟内均匀请求100次,以及只在1分钟内很小的一个时间窗口里请求100次,这两种情况。

那如何提高精度呢?

基于滑动窗口的限流

TCP中的滑动窗口思想就可以被我们借鉴了。

在刚才的例子里,既然,以分钟为单位直接统计访问次数粒度太粗,我们可以把统计计数器的区间变小一些,比如以10秒为一个区间,每个区间独立统计落在区间内的请求数量。

图片

现在如何去限制一分钟整体的请求数量呢?

很简单,我们遍历过去一分钟内每个独立区间,也就是每10秒内的计数器的计数总和,这个总和当然就是过去一分钟内全部的请求数量了。当然每次经过10s,我们也自然需要把整个计数区间往右边移动一格。在图中的体现就是,当时间经过1:00的时候,整个限流器的计数器范围就去掉了最左边0:00-0:10的区间,增加了橙色的格子,也就是1:00-1:10的区间,这样的过程也就是我们通常所说的“滑动窗口”了。

所以,刚才简单计数的限流算法,它不能正确拒绝的00:59和01:00连续两秒内请求200次的情况,在现在的滑动窗口下显然就会被拒绝了。

在这个思路下,想要进一步提高被控制流量的平滑性,就需要不断增加窗口的精度,也就是缩小每个区间的大小。

但这样也会带来更多的内存开销,那有没有什么更好的方式可以帮助我们获得理论上更平滑的流量控制能力呢?

漏桶算法

漏桶算法就是这样一种非常平滑的流控方式,它可以严格控制系统处理请求的频率。

漏桶也就是leaky bucket,名字非常直观,本质就是用计算机模拟一个有漏洞的水桶,只要漏桶中有水存在,水桶的漏洞处就会稳定的有水流漏出。

我们的流量,比如说请求,就像是一个往水桶里不断加入水的水管,水管的流量自然是可以有波动的,可以时快时慢,当漏桶已经被装满的时候,拒绝请求就可以了,就像让水不溢出一样。看这张示意图:

图片

只要我们能让漏洞漏水的消费速度可控、稳定,整个系统只处理漏桶漏出的稳定流量,自然就可以达到限流的效果

具体如何实现呢?在看到上面对漏桶性质的描述之后,不知道你有没有想到消息队列削峰填谷的特性,这两者的本质其实一样的。所以,漏桶最简单的一种实现正是基于队列。

我们用一个FIFO队列模拟水桶,队列的容量就是水桶的最大大小。每来一个请求,就存储到队列中,如果队列满了,就直接拒绝。而另一侧,我们会有一个线程稳定消费队列中的数据,这样,整个系统不管在请求流量多么不稳定的情况下,都可以维持一个非常稳定的流量处理速度。思路非常清晰也很简单,你可以试着实现一下具体的逻辑。

那漏桶算法有没有什么问题呢?

其实从流量平滑的角度来看,已经没什么可挑剔的了,但计算机的trade off无处不在,漏桶这样严格完美的流量限制策略,也使得它完全放弃了应对突发流量的能力,因为在遇到突发流量时,漏桶的处理和平时并没有区别。

但是大部分服务,短时的处理压力增大,并不会导致整个系统崩溃,很多时候我们也希望在面对突发流量的时候,系统可以稍微提高一下自己的处理速度,以获得更好的用户体验。有没有什么办法呢?

令牌桶算法

这就要提到我们今天最后一个要学习的算法——令牌桶,它也是各个系统中最常见的一种限流策略。

和漏桶一样,令牌桶也采用了桶的模型,不过不同的是,这次我们不再是让系统处理定速从漏桶中流出的流量,而是改成把令牌以稳定的速度放入桶中。桶中令牌数会有一个上限,所以如果令牌桶已经被放满,多余的令牌不会被继续放入了。

每来一个请求,必须先去令牌桶里申请令牌,申请到令牌的请求才能被服务器处理,否则,我们也会拒绝对应的请求。

图片

这个方案流量限制的核心就在于:令牌桶中令牌的数量是有限的。如果在一段时间内,请求的速度都是高于令牌放入的速度,令牌桶中很快就会没有令牌可用了,服务就会拒绝一部分请求,并保证系统处理的流量在我们的控制范围内

好,了解了设计思路,现在我们来看刚才说的面对突发流量,想提高系统短时处理速度的问题。

对比漏桶,令牌桶,在请求流量低的时候,令牌数会慢慢增加直到放满,那么在遇到突发流量时,通常令牌桶内会有一定的令牌数,在这个限度内的请求,即使短时请求速度很快,我们也不会拒绝对应的请求,这就保证了我们在控制流量的同时,也有了一定的突发流量的应对能力。

基于同样的道理,令牌桶自然也可以基于队列实现。用队列存储令牌,每来一个请求就从队列中取出一个令牌,队列为空的时候则拒绝请求,另一边用一个线程稳定的向队列里放入令牌,在队列满的时候停止放入即可。

不过事实上,我们 不需要真的在内存里维护这样占据内存空间的队列,可以采用计算时间的方式来模拟这个过程,核心思路就是:既然我们知道令牌放入的速度,那完全可以通过上一次请求到达的时间和这次请求到达的时间差,判断请求来之前我们又多出来的可用的令牌数量。

用代码实现并不难,大约50行左右。你可以参考我写的详细的注释,相关代码在GitHub上也能找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package ratelimit

import (
"fmt"
"sync"
"time"
)

type RateLimiter struct {
rate int64 // 令牌放入速度
max int64 // 令牌最大数量
last int64 // 上一次请求发生时间
amount int64 // 令牌数量
lock sync.Mutex // 由于读写冲突,需要加锁
}

// 获得当前时间
func cur() int64 {
return time.Now().Unix()
}

func New(rate int64, max int64) *RateLimiter {
// TODO: 检查一下rate和max是否合法
return &RateLimiter{
rate: rate,
max: max,
last: cur(),
amount: max,
}
}

func (rl *RateLimiter) Pass() bool {
rl.lock.Lock()
defer rl.lock.Unlock()

// 距离上一次请求过去的时间
passed := cur() - rl.last
fmt.Println("passed is: ", passed)

// 计算在这段时间里 令牌数量可以增加多少
amount := rl.amount + passed*rl.rate

// 如果令牌数量超过上限;我们就不继续放入那么多令牌了
if amount > rl.max {
amount = rl.max
}

// 如果令牌数量仍然小于0,则说明请求应该拒绝
if amount <= 0 {
return false
}

// 请求被放行则令牌数-1
amount--
rl.amount = amount
// 更新上次请求时间
rl.last = cur()

return true
}

总结

我们学习了四种常见的限流算法:基于计数的限流算法、基于滑动窗口的限流算法、漏桶算法、令牌桶算法。

从递进的顺序来看,你很可能会觉得令牌桶算法要比前面的算法都好,而时间窗口是一种不够优秀的算法。但事实上,这几种算法其实都有各自的长处。计算机的世界里到处都是trade off,魔法并不存在,要始终铭记在权衡方案时,如果我们在某些方面获得了一些好处,那就一定要警觉在另一些方面是否付出了一些代价,以及代价是否可承受

比如,滑动窗口,虽然会产生一定的尖峰,且需要比较大的内存开销,但是一旦请求来了,要么会被立刻拒绝,要么会被立刻响应,不会有太大的延时。

而漏桶会维护一个队列,导致没有被拒绝的请求,真正被执行的时间可能会比较靠后,这就可能产生较大的时延,在时间敏感的场景下,漏桶就不太合适。在一些令牌桶的实现中,也会有一个队列缓冲部分没有令牌的请求,这些请求的处理也同样会产生比较大的时延。所以,令牌桶和漏桶其实更适合后台任务这样可以接受一定时延的场景。

在不同的场景下,我们可以选择不同的限流实现,当然在生产环境中相比于自己动手实现,采用成熟的中间件或者类库,当然是更稳妥的选择。Google 的 Guava 库就提供了基于令牌桶的实现,Nginx和Resty这样的网关代理组件也都有相关的实现,可以择优选用。

课后习题

请你实现一下漏桶算法,并思考除了用FIFO队列的方式,还有没有什么其他内存使用更少的实现方式呢?

欢迎你在评论区留下你的代码和思考,一起参与讨论,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见~

34|前缀树:Web框架中如何实现路由匹配?

作者: 黄清昊

你好,我是微扰君。

不知不觉,已经到工程实战篇的最后一讲了,在这个章节中,我们一起学习了很多工程中常用的算法,如果你从事后端开发,应该或多或少有些接触,比如在Redis、Kafka、ZooKeeper等常用中间件里就经常出现,理解它们的核心思想,能给你的工作带来很大的帮助。

今天,我们最后来聊一聊大部分Web开发工程师都会用到的后端Web框架中的算法。

路由匹配

Web框架的作用,我们都知道,主要就是封装Web服务,整合网络相关的通用逻辑,一般来说也就是帮助HTTP服务建立网络连接、解析HTTP头、错误恢复等等;另外,大部分框架可能也会提供一些拦截器或者middleware,帮助我们处理一些每个请求可能都需要进行的操作,比如鉴权、获取用户信息。

但是所有Web框架,无论设计得多么不同,必不可少的能力就是路由匹配。

因为我们的Web服务通常会对外暴露许多不同的API,而区分这些API的标识,主要就是用户请求 API的URL。所以,一个好用的Web框架,要能尽可能快地解析请求URL并映射到不同API 的处理逻辑,也就是我们常说的“路由匹配”

以Golang中常用的Web框架Gin为例,如果用户想注册一套遵循RESTful风格的接口,只需要像这样,写一下注册每个路由所对应的handler方法就完成了:

1
2
3
4
5
6
7
8
9
userRouter := router.Group("/users")
{
userRouter.POST("", user.CreateUser)
userRouter.DELETE("/:userID", user.DeleteUserByUserID)
userRouter.GET("/:userID", user.GetUserInfoByUserID)
userRouter.GET("", user.GetUserList)
userRouter.PUT("/:userID", user.UpdateUser)
userRouter.POST("/:userID/enable", user.EnableUser)
}

例子中userRouter就代表着和用户相关的接口,POST、DELETE、GET等方法标识着HTTP请求的method,方法里第一个参数就是路由具体的值,也就是URL的值,而第二个参数是一个方法,可以用来实现不同接口的处理逻辑。

这样的路由功能我们是如何实现的呢?

动态路由

每个 HTTP请求都会带上需要访问的URL,Web框架,其实也就是根据这个信息,再通过在用户写出的代码中注册的路由和handler的关系,找到每个请求应该调用的处理逻辑。

所以,如何保存路由和处理方法的对应关系呢?

初看这个问题,估计你一定会有一个非常直接的想法,采用HashMap来存储路由表吧,这样索引起来非常高效。

但是事实上主流的Web框架都不会这样做,因为利用哈希表存储的路由和处理逻辑的关系,只能用来索引静态路由,也就是路由中没有动态参数的路由,比如/user/enable time.geekbang.org/hybrid/pvip ,这样的路由,路径是明确的,一个路由只有一种可能性。

但是在Web开发中,我们经常需要在路由中带上参数,这也是RESTful风格的接口所要求的。

最常见的动态参数就是各种ID,比如极客时间的专栏URL,路由中就带了专栏ID的参数 column/intro/100100901 ,这里的100100901就是一个特定的参数。虽然参数不同,但所对应的处理逻辑实际上是一致的,在很多Web框架中,这种路由的注册方式一般是写成 /column/article/:id ,其中id的参数就是100100901或者其他不同的值,在框架的处理方法里,一般可以通过 context 之类的变量拿到。

这样的路由,就不再是单一的静态路由,而是可以对应某一类型的许多不同的路由,我们也称这种带有参数的路由为“动态路由”。

显然在这种需要支持动态路由的场景下,我们就不太能继续用HashMap记录路由和方法的绑定关系了。那动态路由如何实现呢?方式有很多种,可以用正则表达式匹配来实现,另一种更常用的方式就是我们要重点学习的Trie树。

Trie树

我们先学习一下Trie树这个数据结构。

Trie树,也称为前缀树或者字典树,是一种常用的维护字符串集合的数据结构,能用来做排序、保存、查询字符串,常用场景比如搜索引擎关键词匹配、路由匹配、词频统计和字符串排序等等。

相比于HashMap和Map这样的数据结构,Trie树有一些特别的优势,尤其是上面说的可以适应类似于动态路由匹配的场景,有着不可替代的作用。我们公司的开源产品EMQ X也有用到相关的数据结构来实现MQTT协议路由表。

先简单剧透一下,Trie树主要的特点和优势都建立基于前缀的树状存储方式上。具体是什么样的呢,我们看例子理解。

比如现在想要存储,geekgeektimegeekbanggetgo这样几个单词,在trie树上我们是怎么存储的呢?

图片

看trie树的示意图。你可以注意到,在trie树中,每个节点都代表着一个字符,且有若干个子节点,对于整个树状图来说,从根节点出发,到任意其他节点构成的路径一定构成我们要存储的字符串集中某一个的前缀,或者就是其本身。

所以,不同于同样是树状结构的二叉查找树实现的treemap,在trie树中,我们存储的字符串并不是直接存储在二叉树的节点中,而是通过节点在树中的位置表示的。我们会为trie树中的结点标记颜色。如果标记为绿色,表示根节点到当前节点的路径是一个集合中的字符串,反之,代表这个节点仅仅是某个字符串的前缀。

显然,相比于treemap来说,trie树存储的开销要小得多,并且因为它天然的前缀匹配和排序的特性,在很多时候也能帮助我们更快检索数据。最常见的比如在搜索引擎的网站中,我们有时候输入一部分内容,搜索框可能就会自动补全一些可能的选项,很多时候这个小功能的实现,用的就是前缀树前缀匹配的特性。

图片

前缀树具体如何用代码来实现呢?

前缀树实现

首先,我们还是要先用代码定义一下前缀树的结构体。

为了方便讲解,我们就假设前缀树只存储英文单词,所以我们的字符集只包括26个小写字母。那在这样的情况下,用来表示每个节点的子节点也不用开动态数组了,直接开一个26维的静态数组就可以,下标0~25正好可以对应a~z这26个字母。

同时,每个节点还需要像前面说的那样标记一下自身的颜色,我们用isKey来表示当前节点是集合中的单词还是只是某些单词的前缀。

写成C++代码如下:

1
2
3
4
5
struct trie_node
{
bool isKey; // 标记该节点是否代表一个关键字
trie_node *children[26]; // 各个子节点
};

现在有了基本的数据结构定义,我们自然也需要进行初始化、插入、查询等操作。先来看初始化和插入的过程。

一开始,我们只有一个代表空串的空节点,所以初始化的过程很简单,就是创造一个空的根节点,其子节点也都是空指针;同时因为空节点不代表任何串,isKey必然也是false。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Trie {
trie_node* root;
public:
Trie() {
root = new trie_node();
root->isKey = false;
for (int i = 0; i < 26; i++) {
root->children[i] = NULL;
}
}

/** Inserts a word into the trie. */
void insert(string word) {
trie_node* node = root;
// 循环判断单词的每个字母是否被当前节点node的子节点所包含
for (auto ch: word) {
// 不包含则需要创建
if (node->children[ch-'a'] == NULL) {
node->children[ch-'a'] = new trie_node();
node->children[ch-'a']->isKey = false;
}
// 否则将当前节点指向下一个节点继续这个过程
node = node->children[ch-'a'];
}
// 遍历完成时,当前节点的位置就是一个被包含于字符集的串;需要将标记置true
node->isKey = true;
}
}

具体的插入过程,其实就是要沿着根节点,根据插入单词每一位的字母,一路往下遍历,选择合适的分支,判断每个字母是否被trie树所包含,如果遇到尚未被包含的字母,我们需要在对应字母的位置创建节点,并循环这个过程,直到整个单词的每个字母都被添加到trie树中。

最后,记得把单词的最后一个字母节点的isKey标记置为true,表示这个单词被成功加入集合。

有了插入过程的基础,搜索的过程理解起来就很简单了,沿着trie树一路搜索单词的每个字母,直到遇到空的子节点,或者最后遍历完成发现当前节点的isKey为false,这代表所有字母虽然都在trie树中存在,但这只是某个单词的前缀,而不是全部;如果最后一个字母在trie树中且isKey为true,说明单词存在于集合中,我们找到了它。

1
2
3
4
5
6
7
8
9
10
11
/** Returns if the word is in the trie. */
bool search(string word) {
trie_node* node = root;
for (auto ch: word) {
// 字母不存在
if (node->children[ch-'a'] == NULL) return false;
node = node->children[ch-'a'];
}
// 仅仅是前缀
return node->isKey;
}

我们来简单分析一下前缀树的复杂度。

假设查询的单词平均长度为n,字符集大小为t。对于查询和插入来说,我们所做的就是遍历一遍整个单词并在树上创建节点或者移动,时间复杂度和插入单词的平均长度一致,为O(n)。

空间复杂度相对差一些,上面的实现方式里,由于我们为每个节点都开了字符集大小的数组,所以空间复杂度是O(t*N),其中N是节点的数量,最差是所有单词的长度和。

Trie树在路由匹配中的应用

好了,相信你现在已经理解了trie树是如何工作的了,那它是如何用于动态路由匹配的呢?

首先对于静态路由,相信你应该已经猜到了,我们只需要稍作调整,从每个节点表示一个字符,变成每个节点表示路由被 / 分割的一节,比如图片里,我们就存储了两条静态路由 /users/users/register

图片

而动态路由,表示起来其实是一样的,只不过,我们在匹配下一层节点的时候会优先匹配有静态路由规则的值,如果没有匹配上,同一层又有其他动态路由的占位符号,我们才会去认为对应的URL匹配的是动态路由中的动态参数。

比如/users/register肯定会匹配/users/register,但是/users/regissss/enable会匹配到/users/:uid/enable,并把regisss当成/:uid传入对应URL的handler中。

除此之外,插入和查询的过程,和前面讲的的trie树实现是一致的,感兴趣的话你可以自己动手写一个Web框架感受一下,或者去查阅一些经典路由框架的源码,一定会有很多收获的。

总结

前缀树,采用了独特的树状存储结构,是一种高效的有序集合的实现,通常集合元素存储的是字符串。但是不同于treemap直接在节点中存储键,前缀树在节点中存储的是某个串的一个组成单元,对于字符串来说通常就是一个字符;集合中的每个元素由节点在树中的位置来标记,根结点到每个标记为key的节点的路径,构成了集合中的所有元素。

也正是因为这样的特性,前缀树天然就做到了对集合的字典序的维护,特别适合各种前缀匹配的场景,在字符串检索、敏感词过滤、搜索推荐、词频统计等场景中多有应用。我们Web框架动态路由的功能也多是基于trie树实现的。另外力扣上就有一道实现前缀树的题目,你可以试着做一做。

课后思考

今天留给你两个课后思考题。

  1. 可以尝试自己实现一下trie树和基于trie树的路由匹配逻辑,看看在路由匹配的场景下,我们是否需要做一些什么不同的改造。
  2. 文中给出了一种trie树的C++实现,其中提到这种实现的空间效率不是很好,主要原因就在于我们为每个节点都开了等同于字符集大小的数组,但其中显然很大一部分都存的是空指针。你有没有什么办法优化呢,优化后会出现什么新的问题吗?

欢迎你在评论区留下你的思考,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习~

35|洗牌算法:随机的哲学,如何用程序来洗一副牌?

作者: 黄清昊(微扰君)

你好,我是微扰君。

专栏正文已经结束了,在过去一段时间里我们一起学的知识,不知道你掌握得怎么样了呢?如果还意犹未尽的话,从今天开始我们会陆陆续续聊一些其他话题,作为特别番外,希望你可以和我一起继续享受其中的思维乐趣。

今天就先从一个颇有趣味的“洗牌算法” 来开始我们的番外之旅。

“洗牌算法”,顾名思义就是给一副牌,让你用计算机打乱这副牌,这也是一道常见的算法面试题,输入一个数组,让你将数组元素进行“一定程度”的随机重排,也就是使牌组变“乱”。

乍一看你是不是觉得这个问题也太简单了,只需要一点数学基础就能写出来。但是实际上,不同的实现,效率和正确性会有巨大的差异。

那现在就让我们一起来探究洗牌算法的不同实现方式吧。

如何洗牌

首先考虑最直观的实现,就是直接模拟现实世界里人们是如何洗牌的。

生活中一种比较常见的洗牌做法就是把牌从牌堆中切出一叠,调换其在牌组中的顺序,然后重复这个过程许多次,直至牌组被打乱至不可预测的状态,我们就认为之后的发牌是具有随机性的,所以游戏可以公平的进行。

用计算机当然可以很轻松地模拟这个过程,而且相比手动一次切出一叠,用计算机我们可以更精细的每次选出两张牌直接进行位置交换,并反复进行这个过程。直觉告诉我们,经过很多次操作之后,牌就被很好的打乱了,而且因为我们是随机交换的,所以各种可能的牌组排列理论上出现的概率是差不多的。

代码写出来并不难,大概如下:

1
2
3
4
5
6
7
8
9
void shuffle(vector<int>& arr) {
int n = arr.size();
int swaps = 1000;
for (int i = 0; i < swaps; i++) {
int x = rand() % n;
int y = rand() % n;
swap(arr[x], arr[y]);
}
}

但这个算法真的是正确的吗?

我们需要打乱多少次才能确保牌真的是乱的呢?这里我们选的交换次数是1000次,对于一副扑克牌来说肯定是够了,但如果只交换5次,显然没有办法覆盖所有可能出现的排列方式,那交换的次数到底如何取就是一个问题,这个数字显然会和牌组的大小有关。

图片

不过,在讨论需要多少次才能真正打乱数组之前,我们首先要来明确定义一下“乱”这个词,毕竟这不是一个很严谨的数学描述,无法量化,在计算机的世界里,我们当然更青睐精确的描述。

对于洗牌算法来说,“乱”可以这样定义:随机生成数组的一种排列,使数组的所有排列情况都能以等概率的方式被选出来,并且我们的方案需要覆盖所有的排列方式。

有n个元素的数组,通过排列组合的知识,我们知道一共有 n! 种不同的排列方式,所以我们就需要有一个算法,让每种排列方式都以 1/n! 的概率出现,或者说,让每个位置出现各个元素的概率是1/n,就可以覆盖所有排列且概率相同。

具体怎么操作呢?

在对n个不同元素进行排列时,我们可以从第一位开始枚举,等概率的选择1~n中的某一个,然后进行第二位的枚举,选择剩下的n-1个元素中的某一个……直到选取最后一个元素,这样的排列总数为n!,因为我们是完全随机选择的,不会产生某些排列出现概率高于其他情况概率的情况。

Fisher-Yates Shuffle 算法

基于这个想法,我们自然就能想到一种很直观的实现方式,Fisher-Yates Shuffle 算法。

思路就是将刚刚描述的排列过程直接翻译成代码,逐位确定数组每个位置应该选择的元素,每次选择的时候从剩余可选的元素中随机选择一个即可。

图片

为了维护剩余的元素,我们需要用另一个数组去存储剩余元素,一开始放入所有的元素,然后每次确定一位就要将该元素从数组中移除掉。

翻译成代码如下:

1
2
3
4
5
6
7
8
9
void Fisher_Yates_Shuffle(vector<int>& arr,vector<int>& res) {
&nbsp; &nbsp; &nbsp;int k;
int n = arr.size();
&nbsp; &nbsp; &nbsp;for (int i=0;i<n;i++) {
&nbsp; &nbsp; &nbsp; k=rand()%arr.size();
&nbsp; &nbsp; &nbsp; res.push_back(arr[k]);
&nbsp; &nbsp; &nbsp; arr.erase(arr.begin()+k);
&nbsp; &nbsp; &nbsp;}
}

其中 arr 就是我们待打乱的数组,res 则是最终打乱之后的排列结果。

但是相信你也看到了,这个算法的弊端很明显。数组的随机删除操作会带来O(n)的时间复杂度,所以整体的时间复杂度就是O(n^2),并且引入了额外的数组来存放候选集时,也引入了O(n)的空间复杂度。这并不是一个理想的时空复杂度。

Knuth-Durstenfeld Shuffle 算法

因此,Knuth 和 Durstenfeld 在此基础上进行了改进,采用直接在原始数组上操作的方式,避免了O(n)的额外空间开销,也把时间复杂度降到了O(n)。

做法和Fisher的本质其实是一样的,只不过在随机选择和交换的时候采取了一个小trick,我们来看具体是怎么做的。

图片

仍然逐位进行选择,但是当我们在选择第i位的时候,会假设0~i-1位已经确定,那么i的可选范围其实就是当前数组的第i~n-1位,于是我们只需要把第i位元素和第i~n-1位元素中的任意一个元素交换,就可以实现随机选择的效果

翻译成代码如下:

1
2
3
4
5
6
7
8
9
&nbsp; &nbsp; void shuffle(vector<int>& arr) {
&nbsp; &nbsp; &nbsp; &nbsp; for (int i = 0; i < n; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; int tmp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; int target = i + rand() % (n - i);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; tmp = arr[i];
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; arr[i] = arr[target];
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; arr[target] = tmp;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

这样我们就可以逐个确定每一位元素的位置,等可能的从所有的可能项中选择,保证等概率性。所需的时间复杂度为O(n),空间复杂度为O(1)。

看这个做法你可能还是有点疑惑,怎么严格证明这样的洗牌算法是等概率的呢?

其实我们只需要看每个位置出现各个元素的概率是不是1/n就可以了。这里直接引用我之前写的洗牌算法的力扣题解的证明:

先看第一个位置,由于我们是从n个元素中随机选择一个,每个元素出现的概率显然是1/n。

而对于第二个位置,我们要考虑这个元素没有出现在之前的选择中,然后是从剩下n-1个元素中随机选择一个,所以任意一个元素出现的概率是 ((n-1)/n) * (1/(n-1)) = 1/n 。

同样,第三个位置,任意元素出现的概率应该保证前面两个位置都没有选中,那是 (n-1/n)*(n-2/n-1)*(1/n-2) = 1/n 。

依次递推,所以你会发现每个位置任意元素出现的概率都是相等的,1/n 。

这就可以严格的证明我们的算法是正确的。

那事实上,在我们日常业务开发中,这种需要随机的场景其实也是很多的。

比如知名的注册中心 Eureka,它客户端侧的负载均衡就是基于洗牌算法实现的,大致的做法就是把每个客户端维护的服务器的IPList打乱,然后尝试逐个请求服务器的接口,直至请求成功。

在 java.util.Collections 里也有内置的 shuffle 函数(也就是洗牌算法)用于打乱数组元素,事实上最新的 Eureka 里的随机负载均衡策略也是用JDK内置的 shuffle 函数实现的,感兴趣的话,你可以看具体代码

1
2
3
4
5
6
7
8
public static <T extends EurekaEndpoint> List<T> randomize(List<T> list) {
List<T> randomList = new ArrayList<>(list);
if (randomList.size() < 2) {
return randomList;
}
Collections.shuffle(randomList,ThreadLocalRandom.current());
return randomList;
}

核心逻辑非常简单,就是简单的调用shuffle函数,将实现注册的IP列表通过randomize函数打乱,之后逐一请求。

总结

我们今天学习了三种不同的洗牌算法思路:每次随机选出两张牌交换,然后不断重复这一过程、Fisher-Yates Shuffle 算法、Knuth-Durstenfield算法。

第一种是源于日常生活中的观察,但是它不能说是正确的洗牌算法,根本原因是因为对问题没有清晰的定义,比如对于打乱的结果“乱”到底如何量化?这个问题不解决,我们就没法确定自己的算法是否是正确的。

而我们在解决计算机问题的时候,很多时候是需要去寻求本质解的。后两种思路,在找到了精确的定义之后,就可以让算法能保证数组每种排列出现的概率是等可能的了。

Knuth-Durstenfield算法是对Fisher-Yates算法的一种优化。在日常开发中,我们也需要分析每个可能产生性能开销的地方,然后尝试是否有优化的可能,这个时候可能需要一些tricks,除了灵光一现,也需要一些经验。不断学习各种算法就是一种非常好的积累经验的方法。

课后思考

如果别人提供了一个洗牌算法的实现,你有没有什么办法可以大致验证这个算法的正确性呢?

欢迎在留言区留下你的思考,和我一起讨论。如果觉得这篇文章对你有帮助,也欢迎转发给你的朋友或者同事一起学习。

36|分布式事务:如何理解两阶段提交?

作者: 黄清昊

你好,我是微扰君。

今天我们来聊一个经典问题“分布式事务”,以及它的常见解决方案“两阶段提交”。

关于事务,我们之前在介绍日志型文件系统的时候就已经一起学习过了(戳这里复习),主要特点就是需要保证在应用程序中,一系列连续操作要么全部成功执行,要么一个都不能执行,这个特点我们一般也称为原子性。

在单体应用中,通常用来保证事务特性的主要手段就是引入日志,比如MySQL中的redo log就是这样一种通过日志保证数据完整性的机制。

但是随着互联网不断发展,我们的应用规模也在稳固提升,单机服务已经没有办法满足需要,分布式系统自然而然也就登上了历史舞台。在分布式系统下,如何保证应用的事务性,就是所谓的分布式事务问题,也是我们今天要研究的内容。

分布式事务问题

在分布式应用场景中,分布式事务问题是不可回避的,在目前流行的微服务场景下更是如此。

我来举一个工作遇到的实际例子。在我参与开发的一款云产品的建设中,我们引入了开源的Keycloak组件,作为用户鉴权和账号创建的认证中心服务,但同时,因为业务也有许多和用户相关的字段需要在创建用户时生成,这一部分数据我们最终选择在数据库中自行维护。

因此,创建用户的步骤就分成了两个部分,首先要去Keycloak中创建账号,然后再在我们的数据库中存储一些额外的用户信息数据。

图片

从语义上来说,创建用户的这两个步骤应该是一个整体,要么全部成功,要么就一个都不能执行。

但是,从系统本身的角度来说,很可能出现请求IAM成功但请求用户服务失败的情况,比如调用完IAM服务之后,用户服务直接宕机了,那相应的请求自然就会失败。所以我们需要引入一些额外的手段保证这样的情况不会发生,这其实就是分布式事务所需要解决的问题。

至于如果破坏了这种多个分布式组件之间的分布式事务性,会造成什么样的后果,其实要视情况讨论。

很多时候可能也不会有什么后果,比如我们刚才的例子只有两个组件,如果能在业务代码里,就严格处理好IAM调用成功但用户服务调用失败的情况;或者在一些数据一致性本身没有很重要的场景下,并不会产生什么严重的问题。

但是如果是一些更复杂且数据一致性要求更高的情况,比如,某个电商应用的下单过程需要调用账户服务、库存服务、订单服务、物流服务等多个不同的服务,一旦数据产生不一致,就很可能导致用户付了款但是收不到商品的情况,这肯定是不能接受的,像这样涉及现金的场景,我们自然需要更妥善处理分布式事务的问题了。

现在解决分布式事务的手段有很多种,一种最常见的思路就是两阶段提交,我们今天主要就来介绍这个协议,它的思路其实非常符合我们的直觉。

方案:两阶段提交

你很有可能听说过这个协议很多次了,不过要彻底理解到位还是需要花费一点功夫。

首先要明确一点,分布式系统中的每个节点,在没有通信的情况下是没有办法获得全局的信息,也就是说某个参与事务的节点并没有办法直接知道其他事务的执行情况。

那么想要保证事务的每个参与方都一起成功提交事务,我们要么通过点对点的多次通信交换信息,要么通过引入一个掌握全局信息的协调者来进行事务的最终提交。

两阶段提交采用的是后者。

记住这一点我们继续看。两阶段提交协议,把整个分布式事务的提交分成了两个阶段:投票、提交,从协议的名字上也很容易看出来。

投票

第一个阶段,投票。在这个阶段,我们引入的协调者会向每个事务的参与者发出一个投票请求,主要目的就是了解每个事务的参与者是否可以正常完成事务的执行,并让参与者完成大部分提交事务所需要的工作。

以分布式数据库的场景为例,在投票阶段里,包括三个步骤:

  1. 协调者发起投票请求至每个参与者
  2. 参与者收到请求执行事务但不提交;在数据库中会把redo log写好,但不提交
  3. 协调者收齐所有参与者的投票进行后续提交操作,或者未收齐投票进行回滚操作

图片

通过这样的第一次提交,协调者就可以感知到每个节点是否可以完成事务。如果出现某个参与者完成不了的情况,或者协调者等待超时,这个阶段就可以将事务进行回滚,而且因为事务没有正式提交,不会产生数据不一致的问题。

提交

有了第一个阶段的准备,当协调者发现所有参与者都进入了尚未提交但是已经执行事务的状态,我们就可以进入第二个阶段,也就是提交阶段了。

图片

这个时候,协调者会正式要求每个参与者进行刚刚事务的提交。由于在第一个步骤里,我们已经完成了大量的准备工作,包括事务的执行(虽然还没有提交),所以这第二个步骤是很轻量的,只要网络正常,成功的概率会非常高,这点也是分阶段很重要的意义之一,我们把更容易出错的工作和最终提交的步骤隔离开了

正常情况很简单,协调者向每个参与者发起最终的commit请求,并得到了成功执行,最终事务得到了正确的提交。

但是第二个阶段失败的情况有哪些呢,网上没有特别清楚的描述,我们来一起讨论一下。

参与者失败

第一种情况是参与者在第二个阶段里失败。

在经典的两阶段提交下,当协调者发现这一情况时,做法很简单,就是直接回滚

协调者是全局里唯一有全局信息的节点,而分布式事务本身的意义就是要保证不同节点要么一起提交事务,要么一起回滚。所以,有全局信息的协调者,如果收到了任意参与者发来的提交失败或者等待超时,都应该像第一个阶段一样,立刻终止事务,并向所有参与者发起回滚的请求。

协调者失败

另一种情况就是协调者失败,更复杂一些,我们分类讨论。

  • 第一种情况,协调者已经让部分参与者完成了提交操作

如果部分参与者已经完成了提交,说明提交的决策已经完成,并由协调者在失败之前发送给了部分参与者,这个时候其他参与者照理来说也必须提交事务。

不过由于协调者已经失败,没有收到通知的参与者除了等待协调者恢复,还有一种优化策略就是让每个参与者都去通过彼此的通信查询一下其他参与者的提交状态,如果发现已经有参与者提交了,说明自己也应该进行提交。协调者恢复之后再根据参与者是否提交的情况,判断是否提交本次事务。

  • 第二种情况,还没有参与者完成了提交操作。

这个时候所有的参与者都已经完成了准备工作但没有提交,协调者就已经失败了,没有节点知道当前事务到底处于要提交还是要放弃的状态,所以只能等待协调者恢复。这也是整个两阶段提交的一个问题所在:协调者宕机则导致系统不可用,成为系统的单点。

不过总的来说,引入两阶段提交的协议之后,整个系统就可以几乎处于一个总是正确的状态了。

总结

今天学习的内容不太多,我们简单总结一下,在分布式系统中,需要保证事务的原子性,通常需要引入一个有全局信息的协调者,通过将事务提交分成投票提交两个阶段和一些精细的设计,我们最终保证了整个系统在大部分时刻都处于正确的状态了。

像两阶段提交这样引入协调者的思路,在整个分布式的世界里是非常常见的,主要目的就是要获取分布式系统中的全局信息,你可以好好体会。

课后思考

最后留一个小小的课后思考题,学习了两阶段提交协议之后,你有没有发现这个协议的一些弊端呢,是否有办法进一步提高他的性能,比如减少整个系统的阻塞情况?

欢迎你在评论区留言与我一起讨论。如果觉得文章对你有帮助的话,也欢迎你转发给你的朋友一起学习~

37|Thrift编码方法:为什么RPC往往不采用JSON作为网络传输格式?

作者: 黄清昊

你好,我是微扰君。今天我们来聊聊RPC的网络传输编码方式。

如果你有过几年后端服务的开发经验,对RPC,也就是远程过程调用,应该不会陌生。随着互联网应用的发展,我们的服务从早期流行的单体架构模式,逐步演进成了微服务架构的模式,而微服务之间通信,最常见的方式就是基于RPC的通信方式。

因此微服务的RPC框架也逐步流行开来,我们比较耳熟能详的框架包括阿里的Dubbo、Google的gRPC、Facebook的Thrift等等,这些系统的核心模块之一就是传输内容的序列化反序列化模块,它能让我们可以像调用本地方法一样,调用远程服务器上的方法。

具体来说,我们会将过程调用里的参数对象转化成网络中可传输的二进制流,从客户端发送给服务端,然后在服务端按照同样的协议规范,从二进制流中反序列化并组装出调用方法中的入参对象,进行本地方法调用。

当然最后,要用类似的方式,将方法的返回值对象传回给发起调用的客户端,这里也会经过序列化和反序列化的过程。

图片

整个调用的过程大概就是图片这个样子,从原理上来说非常直观,相信你一看就能明白。

为什么要经过序列化和反序列化过程,本质上是因为网络传输的是二进制,而方法调用的参数和返回值是编程语言中定义的对象,要实现远程过程调用,序列化和反序列化过程是不可避免的环节。

那RPC的序列化反序列化具体是如何实现的呢?我们今天就主要讨论这一点。

JDK原生序列化

首先来了解序列化具体是怎么实现的。其实,所有的序列化过程从本质上讲都是类似的,我们就以JDK为例详细分析。你只要掌握一个,就能一通百通,理解RPC序列化的主要思想了。

JDK原生就支持对Java对象到二进制的序列化方式,我们利用 java.io.ObjectOutputStream 就很容易完成序列化和反序列化。

看一个例子,代码运行后,我们预先定义好的Dog类的对象,会被序列化并写入某个文件,然后会再从该文件中读取二进制流,并反序列化出一个新的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class Dog implements Serializable {
String name;
String breed;

public Dog(String name, String breed) {
this.name = name;
this.breed = breed;
}
}

class Main {
public static void main(String[] args) {
// 创建 Dog 对象
Dog dog1 = new Dog("Tyson", "Labrador");

try {
FileOutputStream fileOut = new FileOutputStream("file.txt");

// 创建 ObjectOutputStream
ObjectOutputStream objOut = new ObjectOutputStream(fileOut);

// 将 dog1 序列化为二进制并写出
objOut.writeObject(dog1);

// 读取文件
FileInputStream fileIn = new FileInputStream("file.txt");
ObjectInputStream objIn = new ObjectInputStream(fileIn);

// 读出并反序列化为 newDog
Dog newDog = (Dog) objIn.readObject();

System.out.println("Dog Name: " + newDog.name);
System.out.println("Dog Breed: " + newDog.breed);

objOut.close();
objIn.close();
}

catch (Exception e) {
e.getStackTrace();
}
}
}

打印这个新对象的一些属性值之后,你会发现和序列化前的对象是完全一致的。事实上,如果对象有一些方法的话,我们在序列化反序列化之后也是可以正常运行的。

那JDK序列化具体是怎么实现的呢?

本质就是要把Java中的类型以一种特定的协议翻译成二进制流,然后就可以依据协议再次从这个流中恢复出原始的类型。

因为在Java中,对象的核心属性本质上就是一些成员变量,每个成员变量都有自己特定的类型和值,比如上面的例子,Dog类型就包括公共变量name和breed,两者都是String类型。当然,一个被实例化的对象的成员变量也会有对应的具体值。这些就是一个对象所包含的全部信息了。

如果把它们按照某种方式记录下来,我们自然就可以恢复出整个对象本身,从而也就可以通过网络在不同的服务器间传递参数了。

这种特定的翻译协议,在JDK中,也就是默认的序列化协议,它有一个非常清晰的文档。因为Java类型比较丰富,又支持类型的嵌套,协议比较复杂,这里我就简单介绍一下。

整个二进制流的编码,大致分为对象信息和类型信息两个部分:

  • 对象信息,是按照成员变量顺序,依次填入具体值的二进制。
  • 类型信息,通常有一组成员变量信息,包括成员变量类型、成员变量名长度和成员变量名3个部分,其中成员变量类型是用一组特殊的常量表来标示的。

具体对应关系可以看这张图,比如String类型在二进制流中就标识为0x74。

图片

这就是序列化的基本用法和原理,很好理解吧。

不同序列化方式的差异

事实上,所有的序列化实现本质上都是类似这样的,都是把对象里包含的成员信息,以某种顺序,编码成不同的二进制,通过某种协议用不同的长度、分隔符和特殊符号来区分类型和具体的值。

只不过,不同的实现方式,在性能、跨语言特性等能力上有所差异。

JDK中的序列化,因为协议设计高度依赖于Java语言本身,同样的协议就很难被其他语言所支持。

而另一种序列化方式JSON,就可以认为和语言并没有强绑定关系,各大主流语言都有对JSON解析的良好支持,所以,如果采用JSON作为RPC框架中的序列化反序列化方式,通常就可以支持跨语言服务的调用。

但是JSON缺点也很明显,它本质上是纯文本的编码方式,编码空间利用率很低,导致一次RPC调用在网络上传输的二进制流长度比JDK的实现要高很多,而且,编解码需要对JSON文本进行嵌套的解析,整体上性能比较差。

所以JSON并不是首选的RPC序列化协议。不过如果你感兴趣,完全可以基于JSON的序列化方式实现一个自己的玩具RPC框架,相信能帮助你深入理解RPC框架的工作机制。

那么,参考JDK自带的编码方式和JSON的无语言绑定的实现方式,我们能不能进一步提升传输效率呢?答案是肯定的。

来仔细分析一下JDK编码的问题所在。我们知道,RPC调用在实现的时候,客户端和服务端通常都需要有指定服务接口的信息,这样客户端可以按照接口调用,服务端可以按照接口进行实现。也就是说,服务接口中的参数类型,在客户端和服务端通常也是都可获取的。

既然如此,我们其实完全没有必要将成员变量名等信息一起放到传输的数据中,取而代之,如果为每个成员变量设置一个编号,在网络中传输数据的时候,只是传输编号和对应的内容,这样整体的传输数据量不就大大减少了嘛

而Facebook发明的Thrift正是这样做的!

Thrift协议

当然,这也造成了Thrift协议相比于用JSON这种方式进行序列化而言,其编码方式是不足以自解释的,Thrift为了让服务器和客户端都能反序列化或序列化方法参数,需要在服务端和客户端都保存一份同样的schema文件。

你可以认为是用Thrift的语法定义了一个类。比如前面的Dog类,如果用Thrift定义的话,大致是这样的:

1
2
3
4
struct dog {
1: required string name,
2: required string breed,
}

具体语法就不展开讲解了,感兴趣你可以查阅thrift官方文档

之所以用一个特有的语法进行schema的定义,也是为了让Thrift支持更多的语言,做到语言中立。

事实上,使用Thrift的时候,不同的语言,会根据你定义的schema生成一系列代码,你只需要去依赖Thrift生成的文件,就能完成RPC的调用和实现了;schema中的每个类型,在你所使用的面向对象的语言中,也会生成一个结构相似的类。感兴趣的话你可以照着官方的sample,用你熟悉的语言尝试一下,Learn by doing it,这对你了解Thrift很重要。

那有了schema,在序列化的时候,我们自然就不需要再使用冗长的字段名了。每个序列化后的struct,二进制大约是一组连续排列的字段类型+编号+字段值:

图片

字段值根据不同的类型,会有不同的表示方式。

而字段类型只占一字节,Thrift官方定义了一个映射表,记录了每个不同类型的字段类型值。

1
2
3
4
5
6
7
8
9
10
11
BOOL, encoded as&nbsp;2
I8, encoded as&nbsp;3
DOUBLE, encoded as&nbsp;4
I16, encoded as&nbsp;6
I32, encoded as&nbsp;8
I64, encoded as&nbsp;10
BINARY, used for binary and string fields, encoded as&nbsp;11
STRUCT, used for structs and union fields, encoded as&nbsp;12
MAP, encoded as&nbsp;13
SET, encoded as&nbsp;14
LIST, encoded as&nbsp;15

对于一些定长类型比如 Bool、I16、I32 等,字段值的编排很直接,就是对应类型二进制的表示。由于每个类型的二进制长度都是确定的,我们不需要引入额外的信息进入编码。

还有一些类型,比如Map、List和Set等,是通常意义上的容器,容纳的元素数量不定,我们可以引入一个size来表示容器内具体有多少个元素。思想和许多编程语言中对数组的实现是类似的。

先看List和Set,编码方式如下:

1
2
3
4
Binary protocol list (5+ bytes) and elements:
+--------+--------+--------+--------+--------+--------+...+--------+
|tttttttt| size | elements |
+--------+--------+--------+--------+--------+--------+...+--------+

tttt就是SET和LIST的类型值,size就代表具体有多少个元素,elements则按照顺序依次排列每一个元素。

对于Map来说,其编码方式也是类似的:

1
2
3
4
Binary protocol map (6+ bytes) and key value pairs:
+--------+--------+--------+--------+--------+--------+--------+...+--------+
|kkkkkkkk|vvvvvvvv| size | key value pairs |
+--------+--------+--------+--------+--------+--------+--------+...+--------+

kkkk和vvvv代表Map中键值对的类型编号,size同样代表Map中具体有多少个键值对,然后依次排列键值对即可。

这就是Thrift Binary的编码方式了。可以看出,由于去掉了冗长的类型名称,并采用二进制而非文本的方式进行元素存储,Thrift的空间效率和性能都得到了极大的提升;再加上Thrift一开始就是语言中立的协议,广泛支持主流语言,在生产环境中得到了比较广泛的应用,流行程度应该仅次于Google的Protobuf协议。如果对Protobuf和Thrift的不同点感兴趣,你可以参考这篇文章

总结

今天我们一起学习了三种不同的RPC序列化方式:JDK原生序列化、基于JSON的序列化,以及基于Thrift的序列化。

现在你知道JSON的序列化为什么不那么流行了吗?主要原因就是,JSON序列化采用了文本而非二进制的传输方式,并且在序列化过程中引入了冗长的成员变量名等数据,空间利用率就很差;加上使用方还需要对JSON文本进行解析和转化,很耗费CPU资源,因此,即使JSON本身非常流行,也并没有成为主流的RPC序列化协议。

而Thrift或者Protobuf的协议,采用二进制编码,并引入了schema文件,去掉了许多冗余的成员变量信息,直接采用字段编号进行成员标识,效率很高,得到了广泛的应用。

课后作业

课后作业也很简单,在你熟悉的语言中使用一下Thrift搭建一个简单的RPC服务demo,体验一下Thrift的使用过程,观察一下Thrift生成的代码和你日常写的有没有什么不同。

欢迎在留言区留下你的思考,如果觉得这篇文章对你有帮助的话,也欢迎转发给你的好朋友一起学习。

38|倒排索引:搜索引擎是如何做全文检索的?

作者: 黄清昊

你好,我是微扰君。今天我们来聊一聊倒排索引算法。

倒排索引算法,作为一种经典的索引方式,在工业界中应用非常广泛,比如搜索引擎、推荐系统、广告系统等等。在广告系统中,我们需要根据定向信息去广告库中召回合适的广告;在搜索引擎中,需要根据某个关键字返回若干搜索结果,如何做得又快又准,就离不开倒排索引的加持了。

那倒排索引背后的思想到底是什么呢?就让我们来一探究竟吧。

飞花令

首先,我们来看一个生活中的例子,理解一下倒排索引的大致作用机制。

不知道你喜不喜欢电影,因为我和几个朋友都很喜欢看电影,一块出去吃饭的时候,我们就会玩一个电影元素的接龙游戏,规则很简单:给定一个电影元素,然后每人轮流快速说出一个和这个元素相关的电影,谁能战到最后,谁就能免单其他人请客,比如元素是“海盗”,有关的电影就有《加勒比海盗》《菲利普船长》等等。

图片

别看规则简单,其实这个游戏难度很高。因为,即使你看过很多和这个元素相关的电影,你也很难马上想到这些电影,而在别人真的说出这个你看过的某个电影时,你其实又很容易回想起这个元素和该电影的关联。

这也是这个游戏的乐趣所在。古人们爱玩的飞花令也正是这样一种本质上是反向信息检索的游戏(关键词是“花”,按“花”字的位置接龙背诗,比如第一个人“花”要在诗第一个字,第二个人“花”在诗句的第二个字)。

这背后显然和人脑对数据检索的方式有关。我们的大脑存储信息的时候,建立了一种和索引很相似的机制:给定一个电影名,你是很容易马上想起电影内容的,但反过来则困难很多。

倒排索引

面对这个问题,如果我们能建立一种从电影元素到电影名的索引,这个事情就会简单很多了。而这样的索引,我们在小时候读书的时候其实就遇到过。

我们的英语课本上,书本最后会有一个单词表,每个单词后面除了释义,还会写出在课本中出现的页码信息,这样我们复习单词的时候,就可以根据这个表快速找到对应的课文,并回忆老师讲解的内容了。

图片

事实上,这个表就是一种在全文检索的场景下的倒排索引,它记录了每个单词在哪些文档中出现的信息,可以帮助我们快速信息检索。

类似地,当我们使用搜索引擎根据某些关键词搜索网页的时候,背后非常关键的一个技术就是倒排索引技术。

根据关键词搜索时,我们可以把网页的内容看成是全文检索的文档,当用户想要根据某个关键词对文档进行查询匹配的时候,如果没有任何倒排索引,我们所能做的就是在数据库中对每个文档进行全文遍历,查看其中是否出现某个关键词,写成 SQL 语句类似这样:

1
select content from pages where content like `%keyword%`

但是,在互联网海量数据的场景下,这样的查询效率显然是没有办法被接受的。

解决方式就是用空间换时间,在搜索引擎中引入倒排索引,提前预处理存储的全部数据,整理出每个单词到网页ID的映射关系,并记录下来;这样我们就可以得到单词到倒排列表的映射,从而快速查询关键词匹配的网页。有了倒排索引后,我们也很容易对多个关键词做与查询。

图片

还是来举一个具体的例子帮助你理解这个过程。比如我们现在有这样4个文档:

  1. 极客时间App,为用户提供前沿的IT技术、产品设计、摄影跑步等知识服务。
  2. 微扰理论是极客时间上喜爱算法、喜爱学习的一个用户。
  3. 微扰理论是从相关问题的确切解中找出问题的近似解的数学方法。
  4. 微扰理论经常写算法题解,最近正在学习数学基础知识。

我们可以对文档进行分词,并建立倒排索引。以“极客”、“理论”、“学习”这几个词所对应的倒排列表为例,得到类似于下图的索引结构:

1
2
3
4
极客 -> 1 2
理论 -> 2 3 4
学习 -> 2 4
数学 -> 3 4

直接检索某个关键字的结果当然是显而易见的,那如果我们需要对多个关键词进行与查询如何做呢?

其实就是对这些关键词的倒排索引取交集的过程,具体实现方式和数据库的join很像,我们既可以用hash表来实现,也可以用排序+归并的方式实现,比如:

1
2
3
极客 & 理论 -> 2
数学 & 理论 -> 3
理论 & 学习 -> 2 4

从这个具体的例子里,相信你也能感受到,关键词匹配出来的文档应该是很多的,那如何排序呢?

因为倒排索引常用于信息检索,而信息检索质量的一个很重要的指标是文档和关键词的相关性,所以,有时候倒排索引还会在倒排列表中增加每个单词在特定文档中出现的次数,也就是词频信息。

在早年大数据、统计学习乃至深度学习没有得到广泛应用时,词频信息,是文档和关键词匹配度最重要的指标之一,记录了词频信息,就可以让我们在倒排索引中对检索结果进行简单的排序。

小结

学完今天的内容,倒排索引的基本概念还是非常好理解的。虽然思想朴素,但是它的工业实现却非常复杂。

因为在倒排使用的场景中,所需存储查询的数据量通常很大,同时,对检索时延的要求又很高,无论是搜索引擎、推荐系统还是广告系统,每次检索的时间都要求在几十到几百毫秒以内,这就导致我们需要对倒排索引的实现持续做性能优化。

Lucene就是一个非常值得学习的全文搜索引擎库,它在数据结构上做了非常多巧妙的优化,知名的搜索引擎Elasticsearch就是建立在Lucene的基础之上实现的。

不过限于篇幅,我们今天就不对倒排索引的工业实现做展开讲解了,因为内容真的非常非常多,希望你感兴趣的话可以结合着lucene的源码细细品味。

思考题

如果现在让你实现一个倒排索引的库,那你会如何设计倒排列表的存储方式呢?如果要让多关键词的与查询尽量快速,你会如何设计取交集的算法呢?

欢迎你在留言区分享你的思考,如果觉得这篇文章对你有帮助的话也欢迎你转发给你的朋友一起学习。

39|Geohash:点外卖时我们是如何查找到附近餐厅的?

作者: 黄清昊

你好,我是微扰君。

今天我们来聊一聊另一个和索引相关的非常有趣的问题:“地理位置检索”问题。

身处移动互联网时代,我们的衣食住行少不了各种用到地理位置信息的APP。比如周末想和朋友小聚一下,不知道去哪,我就会在美团点评上检索餐厅或者休闲场所,除了看评价,也经常会按照距离排序或者限定距离范围,找距离近、评价也高的地方,这样通勤成本就可以低很多。

图片

再比如许多基于地理位置推荐用户的社交类APP,或者摩拜单车这样的APP里,也都有类似的地理位置检索的需求。

作为一个软件工程师,不知道你有没有思考过这背后用了什么样的算法呢?今天就让我们来一探究竟。

地理位置检索

来明确一下这里要说的地理位置检索问题的讨论范畴。

所谓检索,自然是说从数据中找到一些有指定特征的数据。比如,在数据库中做一个select的查询就可以认为是一种检索行为,语句中如果有where的子句,就说明我们的查询是有条件的。

地理位置检索,正是从这样一些含有地理位置信息(通常用经纬度表示)的元素中,查询满足特定地理位置条件的元素的行为。比如在存有店铺信息和对应经纬度的数据库中,查询某个经纬度坐标3km之内的店铺,或者进一步按照距离远近进行排序。这就是我们今天主要讨论的问题。许多数据存储系统也都支持了相关的查询实现,比如MongoDB、Redis等等。

首先,我们要知道的是,在所有的数据库,索引,在地理位置检索中并不是必须的,因为我们总可以通过遍历一遍数据库中所有的元素,来进行数据的过滤或者排序。

但是,在数据量很大的时候,这样的低效查询显然是不尽如人意的。试想,美团这个级别的互联网应用,如果在每个用户按距离查询商铺的时候,都进行全量的过滤,估计再大的数据中心也不可能在几十毫秒内完成大部分查询。

所以建立类似索引的机制是必须的,那我们具体该怎么做呢?

分块思想

由于地理位置检索问题历史悠久,又很有现实意义,人们对它的讨论还是比较充分的,解决方案也很多,但其中大部分想法都源于一个非常浅显的直觉——分块思想。

分块思想,在很多时候,都可以帮助我们降低算法的时间复杂度,其实这也是某种用空间换时间思想的应用。通过以块为单位记录一些额外的信息,就能加速查询或者计算的时间。

对于地理位置检索的场景,由于地理位置是二维的,我们无法对距离使用类似于B+树这样的一维索引加速查询(除非,我们针对每个坐标都建一个其他店铺到该坐标距离的索引,这显然是不现实的),但是,直接遍历的时间复杂度又太高,有没有什么办法可以不用全量遍历数据,而是部分遍历数据呢?

当然是可以的。看个具体例子帮助分析。假设,现在有一个数据库存储了全国的店铺,如果我们要查询距离五道口地铁站(坐标 39.9929° N, 116.3379° E)最近的几个剧本杀店。

在查询的时候,即使采用全部遍历一一比较的策略,我们也没有必要真的遍历世界上所有的店铺,只需要选择北京市海淀区的店铺就可以了。这样我们就过滤了大量不可能是查询目标的数据,提高了查询效率,所需要的代价仅仅是为每个数据增加一个“省-市-区”的标签而已。

这样的标签,实际上可以认为是对地理位置做了一个以“区”为粒度的分块,在检索距离最近的标签时,我们直接查找同一个区内的就行。

如果觉得同一个区内的店铺还是太多了,遍历起来时间成本依旧很高,那我们就可以把分块的粒度划得更细,还可以用更规则的方式。比如把整个地球分成很多个规则的方块,检索临近店铺的时候,只需要找到目标的分块,然后遍历分块内的店铺即可:

图片

具体每个块要分到多细,就需要看具体的场景了,指导思想就是让每个最小块内的元素不要太多,这样检索的时候效率就会大大增快了。

不过讲到这里,细心的你应该会发现一个问题,很多时候如果只检索和目标坐标同一个块内的元素是不正确的,尤其是目标坐标在某个块边缘的时候,就像这样,我们要查询离A地点最近的店铺:

图片

A所在的分块在图中被红色边界标出来了。对于A地点来说,如果我们想找离A最近的店铺,只在红色块中查询就是不正确的。因为显然图中的B店铺到A的距离,比C店铺到A的距离更近,但B却不在A所处的分块范围之内。

不过,处理方式其实也很简单,在检索的时候,不只检索和目标坐标处于同一个分块下的元素,也检索和目标分块相邻的8个分块内的元素,再根据查询要求进行排序或者选择就可以了。

思想是不是很简单清晰呢~

Geohash

有了基本思想,具体如何进行这样的分块和打标,并将其落地到一个数据存储查询的系统,比如数据库中呢?

一种常见的实现就是Geohash算法,它将地理位置的二维信息映射到一个可比较且易于存储的字符串中,检索时,基于这个字符串进行比较查询,实质就是分块思想的实现。Redis中对地理位置检索也是基于Geohash实现的。

根据前面所说的,我们会对整个地图按层进行分块,而块信息最简单的表示方式当然就是一个编号了,Geohash就采用了一种巧妙的编码方式,把二维信息转化成一维编码的时候,也极大保留了地理信息上的连续性

我们知道,整个地球的经度范围和纬度范围分别是[-180,180]和[-90,90],一个合法的坐标值当然坐落于这个范围之内。我们先看Geohash对纬度信息是如何处理的,同样以五道口地铁站的坐标(39.9929° N, 116.3379° E)为例来考虑这个问题。

先看纬度的分块思路。

Geohash首先考虑这个坐标处于南半球还是北半球,如果是北半球,就用1来标记,反之就用0来标记。那显然,39.9929属于[0,90]的范围,也就是北半球,应该用1标记,现在我们的纬度可取范围就缩小到了[0,90]。

然后我们再继续考虑39.9929纬度属于[0,45]还是[45,90],如果属于[45,90],用1标记,反之用0。这个时候,我们发现39.9929属于[0,45]应该用1标记。

以此类推,如果我们不断地对纬度进行二分和选择,目标的纬度范围就会越来越接近39.9929,我们也就可以得到一串二进制数,也就是将纬度均匀等分为2的幂次后我们处于哪一段区间的编号,而二分的次数越多,二进制的位数越高,我们定位的精度自然也就越高。

你可以看这段完整的编码过程结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[0,90] 1
[0,45] 0
[22.5,45] 1
[33.75,45] 1
[39.375,45] 1
[39.375,42.1875] 0
[39.375,40.7812] 0
[39.375,40.0781] 0
[39.7266,40.0781] 1
[39.9023,40.0781] 1
[39.9902,40.0781] 1
[39.9902,40.0342] 0
[39.9902,40.0122] 0
[39.9902,40.0012] 0
[39.9902,39.9957] 0
[39.9902,39.993] 0

如果我们将地图横切成65536份,五道口地铁站的目标纬度,就可以被确定在[39.9902,39.993]的范围内,编号写成二进制是1011100011100000。

那同样的事情,也可以对经度做一遍,这样我们就相当于对整个地图进行了竖切,得到的编号是1101001010111010:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[0,180] 1
[90,180] 1
[90,135] 0
[112.5,135] 1
[112.5,123.75] 0
[112.5,118.125] 0
[115.312,118.125] 1
[115.312,116.719] 0
[116.016,116.719] 1
[116.016,116.367] 0
[116.191,116.367] 1
[116.279,116.367] 1
[116.323,116.367] 1
[116.323,116.345] 0
[116.334,116.345] 1
[116.334,116.34] 0

现在,如果我们将两个编号按照某种方式拼接起来,是不是就可以将目标坐标确定在了一个很小的方块范围内了呢?

Geohash就是这样做的。不过相比于直接把两个二进制拼接在一起,Geohash非常聪明地采用了经纬度标号交替拼接的方式:“奇数位放纬度,偶数位放经度”,将两个编号信息组合在一起,这样在大部分情况下,两个编码接近的元素在真实地理位置上也会更接近一些。

为了让这个编码更加紧凑,精度范围更精确,Geohash最终存储编码的时候选择了base32的字符串进行存储。

五道口的坐标,如果写成精度为5位的Geohash编码,也就变成了wx4ex,是不是看起来非常简短呢?而这样简短的编码,在地图上却已经只是一块很小的区域了。你可以在这个网站(https://csxgame.top/#/)上查看,作者利用百度地图接口提供了一个Geohash可视化的工具。

图片

总结

我们今天学习了一种常见的地理位置检索的索引方式,Geohash。

作为一种非常经典的分块思想和空间换时间思想的应用,它通过将地理位置分块和巧妙的编码,让我们检索地理位置的时候,只需要在很小的范围内进行排序选择等操作。

编码中体现的分层思想,在计算机的系统中也是随处可见,比如文件系统中的多级索引数据块的建立方式,也和Geohash分层编码的方式有着异曲同工之妙,你可以好好体会。

课后作业

分块思想也是算法面试中时常考到的知识点。比如力扣的307区间和检索,我们就可以采用分块的思想解决,避免高阶数据结构如树状数组、线段树的使用,感兴趣的同学不妨去做做这道题:

图片

欢迎你在评论区留言与我一起讨论,我们下节课见~

40|垃圾回收:现代语言是如何自动管理回收内存的?

作者: 黄清昊

你好,我是微扰君。

今天我们来聊一聊和编程语言有关的一个话题——垃圾回收,作为现在许多编程语言都支持的特性,垃圾回收机制能大大解放程序员的心智,让我们把主要精力放在实现业务逻辑上,而不是关注内存分配这样繁琐的编程细节上。

如果你写过C语言的话,一定能深切体会到在堆上分配内存是一件多么麻烦的事情。就我个人来说,只要能在栈上分配的内存,我一定尽量都在栈上分配,因为一旦在堆上分配了内存,我们就得时刻谨记要在一个合适的时机把内存释放掉。

其实,释放内存本身并不难,但是在程序的复杂性越来越高的时候,这会带来越来越高的维护成本。

因为当程序中有许多不同的分支时,一旦要修改代码,就需要确保在每个分支下内存都能得到正确地释放,这样,我们每次写关键业务逻辑的代码时,都需要反复斟酌和业务本身没有关系的内容,这就给我们程序员们带来了巨大的心智负担。这也是为什么写过C语言的同学,再去写Golang和Java这样有垃圾回收特性的语言会觉得非常爽,开发效率也能显著提高。

但是,GC的实现会带来不小的性能开销,在一些性能敏感的场景,比如高频交易或者网关中间件等场景下,我们还是会用到C\C++这样内存分配自主可控的语言。

不过在很多互联网业务场景下,我们其实并没有这么极致的性能追求,偶尔的服务时延抖动在分布式架构下并不造成用户体验的损失。

垃圾回收机制的引入,能让程序自动管理堆上内存分配的生命周期,开发者基本不用考虑繁琐的内存分配细节,可以大大提高业务人员的开发效率,以适应互联网快速迭代的特性,对互联网应用来说就是绝佳的选择了。也因此,Golang和Java在互联网公司中得到了非常广泛的使用。这就是开发效率和运行效率的权衡了,也是计算机世界的经典命题之一。

那垃圾回收算法具体是怎么做的?不同的算法又有哪些不同的设计思路呢?我们接着聊。

垃圾回收的作用

从前面讲的能总结出来,一个垃圾回收器,主要的作用就是帮助我们开发者管理动态内存的分配请求。

所谓动态内存,就是我们通常说的堆区的内存,供一些生命周期比较长且大小不确定的对象使用,与堆区相对的概念就是常提到的栈区的内存,这里的栈,指的是函数调用栈,我们在栈的章节也提过相关的概念,你可以复习一下。

动态内存分配在业务开发里是必不可少的,因为只分配不释放,就会造成内存泄漏的问题,而各种语言之所以引入了垃圾回收机制,正是为了让内存回收的部分由计算机自动完成。

所以一个垃圾回收器一般至少要有这么几点职责:

  • 从操作系统申请和释放动态内存
  • 根据应用的请求提供相应的内存
  • 决定内存中的哪些空间仍在被应用使用中
  • 把不再继续被应用使用的空间声明为可使用的空间

不同的垃圾回收算法

那Golang和Java的垃圾回收具体是怎么做的呢?如果你对这两门语言有一定使用经验应该知道,它们采用的是不同的垃圾回收机制。

垃圾回收,一度也是学界的热点,不同的垃圾回收算法当时层出不穷,有的侧重减少每次垃圾回收程序的停止运行时间,有的则侧重提高垃圾回收的实际效率,所以Golang和Java的垃圾回收算法选择不同也就在情理之中了。

我们就以这两门语言简单举个例子。Golang相比于Java来说确实是一门更现代的语言,在语言实现上采用了更强的内存分配器,而且通过引入更完备的逃逸分析机制等手段一定程度上避免了大量小对象的产生,所以Golang并不像Java那么需要快速的GC,也就没必要引入分代GC这样的复杂设计了。

事实上,Java的不同版本所用的垃圾回收机制也不一样,比如JDK社区目前比较新且活跃的垃圾回收器实现是ZGC,它的一些设计目标是这样的:

  1. 停顿时间小于10ms
  2. 使得停顿时间和使用中的内存大小无关
  3. 支持更大规模的堆空间

我们今天主要学习两种简单、经典的垃圾回收算法:标记-清除算法、标记-整理算法,它们体现了各种现代语言中常见的垃圾回收器的基本思想,我们前面说到的分代的JVM的垃圾回收器里,就有用到这两种算法。

不过在此之前,我们还是要先来理解清楚垃圾回收里“垃圾”到底是如何定义,以及如何被计算机识别的。

内存垃圾标记算法

前面我们讲到,垃圾回收主要就是要避免开发者手动回收内存,所以内存中的垃圾指的就是:某段被开发者申请用于存储某个对象的内存空间,如果在某个时间之后,该对象生命周期已经结束,我们就可以将这个内存空间释放出来并重新利用。从这个意义上来说,“垃圾回收”本质上就是一个非常好的比喻。

那什么样的对象是可以被释放的呢?

通常来说,如果对象被一个正在活跃的对象所引用,这个对象就仍在被使用中,而如果某个对象已经没有被任何活跃对象引用的话,我们就可以认为这个对象已经是一个垃圾,可以回收了。

那可以想见,所有的对象之间如果以引用关系为边,一定会构成一个有向图。其中有一些节点入度为零,我们就称为GC根对象,常见的根对象包括活跃中的进程、线程或协程、方法区中的静态变量等等。

要标记某个对象是否可以仍在使用中,一种方式就是从每个根对象出发,按照引用关系遍历所有能达到的节点,这些节点就是仍在被使用中的对象;而剩下的节点就被我们称为不可达对象,也就是可以被回收的垃圾。

图片

这样我们就有了标记垃圾的办法。在程序运行过程中,我们就可以定期或者在需要的时候进行这样的搜索,标记出所有可以被回收的内存中间,然后进行回收。

不过可以想见,在这个搜索的过程中,我们最好不要让新的引用关系产生或者消失,这样才能保证垃圾回收算法的正确性,这也是为什么我们的垃圾回收器通常需要一个停顿时间(stop the world time),而减少这个时间也是许多垃圾回收器的设计目标。不过,这也是一个很复杂的话题,我们今天就不展开讨论了,感兴趣的话你可以看这篇文章

根据这种思路,下面我们看两种主流、经典的垃圾回收算法:标记-清除算法、标记-整理算法。

标记-清除算法(Tracing Collector)

标记清除算法,顾名思义,整个算法分为标记和清除两个部分。

标记的部分其实就是之前介绍的搜索算法。有了标记的结果,下一步就是将标记的对象全部释放。

你可以把内存想象成一个连续的数组空间,每个对象在空间里占用了一定的槽位,我们会将内存空间分成三种不同的标记,分别是存活对象、可回收空间、未使用空间。这里画了一个示意图供你参考:

图片

当垃圾回收触发的时候,我们扫描完整个内存空间获得了相应的标签之后,就会将所有可回收的区域直接标记为“未使用”的状态,这样申请内存的时候就可以直接使用了。

从时间复杂度上来说,回收过程的代价最少就是遍历一遍所有可回收对象而已,在大部分内存都被活跃对象占用的场景下,效率是非常高的。

但这个算法最大的问题是“内存碎片”的产生。因为我们不会对内存做任何整理的操作,而只是简单释放,这会导致随着程序的运行,我们能使用的、大的连续的内存空间越来越少,大量在存活对象之间的内存空间,可能由于过小而无法得以有效利用。

标记-整理算法(Compacting Collector)

为了解决内存空间碎片的问题,第二种标记-整理算法应运而生。

图片

它和标记-清除算法的主要区别体现在第二步回收上,我们不再只是简单地把可回收的对象空间标记成“未使用”,而是会把所有仍然存活的对象往内存空间的一端移动,让内存空间可使用的部分都连续集中在内存空间的另一侧。

这样我们就不再会产生众多的内存碎片,但同时也需要付出更大的移动对象的成本。

了解了这两种算法的设计思路,它们在使用选择上也自然有所不同。在商业的Java虚拟机中,我们都采用了分代的垃圾回收实现,在老生代中通常采用标记-整理的方式进行垃圾回收,这是因为老生代中的对象生命周期往往比较长,GC触发的频率不高,我们就可以接受更长的停机时间来获得更有效的内存空间利用率。

总结

垃圾回收是为了实现自动化地分配回收内存空间,从根对象开始,根据引用关系搜索内存空间,可以帮助我们分辨出哪些内存空间是可以被重新利用的,再通过垃圾回收算法,清除或整理对应的内存即可。

为了保证垃圾回收算法的正确性,在垃圾回收的过程中,我们通常需要停止应用程序继续在内存上分配或者释放空间,从而引入了停顿时间STW,这是各大垃圾回收算法所努力的目标。

课后思考题

你知道自己熟悉的语言中垃圾回收器都做了哪些努力减少停顿时间吗?

欢迎你在留言区与我一起讨论,如果你觉得课程对你有帮助的话,也欢迎转发给你的朋友一起学习。

先导篇|诶,这个 git diff 好像不是很直观?

作者: 黄清昊

你好,我是微扰君。

相信你每天都会使用Git,作为一款免费、开源的分布式版本控制系统,Git最初是 Linus Torvalds 为了帮助管理 Linux 内核开源协作而开发的,随着GitHub的流行和Git本身的系统优势,它也渐渐成为我们广大研发人员日常工作中必不可少的版本管理利器。

在使用Git的过程中,你一定会常常用到 git diff 的命令,去查看这次待提交的本地代码和修改前的代码有什么区别,确定没有问题才会进行 commit 操作。像 git diff 这样求解两段文本差异的问题,我们一般称为“文本差分”问题。

但是不知道你有没有思考过文本差分的算法是怎么实现的呢?

如果你现在静下心来思考一下,就会发现写出一个简明的文本差分算法并不是一件非常容易的事情。因为代码的文本差分展现形式可能有很多,但并不一定都有非常好的可读性。

而 git diff 给我们展示的,恰恰是比较符合人们阅读习惯且简明的方式,简明到让我们即使天天都在使用这个功能也不会有意识地去思考:“诶,这个difference生成的好像不是很清晰?是怎么做的呢?”。

就让我们从这样一个“简单”、有趣、常用的文本差分算法开始,探索那些其实就在我们身边却常常被熟视无睹的算法们吧。希望能给你一些启发,而且探索算法思想的过程也会非常有趣(如果你在学习这一讲的过程中觉得有点难,最后我们会揭秘原因)。

文本差分是什么

文本差分算法其实是一个历史悠久的经典算法问题,许多学者都有相关的研究,解决这个问题的思路也是百家争鸣,复杂度相差甚远。

而在git diff的实现里,其实就内置有多个不同的diff算法,我们今天主要介绍的是git diff的默认算法:Myers 差分算法,这是一个相对简单、效率高且直观的文本差分算法(原论文)。

在学习这个算法之前,我们得首先来定义一下什么是文本差分(difference),毕竟这个词本身就不是那么直观。

我们找原始出处,Myers在论文中,提到了这样一句话:

An edit script for A and B is a set of insertion and deletion commands that transform A into B.

其中有一个概念叫作 edit script,也就是编辑脚本。比如,对于源文本A和目标文本B,我们一定可以通过不断执行删除行和插入行两种操作,使得A转化成B,这样的一系列插入和删除操作的序列就被称作编辑脚本。所以,文本差分算法,可以定义为用于求出输入源文本和目标文本之间的编辑脚本的算法,广泛运用于各种需要进行文本对比的地方。

比如,git diff 就是一个很经典的应用场景,下图是一个真实的例子(具体的commit可以在这里找到)。

图片

但是,两个文本之间的差分方式可能远远不止一种。

比如说,对于任意两个文本A和B,我们总是可以通过将源文本逐行全部删去,再逐行添加目标文本的方式来做变换,也可以通过只修改差异部分的方式,做从A到B的变换,比如上面的例子中所展示的这样。

那我们如何评价不同编辑脚本之间的优劣呢?

评价指标1

第一个评价指标,其实也不难想到就是:编辑脚本的长度

我们举一个论文中的例子来讨论,后面大部分讨论也都会基于这个例子展开:

1
源序列 S = ABCABBA   目标序列 T = CBABAC

想要完成从S到T的变换,图中左边的编辑脚本就是前面所说的先删后添的方式,并没有体现出两个文档之间的修改点,显然不是一个很直观的变换表示;而右边的编辑脚本就明显好得多。

图片

直观地来说,右边的编辑脚本要比左边短的多,因为它尽可能保留了更多的原序列中的元素。

所以,一种符合直觉的文本差分算法的衡量指标,就是其编辑脚本的长度,长度越短越好。我们一会要介绍的Myers算法,也是在求一种最短的编辑脚本(也就是SES Shortest Edit Script)的算法。

但是SES要怎么求呢?原论文中也提到,最短编辑距离问题也就是SES,和最长公共子序列问题也就是LCS其实是一对对偶问题,如果求得其中一个问题的解等同于找到了另一个问题的解。

而最长公共子序列问题,相信许多准备过面试的同学都有所了解吧。大部分算法面试题中要求的解法复杂度是O(N*LogN),采用动态规划就可以解决。不过呢,这并不是今天的重点,先不展开具体算法了。

这里我们简短地说明一下两个问题的关联性,还是用刚才的例子:

1
2
3
4
5
6
7
源序列 
S = ABCABBA length m = 7
目标序列
T = CBABAC length n = 6

最长公共子序列(不唯一)
C = CBBA length LC = 4

我们很容易发现最短的编辑脚本的长度就等于 m + n - 2 * LC 。其中,M和N为原序列S和目标序列T的长度,LC为最长公共子序列的长度。这是因为在从原序列到目标序列的变化过程中,两者的最长公共子序列中的元素我们都是可以保留的,只需要在编辑脚本里,按顺序分别删除原序列和插入目标序列里不在公共序列中的元素即可。

当然,两个序列的最长公共子序列往往也不唯一,不同的最长公共子序列都对应着不同的编辑脚本产生,但这些编辑脚本一定都是最短的。

评价指标2

那只是找到A到B的最短编辑脚本,我们就能满意了吗?并不能,因为即使编辑脚本长度一样,由于删除和插入的顺序不同,人们理解它的难度也会不同。所以,这里就需要引入第二个指标:可读性,毕竟文本的编辑脚本往往最终是要展示给用户看的。

这当然是一个很笼统的讲法,我们再借用刚才的例子,来直观地比较一下不同方式的区别吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
源序列 
S = ABCABBA
目标序列
T = CBABAC
1.&nbsp;- A&nbsp; &nbsp; &nbsp; &nbsp;2.&nbsp; - A&nbsp; &nbsp; &nbsp; &nbsp;3.&nbsp; + C
&nbsp; &nbsp; - B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;+ C&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;- A
&nbsp; &nbsp; &nbsp; C&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;B
&nbsp; &nbsp; - A&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;- C&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;- C
&nbsp; &nbsp; &nbsp; B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;A&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;A
&nbsp; &nbsp; + A&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;B
&nbsp; &nbsp; &nbsp; B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;- B&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;- B
&nbsp; &nbsp; &nbsp; A&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;A&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;A
&nbsp; &nbsp; + C&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;+ C&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;+ C

从S变化到T,我们至少可以得到这三种编辑脚本,都是最短的编辑脚本。相信你仔细观察一下之后,可能会有一种感觉,就是第一种比后面两种可读性会好一些,因为删去的行和增加的行并没有彼此交叉,所以可以更清晰地看出修改的代码是哪些。

下面这个例子感受可能更明显一点:

1
2
3
4
5
6
Good:   - aaa         Bad:  + ddd
- bbb - aaa
- ccc + eee
+ ddd - bbb
+ eee - ccc
+ fff + fff

对于整段的代码修改来说,左侧对于大部分人来说往往是更清晰的一种展示方式。编辑长度相同的前提下,左侧之所以“更清晰”的直观感受可以总结为两点:

  1. 我们希望尽可能多地保留整段文本,尽可能连续地删除和插入操作,而不是彼此交叉。
  2. 大部分人可能更习惯先看到原文本的删除,再看到目标文本的插入。

Myers也是这么觉得的,所以他提出了一种贪心的策略,可以让我们大部分时候得到一个最短且“质量高”的编辑脚本。策略的逻辑就是,在最短的编辑脚本里,尽量找到删除在增加前面,且尽可能多地连续删除更多行的方式

直觉上来说,这就能避免许多交叉的修改,将整段代码的添加更直观地展现给用户;当然这也并不是绝对的,只能说在大部分情况下都更加直观一些。

到底如何找到最短编辑脚本中比较直观的一个?这就是Myers算法的用武之地啦,下面我们就来看看Myers为了解决这个问题所做的独特抽象和建模,这是理解Myers算法的关键。

Myers Diff Algorithm 模型抽象

正如前面提到的,从源序列S到目标序列T,有两种操作,分别是删除行和插入行。

1
2
3
4
源序列 
S = ABCABBA length m = 7
目标序列
T = CBABAC length n = 6

现在我们就需要找到一种对这两个操作和相应变换状态的抽象方式,帮助我们更好地将问题转化成可以被编程语言实现的代码。

如何抽象-转化为图搜索问题

Myers采用了一种非常独特的视角,将这个问题很好地转化为了一个图上的搜索问题。具体做法是这样的:建立一个放在二维平面的网格,网格宽度为m+1,高度为n+1,如下图所示,在坐标系中,X轴向右侧延伸,Y轴则向下延伸。其中横轴代表着源序列,而纵轴代表着目标序列。我们把这张图称为编辑图。

具体什么意思呢? 我们还是看之前那个例子的编辑图:

看图上的坐标, (0, 0) 这个点,代表什么操作都没有做的初始状态,而(7, 6)则对应了最终完整的从S到T的编辑脚本。

我们从(0, 0)出发进行图上的搜索,每次经过网格中所有的横线代表的是删除操作,如经过(0,0) -> (1,0) 的横线,代表了将S的第一个字符A删去。与之相对地,经过所有的竖线则代表着插入操作,如 (3,3) -> (3,4) 代表着对T中的第4个字符B进行插入操作。

显然,网格中的这些横线或竖线的权重为1,因为每次删除和插入操作所需要花费的操作数都是一样的,我们就记为1。而二维网格从原点出发到每一个坐标的路径,都对应着一段不完全的编辑脚本,也代表着从原文本到目标文本某种变换的一个中间状态。

比如下图中从 (0,0) -> (3,4) 的路径就表示着字符串ABC -> CBAB的一种编辑方式,我们先插入CB,然后删除AB再插入AB,最后删除C。这是完整路径中的一部分,也就是完整编辑脚本的一部分。

就此,文本差分问题成功转化成了,如何在这样的网格中找到仅允许向下和向右移动的一个从(0,0)出发到(m,n)的路径,路径的长度就代表了总共需要的操作数。

比如之前的,先将源序列字符逐一删除,再将目标序列逐一添加的方式,在图中的表现形式就是从(0,0)出发一路往右,走到(m,0),随后一路向下走到(m,n),这样的总操作数就是m+n=13,等于路径中所有横线的数量和竖线的数量之和,这对应着众多最长的编辑脚本中的一种。

前面我们也说过,并不是所有出现在S中的字符都要删除的,可以证明所有出现在最长公共子序列中的字符,都是可以被保留下来的。那可以保留的字符,我们在图中又要如何表现呢?

比如为了从 (2,0)转移到 (3,1) ,我们当然可以先经过一个竖线,再经过一个横线,对应到脚本上也就是先插入目标序列T中的C,再删除源序列S中的C,这显然是没有意义的操作。所以,我们应该选择跳过这步操作而保留原有字符串中的C。

Myers在图上这样描述保留字符的操作:我们在网格里加入一些以相同字符所对应的坐标为起点的斜线,比如图中(2,0) -> (3,1)的斜线,并且经过斜线所需要的操作数应该计为0,因为我们不需要在编辑脚本中进行插入或者删除操作。

所以,在编辑图中,所有源序列和目标序列字符相等的坐标,如(2,0)和(3,1)、(4,1)和(5,2)等,我们都会有一条从上一状态(x-1,y-1)到这一状态(x,y)的斜经过,且穿过斜线路径不耗费任何操作数

至此,我们终于可以完美地将两个评价指标在图模型中量化出来。

  1. 寻找一个最短的编辑脚本,等同于在编辑图上找到从(0,0)出发到(m,n)的最短路径。
  2. 而可读性则要求我们通过一定的策略,从最短路径中找到一个更符合人们阅读习惯的增删序列。

还是用这个例子,你可以直观地理解在编辑图上具体的搜索过程。图中粗箭头所示的路径,即为一条最短的路径,其对编辑脚本操作如下:

由于编辑图是一个有权的图,我们所求的问题可以认为是一种特化的单源最短路问题(SSSP问题),即求解一个有权图中,从指定源点出发到某个其他点的最短路径问题。常用的算法包括Dijkstra、SPFA、Bellman Ford等,这些算法复杂度比较高,我们之后会在网络篇展开讲解。

回到这里的编辑图搜索问题,因为图中的边权只有1和0两种,我们当然可以找到更高效的算法来处理。

如何解决图搜索问题

Myers 就通过动态规划思想很好地解决了这个问题。下面就让我们来看一下他是怎么做的,使得找到的路径既是最短的,也是在大部分情况下非常可读的。

为了方便进一步表述和建模,首先学习Myers在论文中定义的几个重要概念。

  • D-Path

在编辑图中,路径长度对应着每一条横线或竖线需要花费的操作数之和,也就对应着编辑脚本中增删的行数。为了方便描述,我们把需要D步操作的路径称为D-Path,一条D-path指向一个经过D次增或删操作的变换过程的中间状态。

  • Snake

斜线都是不需要操作数的。我们定义一个横线或者竖线之后紧跟着0条或n条斜线所形成的路径称为snake,所以一条snake所需要的操作数为1。而且我们规定,snake结尾坐标后继不能为斜线,也就是如果snake路径中某个坐标后面有斜线,我们就会继续沿着斜线走,直到走不了为止。比如 1,0 -> 2,0 -> 3,1 就是一条snake,而1,0->2,0 就不是一条snake。

  • Line

每个坐标都在一条从左上到右下45度角的斜线上。我们可以用k=x-y来描述这条斜线,在m*n的网格中,k的取值范围为[m,-n]。比如坐标(2,4)和(1,3)在Line(-2)上,(3,0)和(5,2)在Line(3)上。

Myers 论文里的这张图就是对这几个概念的一个示意:

图片

有了这些概念的定义之后,我们求解最短编辑脚本的目标就可以定义为要找到最短的可以抵达 (m,n) 的 D-Path

由于所有的 D-Path 都是由 (D-1)-Path 接上一条 snake 构成 (也就是说,所有的编辑脚本都是由一个更短的、指向某个中间状态的脚本,加上一次增删和若干行保留操作所产生的)。

所以,很自然产生的一种想法就是从1-Path开始,去搜索所有和1-Path相接的2-Path看看最远能走到哪里,然后以此为基础一直递推到D-Path,当我们在搜索过程中第一次遇到终点,也就是(m, n)时,就找到最短编辑脚本路径了。这样自底向上,通过先解决子问题,逐步递推出问题的解,就是典型的动态规划思想,我们之后也会专门展开讲解。

第二个指标可读性,就是假设有多条D-Path都可以抵达(m,n),我们如何从里面选出可读简明的路径呢?Myers采取的是一种贪心的策略,背后的思想主要就是前面讲过的,Myers认为更简明的Diff操作有以下特征:

  1. 我们希望尽可能多地保留整段文本,尽可能连续删除或插入,而不是彼此交叉。
  2. 大部分人可能更习惯先看到原文本的删除,再看到目标文本的插入。

这两点其实也非常符合我们的直觉。反映到对编辑图的搜索上也非常直观:

  1. 我们在探索路径时,如果碰到斜线一定要一路沿着斜线一路往下,直到不能继续为止,只有这样我们才能尽量多地保留连续的原始文本,这就是为什么要求 snake 终点不能停留在连续斜线中间的原因。
  2. 在考虑D-Path的时候,我们会优先从许多(D-1)-Path中,挑选出一条终点的横坐标更大的路径来构建。这就意味着在做选择时倾向于选删除优先于插入的方式

现在你应该明白为什么要引入snake和line这样的概念了吧。核心就是斜线上的路径都是不需要产生编辑脚本长度的,因此我们可以选择在斜线上进行动态规划。

好了,最后我们来学习Myers的动态规划算法实现细节,理解了前面的概念,算法的思路就不是特别复杂了。

代码实现

我们用一个二维数dp来记录图上的搜索状态:

  • dp的第一个维度代表着操作数,最大范围也就是我们最短编辑脚本的长度 m+n-2*LC。
  • 第二个维度是k-Lines的行号,在操作数为d时,其取值范围为[-d,d],范围的左右边界分别代表了d次操作都是只插入不删除,或者只删除不插入。
  • dp本身的值记录为当前操作数及行号所对应的x坐标。

把之前的递推例子过程画到二维表格中大概如下图所示,横轴的数字代表着D-Path的D也就是操作数,纵轴的数字代表k-Lines的行号,树状图中每个节点展示的是网格中的二维坐标也都对应着某个编辑脚本的一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|      0     1     2     3     4     5
----+--------------------------------------
|
4 | 7,3
| /
3 | 5,2
| /
2 | [3,1] [7,5]
| / \ / \
1 | [1,0] [5,4] [7,6]
| / \ \
0 | [0,0] 2,2 5,5
| \ \
-1 | 0,1 4,5 5,6
| \ / \
-2 | 2,4 4,6
| \
-3 | 3,6

而方括号括起来的路径代表着我们最终选择的路径,也就是之前图里箭头表示的那条路径:

图片

我们从左到右、从下到上用两层循环依次更新二维表格。外层循环就是从左往右遍历图上的每一列,内层循环就是从下到上遍历树状图每一层的状态,也就是遍历每一条line。操作数为d时,我们从行号为-d开始以步长为2遍历,一直遍历到d。

整个树的结构是二叉的,奇数步时,必然处于奇数行号,偶数步时必然处于偶数行号。这是因为从k-Line的第k条线进行一步snake,只会有一次删除或者一次插入操作;对应到图上也就是经过一条横线或者一条竖线加若干条斜线,因而只能进入k+1行或者k-1行,所以每一个操作数对应行号的奇偶性是确定的,遍历的时候步长为2也就很好理解了。

所以总结一下就是,行号为k、操作数为d的状态,只能从相邻的两行k-1或者k+1,通过横线或者竖线转移过来

写成状态转移方程就是 dp[d][k] = max(dp[d-1][k-1]+1,dp[d-1][k+1])。从k-1行过来的必然走的是横线,所以状态也就是横坐标+1,从k行转移过来的走的是竖线,状态也就是横坐标会保持不变(动态规划状态不变)。这样,有多个选择的时候,我们会将状态更新为不同路径中最远的,也就是横坐标最大的一个。

思路很清晰,写成伪代码也非常简单啦。

如果在某次循环的时候找到了终点,就会停止循环,此时也找到了一种“简明”且最短的编辑脚本,直接return就行。由于操作数为D的状态数组的计算,仅依赖了操作数为D-1的一层状态数组,我们可以将状态维度压缩一下,采用一维数组记录状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
V[1]←0 
For D ← 0 to MAX Do
For k ← −D to D in steps of 2
Do If k=−D or k≠D and V[k−1] < V[k+1] Then
x ← V[k+1]
Else
x ← V[k−1]+1 y ← x−k
While x < N and y < M and a[x+1] = b[y+1] Do
(x,y) ← (x+1,y+1)
V[k] ← x
If x ≥ N and y ≥ M Then
Length of an SES is D
Stop

最后,我们来计算一下这个算法的时间复杂度,原论文花了许多篇幅在严谨的数学描述上,我们这里就写的简洁些,有兴趣的同学可以自己查阅论文进一步理解。

在内外两层的循环中,每一层循环都循环了D次,循环次数最多为总操作步数D*D。循环体中,除了第8-10行的while,都是O(1)的复杂度。所以去掉8-10行之后,复杂度为O(D^2)。

8-10行的代码看似多加了一层复杂度不是常数的循环,但在做的事情就是沿着Line,在不耗费额外操作的时候,一路沿着snake往下拓展,所以整体复杂度加起来不可能超过搜索范围内的所有的长度斜线,而斜线的最大长度为min(M,N)。那么在循环范围内,8-10行的操作带来的总的时间复杂度不会超过O(M+N)。

所以算法的整体时间复杂度是O(D*(M+N))。大部分情况下,D其实比M或者N要小许多,所以Myers算法在复杂度比O(MM+NN)要小很多。

总结

我们学习了一种高效求文本差分的方式 Myers 算法,基于动态规划的思想和编辑图的抽象,给出了一种复杂度很低又能求出可读性很高的编辑脚本的方法。这个算法被广泛使用在各种需要求文本差分的场景里,如Git中的git-diff、Android中的DiffUtil等。

其实,Myers算法并不是一个非常基础的算法。我会把这篇文章作为专栏的第一篇文章,不止因为这个算法确实非常有趣,能让你提前体验一下用算法来解决实际问题的思维乐趣;更是想告诉你,算法离我们的距离比你想的可能还要更近一些。

算法不只存在于各种高大上的基础设施或者艰深的论文里,而会出现在我们程序员日常开发工作中的每个角落,甚至生活的方方面面。只不过我们太习以为常,才忽略了这些算法。

所以,很期待在我们并肩探索算法的这段时间里,你能对真实世界中的算法有一个新的认知,并在欣赏它们的过程中提升自己,收获乐趣。

课后作业

留个小作业,前面有提到最长公共子序列的问题,不知道你会不会做呢?你可以试着实现一个朴素的基于动态规划的最长公共子序列算法,看看能不能基于这种实现改造出一个文本差分算法。

欢迎在留言区留下你的代码参与讨论。我为专栏开设的GitHub仓库也欢迎你来提issue和pr。

拓展阅读

感兴趣的话,你也可以自己尝试实现一下Myers算法。如果发现git-diff算出来的结果和你的结果略有不同,也不用担心,这很可能是因为git-diff优化了Myers算法的空间复杂度所导致的,这一点原论文里也有提到。

即学即练|分布式篇:复习卡一键直达

作者: 黄清昊

你好,我是微扰君。

我们已经学完了分布式章,今天就来复习回顾一下这一章的要点内容。既然讲的是工程中的问题,也就没有LeetCode练习题了,主要是在工作中多观察多分析多总结。在学习的时候,我们也不要想着只是解决这一个简单的问题,而是要更多地考虑别人解决问题背后的思路。

有些算法,虽然现在已经有了现成的库类和中间件,供我们开箱即用,但是如果你对它背后的原理更加熟悉,一旦在业务开发中碰到类似的问题,你才能比别人更快地想到有效的解决方案,这也是我认为学习这些算法最重要的意义。

分布式篇

今天来复习&练习专栏的第四章,分布式篇。在这个模块里,我们一起学习了MapReduce 、PageRank、Raft、UUID、一致性哈希。你可以借助整理好的要点卡片,回到相应章节,有针对性地复习。

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片

图片

图片

复习的过程中,如果有什么困惑,获得了什么收获,都欢迎你留言讨论,也欢迎你把复习卡分享给你的朋友。

学习愉快,我们工程实战篇见~

即学即练|基础数据结构篇:复习卡 & 算法题特训

作者: 黄清昊

你好,我是微扰君。

到目前为止我们已经学习了课程基础部分的4章内容,包括基础数据结构篇、基础算法思想篇,以及操作系统和计算机网络这两门非常重要的计算机基础课中会用到的基础算法。不知道你掌握的如何啦?

我常在课程里提到只有掌握优秀算法的精髓,才能根据实际的workload选择合适的算法,但如果缺少足够的练习,我们在实际写的时候,可能还是会遗漏一些值得考虑的细节,而长期不间断的算法训练,能磨练我们的思维能力。上一节课也分享了我自己刷算法题的方法。

春节期间我们就来4期特别策划,我会带你复习这四大章的要点内容,把每篇文章的要点都梳理出来,供你快速回顾内容。

另外我也整理了这4章每个核心知识点对应的必知必会的18道LeetCode练习题,供你练手复习巩固。你可以每天花一点时间,来完成测验。测验完成后,如果发现自己哪里还不太明白,可以点击要点卡,可以直接跳转到你最需要复习的那篇。有针对性地复习。

基础数据结构篇

今天我们来复习&练习专栏的第一章,基础数据结构篇。在这个模块里,我们一起学习了动态数组、双向链表、双端队列、栈、HashMap、Treemap、堆这几个内容。

必知必会力扣题

  • 题目名 随机翻转矩阵

题目链接:https://leetcode-cn.com/problems/random-flip-matrix/

题解思路

  • 题目名 786. 第 K 个最小的素数分数

题目链接:https://leetcode-cn.com/problems/k-th-smallest-prime-fraction/

题解思路

  • 题目名 430. 扁平化多级双向链表

题目链接:https://leetcode-cn.com/problems/flatten-a-multilevel-doubly-linked-list/

题解思路

  • 题目名 678. 有效的括号字符串

题目链接:https://leetcode-cn.com/problems/valid-parenthesis-string/

题解思路

  • 题目名 1705. 吃苹果的最大数目

题目链接:https://leetcode-cn.com/problems/maximum-number-of-eaten-apples/

题解思路

  • 题目名 剑指offer 49.丑数

题目链接:https://leetcode-cn.com/problems/chou-shu-lcof/

题解思路

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片

图片

图片

图片

图片


复习&练习的过程中,如果有什么困惑,获得了什么收获,都欢迎你留言。做完题目之后,欢迎你把复习卡和你的LeetCode题解分享给你的朋友,说不定就帮他解决了一个难题。

学习愉快,我们下期见~

即学即练|基础算法思想篇:复习卡 & 算法题特训

作者: 黄清昊

你好,我是微扰君。

大年初一,祝你新春快乐!

今天是春节特别策划的第二期。昨天的第一期是关于基础数据结构篇的,如果你错过了可以点这里答题&复习。

这4篇文章中的题目都是我精选出来的,无论是对知识点的理解,还是为了准备面试,都是必须要掌握的,建议你一定要全部手写练习。如果一遍搞不定,你可以点击对应章节的要点卡,再复习一下文章,多看几遍,结合题目反复练习,一定会收获很多。

基础算法思想篇

今天我们来复习&练习专栏的第二章,基础算法思想篇。在这个模块里,我们一起学习了外部排序、二分、搜索、字符串匹配、拓扑排序、哈夫曼树这些内容。

必知必会力扣题

  • 题目名 846. 一手顺子

题目链接:https://leetcode-cn.com/problems/hand-of-straights/

题解思路

  • 题目名 475. 供暖器

题目链接:https://leetcode-cn.com/problems/heaters/

题解思路

  • 题目名 剑指 Offer II 069. 山峰数组的顶部

题目链接:https://leetcode-cn.com/problems/B1IidL/

题解思路

  • 题目名 162. 寻找峰值

题目链接:https://leetcode-cn.com/problems/find-peak-element/

题解思路

  • 题目名 1044. 最长重复子串

题目链接:https://leetcode-cn.com/problems/longest-duplicate-substring/

题解思路

  • 题目名 851. 喧闹和富有

题目链接:https://leetcode-cn.com/problems/loud-and-rich/

题解思路

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片

图片

图片

图片


复习&练习的过程中,如果有什么困惑,获得了什么收获,都欢迎你留言讨论。做完题目之后,欢迎你把复习卡和你的LeetCode题解分享给你的朋友。

学习愉快,我们下期见~

即学即练|工程实战篇:复习卡一键直达

作者: 黄清昊

你好,我是微扰君。

今天我们来回顾一下工程实战篇的要点内容,这也是专栏的最后一个模块。

在这个章节中,相信你能感受到我们更多提到了前面学习的算法思想和基础数据结构的知识点,这也正是工程中算法应用的特点,它们都是某个庞大系统的一部分,被用来解决不同场景下的技术问题,而不是一个独立可抽离的知识点。

在这些算法中,一方面,我们会借鉴很多其他系统中的设计经验和思想,比如LSM Tree中提到的批量写降低IO成本的思想,在很多系统中都有体现;另一方面,这些系统中的算法本身也会跟很多其他的系统进行交互,比如B+ Tree和LSM Tree的设计思想就和磁盘读写的特性息息相关。

所以在学习这些算法的时候,我们更多的还是要结合工程实战的场景来学习。我也相信,在学习它们的过程中,你也能更多收获融会贯通的快乐,也希望你能借此打好算法和计算机基础,在编程的世界里一直有所精进。

工程实战篇

来复习专栏的第四章,工程实战篇。在这个模块里,我们一起学习了B+ Tree、LSM Tree、MVCC、BitMap、布隆过滤器、跳表、时间轮、限流算法、Trie 树。

你可以借助整理好的要点卡片,回到相应章节,有针对性地复习。

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片

图片

图片

图片

图片

图片

图片

复习的过程中,如果有什么困惑,获得了什么收获,都欢迎你留言讨论,也欢迎你把复习卡分享给你的朋友。

学习愉快~

即学即练|操作系统篇:复习卡 & 算法题特训

作者: 黄清昊

你好,我是微扰君。初三好!

今天是我们春节特别策划的第三期。

你可以借助整理好的要点卡片,快速回顾这四大章的要点内容。复习完知识点,当然也需要有对应的LeetCode练习题,供你练手复习巩固,你可以点击必知必会算法题链接,检验一下自己的掌握情况,如果发现自己哪里还不太明白,可以回到相应章节,有针对性地复习。

操作系统篇

今天我们来复习&练习专栏的第三章,操作系统篇。在这个模块里,我们一起学习了调度算法、页面置换算法、日志文件系统这三个内容。

必知必会力扣题

  • 题目名 432. 全O(1) 数据结构

题目链接:https://leetcode-cn.com/problems/all-oone-data-structure/

题解思路:十字链表,也是 LFU 的常见实现方式

  • 题目名 146. LRU 缓存

题目链接:https://leetcode-cn.com/problems/all-oone-data-structure/

题解思路:可以直接参考官方题解,我用Golang实现了一个基于LRU的分布式缓存

  • 题目名 1701. 平均等待时间

题目链接:https://leetcode-cn.com/problems/average-waiting-time/solution/

题解思路:先来先服务策略

  • 题目名 1166. 设计文件系统

题目链接:https://leetcode-cn.com/problems/design-file-system/

题解思路:字典树、哈希表

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片


复习&练习的过程中,如果有什么困惑,获得了什么收获,都欢迎你留言讨论。做完题目之后,欢迎你把复习卡和你的LeetCode题解分享给你的朋友。

学习愉快,我们下期见~

即学即练|计算机网络篇:复习卡 & 算法题特训

作者: 黄清昊

你好,我是微扰君。初五好!

通过这几次的复习和练习,你掌握了多少呢,想必收获颇丰。今天是我们春节特别策划的最后一期了,继续坚持哦,加油。

你可以借助整理好的要点卡片,快速回顾今天的要点内容。复习完知识点,也可以用对应的LeetCode练习题练练手,复习巩固一下。

计算机网络篇

今天我们来复习&练习专栏的第四章,计算机网络篇。在这个模块里,我们一起学习了选路问题中的两种算法及背后的最短路求解算法:链路状态算法(Dijkstra)、距离矢量算法(Bellman-Ford),以及滑动窗口算法这4讲内容。

必知必会力扣题

  • 题目名 187. 重复DNA序列

题目链接:https://leetcode-cn.com/problems/repeated-dna-sequences/

题解思路:滑动窗口

  • 题目名 743. 网络延迟时间

题目链接:https://leetcode-cn.com/problems/network-delay-time/

题解思路:参考官解即可,SPFA和Dijkstra都可以解决

复习要点卡

点击要点卡,直达你最需要复习的那篇。

图片

图片

图片

图片


如果有什么困惑,获得了什么收获,都欢迎你留言讨论。

春节的刷题复习课到这里就结束了,我们之后就要开始学习在分布式和工程实践中算法的应用了,会学习在各个业务场景下如何拆解问题、应用算法,升级自己的编程思维。

这7天的学习只是改变的开始。我们下节课见~

开篇词|真实世界的算法,和你想的不一样

作者: 黄清昊

你好,我是黄清昊,毕业于上海交通大学信息工程专业,目前在EMQ X担任存储工程师。

在LeetCode上,我还有一个名字叫“微扰理论”(之后就以微扰君自称),刷了800多道题,贡献了200多篇优质算法题解,可以说对算法学习很有心得了。

提到算法,不知道你有没有这样的疑惑。之前花很多时间学的算法和数据结构,好像就是为了应对算法面试,对日常的开发工作没有什么帮助。

入职之后,往往做着增删改查的活,算法的存在感,最多就是调用调用JDK的包、STL的函数,算法就像是只存在于那些开箱即用的中间件和基础库中而已,和我们的日常开发没什么关系。

而且学习算法的过程还很痛苦,不只是学习曲线比较陡峭,主要还是因为平时可能完全用不到这些知识点,没有连续的时间投入和充分的刻意练习。

许多同学在工作中没什么机会和需求要手写一些基础的数据结构,只是偶尔想起来才做一做LeetCode,很容易发现刚学完的知识点根本记不住,边学边忘。于是开始日常吐槽算法难学,工作中又用不到,不理解大厂面试为什么问这么多算法题,毕竟我们都知道算法面试的著名槽点就是“面试造火箭,工作拧螺丝”。

这确实是一种非常普遍的疑惑。我刚毕业那会也是这么想的。

01

和很多同学一样,我也不是科班出身,不过因为对算法相当感兴趣投入很多时间练习,后来又加入了学校的网络工作室,做了许多偏应用的开发,大三顺利进入阿里实习并转正成为前端工程师。当时觉得自己作为全栈,拳打React,脚踢Spring,比周围只懂理论却毫无动手能力的同学厉害得多,优越感油然而生。

但又和很多同学一样,这样的良好感觉也并没有持续太久。

毕业半年左右我开始感觉日常的增删改查工作非常无聊,写简单的业务代码好像真的只是体力活。因为所在的团队偏创业团队,资源有限,流量很低,所以日常工作中探寻业务价值远多于技术价值。和同事交流,最多也就是学一学代码规范和常用的设计模式,非常没有挑战性,我逐渐失去了对技术的敬畏心。

但现在工作多年回头看,这只是因为当时的自己在工作中的深度还没有达到一定程度,才对计算机基础知识的应用比较少。而事实上,掌握计算机基础知识,学好数据结构和算法,尤其是真实世界的算法,是一个资深程序员的必备素养。

02

我们知道衡量程序运行快慢的一个理论依据就是时间复杂度,这也是面试算法环节重要的考察点,但只考虑时间复杂度显然不够,程序的运行时、操作系统的各种开销都需要考虑到。因为在我们工作的真实世界中,算法数据结构和计算机基础知识,是紧密联系在一起的,对代码的性能产生着至关重要的影响。

很多非科班同学在刚开始学习算法时,可能都会忽略运行时和操作系统的开销,而这在面试中其实经常会考查到的。

当时我第一次社招跳槽面试微软一个存储相关的岗位,面试官问了一个算法问题,因为刷了很多题,我很快就写完了代码。而再被追问代码瓶颈在哪里时,我就只是一直分析算法复杂度觉得没有任何问题,信心满满,最后却只拿到了weak hire,当时我很不理解(weak hire 是一个比较中间的评价,但实际上大部分面试官一般是考虑不录用的)。

过了好几个月,我才终于意识到面试官的意思是要考虑算法在真实的物理机器上运行的情况。我当时写的代码中有一段循环,里面开了一个比较长的数组,导致程序反复地向操作系统申请堆内存;而且我在代码中不断地对数组进行插入操作,这也会导致数组频繁扩容。正确的做法则是,事先考虑好应该申请多少空间,就可以避免持续扩容操作,从而提高程序的运行效率。

但要了解清楚这些事情,显然需要对算法、数据结构、操作系统、程序是如何运行的、内存是如何分配的等知识都有比较清晰的认识才行。毕竟,真实世界的算法,远不是时间复杂度这么简单

03

而在日常开发中,我们就更要考虑这些问题。良好的算法和计算机基础知识才能让我们写出真正高效运行的程序。

许多程序员总是以架构师为目标,在技术上热衷追求新的框架、新的架构,只关注那些看似高大上的架构设计、微服务、云原生等概念,但当架构真的出现性能瓶颈却不知道从何下手开始拆解分析。

他们学了很多,真正掌握的却很少。比如很多同学都知道Redis实现有序集合底层采用的是跳表,但跳表的实现细节、跳表和红黑树相比有什么优势,就很少有人真正理解了。要进行代码调优的时候,脑子里都是只了解了皮毛、但没有充分理解的知识

之前我维护过一个要求单机可支持百万连接的高并发长连网关,在开发时,有一个需求是需要从海量的连接中,寻找一些长期没有上下行数据的非活跃连接并移除他们,减少内存使用。

最开始的代码实现是非常暴力的,直接在存有所有连接信息的巨大HashMap中做全量扫描,依次判断每个连接是否已经满足非活跃连接的条件,显然,这会带来巨大开销,尤其当非活跃连接占比不是很高的时候。

由于之前看过Redis源代码,对LRU背后的hash-linked-list数据结构非常敏感,我看到这个需求,脑海里突然闪过一个念头,如果在内存里维护一个基于最近一次上下行事件时间排序的hash-linked-list呢?关闭非活跃连接的时候,只需要从这个有序列表的头部开始遍历,到第一个没有数据上下行的链接之后就不再需要继续遍历了。

如果只分析时间复杂度的话,你会发现这两个方案,从扫描连接的开销上来说,都是O(N)的复杂度,因为最差情况下可能所有的连接都是非活跃的。但很显然,在我们的场景中,非活跃连接占比是很低的,基于hash-linked-list的实现方案扫描连接的开销,远远小于全量扫描哈希表的开销。

在工作中,我们只有掌握优秀算法的精髓,才能根据实际的workload选择合适的算法。

04

优秀算法思路值得借鉴,但在实际写的时候,可能也还会有更多值得考虑的细节。比如在引入hash-linked-list之后,维护整个数据结构就需要保证并发读写的线程安全,因而带来了锁的开销。

所以很多时候,实际的问题,我们甚至不太好简单地通过理论分析,就得出哪个解决方案是更优的,还需要进行实验对比,不断提出新的猜想和优化方案,直到系统的性能逐步趋于完美。

而提出这些性能调优的方案,就需要我们对基础的数据结构和常用中间件的底层原理比较了解了。虽然在一些偏业务的开发中,不见得都能亲自实现这样比较底层的数据结构,但了解它们,一定也会对我们平时的开发和问题排查带来巨大的好处

毕竟如果你的算法基础不太好,比如理解一个简单的基于数组实现的循环队列都很困难。那当其他人讨论着不同数据结构的优劣和性能瓶颈在哪里,或者复杂场景下各种方案的区别和可能的验证手段时,相信你是很难跟上节奏的。

这也是为什么这个专栏主题是真实世界的算法、工程中的算法。我希望能真正帮助遇到类似问题的你,我们不只会讨论基础的数据结构和算法思想,更会着重掌握这些算法是如何运行在真实的物理机器上的,如何解决实际业务系统中的问题,还有具体是如何在各个稳定运行的中间件、分布式系统、基础库中实现的

专栏主要分为偏基础和偏实战的两部分,共6个篇章,精讲我们在工作中真正用得上的算法。

不过正式学习之前,我们会通过一个“简单”、有趣、常用的文本差分算法为先导,探索那些就在我们身边却常常被熟视无睹的算法,体验思维的乐趣。最后会挑选出几个有趣的算法,在高手番外篇中不定期奉上。

  • 数据结构篇、算法思想篇

这两个模块,包含了工程中常用的基础数据结构和算法思想,比如双向链表、动态数组、哈希表、红黑树、二分搜索、深度优先搜索等,由浅入深,推演算法的来历和特点,分析源码实现思路,不只是了解算法知识,更要理解工业级的算法实现是如何运行在真实的物理机上的。

  • 操作系统篇、计算机网络篇

这两个模块,会带你学习两门非常重要的计算机基础课——操作系统和计算机网络中会用到的基础算法,同样会结合真实的网络库、操作系统的源码进行讲解。这样当你了解许多经典算法的发明背景和应用场景时,再结合操作系统和计算机网络的基础知识,你可以对算法有更深入的理解。

  • 分布式篇、工程实践篇

学习高流量、高并发、高可用的现代互联网应用中各种算法的应用,解析Redis、MySQL和MapReduce等系统或者论文的经典源码。深入理解在各场景下如何拆解问题、应用算法,目的是升级编程思维,帮助你排查真实业务开发中的各种问题,做出良好的架构设计。

当然那些生产环境下稳定运行的系统源码,大多数时候非常复杂,但当你拨开云雾,搞清楚其中的算法思想,并且融汇到你的工作中时,相信我,你一定会觉得当程序员是一件非常快乐的事情。

而且不得不提的一点是,学习这些数据结构和算法的过程本身其实也非常有趣,练习能很好地锻炼我们的编程思维。你不仅能通过对算法的充分训练真切地体会到思维敏捷和思路清晰,而且在工作中遇到的实际问题中,你往往也能更快也更全面地考虑到各种边界情况,并比较准确、优雅地写出正确的实现

聊到这里估计有同学会疑惑,既然算法的实际应用更有意义,那算法题还要刷吗?

是的,不过我们只是把刷题作为方法,而不是目的。我认为做算法题对帮助我们充分理解算法是大有裨益的,这也是为什么我即使现在没有换工作的计划,也一直在坚持参加力扣的周赛。磨刀不误砍柴功,非常推荐你进行长期不间断的算法训练,磨练自己的思维能力,所以在专栏里,我也会附上一些和知识点相关的经典面试算法题,这可以更好地帮助你理解和记忆相关的知识点。

祝你在这段算法学习之旅中,可以真的感受到算法学习真的是一件非常有乐趣,也非常有成就感的事情,就让我们一起来探索工程实战中,每天都存在的算法问题吧,以这个专栏为期,相信你坚持到最后一定会感觉到明显的进步。

期末测试|来赴一场满分之约!

作者: 黄清昊

你好,我是微扰君。

《业务开发算法50讲》暂时就要结课了。非常感谢你一直以来的认真学习和支持!

为了帮你检验自己的学习效果,我特意给你准备了一套结课测试题(可以多次测试噢)。这套测试题一共有 11道单选、9道多选,考点都来自我们前面讲到的重要知识。点击下面按钮开始测试吧!

最后,我还给你准备了一个调查问卷。题目不多,大概两分钟就可以填完,主要是想听一下你对这门课的看法和建议。期待你的反馈!

特别策划|面试:BAT面试三关准备方法大揭秘

作者: 黄清昊

你好,我是微扰君。

春节将近,从今天起我们就不学习新算法了,轻松一点来聊一聊大厂算法面试该怎么准备更高效。

毕竟来极客时间学习,很多同学可能都有一个相同目的,想要通过自我提升拿到大厂Offer,想要进大厂,首先我们一定要过的就是算法面试。算法面试怎么过呢?来得最快、效果也相当不错的方法当然就是刷题了。

因为我自己本科也不是计算机科班出身,第一份工作是做前端,但工作内容并没有给到很好的技术基础,对我后期的技术成长不是特别有利,加上自己又比较想去做一些偏基础架构的工作,我下定决心准备尽早跳槽到偏基础设施的岗位了。今天就来和你一起分享一下我的一些面试心得。

对于工作经验比较浅的同学,大厂面试核心考察的就是你的潜力,主要就三关:算法、计算机基础知识、领域知识,领域知识这块通常会和项目经验一起考察。

这里项目经历、领域知识和计算机基础知识,都需要较长时间的积累,我们短期能操作且一定能有效提升的就是算法面试。

我2020年筹备跳槽,1月份春节的时候就开始疯狂刷题,差不多两个月做了400道题,提交最多的一次是就出现在大年初一那天,我从早上做题做到晚上,差不多AC了37道题。因为算法面试关,技巧都是辅助的,核心就是得下定决心坚持刷,如果你真的能像我当时这样刷题,想过大厂的面试关还是比较容易的。

不过刷题当然也是有窍门的,今天也整理了我自己的算法面试筹备经验,希望能对你有启发。

面试如何刷算法题

既然目的非常清晰,就是为了过算法面试关。首先要搞清楚大厂面试大概的能力要求是什么样的。

我自己的经验是,如果你打力扣的周赛,rating差不多能到1850分,大厂面试就比较稳了。基本面试三家,就能拿到两家的offer,另一个没有拿到的,可能也不是因为算法被刷,有可能是经历被卡或者是八股文被卡。

另一个等价的标准,力扣题目是分难度的easy、medium、hard,如果你做medium没有什么压力的话,或者周赛能稳定输出3道题,基本上就是可以过面试了

目标理解清楚,再就要考虑投入时间了。我个人比较建议用2~3个月的时间集中高强度地刷算法题,因为这段时间其实就相当于在边刷边复习,密集地学习和复习知识,会让你对知识掌握地更牢固,也不太容易忘。在这两三个月中做250~400道题,就可以基本达到目标了。

刷题也是有方法的,我自己总结自己的刷题过程,大概分为三个阶段,按照这个方法刷题准备的同学面试结果往往也都还不错。

第一阶段:分专题刷

刚开始比较建议先按专题刷,有一本很好的入门书是《剑指offer》,很多人应该都听过,它有题目、题解和对应知识点,书里也把题目分了类。你一开始做题的时候可能很多题目都没思路,甚至题目都要读半天。当然面试的时候,如果是这个表现那肯定是过不了的,但是也不要气馁,这说明自己提升的空间很大。

一开始刷题千万不要死磕,一个专题只需要刷10道左右就可以了。拿到题目,不会做可以直接去看答案,差不多看两三道题,你就对题目有基本思路了。10道做完,基本上再看力扣medium的题,虽然不一定正确,起码是有思路能上手写一写了。

到这个程度,我们就可以开始第二阶段的训练了。

第二阶段:随机精刷

现在你就可以力扣随机刷题了,不过这个阶段不建议先看答案,一定要先自己多去思考。因为很多题目比如说medium,它比简单题难的地方就在于有不止一个知识点,每道题也有可能有不同的做法,我们需要把不同的做法和不同的知识点都掌握到位,找经典的题目认真研究精刷

另外这个阶段就可以开始打周赛了,帮助你检验自己学习的程度。周赛题,除了第一道是easy的,后面两道一般是medium,也会考察不止一个知识点。我们可以按照一定的策略去刷,先易后难,先做排序、字符串、搜索之类的简单题,之后再挑战比较进阶的比如DP、并查集等专题,或者做一些比较综合的题目。因为困难一些的题目,往往涉及不只一个知识点,通常也能有效复习到前面的题。

图片

学到这里差不多两个月也过去了,第三个月可以进入最后一个阶段。

第三阶段

现在估计你也形成打周赛的习惯了,可以再加一些随机赛锻炼一下自己的临场发挥。力扣上有功能叫随机面试(当然也是要开会员的,能花小钱解决问题的,我们可以花钱解决),可以很好地模拟实际面试的过程,有时间限制所以会有压力。

这个阶段如果有题目做不出来,一定要记得补题,把这道题高质量的题解看明白,然后自己再把答案再写一遍,定期复习查漏补缺。

最后还有一个我自己准备面试的小trick,当然也不见得每个人都认可,你可以去试一试。因为我自己是从前端转基础架构,对整个行情不是特别了解,所以前期我有投一些公司,其实我也不太可能会去,投了一下就是想去体验真正面试的过程。收获也确实很多。

图片

这就是我刷题的三个阶段,你可以参考。刷题其实最重要的是坚持,如果觉得自己执行力不够强,我也有两个方法,一是可以加一些社群大家互相帮助互相监督,二自然就是花钱了,极客时间算法训练营也不错,会有人专门监督你按进度学。

最基础、提升也最快的算法关解决,另外两个部分基础知识、项目经历同样也是需要准备起来的。

转岗如何准备基础知识

第一大问题是基础知识如何补充。

如果你不是科班出身,针对八股文的面试关有两种办法,一种就是你去看面经,找一些类似《Java面试指南》这些比较浅显易懂的内容,因为看完之后很快就可以背出来,如果面试官问的不深,你是真的可以答出来的。运气好,自然就过了。

但是很显然这种快餐式的学习并不系统也不够深入,面试官如果多追问几个问题,很容易判断出来你是背的,还是真正对计算机基础知识、中间件、原理有自己的理解。

所以如果你时间还比较充分,也对技术感兴趣,可以先从计算机基础知识补起,毕竟源码都是在建立在这些基础上的,比如为什么Kafka读写比较快,这个问题肯定要对计算机体系结构和磁盘的工作原理有一定了解,才能回答得出来。

因为我当时主要想要做分布式存储,对数据库比较感兴趣,整理了一些底层的学习资料也分享给你。

  • CMU 15445,讲数据库非常好的一门课,里面讲了很多经典的paper。帮助你非常系统地梳理数据库发展的历史,而且内容很新。
  • CMU 15213,可以打好计算机体系结构和操作系统的知识基础。CSAPP是这门课的指定教材。
  • MIT 6.824,课程会带着你一起阅读许多经典的分布式领域的论文,了解大规模互联网应用会遇到哪些问题。
  • DDIA,被国外很多同学称为系统设计的圣经,同样是能带你系统了解许多基础数据系统设计精髓的经典书籍。
  • SICP,能很好的帮助你了解编程语言底层原理。

这些课程能给到你非常扎实的基础知识积累了,面试是绰绰有余。

当然在学习的时候,要有意识地深入思考和总结,虽然看起来一开始要投入更多时间,但长期看这一定是一条捷径

比如,有的同学学快速排序,可能过一道题,背一背代码,一个小时就掌握了,但这个知识的深度是经不起工作考验的,比如为什么复杂度是O(n*logn)呢?快速选择法是什么你又知道吗?如果不知道的话,你可以去搜索了解一下哈,这里就不展开讲了。从我的经验来看,可以说只背题的人走的才是歪路。因为我不是科班出身,这一点之前没有深刻意识到,也确实走了弯路。

没有项目经历怎么办

项目经历是很多小厂同学头疼的事,毕竟平时的业务一般都在做增删改查。我当时是想转后端架构,但是自己工作是前端,项目上肯定是没有有优势了。怎么办?

第一个方法就是自己写一些面试项目。我当时写了一个玩具级别的工具类项目,做分布式的缓存,实现了一致性哈希、LRU缓存的功能。虽然是玩具级,但是在没有什么工作内容可以讲的时候,贴一个这样的项目,面试官会觉得我比较努力,另一方面项目里的一些东西,我可以跟面试官聊一聊,展示我对缓存设计的思考和想法。

当然现在这种项目也很多,但是有总比没有强。网上一搜比如手把手教你实现一个RPC框架,你完全可以按照教程来一遍。虽然在开源社区里,这种项目是垃圾项目,但起码是自己动手做过的,面试官看到一般还是会对你比较认可。当然如果你工作中有相关项目肯定是更好了。

另一个方法就是可以参加知名的开源项目,从熟悉的工作入手,尝试做一些issue,一旦你提交了PR,开源社区里会有很多人来给你一些帮助,只要你虚心请教,一定要看提交的规范文档,不要问一些愚蠢的问题(什么叫愚蠢的问题,谷歌上一搜就能搜到答案的问题就是这一类),很多好的开源项目社区,其实是很乐于帮助你的,你甚至可以得到免费的指点。

大厂和算法

可能有同学一直很困惑,为什么大厂要考算法?很多人都会觉得算法在工作中并没有很多用武之地。实际不是的。

第一台计算机差不多应该是上个世纪40年代发明的,是相当伟大的发明了,把物理学上的很多知识运用到生活中解放生产力,当时主要被用来做科学运算、密码破译,然后六七十年代人们开始用计算机做一些伟大的事情,比如登月,探索星空探索宇宙。

这个时候最早期的一批程序员就出现了。他们用计算机处理了很多登月上的难题,比如说着陆、碰撞检测、导航系统设计等等,但是我们都知道那个时候的计算机性能其实很差,登月的阿波罗计划有一张非常有名的照片,一名女程序员和一摞齐人高的纸,那一摞纸就是阿波罗计划里面用到的源代码。

图片

当时的计算机,内存就只有几KB,在这种硬件条件下就需要极致压榨系统的性能,数组能少开就少开,能原地做的算法就一定要原地做,空间复杂度可能比时间复杂度更重要,总的来说,怎么样写性能好就怎样写。所以程序员们的算法要求就特别高。

当然他们当时没有那么多复杂的依赖,也没有那么多抽象,很多问题都是通过比较暴力的方式,先把问题解决,也不会去管设计模式、让代码有多么的可读。但他们那时候发明的很多算法,包括早期的计算机网络、最短路算法等等,到现在都非常有用。

那现在算法要求比较高的程序员们在什么地方呢?其实也是在需要更加注重性能的地方,以前是因为资源很有限,现在是因为并发量很大

一些互联网大厂,虽然现在红利已经不在了,但是毕竟积累的存量用户足够多,服务器每天都是在面对海量的请求,而且计算机毕竟受摩尔定律的约束,你肯定希望在有限的内存里让它跑得更好。就比如,基础的PyTorch或者中间件Kafka都是需要压榨性能的,要么是因为IO的要求特别高,要么是因为它本身要做的计算就非常复杂,对于很复杂的计算来说,即使现在的内存和硬件资源也不是很充足,我们需要让它跑得尽量快。

在这些底层的东西里我们肯定也是需要用到算法的,在大厂里相关的岗位就是基础架构的开发,做数据库或者RPC服务框架之类。

当然我们更多的程序员是做普通的业务开发,日常练习算法除了能让思维更清晰敏捷、考虑问题更加全面之外,更重要的是,我们最终还是要依赖这些中间件的,一旦中间件出现问题就需要自己去定位去判断,这个时候就需要阅读相应源码,如果对源码里的算法不是很熟悉,你可能就会觉得源码这东西也太难啃了吧。

还有比如要做架构的选型,肯定是不能只是去看网上的文章别人怎么说,得自己更深入地去看、去使用这个中间件它到底快在什么地方,比如用消息队列,你是用Kafka、RocketMQ还是Pulsar?它们之间到底有什么区别?比如现在很多人就觉得Pulsar比Kafka性能更好,为什么呢?有些是架构上的变化,也有很多是算法上的变化。

总结

如果你真的想在程序员这条路上走得更远,还是要想办法让自己在编程中找到自己的乐趣,无论是看业务发展突飞猛进,还是从技术实现自我的提升,找到自己的成就感。

我个人更喜欢在技术上做出成绩,所以如果你也是这样,可以研究偏中间件的系统、研究底层的算法、研究一些背后的机制,这些东西才是有技术价值也有技术乐趣的。当然这些背后我们一定要有好的计算机基础知识,也需要有好的算法和数据结构的基础知识。

图片

聊一聊

今天就不设置课后作业了,欢迎你在留言区分享自己之前面试的有趣故事,我们一起聊一聊。

如果觉得这篇文章对你的面试准备有帮助的话,也欢迎转发给你的朋友。我们下节课见~

结束语|在技术的世界里享受思维的乐趣

作者: 黄清昊

你好,我是微扰君。

不知不觉,我们的专栏就要暂时结束了。

不知道你在学习的时候有什么感觉,这个专栏对我来说算是一个很大的挑战,长达半年的写作可不轻松,甚至可以说是“痛苦”的。

说这个专栏挑战大,主要因为涉及的内容广泛,如果你从头到尾跟完专栏也一定会深有体会。从最基础的数据结构和算法原理,到操作系统、计算机网络这样的计算机基础知识,再到解决真实生产环境下不同系统所面临的各种不同问题的算法,我们都有所涉猎。

学习了这么多不同领域的算法,相信你也能充分感受到我们一直在强调的观点:学习算法绝不只是为了应付面试,事实上算法在真实的生产环境中是有很大用处的。只不过很多时候,这些真实的算法问题比较复杂,比起业务问题也更为通用,所以有很多前辈们把这些复杂性都封装了起来,给了我们普通业务开发工程师们一个看起来简单的编程世界。

但如果我们想在技术的世界里走得更远,那么,勇于揭开这层漂亮面纱,直面系统中的复杂性,就是我们必须要迈出的一步。

但是作为普通的工程师,选择直面并不意味着之后的困难减少了,我们在深入学习的过程中可能会更“困惑”。在写专栏的时候,我也尤其有这个感受。

在开始写课程目录的时候,实际上我列出了60多个算法主题,觉得很值得讲、很值得学。但是编辑提醒我说,学习这件事,如果想持续地进行,必须要考虑到时间和体量。后来我和编辑一起来回迭代好几轮,才删减到 40 个左右。本以为好不容易课程框架通过了,课程设计后面就会是一片坦途,结果发现挑战依旧远超想象。

最大的困难就是在实际落地写的时候,我经常发现有些问题想要讲清楚,需要的篇幅比当初想象中要大得多,必须不断地对内容做修剪,力求在篇幅内讲清楚每个核心问题是什么、为什么有这样的问题、大致的解法是怎样的,尽量删减掉一些不那么重要的技术细节。

当然,最后的成品我还是比较满意的。今天回看前面的每一讲,虽然并不算太深入,但应该还是足以开拓你的技术广度,如果你想深入研究某个相关技术,现在估计也有点线索了吧。

值得讲的算法远比在专栏中讲到的多,但是我们都是在学习的路上,又有谁能走到尽头呢。

毕竟,计算机和互联网发展了这么久,不同场景下的技术问题本来就层出不穷。直到现在很多问题也依旧会拿出来被反复讨论,不断有更好更新的解决方法被提出,而未来,也必然会有各种各样新的问题被提出

所谓,罗马不是一日建成的,整个计算机的世界正是这样演进了几十年。比如早期,计算机都是单机的,无论是计算能力还是存储能力都非常有限,我们依旧在这样的硬件基础上造出了非常复杂的系统,比如操作系统、文件系统、数据库等等,随便一个成熟的项目可能都有十几万行以上的代码;而现在,随着硬件能力的提升和数据爆发式的增长,在分布式环境下,我们面临的挑战当然会更大,除了要解决很多新问题,也要重新解决一些老的问题。

想解决这些问题,除了需要一些天才的想法,更大程度上需要程序员能对之前系统有深刻理解。但想一个人把这些问题全部涉猎是不切实际的,我们能做的、更应该花时间做的,就是通过了解和学习其中一部分经典问题,获得解决另一些问题的思路和方法

而学习这些问题的时候一定要注意溯本求源,这才是学习最好的捷径,只有先搞清楚“为什么”才能真正搞清楚“怎么做”;这样再碰到新的问题的时候,我们才能站在巨人的肩膀上继续前行。

那过去几十年里,无数不同国籍的工程师和科学家正是这么做的,他们通力协作,一起打造了我们如此繁荣的计算机世界。

总的来说,虽然没有办法把前人的智慧在专栏中全部展现给你,不过你可以把这个专栏作为一个简单的算法索引,帮助自己更快找到感兴趣领域内的一手资料,比如论文或者项目源码,后续进行更深入的研究。希望通过这个专栏的学习,你能多掌握一些前人们解决不同问题的思考角度,至少,希望他们能给你的工作带来一些启发和乐趣

在整个专栏的写作过程,除了“痛苦”和“困惑”,我也确实感受到了很大的快乐。

其中一部分快乐当然来自于完成专栏的成就感,无论是许多读者的订阅和留言,还是社群里大家的讨论,都让我感受到自己在做的事情是很有意义的。但更大的快乐还是来源于对知识的反复求证和探索(毕竟我更多时间在写:))。

专栏里很多主题的算法,我在工作中其实也没有太多机会使用,所以为了把问题讲清楚,在写的时候,我反复看了很多项目的源码、文档、博客,还有论文,力求将问题理解透彻。在这个过程里,我发现了很多以前没有注意到的细节,以及理解错误的地方,也对很多概念有了更系统的认知,可以说在欣赏前人的智慧之光中,我获得了无与伦比的快乐。

比如在讲字符串匹配的章节里,我第一次系统地梳理了各种求解字符串匹配的方法和思路,研读了 Boyer Moore 算法复杂度的证明,并自己做了实验对比了几种算法的效率,这让我对这个问题有了比以往深刻得多的理解,也更惊叹于Boyer Moore算法的巧妙和在实际生产环境下的高效性。不知道你在学习哪些章节的时候,有没有类似的感受。

在过去的工作中,我认识了许多不同的工程师,其中不乏优秀的同事,但也有很多同事对技术问题的理解不那么尽如人意,在他们身上,我观察到一个显著的区别:优秀的同事往往对技术本身有着更强烈的好奇,比如出现一些事故的时候,想办法快速解决问题之后,他们往往会做更深刻的复盘,去了解相关中间件或者代码背后的运行机制,甚至还会做一些分享;而技术一般的同事,往往懒于做更多的研究和探讨。

其实,对于大部分程序员来说,既然当初选择了编程这条道路,内心都是能找到对编程的兴趣,尤其是那些愿意投入时间主动学习的同学,没错,我说的就是在极客时间学习的你。

那如何能在日常工作中找到编程的乐趣呢?说实话,我个人觉得在日常的增删改查中找到乐趣还是比较难,因为这样的工作确实比较重复,这也是我自己花了很大的努力从前端转行基础组件开发的原因,但这个选择并不是适合任何人,毕竟大家的兴趣点不同。

但是我相信,对于大部分业务开发同学来说,只需要在日常开发中多多留心观察,

  • 在碰到的坑的时候,多走一步,研究一下所用组件、框架或者语言背后的一些机制,很快你就会发现这些日常工作中能接触到的程序,其实设计得都非常巧妙;
  • 然后,你再想一想如果由你来设计相关的组件又会怎么做,或者,对比一下以前见过的类似组件的工作原理有没有什么差异;
  • 最后,和身边的同事一起讨论分享一下。

相信我,只要你坚持这样做,一定可以收获思维的乐趣,并且有一天你也一定会发现,自己对技术的理解进入了下一个层次,因为这些日积月累的知识,早就悄悄地融进你日常的代码和设计中了。

最后,谢谢你学习这个专栏,如果你在学习之后还根据思考题,自己做了一些相关的课外阅读或者动手实验,这些努力一定不会白费,这两件事在算法专栏的学习中其实起着更重要的作用。

专栏暂时结束了,但我们的技术之旅还在继续,这次的结束只是我们的新开始。我会继续更新若干篇更进阶的加餐和修订过去的章节(6讲高手番外会在接下来的一个半月内持续更新),还有很多评论我还没来得及一一回复,这些我都会在未来一段时间内抽时间完成。

在专栏要暂时结束的今天,我也非常希望能听到你的声音,点这里参与问卷反馈。如果哪天你发现自己在工作中用到了相关知识点,也欢迎你常回来复习留言。未来,你一定会有更多的问题需要解决,希望有了这个专栏能让你解决那些问题时更加从容一些,也更加快乐一些。

在技术的世界里享受思维的乐趣,这是我对你最大的祝愿,希望这个专栏可以帮助你更快地实现这个目标。