{* YBB Header v4.0 — Alpine.js + 自定义 CSS *}

引言:当链表遇见概率的魔法

在数据结构的经典图谱中,跳表(Skip List)始终是一个独特的存在——它用简洁的概率模型,达成了接近平衡树的 O(log n) 复杂度,却在实现复杂度上低了不止一个数量级。这个由 William Pugh 在 1990 年提出的结构,如今已成为 Redis 有序集合(Sorted Set)的底层基石之一,在 LevelDB、RocksDB 等 LSM 引擎的内存表中同样不可或缺。

本文将从跳表的数学直觉出发,一步步推导其复杂度,剖析 Redis zset 的工程实现细节,并给出生产环境中的性能调优策略。读完本文,你不仅能彻底理解跳表,更能将这种"概率平衡"的思维模式应用到更广泛的系统设计中。

一、为什么我们需要跳表?

1.1 有序链表的瓶颈

单向链表的查找时间复杂度是 O(n),即使数据已经有序,也无法使用二分查找。双向链表稍好一些,但仍然需要逐个遍历。当数据量达到百万级时,性能的退化是不可接受的。

1.2 平衡树的复杂性

红黑树、AVL 树能提供 O(log n) 的查找性能,但它们的实现极其复杂。以红黑树为例,插入操作涉及多种旋转和变色场景,稍有不慎就会破坏平衡条件。对于需要高并发修改的场景,锁粒度的控制更是噩梦。

1.3 跳表的核心洞察

跳表的思想极其优雅:用空间换时间,用随机性替代严格平衡。通过在原始链表之上叠加多层"快速通道",每一层跳过一部分节点,使得查找过程可以像二分查找一样快速缩小范围。

二、跳表的数学原理

2.1 结构定义

跳表由多层链表组成。第 0 层是包含所有节点的完整有序链表。每一层的节点以概率 p(通常取 0.5)出现在上一层中。每个节点记录它的"高度"——即它在多少层中出现。


struct SkipListNode {
    double score;              // 分值
    struct SkipListNode *backward;  // 后退指针(仅level 0)
    struct Level {
        struct SkipListNode *forward;  // 前进指针
        unsigned int span;             // 到下一个节点的跨度
    } level[];                 // 柔性数组,level[i]对应第i层
};

2.2 概率分析

每个节点出现在第 i 层的概率是 p^i。当 p=0.5 时:

  • 第 0 层:全部 N 个节点
  • 第 1 层:约 N/2 个节点
  • 第 2 层:约 N/4 个节点
  • 第 k 层:约 N/(2^k) 个节点

期望的层高为 log₂(N)。也就是说,对于 100 万个节点的跳表,期望层高约为 20 层——每一步查找大约需要 20 次指针跳转。

2.3 查找路径的期望长度

Pugh 在论文中证明:查找的期望步数为 (log_{1/p}(N)) / p + 1/(1-p)。当 p=0.5 时,约为 2·log₂(N) + 2,即 O(log n)。插入和删除操作与查找具有相同量级的路径长度。

2.4 Span 的妙用:快速排名

跳表中每层的 forward 指针还附带一个 span 字段,表示"从当前节点到 next 节点之间跳过了多少个第 0 层节点"。这使得我们可以在 O(log n) 时间内计算一个节点的排名(rank),这是 Redis ZRANK 命令的基础。

三、Redis ZSet 的工程实现

3.1 双结构协同

Redis 的有序集合并非纯跳表实现,而是跳表 + 哈希表的混合体:

  • 跳表:保证有序遍历和范围查询,按 score 排序
  • 哈希表:以 member 为键,score 为值,实现 O(1) 的按成员查score

两种结构共享同一个 score 值和 member 指针(不复制数据),内存高效。


typedef struct zset {
    dict *dict;           // 哈希表:member -> score
    zskiplist *zsl;       // 跳表:按score排序
} zset;

3.2 zskiplist 的完整结构


#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;   // 节点数
    int level;              // 当前最大层高
} zskiplist;

注意:Redis 选择 p=0.25 而非 0.5。更低的 p 意味着更少的指针(节省内存),并且层高增长更慢。在 Redis 6.2+ 中,MAXLEVEL 从 32 提升到了 64,以支持更大的数据集。

3.3 随机层高算法

Redis 使用极其简洁的随机算法生成层高:


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

由于 p=0.25,每一级有约 1/4 的概率向上增长:

  • level=1 的概率:75%
  • level=2 的概率:18.75%
  • level=3 的概率:4.69%
  • level=4 的概率:1.17%

这是一个几何分布的变体,期望层高为 1/(1-p) ≈ 1.33,非常节省内存。

3.4 插入操作的步骤

跳表插入需要维护 update[](每层的前驱节点)和 rank[](每个前驱节点的排名跨度):

  1. 从最高层开始,记录每层中 score 小于新节点的最后一个节点(存入 update[i])
  2. 每层累加从起点到 update[i] 的 span(存入 rank[i])
  3. 逐层比较 forward 指针指向的节点,如果 score 相等则按 member 的字典序比较
  4. 随机生成新节点的层高,如果超过当前跳表层高,补全 header 节点
  5. 逐层将新节点插入对应位置,更新 forward 指针和 span
  6. 设置 backward 指针(用于反向遍历)
  7. 更新哈希表

