SkipList 跳跃表

http://blog.csdn.net/likun_tech/article/details/7354306

http://www.cnblogs.com/zhuangli/articles/1275665.html

http://www.cnblogs.com/xuqiang/archive/2011/05/22/2053516.html

为什么选择跳表

目前经常使用的平衡数据结构有:B树,红黑树,AVL树,Splay Tree, Treep等。


想象一下,给你一张草稿纸,一只笔,一个编辑器,你能立即实现一颗红黑树,或者AVL树

出来吗? 很难吧,这需要时间,要考虑很多细节,要参考一堆算法与数据结构之类的树,

还要参考网上的代码,相当麻烦。


用跳表吧,跳表是一种随机化的数据结构,目前开源软件 Redis 和 LevelDB 都有用到它,

它的效率和红黑树以及 AVL 树不相上下,但跳表的原理相当简单,只要你能熟练操作链表,

就能轻松实现一个 SkipList。


有序表的搜索

考虑一个有序表:


d5d03b36-abff-34ea-9c40-a1fbfb709a81.jpg


从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数

为 2 + 4 + 6 = 12 次。有没有优化的算法吗?  链表是有序的,但不能使用二分查找。类似二叉

搜索树,我们把一些节点提取出来,作为索引。得到如下结构:


7c904c3f-1f39-31af-b8cd-b6de27a94061.jpg


这里我们把 < 14, 34, 50, 72 > 提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。

我们还可以再从一级索引提取一些元素出来,作为二级索引,变成如下结构:


96983cb0-d60a-31da-953d-2dde4036ea6b.jpg


 这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了


跳表

下面的结构是就是跳表:

其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。


f4c149bd-d8ea-39ff-813f-93d809c90966.jpg


跳表具有如下性质:

(1) 由很多层结构组成

(2) 每一层都是一个有序的链表

(3) 最底层(Level 1)的链表包含所有元素

(4) 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。

(5) 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。


跳表的搜索


ec9fd643-f85c-3072-8634-60cfc88ab334.jpg


例子:查找元素 117

(1) 比较 21, 比 21 大,往后面找

(2) 比较 37,   比 37大,比链表最大值小,从 37 的下面一层开始找

(3) 比较 71,  比 71 大,比链表最大值小,从 71 的下面一层开始找

(4) 比较 85, 比 85 大,从后面找

(5) 比较 117, 等于 117, 找到了节点。


具体的搜索算法如下:


C代码  
/* 如果存在 x, 返回 x 所在的节点,
 * 否则返回 x 的后继节点 */
find(x) 
{
    p = top;
while (1) {
while (p->next->key < x)
            p = p->next;
if (p->down == NULL) 
return p->next;
        p = p->down;
    }
}



跳表的插入

先确定该元素要占据的层数 K(采用丢硬币的方式,这完全是随机的)

然后在 Level 1 ... Level K 各个层的链表都插入元素。

例子:插入 119, K = 2


bb72be16-6162-3fee-b680-311f25dd7c3a.jpg


如果 K 大于链表的层数,则要添加新的层。

例子:插入 119, K = 4


6eac083f-45d9-37f9-867f-0d709d9659d3.jpg


丢硬币决定 K

插入元素的时候,元素所占有的层数完全是随机的,通过一下随机算法产生:


C代码
int random_level()
{
    K = 1;
while (random(0,1))
        K++;
return K;
}



相当与做一次丢硬币的实验,如果遇到正面,继续丢,遇到反面,则停止,

用实验中丢硬币的次数 K 作为元素占有的层数。显然随机变量 K 满足参数为 p = 1/2 的几何分布,

K 的期望值 E[K] = 1/p = 2. 就是说,各个元素的层数,期望值是 2 层。



跳表的高度。

n 个元素的跳表,每个元素插入的时候都要做一次实验,用来决定元素占据的层数 K,

跳表的高度等于这 n 次实验中产生的最大 K,待续。。。


跳表的空间复杂度分析

根据上面的分析,每个元素的期望高度为 2, 一个大小为 n 的跳表,其节点数目的

期望值是 2n。


跳表的删除

在各个层中找到包含 x 的节点,使用标准的 delete from list 方法删除该节点。

例子:删除 71


