【C++高阶数据结构系列】:跳表 SkipList 详解:多层索引、随机晋升与C++ 完整实现(附跳表实现的源码)

🔥 本文专栏:C++高阶数据结构

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录真正属于你的机会,不一定来得早,但它一定更偏爱那个一直在准备的人。


思维导图

引入

在此前的学习中,我们已经接触过多种用于维护有序数据的数据结构,比如红黑树、AVL 树以及 B+ 树等。

其中,红黑树和 AVL 树本质上都属于二叉搜索树。它们在满足"左子树节点小于根节点,右子树节点大于根节点"这一基本性质的基础上,又引入了额外的平衡约束,从而尽可能压缩树的高度,使查找、插入和删除操作能够维持在 O(logN) 级别。

而 B+ 树则更多应用在外存查找场景中。比如在 MySQL 的 InnoDB 存储引擎中,索引和数据就是通过 B+ 树来组织的。相比于普通的二叉搜索树,B+ 树是一种多路平衡搜索树,一个节点中可以存放多个 key,因此能够有效降低树的高度,减少磁盘 IO 次数。同时,B+ 树的叶子节点之间还会通过链表连接起来,因此也非常适合范围查询场景。

不过,本文要介绍的并不是上述这些树形结构,而是另一种同样可以用于维护有序集合的数据结构------跳表。

接下来,我们就正式进入跳表的学习。

跳表的结构与查询原理:多层索引如何实现高效查找

跳表的核心思想:在有序链表之上构建多层索引

在前面的学习中,我们已经知道,跳表也是一种用于维护有序集合的数据结构。

不过,跳表实现有序性的方式并不是依赖树形结构,而是建立在有序链表的基础之上。对于链表这种数据结构,我们并不陌生。一个普通单链表中的每个节点通常由数据域和指针域组成,节点之间通过指针依次串联起来。

如果我们希望使用单链表来维护一个有序集合,那么最直接的方式就是让每个节点存储一个元素,并且让这些节点按照升序排列。这样一来,链表本身就具备了有序性。

但是,普通有序链表存在一个很明显的问题:当我们需要查找某个元素,或者插入一个新元素时,都必须从头节点开始向后线性遍历,直到找到目标节点或者找到合适的插入位置。因此,普通有序链表的查找复杂度是 O(N);而插入操作虽然修改指针本身是 O(1),但是定位插入位置仍然需要 O(N) 的时间,所以整体插入复杂度也是 O(N)

很显然,如果只是单纯使用普通有序链表,是无法实现高效查找的。

而跳表的核心改进,正是在普通有序链表的基础上增加了多层索引结构。也就是说,跳表的最底层仍然是一条完整的有序链表,用来存储所有元素;而在这条链表之上,跳表会从部分节点中抽取出一些节点,组成更高一层的索引链表。由于底层链表本身是有序的,因此从中抽取出来的索引节点也天然保持有序。

这个思想其实和我们此前学习过的 B+ 树有些相似。

对于 B+ 树来说,它是一种多路平衡搜索树,整体结构可以分为内部节点和叶子节点。内部节点并不存储完整的数据记录,而是主要存储 key 和子节点指针,用来起到导航作用;真正的数据则存储在叶子节点中。由于 B+ 树的节点大小通常会和磁盘页大小相匹配,一个节点中可以存放大量 key 和子节点指针,因此可以显著降低树的高度,从而减少磁盘 IO 次数。查找数据时,我们从根节点开始,根据内部节点中的 key 逐层向下定位,最终找到目标数据所在的叶子节点。

跳表中的索引层也可以理解为一种"导航结构"。只不过,B+ 树是通过树形结构自上而下地选择子节点,而跳表则是在链表结构上通过"向右跳跃"和"向下下沉"的方式逐步逼近目标节点。

具体来说,跳表会在底层有序链表之上建立一层索引链表;在这一层索引链表之上,还可以继续抽取部分节点,再建立更高一层的索引链表。这样一来,越往上的索引层,节点数量越少,跳跃跨度越大;越往下的链表,节点数量越多,信息越完整。最底层链表则保存所有元素。

当我们查找一个目标值时,并不会直接从底层链表的头节点开始线性遍历,而是先从最高层索引开始查找。

在当前层中,如果下一个节点的值小于目标值,说明目标节点一定还在更靠后的位置,于是继续向右移动;如果下一个节点的值等于目标值,说明查找成功;如果下一个节点的值大于目标值,说明目标值不可能出现在下一个节点之后,此时就不能继续向右移动,而应该从当前位置下沉到下一层链表中,继续进行更细粒度的查找。

也就是说,跳表的查找过程可以概括为:

next 小于目标值,就向右移动;

next 等于目标值,就查找成功;

next 大于目标值,就向下移动。


所以,跳表提升查找效率的核心思想,就是在底层有序链表的基础上,构建多层索引结构。

首先,跳表的最底层是一条完整的有序链表,用来存储所有元素。由于底层链表中的节点本身就是按照升序排列的,因此它天然能够维护数据的有序性。在此基础上,跳表会从底层链表中选取一部分节点,让这些节点额外参与更高一层的链表,从而形成索引层。接着,又可以从这一层索引节点中继续选取部分节点,让它们参与更高一层的链表。如此反复,最终就形成了多层索引结构。

例如,一个跳表的逻辑结构可以抽象成下面这样:

text 复制代码
第3层索引:                         80
                                  |
第2层索引:             40 -------- 80
                        |          |
第1层索引:       20 --- 40 --- 60 --- 80
                  |      |      |      |
第0层链表: 10 -> 20 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

可以看到,最底层的第 0 层链表保存了所有节点,而越往上的索引层,节点数量越少,节点之间的跨度也越大。这样在查找目标元素时,就可以先在高层索引中快速跳过大量不可能的节点,然后再逐层下沉到更低层继续查找。

从逻辑上看,跳表好像是在底层链表之上额外维护了多条索引链表;但从实现角度来看,通常并不是单独创建一批只存储指针的索引节点,而是让同一个节点维护多个 forward 指针。

也就是说,跳表中的每个节点除了保存数据之外,还会维护一个指针数组。这个指针数组中的每一个元素,都是指向后继节点的指针。不同下标位置对应不同层级的链表。

例如,对于节点 40 来说,如果它的高度为 3,那么它的内部结构可以抽象成下面这样:

text 复制代码
节点 40
+-------------+
| value = 40  |
+-------------+
| forward[2]  | ---> 第2层中的后继节点,比如 80
+-------------+
| forward[1]  | ---> 第1层中的后继节点,比如 60
+-------------+
| forward[0]  | ---> 第0层中的后继节点,比如 50
+-------------+

其中,forward[0] 表示该节点在第 0 层,也就是底层全量链表中的后继节点;forward[1] 表示该节点在第 1 层索引链表中的后继节点;forward[2] 表示该节点在第 2 层索引链表中的后继节点。

因此,一个节点的高度越高,就说明它参与的索引层级越多;一个节点的高度越低,就说明它只出现在较低层的链表中。

不过,跳表的层数并不会无限增长。因为每增加一层索引,就意味着节点可能需要额外维护一个 forward 指针,这会带来额外的内存开销。同时,如果完全不限制高度,也可能出现极端情况:某个节点在随机晋升时连续成功,就像连续抛硬币都抛到了正面一样,一直向上晋升。因此,在具体实现中,跳表通常会设置一个最大高度,用来限制节点最多能够参与多少层链表。

在理解最大高度之后,我们再来看一个重要概念:节点的随机高度。

所谓随机高度,本质上就是通过概率生成出来的节点层数。当一个新节点插入跳表时,它一定会出现在第 0 层,也就是底层全量链表中。随后,跳表会通过随机判断来决定这个节点是否继续晋升到更高一层。如果晋升成功,该节点就会继续出现在第 1 层索引链表中;如果再次晋升成功,它还可以继续出现在第 2 层索引链表中。这个过程会一直持续,直到某一次晋升失败,或者节点高度已经达到跳表设置的最大高度为止。

这个过程可以类比为抛硬币:如果硬币朝正面,那么节点就继续晋升到更高一层;如果硬币朝反面,那么节点的晋升过程就停止。

