手把手实现高性能Go 跳表

85 阅读8分钟

一、  背景介绍

跳表(skiplist)首先由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出,底层是一条单向有序链表,上层构建多级索引,效率媲美平衡二叉树--插入、删除、查询等操作都可以在O(logn)期望时间完成。跳表还有一个特性是平衡二叉树不具备的,那就是区间查询效率,因为跳表底层是有序链表,只需要找到区间边界位置就可以高效查询区间。故工业上有很多场景使用跳表,例如Redis 的sorted set 有序集合。

二、  理解跳表

如图,原始链表是一条单向有序链表,节点中橙色部分是可以用来比较的key,黄色部分是存储的value interface。如果只有单链表,那查找平均复杂度是O(n)。如图所示增加了两级索引,如果查找key为8的节点,比较路径是

二级索引 1->7->13(13比8大放弃)

一级索引7->9 (9比8大放弃)

原始链表7->8 找到! 由此可见增加多级索引,以空间换时间,可有效提高查询效率。

image.png

这里大家可能会想,就上面的案例来看,提升的效率并不明显,直接遍历原始链表也只需要遍历6个元素而已。这是因为我们给的数据量太少了,当数据量足够大时效率提升会很大。例如下图的单链表存储1万个元素,最高层的索引直接跳过前5000个元素,次高层直接跳过2500个元素,这使得链表查询效率近似二分查找。

image.png  

三、  算法分析

 

跳表的思想是给链表添加多级索引,那需要考虑以下几个问题

1、  层级差的粒度以及最高层级

以上案例的层级差是1/2,每高一层减少1/2 个元素。层级差越小代表空间复杂度越低,时间复杂度越高。例如Redis里的算法使用的是1/4的层级差,索引少一半,可以减少差不多一半的内存。

github.com/redis/redis…

构建跳表需要确定最高层级,跳表层数的设置跟数据量息息相关,合理的取值应该略大于log(n),底数是层级差粒度。比如数据量为30亿条,根据31<log(3000000000)<32,Max Level 应取32。

 

2、  插入元素随机层级确定

插入数据如果不更新索引,只是查到需要更新的原始链表位置就执行更新,可能会导致两个索引节点之间的数据非常多,极端情况跳表会退化为单链表。

解决这个问题有个容易理解的做法,那就是每次插入数据后,将跳表的索引删掉并全部重建,后果就是为了维护索引,每次插入数据的时间复杂度退化成了O(n)。

换一种思路,层级差为1/2,除了靠每隔一个数据做一次索引外,每个层级随机取1/2 个对于大数据量来说也是均匀的。例如下图,比如要查找元素9,通过1、5、7、8、9依旧是高效的。我们可以认为 当原始链表的元素足够多,抽取方式足够随机的话,最终得到的索引是足够均匀的。

image.png

由此,插入元素的层级确定转化为求总体概率分布,与单次插入元素无关。即插入元素有1/2的几率建立一级索引、1/4的几率建立二级索引,1/8的几率建立三级索引,以及类推。

我们可以实现一个randomLevel() 方法,分别有1/2的概率返回1,表示不需要建立索引,存储到原始链表即可;有1/4的概率返回2,表示当前元素需要建立一级索引,以此类推。需要注意的是虽然是1/4的概率做一级索引,但高层索引的建立必然会有一级索引,故最终一级索引的概率是1/2。

liyue201@github.com 的 gostl的实现如下

func (sl *Skiplist) randomLevel() int {
    total := uint64(1)<<uint64(sl.maxLevel) - 1 // 2^n-1
    k := sl.rander.Uint64() % total // 随机数
    levelN := uint64(1) << (uint64(sl.maxLevel) - 1) // 左移
 
    level := 1
    for total -= levelN; total > k; level++ {
        levelN >>= 1 
        total -= levelN
    }
    return level
}

 

total :元素总数,比如max level 为10时,total 为2^10-1=1023

可见随机数k越小,level 越高,

randomLevel() = 1 512<k<1023 k在此区间概率1/2

randomLevel() = 2 256<k<511 k在此区间概率1/4

randomLevel() = 3 128<k<255 k在此区间概率1/8

....

当level 比较高时循环次数就会增加,算法通过位计算右移一位正好减少一半,可以用math/bits 里的Len64()函数来计算随机位数

func (sl *Skiplist) randomLevel() int {
    total := uint64(1)<<uint64(sl.maxLevel) - 1 // 2^n-1
    k := sl.rander.Uint64() % total
    return skipListMaxLevel - bits.Len64(k) + 1
}

改进点在于Len64函数是查表实现的,查询时间固定,不会随着高循环而变高。

3、  多级索引存储及重建

跳表的查找、插入、删除过程比较类似,基本上都是判断索引是否存在,再进一步增加或删除索引。以增加数据为例,如下图为三级跳表,插入数据6。

首先randomLevel返回3表示需要建二级索引

再从最高层3级索引比较,发现6比1大比13小,所以下沉到2级索引