7bab9ad1-9f5a-37d0-bc38-89ee50d1bc0d.jpg


#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
typedefint key_t;
typedefint value_t;
typedefstruct node_t
{
    key_t key;
    value_t value;
struct node_t *forward[];
} node_t;
typedefstruct skiplist
{
int level;
int length;
    node_t *header;
} list_t;
#define MAX_LEVEL   16
#define SKIPLIST_P  0.25
node_t* slCreateNode(int level, key_t key, value_t value)
{
    node_t *n = (node_t *)malloc(sizeof(node_t) + level * sizeof(node_t*));
if(n == NULL) return NULL;
    n->key = key;
    n->value = value;
return n;
}
list_t* slCreate(void)
{
    list_t *l = (list_t *)malloc(sizeof(list_t));
int i = 0;
if(l == NULL) return NULL;
    l->length = 0;
    l->level = 0;
    l->header = slCreateNode(MAX_LEVEL - 1, 0, 0);
for(i = 0; i < MAX_LEVEL; i++)
    {
        l->header->forward[i] = NULL;
    }
return l;
}
int randomLevel(void)
{
int level = 1;
while ((rand()&0xFFFF) < (SKIPLIST_P * 0xFFFF))
        level += 1;
return (level<MAX_LEVEL) ? level : MAX_LEVEL;
}
value_t* slSearch(list_t *list, key_t key)
{
    node_t *p = list->header;
int i;
for(i = list->level - 1; i >= 0; i--)
    {
while(p->forward[i] && (p->forward[i]->key <= key)){
if(p->forward[i]->key == key){
return &p->forward[i]->value;
            }
            p = p->forward[i];
        }
    }
return NULL;
}
int slDelete(list_t *list, key_t key)
{
    node_t *update[MAX_LEVEL];
    node_t *p = list->header;
    node_t *last = NULL;
int i = 0;
for(i = list->level - 1; i >= 0; i--){
while((last = p->forward[i]) && (last->key < key)){
            p = last;
        }
        update[i] = p;
    }
if(last && last->key == key){
for(i = 0; i < list->level; i++){
if(update[i]->forward[i] == last){
                update[i]->forward[i] = last->forward[i];
            }
        }
        free(last);
for(i = list->level - 1; i >= 0; i--){
if(list->header->forward[i] == NULL){
                list->level--;
            }
        }
        list->length--;
    }else{
return -1;
    }
return 0;
}
int slInsert(list_t *list, key_t key, value_t value)
{
    node_t *update[MAX_LEVEL];
    node_t *p, *node = NULL;
int level, i;
    p = list->header;
for(i = list->level - 1; i >= 0; i--){
while((node = p->forward[i]) && (node->key < key)){
            p = node;
        }
        update[i] = p;
    }
if(node && node->key == key){
        node->value = value;
return 0;
    }
    level = randomLevel();
if (level > list->level)
    {
for(i = list->level; i < level; i++){
            update[i] = list->header;
        }
        list->level = level;
    }
    node = slCreateNode(level, key, value);
for(i = 0; i < level; i++){
        node->forward[i] = update[i]->forward[i];
        update[i]->forward[i] = node;
    }
    list->length++;
return 0;
}
int main(int argc, char **argv)
{
    list_t *list = slCreate();
    node_t *p = NULL;
    value_t *val = NULL;
//插入
for(int i = 1; i <= 15; i++){
        slInsert(list, i, i*10);
    }
//删除
if(slDelete(list, 12) == -1){
        printf("delete:not found\n");
    }else{
        printf("delete:delete success\n");
    }
//查找
    val = slSearch(list, 1);
if(val == NULL){
        printf("search:not found\n");
    }else{
        printf("search:%d\n", *val);
    }
//遍历
    p = list->header->forward[0];
for(int i = 0; i < list->length; i++){
        printf("%d,%d\n", p->key, p->value);
        p = p->forward[0];
    }
    getchar();
return 0;
}




http://www.cxphp.com/?p=234(Redis中c语言的实现)

http://imtinx.iteye.com/blog/1291165

http://kenby.iteye.com/blog/1187303

http://bbs.bccn.net/thread-228556-1-1.html

http://blog.csdn.net/xuqianghit/article/details/6948554(leveldb源码)