例如,插入节点 50 时,它的随机晋升过程可能是下面这样:

text 复制代码
第3层:        停止晋升
              ↑
第2层:       [50]    晋升失败
              ↑
第1层:       [50]    晋升成功
              ↑
第0层:       [50]    新节点默认进入底层链表

在这个例子中,节点 50 最终出现在第 0 层和第 1 层中,因此它的高度就是 2。也就是说,节点高度表示这个节点一共参与了多少层链表。

需要注意的是,节点高度和层下标不是一回事。如果一个节点的高度为 2,那么它参与的是第 0 层和第 1 层;如果一个节点的高度为 3,那么它参与的是第 0 层、第 1 层和第 2 层。

text 复制代码
节点高度 = 3

forward[2]  -> 第2层后继节点
forward[1]  -> 第1层后继节点
forward[0]  -> 第0层后继节点

由于节点是否继续晋升是由概率决定的,所以不同节点最终得到的高度可能不同。有的节点只存在于底层链表中,有的节点则会被提升到更高层索引中。正是这种随机生成高度的方式,使跳表能够在不依赖固定位置规则的情况下,形成多层稀疏索引结构。

那么,为什么跳表不按照固定规则来选择索引节点呢?

比如,我们完全可以设想这样一种规则:在第 0 层链表中,每隔两个节点提升一个节点;在第 1 层链表中,每隔四个节点提升一个节点;或者按照奇数位置、偶数位置来决定哪些节点进入上层索引。

如果跳表只是一个只读结构,那么这种固定规则确实可以构建出比较均匀的索引层。例如:

text 复制代码
第2层索引:             40 ---------------- 80
                        |                  |
第1层索引:       20 --- 40 --- 60 -------- 80
                  |      |      |          |
第0层链表: 10 -> 20 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

但是,跳表通常不是只读结构,它还需要支持插入和删除操作。一旦插入或者删除一个元素,底层链表中节点之间的位置关系就会发生变化。此时,原本符合固定规则、已经出现在上层索引链表中的节点,可能因为位置编号改变而不再符合规则;而原本没有出现在上层索引中的节点,也可能变成了应该被提升的节点。

比如,在底层链表中插入一个新节点 25

text 复制代码
插入前:
10 -> 20 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

插入后:
10 -> 20 -> 25 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

如果索引层严格依赖"第几个节点"这种位置规则,那么插入 25 之后,后面很多节点的位置编号都会发生变化。为了继续维持固定规则,跳表就不得不重新调整索引层结构:有些节点需要从上层索引链表中删除,有些节点又需要被提升到上层索引链表中。随着链表规模增大,这种调整可能会影响大量节点,从而带来很高的维护成本。

而随机晋升的优势就在于:一个节点是否能够进入上层索引,是由它自身的随机高度决定的,而不是由它在链表中的位置决定的。换句话说,各个节点的晋升结果是相对独立的,不会因为其他节点的插入或删除而发生整体性的变化。

因此,当跳表插入一个新节点时,只需要根据这个新节点随机生成的高度,把它插入到对应层级的链表中即可;删除节点时,也只需要修改该节点参与的那些层级中的前驱指针。这样就把维护范围限制在局部,避免了固定规则索引可能带来的大规模重排问题。


跳表查询原理:向右跳跃、向下下沉与期望 O(logN)

根据前面的分析,我们已经认识了跳表的基本存储结构:跳表由底层全量有序链表和上层多级索引链表组成。底层链表负责保存所有节点,而上层索引链表则是由部分节点通过随机晋升形成的。

在理解了跳表的基本结构之后,接下来我们就进一步分析跳表中最核心的操作之一:查询操作。

由于跳表由多层链表组成,因此查询并不是直接从底层链表的头节点开始线性遍历,而是从当前跳表的最高层索引链表开始查找。

在正式分析查询过程之前,我们还需要明确一点:如果一个节点的随机高度为 n,那么这个节点会同时出现在第 0 层到第 n - 1 层中。也就是说,一个高度为 n 的节点,并不是只存在于最高层,而是从底层链表开始,连续参与了多层链表。

例如,节点 40 的高度为 3,那么它会参与第 0 层、第 1 层和第 2 层:

text 复制代码
节点高度 = 3

forward[2]  -> 第2层后继节点
forward[1]  -> 第1层后继节点
forward[0]  -> 第0层后继节点

这一点非常关键,因为跳表查询过程中之所以能够从当前层向下一层下沉,本质上就是因为当前节点同时存在于更低层链表中。

需要再次强调的是,这里所谓的"索引链表",并不是额外创建出一批独立的索引节点。跳表在物理上只有一批真实节点,每个节点只保存一份数据;但是这些节点内部维护了一个 forward 指针数组,不同下标位置的指针将节点串联起来,从逻辑上形成了不同层级的链表。

也就是说:

text 复制代码
forward[2] 串联起来:第2层索引链表
forward[1] 串联起来:第1层索引链表
forward[0] 串联起来:第0层全量链表

因此,跳表可以理解为:

text 复制代码
物理上:一批节点
逻辑上:多层链表

接下来,我们来看跳表的查询过程。

假设我们要查找目标值 target,查询会从跳表最高层的头节点开始。此时我们会观察当前节点在当前层的后继节点,也就是 cur->forward[level]

如果后继节点的值小于目标值,说明目标值还在当前节点的右侧,因此继续向右移动;如果后继节点的值等于目标值,说明查找成功;如果后继节点的值大于目标值,说明目标值不可能出现在这个后继节点之后,此时就不能继续向右移动,而应该从当前节点下沉到下一层链表中继续查找。

这个过程可以概括为:

text 复制代码
next < target :向右移动
next = target :查找成功
next > target :向下移动

例如,假设跳表结构如下:

text 复制代码
第3层索引:                         80
                                  |
第2层索引:             40 -------- 80
                        |          |
第1层索引:       20 --- 40 --- 60 --- 80
                  |      |      |      |
第0层链表: 10 -> 20 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

如果我们要查找 50,查询过程大致如下:

text 复制代码
第3层:head -> 80
        80 > 50,不能右移,下沉

第2层:head -> 40 -> 80
        40 < 50,右移到 40
        80 > 50,不能右移,下沉

第1层:40 -> 60
        60 > 50,不能右移,下沉

第0层:40 -> 50
        50 == 50,查找成功

可以看到,跳表查找目标节点的过程,本质上就是不断进行"向右跳跃"和"向下下沉"。

这里还有一个关键问题:当某一层无法继续向右移动时,为什么进入下一层之后不需要从头节点重新开始查找,而是可以直接从当前节点继续向右查找?

原因在于,跳表中的每一层链表都是有序的。当我们在某一层已经移动到当前节点时,就说明当前节点左侧的所有节点都已经小于目标值,不可能再命中目标。因此,目标值只可能出现在当前节点的右侧区间中。

例如,当我们在第 2 层已经走到节点 40,并且发现下一个节点 80 大于目标值 50 时:

text 复制代码
第2层:             40 -------- 80
                    ↑          ↑
                 当前节点     next

此时可以确定,目标值 50 只可能出现在 4080 之间。因此下沉到第 1 层或者第 0 层时,就没有必要再从头节点重新开始,而是可以直接从节点 40 继续向右查找。

这也是跳表提升查找效率的核心原因。

如果只遍历底层有序链表,那么每次只能跨过一个节点:

text 复制代码
第0层链表:10 -> 20 -> 30 -> 40 -> 50 -> 60 -> 70 -> 80

而跳表在上层维护了稀疏索引链表,上层相邻节点之间的跨度更大,因此一次右移就可以跳过底层的大量节点。查找时,跳表会先在高层索引中进行大跨度跳跃,快速缩小目标值可能所在的区间;当无法继续大跨度跳跃时,再下沉到更低层链表中,以更小的跨度继续查找。

也就是说,跳表的查找过程可以理解为:

text 复制代码
高层索引:节点少,跨度大,负责快速缩小范围
低层索引:节点多,跨度小,负责进一步细化范围
底层链表:保存全量数据,负责最终确认目标是否存在

