skiplist(高阶数据结构)

目录

一、概念

二、实现

三、对比


一、概念

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

skiplist本质上是一种查找结构,用于解决算法中的查找问题,跟平衡搜索树和哈希表的价值是一样的,可以作为key或者key/value的查找模型。skiplist是一个list,是在有序链表的基础上发展起来的。若是一个有序的链表,查找数据的时间复杂度是

William Pugh开始的优化思路

  1. 假如每相邻两个结点升高一层,增加一个指针,让指针指向下下个结点,这样所有新增加的指针连成了一个新的链表,但包含的结点个数只有原来的一半。由于新增加的指针,不再需要与链表中每个结点逐个进行比较了,需要比较的结点数大概只有原来的一半

  2. 以此类推,可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表,这样搜索效率就进一步提高了

  3. skiplist正是受这种多层链表的启发而设计出来的。按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的结点个数的一半,这样查找过程就非常类似二分查找,使得查找的时间复杂度可以降低到。但是这个结构在插入删除数据的时候有很大的问题,插入或者删除一个结点后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。若要维持这种对应关系,就必须把新插入结点后面的所有结点(也包括新插入的结点)重新进行调整,这会让时间复杂度重新退化成

  1. skiplist的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是插入一个结点时随机出一个层数。这样每次插入和删除都不需要考虑其他结点的层数

skiplist的效率如何保证?

skiplist插入一个节点时随机出一个层数,听起来如此随意,如何保证搜索时的效率呢?

一般跳表会设计最大层数maxLevel的限制,其次会设置一个多增加一层的概率p,伪代码如下:

在Redis的skiplist实现中,这两个参数的取值为:maxLevel = 32p = 1/4

  • 结点层数至少为1。而大于1的结点层数,满足一个概率分布
  • 结点层数恰好等于1的概率为
  • 结点层数大于等于2的概率为 ,而节点层数恰好等于2的概率为
  • 结点层数大于等于3的概率为 ,而节点层数恰好等于3的概率为
  • 结点层数大于等于4的概率为 ,而节点层数恰好等于4的概率为

一个结点的平均层数(即包含的平均指针数目)

现在可以计算出:

  • 当p = 1/2时,每个结点所包含的平均指针数目为2
  • 当p = 1/4时,每个结点所包含的平均指针数目为1.33

跳表的平均时间复杂度为,推导过程较为复杂,需要一定数学功底,有兴趣可以参考以下大佬文章中的讲解

Redis内部数据结构详解(6)------skiplist - 铁蕾的个人博客 Redis内部数据结构详解(6)------skiplist - 铁蕾的个人博客 - 作者:张铁蕾http://zhangtielei.com/posts/blog-redis-skiplist.html

二、实现

https://leetcode.cn/problems/design-skiplist/description/

结点设计

cpp 复制代码
struct SkiplistNode
{
    SkiplistNode(int value, int level)
        :_value(value) ,_nextVector(level, nullptr) {}
    int _value;
    vector<SkiplistNode*> _nextVector;
};

Skiplist成员变量和构造函数

cpp 复制代码
class Skiplist
{
    typedef SkiplistNode Node;
public:
    Skiplist()
    {
        srand(time(0)); //设置随机数种子,后续随机生成结点层数时使用
        _head = new SkiplistNode(-1, 1);//头结点初始层数为1
    }

private:
    Node* _head;
    size_t maxLevel = 32; //最大层数限制
    double _p = 0.25; //多增加一层的概率
};

search函数

  • 记录当前所在结点以及所在结点的层数
  • 只有level >=0 时查找才有效,否则返回false
  • 若当前结点的值 > 目标值则向右走
  • 若当前结点的值 < 目标值 或者 同一层下一个结点为空(下标--) ,则向下走
cpp 复制代码
bool search(int target)
{
    Node* current = _head;
    int levelIndex = _head->_nextVector.size() - 1;
    while (levelIndex >= 0)
    {
        //目标值比下一个结点的值大
        if (current->_nextVector[levelIndex] != nullptr && current->_nextVector[levelIndex]->_value < target)
            current = current->_nextVector[levelIndex]; //向右走
        //下一个结点是空(尾)
        //目标值比下一个结点要小
        else if (current->_nextVector[levelIndex] == nullptr || current->_nextVector[levelIndex]->_value > target)
            --levelIndex; //向下走
        else
            return true; //找到了
    }
    return false;
}

FindPrevNode函数

设计该函数的目的:

  • add函数添加新结点要找到新结点每一层的前一个结点进行连接
  • erase函数删除结点要找到该结点每一层前一个结点 与 每一层的后一个结点进行连接
  • 使代码简洁,设计了FindPrevNode函数,实现代码的复用
cpp 复制代码
vector<Node*> FindPrevNode(int number)
{
    Node* current = _head;
    int levelIndex = _head->_nextVector.size() - 1;

    //待插入结点或待删除结点 的每一层的前一个结点的指针
    vector<Node*> prevVector(levelIndex + 1, _head);

    while (levelIndex >= 0)
    {
        if (current->_nextVector[levelIndex] != nullptr && current->_nextVector[levelIndex]->_value < number)
            current = current->_nextVector[levelIndex];
        else if (current->_nextVector[levelIndex] == nullptr || current->_nextVector[levelIndex]->_value >= number)
        {
            prevVector[levelIndex] = current;
            --levelIndex;
        }
    }
    return prevVector;
}