二叉树是我们都非常熟悉的一种数据结构。它支持包括查找、插入、删除等一系列的操作。但它有一个致命的弱点,就是当数据的随机性不够时,会导致其树型结构的不平衡,从而直接影响到算法的效率。

跳跃表(Skip List)是1987年才诞生的一种崭新的数据结构,它在进行查找、插入、删除等操作时的期望时间复杂度均为O(logn),有着近乎替代平衡树的本领。而且最重要的一点,就是它的编程复杂度较同类的AVL树,红黑树等要低得多,这使得其无论是在理解还是在推广性上,都有着十分明显的优势。


跳跃表由多条链构成(S0,S1,S2 ……,Sh),且满足如下三个条件:

(1)每条链必须包含两个特殊元素:+∞ 和 -∞

(2)S0包含所有的元素,并且所有链中的元素按照升序排列。

(3)每条链中的元素集合必须包含于序数较小的链的元素集合,即:



【基本操作】


在对跳跃表有一个初步的认识以后,我们来看一下基于它的几个最基本的操作


一、查找

目的:在跳跃表中查找一个元素x

在跳跃表中查找一个元素x,按照如下几个步骤进行:

i)从最上层的链(Sh)的开头开始

ii)假设当前位置为p,它向右指向的节点为q(p与q不一定相邻),且q的值为y。将y与x作比较

(1) x=y     输出查询成功及相关信息

(2) x>y     从p向右移动到q的位置

(3) x<y     从p向下移动一格


iii)    如果当前位置在最底层的链中(S0),且还要往下移动的话,则输出查询失败

二、插入

目的:向跳跃表中插入一个元素x

首先明确,向跳跃表中插入一个元素,相当于在表中插入一列从S0中某一位置出发向上的连续一段元素。有两个参数需要确定,即插入列的位置以及它的“高度”。

关于插入的位置,我们先利用跳跃表的查找功能,找到比x小的最大的数y。根据跳跃表中所有链均是递增序列的原则,x必然就插在y的后面。

而插入列的“高度”较前者来说显得更加重要,也更加难以确定。由于它的不确定性,使得不同的决策可能会导致截然不同的算法效率。为了使插入数据之后,保持该数据结构进行各种操作均为O(logn)复杂度的性质,我们引入随机化算法(Randomized Algorithms)。

我们定义一个随机决策模块,它的大致内容如下:

・产生一个0到1的随机数r                   r ← random()

・如果r小于一个常数p,则执行方案A,       if  r<p then do A

否则,执行方案B                                   else do B


初始时列高为1。插入元素时,不停地执行随机决策模块。如果要求执行的是A操作,则将列的高度加1,并且继续反复执行随机决策模块。直到第i次,模块要求执行的是B操作,我们结束决策,并向跳跃表中插入一个高度为i的列。

性质1:    根据上述决策方法,该列的高度大于等于k的概率为pk-1。

此处有一个地方需要注意,如果得到的i比当前跳跃表的高度h还要大的话,则需要增加新的链,使得跳跃表仍满足先前所提到的条件。

我们来看一个例子:

假设当前我们要插入元素“40”,且在执行了随机决策模块后得到高度为4

・步骤一:找到表中比40小的最大的数,确定插入位置



・步骤二:插入高度为4的列,并维护跳跃表的结构


三、删除

目的:从跳跃表中删除一个元素x

删除操作分为以下三个步骤:

(1)在跳跃表中查找到这个元素的位置,如果未找到,则退出     *

(2)将该元素所在整列从表中删除                              *

(3)将多余的“空链”删除                                    *

所谓“记忆化”查找,就是在前一次查找的基础上进行进一步的查找。它可以利用前一次查找所得到的信息,取其中可以被当前查找所利用的部分。利用“记忆化”查找可以将一次查找的复杂度变为O(logk),其中k为此次与前一次两个被查找元素在跳跃表中位置的距离。

下面来看一下记忆化搜索的具体实现方法:

假设上一次操作我们查询的元素为i,此次操作我们欲查询的元素为j。我们用一个update数组来记录在查找i时,指针在每一层所“跳”到的最右边的位置。如图4.1中橘黄色的元素。(蓝色为路径上的其它元素)


在插入元素j时,分为两种情况:

