Redis为什么用跳表实现有序集合
手写一个跳表
为了更好的回答上述问题以及更好的理解和掌握跳表,这里可以通过手写一个简单的跳表的形式来帮助读者理解跳表这个数据结构。
我们都知道有序链表在添加、查询、删除的平均时间复杂都都是 O(n) 即线性增长,所以一旦节点数量达到一定体量后其性能表现就会非常差劲。而跳表我们完全可以理解为在原始链表基础上,建立多级索引,通过多级索引检索定位将增删改查的时间复杂度变为 O(log n) 。
可能这里说的有些抽象,我们举个例子,以下图跳表为例,其原始链表存储按序存储 1-10,有 2 级索引,每级索引的索引个数都是基于下层元素个数的一半。
假如我们需要查询元素 6,其工作流程如下:
-
从 2 级索引开始,先来到节点 4。
-
查看 4 的后继节点,是 8 的 2 级索引,这个值大于 6,说明 2 级索引后续的索引都是大于 6 的,没有再往后搜寻的必要,我们索引向下查找。
-
来到 4 的 1 级索引,比对其后继节点为 6,查找结束。
相较于原始有序链表需要 6 次,我们的跳表通过建立多级索引,我们只需两次就直接定位到了目标元素,其查寻的复杂度被直接优化为O(log n)。
对应的添加也是一个道理,假如我们需要在这个有序集合中添加一个元素 7,那么我们就需要通过跳表找到小于元素 7 的最大值,也就是下图元素 6 的位置,将其插入到元素 6 的后面,让元素 6 的索引指向新插入的节点 7,其工作流程如下:
-
从 2 级索引开始定位到了元素 4 的索引。
-
查看索引 4 的后继索引为 8,索引向下推进。
-
来到 1 级索引,发现索引 4 后继索引为 6,小于插入元素 7,指针推进到索引 6 位置。
-
继续比较 6 的后继节点为索引 8,大于元素 7,索引继续向下。
-
最终我们来到 6 的原始节点,发现其后继节点为 7,指针没有继续向下的空间,自此我们可知元素 6 就是小于插入元素 7 的最大值,于是便将元素 7 插入。
这里我们又面临一个问题,我们是否需要为元素 7 建立索引,索引多高合适?
我们上文提到,理想情况是每一层索引是下一层元素个数的二分之一,假设我们的总共有 16 个元素,对应各级索引元素个数应该是:
1. 一级索引:16/2=8 2. 二级索引:8/2 =4 3. 三级索引:4/2=2
由此我们用数学归纳法可知:
1. 一级索引:16/2=16/2^1=8 2. 二级索引:8/2 => 16/2^2 =4 3. 三级索引:4/2=>16/2^3=2
假设元素个数为 n,那么对应 k 层索引的元素个数 r 计算公式为:
r=n/2^k
同理我们再来推断以下索引的最大高度,一般来说最高级索引的元素个数为 2,我们设元素总个数为 n,索引高度为 h,代入上述公式可得:
2= n/2^h => 2*2^h=n => 2^(h+1)=n => h+1=log2^n => h=log2^n -1
而 Redis 又是内存数据库,我们假设元素最大个数是65536,我们把65536代入上述公式可知最大高度为 16。所以我们建议添加一个元素后为其建立的索引高度不超过 16。
因为我们要求尽可能保证每一个上级索引都是下级索引的一半,在实现高度生成算法时,我们可以这样设计:
-
跳表的高度计算从原始链表开始,即默认情况下插入的元素的高度为 1,代表没有索引,只有元素节点。
-
设计一个为插入元素生成节点索引高度 level 的方法。
-
进行一次随机运算,随机数值范围为 0-1 之间。
-
如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为 50% ,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。
-
同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为 25% ,3 级索引为 12.5% ……
我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:
最后我们再来说说删除,假设我们这里要删除元素 10,我们必须定位到当前跳表各层元素小于 10 的最大值,索引执行步骤为:
-
2 级索引 4 的后继节点为 8,指针推进。
-
索引 8 无后继节点,该层无要删除的元素,指针直接向下。
-
1 级索引 8 后继节点为 10,说明 1 级索引 8 在进行删除时需要将自己的指针和 1 级索引 10 断开联系,将 10 删除。
-
1 级索引完成定位后,指针向下,后继节点为 9,指针推进。
-
9 的后继节点为 10,同理需要让其指向 null,将 10 删除。
总结:
有几个原因:
1、它们不是很占用内存。这主要取决于你。改变节点拥有给定层数的概率的参数,会使它们比 B 树更节省内存。
2、有序集合经常是许多 ZRANGE 或 ZREVRANGE 操作的目标,也就是说,以链表的方式遍历跳表。通过这种操作,跳表的缓存局部性至少和其他类型的平衡树一样好。
3、它们更容易实现、调试等等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 主分支中),用增强的跳表实现了O(log(N))的 ZRANK。它只需要对代码做很少的修改。