深入理解跳表及其在Redis中的应用

前言

跳表可以达到和红黑树一样的时间复杂度 O(logN),且实现简单,Redis 中的有序集合对象的底层数据结构就使用了跳表。其作者威廉·普评价:跳跃链表是在很多应用中有可能替代平衡树的一种数据结构。本篇文章将对跳表的实现及在Redis中的应用进行学习。

一. 跳表的基础概念

跳表,即跳跃链表(Skip List),是基于并联的链表数据结构,操作效率可以达到O(logN),对并发友好,跳表的示意图如下所示。

跳表的特点,可以概括如下。

•跳表是多层(level)链表结构;

•跳表中的每一层都是一个有序链表,并且按照元素升序(默认)排列;

•跳表中的元素会在哪一层出现是随机决定的,但是只要元素出现在了第 k 层,那么 k 层以下的链表也会出现这个元素;

•跳表的底层的链表包含所有元素;

•跳表头节点和尾节点不存储元素,且头节点和尾节点的层数就是跳表的最大层数;

•跳表中的节点包含两个指针,一个指针指向同层链表的后一节点,一个指针指向下层链表的同元素节点。

以上图中的跳表为例,如果要查找元素 71,那么查找流程如下图所示。

从顶层链表的头节点开始查找,查找到元素71的节点时,一共遍历了4个节点,但是如果按照传统链表的方式(即从跳表的底层链表的头节点开始向后查找),那么就需要遍历7个节点,所以跳表以空间换时间,缩短了操作跳表所需要花费的时间。跳跃列表的算法有同平衡树一样的渐进的预期时间边界,并且更简单、更快速和使用更少的空间。这种数据结构是由William Pugh(音译为威廉·普)发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。 谷歌上找到一篇作者关于跳表的论文,感兴趣强烈建议下载阅读:

https://epaperpress.com/sortsearch/download/skiplist.pdf

跳表在动态查找过程中使用了一种非严格的平衡机制来让插入和删除都更加便利和快捷,这种非严格平衡是基于概率的,而不是平衡树的严格平衡。说到非严格平衡,首先想到的是红黑树RbTree,它同样采用非严格平衡来避免像AVL那样调整树的结构,这里就不展开讲红黑树了,看来跳表也是类似的路子,但是是基于概率实现的。

二. 跳表的节点

已知跳表中的节点,需要有指向当前层链表后一节点的指针,和指向下层链表的同元素节点的指针,所以跳表中的节点,定义如下。