因此,跳表并不是单纯地减少了底层链表的节点数量,而是通过多层索引结构,让查找过程可以不断跳过不可能的区间。

索引链表的稀疏程度和节点晋升概率有关。如果晋升概率越大,节点越容易进入更高层索引,那么上层索引链表中的节点就越密集;如果晋升概率越小,节点越不容易进入更高层索引,那么上层索引链表就越稀疏。

在很多跳表实现中,晋升概率通常会选择 0.5,也就是类似抛硬币的概率。这样一来,从期望角度来看,每一层的节点数量大约是下一层的一半:

text 复制代码
第0层:N 个节点
第1层:N / 2 个节点
第2层:N / 4 个节点
第3层:N / 8 个节点
...

因此,跳表的层数期望大约是 O(logN)。

不过这里需要注意,跳表的索引层是随机生成的,所以我们不能说每一层的节点数量严格等于下一层的一半,而只能说在期望意义下大致符合这个规律。

同样地,跳表的查找复杂度通常也是从期望角度分析的。由于跳表的高度期望为 O(logN),并且查找过程中每一层横向移动的次数在期望上是常数级的。

这里还需要进一步解释一下:为什么说跳表在查询过程中,每一层横向移动的次数在期望上是常数级的?

所谓"常数级",并不是说每一层最多只能横向移动 1 次或者 2 次,而是指在随机晋升的平均效果下,每一层横向移动的次数不会随着数据规模 N 的增大而增大。

也就是说,当跳表进入某一层之后,平均情况下并不会在这一层横向遍历很多节点,而是大概走少量几个节点之后,就会继续向下层下沉。比如当 N = 1000 时,某一层平均可能横向移动 2 次左右;当 N = 1000000 时,平均横向移动次数也仍然是一个较小的固定数量,而不会变成几千次或者几万次。

为什么会这样呢?

假设节点晋升到上一层的概率是 0.5,也就是类似抛硬币的概率。那么从期望角度来看,每一层的节点数量大约是下一层的一半:

text 复制代码
第0层:N 个节点
第1层:N / 2 个节点
第2层:N / 4 个节点
第3层:N / 8 个节点
...

换句话说,在下一层链表中,平均每隔少量几个节点,就会有一个节点出现在上一层索引链表中。因此,当查找过程从上一层下沉到下一层时,下一层中两个上层索引节点之间的间隔,在期望上并不会特别大,而是一个常数级别的区间。

例如:

text 复制代码
第2层:        20 -------- 50 -------- 80
               |          |          |
第1层:        20 -- 30 -- 50 -- 60 -- 80

假设我们在第 2 层已经走到节点 20,并且发现下一个节点 50 已经大于目标值,于是查找过程会下沉到第 1 层。此时并不会从第 1 层的头节点重新开始查找,而是直接从节点 20 开始,在 2050 之间继续向右遍历:

text 复制代码
第1层:        20 -- 30 -- 50
               ↑
             从这里继续

由于节点晋升到上一层的概率通常取 0.5,因此从期望上看,当前层中大约每隔少量几个节点,就会有一个节点出现在上一层索引链表中。

而跳表在查找过程中,每次从上一层下沉到当前层时,并不是从当前层的头节点重新开始遍历,而是在上一层已经确定出来的两个相邻索引节点之间继续查找。也就是说,当前层的横向遍历范围,实际上被限制在上一层相邻节点所划分出来的局部区间中。

由于这个局部区间的长度在期望上是常数级的,所以跳表在每一层中的横向移动次数,在期望意义下也是常数级的。

从整体上看,跳表的高度在期望上是 O(logN),而每一层横向移动次数在期望上是 O(1),因此一次查询的总路径长度就是:

text 复制代码
跳表高度:O(logN)

每层横向移动次数:O(1)

总查找路径长度:
O(logN) × O(1) = O(logN)

因此,跳表查询操作的期望时间复杂度是 O(logN)。这里的关键点在于:跳表不是每一层都从头开始扫描,而是在上一层已经缩小出来的区间内继续查找;并且由于随机晋升使索引层保持合理的稀疏程度,所以每一层需要横向移动的节点数量在期望上是常数级的。

示意图

C++ 实现跳表:从结构设计到核心接口完整实现

C++ 实现跳表:节点结构与整体骨架设计

根据前面的分析,我们已经理解了跳表的核心思想:跳表通过底层全量有序链表保存所有数据,同时利用上层稀疏索引链表加速查找过程。

在理解了跳表的原理之后,接下来我们就尝试使用 C++ 来实现一个简单的跳表结构。这里的实现思路是:先确定跳表的整体骨架以及相关成员变量,然后再逐步实现跳表的核心接口,例如随机高度生成、查询、插入和删除等操作。

首先需要明确的是,跳表虽然在逻辑上可以看成由"底层链表 + 多级索引链表"组成,但它并不是为每一层索引都单独创建一批节点。更准确地说,跳表在物理上只有一批真实节点,每个节点只保存一份数据;而这些节点内部会维护一个 forward 指针数组,不同下标位置的指针将节点串联起来,从而在逻辑上形成不同层级的链表。

也就是说:

text 复制代码
物理上:一批真实节点
逻辑上:多层链表结构
实现上:每个节点维护一个 forward 指针数组

因此,我们首先需要定义跳表的节点结构。由于跳表中存储的数据类型不一定固定,所以节点类应该设计成模板类。节点内部主要包含两部分:一个是数据域 value,用于保存当前节点的值;另一个是 forward 指针数组,用于保存当前节点在不同层级中的后继节点指针。

cpp 复制代码
template<typename T>
class SkipListNode
{
public:
    SkipListNode(int level, const T& value)
        : value(value)
        , forward(level, nullptr)
    {}

public:
    T value;
    std::vector<SkipListNode<T>*> forward;
};

其中,构造函数中的 level 表示当前节点的高度,也就是该节点参与了多少层链表。比如一个节点的高度为 3,那么它的 forward 数组大小就是 3,分别对应第 0 层、第 1 层和第 2 层中的后继节点指针。

text 复制代码
节点高度 = 3

forward[2]  -> 第2层后继节点
forward[1]  -> 第1层后继节点
forward[0]  -> 第0层后继节点

定义完节点结构之后,接下来就可以定义跳表本身。

由于 C++ 是一门面向对象语言,因此我们可以使用一个 SkipList 类来描述整个跳表结构。需要注意的是,SkipList 类本身并不直接存储所有数据,真正的数据存储在各个节点中。SkipList 类主要负责保存跳表的管理信息,例如最大高度、当前实际高度、节点个数、头节点以及比较器等。

同时,为了让跳表能够支持不同类型的数据,SkipList 也应该设计成模板类。除了数据类型 T 之外,还需要额外引入一个比较器类型 Comparator。这是因为跳表需要根据元素大小关系来维护有序性,而对于自定义类型来说,不一定能够直接使用内置的 < 运算符进行比较。

因此,模板参数可以设计成下面这样:

cpp 复制代码
template<typename T, typename Comparator = std::less<T>>
class SkipList
{
    // ...
};

如果用户在实例化 SkipList 对象时没有显式传入自定义比较器,那么 Comparator 就会使用默认的 std::less<T>

std::less<T> 本质上是一个仿函数对象,它重载了 operator()。该运算符重载函数会接收两个参数 ab,其作用就是判断 a 是否小于 b。可以简单理解为,其内部逻辑类似于:

cpp 复制代码
return a < b;

因此,对于内置类型来说,std::less<T> 会直接按照对应类型的 < 运算符进行比较;而如果 T 是自定义类型,那么这里就要求该类型支持 < 运算符重载。此时,std::less<T> 在比较两个对象时,实际调用的就是该类中定义的 < 运算符重载函数。

接下来给出跳表类的基本骨架:

cpp 复制代码
#include <vector>
#include <functional>
#include <cstddef>

template<typename T>
class SkipListNode
{
public:
    SkipListNode(int level, const T& value)
        : value(value)
        , forward(level, nullptr)
    {}

public:
    T value;
    std::vector<SkipListNode<T>*> forward;
};

template<typename T, typename Comparator = std::less<T>>
class SkipList
{
public:
    using Node = SkipListNode<T>;

