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

Redis 跳跃表的构建与维护技巧

2021-06-171.9k 阅读

Redis 跳跃表简介

在 Redis 中,跳跃表(Skip List)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表在 Redis 中主要用于实现有序集合(Sorted Set)数据结构,它提供了一种可以在平均 O(log n)、最坏 O(n) 的时间复杂度下完成查找、插入和删除操作的数据结构,性能上与平衡树相近,但是实现相对简单,更易于理解和维护。

跳跃表的基本思想是通过建立多层次的链表结构,每一层链表都是上一层链表的子集。高层链表中的节点稀疏,而底层链表中的节点密集。这样,在查找元素时,可以从高层链表开始,快速跳过大量不需要的节点,逐渐降低层次,直到找到目标节点,从而提高查找效率。

跳跃表的构建

跳跃表节点结构

在 Redis 中,跳跃表节点由 zskiplistNode 结构体表示,其定义如下:

typedef struct zskiplistNode {
    sds ele; // 成员对象,这里是字符串
    double score; // 分值,用于排序
    struct zskiplistNode *backward; // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned int span; // 跨度,表示当前节点到下一个节点之间的距离
    } level[]; // 层,柔性数组,实际的层数根据需要动态分配
} zskiplistNode;

ele 字段存储节点的值,score 字段用于节点的排序。backward 指针指向前一个节点,方便在遍历跳跃表时反向移动。level 是一个柔性数组,其实际大小在运行时确定,每个 zskiplistLevel 结构体包含一个 forward 指针和一个 span 跨度。

跳跃表结构

跳跃表整体由 zskiplist 结构体表示,定义如下:

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

header 是跳跃表的表头节点,tail 是表尾节点。length 记录跳跃表中节点的总数,level 表示当前跳跃表的层数。

创建跳跃表

下面是创建一个新的跳跃表的代码示例:

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 的内存,然后初始化其层数为 1,长度为 0。接着创建了表头节点 zslCreateNode,并初始化表头节点的每一层的前进指针和跨度。最后将表尾节点初始化为 NULL

随机层数生成

跳跃表在插入节点时,需要为新节点随机生成层数。这个随机层数的生成决定了节点在跳跃表中的层次结构,对跳跃表的性能有重要影响。在 Redis 中,使用如下方法生成随机层数:

int zslRandomLevel(void) {
    int level = 1;
    // 以 1/4 的概率增加层数
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这里的 ZSKIPLIST_P 定义为 0.25,意味着节点层数有 25% 的概率增加一层。通过这种随机化的方式,可以使跳跃表的结构在平均情况下接近理想的平衡状态,从而保证较好的查找性能。

插入节点

插入节点是跳跃表构建过程中的关键操作。下面是插入节点的代码示例:

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--) {
        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;
    }
    // 检查是否存在相同分值和元素的节点
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        // 若存在,更新元素值
        sdsfree(x->ele);
        x->ele = sdsdup(ele);
        return x;
    } else {
        // 生成随机层数
        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++;
        return x;
    }
}

在插入节点时,首先通过 update 数组记录从高层到低层需要修改指针的节点,通过 rank 数组记录从表头到当前节点的跨度。然后查找插入位置,如果找到相同分值和元素的节点,则更新元素值。否则,生成随机层数,创建新节点并插入到跳跃表中,同时更新相关指针和跨度信息。

跳跃表的维护

删除节点

删除节点是跳跃表维护操作中的重要部分。下面是删除节点的代码示例:

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) {
        // 调整指针
        if (node != NULL) *node = x;
        for (i = 0; i < zsl->level; i++) {
            if (update[i]->level[i].forward == x) {
                update[i]->level[i].span += x->level[i].span - 1;
                update[i]->level[i].forward = x->level[i].forward;
            } else {
                update[i]->level[i].span -= 1;
            }
        }
        // 更新后退指针
        if (x->level[0].forward)
            x->level[0].forward->backward = x->backward;
        else
            zsl->tail = x->backward;
        // 调整跳跃表的层数
        while (zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
            zsl->level--;
        // 释放节点内存
        zslFreeNode(x);
        // 更新跳跃表的长度
        zsl->length--;
        return 1;
    }
    return 0;
}

