当前位置: 首页 > news >正文

数据结构之跳表

跳表(Skip List)是一种基于概率平衡的数据结构,通过多层有序链表实现高效的查找、插入和删除操作。它在最坏情况下时间复杂度为 (O(n)),但通过随机化设计,平均时间复杂度可优化至 (O(\log n)),与平衡二叉搜索树性能相当,但实现更为简单。

1. 什么是跳表?

想象一下,你有一本非常厚的电话簿,里面的名字是按字母顺序排列的。现在你想找 “Zhang San” 这个名字。

  • 方法一(链表/线性查找):从第一页的第一个名字 “A” 开始,一页一页地翻,直到找到 “Z”。这非常慢。
  • 方法二(跳表):聪明的电话簿出版商在书的侧面贴上了一些“索引标签”。比如:
    • 第一个标签指向 “B” 开头的部分。
    • 第二个标签指向 “G” 开头的部分。
    • 第三个标签指向 “M” 开头的部分。
    • 第四个标签指向 “S” 开头的部分。
    • 第五个标签指向 “Z” 开头的部分。

现在你找 “Zhang San”:

  1. 你先看侧面的标签,发现 “Zhang” 的首字母 “Z” 在 “S” 和 “Z” 之间。于是你直接翻到 “S” 标签指向的页面。
  2. 从 “S” 开始,你继续向后翻,因为 “Z” 在 “S” 之后。你可能还会看到一些更细分的标签,比如 “T”, “W”, “X” 等,帮助你更快地定位。
  3. 很快,你就找到了 “Z” 开头的部分,然后在这个小范围内顺序查找,最终找到 “Zhang San”。

跳表就是这种“带有多级索引的有序链表”。它通过在原始有序链表之上建立多级“索引”层,来大幅提升查找效率,使得查找、插入、删除操作都能在接近对数的时间复杂度内完成。


2. 为什么需要跳表?

跳表是为了解决有序链表的痛点而诞生的。

  • 有序链表的优点
    • 插入和删除操作非常快,只需要修改相邻节点的指针,时间复杂度为 O(1)在找到位置后。
  • 有序链表的致命缺点
    • 查找效率极低。由于不能像数组那样通过下标随机访问,查找一个元素必须从头节点开始逐个遍历,时间复杂度为 O(n)。当数据量很大时,这个性能是无法接受的。

我们希望有一种数据结构,既能保持链表高效的插入/删除特性,又能拥有像平衡二叉搜索树那样 O(log n) 级别的查找效率。

跳表就是答案之一。它通过增加空间(建立索引)来换取时间,完美地平衡了查找、插入、删除的性能。


3. 跳表的结构与原理

一个跳表由以下几部分构成:

  1. 基础层:最底层是一个标准的有序链表,包含了所有的元素。
  2. 索引层:在基础层之上,有多层稀疏的“索引”链表。
    • 每一层都是一个有序链表。
    • 第 L+1 层的元素是第 L 层元素的一个子集
    • 上层的每个节点,都有一个指针指向其在下层中相同的节点。
  3. 头节点:所有层的链表都共享一个头节点,方便从最高层开始查找。
  4. 概率性:一个节点出现在多少层索引中,是通过**“抛硬币”**这样的随机算法决定的。这保证了跳表的平衡性,避免了像平衡树那样复杂的旋转操作。

结构示意图:

假设我们有基础链表 1 <-> 3 <-> 4 <-> 5 <-> 7 <-> 8 <-> 10

一个可能的跳表结构如下:

Level 3:  ------------------------> 8 ------------------------> NULL|                        |
Level 2:  ----> 3 ----------------> 8 ------------------------> NULL|     |                  |
Level 1:  ----> 3 ------> 5 ------> 8 ------> 10 -------------> NULL|     |        |        |        |
Level 0:  -> 1 <-> 3 <-> 4 <-> 5 <-> 7 <-> 8 <-> 9 <-> 10 -> NULL