    SkipList(int maxLevel = 32, float probability = 0.5f, Comparator comp = Comparator())
        : maxLevel(maxLevel)
        , level(1)
        , head(nullptr)
        , nodeCount(0)
        , comp(comp)
        , probability(probability)
    {
        head = new Node(maxLevel, T());
    }
    //....

    SkipList(const SkipList&) = delete;
    SkipList& operator=(const SkipList&) = delete;
    
private:
    int maxLevel;          // 跳表允许的最大高度
    int level;             // 当前跳表的实际层数
    Node* head;            // 头节点,作为每一层链表的入口
    size_t nodeCount;      // 当前跳表中的有效节点个数
    Comparator comp;       // 比较器,用于维护节点之间的有序关系
    float probability;     // 节点晋升到上一层的概率
};

这里对几个成员变量做一下说明。

maxLevel 表示跳表允许的最大高度。由于跳表的层数不能无限增长,否则会带来额外的指针开销,因此通常需要设置一个最大高度。

level 表示当前跳表的实际层数。初始化时跳表中还没有有效数据节点,因此只需要保留第 0 层,所以这里将其初始化为 1

head 表示跳表的头节点。头节点本身不用于存储有效数据,而是作为每一层链表的入口。正因为头节点需要能够访问所有层级的链表,所以它的 forward 数组大小需要初始化为 maxLevel

nodeCount 用来记录当前跳表中的有效节点个数。

comp 是比较器对象,用于决定节点之间的有序关系。由于跳表本质上是一个有序结构,因此无论是查询、插入还是删除,都需要借助比较器来判断节点值之间的大小关系。

probability 表示节点晋升到上一层的概率。比如当 probability = 0.5 时,就可以理解为每个节点有一半概率继续晋升到更高一层。

接下来重点看一下 SkipList 的构造函数:

cpp 复制代码
SkipList(int maxLevel = 32, float probability = 0.5f, Comparator comp = Comparator())
    : maxLevel(maxLevel)
    , level(1)
    , head(nullptr)
    , nodeCount(0)
    , comp(comp)
    , probability(probability)
{
    head = new Node(maxLevel, T());
}

这个构造函数一共接收三个参数:maxLevelprobabilitycomp

其中,maxLevel 表示跳表允许的最大高度,用来限制索引层级的数量,避免节点无限向上晋升;probability 表示节点晋升到上一层的概率,默认值为 0.5,也就是类似抛硬币的概率;comp 表示比较器对象,用来决定节点之间的大小关系,从而维护跳表的有序性。

这三个参数都提供了缺省值,因此用户在实例化 SkipList 对象时,可以直接使用默认配置:

cpp 复制代码
SkipList<int> list;

此时跳表的最大高度默认为 32,节点晋升概率默认为 0.5,比较器默认使用 std::less<int>

在构造函数的初始化列表中,首先会初始化跳表的管理信息。maxLevel 保存跳表允许的最大高度;level 初始化为 1,表示当前跳表至少包含第 0 层;head 先初始化为空指针;nodeCount 初始化为 0,表示当前还没有有效数据节点;compprobability 分别保存比较器和晋升概率。

构造函数体内部则会申请头节点:

cpp 复制代码
head = new Node(maxLevel, T());

这里需要注意,头节点本身并不存储有效数据,它的作用是作为每一层链表的入口。由于头节点需要能够访问跳表中的所有层级,所以头节点的高度必须设置为 maxLevel。这样一来,头节点内部的 forward 指针数组大小也会被初始化为 maxLevel,并且数组中的每个指针都会被初始化为空指针。

也就是说,构造函数完成之后,跳表虽然还没有存储任何有效数据节点,但是它的基本管理信息和头节点结构已经初始化完成,后续的查询、插入和删除操作都可以从这个头节点开始执行。

此外,还需要注意的是,由于跳表内部管理了动态申请的节点资源,为了避免默认拷贝构造和赋值运算导致浅拷贝问题,这里直接禁用了拷贝构造函数和赋值运算符。

到这里,跳表的节点结构以及整体管理类的骨架就已经搭建完成了。接下来,我们就可以继续实现跳表中的核心接口,例如随机高度生成函数、查询函数、插入函数以及删除函数。

跳表查询接口实现:isPresent 函数的查找逻辑

接下来,我们首先关注跳表的查询函数 isPresent 的实现。

isPresent 函数接收一个目标值 value,返回值类型为 bool。它的作用是判断目标值是否存在于当前跳表中:如果存在,则返回 true;如果不存在,则返回 false

在实现查询函数之前,需要先明确一个细节:虽然跳表中设置了最大高度 maxLevel,但是当前跳表的实际高度并不一定能够达到 maxLevelmaxLevel 只是跳表允许的最大高度,而 level 才表示当前跳表实际使用到的层数。

也就是说,当前跳表的最高层并不是 maxLevel - 1,而是 level - 1。因此,查询时应该从头节点的 forward[level - 1] 开始,也就是从当前跳表实际存在的最高层开始向后遍历。

查询过程本质上仍然遵循前面分析过的规则:从最高层开始,能向右就向右,不能向右就向下。

具体来说,假设当前节点为 current,当前所在层数为 i,那么当前层的后继节点就是:

cpp 复制代码
current->forward[i]

如果当前层的后继节点存在,并且后继节点的值小于目标值 value,说明目标值还在右侧,因此继续向右移动:

cpp 复制代码
current = current->forward[i];

如果当前层的后继节点等于目标值,说明查找成功,直接返回 true

如果当前层的后继节点大于目标值,说明当前层已经不能继续向右移动,此时就需要下沉到下一层,也就是让层数 i 减一,然后重复同样的查找过程。

这里还需要注意比较方式。由于我们的跳表是一个模板类,并且支持用户传入自定义比较器,所以在比较节点值时,不应该直接写死使用 < 或者 ==。判断大小关系时,应该使用比较器 comp;判断两个值是否相等时,也可以通过比较器间接推导。

如果 a 不小于 b,并且 b 也不小于 a,那么就可以认为二者在当前比较规则下相等:

cpp 复制代码
 !comp(a, b) && !comp(b, a)

因此,isPresent 函数可以实现如下:

cpp 复制代码
  bool isPresent(const T& value) const
       {
            if(nodecount==0)
            {
                 return false;
            }
            Node* current=head;
            for(int i=level-1;i>=0;i--)
            {
                 while(current->forward[i] && comp(current->forward[i]->value,value))
                 {
                      current=current->forward[i];
                 }
                 if(current->forward[i] && !comp(current->forward[i]->value,value) && !comp(value,current->forward[i]->value))
                 {
                      return true;
                 }
            }
            return false;                      
       }

这段代码中,外层 for 循环负责从当前跳表的最高层一路向下遍历到底层链表;内层 while 循环负责在当前层中不断向右移动,直到当前层的后继节点为空,或者后继节点的值不再小于目标值。

当内层循环结束时,说明当前层已经无法继续向右移动。此时如果当前层的后继节点刚好等于目标值,就说明查找成功,返回 true;否则外层循环会继续向下一层查找。

如果从最高层一直查找到第 0 层,仍然没有找到目标值,那么就说明该元素不存在于跳表中,最终返回 false

所以,isPresent 函数的核心逻辑可以概括为:

text 复制代码
next < value :继续向右移动
next = value :查找成功
next > value :下沉到下一层

需要注意的是,查询过程中 current 并不会在每一层都重新回到头节点,而是从当前节点直接下沉到下一层继续查找。正因为如此,跳表能够借助上层索引快速缩小搜索范围,从而在期望意义下实现 O(logN) 的查询效率。

跳表插入接口实现:update 数组、随机高度与多层挂接

认识了跳表的查询操作之后,接下来我们再来分析跳表的插入操作。

跳表的插入函数同样会接收一个目标值 value,其功能就是将该值插入到跳表中。不过,跳表的插入并不能只找到第 0 层链表中的直接前驱节点。原因在于,新节点插入之后,不一定只出现在底层链表中,它还可能根据随机高度出现在多层索引链表中。

比如一个新节点的随机高度为 4,那么它就需要同时插入到第 0 层、第 1 层、第 2 层和第 3 层中。此时,如果我们只知道它在第 0 层中的前驱节点,就无法完成更高层索引链表中的指针挂接。

