Skiplist跳表详解及其模拟实现

跳表

1.跳表的概念

 跳表是基于有序链表扩展实现的。对于一个普通的有序链表,我们查找数据的时间复杂度是O(N)。而跳表的出现,让当前结点可能有能力跳过多个结点,以此来减少不必要的查找与比较,从而提高效率,跳表的时间复杂度是O(logN)

跳表的发展过程:

  1. 假设我们让每相邻的两个结点升高一层,增加一个指针,让该指针指向下下个节点,如下图b中所示。这样所有新增加的指针又形成了一个新的链表(不同层级的),它包含的结点数目是原来的一般。因此在进行数据查找的时候,我们不需要与链表重的每个结点进行比较了,需要比较的结点数目变为了原来的一半(大约)。
  2. 我们可以在2层链表的基础上进行进一步的扩展,让每相邻的两个结点(都是2层链表的结点)升高一层,增加一个指针,从而产生三层链表,如下图c中所示。这样搜索效率进一步提高了。
Clip_20221024_105859

 跳表的这个提高链表层级的思路非常类似于二分查找,这使得它的查找时间复杂度可以降低到O(logN),因为每提高一层,搜索的效率就会提高一些。

 但是这个结构在插入或删除数据的时候是存在很大缺陷的,插入或删除数据后,会打乱相邻两层链表上结点个数的严格的比例关系(2:1关系),因此如果要进行调整的话,也必须要把后面的所有结点都重新调整,这样会使时间复杂度退化为O(N)。

  1. 为了避免上述的问题,Skiplist在设计的时候不再要求严格的按照比例关系了。它要求在插入一个结点的时候,随机出一个层数。这样每次插入和删除的时候不需要再考虑其它结点的层数了。下图就是使用随机层数后的插入过程。
Clip_20221024_105453

2.Skiplist在插入时采用随机层数的方法是如何保证效率的呢?

 在此之前,我们先来了解一下Skiplist是如何实现随机层数的方法吧!

实现随机层数的方法:

Clip_20221024_134608

在Redis的Skiplist的实现中,参数maxLevel和p的取值如下:

maxLevel = 32
p = 0.25

maxLevel表示最大层数,p表示每多一层的概率。

设当前层数level=1
产生层数为1的概率为(1 - p), 产生大于1层的概率为p
产生层数为2的概率为p * (1 - p),产生大于2层的概率为p^2
产生层数为3的概率为p^2 * (1 - p),产生大于3层的概率为p^3
产生层数为4的概率为p^3 * (1 - p),产生大于4层的概率为p^4

产生层数为n的概率为p^(n-1) * (1 - p),产生大于n层的概率为p^n

一个结点的平均层数(即,每个结点包含的平均指针数目),计算如下:

Clip_20221024_140043
  • 当p = 0.25时,每个结点的平均层数为1.33层。(每个结点包含的平均指针个数为1.33)
  • 当p = 0.5时, 每个节点的平均层数为2层。 (每个节点包含的平均指针个数为2)

3.跳表的模拟实现

struct SkiplistNode{
    int val;
    vector<SkiplistNode*> nextV;

    SkiplistNode(int num, int level)
        :val(num)
        ,nextV(level, nullptr)
    {}
};

class Skiplist {
public:
    Skiplist() 
        :head(new SkiplistNode(-1, 1))  //头结点处我们初始化它是1层的(其实也可以直接拉到32层maxLevel,不过性能略有损耗)
    {
        srand(time(nullptr));   //生成随机数种子
    }
    
    bool search(int target) {
        SkiplistNode* cur = head;
        int level = cur->nextV.size() - 1;  //层数的下标

        //当level小于0时退出循环
        while(level >= 0)
        {
            //1. 当前结点的下一个为nullptr 或 下一个的值 > target, 此时向下走
            if(cur->nextV[level] == nullptr || cur->nextV[level]->val > target){
                --level;
            }//2. 当前结点的下一个不为nullptr && 下一个的值 < target, 此时向右走
            else if(cur->nextV[level]->val < target){
                cur = cur->nextV[level];
            }//3. 下一个的值 == target
            else{
                return true;
            }
        }
        return false;
    }
    
    //找prevV, add和erase均需要用
    vector<SkiplistNode*> findPrevVector(int target){
        SkiplistNode* cur = head;
        int level = cur->nextV.size() - 1;  //下面使用的都是下标, 所以这里作-1处理
        vector<SkiplistNode*> prevV(level + 1, head); //这里的prevV数组中, "i下标处存储的是第i+1层"的前一个节点
                                 //这里prevV默认初始化必须为head, 因为SkipList为空时, 插入的前一个节点一定是head
        while(level >= 0)
        {
            //1. 当下一个为nullptr 或 下一个的值 >= target, 此时向下走。(这里考虑允许数据冗余了)
            if(cur->nextV[level] == nullptr || cur->nextV[level]->val >= target){
                prevV[level] = cur; //prevV[level]存的是cur!!!
                --level;
            }
            else{//2. 当下一个不为nullptr && 下一个的值 < target
                cur = cur->nextV[level];
            }
        }
        return prevV;
    }

