Redis 有序列表和跳表

118 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第16天,点击查看活动详情

我将讨论Redis中一个非常有用的数据结构——排序集。

我在Redis中搜索优先级队列时发现了Sorted Sets数据结构。令我惊讶的是,在Redis中没有优先级队列实现,尽管它是在许多实际场景中使用的最重要的数据结构之一。

优先级队列的优点是:

快速的最小和最大get()操作。事实上,对于最小优先级队列,获得最小值是O(1)。与最大优先级队列类似。但是您不能在O(1)中使用相同的优先级队列同时获得两者。

去掉最小值和最大值是O(logN),其中N是优先级队列的总大小。但是同样,您不能使用相同的优先级队列在O(logN)中删除两者。

在优先级队列中插入一个新元素是O(logN)。类似地,删除一个现有元素是O(logN)

更新现有索引或键也是O(logN)。

优先队列是使用堆数据结构实现的,其中对于Min-Heap,第i个索引上的值小于索引2i+1和2i+2上的值。

这些在以下场景中很有用:

保持K个最高或最低的元素。例如,在查询数据流中跟踪100个最频繁的搜索查询。

找到K个最接近的元素。

……还有更多。

为了找到K个最高或最低的元素,我们总是可以对数组进行排序,然后取第一个或最后一个K个元素,但想象一下,您正在接收一个数据流,例如搜索查询,您必须为每个新查询对迄今为止看到的整个数据进行排序。这是非常低效的。

Python有一个库' heapq '实现了最小优先级队列。但是这种实现无法更新堆中的索引或键。

例如,如果我想更新一个查询的频率,那么如果查询不是在堆的根,那么我不能直接更新频率。因此,我用Python编写了一个实现,可以在O(logN)中进行更新。

令我惊讶的是,Redis没有优先队列实现,但它依赖于所谓的排序集。排序集没什么特别的,但它是一种哈希映射,其中的条目是根据它们的值排序的。

{instagram的‘facebook’:1.0:3.0,“苹果”:6.0,“谷歌”:10.0}

通常,当我们使用HashMap(或Python中的dict)时,不能保证键或值是有序的。

简单HashMaps提供了以下复杂的时间(在平均情况下):

按键- O(1)搜索

按值搜索- O(N)(我们需要扫描整个哈希图)

插入、删除和更新- O(1)

获取最小/最大值- O(N)的键值对(我们需要扫描整个哈希映射)

删除最小/最大值(键值对)- O(N)(与第4点相同)

但是使用HashMaps按值排序,我们可以改进按值搜索,获得具有最小/最大值的键值对,并从O(N)中移除最小/最大值到O(logN)。

但这也会影响搜索、插入、删除和更新,因为现在我们不能只在O(1)中执行,因为我们需要保持顺序。

在许多实际场景中,排序集可以使用平衡二叉搜索树实现。

平衡bst是任意节点上左右子树高度之差不超过1的bst。构建平衡bst的常用算法是avl树和红黑树。

这些也可以用于流数据。

通常,当元素被添加到BST时,它可以是有序的,也可以是随机打乱的。当连续爆发的元素在流中排序时,搜索、插入/删除/更新在最坏的情况下是O(N),因为树会倾斜。

因此,我们需要平衡的bst,以便搜索、插入/删除/更新在最坏的情况下是O(logN)。

按键- O(1)搜索

按值搜索- O(logN)

插入、删除和更新- O(logN)

获取具有最小/最大值的键值对- O(logN)

删除最小/最大值(键值对)- O(logN)

在Redis中,排序集是使用一种称为跳过列表的概率数据结构实现的。

提出跳表的动机是为了提高链表的搜索时间复杂度。

排序数组和排序链表之间的区别是,在排序数组中,我们可以在O(logN)时间内搜索一个元素,但在排序链表中我们不能这样做,因为我们需要遍历下一个指针才能到达搜索节点。

在跳过列表中,有多个级别的排序列表。基本上每个节点都可以有多个next指针和previous指针。跳跃列表中的每个节点被随机分配一个级别l。对于跳跃列表中的级别' i ',在级别至少为' i '的节点之间存在下一个指针。

让我们看看从原始论文中截取的下图。

image.png

这些值在上面的列表中排序。此外,每个节点都有不同的级别(如果你喜欢,也可以是高度)。例如,值为6的节点属于级别4(因为它有4个级别),同样,值为9和17的节点属于级别2,值为25的节点属于级别3,其余的节点属于级别1。

头节点被分配为最高级别,即存在于所有级别。

级别是按概率分配的。在更常见的场景中,级别为“i”的节点被“提升”到级别为“i+1”的概率为50%。或者你可以这样想:

假设级别的数量在1到4之间,那么如果一个节点有100%的机会被分配到级别1,那么它也有50%的机会分配到级别2,25%的机会分配到级别3,12.5%的机会分配到级别4。因此,1/8的节点将被分配到级别4。

在跳跃表中搜索的过程如下:

设最大能级= L = 4。在上面的列表中搜索值17。

从级别L的头节点指针开始。

在L层跟踪next指针,直到next为17或next为NULL或next的值大于17。

如果级别L的next为NULL或值大于17,则执行L = L-1

重复步骤2和3。如果值17存在,那么它一定是在第1级被发现的。否则,如果我们在第1级到达NULL指针,则该值不存在。

上述算法相对于普通排序链表的优点是,在单层排序链表中,我们需要遍历每个节点才能到达我们的搜索值。

但是使用跳跃列表,我们跳过了节点3、9和12,而在上面的图中搜索值17。平均搜索时间复杂度为O(logN), log的底数为1/p,其中“p”是“i”级别的节点也被分配到“i+1”级别的概率。这里是0.5。

类似地,插入值为17的节点,如下所示:

设最大能级= L = 4。

对于新节点,在1到l之间随机抽取一个级别,我们称这个级别为H。

对于所有级别≤H,维护数组更新[]。

数组update[]将包含指向最终列表中新节点之前的所有节点的指针。也就是说,update[j]将包含指向“j”级节点的指针,该节点的最大值小于17。

从级别L的头节点指针开始。

在L层跟踪next指针,直到next为NULL或next的值大于17。

如果级别L的next为NULL或值大于17,则执行L = L-1

如果level L的next值为NULL或大于17且L≤H,则执行update[L] = current_node。

重复步骤5至7。

一旦达到第1级,我们创建新节点,然后将next指针分配给数组update[]中所有指向新节点的节点。

image.png

插入跳跃列表的时间复杂度仍然是O(logN)。

类似地,对于删除和更新,我们有O(logN)时间复杂度。

没有解释所有其他算法,它们非常相似,也很容易理解。下面是跳跃列表类实现的所有必需功能:

按键搜索(' search_key ')

按值搜索(' search_val ')

插入(“插入”)

删除(删除)

获取最小值(' get_min ')和最大值(' get_max ')

弹出最小值(' pop_min ')和弹出最大值(' pop_max ')

排序集除了为所有优先级队列操作提供类似的时间复杂度保证外,还提供了比优先级队列更多的好处:

可以在相同的数据结构中获得最小值和最大值,或者进行min-pop和max-pop,而不像在heap中,我们需要分别获得min-heap和max-heap。

有用的分析与流数据,需要在每个时间实例的数据排序快照。例如,计算一串数字的中位数、四分位范围、百分位数、小于或大于X的数值的数目等。

与优先队列中的O(NlogN)相比,在O(N)中获得排序的值列表(因为列表已经排序)。