最近在做项目的时候,遇到了跳跃表这种数据结构,仔细研究了之后发现这是一种很有用的数据结构,值得写一篇博客来记录一下。

1. 跳跃表的作用

   大家都清楚,对于有序数组,我们可以通过二分法将查找元素的复杂度降到O(logn)。可是因为存储地址不连续,我们没办法将二分法运用到有序链表中。跳跃表就是用来解决这个问题的数据结构。跳跃表最大的优点,就是在保留了链表易于删改元素的特性的基础上,实现有序元素的高效查询。

2. 跳跃表原理

  正常情况下,在有序链表中寻找元素,需要逐个遍历链表中的每一个元素。

  一个一个遍历是在是太慢了,为了提升速度,我们想着能不能在遍历的过程中,一次多跳过几个节点,这样不就能加快查找速度了吗?为了达到这个目的,可以将链表中的一些节点提取出来,当作索引来用,在遍历的时候,我们先在这些节点上遍历,当找到一个大致的位置以后,再回到链表中,找到具体的位置。

如果数据量很大的话,我们可以建立多级索引,来提升性能。

3. 跳跃表结构

 3.1 跳跃表节点

  可以通过单节点,多指针的方式来实现跳跃表,即在跳跃表的每一个节点中存储一个指针数组,数组中的指针表示不同层中指向下一个节点的指针。

  

 1 //节点类模板
 2 template<typename V>
 3 class Node{
 4 public:
 5 
 6     Node(){}
 7 
 8     Node(V v){};
 9 
10     V get_value() const;
11 
12     Node<V>**forward;    //表示不同层指向下一个节点的指针数组
13 
14 private:
15     V value;
16 };

3.2 跳跃表

  其实使用节点就可以表示跳跃表,但为了插入删除节点等操作方便,可以对其再进行一层封装,构成一个新的类SkipList。这个类持有一个头结点,该头结点不保存任何数据,仅作为哑节点使用。除了头结点外,SkipList还维护当前层(_skip_list_level)和最大层数(max_level)两个值。

 1 template<typename V>
 2 class SkipList{
 3 public:
 4     SkipList(int);
 5     ~SkipList();
 6 private:
 7     int _max_level;  // 最大层
 8     int _skip_list_level; //当前跳跃表的层数
 9     Node<V>*_header;   // 跳跃表的头结点
10     int _element_count;  // 节点数量
11 };

 

3. 跳跃表的操作

3.1 查询

   在查询时,跳跃表会先在最高层查询,当下一个查询节点的值大于待查询值时,跳跃表跳转到下层节点继续查询,重复这个过程,直到到达跳跃表的最底层。此时,如果下一个查询节点的值等于待查询值,则查询成功,如果该节点为空或者该节点值大于待查询值,则查询失败,这个值不在跳跃表中。

  查询代码如下:

 1 template<typename V>
 2 int SkipList<V>::search_element(V value){
 3     Node<V>*current = _header;     // 当前节点
 4     for (int i = _skip_list_level; i >= 0; i--){
 5         while (current->forward[i] != NULL&&current->forward[i]->get_value() < value){
 6             current = current->forward[i];
 7         }
 8     }
 9     current = current->forward[0];
10 
11     if (current == NULL || current->get_value() != value){
12         std::cout << "Value: " << value << "Not Exists." << std::endl;
13         return -1;
14     }else{
15         std::cout << "Found Value: " << value << std::endl;
16         return 1;
17     }
18 }

3.2 插入

  插入节点的第一步是找到待插入节点的位置,方法和上面将的查询操作大体类似;插入节点带来的一个新问题是,插入了一个新节点,那么要为这个节点建立几层索引呢?跳跃表的发明者提出了一种有趣的解决方法,这种解决方法类似于抛硬币,每个插入的节点都有50%的机会被提升到上一层,到达上一层以后,则继续按照这个概率来判断要不要提升。当节点数很多的时候,采用这种方法可以大体上保证,每一层节点数是它下一层节点数的1/2,从而使每一层的索引数尽量均匀。确定了这个节点提升的层数以后,则修改各层层的指针的指向,插入这个节点。

