Skiplist 跳表 学习笔记

一、Skiplist简介

Skiplist是功能强大且原理简单的数据结构,且相比布隆过滤器,他的缺点和短板更少,应用更加广泛,如redis就用到了Skiplist。

Skiplist是一个实现快速查找、增删数据的数据结构,可以做到O(logN) 时间复杂度的增删查,与红黑树相比他的logN中的N更小(skiplist的logN的在N范围内是随机的,一定小于N),且编码复杂度更低。因为Skiplist不需要旋转来维护红黑树的平衡。

二、Skiplist原理

Skiplist本质上是list, 也就是链表。我们都知道,链表是线性结构的,每次只能移动一个节点,这也是为什么链表获取元素和删除元素的时间复杂度都是O(n)。
Skiplist 跳表 学习笔记_第1张图片
如果我们要优化这个问题,可以在当中的一半的节点上都加入一个指针,执行后面两个的元素,这样我们遍历的性能就能快一倍,最快可以在O(n/2)的时间内遍历完整个链表了。
在这里插入图片描述
同样的道理,如果我们继续增加节点上指针的个数,那么这个速度可以进一步加快。理论上来说,如果我们设置logN个指针,完全可以在logN的时间负责度内完成元素的查找,这也是Skiplist的精髓。

但是有一个问题,我们只实现快速查找是不够的,我们还需要保证元素的有序性,否则查找也无从谈起。但元素添加的顺序并不是有序的,我们怎么保证节点分配到的指针数量合理呢?

为了解决这个问题,Skiplist引入了随机深度的机制,也就是一个节点能够拥有的指针数是随机的。这种策略可以保证元素尽可能分散,不会发生数据倾斜的情况。
Skiplist 跳表 学习笔记_第2张图片
可以把每个节点想像成一个小楼,每个节点的多个指针可以看成是小楼的各个楼层。很显然,由于所有的小楼都排成一排,所以每栋楼的每一层都只能看到同样高度的最近的一栋。

由于各节点的高度是随机的,所以每个节点能看到的情况是分散的,可以防止数据聚集不平均问题,从而可以保证运行效率。

三、定义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]

五、实现Skiplist

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,这些逻辑清楚之后,代码就不难了。

六、query方法

查找的逻辑,类似于贪心算法:每次都尝试从最高的楼层往后看,如果看到的数值小于当前查找的key, 那么就跳跃过去,否则说明我们一下看得太远了,我们应该看近一些,于是往楼下走,重复上述过程,直到到达底层。

Skiplist 跳表 学习笔记_第3张图片
比如上图中,我们要查找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

七、delete方法

我们要删除节点,首先先找到节点,所以我们可以服用查找的代码来找到待删除的节点可能存在的位置。

找到了位置并不是直接删除他,因为我们删除他可能会影响其他元素。

还拿下图举例,假设我们要删除25这个元素,那么会发生什么?

Skiplist 跳表 学习笔记_第4张图片
对于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方法

在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的原理虽然不难理解,但代码写起来,由于涉及到指针操作,所以还是挺麻烦的,要写对并调试好并不容易,但相比于臭名昭著的各类平衡树而言,已经算非常简单了。

你可能感兴趣的:(数据结构与算法)