跳表(Skip list),就是会跳的链表。。。。。。是一种在各方面性能都比较优秀的数据结构,可以支持快速的插入,删除,查找操作,写起来完全没有红黑树那么复杂和难理解。
其中在Redis中的有序集合就是用跳表来实现的,其实红黑树也是一样的支持快速插入,删除和查找,那么为什么Redis要使用跳表实现有序集合,而在很多语言里为什么要使用红黑树来实现Map,例如C++STL
讲了这么多,我们来看一下怎么来理解跳表这种数据结构?就是链表+索引
在查找的时候,假如你想查找16,那么先会找到13,13小于16,然后就下一个17大于16,那么就是13的下一个,这样遍历的话只需要7次就可以找到了,就减少了3次遍历。
那我们再网上加一层索引会有怎样的效果呢?
这样我们只需要遍历6个节点就能找到了16了,当数据量和索引表都增加的时候,效果就会更加明显
到了这里大概对跳表有了大概了解了
那么接下来我们来看一下时间复杂度和空间复杂度
按照上图,我们是从每两个节点中抽出一个结点作为上一级的索引结点,第一级索引的结点数n/2 第二级是n/4 第三级是n/8,那么第k-1级是1/2,第k级是索引结点的个数是n/(2^k)
假如索引有h级,最高索引有2个结点
,通过上面的公式我们可以得到n/(2^h)=2,就可以得到h=log2n-1,假如包含最原始的链表,那么就是log2n,那么我们在每一次需要遍历m个结点,那么时间复杂度是O(m*logn),你试试自己取算算,发现m最大也就是3,综上所述时间复杂度是O(logn),这就是用空间换时间的
跳表的空间复杂度并不复杂,n/2,n/4,n/8,...8,4,2 = n-2,也就是一个等比数列,所以它的空间复杂度是O(n)
当我们每三个结点抽一个索引,那么就是n/3,n/9,n/27,...,9,3,1 = n/2,尽管空间复杂度还是O(n),但是减少了索引空间,当结点的数据量大的时候,索引这个结点就可以被忽略了。
接下来我们来看一下跳表的高效插入和删除,删除和插入的操作时间复杂度还是O(logn),无论是插入还是删除我们都是需要先进行查找找到位置先,然后插入结点或者是删除结点,也就是链表的操作,突然发现我没有写链表的学习记录,找一天补上。
当我学到这里的时候我第一反应就是,这样不会把跳表的性质给破坏了嘛,然后退化成了链表,就像二叉树一样,所以才有了AVL二叉树,那么这里也是一样,所以我们需要取动态维护跳表,避免产生退化,性能下降。
当我们往跳表插入数据的时候,我们同是将这个数据插入到部分索引层中,我们可以通过一个随机函数,来决定结点插入那几级索引中,比如产生了k=2,就是如图所示,从概率上来讲能保证跳表的索引大小和数据的平衡性,不至于性能过度退化。
关于开头讲到的为什么用跳表而不用红黑树,作者是这样说的
Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:
插入一个数据;
删除一个数据;
查找一个数据;
按照区间查找数据(比如查找值在 [100, 356] 之间的数据);
迭代输出有序序列。
其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。
当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。
不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。