那些年背过的题:Redis跳跃表的设计与实现

305 阅读9分钟

Redis 使用跳跃表(Skip List)作为其某些数据结构的底层实现,例如有序集合(Sorted Set)。跳跃表是一种随机化的数据结构,具有良好的平均时间复杂度,可以高效地进行插入、删除和查找操作。

跳跃表的基本概念

跳跃表通过多级链表来实现,每一级都包含从前一级抽取的一部分元素。因此,可以将跳跃表看作是多个层次的链表,这些链表以不同的概率跳过一些节点,从而实现快速的查找。

我们可以通过图形描述来展示跳跃表的各个节点及其层次关系。在下面的示例中,我们将展示一个包含若干节点的跳跃表,并分别说明插入、删除和查找操作的过程。

跳跃表的基本结构

假设我们有一个简单的跳跃表,包含以下几个节点:1, 3, 5, 7, 9。这些节点在跳跃表中的分布可能如下所示:

Level 3:        1------------------7
                |                  |
Level 2:        1--------5---------7
                |        |         |
Level 1:        1---3----5----7----9
                |   |    |    |    |
Level 0:  head--1---3----5----7----9--NULL

在这个图中,每一层表示跳跃表的一个级别(从底层 Level 0 到最高层 Level 3),每个节点都可能存在于多个层。

插入操作示例

假设我们要插入一个新的节点 4。首先,我们需要找到它应该插入的位置,然后按照随机生成的层数更新相关的指针。

  1. 在插入之前:
Level 3:        1------------------7
                |                  |
Level 2:        1--------5---------7
                |        |         |
Level 1:        1---3----5----7----9
                |   |    |    |    |
Level 0:  head--1---3----5----7----9--NULL
  1. 找到插入位置(介于 3 和 5 之间)并确定新节点的层数,假设新节点 4 的层数为 2:
Level 3:        1------------------7
                |                  |
Level 2:        1--------5---------7
                |        |         |
Level 1:        1---3----5----7----9
                |   |    |    |    |
Level 0:  head--1---3----5----7----9--NULL
                        ^
                        4 (待插入)
  1. 插入后的结果:
Level 3:        1------------------7
                |                  |
Level 2:        1--------5---------7
                |        |         |
Level 1:        1---3---4----5----7----9
                |   |   |    |    |    |
Level 0:  head--1---3---4----5----7----9--NULL

删除操作示例

假设我们要删除节点 5。首先,我们需要找到节点 5 的位置,然后更新相关的指针。

  1. 在删除之前:
Level 3:        1------------------7
                |                  |
Level 2:        1--------5---------7
                |        |         |
Level 1:        1---3---4----5----7----9
                |   |   |    |    |    |
Level 0:  head--1---3---4----5----7----9--NULL
  1. 确定删除位置并逐层更新指针:
Level 3:        1------------------7
                |                  |
Level 2:        1--------(delete)--7
                |                  |
Level 1:        1---3---4----5----7----9
                |   |   |    |    |    |
Level 0:  head--1---3---4----5----7----9--NULL
                            ^
                            5 (待删除)
  1. 删除后的结果:
Level 3:        1------------------7
                |                  |
Level 2:        1------------------7
                |                  |
Level 1:        1---3---4---------7----9
                |   |   |         |    |
Level 0:  head--1---3---4---------7----9--NULL

查找操作示例

假设我们要查找节点 7。我们会从最高层开始,逐层向下前进,直到找到目标节点。

  1. 从最高层(Level 3)开始查找:
Level 3:        1------------------7
                                ^
                                找到目标
Level 2:        1------------------7
Level 1:        1---3---4---------7----9
Level 0:  head--1---3---4---------7----9--NULL
  1. 如果在当前层(Level 3)没有找到目标节点 7,就会继续在下一层(Level 2)查找:
Level 3:        1------------------7
                                |
Level 2:        1------------------7
                                ^
                                找到目标
Level 1:        1---3---4---------7----9
Level 0:  head--1---3---4---------7----9--NULL
  1. 如果在当前层(Level 2)没有找到目标节点 7,就会继续在下一层(Level 1)查找:
Level 3:        1------------------7
                                |
Level 2:        1------------------7
                                |
Level 1:        1---3---4---------7----9
                                ^
                                找到目标
Level 0:  head--1---3---4---------7----9--NULL
  1. 最后,如果需要,还可以继续在底层(Level 0)查找:

Level 3:        1------------------7
                                |
Level 2:        1------------------7
                                |
Level 1:        1---3---4---------7----9
                                |
Level 0:  head--1---3---4---------7----9--NULL
                                ^
                                找到目标

通过这种逐层查找的方法,跳跃表能够快速定位目标节点。每次查找都从最高层开始,逐层向下缩小范围,最终达到有效的查找性能。

