数据结构-redis数据结构-跳表skiplist

898 阅读11分钟

这篇文章简单分享学习redis(6.0)数据结构-跳表skiplist

redis中的有序数据集合[zset],有两种实现方式:跳表和压缩列表,我们今天学习下跳表的实现原理。

学习新的知识,我们先从已掌握的知识入手,由浅入深,让我们先从普通链表开始,如果希望一个集合有序,我们会想到通过有序链表实现:

image.png

我们在该单链表中查询元素[15],经过1,3,5,7,9,11,13,15,需要从头到尾遍历8次,效率很低。单链表的查询时间复杂度为O(N)。

如何提升查询效率呢?我们可以为链表建立“索引”:每两个元素提取一层建立索引:

image.png

然后我们在新的两层链表中查询元素[15],经过1,5,9,13,15,需要查询5次。通过建立索引达到了减少查询次数的目的。时间复杂度为O(logN)O(logN)

这就是我们要认识的跳表:跳跃表(跳表)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

下面我们分析下跳表的时间复杂度如何计算的:

image.png

假设我们严格的按照每两个元素提取一层建立索引,如上图的三层索引,N:元素的总规模,h:索引层高

第一层索引元素个数:N/2N/2

第二层索引元素个数N/22N/2*2

第三层索引元素个数N/222N/2*2*2

我们可以得到 数据总规模N与索引层高h的函数关系,最底层的索引元素个数为2=N/2h2=N/2h,即可推出h=log2N1h = log2N - 1,再加上最底层的原始链表 h=log2Nh = log2N (log以2为底N的对数)

跳表的时间复杂度 = 层高(h) * 每层遍历的个数(m),当数据规模很大的时候,我们可以忽略常数项m,所以链表的时间复杂度O(logN)O(logN)

我们上面的例子是为了说明跳表的基本结构,简化的模型,下面再举个更直观的例子,当数据量很大时,可以明显的看到极大的提高查询效率:

image.png 比如查找9998,通过建立索引我们能减少很多的查询过程。



以上我们简单认识了跳表,今天的重点是分析redis中跳表的数据结构。

Redis 的跳跃表实现和 WillianmPugh [WillianmPugh博士在他的论文中提出跳表的数据结构]在 “ Skip Lists: A Probabilistic Alternative to Balanced Trees ” 中描述的跳跃表算法类似,但又有有些区别:

1、Redis 的跳跃表允许分值重复;
2、Redis 的跳跃表排序不止根据分值,在分值相同的时候会对比成员对象;
3、Redis 的跳跃表有一个后退指针,形成了一个双向链表,可以从表尾向表头遍历,用于 ZREVRANGE 命令的实现。

image.png Redis的跳跃表由 zskiplistNode 和 skiplist 两个结构定义:

zskiplistNode结构用于表示跳跃表节点;

zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等

skiplist

header:指向跳跃表的表头节点。
tail:指向跳跃表的表尾节点。
length:记录跳跃表的长度,即除去表头节点之外的节点数量之和。
level:记录跳跃表内层数最大的那个节点的层数(表头节点的层数不计算在内)。

zskiplistNode

ele:用于记录跳跃表节点的值,类型是 SDS。
score:保存跳跃表节点的分值,在跳跃表中,节点按各自所保存的分值从小到大排列。
backward:后退指针,它指向当前节点的前一个节点,在程序从表尾向表头遍历时使用。因为一个跳跃表节点只有一个后退指针,所以每次只能后退至前一个节点。
level:跳跃表节点的层,每个层都带有两个属性:前进指针 forward 和跨度 span。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。每个跳跃表节点最多有 32 层。

zskiplistLevel包含以下两个属性:

forward:指向同一层的下一个节点,为节点的forward指向NULL
span:forward指向的节点与本节点之间的节点的个数,span越大说明跳过的节点的个数越多

下面我们分析下跳表插入节点的过程,有助于进一步了解跳表的数据结构及实现原理:

**1、查找要插入的位置**

update[]:用来保存插入节点每一层的前一个节点。update[i]表示Node M插入后的第i层的前一个节点
rank[]:用来保存头节点到update[i]节点的距离。rank[i]表示header节点到update[i]节点的距离
我们通过下面这段代码,详细分析如何更新update和rank这两个数组的值。在一个for循环中,从数组下标为level-1开始一直更新到下标为0,在这个例子中,level=3,因此从数组下标2开始更新,先计算update[2]和rank[2],一直到update[0]和rank[0]。

第一次进入循环,rank[2]的初始值为0,然后通过一个while循环,从头节点的第2层开始一直往后查找,找到最后一个小于Node M的节点,这个节点就是Node M插入后的第2层的前一个节点,显然这个节点是Node 3,在查找的过程中,会将经过的节点的第2层的span值累加到rank[2]上,因此rank[2]=0(初始值)+1+2=3,(查找路径为从header -> Node 1 -> Node 3)。最后得到update[2]=Node 3,即Node M第2层的前一个节点为Node 3,rank[2]=3,即从header节点到Node 3的距离为3
第二次进入循环,rank[1]的初始值为rank[2]=3,从Node 3的第1层开始往后查找最后一个小于Node M的节点,这个节点仍然是Node 3,因此update[1]=Node 3,即Node M第1层的前一个节点也是Node 3,rank[1]=3(查找路径停留在Node 3,因此rank[1]最终还是为初始值)。
第三次进入循环,rank[0]的初始值为rank[1]=3,从Node 3的第0层开始往后查找最后一个小于Node M的节点,这个节点是Node 4,因此update[0]=Node 4,即Node M第0层的前一个节点是Node 4,rank[0]=3+1=4,(查找路径为Node 3 -> Node 4),即header节点到Node 4的距离为4


