跳表 skiplist 简介

跳表 skiplist

跳表 (Skip List) 是由 William Pugh 在 1990 年发表的文章 Skip Lists: A Probabilistic Alternative toBalanced Trees 中描述的一种查找数据结构,支持对数据的快速查找,插入和删除。

对于 AVL 树、红黑树等平衡树,在插入过程中需要做多次旋转操作,实现复杂,而跳表实现更简单、开销更低,应用广泛。

在这里插入图片描述
跳表是一个包含随机算法的数据结构,跳表的查找、插入、删除的平均时间复杂度都是 O(logn),而且可以按照范围区间查找元素,空间复杂度是 O(n)。

跳表的最底层通常是一个有序链表,上层是下层的一个索引,下层元素出现在上层的概率为 p (通常 p=1/2)。

跳表的创建过程

首先,对于一个有序链表,如果我们需要查找元素,则需要从头开始遍历链表,查找时间复杂度为 O(n)。如果我们每相邻两个节点增加一个指针,让指针指向下下个节点,那么上层形成了一个减缩版的链表,当我查找元素时,先在上层链表查找,当待查元素在两个节点之间时,再回到下层链表查找,这样我们就可以跳过一些节点,提高查找速度。以此类推,我们可以在第二层链表上建立第三层链表……查找一个元素时,从最高层开始,逐层向下,直到找到为止。

在这里插入图片描述
上述我们建立的数据结构就是一个跳表,我们可以将上层链表看作是下层链表的一个索引,每相邻两个、三个……建立一个索引,这是一种确定性策略,如果只是查找操作,这么建立跳表没有问题。但是当我们需要频繁插入和删除元素时,这种确定性策略会使维护跳表变得复杂。

为了解决上述问题,我们引入随机策略,不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机产生一个层数(level)。

// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
//        1/2 的概率返回 1
//        1/4 的概率返回 2
//        1/8 的概率返回 3 以此类推
private int randomLevel() {
  int level = 1;
  // 当 level < MAX_LEVEL,且随机数小于设定的晋升概率时,level + 1
  while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
    level += 1;
  return level;
}

创建跳表我们首先需要一个返回层数的随机函数 randomlevel(),在有序链表的每一个元素上运行该随机函数,当返回 level=1 时,不创建索引;当返回 level=2 时,对该元素创建一级索引;当返回 level=3 时,对该元素创建一级和二级索引……

如果 SKIPLIST_P=1/2 时,一级索引有 n/2 个元素,二级索引有 n/4 个元素……空间复杂度为 O(n)。如果底层链表中的元素存储的是对象,索引只需存储对象排序的键值 key 即可。

查找元素

在这里插入图片描述
查找元素时,从最高层索引开始查找,逐层向下,直到找到为止。根据概率可以证明查找的平均时间复杂度为 O(logn)。

V& find(const K& key) {
  SkipListNode<K, V>* p = head;
  // 找到该层最后一个键值小于 key 的节点,然后走向下一层
  for (int i = level; i >= 0; --i) {
    while (p->forward[i]->key < key) {
      p = p->forward[i];
    }
  }
  // 现在是小于,所以还需要再往后走一步
  p = p->forward[0];
  // 成功找到节点
  if (p->key == key) return p->value;
  // 节点不存在,返回 INVALID
  return tail->value;
}

插入元素

在这里插入图片描述
插入元素的关键是查找元素的合适插入位置,将元素插入链表中之后,运行 randomlevel 函数,确定在该元素上建立几层索引。

void insert(const K &key, const V &value) {
  // 用于记录需要修改的节点
  SkipListNode<K, V> *update[MAXL + 1];
  SkipListNode<K, V> *p = head;
  for (int i = level; i >= 0; --i) {
    while (p->forward[i]->key < key) {
      p = p->forward[i];
    }
    // 第 i 层需要修改的节点为 p
    update[i] = p;
  }
  p = p->forward[0];
  // 若已存在则修改
  if (p->key == key) {
    p->value = value;
    return;
  }
  // 获取新节点的最大层数
  int lv = randomLevel();
  if (lv > level) {
    lv = ++level;
    update[lv] = head;
  }
  // 新建节点
  SkipListNode<K, V> *newNode = new SkipListNode<K, V>(key, value, lv);
  // 在第 0~lv 层插入新节点
  for (int i = lv; i >= 0; --i) {
    p = update[i];
    newNode->forward[i] = p->forward[i];
    p->forward[i] = newNode;
  }
  ++length;
}

删除元素

在这里插入图片描述
删除元素的关键同样是查找操作,先找到待删除元素的位置,然后删除对应元素及其索引,删除操作调整对应指针即可。

