MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis跳跃表数据结构解析

2023-01-305.2k 阅读

Redis跳跃表概述

在Redis中,跳跃表(Skip List)是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速查找的目的。跳跃表的平均查找时间复杂度为O(log n),与平衡二叉树类似,但实现上更加简单和灵活。

跳跃表主要用于实现Redis的有序集合(Sorted Set)数据结构。在有序集合中,每个成员都关联着一个分数(score),通过分数来进行排序。跳跃表可以高效地插入、删除和查找元素,同时还能以O(n)的时间复杂度遍历所有元素。

跳跃表的基本结构

一个跳跃表由多个层(level)组成,每一层都是一个有序的链表。最底层(第0层)包含了所有的元素,而上面的层则是下面层的“快速通道”。每个节点在不同层中可能有不同的跨度(span),跨度表示该节点到下一个节点之间的元素数量。

跳跃表的节点结构如下:

typedef struct zskiplistNode {
    sds ele;      // 元素值
    double score; // 分数
    struct zskiplistNode *backward; // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned int span; // 跨度
    } level[];
} zskiplistNode;

在这个结构中,ele 存储元素的值,score 存储元素的分数,backward 指针指向前一个节点,level 数组则存储了不同层的前进指针和跨度。

跳跃表本身的结构如下:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; // 头节点和尾节点
    unsigned long length; // 跳跃表的长度
    int level; // 跳跃表的当前层数
} zskiplist;

header 指向跳跃表的头节点,tail 指向尾节点,length 表示跳跃表中节点的数量,level 表示跳跃表当前的层数。

跳跃表的创建与初始化

  1. 创建节点 创建一个跳跃表节点时,需要随机确定节点的层数。Redis中使用一种简单的随机算法来决定节点的层数,使得层数较高的节点出现的概率较低。
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->ele = sdsdup(ele);
    return zn;
}
  1. 初始化跳跃表 初始化跳跃表时,创建一个头节点,并将层数设置为1。
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;
    zsl->length = 0;
    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;
}

这里 ZSKIPLIST_MAXLEVEL 是跳跃表允许的最大层数,通常设置为32。

随机层数生成算法

为了决定一个新插入节点的层数,Redis使用了如下的随机算法:

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这里 ZSKIPLIST_P 是一个概率参数,通常设置为0.25。该算法使得节点层数为1的概率是75%,层数为2的概率是18.75%,以此类推,层数越高概率越低。

跳跃表的插入操作

  1. 查找插入位置 在插入一个新节点时,首先需要找到它在跳跃表中的正确位置。这通过从最高层开始,沿着前进指针进行查找,当遇到一个节点的下一个节点的分数大于要插入节点的分数时,就移动到下一层继续查找。
void 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--) {
        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;
    }
    // 省略后续插入节点的代码
}
  1. 插入节点 找到插入位置后,创建新节点并更新相关指针和跨度。
    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 = 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;

        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];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;

跳跃表的删除操作

  1. 查找要删除的节点 删除节点时,同样从最高层开始查找要删除的节点,并记录下每个层中前一个节点的位置。
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;
    }
    // 省略后续删除节点的代码
}
  1. 删除节点并更新指针和跨度 找到要删除的节点后,更新相关指针和跨度,并释放节点内存。
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        zslDeleteNode(zsl, x, update);
        if (!zsl->header->level[0].forward)
            zsl->tail = zsl->header;
        while (zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
            zsl->level--;
        zsl->length--;
        if (node)
            *node = x;
        sdsfree(x->ele);
        zfree(x);
        return 1;
    }
    return 0;
}

跳跃表的查找操作

查找操作与插入和删除操作类似,从最高层开始沿着前进指针查找,直到找到目标节点或者确定目标节点不存在。

zskiplistNode* zslFind(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *x = zsl->header;
    int i;

    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;
        }
    }
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0)
        return x;
    else
        return NULL;
}

跳跃表的遍历操作

可以从最底层的头节点开始,通过前进指针依次遍历跳跃表中的所有节点。

void zslTraverse(zskiplist *zsl) {
    zskiplistNode *x = zsl->header->level[0].forward;
    while (x) {
        printf("score: %f, ele: %s\n", x->score, x->ele);
        x = x->level[0].forward;
    }
}

跳跃表的空间复杂度

跳跃表的空间复杂度取决于节点的数量和每个节点的平均层数。由于节点层数是通过随机算法生成的,平均情况下每个节点的层数为1/(1 - P),其中P是随机层数生成算法中的概率参数(在Redis中P = 0.25),因此平均每个节点的层数约为1.33。所以跳跃表的空间复杂度为O(n),其中n是节点的数量。

跳跃表的时间复杂度

  1. 查找操作 在平均情况下,跳跃表的查找时间复杂度为O(log n),因为每次查找可以跳过大约一半的节点。在最坏情况下,所有节点都在同一层,此时查找时间复杂度退化为O(n)。
  2. 插入和删除操作 插入和删除操作都需要先查找插入或删除的位置,因此平均时间复杂度也为O(log n),最坏情况下为O(n)。

跳跃表与其他数据结构的比较

  1. 与平衡二叉树的比较
    • 实现复杂度:跳跃表的实现相对简单,不需要像平衡二叉树那样进行复杂的旋转操作来保持平衡。
    • 查找性能:平均情况下,跳跃表和平衡二叉树的查找时间复杂度都是O(log n),但跳跃表的常数因子可能更小,因为它的指针结构更简单。
    • 插入和删除性能:同样,平均情况下两者性能相似,但跳跃表在插入和删除时不需要调整大量节点的结构,操作更局部化。
  2. 与普通链表的比较
    • 查找性能:普通链表的查找时间复杂度为O(n),而跳跃表平均为O(log n),性能有显著提升。
    • 插入和删除性能:虽然两者插入和删除操作的时间复杂度在平均情况下都是O(1)(不考虑查找位置的时间),但跳跃表在插入和删除后不需要重新排序,而如果普通链表是有序的,插入和删除后可能需要重新排序。

跳跃表在Redis中的应用

在Redis的有序集合数据结构中,跳跃表用于存储集合中的元素及其分数。通过跳跃表,Redis可以高效地进行插入、删除、查找和范围查询等操作。同时,Redis还结合了哈希表来实现快速的元素存在性检查,以提高整体性能。

例如,在执行 ZADD 命令向有序集合中添加元素时,实际上就是在跳跃表中插入一个新节点。而执行 ZRANGE 命令获取指定范围内的元素时,则是通过跳跃表的查找和遍历操作来实现的。

总结

跳跃表是一种高效的有序数据结构,它以相对简单的实现提供了接近平衡二叉树的查找性能。在Redis中,跳跃表被广泛应用于有序集合的实现,充分发挥了其插入、删除和查找的高效性。通过深入理解跳跃表的数据结构和操作原理,开发者可以更好地优化和使用Redis的有序集合功能,同时也能为设计其他高效的数据结构提供有益的参考。在实际应用中,根据具体的需求和场景,合理选择和使用跳跃表可以显著提升系统的性能和效率。

通过以上对Redis跳跃表数据结构的详细解析,希望读者能够对跳跃表有更深入的理解,并能在实际开发中灵活运用。同时,建议读者结合Redis的源代码进一步研究跳跃表的实现细节,以便更好地掌握这一重要的数据结构。