Redis之跳跃表

跳跃表是一种有序数据结构,他通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,redis就会使用跳跃表来作为有序集合键的底层实现。我们这节主要来看一下跳跃表的具体实现:

typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist

这个时跳跃表的结构,里面维护了头节点和尾节点,节点的数量和层数最大的节点的层数

typedef struct zskiplistNode {

    // 成员对象
    robj *obj;
    // 分值
    double score;
    // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
        // 跨度
        unsigned int span;
    } level[];

} zskiplistNode

zskiplistNode 用来描述跳跃表中的每个节点,存储了成员对象,分值,后退指针,以及当前层数的一个结构体,指向下一个节点的当前层
我们先看一下几个概念:
1、层:
跳跃表节点的level数组可以个包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他系欸但的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
每次创建要给新跳跃表节点的时候,程序都根据幂次定律随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。
2、前进指针
每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点
3、跨度
层的跨度用来记录两个节点之间的距离
两个节点之间的跨度越大,它们相距的就越远。
指向NULL的所欲前进指针的跨度都为0
遍历操作只使用前进指针就可以完成了,跨度实际上时用来计算排位的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
4、后退指针
节点的后退指针用于从表尾向表头方向访问节点:每次只能后退至前一个节点。
5、分值和成员
系欸但的分值死一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
下面我们就来看跳跃表的一些实现方法:
zslCreate:创建一个新的跳跃表

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    // 分配空间
    zsl = zmalloc(sizeof(*zsl));

    // 设置高度和起始层数
    zsl->level = 1;
    zsl->length = 0;

    // 初始化表头节点
    // T = O(1)
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;

    // 设置表尾
    zsl->tail = NULL;

    return zsl;
}

初始化了跳跃表结构和头节点,我们可以发现跳跃表的头节点的层数是32层,但是他不用来保存数据,所以我们可以通过这个头节点到达任意。

zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
    
    // 分配空间
    zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));

    // 设置属性
    zn->score = score;
    zn->obj = obj;

    return zn;
}

在创建节点的时候我们需要指定当前节点的层数,分值,以及要存储的对象

unsigned long zslGetRank(zskiplist *zsl, double score, robj *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 ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员对象
                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 */
        // 必须确保不仅分值相等,而且成员对象也要相等
        // T = O(N)
        if (x->obj && equalStringObjects(x->obj,o)) {
            return rank;
        }
    }

    // 没找到
    return 0;
}

查找包含给定分值和成员对象的节点在跳跃表中的排位。我们来看一下这个过程
1、获取跳跃表的头节点
2、由于头节点的层数是32层,保存了后面第一个直接到达的最大层数的节点的引用,所以我们需要遍历头节点的level数组
3、每遍历到一个层数,在while循环中判断当前元素前进指针指向的下一个节点是否存在并且分数小于我们要查找的节点,或者指向的下一个节点的分数和要查找的元素分数相同并且是同一个对象,进入循环,在计算排名的字段rank上加上当前跨度
4、如果分值相等,成员对象也相等,直接返回计算出的排名

zskiplistNode* zslGetElementByRank(zskiplist *zsl, unsigned long rank) {
    zskiplistNode *x;
    unsigned long traversed = 0;
    int i;

    // T_wrost = O(N), T_avg = O(log N)
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        // 遍历跳跃表并累积越过的节点数量
        while (x->level[i].forward && (traversed + x->level[i].span) <= rank)
        {
            traversed += x->level[i].span;
            x = x->level[i].forward;
        }

        // 如果越过的节点数量已经等于 rank
        // 那么说明已经到达要找的节点
        if (traversed == rank) {
            return x;
        }

    }

    // 没找到目标节点
    return NULL;
}

查找指定排名的元素
1、遍历跳跃表
2、如果当前遍历到的节点的当前层的前进指针不为空并且,将保存的走过的总跨度加上下一个跳的跨度小于我们的指定的rank,那么跳到前进指针指向的节点上,并且计算出已经走的总跨度
3、否则,在当前节点上向下移动一层,执行2
4、如果走过的总跨度等于我们指定的rank,返回,如果没有找到,返回null

zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    redisAssert(!isnan(score));

    // 在各个层查找节点的插入位置
    // T_wrost = O(N^2), T_avg = O(N log N)
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        /* store rank that is crossed to reach the insert position */
        // 如果 i 不是 zsl->level-1 层
        // 那么 i 层的起始 rank 值为 i+1 层的 rank 值
        // 各个层的 rank 值一层层累积
        // 最终 rank[0] 的值加一就是新节点的前置节点的排位
        // rank[0] 会在后面成为计算 span 值和 rank 值的基础
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

        // 沿着前进指针遍历跳跃表
        // T_wrost = O(N^2), T_avg = O(N log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {

            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;

            // 移动至下一指针
            x = x->level[i].forward;
        }
        // 记录将要和新节点相连接的节点
        update[i] = x;
    }

    /* we assume the key is not already inside, since we allow duplicated
     * scores, and the re-insertion of score and redis object should never
     * happen since the caller of zslInsert() should test in the hash table
     * if the element is already inside or not. 
     *
     * zslInsert() 的调用者会确保同分值且同成员的元素不会出现,
     * 所以这里不需要进一步进行检查,可以直接创建新元素。
     */

    // 获取一个随机值作为新节点的层数
    // T = O(N)
    level = zslRandomLevel();

    // 如果新节点的层数比表中其他节点的层数都要大
    // 那么初始化表头节点中未使用的层,并将它们记录到 update 数组中
    // 将来也指向新节点
    if (level > zsl->level) {

        // 初始化未使用层
        // T = O(1)
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }

        // 更新表中节点最大层数
        zsl->level = level;
    }

    // 创建新节点
    x = zslCreateNode(level,score,obj);

    // 将前面记录的指针指向新节点,并做相应的设置
    // T = O(1)
    for (i = 0; i < level; i++) {
        
        // 设置新节点的 forward 指针
        x->level[i].forward = update[i]->level[i].forward;
        
        // 将沿途记录的各个节点的 forward 指针指向新节点
        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]);

        // 更新新节点插入之后,沿途节点的 span 值
        // 其中的 +1 计算的是新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    // 未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点
    // T = O(1)
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    // 设置新节点的后退指针
    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;
}