(1)i<=j

从S0层开始向上遍历update数组中的元素,直到找到某个元素,它向右指向的元素大于等于j,并于此处开始新一轮对j的查找(与一般的查找过程相同)

(2)i>j

从S0层开始向上遍历update数组中的元素,直到找到某个元素小于等于j,并于此处开始新一轮对j的查找(与一般的查找过程相同)


图4.2十分详细地说明了在查找了i=37之后,继续查找j=15或53时的两种不同情况。






复杂度分析

一个数据结构的好坏大部分取决于它自身的空间复杂度以及基于它一系列操作的时间复杂度。跳跃表之所以被誉为几乎能够代替平衡树,其复杂度方面自然不会落后。我们来看一下跳跃表的相关复杂度:


空间复杂度: O(n)           (期望)

跳跃表高度: O(logn)        (期望)

相关操作的时间复杂度:

查找: O(logn)        (期望)

插入:  O(logn)        (期望)

删除: O(logn)        (期望)


之所以在每一项后面都加一个“期望”,是因为跳跃表的复杂度分析是基于概率论的。有可能会产生最坏情况,不过这种概率极其微小。

下面我们来一项一项分析。





一、 空间复杂度分析 O(n)

假设一共有n个元素。根据性质1,每个元素插入到第i层(Si)的概率为pi-1 ,则在第i层插入的期望元素个数为npi-1,跳跃表的元素期望个数为 ,当p取小于0.5的数时,次数总和小于2n。

所以总的空间复杂度为O(n)


二、跳跃表高度分析 O(logn)

根据性质1,每个元素插入到第i层(Si)的概率为pi ,则在第i层插入的期望元素个数为npi-1。

考虑一个特殊的层:第1+ 层。

层的元素期望个数为  = 1/n2,当n取较大数时,这个式子的值接近0,故跳跃表的高度为O(logn)级别的。


三、查找的时间复杂度分析 O(logn)

我们采用逆向分析的方法。假设我们现在在目标节点,想要走到跳跃表最左上方的开始节点。这条路径的长度,即可理解为查找的时间复杂度。

设当前在第i层第j列那个节点上。

i)如果第j列恰好只有i层(对应插入这个元素时第i次调用随机化模块时所产生的B决策,概率为1-p),则当前这个位置必然是从左方的某个节点向右跳过来的。

ii)如果第j列的层数大于i(对应插入这个元素时第i次调用随机化模块时所产生的A决策,概率为p),则当前这个位置必然是从上方跳下来的。(不可能从左方来,否则在以前就已经跳到当前节点上方的节点了,不会跳到当前节点左方的节点)

设C(k)为向上跳k层的期望步数(包括横向跳跃)

有:

C(0) = 0

C(k) = (1-p)(1+向左跳跃之后的步数)+p(1+向上跳跃之后的步数)

    = (1-p)(1+C(k)) + p(1+C(k-1))

C(k) = 1/p + C(k-1)

C(k) = k/p

而跳跃表的高度又是logn级别的,故查找的复杂度也为logn级别。


对于记忆化查找(Search with fingers)技术我们可以采用类似的方法分析,很容易得出它的复杂度是O(logk)的(其中k为此次与前一次两个被查找元素在跳跃表中位置的距离)。


四、插入与删除的时间复杂度分析 O(logn)

插入和删除都由查找和更新两部分构成。查找的时间复杂度为O(logn),更新部分的复杂度又与跳跃表的高度成正比,即也为O(logn)。

所以,插入和删除操作的时间复杂度都为O(logn)


五、实际测试效果

(1)不同的p对算法复杂度的影响



P


平均操作时间


平均列高


总结点数

每次查找跳跃次数

(平均值)

每次插入跳跃次数

(平均值)

每次删除跳跃次数

(平均值)

2/3

0.0024690 ms

3.004

91233

39.878

41.604

41.566

1/2

0.0020180 ms

1.995

60683

27.807

29.947

29.072

1/e

0.0019870 ms

1.584

47570

27.332

28.238

28.452

1/4

0.0021720 ms

1.330

40478

28.726

29.472

29.664

1/8

0.0026880 ms

1.144

34420

35.147

35.821

36.007

表1   进行106次随机操作后的统计结果