因此,插入操作的关键在于:在定位插入位置的过程中,同时记录每一层中目标值的前驱节点。

为此,我们可以使用一个 update 数组来保存这些前驱节点。数组下标对应跳表的层级,update[i] 表示新节点在第 i 层中的前驱节点。其中,update[0] 表示底层全量链表中的前驱节点,update[1] 表示第 1 层索引链表中的前驱节点,以此类推。

这个过程本质上和查询操作类似,都是从当前跳表的最高层开始向右移动,并在无法继续向右时下沉到下一层。不同的是,查询操作只关心目标值是否存在,而插入操作还需要在每一层下沉之前记录当前节点,因为这个当前节点就是目标值在该层中的前驱节点。

因此,我们可以单独封装一个 findPredecessors 函数,用来查找并返回各层的前驱节点:

cpp 复制代码
std::vector<Node*> findPredecessors(const T& value) const
{
    std::vector<Node*> update(maxLevel, nullptr);
    Node* current = head;

    for (int i = level - 1; i >= 0; --i)
    {
        while (current->forward[i] && comp(current->forward[i]->value, value))
        {
            current = current->forward[i];
        }

        update[i] = current;
    }

    return update;
}

这段代码中,外层 for 循环依然是从当前跳表的最高层 level - 1 开始,逐层向下查找。内层 while 循环负责在当前层中不断向右移动,直到当前层的后继节点为空,或者后继节点的值不再小于目标值 value

当内层循环结束时,说明当前层已经无法继续向右移动。此时,current 就是目标值在当前层中的前驱节点,因此将其记录到 update[i] 中。

需要注意的是,这里的"前驱节点"不是单纯指第 0 层链表中的物理前驱节点,而是指目标值在每一层链表中的前驱节点:

text 复制代码
update[0] -> 第0层中的前驱节点
update[1] -> 第1层中的前驱节点
update[2] -> 第2层中的前驱节点
...

有了 update 数组之后,我们就可以进一步判断待插入的值是否已经存在。

由于跳表中要求元素不重复,因此插入前需要进行去重判断。常规做法是先调用一次查询函数判断元素是否存在,如果不存在再执行插入。但这样会导致插入前后重复进行两次查找。

这里可以采用更直接的方式:因为 update[0] 已经记录了目标值在底层全量链表中的前驱节点,而第 0 层保存了所有数据,所以我们只需要检查 update[0] 的后继节点是否等于目标值即可。

cpp 复制代码
 Node* next = current->forward[0];
if(next && !comp(next->value, value) && !comp(value, next->value))
{
   return;
}

如果第 0 层前驱节点的后继节点刚好等于目标值,就说明该值已经存在于跳表中,直接返回即可,不需要重复插入。

在确认目标值不存在之后,接下来就需要为新节点生成随机高度。这里可以单独定义一个 randomLevel 函数:

cpp 复制代码
  int randomLevel()
        {
            int newlevel=1;
            while(newlevel<maxLevel)
            {
                 if(rand()/double(RAND_MAX)<probability)
                 {
                     newlevel++;
                 }else
                 {
                      break;
                 }
            }
            return newlevel;
        }

新节点默认至少会出现在第 0 层,因此初始高度为 1。随后,通过 rand() 生成一个随机数,并将其转换到 [0, 1] 范围内。如果该随机数小于晋升概率 probability,就说明晋升成功,节点高度加一;否则晋升失败,停止继续向上提升。

这个过程会一直持续,直到某一次晋升失败,或者新节点高度已经达到 maxLevel 为止。

当随机高度生成之后,就可以创建新节点,并将其挂接到跳表对应层级中:

cpp 复制代码
   void insert(const T& value)
       {
            std::vector<Node*> update(maxLevel, nullptr);
            Node* current=head;
            for(int i=level-1;i>=0;i--)
            {
                  while(current->forward[i] && comp(current->forward[i]->value,value))
                  {
                          current=current->forward[i];
                  }
                    update[i]=current;
            }
             Node* next = current->forward[0];
             if(next && !comp(next->value, value) && !comp(value, next->value))
              {
                return;
             }
            int newlevel=randomLevel();
             // 如果新节点高度超过当前跳表高度,高出来的层前驱都是 head
            if(newlevel > level)
            {
                for(int i = level; i < newlevel; i++)
                   {
                      update[i] = head;
                          }

                  level = newlevel;
           }

            Node* newNode=new Node(newlevel,value);
            for(int i=0;i<newlevel;i++)
            {
                 Node* next=update[i]->forward[i];
                 update[i]->forward[i]=newNode;
                 newNode->forward[i]=next;
            }
             nodecount++;
             return;
       }

这段插入逻辑可以分为几个步骤来看。

首先,通过 findPredecessors(value) 查找目标值在各层中的前驱节点,并保存到 update 数组中。

接着,通过 update[0]->forward[0] 判断目标值是否已经存在。如果已经存在,就直接返回,避免重复插入。

然后,调用 randomLevel() 生成新节点的随机高度 newLevel。如果 newLevel 大于当前跳表的实际层数 level,说明新节点会成为更高层索引链表中的第一个有效节点。对于这些新增层来说,新节点的前驱节点都应该是头节点 head,因此需要将对应层的 update[i] 设置为 head,并更新跳表当前实际层数 level

最后,创建新节点,并根据 update 数组逐层修改指针。对于第 i 层来说,新节点需要插入到 update[i]update[i]->forward[i] 之间,因此指针修改过程如下:

cpp 复制代码
newNode->forward[i] = update[i]->forward[i];
update[i]->forward[i] = newNode;

这个过程和普通单链表插入节点的指针修改类似,只不过跳表中一个节点可能参与多层链表,因此需要在每一层都执行一次类似的挂接操作。

所以,跳表插入操作的核心可以总结为:

text 复制代码
1. 查找每一层中的前驱节点,并保存到 update 数组中
2. 通过第 0 层前驱节点的后继节点判断是否重复
3. 生成新节点的随机高度
4. 如果新节点高度超过当前跳表高度,则补充高层前驱为 head
5. 创建新节点,并在对应层级中修改 forward 指针
6. 更新跳表节点数量

可以看到,跳表插入操作并不是只在底层链表中插入一个节点,而是根据新节点的随机高度,将其同时插入到多层链表中。而 update 数组正是实现这一点的关键,它保存了新节点在每一层中的前驱节点,使得插入操作可以通过局部修改指针完成。

跳表删除接口实现:update 数组、指针断开与高度维护

认识了跳表的插入操作之后,接下来我们再来分析跳表的删除操作。

删除操作的功能是从跳表中删除指定元素值对应的节点。不过,跳表的删除并不是只在物理上释放目标节点这么简单。因为一个节点可能同时参与多层链表,所以删除目标节点之前,还需要先修改该节点在各层链表中的前驱节点指针,让这些前驱节点直接绕过目标节点,指向目标节点原来的后继节点。

也就是说,如果目标节点出现在第 0 层、第 1 层和第 2 层,那么删除时就需要分别修改这几层中的前驱节点指针:

text 复制代码
update[0]->forward[0] 需要跳过目标节点
update[1]->forward[1] 需要跳过目标节点
update[2]->forward[2] 需要跳过目标节点

因此,删除操作和插入操作类似,也需要先调用 findPredecessors 函数,找到目标值在每一层中的前驱节点,并将这些前驱节点保存到 update 数组中。

拿到 update 数组之后,可以通过第 0 层前驱节点的后继节点来判断目标值是否存在。因为第 0 层链表保存了跳表中的所有节点,所以如果目标值存在,那么它一定会出现在第 0 层中。

如果 target 为空,或者 target 的值不等于目标值,就说明跳表中不存在该元素,直接返回即可。

如果目标节点存在,那么接下来就需要根据目标节点的高度,逐层修改前驱节点的 forward 指针。由于目标节点的 forward 数组长度就是它的随机高度,所以可以通过 target->forward.size() 得到目标节点参与的层数。

删除某一层中的目标节点时,指针修改逻辑如下:

