数据结构与算法|第十一章:跳表

数据结构与算法|第十一章:跳表

  • [第十一章 跳表(Skip List)](#第十一章 跳表(Skip List))
    • [11.1 跳表的诞生背景:链表二分查找的困境](#11.1 跳表的诞生背景:链表二分查找的困境)
    • [11.2 跳表的核心原理:多层索引](#11.2 跳表的核心原理:多层索引)
    • [11.3 跳表的节点结构与操作](#11.3 跳表的节点结构与操作)
      • [11.3.1 节点结构](#11.3.1 节点结构)
      • [11.3.2 查找操作](#11.3.2 查找操作)
      • [11.3.3 插入操作(随机层数)](#11.3.3 插入操作(随机层数))
      • [11.3.4 删除操作](#11.3.4 删除操作)
    • [11.4 手写跳表完整实现](#11.4 手写跳表完整实现)
    • [11.5 跳表 vs 红黑树 vs B+ 树](#11.5 跳表 vs 红黑树 vs B+ 树)
    • [11.6 跳表在实际工程中的应用](#11.6 跳表在实际工程中的应用)
      • [11.6.1 Redis 有序集合(ZSet)](#11.6.1 Redis 有序集合(ZSet))
      • [11.6.2 Java ConcurrentSkipListMap / ConcurrentSkipListSet](#11.6.2 Java ConcurrentSkipListMap / ConcurrentSkipListSet)
    • [11.7 经典实战:](#11.7 经典实战:)
      • [11.7.1 设计跳表(LeetCode 1206)](#11.7.1 设计跳表(LeetCode 1206))
      • [11.7.2 基于跳表的文本压缩](#11.7.2 基于跳表的文本压缩)
    • 总结与预告

上篇:第十章、散列与哈希表

下篇:第十二章、图

第十一章 跳表(Skip List)

假设你在一个有序数组中查找一个数------二分查找,O(log n),完美。但如果数据是有序链表呢?

链表不支持随机访问,你只能从头到尾一个个地找,时间复杂度 O(n)。既然有序链表已经排好序了,有没有办法在它上面也实现类似二分查找的 O(log n) 效率?

这就是**跳表(Skip List)**诞生的动机------给链表"加上索引",让它可以跳着走。


11.1 跳表的诞生背景:链表二分查找的困境

问题根源 :有序数组能二分查找,是因为可以通过下标 O(1) 跳到任意位置。链表只有 next 指针,天然不具备"跳跃"能力。

解决思路 :既然链表本身不支持跳跃,那我们就人为地在链表上方构建多层"索引"------就像给一本书加上目录导航,你可以先翻到"第 3 章",再翻到"3.2 节",最后定位到具体页码。
原始链表
1
3
5
7
9
11
13

复制代码
原始链表查找 11:挨个遍历 1→3→5→7→9→11(6 步)

加索引后:
索引层:  1 ──────→ 5 ──────→ 9 ──────→ 13
          ↓          ↓          ↓          ↓
原始层:  1 → 3 → 5 → 7 → 9 → 11 → 13

查找 11:从索引层 1→5→9(<11,继续)→ 13(>11,退回 9,到原始层)
         → 9→11(2 步!)
总共只需 5 步,比原始的 6 步更少。

这就是跳表的核心思想------空间换时间,用额外的索引层换取更快的查找速度。


11.2 跳表的核心原理:多层索引

跳表的正式定义如下:

跳表(Skip List):是一种随机化 的数据结构,基于多层有序链表 。每个结点拥有随机数量的层(level),高层结点稀疏(充当"快速通道"),低层结点密集(完整数据)。查找时从最高层开始,逐层下降,每层通过水平遍历定位到目标区间。

理想跳表:第 k 层每 2 k 2^k 2k 个结点被选为索引结点。此时查询复杂度严格 O(log n)。

复制代码
Level 2:  head ───────────────────→ 9 ───────────────────→ tail
Level 1:  head ────────→ 5 ────────→ 9 ────────→ 13 ────→ tail
Level 0:  head → 1 → 3 → 5 → 7 → 9 → 11 → 13 → 15 → tail

查找 11 的路径:L2: 9 < 11 → L1: 13 > 11(越过了,退回到 9)→ L0: 9 → 11 命中!总共跳过了 1, 3, 5, 7 四个结点。


11.3 跳表的节点结构与操作

11.3.1 节点结构

每个跳表结点包含两个核心部分:

  1. 数据域 :存储的值 val
  2. 指针数组 next[]next[i] 表示当前结点在第 i 层的下一个结点
java 复制代码
/**
 * 跳表结点定义
 */
static class SkipNode {
    int val;                    // 存储的值
    SkipNode[] next;            // next[i] = 第 i 层的下一个结点

    SkipNode(int val, int level) {
        this.val = val;
        this.next = new SkipNode[level];
    }
}

例如一个 level=3 的结点,拥有 next[0]next[1]next[2] 三个指针,分别指向 L0、L1、L2 层的后继结点。level 越高,结点越"显眼",充当快速通道。

11.3.2 查找操作

查找是跳表最核心的操作,体现了"跳"的精髓。

查找流程(假设跳表有 maxLevel 层,索引从 0 开始):

  1. 最高层maxLevel - 1)开始
  2. 在当前层水平遍历:若 next.val < target,继续向右;若 next.val > target 或到达末尾,则下降一层
  3. 重复步骤 2,直到降到第 0 层
  4. 在第 0 层检查 next.val == target
java 复制代码
/**
 * 查找目标值是否存在于跳表中
 * @param target 目标值
 * @return 是否存在
 */
public boolean search(int target) {
    SkipNode cur = head;
    // 从最高层开始,逐层下降
    for (int level = curLevel - 1; level >= 0; level--) {
        // 在当前层尽量向右走
        while (cur.next[level] != null && cur.next[level].val < target) {
            cur = cur.next[level];
        }
        // 此时 cur.next[level] 要么是 null,要么 val >= target
    }
    // 降到第 0 层后,检查紧邻的下一个结点
    cur = cur.next[0];
    return cur != null && cur.val == target;
}

为什么需要 update 数组? 在插入和删除操作中,我们需要记录"在每一层下降之前的位置"------因为修改指针时,需要知道每一层的前驱结点在哪。这就是 update[] 数组的作用。

11.3.3 插入操作(随机层数)

插入分为两步:

第 1 步 :确定新结点的层数。通过随机层数生成器决定。

java 复制代码
/**
 * 随机生成新结点的层数
 * 每层有 50% 的概率继续增加(类似抛硬币)
 * @param maxLevel 最大层数限制
 * @return 随机生成的层数
 */
private int randomLevel(int maxLevel) {
    int level = 1;
    // 每次有 1/2 概率升层,期望层数为 2
    while (Math.random() < 0.5 && level < maxLevel) {
        level++;
    }
    return level;
}

为什么用 0.5 的概率? 这样每层的结点数大约是上一层的 1/2,期望层数为 1 / (1 - 0.5) = 2。这种随机策略使得跳表在概率上保持平衡,无需像 AVL 树那样复杂的旋转操作。

第 2 步 :从最高层开始向下查找插入位置,在每层记录前驱结点(update[level]),然后在各层插入新结点。

java 复制代码
/**
 * 插入一个值(不允许重复)
 * @param num 要插入的值
 */
public void add(int num) {
    SkipNode[] update = new SkipNode[MAX_LEVEL];
    SkipNode cur = head;

    // 从最高层向下查找,记录每层的前驱
    for (int level = curLevel - 1; level >= 0; level--) {
        while (cur.next[level] != null && cur.next[level].val < num) {
            cur = cur.next[level];
        }
        update[level] = cur; // 记录该层的前驱
    }

    // 检查是否已存在
    if (cur.next[0] != null && cur.next[0].val == num) {
        return; // 重复值,不插入
    }

    // 随机生成新结点的层数
    int newLevel = randomLevel(MAX_LEVEL);
    if (newLevel > curLevel) {
        // 如果新结点层数超过当前最高层,多出的层由 head 作为前驱
        for (int i = curLevel; i < newLevel; i++) {
            update[i] = head;
        }
        curLevel = newLevel;
    }

    SkipNode newNode = new SkipNode(num, newLevel);
    // 在每一层插入新结点(链表插入的标准操作)
    for (int i = 0; i < newLevel; i++) {
        newNode.next[i] = update[i].next[i];
        update[i].next[i] = newNode;
    }
}

插入过程示意图(插入 6,随机得到 level=2):

11.3.4 删除操作

删除是三个操作中最简单的------在每层找到待删结点的前驱,然后将前驱的 next 指针直接跨过待删结点。

java 复制代码
/**
 * 删除一个值
 * @param num 要删除的值
 * @return 是否成功删除
 */
public boolean erase(int num) {
    SkipNode[] update = new SkipNode[MAX_LEVEL];
    SkipNode cur = head;

    // 从最高层向下查找,记录每层的前驱
    for (int level = curLevel - 1; level >= 0; level--) {
        while (cur.next[level] != null && cur.next[level].val < num) {
            cur = cur.next[level];
        }
        update[level] = cur;
    }

    // 检查待删结点是否存在
    cur = cur.next[0];
    if (cur == null || cur.val != num) {
        return false;
    }

    // 从第 0 层到待删结点的最高层,逐层删除
    for (int i = 0; i < curLevel; i++) {
        if (update[i].next[i] == cur) {
            update[i].next[i] = cur.next[i]; // 跨过 cur
        }
    }

    // 如果高层变空,降低 curLevel
    while (curLevel > 1 && head.next[curLevel - 1] == null) {
        curLevel--;
    }
    return true;
}

删除后需要维护 curLevel:如果高层索引全空了,应该降低当前最高层数(虽然不影响正确性,但能减少查找时的无效遍历)。


11.4 手写跳表完整实现

完整代码示例:SkipList(LeetCode 1206 风格)

java 复制代码
import java.util.Random;

/**
 * 跳表(Skip List)完整实现
 * 支持:查找、插入、删除,不允许重复值
 */
public class SkipList {

    /** 最大层数限制 */
    private static final int MAX_LEVEL = 16;

    /** 升层概率 */
    private static final double P = 0.5;

    /** 头结点(哨兵,不存数据) */
    private final SkipNode head;

    /** 当前最高层数 */
    private int curLevel;

    /** 随机数生成器 */
    private final Random random;

    /* ==================== 结点定义 ==================== */
    static class SkipNode {
        int val;
        SkipNode[] next;  // next[i] = 第 i 层的后继

        SkipNode(int val, int level) {
            this.val = val;
            this.next = new SkipNode[level];
        }
    }

    /* ==================== 构造方法 ==================== */
    public SkipList() {
        head = new SkipNode(-1, MAX_LEVEL); // 哨兵,值无意义
        curLevel = 1;
        random = new Random();
    }

    /* ==================== 查找 ==================== */
    public boolean search(int target) {
        SkipNode cur = head;
        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < target) {
                cur = cur.next[i];
            }
        }
        cur = cur.next[0];
        return cur != null && cur.val == target;
    }

    /* ==================== 插入 ==================== */
    public void add(int num) {
        SkipNode[] update = new SkipNode[MAX_LEVEL];
        SkipNode cur = head;

        // 从最高层向下查找,记录每层的前驱
        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            update[i] = cur;
        }

        // 检查重复
        if (cur.next[0] != null && cur.next[0].val == num) {
            return;
        }

        // 随机生成层数
        int newLevel = randomLevel();
        if (newLevel > curLevel) {
            for (int i = curLevel; i < newLevel; i++) {
                update[i] = head;
            }
            curLevel = newLevel;
        }

        // 在各层插入新结点
        SkipNode newNode = new SkipNode(num, newLevel);
        for (int i = 0; i < newLevel; i++) {
            newNode.next[i] = update[i].next[i];
            update[i].next[i] = newNode;
        }
    }

    /* ==================== 删除 ==================== */
    public boolean erase(int num) {
        SkipNode[] update = new SkipNode[MAX_LEVEL];
        SkipNode cur = head;

        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            update[i] = cur;
        }

        cur = cur.next[0];
        if (cur == null || cur.val != num) {
            return false;
        }

        // 逐层删除
        for (int i = 0; i < curLevel; i++) {
            if (update[i].next[i] == cur) {
                update[i].next[i] = cur.next[i];
            }
        }

        // 降低 curLevel(如果高层空了)
        while (curLevel > 1 && head.next[curLevel - 1] == null) {
            curLevel--;
        }
        return true;
    }

    /* ==================== 辅助方法 ==================== */
    private int randomLevel() {
        int level = 1;
        while (random.nextDouble() < P && level < MAX_LEVEL) {
            level++;
        }
        return level;
    }
}

时间复杂度分析:

操作 平均时间复杂度 最坏时间复杂度 说明
search(target) O(log n) O(n) 概率保证 O(log n),极低概率退化为链表
add(num) O(log n) O(n) 查找 O(log n) + 各层插入 O(level)
erase(num) O(log n) O(n) 查找 O(log n) + 各层删除 O(level)

空间复杂度 :结点期望层数为 1/(1−P)。当 P=0.5 时,期望层数为 2,即平均每个结点有 2 个指针。总空间 O(n),常数因子约为链表的 2 倍。


11.5 跳表 vs 红黑树 vs B+ 树

在第八章和第十章中我们学习了红黑树和 B+ 树,现在将它们与跳表做一个全面对比:

对比维度 跳表(Skip List) 红黑树(RB Tree) B+ 树(B+ Tree)
查找性能 O(log n) 概率 O(log n) 严格 O(log n) 严格
插入性能 O(log n),无旋转 O(log n),最多 3 次旋转 O(log n),结点分裂
删除性能 O(log n),无旋转 O(log n),最多 3 次旋转 O(log n),结点合并
实现难度 ⭐⭐ 简单 ⭐⭐⭐ 中等 ⭐⭐⭐⭐ 复杂
范围查询 ✅ O(log n + k),沿 L0 链表 ✅ O(log n + k),中序遍历 ✅✅ O(log n + k),叶子链表
空间开销 约 2n 指针(P=0.5 时) 3n 引用(left/right/parent)+ 颜色 每个结点约 B 个指针
并发友好度 ✅✅✅ 仅修改相邻结点指针 ✅ 旋转涉及多个结点 ✅✅ 页级别锁
有序遍历 ✅ L0 层天然有序 ✅ 中序遍历 ✅✅ 叶子链表天然有序

选型建议:

场景 推荐 理由
简单有序集合,小数据量 跳表 实现最简单,调试方便
需要严格 O(log n) 保证 红黑树(TreeMap) 最坏情况也有保证
高并发读写 跳表(ConcurrentSkipListMap) 无锁/轻量级锁,并发性能优异
磁盘存储,海量数据 B+ 树 减少 I/O,页对齐
范围查询为主 B+ 树 > 跳表 > 红黑树 B+ 树叶子链表最直接

11.6 跳表在实际工程中的应用

11.6.1 Redis 有序集合(ZSet)

Redis 的**有序集合(Sorted Set / ZSet)**是跳表最著名的工业应用之一。

当 ZSet 同时满足以下两个条件时,Redis 使用压缩列表(ziplist)作为底层结构;否则使用跳表 + 哈希表的混合结构:

  • 元素个数 < zset-max-ziplist-entries(默认 128)
  • 每个元素长度 < zset-max-ziplist-value(默认 64 字节)
redis 复制代码
┌─────────────────────────────┐
│  Redis ZSet(跳表 + 字典)   │
├─────────────────────────────┤
│  跳表(zskiplist):          │
│    - 按 score 排序            │
│    - 支持 ZRANGE 范围查询     │
│    - 支持 ZRANK 排名查询      │
│                             │
│  字典(dict):               │
│    - member → score 映射     │
│    - 支持 ZSCORE O(1) 查询   │
└─────────────────────────────┘

为什么 Redis 选择跳表而非红黑树?

  1. 范围查询更自然:跳表的 L0 层天然就是一个有序链表,ZRANGE 直接沿链表遍历即可
  2. 实现更简单:ZSet 的跳表代码约 200 行,红黑树需要更复杂的旋转逻辑
  3. 内存效率:跳表平均每结点 2 个指针,红黑树每结点 3 个引用 + 颜色标记

11.6.2 Java ConcurrentSkipListMap / ConcurrentSkipListSet

Java 在 java.util.concurrent 包中提供了基于跳表的并发有序映射:

底层结构 线程安全 有序性
TreeMap 红黑树 ✅ 自然顺序/比较器
ConcurrentSkipListMap 跳表 ✅ CAS + 轻量级锁 ✅ 自然顺序/比较器
TreeSet 红黑树(TreeMap 封装)
ConcurrentSkipListSet 跳表(ConcurrentSkipListMap 封装)

ConcurrentSkipListMap 的并发设计要点:

java 复制代码
// ConcurrentSkipListMap 的结点定义(简化)
static final class Node<K,V> {
    final K key;
    volatile Object value;     // volatile 保证可见性
    volatile Node<K,V> next;   // volatile 保证可见性
}

// 索引结点
static class Index<K,V> {
    final Node<K,V> node;      // 指向数据结点
    final Index<K,V> down;     // 指向下一层索引
    volatile Index<K,V> right; // 指向右侧索引(volatile)
}

为什么跳表比红黑树更适合并发?

  • 跳表的插入/删除只影响局部相邻结点,可以使用 CAS 无锁操作
  • 红黑树的旋转可能影响从根到叶子的整条路径,加锁范围大
  • 这就是 ConcurrentSkipListMap 存在而 没有 ConcurrentTreeMap 的原因

11.7 经典实战:

11.7.1 设计跳表(LeetCode 1206)

LeetCode 1206. 设计跳表 :实现一个不含重复值的跳表,支持 searchadderase 操作,并返回 true/false 表示操作是否成功。

这恰好就是我们在 11.4 中实现的完整 SkipList 类。以下是针对 LeetCode 1206 接口的适配版本:

java 复制代码
/**
 * LeetCode 1206:设计跳表
 * https://leetcode.cn/problems/design-skiplist/
 */
class Skiplist {

    private static final int MAX_LEVEL = 16;
    private static final double P = 0.5;

    private final Node head;
    private int curLevel;

    static class Node {
        int val;
        Node[] next;
        Node(int val, int level) {
            this.val = val;
            this.next = new Node[level];
        }
    }

    public Skiplist() {
        head = new Node(-1, MAX_LEVEL);
        curLevel = 1;
    }

    public boolean search(int target) {
        Node cur = head;
        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < target) {
                cur = cur.next[i];
            }
        }
        cur = cur.next[0];
        return cur != null && cur.val == target;
    }

    public void add(int num) {
        Node[] update = new Node[MAX_LEVEL];
        Node cur = head;
        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            update[i] = cur;
        }

        int newLevel = randomLevel();
        if (newLevel > curLevel) {
            for (int i = curLevel; i < newLevel; i++) {
                update[i] = head;
            }
            curLevel = newLevel;
        }

        Node newNode = new Node(num, newLevel);
        for (int i = 0; i < newLevel; i++) {
            newNode.next[i] = update[i].next[i];
            update[i].next[i] = newNode;
        }
    }

    public boolean erase(int num) {
        Node[] update = new Node[MAX_LEVEL];
        Node cur = head;
        for (int i = curLevel - 1; i >= 0; i--) {
            while (cur.next[i] != null && cur.next[i].val < num) {
                cur = cur.next[i];
            }
            update[i] = cur;
        }

        cur = cur.next[0];
        if (cur == null || cur.val != num) return false;

        for (int i = 0; i < curLevel; i++) {
            if (update[i].next[i] == cur) {
                update[i].next[i] = cur.next[i];
            }
        }

        while (curLevel > 1 && head.next[curLevel - 1] == null) {
            curLevel--;
        }
        return true;
    }

    private int randomLevel() {
        int level = 1;
        while (Math.random() < P && level < MAX_LEVEL) {
            level++;
        }
        return level;
    }
}

提交结果search/add/erase 均 O(log n) 概率保证,空间 O(n)。LeetCode 实测性能与 TreeMap 方案相当,但代码更简洁。

11.7.2 基于跳表的文本压缩

前面我们已经从理论、手写实现到 LeetCode 都走了一遍。这一节,我们来玩一个"创意实战"------用跳表做一个简易文本压缩器 。虽然不是工业级方案,但它能让你直观感受到:跳表在实际问题中到底是怎么用的

  • 压缩思路:字典编码

字典编码(Dictionary Encoding) 是一种经典的无损压缩策略:

  1. 遍历文本,收集所有不重复的单词

  2. 为每个单词分配一个短整数编码 (比如用 2 字节的 int 替代平均 5 字节的英文单词)

  3. 压缩时用编码替换单词,解压时根据编码查回单词

    原始文本:hello world hello skip list world
    ↓ 构建字典 + 编码替换
    压缩结果:1,2,1,3,4,2 | 1:hello 2:world 3:skip 4:list
    └─ 编码序列 ─┘ └───── 字典(换行分隔)─────┘

这里的关键挑战:字典需要支持「按单词查找编码」和「插入新单词」,而且当文本量大时(比如一本小说),操作效率至关重要。

跳表天然适合这个场景! 有序 + O(log n) 查找/插入 + 代码简单。

  • 算法设计

整个过程分为两个阶段:

阶段一:构建字典(使用跳表)

复制代码
对于文本中的每个单词:
  1. 在跳表中查找该单词 → 若找到,返回已有编码
  2. 若未找到,在跳表中插入(单词, 新编码),跳表自动维护字典序

阶段二:编码替换 + 输出

复制代码
再次遍历文本:
  - 每个单词 → 查找跳表获取编码 → 追加到编码序列
最后输出:编码序列 | 字典条目(用于解压时还原)

注意:这里用「两遍扫描」是为了代码清晰。实际可以一遍完成------边构建字典边替换,遇到新单词先插入再输出编码。

  • 完整代码实现
java 复制代码
import java.util.*;

/**
 * 基于跳表的文本压缩器
 *
 * 采用字典编码策略:将文本中的单词替换为短整数编码。
 * 跳表用于维护按字母序排列的「单词 → 编码」映射表,
 * 提供 O(log n) 的查找与插入性能。
 *
 * <p>压缩格式:code1,code2,...|code:word\ncode:word\n...
 * <p>示例:
 *   输入: "hello world hello skip"
 *   输出: "1,2,1,3|1:hello\n2:skip\n3:world"
 */
public class SkipListTextCompressor {

    // ==================== 跳表数据结构 ====================

    private static final int MAX_LEVEL = 16;
    private static final double PROMOTE_RATE = 0.5;

    /** 跳表结点:存储 (单词, 编码) 映射 */
    static class SkipNode {
        final String word;
        final int code;
        final SkipNode[] next;   // next[i] = 第 i 层的后继结点

        SkipNode(String word, int code, int level) {
            this.word = word;
            this.code = code;
            this.next = new SkipNode[level];
        }
    }

    private final SkipNode head;
    private int curLevel;
    private int nextCode;
    private final Random random;

    public SkipListTextCompressor() {
        this.head = new SkipNode("", -1, MAX_LEVEL);
        this.curLevel = 1;
        this.nextCode = 1;
        this.random = new Random();
    }

    /* ---------- 跳表核心操作 ---------- */

    /**
     * 查找单词对应的编码
     * @return 编码,不存在返回 -1
     */
    public int findCode(String word) {
        SkipNode cur = head;
        for (int level = curLevel - 1; level >= 0; level--) {
            while (cur.next[level] != null
                   && cur.next[level].word.compareTo(word) < 0) {
                cur = cur.next[level];
            }
        }
        cur = cur.next[0];
        return (cur != null && cur.word.equals(word)) ? cur.code : -1;
    }

    /**
     * 插入单词,返回编码(已存在则返回已有编码)
     */
    public int insertWord(String word) {
        SkipNode[] update = new SkipNode[MAX_LEVEL];
        SkipNode cur = head;

        // 从最高层向下查找,记录每层的前驱
        for (int level = curLevel - 1; level >= 0; level--) {
            while (cur.next[level] != null
                   && cur.next[level].word.compareTo(word) < 0) {
                cur = cur.next[level];
            }
            update[level] = cur;
        }

        // 已存在,直接返回已有编码
        if (cur.next[0] != null && cur.next[0].word.equals(word)) {
            return cur.next[0].code;
        }

        // 随机层数
        int newLevel = randomLevel();
        if (newLevel > curLevel) {
            for (int i = curLevel; i < newLevel; i++) {
                update[i] = head;
            }
            curLevel = newLevel;
        }

        int code = nextCode++;
        SkipNode newNode = new SkipNode(word, code, newLevel);
        for (int i = 0; i < newLevel; i++) {
            newNode.next[i] = update[i].next[i];
            update[i].next[i] = newNode;
        }
        return code;
    }

    private int randomLevel() {
        int level = 1;
        while (random.nextDouble() < PROMOTE_RATE && level < MAX_LEVEL) {
            level++;
        }
        return level;
    }

    /** 字典大小(唯一单词数) */
    public int dictionarySize() {
        return nextCode - 1;
    }

    /** 遍历字典(按字母序),用于导出 */
    public List<String> exportDictionary() {
        List<String> dict = new ArrayList<>();
        SkipNode cur = head.next[0];
        while (cur != null) {
            dict.add(cur.code + ":" + cur.word);
            cur = cur.next[0];
        }
        return dict;
    }

    // ==================== 压缩与解压 ====================

    /**
     * 压缩文本
     * @param text 原始文本(仅保留英文单词,忽略标点与空格位置)
     * @return 压缩后的字符串,格式:编码序列|字典条目
     */
    public String compress(String text) {
        // 按非字母字符分词
        String[] words = text.split("[^a-zA-Z]+");

        // 阶段一:构建字典(跳表自动按字母序维护)
        for (String word : words) {
            if (!word.isEmpty()) {
                insertWord(word.toLowerCase());
            }
        }

        // 阶段二:编码替换
        StringBuilder codes = new StringBuilder();
        for (String word : words) {
            if (word.isEmpty()) continue;
            int code = findCode(word.toLowerCase());
            if (codes.length() > 0) codes.append(",");
            codes.append(code);
        }

        // 导出字典
        StringBuilder dictStr = new StringBuilder();
        for (String entry : exportDictionary()) {
            if (dictStr.length() > 0) dictStr.append("\n");
            dictStr.append(entry);
        }

        return codes.toString() + "|" + dictStr.toString();
    }

    /**
     * 解压文本
     * @param compressed compress() 的输出
     * @return 还原后的文本(单词间用空格分隔)
     */
    public static String decompress(String compressed) {
        int sep = compressed.indexOf('|');
        if (sep == -1) return "";

        String[] codes = compressed.substring(0, sep).split(",");

        // 解析字典:编码 → 单词
        String[] dictLines = compressed.substring(sep + 1).split("\n");
        String[] codeToWord = new String[dictLines.length + 1];
        for (String line : dictLines) {
            if (line.isEmpty()) continue;
            int colon = line.indexOf(':');
            int code = Integer.parseInt(line.substring(0, colon));
            codeToWord[code] = line.substring(colon + 1);
        }

        // 解码还原
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < codes.length; i++) {
            if (i > 0) sb.append(' ');
            int code = Integer.parseInt(codes[i]);
            sb.append(codeToWord[code]);
        }
        return sb.toString();
    }

    // ==================== 测试入口 ====================

    public static void main(String[] args) {
        String text = "hello world hello skip list world skip jump "
                    + "list jump hello world list skip";
        System.out.println("原文:  " + text);
        System.out.println("原文长度:" + text.length() + " 字符");

        SkipListTextCompressor compressor = new SkipListTextCompressor();
        String compressed = compressor.compress(text);

        System.out.println("\n压缩后:" + compressed);
        System.out.println("压缩后长度:" + compressed.length() + " 字符");
        System.out.println("字典单词数:" + compressor.dictionarySize());

        String restored = decompress(compressed);
        System.out.println("\n解压后:" + restored);

        double ratio = 1.0 - (double) compressed.length() / text.length();
        System.out.printf("压缩率:%.1f%%\n", ratio * 100);
    }
}

运行输出示例:

复制代码
原文:  hello world hello skip list world skip jump list jump hello world list skip
原文长度:72 字符

压缩后:1,2,1,3,4,2,3,5,4,5,1,2,4,3|1:hello
2:world
3:skip
4:list
5:jump
压缩后长度:65 字符
字典单词数:5

解压后:hello world hello skip list world skip jump list jump hello world list skip
压缩率:9.7%
  • 压缩效果分析

空间节省的理论公式:

假设文本有 N 个单词,其中 U 个唯一单词,每个单词平均长度 L 字节:

项目 原始文本 压缩后
单词数据 N × L 字节 N × 2 字节(编码用 Short)
字典 --- U × (2 + L) 字节(编码 + 单词)
分隔符 空格(已计入) 逗号 + `

N 很大且 U ≪ N(高度重复)时,压缩效果显著。例如对一篇 10 万词的英文文章,若唯一词汇量只有 5000,则压缩率可达 60% 以上

  • 为什么不用 HashMap?跳表的独特优势

你可能会问:"字典查找直接用 HashMap<String, Integer> 不就行了?O(1) 比 O(log n) 更快啊!"

说得没错,对于纯查找场景,HashMap 确实更快。但跳表在这里有几个额外的优势

维度 跳表 HashMap TreeMap (红黑树)
查找 O(log n) O(1) ~ O(n) O(log n)
插入 O(log n) O(1) ~ O(n) O(log n)
字典有序 ✅ 天然有序 ❌ 无序 ✅ 有序
空间开销 ≈ 2n 引用 ≈ n + 桶数组 ≈ 3n 引用 + 颜色
实现复杂度 ⭐⭐ ⭐⭐⭐
调试友好 ✅ L0 层可遍历 ❌ 遍历无序 ✅ 中序遍历

跳表有价值的场景

  • 压缩后的字典需要导出、展示,有序更易读
  • 需要范围查询(如"查找以 a 开头的所有单词")
  • 多线程环境下,ConcurrentSkipListMap 优于加锁的 HashMap
  • 教学场景:一段代码同时展示「跳表实现」和「压缩算法」两个知识点

一句话总结 :如果只追求速度,用 HashMap;如果需要有序 + 一定性能 + 代码可控 ,跳表是非常优雅的选择------你不需要引入 TreeMap 那套复杂的红黑树旋转逻辑。


总结与预告

本章我们学习了一种既简单又优雅的数据结构------跳表:

  • 11.1 诞生背景:链表无法二分查找 → 人为加索引,实现"跳着走"
  • 11.2 核心原理:多层索引、高层稀疏/低层密集、查找时从高层逐级下降
  • 11.3 节点与操作next[] 指针数组、查找(逐级下降)、插入(随机层数 + update 数组)、删除(逐层移除)
  • 11.4 手写实现 :完整的 SkipList 类,约 100 行代码实现 O(log n) 的有序集合
  • 11.5 性能对比:跳表 vs 红黑树 vs B+ 树------跳表实现最简单、并发最友好
  • 11.6 工业应用 :Redis ZSet(跳表 + 字典混合结构)、Java ConcurrentSkipListMap(CAS 无锁并发)
  • 11.7 LeetCode 1206:设计跳表的完整题解

跳表的核心理念:

随机化 替代复杂旋转 ,用多层索引 换取对数级查找。跳表证明了:有时候,扔硬币比精密的平衡算法更实用。

下一章我们将进入本系列的最后一个非线性数据结构------图(Graph)。图是比树更一般的结构,它取消了"一对多"的层级限制,任意两点都可以建立联系。从社交网络到地图导航,图算法是计算机科学中最迷人的领域之一。


上篇:第十章、散列与哈希表

下篇:第十二章、图

相关推荐
晚风叙码1 小时前
归并排序:从原理到非递归实现,一文搞定
数据结构·算法
悲伤小伞1 小时前
LeetCode 热题 100_3-128. 最长连续序列
c++·算法·leetcode·哈希算法
多加点辣也没关系1 小时前
数据结构与算法|第十三章:递归与分治
数据结构·算法
梦梦代码精2 小时前
LikeShop 是否安全可靠?——从架构设计到数据表现的系统性分析
数据结构·团队开发·安全性测试
m0_629494732 小时前
LeetCode 热题 100-----21.搜索二维矩阵 II
数据结构·算法·leetcode
平行侠2 小时前
018二进制GCD(Stein算法)- 用位运算代替除法的最大公因数
数据结构·算法
月疯2 小时前
卡尔曼滤波的数学计算流程
算法
黎阳之光2 小时前
黎阳之光:深耕视频孪生核心领域 构筑数字孪生全域数智新标杆
大数据·人工智能·算法·安全·数字孪生
sbjdhjd3 小时前
2026年第十七届蓝桥杯大赛软件赛省赛 Python 大学 B 组 A-F 题 完整题解(小白友好版)
python·算法·职场和发展·蓝桥杯·pycharm·开源·动态规划