解读:

  • Level 0 是基础层,包含所有元素。
  • Level 1 包含了 {3, 5, 8, 10},它们是 Level 0 的一个子集。
  • Level 2 包含了 {3, 8},是 Level 1 的一个子集。
  • Level 3 只包含了 {8}
  • 每个上层的节点都通过一个向下的指针连接到下层对应的节点。

4. 操作详解

a. 查找

查找是跳表最核心的操作,插入和删除都依赖于它。查找过程遵循“先高层后低层,先大步后小步”的原则。

目标:查找元素 5

  1. 从最高层开始:从头节点 Head 的 Level 3 开始。
  2. Level 3 查找
    • 当前节点是 Head,下一个节点是 8
    • 5 < 8,说明 5 在 Head 和 8 之间。不能在 Level 3 继续向右走了。
    • 向下:移动到 Level 2 的 Head 节点。
  3. Level 2 查找
    • 当前节点是 Head,下一个节点是 3
    • 5 > 3,可以继续向右走。移动到节点 3
    • 在节点 3,它的下一个节点是 8
    • 5 < 8,说明 5 在 3 和 8 之间。不能在 Level 2 继续向右走了。
    • 向下:移动到 Level 1 的节点 3
  4. Level 1 查找
    • 当前节点是 3,下一个节点是 5
    • 5 == 5找到目标!

如果查找 6

  1. … (过程同上,直到 Level 1 的节点 5)
  2. 在 Level 1 的节点 5,下一个节点是 8
  3. 6 < 8,不能向右。
  4. 向下:移动到 Level 0 的节点 5
  5. Level 0 查找
    • 当前节点是 5,下一个节点是 7
    • 6 < 7,且 6 != 5,说明 6 不存在。

查找路径总结Head(L3) -> Head(L2) -> 3(L2) -> 3(L1) -> 5(L1)

b. 插入

插入分为两步:查找位置 和 随机建层

目标:插入元素 6

  1. 查找插入位置

    • 按照查找 6 的方法,找到它在 Level 0 中的前驱节点。从上面的查找过程可知,6 应该插入在 5 和 7 之间。我们需要记录下每一层中 6 的前驱节点,这里主要是 Level 0 的 5
    • 在实际操作中,我们会在查找过程中维护一个 update 数组,记录每一层中待插入位置的前驱节点。
  2. 随机决定层数

    • 这是跳表的精髓。我们通过一个随机函数(比如,模拟抛硬币)来决定新节点 6 要“晋升”到多少层。
    • 算法:初始化层数 level = 1。然后循环,每次有 p (通常 p=1/2 或 1/4) 的概率 level++,直到失败为止。level 不能超过跳表当前的最大层数。
    • 假设我们“抛硬币”的结果是:第一次成功(level=2),第二次成功(level=3),第三次失败。那么新节点 6 的层数就是 3
  3. 插入节点并更新指针

    • 如果新节点的层数 3 大于当前跳表的最大层数(比如是 2),则需要增加一个新的 Level 3 层。
    • 从 level=3 开始,逐层向下插入新节点:
      • Level 3:将 6 插入到 update[3] (即 Head) 之后。Head -> 6
      • Level 2:将 6 插入到 update[2] (即 3) 之后。3 -> 6
      • Level 1:将 6 插入到 update[1] (即 5) 之后。5 -> 6
      • Level 0:将 6 插入到 update[0] (即 5) 之后。5 -> 6 -> 7
    • 同时,要确保新节点 6 在每一层的实例都通过 down 指针连接起来。
c. 删除

删除操作比插入简单,因为它不需要随机建层。

