Redis为什么用跳表实现有序集合

1. 元素查询逻辑

假如我们需要查询元素 6,其工作流程如下:

  1. 从 2 级索引开始,先来到节点 4。
  2. 查看 4 的后继节点,是 8 的 2 级索引,这个值大于 6,说明 2 级索引后续的索引都是大于 6 的,没有再往后搜寻的必要,我们索引向下查找。
  3. 来到 4 的 1 级索引,比对其后继节点为 6,查找结束。

相较于原始有序链表需要 6 次,我们的跳表通过建立多级索引,我们只需两次就直接定位到了目标元素,其查寻的复杂度被直接优化为O(log n)。

2. 元素添加逻辑

假如我们需要在这个有序集合中添加一个元素 7,那么我们就需要通过跳表找到小于元素 7 的最大值,也就是下图元素 6 的位置,将其插入到元素 6 的后面,让元素 6 的索引指向新插入的节点 7,其工作流程如下:

  1. 从 2 级索引开始定位到了元素 4 的索引。
  2. 查看索引 4 的后继索引为 8,索引向下推进。
  3. 来到 1 级索引,发现索引 4 后继索引为 6,小于插入元素 7,指针推进到索引 6 位置。
  4. 继续比较 6 的后继节点为索引 8,大于元素 7,索引继续向下。
  5. 最终我们来到 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. 跳表的高度计算从原始链表开始,即默认情况下插入的元素的高度为 1,代表没有索引,只有元素节点。
  2. 设计一个为插入元素生成节点索引高度 level 的方法。进行一次随机运算,随机数值范围为 0-1 之间。
  3. 如果随机数大于 0.5 则为当前元素添加一级索引,自此我们保证生成一级索引的概率为 50% ,这也就保证了 1 级索引理想情况下只有一半的元素会生成索引。
  4. 同理后续每次随机算法得到的值大于 0.5 时,我们的索引高度就加 1,这样就可以保证节点生成的 2 级索引概率为 25% ,3 级索引为 12.5% ……

我们回过头,上述插入 7 之后,我们通过随机算法得到 2,即要为其建立 1 级索引:

3. 元素删除逻辑

假设我们这里要删除元素 10,我们必须定位到当前跳表各层元素小于 10 的最大值,索引执行步骤为:

  1. 2 级索引 4 的后继节点为 8,指针推进。
  2. 索引 8 无后继节点,该层无要删除的元素,指针直接向下。
  3. 1 级索引 8 后继节点为 10,说明 1 级索引 8 在进行删除时需要将自己的指针和 1 级索引 10 断开联系,将 10 删除。
  4. 1 级索引完成定位后,指针向下,后继节点为 9,指针推进。
  5. 9 的后继节点为 10,同理需要让其指向 null,将 10 删除。

4. 和其余三种数据结构的比较

4.1 平衡树 vs 跳表

先来说说它和平衡树的比较,平衡树我们又会称之为 AVL 树,是一个严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过 1,即平衡因子为范围为 [-1,1])。平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。

对于范围查询来说,它也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。

4.2 红黑树 vs 跳表

红黑树(Red Black Tree)也是一种自平衡二叉查找树,它的查询性能略微逊色于 AVL 树,但插入和删除效率更高。红黑树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n) 。

红黑树是一个黑平衡树,即从任意节点到另外一个叶子叶子节点,它所经过的黑节点是一样的。当对它进行插入操作时,需要通过旋转和染色(红黑变换)来保证黑平衡。不过,相较于 AVL 树为了维持平衡的开销要小一些。

相比较于红黑树来说,跳表的实现也更简单一些。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。

4.3 B+树 vs 跳表

想必使用 MySQL 的读者都知道 B+树这个数据结构,B+树是一种常用的数据结构,具有以下特点:

  1. 多叉树结构:它是一棵多叉树,每个节点可以包含多个子节点,减小了树的高度,查询效率高。
  2. 存储效率高:其中非叶子节点存储多个 key,叶子节点存储 value,使得每个节点更够存储更多的键,根据索引进行范围查询时查询效率更高。
  3. 平衡性:它是绝对的平衡,即树的各个分支高度相差不大,确保查询和插入时间复杂度为 O(log n) 。
  4. 顺序访问:叶子节点间通过链表指针相连,范围查询表现出色。
  5. 数据均匀分布:B+树插入时可能会导致数据重新分布,使得数据在整棵树分布更加均匀,保证范围查询和删除效率。


