【数据结构】跳表

跳表


一、基本概念

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。

我们可以想象一下我们之前学的很清晰的链表,它就是一个链式的结构,我们假如说是想要找到某一个值的话我们的算法复杂度为O(N),是不是太慢了,所以我们接下来的跳表就很好地实现优化算法复杂度的做法。

假如我们每相邻两个节点增加一个指针,让指针指向下下个节点:

这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。

我们经过上面的操作以后,发现了我们可以跳过一些结点,那么就很简单了,我们的算法复杂度整整下降了一半,跟二分查找有一定的类似之度。

利用同样的方式,我们可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表:

实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。 简单来讲就是多增加一个结点,其层数的分配必定打乱,不可能是二分查找的效率,百分百会导致时间复杂度下降。

所以,有一个很酷很厉害的方法,是William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》这位大佬想到的,我们用一个随机的层数不行吗?这样不就能够完美解决了这个问题吗?每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数,我们根本不用严格保持1:2的关系了,就不用调整那么多,只需要插入随机结点层数就行,而且这个随机结点层数还跟概率有关,我们先来计算一下每个节点所包含的平均指针数目(概率期望)。节点包含的指针数目,相当于这个算法在空间上的额外开销(overhead),可以用来度量空间复杂度:

节点层数至少为1。而大于1的节点层数,满足一个概率分布。

节点层数恰好等于1的概率为1-p。

节点层数大于等于2的概率为p,而节点层数恰好等于2的概率为p(1-p)。

节点层数大于等于3的概率为p2,而节点层数恰好等于3的概率为p2(1-p)。

节点层数大于等于4的概率为p3,而节点层数恰好等于4的概率为p3(1-p)。

......

因此,一个节点的平均层数(也即包含的平均指针数目):

当p=1/2时,每个节点所包含的平均指针数目为2;

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

插入删除过程:

二、Leetcode练习题

1、题目描述

leetcode链接

2、结点构造

首先需要一个_nextv的指针数组(vector<Node*> _nextv)用来存放指向的下一排节点。再需要一个val用来存当前节点的值!

cpp 复制代码
struct NumListNode
{
    int _val; // 值
    std::vector<NumListNode*> _nextv; // 存的是节点的指针
    NumListNode(int val, int level)
        : _val(val)
        , _nextv(level, nullptr)
    {}
};
class Skiplist
{
    typedef NumListNode Node;
public:
    Skiplist()
    {
        srand(time(NULL));
        // 头结点,层数是1
        _head = new NumListNode(-1, 1);
    }
private:
    Node* _head; // 头节点
    size_t _maxLevel = 32; // 最大层数
    double _p = 0.25; // 概率
};

3、search函数

这个函数当然是很简单的,我们根据上面的图例来进行分析能够看到,我们只需要跟当前节点的层数所指向的下一个结点的值进行比较,如果我目标值小,那么我们就继续往下面层数去看,直到层数找完为-1退出返回false,而如果我们的目标值大,我们就直接从当前层跳到下一个结点的当前层继续比较!

函数代码:

cpp 复制代码
    bool search(int target)
    {
        Node* cur = _head;
        int level = cur->_nextv.size() - 1; // 节点指针个数-1
        // 当target比下一个结点值要大,往右走
        // 下一个结点是空/尾,此时target比下一个结点值要小,往下走
        while (level >= 0)
        {
            if (cur->_nextv[level] && target > cur->_nextv[level]->_val)
            {
                // 往右走
                cur = cur->_nextv[level];
            }
            else if (cur->_nextv[level] == nullptr || target < cur->_nextv[level]->_val)
            {
                --level;
            }
            else
            {
                return true;
            }
        }
        return false;
    }

4、add函数

cpp 复制代码
    void add(int num)
    {
        Node* cur = _head;
        int level = cur->_nextv.size() - 1; // 节点指针个数-1
        std::vector<Node*> prevv(level + 1 , _head);

        // 当target比下一个结点值要大,往右走
        // 下一个结点是空/尾,此时target比下一个结点值要小,往下走
        while (level >= 0)
        {
            if (cur->_nextv[level] && num > cur->_nextv[level]->_val)
            {
                // 往右走
                cur = cur->_nextv[level];
            }
            else if (cur->_nextv[level] == nullptr || num <= cur->_nextv[level]->_val)
            {
                // 更新prevv链接的前一个
                prevv[level] = cur;
                // 往下走
                --level;
            }
        }
        // 随机的层数
        int n = RadomLevel();
        Node* newnode = new Node(num, n);
        // 如果n大于层数的话,就升高一下_head头结点的层数
        if (n > _head->_nextv.size())
        {
            _head->_nextv.resize(n);
            prevv.resize(n, _head);
        }
        // 前后进行链接
        for (size_t i = 0; i < n; i++)
        {
            newnode->_nextv[i] = prevv[i]->_nextv[i];
            prevv[i]->_nextv[i] = newnode;
        }
        return;
    }

5、RadomLevel随机层数函数

