Skiplist

跳表(Skip List)是一种基于链表的数据结构,它通过添加多层索引来加速搜索操作。

image-20240725233537853

跳表的特点如下:

  1. 跳表中的数据是有序的。
  2. 跳表中的每个节点都包含一个指向下一层和右侧节点的指针。

跳表通过多层索引的方式来加速搜索操作。最底层是一个普通的有序链表,而上面的每一层都是前一层的子集,每个节点在上一层都有一个指针指向它在下一层的对应节点。这样,在搜索时可以通过跳过一些节点,直接进入目标区域,从而减少搜索的时间复杂度。

跳表的平均搜索、插入和删除操作的时间复杂度都为 O(logN),并且与红黑树相比,跳表的实现更加简单。跳表常用于需要高效搜索和插入操作的场景,如数据库、缓存等。

epoll 用了红黑树来保存监听的 socket,redis 用了跳表来实现 zset。

1. 跳表的期望高度是 \(O(log^n)\)

可以只记一个非常直观的证明。

假设跳表每个节点有 1/2 的概率升到上一层

那么平均来说:

\(\text{第1层:} n\)

\(\text{第2层:} \frac n2\)

\(\text{第3层:} \frac n4\)

\(\text{第4层:} \frac n8\)

……

也就是每升一层,节点数大约减半。

那什么时候到顶呢?就是节点数量大约只剩 1 个的时候。

所以设高度为 \(h\),有

\(\frac{n}{2^h}\approx 1\)

于是

\(2^h\approx n\)

两边取 \(\log_2\)

\(h\approx \log_2 n\)

当然上面讨论的是 \(p\)\(\frac{1}{2}\) 的情况,推广到任意 \(p\)

所以跳表的期望高度就是

\(\boxed{O(\log _{\frac{1}{p}} ^n)}\)

严格证明会多一些概率论细节,但理解跳表时,这个直觉通常就够了。

2. 为什么跳表的期望复杂度是 \(O(log^n)\)

要理解跳表增删改查的期望复杂度,本质上就是基于查询的复杂度,可以把查找过程拆解为两个维度来分析:

  • 跳表的期望高度(决定了纵向查找的步数)
  • 每一层的期望查找步数(决定了横向查找的步数)。

我们在之前已经分析了,跳表的期望高度是 \(O(\log _{\frac{1}{p}} ^n)\),那么我们现在只需要证明,每一层的期望查找次数是 \(O(1)\) 级别的。

要计算横向走过的步数,最经典的证明方法是 逆向推导查找路径

想象一下你已经找到了目标节点,现在我们要顺着查找路径 倒退回 跳表的左上角起点。在倒退的过程中,每一步你要么向左退,要么向上退。站在任意一个节点上,它是怎么来的呢?

  • 如果这个节点在创建时没有晋升到更高层(概率为 \(1-p\)),说明正向查找时必定是从左边走过来的,那么倒退时我们就向左走
  • 如果这个节点在创建时成功晋升到了更高层(概率为 \(p\)),说明正向查找时必定是从上面降下来的,那么倒退时我们就向上走

假设每检查一个节点,它有概率 \(p\) 能“晋升 / 出现在上一层”。

我们定义:\(Y=\text{直到第一次遇到晋升节点,一共检查了多少个节点}\)

那么:

  • \(P(Y=1)=p\),表示第 \(1\) 个节点就成功
  • \(P(Y=2)=(1-p)p\),表示第 \(1\) 个失败,第 \(2\) 个成功
  • ...
  • \(P(Y=k)=(1-p)^{k-1}p\)

因此总体的期望为:$E[Y]

\sum_{k=1}^{\infty}
k(1-p)^{k-1}p$

利用公式:$\sum_{k=1}^{\infty}
kx^

\frac{1}{(1-x)^2}$

这个等式在 \(\vert{}x\vert{} < 1\) 的收敛范围内完全成立。

