一、 背景介绍
跳表(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 找到! 由此可见增加多级索引,以空间换时间,可有效提高查询效率。
这里大家可能会想,就上面的案例来看,提升的效率并不明显,直接遍历原始链表也只需要遍历6个元素而已。这是因为我们给的数据量太少了,当数据量足够大时效率提升会很大。例如下图的单链表存储1万个元素,最高层的索引直接跳过前5000个元素,次高层直接跳过2500个元素,这使得链表查询效率近似二分查找。
三、 算法分析
跳表的思想是给链表添加多级索引,那需要考虑以下几个问题
1、 层级差的粒度以及最高层级
以上案例的层级差是1/2,每高一层减少1/2 个元素。层级差越小代表空间复杂度越低,时间复杂度越高。例如Redis里的算法使用的是1/4的层级差,索引少一半,可以减少差不多一半的内存。
构建跳表需要确定最高层级,跳表层数的设置跟数据量息息相关,合理的取值应该略大于log(n),底数是层级差粒度。比如数据量为30亿条,根据31<log(3000000000)<32,Max Level 应取32。
2、 插入元素随机层级确定
插入数据如果不更新索引,只是查到需要更新的原始链表位置就执行更新,可能会导致两个索引节点之间的数据非常多,极端情况跳表会退化为单链表。
解决这个问题有个容易理解的做法,那就是每次插入数据后,将跳表的索引删掉并全部重建,后果就是为了维护索引,每次插入数据的时间复杂度退化成了O(n)。
换一种思路,层级差为1/2,除了靠每隔一个数据做一次索引外,每个层级随机取1/2 个对于大数据量来说也是均匀的。例如下图,比如要查找元素9,通过1、5、7、8、9依旧是高效的。我们可以认为 当原始链表的元素足够多,抽取方式足够随机的话,最终得到的索引是足够均匀的。
由此,插入元素的层级确定转化为求总体概率分布,与单次插入元素无关。即插入元素有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,插入结束。
由此可见插入、删除都会影响多级链表索引的重建,采取一个合适的数据结构存储索引至关重要。跳表采用指针数组存储索引,节点定义如下:
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
评测结果如下,数值越小越好:
| iterations | 100000 | 200000 | 300000 | 400000 | 500000 | 600000 | 700000 | 800000 | |
|---|---|---|---|---|---|---|---|---|---|
| insert | sean | 288 | 214 | 201 | 184 | 194 | 187 | 182 | 174 |
| me | 153 | 174 | 174 | 177 | 164 | 162 | 180 | 170 | |
| liyue | 328 | 320 | 322 | 314 | 328 | 313 | 296 | 289 | |
| delete | sean | 61 | 61 | 62 | 67 | 74 | 61 | 64 | 68 |
| me | 58 | 65 | 54 | 56 | 56 | 61 | 66 | 62 | |
| liyue | 142 | 133 | 136 | 146 | 136 | 151 | 145 | 135 | |
| search | sean | 113 | 114 | 115 | 121 | 109 | 118 | 115 | 113 |
| me | 106 | 111 | 121 | 115 | 107 | 120 | 115 | 112 | |
| liyue | 210 | 209 | 238 | 223 | 214 | 220 | 222 | 224 |
和gostl 相比由于randomLevel方法的改进,插入数据会快一点。gostl 的实现中,对于插入/删除都是需要统一找到前面的节点 findPrevNodes,存在的问题是假设插入时key 存在只需要更新value 即可,并不需要完整的找出来前面的节点
六、 参考资料
1. Skip List--跳表(全网最详细的跳表文章没有之一)