1public class SkiplistNode { 2 3 public int data; 4 public SkiplistNode next; 5 public SkiplistNode down; 6 public int level; 7 8 public SkiplistNode(int data, int level) { 9 this.data = data; 10 this.level = level; 11 } 12

上述是跳表中的节点的最简单的定义方式,存储的元素 data 为整数,节点之间进行比较时直接比较元素 data 的大小。

三. 跳表的初始化

跳表初始化时,将每一层链表的头尾节点创建出来并使用集合将头尾节点进行存储,头尾节点的层数随机指定,且头尾节点的层数就代表当前跳表的层数。初始化后,跳表结构如下所示。

跳表初始化的相关代码如下所示。

1public LinkedList<SkiplistNode> headNodes; 2public LinkedList<SkiplistNode> tailNodes; 3 4public int curLevel; 5 6public Random random; 7 8public Skiplist() { 9 random = new Random(); 10 11 //headNodes用于存储每一层的头节点 12 headNodes = new LinkedList<>(); 13 //tailNodes用于存储每一层的尾节点 14 tailNodes = new LinkedList<>(); 15 16 //初始化跳表时,跳表的层数随机指定 17 curLevel = getRandomLevel(); 18 //指定了跳表的初始的随机层数后,就需要将每一层的头节点和尾节点创建出来并构建好关系 19 SkiplistNode head = new SkiplistNode(Integer.MIN_VALUE, 0); 20 SkiplistNode tail = new SkiplistNode(Integer.MAX_VALUE, 0); 21 for (int i = 0; i <= curLevel; i++) { 22 head.next = tail; 23 headNodes.addFirst(head); 24 tailNodes.addFirst(tail); 25 26 SkiplistNode headNew = new SkiplistNode(Integer.MIN_VALUE, head.level + 1); 27 SkiplistNode tailNew = new SkiplistNode(Integer.MAX_VALUE, tail.level + 1); 28 headNew.down = head; 29 tailNew.down = tail; 30 31 head = headNew; 32 tail = tailNew; 33 } 34} 35

四. 跳表的添加方法

每一个元素添加到跳表中时,首先需要随机指定这个元素在跳表中的层数,如果随机指定的层数大于了跳表的层数,则在将元素添加到跳表中之前,还需要扩大跳表的层数,而扩大跳表的层数就是将头尾节点的层数扩大。下面给出需要扩大跳表层数的一次添加的过程。

初始状态时,跳表的层数为 2,如下图所示。

现在要往跳表中添加元素 120,并且随机指定的层数为 3,大于了当前跳表的层数 2,此时需要先扩大跳表的层数,2如 下图所示。

现在要往跳表中添加元素 120,并且随机指定的层数为 3,大于了当前跳表的层数 2,此时需要先扩大跳表的层数,2如 下图所示。

将元素 120 插入到跳表中时,从顶层开始,逐层向下插入,如下图所示。

跳表的添加方法的代码如下所示。

1public void add(int num) { 2 //获取本次添加的值的层数 3 int level = getRandomLevel(); 4 //如果本次添加的值的层数大于当前跳表的层数 5 //则需要在添加当前值前先将跳表层数扩充 6 if (level > curLevel) { 7 expanLevel(level - curLevel); 8 } 9 10 //curNode表示num值在当前层对应的节点 11 SkiplistNode curNode = new SkiplistNode(num, level); 12 //preNode表示curNode在当前层的前一个节点 13 SkiplistNode preNode = headNodes.get(curLevel - level); 14 for (int i = 0; i <= level; i++) { 15 //从当前层的head节点开始向后遍历,直到找到一个preNode 16 //使得preNode.data < num <= preNode.next.data 17 while (preNode.next.data < num) { 18 preNode = preNode.next; 19 } 20 21 //将curNode插入到preNode和preNode.next中间 22 curNode.next = preNode.next; 23 preNode.next = curNode; 24 25 //如果当前并不是0层,则继续向下层添加节点 26 if (curNode.level > 0) { 27 SkiplistNode downNode = new SkiplistNode(num, curNode.level - 1); 28 //curNode指向下一层的节点 29 curNode.down = downNode; 30 //curNode向下移动一层 31 curNode = downNode; 32 } 33 //preNode向下移动一层 34 preNode = preNode.down; 35 } 36} 37 38private void expanLevel(int expanCount) { 39 SkiplistNode head = headNodes.getFirst(); 40 SkiplistNode tail = tailNodes.getFirst(); 41 for (int i = 0; i < expanCount; i++) { 42 SkiplistNode headNew = new SkiplistNode(Integer.MIN_VALUE, head.level + 1); 43 SkiplistNode tailNew = new SkiplistNode(Integer.MAX_VALUE, tail.level + 1); 44 headNew.down = head; 45 tailNew.down = tail; 46 47 head = headNew; 48 tail = tailNew; 49 50 headNodes.addFirst(head); 51 tailNodes.addFirst(tail); 52 } 53} 54

五. 跳表的搜索方法

在跳表中搜索一个元素时,需要从顶层开始,逐层向下搜索。搜索时遵循如下规则。

•目标值大于当前节点的后一节点值时,继续在本层链表上向后搜索;

•目标值大于当前节点值,小于当前节点的后一节点值时,向下移动一层,从下层链表的同节点位置向后搜索;

•目标值等于当前节点值,搜索结束。

•下图是一个搜索过程的示意图。

•跳表的搜索的代码如下所示。

1public boolean search(int target) { 2 //从顶层开始寻找,curNode表示当前遍历到的节点 3 SkiplistNode curNode = headNodes.getFirst(); 4 while (curNode != null) { 5 if (curNode.next.data == target) { 6 //找到了目标值对应的节点,此时返回true 7 return true; 8 } else if (curNode.next.data > target) { 9 //curNode的后一节点值大于target 10 //说明目标节点在curNode和curNode.next之间 11 //此时需要向下层寻找 12 curNode = curNode.down; 13 } else { 14 //curNode的后一节点值小于target 15 //说明目标节点在curNode的后一节点的后面 16 //此时在本层继续向后寻找 17 curNode = curNode.next; 18 } 19 } 20 return false; 21} 22

六. 跳表的删除方法

当在跳表中需要删除某一个元素时,则需要将这个元素在所有层的节点都删除,具体的删除规则如下所示。

•首先按照跳表的搜索的方式,搜索待删除节点,如果能够搜索到,此时搜索到的待删除节点位于该节点层数的最高层;

•从待删除节点的最高层往下,将每一层的待删除节点都删除掉,删除方式就是让待删除节点的前一节点直接指向待删除节点的后一节点。

•下图是一个删除过程的示意图。

•跳表的删除的代码如下所示。

1public boolean erase(int num) { 2 //删除节点的遍历过程与寻找节点的遍历过程是相同的 3 //不过在删除节点时如果找到目标节点,则需要执行节点删除的操作 4 SkiplistNode curNode = headNodes.getFirst(); 5 while (curNode != null) { 6 if (curNode.next.data == num) { 7 //preDeleteNode表示待删除节点的前一节点 8 SkiplistNode preDeleteNode = curNode; 9 while (true) { 10 //删除当前层的待删除节点,就是让待删除节点的前一节点指向待删除节点的后一节点 11 preDeleteNode.next = curNode.next.next; 12 //当前层删除完后,需要继续删除下一层的待删除节点 13 //这里让preDeleteNode向下移动一层 14 //向下移动一层后,preDeleteNode就不一定是待删除节点的前一节点了 15 preDeleteNode = preDeleteNode.down; 16 17 //如果preDeleteNode为null,说明已经将底层的待删除节点删除了 18 //此时就结束删除流程,并返回true 19 if (preDeleteNode == null) { 20 return true; 21 } 22 23 //preDeleteNode向下移动一层后,需要继续从当前位置向后遍历 24 //直到找到一个preDeleteNode,使得preDeleteNode.next的值等于目标值 25 //此时preDeleteNode就又变成了待删除节点的前一节点 26 while (preDeleteNode.next.data != num) { 27 preDeleteNode = preDeleteNode.next; 28 } 29 } 30 } else if (curNode.next.data > num) { 31 curNode = curNode.down; 32 } else { 33 curNode = curNode.next; 34 } 35 } 36 return false; 37} 38

七. 跳表完整代码

跳表完整代码如下所示。

1public class Skiplist { 2 3 public LinkedList<SkiplistNode> headNodes; 4 public LinkedList<SkiplistNode> tailNodes; 5 6 public int curLevel; 7 8 public Random random; 9 10 public Skiplist() { 11 random = new Random(); 12 13 //headNodes用于存储每一层的头节点 14 headNodes = new LinkedList<>(); 15 //tailNodes用于存储每一层的尾节点 16 tailNodes = new LinkedList<>(); 17 18 //初始化跳表时,跳表的层数随机指定 19 curLevel = getRandomLevel(); 20 //指定了跳表的初始的随机层数后,就需要将每一层的头节点和尾节点创建出来并构建好关系 21 SkiplistNode head = new SkiplistNode(Integer.MIN_VALUE, 0); 22 SkiplistNode tail = new SkiplistNode(Integer.MAX_VALUE, 0); 23 for (int i = 0; i <= curLevel; i++) { 24 head.next = tail; 25 headNodes.addFirst(head); 26 tailNodes.addFirst(tail); 27 28 SkiplistNode headNew = new SkiplistNode(Integer.MIN_VALUE, head.level + 1); 29 SkiplistNode tailNew = new SkiplistNode(Integer.MAX_VALUE, tail.level + 1); 30 headNew.down = head; 31 tailNew.down = tail; 32 33 head = headNew; 34 tail = tailNew; 35 } 36 } 37 38 public boolean search(int target) { 39 //从顶层开始寻找,curNode表示当前遍历到的节点 40 SkiplistNode curNode = headNodes.getFirst(); 41 while (curNode != null) { 42 if (curNode.next.data == target) { 43 //找到了目标值对应的节点,此时返回true 44 return true; 45 } else if (curNode.next.data > target) { 46 //curNode的后一节点值大于target 47 //说明目标节点在curNode和curNode.next之间 48 //此时需要向下层寻找 49 curNode = curNode.down; 50 } else { 51 //curNode的后一节点值小于target 52 //说明目标节点在curNode的后一节点的后面 53 //此时在本层继续向后寻找 54 curNode = curNode.next; 55 } 56 } 57 return false; 58 } 59 60 public void add(int num) { 61 //获取本次添加的值的层数 62 int level = getRandomLevel(); 63 //如果本次添加的值的层数大于当前跳表的层数 64 //则需要在添加当前值前先将跳表层数扩充 65 if (level > curLevel) { 66 expanLevel(level - curLevel); 67 } 68 69 //curNode表示num值在当前层对应的节点 70 SkiplistNode curNode = new SkiplistNode(num, level); 71 //preNode表示curNode在当前层的前一个节点 72 SkiplistNode preNode = headNodes.get(curLevel - level); 73 for (int i = 0; i <= level; i++) { 74 //从当前层的head节点开始向后遍历,直到找到一个preNode 75 //使得preNode.data < num <= preNode.next.data 76 while (preNode.next.data < num) { 77 preNode = preNode.next; 78 } 79 80 //将curNode插入到preNode和preNode.next中间 81 curNode.next = preNode.next; 82 preNode.next = curNode; 83 84 //如果当前并不是0层,则继续向下层添加节点 85 if (curNode.level > 0) { 86 SkiplistNode downNode = new SkiplistNode(num, curNode.level - 1); 87 //curNode指向下一层的节点 88 curNode.down = downNode; 89 //curNode向下移动一层 90 curNode = downNode; 91 } 92 //preNode向下移动一层 93 preNode = preNode.down; 94 } 95 } 96 97 public boolean erase(int num) { 98 //删除节点的遍历过程与寻找节点的遍历过程是相同的 99 //不过在删除节点时如果找到目标节点,则需要执行节点删除的操作 100 SkiplistNode curNode = headNodes.getFirst(); 101 while (curNode != null) { 102 if (curNode.next.data == num) { 103 //preDeleteNode表示待删除节点的前一节点 104 SkiplistNode preDeleteNode = curNode; 105 while (true) { 106 //删除当前层的待删除节点,就是让待删除节点的前一节点指向待删除节点的后一节点 107 preDeleteNode.next = curNode.next.next; 108 //当前层删除完后,需要继续删除下一层的待删除节点 109 //这里让preDeleteNode向下移动一层 110 //向下移动一层后,preDeleteNode就不一定是待删除节点的前一节点了 111 preDeleteNode = preDeleteNode.down; 112 113 //如果preDeleteNode为null,说明已经将底层的待删除节点删除了 114 //此时就结束删除流程,并返回true 115 if (preDeleteNode == null) { 116 return true; 117 } 118 119 //preDeleteNode向下移动一层后,需要继续从当前位置向后遍历 120 //直到找到一个preDeleteNode,使得preDeleteNode.next的值等于目标值 121 //此时preDeleteNode就又变成了待删除节点的前一节点 122 while (preDeleteNode.next.data != num) { 123 preDeleteNode = preDeleteNode.next; 124 } 125 } 126 } else if (curNode.next.data > num) { 127 curNode = curNode.down; 128 } else { 129 curNode = curNode.next; 130 } 131 } 132 return false; 133 } 134 135 private void expanLevel(int expanCount) { 136 SkiplistNode head = headNodes.getFirst(); 137 SkiplistNode tail = tailNodes.getFirst(); 138 for (int i = 0; i < expanCount; i++) { 139 SkiplistNode headNew = new SkiplistNode(Integer.MIN_VALUE, head.level + 1); 140 SkiplistNode tailNew = new SkiplistNode(Integer.MAX_VALUE, tail.level + 1); 141 headNew.down = head; 142 tailNew.down = tail; 143 144 head = headNew; 145 tail = tailNew; 146 147 headNodes.addFirst(head); 148 tailNodes.addFirst(tail); 149 } 150 } 151 152 private int getRandomLevel() { 153 int level = 0; 154 while (random.nextInt(2) > 1) { 155 level++; 156 } 157 return level; 158 } 159 160} 161

八. 跳表在Redis中的应用

ZSet结构同时包含一个字典和一个跳跃表,跳跃表按score从小到大保存所有集合元素。字典保存着从member到score的映射。这两种结构通过指针共享相同元素的member和score,不会浪费额外内存。

1typedef struct zset { 2 dict *dict; 3 zskiplist *zsl; 4} zset; 5

ZSet中的字典和跳表布局:

1.ZSet中跳表的实现细节

随机层数的实现原理:

跳表是一个概率型的数据结构,元素的插入层数是随机指定的。Willam Pugh在论文中描述了它的计算过程如下:指定节点最大层数 MaxLevel,指定概率 p, 默认层数 lvl 为1;

生成一个0~1的随机数r,若r<p,且lvl<MaxLevel ,则lvl ++;

重复第 2 步,直至生成的r >p 为止,此时的 lvl 就是要插入的层数。

论文中生成随机层数的伪码:

在Redis中对跳表的实现基本上也是遵循这个思想的,只不过有微小差异,看下Redis关于跳表层数的随机源码src/z_set.c:

1/* Returns a random level for the new skiplist node we are going to create. 2 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL 3 * (both inclusive), with a powerlaw-alike distribution where higher 4 * levels are less likely to be returned. */ 5int zslRandomLevel(void) { 6 int level = 1; 7 while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) 8 level += 1; 9 return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL; 10} 11

其中两个宏的定义在redis.h中:

1#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */ 2#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */ 3

可以看到while中的:

1(random()&0xFFFF) < (ZSKIPLIST_P*0xFFFF) 2

第一眼看到这个公式,因为涉及位运算有些诧异,需要研究一下Antirez为什么使用位运算来这么写?

最开始的猜测是random()返回的是浮点数[0-1],于是乎在线找了个浮点数转二进制的工具,输入0.5看了下结果:

可以看到0.5的32bit转换16进制结果为0x3f000000,如果与0xFFFF做与运算结果还是0,不符合预期。

实际应用时对于随机层数的实现并不统一,重要的是随机数的生成,在LevelDB中对跳表层数的生成代码是这样的:

1template <typename Key, typename Value> 2int SkipList<Key, Value>::randomLevel() { 3 4 static const unsigned int kBranching = 4; 5 int height = 1; 6 while (height < kMaxLevel && ((::Next(rnd_) % kBranching) == 0)) { 7 height++; 8 } 9 assert(height > 0); 10 assert(height <= kMaxLevel); 11 return height; 12} 13 14uint32_t Next( uint32_t& seed) { 15 seed = seed & 0x7fffffffu; 16 17 if (seed == 0 || seed == 2147483647L) { 18 seed = 1; 19 } 20 static const uint32_t M = 2147483647L; 21 static const uint64_t A = 16807; 22 uint64_t product = seed * A; 23 seed = static_cast<uint32_t>((product >> 31) + (product & M)); 24 if (seed > M) { 25 seed -= M; 26 } 27 return seed; 28} 29

可以看到leveldb使用随机数与kBranching取模,如果值为0就增加一层,这样虽然没有使用浮点数,但是也实现了概率平衡。

2.跳表结点的平均层数

我们很容易看出,产生越高的节点层数出现概率越低,无论如何层数总是满足幂次定律越大的数出现的概率越小。

如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。

幂次定律的表现是少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。

幂次定律应用到跳表的随机层数来说就是大部分的节点层数都是黄色部分,只有少数是绿色部分,并且概率很低。

定量的分析如下:

•节点层数至少为1,大于1的节点层数满足一个概率分布。

•节点层数恰好等于1的概率为p^0(1-p)

•节点层数恰好等于2的概率为p^1(1-p)

•节点层数恰好等于3的概率为p^2(1-p)

•节点层数恰好等于4的概率为p^3(1-p)

依次递推节点层数恰好等于K的概率为p^(k-1)(1-p)

因此如果我们要求节点的平均层数,那么也就转换成了求概率分布的期望问题了:

表中P为概率,V为对应取值,给出了所有取值和概率的可能,因此就可以求这个概率分布的期望了。方括号里面的式子其实就是高一年级学的等比数列,常用技巧错位相减求和,从中可以看到结点层数的期望值与1-p成反比。对于Redis而言,当p=0.25时结点层数的期望是1.33。

总结

跳表的时间复杂度与AVL树和红黑树相同,可以达到O(logN),但是AVL树要维持高度的平衡,红黑树要维持高度的近似平衡,这都会导致插入或者删除节点时的一些时间开销,所以跳表相较于AVL树和红黑树来说,省去了维持高度的平衡的时间开销,但是相应的也付出了更多的空间来存储多个层的节点,所以跳表是用空间换时间的数据结构。以Redis中底层的数据结构zset作为典型应用来展开,进一步看到跳跃链表的实际应用。

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

MySQL部分从库上面因为大量的临时表tmp_table造成慢查询

背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_

PPDB:今晚老齐直播

【今晚老齐直播】今晚(本周三晚)20:0021:00小白开始“用”飞桨(https://www.oschina.net/action/visit/ad?id1185)由PPDE(飞桨(https://www.oschina.net/action/visit/ad?id1185)开发者专家计划)成员老齐,为深度学习小白指点迷津。

VBox 启动虚拟机失败

在Vbox(5.0.8版本)启动Ubuntu的虚拟机时,遇到错误信息:NtCreateFile(\\Device\\VBoxDrvStub)failed:0xc000000034STATUS\_OBJECT\_NAME\_NOT\_FOUND(0retries) (rc101)Makesurethekern

FLV文件格式

1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.