简介
前面一篇文章介绍了红黑树的特性以及代码实现方法,了解到红黑树是高效查询和插入的链表之一。在一些数据结构有关的数据中,跳表出现的次数很少,但是跳表的性能和红黑树是一个量级的,都是O(logn)。在redis中也是使用的跳表而没有使用红黑树,证明了跳表也有红黑树不可匹敌的优势。废话不多说了,开始今天的跳表学习。
跳表原理
提高数组的查询插入删除的方法是:将数组排序,然后进行二分插入和二分查询,使得整个数组都任意时刻都为有序状态。
由于链表并不能像数组一样能够随机访问,所以常规链表想要实现有序很困难,传统的方法只能遍历整个链表,这样的复杂度并不高效。
跳表结合了二分查找的思想,但是由于链表的特性不能随机访问,所以必须设置一些目标点,所以跳表的整体结构如图所示:
跳跃链表实际上是多条有序链表组成的,当需要查询一个数据的时候,最上层第一个节点开始遍历,类似二分查找一样一层一层向下查找,最下面一层保存了所有的数据。可以轻易的总结出来跳表的最佳查询性能和二分查找一样。
跳表的最好结构也可以轻松看出来,就是每一层的数据都为下面一层的50%,但是稍微思考一下就可以发现,如果要维持这种完美结构,在数据插入删除的情况下,需要不断的调整每一层链表的结构,开销非常大。如果不跟新上层索引,那么跳表的查询性能会退化到O(n)。
换一种思路,我们没必要追求完美跳表,既然每一层节点数量都为下一层的50%,就在插入的过程中计算该节点的概率来决定这个节点总共的层数,就可以以一个简单的方法来插入删除,因为一个节点的层数是永远不会改变的,除非删除了该节点。
代码实现(Golang)
java和golang实现过程其实都是差不多的,仅仅看得懂语法就可以翻译成Java版本,最新比较喜欢用golang,有时间翻译成java版本再发一遍。
首先定义数据结构
type skipListNode struct {
value int
level int
forwards []*skipListNode
}
type skipList struct {
head *skipListNode
level int
length int
}
++首先跳表的节点:level表示节点的层数,对于一个普通链表节点内部会保存next指向下一个节点,由于跳表是多层的结构,所以使用一个数组来表示不同层数的下一个节点++
const probability = 0.5
const maxLevel = 16
func randomLevel() int {
level := 1
for ; rand.Float64() > probability && level < maxLevel; rand.Seed(time.Now().UnixNano()) {
level++
}
return level
}
func newNode(val, level int) *skipListNode {
return &skipListNode{
value: val,
level: level,
forwards: make([]*skipListNode, maxLevel),
}
}
跳表的最大层数为16,节点的晋升概率为50%,每一次概率大于0.5就增加节点的层数。初始化节点的时候直接将forward设置为最大层,避免以后插入删除时候再去调整
首先编写查询代码,查询非常简单,只需要一层一层遍历即可
func (s *skipList) Find(target int) *skipListNode {
p := s.head
for i := s.level - 1; i >= 0; i-- {
for nil != p.forwards[i] {
if p.forwards[i].value < target {
p = p.forwards[i]
} else if p.forwards[i].value == target {
return p.forwards[i]
} else {
break
}
}
}
return nil
}
找到小于目标节点的最大值,一层一层往下遍历,如果中途找到了该节点,就直接返回。
插入过程稍稍复杂,但比起来红黑树还是要简单许多
func (s *skipList) Insert(val int) {
// 1
level := randomLevel()
node := newNode(val, level)
update := make([]*skipListNode, level)
//2
for i := 0; i < level; i++ {
update[i] = s.head
}
p := s.head
//3
for i := 0; i < level; i++ {
for nil != p.forwards[i] && p.forwards[i].value < val {
p = p.forwards[i]
}
update[i] = p
}
//4
for i := 0; i < level; i++ {
node.forwards[i] = update[i].forwards[i]
update[i].forwards[i] = node
}
//5
if level > p.level {
p.level = level
}
s.length++
}
插入一共可以分为5步:
- 随机层数并且初始化节点,创建一个update的节点数组。来保存每一层节点目标节点之前的那一个节点。
比如要在c和e之间插入一个随机到3层的节点,update数组保存的是目标节点即将插入对应层数之前的那一个节点,
- 先把update设置成head,因为每一层都是从head开始遍历的,head是一个空节点
- 每一层不断遍历,直到找到待插入位置的前驱节点。
- 链表的插入操作。
- 跟新最大level值
++可以看出来插入操作基本上很好理解,就是每一层都找呗,head是一个空的哨兵节点,永远在最前面,所以说图片没有画出来。++
删除操作和插入大体上相似
func (s *skipList) Delete(val int) {
p := s.head
update := make([]*skipListNode, s.level)
for i := s.level - 1; i >= 0; i-- {
for nil != p.forwards[i] && p.forwards[i].value < val {
p = p.forwards[i]
}
update[i] = p
}
// 关键步骤
if update[0].forwards[0].value == val {
for i := 0; i < s.level; i++ {
if nil != update[i].forwards[i] && update[i].forwards[i].value == val {
update[i].forwards[i] = update[i].forwards[i].forwards[i]
}
}
}
for s.level > 1 && s.head.forwards[s.level] == nil {
s.level--
}
}
同样的也是找到每一层目标节点的前驱节点,首先判断最底层节点是否包含带删除节点,然后就是循环每一层断开节点而已。 最后再统计一下跳表的层高看是否能够降低层数。
总结
前面提到了红黑树和跳表,经过两篇文章的的介绍可以了解到红黑树和跳表的性质。
| 性能 | 范围查找 | 空间 | 实现难度 | |
|---|---|---|---|---|
| 红黑树 | O(logn) | 中序遍历 | 节点内部指针消耗 | 难 |
| 跳表 | O(logn) | 直接遍历 | 多个重复节点 | 简单 |
根据红黑树和跳表的简单对比,明显跳表在每一层保存了相同的节点,更加耗费空间,但是跳表得益于实现简单,并且范围查找方便,也有很大的使用价值。