在删除节点时,同样通过 update 数组记录需要修改指针的节点。找到要删除的节点后,调整相关指针和跨度信息,更新后退指针,并根据情况调整跳跃表的层数,最后释放节点内存并更新跳跃表的长度。

查找节点

查找节点是跳跃表的基本操作之一,通过从高层链表开始逐步查找,可以快速定位到目标节点。下面是查找节点的代码示例:

zskiplistNode *zslSearch(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;
    }
    return NULL;
}

查找节点时,从跳跃表的表头开始,在每一层链表中根据分值和元素值进行比较,逐步移动到下一个节点,直到找到目标节点或者确定目标节点不存在。

遍历跳跃表

遍历跳跃表可以从表头开始,通过前进指针依次访问每个节点。下面是正向遍历跳跃表的代码示例:

void zslTraverseForward(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;
    }
}

通过 zsl->header->level[0].forward 获取第一个实际节点,然后通过 x->level[0].forward 不断移动到下一个节点,直到节点为 NULL,从而完成正向遍历。

反向遍历跳跃表则可以通过后退指针实现,代码示例如下:

void zslTraverseBackward(zskiplist *zsl) {
    zskiplistNode *x = zsl->tail;
    while (x && x != zsl->header) {
        printf("score: %f, ele: %s\n", x->score, x->ele);
        x = x->backward;
    }
}

从表尾节点 zsl->tail 开始,通过 x->backward 不断移动到前一个节点,直到回到表头节点,完成反向遍历。

跳跃表性能分析

查找性能

在平均情况下,跳跃表的查找操作时间复杂度为 O(log n)。这是因为在每一层链表中,平均可以跳过一半的节点。例如,假设有 n 个节点的跳跃表,第一层链表有 n 个节点,第二层链表大约有 n/2 个节点,第三层链表大约有 n/4 个节点,以此类推。在查找时,从最高层链表开始,每下降一层,需要遍历的节点数大致减半。

然而,在最坏情况下,跳跃表的查找时间复杂度为 O(n)。例如,当所有节点的层数都为 1 时,跳跃表退化为普通链表,查找操作就需要遍历所有节点。

插入和删除性能

插入和删除操作的时间复杂度与查找操作类似。插入节点时,首先需要查找插入位置,然后调整指针和跨度信息。删除节点时,同样需要先查找节点,再调整指针和跨度。在平均情况下,插入和删除操作的时间复杂度也为 O(log n),最坏情况下为 O(n)。

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

与平衡树比较

平衡树(如 AVL 树、红黑树)也可以在 O(log n) 的时间复杂度下完成查找、插入和删除操作。但是平衡树的实现相对复杂,需要维护节点的平衡因子或者颜色等额外信息,并且在插入和删除节点后需要进行复杂的旋转操作来保持平衡。而跳跃表通过随机化的层数生成,在平均情况下能够达到与平衡树相近的性能,且实现相对简单,代码量更少,更易于理解和维护。

与普通链表比较

普通链表在查找操作上的时间复杂度为 O(n),因为需要依次遍历每个节点。而跳跃表通过多层次的链表结构,可以在平均 O(log n) 的时间复杂度下完成查找,大大提高了查找效率。在插入和删除操作上,虽然普通链表的插入和删除操作在已知位置时时间复杂度为 O(1),但在实际应用中,往往需要先查找位置,整体时间复杂度仍为 O(n),而跳跃表在平均情况下插入和删除的时间复杂度为 O(log n),具有明显优势。

总结

Redis 跳跃表作为一种高效的有序数据结构,在 Redis 的有序集合实现中发挥了重要作用。通过合理的节点结构设计、随机层数生成以及插入、删除、查找等操作的优化,跳跃表在平均情况下能够提供接近平衡树的性能,同时保持了相对简单的实现。了解跳跃表的构建与维护技巧,对于深入理解 Redis 的有序集合以及优化相关应用具有重要意义。无论是在大数据存储、排序还是其他需要有序数据结构的场景中,跳跃表都能展现出其独特的优势。