Skiplist是功能强大且原理简单的数据结构,且相比布隆过滤器,他的缺点和短板更少,应用更加广泛,如redis就用到了Skiplist。
Skiplist是一个实现快速查找、增删数据的数据结构,可以做到O(logN) 时间复杂度的增删查,与红黑树相比他的logN中的N更小(skiplist的logN的在N范围内是随机的,一定小于N),且编码复杂度更低。因为Skiplist不需要旋转来维护红黑树的平衡。
Skiplist本质上是list, 也就是链表。我们都知道,链表是线性结构的,每次只能移动一个节点,这也是为什么链表获取元素和删除元素的时间复杂度都是O(n)。
如果我们要优化这个问题,可以在当中的一半的节点上都加入一个指针,执行后面两个的元素,这样我们遍历的性能就能快一倍,最快可以在O(n/2)的时间内遍历完整个链表了。
同样的道理,如果我们继续增加节点上指针的个数,那么这个速度可以进一步加快。理论上来说,如果我们设置logN个指针,完全可以在logN的时间负责度内完成元素的查找,这也是Skiplist的精髓。
但是有一个问题,我们只实现快速查找是不够的,我们还需要保证元素的有序性,否则查找也无从谈起。但元素添加的顺序并不是有序的,我们怎么保证节点分配到的指针数量合理呢?
为了解决这个问题,Skiplist引入了随机深度的机制,也就是一个节点能够拥有的指针数是随机的。这种策略可以保证元素尽可能分散,不会发生数据倾斜的情况。
可以把每个节点想像成一个小楼,每个节点的多个指针可以看成是小楼的各个楼层。很显然,由于所有的小楼都排成一排,所以每栋楼的每一层都只能看到同样高度的最近的一栋。
由于各节点的高度是随机的,所以每个节点能看到的情况是分散的,可以防止数据聚集不平均问题,从而可以保证运行效率。
整个Skiplist本质上是个链表,既然是链表,就可以从定义节点开始,与链表不同的是,每个节点会有一个指针列表,记录可以指向的位置:
class Node:
def __init__(self, key, value=None, depth=1):
self._key = key
self._value = value
# 一开始全部赋值为None
self._next = [None for _ in range(depth)]
@property
def key(self):
return self._key
@key.setter
def key(self, key):
self._key = key
@property
def value(self):
return self._value
@value.setter
def value(self, value):
self._value = value
Python中面向对象的规范,因为Python不像C++或java做了public和private的区分,在Python中,所有字段都是public的。这显然是不安全的,有时候我们并不希望调用方可以获取我们的所有的信息,所以在python中,大家规定变量名前面添加下划线表示Private变量。
关于注解:在java中,我们默认会为用到的private变量提供public的get和set方法,python中也一样,不过Python提供了强大的注解器。可以通过添加@property和@param.setter注解来简化代码的编写,有了注解之后,python会自动将方法名和变量名映射起来。
在node累中添加节点的方法:
# 为第k个后向指针赋值
def set_forward_pos(self, k, node):
self._next[k] = node
# 获取指定深度的指针指向的节点的key
def query_key_by_depth(self, depth):
# 后向指针指向的内容有可能为空,并且深度可能超界
# 我们默认链表从小到大排列,所以当不存在的时候返回无穷大作为key
return math.inf if depth > self._depth or self._next[depth] is None else self._next[depth].key
# 获取指定深度的指针指向的节点
def forward_by_depth(self, depth):
return None if depth > self._depth else self._next[depth]
class SkipList:
def __init__(self, max_depth, rate=0.5):
# head的key设置成负无穷,tail的key设置成正无穷
self.root = Node(-math.inf, depth=max_depth)
self.tail = Node(math.inf)
self.rate = rate
self.max_depth = max_depth
self.depth = 1
# 把head节点的所有后向指针全部指向tail
for i in range(self.max_depth):
self.root.set_forward_pos(i, self.tail)
def random_depth(self):
depth = 1
while True:
rd = random.random()
# 如果随机值小于p或者已经到达最大深度,就返回
if rd < self.rate or depth == self.max_depth:
return depth
depth += 1
先看skiplist的构造函数,以及随机生成节点深度的函数。关于节点深度,skiplist会设计一个概率p, 每次随机一个 0~1 的浮点值,如果他大于p,那么深度+1,否则就返回当前深度,为了防止极端情况深度爆炸,我们也会设定一个最大深度。
在skiplist中,除了需要定义head节点之外,还需要节点tail, 他表示链表的结尾。由于我们希望skiplist来实现快速查询,所以skiplist中的元素是有序的,为了保证有序性,我们把head的key设置成无穷小,tail的key设置成无穷大。以及我们默认head的后向指针是满的,全部指向tail,这些逻辑清楚之后,代码就不难了。
查找的逻辑,类似于贪心算法:每次都尝试从最高的楼层往后看,如果看到的数值小于当前查找的key, 那么就跳跃过去,否则说明我们一下看得太远了,我们应该看近一些,于是往楼下走,重复上述过程,直到到达底层。
比如上图中,我们要查找20,首先在head的位置的最高点往后看,直接看到了正无穷,他是大于20的,说明看得太远了,我们应该往下走一层,于是我们走到第四层,这次我们看到了17,他小于20,所以就移动过去。
移动到17之后,我们还是从第四层看上看起,然后发现每一层看到的元素都大于20,那么说明17就是举例20最近的元素(有可能20不存在),那么我们从17开始往后移动一格,就是20可能出现的位置,如果这个位置不是20,那么说明20不存在。
def query(self, key):
# 从头开始
pnt = self.root
# 遍历当下看的高度,高度只降不增
for i in range(self.depth-1, -1, -1):
# 如果看到比目标小的元素,则跳转
while pnt.query_key_by_depth(i) < key:
pnt = pnt.forward_by_depth(i)
# 走到唯一可能出现的位置
pnt = pnt.forward_by_depth(0)
# 判断是否相等,如果相等则说明找到
if pnt.key == key:
return True, pnt.value
else:
return False, None
我们要删除节点,首先先找到节点,所以我们可以服用查找的代码来找到待删除的节点可能存在的位置。
找到了位置并不是直接删除他,因为我们删除他可能会影响其他元素。
还拿下图举例,假设我们要删除25这个元素,那么会发生什么?
对于25以后的元素,其实并不会影响,因为节点只有后向指针,会影响的是指向25的这些节点。由于25被删除,他们的指针要穿过25的位置,继续往后,指向后面的元素。
那么我们怎样找到这些指向25的指针呢?在我们查找的时候,每次都看得尽量远,这是贪心算法。我们每次发生”下楼“操作的元素不就是该楼层最近的一个能看到25的位置吗?也就是我们把查找过程中发生下楼的位置都记录下来即可。
def delete(self, key):
# 记录下楼位置的数组
heads = [None for _ in range(self.max_depth)]
pnt = self.root
for i in range(self.depth-1, -1, -1):
while pnt.query_key_by_depth(i) < key:
pnt = pnt.forward_by_depth(i)
# 记录下楼位置
heads[i] = pnt
pnt = pnt.forward_by_depth(0)
# 如果没找到,当然不存在删除
if pnt.key == key:
# 遍历所有下楼的位置
for i in range(self.depth):
# 由于是从低往高遍历,所以当看不到的时候,就说明已经超了,break
if heads[i].forward_by_depth(i).key != key:
break
# 将它看到的位置修改为删除节点同样楼层看到的位置
heads[i].set_forward_pos(i, pnt.forward_by_depth(i))
# 由于我们维护了skiplist当中的最高高度,所以要判断一下删除元素之后会不会出现高度降低的情况
while self.depth > 1 and self.root.forward_by_depth(self.depth - 1) == self.tail:
self.depth -= 1
else:
return False
在insert之前,也同样需要查找,因为我们要把元素放到正确的位置。
如果这个位置已经有元素了,那么我们直接修改他的value,这其实就是修改操作了。如果设计成禁止修改,也可以返回失败。插入的过程同样会影响其他元素的指针指向的内容,我们分析发现,查过的过程和删除的过程是相反的。删除的过程中我们需要将指向x的指向x指向的位置,而插入则想法,我们要把指向x后面的指针指向x,并且也需要更新x指向的位置,如果能理解delete, 那么理解insert就很容易。
def insert(self, key, value):
# 记录下楼的位置
heads = [None for _ in range(self.max_depth)]
pnt = self.root
for i in range(self.depth-1, -1, -1):
while pnt.query_key_by_depth(i) < key:
pnt = pnt.forward_by_depth(i)
heads[i] = pnt
pnt = pnt.forward_by_depth(0)
# 如果已经存在,直接修改
if pnt.key == key:
pnt.value = value
return
# 随机出楼层
new_l = self.random_depth()
# 如果楼层超过记录
if new_l > self.depth:
# 那么将头指针该高度指向它
for i in range(self.depth, new_l):
heads[i] = self.root
# 更新高度
self.depth = new_l
# 创建节点
new_node = Node(key, value, self.depth)
for i in range(0, new_l):
# x指向的位置定义成能看到x的位置指向的位置
new_node.set_forward_pos(i, self.tail if heads[i] is None else heads[i].forward_by_depth(i))
# 更新指向x的位置的指针
if heads[i] is not None:
heads[i].set_forward_pos(i, new_node)
Skiplist的原理虽然不难理解,但代码写起来,由于涉及到指针操作,所以还是挺麻烦的,要写对并调试好并不容易,但相比于臭名昭著的各类平衡树而言,已经算非常简单了。