5.跳表(skiplist)
skiplist 本质上也是一种查找结构,用于解决算法中的查找问题,跟平衡搜索树和哈希表的价值是一样的,可以作为key 或者 key/value 的查找模型。skiplist ,顾名思义,首先它是一个 list 。实际上,它是在有序链表的基础上发展起来的。如果是一个有序的链表,查找数据的时间复杂度是O(N)。
设计思路:
William Pugh 开始的优化思路:1. 假如我们 每相邻两个节点升高一层,增加一个指针,让指针指向下下个节点 ,如下图 b 所示。这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了,需要比较的节点数大概只有原来的一半。2. 以此类推,我们可以在第二层新产生的链表上,继续为每相邻的两个节点升高一层,增加一个指针,从而产生第三层链表。如下图c ,这样搜索效率就进一步提高了。3. skiplist 正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常 类似二分查找 ,使得查找的时间复杂度可以降低到 O(log n) 。但是这个结构在插入删除数据的时候有 很大的问题 ,插入或者删除一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成 O(n) 。
为了解决插入时维持严格的对应关系问题:
解决方法:插入节点时随机给一个层数。
2.skiplist的效率如何保证?
一般跳表会设计一个最大层数 maxLevel 的限制,其次会设置一个多增加一层的概率p 。
其中要求random()产生的随机数等概率均匀分布。在 Redis 的 skiplist 实现中,这两个参数的取值为:p = 0.25,maxlevel = 32。跳表的平均时间复杂度为 O(logN)。(待拓展)
3.skiplist实现
1206. 设计跳表 - 力扣(LeetCode)
search设计:
具体实例:以查找20为例,从头结点出发,从最高层开始比较,6比20小,往6跳;6的最高层为空,往下走一层,25比20大,继续往下走一层;9比20小,往9跳;17比20小,往17跳;17最高层25比20大,往下走一层;19比20小,往19跳,19最高层21比20大,往下走一层,但是此时没有下一层了,找不到20。
思路:从头结点出发,从最高层开始比较,
1)如果 当前层为空 或 当前层下一个比查找的值大,往下走一层;
2)如果当前层下一个等于要查找的值,返回true;
3)如果当前层下一个小于要查找的值,往当前层的下一个跳。
直到往下走的过程中没有下一层了,那就找不到了,返回false。
代码实现如下:
bool search(int target) {Node* cur = _head;int level = _head->_nextv.size() - 1;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; }
add设计(允许键值冗余):
分析:类似查找逻辑,从头结点出发,每次从最高层开始,但不同的是如果遇到相等的值,也要往下走(因为要记录前一个),并记录前一个。
实现思路:定义一个prevV,存放对应层数前一个节点的指针,都初始化成头结点指针。
1.记录prevV
cur从头结点出发,level每次从最高层开始,
1)如果 当前层为空 或 当前层下一个大于等于查找的值,更新prevV对应的当前层节点指针为cur,level往下走;
2)如果当前层下一个小于要查找的值,往当前层的下一个跳。
直到往下走的过程中没有下一层了,那就结束了,prevV[0]第一层即为要插入节点的最接近一个。
2.创建新节点,链接新节点与prevV,可能修正头结点
1)如果新的节点层数超出头结点层数,头结点增加层数至与新节点一致,同时prevV也要扩至与新节点层数一致。
2)链接前后指针。(头结点若更新了,也会被修正)
由于删除也要复用记录prevV的逻辑,因此提取出来写个函数。
代码实现如下:
void add(int num) {vector<Node*> prevV = getPrevV(num);int n = getLevel();Node* newnode = new Node(num, n);//如果超出头节点层数,修正头节点和prevVif ((unsigned)n > _head->_nextv.size()){_head->_nextv.resize(n, nullptr);prevV.resize(n, _head);}//若头结点层数变了,也会更新头结点的指针for (size_t i = 0; i < (unsigned)n; ++i){newnode->_nextv[i] = prevV[i]->_nextv[i];prevV[i]->_nextv[i] = newnode;} }vector<Node*> getPrevV(int num) {Node* cur = _head;int level = _head->_nextv.size() - 1;vector<Node*> prevV(level + 1, _head);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[level] = cur;--level;}}return prevV; }
erase设计:
实现思路:定义一个prevV,存放对应层数前一个节点的指针,都初始化成头结点指针。
1.记录prevV
cur从头结点出发,level每次从最高层开始,
1)如果 当前层为空 或 当前层下一个大于等于查找的值,更新prevV对应的当前层节点指针为cur,level往下走;
2)如果当前层下一个小于要查找的值,往当前层的下一个跳。
直到往下走的过程中没有下一层了,那就结束了,prevV[0]第一层即为要插入节点的最接近一个。
2.删除节点,链接前后指针,若头结点层数降了,修正
拿到需要删除的节点的指针del,为prevV[0]的第一层的下一个。若 del为空 或 del->val不等于要删除的值,返回false。
1)链接前后指针。
2)从头结点的最高层开始,如果当前层的下一个为空,--level;反之终止。
直到头节点层数被降为1 或者 已经降到不能再降了,循环终止。
头结点resize新的层数,降低层数。
代码实现如下:
bool erase(int num) {vector<Node*> prevV = getPrevV(num);//prevV[0]是被删除元素的前一个Node* del = prevV[0]->_nextv[0];if (del == nullptr || del->_val != num){return false;}//prevV->_next del del->_nextfor (size_t i = 0; i < del->_nextv.size(); ++i){prevV[i]->_nextv[i] = del->_nextv[i];}delete del;//修正头结点的层数int level = _head->_nextv.size() - 1;//修正直到层数为1 或 不出现空了while (level > 0){if (_head->_nextv[level] == nullptr)--level;elsebreak;}_head->_nextv.resize(level + 1);return true; }
getLevel(randomLevel)设计:
1)用c的rand和srand,利用几何概型,[0,RAND_MAX*p]占[0,RADN_MAX]的p,概率为p。
int getLevel() {int level = 1;while (rand() <= RAND_MAX * _p && level < _maxLevel){++level;}return level; }
2)用c++的库,头文件<random>,<chrono>
int getLevel() {static std::default_random_enginegenerator(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; }
Skip lists are a data structure that can be used in place of balanced trees.Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.跳表是一个可以被用来代替平衡搜索树的数据结构。跳表用基于概率的平衡而不是严格的平衡,事实上,插入和删除在跳表中比起等效平衡树的算法更简单,而且更快。
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 、哈希表再极端场景下哈希冲突高,效率下降厉害,需要红黑树补足接力。