跳跃表的结构

跳跃表的每个节点不仅包含指向下一个节点的指针,还可能包含指向更高层次的节点的指针。通常,一个节点会有多级指针,这些指针形成了多个层次的链表结构。

节点结构

在 Redis 的实现中,每个跳跃表节点(zskiplistNode)包含以下内容:

  • ele:节点存储的元素。
  • score:节点的分数,用于排序。
  • level[]:多个层次的指针和跨度信息。
typedef struct zskiplistNode {
    sds ele; // 元素
    double score; // 分数
    struct zskiplistNode *backward; // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前进指针
        unsigned int span; // 跨度
    } level[];
} zskiplistNode;

跳跃表结构

跳跃表本身(zskiplist)包含:

  • header:指向头节点的指针。
  • tail:指向尾节点的指针。
  • level:当前跳跃表的最高层数。
  • length:跳跃表中的节点数量。
typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

跳跃表的操作

插入操作

插入操作需要找到合适的位置并调整相关指针。具体步骤如下:

  1. 从最高层开始,逐级向下查找插入位置。
  2. 在每一层,更新要插入位置之前的节点的指针。
  3. 根据随机算法确定新节点的层数。
  4. 插入新节点,并更新相关指针和跨度信息。
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;
    }

    // 确定新节点的层数
    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;
}

在这里,我们完成了插入操作,包括更新指针和跨度信息,并正确设置前后指针。

删除操作

删除操作需要找到要删除的节点,并调整相关指针。具体步骤如下:

  1. 从最高层开始,逐级向下查找待删除节点的位置。
  2. 在每一层,更新前一个节点的指针,使其跳过待删除节点。
  3. 更新跨度信息和后退指针。
  4. 如果删除的是最高层节点,还需要更新跳跃表的层数。
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) {
        /* Update the forward pointers of the nodes at each level */
        for (i = 0; i < zsl->level; i++) {
            if (update[i]->level[i].forward == x) {
                update[i]->level[i].forward = x->level[i].forward;
                update[i]->level[i].span += x->level[i].span - 1;
            } else {
                update[i]->level[i].span -= 1;
            }
        }

        /* Update backward pointer */
        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--;

        zsl->length--;
        if (node) *node = x;
        return 1;
    }
    return 0;
}

在上述代码中,我们完成了删除操作的具体步骤,包括更新多层链表中的指针、跨度信息和后退指针,同时也处理了层级调整等细节。

查找操作

查找操作是跳跃表的核心功能之一。通过逐层前进,可以快速找到目标节点。

zskiplistNode* zslFind(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *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;
        }
    }

    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele, ele) == 0) {
        return x;
    }
    return NULL;
}

在查找操作中,从最高层开始逐层向下查找,直到找到目标节点或者确定目标节点不存在。

思考题1: 跳跃表层级个数是如何决定的?

在Redis中,跳跃表(Skip List)的实现是通过随机化算法来决定每个节点的层级数。具体来说,当一个新元素被插入到跳跃表中时,会为该元素随机生成一个层级数,称为“高度”。这个高度决定了该元素将在跳跃表中的多少个层级上存在。

以下是决定高度的过程:

  1. 随机数生成:使用一个随机数生成器来决定是否增加层级。具体方法通常是掷硬币(即生成0或1的随机数)。如果掷硬币结果为1,则增加一层,重复这一过程直到掷硬币结果为0或者达到预设的最大层级。
  2. 概率控制:可以通过控制掷硬币得到1的概率来调整层级增长的速度。在Redis的默认实现中,掷硬币得到1的概率是0.5,这意味着每一层下一层都有50%的概率被包含。
  3. 最大层级限制:为了避免生成过高的节点,Redis限制了节点的最高层级。默认情况下,这个最大层级数是32,但实际使用中会根据需要进行调整。

举个例子,如果你插入一个新元素,系统可能会连续多次得到1(假设前四次都得到1),然后得到一次0,那么这个新元素的层级就是4。

这种随机化的方法使得跳跃表具有良好的期望性能,能够在O(log N)时间复杂度内完成查找、插入和删除操作。

思考题2: 删除操作,是每个层级都删除嘛

是的,删除操作涉及到在跳跃表中每个层级都删除对应的节点。具体步骤如下:

  1. 查找目标节点:首先,通过各层级找到需要删除的目标节点。这一步和查找操作类似,从最高层级开始,如果当前层级的指针所指向的节点值大于或等于要删除的节点,则移动到下一层级重复这个过程。
  2. 更新指针:一旦找到所有层级中的目标节点,在每个层级上更新前驱节点的指针,使其跳过被删除的节点,直接指向目标节点的后继节点。
  3. 释放节点:最后,释放目标节点的内存。