掘金 后端 ( ) • 2024-06-21 17:18

什么是跳表

跳表(Skip List),首先它是链表,是一种随机化的数据结构,Redis 使用跳表作为有序集合(Sorted Set)的底层实现之一。跳表能够提供高效的插入、删除、查找操作。本文通过阅读源码来分析跳表的工作原理。

跳表的设计思想

跳表的设计思想是通过多级链表来加速查找操作。在一个简单的链表中,查找元素的时间复杂度是 O(n),而在跳表中,通过引入多级索引,查找操作的平均时间复杂度可以降到 O(log n)。

跳表的基本结构如下图所示:

Level 4:        1---------------------->7
Level 3:        1-------->4------------>7
Level 2:        1-------->4------>6---->7
Level 1:  0---->1---->2-->4-->5-->6-->7-->8

每个节点以一定的概率提升到更高一级,形成多个层级的链表。最高层级的链表包含所有节点的索引,而底层链表包含所有节点。

跳表的结构

在 Redis 的源码中,跳表的数据结构定义在 server.h 文件中,主要由以下几个结构体组成:

  • zskiplistNode:跳表节点。
  • zskiplist:跳表。
// 跳表节点
typedef struct zskiplistNode {
    double score;                       // 节点的分数,排序、查找使用
    sds ele;                            // 节点的值
    struct zskiplistNode *backward;     // 后退指针,指向前一个节点
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针,指向后一个节点
        unsigned int span;              // 跨度
    } level[];                          // 层级数组
} zskiplistNode;

// 跳表
typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头节点和尾节点
    unsigned long length;                // 跳表长度
    int level;                           // 当前最大层级,默认1
} zskiplist;

跳表的操作

接下来,我们来看一下 Redis 中对跳表的主要操作:插入、删除和查找。具体实现代码在 t_zset.c 文件中:

创建跳表节点

创建一个新的跳表节点:

zskiplistNode* zslCreateNode(int level, double score, sds ele) {
    // 为节点分配内存,level 决定节点具有的层数
    zskiplistNode *zn = zmalloc(sizeof(*zn) + level * sizeof(struct zskiplistLevel));
    zn->score = score;  // 节点的分数
    zn->ele = ele;      // 节点的值
    return zn;
}

创建跳表

创建一个新的跳表:

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

    // 分配跳表的内存
    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;  // 初始层级为 1
    zsl->length = 0; // 初始长度为 0
    // 创建头节点,最大层数为 ZSKIPLIST_MAXLEVEL
    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;
}

插入

插入操作的核心在于找到新节点插入的位置,并更新相关的指针:

zskiplistNode* zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;
    
    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 && 
                sdscmp(x->level[i].forward->ele, ele) < 0))) {  // 查找插入位置
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;  // 记录每层的前驱节点
    }
    level = zslRandomLevel();  // 随机生成新节点的层数
    if (level > zsl->level) {  // 如果新节点层数超过当前最大层数
        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 = zmalloc(sizeof(*x)+level*sizeof(struct zskiplistLevel));  // 分配新节点内存
    x->score = score;
    x->ele = sdsdup(ele);
    for (i = 0; i < level; i++) {  // 插入新节点,并更新相关指针和跨度
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;
        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++) {
        update[i]->level[i].span++;
    }
    x->backward = (update[0] == zsl->header) ? NULL : update[0];  // 更新 backward 指针
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;  // 如果新节点是最后一个节点,更新尾节点指针
    zsl->length++;  // 更新跳表的长度
    return x;
}

这段代码做了以下几个关键步骤:

  1. 遍历各层,找到新节点的插入位置。
  2. 随机确定新节点的层数,并更新跳表的层数。
  3. 插入新节点,并更新相关指针和跨度。

随机层级生成

在插入操作中有一个随机层级的生成操作,使用的随机函数zslRandomLevel

int zslRandomLevel(void) {
// 初始层数是1
    int level = 1;
    // 以 ZSKIPLIST_P 的概率提升层级,随机层数的值是0.25
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    // ZSKIPLIST_MAXLEVEL 最大层数是64
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

删除

删除操作的核心在于找到要删除的节点,并更新相关的指针。

int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    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 && 
                sdscmp(x->level[i].forward->ele, ele) < 0))) {  // 查找要删除的节点位置
            x = x->level[i].forward;
        }
        update[i] = x;  // 记录每层的前驱节点
    }

    x = x->level[0].forward;  // 指向要删除的节点
    if (x && score == x->score && sdscmp(x->ele, ele) == 0) {  // 确认节点存在
        zslDeleteNode(zsl, x, update);  // 删除节点,并更新指针
        if (!node) 
            zfree(x->ele);  // 释放节点内存
        zfree(x);
        return 1;  // 删除成功
    }
    return 0;  // 节点不存在,删除失败
}

在删除操作中:

  1. 遍历各层,找到要删除节点的位置。
  2. 删除节点,并更新相关指针和跨度。

查找

查找操作相对简单,核心在于从高层到低层逐层遍历,直到找到目标节点或确认目标节点不存在。

unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
    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 && 
                sdscmp(x->level[i].forward->ele, ele) < 0))) {  // 查找目标节点
            rank += x->level[i].span;
            x = x->level[i].forward;
        }
        if (x->level[i].forward && score == x->level[i].forward->score && 
            sdscmp(x->level[i].forward->ele, ele) == 0) {  // 找到目标节点
            rank += x->level[i].span;
            return rank;  // 返回目标节点的排名
        }
    }
    return 0;  // 目标节点不存在
}

在查找操作中:

  1. 从最高层开始,逐层向前移动,直到找到目标节点或确认其不存在。
  2. 返回目标节点的排名(或返回 0 表示不存在)。

在比较结点时,相应地有两个判断条件:

  1. 当查找到的结点保存的元素权重,比要查找的权重小时,跳表就会继续访问该层上的下一个结点。
  2. 当查找到的结点保存的元素权重,等于要查找的权重时,跳表会再检查该结点保存的 SDS 类型数据,是否比要查找的 SDS 数据小。如果结点数据小于要查找的数据时,跳表仍然会继续访问该层上的下一个结点。

总结

Redis 的跳表通过多级索引结构,实现了高效的插入、删除和查找操作。希望这篇文章能够帮助你更好地理解跳表的工作原理和实现细节。