gitbook/业务开发算法50讲/docs/491979.md
2022-09-03 22:05:03 +08:00

18 KiB
Raw Blame History

31跳表Redis是如何存储有序集合的

你好,我是微扰君。

上一讲我们一起学习了布隆过滤器它可以帮助我们用更低的存储成本、更高效地判断某个元素是否在一个集合中出现当然代价是一定的误判率。总的来说布隆过滤器特别适合用来解决Redis中缓存穿透的问题。

今天我们同样来讨论一个在Redis中发挥巨大作用的数据结构跳表。如果你有一定的Redis使用经验常用的ZSET底层实现就是基于跳表的。

跳表这个数据结构其实在之前介绍红黑树的时候我们简单提到过和红黑树一样它可以非常高效地维护有序键值对插入、查询和删除的平均时间复杂度都是O(logN)所以被Redis用来存储有序集合。但在时空复杂度差不多的情况下跳表比红黑树实现起来要简洁优雅得多。

我个人认为跳表几乎在每个方面都比红黑树更好当然红黑树由于发明更早得到了更广泛的应用所以很多TreeMap之类的语言原生的数据结构还是常常采用红黑树。但是跳表作为一种非常高效的有序集合的实现背后的原理很值得我们学习。

那跳表是如何设计、实现的呢,我们开始今天的学习之旅。

跳表为什么诞生

故事的开头,还是要从链表说起。

之前讲红黑树(点这里复习)我们也提到,如果要实现一个字典这样的数据结构,其实可以直接用一个线性数据结构,来存储所有的元素,至于每次插入元素前如何判断元素是否存在,也很简单,遍历一遍就可以了。

但是这样的时间复杂度不是特别好,一种优化思路就是尽量让这个线性的数据结构有序,方便快速二分查找,更高效,因此我们也就延伸出了基于树状结构的二叉搜索树,以及对平衡性进一步优化的平衡二叉搜索树。

除此之外,是否还有其他高效的、可以加快搜索速度的优化方式呢?

可能你会想到哈希表当然了哈希表也是一个思路事实上在Redis对有序集合的实现里我们同时维护了跳表和哈希表为的就是利用单值查询时哈希表的高效性但哈希表的存储是无序的这意味着当我们想要使用范围查询的时候相比于红黑树或者跳表这样有序的数据结构哈希表就会产生一定的劣势这里复习)。

其实,不采用树状结构,仍然采用链表,再通过一些数据结构上的调整,我们也是可以实现类似二分查询这样跳跃查询效果的。这就是跳表主要被发明出来的动机。

跳表具体解决什么问题