插入一个节点,需要传入的参数有要执行操作的跳跃表,分值,对象。插入操作的代码比较长,下面我们分部分来看:

    // T_wrost = O(N^2), T_avg = O(N log N)
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        /* store rank that is crossed to reach the insert position */
        // 如果 i 不是 zsl->level-1 层
        // 那么 i 层的起始 rank 值为 i+1 层的 rank 值
        // 各个层的 rank 值一层层累积
        // 最终 rank[0] 的值加一就是新节点的前置节点的排位
        // rank[0] 会在后面成为计算 span 值和 rank 值的基础
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

        // 沿着前进指针遍历跳跃表
        // T_wrost = O(N^2), T_avg = O(N log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {

            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;

            // 移动至下一指针
            x = x->level[i].forward;
        }
        // 记录将要和新节点相连接的节点
        update[i] = x;
    }

在各个层查找节点的插入位置
1、遍历跳跃表
2、首先初始化rank[i]的值
3、每遍历到一个层数,在while循环中判断当前元素前进指针指向的下一个节点是否存在并且分数小于我们要查找的节点,或者指向的下一个节点的分数和要查找的元素分数相同并且是同一个对象,进入循环,在计算排名的字段rank[i]上加上当前跨度
4、当跳出while循环的时候说明要在当前的跳跃表的节点的当前层的前进指针指向要插入的节点,记录当前节点
5、最终rank数组记录了到达新节点对应层之前的跨度,update数组记录了要和新节点相连的节点

    level = zslRandomLevel();

获取一个随机值作为新节点的层数

if (level > zsl->level) {

        // 初始化未使用层
        // T = O(1)
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }

        // 更新表中节点最大层数
        zsl->level = level;
    }

如果生成的层数大于跳跃表结构中保存的最大层数,说明层level以上都没有用到过,要将没用到的层初始化

x = zslCreateNode(level,score,obj);

创建一个新的节点

for (i = 0; i < level; i++) {
        
        // 设置新节点的 forward 指针
        x->level[i].forward = update[i]->level[i].forward;
        
        // 将沿途记录的各个节点的 forward 指针指向新节点
        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]);

        // 更新新节点插入之后,沿途节点的 span 值
        // 其中的 +1 计算的是新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

遍历我们新创建的节点:
1、新节点的当前层的前进指针指向指向新节点的节点的前进指针指向的节点
2、将当前层对应的保存的节点的前进指针执行当前节点
3、修改新节点当前层的跨度
4、修改当前层对应的保存的节点的跨度

 /* increment span for untouched levels */
    // T = O(1)
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点

 // 设置新节点的后退指针
    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++;

设置新节点的后退指针
删除操作和插入操作差不多,我们就不介绍了
下面我们对跳跃表做一个总结:
1、跳跃表是有序集合的底层实现之一
2、redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息,zskiplistNode则用于表示跳跃表节点
3、每个跳跃表节点的层高都是1至32之间的随机数
4、在同一个跳跃表中,多个节点可以给包含相同的分值,但每个节点的成员对象必须是唯一的
5、跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序

Redis跳跃表(Skip List)是一种有序数据结构,用于实现有序集合(Sorted Set)数据类型。在Redis中,插入和更新操作都是基于跳跃表进行的。 对于插入操作,Redis使用跳跃表来维护有序集合。当需要将一个新的元素插入到有序集合中时,Redis首先会在跳跃表中寻找插入位置。通过跳跃表的索引层,Redis可以快速定位到需要插入的位置,而不必遍历整个有序集合。 具体的插入过程如下: 1. 生成一个随机层数level,决定要在跳跃表中插入的元素的索引层高度。 2. 从跳跃表的最高层开始,沿着索引层逐层下降,寻找插入位置,并记录每一层上离插入位置最近的节点。 3. 在底层插入新元素,并将该元素连接到每一层上离插入位置最近的节点。 4. 根据一定的概率,判断是否将新插入的元素提升为索引层的节点。 对于更新操作,Redis将其视为先删除旧元素,再插入新元素的操作。具体的更新过程如下: 1. 在跳跃表中搜索要更新的元素。 2. 如果找到了要更新的元素,从跳跃表中删除该元素。 3. 根据插入操作的方法,在跳跃表中插入新元素。 插入和更新操作都利用了跳跃表的特性,即通过索引层的建立,可以快速定位和搜索元素,从而提高插入和更新操作的效率。跳跃表的插入和更新操作时间复杂度都是O(log n),其中n是有序集合中元素的数量。因此,Redis利用跳跃表实现了高效的插入和更新功能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值