bool erase(const K &key) {
  // 用于记录需要修改的节点
  SkipListNode<K, V> *update[MAXL + 1];
  SkipListNode<K, V> *p = head;
  for (int i = level; i >= 0; --i) {
    while (p->forward[i]->key < key) {
      p = p->forward[i];
    }
    // 第 i 层需要修改的节点为 p
    update[i] = p;
  }
  p = p->forward[0];
  // 节点不存在
  if (p->key != key) return false;
  // 从最底层开始删除
  for (int i = 0; i <= level; ++i) {
    // 如果这层没有 p 删除就完成了
    if (update[i]->forward[i] != p) {
      break;
    }
    // 断开 p 的连接
    update[i]->forward[i] = p->forward[i];
  }
  // 回收空间
  delete p;
  // 删除节点可能导致最大层数减少
  while (level > 0 && head->forward[level] == tail) --level;
  // 跳表长度
  --length;
  return true;
}

复杂度证明

空间复杂度

对于一个节点而言,节点的层数为 i i i 的概率为 p i − 1 ( 1 − p ) p^{i-1}(1-p) pi1(1p),则该节点的期望层数为:
∑ i ≥ 1 , p < 1 i p i − 1 ( 1 − p ) = 1 1 − p \sum_{i\ge 1,p<1}ip^{i-1}(1-p)=\frac{1}{1-p} i1,p<1ipi1(1p)=1p1
则跳表的期望空间为 n 1 − p \frac{n}{1-p} 1pn,且因为 p p p 为常数,所以跳表的期望空间复杂度为 O(n)。在最坏的情况下,每一层有序链表等于初始有序链表,即跳表的最差空间复杂度为 O(nlogn)。

时间复杂度

从后向前分析查找路径,这个过程可以分为从最底层爬到第 L ( n ) L(n) L(n) 层和后续操作两个部分。在分析时,假设一个节点的具体信息在它被访问之前是未知的。

假设当前我们处于一个第 i i i 层的节点 x x x,我们并不知道 x x x 的最大层数和 x x x 左侧节点的最大层数,只知道 x x x 的最大层数至少为 i i i。如果 x x x 的最大层数大于 i i i,那么下一步应该是向上走,这种情况的概率为 p p p;如果 x x x 的最大层数等于 i i i,那么下一步应该是向左走,这种情况概率为 1 − p 1-p 1p

C ( i ) C(i) C(i) 为在一个无限长度的跳表中向上爬 i i i 层的期望代价,定义 C ( 0 ) = 0 C(0)=0 C(0)=0,那么有:
C ( i ) = ( 1 − p ) ( 1 + C ( i ) ) + p ( 1 + C ( i − 1 ) ) C(i)=(1-p)(1+C(i))+p(1+C(i-1)) C(i)=(1p)(1+C(i))+p(1+C(i1))
解得 C ( i ) = i p C(i)=\frac{i}{p} C(i)=pi

由此可以得出:在长度为 n n n 的跳表中,从最底层爬到第 L ( n ) L(n) L(n) 层的期望步数存在上界 L ( n ) − 1 p \frac{L(n)-1}{p} pL(n)1

现在只需要分析爬到第 L ( n ) L(n) L(n) 层后还要再走多少步。易得,到了第 L ( n ) L(n) L(n) 层后,向左走的步数不会超过第 L ( n ) L(n) L(n) 层及更高层的节点数总和,而这个总和的期望为 1 p \frac{1}{p} p1。所以到了第 L ( n ) L(n) L(n) 层后向左走的期望步数存在上界 1 p \frac{1}{p} p1。同理,到了第 L ( n ) L(n) L(n) 层后向上走的期望步数存在上界 1 p \frac{1}{p} p1

所以,跳表查询的期望查找步数为 L ( n ) − 1 p + 2 p \frac{L(n)-1}{p}+\frac{2}{p} pL(n)1+p2,又因为 L ( n ) = l o g 1 p n L(n)=log_{\frac{1}{p}}n L(n)=logp1n,所以跳表查询的期望时间复杂度为 O(logn)。

在最坏的情况下,每一层有序链表等于初始有序链表,查找过程相当于对最高层的有序链表进行查询,即跳表查询操作的最差时间复杂度为 O(n)。

插入操作和删除操作就是进行一遍查询的过程,途中记录需要修改的节点,最后完成修改。易得每一层至多只需要修改一个节点,又因为跳表期望层数为 l o g 1 p n log_{\frac{1}{p}}n logp1n,所以插入和修改的期望时间复杂度也为 O(logn)。