    void add(int num) {
        int level = randomLevel();  //随机生成层数
        if(level > head->nextV.size()){ //判断是否需要更新头结点的层数
            head->nextV.resize(level, nullptr);
        }
        vector<SkiplistNode*> prevV = findPrevVector(num);
        SkiplistNode* newNode = new SkiplistNode(num, level);
        //更新连接关系
        for(size_t i = 0; i < level; ++i)
        {
            newNode->nextV[i] = prevV[i]->nextV[i]; //prevV[i]: i下标就是第i+1层的前一个结点
            prevV[i]->nextV[i] = newNode;
        }
    }
    
    bool erase(int num) {
        vector<SkiplistNode*> prevV = findPrevVector(num);
        //先判断num值是否存在. 判断prevV[0]的下一个是否为nullptr, 以及下一个值是否为num
        //这里必须使用0下标, 其它下标的层数可能不存在
        if(prevV[0]->nextV[0] == nullptr || prevV[0]->nextV[0]->val != num){
            return false;
        }
        else{//删除, 并更新连接关系
            SkiplistNode* delNode = prevV[0]->nextV[0];
            for(size_t i = 0; i < delNode->nextV.size(); ++i)
            {
                prevV[i]->nextV[i] = delNode->nextV[i];
            }
            delete delNode;

            //判断是否需要更新头结点nextV的大小(无关紧要)
            size_t i = head->nextV.size() - 1;
            for(; i >= 0; --i)
            {
                if(head->nextV[i] != nullptr)
                    break;
            }
            head->nextV.resize(i + 1);

            return true;
        }
    }

    int randomLevel(){
        int level = 1;
        //rand()的概率是[0, RAND_MAX], 我们这里限定rand() < RAND_MAX * p, 这个概率正好为p
        while(rand() <= RAND_MAX * p && level < maxLevel)
        {
            ++level;
        }
        return level;
    }

private:
    SkiplistNode* head;
    size_t maxLevel = 32;
    double p = 0.25;
};

4.跳表VS平衡搜索树和哈希表

 跳表本质是一种搜索结构,它根平衡搜索树和哈希表是同一领域的,它同时支持key或key/value的搜索模型。

  1. Skiplist相比平衡搜索树(AVL、红黑树),遍历数据时都可以做到有序输出,时间复杂度也差不多。
    Skiplist的优势:
    a. Skiplist的实现更简单,容易控制; 平衡搜索树的增删查改都较为复杂。
    b. Skiplist的额外空间消耗更低,Skiplist在p=0.25时,每个结点包含的指针数目为1.33; 而平衡搜索树每个结点都是一个三叉链(存储3个指针),并且还需要额外存储平衡因子/颜色属性。
  2. Skiplist相比哈希表就没什么优势了。哈希表的时间复杂度为O(1), 要比Skiplist快一些,但是Skiplist的空间消耗要少一些。
    Skiplist的优势:
    a. 遍历数据有序。
    b. Skiplist空间消耗少一些,哈希表存在表空间的消耗以及每个下标处挂的链表的指针消耗。
    c. 哈希表扩容时有性能损耗。
    d. 哈希表在极端场景下(哈希冲突高),效率会大幅度下降,此时需要使用红黑树来补救。
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Redis中的跳表Skip List)是一种有序数据结构,用于实现有序集合(Sorted Set)的数据存储和查询。跳表通过添加多级索引来加速查询操作。 跳表实现主要包含以下几个步骤: 1. 跳表节点的定义:定义一个节点结构,包含键值对以及多个指向下一个节点的指针,其中指针的数量由一个随机函数决定,通常设置为1到32之间。 2. 跳表层级的定义:定义一个层级结构,包含多个指向不同层级的节点的指针。每个层级都是一个链表,其中最底层是原始数据链表,每个节点按照键值进行排序。 3. 插入操作:在插入新节点时,需要选择节点要插入的层级。从最高层级开始,逐层向下遍历,直到找到插入位置。在遍历过程中,如果遇到相同键值的节点,则更新节点的值;如果没有找到相同键值的节点,则将新节点插入到对应位置,并将相关指针进行更新。 4. 删除操作:在删除节点时,需要找到对应键值的节点,并将相关指针进行更新。如果删除后某个层级中没有节点了,则需要将该层级删除。 5. 查询操作:在查询某个键值对应的节点时,从最高层级开始,逐层向下遍历,直到找到节点或者遍历到最底层。在遍历过程中,根据节点的键值与目标键值的大小关系,决定向右还是向下移动。 跳表的优点是查询效率高,时间复杂度为O(log N),与平衡二叉树相当。同时,跳表实现相对简单,不需要进行平衡操作,适用于实际应用中有序集合的存储和查询需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值