参考

小林:https://xiaolincoding.com/redis/data_struct/data_struct.html#%E8%B7%B3%E8%A1%A8

cmu: https://www.cs.cmu.edu/~ckingsf/bioinfo-lectures/skiplists.pdf

wiki:https://en.wikipedia.org/wiki/Skip_list

 

 

跳表

这种数据结构是由William Pugh(音译为威廉·普)发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。

https://dl.acm.org/doi/pdf/10.1145/78973.78977

链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的(相当于给链表加上索引),实现了一种「多层」的有序链表,这样的好处是能快读定位数据。跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

查找例1 

查找例2

完美的跳表:

  • key 按序排列
  • 一共有 O(logn) 层
  • 每层包含的元素数量是它下面那一层的 1/2
  • 头节点(哨兵)节点在每一层(如上面的 31 节点 1、2、3、4 层都在)
  • 节点大小可变,可以包含从 1 到 O(logn) 个 next 指针(如 31 节点包含4个next 指针,第一层的只包含一个指针)
  • 叫做跳表是因为更高层的 list 让你可以跳过很多元素
  • 单点查询复杂度 O(log2N)。插入操作依赖于查询操作,所以插入操作的时间复杂度同样为 O(log2N)。范围查询复杂度为  O(log2(N)+M);N为有序集的基数,而M为结果集的基数。

如果要 插入 或 删除,同时保持 每层包含的元素数量是它下面那一层的 1/2 的完美结构,那么每次插入或删除都可能要重排列整个跳表。

像完美二叉搜索树一样,完美跳跃列表过于结构化,无法支持高效更新。因此,解决方法:

  • 放松 每层包含的元素数量是它下面那一层的 1/2 的条件
  • 取而代之地,设计一种结构,期望 每层 1/2 的元素被提升到一个新的层级
  • 跳表是一种随机化的数据结构:同样的 插入/删除的顺序可能会产生不同的结构,取决于随机结果

随机化

  • 随机化就意味着,允许不严格满足 每层包含的元素数量是它下面那一层的 1/2 的条件
  • 每个节点提升到下一个更高层次的概率为 1/2 (不一定是 1/2,可以是一个概率 p)
    • 期望 1/2 的节点在 层级1
    • 期望 1/4 的节点在 层级2
    • 期望 1/8 的节点在 层级3
    • ......
  • 期望概率会使所有节点的分布接近完美跳表

所以可以在 每插入一个新元素时,随机生成这个元素所在的层数,使每一层出现的概率满足上面的要求——幂次定律:越大的数(层数)出现的概率越小

例如 p 取 0.5,每次随机生成 [0,1) 之间的一个随机数,如果这个随机数小于 0.5 并且小于限制的最高层数,那么层数+1;再次生成一个随机数,如果这个随机数小于 0.5 并且小于限制的最高层数,那么层数+1;直到生成的随机数大于 0.5 或者层数已经超过最大层数限制,那么停止。

每个节点的平均层数为 1/(1-p)(也是每个节点的指针个数)

  • 节点层数至少为1,大于1的节点层数满足一个概率分布。
  • 节点层数恰好等于1的概率为 (p^0)(1-p)
  • 节点层数恰好等于2的概率为 (p^1)(1-p)
  • 节点层数恰好等于3的概率为 (p^2)(1-p) 【前两层随机到 p 向上升级,第三层随机到 1-p ,不再向上升级,停止】
  • 节点层数恰好等于4的概率为 (p^3)(1-p)
  • 依次递推节点层数恰好等于K的概率为 [p^(k-1)](1-p)

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

每个节点的层数期望:

  1*(p^0)(1-p) + 2*(p^1)(1-p) + 3*(p^2)(1-p)  + ... + k* [p^(k-1)](1-p)

= (1-p)*[1*p^0 + 2*p^1 + 3*p^2 + ... + k*p^(k-1)]

= (1-p) * (1/(1-p)^2) 

= 1/(1-p)

插入例

 

删除例

 

跳表相对于平衡树的优势

  1. 内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针(1/(1-p)),比平衡树更有优势。
  2.  ZRANGE 或 ZREVRANGE 范围查找 的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序(中序遍历是有序的)继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历(最底层包含所有元素且有序)就可以实现。
  3. 算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。

 

Redis 跳表

Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。

zset 结构体 

两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的 范围查询(跳表),也能进行高效 单点查询 (哈希表)

Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

「跳表」结构体

定义哪个跳表节点是头节点

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

「跳表节点」的数据结构

  • 「元素」:sds 类型的 ele 变量
  • 「元素的权重」:double 类型的 score 变量
  • 「节点的 level 数组」:该节点每个层级指向下个节点的指针。leve[0] 表示第一层的 forward 指针,leve[1] 表示第二层的 forward 的指针。每个 level 元素,还包含 「跨度」span,跨度是用来记录两个节点之间的距离。
  • 后向指针」:指向(第一层的)前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

跨度 实际上是为了 计算这个节点在跳表中的排位。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。

Redis 里面随机化的概率 p 取 1/4 , 即 每个节点提升到下一个更高层次的概率为 1/4