skip list

511 阅读12分钟

712586f53becfa700901d909de4416a.jpg

概念

skip list是一种可以用来代替平衡树的数据结构。 查找的数据结构主要分成两类:基于平衡树、基于哈希表,而skip list并不属于二者。skip list本质上是一个list, 是由有序链表发展而来

compare to binary tree

二叉树可以用于表示抽象的数据类型,如字典、有序列表(由二叉查找树、平衡二叉树实现)。当元素以随机顺序插入时效果很好,但是一些按顺序的操作,比如:有序的插入元素,那么它就会退化成链表。平衡树的平衡算法在执行操作时对树进行重新排列,以保持一定的平衡条件并保证良好的性能。

Binary serach tree.png

Binary serach tree.png

跳跃表是平衡树的一种概率选择。跳跃表是通过使用随机数生成器来平衡。尽管跳跃表的最坏性能很差,但不是所有的输入顺序都能够始终产生最坏性能。 跳跃表可以看成具有随机插入能力的平衡树,且不要求插入的时候是随机的

为了实现二叉树的随机性插入,需要提供算法实现去维护,但skiplist使用概率地平衡数据结构比显式地维护平衡更容易。更简单、更好实现。

skip list

当搜索一个链表时,可能需要检查链表的每个节点。如果列表是按排序的顺序存储的

  • 列表每隔2个节点增加一个指向下下个节点的指针,必须检查不超过[n/2]+ 1节点(其中n是列表的长度)。
  • 如果每隔4个节点就有一个指针后面第4个节点,必须检查不超过[n/4]+ 2节点。
  • 如果每隔(2i)个节点都有一个指针指向后面第(2i)个节点,必须检查不超过[log n]节点。

skip list.png

skip list.png

为上述设置一个简单的定义:50%的节点是级别1,25%是第2级,12.5%是第3级,以此类推。

插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。为了维持这种对应关系,就必须把新插入的节点及后面的所有节点重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。如果节点的级别是随机选择的,节点i也就不会严格指向后面的2i-1节点。插入后只需要调整插入节点前后指针降低操作复杂度,其他节点不影响层高。

skip list algorithms

插入

skip list insert.png

skip list insert.png

在插入之前也要先经历的查找过程,在确定插入位置后,再完成插入操作。

关于节点层次是服从均匀分布的随机数,计算过程:

  • 每个节点肯定都有第1层指针(每个节点都在第1层链表里)
  • 如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p
  • 节点最大的层数不允许超过一个最大值,记为MaxLevel

论文random伪代码

randomLevel()
    level := 1
    // random()返回一个[0...1)的随机数
    while random() < p and level < MaxLevel do
        level := level + 1
    return level

关于MaxLevel与p的的取值需要衡量,假设MaxLevel设置较大但节点数量较少,skip list可能会形成插入节点时其层高过大,查询效率降低。

查询

skip list search.png

skip list search.png

complexity analysis

空间复杂度

计算每个节点所包含的平均指针数目(概率期望) 。节点包含的指针数目,相当于这个算法在空间上的额外开销,可以用来度量空间复杂度。

由random代码可知产生越高的节点层数,概率越低 。定量的分析如下:

  • 节点层数至少为1。而大于1的节点层数,满足一个概率分布
  • 节点层数恰好等于1的概率为1-p
  • 节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)
  • 节点层数大于等于3的概率为p2,而节点层数恰好等于3的概率为
p2(1p)p^2(1-p)
  • 节点层数大于等于4的概率为p3,而节点层数恰好等于4的概率为
p3(1p)p^3(1-p)

一个节点的平均层数(也即包含的平均指针数目),计算如下:

1(1p)+2p(1p)+3p2(1p)+4p3(1p)+......=(1p)k=1kp(k1)1*(1-p)+2p(1-p)+3p^2(1-p)+4p^3(1-p)+......=(1-p)\sum_{k=1}^\infty kp^(k-1)

对其积分可得:

k=1kp(k1)dk=k=1pk=p((1pk)/(1p))=1/(1p)1\sum_{k=1}^\infty \int kp^(k-1) dk =\sum_{k=1}^\infty p^k=p((1-p^k)/(1-p))=1/(1-p)-1

再对积分结果求导,可得:

(1p)k=1kp(k1)=(1p)1(1p)2=11p(1-p)\sum_{k=1}^\infty kp^(k-1)=(1-p)* \frac{1}{(1-p)^2}=\frac{1}{1-p}

计算可得:

  • 当p=1/2时,每个节点所包含的平均指针数目为2;
  • 当p=1/4时,每个节点所包含的平均指针数目为1.33。

时间复杂度

