【C++】手把手教你实现自己的SkipList

1. 什么是SkipList跳表

skiplist基于链表进行升级,用于查找,与平衡搜索树和哈希表的作用是一致的,可以作为key或者k/v的查找模型(kv存储引擎redis,leveldb使用了skiplist

skiplist是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。

感兴趣的同学可以阅读一下论文,这里我总结了William Pugh的思路:

在这里插入图片描述

  1. 假设每相邻的两个节点升高一层,增加一个指针,让指针指向下下个节点。如图b,此时因为增高操作而产生的指针连成了一个新的链表,但是包含的节点个数只有原来的一半,由于增加了新的指针,我们不需要在遍历原链表查找了,需要比较的节点数大概只有原来的一半。
  2. 依此类推,我们不断增高链表,每次节点都变为下一层的一半(如图c),进一步增加查找效率(思想与二分一致

按照上面的思想,SkipList可以简单理解为一个多层的链表。不过实际上,按照上面的拓展方式,每层之间的节点数量关系是严格1/2的,是一个标准的二分查找模型,时间复杂度是O(log_2 N)。
但是,这个结构面临增删场景时会将十分棘手,插入一个节点,或者删除一个节点,都会打乱数量规则,此时,为了恢复数量关系,需要对新插入的节点后面的所有节点进行调整,意味着时间复杂度降为O(n);

为此,William Pugh进行了一个大胆的处理,即不再严格要求对应的比例关系,而是在插入节点的时候随机出一个层数,此时每次插入,删除不需要考虑其他节点层数了,处理难度大大简化。

在这里插入图片描述


2. SkipList的效率分析与保证

2.1 插入随机数的确定

这个大胆的决定看上去有一点随意,这样的设计真的能抱证搜索的时候的效率吗?

skiplist插入一个节点的时候随机出一个层数,但是这里的随机也不是存粹的随机,这里规定:

  1. 跳表存在一个最大层数maxlevel的限制
  2. 会设置一个 增加一层的概率p

此时计算随机层数的伪代码为:
在这里插入图片描述
在我们熟知的redis中,其内存管理使用了跳表,其参数取指是:

  • p = 1/4
  • maxlevel = 32

这样的设计其实很好理解:产生越高的节点层数,概率是越低的:

  • 节点层数至少为1。而大于1的节点层数,满足几何分布
  • 节点层数恰好等于1的概率为1-p。
  • 节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)。
  • 节点层数大于等于3的概率为p^2,而节点层数恰好等于3的概率为 p*p *(1-p)。
  • 节点层数大于等于4的概率为p^3,而节点层数恰好等于4的概率为ppp*(1-p)。

因此,一个节点的平均层数(也即包含的平均指针数目),计算如下:
在这里插入图片描述
也就是说,当p=1/2,每个节点的平均指针数为2

2.2 时间复杂度的计算

跳表的时间复杂度可以保证为O(logN),但是由于其复杂性,推导过程比较麻烦,有兴趣的同学,可以自行了解:
铁蕾大佬的博客


3. SkipList的实现

skiplist的实现在leetcode正好有相应习题,同学们可以尝试去AC一下:1206. 实现跳表

这里给出我的设计思路与方案。
在这里插入图片描述

3.1 节点的设计

之前我们说将节点增高,并不需要将节点复制一份,这样会造成值的冗余,实际上只需要设置多个指针,每个指针处于不同的层即可,即_nextV数组,每个指针的索引即为层数level.

根据上图,最右边是头结点,不存储数据,所以我们在初始化头结点的时候,设置_val=-1,同时将高度设置为1,即初始只有一层。

struct SkipListNode
{
    int _val;
    vector<SkipListNode*>_nextV;

    SkipListNode(int val,int level)
        :_val(val)
        ,_nextV(level,nullptr)//将_nextV数组初始化为长度level的数组,即初始level层
    {}
};

SkipList初始化代码:

SkipList(){
   //头结点,初始层数为1
   _head = new SkipListNode(-1,1);
   srand(time(0));
}

3.2 功能的实现

在这里插入图片描述

节点查找

查找操作比较简单,每次查找一定会查找到最低一层,每次要么向右走,要么向下走。

对于给定的目标值,当目标值比当前节点的下一个节点的值更大,则向右走;如果下一个节点为空(即当前节点是尾结点),或者目标节点的值比下一个节点的值更小,则向下走。

eg: 查找19

  1. 我们从head的最高层即第四层开始搜索,发现下一个节点值为6,19 > 6,则向右走
  2. 发现下一个节点是空的,向下走,到达节点6的第三层
  3. 发现节点6第三层的下个节点为25,19<25,向下走,到达节点6的第二层
  4. 发现节点6的第二层的下个节点为9,19>9,向右走,到达节点9
  5. 发现节点9的第二层的下个节点为17,19>17,向右走,到达节点17
  6. 发现节点17的第二层的下个节点为25,19<25,向下走,到达节点17第一层
  7. 发现节点17第一层的下个节点为19,找到了。

如果对应的节点没有被走到,level最终会降至-1层。

       
        bool search(int target){
            Node* cur = _head;
            int level = _head->_nextV.size()-1;
            while(level >= 0)
            {
                if(cur->_nextV[level] && cur->_nextV[level]->_val < target){ //向右走
                    cur = cur->_nextV[level];
                }else if(!cur->_nextV[level] || cur->_nextV[level]->_val > target){
                    level--;
                }else{
                    return true;
                }
            }
            return false;
        }

层数随机

层数随机根据之前的原理,写出来即可,这里提供两种写法,第二种是基于C++11的库函数实现的:

		int RandomLevel()
        {
            size_t level = 1;
            while(rand()<RAND_MAX*_p && level <_maxLevel)
            {
                ++level;
            }
            return level;
        }

        int RandomLevel2()
        {
            static default_random_engine generator(chrono::system_clock::now().time_since_epoch().count());
            static uniform_real_distribution<double> distribution(0.0,1.0);

            size_t level = 1;
            while(distribution(generator)<=_p && level<_maxLevel)
            {
                ++level;
            }
            return level;
        }

添加节点

插入节点的思想在于找到待插入位置的前驱节点,这样就能够插入新节点了,和单链表是一致的。

下面代码中的prevV数组存储了待添加节点每一层的前驱节点是谁,这样每层的链接关系都可以顺利更新。

		vector<Node*> FindPrevNode(int num)
        {
            Node*cur = _head;
            int level = _head->_nextV.size()-1;

            vector<Node*>prevV(level+1,_head);//默认前驱节点
            
            while(level >= 0) {
                if(cur->_nextV[level] && cur->_nextV[level]->_val < num){ //向右走
                    cur = cur->_nextV[level];
                }else if(!cur->_nextV[level] || cur->_nextV[level]->_val >= num){//向下走
                    prevV[level] = cur;//更新前驱
                    level--;
                }else{
                }
            }

            return prevV;
        }

        void add(int num){
            auto prevV = FindPrevNode(num);

            int n = RandomLevel();
            Node*newnode = new Node(num,n);

            //如果当前n大于最大高度,则更新最大高度
            if(n>_head->_nextV.size()){
                _head->_nextV.resize(n,nullptr);
                prevV.resize(n,_head);
            }

            for(size_t i=0;i<n;i++)
            {
                newnode->_nextV[i] = prevV[i]->_nextV[i];
                prevV[i]->_nextV[i] = newnode;
            }
        }

删除节点

与add操作基本一致,不做赘述:

		bool erase(int num){
            auto prevV = FindPrevNode(num);
            /*
            * 第一层的下一个不是val,val不在表中
            */
            if(prevV[0]->_nextV[0]==nullptr ||prevV[0]->_nextV[0]->_val!=num)
            {
                return false;
            }else{
                Node* del = prevV[0]->_nextV[0];
                for(size_t i = 0;i<del->_nextV.size();i++)
                {
                    prevV[i]->_nextV[i] = del->_nextV[i];
                }
                delete del;

                //若最高层无节点,则降低头结点层数
                int i =_head->_nextV.size()-1;
                while(i>=0){
                    if(_head->_nextV[i]==nullptr)
                        --i;
                    else 
                        break;
                }
                _head->_nextV.resize(i+1);

                return true;
            }
        }

4. SkipList与平衡树和哈希表的比较

ps:对于平衡树和哈希表不熟悉的同学可以翻我之前的博客来学习()

skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差
不多。
skiplist的优势是:

  • skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂。
  • skiplist的额外空间消耗更低。平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗, skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包
    含的平均指针数目为1.33;

skiplist相比哈希表而言,就没有那么大的优势了。

相比而言:

  • 哈希表平均时间复杂度是O(1),比skiplist快。
  • 哈希表空间消耗略多一点。

skiplist优势如下:

  • 遍历数据有序
  • skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。
  • 哈希表扩容有性能损
    耗。
  • 哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。
  • 20
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Ornamrr

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

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

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

打赏作者

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

抵扣说明:

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

余额充值