一、为什么选择跳表?
目前经常使用的平衡数据结构有:B树,红黑树,AVL树,Splay Tree, Treep等。想象一下,给你一张草稿纸,一只笔,一个编辑器,你能立即实现一颗红黑树,或者AVL树出来吗?
很难吧,这需要时间,要考虑很多细节,要参考一堆算法与数据结构之类的树,还要参考网上的代码,相当麻烦。
用跳表吧,跳表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,它的效率和红黑树以及 AVL 树不相上=下,但跳表的原理相当简单,只要你能熟练操作链表,就能轻松实现一个 SkipList。
二、定义
如果你要在一个有序的序列中查找元素 k ,相信大多数人第一反应都是二分查找。
如果你需要维护一个支持插入操作的有序表,大家又会想到链表。
简单的说,要达到以logn的速度查找链表中的元素
我们先来看看这张图:
如果要在这里面找 21 ,过程为 3→ 6 → 7 → 9 → 12 → 17 → 19 → 21 。
我们考虑从中抽出一些节点,建立一层索引作用的链表:
跳表的主要思想就是这样逐渐建立索引,加速查找与插入。
一般来说,如果要做到严格 O(logn) ,上层结点个数应是下层结点个数的 1/2 。但是这样实现会把代码变得十分复杂,就失去了它在 OI 中使用的意义。
此外,我们在实现时,一般在插入时就确定数值的层数,而且层数不能简单的用随机数,而是以1/2的概率增加层数。
用实验中丢硬币的次数 K 作为元素占有的层数。显然随机变量 K 满足参数为 p = 1/2 的几何分布,K 的期望值 E[K] = 1/p = 2. 就是说,各个元素的层数,期望值是 2 层。
同时,为了防止出现极端情况,设计一个最大层数MAX_LEVEL。如果使用非指针版,定义这样一个常量会方便许多,更能节省空间。如果是指针版,可以不加限制地任由它增长。
inline int rand_level()
{
int ret = 1;
while (rand() % 2 && ret <= MAX_LEVEL)
++ret;
return ret;
}
我们来看看存储结点的结构体:
struct node
{
int key;
int next[MAX_LEVEL + 1];
} sl[maxn + 10];
next[i] 表示这个结点在第 i 层的下一个结点编号。
分配新结点
为了充分地利用空间,就是用一个栈或是队列保存已经被删除的节点,模拟一个内存池,记录可以使用的内存单元。
可以节省很多空间,使空间在 O(n * MAX_LEVEL) 级
inline void new_node(int &p, int key)
{
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, -INF), new_node(tail, INF);
for (register int i = 1; i <= MAX_LEVEL; ++i)
sl[head].next[i] = tail;
}
查找
从最上层开始,如果key小于或等于当层后继节点的key,则平移一位;如果key更大,则层数减1,继续比较。最终一定会到第一层(想想为什么)
查找 117
插入
先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的)。
然后在 Level 1 … Level K 各个层的链表都插入元素。
用Update数组记录插入位置,同样从顶层开始,逐层找到每层需要插入的位置,再生成层数并插入。
例子:插入 119, K = 2
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]