skiplist 与平衡树、哈希表的比较

  • skiplist 和各种平衡树(如 AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个 key 的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
  • 在做范围查找的时候,平衡树比 skiplist 操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在 skiplist 上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 skiplist 的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist 比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而 skiplist 每个节点包含的指针数目平均为 1 / ( 1 − p ) 1/(1-p) 1/(1p),具体取决于参数 p p p 的大小。如果像 Redis 里的实现一样,取 p = 1 / 4 p=1/4 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  • 查找单个 key,skiplist 和平衡树的时间复杂度都为 O(logn),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近 O(1),性能更高一些。所以我们平常使用的各种 Map 或 dictionary 结构,大都是基于哈希表实现的。
  • 从算法实现难度上来比较,skiplist 比平衡树要简单得多。

参考文献

[1] Skip Lists: A Probabilistic Alternative toBalanced Trees
[2] Skip list - Wikipedia
[3] Redis内部数据结构详解(6)——skiplist
[4] 跳表 - OI Wiki

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
跳表Skiplist)是一种基于链表实现的数据结构,用于快速查找和插入有序序列中的元素。它是一种随机化数据结构,可以在O(log n)的时间内完成查找、插入和删除操作。 以下是一个简单的C++实现示例: ```c++ #include <iostream> #include <cstdlib> #include <ctime> using namespace std; const int MAX_LEVEL = 16; // 最大层数 class Node { public: int key; Node **forward; Node(int level, int key) { forward = new Node*[level+1]; memset(forward, 0, sizeof(Node*)*(level+1)); this->key = key; } ~Node() { delete[] forward; } }; class SkipList { public: SkipList() { levelCount = 1; head = new Node(MAX_LEVEL, 0); srand(time(0)); } ~SkipList() { delete head; } int randomLevel() { int level = 1; while (rand() % 2 == 1 && level < MAX_LEVEL) level++; return level; } void insert(int key) { Node *update[MAX_LEVEL+1]; memset(update, 0, sizeof(Node*)*(MAX_LEVEL+1)); Node *p = head; for (int i = levelCount; i >= 1; i--) { while (p->forward[i] != nullptr && p->forward[i]->key < key) p = p->forward[i]; update[i] = p; } p = p->forward[1]; if (p != nullptr && p->key == key) return; int level = randomLevel(); if (level > levelCount) { for (int i = levelCount+1; i <= level; i++) update[i] = head; levelCount = level; } p = new Node(level, key); for (int i = 1; i <= level; i++) { p->forward[i] = update[i]->forward[i]; update[i]->forward[i] = p; } } void remove(int key) { Node *update[MAX_LEVEL+1]; memset(update, 0, sizeof(Node*)*(MAX_LEVEL+1)); Node *p = head; for (int i = levelCount; i >= 1; i--) { while (p->forward[i] != nullptr && p->forward[i]->key < key) p = p->forward[i]; update[i] = p; } p = p->forward[1]; if (p == nullptr || p->key != key) return; for (int i = 1; i <= levelCount; i++) { if (update[i]->forward[i] != p) break; update[i]->forward[i] = p->forward[i]; } delete p; while (levelCount > 1 && head->forward[levelCount] == nullptr) levelCount--; } bool search(int key) { Node *p = head; for (int i = levelCount; i >= 1; i--) { while (p->forward[i] != nullptr && p->forward[i]->key < key) p = p->forward[i]; } p = p->forward[1]; if (p != nullptr && p->key == key) return true; return false; } void display() { for (int i = 1; i <= levelCount; i++) { Node *p = head->forward[i]; cout << "Level " << i << ": "; while (p != nullptr) { cout << p->key << " "; p = p->forward[i]; } cout << endl; } } private: Node *head; int levelCount; }; int main() { SkipList skiplist; skiplist.insert(1); skiplist.insert(3); skiplist.insert(2); skiplist.insert(4); skiplist.display(); skiplist.remove(3); skiplist.display(); cout << skiplist.search(2) << endl; cout << skiplist.search(3) << endl; return 0; } ``` 在这个示例中,我们使用了一个类`Node`作为跳表中的节点,`SkipList`类则封装了跳表的插入、删除、搜索和显示等操作。其中,`randomLevel()`函数用于随机生成节点的层数,`insert()`函数用于插入一个节点,`remove()`函数用于删除一个节点,`search()`函数用于查找一个节点,`display()`函数用于显示整个跳表跳表是一种比较高级的数据结构,它可以在很多场景中代替平衡树,以提高数据结构操作的效率。如果你对跳表感兴趣,可以尝试阅读一些更深入的资料来了解更多。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值