为了分析时间复杂度,我们计算一下skiplist的平均查找长度。查找长度指的是查找路径上跨越的跳数,而查找过程中的比较次数就等于查找长度加1。上图中查找节点20 查找长度为4。

每个节点插入的时候,它的层数是由随机函数计算出来的,而且随机的计算不依赖于其它节点,每次插入过程都是完全独立的。所以,从统计上来说,一个skiplist结构的形成与节点的插入顺序无关

为了计算查找长度,可以将查找过程倒过来看,从右下方第1层上最后到达的那个节点开始,沿着查找路径向左向上回溯,类似于爬楼梯的过程。假设当回溯到某个节点的时候,它才被插入,这虽然相当于改变了节点的插入顺序,但从统计上不影响整个skiplist的形成结构。

假设从一个层数为i的节点x出发,需要向左向上攀爬k层。这时有两种可能:

  • 如果节点x有第(i+1)层指针,那么需要向上走。这种情况概率为p
  • 如果节点x没有第(i+1)层指针,那么需要向左走。这种情况概率为(1-p)

image.png

2.jpg

用C(k)表示向上攀爬k个层级所需要走过的平均查找路径长度(概率期望),那么:

C(0)=0
C(k)=(1-p)×(上图中情况b的查找长度) + p×(上图中情况c的查找长度)

代入,得到一个差分方程并化简:

C(k)=(1-p)(C(k)+1) + p(C(k-1)+1) 
C(k)=1/p+C(k-1) 
C(k)=k/p

得出结论:每爬升1个层级,需要在查找路径上走1/p步。而总共需要攀爬的层级数等于整个skip list的总层数-1。 则需要分析一下当skip list中有n个节点的时候,求出它的总层数的概率均值。根据节点的层数随机算法,容易得出:

  • 第1层链表固定有n个节点;
  • 第2层链表平均有n*p个节点;
  • 第3层链表平均有n*p2个节点;
  • ......

从第1层到最高层,各层链表的平均节点数是一个指数递减的等比数列。推算出,总层数的均值为

log1/pnlog_{1/p} n

而最高层的平均节点数为

1/p1/p

粗略来计算的话,平均查找长度约等于:

C(log1/pn)=(log1/pn1)/pC(log_{1/p} n)=(log_{1/p} n -1)/p

即,平均时间复杂度为O(log n)

总结

skip list、平衡树、Hash表对比

  • skip list和平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。即Hash无法进行范围查找。
  • 关于范围查找,skip list的效率要高于平衡树。平衡树实现范围查找需要进行中序遍历直到找到目标节点,平衡树只需要在level 1的节点遍历即可。
  • 平衡树的插入和删除操作可能引发子树的re-balance,而skip list的插入和删除只需要修改相邻节点的指针。
  • 查找单个key,skip list和平衡树的时间复杂度都为O(log n);而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高。
  • 从内存占用上来说,skip list比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skip list每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。

b树与skip list应用场景对比

  • MySQL中使用b+树,b+是在平衡树的基础上做了矮胖化处理,达到降低了树高,将一部分随机io转化为顺序io,扫的数据很多,时间复杂度其实是变大了,但是因为磁盘顺序io比随机io快太多了,所以可以牺牲时间复杂度来提高性能。
  • Redis 使用skip list,redis都是在内存里面,不存在随机io和顺序io的区别,也就只需要考虑时空复杂度。

skip list in Redis

Redis中zset:

  • 当数据较少时,sorted set是由一个ziplist来实现的。
  • 当数据多的时候,sorted set是由一个dict + 一个skiplist来实现的。dict保存分数与节点的对应关系,skip list用来进行范围查找。

实体类

zskiplistNode: zset的跳跃表节点

typedef struct zskiplistNode {
    robj *obj;                          // 成员对象的地址
    double score;                       // 分值
    struct zskiplistNode *backward;     // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned int span;              // 跨度
    } level[];                          // 层级,柔型数组
} zskiplistNode;

zskiplist:跳跃表表头(记录跳跃表信息)

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;//header指向跳跃表的表头节点,tail指向表尾节点
    unsigned long length;       //跳跃表的长度或跳跃表节点数量计数器,除去第一个节点
    int level;                  //跳跃表中节点的最大层数,除了第一个节点
} zskiplist;

幂次定律

在redis中,返回一个随机层数值,随机算法所使用的幂次定律

  • 含义:如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。
  • 表现:少数几个事件的发生频率占了整个发生频率的大部分, 而其余的大多数事件只占整个发生频率的一个小部分。

t_set.c中,zslRandomLevel函数的定义为:

