跳表(skip list)
- LINDA
- 2018/8/9
跳表可以作为AVL、红黑树等平衡二叉树或伸展树等自适应树的替代品。
对于跳表,任何数据集的查找、插入、删除操作都是O(lgn)
。
在普通的链表中,查找、插入、删除都是O(n)
,这是因为它是按顺序扫描的。如果可以增大扫描的步长(即跳过某些结点),我们就可以减少扫描的开销了。这就是跳表的基本思想。
跳表相对于普通链表有两点不同:
- 普通链表的结点只有一个
next
指针指向下一个结点,跳表的结点有多个next
指针指向多个下个结点(也叫作向前指针(forward reference)。) - 对于一个给定的结点,它的向前指针的个数是随机的。
我们可以说一个跳表的结点是有层数的(levels
),一层就有一个向前指针。所以,结点的层数被称为是结点的大小。
基本思想
为了介绍跳表的数据结构,我们先来看下面这三个可以”跳“的表数据结构,但还不是真正的跳表。在遍历过程中,第一个允许跳过一个,第二个允许跳过四个,第三个允许跳过八个结点,如果继续下去,我们就可以得到可以跳过2^i
结点的表结构了。
对于跳表,我们知道头结点和其他结点都是不一定有相同的向前指针的。有的有一个,有的有多个。
那么,我们先假设它的结点的数据结构是这样的:
struct Node {
int key; // 值
vector<Node*> next; // 向前指针的集合
};
那么,查找操作的例程应该是这样的:
Node* find(const int x) {
Node* p = head; // 头结点
int levels = p->next.size();
if(x < p->key)
return nullptr; // 表示没找到
for(int i = 0; i < levels; ++i) {
if(p->next[i]->key == x) {
return p->next[i];
}
if(p->next[i]->key > x) { // 如果目标值比当前结点值小,查找下一层
continue;
}
if(p->next[i]->key < x) { // 如果目标值比当前结点值大,查找该层的下一个结点
p = p->next[i];
i = 0;
levels = p->next.size();
}
}
return nullptr; // 查找不到
}
我们从头结点的最高层开始,如果目标值比当前结点小,我们就向下一层查找,如果目标值比当前值大,我们就向该层的下一个结点查找,找到则返回该结点,找不到返回空。
跳过每两个结点
图一表示了一个16结点的链表结构,每两个结点就有两个向前指针,存储值表示在结点下面。头结点有两层,且不能小于最大的结点。2
结点有一个向前指针指向4
结点,相似的有4
,6
,8
,等。每两个就有一个拥有两个向前指针。
显然,它的查找操作并不需要一个一个结点查找。可以每次都先跳过一个结点,直到无法跳过,再找下一个结点就好了。所以,需要查找的结点不会超过
⌈n2⌉+1
⌈
n
2
⌉
+
1
。例如,查找15
,它只需要查找2,4,6,8,10,12,14,16,15
,总共
⌈162⌉+1=9
⌈
16
2
⌉
+
1
=
9
跳过每两个和四个结点
第二个例子,就是一个链表的每两个结点能一次向前两个结点,每四个结点能一次向前四个结点,如图2,头结点必须不小于最大(层数)结点(的层数)。
这样,查找操作就可以以更大的步长来查找了。可以每次跳过四个结点直到无法跳过,在查找接着的最多3个结点。所以,查找的结点不会超过
⌈n4⌉+3
⌈
n
4
⌉
+
3
。例如,查找15
,它只需要查找4,8,12,16,14,16,15
,总共
⌈164⌉+3=7
⌈
16
4
⌉
+
3
=
7
。
跳过每 2i 2 i 个结点
这最后一个例子,就是一个允许跳过更大的步长的链表:
每第
2i
2
i
个结点
i=1,…⌈lgn⌉
i
=
1
,
…
⌈
lg
n
⌉
,就可以向前
2i
2
i
个结点。例如每个第2
结点就可以向前两个结点,每第八个结点就可以向前8个结点,等等。同样的,头结点不能比最大结点小。
假设图3的链表拥有32个结点,那么我们要查找一个数,就从头结点的最高层开始,先比较第16个结点,如果比它小则进入前半段查找,如果比它大这进入后半段查找,如此反复,直到查找到,显然,这是一个折半查找,时间复杂度为O(lgn)
。
这个数据结构看来已经很完美了,其实,它在插入或删除操作上有很严重的问题。它要求插入一个数或删除一个数后,重新调整好整张表,这个操作的复杂度为O(n)
。
所以,真正的跳表就采用了概率的方法来操作整张表。一个跳表被要求结点大小要有一致的分布,但不需要固定的结点大小。所以,也就不需要在删除或插入后做相应的结点调整了。
跳表——概率的方法
图4可以看做是图3的各结点的重新组织。结点大小的分布和图3是一样的,仅仅是位置不同而已。在这个例子中,这种方法要求有一个向前指针的结点有50%
,有两个向前指针的结点有25%
,有三个向前指针的结点有12.5%
,等等。这个分布是固定的,但像图3的严格的按顺序分布则不需要。
图4只是这种概率下的一种可能的情况而已。在满足概率分布的条件下,其他情况也是允许的。
当插入一个新值,我们按概率选择一个新结点的大小。每个跳表有一个先确定的概率p
,来决定结点的概率分布。也可以说,拥有r
个向前指针的结点的p
倍的结点数拥有r+1
个向前指针。在一个新结点插入后,跳表不需要再重新组织。
结点的分布
假设我们有一个给定概率为p
的无限长的跳表。也就是第i
层的结点的p
倍为第i+1
层的结点(和上面同个意思)。设
Lk
L
k
为拥有k
个向前指针的结点数,那么,
Lk=pLk−1
L
k
=
p
L
k
−
1
并且
因为
所以,我们可以这样,
几何级数的和 ∑∞i=0pi ∑ i = 0 ∞ p i 可以表示为:
因此,
又因为 L2=p∗L1 L 2 = p ∗ L 1 ,所以
在图4的情况,
p=0.5
。因此,有
1-1/2=1/2
的结点拥有一个向前指针,它的
1/2
的结点拥有两个向前指针,等等。
选择一个结点概率
当我们需要插入一个结点时,结点的大小通过一个随机数生成器r
来选取。这是有确定概率p
和最大层数maxLevel
的跳表的一种选择方法:
int generateNodeLevel(double p, int maxLevel) {
int level = 1;
while(drand48() < p) // drand48()产生一个0~1之间的随机数
++level;
return (level > maxLevel)? maxLevel : level;
}
注意,每个新结点的层数是独立于跳表中已经存在的结点的。每个结点的选择仅仅基于跳表的概率p
。查找操作需要比较的平均次数为
例如,大小为65536的跳表,在
p=1/4时,
平均查找结点数为
34.3
,在
p=1/2
时,平均查找结点数为
35
,这比相同的普通链表的平均查找次数
n/2=32768
要好很多。
头结点的层数
头结点的层数是整个跳表被允许的最大的层数。Pugh(跳表的发明者) 认为最大的层数应该被设为 log1pn log 1 p n 。因此,对于 p=12 p = 1 2 ,拥有最多65536个元素的跳表的最大高度应该不小于 lg265536=16 lg 2 65536 = 16 。
关于性能的思考
查找一个元素(或插入、删除)的期望时间是O(lgn)
。有时候,如果结点的层数分布不好,将会导致某个操作的时间增加。因为结点的大小是随机生成的,它可能会产生一个不好的大小值。例如,它可能对每个结点都生成相同的大小,这就等价于生成了一个普通链表。一个不好的大小值将导致比期望时间大的查找(插入或删除)时间。显然,一个不好的大小值将和跳表的长度成反比,也就是随着结点数的增加,性能会逐步增加。
一个操作的时间将会比期望的时间长的概率是和跳表相关的概率p
有函数关系的。例如,Pugh 计算过一个p = 0.5
,拥有4096个元素的跳表,实际时间超过期望时间的3倍的概率小于2亿分之一。
跳表相关的时间和空间性能依赖于这个表的层数的概率。Push 建议更多情况下使用p=0.25
。如果性能伸缩更重要的,它建议使用p=0.5
(伸缩性随着概率的增加而减少)。有趣的是,当p=0.25
时,每个结点的向前指针平均值为1.33,而一个平衡二叉树,每个结点需要两个指针,所以,跳表可能更节省空间。
跳表的实现
template <class Comparable>
class SkipList {
private:
class SkipListNode {
public:
void setDatum(const Comparable & datum); // 设置数据
void setForward(int i, SkipListNode* f); // 设置向前指针
void setSize(int sz); // 设置大小
SkipListNode();
SkipListNode(const Comparable& datum, int levels);
SkipListNode(const SkipListNode& );
~SkipListNode();
const Comparable& getDatum() const;
int getSize() const;
SkipListNode * getForword(int level);
private:
int _levels; // 该结点的层数
vector<SkipListNode* > _forward; // 向前指针
Comparable _datum; // 数据
}; // SkipListNode
public:
SkipList(); // max_node_size=16, probab=0.25
SkipList(int max_node_size, double probab);
SkipList(const SkipList &);
~SkipList();
int getHighNodeSize() const; // 获取当前跳表的最大结点的最高层
int getMaxNodeSize() const; // 获取该跳表允许的最大结点大小
double getProbability() const; // 获取概率
// 插入
void insert(const Comparable& item);
// 查找,如果找到,返回该项,并设`success`为true,失败,返回空项,success为false
const Comparable& find(const Comparable& item, bool& success);
// 删除
void remove(const Comparable& item);
private:
// 查找,从`startnode`开始,通常是header,如果找到,返回结点,否则返回前一个结点。前提条件:startnode非空。
SkipListNode* find(const Comparable& item, SkipListNode* startnode);
SkipListNode* getHeader() const;
SkipListNode *findInsertPoint(const Comparable& item, int nodesize);
void insert(const Comparable& item, int nodesize, bool& success);
int _high_node_size; // 当前跳表中最大结点的层数
int _max_node_size; // 跳表允许的最大层数
double _prob; // 概率
SkipListNode* _head; // 头结点
}; // SkipList
具体实现后续放上。
翻译自(有部分改动)