当前位置: 首页 > article >正文

[高阶数据结构七]跳表的深度剖析

1.前言

跳表是一种查找结构,它有着与红黑树、AVL树和哈希表相同的作用,那么已经学习了红黑树和哈希表这种效率高的数据结构,那么为什么还需要学习跳表呢?--请听我娓娓道来。

本章重点:

本章着重讲解跳表的概念,跳表的实现原理,跳表的模拟实现,以及有了红黑树和哈希表之后,为什么还要学习跳表这种数据结构。

2.跳表的概念

跳表是在有序链表的基础上发展而来的。且看下面发展历程。

有序链表的查找和搜索的效率都是O(N)的。

但是0(N)明显时间复杂度较慢,于是乎,就有大佬相出了,那么能不能相邻的节点上升一层,增加一个指针,让指针指向下下个节点?这样当我们查找时,原来需要比较全部的节点,现在由于上升了一层,那么就变成了一半了。

如下图:

依次重复上述步骤,再上升

发现只有两个节点了,那么就不需要上升了。

skiplist 正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方
式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常 类似
二分查找 ,使得查找的时间复杂度就降低到了O (LogN)。
但是这样也会有一个问题, 那么就是在插入和删除时,就会有很大的问题了,由于在升层的时候是严格的按照每相邻两个节点上升一层的,那么上一层的节点的个数和下一层节点的个数比例严格的就是2:1,那么当你插入和删除某一个节点时,也需要改变其他节点的层数,这样时间效率又退化成了O(N),这样是让人无法接受的。
于是这个时候又有大佬相出了一个好的方法: 不再严格要求对应比例关系,而是 插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数, 这样就好处理多了。

 设计一个东西,最终都是要考虑它的时间复杂度的,那么按照上述的设计思路,他的时间复杂度如何呢?他的效率如何呢?

SKipList效率分析

上面我们说到, skiplist 插入一个节点时随机出一个层数,听起来怎么这么随意,如何保证搜索时
的效率呢?
这里首先要细节分析的是这个随机层数是怎么来的。一般跳表会设计一个最大层数 maxLevel 的限
制,其次会设置一个多增加一层的概率 p 。那么计算这个随机层数的伪代码如下图:

其中p代表效率,maxlevel代表的是层数

根据前面的随机层函数,我们其实可以很清晰的观察出,产生越高的节点层数,概率越低。

具体分析过程如下:

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

因此通过综合分析,跳表的平均时间复杂度是0(LogN)的。这个过程比较复杂,需要有一定的数学功底,有兴趣想知道怎么来的老铁,可以阅读以下文章:

ftp://ftp.cs.umd.edu/pub/skipLists/skiplists.pdf

3.跳表的模拟实现

通过了解了跳表的原理,那么就能够很清楚的知道跳表是如何来的了。

那么对于跳表中的层数应该如何把控呢?---用一个vector,只不过我们平常用的vector是水平来看的,现在把vector竖过来看,发现那就是层数了,每一层里面存放的是指向下一个位置的节点。

基本框架:

struct SkiplistNode
{
    int _val;
    vector<SkiplistNode*> _nextV;
    SkiplistNode(int val, int level)
        :_val(val)
        , _nextV(level, nullptr)
    {}
};
class Skiplist {
    typedef SkiplistNode Node;
public:
    Skiplist() {
        //最开始第一个数插入的时候先给1层
        srand((unsigned int)time(0));//生成随机数的种子
        _head = new Node(-1, 1);
    }

private:
    Node* _head;//头结点
    size_t _maxlevel = 32;//最大层数
    double _p = 0.25;//增加一层必须满足的概率
};

这里把最大层数和概率都设置了,如果后续想改成别的,直接修改即可。

跳表需要实现的函数--查找,删除,增加。

搜索函数,即查找:

bool search(int target) {
        Node* cur = _head;
        int level =(int) cur->_nextV.size() - 1;
        while (level >= 0)
        {
            if (cur->_nextV[level] && cur->_nextV[level]->_val < target)
                //向右走
                cur = cur->_nextV[level];
            else if (cur->_nextV[level] == nullptr ||
                cur->_nextV[level]->_val > target)
                //向下走
                level--;
            else return true;//找到了
        }
        return false;//没有这个值
    }

搜索的规则,_val比它(目标值)小,向右走;_val比它(目标值)大,向下走。这里一定要用的是cur->next来进行比较,否则的话,一旦走到了空又要重新走,这就严重影响效率 了。

增加函数:

vector<SkiplistNode*> FindPrev(int num)
    {
        //当cur->nextv[level]->val>num时,
        //那么此时prev[level]=cur->next[level]
        Node* cur = _head;
        int level = (int)cur->_nextV.size() - 1;
        //找到的前节点最大的层数就是level+1,前一个位置的指针放入prevV
        vector<Node*> prevV(level + 1, nullptr);
        while (level >= 0)
        {
            if (cur->_nextV[level] && cur->_nextV[level]->_val < num)
                //向右走
                cur = cur->_nextV[level];
            else if (cur->_nextV[level] == nullptr ||
                cur->_nextV[level]->_val >= num)
                //向下走
            {
                prevV[level] = cur;//记录该层的前一个节点
                level--;
            }
        }
        return prevV;
    }
    void add(int num) {
        //在插入之前应该把这个位置的每前一层的节点都记录一下,方便后续连接
        vector<Node*> prevV = FindPrev(num);
        int level = RandLevel();
        Node* newnode = new Node(num, level);

        //如果插入的值是大于头结点的层数,那么头结点扩容到对应的层数
        if (level > _head->_nextV.size())
        {
            _head->_nextV.resize(level, nullptr);

            //由于之前prevV对应的是没变化之前的头结点的层数,所以这里
            //也需要改变一下
            prevV.resize(level, _head);
        }

        //开始每一层的连接
        for (int i = 0; i < level; i++)
        {
            newnode->_nextV[i] = prevV[i]->_nextV[i];
            prevV[i]->_nextV[i] = newnode;
        }
    }

解释:在插入之前,一定要找到插入这个值所在位置的所有前一层的节点,后续才方便插入。

删除函数:

 bool erase(int num) {
        //在删除之前也需要找到删除位置的值的每一层的前一个结点
        vector<Node*> prevV = FindPrev(num);
        //有可能num不在表中,那么就删除失败
        if (prevV[0]->_nextV[0] == nullptr ||
            prevV[0]->_nextV[0]->_val != num)
        {
            return false;
        }
        //否则就进行删除
        else
        {
            Node* del = prevV[0]->_nextV[0];
            for (int i = 0; i < del->_nextV.size(); i++)
            {
                prevV[i]->_nextV[i] = del->_nextV[i];
            }
            delete del;
            //如果删掉了最高层的话,那么头结点的层数也要下降
            int level = (int)_head->_nextV.size() - 1;
            while (level >= 0)
            {
                if (_head->_nextV[level] == nullptr)
                    level--;
                else break;
            }
            _head->_nextV.resize(level + 1);
            return true;
        }
    }

删除之前也同理,需要找到要删除节点的所有的前一层的节点值是谁,然后把删除值的前一个节点和后一个节点进行链接上即可。

完整代码如下:

SkipList/SkipList · 青酒余成/初识数据结构 - 码云 - 开源中国 (gitee.com)

4.跳表与红黑树、哈希表的对比

首先与红黑树进行对比:

从时间效率上来看:两种都是差不多的。

从空间效率上来看:红黑树要维护三叉链和节点的颜色,相比与跳表有点浪费空间;

从实现角度上来看:跳表的增删查改代码明显比红黑树的增删查改代码要简单的多。

其次与哈希表进行对比:

严格意义上来说,跳表在哈希表面前就有点cuo了。

但是跳表还是有一定的优势的:1.跳表是有序的;2.跳表的空间消耗低,哈希表要存放指针和表空间;3.哈希表在极端情况下,性能会严重退化,可能需要把红黑树挂接到表空间里面。

5.总结

跳表就讲解到这里啦,后续有疑问的小伙伴欢迎后台TT我。


http://www.kler.cn/a/427565.html

相关文章:

  • C# 设计模式--建造者模式 (Builder Pattern)
  • 深度解析 Ansible:核心组件、配置、Playbook 全流程与 YAML 奥秘(上)
  • [C++]构造函数和析构函数
  • 如何“安装Android SDK“?
  • 华为问界M9 [电气架构] 信息梳理
  • 专题02-7-5 打印菱形图案
  • Brocade 7840 Extension 交换机
  • 一文了解MySQL写缓冲Change Buffer(定义 作用 执行过程 触发时机 业务场景)
  • Elasticsearch scroll 之滚动查询
  • Spring Cloud Alibaba 之 “Sentinel”
  • 21. C++STL 7(8000字详解list及其迭代器的模拟实现)
  • python实现AES加解密功能
  • Reactive-Resume - AI 驱动的简历匹配分析工具
  • 药剂学试卷
  • LeetCode279. 完全平方数(2024冬季每日一题 27)
  • 【Rive】混合动画
  • STM32进阶 定时器4 高级定时器 + 高级定时器实验输出有限个周期的PWM波
  • 01、SpirngMVC快速入门
  • 四十一:掩码及其所针对的代理污染攻击
  • JWT 在 SaaS 系统中的作用与分布式 SaaS 系统设计的最佳实践