2、调整跳表高度

在 Redis 的跳跃表中,如果新增一个节点,节点的层数是通过函数 zslRandomLevel 随机出来的,节点层数的范围为 1320xFFFF65535,即每次随机都是生成一个 065535之间的数,总结下来有以下规律:

因为 level 初始化为 1,所以最终层数为 1 的概率为 1-ZSKIPLIST_P,即 0.75。
当最终层数大于 1 时,每次层数增加的概率都是 ZSKIPLIST_P,那么 n 层的概率是 (1-ZSKIPLIST_P ) * ZSKIPLIST_P ^ (n - 1),即  。
因为层数增加的概率是 ZSKIPLIST_P,可以看成是第 k 层的节点数是第 k+1 层节点数的 1/ZSKIPLIST_P 倍,所以 Redis 的跳跃表相当于是一颗四叉树。


节点层高为1的概率为 (1-p)
节点层高为2的概率为 (1-p)*p
节点层高为3的概率为 (1-p)*p^2
...
节点层高为n的概率为 (1-p)*p^2

当节点层高大于32时,最终也会取32,所以实际上节点层高为32的概率为:1 - (节点层高小于32的概率值和)


3、插入节点
设置Node M的forward属性
将update[i]的forward修改为Node M
设置Node M每一层的span值
修改update[i]的span值

4、调整backward
因为新插入节点Node M的前一个节点一定是update[0],因此将Node M的backward设置为update[0],
Node M的下一节点的backward设置为Node M,然后跳表的长度+1。至此,整个节点的插入操作就完成了。

这是第一步查询的要插入的位置的描述:

此处详细分析了查找插入节点的过程分析,因为新增、修改、删除节点都涉及查询

掘金技术文档.png image.png

总结下redis的跳表结构:

1、跳跃表基于单链表加索引的方式实现

2、跳跃表以空间换时间的方式提升了查找速度

3Redis有序集合在节点元素较大或者元素数量较多时使用跳跃表实现

4Redis的跳跃表实现由 zskiplist和 zskiplistnode两个结构组成,其中 zskiplist用于保存跳跃表信息(比如表头         节点、表尾节点、长度),而zskiplistnode则用于表示跳跃表节点

5Redis每个跳跃表节点的层高都是132之间的随机数

6、在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序

附带插入节点的部分代码

1、查找要插入的位置

/* Insert a new node in the skiplist. Assumes the element does not already
 * exist (up to the caller to enforce that). The skiplist takes ownership
 * of the passed SDS string 'ele'. */
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    //update数组记录新增节点在每一层的前驱节点
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    //rank数组用于记录新增节点在跳跃表中的排名
    //表头节点排名为0,所以查找到插入节点位置时,经过的节点数量之和就是其排名
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    //x指向跳跃表的头节点
    x = zsl->header;
    //从最高层依次向下遍历
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        //最高层对应的rank数组的元素初始化为0,其他层初始化为上一层对应的数组元素的值
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        //当前层下一个节点存在
        //当前层下一个节点的分值小于插入节点的分值
        //当分值相等时,插入节点的元素大于下一节点元素
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            //统计当前层查找过的节点跨度之和
            rank[i] += x->level[i].span;
            //节点指针游标后移,继续往后查找插入位置
            x = x->level[i].forward;
        }
        //记录新增节点在当前层的前驱节点
        update[i] = x;
    }

2、调整跳表高度

/* we assume the element is not already inside, since we allow duplicated
     * scores, reinserting the same element should never happen since the
     * caller of zslInsert() should test in the hash table if the element is
     * already inside or not. */
    //我们假定元素还没有插入到跳跃表,尽管我们允许跳跃表存在重复的节点分值,重复插入相同的元素是不允许
    //的,所以在调用 zslInsert 函数之前我们需要测试一下在哈希表中是否已经存在相同的元素。
    //随机出新节点的层数
    level = zslRandomLevel();
    //新节点的层高大于当前跳跃表节点的最大层高
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            //高出的层数只有当前一个节点,所以该层节点跨度之和为0
            rank[i] = 0;
            //高出的层插入节点的前驱节点是头节点
            update[i] = zsl->header;
            //初始化该层头节点的跨度,实际是头节点到尾节点的距离
            update[i]->level[i].span = zsl->length;
        }
        //更新跳跃表的最高层数
        zsl->level = level;
    }

3、插入节点

//创建跳跃表节点
    x = zslCreateNode(level,score,ele);
    for (i = 0; i < level; i++) {
        //在当前层插入新节点(这里和链表插入节点的做法类似)
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        //需要计算新增节点的每一层跨度
        //每层的跨度为其后继节点的排名减去新增节点的排名
        //后继节点的排名为:update[i]->level[i].span + rank[i] + 1
        //新增节点的排名为:rank[0] + 1
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        //需要计算新增节点每一层的前驱节点的跨度
        //前驱节点的跨度为:新增节点的排名减去前驱节点的排名
        //新增节点的排名:rank[0] + 1
        //前驱节点的排名为:rank[i]
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    for (i = level; i < zsl->level; i++) {
        //高出的层,新增节点的前驱是头节点,头节点的跨度是距离尾部的长度,因为新增节点,所以跨度加1
        update[i]->level[i].span++;
    }

4、调整backward

//设置后退节点
    //如果新增节点的前驱为头节点,则后退节点为空
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    //如何新增节点不是尾节点,则设置其下一个节点的后退指针指向新增节点
    //后退节点只能使得逆序遍历时后退一位,所以只判断第一层
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    //跳跃表节点数量增加
    zsl->length++;
    return x;