下沉到2级索引后,发现6比1大比7小,需要在1和7 之间增加数据6,并由数据1 下沉到一级索引

下沉到1级索引后,发现比4大比7小,需要在4和7 之间增加数据6,并把二级索引的6指向一级索引的6,最后由数据4下沉到原始链表

下沉到原始链表后,从4往后遍历,发现6比5大比7小,所以将数据6插入到5和7之间,并由一级索引6指向原始链表的6,插入结束。

image.png  

由此可见插入、删除都会影响多级链表索引的重建,采取一个合适的数据结构存储索引至关重要。跳表采用指针数组存储索引,节点定义如下:

type SkipListNode struct {
    key int      
    data interface{}
    next []*SkipListNode 
}

 

其中key用于比较大小,value实际存储的value,任意类型,next 为 节点指针切片,存储该节点后续的索引,例如1节点的next应该是[&13,&6,&4,&4],7节点的next是[&13,&9,&8]

 

四、  代码实现

1、  定义跳表结构体

type SkipList struct {
    head    *SkipListNode  // 头节点
    tail        *SkipListNode  // 尾节点
    length    int     // 数据总量
   level      int  //层数
    mut       *sync.RWMutex // 互斥锁
}

2、  工厂构造

func NewSkipList(level    int) *SkipList {
    list := &SkipList{}
    if level <=0 {
         level =10
    }
 
    list.level = level
    list.head = &SkipListNode{next:make([]*SkipListNode, level, level)}
    list.tail = &SkipListNode{}
    list.mut = &sync.RWMutex{}
    for index :=range list.head.next {
          list.head.next[index] = list.tail
    }
    return list
}
 

3、  插入

func (sl *SkipList)Insert(key int, data interface{}) {
    sl.mut.Lock()
    defer sl.mut.Unlock()
    //1.确定插入深度
    level := sl.randomLevel()
    //2.查找插入部位
    update :=make([]*SkipListNode, level, level)
    node := sl.head
    for index := level -1; index >=0; index -- {
        for {
            node1 := node.next[index]
            if node1 == sl.tail || node1.key > key {
                    update[index] = node//找到一个插入部位
                 break
          }else if node1.key == key{
                 node1.data = data //
                     return
           }else {
                    node = node1
            }
        }
    }
    //3.执行插入
    newNode := &SkipListNode{key, data, make([]*SkipListNode, level, level)}
    for index, node :=range update {
          node.next[index],newNode.next[index] = newNode,node.next[index]
    }
   sl.length ++
}

4 删除

func (sl *SkipList) Remove(key int) bool {
 
    sl.mut.Lock()
    defer sl.mut.Unlock()
//1.查找删除节点
    node := sl.head
    remove :=make([]*SkipListNode,sl.level,sl.level)
    var target *SkipListNode
    for index :=len(node.next) -1; index >=0; index -- {
        for {
            node1 := node.next[index]
            if node1 == sl.tail ||  node1.key >  key{
                    break
        }else if node1.key == key {
                    remove[index] = node//找到啦
                 target = node1
                    break
         }else {
                    node = node1
            }
        }
    }
//2.执行删除
    if target != nil {
            for index, node1 :=range remove {
                    if node1 != nil {
                           node1.next[index] = target.next[index]
                     }
             }
             sl.length --
             return true
      }
    return false
}

5 查找

func (sl *SkipList)Find(key int) interface{} {
   sl.mut.RLock()
    defer sl.mut.RUnlock()
    node :=sl.head
    for index :=len(node.next) -1; index >=0; index -- {
        for {
            node1 := node.next[index]
            if node1 == sl.tail ||  node1.key >  key   {
                     break
        }else if node1.key == key {
                    return node1.data
            }else {
                    node = node1
            }
        }
    }
    return nil
}

6 获取总量

func (sl *SkipList) Length() int {
        sl.mut.RLock()
        defer sl.mut.RUnlock()
        return sl.length
}

 

五、  Benchmark

sean-public 实现了一个以float64为key的跳表,并做了benchmark和其他算法进行评测。github.com/sean-public… 我在benchmark 里也加入了自己的实现,迭代次数从100000到900000,间隔100000。对比对象是github.com/sean-public…liyue201@github.com 的 gostl

评测结果如下,数值越小越好:

 iterations100000200000300000400000500000600000700000800000
insertsean288214201184194187182174
me153174174177164162180170
liyue328320322314328313296289
deletesean6161626774616468
me5865545656616662
liyue142133136146136151145135
searchsean113114115121109118115113
me106111121115107120115112
liyue210209238223214220222224

 

和gostl 相比由于randomLevel方法的改进,插入数据会快一点。gostl 的实现中,对于插入/删除都是需要统一找到前面的节点 findPrevNodes,存在的问题是假设插入时key 存在只需要更新value 即可,并不需要完整的找出来前面的节点

 

 

六、  参考资料

 

1.  Skip List--跳表(全网最详细的跳表文章没有之一)

2.  使用golang简单实现跳跃表SkipList