目录
前言
A.建议
1.学习算法最重要的是理解算法的每一步,而不是记住算法。
2.建议读者学习算法的时候,自己手动一步一步地运行算法。
B.简介
Skip List(跳跃表)是一种高效且易于实现的随机化数据结构,用于维护有序序列。它结合了链表和二分查找的思想,通过牺牲额外的空间来换取对有序数据进行快速插入、删除和查找的操作效率,这些操作在平均情况下均能达到的时间复杂度。
一 代码实现
A.数据结构
Skip List由多个层次的链表组成,其中最底层链表包含了所有元素,而上层链表则是对底层链表的稀疏采样。每个节点除了存储实际数据(如键值对)外,还包含若干个指向同一链表中后续节点的指针,这些指针的数量决定了节点所在的层次。层次越高,节点之间的跨度越大,即“跳跃”得越远。下图展示了Skip List的一个简化示例:
+------------------+
| Level 3 |
+------------------+
↓
+------------------+
| Level 2 |
+------------------+
↓
+------------------+
| Level 1 |
+------------------+
↓
+------------------+
| Level 0 |
+------------------+
(最底层链表)
每个节点的具体结构如下:
typedef struct Node {
int key; // 节点的键值
int value; // 节点对应的值(如果适用)
struct Node* next[1]; // 指向同一层下一个节点的指针数组,动态分配大小
} Node;
// 动态分配Node结构体时,next数组的实际大小根据所需层数确定
Node* createNode(int level, int key, int value) {
size_t nodeSize = sizeof(Node) + level * sizeof(Node*);
Node* newNode = (Node*)malloc(nodeSize);
newNode->key = key;
newNode->value = value;
return newNode;
}
B.工作原理
Skip List的关键在于节点层次的随机化生成。当插入新节点时,通过一个概率函数(通常为抛硬币或使用伪随机数生成器)决定其应具有的层数。这样,每个新节点都有一定的概率“晋升”到更高的层次,使得高层链表成为底层链表的稀疏索引。
查找、插入和删除操作均从最高层开始,沿着链表依次向下进行。在每一层,通过比较节点的键值来决定是否“跳跃”到下一个节点。直到找到目标节点(查找)、找到合适的位置插入新节点(插入),或找到待删除节点(删除)。由于高层链表的稀疏性,查找过程能够在平均情况下减少比较次数,从而实现高效的搜索。
C.C语言实现概览
下面简述一下使用C语言实现Skip List的主要步骤和函数:
a. 初始化
创建一个表示Skip List的数据结构,包括头节点(通常包含所有层次的指针,但不存储有效数据)和当前最大层数。
typedef struct SkipList {
int maxLevel; // 当前最大层数
float probability; // 层级增长的概率(如0.5)
Node* header; // 头节点
} SkipList;
SkipList* createSkipList(float p) {
SkipList* list = (SkipList*)malloc(sizeof(SkipList));
list->maxLevel = INITIAL_LEVEL; // 初始层数
list->probability = p;
list->header = createNode(list->maxLevel, INT_MIN, 0); // 使用最小可能的键值初始化头节点
// 初始化头节点的所有指针为NULL
for (int i = 0; i < list->maxLevel; i++) {
list->header->next[i] = NULL;
}
return list;
}
b. 随机层级生成
实现一个函数,根据给定的概率生成新节点的层级。通常采用几何分布模拟抛硬币的过程。
int randomLevel(SkipList* list) {
int level = 1;
while ((rand() / (float)RAND_MAX) < list->probability && level < list->maxLevel) {
level++;
}
return level;
}
c. 查找
从顶层开始,逐层向下查找指定键值的节点。返回找到的节点或NULL(未找到)。
Node* search(SkipList* list, int targetKey) {
Node* x = list->header;
for (int i = list->maxLevel - 1; i >= 0; i--) {
while (x->next[i] != NULL && x->next[i]->key < targetKey) {
x = x->next[i];
}
}
x = x->next[0]; // 最底层的实际节点
if (x != NULL && x->key == targetKey) {
return x; // 找到目标节点
}
return NULL; // 未找到
}
d. 插入
首先查找目标位置,然后创建一个具有随机层级的新节点,并更新沿途节点的指针以包含新节点。
void insert(SkipList* list, int key, int value) {
Node** update = (Node**)malloc(list->maxLevel * sizeof(Node*)); // 用于保存沿途节点的指针
Node* x = list->header;
for (int i = list->maxLevel - 1; i >= 0; i--) {
while (x->next[i] != NULL && x->next[i]->key < key) {
x = x->next[i];
}
update[i] = x; // 记录当前层的前驱节点
}
int newNodeLevel = randomLevel(list);
if (newNodeLevel > list->maxLevel) {
// 更新最大层数并调整头节点
list->maxLevel = newNodeLevel;
for (int i = list->maxLevel; i > list->header->level; i--) {
list->header->next[i] = NULL;
}
}
Node* newNode = createNode(newNodeLevel, key, value);
for (int i = 0; i <= newNodeLevel; i++) {
newNode->next[i] = update[i]->next[i];
update[i]->next[i] = newNode;
}
free(update);
}
e. 删除
类似于查找过程,找到目标节点后,回溯沿途节点并更新它们的指针以移除目标节点。
void deleteNode(SkipList* list, int key) {
Node** update = (Node**)malloc(list->maxLevel * sizeof(Node*));
Node* x = list->header;
for (int i = list->maxLevel - 1; i >= 0; i--) {
while (x->next[i] != NULL && x->next[i]->key < key) {
x = x->next[i];
}
update[i] = x;
}
x = x->next[0]; // 最底层的实际节点
if (x != NULL && x->key == key) {
for (int i = list->maxLevel - 1; i >= 0; i--) {
if (update[i]->next[i] == x) {
update[i]->next[i] = x->next[i];
}
}
free(x);
}
free(update);
}
D.总结
Skip List通过构建多层链表结构,利用随机化策略提高数据结构的查询效率,实现了在O(log n)时间内完成插入、删除和查找操作。上述C语言实现提供了Skip List的基本框架,包括结构定义、关键函数的实现以及随机层级生成方法。实际应用中可能还需要添加边界条件检查、错误处理以及适当的内存管理机制。
二 优缺点
Skip List(跳跃表)作为一种数据结构,具有以下显著的优点和缺点:
A.优点:
-
高效查找性能:
- 时间复杂度:在平均情况下,Skip List支持在时间内完成查找、插入和删除操作,与平衡二叉查找树(如AVL树、红黑树等)的性能相当,这得益于其分层索引结构,允许在较高层链表中快速“跳跃”过大量无关节点。
-
易于理解和实现:
- 代码简洁:相较于复杂的平衡二叉树,Skip List的逻辑相对简单,更易于理解和实现。不需要复杂的旋转操作来维持平衡,仅需维护链表指针和随机层级生成。
-
良好的并发支持:
- 无锁实现:由于底层基于链表,Skip List在实现并发访问控制时可以采取无锁或基于CAS(Compare-and-Swap)的乐观锁策略,降低了线程间同步的开销。这使得它在高并发环境中表现良好,如Redis中的有序集合(Sorted Set)就采用了无锁的Skip List实现。
-
动态调整:
- 无需显式 rebalance:在插入和删除操作过程中,Skip List无需像平衡二叉树那样进行显式的平衡调整。层级的增减是随机且局部的,这使得它在面对数据集动态变化时更为灵活。
-
区间查询便利:
- 支持范围查询:Skip List能够方便地支持在有序序列中查找特定范围内的元素,只需在最高层找到范围的起始和结束节点,然后在底层链表中遍历即可。相比之下,虽然平衡二叉树也能实现范围查询,但在实现和理解上可能更为复杂。
B.缺点:
-
额外空间开销:
- 空间换时间:Skip List为了实现高效的查找,引入了多层链表结构和额外的指针,导致其空间复杂度高于单链表。尽管实际空间消耗通常低于平衡二叉树,但在内存敏感的场景中,可能需要权衡其空间效率。
-
随机化带来的不确定性:
- 性能波动:由于节点层级的生成依赖于随机过程,极端情况下可能会导致实际性能偏离理论上的O(log n)。尽管这种情况发生的概率较低且对整体性能影响有限,但在对性能要求极为严格的系统中,这种不确定性可能需要考虑。
-
插入和删除操作的复杂性:
- 更新多个指针:与单链表相比,插入和删除节点时需要更新跨越多层的多个指针。虽然操作逻辑并不复杂,但涉及的指针数量较多,可能导致代码实现略显繁琐。
综上所述,Skip List适合在追求查找性能、易于实现和维护、需要高效并发支持以及频繁进行区间查询的场景中使用,尤其是在这些需求优先于严格空间效率的情况下。然而,如果对内存使用有严格限制,或者对性能波动的容忍度较低,可能需要考虑其他数据结构,如B树、B+树或紧凑型哈希表等。
三 现实中的应用
Skip List数据结构在现实中有多种应用场景,以下是几个典型的应用实例:
-
数据库与存储系统:
- 索引结构:在数据库管理系统中,Skip List可以作为二级索引来加速对有序数据的查询,特别是在需要快速进行范围查询的场景。例如,它可以用于实现关系型数据库中的索引结构,提高SQL查询的执行速度。
-
缓存系统:
- 键值存储:在分布式缓存系统(如Redis)中,Skip List被用于实现有序集合(Sorted Set)数据类型。这使得用户不仅可以高效地查找单个键值对,还能进行按序检索、范围查询以及计算排名等操作。
-
搜索引擎:
- 倒排索引:搜索引擎构建倒排索引时,Skip List可用于快速查找包含特定关键词的文档列表,尤其是在需要支持“模糊匹配”(如近似查询、短语查询)和排序结果时。
-
实时分析与流处理:
- 时间序列数据:在处理时间序列数据或事件流时,Skip List可用于快速定位到某个时间窗口内的数据,支持高效的时间窗口查询和滑动窗口统计。
-
地理信息系统(GIS):
- 地理坐标索引:在地理信息系统中,Skip List可用于对二维空间中的点进行索引,以便快速查找位于特定区域内的地理对象,尤其适用于需要频繁进行范围查询的GIS应用。
-
金融交易系统:
- 订单簿管理:在金融市场中,Skip List可用于维护买卖订单簿,支持按照价格优先、时间优先的原则快速查找、插入和删除订单,确保高效撮合交易。
-
图数据处理:
- 邻接列表优化:在图数据结构中,尤其是大规模稀疏图,Skip List可以用于优化邻接列表的存储和查询,加快对节点邻居的查找速度,尤其是在进行广度优先搜索(BFS)、最短路径计算等操作时。
-
并发编程与数据结构库:
- 并发容器:在并发编程中,如Java的
ConcurrentSkipListSet
和ConcurrentSkipListMap
,Skip List被用来实现线程安全且高效并发访问的有序集合和映射,适用于多线程环境下的高性能数据管理。
- 并发容器:在并发编程中,如Java的
以上列举的应用场景展示了Skip List在处理有序数据、支持快速查询和更新、以及在并发环境下保持高效性等方面的广泛应用。其简单的设计、优秀的性能和对并发友好的特性使其成为许多实际系统中不可或缺的数据结构组件。