cpp 复制代码
update[i]->forward[i] = target->forward[i];

这句代码的含义是:让第 i 层中的前驱节点不再指向目标节点,而是直接指向目标节点在第 i 层中的后继节点。这样一来,目标节点就从第 i 层链表中被断开了。

对应的删除函数可以实现如下:

cpp 复制代码
  void remove(const T& value)
       {
               if (nodecount == 0)
                {
                  return;
                }

               std::vector<Node*> update = findPredecessors(value);
               Node* target = update[0]->forward[0];

                if(target!= nullptr && !comp(target->value, value) && !comp(value, target->value))
                {
                    size_t targetLevel = target->forward.size();
                    for (size_t i = 0; i < targetLevel; ++i)
                    {
                   update[i]->forward[i] = target->forward[i];
                    }
                   delete target;
                   --nodecount;
                    while (level > 1 && head->forward[level - 1] == nullptr)
                    {
                         --level;
                    }
                }
    }

这段代码的整体流程可以分为几步。

首先,如果当前跳表为空,直接返回。

接着,通过 findPredecessors(value) 找到目标值在各层中的前驱节点,并保存到 update 数组中。

然后,通过 update[0]->forward[0] 定位到底层链表中可能的目标节点。由于第 0 层保存了所有数据,所以只需要判断这个节点是否存在,并且值是否等于目标值,就可以确定待删除元素是否存在于跳表中。

如果目标节点存在,就根据目标节点的高度,遍历它参与的每一层。在第 i 层中,如果 update[i]->forward[i] 确实指向目标节点,就让它直接指向 target->forward[i],从而将目标节点从这一层链表中断开。

等所有层级的指针关系都修改完成之后,才可以真正释放目标节点:

cpp 复制代码
delete target;
--nodeCount;

最后,还需要更新跳表的实际高度 level

这是因为删除节点之后,最高层索引链表可能会变空。比如当前最高层只有一个有效节点,而这个节点刚好被删除了,那么此时:

cpp 复制代码
head->forward[level - 1] == nullptr

就说明当前最高层已经没有有效节点了。此时需要不断降低 level,直到找到一个仍然存在有效节点的层,或者最终降到第 0 层为止。

cpp 复制代码
while (level > 1 && head->forward[level - 1] == nullptr)
{
    --level;
}

所以,跳表删除操作的核心可以总结为:

text 复制代码
1. 通过 findPredecessors 找到各层前驱节点
2. 通过第 0 层前驱节点的后继节点判断目标是否存在
3. 如果目标存在,则在目标节点参与的每一层中修改前驱节点的 forward 指针
4. 释放目标节点,并更新节点数量
5. 如果最高层索引链表变空,则更新跳表的实际高度

可以看到,跳表删除操作和插入操作一样,核心都离不开 update 数组。插入时,update 数组用于确定新节点在各层中的插入位置;删除时,update 数组则用于找到目标节点在各层中的前驱节点,从而完成局部指针修改。

clear 清空函数与析构函数:释放节点资源并重置跳表状态

接下来,我们再实现跳表的 clear 函数。

clear 函数的作用是清空跳表中的所有有效数据节点。需要注意的是,头节点 head 本身并不存储有效数据,它只是作为各层链表的入口,因此 clear 函数只需要释放头节点之后的所有普通节点,而不需要释放头节点本身。

由于第 0 层链表保存了跳表中的所有节点,所以清理节点时,只需要沿着第 0 层链表依次向后遍历即可。遍历过程中,需要先记录当前节点的后继节点,然后再释放当前节点,否则一旦当前节点被 delete,就无法继续访问它的后继节点了。

代码实现如下:

cpp 复制代码
  void clear()
      {
           if(nodecount==0)
           {
                return;
           }
           Node* current=head->forward[0];
           while(current)
           {
                Node* next=current->forward[0];
                delete current;
                current=next;
           }
           
            for(int i = 0; i < maxLevel; ++i)
           {
               head->forward[i] = nullptr;
           }

            level = 1;
            nodecount = 0;
      }

这段代码中,current 首先指向第 0 层链表中的第一个有效节点。随后在循环中不断记录 next 节点,释放当前节点,并让 current 继续向后移动,直到所有有效节点都被释放。

当所有节点释放完成后,还需要将头节点的 forward 指针数组全部置空。因为普通节点虽然已经被释放,但头节点中的部分 forward 指针之前仍然可能指向这些节点,如果不清空,就会留下悬空指针。

最后,将跳表的实际层数 level 重置为 1,表示当前跳表只保留第 0 层;同时将节点个数 nodeCount 重置为 0,表示跳表中已经没有有效数据节点。

有了 clear 函数之后,析构函数就可以直接复用它。因为 clear 只负责释放普通数据节点,不会释放头节点,所以析构函数中还需要额外释放 head

cpp 复制代码
~SkipList()
{
    clear();
    delete head;
}

这样一来,当 SkipList 对象生命周期结束时,析构函数会先释放所有有效数据节点,再释放头节点本身,从而避免动态申请的节点内存发生泄漏。

跳表迭代器实现:沿第 0 层链表完成有序遍历

由于跳表本质上是一个有序结构,因此除了提供查询、插入和删除操作之外,我们还可以为跳表定义一个迭代器,用来按照有序顺序遍历跳表中的所有元素。

虽然跳表在逻辑上由多层链表组成,但需要注意的是,只有第 0 层链表保存了全部数据节点。上层索引链表只是由部分节点组成,用来加速查找。如果迭代器沿着上层索引链表遍历,就会跳过很多没有出现在该层的节点,导致遍历结果不完整。

因此,跳表迭代器的遍历逻辑应该非常明确:始终沿着第 0 层链表向后移动。也就是说,迭代器每次执行自增操作时,都应该让当前指针移动到 forward[0] 指向的下一个节点。

基于这个思路,我们可以定义一个 SkipListIterator 类。该类内部保存一个指向当前节点的指针 current,并通过重载 operator*operator++operator!= 来支持基本的迭代器操作。

代码实现如下:

cpp 复制代码
template<typename T>
 class SkipListIterator
 {
     public:
     SkipListIterator(SkipListNode<T>* node)
     :current(node)
     {

     }
       T& operator*() const
       {
            return current->value;
       }
       SkipListIterator& operator++()
       {
            current=current->forward[0];  // 移动到最底层的下一个节点
            return *this;
       }
 
       bool operator!=(const SkipListIterator& other) const
       {
            return current!=other.current;
       }
    private:
     SkipListNode<T>* current;
 };

这里的 current 指针表示迭代器当前指向的节点。

当我们对迭代器进行解引用时,也就是调用 operator*,就会返回当前节点中保存的 value 值:

cpp 复制代码
T& operator*() const
{
    return current->value;
}

当我们对迭代器执行前置 ++ 操作时,就会让 current 沿着第 0 层链表移动到下一个节点:

cpp 复制代码
current = current->forward[0];

这里再次强调,迭代器必须沿着 forward[0] 遍历。因为 forward[0] 串联的是底层全量链表,只有这一层包含跳表中的所有元素。

operator!= 用于判断两个迭代器是否指向同一个节点:

cpp 复制代码
bool operator!=(const SkipListIterator& other) const
{
    return current != other.current;
}

定义完迭代器类之后,还需要在 SkipList 类中为该迭代器取一个别名,并提供 beginend 函数:

cpp 复制代码
public:
    using Iterator = SkipListIterator<T>;

    Iterator begin()
    {
        return Iterator(head->forward[0]);
    }

    Iterator end()
    {
        return Iterator(nullptr);
    }

其中,begin 函数返回第 0 层链表中第一个有效节点对应的迭代器,也就是 head->forward[0];而 end 函数返回一个空迭代器,用 nullptr 表示遍历结束位置。

这样一来,我们就可以像遍历普通容器一样遍历跳表:

cpp 复制代码
SkipList<int> list;

list.insert(30);
list.insert(10);
list.insert(20);

 for (auto it = list.begin(); it != list.end(); ++it)
{
    std::cout << *it << " ";
}

由于跳表底层链表本身就是有序的,因此遍历结果也是有序的:

text 复制代码
10 20 30

