作者:京东物流 纪卓志
目前市面上充斥着大量关于跳跃表结构与Redis的源码解析,但是经过长期观察后发现大都只是在停留在代码的表面,而没有系统性地介绍跳跃表的由来以及各种常量的由来。作为一种概率数据结构,理解各种常量的由来可以更好地进行变化并应用到高性能功能开发中。本文没有重复地以对现有优秀实现进行代码分析,而是通过对跳跃表进行了系统性地介绍与形式化分析,并给出了在特定场景下的跳跃表扩展方式,方便读者更好地理解跳跃表数据结构。
跳跃表[1,2,3]是一种用于在大多数应用程序中取代平衡树的概率数据结构。跳跃表拥有与平衡树相同的期望时间上界,并且更简单、更快、是用更少的空间。在查找与列表的线性操作上,比平衡树更快,并且更简单。
概率平衡也可以被用在基于树的数据结构[4]上,例如树堆(Treap)。与平衡二叉树相同,跳跃表也实现了以下两种操作
- 通过搜索引用[5],可以保证从任意元素开始,搜索到在列表中间隔为k的元素的任意期望时间是O(logk)
- 实现线性表的常规操作(例如将元素插入到列表第k个元素后面)
这几种操作在平衡树中也可以实现,但是在跳跃表中实现起来更简单而且非常的快,并且通常情况下很难在平衡树中直接实现(树的线索化可以实现与链表相同的效果,但是这使得实现变得更加复杂[6])
预览
最简单的支持查找的数据结构可能就是链表。Figure.1是一个简单的链表。在链表中执行一次查找的时间正比于必须考查的节点个数,这个个数最多是N。
Figure.1 Linked List
Figure.2表示一个链表,在该链表中,每个一个节点就有一个附加的指针指向它在表中的前两个位置上的节点。正因为这个前向指针,在最坏情况下最多考查⌈N/2⌉+1个节点。
Figure.2 Linked List with fingers to the 2nd forward elements
Figure.3将这种想法扩展,每个序数是4的倍数的节点都有一个指针指向下一个序数为4的倍数的节点。只有⌈N/4⌉+2个节点被考查。
Figure.3 Linked List with fingers to the 4th forward elements
这种跳跃幅度的一般情况如Figure.4所示。每个2i节点就有一个指针指向下一个2i节点,前向指针的间隔最大为N/2。可以证明总的指针最大不会超过2N(见空间复杂度分析),但现在在一次查找中最多考查⌈logN⌉个节点。这意味着一次查找中总的时间消耗为O(logN),也就是说在这种数据结构中的查找基本等同于二分查找(binary search)。
Figure.4 Linked List with fingers to the 2ith forward elements
在这种数据结构中,每个元素都由一个节点表示。每个节点都有一个高度(height)或级别(level),表示节点所拥有的前向指针数量。每个节点的第i个前向指针指向下一个级别为i或更高的节点。
在前面描述的数据结构中,每个节点的级别都是与元素数量有关的,当插入或删除时需要对数据结构进行调整来满足这样的约束,这是很呆板且低效的。为此,可以将每个2i节点就有一个指针指向下一个2i节点的限制去掉,当新元素插入时为每个新节点分配一个随机的级别而不用考虑数据结构的元素数量。
虽然无法通过元素数量来确定每个节点的级别,但是通过考查Figure.1到Figure.4中的节点分布规律不难发现,随着级别的增加,当前级别的节点数量成比例减少。在Figure.1到Figure.4中,这个比例是12,也就是说只有12i个节点的级别是i,随机选择节点的级别的概率分布遵循P(X)=(12)X。所以Figure.5也可以认为是这种数据结构的一个实例。
Figure.5 Skip List
数据结构
到此为止,已经得到了所有让链表支持快速查找的充要条件,而这种形式的数据结构就是跳跃表。接下来将会使用更正规的方式来定义跳跃表
- 所有元素在跳跃表中都是由一个节点表示。
- 每个节点都有一个高度或级别,有时候也可以称之为阶(step),节点的级别是一个与元素总数无关的随机数。规定NULL的级别是∞。
- 每个级别为k的节点都有k个前向指针,且第i个前向指针指向下一个级别为i或更高的节点。
- 每个节点的级别都不会超过一个明确的常量MaxLevel。整个跳跃表的级别是所有节点的级别的最高值。如果跳跃表是空的,那么跳跃表的级别就是1。
- 存在一个头节点head,它的级别是MaxLevel,所有高于跳跃表的级别的前向指针都指向NULL。
稍后将会提到,节点的查找过程是在头节点从最高级别的指针开始,沿着这个级别一直走,直到找到大于正在寻找的节点的下一个节点(或者是NULL),在此过程中除了头节点外并没有使用到每个节点的级别,因此每个节点无需存储节点的级别。
在跳跃表中,级别为1的前向指针与原始的链表结构中next指针的作用完全相同,因此跳跃表支持所有链表支持的算法。
对应到高级语言中的结构定义如下所示(后续所有代码示例都将使用C语言描述)
从前面的预览章节中,不难看出MaxLevel的选值影响着跳跃表的查询性能,关于MaxLevel的选值将会在后续章节中进行介绍。在此先将MaxLevel定义为32,这对于232个元素的跳跃表是足够的。延续预览章节中的描述,跳跃表的概率被定义为0.5,关于这个值的选取问题将会在后续章节中进行详细介绍。
算法
搜索
在跳跃表中进行搜索的过程,是通过Z字形遍历所有没有超过要寻找的目标元素的前向指针来完成的。在当前级别没有可以移动的前向指针时,将会移动到下一级别进行搜索。直到在级别为1的时候且没有可以移动的前向指针时停止搜索,此时直接指向的节点(级别为1的前向指针)就是包含目标元素的节点(如果目标元素在列表中的话)。在Figure.6中展示了在跳跃表中搜索元素17的过程。
Figure.6 A search path to find element 17 in Skip List
整个过程的示例代码如下所示,因为高级语言中的数组下标从0开始,因此forwards[0]表示节点的级别为1的前向指针,依此类推
插入和删除
在插入和删除节点的过程中,需要执行和搜索相同的逻辑。在搜索的基础上,需要维护一个名为update的向量,它维护的是搜索过程中跳跃表每个级别上遍历到的最右侧的值,表示插入或删除的节点的左侧直接直接指向它的节点,用于在插入或删除后调整节点所在所有级别的前向指针(与朴素的链表节点插入或删除的过程相同)。
当新插入节点的级别超过当前跳跃表的级别时,需要增加跳跃表的级别并将update向量中对应级别的节点修改为head节点。
Figure.7和Figure.8展示了在跳跃表中插入元素16的过程。首先,在Figure.7中执行与搜索相同的查询过程,在每个级别遍历到的最后一个元素在对应层级的前向指针被标记为灰色,表示稍后将会对齐进行调整。接下来在Figure.8中,在元素为13的节点后插入元素16,元素16对应的节点的级别是5,这比跳跃表当前级别要高,因此需要增加跳跃表的级别到5,并将head节点对应级别的前向指针标记为灰色。Figure.8中所有虚线部分都表示调整后的效果。
Figure.7 Search path for inserting element 16
Figure.8 Insert element 16 and adjust forward pointers
在删除节点后,如果删除的节点是跳跃表中级别最大的节点,那么需要降低跳跃表的级别。
Figure.9和Figure.10展示了在跳跃表中删除元素19的过程。首先,在Figure.9中执行与搜索相同的查询过程,在每个级别遍历到的最后一个元素在对应层级的前向指针被标记为灰色,表示稍后将会对齐进行调整。接下来在Figure.10中,首先通过调整前向指针将元素19对应的节点从跳跃表中卸载,因为元素19对应的节点是级别最高的节点,因此将其从跳跃表中移除后需要调整跳跃表的级别。Figure.10中所有虚线部分都表示调整后的效果。
Figure.9 Search path for deleting element 19
Figure.10 Delete element 19 and adjust forward pointers
生成随机级别
在前面提到过,随机选择节点的级别的概率分布遵循概率为12的指数分布,也就是说第i+1层的节点数量是第i层的一半。为了避免使用魔法值,定义一个分数p代表节点同时带有第i层前向指针和第i+1层前向指针的概率(在之前的讨论中,p=12)。节点的级别可以使用如下方法随机生成,在这个算法中节点的级别可以在不依赖跳跃表元素数量的前提下生成
以下表格为按上面算法执行232次后,所生成的随机级别的分布情况。
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
2147540777 | 1073690199 | 536842769 | 268443025 | 134218607 | 67116853 | 33563644 | 16774262 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
8387857 | 4193114 | 2098160 | 1049903 | 523316 | 262056 | 131455 | 65943 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
32611 | 16396 | 8227 | 4053 | 2046 | 1036 | 492 | 249 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
121 | 55 | 34 | 16 | 7 | 9 | 2 | 1 |
MaxLevel的选择
在Figure.4中曾给出过对于10个元素的跳跃表最理想的分布情况,其中5个节点的级别是1,3个节点的级别是2,1个节点的级别是3,1个节点的级别是4。
这引申出一个问题:既然相同元素数量下,跳跃表的级别不同会有不同的性能,那么跳跃表的级别为多少才合适?
经过分析得出,跳跃表在级别L上期望的节点数量是1p,当L=log1pn时这是成立的[1]。在此可以定义L(n)来代表log1pn。
由于级别可以安全地限定在L(n),可以选择MaxLevel=L(N)(N是跳跃表中元素数量的上限)。如果p=12,那么MaxLevel=32就可以安全地包含最多232个元素。
分析
空间复杂度分析
前面提到过,分数p代表节点同时带有第i层前向指针和第i+1层前向指针的概率,而每个节点的级别最少是1,因此级别为i的前向指针数为npi−1。定义S(n)表示所有前向指针的总量,由等比数列求和公式可得
S(n)=n∗(1+p+p2+...+pL(n)−1)=n∗1−pL(n)−11−p=n∗1−plog1pn−11−p
对S(n)求极限,有
limn→+∞S(n)=n∗11−p=n1−p
易证,这是一个关于p的单调递增函数,因此p的值越大,跳跃表中前向指针的总数越多。因为1−p是已知的常数,所以说跳跃表的空间复杂度是O(n)。
时间复杂度分析
非形式化分析
延续前面的定义,分数p代表节点同时带有第i层前向指针和第i+1层前向指针的概率,也就是说相邻两个级别为i+k(k>0)的节点中间期望有1p−1个级别为i的节点。
而通过考查Figure.6搜索路径,不难发现,在级别i的搜索永远不会触达级别大于i的节点(因为级别大于i的节点已经在级别i+k(k>0)的搜索中被否决),因此可以认为在理想情况下每一层最多只需要访问1p个节点。
由搜索是通过Z字形遍历所有没有超过要寻找的目标元素的前向指针来完成的,因此搜索总是需要经过L(n)层。所以期望的访问次数是1pL(n),因为L(n)=log1pn,所以期望的访问次数是1plog1pn,因为1/p是可以确定的常数,因此跳跃表的搜索、插入和删除的时间复杂度都是O(logn)
形式化分析
定义1. $={prob} and \le{prob}:定义:定义X和和Y是两个非负的无关的随机变量(通常来说,是两个非负的无关的随机变量(通常来说,X和和Y表示算法表示算法A_X和和A_Y的执行时间)。定义的执行时间)。定义X\le_{prob}Y当且仅当对于任意当且仅当对于任意t,,t在在X中出现的概率总是小于中出现的概率总是小于t在在Y$中出现的概率时为真。更加形式化的定义
X=probY if ∀t,Prob{X>t}=Prob{Y>t}
X≤probYif∀t,Prob{X>t}≤Prob{Y>t}
定义2. 负二项分布(NB(s,p))[7]):定义s为一个非负整数,并且p是概率。NB(s,p)表示一个在成功概率为p的情况下,重复执行随机独立试验在第s次成功前的失败次数的随机变量。NB(s,p)的数学期望是s(1−p)/p,而方差是s(1−p)/p2。
延续分数p代表节点同时带有第i层前向指针和第i+1层前向指针的概率的定义,考虑反方向分析搜索路径。
搜索的路径总是小于必须执行的比较的次数的。首先需要考查从级别1(在搜索到元素前遍历的最后一个节点)爬升到级别L(n)所需要反向跟踪的指针数量。虽然在搜索时可以确定每个节点的级别都是已知且确定的,在这里仍认为只有当整个搜索路径都被反向追踪后才能被确定,并且在爬升到级别L(n)之前都不会接触到head节点。
在爬升过程中任何特定的点,都认为是在元素x的第i个前向指针,并且不知道元素x左侧所有元素的级别或元素x的级别,但是可以知道元素x的级别至少是i。元素x的级别大于i的概率是p,可以通过考虑视认为这个反向爬升的过程是一系列由成功的爬升到更高级别或失败地向左移动的随机独立实验。
在爬升到级别L(n)过程中,向左移动的次数等于在连续随机试验中第L(n)−1次成功前的失败的次数,这符合负二项分布。期望的向上移动次数一定是L(n)−1。因此可以得到无限列表中爬升到L(n)的代价C C=prob(L(n)−1)+NB(L(n)−1,p) 列表长度无限大是一个悲观的假设。当反向爬升的过程中接触到head节点时,可以直接向上爬升而不需要任何向左移动。因此可以得到n个元素列表中爬升到L(n)的代价C(n)
C(n)≤probC=prob(L(n)−1)+NB(L(n)−1,p)
因此n个元素列表中爬升到L(n)的代价C(n)的数学期望和方差为
因为1/p是已知常数,因此跳跃表的搜索、插入和删除的时间复杂度都是O(logn)。
P的选择
得到搜索代价为E(C(n))<1plog1pn后,可以分析p的选择对E(C(n))的影响。为了更好的分析p对搜索时间的影响,需要对E(C(n))进行等价变换
E(C(n))<1plog1pn=ln(n)pln(1p)
不难看出,对于任意确定的n,ln(n)都是确定的,因此只需要分析1pln(1p)的变化趋势就可以。为了更好的进行对比,以p=12为基础值进行标准化,Figure.11就是随着p的变化,搜索时间的变化趋势,其中p=1e的点是搜索时间最好的点,但是二的整数次幂可以更好地进行随机级别的生成。
Figure.11 Normalized search times
而前面分析空间复杂度时也确定了n1−p,空间用量随着p减小而降低,因此p=14是优于p=12的,这也是Redis中使用p=0.25而不是p=0.5的一个原因。
扩展
快速随机访问
在跳跃表中通过前向引用实现了O(log1pn)时间复杂度的搜索、插入与删除算法,但是对于随机访问数组中第i个元素操作仍需要O(i)时间。通过观察Figure.7中的Z字形搜索路径,不难发现,从头节点到某个节点的路径中所有前向指针的跨度的和,就是这个节点在跳跃表中的位置。那么通过在前向指针中维护当前节点到目标节点的跨度,就可以保证随机访问的时间复杂度也是O(log1pn)。
首先,需要对数据结构重新进行定义,在前向指针中增加跨度相关的记录,并将其初始设置为0。此外,可以认为NULL在跳跃表中的位置永远是跳跃表的长度(从0开始),因此需要在跳跃表中记录总长度。
本节中的代码参考自Redis的跳跃表实现[8], 为了与前面的代码风格保持一致,进行了适当的修改
接下来需要修改插入和删除操作,来保证在跳跃表修改后跨度的数据完整性。
需要注意的是,在插入过程中需要使用indices记录在每个层级遍历到的最后一个元素的位置,这样通过做简单的减法操作就可以知道每个层级遍历到的最后一个元素到新插入节点的跨度。
当实现了快速随机访问之后,通过简单的指针操作即可实现区间查询功能。
参考文献
- Pugh, W. (1989). A skip list cookbook. Tech. Rep. CS-TR-2286.1, Dept. of Computer Science, Univ. of Maryland, College Park, MD [July 1989]
- Pugh, W. (1989). Skip lists: A probabilistic alternative to balanced trees. Lecture Notes in Computer Science, 437–449. https://doi.org/10.1007/3-540-51542-9_36
- Weiss, M. A. (1996). Data Structures and Algorithm Analysis in C (2nd Edition) (2nd ed.). Pearson.
- Aragon, Cecilia & Seidel, Raimund. (1989). Randomized Search Trees. 540-545. 10.1109/SFCS.1989.63531.
- Wikipedia contributors. (2022b, November 22). Finger search. Wikipedia. https://en.wikipedia.org/wiki/Finger_search
- Wikipedia contributors. (2022a, October 24). Threaded binary tree. Wikipedia. https://en.wikipedia.org/wiki/Threaded_binary_tree
- Wikipedia contributors. (2023, January 4). Negative binomial distribution. Wikipedia. https://en.wikipedia.org/wiki/Negative_binomial_distribution
- Redis contributors. Redis ordered set implementation. GitHub. https://github.com/redis/redis