int zslRandomLevel(void) {          //返回一个随机层数值
    int level = 1;
    //(random()&0xFFFF)只保留低两个字节的位值,其他高位全部清零,所以该值范围为0到0xFFFF
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))  //ZSKIPLIST_P(0.25)所以level+1的概率为0.25
        level += 1;         //返回一个1到ZSKIPLIST_MAXLEVEL(32)之间的值
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
​
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

redis的概率 ZSKIPLIST_P 取值就为0.25,所以跳跃表的指针开销为1.33

创建跳跃表 zslCreate()

zskiplist *zslCreate(void) {        //创建返回一个跳跃表 表头zskiplist
    int j;
    zskiplist *zsl;
​
    zsl = zmalloc(sizeof(*zsl));       //分配空间
    zsl->level = 1;                     //设置默认层数
    zsl->length = 0;                    //设置跳跃表长度
    //创建一个层数为32,分数为0,没有obj的跳跃表头节点
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
​
    //跳跃表头节点初始化
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;   //将跳跃表头节点的所有前进指针forward设置为NULL
        zsl->header->level[j].span = 0;         //将跳跃表头节点的所有跨度span设置为0
    }
    zsl->header->backward = NULL;           //跳跃表头节点的后退指针backward置为NULL
​
    zsl->tail = NULL;                       //表头指向跳跃表尾节点的指针置为NULL
    return zsl;
}

插入节点 zslInsert()

//创建一个节点,分数为score,对象为obj,插入到zsl表头管理的跳跃表中,并返回新节点的地址
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    redisAssert(!isnan(score));

    x = zsl->header;            //获取跳跃表头结点地址,从头节点开始一层一层遍历
    for (i = zsl->level-1; i >= 0; i--) {       //遍历头节点的每个level,从下标最大层减1到0
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];  //更新rank[i]为i+1所跨越的节点数,但是最外一层为0

        //这个while循环是查找的过程,沿着x指针遍历跳跃表,满足以下条件则要继续在当层往前走
        while (x->level[i].forward &&       //当前层的前进指针不为空且
            (x->level[i].forward->score < score ||  //当前的要插入的score大于当前层的score或
                (x->level[i].forward->score == score && //当前score等于要插入的score且
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {//当前层的对象与要插入的obj不等

            rank[i] += x->level[i].span;   //记录该层一共跨越了多少节点 加上 上一层遍历所跨越的节点数
            x = x->level[i].forward;       //指向下一个节点
        }
        //while循环跳出时,用update[i]记录第i层所遍历到的最后一个节点,遍历到i=0时,就要在该节点后要插入节点
        update[i] = x;
    }
    /* 
     * zslInsert() 的调用者会确保同分值且同成员的元素不会出现,
     * 所以这里不需要进一步进行检查,可以直接创建新元素。
     */

    level = zslRandomLevel();       //获得一个随机的层数
    if (level > zsl->level) {       //如果大于当前所有节点最大的层数时
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;            //将大于等于原来zsl->level层以上的rank[]设置为0
            update[i] = zsl->header;    //将大于等于原来zsl->level层以上update[i]指向头结点
            update[i]->level[i].span = zsl->length; //update[i]已经指向头结点,将第i层的跨度设置为length
                                                    //length代表跳跃表的节点数量
        }
        zsl->level = level;     //更新表中的最大成数值
    }
    x = zslCreateNode(level,score,obj);     //创建一个节点
    for (i = 0; i < level; i++) {       //遍历每一层
        x->level[i].forward = update[i]->level[i].forward;  //设置新节点的前进指针为查找时(while循环)每一层最后一个节点的的前进指针
        update[i]->level[i].forward = x;//再把查找时每层的最后一个节点的前进指针设置为新创建的节点地址

        /* update span covered by update[i] as x is inserted here */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);  //更新插入节点的跨度值
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;               //更新插入节点前一个节点的跨度值
    }

    for (i = level; i < zsl->level; i++) {  //如果插入节点的level小于原来的zsl->level才会执行
        update[i]->level[i].span++;             //因为高度没有达到这些层,所以只需将查找时每层最后一个节点的值的跨度加1
    }
    //设置插入节点的后退指针,就是查找时最下层的最后一个节点,该节点的地址记录在update[0]中
    //如果插入在第二个节点,也就是头结点后的位置就将后退指针设置为NULL
    x->backward = (update[0] == zsl->header) ? NULL : update[0];

    if (x->level[0].forward)    //如果x节点不是最尾部的节点
        x->level[0].forward->backward = x;  //就将x节点后面的节点的后退节点设置成为x地址
    else
        zsl->tail = x;  //否则更新表头的tail指针,指向最尾部的节点x
    zsl->length++;      //跳跃表节点计数器加1
    return x;           //返回x地址
}

删除节点

