数据结构与算法|第十一章:跳表
- [第十一章 跳表(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 节点结构
每个跳表结点包含两个核心部分:
- 数据域 :存储的值
val - 指针数组
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 开始):
- 从最高层 (
maxLevel - 1)开始 - 在当前层水平遍历:若
next.val < target,继续向右;若next.val > target或到达末尾,则下降一层 - 重复步骤 2,直到降到第 0 层
- 在第 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 选择跳表而非红黑树?
- 范围查询更自然:跳表的 L0 层天然就是一个有序链表,ZRANGE 直接沿链表遍历即可
- 实现更简单:ZSet 的跳表代码约 200 行,红黑树需要更复杂的旋转逻辑
- 内存效率:跳表平均每结点 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. 设计跳表 :实现一个不含重复值的跳表,支持
search、add、erase操作,并返回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) 是一种经典的无损压缩策略:
-
遍历文本,收集所有不重复的单词
-
为每个单词分配一个短整数编码 (比如用 2 字节的
int替代平均 5 字节的英文单词) -
压缩时用编码替换单词,解压时根据编码查回单词
原始文本: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)。图是比树更一般的结构,它取消了"一对多"的层级限制,任意两点都可以建立联系。从社交网络到地图导航,图算法是计算机科学中最迷人的领域之一。
上篇:第十章、散列与哈希表
下篇:第十二章、图