目标:删除元素 5

  1. 查找待删除节点

    • 首先查找 5。在查找过程中,同样需要记录每一层中 5 的前驱节点(update 数组)。
    • 如果找不到 5,则删除失败。
  2. 逐层删除

    • 从 5 存在的最高层开始(比如 Level 1),逐层向下删除。
    • 在每一层,修改前驱节点(update[i])的 next 指针,使其指向 5 的后继节点。
    • Level 1update[1] 是 3,将 3->next 从指向 5 改为指向 8
    • Level 0update[0] 是 4,将 4->next 从指向 5 改为指向 6
    • 这样,5 在所有层中的引用都被移除了,垃圾回收器会自动回收内存。
  3. (可选)清理空层:如果删除某个节点后,最高层只剩下一个头节点,可以考虑移除这一层以节省空间。


5. 性能分析

  • 时间复杂度

    • 查找、插入、删除:平均时间复杂度均为 O(log n)
    • 最坏情况:如果运气极差,所有节点都在同一层,那么跳表就退化成了普通链表,时间复杂度为 O(n)。但由于层数是随机决定的,这种情况的概率极低,可以忽略不计。
  • 空间复杂度

    • 平均空间复杂度为 O(n)
    • 每个节点被包含在第 i 层索引中的概率是 p^(i-1)。一个节点平均包含的指针数是 1/(1-p)
    • 当 p = 1/2 时,每个节点平均有 1 + 1/2 + 1/4 + ... = 2 个指针(一个 next,一个 down)。所以总空间开销大约是基础链表的 2 倍,即 O(2n) = O(n)

6. 跳表的优缺点

优点:

  1. 性能均衡:查找、插入、删除的性能都非常优秀,都是 O(log n)。
  2. 实现简单:相比于红黑树、AVL树等平衡二叉搜索树,跳表的实现逻辑要简单得多,没有复杂的旋转和颜色变换操作。
  3. 天然有序:底层是有序链表,非常适合需要范围查询的场景(如 “查找所有 score 在 100 到 200 之间的元素”)。
  4. 并发友好:由于跳表的修改操作(插入、删除)通常只影响局部节点,不像平衡树那样可能引起全局性的结构调整,因此更容易实现高效的并发控制。Redis 的作者就曾评价,跳表在并发环境下比平衡树更有优势。

缺点:

  1. 空间开销:需要额外的空间来存储多层索引,空间复杂度高于普通链表和平衡树(虽然都是 O(n),但常数因子更大)。
  2. 非最坏情况保证:性能是概率性的,虽然最坏情况概率极低,但不像平衡树那样能提供硬性的性能保证。

7. 实际应用

跳表最著名的应用是在 Redis 中。

  • Redis 的有序集合:Redis 的 ZSET (Sorted Set) 数据结构,在元素数量较少时使用 ziplist(压缩列表)来节省内存,当元素数量超过阈值(zset-max-ziplist-entries)时,会转换为 跳表 + 哈希表 的实现。

    • 跳表:负责按 score 排序,支持高效的范围查找、按 rank 查找等操作。
    • 哈希表:负责建立 member 到 score 的映射,支持 O(1) 复杂度的按 member 查找 score
    • 这种组合完美地发挥了两种数据结构的优势。
  • LevelDB / RocksDB:这些著名的键值存储引擎在其内部内存表(MemTable)的实现中也使用了跳表。


8. 与其他数据结构的对比

特性跳表平衡二叉搜索树 (如红黑树)哈希表
查找O(log n) 平均O(log n) 最坏O(1) 平均
插入O(log n) 平均O(log n) 最坏O(1) 平均
删除O(log n) 平均O(log n) 最坏O(1) 平均
有序性天然有序,范围查询高效天然有序,范围查询高效无序,不支持范围查询
实现复杂度相对简单复杂(旋转/变色)简单
空间开销较高 (约 2n)较低 (n)可能有额外开销(解决冲突)
并发友好性(局部修改)较低(结构调整可能影响大)需要处理哈希冲突和扩容

总结:

跳表是一种非常精巧的数据结构,它用一种简单而优雅的方式,在有序链表的基础上通过“空间换时间”和“随机化”思想,实现了与平衡树相媲美的对数级性能。它实现简单、性能均衡、并发友好,特别适合作为内存数据库索引的底层实现,是程序员工具箱中一个非常有价值的工具。