先来仔细回顾一下如果用链表存储有序集合,在查询的时候会碰到什么样的问题?看这张典型的链表结构图(出自跳表原始的论文

图片

这里我们讨论的是有序集合,所以会让整个链表是有序排列的。

要存储有序集合,链表相比数组这样容器的好处,我们之前已经仔细讨论过了(点这里复习主要就是在插入的时候由于链表本身不要求内存连续所以插入和删除的时间复杂度是O(1)而数组为了保持内存空间的连续性需要花费O(n)的成本做插入和删除的操作。

但同样的也正是因为链表内存不连续我们在基于key查询链表节点时即使整个链表已经是按照key有序排列了我们仍然需要顺次遍历进行查询不能像在有序数组中那样二分地跳跃查询。O(n)的查询复杂度,显然没有有效地利用有序集合的有序特性。因此在这一点上,红黑树完胜。

那链表真的没有办法得到接近二分查找的时间复杂度了吗?只通过链表本身肯定是不行了,我们找到的本质问题是:链表不依次遍历就没有办法寻址到每一个节点,但是如果我们有办法在链表上增加一些捷径,跳着走呢?

链表是怎么设计的

这个想法有点抽象,我们找生活中的场景来类比联想。

请你回想自己每次从一个城市去往另一个城市,是怎么换乘公共交通的?是不是先到本地的机场或者火车站,乘坐站距比较大、速度比较快的飞机或者火车,再换成市内的、站距小的交通工具比如公交车、地铁。

这样的换乘选择就是考虑到,直接坐地铁的耗时,比先坐火车再换地铁长得多,因为地铁站比较密集,速度也比较慢,而高铁则快得多,一站相当于地铁的很多站。

那回到链表上,我们把一个个遍历链表节点比作是一站站地铁,如果在链表上能加一些间距更大的火车站,自然就快得多

这就是跳表的思想本质,具体来说就像图里这样:

图片

我们会给链表上增加一些额外的层和指针,越高的层,指针指向的下一个节点会跳跃更大的距离,越低的层,指针间距越小;所有的节点都会出现在最低层,也就是第一层,这一层就是一个包含了所有有序集合中元素的有序链表。

这样我们寻找某个元素的时候就可以像换乘公共交通一样先坐站距更远的交通工具再换站距更小的交通工具最后一段可能就是徒步整个搜索效率就会高很多。比如这个具体的例子假设我们想搜索的是71这个节点例子来源于CMU的课件

图片

在原始的链表中我们需要逐个遍历需要进行6次跳跃。如果采用了跳表这样分层链表的存储方式沿着指针移动的过程可以减少为了2次。

当然我们还是需要做一些next指针大小的判断具体的搜索过程从高到低每次比较包括三条分支

  1. 如果当前指针指向的值为target说明找到返回即可
  2. 如果当前指针指向的右节点的值大于target我们进入更低的一层
  3. 如果当前指针指向的右节点的值小于等于target我们将指针移动到右节点。

整个过程直至搜索到最低一层,如果仍然没有搜索到,说明元素不存在。

图中你会看到整个链表的左侧和右侧还有一个绿色和红色的节点,我们一般称为哨兵,和之前讲的链表的哑节点起到一样的作用,帮助我们用更统一、更简短的逻辑来处理边界条件。

看到这里,你应该很容易直观地感受到采用分层链表在检索上的优势吧。本质上来说,高层的链表和线性索引的原理是很像的,我们就是通过为原始的链表增加了不同层的索引,起到了和平衡二分搜索树一样的快速搜索的效果。

跳表实现原理

设计想法很好,现在真正的问题来了:我们如何维护这样的多层链表结构?如何在合适的时机里加入新的层,以保证既可以高效查询,又不至于带来太高的维护成本呢?

跳表节点定义

我们先回答第一个问题,跳表节点的基本数据结构。

由于搜索是从高层往底层进行的,基本上就是一个从左上到右下的过程,所以跳表的每个节点,至少需要一个右向指针和一个可以表示层的数据结构。

如果简单地用一个链表表示那就还需要引入一个下向的指针。当然跳表需要存储元素通常是一组键值键是任意可比较的类型。为了方便实现我们就假设元素只存储键且必须是int类型直接用val来表示。

写成C++的话,整个跳表节点的定义如下:

struct Node
{
    // 至少需要向右、向下指针
    Node* right;
    Node* down;  
    int val;
    Node(Node *right, Node *down, int val) 
      : right(right), down(down), val(val){}
};

有了这样向下和向右的指针,我们就可以维护整个多层的跳表结构了,也可以顺着指针进行前面说的查找过程了。

整个从左上到右下的搜索过程翻译成代码如下,我写了比较详细的注释供你参考:

    bool search(int target)
    {
        Node *p = head;
        while(p)
        {
            // 左右寻找目标区间
            while(p->right && p->right->val < target)
            {
                p = p->right;
            }
            // 没找到目标值,则继续往下走
            if(!p->right || target < p->right->val)
            {
                p = p->down;
            }
            else
            {   
                //找到目标值,结束
                return true;
            }
        }
        return false;
    }

完美跳表

继续看第二个问题,跳表每一层间距到底是多少合适呢?

其实最理想状态下,跳表所用的存储空间和查询过程,应该和二叉树是非常像的,我们会要求每一层都包含下一层一半的节点,且同一层指针跨越的节点数量是一样的。

所以基于和二叉树一样的原因层数一共是logN层在每一层中我们最多只会进行一次跳跃这是因为如果需要跳跃两次的话我们在上一层判断的时候就会选择直接右跳而不是下跳。因此每一层我们最多访问两个节点。整体搜索时间复杂度为O(logN),我们上面举的例子其实就是一个完美跳表。

但是完美跳表有一个非常显著的问题:在有序集合动态插入和删除的过程中,我们很难高效地维护这样的结构。比如下图也是一个完美跳表,满足每一层的节点数量是下一层的一半,且中间的每个元素都被上一个例子所包含。

图片

所以我们可以认为这个状态是上一个例子的前置状态之一也就是说在这个跳表中只需要顺次添加76、87、91三个元素理论上就应该得到上一个例子的跳表。

但是事实上你会发现,如果要维持完美列表,每一层的间距是一样的,我们就需要不断地调整每一个节点的层数,因为这个层数完全取决于该节点处于链表中的第几个位置。比如上图中的96的层数就有所变化。

随着不同的插入顺序,我们最差可能需要在某次插入中重置大部分节点的指针关系,这样的更新的维护成本显然不满足我们的期望,在引入了完美跳表的约束后,链表的插入、删除优势荡然无存。那怎么办呢?

引入随机性

关键就是我们需要放弃不同层数里严格倍增的节点数量约束,而只是让每一层的节点数量,在期望上,满足均匀分配和倍增的关系即可。这样从时间复杂度和空间复杂度上来说,我们的期望值其实不会有变化,只是会有一定的小波动。

还记得快排吗其实也是有一定随机性的算法虽然最差的时间复杂度是O(n*n)但可以认为这样极度劣化的情况基本不会发生所以我们认为快排的时间复杂度仍然为O(n*logn)。这里引入随机性的跳表也是一样的情况。

跳表和随机性相关的地方主要体现在插入过程。假设需要插入的节点值为val具体过程我们先梳理一下后面会结合具体例子加强理解。

首先我们进行一遍查找过程也就是根据三个分支条件判断要么返回要么向下移动要么向右移动直到找到某个次小于且最接近于val的节点。

其次在搜索过程中我们需要记录一下搜索路径这个和DFS中记录路径的方式是一样的每进到下一层前把当前节点推入一个数组即可。

最后,随着搜索结束,我们一定会停留在跳表的最底层,且搜索指针指向的是最接近于目标值的节点,这个时候就需要进行真正的插入操作了。

为了保证每一层的节点数量从期望上来说是上一层的两倍,每次插入一个节点的时候,我们可以采用抛硬币的策略,通过50%的概率决策,决定是否需要继续将这个插入到更高的一层。由于我们记录了整个路径,插入上一层的实现,也就是简单将一个新的节点插入到路径里上一层节点的右侧。简单算一下你就可以发现每个节点插入时在每一层的概率分别是:

  • 第一层时100%会被插入(所有节点都出现在第一层)
  • 第二层只有1/2的概率会被插入
  • 第三层是1/4的概率会被插入

基于这样的策略,从期望上来说,层与层之间的节点数量自然就会满足期望倍增约束,且每个节点都不会有任何优待,每一层节点间的间距也比较均匀。到这里,多层的跳跃表结构也就完成了,在实际应用中达到了不输于红黑树的查询、插入、删除的效率。

看个例子

我们来看一个具体例子理解一下这个过程比如我们希望插入val=87的元素

图片

第一步当然就是要做搜索找到链表中离87最近的86并记录路径上每一层的节点

图片

其次需要将新建的87节点插入到86的右侧。按照抛硬币的策略我们决定要不要往上一层继续添加该节点。比如我们连续抛了两次正面的硬币第三次抛了反面最终就只在1、2、3层中添加了87节点

图片

整个过程写成代码也不难,思路和前面说的完全一致,代码和注释供你参考。这里唯一需要注意的点就是,如果已经超过了目前的层数,但是抛硬币的结果还是正面,我们会在跳表中新建一层,其右节点为空,也就是该层只有这一个节点。

    void add(int num) {
        // 从上至下记录搜索路径
        pathList.clear();
        Node *p = head;
        // 从上到下去搜索 次小于num的数字
        while(p)
        {
            // 向右找到次小于num的p
            while (p->right && p->right->val < num)
            { 
                p = p->right;
            }
            pathList.push_back(p);
            p = p->down;
        }

        bool  insertUp = true;
        Node* downNode = NULL;
        // 从下至上搜索路径回溯50%概率
        // 这里实现是会保证不会超过当前的层数的,然后靠头结点去额外加层, 即每次新增一层
        while (insertUp && pathList.size() > 0)
        {
            Node *insert = pathList.back();
            pathList.pop_back();
            // add新结点
            insert->right = new Node(insert->right,downNode,num); 
            // 把新结点赋值为downNode
            downNode = insert->right; 
            // 50%概率   
            insertUp = (rand()&1)==0;
            // cout << " while new node " << num << " insertUp " << insertUp << endl;
        }
        // 插入新的头结点,加层
        if(insertUp)
        {  
            // cout << " insertUp new node " << num << endl;
            head = new Node(new Node(NULL,downNode,num), head, -1);
        }
    }

删除的过程呢?相比于插入就简单很多。我们同样需要先找到该节点,但指针还是始终指向目标节点的左侧节点。在每层中,发现目标节点存在后,用当前节点指向右侧节点的右侧节点,和删除链表节点的写法是一致的,然后在后面的每一层都进行同样的操作,直到遍历完成。

总结

今天我们一起学习了Redis中有序集合的底层实现跳表作为字典类数据结构它有着和红黑树、哈希表都不同的实现方式。

跳表和红黑树一样都提供了O(N)的空间复杂度O(logN)的插入、查询、删除的时间复杂度,但实现起来比红黑树简单很多,通过引入随机性,我们只需要搜索并记录路径,就可以在保持跳表查询效率的同时,快捷地插入元素。这个操作比红黑树的旋转操作要简单很多。

虽然单点查询的效率确实不如哈希表,但跳表可以很好地支持范围查询,只要找到对应的范围节点,然后顺次在链表上遍历就可以了,这一点比红黑树也有明显优势。

可以认为在大部分方面,跳表都是非常有优势的有序集合实现方式,引入随机性从期望上保证效率、降低维护成本的思想也值得你好好体味。在力扣上的1206.设计跳表就是一个实现跳表的题目,你可以去上面试一试,平台给你提供了丰富的用例,能帮助你快速判断实现的正确性。

课后作业

今天的作业就是尝试实现一下跳表的删除操作,当然直接实现整个跳表是更好的,能帮助你复习一下链表的许多操作。

欢迎你在留言区留下你的实现,我们可以一起讨论。如果觉得这篇文章对你有帮助的话,也欢迎把这篇文章转发给你的朋友一起学习,我们下节课见~