跳跃表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上下,但跳表的原理相当简单,只要你能熟练操作链表,就能轻松实现一个 Skip List。
有序表的搜索
比如一个有序表:
从该有序表中搜索元素 (23, 43, 59 ) ,需要比较的次数分别为 ( 2, 4, 6 ),总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:
这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。
我们还可以再从一级索引提取一些元素出来,作为二级索引,变成如下结构:
跳跃表
其实这种结构就是跳跃表,这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了。
如上图所示,跳跃表有以下性质:
- 由很多层结构组成
- 每一层都是一个有序的链表
- 最底层(Level 1)的链表包含所有元素
- 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
- 每个节点包含两个指针,一个指向同一链表中的下一个元素,另外一个指向下面一层的元素。
我们看下跳跃表的定义:
可以看到一个跳跃表包含一个头、尾指针(header/tail),以及一个length长度字段,一个跳表层数字段。
那么跳跃表包含多个层级,每个层级包含多个节点。从图中我们看到,一个几点包含一个ele字段,表示成员对象,一个score字段表示该成员的得分,一个backward后退指针,一个层级结构包含后退指针和跨度。
另外说一个题外话,图中最后一个结构即有序集合的定义,我们可以看到是由一个字典 和 一个跳跃表实现的,今天我们主要介绍跳跃表实现的细节,关于字典的方方面面可以查阅公众号之前的一片文章 Redis源码阅读 - 哈希表的设计与实现。
节点插入
节点插入需要先确定该元素要占据的层数 K(一般采用丢硬币的方式)然后在 Level 1 ... Level K 各个层的链表都插入元素。我们先看下K的随机策略实现:
我们知道(random()&0xFFFF 都会将高位清零,得到 <= 0xFFFF的随机数,这个随机数比ZSKIPLIST_P * 0xFFFF小的概率为ZSKIPLIST_P。看源码定义,是0.25,即1/4的概率会+1。最后还有一个限制,层数不能大于最大层级限制常量ZSKIPLIST_MAXLEVEL,目前这个常量的默认值是32层。
下面我们看下 skiplist 数据插入算法的 具体的实现:
我们可以看到整个插入过程包含以下几个重要阶段:
- 遍历 skiplist 中所有的层,通过score定位ele应该所处的位置,并保存在 update 中
- random一个level的K值,是按我们上文介绍的方法随机出来的
- 如果random出来的level比跳表原有level大,则增加跳表的level
- 把新节点插入到update[i]的后面
- 调整新节点的前驱指针、调整 skiplist 的长度
节点删除
有数据插入就有数据删除,跳表的删除算和插入算法步骤类似:找出每一层需删除数据的前驱并保存;接着调整指针,在 redis 中还会调整 span。我们看下源码实现:
我们可以看到,跳表节点删除大致的过程如下:
- x是需要删除的节点ele,先遍历找到节点x,update是每一个层x的前驱数组
- 调整span和forward指针
- 调整后驱指针
- 删除某一个节点后,层数 level 可能降低,调整 level,调整跳表的长度
总结
- 跳跃表是一种随机化数据结构,查找、添加、删除操作都可以在对数期望时间下完成。
- 跳跃表目前在 Redis 的唯一作用,就是作为有序集类型的底层数据结构(之一,另一个构成有序集的结构是字典)。
- 为了满足自身的需求,Redis 基于 William Pugh 论文中描述的跳跃表进行了修改。
包括:score 值可重复;对比一个元素需要同时检查它的 score 和 memeber ;每个节点带有高度为 1 层的后退指针,用于从表尾方向向表头方向迭代。
自此我们学习完 skiplist 的方方面面,如有其他疑问欢迎留言探讨。