所以,跳表迭代器的核心并不复杂。它不需要关心上层索引链表,只需要沿着第 0 层链表依次向后移动即可。因为第 0 层保存了跳表中的全部数据节点,而上层索引只是用来加速查询的辅助结构。

源码

SKipList.hpp:

cpp 复制代码
#pragma once
#include<iostream>
#include<vector>
#include<functional>
#include<cstdlib>
#include<ctime>

template<typename T>
class SkipListNode
{

    public:
            SkipListNode(int level, const T& value) 
            : value(value) 
            {
                forward.resize(level, nullptr);
            }
         T value;
         std::vector<SkipListNode<T>*> forward;
};

/**
 *  跳表迭代器类模板
 *  T 跳表中存储的数据类型
 */
template <typename T>
 class SkipListIterator
 {
     public:
    /**
     *  构造函数
     *  node 指向跳表节点的指针
     */
     SkipListIterator(SkipListNode<T>* node)
     :current(node)  // 初始化当前节点指针
     {

     }
    /**
     *  重载解引用运算符
     *  返回当前节点的值引用
     */
       T& operator*() const
       {
            return current->value;
       }
    /**
     *  重载前置++运算符
     *  返回迭代器的引用
     *   移动到最底层的下一个节点
     */
       SkipListIterator& operator++()
       {
            current=current->forward[0];  // 移动到最底层的下一个节点
            return *this;
       }
 
    /**
     *  重载不等于运算符
     *  other 另一个迭代器对象
     *  如果当前节点指针不等于另一个迭代器的节点指针,返回true,否则返回false
     */
       bool operator!=(const SkipListIterator& other) const
       {
            return current!=other.current;
       }
    private:
     SkipListNode<T>* current;  // 指向当前节点的指针
 };

template<typename T,typename Comparator =std::less<T>>
 class SkipList
 {
      public:
      using Node= SkipListNode<T>;
      using Iterator=SkipListIterator<T>;
           SkipList(int _maxLevel=32, float _probability=0.5, Comparator comp = Comparator())
             : maxLevel(_maxLevel)
             , level(1)
             , head(nullptr)
             , nodecount(0)
             , comp(comp)
             , probability(_probability)
                {
                   
                   head = new Node(maxLevel, T());
                }
            ~SkipList()
            {
                 clear();
                 delete head;
            }
     void insert(const T& value)
{
    // 1. 准备工作:记录每一层插入位置的前驱节点
    // update[i] 表示在第 i 层中,新节点应该插入在 update[i] 之后
    std::vector<Node*> update(maxLevel, nullptr);
    Node* current = head; // 从跳表的头节点开始遍历

    // 2. 查找插入位置:从最高层开始查找,逐层下降
    for(int i = level - 1; i >= 0; i--)
    {
        // 在当前层向右查找,直到找到第一个大于或等于 value 的节点的前驱
        // comp(a, b) 通常是一个比较器(如 std::less),comp(a, b) 为真表示 a < b
        while(current->forward[i] && comp(current->forward[i]->value, value))
        {
            current = current->forward[i]; // 当前节点的值比 value 小,继续向右移动
        }
        // 退出 while 循环时,current->forward[i] 要么为空,要么 >= value
        // 此时 current 就是第 i 层中插入新节点的直接前驱
        update[i] = current;  
    }

    // 3. 检查值是否已存在(跳表通常不允许插入重复键值)
    // 因为上面的循环结束在第 0 层的插入前驱位置,所以检查第 0 层的下一个节点即可
    Node* next = current->forward[0];
    // 如果 next 存在,且 next->value 既不小于 value,value 也不小于 next->value
    // 这意味着 next->value == value(严格弱序下的等价判断)
    if(next && !comp(next->value, value) && !comp(value, next->value))
    {
        return;  // 值已存在,跳过插入,直接返回
    }

    // 4. 随机生成新节点的高度(层数)
    // 跳表的核心特性:通过随机层数来维持概率性平衡
    int newlevel = randomLevel();

    // 5. 处理新节点层数超过当前跳表最大层数的情况
    if(newlevel > level)
    {
        // 高出来的这些层(从原 level 到 newlevel-1),目前没有任何节点
        // 所以新节点在这些层的前驱节点只能是 head
        for(int i = level; i < newlevel; i++)
        {
            update[i] = head;
        }
        // 更新跳表当前的 最大层数
        level = newlevel;
    }

    // 6. 创建新节点
    Node* newNode = new Node(newlevel, value);

    // 7. 核心插入操作:逐层修改指针,将新节点插入到跳表中
    for(int i = 0; i < newlevel; i++)
    {
        // 经典的链表插入两步曲(在第 i 层进行):
        // 1. newNode 的 forward[i] 指向原前驱节点的后继
        Node* next = update[i]->forward[i];
        newNode->forward[i] = next;
        
        // 2. 原前驱节点的 forward[i] 指向 newNode
        update[i]->forward[i] = newNode;
        
        // 注意:上面两行也可以简写为:
        // newNode->forward[i] = update[i]->forward[i];
        // update[i]->forward[i] = newNode;
    }

    // 8. 节点计数加一
    nodecount++;
    
    return;
}
   bool isPresent(const T& value) const
{
    // 优化:如果跳表中没有节点,直接返回 false,避免不必要的空指针判断
    if(nodecount == 0)
    {
         return false;
    }

    // 从跳表的头节点开始遍历
    Node* current = head;

    // 从最高层开始向下搜索,逐层下降
    for(int i = level - 1; i >= 0; i--)
    {
        // 在当前层向右移动:
        // 只要当前节点的右指针不为空,且右节点的值严格小于 value,就继续向右走
        while(current->forward[i] && comp(current->forward[i]->value, value))
        {
             current = current->forward[i];
        }

        // 退出 while 循环时,说明当前层的下一个节点要么为空,要么 >= value
        // 检查当前层的下一个节点是否就是我们要找的 value
        // 逻辑与 insert 相同:既不小于 value,value 也不小于它,即两者等价
        if(current->forward[i] && !comp(current->forward[i]->value, value) && !comp(value, current->forward[i]->value))
        {
             // 找到精确匹配,直接返回 true
             return true;
        }
        // 如果当前层没找到,循环会自动让 i-- 降到下一层继续寻找
    }

    // 遍历完所有层(直到最底层的第 0 层)都没有找到等价的值,说明元素不存在
    return false;                      
}

void remove(const T& value)
{
    // 1. 边界检查:如果跳表为空,无需删除,直接返回
    if (nodecount == 0)
    {
        return;
    }

    // 2. 查找前驱节点:
    // 调用 findPredecessors 获取每一层中值小于 value 的最大节点(即前驱节点)
    // update[i] 记录了在第 i 层中,待删除节点的前一个节点
    std::vector<Node*> update = findPredecessors(value);

    // 3. 定位待删除节点:
    // 因为第 0 层包含所有节点,所以待删除节点一定是第 0 层前驱节点的直接后继
    Node* target = update[0]->forward[0];

    // 4. 判断节点是否存在:
    // 如果 target 存在,且 target->value 与 value 等价(既不小于也不大于,即相等)
    if(target != nullptr && !comp(target->value, value) && !comp(value, target->value))
    {
        // 获取待删除节点的层数,决定了我们需要修改多少层的指针
        size_t targetLevel = target->forward.size();

        // 5. 核心删除操作:逐层修改指针,将 target 从链表中摘除
        for (size_t i = 0; i < targetLevel; ++i)
        {
            // 经典的链表删除操作:让前驱节点直接跨过 target,指向 target 的后继
            // 相当于:update[i]->forward[i] = target->forward[i];
            update[i]->forward[i] = target->forward[i];
        }

        // 6. 释放内存,防止内存泄漏
        delete target;

        // 7. 节点计数减一
        --nodecount;

        // 8. 维护跳表的最大层数 level:
        // 如果删除的是最高层的节点,且最高层已经没有其他节点了,需要降低跳表的总层数
        // 这是为了避免跳表层数无限增长,维持结构的紧凑性
        while (level > 1 && head->forward[level - 1] == nullptr)
        {
            --level; // 当前最高层为空,层数减 1,继续检查下一层
        }
    }
    // 如果 target 为空或者值不相等,说明要删除的值不存在,什么都不做
}
void clear()
{
    // 1. 优化:如果跳表已经为空,直接返回,避免无意义的操作
    if(nodecount == 0)
    {
         return;
    }

    // 2. 遍历并删除所有数据节点
    // 核心技巧:跳表的第 0 层(最底层)是一个包含所有节点的完整有序链表。
    // 因此,我们只需要像遍历普通单链表一样,沿着第 0 层遍历即可访问到所有节点,
    // 而不需要逐层去删除,这大大简化了清空逻辑并保证了 O(N) 的时间复杂度。
    Node* current = head->forward[0]; // 获取第 0 层的头节点的下一个节点(即第一个真实数据节点)
    
    while(current)
    {
         // 先保存当前节点的下一个节点指针,因为删除当前节点后,将无法通过它获取下一个节点
         Node* next = current->forward[0];
         
         // 释放当前节点占用的堆内存,防止内存泄漏
         delete current;
         
         // 移动到下一个节点继续处理
         current = next;
    }
    
    // 3. 重置头节点的所有前向指针
    // 此时所有数据节点已被 delete,但头节点 head 仍然存在于栈或堆上(取决于实现)。
    // 必须将 head 的所有层的前向指针置为 nullptr,防止出现悬空指针。
    for(int i = 0; i < maxLevel; ++i)
    {
         head->forward[i] = nullptr;
    }

    // 4. 重置跳表的状态变量
    level = 1;      // 当前层数重置为初始值 1(通常跳表默认只有最底层)
    nodecount = 0;  // 节点计数归零
}

     
       Iterator begin() const
       {
            return Iterator(head->forward[0]);
       }
       Iterator end() const
       {
            return Iterator(nullptr);
       }
       bool empty() const
       {
            return nodecount==0;
       }
        size_t size() const
        {
             return nodecount;
        }
        int maxlevel() const
        {
             return maxLevel;
        }
        int currentlevel() const
        {
                return level;
        }

        SkipList(const SkipList&) = delete;
        SkipList& operator=(const SkipList&) = delete;
        private:
        /**
         * 查找指定值在跳表中的所有前驱节点
         *  value 要查找的值
         * return 包含各层前驱节点指针的向量
         */
        std::vector<Node*> findPredecessors(const T& value) const
        {
        // 初始化更新向量,用于存储各层的前驱节点指针
        // 向量大小为跳表的最大层级,初始值为nullptr
                std::vector<Node*> update(maxLevel, nullptr);
        // 从跳表的最高层开始查找,逐层向下
                Node* current=head;
                for(int i=level-1;i>=0;i--)
                {
            // 在当前层中向右移动,直到找到第一个大于或等于目标值的节点
            // comp函数用于比较节点值和目标值的大小关系
                     while(current->forward[i] && comp(current->forward[i]->value,value))
                     {
                // 向右移动指针
                           current=current->forward[i];
                     }
            // 记录当前层的前驱节点
                     update[i]=current;
                }
                return update;
        }
/**
 * 随机生成跳表节点的层级
 *  返回随机生成的层级,范围在1到maxLevel之间
 */
        int randomLevel()
        {
    // 初始化新层级为1,因为跳表至少有第一层
            int newlevel=1;
    // 当当前层级小于最大允许层级时,继续尝试提升层级
            while(newlevel<maxLevel)
            {
        // 以一定概率决定是否提升层级
        // 使用rand()函数生成0到1之间的随机数,与预设概率比较
                 if(rand()/double(RAND_MAX)<probability)
                 {
             // 如果随机数小于预设概率,则层级加1
                     newlevel++;
                 }else
                 {
             // 否则终止循环,不再提升层级
                      break;
                 }
            }
    // 返回最终生成的随机层级
            return newlevel;
        }
          int maxLevel;
          int level;
          Node* head;
          size_t nodecount;
          Comparator comp;
          float probability;
 };
 