执行插入操作时计算随机数的过程,是一个很关键的过程,它对skiplist的统计特性有着很重要的影响。这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:
首先,每个节点肯定都有第1层指针(每个节点都在第1层链表里)。
如果一个节点有第i层(i>=1)指针(即节点已经在第1层到第i层链表中),那么它有第(i+1)层指针的概率为p。
节点最大的层数不允许超过一个最大值,记为MaxLevel。

randomLevel()的伪码中包含两个参数,一个是p,一个是MaxLevel。在Redis的skiplist实现中,这两个参数的取值为:
p = 1/4
MaxLevel = 32

cpp 复制代码
	// 二选一
    int RadomLevel()
    {
        size_t level = 1;
        while (rand() < RAND_MAX * _p && level < _maxLevel)
        {
            ++level;
        }
        return level;
    }

    int RadomLevel()
    {
        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;
    }

第二种方法是C++11库中给我们的样例,我们用下面代码来进行演示一下:

6、erase函数

cpp 复制代码
    std::vector<Node*> FindNode(int num)
    {
        Node* cur = _head;
        int level = cur->_nextv.size() - 1; // 节点指针个数-1
        std::vector<Node*> prevv(level + 1, _head);

        // 当target比下一个结点值要大,往右走
        // 下一个结点是空/尾,此时target比下一个结点值要小,往下走
        while (level >= 0)
        {
            if (cur->_nextv[level] && num > cur->_nextv[level]->_val)
            {
                // 往右走
                cur = cur->_nextv[level];
            }
            else if (cur->_nextv[level] == nullptr || num <= cur->_nextv[level]->_val)
            {
                // 更新prevv链接的前一个
                prevv[level] = cur;
                // 往下走
                --level;
            }
        }
        return prevv;
    }
    bool erase(int num)
    {
        std::vector<Node*> prevv = FindNode(num);
        // 第一层下一个不是val,val不在表中
        if (prevv[0]->_nextv[0] == nullptr || prevv[0]->_nextv[0]->_val != num)
        {
            return false;
        }
        else
        {
            Node* del = prevv[0]->_nextv[0];
            // del节点每一层的前后指针链接起来
            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;
        }
    }

先找到这个del删除的结点,然后从它前面prevv所存的前面节点的指向指向del当前指向的后面的结点,然后再删除这个del结点。

7、打印函数及展示成果

cpp 复制代码
    void Print()
    {
        Node* cur = _head;
        while (cur)
        {
            printf("%2d\n", cur->_val);
            // 打印每个每个cur节点
            for (auto e : cur->_nextv)
            {
                printf("%2s", "↓");
            }
            printf("\n");

            cur = cur->_nextv[0];
        }
    }
int main()
{
    Skiplist sl;
    int a[] = { 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6 };
    // int a[] = { 5, 2, 3, 8, 9, 6 };
    for (auto e : a)
    {
        sl.add(e);
    }
    sl.Print();
    printf("--------------------------------------\n");
    sl.erase(5);
    sl.Print();
    return 0;
}

由于是随机的,所以我们就来了两种情况,同时我直接倒着放了。

三、skiplist跟平衡搜索树和哈希表的对比

  1. skiplist相比平衡搜索树(AVL树和红黑树)对比,都可以做到遍历数据有序,时间复杂度也差不多。skiplist的优势是:a、skiplist实现简单,容易控制。平衡树增删查改遍历都更复杂。 b、skiplist的额外空间消耗更低。平衡树节点存储每个值有三叉链,平衡因子/颜色等消耗。skiplist中p=1/2时,每个节点所包含的平均指针数目为2;skiplist中p=1/4时,每个节点所包含的平均指针数目为1.33。
  2. skiplist相比哈希表而言,就没有那么大的优势了。相比而言a、哈希表平均时间复杂度是O(1),比skiplist快。b、哈希表空间消耗略多一点。skiplist优势如下:a、遍历数据有序 b、skiplist空间消耗略小一点,哈希表存在链接指针和表空间消耗。c、哈希表扩容有性能损耗。d、哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。
相关推荐
呆萌很3 分钟前
C++ 集合 list 使用
c++
诚丞成1 小时前
计算世界之安生:C++继承的文水和智慧(上)
开发语言·c++
1nullptr2 小时前
三次翻转实现数组元素的旋转
数据结构
TT哇2 小时前
【数据结构练习题】链表与LinkedList
java·数据结构·链表
东风吹柳2 小时前
观察者模式(sigslot in C++)
c++·观察者模式·信号槽·sigslot
A懿轩A2 小时前
C/C++ 数据结构与算法【栈和队列】 栈+队列详细解析【日常学习,考研必备】带图+详细代码
c语言·数据结构·c++·学习·考研·算法·栈和队列
大胆飞猪3 小时前
C++9--前置++和后置++重载,const,日期类的实现(对前几篇知识点的应用)
c++
1 9 J3 小时前
数据结构 C/C++(实验五:图)
c语言·数据结构·c++·学习·算法
夕泠爱吃糖3 小时前
C++中如何实现序列化和反序列化?
服务器·数据库·c++
长潇若雪3 小时前
《类和对象:基础原理全解析(上篇)》
开发语言·c++·经验分享·类和对象