跳表是对链表结构的一种优化。
链表的缺点是无法随机访问节点,每次访问元素都需要进行遍历,时间复杂度为 O ( n ) O(n) O(n)。
一个基本的改进思路是增加一些指针,帮助跳跃,减少遍历次数。
首先,在链表的前面增加一个“哨兵节点”,并把该链表当作 L0 层(不进行跳越)。
跳表是一种随机数据结构,随机地决定节点高度是否增长。
现在,我们开始来搭建 L1 层,即:从 L0 层的哨兵节点开始,遍历链表,随机地决定该节点是否“长高”。
我们再来搭建 L2 层,即:从 L1 层的哨兵节点开始,遍历链表,随机地决定该节点是否“长高”。
以上就是跳表的结构原理,通过搭建“跳越层”,我们可以实现更快的搜索和插入节点元素!
跳表的层数最好为 O ( l o g n ) O(logn) O(logn) 层,因为这样可以让搜索效率和插入效率都为 O ( l o g n ) O(logn) O(logn)。
现在,我们来实现对节点的定义,Skip List 的节点定义如下:
const int MAX_LEVEL = 16; // 最大层数
// 跳表节点
struct Node {
int key;
int value;
// 用数组存放指针,表示最多可以有 MAX_LEVEL 层
std::vector<Node*> forward;
Node(int k, int v, int level) : key(k), value(v), forward(level, nullptr) {}
};
我们定义的是一个具有 key 和 value 的节点,相比单一的 value,更为贴切 K-V 存储系统。
接下来,是对跳表结构的定义:
class SkipList{
public:
SkipList() : level(1), head(new Node(0, 0, MAX_LEVEL)) {}
~SkipList() {
Node* current = head;
while (current) {
Node* next = current->forward[0];
delete current;
current = next;
}
}
int search(int key);
void insert(int key, int val);
void remove(int key);
private:
int randomLevel(); // 随机决定新节点的层数
Node* head;
int level;
}
在上面的定义中,我们看到了 randomLevel
的声明,现在试着来实现它。我们已经知道跳表是一种 随机数据结构 ,那怎么设置随机的*「概率」*呢?
这就涉及到一个*「概率更新算法」*;对于一个新插入的节点,将它的层数每提升一级的概率设置为1/2。
那么可知,L0 层的节点提升至 L2 层的概率为1/4,L0 层的节点提升至 L3 层的概率为1/8 …
int SkipList::randomLevel(){
int level = 1;
while(rand() % 2 && level < MAX_LEVEL){ // 随机数生成 0 或 1,rand() 在头文件 中
level++;
}
return level;
}
SkipList 的搜索思路为,从 head 的最高层开始,如果节点值小于搜索值,向后遍历;否则,向下一层寻找;遍历之后,当前位置为目标节点(如果存在的话)的前一个节点,因此需要再往后移动一位。具体代码如下:
int SkipList::search(int key){
Node* current = head;
// 从最高层开始找起
for(int i = level - 1; i >= 0; --i){
while(current[i] && current->forward[i]->key < key){
current = current->forward[i];
}
}
current = current->forward[0];
if(current && current->key == key) return current->value;
return -1; // 未找到
}
现在我们来看插入节点的情况;假设在跳表中插入一个 D2 节点,且随机层数为 4 层,则插入效果如下:
从上图可知,如果新节点的层数大于 1,则需要让对应层数的*「前一个节点」指向新节点,而新节点指向原先该节点的「后一个节点」,相当于每层都需要进行一次“单链表插入”*。
因为我们不知道一个新节点的层数会是多少;所以,我们需要记录一个节点数值 Node* update[MAX_LEVEL]
,并在搜索插入位置的过程中记录「新节点在每层中的前一个节点」。具体的代码如下:
void insert(int key, int value) {
Node* current = head;
std::vector<Node*> update(MAX_LEVEL, nullptr); // 记录新节点在每层中的前一个节点
// 找到每层中要插入的位置
for (int i = level - 1; i >= 0; --i) {
while (current->forward[i] && current->forward[i]->key < key) {
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
// 如果键已存在,则更新值
if (current && current->key == key) {
current->value = value;
}
else {
// 随机确定新节点的层数
int newLevel = randomLevel();
if (newLevel > level) {
for (int i = level; i < newLevel; ++i) {
update[i] = head;
}
level = newLevel;
}
// 创建新节点
Node* newNode = new Node(key, value, newLevel);
// 更新前后连接,即:在每层中进行一次 “单链表插入”
for (int i = 0; i < newLevel; ++i) {
newNode->forward[i] = update[i]->forward[i];
update[i]->forward[i] = newNode;
}
}
}
最后再来看删除节点的代码,和插入一个新节点一样,需要记录节点(如果存在)在每层中的 「前一个节点」,并且需要删除节点在每层中的引用。
void remove(int key){
Node* current = head;
std::vector<Node*> update(MAX_LEVEL, nullptr); // 记录待删除的节点在每层中的前一个节点
// 找到要删除的节点
for(int i = level - 1; i >= 0; --i){
while(forward[i] && current->forward[i]->key < key){
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
if(current && current->key == key){
// 删除该节点在各层中的引用
for(int i = 0; update[i]->forward[i] == current; ++i){
update[i]->forward[i] = current->forward[i];
}
delete current;
// 更新跳表的层数
while(level > 1 && head->forward[level - 1] == nullptr){
--level;
}
}
}
OK,我们现在已经实现了一个最基本的跳表!
后续我们可以为跳表的增加 文件操作,使得我们的数据可以保存下来;也可以将这个跳表改写称一个模板类,让它支持更多类型;另外,还可以加入互斥锁(mutex)来确保线程安全。