前言
零、跳表的实现代码(leetcode 1206)
在传统的单链表中,每个元素都存放这下一个元素的引用,我们查找元素时,需要从链表头开始从前向后遍历,查找的时间复杂度为O(n)。
传统链表的查询效率非常低。那有没有什么办法提高效率呢?
我们可以采用空间换时间的办法,将上面的每两个元素抽出来做一个类似于索引的链表。
假设我们要查询6,我们从上层链表开始遍历,当遍历到7时,大于目标值,我们需要到下一层接着找目标值。
在上述遍历中,我们只需要遍历1、3、5、5、6即可找到目标结点,在普通的链表中需要1、2、3、4、5、6才能找到
constexpr int MAX_LEVEL = 32;
constexpr double SKIPLIST_P = 0.25;
struct Node {
int val;
vector<Node*> next;
Node(int val, int level) : val(val), next(vector<Node*>(level, nullptr)) {}
};
class Skiplist {
private:
int curLevel;
Node* head;
//生成1~maxLevel之间的数字. 且1/2概率返回2, 1/4概率返回3...
int randomLevel() {
int level = 1;
while (((double)rand() / (RAND_MAX)) < SKIPLIST_P && level < MAX_LEVEL) ++level;
return level;
}
public:
Skiplist() : curLevel(0), head(new Node(-1, MAX_LEVEL)) { //根据题目中num的取值范围, 我们让head值为-1即可保证head不会被更新
}
bool search(const int target) {
auto cur = head;
for (int i = curLevel - 1; i >= 0; --i) {
//找到第i层的最大的小于target的元素. 0层在下, max_level在上. 越下层的元素越多
while (cur->next[i] && cur->next[i]->val < target) cur = cur->next[i];
}
//已经到第0层了
cur = cur->next[0];
//检查当前元素的值是否等于target
return cur && cur->val == target;
}
void add(int num) {
//存放每层需要更新的位置. 我们先假设都更新的是head
vector<Node*> update(MAX_LEVEL, head);
auto cur = head;
for (int i = curLevel - 1; i >= 0; --i) {
//找到所有层的值小于num的最后一个结点
while (cur->next[i] && cur->next[i]->val < num) cur = cur->next[i];
update[i] = cur; //该节点即为num应当插入的位置的前驱节点
}
auto level = randomLevel(); //随机插入任意一层
curLevel = max(curLevel, level);
auto node = new Node(num, level); //创建要插入的节点, 其值为num, 其层级为randomLevel
//在所有预期的层级中插入随机出来的node. 从第0层开始插入到其可能的最上层!
for (int i = 0; i < level; ++i) {
node->next[i] = update[i]->next[i]; //与其后缀节点建立联系
update[i]->next[i] = node; //与其前驱结点建立联系
}
}
bool erase(int num) {
//记录每层要更新的位置. 依然假定要更新的为head
vector<Node*> update(MAX_LEVEL);
auto cur = head;
for (int i = curLevel - 1; i >= 0; --i) {
while (cur->next[i] && cur->next[i]->val < num) cur = cur->next[i];
update[i] = cur;
}
cur = cur->next[0]; //返回当前层的下一个节点
if (!cur || cur->val != num) return false; //若不存在num的节点, 则返回false
for (int i = 0; i < curLevel; ++i) {
if (update[i]->next[i] != cur) break; //从最下层开始向上遍历, 若有一层的后面的节点不为cur, 则说明cur没能进入这一层(以及更上层). 则我们可以直接退出循环
update[i]->next[i] = cur->next[i]; //更新当前层的节点. 在当前层中移除cur
}
delete cur; //我们可以将cur回收
while (curLevel > 1 && !head->next[curLevel-1]) --curLevel; //若当前的最上层已经只有一个head了, 则我们可以直接将当前层移除掉
return true;
}
};
一、代码实现细节
1. 跳表如何插入数据?
插入数据也很简单,跳表的原始链表需要保持有序,所以我们会向查找元素一样,找到元素应该插入的位置。
但是这样插入会有问题,如下图所示假如一直往原始列表中添加数据,但是不更新上层元素,就可能出现两个节点之间数据非常多的情况,极端情况,跳表退化为单链表,从而使得查找效率退化为O(n)
我们如何去维护上层元素呢?
比较容易理解的做法就是完全重建上层元素,我们每次插入数据后,都把这个跳表的上层元素删掉全部重建,重建的时间复杂度是多少呢?因为上层元素的空间复杂度是O(n),即:上层元素节点的个数是O(n)级别,时间复杂度是O(n)。导致每次插入的时间复杂度也变为了O(n),而不是O(logn)。
由于我们是均匀的选取n/2个元素作为上一层的元素,我们也可以采用随机的方式,也就是在链表中随机的选取n/2个元素作为他的上一层元素,并且当原始链表中元素数量足够大,且抽取足够随机的话,我们得到的上层元素是均匀的。于是我们可以在每次新插入元素的时候,一定要插入第一层,有1/2的概率插入第二层、1/4的概率插入第三层、1/8的概率插入第四层。当每次有数据要插入时,先通过概率算法告诉我们这个元素需要插入到几层中。(SKIPLIST_P配置0.5 实际代码参考了zset配置了0.25 也就是1/4的概率插入第二层、1/16的概率插入第三层依次类推)
int randomLevel() {
int level = 1;
// 当 level < MAX_LEVEL,且随机数小于设定的晋升概率时,level + 1
while (random() < SKIPLIST_P && level < MAX_LEVEL) {
level++;
}
return level;
上述代码可以实现我们的功能,而且,我们的例子中概率 SKIPLIST_P 设置的 1/2,即:每两个结点抽出一个结点作为上一层的结点。如果我们想节省空间利用率,可以适当的降低代码中的 SKIPLIST_P,从而减少上层元素个数,Redis 的 zset 中 SKIPLIST_P 设定的 0.25,MAX_LEVEL为32。
假设插入5,随机了2层,则给第一层和第二层都插入元素
2. 跳表如何删除数据
- 删除元素的过程跟查找元素的过程类似,只不过在查找的路径上如果发现了要删除的元素 ,则执行删除操作。我们把每一层的元素删除即可,如果顶层数据没有,则需要降低层数。
public boolean erase(int num) {
Node[] update = new Node[MAX_LEVEL];
Node cur = this.head;
for (int i = curLevel - 1; i >= 0; i--) {
//找到第i层最大的小于target的元素
while (cur.next[i] != null && cur.next[i].val < num) {
cur = cur.next[i];
}
update[i] = cur;
}
cur = cur.next[0];
//判断num是否存在
if (cur == null || cur.val != num) {
return false;
}
for (int i = 0; i < curLevel; i++) {
if (update[i].next[i] != cur) {
break;
}
//删除第i层的值和num相等的元素
update[i].next[i] = cur.next[i];
}
//有可能最上层只有一个元素,缩短层数
while (curLevel > 1 && head.next[curLevel - 1] == null) {
curLevel--;
}
return true;
}
二、跳表是什么以及 跳表的雏形
允许快速查询一个有序连续元素的数据链表。跳表的平均查找和插入的时间复杂度都是O(log n),比普通队列的O(n)要快.
我们可以作为查找数据结构的包括
- 线性结构 : 数组和链表
- 非线性结构:平衡树
数组和链表难以高效地维护一个有序的数据集合。平衡树的缺点在于需要存储的内容很多,复杂的结构增加了调整平衡树的难度,优点在于可以处理动态查找问题,并且可以存储父子兄弟节点。
我们可以得到以下几点信息:
- 因为数组的内存连续性还有树的平衡树的多节点关系决定了他们不好改造, 而链表最适合被改造
- 链表插入和删除都很简单, 但是搜索时却需要步步遍历, 明明可以一步到位可偏偏得走很多步, 这就是可以优化的地方
- 给链表加索引, 这就是跳表的由来。
三、 简单索引
给下标为偶数的节点多添加一个指针指向下一个偶数节点。
四、多级索引
基于偶数节点增加索引并且只有两层的情况下,最高层的节点数是 n/2 ,整体来看搜索的复杂度降低为 O(n/2),并不要小看这个1 / 2 的系数,看到这里会想 增加索引层数到 k ,那么复杂度将指数降低为O(n/2^k)
当索引节点数量太少时将退化为普通链表, 而当索引节点数量太大时查询效率同样非常低效。
五、跳表的复杂度分析
- 时间复杂度分析
时间复杂度与索引层数 m , 索引节点间隙 d 有直接关系
假如链表有 n 个节点, 那么就可以推出时间复杂度, 如下图:
显然深度是Ologn,如果每一层需要遍历的节点数是d,那么整个的时间复杂度就是O(dlogn).
- 空间复杂度分析
1.以 d = 2 情况来计算, 索引节点总数为 2 + 4 + 8 + … + n / 4 + n / 2
2.由等比数列求和公式可得所需额外空间为 n - 2
3.即额外空间消耗为 O(n - 2)
六、跳表在redis中的应用
ZSet结构同时包含一个字典和一个跳跃表,跳跃表按score从小到大保存所有集合元素。字典保存着从member到score的映射。这两种结构通过指针共享相同元素的member和score,不会浪费额外内存