该函数基本与search函数相同,需要注意的是:当current->_nextVector[levelIndex]->_value >= number时,记录current结点。比search多一个等于,因为最低层的指针也需要修改链接

add函数

  • 获取要添加结点的前一个Node的集合
  • 随机获取层数,构建新结点并初始化
  • 若随机获取的层数超过当前最大的层数,那就升高一下_head的层数
  • 利用前一个Node集合 prevVector 和 当前结点的每一层建立连接关系
cpp 复制代码
void add(int number)
{
    //获取要添加数据的前一个Node的集合
    vector<Node*> prevVector = FindPrevNode(number);

    //随机获取层数,构建新结点并初始化
    int level = RandomLevel();
    Node* newNode = new Node(number, level);

    //若随机获取的层数超过当前最大的层数,那就升高一下_head的层数
    if (level > _head->_nextVector.size()) {
        _head->_nextVector.resize(level, nullptr);
        prevVector.resize(level, _head);
    }

    //链接前后结点
    for (int i = 0; i < level; ++i) {
        newNode->_nextVector[i] = prevVector[i]->_nextVector[i];
        prevVector[i]->_nextVector[i] = newNode;
    }
}

为什么不一开始就将_head头结点的层数设为最高呢?

一开始设为最高,后序查找有很多是无用的,所以不直接将_head设为最高,且利用一个变量记录最高层,当新插入数据的层数 > 最高层时才增加层数

erase函数

  • 获取要删除结点的前一个Node的集合prevVector
  • 若prevVector[0]->_nextVector[0] == nullptr || prevVector[0]->_nextVector[0]->_value != number 即未找到该数据,返回false
  • 否则记录要删除的Node ,去除前后连接关系,然后delete释放资源
  • 若删除的是最高层节点,重新调整头结点层数,下次查找时就不会从无用的最高层开始查找 (这个过程做不做都行,提升不太大)
cpp 复制代码
bool erase(int number)
{
    //获取要删除结点的前一个Node的集合
    vector<Node*> prevVector = FindPrevNode(number);

    if (prevVector[0]->_nextVector[0] == nullptr || prevVector[0]->_nextVector[0]->_value != number)
        return false;
    else
    {
        Node* deleteNode = prevVector[0]->_nextVector[0];
        // deleteNode结点每一层的前后指针链接起来
        for (int i = 0; i < deleteNode->_nextVector.size(); ++i)
            prevVector[i]->_nextVector[i] = deleteNode->_nextVector[i];
        delete deleteNode;

        //若删除的是最高层节点,重新调整头结点层数
        int headLevel = _head->_nextVector.size() - 1;
        while (headLevel >= 0)
        {
            if (_head->_nextVector[headLevel] == nullptr)
                --headLevel;
            else break;
        }
        _head->_nextVector.resize(headLevel + 1);
    }
    return true;
}

获取随机数

**方法一:**C语言

cpp 复制代码
int RandomLevel()
{
    size_t level = 1;
    // rand() ->[0, RAND_MAX]之间,将[0,RAND_MAX]看作为[0,1]
    while (rand() <= RAND_MAX * _p && level < _maxLevel)
        ++level;
    return level;
}

**方法二:**C++

std::uniform_real_distribution<double> distribution(0.0, 1.0) ,随机生成0.0 - 1.0的数,生成的数是均匀分布的

cpp 复制代码
int RandomLevelCPP()
{
    static std::default_random_engine generator(std::chrono::system_clock::now().time_since_epoch().count());
    static std::uniform_real_distribution<double> distribution(0.0, 1.0);

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

三、对比

skiplist与红黑树、AVL树对比

  • skiplist和平衡搜索树(AVL树和红黑树)都可以做到遍历数据有序,时间复杂度也差不多
  • 但skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂.
  • 并且skiplist的额外空间消耗更低。平衡树结点存储每个值有三叉链,平衡因子/颜色等消耗。skiplist中 p=1/2 时,每个结点所包含的平均指针数目为2;skiplist中 p=1/4 时,每个结点所包含的平均指针数目为1.33

skiplist与哈希表对比

skiplist与哈希表对比,就没有那么大的优势了。哈希表平均时间复杂度是 ,比skiplist快,但是哈希表空间消耗略多一点

  • 哈希表扩容有性能损耗
  • 哈希表在极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力
相关推荐
我们的五年5 分钟前
【Linux课程学习】:进程程序替换,execl,execv,execlp,execvp,execve,execle,execvpe函数
linux·c++·学习
kitesxian7 分钟前
Leetcode448. 找到所有数组中消失的数字(HOT100)+Leetcode139. 单词拆分(HOT100)
数据结构·算法·leetcode
做人不要太理性31 分钟前
【C++】深入哈希表核心:从改造到封装,解锁 unordered_set 与 unordered_map 的终极奥义!
c++·哈希算法·散列表·unordered_map·unordered_set
程序员-King.40 分钟前
2、桥接模式
c++·桥接模式
chnming198744 分钟前
STL关联式容器之map
开发语言·c++
程序伍六七1 小时前
day16
开发语言·c++
小陈phd1 小时前
Vscode LinuxC++环境配置
linux·c++·vscode
火山口车神丶1 小时前
某车企ASW面试笔试题
c++·matlab
薯条不要番茄酱2 小时前
数据结构-8.Java. 七大排序算法(中篇)
java·开发语言·数据结构·后端·算法·排序算法·intellij-idea
是阿建吖!2 小时前
【优选算法】二分查找
c++·算法