skipList原理与实现

1,206 阅读5分钟

简介

前面一篇文章介绍了红黑树的特性以及代码实现方法,了解到红黑树是高效查询和插入的链表之一。在一些数据结构有关的数据中,跳表出现的次数很少,但是跳表的性能和红黑树是一个量级的,都是O(logn)。在redis中也是使用的跳表而没有使用红黑树,证明了跳表也有红黑树不可匹敌的优势。废话不多说了,开始今天的跳表学习。

跳表原理

提高数组的查询插入删除的方法是:将数组排序,然后进行二分插入和二分查询,使得整个数组都任意时刻都为有序状态。

由于链表并不能像数组一样能够随机访问,所以常规链表想要实现有序很困难,传统的方法只能遍历整个链表,这样的复杂度并不高效。

跳表结合了二分查找的思想,但是由于链表的特性不能随机访问,所以必须设置一些目标点,所以跳表的整体结构如图所示:

7711FD6B6D0DEBAF28805EA37316437A.png

跳跃链表实际上是多条有序链表组成的,当需要查询一个数据的时候,最上层第一个节点开始遍历,类似二分查找一样一层一层向下查找,最下面一层保存了所有的数据。可以轻易的总结出来跳表的最佳查询性能和二分查找一样。

跳表的最好结构也可以轻松看出来,就是每一层的数据都为下面一层的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步:

  1. 随机层数并且初始化节点,创建一个update的节点数组。来保存每一层节点目标节点之前的那一个节点。 D1E00ECC98F447287806A71B898D210A.png 比如要在c和e之间插入一个随机到3层的节点,update数组保存的是目标节点即将插入对应层数之前的那一个节点,
  2. 先把update设置成head,因为每一层都是从head开始遍历的,head是一个空节点
  3. 每一层不断遍历,直到找到待插入位置的前驱节点。
  4. 链表的插入操作。
  5. 跟新最大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)直接遍历多个重复节点简单

根据红黑树和跳表的简单对比,明显跳表在每一层保存了相同的节点,更加耗费空间,但是跳表得益于实现简单,并且范围查找方便,也有很大的使用价值。