跳表的设计思路,值得每一个程序员学习

本文介绍了跳表这种数据结构,通过对比单链表和跳表的查找效率,阐述了跳表如何利用多级索引来加速查找过程。跳表的时间复杂度为O(log2n),空间复杂度为O(n)。文章还讨论了如何在Python中实现跳表,包括插入、删除、查找和遍历等操作,并解释了Redis选择跳表而非红黑树的原因。最后提到了Python中已有的跳表实现库。
摘要由CSDN通过智能技术生成

学习《数据结构与算法之美》中的第 17 节 [为什么redis一定要用跳表来实现有序集合]后,觉得很有价值,以自己的理解整理出下文,分享给爱学习的你,希望你可以看懂。

上篇文章二分法解决妹子遇到的难题 介绍了二分查找算法法的强大之处。二分查找算法之所以能达到 O(logn) 这样高效的一个重要原因在于它所依赖的数据结构是数组,数组支持随机访问一个元素,通过下标很容易定位到中间元素。而链表是不支持随机访问的,只能从头到尾依次访问。但是数组有数组的局限性,比如需要连续的内存空间,插入删除操作会引起数组的扩容和元素移动,链表有链表的优势,链表不需要先申请连续的空间,插入删除操作的效率非常高。在很多情况下,数据是通过链表这种数据结构存储的,如果是有序链表,真的就没有办法使用二分查找算法了吗?

实际上对有序链表稍加改造,我们就可以对链表进行二分查找。这就是我们要说的跳表。下面我们来看一下,跳表是怎么跳的。

image.png

上图是一个简单的有序的单链表,如果要查找某个数据,只能从头至尾遍历链表,查找到值与给定元素时返回该结点,这样的查询效率很低,时间复杂度是为O(n)。

假如对链表进行改造,先对链表中每两个节点建立第一级索引,再对第一级索引每两个节点建立第二级索引。如下图所示:

对于上图中的带二级索引的链表中,我们查询元素 16,先从第二级索引查询 1 -> 7->13,发现16大于13 ,然后通过 13 的 down 指针找到第一级索引的 17,发现 16 小于17 ,再通过13 的 down 指针找到链表中的 16,只需要遍历 6 个节点就完成 16 的查找。如果在单链表中直接查找 16 的话,只能顺序遍历,需要遍历 10 个节点,是不是效率上有所提升呢,由于数据量较小,遍历 10 个节点到遍历 6 个节点你可能觉得没有提升多少性能,那么请看下图:

从图中我们可以看出,原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点,速度是不是提高了很多?所以,当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。

这种带多级索引的链表,就是跳表。,是不是很像数据库中的索引?

跳表有多快?

单链表的查找一个元素的时间复杂度为O(n),那么跳表的时间复杂度是多少?

假如链表中有 n 个元素,我们每两个节点建立一个索引,那么第 1 级索引的结点个数就是 n/2 ,第二级就是 n/4,第三级就是 n/8, 依次类推,也就是说第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那么第k级索引的节点个数为 n 除以 2 的 k 次方,即 n/(2^k)。

假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2^h) = 2,得到 h=log2n - 1,包含原始链表这一层的话,跳表的高度就是 log2n,假设每层需要访问 m 个结点,那么总的时间复杂度就是O(m*log2n)。而每层需要访问的 m 个结点,m 的最大值不超过 3,这里为什么是 3 ,可以自己试着走一个。

因此跳表的时间复杂度为O(3log2n) = O(log2n)

跳表有多占内存?

天下没有免费的午餐,时间复杂度能做到 O(logn) 是以建立在多级索引的基础之上,这会导致内存占用增加,那么跳表的空间复杂度是多少呢?

假如有 n 个元素的链表,第一级索引为 n/2 个,第二级为 n/4 个,第三级为 n/8 个,…,最后一级为 2 个。这几级索引的结点总和就是n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢?

假如每 3 个节点抽取一个作为索引,同样的方法,可以计算出空间复杂度为 O(n/2) ,已经节约一半的存储空间了。

实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

跳表如何实现

跳表这种拿空间换时间的思想非常巧妙。那么如何编程实现一个跳表的数据结构呢?

其实,知道与践行之间隔着巨大的鸿沟,知道那么多的算法,可是仍写不出牛逼的代码。还是要多写多练,不然就会被说 talk is cheap,show me the code。

编码之前,应该思考一下跳表应支持的功能:
1、插入一个元素
2、删除一个元素
3、查找一个元素
4、查找一个区间的元素
5、输出有序序列

其实 redis 中有序集合支持的核心操作也就是这几个。这里说下为什么 redis 使用跳表而不使用红黑树。

1、红黑树在查找区间元素的效率没有跳表高,其他操作时间复杂度一致。
2、相比红黑树,跳表的实现还是简单的,简单就意味着不容易出错,bug 少,稳定,易读,易维护。
3、跳表更加灵活,通过改变索引构建策略,有效平衡效率和内存消耗。

0、跳表的数据结构 (python)

链表结点

class ListNode:
    def __init__(self, data = None):
        self._data = data
        self._forwards = []   # 存放类似指针/引用的数组,占用空间很小

这里的 _data 是 ListNode 的变量,前而加 _data 表示这是一个私有变量,虽然你能在类的外部修改它,但你最好不要这样做。(Python 在编码规范上并不阻止你做一些破坏(灵活),全靠你自觉)
_data 这里是做比较用的,在实际应用中,你可以这样写:

class ListNode:
    def __init__(self, key=None, value  = None):
        self._key = key
        self._value = value
        self._forwards = []   # 存放类似指针/引用的数组,占用空间很小

这里的 _key 就相当于上述的 _data。

跳表的类

class SkipList:

    _MAX_LEVEL = 4 

    def __init__(self):
        self._level_count = 1
        self._head = ListNode()
        self._head._forwards = [None] * self._MAX_LEVEL

    def find(self, value):
        '''
        查找一个元素,返回一个 ListNode 对象
        '''
        pass

    def find_range(self, begin_value, end_value) :
        '''
        查找一个元素,返回一组有序 ListNode 对象
        '''
        pass

    def insert(self, value):
        '''
        插入一个元素,返回 True 或 False
        '''
        pass
        
    def delete(self, value):
        '''
        删除一个元素,返回 True 或 False
        '''
        pass

    def _random_level(self, p = 0.5):
        '''
        返回随机层数
        '''
        pass

    def pprint(self):
        '''
        打印跳表
        '''
        
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值