C++实现跳表:一、跳表简介

本节是 K-V 存储引擎项目的第一章节,最主要目的是用于介绍项目中的核心数据结构 —— 跳表的基本原理。次要目的则是了解整个项目的结构,以及全部章节的内容安排。

1. 章节安排

完整章节总共有 9 章(包括本章)。9 章的内容分别如下:
第一章、跳表简介(本章节)
在实现一个基于跳表的 K-V 存储引擎之前,我们需要了解一下跳表的基本原理,第一章将会介绍跳表的基本概念和原理,并不涉及到具体的实现细节。并且将会介绍一些工业软件上使用跳表的案例。
第二章、跳表的定义
第二章将会介绍在具体实现基于跳表的 K-V 存储引擎时底层使用的数据结构。具体来说,我们会在第二章明确跳表类的定义,介绍对外提供各种操作的接口,并且在后续章节中会详细介绍各个接口的具体实现。
第三章、跳表的层级
作为一种用于存储有序元素,并且可以替代平衡树的数据结构,跳表的分层机制在这其中起到了关键的作用,第三章将会介绍这个关键并且十分简单易懂的概念以及具体实现。
第四章、跳表的插入
第四章的主题是跳表的插入,在这章我们会具体介绍跳表中是如何插入节点的,并且由于跳表的插入操作是依赖于跳表的搜索操作的,所以跳表的搜索操作也将会在这章进行介绍。
第五章、跳表的删除
第五章的主题是跳表的删除。在这章我们会具体介绍跳表中是如何删除节点的。
第六章、跳表的展示
第六章的主题是跳表的展示。在实现了跳表的插入操作和删除操作后,我们需要验证这两种操作的正确性,所以我们需要将跳表中的数据打印出来。
第七章、生成持久化文件
第七章的主题是生成持久化文件。在本章中,我们会介绍如何将内存中跳表的数据生成持久化文件,以及将持久化文件中的数据读取内存中的跳表内。
第八章、模块合并
第八章的主题是模块合并,在本章中,我们会介绍如何将前面所有章节分别介绍的各个模块合并成一个完整的 K-V 存储引擎,并将其编译为可执行文件。
第九章、压力测试
第九章的主题是压力测试,将会提供一些对 K-V 存储引擎进行压力测试的方案。

2. 存储引擎简介

本项目是使用 C++ 开发、基于跳表实现的轻量级键值数据库。实现了插入数据、删除数据、查询数据、数据展示、生成持久化文件、恢复数据以及数据库大小显示等功能。

项目整体上拥有一个 skiplist.h 文件。拥有两个核心类:Node 类、 SkipList 类。
Node 类是存储引擎中用于存放实际数据的类,而 SkipList 则对外提供了组织,访问,操作 Node 类的功能。

3. 什么是跳表

为了深入理解本项目中采用的数据结构 — 跳表,我们必须从其基础出发:链表。链表是许多复杂数据结构的基石,在这其中也包括了跳表。

跳表(Skip List)是由 William Pugh 发明的一种数据结构,他本人对跳表的评价是:“跳跃列表是在很多应用中有可能替代平衡树而作为实现方法的一种数据结构。跳跃列表的算法有同平衡树一样的渐进的预期时间边界,并且更简单、更快速和使用更少的空间。”

设想我们的存储引擎是以有序链表作为基础构建的。在这样的设置下,存储引擎中的数据结构呈现如下特点:
在这里插入图片描述
其中每个节点都存储着一对键值对。为了便于理解,假设其中键是数字,值是字符串,并且它们按键的顺序排列。这便构成了一个基于有序链表的简易键值(K-V)存储引擎。

设想现在我们需要在存储引擎中查找特定键(比如 key = 6)对应的值。由于单链表的线性结构,我们不得不从头节点开始,逐个遍历节点。

例如,在查找 key = 6 的过程中,我们需要按顺序检查每个节点,即查找路径为 1 -> 2 -> 3 -> 4 -> 5 -> 6。这种方法的时间复杂度为 O(n),在数据量庞大时效率低下。

因此,需要一种更高效的查找方法,而跳表正是这样的一种解决方案。

首先需要明确,我们所有的优化操作都基于链表是有序的这一前提。

那么,问题来了:我们该如何提升原有链表的查找速度呢?

如下图所示:
在这里插入图片描述
为了提高查找效率,我们采取了一种独特的策略:从原链表中选取关键节点作为索引层。这些被选出的节点形成了一个新的,较原链表更为简短的链表。由于原链表本身是有序的,索引层中的节点也同样保持有序,利用这个有序性,我们能够加快查找速度。

以查找 key = 6 的节点为例。在传统的单链表中,我们需要从头至尾逐个检查节点。例如,我们首先比较 key = 1 的节点,发现它小于 6,然后继续比较 key = 2 的节点,如此循环。

