本节将深入探讨 K-V 存储引擎实现的关键组成部分:Node 类与 SkipList 类。Node 类的核心在于其属性,特别是其 forward 属性。可以说理解了 forward 属性,就理解了整个 K-V 存储引擎底层使用数据结构 —— 跳表。
而 SkipList 类的重点在于其提供的一系列公共成员函数,这些函数负责组织和管理 Node 类的实例。后续的章节将围绕这些函数进行展开,详细介绍它们的实现和应用。
1. Node 类
1.1 Node 类中的关键属性
在开发一个基于跳表的 K-V 存储引擎、设计用于实际存储数据的 Node 类(节点)时,我们需要考虑以下三个因素。
(为了明确语义以及方便叙述,后文中所有的名词「节点」都是代指 Node 类,更具体的来说是指 Node 类的实例)
- 键值存储机制:如何存储键和对应的值
- 跳转机制实现:跳表的搜索操作核心在于节点间的跳转,这如何实现
- 层级确定:如何确定节点存在于跳表中的哪些层级
针对上述第一点和第三点考虑因素,Node 类需要包含两个主要私有属性:key 和 value,分别用于存储键和值。此外,Node 类还有一个 node_level 公共属性,用于标识节点在跳表中的层级位置。
- 当 node_level = 1 时,表示当前的节点只会出现在跳表中的第 1 层
- 当 node_level = 2 时,表示当前的节点会出现在跳表中的第 2 层和第 1 层
- 以此类推
此时,Node 类的结构如下:
template <typename K, typename V>
class Node {
public:
int node_level;
private:
K key;
V value;
}
在介绍了 Node 类中负责键值存储和标识节点层次的属性之后,下文将会介绍用于支持节点间跳转机制的属性。(所谓跳转,指的是通过定义一种特定的指针机制,使得该指针能够以一定规则指向跳表中的各个节点。以单链表为例,其 next 指针便是一种实现节点间顺序跳转功能的关键属性)
在 “跳表简介” 章节中模拟跳表搜索部份,我们知道节点间的跳转机制可以分为两大类:
- 同一节点的多层跳转:在相同键值、不同层级节点之间的跳转
- 不同节点的单层跳转:在相同层级、不同键值节点之间的跳转
通过这两类跳转机制的结合,我们可以在跳表中灵活地实现不同层级和不同节点之间的跳转。
(就像通过 x 坐标和 y 坐标结合,可以表示坐标轴内上的任意一个点一样)
那么,能够支持这两种节点间的跳转机制的属性长什么样子呢?
让我们先聚焦于第二种跳转机制:不同节点的单层跳转。这实际上与单链表的结构相似。
在单链表中,每个节点由两部分组成:数据域和指针域。数据域负责存储当前节点的值,而指针域则指向下一个节点,将各个单独的节点链接起来。
链表结构的实现如下:
class LinkList {
int data;
LinkList* next;
}
在单链表的结构中,可以通过访问当前节点的 next 指针,来实现从当前节点到下一个节点之间跳转的功能。这个 next 指针指向链表中的后续节点,从而使我们能够从当前节点顺利跳转到紧随其后的节点。
// 单链表实现节点跳转的简单实现
void travasal(LinkList* listHeader) {
LinkList* current = listHeader;
while (current->next != nullptr) {
current = current->next;
}
}
所以,我们可以借鉴单链表中访问 next 指针的成员函数,来实现跳表内同一层级上不同节点间的跳转功能。也就是说,节点内部用于支持跳转功能的属性,实质上是一种指针。这个指针将会指向当前节点同一层中的后一个节点。
现在还需要解决节点跳转的第一个问题,就是节点内的该属性如何支持节点在其不同层级间的跳转呢?
到目前为止,我们可以通过 node_level 属性确定一个节点会在跳表的哪些层级出现。基于这一点,我们可以采用数组结构来组织一个节点在不同层级的指针。这意味着,用于支持两种跳转功能的属性,实际上是一个指针数组,数组其中的每个指针对应节点在一个特定层级的后继节点。通过变更数组下标,我们便能够实现同一节点在不同层级之间的跳转功能。这样的设计不仅保持了结构的简洁性,也为跳表提供了必要的灵活性和效率。
为了保持一致性和易于理解,我们将这个指针数组命名为 forward,这个命名方式与大多数跳表实现中的惯例相同。
最终的节点定义如下:
template <typename K, typename V>
class Node {
public:
Node<K, V>** forward; // 在 C++ 中,二维指针等价于指针数组
int node_level;
private:
K key;
V value;
};
假设一个节点在跳表中的层级为 3,那么这个节点的 forward 指针数组的大小为 3。其 forward[0] 指向该节点在第一层的下一个节点;forward[1] 指向该节点在第二层的下一个节点,forward[2] 指向该节点在第三层的下一个节点。
完成节点的最终定义后,我们再介绍这个结构的跳转机制是如何运作的。
同一层级内节点的跳转:
/**
* 遍历跳表的底层链表
* current : 指向当前遍历节点的指针
*/
Node<K,V>* current = head; // 假设 head 是跳表第一层的头节点
while (current->forward[0] != nullptr) {
// 通过迭代的方式,实现同一层内的不同节点之间的跳转
current = current->forward[0];
}
不同层级内同一节点的跳转:
/**
* 同一个节点,不同层级之间的跳转
* node : 当前节点
* n : 节点所在的最高层级
*/
Node<K,V>* node; // 假设 node 是当前节点
int n = node->forward.size(); // 假设 forward 是动态数组
for (int i = n - 1; i >= 0; i--) {
// 通过变更数组下标进行同一个节点在不同层级之间的跳转
Node<K,V>* current = node->forward[i];
}
1.2 Node 类的代码实现
在定义完毕 Node 类关键的属性之后,还需要一些基本的问题需要处理。例如获取 / 设置key 对应的 value、构造函数的实现、析构函数的实现等。由于相对简单,就不做过多的介绍,以下是详细实现。
template <typename K, typename V>
class Node {
public:
Node() {}
Node(K k, V v, int);
~Node();
K get_key() const;
V get_value() const;
void set_value(V);
Node<K, V> **forward;
int node_level;
private:
K key;
V value;
};
template <typename K, typename V>
Node<K, V>::Node(const K k, const V v, int level) {
this->key = k;
this->value = v;
this->node_level = level;
this->forward = new Node<K, V> *[level + 1];
memset(this->forward, 0, sizeof(Node<K, V> *) * (level + 1));
};
template <typename K, typename V>
Node<K, V>::~Node() {
delete[] forward;
};
template <typename K, typename V>
K Node<K, V>::get_key() const {
return key;
};
template <typename K, typename V>
V Node<K, V>::get_value() const {
return value;
};
template <typename K, typename V>
void Node<K, V>::set_value(V value) {
this->value = value;
};
2. SkipList 类
在确定了具体用于存储键值对的 Node 类之后,现在我们需要设计一个能组织和管理存储引擎 Node 类的 SkipList 类。
2.1 SkipList 属性
头节点:作为跳表中所有节点组织的入口点,类似于单链表
最大层数:跳表中允许的最大层数
当前层数:跳表当前的层数
节点数量:跳表当前的组织的所有节点总数
文件读写:跳表生成持久化文件和读取持久化文件的写入器和读取器
具体定义如下:
template <typename K, typename V>
class SkipList {
private:
int _max_level; // 跳表允许的最大层数
int _skip_list_level; // 跳表当前的层数
Node<K, V> *_header; // 跳表的头节点
int _element_count; // 跳表中组织的所有节点的数量
std::ofstream _file_writer; // 文件写入器
std::ifstream _file_reader; // 文件读取器
};
2.2 SkipList 成员函数
在定义完毕 SkipList 类的关键属性后,我们还需要设计出组织和管理 Node 类的成员函数。
核心成员函数:
节点创建:生成新的节点实例
层级分配:为每个新创建的节点分配一个合适的层数
节点插入:将节点加入到跳表中的适当位置
节点搜索:在跳表中查找特定的节点
节点删除:从跳表中移除指定的节点
节点展示:显示跳表中所有节点的信息
节点计数:获取跳表中当前的节点总数
数据持久化:将跳表的数据保存到磁盘中
数据加载:从磁盘加载持久化的数据到跳表中
垃圾回收:对于删除的节点,需要回收其内存空间
获取节点数量:获取跳表组织的节点个数
接口的具体代码如下:
template <typename K, typename V>
class SkipList {
public:
SkipList(int); // 构造函数
~SkipList(); // 析构函数
int get_random_level(); // 获取节点的随机层级
Node<K, V> *create_node(K, V, int); // 节点创建
int insert_element(K, V); // 插入节点
void display_list(); // 展示节点
bool search_element(K); // 搜索节点
void delete_element(K); // 删除节点
void dump_file(); // 持久化数据到文件
void load_file(); // 从文件加载数据
void clear(Node<K, V> *); // 递归删除节点
int size(); // 跳表中的节点个数
private:
// ...
};
在定义完毕 SkipList 类中的属性和成员函数之后,后续的章节内容就是实现上述的各个函数。
3 小结
我们实现一个 Node 类,用于表示跳表中的节点。
Node 类的定义如下,这是本存储引擎项目中拥有完整功能的类,在定义完毕之后,我们分别实现 Node 类的构造函数,析构函数,获取值,设置值等成员函数。
#include <iostream>
#include <cstdlib>
#include <cmath>
#include <cstring>
// 定义节点
template <typename K, typename V>
class Node {
public:
Node() {}
Node(K k, V v, int);
~Node();
K get_key() const;
V get_value() const;
void set_value(V);
Node<K, V> **forward;
int node_level;
private:
K key;
V value;
};
// 类拥有的构造函数
template <typename K, typename V>
Node<K, V>::Node(const K k, const V v, int level) {
this->key = k;
this->value = v;
this->node_level = level;
this->forward = new Node<K, V> *[level + 1];
memset(this->forward, 0, sizeof(Node<K, V> *) * (level + 1));
};
// 类拥有的析构函数
template <typename K, typename V>
Node<K, V>::~Node() {
delete[] forward;
};
// 类拥有的获取 key 成员函数
template <typename K, typename V>
K Node<K, V>::get_key() const {
return key;
};
// 类拥有的获取 value 成员函数
template <typename K, typename V>
V Node<K, V>::get_value() const {
return value;
};
// 类拥有的设置 value 成员函数
template <typename K, typename V>
void Node<K, V>::set_value(V value) {
this->value = value;
};
int main() {
int K, V, L; // 定义变量
std::cin >> K >> V >> L; // 获取变量
// 创造对应的类
Node<int, int> *node = new Node<int, int>(K, V, L);
// 调用 get_key 成员函数和 set 成员函数
std::cout << node->get_key() << " " << node->get_value() << std::endl;
// 释放内存
delete node;
return 0;
}