跳表实现逻辑梳理与面试问题解析
跳表简介
跳表是一种基于链表的数据结构,通过引入多层索引提升查询效率。其核心思想是通过随机层数的节点构建多级索引,类似于平衡树的效果,但实现更简单。时间复杂度平均为 O(log n),空间复杂度为 O(n)。
数据结构定义
- Node 类:每个节点包含分数(score)、成员(member)和指向后继节点的数组(next),数组长度为最大层数 MAX_LEVEL(32)。
- SkipList 类:包含头节点(head)、当前层数(level)、随机数生成器(random)、节点数量(size)等字段。
- 关键参数 :
- MAX_LEVEL = 32:最大层数限制。
- P = 0.25:节点晋升概率,影响索引层数分布。
核心操作逻辑梳理
1. 插入节点(insert 方法)
逻辑步骤
- 查找插入位置 :
- 从最高层(level - 1)开始,向下遍历每一层。
- 在每层中,沿着 next[i] 前进,直到遇到 score 更大或相等但 member 更大的节点。
- 记录每层的"前驱节点"到 update 数组中。
- 检查是否已存在 :
- 检查第 0 层的后继节点(cur.next[0])是否与插入的 member 相等。
- 若存在,则更新其 score 并返回 false(表示更新而非插入)。
- 生成随机层数 :
- 调用 randomLevel(),基于概率 P(0.25)生成新节点的层数(newLevel)。
- 若 newLevel 超过当前 level,则更新 level,并将新层的前驱设为 head。
- 插入新节点 :
- 创建新节点(newNode),将其插入到每一层(0 到 newLevel-1)。
- 对于每层 i,将 newNode.next[i] 指向 update[i].next[i],然后将 update[i].next[i] 指向 newNode。
- size 自增,返回 true。
代码片段
java
int newLevel = randomLevel();
if (newLevel > level) {
for (int i = level; i < newLevel; i++) {
update[i] = head;
}
level = newLevel;
}
Node newNode = new Node(score, member);
for (int i = 0; i < newLevel; i++) {
newNode.next[i] = update[i].next[i];
update[i].next[i] = newNode;
}
2. 删除节点(delete 方法)
逻辑步骤
- 查找删除位置 :
- 从最高层(level - 1)开始,向下遍历每一层。
- 在每层中,沿着 next[i] 前进,直到遇到 member 更大的节点。
- 记录每层的"前驱节点"到 update 数组中。
- 确认删除目标 :
- 检查第 0 层的后继节点(cur.next[0])是否与目标 member 相等。
- 若不匹配,返回 false。
- 删除节点 :
- 从第 0 层到 level-1 层,检查 update[i].next[i] 是否为目标节点 cur。
- 若匹配,将 update[i].next[i] 更新为 cur.next[i],断开与 cur 的连接。
- 若不匹配(因更高层可能无此节点),跳出循环。
- 调整层数 :
- 检查最高层(level-1)是否为空,若为空则 level 自减,直到非空或 level 降至 1。
- size 自减,返回 true。
代码片段
java
if (cur != null && cur.member.equals(member)) {
for (int i = 0; i < level; i++) {
if (update[i].next[i] != cur) {
break;
}
update[i].next[i] = cur.next[i];
}
while (level > 1 && head.next[level - 1] == null) {
level--;
}
size--;
return true;
}
面试问题与解答
Q1: 为什么插入时需要记录 update 数组?
答:update 数组记录了每一层插入位置的前驱节点。在跳表中,节点按 score 和 member 排序,插入新节点时需要更新前驱的 next 指针指向新节点。update 数组保存了这些前驱,便于在随机生成的层数内快速调整指针。如果不记录,需要重新遍历查找前驱,效率降低到 O(n)。
Q2: randomLevel 的概率 P 如何影响性能?
答:P(默认 0.25)决定了节点晋升到更高层的概率。P 越大,层数越高,索引越稀疏,查询效率接近 O(log n),但空间开销增加;P 越小,层数越低,索引稠密,查询退化为 O(n)。P = 0.25 是经验值,平衡了时间和空间复杂度,类似于 Redis 的跳表实现。
Q3: 删除时为什么需要调整 level?
答:删除节点后,某些高层可能变为空(即 head.next[i] == null)。保留空层会浪费空间且无意义,因此需要自顶向下检查并减少 level,直到遇到非空层或 level 降至 1。这确保跳表层数与实际数据分布一致。
Q4: 如何处理并发插入和删除?
答:当前代码未加锁,不支持并发操作。并发环境下可能出现指针混乱(如插入时另一线程删除前驱节点)。解决方法是:
- 加全局锁(如 synchronized),简单但性能差。
- 使用无锁算法(如 CAS),在更新 next 指针时确保原子性,但实现复杂。
- 借鉴 Redis 的 zskiplist,使用读写锁优化并发读写。
Q5: 跳表与平衡树的区别和优劣?
答:
- 区别 :
- 跳表基于链表加随机索引,平衡树(如 AVL、红黑树)基于树结构。
- 跳表层数随机生成,平衡树通过旋转保持平衡。
- 优劣 :
- 跳表实现简单,插入删除只需调整指针;平衡树调整逻辑复杂。
- 跳表查询平均 O(log n),最坏 O(n);平衡树保证 O(log n)。
- 跳表适合范围查询(如 getRangeByScore),平衡树需中序遍历。
总结
跳表的插入和删除逻辑通过多层索引和前驱记录(update 数组)实现高效操作。插入时随机层数动态扩展索引,删除时自适应调整层高。理解这些细节有助于在面试中清晰阐述实现原理,并应对延伸问题。跳表作为一种轻量级数据结构,在 Redis 等场景中广泛应用,值得深入学习。