但在跳表中,情况就大不相同了。我们首先检查第一层索引,比较 key = 1 的节点后,可以直接跳到 key = 3 的节点,因为 6 大于 3,我们再跳到 key = 5 的节点。在这个过程中,我们省略了与 key = 2 和 key = 4 的节点的比较,但实际上,通过与 key = 3 和 key = 5 的比较,我们已经间接地排除了它们。

如此一来,查找路径缩短为 1 -> 3 -> 5 -> 6。与原始的单链表相比,效率有所提升。

那么,如果我们在第一层索引上再构建一层索引会怎样呢?
在这里插入图片描述当我们从第二层索引开始进行查找时,查找会变得更加高效。在比较了 key = 1 的节点 和 key = 6 的节点后,我们不再逐个检查 key = 2、key = 3 和 key = 4 的节点,而是直接跳到 key = 5 节点进行比较。如此一来,整个查找路径便缩短为 1 -> 5 -> 6。

在节点数量众多且索引层级充足的情况下,这种查找方法的效率极高。例如,如果在每层索引中,每两个节点就有一个被提升为上一层的索引,那么查找的时间复杂度可以降至 O(log n),这与二分查找的效率相仿。

这样的机制不仅显著提升了查找效率,还在保持链表灵活性的同时,为我们的存储引擎带来了接近二分查找的高效性能。

4. 跳表如何搜索节点

由于搜索操作是跳表中最基础的功能,不管是跳表的插入、删除,都依赖其搜索操作。所以我们先从跳表的搜索机制说起。

跳表的搜索流程:

开始于顶层索引:首先定位到跳表最顶层索引的首个节点
水平遍历:从最顶层的首个节点开始向右遍历。如果当前节点的下一个节点的值小于或等于待查找的值,表明该节点左侧的所有节点都小于或等于待查找值,此时跳转到下一个节点
下沉操作:若当前节点的下一个节点的值大于待查找值,意味着所需查找的节点位于当前位置左侧的某处,此时执行下沉操作,即向下移动到较低层的同一位置
重复查找与下沉:继续执行第二步和第三步的操作,直到到达最底层链表。在此层继续向右移动,直到找到目标节点或达到链表末端
现有一个跳表结构,目标是查找值为 70 的数据,让我们模拟一下这一查询过程:
在这里插入图片描述

如图一所示,从最顶层开始,首个节点为 30,既然 30 小于 70,我们继续向右,比较下一个节点 80。发现 80 大于 70,此时,我们需要执行下沉操作,移动到下一层索引,如图二所示。

在图二中,我们比较节点 50 与 70。由于 50 小于 70,我们向右移动到下一个节点,如图三所示。
在这里插入图片描述

在图三,我们继续比较节点 50 的下一个节点 80 与 70。由于 70 小于 80,再次执行下沉操作,如图四所示。

在图四,我们将节点 60(即 50 的下一个节点)与 70 进行比较。此时,70 大于 60,因此我们向右移动,结果如图五所示。
在这里插入图片描述
到了图五,我们比较节点 60 的下一个节点 80 与 70。由于 70 小于 80,我们再次下沉到更底层的索引,如图六所示。

最后,在图六中,我们比较节点 60 的下一个节点 70。发现我们已经成功找到了目标值 70,此时查找成功。

5. 跳表如何插入节点

5.1 跳表节点的有序性

因为跳表的所有节点都是有序排列的,无论是插入还是删除操作,都必须维持这种有序性。

这一点使得跳表具有与平衡树相似的特性,即它的任何操作都密切依赖于高效的查询机制。

为了维护跳表中节点的有序性,我们必须先通过搜索找到一个合适的位置进行操作。

以插入一个新节点为例,假设我们需要插入数值为 61 的节点。如下图所示,在执行跳表的搜索操作后,我们可以定位到一个特定的区域:此区域的左侧节点值小于 61,而右侧节点值大于 61。在确定了这一位置之后,我们便可在此处插入新节点 61。
在这里插入图片描述
实际上,我们已经成功地在跳表中插入了一个节点,并且能够有效地搜索到这个节点。

然而,如果我们持续向跳表中添加数据,而忽视对索引的更新,这将导致跳表效率的显著退化。在最极端的情况下,这种效率下降甚至可能使跳表的查询效率降至 O(n),与普通链表的查询效率相当。
在这里插入图片描述

5.2 插入数据时需要维护索引

从以上案例中,我们可以看出,为了保持跳表的高查询效率,其索引必须进行动态更新。

考虑到这一点,一种可能的思路是,在每次插入新节点时,删除所有现有索引,并从每两个节点中抽取一个作为新索引,再逐层执行此操作。虽然这个方法概念上简单,但实际上它效率低下,并且实现起来相当复杂。

5.3 随机过程决定索引层级

跳表的索引构建是一个层层递进的过程。理想情况下,在原始链表中,我们每隔一个节点选择一个作为上层的索引。然后,把这一层的索引视为新的基础链表,重复同样的选择过程,直到顶层索引仅包含两个节点。

