大家好,这是大厂面试拆解--数据结构与算法系列的第2篇文章
如果你面试 或者工作中遇到相关问题,欢迎留言!
阅读本文 你获得如下收益
大纲如下:
面试官:什么是跳表(Skip List)?
小义:我开始认真思考这个问题:
老王:打住,忘记很正常
老王: 现在就是最好的时候,不是课下回家 在学习 。
画外音:
小义:
画外音:
-----> 我就会问 跳表插入,删除,查询步骤是什么?
-----> 我就问:跳表是怎么查询的,时间 复杂度多少?
画外音:
-----> 我就问:什么情况下 redis为什么要使用skiplist跳表,不用 红黑树,hash表
画外音:
因此,一个 4 层的 B+ 树在上述假设下,最多可以存储约 220 亿条记录
4 层的 B+ 存储220 亿条 占用256GB空间
4KB(第 1 层) + 1.6MB(第 2 层) + 640MB(第 3 层) + 256GB(第 4 层) ≈ 256.6GB
画外音:
-----> 为什么MySQL用B+树而不用B树呢,跳表呢
-----> 如何使用B+树对海量磁盘数据建立索引
-----> 为什么日志系统主要用LSM(Log Structured Merge Trees)树而非B+树?
划重点:
特性 | 🪜 跳表 (Skip List) | 🌳 红黑树 (Red-Black Tree) | 🧩 哈希表 (Hash Table) |
---|---|---|---|
查找 - 平均 | O(logN) | O(logN) | O(1)(理想) |
查找 - 最坏 | O(logN) | O(logN) | ❌ O(n)(哈希冲突严重时) |
插入/删除 - 平均 | O(logN) | O(logN) | O(1) |
插入/删除 - 最坏 | O(logN) | O(logN) | ❌ O(n) |
是否有序 | ✅ 支持有序遍历 | ✅ 天生有序 | ❌ 无序 |
结构复杂度 | 中(链表+概率) | 高(旋转+颜色维护) | 中 |
实现难度 | 简单~中 | 高 | 中 |
空间开销 | 高(需多层索引) | 中 | 中(需哈希表+链) |
并发友好性 | ✅ 好(可分段加锁) | ❌ 差(全局旋转难并发) | 一般(需无锁/加锁) |
范围查找(如区间) | ✅ 支持 | ✅ 支持 | ❌ 不支持(只能找某个 key) |
典型使用场景 | Redis SortedSet | C++ STL map/set | C++ unordered_map 等 |
特性 | 🔵 AVL树 | 🔴 红黑树 |
---|---|---|
查询效率 | ✅ 更快(更平衡) | 稍慢(不如AVL平衡) |
插入操作 | ❌ 慢一些,需要较多旋转(最多 O(logN) 次) | ✅ 快一些,旋转较少(最多 2 次) |
删除操作 | ❌ 最慢,重平衡代价高(最多 O(logN) 次旋转) | ✅ 快,最多 3 次旋转 + 染色 |
平衡条件 | 严格:任意节点左右子树高度差 ≤ 1 | 宽松:满足红黑规则即可 |
旋转次数 | 插入/删除时可能频繁,最多 O(logN) 次 | 插入最多 2 次,删除最多 3 次 |
使用场景 | 查询为主的场景,如数据库索引 | 插入/删除频繁,如 STL map/set、OS调度等 |
下面是我的理解:
定义:
维度 | 跳表 | 平衡树 |
---|---|---|
实现复杂度 | 无需旋转/再平衡操作 | 需要复杂旋转和平衡维护逻辑 |
并发性能 | 天然支持高效并发操作 | 并发实现复杂 |
空间开销 | 平均1.33指针/元素 | 通常需要2-3指针/元素 |
查询性能稳定性 | 概率保证O(log n) | 严格保证O(log n) |
持久化结构 | 更易实现无锁版本 | 无锁实现难度大 |
数据结构:
https://github.com/redis/redis/blob/unstable/src/server.h
/* ZSETs use a specialized version of Skiplists */
typedefstruct zskiplistNode {
sds ele;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 记录当前节点到下一个节点的距离,用于 `ZRANK` 等排名操作。
unsignedlong span;
} level[]; //柔性数组,表示节点的层级(长度在运行时动态决定),默认 `32`
} zskiplistNode;
typedefstruct zskiplist {
// 头节点,尾节点
//是一个虚拟头节点,层级固定为 `ZSKIPLIST_MAXLEVEL`(默认 32)
struct zskiplistNode *header, *tail;
// 节点数量
unsignedlong length;
// 目前表内节点的最大层数
int level;
} zskiplist;
小提示:
zskiplist *zslCreate(void) {
int j;
zskiplist *zsl;
zsl = zmalloc(sizeof(*zsl)); // 分配跳表内存
zsl->level = 1; // 初始层级为1
zsl->length = 0; // 没有任何一个元素
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL, 0, NULL); // 创建头节点
//头节点 header 是一个虚拟节点,层级固定为 `ZSKIPLIST_MAXLEVEL`(32)。
// 初始化头节点的每一层 forward 和 span
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
zsl->header->level[j].forward = NULL;
zsl->header->level[j].span = 0;
}
zsl->header->backward = NULL; // 头节点的后退指针为 NULL
zsl->tail = NULL; // 尾节点初始为 NULL
return zsl;
}
图2-插入操作
/*
* 向跳表 zsl 中插入一个新节点,键为 ele (SDS字符串),值为 score (排序依据)
* 调用者需确保 ele 在跳表中不存在(通常由上层哈希表检查)
* 跳表会接管 ele 的内存所有权
* 返回值:新插入的节点指针
*/
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x; // update[] 记录每层的前驱节点
unsignedlong rank[ZSKIPLIST_MAXLEVEL]; // rank[] 记录前驱节点的累积跨度
int i, level;
//zskiplistNode
x = zsl->header; // 从头节点开始遍历
/* 1. 查找插入位置:从最高层向下逐层搜索 */
for (i = zsl->level-1; i >= 0; i--) {
// 初始化当前层的 rank(高层 rank 继承自低层)
rank[i] = (i == zsl->level-1) ? 0 : rank[i+1];
// 向右查找,直到找到第一个 score 大于等于目标或到达链表末尾
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; // 记录当前层的前驱节点
}
/* 2. 生成新节点的随机层高(幂次定律) */
level = zslRandomLevel();
if (level > zsl->level) { // 若新层高超过当前跳表层高,需扩展层级
for (i = zsl->level; i < level; i++) {
rank[i] = 0; // 新层级的初始 rank 为 0
update[i] = zsl->header; // 新层级的前驱节点初始化为头节点
update[i]->level[i].span = zsl->length; // 新层级的初始跨度为跳表长度
}
zsl->level = level; // 更新跳表的最大层高
}
/* 3. 创建新节点并分层插入 */
x = zslCreateNode(level, score, ele); // 分配节点内存,复制 ele
for (i = 0; i < level; i++) {
x->level[i].forward = update[i]->level[i].forward; // 新节点指向原前驱的后继
update[i]->level[i].forward = x; // 前驱节点指向新节点
// 更新跨度:新节点跨度 = 原前驱跨度 - (rank[0] - rank[i])
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1; // 前驱节点跨度更新
}
/* 4. 处理未触及的层级:仅增加前驱节点的跨度 */
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
/* 5. 设置新节点的后退指针和尾节点 */
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; // 返回新节点
}
图3-高度控制
int zslRandomLevel(void) {
int level = 1;
while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) // ZSKIPLIST_P = 0.25
level++;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
p=0.25
(ZSKIPLIST_P
),即:level=1
概率:75%level=2
概率:18.75%level=3
概率:4.6875%ZSKIPLIST_MAXLEVEL=32
红黑树为什么综合性能好?
其中2-节点等价于普通平衡二叉树的节点,3-节点本质上是非平衡性的缓存。
继续追根溯源,红黑树的性能优势,本质上是用空间换时间。
演示:https://algo.hufeifei.cn/RedBlack.html
图6-为什么
size
字段,表示子树个数。Balancing a data structure probabilistically is easier than explicitly maintaining the balance 用概率来平衡数据结构比明确地维持平衡更容易
Skip lists are balanced by consulting a random number generator. Although skip lists have bad worst-case performance
随机数生成器来平衡
no input sequence consistently produces the worst-case performance (much like quicksort when the pivot element is chosen randomly).
Because these data structures are linked lists with extra pointers that skip over intermediate nodes, I named them skip lists 由于这些数据结构是带有额外指针的链表,这些指针可以跳过中间节点,因此我将它们命名为“跳过列表”
top k查询 对应原文:
★Using skip lists, it is easy to do most (all?) the sorts of operations you might wish to do with a balanced tree such as use search fingers, merge skip lists and allow ranking operations (e.g., determine the kth element of a skip list)
划重点:
size
字段。在插入、删除时维护 size
字段即可 15(size=7)
/ \
6(size=3) 18(size=3)
/ \ /
3(1) 7(1) 17(1)
查询第3小的元素(`i=3`):
画外音:
图8:n-1层加载到内存
遗留任务