test.cpp:

cpp 复制代码
#include <iostream>
#include <ctime>
#include <cstdlib>
#include "SkipList.hpp"

int main()
{
    std::srand((unsigned int)std::time(nullptr));

    SkipList<int> list(8, 0.5f);

    std::cout << "===== SkipList Test Start =====" << std::endl;

    std::cout << "Initial state:" << std::endl;
    std::cout << "Empty: " << (list.empty() ? "true" : "false") << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Max level: " << list.maxlevel() << std::endl;
    std::cout << "Current level: " << list.currentlevel() << std::endl;
    std::cout << std::endl;

    std::cout << "Insert values: 30, 10, 50, 20, 40, 30" << std::endl;
    list.insert(30);
    list.insert(10);
    list.insert(50);
    list.insert(20);
    list.insert(40);
    list.insert(30); // duplicate value, should not be inserted again

    std::cout << "After insertion:" << std::endl;
    std::cout << "Empty: " << (list.empty() ? "true" : "false") << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Current level: " << list.currentlevel() << std::endl;

    std::cout << "Elements in order: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl << std::endl;

    std::cout << "Search test:" << std::endl;
    std::cout << "Contains 10: " << (list.isPresent(10) ? "true" : "false") << std::endl;
    std::cout << "Contains 30: " << (list.isPresent(30) ? "true" : "false") << std::endl;
    std::cout << "Contains 35: " << (list.isPresent(35) ? "true" : "false") << std::endl;
    std::cout << "Contains 50: " << (list.isPresent(50) ? "true" : "false") << std::endl;
    std::cout << std::endl;

    std::cout << "Remove value 10." << std::endl;
    list.remove(10);

    std::cout << "Elements after removing 10: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Contains 10: " << (list.isPresent(10) ? "true" : "false") << std::endl;
    std::cout << std::endl;

    std::cout << "Remove value 30." << std::endl;
    list.remove(30);

    std::cout << "Elements after removing 30: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Contains 30: " << (list.isPresent(30) ? "true" : "false") << std::endl;
    std::cout << std::endl;

    std::cout << "Remove missing value 999." << std::endl;
    list.remove(999);

    std::cout << "Elements after trying to remove 999: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << std::endl;

    std::cout << "Clear the skip list." << std::endl;
    list.clear();

    std::cout << "After clear:" << std::endl;
    std::cout << "Empty: " << (list.empty() ? "true" : "false") << std::endl;
    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Current level: " << list.currentlevel() << std::endl;

    std::cout << "Elements after clear: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl << std::endl;

    std::cout << "Reinsert values: 7, 3, 9" << std::endl;
    list.insert(7);
    list.insert(3);
    list.insert(9);

    std::cout << "Elements after reinsertion: ";
    for (auto it = list.begin(); it != list.end(); ++it)
    {
        std::cout << *it << " ";
    }
    std::cout << std::endl;

    std::cout << "Size: " << list.size() << std::endl;
    std::cout << "Empty: " << (list.empty() ? "true" : "false") << std::endl;

    std::cout << "===== SkipList Test End =====" << std::endl;

    return 0;
}

运行截图:

结语

那么这就是本篇文章的全部内容,带你掌握以及实现时间轮,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!

相关推荐
Elastic 中国社区官方博客1 小时前
Elasticsearch Reindex 现已支持跨节点自动迁移:无需人工干预,不会丢失进度
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
Xin_ye100861 小时前
C# 零基础到精通教程 - WPF 深度专题:自定义布局与性能优化
开发语言·c#·wpf
学代码的真由酱1 小时前
MySQL数据库进阶-Java
数据库·mysql
Devin~Y1 小时前
从Spring Boot到AI Agent:大厂Java微服务面试三轮实战问答解析
java·spring boot·redis·spring cloud·微服务·ai·kafka
更深兼春远1 小时前
scala基于IDEA部署
开发语言·scala·intellij-idea
AIFQuant1 小时前
贵金属投资 APP 开发:实时报价、图表、提醒与交易数据全链路
开发语言·前端·websocket·金融·web app
小七在进步1 小时前
C语言:编译与链接
c语言·开发语言
shuoshuohaohao1 小时前
《JavaScript》
开发语言·前端·javascript
ch.ju1 小时前
Java程序设计(第3版)第四章——私有属性
java·开发语言