推导核心:逐项求导法

  1. 基础几何级数:当 \(\vert{}x\vert{} < 1\) 时,无穷等比数列求和公式为:

    \[\sum_{k=0}^{\infty} x^k = \frac{1}{1-x} \]

  2. 两端对 \(x\) 求导:在收敛圆盘内,幂级数支持逐项求导:

    \[\frac{d}{dx}\left(\sum_{k=0}^{\infty} x^k\right) = \frac{d}{dx}\left(\frac{1}{1-x}\right) \]

  3. 化简结果

    • 左边 \(k=0\) 的常数项求导为 \(0\),从 \(k=1\) 开始:

      \[\sum_{k=1}^{\infty} k x^{k-1} \]

    • 右边应用链式法则:

      \[\frac{d}{dx}(1-x)^{-1} = -(1-x)^{-2} \cdot (-1) = \frac{1}{(1-x)^2} \]

两端相等,结论得证。

:在算法分析与概率论中,两端同乘 \(x\) 即可得到求解离散几何分布期望值的经典形式:

\[\sum_{k=1}^{\infty} kx^k = \frac{x}{(1-x)^2} \]

令:\(x=1-p\)

得到:$E[Y]

p\cdot
\frac{1}{(1-(1-p))^2}$

也就是:**$E[Y]

p\cdot\frac{1}

\frac{1}{p}$**

所以当:\(p=\frac{1}{2}\)

时:$E[Y]

\frac{1}

2$

也就是说:平均检查 2 个节点,就会遇到一个能够晋升到上一层的节点。

因此横向移动的时间复杂度是常数级别的。

于是:\(总搜索成本=层数(纵向)×每层平均成本(横向)\)

也就是 \(O(log^n)×O(1)=O(log^n)\)

3. 跳表为什么用概率实现多层索引?

跳表(Skip List)采用抛硬币式的概率机制来决定节点的索引层数,核心原因在于用极低的维护成本换取了与平衡树媲美的渐进性能

1. 避免昂贵的“连锁重构”

如果采用严格的确定性规则(例如强制要求每隔 2 个节点建一个上层索引):

  • 插入或删除一个节点,就会破坏原有的“等距”排列。
  • 为了维持结构,必须级联调整后续所有节点的高度,复杂度会退化为 \(O(N)\)
  • 改用概率后: 节点的层数在插入时通过随机数一次性决定,只影响局部指针连接,无需全局调整,增删的时间复杂度稳定维持在期望的 \(O(\log n)\)

2. 实现极佳的并发性能

这是跳表在很多现代存储引擎(如 Redis、LevelDB、RocksDB)中击败红黑树的关键:

  • 平衡树: 插入或删除可能引发旋转、重新着色,往往需要锁住大片子树甚至根节点。
  • 跳表: 依靠概率独立生成层高,修改操作只影响被改节点及其前驱节点的局部指针,使用细粒度锁或 CAS(无锁编程)即可高效支持高并发读写。

3. 用数学期望保证结构平衡

从统计概率上看,若晋升概率设为 \(p = 0.5\)(或 Redis 中的 \(0.25\)):

  • 第 1 层有 \(n\) 个节点,第 2 层期望有 \(n/2\) 个,第 \(k\) 层期望有 \(n/2^{k-1}\) 个。
  • 宏观上看,它在统计学意义上自然形成了一个近似理想的折半查找二叉树,空间开销仅为 \(O(n)\),且基本不会出现极端退化。

4. 跳表和平衡二叉搜索树相比,优缺点是什么?

跳表与平衡二叉搜索树(以红黑树、AVL 树为代表)的时间复杂度均为期望 \(O(\log n)\),二者的核心权衡在于工程落地的灵活性最坏情况的确定性

维度 跳表 (Skip List) 平衡二叉搜索树 (如红黑树)
范围查询 (Range Query) 极佳(定位起点后直接沿底层单链表向后扫) 较弱(需要中序遍历,递归或借助父节点回溯)
高并发读写 极佳(局部更新指针,易于实现细粒度锁或 CAS 无锁) (平衡旋转牵扯多节点,锁粒度大甚至锁全局)
实现与调试复杂度 极低(简单指针修改,代码量通常只有几百行) 极高(AVL 旋转/红黑树变色旋转情况繁多)
空间开销 略高(期望每个节点多消耗若干指针空间,但可通过参数调优) 较低(每个节点固定两个子指针加颜色/高度位)
最坏时间复杂度 \(O(n)\)(极端概率退化,但工程上概率极低) 严格 \(O(\log n)\)(理论上限受严格数学保证)