所以,B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。

4.4 Redis 作者给出的理由

1、它们不是很占用内存。这主要取决于你。改变节点拥有给定层数的概率的参数,会使它们比 B 树更节省内存。
2、有序集合经常是许多 ZRANGE 或 ZREVRANGE 操作的目标,也就是说,以链表的方式遍历跳表。通过这种操作,跳表的缓存局部性至少和其他类型的平衡树一样好。
3、它们更容易实现、调试等等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 主分支中),用增强的跳表实现了 O(log(N))的 ZRANK。它只需要对代码做很少的修改。

来源:https://javaguide.cn/database/redis/redis-skiplist.html

在实际应用中,跳表和链表的选择依据主要基于以下几个方面: ### 操作复杂度 - **查找操作**:链表查找元素的时间复杂度为 $O(n)$,需要从头节点开始逐个遍历,对于大规模数据查找效率较低。而跳表通过多级索引结构,查找元素的平均时间复杂度为 $O(log n)$,在查找效率上有显著提升。例如,在需要频繁进行查找操作的场景中,如数据库的索引系统,跳表更具优势。 - **插入和删除操作**:链表在已知插入或删除位置的情况下,时间复杂度为 $O(1)$,但如果需要先查找插入或删除的位置,整体时间复杂度会变为 $O(n)$。跳表的插入和删除操作平均时间复杂度也是 $O(log n)$,虽然略高于链表的 $O(1)$,但在动态数据频繁变化且需要快速定位插入删除位置的场景下,跳表的性能更稳定。 ### 空间复杂度 - 链表的空间复杂度为 $O(n)$,每个节点只需要存储数据和指向下一个节点的指针。跳表由于引入了多级索引,空间复杂度为 $O(n)$ 到 $O(n log n)$ 之间,需要额外的空间来存储索引节点。因此,在对空间要求较高的场景中,链表更合适;而在对空间要求相对宽松,但对时间性能要求较高的场景下,跳表是更好的选择。 ### 数据有序性 - 链表本身不保证数据的有序性,如果需要有序数据,需要额外的排序操作。跳表的数据元素默认按 key 值升序,天然有序,适合需要有序数据的场景,如范围查询等。 ### 并发场景 - 链表在并发场景下,如果多个线程同时进行插入、删除操作,需要复杂的同步机制来保证数据的一致性,实现难度较大。跳表的结构相对简单,在并发场景下更容易实现并发控制,通过对不同层次的索引进行加锁等方式,可以提高并发性能。 以下是链表和跳表的简单代码示例: ```python # 链表节点类 class ListNode: def __init__(self, value=0, next=None): self.value = value self.next = next # 链表类 class LinkedList: def __init__(self): self.head = None def insert(self, value): new_node = ListNode(value) if not self.head: self.head = new_node else: current = self.head while current.next: current = current.next current.next = new_node def search(self, value): current = self.head while current: if current.value == value: return True current = current.next return False # 跳表节点类 class SkipNode: def __init__(self, value=None, levels=1): self.value = value self.forward = [None] * levels # 跳表类 class SkipList: def __init__(self, max_levels=16, probability=0.5): self.max_levels = max_levels self.probability = probability self.header = SkipNode(float('-inf'), max_levels) self.level = 1 def random_level(self): level = 1 while random.random() < self.probability and level < self.max_levels: level += 1 return level def insert(self, value): update = [None] * self.max_levels current = self.header for i in range(self.level - 1, -1, -1): while current.forward[i] and current.forward[i].value < value: current = current.forward[i] update[i] = current current = current.forward[0] if current is None or current.value != value: new_level = self.random_level() if new_level > self.level: for i in range(self.level, new_level): update[i] = self.header self.level = new_level new_node = SkipNode(value, new_level) for i in range(new_level): new_node.forward[i] = update[i].forward[i] update[i].forward[i] = new_node def search(self, value): current = self.header for i in range(self.level - 1, -1, -1): while current.forward[i] and current.forward[i].value < value: current = current.forward[i] current = current.forward[0] if current and current.value == value: return True return False ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

算法小生Đ

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值