1.前言
在处理有序数据时,我们经常会遇到这样的问题:
- 查找慢:链表虽然灵活,但查找操作需要从头遍历,时间复杂度为 O(n)
- 插入删除代价高:数组支持随机访问,但在中间插入或删除元素时,需要移动大量元素,效率不高
- 动态扩展困难:静态数组在容量固定的情况下,难以应对数据量增长或频繁变动的场景 为了在有序数据管理中同时兼顾查找效率和插入删除灵活性,计算机科学家提出了多种解决方案,其中平衡二叉搜索树(如 AVL 树、红黑树)是一种经典选择。然而,平衡树在实现上较为复杂,需要在插入和删除时进行旋转操作,代码逻辑相对繁琐 而跳表则提供了一种更简单、灵活且高效的替代方案。它的核心思想是在普通链表的基础上增加多层“索引”,通过多级跳跃来加快查找速度。跳表在平均情况下能够实现O(log n)的查找、插入和删除操作,同时保持动态可扩展性。这种概率平衡的方式,比严格平衡树更易于实现,而且性能稳定
跳表不仅是一个理论上的数据结构,在实际系统中也有广泛应用:
- Redis ZSet:底层使用跳表 + 哈希表来实现有序集合,保证分数排序和快速查找
- 排行榜系统:游戏积分榜、社交排行榜等场景需要快速查询和更新排名
- 延迟队列和任务调度:跳表可以按时间戳排序,便于高效执行到期任务
- 时间序列数据处理:日志、监控数据按时间排序查询
在本篇文章中中,我会从基础概念、原理分析,到静态和动态两种实现方式全面讲解跳表
- 详细讲解跳表的核心原理:多层链表、查找、插入、删除
- 提供动态链表版本的实现和代码讲解
2.跳表的概念和原理
2.1 跳表的定义
跳表(Skip List)是一种动态数据结构,它在有序链表的基础上增加多级索引,使得查找、插入和删除操作的平均时间复杂度可以达到 O(logn)O(\log n)O(logn) 跳表的核心思想是:
- 保持底层是一个有序链表,保证数据的顺序性。
- 在底层之上随机或按规则建立多层索引,每一层跳过若干节点,用于加速查找 相比于平衡二叉搜索树(AVL、红黑树),跳表实现更简单,同时支持动态增删改查
2.2 跳表的结构
假设我们现在有一个有序链表
Level1底层链表
跳表会在上层建立索引层,跳过一些节点,例如Level2:
再往上,如果有Level3:
结合多层表示:
- Level 1 是底层链表,存储所有节点
- 每向上一层,节点数量会减少,查找时可以快速跳过中间节点
- 随机化插入保证了每层节点的分布均匀,平均查找效率 O(logn)O(\log n)O(logn)
2.3 跳表的原理
跳表操作基于逐层搜索 + 层级下降:
- 查找(Search)
- 从最高层的头节点开始向右遍历,直到遇到比目标大的节点或到达当前层尾部
- 向下到下一层继续向右搜索,直到到达底层
- 如果在底层找到 key,则返回节点,否则说明不存在
- 插入(Add)
- 先查找目标位置的前驱节点
- 随机生成新节点的层数
为什么要用随机化
防止分布不均匀
如果每 N 个节点固定生成一层,可能出现节点集中在某些区域,高层索引不均衡。
查询时会遇到“高层空洞”或“密集区”,查找效率下降
实现简单
比 AVL、红黑树等平衡树更容易实现,不需要旋转或复杂平衡操作
保证平均复杂度
- 随机分布保证每层节点数量大约减半,从而平均查找、插入、删除时间复杂度都是 O(logn)O(\log n)O(logn)
- 从底层到新节点的最高层,将新节点插入到每一层对应位置,更新前驱节点的 next 指针
原链表:
插入一个值为55的节点,随机的层数为2:
- 删除(Erase)
- 类似查找,找到要删除节点的前驱节点
- 从底层到节点的最高层,将前驱节点的 next 指针指向被删除节点的下一节点
- 如果最高层为空,则跳表层数减少
3.具体代码实现与讲解
3.1 动态结构实现
下面将会展示一个简单跳表的动态实现(根据leetcode中给出的结构进行示例)
package main
import (
"math/rand"
"time"
)
// 最大层数和随机晋升概率
const (
MaxLevel = 20 // 跳表允许的最大层数
Probability = 0.5 // 每次向上一层晋升的概率
)
// 节点结构体
type Node struct {
key int // 节点值
next []*Node // 每一层的指针
}
// 跳表结构体
type Skiplist struct {
head *Node // 哨兵头节点
level int // 当前跳表最高层
}
// 构造函数
func Constructor() Skiplist {
rand.Seed(time.Now().UnixNano()) // 初始化随机种子
return Skiplist{
head: &Node{
next: make([]*Node, MaxLevel), // 头节点每层指针初始化为 nil
},
level: 1, // 初始只有底层
}
}
// 随机生成节点层数
func (s *Skiplist) randomLevel() int {
level := 1
// 每次向上一层的概率为 Probability
for rand.Float64() < Probability && level < MaxLevel {
level++
}
// 返回节点的层数,保证底层至少为1
return level
}
// 内部查找,返回节点指针
func (s *Skiplist) search(key int) *Node {
cur := s.head
// 从最高层开始逐层向下
for i := s.level - 1; i >= 0; i-- {
// 在当前层水平移动,直到找到下一个节点大于等于 key
for cur.next[i] != nil && cur.next[i].key < key {
cur = cur.next[i]
}
// 如果找到了 key,直接返回节点
if cur.next[i] != nil && cur.next[i].key == key {
return cur.next[i]
}
}
// 没找到返回 nil
return nil
}
// 对外查找接口
func (s *Skiplist) Search(key int) bool {
// 调用内部 search 函数
return s.search(key) != nil
}
// 插入节点
func (s *Skiplist) Add(key int) {
level := s.randomLevel() // 随机生成节点层数
if level > s.level {
s.level = level // 更新跳表最高层
}
// 创建新节点,切片长度为节点层数
newNode := &Node{
key: key,
next: make([]*Node, level),
}
cur := s.head
// 从新节点最高层向下插入
for i := level - 1; i >= 0; i-- {
// 在当前层找到前驱节点
for cur.next[i] != nil && cur.next[i].key < key {
cur = cur.next[i]
}
// 指针调整:新节点指向前驱节点的下一个节点
newNode.next[i] = cur.next[i]
// 前驱节点指向新节点
cur.next[i] = newNode
}
}
// 删除节点
func (s *Skiplist) Erase(key int) bool {
if s.search(key) == nil {
return false // 节点不存在
}
cur := s.head
// 从最高层向下删除
for i := s.level - 1; i >= 0; i-- {
// 找到当前层前驱节点
for cur.next[i] != nil && cur.next[i].key < key {
cur = cur.next[i]
}
// 如果下一个节点是目标节点,删除它
if cur.next[i] != nil && cur.next[i].key == key {
cur.next[i] = cur.next[i].next[i]
}
}
// 调整跳表层数:去掉空的最高层
for s.level > 1 && s.head.next[s.level-1] == nil {
s.level--
}
return true
}
后面会写关于链表的静态数据结构的代码,会展示更多的功能