跳表的优势

  1. 天然支持高效率的范围查找

    底层是完整的有序双向/单向链表。一旦查到区间左端点,顺序向右遍历即可;而平衡树若要按范围取值,必须做跨层级的中序遍历,指针跳跃频繁且缓存命中率低。

  2. 并发控制极为友好

    节点层高由随机数决定,插入/删除只涉及局部相邻节点的指针改动,不需要像红黑树那样做向上传播的“再平衡旋转”,天然适合多线程无锁化(Lock-Free)设计。

  3. 工程实现极其轻量

    红黑树插入删除共有多达十余种变色与旋转分支,极难调试;跳表的核心逻辑只依赖多层链表的扫描与指针修改,不易产生隐蔽 Bug。

跳表的劣势

  1. 依赖概率,缺乏严格的最坏情况保证

    虽然退化为单链表的最坏情况(\(O(n)\))概率趋近于 0,但在对确定性延迟(SLA)要求严苛的实时系统或抗拒绝服务攻击(DoS)场景中,平衡树的严格上界更让人放心。

  2. 内存额外开销稍大

    跳表节点平均持有若干个 forward 指针(\(p=0.5\) 时平均 2 个,加上元数据),且为了实现双向范围扫常需引入 backward 指针;而平衡树通常固定 2 个子指针加若干状态位。

  3. 缓存局部性(Cache Locality)一般

    由于指针稀疏,多层跳转时内存访问并不连续(平衡树同样有此问题,B+ 树在这方面更胜一筹)。

选型准则:

  • 跳表:需要频繁范围查询高并发读写、或追求快速自研与维护(典型如 Redis 的 Sorted Set、LevelDB/RocksDB 的 MemTable)。
  • 平衡树:需要严格稳定的最坏执行时间、以单点精准查找为主、且运行在单线程或粗粒度锁环境下(典型如 Linux 内核的 rbtree、Java 的 TreeMap)。

5. 跳表如果随机层数分布不好怎么办?

跳表(Skip List)的检索效率高度依赖几何分布的随机层数。如果随机数生成器较差或遭遇极端最坏情况,层数分布会严重失衡,主要表现为:层数普遍过低(退化为普通单链表,\(O(N)\) 复杂度)高层节点扎堆/冗余(跳跃失效且占用大量额外指针内存)

解决和缓解这一问题,工程与学术界通常采用以下 4 个核心策略:

  • 使用高质量的轻量伪随机算法(根治源头)

    绝大多数业务级跳表退化,本质是因为直接用了低劣的伪随机生成器(如简单的模运算或低阶 LCG)。工业实现(如 Redis 的 zskiplist)会选用周期极长、分布均匀且极快的算法(如 xorshiftPCG),保证随机层数严格服从理论上的几何分布(\(p = 1/2\)\(1/4\))。

  • 硬性约束与工程参数调优(兜底保障)

    • 硬编码最大层数(Max Level): 根据预估数据量 \(N\) 严格限制最高层(如 \(32\) 层足以支持 \(2^{32}\) 个元素),防止因随机数故障产生无意义的超高层指针。
    • 调整晋升概率 \(p\) 实践中常用 \(p = 0.25\)(如 Redis)替代理论上的 \(p = 0.5\)。这样高层节点更稀疏,每个节点平均仅占用约 1.33 个指针,既压低了内存,又降低了连续高层节点扎堆的概率。
  • 确定性跳表(Deterministic Skip List / 1-2 Skip List)

    如果不允许任何退化概率(如硬实时系统),可放弃随机化,改用确定性跳表。它通过类似 2-3 树或红黑树的约束(例如强制规定“同层两个高层索引之间必须夹着 1 到 2 个低层节点”),在插入和删除时通过局部的分裂/合并维护层高,保证最坏时间复杂度恒为严格的 \(O(\log N)\)。不过这也牺牲了跳表原生的无锁/并发优势。

  • 周期性重构或转用平衡树(架构选型权衡)

    如果纯粹是为了在内存中维持极致稳定的查找性能且不能接受长尾抖动:

    • 后台重构: 在只读较多的批量场景下,可以定期按固定步长重构所有索引层。
    • 替换为平衡树: 若需要绝对稳定的最坏复杂度,直接选用红黑树、AVL 树或 B+ 树更为彻底;跳表的核心卖点本就在于“用概率换取简单实现与高并发友好性”。