文章转载自:

http://56Yjt8dw.skmzm.cn
http://Rysuwyuf.skmzm.cn
http://ytIO2eaL.skmzm.cn
http://METMcPIG.skmzm.cn
http://1tkX85Ix.skmzm.cn
http://ccyP3cjj.skmzm.cn
http://SQVODSVR.skmzm.cn
http://WdHtoNsY.skmzm.cn
http://gisoMRpK.skmzm.cn
http://zYnzrQNF.skmzm.cn
http://AQZPZhbE.skmzm.cn
http://QVHefq0P.skmzm.cn
http://HhXYOeCS.skmzm.cn
http://MivJR3QU.skmzm.cn
http://Q49V7WSw.skmzm.cn
http://b3Dp2gaO.skmzm.cn
http://r2dtF1P0.skmzm.cn
http://hoUgHMQt.skmzm.cn
http://8wOvm2Kk.skmzm.cn
http://Wqh67Uof.skmzm.cn
http://w0djF4po.skmzm.cn
http://3HiqqPDn.skmzm.cn
http://tWHzeS0b.skmzm.cn
http://prbpwwWg.skmzm.cn
http://6jKaPNCk.skmzm.cn
http://96N6ZIg7.skmzm.cn
http://2zR1iR9b.skmzm.cn
http://oCXxiDOy.skmzm.cn
http://Yc58iT0s.skmzm.cn
http://1G9yF4py.skmzm.cn
http://www.dtcms.com/a/378159.html

相关文章:

  • 记录豆包的系统提示词
  • Docker 从入门到实践:容器化技术核心指南
  • 【Python-Day 43】告别依赖混乱:Python虚拟环境venv入门与实战
  • CF702E Analysis of Pathes in Functional Graph 题解
  • 元宇宙与智慧城市:数字孪生赋能的城市治理新范式
  • es通过分片迁移迁移解决磁盘不均匀问题
  • 深入浅出CRC校验:从数学原理到单周期硬件实现 (2)CRC数学多项式基础
  • 无人设备遥控器之控制指令发送技术篇
  • LinuxC++项目开发日志——高并发内存池(4-central cache框架开发)
  • 解决蓝牙耳机连win11电脑画质依托答辩问题
  • 农业养殖为何离不开温湿度传感器?
  • Android开发 AlarmManager set() 方法与WiFi忘记连接问题分析
  • CKA02-Ingress
  • JavaEE 初阶第二十一期:网络原理,底层框架的“通关密码”(一)
  • TOL-API 基于Token验证文件传输API安全工具
  • 构建一个优雅的待办事项应用:现代JavaScript实践
  • 计算机视觉进阶教学之图像投影(透视)变换
  • 计算机视觉与深度学习 | 基于MATLAB的AI图片识别系统研究
  • 计算机视觉----图像投影(透视)变换(小案例)
  • Docker 学习笔记(七):Docker Swarm 服务管理与 Containerd 实践
  • 3-10〔OSCP ◈ 研记〕❘ WEB应用攻击▸XSS攻击理论基础
  • 微信小程序开发笔记(01_小程序基础与配置文件)
  • ArcGIS JSAPI 高级教程 - ArcGIS Maps SDK for JavaScript - 自定义(GLSL)修改高亮图层样式
  • idea npm install 很慢(nodejs)
  • Elasticsearch 创建索引别名的正确姿势
  • Kite Compositor for Mac v2.1.2 安装教程|DMG文件安装步骤(Mac用户必看)
  • 深入探索 Unity 错误排查过程:从“滚动条问题”到“鼠标悬浮异常”
  • 【设计模式】从游戏角度开始了解设计模式 --- 抽象工厂模式
  • 南京大学实现非线性光学新范式丨《Light》报道光电可调谐液晶二次谐波衍射研究
  • 【Java】Hibernate-5:Hibernate的一级缓存