• 数据结构——跳表


    简单介绍跳表

      跳表(Skip List)是一种可以进行对数级别查找的数据结构,它通过在数据中构建多级索引来提高查询效率。跳表是一种基于链表的随机化数据结构,其本质是由多个链表组成,每个链表中的元素都是原始链表中的元素。

      我们知道链表的查找时间复杂度是O(N),如果这个链表数据是有序的,还是O(N),我们如何利用有序这一点,来进行优化呢?接下来就是我们的主角跳表登场。

      跳表在实际应用中有许多用途,例如作为Redis等数据库的有序数据结构实现,以及作为平衡树等数据结构的替代方案。与其他数据结构相比,跳表具有实现简单、空间复杂度低、查询效率高等优点。

      skiplist是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A
    Probabilistic Alternative to Balanced Trees》。
    William Pugh开始的优化思路:
    1. 假如我们每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点,如下图b所
    示。这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。由
    于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概
    只有原来的一半。
    2. 以此类推,我们可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一
    个指针,从而产生第三层链表。如下图c,这样搜索效率就进一步提高了。
    3. skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方
    式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似
    二分查找,使得查找的时间复杂度可以降低到O(log n)。但是这个结构在插入删除数据的时
    候有很大的问题,插入或者删除一个节点之后,就会打乱上下相邻两层链表上节点个数严格
    的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也
    包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。
    理想状态的跳表大概长这样:
    但是一旦我们进行了插入和删除操作,就会很难维护这个2:1的关系。因此
    4. skiplist的设计为了避免这种问题,做了一个大胆的处理,不再严格要求对应比例关系,而是
    插入一个节点的时候随机出一个层数。这样每次插入和删除都不需要考虑其他节点的层数,
    这样就好处理多了。细节过程入下图:

     

    注意重点,在这种处理下,我们插入的结点的层数是随机的!如此一来,插入删除操作就简化了很多。

    skiplist的效率 

      首先要分析,这个随机层数是怎么来的。一般跳表会设置一个最大的层数限制maxLevel。其次会设计一个概率p。这个p就是指 最开始从第一层开始,每多一层的概率为p。

      最大层数限制很好理解,这个p就是每次插入的时候,由它来决定这个结点有多少层。每多一层其实就是多一个指针。

    在Redis中,这两个参数的取值为

    1. p = 1/4
    2. maxLevel = 32

     

    再简单分析这个p就是:

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

    平均计算如下:

     

     skiplist的简单实现

      其实skiplist只要理解了思想,实现起来还是比较简单了,我们可以参考力扣上的一道题

    1206. 设计跳表 - 力扣(LeetCode) 

     代码:

    1. #pragma once
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. using namespace std;
    8. struct SkiplistNode
    9. {
    10. int _val;
    11. vector _nextV; // 层数也就是指针,用数组存起来
    12. SkiplistNode(int val, int level)
    13. :_val(val)
    14. , _nextV(level, nullptr)
    15. {}
    16. };
    17. class Skiplist
    18. {
    19. typedef SkiplistNode Node;
    20. public:
    21. Skiplist()
    22. {
    23. srand(time(0));
    24. // 头结点的层数设置为1
    25. _head = new SkiplistNode(-1, 1);
    26. }
    27. bool search(int target)
    28. {
    29. Node* cur = _head;
    30. int level = _head->_nextV.size() - 1;
    31. while (level >= 0)
    32. {
    33. // 目标值比下一个结点的值要大的话,就往右走
    34. // 如果下一个结点是空(尾),或者目标值比下一个结点要小,就向下走
    35. if (cur->_nextV[level] && cur->_nextV[level]->_val < target)
    36. {
    37. // 往右走
    38. cur = cur->_nextV[level];
    39. }
    40. else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val > target)
    41. {
    42. // 往下走
    43. --level;
    44. }
    45. else
    46. {
    47. // 找到了
    48. return true;
    49. }
    50. }
    51. return false;
    52. }
    53. vector FindPrevNode(int num)
    54. {
    55. Node* cur = _head;
    56. int level = _head->_nextV.size() - 1;
    57. // 记录 被改动的位置的每一层的前一个结点指针
    58. vector prevV(level + 1, _head);
    59. while (level >= 0)
    60. {
    61. // 同理 比下一个结点大就往右走
    62. // 下一个结点是空,或者目标值比下一个结点要小,就往下走
    63. if (cur->_nextV[level] && cur->_nextV[level]->_val < num)
    64. {
    65. cur = cur->_nextV[level]; // 向右走
    66. }
    67. else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val >= num)
    68. {
    69. // 注意 这里num等于也是要进来的
    70. // 更新level层的前一个
    71. prevV[level] = cur;
    72. --level; // 向下走
    73. }
    74. }
    75. return prevV;
    76. }
    77. void add(int num)
    78. {
    79. vector prevV = FindPrevNode(num);
    80. int n = RandomLevel();
    81. Node* newnode = new Node(num, n);
    82. // 注意:如果n超过了当前的最大层,那么就要相应的提高_head的层数
    83. if (n > _head->_nextV.size())
    84. {
    85. _head->_nextV.resize(n, nullptr);
    86. prevV.resize(n, _head);
    87. }
    88. // 链接前后结点
    89. for (size_t i = 0; i < n; ++i)
    90. {
    91. // 注意顺序,先设置好目标结点的指针指向
    92. newnode->_nextV[i] = prevV[i]->_nextV[i];
    93. prevV[i]->_nextV[i] = newnode;
    94. }
    95. }
    96. bool erase(int num)
    97. {
    98. vector prevV = FindPrevNode(num);
    99. // 如果第一层的下一个不是val,则val不在表中
    100. if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
    101. {
    102. return false;
    103. }
    104. else
    105. {
    106. Node* del = prevV[0]->_nextV[0];
    107. // 先将del结点的前后结点链接起来
    108. for (size_t i = 0; i < del->_nextV.size(); ++i)
    109. {
    110. prevV[i]->_nextV[i] = del->_nextV[i];
    111. }
    112. delete del;
    113. // 如果删除的这个结点改变了跳表结点的当前最高层数,
    114. // 那么应该将头结点的层数降到第二高的层数
    115. int i = _head->_nextV.size() - 1;
    116. while (i >= 0)
    117. {
    118. if (_head->_nextV[i] == nullptr)
    119. --i;
    120. else
    121. break;
    122. }
    123. _head->_nextV.resize(i + 1);
    124. return true;
    125. }
    126. return false;
    127. }
    128. int RandomLevel()
    129. {
    130. size_t level = 1;
    131. // rand() 的取值范围在 [0,RAND_MAX] 之间
    132. // 这里就转换成了 如果区间在 [0,_p]就加一层
    133. while (rand() <= RAND_MAX * _p && level < _maxLevel)
    134. {
    135. ++level;
    136. }
    137. return level;
    138. }
    139. private:
    140. Node* _head;
    141. size_t _maxLevel = 32;
    142. double _p = 0.25;
    143. };
    144. void Test()
    145. {
    146. Skiplist sl;
    147. //int a[] = { 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6, 5, 2, 3, 8, 9, 6 };
    148. int a[] = { 1, 2, 3, 4 };
    149. for (auto e : a)
    150. {
    151. sl.add(e);
    152. }
    153. /*int x;
    154. cin >> x;
    155. sl.erase(x);*/
    156. sl.erase(1);
    157. //cout << sl.erase(0) << " " << sl.erase(1) << endl;
    158. }

      跳表稍复杂一点的地方就是插入和删除了。因为我们还要需要找到目标结点的每一层前一个结点,将它们放入数组中,然后才能处理好目标结点的前后结点之间的关系。

      另外就是它的查找逻辑,设cur来遍历结点,那么cur的移动就有两种情况,一种是目标值大于cur下一个结点的值的话,cur就向右走;另一种就是小于,那么cur就向下走(--level)。再然后就是等于了。

    跳表跟平衡搜索树及哈希表的对比

    跟平衡搜索树 

      二者都可以做到遍历数据有序,并且时间复杂度都差不多。

      跳表跟平衡搜索树(AVL树和RB树)的优势就是:

    1. 跳表实现简单,且容易控制。不像AVL树和RB树非常复杂,跳表这里我们删除操作都很容易就实现了。

    2.跳表的空间消耗相对较低,不像平衡搜索树,不仅每个结点都有三叉链(指针),而且还要存平衡因子或者颜色。当跳表中的p = 1/2时,每个结点所包含的指针个数为2;p = 1/4时,每个结点所包含的指针个数为1.33。

    因此,跟平衡搜索树比起来,还有是有优势的。

    跟哈希表

    跳表跟哈希表比起来,各有优缺点。

    跳表的优点:

    1.空间消耗还是略低哈希表。哈希表存在链表指针和表空间的消耗。

    2.跳表遍历数据能有序。

    3.哈希表扩容时有性能损耗。跳表就没有。

    4.在极端场景下,哈希表哈希冲突高,效率下降的厉害,还需要红黑树来接力,增加了算法复杂度。

    哈希表的优点:

    时间复杂度是O(1),比跳表要快。

    所以这样看来,跳表跟哈希表比起来,有些还是有优势的,但是没有跟平衡搜索树比起来那么大。

  • 相关阅读:
    mcu日志输出的一种方法
    C# 面向对象
    [常用工具] Python视频解码库DeFFcode使用指北
    【Mysql】mysql学习之旅05-多表操作
    EfficientNet笔记
    【Unity-Cinemachine相机】虚拟相机(Virtual Camera)的本质与基本属性
    linux设置ip地址与换源
    IDEA常用快捷键汇总
    小目标检测的注意特征金字塔网络
    洛谷 P1978 集合
  • 原文地址:https://blog.csdn.net/m0_74099572/article/details/136404339