插入步骤:

  1. 找到待插入节点的前一个节点

      2.确定提升层数,假设这里提升层数为2

   3. 修个各层指针,插入节点

  

  插入节点的代码如下

 1 template<typename V>
 2 int SkipList<V>::insert_element(V value){
 3     Node<V>*current = this->_header;
 4     Node<V>*update[_max_level+1];
 5     memset(update,0,sizeof(Node<V>*)*(_max_level+1));
 6     int random_level = 0;
 7     while (rand() % 2){
 8         random_level++;
 9     }
10     random_level = random_level > _max_level ? _max_level : random_level;
11     if (random_level > _skip_list_level){         //如果这个节点被更新到了超出当前层的位置,那就更新当前层。
12         for (int i = random_level; i > _skip_list_level; i--){
13             update[i] = _header;
14         }
15         _skip_list_level = random_level;          
16     }
17     for (int i = _skip_list_level; i >= 0; i--){
18         while (current->forward[i] != NULL&&current->forward[i]->get_value() < value){
19             current = current->forward[i];
20         }
21         update[i] = current;
22     }
23 
24     current = current->forward[0];
25 
26     if (current != NULL&&current->get_value() == value){
27         std::cout << "Value: " << value << ", exists already!" << std::endl;
28         return -1;
29     }
30 
31     Node<V>*inserted_node = new Node<V>(value);
32     for (int i = random_level; i >= 0; i--){
33         insert_node->forward[i] = update[i]->forward[i];
34         update[i]->forward[i] = inserted_node;
35     }
36     std::cout << "Successfully inserted value: " << value << std::endl;
37     _element_count++;
38     return 0;
39 }

3.3 删除

  删除跳跃表节点的操作比较简单,只要找到每一层待删除节点之前的那层节点,修改该节点的指针就可以了。

  1. 找到待删除节点各层之前的节点

  

  2. 修改指针,删除节点

  

  删除节点代码如下

 1 template<typename V>
 2 void SkipList<V>::deleted_element(int value){
 3     Node<V>*current = this->_header;
 4     Node<V>*update[_max_level + 1];
 5     memset(update, 0, sizeof(Node<V>*)*(_max_level + 1));
 6 
 7     for (int i = _skip_list_level; i >= 0; i--){
 8         while (current->forward[i] != NULL&&current->forward[i]->get_value() < value){
 9             current = current->forward[i];
10         }
11         update[i] = current;
12     }
13 
14     current = current->forward[0];
15     if (current == NULL || current->get_value() != value){
16         std::cout << "Value :" << value << "Not Exists!" << std::endl;
17         return;
18     }else{
19         for (int i = 0; i <= _skip_list_level; i++){
20             if (update[i]->forward[i] != current)    //假如被删除节点不在当前层中,终止循环
21                 break;
22             update[i]->forward[i] = current->forward[i];
23 
24         }
25         //加入删除该节点后出现空层,那么删除该层
26         while (_skip_list_level > 0 && _header->forward[_skip_list_level] == 0){
27             _skip_list_level--;
28         }
29         std::cout << "Successfully deleted value " << value << std::endl;
30         _element_count--;
31     }
32     return;
33 }

4. 跳跃表的在Redis中的应用

   跳跃表有许多有趣的应用,其中著名的NoSQL数据库Redis就用它实现了底层的有序集合键。Redis的实现比本文的实现方法更为复杂,除了前向指针以外,每个跳跃表节点还包括一个后退指针(用于向前遍历)和分值(用于排序),有兴趣的同学可以查找想过资料,或者直接阅读Redis源码,这里就不再累述了。

5. 总结

  跳跃表是一种用来对有序链表中的元素进行高效查询的数据结构,它通过给每个节点维护多个指向其他节点的指针,达到快速访问的目的。跳跃表和二叉平衡树一样,查找的平均时间复杂度为O(logn)。相比之下,跳跃表增删节点的操作比二叉平衡树更加简单,所以在很多场景下,使用跳跃表替代二叉平衡树不失为一种好的选择。