换句话说,由于任何节点都有一半的概率被选为上层的索引,一个节点出现在不同层级的概率呈逐层减半的趋势。例如,一个节点在第 1 层的出现概率是 100%,在第 2 层是 50%,在第 3 层是 25%,以此类推。

在跳表中,如果一个节点出现在较高层级,它必然出现在所有较低的层级。例如,一个节点若出现在第 3 层,那么它必定存在于第 2 层和第 1 层。

所以,我们可以在节点插入的时候,就通过某种随机分层机制,确定它所在的层级。

而这种机制需要保证每个节点有 100% 的概率出现在第 1 层,50% 的概率出现在第 2 层,25% 的概率出现在第 3 层,依此类推,通过这种概率分布,我们能有效地平衡跳表的层级结构和搜索效率。

下面是一个简单的算法实现,用于确定跳表中节点的层级:

int randomLevel() {
    int level = 1;
    while (random() % 2) {
        level++;
    }
    return level;
}

在这个算法中,random() 函数每次生成一个随机数。如果这个随机数是奇数,节点的层级就增加 1;如果是偶数,循环结束并返回当前层级 level。我们可以假设 random() 生成的奇数和偶数的概率各为 50%。

因此,节点层级增加到 2 的概率是 50%。而层级增加到 3 的概率,即连续两次产生奇数,概率为 25%,以此类推。

根据这个算法确定的层级,我们可以将节点插入到跳表中的相应层级。例如,在下图中,我们插入了数值为 61 的节点,并且 randomLevel() 方法返回了 3,表示它会出现在第 1 层、第 2 层和第 3 层。
在这里插入图片描述
最后,让我们解释为什么这种方法能有效维护跳表的索引。根据大数定律,当我们进行足够多的试验时,某个事件发生的实际频率会逐渐接近于该事件发生的理论概率。应用到跳表中,随着节点数量的增加,我们可以合理地假设每一层的节点数量大约是下一层的一半。这是因为每个节点在每一层出现的概率都是前一层的一半。因此,通过这种方法,跳表的索引能够得到有效的维护,保持其结构和效率。

6. 跳表如何删除节点

理解了跳表的搜索操作与插入操作之后,删除操作也就不难理解了。此同样十分依赖于跳表的查询操作。在跳表中搜索到想要删除的节点后,需要将该节点以及该节点的索引一并删除。

7. 应用跳表的工业软件

Redis:这是最著名的使用跳表的例子。Redis 是一个开源的高性能键值存储数据库,广泛用于缓存和消息队列等场景。在 Redis 中,跳表被用于实现有序集合(sorted sets)数据类型,这主要是因为跳表在处理有序数据时可以提供良好的性能,尤其是在插入、删除和搜索操作上。

LevelDB:LevelDB 是一个由 Google 开发的快速键值存储库,用于存储非结构化数据。它使用跳表作为其内存中的数据结构,以支持快速的写入操作。跳表在 LevelDB 中帮助实现高效的数据插入和排序。

RocksDB:RocksDB 是 LevelDB 的一个分支,由 Facebook 进行了大量优化。它同样使用跳表来处理内存中的数据,以优化读写操作的性能。

Apache HBase:HBase 是一个分布式、可伸缩的大数据存储,基于 Google 的 BigTable 模型。在 HBase 中,跳表被用于实现 MemStore,即内存中的存储结构,以支持快速的数据写入和读取。

  • 0
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
抱歉,我是一个AI语言模型,无法提供完整的编程实现。但我可以给您提供一些跳表类的模型的基本思路: 跳表是一种基于有序链表的数据结构,它通过将链表中的一些节点连接起来,形成多级索引,从而实现快速查找的目的。跳表中的每个节点包含一个值和一个指向下一个节点的指针,同时还包含若干个指向下一级索引节点的指针。 跳表的插入、删除、查找操作都比较简单,主要是通过多级索引快速定位到目标节点,然后再进行相应的操作。下面是一个跳表类的模型的基本框架: ```c typedef struct skip_node { int value; struct skip_node **forwards; } skip_node; typedef struct skip_list { int level; int max_level; skip_node *header; } skip_list; skip_node* create_node(int value, int level); skip_list* create_list(int max_level); void insert(skip_list *list, int value); void delete(skip_list *list, int value); skip_node* find(skip_list *list, int value); void print_list(skip_list *list); ``` 其中,skip_node表示跳表中的节点,skip_list表示跳表的头结点,包含了跳表的最大层数和当前的层数。create_node用于创建一个新节点,create_list用于创建一个新的跳表,insert用于向跳表中插入一个元素,delete用于从跳表中删除一个元素,find用于查找跳表中的一个元素,print_list用于输出跳表的所有元素。 具体的实现细节可以参考跳表的相关文献或者网上的代码实现
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值