浅谈玄学数据结构:跳表

Aleph1022

2018-10-20 11:19:29

Personal

## 前言 ### 为啥 Redis 使用跳表而不是使用红黑树? > 1. SkipList 的复杂度和红黑树一样,而且实现起来更简单。 > > 2. 在并发环境下 SkipList 有另外一个优势,红黑树在插入和删除的时候可能需要做一些保持平衡的操作,这样的操作可能会涉及到整个树的其他部分,而 SkipList 的操作显然更加局部性一些,所以需要盯住的节点更少,因此在这样的情况下性能好一些。 上文引用自:[知乎 - 为啥 redis 使用跳表(skiplist)而不是使用 red-black? - 于康的回答](https://www.zhihu.com/question/20202931/answer/16086538) ## 正文 如果你要在一个有序的序列中查找元素 $k$,相信大多数人第一反应都是二分查找。 如果你需要维护一个支持插入操作的有序表,大家又会想到链表。 **但是,链表无法随机访问,因此也无法二分。而且在有序的链表中间插入,寻找插入的位置最坏是 $O(n)$ 的。** 我们先来看看这张图:![](https://alpha1022.img.ihcr.top/SkipList-1.webp) 如果要在这里面找 $21$,过程为 $3 \to 6 \to 7 \to 9 \to 12 \to 17 \to 19 \to 21$。 我们考虑从中抽出一些节点,建立一层索引作用的链表:![](https://alpha1022.img.ihcr.top/SkipList-2.webp) 这样的话,找 $21$ 的过程变成 $6 \to 9 \to 17 \to 21$。 跳表的主要思想就是这样逐渐建立索引,加速查找与插入。 一般来说,如果要做到严格 $O(\log{n})$,上层结点个数应是下层结点个数的 $\dfrac12$。但是这样实现会把代码变得十分复杂,就失去了它在 OI 中使用的意义。 此外,我们在实现时,一般在插入时就确定数值的层数,而且层数不能简单的用随机数,而是每次以 $\dfrac12$ 或 $\dfrac14$ 的概率逐渐增长。 就像这样: ```cpp inline int rand_level() { int ret = 1; while(rand() % 2 && ret < MAX_LEVEL) ++ret; return ret; } ``` 其中 $\texttt{MAX\_LEVEL}$ 是我们指定的一个层数上限,如果使用非指针版,定义这样一个常量会方便许多,更能节省空间。如果是指针版,可以不加限制地任由它增长。 我们来看看存储结点的结构体: ```cpp struct node { int key,value; int next[MAX_LEVEL + 5]; } sl[N + 10];//N 为常量。 ``` `next[i]` 表示这个结点在第 $i$ 层的下一个结点编号。 我们初始化时,应定义两个虚拟结点 `head,tail`,表示链表头与链表尾,其实 `tail` 可以省去,但是为了易于理解,我没有去掉。 笔者个人有一个癖好:就是用一个栈或是队列保存已经被删除的节点,模拟一个内存池,可以节省很多空间,使空间在 $O(n \cdot \texttt{MAX\_LEVEL})$ 级。 分配新结点: ```cpp inline void new_node(int &p,int key,int value = 0) { if(top) p = st[top--]; else p = ++node_tot; sl[p].key = key; sl[p].value = value; } ``` 回收结点: ```cpp inline void free_node(int p) { st[++top] = p; } ``` 初始化: ```cpp inline void init() { new_node(head,-INF),new_node(tail,INF); for(register int i = 1;i <= MAX_LEVEL;++i) sl[head].next[i] = tail; } ``` 按照定义,链表头尾应分别为负与正无穷。但是有时候是不需要的,不过为避免某些锅还是打上的好。 考虑在一个已经建好的跳表中查找一个值 $key$,我们应从最高层开始查找,逐渐往下: ```cpp int find(int key) { int p = head; for(register int i = MAX_LEVEL;i;--i) while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; return sl[p].next[1]; } ``` 注意这个代码并没有判断不存在这个元素的情况。 插入操作的思路是,同样从顶层开始,逐层找到每层需要插入的位置,再生成层数并插入: ```cpp void insert(int key,int value) { int p = head; int update[MAX_LEVEL + 5]; int k = rand_level(); for(register int i = MAX_LEVEL;i;--i) { while(sl[p].next[i] != tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; update[i] = p; } int temp; new_node(temp,key,value); for(register int i = k;i;--i) { sl[temp].next[i] = sl[update[i]].next[i]; sl[update[i]].next[i] = temp; } } ``` 删除操作几乎与插入对称: ```cpp void erase(int key) { int p = head; int update[MAX_LEVEL + 5]; for(register int i = MAX_LEVEL;i;--i) { while(sl[p].next[i] != tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; update[i] = p; } free_node(sl[p].next[1]); for(register int i = MAX_LEVEL;i;--i) { if(sl[sl[update[i]].next[i]].key == key) sl[update[i]].next[i] = sl[sl[update[i]].next[i]].next[i]; } } ``` ## 实战 先来看道水题:[CodeVS 1230](http://codevs.cn/problem/1230/)。 > 给出 $n$ 个正整数,然后有 $m$ 个询问,每个询问一个整数,询问该整数是否在 $n$ 个正整数中出现过。 虽然是 Hash 入门题,但是 set 可过。所以我们也可以尝试用跳表来写。 代码: ```cpp #include <cstdio> #include <cstdlib> #include <algorithm> using namespace std; const int N = 1e5; const int MAX_LEVEL = 25; struct node { int key; int next[MAX_LEVEL + 5]; } sl[N + 10]; int node_tot,head,tail; int st[N + 10],top; inline int rand_level() { int ret = 1; while(rand() % 2 && ret <= MAX_LEVEL) ++ret; return ret; } inline void new_node(int &p,int key = 0) { if(top) p = st[top--]; else p = ++node_tot; sl[p].key = key; } inline void free_node(int p) { st[++top] = p; } inline void init() { new_node(head),new_node(tail); for(register int i = 1;i <= MAX_LEVEL;++i) sl[head].next[i] = tail; } void insert(int key) { int p = head; int update[MAX_LEVEL + 5]; int k = rand_level(); for(register int i = MAX_LEVEL;i;--i) { while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; update[i] = p; } int temp; new_node(temp,key); for(register int i = k;i;--i) { sl[temp].next[i] = sl[update[i]].next[i]; sl[update[i]].next[i] = temp; } } bool find(int key) { int p = head; for(register int i = MAX_LEVEL;i;--i) while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; if(sl[sl[p].next[1]].key == key) return 1;//找到了 return 0;//没找到 } int n,m; int main() { srand(19260817); scanf("%d%d",&n,&m); int x; for(register int i = 1;i <= n;++i) scanf("%d",&x),insert(x); while(m--) scanf("%d",&x),puts(find(x) ? "YES" : "NO"); } ``` 好,现在来看道蓝题:[P2286](https://www.luogu.org/problemnew/show/P2286)。 此题的思路就是,每次找它的前驱与后继中与它差更小的那个。 但是有一个细节:就是要区分人与宠物。所以在其为空的时候我们记录第一个插入的值的类型,此后遇到同类型的就插入,否则就进行领养操作。 `set` 版代码: ```cpp #include <cstdio> #include <cstring> #include <cstdlib> #include <set> #include <algorithm> using namespace std; const int mod = 1e6; int n,ans; int type; set<int> d; inline void answer(int x) { auto l = --d.lower_bound(x); auto r = d.lower_bound(x); if(x - *l <= *r - x && *l ^ -0x3f3f3f3f) { ans = (ans + x - *l) % mod; d.erase(l); } else { ans = (ans + *r - x) % mod; d.erase(r); } } int main() { d.insert(0x3f3f3f3f),d.insert(-0x3f3f3f3f); int x,y; scanf("%d",&n); for(register int i = 1;i <= n;++i) { scanf("%d%d",&x,&y); if(d.size() == 2) type = x,d.insert(y); else if(x == type) d.insert(y); else answer(y); } printf("%d\n",ans); } ``` 我们考虑转换为跳表版。但是此时我们还需要维护跳表的结点个数。这个很简单,只需要在新建结点时顺便 `++`,回收内存时 `--`。或者是在插入删除时处理也可。 以及 `lower_bound` 函数,由于我们存储的不是双向链表,模拟对 `set` 的迭代器 `--` 的操作有两个方法: - 改成双向 - 查找时直接找 `--` 的结果 最终代码: ```cpp #include <cstdio> #include <cstring> #include <cstdlib> #include <algorithm> using namespace std; const int N = 1e5; const int mod = 1e6; int n,ans; int type; const int MAX_LEVEL = 30; struct node { int key; int next[MAX_LEVEL + 5]; } sl[N + 10]; int node_tot,head,tail,size; int st[N + 10],top; inline int rand_level() { int ret = 1; while(rand() % 2 && ret < MAX_LEVEL) ++ret; return ret; } inline void new_node(int &p,int key = 0) { if(top) p = st[top--]; else p = ++node_tot; sl[p].key = key; ++size; } inline void free_node(int p) { st[++top] = p; --size; } inline void init() { new_node(head,-0x3f3f3f3f),new_node(tail,0x3f3f3f3f); for(register int i = 1;i <= MAX_LEVEL;++i) sl[head].next[i] = tail; } void insert(int key) { int p = head; int update[MAX_LEVEL + 5]; int k = rand_level(); for(register int i = MAX_LEVEL;i;--i) { while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; update[i] = p; } int temp; new_node(temp,key); for(register int i = k;i;--i) { sl[temp].next[i] = sl[update[i]].next[i]; sl[update[i]].next[i] = temp; } } void erase(int key) { int p = head; int update[MAX_LEVEL + 5]; for(register int i = MAX_LEVEL;i;--i) { while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; update[i] = p; } free_node(sl[p].next[1]); for(register int i = MAX_LEVEL;i;--i) if(sl[sl[update[i]].next[i]].key == key) sl[update[i]].next[i] = sl[sl[update[i]].next[i]].next[i]; } int lb(int key) { int p = head; for(register int i = MAX_LEVEL;i;--i) while(sl[p].next[i] ^ tail && sl[sl[p].next[i]].key < key) p = sl[p].next[i]; return p;//相当于对 lower_bound 的结果进行 -- } inline void answer(int x) { int l = lb(x); int r = sl[l].next[1];//相当于对迭代器 ++ if(sl[l].key ^ -0x3f3f3f3f && x - sl[l].key <= sl[r].key - x) { ans = (ans + x - sl[l].key) % mod; erase(sl[l].key); } else { ans = (ans + sl[r].key - x) % mod; erase(sl[r].key); } } int main() { srand(19260817); init(); int x,y; scanf("%d",&n); while(n--) { scanf("%d%d",&x,&y); if(size == 2) type = x,insert(y); else if(x == type) insert(y); else answer(y); } printf("%d\n",ans); } ``` ## 拓展 考虑在跳表的每一个结点上,维护**同一层**内该结点与后一个结点差了多少个数,这样就可以维护一棵名次树了。 然鹅笔者太鸽就不写了,~~不如去写平衡树~~。 ## 参考资料 - 任何能被百度到的跳表的资料。 - [hzwer.com - 「BZOJ1208」[HNOI2004] 宠物收养所](http://hzwer.com/1311.html)