/SkipList

基于跳表实现的轻量级键值型KV存储引擎,使用C++实现。

Primary LanguageC++

Skip List

基于跳表实现的轻量级键值型KV存储引擎,使用C++实现。

前言

链表在查找元素的时候,需要进行线性逐一查找比对,时间复杂度为线性O(n).

跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,它的原理就是为链表加多级索引,使得查找的效率提高到O(logn).

## 当读过跳表的定义后,我心里是这么想的:

   查询效率为O(logn), 那不就是 二分 or 平衡二叉查找树
   
=> 进一步,链表不支持随机访问,显然不支持二分查找。 那就是查找树,查找树本质就是加上一层层索引,
   而定义里提到跳表是“一种「多层」的有序链表”,那不就是简单的多层链表么? 于是,我想到了下面这幅图所示的数据结构
   
   这里模仿二分查找或二叉查找树,每两个结点会抽出一个结点作为上一级索引的结点
   
   head -> 1 -------------------- 5 -------------------- 9       二级索引
           |                      |                      |  
           1 -------  3 --------- 5 -------- 7 --------  9       一级索引
           |          |           |          |           | 
           1 ---2 --- 3 --- 4 --- 5 ---6 --- 7 --- 8 ----9       0级索引(原始链表)
  
   ### 查找的时间复杂度:
   
   查找元素的过程是从最高级索引开始, 一层一层遍历最后下沉到原始链表. 所以, 时间复杂度 = 索引的高度 * 每层索引遍历元素的个数
   
   以每两个结点会抽出一个结点作为上一级索引的结点为例, h = log2(n) - 1, 而每层索引最多比较3次, 因此查询效率为 O(logn). 

   ### 空间开销
   
   虽然建立了多级索引, 但可以学习B+树, 索引结点并不需要存储value, 只需要存储key和本层下一个结点及下一层结点的地址值(指针)就好了.
   
   ### 插入数据
   
   插入数据需要插入到原始链表, 以插入6.5   6.6  6.7 为例:
   
      head -> 1 -------------------- 5 --------------------------------------- 9       二级索引
              |                      |                                         |  
              1 -------  3 --------- 5 --------------------------- 7 --------- 9       一级索引
              |          |           |                             |           | 
              1 ---2 --- 3 --- 4 --- 5 --6 -- 6.5 -- 6.6 -- 6.7 -- 7 --- 8 ----9       0级索引(原始链表)
    
    可以发现,当在原始链表的某个区间插入元素后,需要进行索引调整,否则若如果大量数据全部插入到同一个区间后,会退化成线性查找
    
    索引调整其实也并不难,每两个结点会抽出一个结点作为上一级索引的结点,
    我们可以允许一个区间最多三个结点,当达到4个结点时就像上递归"分裂",即递归建立索引结点. 如图  
    (注意将区间为左闭右开, 和常用的C++ STL算法保持一致~)
    
      head -> 1 -------------------- 5 --------------------------------------- 9       二级索引
              |                      |                                         |  
              1 -------  3 --------- 5 ------ 6.5----------------- 7 --------- 9       一级索引
              |          |           |         |                   |           | 
              1 ---2 --- 3 --- 4 --- 5 --6 -- 6.5 -- 6.6 -- 6.7 -- 7 --- 8 ----9       0级索引(原始链表)
    
  支持O(logn)的查找, O(logn)的插入, 且索引不存储实际数据不会有太大空间开销
  
  但向上递归建立索引需要记录从上到下的搜索路径(插入一个结点需要知道插入位置的前一个结点), 那这实在太像B+树(度为2) 了. 

搜了搜,事情果然没有这么简单...

仿 Redis 跳表

个人理解:

Redis 跳表设计了非常有意思的数据结构来实现跳表,主要改进了两个点:

1. 插入数据

为了保持O(logn)查询性能,可以在每次新插入元素的时候,尽量让该元素有 1/2 的几率建立一级索引、1/4 的几率建立二级索引、1/8 的几率建立三级索引,以此类推。

这样同样保证在数据量足够大的情况下,k层 索引的结点数是 k-1层 索引结点数的 1/2. 进而保证了对数时间内完成查找.

Redis的跳表实现,这个值设置的是0.25。 
即跳表在创建节点时候, 会生成范围为[0-1]的一个随机数, 如果这个随机数小于 0.25(相当于概率 25%), 那么层数就增加 1 层,
然后继续生成下一个随机数, 直到随机数的结果大于 0.25 结束, 最终确定该节点的层数

而我们这里相当于这个数是 0.5 

这个值越大, 一个区间内的结点数就越少, 越容易向上建立索引, 或者说相同数据量的情况下“高度”越高。 反之亦然

在对“高度”有限制的情况下,即最高“高度”确定的情况下, 当数据量足够大时, 其实也是会退化成线性查找。
如16层,每次向上建立索引的概率为1/2,则最高层索引的节点数量为 n/(2^16).  当n足够大时,2^16的作用就很小了。

不过这应该也不是问题,因为Redis是内存型数据库,内存再大还能多大呢?

2. 数据结构

image

仅用原始链表的结点就可以表示全部索引!

具体的,加入最高n层索引,则可以简单在原始链表的结点中存n+1个指针,

其中第0个指针指向原始链表中下一个结点,如果这个结点最高上升到k层索引,则第 1..k 个指针指向其在 1..k层索引中的下一个结点

Node 的成员

template<typename K, typename V>
class Node {

//  没有加入前向指针

    K key;
    V value;
    int level;  // 最高出现在第几层(几级索引)

    /*
     * 表示当前节点在所有层的下一个节点,从上层切换到下层,就是数组下标 -1,
     * 比如: forward[3]表示当前节点在第三层的下一个节点。
     */
    std::vector<Node<K, V>* > forward;
};

实现

  • 插入记录 - insert_element(const K&, const V&)
  • 删除记录 - delete_element(const K&)
  • 查找记录 - search_element(const K&);
  • 展示记录 - display_list()
  • 数据加载 - load_file();
  • 数据存盘 - dump_file()
  • 记录数量 - size()

插入数据时会使用此方法决定最高插入到第几级索引(层)

template<typename K, typename V>
int SkipList<K, V>::get_random_level() {
/*
    该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
       1/2 的概率返回 0   => No
       1/4 的概率返回 1   => 一级索引
       1/8 的概率返回 2 以此类推
 */
    int level = 0;
    while(rand() % 2 && level < max_level) 
        ++level;

    return level;
}

reference

https://www.jianshu.com/p/9d8296562806

https://github.com/youngyangyang04/Skiplist-CPP