从表1中可见,当p取1/2和1/e的时候,时间效率比较高(为什么?)。而如果在实际应用中空间要求很严格的话,那就可以考虑取稍小一些的p,如1/4。


(2)运用“记忆化”查找 (Search with fingers) 的效果分析

所谓“记忆化”查找,就是在前一次查找的基础上进行进一步的查找。它可以利用前一次查找所得到的信息,取其中可以被当前查找所利用的部分。利用“记忆化”查找可以将一次查找的复杂度变为O(logk),其中k为此次与前一次两个被查找元素在跳跃表中位置的距离。


P


数据类型

平均操作时间(不运用记忆化查找)

平均操作时间(运用记忆化查找)

平均每次查找跳跃次数(不运用记忆化查找)

平均每次查找跳跃次数(运用记忆化查找)


0.5

随机(相邻被查找元素键值差的绝对值较大)


0.0020150 ms


0.0020790 ms


23.262


26.509


0.5

前后具备相关性(相邻被查找元素键值差的绝对值较小)


0.0008440 ms


0.0006880 ms


26.157


4.932

表1   进行106次相关操作后的统计结果

从表2中可见,当数据相邻被查找元素键值差绝对值较小的时候,我们运用“记忆化”查找的优势是很明显的,不过当数据随机化程度比较高的时候,“记忆化”查找不但不能提高效率,反而会因为跳跃次数过多而成为算法的瓶颈。

合理地利用此项优化,可以在特定的情况下将算法效率提升一个层次。



跳跃表的应用

高效率的相关操作和较低的编程复杂度使得跳跃表在实际应用中的范围十分广泛。尤其在那些编程时间特别紧张的情况下,高性价比的跳跃表很可能会成为你的得力助手。

能运用到跳跃表的地方很多,与其去翻陈年老题,不如来个趁热打铁,拿NOI2004第一试的第一题――郁闷的出纳员(Cashier)来“小试牛刀”吧。


例题一:NOI2004 Day1 郁闷的出纳员(Cashier)

[点击查看附录中的原题]

这道题解法的多样性给了我们一次对比的机会。用不同的算法和数据结构,在效率上会有怎样的差异呢?

首先定义几个变量

   R �C 工资的范围

   N �C 员工总数


我们来看一下每一种适用的算法和数据结构的简要描述和理论复杂度:

(1)线段树

简要描述:以工资为关键字构造线段树,并完成相关操作。

I命令时间复杂度:O(logR)

A命令时间复杂度:O(1)

S命令时间复杂度:O(logR)

F命令时间复杂度:O(logR)

(2)伸展树(Splay tree)

简要描述:以工资为关键字构造伸展树,并通过“旋转”完成相关操作。

I命令时间复杂度:O(logN)

A命令时间复杂度:O(1)

S命令时间复杂度:O(logN)

F命令时间复杂度:O(logN)

(3)跳跃表(Skip List)

简要描述:运用跳跃表数据结构完成相关操作。

I命令时间复杂度:O(logN)

A命令时间复杂度:O(1)

S命令时间复杂度:O(logN)

F命令时间复杂度:O(logN)


实际效果评测: (单位:秒)



Test1

Test2

Test3

Test4

Test5

Test6

Test7

Test8

Test9

Test10

线段树

0.000

0.000

0.000

0.031

0.062

0.094

0.109

0.203

0.265

0.250

伸展树

0.000

0.000

0.016

0.062

0.047

0.125

0.141

0.360

0.453

0.422

跳跃表

0.000

0.000

0.000

0.047

0.062

0.109

0.156

0.368

0.438

0.375


从结果来看,线段树这种经典的数据结构似乎占据着很大的优势。可有一点万万不能忽略,那就是线段树是基于键值构造的,它受到键值范围的约束。在本题中R的范围只有105级别,这在内存较宽裕的情况下还是可以接受的。但是如果问题要求的键值范围较大,或者根本就不是整数时,线段树可就很难适应了。这时候我们就不得不考虑伸展树、跳跃表这类基于元素构造的数据结构。而从实际测试结果看,跳跃表的效率并不比伸展树差。加上编程复杂度上的优势,跳跃表尽显出其简单高效的特点。


你可能感兴趣的:(skiplist)