6. 跳表中每个节点含有的层级指针数量?

假设跳表的晋升概率为 \(p\)。我们要明确一点:每一个插入跳表的节点,都至少拥有 1 层(即最底层的单链表或双向链表),它包含 1 个指针。

然后,程序会通过随机数生成器决定它是否能“晋升”到更高的层级:

  • 节点只有 1 层(没有成功晋升)的概率是:\(1-p\)
  • 节点恰好有 2 层(成功晋升 1 次,第 2 次失败)的概率是:\(p(1-p)\)
  • 节点恰好有 3 层(成功晋升 2 次,第 3 次失败)的概率是:\(p^2(1-p)\)
  • 以此类推,节点恰好拥有 \(k\)的概率为:\(p^{k-1}(1-p)\)

根据概率论中计算期望的定义,我们将“层数 \(k\)”乘以“对应的概率”,然后把所有可能的结果全部累加起来。

设期望指针数量为 \(E\),公式如下:

\[E = \sum_{k=1}^{\infty} k \cdot p^{k-1}(1-p)= (1-p) \sum_{k=1}^{\infty} k \cdot p^{k-1} \]

对于后面的求和部分 \(\sum_{k=1}^{\infty} k \cdot p^{k-1}\),这实际上是等比数列求和公式的一种衍生。我们知道无穷等比数列求和公式为:

\[\sum_{k=0}^{\infty} p^k = \frac{1}{1-p} \quad (\text{当 } |p| < 1 \text{ 时}) \]

对上面这个等式两边同时求导,就可以得到我们需要的形式:

\[\sum_{k=1}^{\infty} k \cdot p^{k-1} = \frac{1}{(1-p)^2} \]

这个无穷级数的计算结果是:

\[\sum_{k=1}^{\infty} k \cdot p^{k-1} = \frac{1}{(1-p)^2} \quad (|p| < 1) \]

下面是两种常用的推导方法:


方法一:利用几何级数求导(最常用)

我们从标准的几何级数公式出发:

\[\sum_{k=0}^{\infty} p^k = \frac{1}{1-p}, \quad |p| < 1 \]

对两边关于 \(p\) 求导:

\[\frac{d}{dp} \left( \sum_{k=0}^{\infty} p^k \right) = \frac{d}{dp} \left( \frac{1}{1-p} \right) \]

左边逐项求导(在收敛域内允许):

\[\sum_{k=1}^{\infty} k \cdot p^{k-1} \]

右边求导:

\[\frac{d}{dp} \left( \frac{1}{1-p} \right) = \frac{1}{(1-p)^2} \]

因此:

\[\sum_{k=1}^{\infty} k \cdot p^{k-1} = \frac{1}{(1-p)^2} \]


方法二:错位相减法(代数法)

设:

\[S = \sum_{k=1}^{\infty} k \cdot p^{k-1} = 1 + 2p + 3p^2 + 4p^3 + \cdots \]

两边乘以 \(p\)

\[pS = p + 2p^2 + 3p^3 + 4p^4 + \cdots \]

\(S - pS\)

\[S - pS = 1 + (2p - p) + (3p^2 - 2p^2) + (4p^3 - 3p^3) + \cdots \]

\[S(1 - p) = 1 + p + p^2 + p^3 + \cdots = \sum_{k=0}^{\infty} p^k = \frac{1}{1-p} \]

所以:

\[S = \frac{1}{(1-p)(1-p)} = \frac{1}{(1-p)^2} \]