//被zslDelete, zslDeleteByScore and zslDeleteByRank使用的内部函数
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {  //删除节点
    int i;
​
    //设置前进指针和跨度
    for (i = 0; i < zsl->level; i++) {              //遍历下标为0到跳跃表最大层数-1的层
        if (update[i]->level[i].forward == x) {     //如果找到该节点
            update[i]->level[i].span += x->level[i].span - 1;   //将前一个节点的跨度减1
            update[i]->level[i].forward = x->level[i].forward;
            //前一个节点的前进指针指向被删除的节点的后一个节点,跳过该节点
        } else {
            update[i]->level[i].span -= 1;  //在第i层没找到,只将该层的最后一个节点的跨度减1
        }
    }
    //设置后退指针
    if (x->level[0].forward) {      //如果被删除的前进节点不为空,后面还有节点
        x->level[0].forward->backward = x->backward;    //就将后面节点的后退指针指向被删除节点x的回退指针
    } else {
        zsl->tail = x->backward;       //否则直接将被删除的x节点的后退节点设置为表头的tail指针
    }
​
    //更新跳跃表最大层数
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;
    zsl->length--;  //节点计数器减1
}

获取节点排名

遍历节点获取排名:

unsigned long zslGetRank(zskiplist *zsl, double score, robj *o) {   //查找score和o对象在跳跃表中的排位
    zskiplistNode *x;
    unsigned long rank = 0;
    int i;
​
    x = zsl->header;        //遍历头结点的每一层
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||          //只要分值还小于给定的score或者
                (x->level[i].forward->score == score &&     //分值相等但是对象小于给定对象o
                compareStringObjects(x->level[i].forward->obj,o) <= 0))) {
            rank += x->level[i].span;   //更新排位值
            x = x->level[i].forward;    //指向下一个节点
        }
​
        /* x might be equal to zsl->header, so test if obj is non-NULL */
        //确保在第i层找到分值相同,且对象相同时才会返回排位值
        if (x->obj && equalStringObjects(x->obj,o)) {
            return rank;
        }
    }
    return 0;   //没找到
}

区间操作

zskiplistNode *zslFirstInRange(zskiplist *zsl, zrangespec *range) { //返回第一个分数在range范围内的节点
    zskiplistNode *x;
    int i;
​
    /* If everything is out of range, return early. */
    if (!zslIsInRange(zsl,range)) return NULL;  //如果不在范围内,则返回NULL,确保至少有一个节点符号range
​
    //判断下限
    x = zsl->header;//遍历跳跃表
    for (i = zsl->level-1; i >= 0; i--) {//遍历每一层
        /* Go forward while *OUT* of range. */
        while (x->level[i].forward &&           //如果该层有下一个节点且
            !zslValueGteMin(x->level[i].forward->score,range))//当前节点的score还小于(小于等于)range的min
                x = x->level[i].forward;        //继续指向下一个节点
    }
​
    /* This is an inner range, so the next node cannot be NULL. */
    x = x->level[0].forward;    //找到目标节点
    redisAssert(x != NULL);     //保证能找到
​
    /* Check if score <= max. */
    //判断上限
    if (!zslValueLteMax(x->score,range)) return NULL;   //该节点的分值如果比max还要大,就返回NULL
    return x;
}
​
zskiplistNode *zslLastInRange(zskiplist *zsl, zrangespec *range) {//返回最后一个分数在range范围内的节点
    zskiplistNode *x;
    int i;
​
    /* If everything is out of range, return early. */
    if (!zslIsInRange(zsl,range)) return NULL;  //如果不在范围内,则返回NULL,确保至少有一个节点符号range
​
    //判断上限
    x = zsl->header;//遍历跳跃表
    for (i = zsl->level-1; i >= 0; i--) {   //遍历每一层
        /* Go forward while *IN* range. */
        while (x->level[i].forward &&   //如果该层有下一个节点且
            zslValueLteMax(x->level[i].forward->score,range))//当前节点的score小于(小于等于)max
                x = x->level[i].forward;    //继续指向下一个节点
    }
​
    /* This is an inner range, so this node cannot be NULL. */
    redisAssert(x != NULL);//保证能找到
​
    /* Check if score >= min. */
    //判断下限
    if (!zslValueGteMin(x->score,range)) return NULL;   //如果找到的节点的分值比range的min还要小
    return x;
}

Redis choose shik list

作者 antirez 提出在Redis使用shik list而不是平衡树的想法

  • 随机性的给予节点层数大小,使其占用内存小于平衡树
  • range获取数据,skip list更有优势
  • skip list算法更容易实现

巨人的肩膀

《Skip Lists: A Probabilistic Alternative to Balanced Trees》

juejin.cn/post/684490…

blog.csdn.net/men_wen/art…