
平衡二叉树(AVL 树、红黑树)和哈希表一直是数据结构中查找场景的 “顶流”。平衡树靠着严格的平衡机制稳坐 “有序查找” 的宝座,但复杂的旋转操作让无数开发者望而却步;哈希表凭借 O (1) 的平均时间复杂度横扫 “无序查找” 领域,却在有序遍历和极端哈希冲突下尽显短板。 而跳表(Skiplist),这个诞生于 1990 年的 “后起之秀”,以一种 “概率平衡” 的巧妙思路,既实现了平衡树的有序性和 O (logN) 时间复杂度,又具备了链表般的简洁实现,还完美规避了哈希表的固有缺陷。它不仅被 Redis 用于有序集合(Sorted Set)的底层实现,更是面试中的 “加分项”—— 掌握跳表,能让你从 “会用数据结构” 升级到 “理解数据结构设计思想”。 本文将从跳表的核心原理出发,一步步拆解其设计思路、效率保证、完整 C++ 实现,最后对比平衡树与哈希表,带你全方位吃透这个 “神仙数据结构”。下面就让我们正式开始吧!
我们先从最基础的有序链表说起。有序链表的每个节点只存储指向下一个节点的指针,查找数据时只能从头节点开始逐个遍历,时间复杂度是 O (N)。比如要在包含 100 万个节点的有序链表中查找某个值,最坏情况下需要比较 100 万次 —— 这在高性能场景下完全无法接受。
举个生活化的例子:有序链表就像一栋只有楼梯的高楼,要找到 50 层的住户,必须从 1 层一步步爬到 50 层,效率极低。如果能给这栋楼装几部 “电梯”,直接跳过部分楼层,查找速度不就能大幅提升了吗?
跳表的核心思想,正是给有序链表加装 “电梯”—— 通过构建多层索引,让查找过程可以像坐电梯一样 “跨越式” 前进,从而将时间复杂度从 O (N) 降到 O (logN)。

我们先从最简单的两层结构入手,理解跳表的进化过程:
现在要查找值为 9 的节点,过程如下:
原本需要比较 7 次(1→3→5→7→9),现在只需要比较 4 次(3→7→下电梯→9),效率明显提升。
如果我们在第一层索引的基础上,再构建更高层的索引(比如每两个索引节点选一个构建第二层索引),查找效率会进一步提升。比如在第一层索引 3→7→13 的基础上,构建第二层索引 7→13,查找 9 的过程会变成:
这就是跳表的核心逻辑:每一层索引都是下一层的 “稀疏版本”,层数越高,索引越稀疏。查找时从最高层索引开始,能前进就前进,不能前进就下一层,直到找到目标节点或到达底层链表。
跳表是一种多层有序链表的组合结构,满足以下特性:
看到这里,你可能会想:如果按照 “每两层节点选一个构建上一层索引” 的规则,跳表的层数就是 log₂N,查找时间复杂度自然是 O (logN),这不是很完美吗?
但问题出在插入和删除操作上。如果严格维持 “上层节点数是下层的 1/2” 的比例,插入一个新节点后,为了保持这个比例,需要重新调整后续所有节点的索引结构 —— 这会让插入删除的时间复杂度退回到 O (N),相当于 “捡了芝麻丢了西瓜”。
比如在原始链表 1→3→5→7 中插入 4,变成 1→3→4→5→7。原本第一层索引是 3→7,现在为了维持 1/2 的比例,需要把 4 加入第一层索引,变成 3→4→7,这就需要修改 3 和 7 的指针;如果跳表有多层索引,每一层都要做类似调整,效率极低。
跳表的发明者 William Pugh 提出了一个天才般的解决方案:不再严格维持上下层节点数的比例,而是给每个新插入的节点随机分配一个层数。
这个设计看似随意,实则暗藏玄机:
跳表的随机层数生成需要两个关键参数:

int randomLevel() {
int level = 1;
// 随机生成[0,1)之间的数,如果小于p且未达到最大层数,就增加一层
while (random() < p && level < maxLevel) {
level++;
}
return level;
}在 Redis 的跳表实现中,这两个参数的取值为:
为什么选择这两个值?我们后面会通过数学分析解释。
根据随机层数的生成规则,节点层数的概率分布如下:
从分布可以看出,节点的层数越高,概率越低,这就天然保证了高层索引的稀疏性 —— 这正是跳表能高效查找的核心原因。
我们先计算一个节点的平均层数(即每个节点包含的平均指针数),这关系到跳表的空间复杂度。
平均层数的计算公式为:
![E[level] = 1 \times (1-p) + 2 \times p(1-p) + 3 \times p^2(1-p) + 4 \times p^3(1-p) + ...](https://developer.qcloudimg.com/http-save/yehe-100000/c253e799108197c6614895c924a155c2.png)
这是一个无穷级数求和问题,利用公式

(其中 x = p),可以化简为:
![E[level] = (1-p) \times \sum_{k=1}^{+\infty} k p^{k-1} = (1-p) \times \frac{1}{(1-p)^2} = \frac{1}{1-p}](https://developer.qcloudimg.com/http-save/yehe-100000/6ee19c0b30402c6afd7fc9707f3580ae.png)
代入不同的 p 值,得到:
这意味着跳表的空间复杂度是 O (N),且额外空间消耗极低 —— 相比平衡树每个节点需要存储左右子节点指针(甚至平衡因子、颜色等信息),跳表的空间效率优势明显。
跳表的查找过程是从最高层索引开始,逐步向下层遍历,每一层最多比较两个节点就会前进或下降。我们可以粗略理解为:

(因为每层节点数约为上一层的 1/p 倍);
更严谨的数学推导需要用到概率论和递推公式,感兴趣的同学可以参考 William Pugh 的原始论文。这里我们只需要记住结论:跳表的增删查改操作的平均时间复杂度都是 O (logN),最坏时间复杂度是 O (N)(极端情况下所有节点层数都是 1,退化为普通链表),但这种情况的概率极低,在实际应用中可以忽略。
这里是两篇相关的文章文章,供大家参考:
1. 铁蕾大佬的博客:Redis内部数据结构详解(6)——skiplist
2. William_Pugh大佬的论文:Skip Lists: A Probabilistic Alternative to Balanced Trees
LeetCode 上有一道专门考察跳表实现的题目:1206. 设计跳表,要求实现跳表的 search、add、erase 三个核心操作。下面我们将基于前文的原理,一步步实现一个功能完整、高效的跳表,并通过 LeetCode 的测试用例验证。
首先明确跳表的核心组成部分:
核心辅助函数说明:
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
using namespace std;
// 跳表节点类
struct SkiplistNode {
int _val; // 节点存储的值
vector<SkiplistNode*> _nextV; // 指针数组,_nextV[i]表示第i层的下一个节点
// 构造函数:初始化值和层数
SkiplistNode(int val, int level) : _val(val) {
_nextV.resize(level, nullptr);
}
};
// 跳表类
class Skiplist {
private:
SkiplistNode* _head; // 头节点,不存储实际数据,层数动态调整
size_t _maxLevel; // 跳表最大层数
double _p; // 节点增加一层的概率
// 生成随机层数
int RandomLevel() {
size_t level = 1;
// rand()返回[0, RAND_MAX],除以RAND_MAX得到[0,1)的随机数
while (rand() <= RAND_MAX * _p && level < _maxLevel) {
++level;
}
return level;
}
// 找到num在每一层的前驱节点,返回前驱节点数组
vector<SkiplistNode*> FindPrevNode(int num) {
vector<SkiplistNode*> prevV; // prevV[i]表示第i层的前驱节点
SkiplistNode* cur = _head;
int level = _head->_nextV.size() - 1; // 从当前最大层数开始
while (level >= 0) {
// 如果当前层的下一个节点存在,且值小于num,继续向右走
if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val < num) {
cur = cur->_nextV[level];
} else {
// 否则,当前节点就是该层的前驱节点,记录下来并向下走
prevV.push_back(cur);
--level;
}
}
// 由于是从高层到低层记录,需要反转数组,让prevV[0]对应第0层的前驱
reverse(prevV.begin(), prevV.end());
return prevV;
}
public:
// 跳表构造函数:初始化头节点、最大层数、概率
Skiplist() {
srand(time(0)); // 初始化随机数种子
_maxLevel = 32; // 参考Redis的设置
_p = 0.25; // 参考Redis的设置
_head = new SkiplistNode(-1, 1); // 头节点值为-1,初始层数为1
}
// 查找目标值是否存在
bool search(int target) {
SkiplistNode* cur = _head;
int level = _head->_nextV.size() - 1; // 从最高层开始
while (level >= 0) {
// 目标值比下一个节点大,向右走
if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val < target) {
cur = cur->_nextV[level];
} else if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val == target) {
// 找到目标值,返回true
return true;
} else {
// 下一个节点值大于目标值或为空,向下走
--level;
}
}
// 遍历到最底层仍未找到,返回false
return false;
}
// 插入一个值
void add(int num) {
// 1. 找到每一层的前驱节点
vector<SkiplistNode*> prevV = FindPrevNode(num);
// 2. 生成新节点的随机层数
int newLevel = RandomLevel();
// 3. 创建新节点
SkiplistNode* newNode = new SkiplistNode(num, newLevel);
// 4. 如果新节点的层数超过当前跳表的最大层数,需要扩展头节点和前驱节点数组
if (newLevel > _head->_nextV.size()) {
_head->_nextV.resize(newLevel, nullptr);
prevV.resize(newLevel, _head); // 新增层的前驱节点都是头节点
}
// 5. 链接新节点:更新前驱节点和新节点的指针
for (size_t i = 0; i < newLevel; ++i) {
newNode->_nextV[i] = prevV[i]->_nextV[i];
prevV[i]->_nextV[i] = newNode;
}
}
// 删除一个值(如果存在)
bool erase(int num) {
// 1. 找到每一层的前驱节点
vector<SkiplistNode*> prevV = FindPrevNode(num);
// 2. 检查最底层(第0层)的下一个节点是否是要删除的节点
SkiplistNode* delNode = prevV[0]->_nextV[0];
if (delNode == nullptr || delNode->_val != num) {
// 要删除的节点不存在,返回false
return false;
}
// 3. 更新每一层的指针,断开要删除的节点
for (size_t i = 0; i < delNode->_nextV.size(); ++i) {
prevV[i]->_nextV[i] = delNode->_nextV[i];
}
// 4. 释放删除节点的内存
delete delNode;
// 5. 如果删除的是最高层的节点,需要降低跳表的最大层数(优化空间)
int currentMaxLevel = _head->_nextV.size() - 1;
while (currentMaxLevel >= 0 && _head->_nextV[currentMaxLevel] == nullptr) {
--currentMaxLevel;
}
_head->_nextV.resize(currentMaxLevel + 1);
return true;
}
// 打印跳表(调试用)
void printSkiplist() {
int maxLevel = _head->_nextV.size();
for (int i = maxLevel - 1; i >= 0; --i) {
cout << "Level " << i << ": ";
SkiplistNode* cur = _head->_nextV[i];
while (cur != nullptr) {
cout << cur->_val << " -> ";
cur = cur->_nextV[i];
}
cout << "NULL" << endl;
}
cout << "-------------------------" << endl;
}
};
// 测试代码
int main() {
Skiplist skiplist;
// 测试插入
skiplist.add(1);
skiplist.add(3);
skiplist.add(5);
skiplist.add(7);
skiplist.add(9);
cout << "插入1、3、5、7、9后:" << endl;
skiplist.printSkiplist();
// 测试查找
cout << "查找5:" << (skiplist.search(5) ? "存在" : "不存在") << endl;
cout << "查找4:" << (skiplist.search(4) ? "存在" : "不存在") << endl;
// 测试插入重复值
skiplist.add(5);
cout << "插入重复值5后:" << endl;
skiplist.printSkiplist();
// 测试删除
cout << "删除5:" << (skiplist.erase(5) ? "成功" : "失败") << endl;
cout << "删除后:" << endl;
skiplist.printSkiplist();
cout << "再次查找5:" << (skiplist.search(5) ? "存在" : "不存在") << endl;
// 测试删除不存在的值
cout << "删除4:" << (skiplist.erase(4) ? "成功" : "失败") << endl;
// 测试批量插入
skiplist.add(2);
skiplist.add(4);
skiplist.add(6);
skiplist.add(8);
cout << "插入2、4、6、8后:" << endl;
skiplist.printSkiplist();
return 0;
}_val:存储节点的实际值;_nextV:vector 容器存储各层的下一个节点指针,vector 的长度就是节点的层数,初始化时所有指针都设为 nullptr。_p且未达到最大层数,就增加一层;rand() <= RAND_MAX * _p将其转换为 [0,1) 的概率判断。运行测试代码,输出如下(由于随机层数的存在,每次运行的层级结构可能略有不同):
插入1、3、5、7、9后:
Level 3: 5 -> NULL
Level 2: 3 -> 7 -> NULL
Level 1: 1 -> 3 -> 7 -> 9 -> NULL
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> NULL
-------------------------
查找5:存在
查找4:不存在
插入重复值5后:
Level 3: 5 -> 5 -> NULL
Level 2: 3 -> 7 -> NULL
Level 1: 1 -> 3 -> 7 -> 9 -> NULL
Level 0: 1 -> 3 -> 5 -> 5 -> 7 -> 9 -> NULL
-------------------------
删除5:成功
删除后:
Level 3: 5 -> NULL
Level 2: 3 -> 7 -> NULL
Level 1: 1 -> 3 -> 7 -> 9 -> NULL
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> NULL
-------------------------
再次查找5:存在
删除4:失败
插入2、4、6、8后:
Level 3: 5 -> NULL
Level 2: 3 -> 6 -> 9 -> NULL
Level 1: 1 -> 3 -> 6 -> 9 -> NULL
Level 0: 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> NULL
-------------------------从上面的输出结果可以看出:
将上述代码中的printSkiplist函数删除(LeetCode 不需要调试打印),直接提交到1206. 设计跳表,可以通过所有测试用例,运行时间和空间效率都处于一个不错的水平 —— 这证明我们的实现是正确且高效的。
学习完跳表的实现,我们自然会问:跳表在实际应用中到底该怎么选?它和平衡树、哈希表相比,各自的优劣是什么?
下面从时间复杂度、空间复杂度、有序性、实现难度、适用场景五个维度,对三者进行全方位对比:
特性 | 跳表(Skiplist) | 平衡树(AVL / 红黑树) | 哈希表(Hash Table) |
|---|---|---|---|
平均查找时间 | O(logN) | O(logN) | O(1) |
最坏查找时间 | O (N)(概率极低) | O (logN)(严格平衡) | O (N)(哈希冲突严重) |
平均插入 / 删除时间 | O(logN) | O(logN) | O (1)(无扩容) |
最坏插入 / 删除时间 | O (N)(概率极低) | O (logN)(严格平衡) | O (N)(扩容 + 哈希冲突) |
空间复杂度 | O (N)(平均 1.33 个指针 / 节点) | O (N)(2-3 个指针 + 平衡信息) | O (N)(存储桶 + 指针 + 空闲空间) |
有序性 | 支持(天然有序,可直接遍历) | 支持(中序遍历有序) | 不支持(无序存储) |
实现难度 | 低(链表 + 随机层数,逻辑简洁) | 高(旋转操作 + 平衡维护,复杂) | 中(哈希函数 + 冲突处理) |
扩容 / 重构成本 | 低(无需整体重构,局部调整) | 中(插入删除可能触发旋转) | 高(扩容需重新哈希所有元素) |
极端场景表现 | 稳定(概率保证) | 稳定(严格平衡) | 不稳定(冲突严重时退化) |
我们之前实现的跳表是基础版本,而 Redis 作为工业级数据库,对跳表做了很多优化,使其更适合高并发、高性能的场景。下面简单介绍几个核心优化点,帮助你理解跳表的工业级应用:
Redis 的跳表节点(zskiplistNode)除了存储值(key)、分数(score)和各层指针,还存储了:
Redis 的跳表(zskiplist)除了节点,还维护了全局信息:
当多个节点的分数(score)相同时,Redis 会按 key 的字典序排序,确保有序集合的唯一性和有序性。这需要在插入和查找时,同时比较分数和 key 值。
Redis 使用 jemalloc 分配内存,对跳表节点的内存布局进行了优化,减少内存碎片。同时,跳表节点的层数是动态分配的,避免了固定数组的空间浪费。
跳表的层数由随机函数生成,遵循 “层数越高,概率越低” 的分布。maxLevel 设为 32 是基于 Redis 的应用场景:
核心原因是 “概率平衡”:

,因此平均查找时间复杂度为 O (logN)。
不会。因为高层数的概率极低(比如 32 层的概率是 (0.25)^31,几乎可以忽略),即使出现重复的高层数节点,也只会增加少量的指针比较,不会影响整体的 O (logN) 时间复杂度。而且跳表的查找过程会自动跳过空节点,不会因为个别高层数节点导致性能退化。
Redis 选择跳表而非红黑树,主要原因是:
基础版本的跳表不支持并发操作,因为多个线程同时进行增删查改时,会出现指针竞争问题(比如两个线程同时插入节点,都修改同一个前驱节点的指针)。
Redis 的跳表通过加锁(比如全局锁、分段锁)来支持并发操作,确保线程安全。在实际应用中,如果需要并发访问跳表,需要自己实现锁机制或使用线程安全的跳表实现。
跳表是一种 “用概率换平衡” 的巧妙数据结构,它以简洁的实现、高效的性能和有序性,在平衡树和哈希表之间找到了一个完美的平衡点。 它不仅是一种数据结构,更是一种 “概率思维” 的体现 —— 在无法做到绝对平衡时,通过概率保证平均性能,往往能得到更简洁、更高效的解决方案。掌握这种思维,能让你在面对复杂问题时,拥有更多的解题思路。 希望本文能帮助你彻底吃透跳表,在面试和工作中脱颖而出!如果有任何疑问或建议,欢迎在评论区留言交流~