⚠️ 收敛条件

该级数仅在 \(|p| < 1\) 时收敛。当 \(|p| \geq 1\) 时,通项 \(k p^{k-1}\) 不趋于零,级数发散。


💡 应用背景

这个级数在概率论中非常常见。例如,若随机变量 \(X \sim \text{Geometric}(p)\)(表示首次成功所需的试验次数,支持集为 \(\{1,2,3,\dots\}\)),其期望为:

\[\mathbb{E}[X] = \sum_{k=1}^{\infty} k \cdot p(1-p)^{k-1} = \frac{1}{p} \]

注意这里的形式略有不同(含额外的 \(p\) 因子),但核心级数正是我们讨论的 \(\sum k q^{k-1}\)(其中 \(q = 1-p\))。

如有其他变体或具体应用场景,欢迎继续提问!

将这个结果代回期望值 \(E\) 的公式中:

\[E = (1-p) \cdot \frac{1}{(1-p)^2} = \frac{1}{1-p} \]

这就是为什么我在前面提到,平均指针数量的计算公式是 \(\frac{1}{1-p}\)

  • \(p = 1/2\) 时:$$E = \frac{1}{1 - 1/2} = \frac{1}{1/2} = 2$$

    每个节点平均拥有 2 个指针。

  • \(p = 1/4\) 时:\(E = \frac{1}{1 - 1/4} = \frac{1}{3/4} = \frac{4}{3} \approx 1.33\)

    每个节点平均拥有约 1.33 个指针。

7. 为什么概率一般取 1/2 或 1/4?

在跳表的实现中,将晋升概率 \(p\) 设为 1/2 或 1/4,本质上是一个经典的空间与时间的权衡 (Space-Time Tradeoff) 问题。

我们可以通过两个关键的数学期望指标,来衡量概率 \(p\) 带来的影响:

  • 空间开销(内存):每个节点平均含有的层级指针数量,计算公式为 \(\frac{1}{1-p}\)
  • 时间开销(速度):在每一层平均需要检查/访问的节点数量(事实上的搜索成本),计算公式为 \(\frac{1}{p}\)

以下是这两种常见选择的具体权衡:

概率设为 1/2 (时间优先)

  • 查找速度极快:每层平均只需横向遍历 2 个节点。
  • 内存占用较大:每个节点平均包含 2 个指针。如果数据量巨大,多出的指针会占用显著的额外内存。
  • 实现极其简单:在代码层面,只需要生成一个随机整数,然后按位与(Bitwise AND)取最低位的值。如果是 1 就晋升,是 0 就停止,非常高效。
  • 适用场景:内存空间充足,且追求极致查找性能的场景。

概率设为 1/4 (空间优先,工业界首选)

  • 内存占用低:每个节点平均只含有约 1.33 个指针。相比 1/2 的概率,直接节省了约 33% 的动态指针内存空间。
  • 查找速度依然优秀:每层平均需要横向遍历 4 个节点。虽然比 1/2 多走两步,但由于遍历时 CPU 缓存(Cache Line)的预取效应,这点性能损耗在现实物理机上微乎其微。
  • 适用场景这是实际工程中最常用的“黄金比例”。著名的 Redis 有序集合(ZSet)在实现跳表时,就明确选择了 1/4 作为晋升概率

为什么不取其他极端值?

  • 概率太大(例如 3/4):大量节点会晋升到高层,跳表变得非常“臃肿”。不仅浪费大量内存,查找时还需要在众多密集的层级间频繁上下穿梭,多层跳跃的优势被抵消。
  • 概率太小(例如 1/16):极其节省内存,但每层平均要横向“缓慢”遍历 16 个节点。跳表的层数大大减少,失去了快速跨越长距离的能力,查找时间开始向 \(O(n)\) 退化。

总结来说,1/2 和 1/4 是经过严格的数学期望计算和长期的系统工程实践后,沉淀下来的最佳平衡点。

posted @ 2026-09-08 17:19  光風霽月  阅读(4)  评论(0)    收藏  举报