对链表稍加改造,就可以支持类似“二分”的查找算法。我们把改造之后的数据结构叫做跳表。它是一个各方面性能比较优秀的动态数据结构,可以支持快速的插入、删除、查找操作。
Redis中的有序集合(Sorted Set)就是用跳表来实现的。Redis为什么会选择跳表来实现有序集合呢?为什么不用红黑树?
1. 理解“跳表”
链表加上多级索引的结构,就是跳表。
原始单链表,想要查找某个数据,即使数据是有序的,也得从头开始遍历,所以效率会比较低,时间复杂度会比较高。
为了提高查找效率,可以像下图这样对链表建立一级“索引”,每两个结点取一个结点到上一级,把抽取出来的那一级叫做索引或是索引层。图中的down表示down指针,指向下一级结点。
还可以在第一级索引的基础上,再往上提取第二级索引,如下图所示:
现在,以查找结点16为例,来进行对比分析。
- 图一的原始单链表,为了查找结点16,需要遍历10个结点
- 图二的原始链表+第一级索引,为了查找结点16,需要遍历7个结点
- 图三的原始链表+第一二级索引,为了找到结点16,需要遍历6个结点
从上面的例子,可以看出,加上索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找的效率提高了。
前面讲的这种链表加上多级索引的结构,就是跳表。
2. 跳表查询的时间复杂度
在一个单链表中查询某个数据的时间复杂度是O(n),那么在一个具有多级索引的跳表结构中,查询某个数据的时间复杂度是多少呢?假设一个链表有n个结点,那第一级索引的结点个数大约是n/2,第二级索引的结点个数大约是n/4,以此类推,第k级索引的结点个数是第k-1级索引的1/2。那就是n/()。
假设索引有h级,最高级有2个结点,即n/()=2,求得h =
-1,如果包含原始那一层,那整个跳表的高度就是
,如果每一层都要遍历m个结点,那在跳表中查询某一个数据的时间复杂度就是O(m*logn)。由前面那种索引结构,每一级索引最多只需要遍历3个结点,所以m=3。
所以,在跳表中查询任意数据的时间复杂度是O(logn)。
3. 跳表查询的空间复杂度
对于一个原始链表大小为n的跳表,各级索引的结点个数依次是:n/2,n/4,n/8...4,2。
n/2+n/4+n/8+...+4+2 = n-2,所以跳表的空间复杂度是O(n)。也就是说,将包含n个结点的单链表构造成跳表,我们需要额外用接近n个结点的存储空间。
如果每三个结点提取一个结点作为上一级索引,如图所示:
那此时每一级的结点个数构成的等比数列如下:
n/3+n/9+n/27+...+3+1 = n/2,尽管空间复杂度仍然是O(n),但是相比上面的两个结点抽一个结点的索引构建方法,这个方法要少一半的存储空间。
4. 跳表高效的动态插入和删除
插入操作:查找需要插入的位置,对于纯粹的单链表来说,需要遍历每个结点,找到插入的位置。但是对于跳表来讲,查找某个数据应该插入的位置与前面讲的查找某个结点的方式类似,时间复杂度也是O(logn)。从下图可以清晰看到插入的过程:
删除操作:删除操作就是找到要删除的结点,然后找到其前驱结点,然后通过指针操作完成删除。但是如果这个结点在索引中也有出现,那除了删除原始链表中的结点,还需要删除索引中的结点。
5. 跳表索引动态更新
当不停往跳表中添加数据的时候,如果不更新索引,会导致某一级索引的两个结点之间的数据非常多,极端情况下,可能会退化为单链表。
作为一种动态数据结构,需要维护索引和原始链表大小之间的平衡关系。如果链表中的结点多,索引结点就相应增多,这样可以避免复杂度退化,以及查询、插入、删除操作性能下降。
当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分的索引层中。通过随机函数生成数k,然后将这个结点添加到第一级到第k级这k级索引当中。
6.为什么Redis一定要用跳表来实现有序集合?
Redis的有序集合支持的核心操作主要有下面这几个:
- 插入一个数据
- 删除一个数据
- 查找一个数据
- 按照区间查找数据(比如查找值在区间[100,356]的数据)
- 迭代输出有序序列(不是很懂)
在插入、删除、查找以及迭代输出有序序列这几个操作上,跳表跟红黑树的时间复杂度是一样的,但是在按区间查找数据的操作上,跳表的效率比红黑树更高。
- 跳表较红黑树更好实现,意味着可读性好、不易出错。
- 跳表更加灵活,可以通过改变索引结构来平衡执行效率和内存消耗之间的关系。
关于红黑树的介绍,参见后面的文章。