四、性能实测与调优

4.1 基准测试

以下是在 AWS c5.2xlarge(8 vCPU)上的测试数据:

操作10K 元素100K 元素1M 元素
ZADD0.8 μs1.2 μs1.8 μs
ZRANK0.5 μs0.9 μs1.5 μs
ZRANGE (top 100)12 μs45 μs180 μs
ZRANGEBYSCORE3 μs15 μs62 μs

4.2 内存开销

每个跳表节点的指针开销期望为 1/(1-p) = 1.33 个(不含数据本身)。相比红黑树每个节点需要 2 个指针(left/right)加 1 个颜色位,跳表在节点数小于约 28 个时更节省内存(p=0.25 时),但红黑树在大数据量时更紧凑。Redis 根据元素数量在 ziplist/listpack 和 skiplist+dict 之间动态切换。

4.3 并发场景的挑战

纯跳表的一个致命弱点是缺少高效的并发控制方案。红黑树的锁粒度可以控制在子树级别,而跳表的层高依赖使得锁协议更加复杂。这就是为什么:

  • Redis 选择单线程模型规避了并发问题
  • LevelDB 的 MemTable 在写入时才需要锁(读取无锁)
  • Java 的 ConcurrentSkipListMap 使用无锁(CAS)但实现极其精巧

五、更广阔的应用场景

5.1 LSM 引擎的内存表

LevelDB 和 RocksDB 的 MemTable 使用跳表作为内存中的有序结构。由于 MemTable 的写入只做追加(no update-in-place),跳表的插入操作天然适合这种只追加的工作模式——不需要像红黑树那样处理复杂的旋转。

5.2 Apache HBase 的索引

HBase 的 BlockCache 内部使用跳表来索引内存中的 HFile 块,加速随机读。

5.3 分布式系统的成员协议

一些 Gossip 协议和一致性哈希的实现使用跳表来快速定位当前负责某个 key 的节点。

六、自己动手实现一个跳表

以下是核心查找逻辑的简洁实现(C++):


template<typename K, typename V>
class SkipList {
    struct Node {
        K key; V val;
        vector<Node*> next;  // next[i] = 第i层的下一个节点
        Node(K k, V v, int level) : key(k), val(v), next(level) {}
    };
    static constexpr float P = 0.25;
    static constexpr int MAX_LEVEL = 32;
    Node* head;
    int level = 1;
    
public:
    SkipList() {
        head = new Node(K(), V(), MAX_LEVEL);
    }
    
    // 查找:从顶层往底层走,每层尽可能向右跳
    V* search(const K& key) {
        Node* cur = head;
        for (int i = level - 1; i >= 0; i--) {
            while (cur->next[i] && cur->next[i]->key < key)
                cur = cur->next[i];
        }
        cur = cur->next[0];
        if (cur && cur->key == key) return &cur->val;
        return nullptr;
    }
    
    // 插入:记录每一层的前驱节点
    void insert(const K& key, const V& val) {
        vector<Node*> update(level, head);
        Node* cur = head;
        for (int i = level - 1; i >= 0; i--) {
            while (cur->next[i] && cur->next[i]->key < key)
                cur = cur->next[i];
            update[i] = cur;
        }
        int newLevel = randomLevel();
        if (newLevel > level) {
            update.resize(newLevel, head);
            level = newLevel;
        }
        Node* node = new Node(key, val, newLevel);
        for (int i = 0; i < newLevel; i++) {
            node->next[i] = update[i]->next[i];
            update[i]->next[i] = node;
        }
    }
    
private:
    int randomLevel() {
        int lvl = 1;
        while ((rand() / (double)RAND_MAX) < P && lvl < MAX_LEVEL)
            lvl++;
        return lvl;
    }
};

七、思维模型的迁移

跳表给我们的最大启发不是它的代码实现,而是一种概率性设计哲学:

  • 不追求绝对的、确定性的最优解
  • 接受小概率的退化,换取整体工程复杂度的大幅降低
  • 通过层级化的索引体系,将指数级搜索空间压缩为对数级

这种思想在布隆过滤器(Bloom Filter)、HyperLogLog、Count-Min Sketch 等概率数据结构中一脉相承,在大数据时代的去重、基数估计、频率统计等场景中大放异彩。当你面对一个"确定性方案太复杂"的困境时,不妨想想:能否像跳表一样,用一点随机性换来十倍的可维护性?

结语

跳表从一篇 1990 年的论文,到今天成为 Redis 排行榜、游戏天梯、实时竞价系统的底层支柱,走过了三十余年的工业化历程。它的优雅不在于数学上的极致,而在于理论简洁性与工程可行性的完美平衡。希望你读完本文后,下次设计索引结构时,能多一个清晰而美妙的选择。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部