SkipList (跳跃表)解析及其实现

导言

伊苏系列是电子游戏(音乐)公司Falcom所制作的一套动作角色扮演游戏(ARPG)系列,该系列的剧情围绕着冒险家——亚特鲁·克里斯汀的冒险故事展开。伊苏·起源是这个系列中我最喜欢的作品之一,喜欢的原因之一是这部作品是以一座魔物修建的垂直耸立、直通天际的未完成建筑——达姆之塔为舞台而展开的,游戏的流程就是从塔底一路向上推动剧情,直到塔顶与达雷斯和达姆决战,这真是独特而刺激的游戏体验啊!

不过,我的主要目的并不是向你推荐这部游戏,而是想通过达姆之塔这个场景做个文章。达姆之塔的游戏场景有上百个,想要从塔底到达塔顶,就只能一路冲上去(这不是废话吗),那么问题来了,玩 rpg 类的游戏总有需要跑地图搜集道具、推动剧情的时候,如果要我一个一个场景地找过去,未免也太枯燥了点吧。还好在我们进入达姆之塔之前,会获得一个名叫水晶的道具,通过这个道具我们就可以在已经激活的存档点进行传送。虽然不能直接传送到我想要去的场景,但是我可以过水晶传送到分散在达姆之塔的各个存档点处,已经比一个一个找场景快了不少了。

查找结点的效率如何提升?

单链表我们很熟悉,插入和删除操作比顺序表快,是个动态的结构,不适合随机访问。当我们想要查找单链表中的某个元素的时候,即使这个表是有序表,我们也不能利用二分查找降低时间复杂度,还是得一个一个遍历结点,如图所示,当我们想要查找元素 24 时,也得从头结点开始一个一个结点比较过去,总共需要比较 5 次才能找到。

如果我们真的需要在链表中频繁地查找,这种方式绝对不可取,因为它的效率实在是太低,我们希望的是查找操作的时间复杂度能够降到二分查找的水平,即 O(㏒n)。用什么方法实现?
回忆一下我提过的达姆之塔,我们把达姆之塔的上百个场景抽象成一个线性表,每个场景都是按照一定的顺序才能到达,所以我们把每一个场景都当成一个结点,这时因为一个一个场景地跑很慢,所以我可以通过已经激活的存档点通过道具传送来节省时间,这不就是我们提高链表查找速度的好方法吗?我们找猫画虎,选择这个链表中的一些结点作为跳跃点,在单链表的基础上开辟一条捷径出来,如图所示。这个时候我们如果要寻找元素 24,我们只需要查找 3 次就行了,换个数字看看,假设我要查找元素 34,按照原来的做法需要查找 6 次,但是有了一条捷径之后我们只需要 4 次就能找到,相比之前这已经有进步了。

不过,是谁规定捷径只有一条的,如果把我们开辟的这个捷径也当做是单链表,我们在这个单链表上继续开辟捷径可否?当然是可行的,如图所示。这个时候查找元素 24 仅需要 2 次,查找元素 34 仅需要 3 次,我们再来看个例子,例如查找元素 42,单链表需要查找 8 次,开辟一条捷径需要 5 次,开辟两条捷径时需要 4 次,效率已经被再一次提高了。

别忘了我们现在举的例子数据量是很少的,如果我有成百上千的结点呢?我们甚至可以再往上继续开辟捷径,数据规模一增大,效率的提升是太明显了,而且这么操作的思想与二分法非常接近。

什么是跳跃表?

跳跃表(skiplist)由大牛 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出,跳跃表以有序的方式在层次化的链表中保存元素,效率和平衡树媲美:查找、删除、添加等操作都可以在对数期望时间下完成。跳跃表体现了“空间换时间”的思想,从本质上来说,跳跃表是在单链表的基础上在选取部分结点添加索引,这些索引在逻辑关系上构成了一个新的线性表,并且索引的层数可以叠加,生成二级索引、三级索引、多级索引,以实现对结点的跳跃查找的功能。与二分查找类似,跳跃表能够在 O(㏒n)的时间复杂度之下完成查找,与红黑树等数据结构查找的时间复杂度相同,但是相比之下,跳跃表能够更好的支持并发操作,而且实现这样的结构比红黑树等数据结构要简单、直观许多。

跳跃表必须是完美的?

但是现在问题来了,上文我举的例子是一个很完美的跳跃表,它严格地按照二分法的思想建立了捷径。从理论上讲,单个跳跃表的层数按照严格二分的条件建立,层数就应该是 ㏒n 层(以2为底,n 为结点个数),但是在实际操作中,我们的数据会刚刚好是 2 的 n 次方吗?如果不是这么刚好的数据,没办法严格地二分,我要怎么开辟捷径呢?如果我对这个跳跃表进行插入或删除操作,破坏了严格地二分结构,又该怎么办?如果我们要强行解决这些问题,那就又要引入一大堆七七八八又难以实现的规则了,只怕这么做比建一棵树更为困难了。
既然我们没有办法很好地严格二分,也没有很好的规则去描述这些问题的处理方式,那么我们就不使用严格二分的方法就行了啊,不要一条绝路走到黑嘛!分析一下我们的目的,我们希望的事情是让查找操作的效率提升,如果我只开辟一条捷径,效率也确确实实是提升了的,如果能继续开辟捷径,如果最后我们能达到严格地二分,效率就会被提升,那也就是说我们并不是为了要实现二分,而是通过不断地努力去尽量地实现二分。

如果你还是不明白,那我举个比较好理解的例子吧。大家都知道,抛一枚硬币,花面朝上的可能性为 0.5,字面朝上的可能性也是 0.5,但是如果要证明这件事实,应该怎么证明呢?如果要严格地去证明,那就必须不断丢硬币,然后统计每一次是哪一面朝上,只有无论抛多少次硬币,统计出来的数据有一半是花面朝上,一半都是字面朝上,才能证明这一点,但是我们知道这种情况只是有可能发生,往往都不能严格地出现这样的统计数据。我们是怎么得到抛硬币硬币花面朝上的概率为 0.5,字面朝上的概率为 0.5的呢?请打开课本《概率论与数理统计》:

我们无法直接证明这个事实,但是我们可以通过“频率的稳定性”得到启发,提出了概率的定义,进而确定了事件的概率。从我举的例子里,我们不仅得到了启发,更是找到了解决问题的方法。也就是说,我们找到某种方式来实现捷径的开辟,这种方式在统计学的角度来说,可以往严格二分的情况趋近,在理论上实现 O(㏒n) 的时间复杂度。

抛硬币实验

模拟建表

其实在我刚刚举的例子中,我们已经解决了理论上趋近二分的问题了,思考一下,我们希望的严格二分,实质上就是任选跳跃表中的两层,上层的结点个数是下层结点个数的 1/2,也就是说下层的每个结点同时也是上层的结点的概率是 1/2,这不就和抛硬币花/字面朝上的概率是一致的嘛!所以我们可以利用算法模拟抛硬币实验,以此决定一个结点被多少层的表所占有。
别急,我们模拟一次建表,首先我们先规定当抛硬币是花面朝上时该层结点同时被上一层占有,该跳跃表的层数上限为 4 层。先造个头结点,然后插入表头结点,如图所示:

接下来插入第 2 个结点,并进行抛硬币实验,抛出字面,说明该结点不被上一层占有,插入结束:

插入第 3 个结点,并进行抛硬币实验,抛出花面,说明该结点被上一层占有,进行操作:

由于此时层数由 1 层变为了 2 层,因此需要开辟一层新的链表,即:

此时插入还没有结束,继续进行抛硬币实验,抛出字面,说明该结点不被上一层占有,插入结束。
接下来插入第 4 个结点,并进行抛硬币实验,抛出字面,说明该结点不被上一层占有,插入结束:

接下来插入第 5 个结点,并进行抛硬币实验,抛出字面,说明该结点不被上一层占有,插入结束。
插入第 6 个结点,并进行抛硬币实验,抛出花面,说明该结点被上一层占有,由于第二层捷径已经开辟,所以进行操作:

此时插入还没有结束,继续进行抛硬币实验,抛出花面,说明该结点被上一层占有,由于此时层数由 2 层变为了 3 层,因此需要开辟一层新的链表,即:

好了,可以就此打住了,相信我们模拟了这么多步,你应该很清楚我们如何用抛硬币的思想来决定一个结点被表中几层同时占有的吧。当数据量足够大,抛硬币实验次数足够多,最终建立的跳跃表就会趋近于二分的情况了。

操作解析

接下来我们写一个函数模拟抛硬币实验,并根据抛硬币实验的结果决定单个结点被多少层共同占有。由于生成一个随机数,这个随机数是奇\偶数的概率也是 1/2,所以用随机数来描述抛硬币实验的实现。

伪代码

代码实现

int tossCoinExperiment()	//抛硬币实验
{
    int level = 1;    //初始化层数为 1

    while (rand() % 2 != 0)    //随机数模拟抛硬币实验
    {
	if (level >= MAXLEVEL)
	{
	    break;    //层数已超过预定最大规模,结束实验
	}
	level++;    //实验结果为正面,层数加 1
    }
    return level;
}

跳跃表的结构体定义

跳跃表表头结构体定义

结构体包含两个成员,分别是该跳跃表的层数,便于我们能够从最高层开始查找,还有一个是表头的后继,连接跳跃表的主体部分,同时该跳跃表的最大层数需要预设完毕。

typedef struct skipList
{
    int level;    //跳跃表的规模,即层数
    Node* head;    //存储 skipList 的后继,即头结点
} skipList;
#define MAXLEVEL 5    //限制跳跃表的规模,需要事前估计,不能让跳跃表的层数无限增生

跳跃表结点结构体定义

我们在实际需要用跳跃表存储的数据往往是没有一种规律来描述顺序的,因此此处效仿 python 的字典结构和 C++ 的 STL 中的 Map 容器,用“键”来作为索引,用有序的数列来充当,作为我们查找的标准,“值”的部分存储我们需要的数据即可,“键”和“值”的数据类型可以修改。

typedef int keyType;
typedef char valueType;
typedef struct node
{
    keyType key;	// 即“键”,起到索引作用
    valueType value;	// 即“值”,用于存储数据
    struct node* next[1];	// 后继指针数组,柔性数组
} Node;

newNode() 方法

柔性数组

数组大小待定的数组,在 C/C++ 中结构体的最后一个元素可以是大小未知的数组,也就是说这个数组可以没有长度,所以柔性数组常出现与结构体中。柔性数组可以满足结构体长度边长的需求,能够解决使用数组时内存的冗余和数组的越界问题。
使用方法是在一个结构体的最后,申明一个长度为空的数组,对于编译器来说,此时长度为 0 的数组并不占用空间,因为数组名本身只是一个偏移量,数组名这个符号本身代表了一个不可修改的地址常量,但对于这个数组的大小,我们可以在后续操作中进行动态分配,对于编译器而言,数组名仅仅是一个符号,它不会占用任何空间,它在结构体中,只是代表了一个偏移量,代表一个不可修改的地址常量!对于柔性数组的这个特点,很容易构造出变成结构体,如缓冲区,数据包等。
为什么在这里提柔性数组呢?一般我们使用数组为了防止越界,都会将数组的长度开得大一些,但是空闲的空间太多就会造成空间上的浪费。而我们一个结点占有的层数是按照一次抛硬币实验的结果来确定的,是一个不确定的量,为了不造成空间的浪费,我们使用了柔性数组来描述结点与层数的关系。如果你还不理解,可以看下方参考资料的相关连接进行进一步学习。

给柔性数组分配空间

为了描述结点的后继,我们在单链表一般是加入一个结点成员,但是由于结点占有的层数由抛硬币实验决定,因此一个结点可能有多个后继。所以这里我们选择的是柔性数组,方便我们动态分配足够的空间,并且不浪费,一个结点需要的空间为这个结点本身和柔性数组的元素个数个单位结点空间。为了配合对柔性数组的动态内存分配,我们需要另外结合 malloc 函数写一个方法,供后续函数调用。

#define newNode(n)((Node*)malloc(sizeof(Node) + n * sizeof(Node*)));    //定义一个方法 newNode() 用于给柔性数组分配空间

跳跃表的建立与销毁

建立跳跃表表头操作

操作解析

表头的成员为层数和后继结点,层数表示了该跳跃表为几层跳跃表,层数的最大值已提前预设,需要初始化。此处建议配一个 MAXLEVEL(暂定为3) 层的头结点,方便修改跳跃表的最大层数和结点之间的逻辑关系。
由于 MAXLEVEL 是个定值,因此虽然有个循环结构,建立表头的时间复杂度为 O(1)。

伪代码

代码实现

skipList* createList()    //建立 SkipList
{
    skipList* a_list = new skipList;	//动态分配空间
    if (a_list == NULL)    //判空操作
    {
	return NULL;
    }
    a_list->level = 0;    //初始化层数为0

    Node* head = createNode(MAXLEVEL - 1, 0, 0);	//建立一个头结点
    if (head == NULL)	//判空操作
    {
	delete a_list;
	return NULL;
    }
	
    a_list->head = head;	//修改 SkipList 的后继为头结点
    for (int i = 0; i < MAXLEVEL; i++)
    {
	head->next[i] = NULL;	//初始化头结点的每一层的后继都是 NULL
    }

    srand(time(0));    //预备抛硬币实验
    return a_list;
}

创建单个结点操作

操作解析

只需要在分配空间之后,将对应的“键”和“值”赋值进结点即可,不要忘了判断内存是否分配成功。时间复杂度为 O(1)。

代码实现

Node* createNode(int level, keyType key, valueType value)	//创建单个结点
{
    Node* ptr = newNode(level);    //根据限定层数,给柔性数组分配空间

    if (ptr == NULL)	//判空操作
    {
	return NULL;
    }

    ptr->key = key;    //将“键”赋值给新结点
    ptr->value = value;    //将“值”赋值给新结点
    return ptr;
}

销毁操作

操作解析

同单链表销毁操作,由于最底层以上的层数都是我们在对应的地方通过柔性数组用多个指针所建立的,因此只需要对最底层链表操作即可,即图中用红色方框括起来的部分,释放完所有结点之后,不要忘了把表头的空间一并释放。时间复杂度 O(n)。

代码实现

void deleteSkipList(skipList* list)    //销毁 skipList
{
    Node* head = list->head;
    Node* ptr;

    if (list == NULL)    //空表操作
    {
	return;
    }

    while (head != NULL && head->next[0] != NULL)    //在最底层依次释放各个结点
    {
	ptr = head->next[0];
	head->next[0] = head->next[0]->next[0];
	delete ptr;
    }
    free(list);    //释放 skipList
}

插入操作

操作解析

函数实现向跳跃表中插入一个“键”为 key,“值”为 value 的结点。由于我们进行插入操作时,插入结点的层数先要确定因此需要进行抛硬币实验确定占有层数。
由于新结点根据占有的层数不同,它的后继可能有多个结点,因此需要用一个指针通过“键”进行试探,找到对应的“键”的所有后继结点,在创建结点之后依次修改结点每一层的后继,不要忘了给结点判空。在插入操作时,“键”可能已经存在,此时可以直接覆盖“值”就行了,也可以让用户决定,可以适当发挥。

模拟插入操作

我们还是举个例子吧,假设如图所示跳跃表,我们要往里面插入一个被 3 层共同占有的结点 16。

首先我们需要用一个试探指针找到需要插入的结点的前驱,即用红色的框框出来的结点。需要注意的是,由于当前的跳跃表只有 2 层,而新结点被 3 层占有,因此新结点在第 3 层的前驱就是头结点。
接下来的操作与单链表相同,只是需要同时对每一层都操作。如图所示,红色箭头表示结点之间需要切断的逻辑联系,蓝色的箭头表示插入操作新建立的联系。

插入的最终效果应该是如图所示的。

时间复杂度

虽然在代码中有一个嵌套循环操作,但这个循环实际上执行的就是查找操作,查找到需要插入的位置,由于到下一层链表查找的时候,可以直接从上层链表跳跃到对应结点,无需从头遍历,因此这个嵌套循环结构时间复杂度为 O(㏒n)。由于代码中各个结构独立运行,所以用加法描述,且时间复杂度最大的部分为就是查找操作(由于跳跃表的层数上限是预设好的,因此相关操作时间复杂度是常数阶),所以时间复杂度为 O(㏒n)。

伪代码

代码实现

bool insertNode(skipList* list, keyType key, valueType value)    //插入操作
{
    Node* successor[MAXLEVEL];    //保存插入位置在每一层的后继
    Node* ptr = NULL;    //前驱试探指针
    Node* pre = list->head;    //拷贝插入的结点位置
    int level;

    for (int i = list->level - 1; 0 <= i; i--)    //通过 ptr 指针的试探,找到每一层的插入位置
    {
	while ((ptr = pre->next[i]) && ptr->key < key)    //单层遍历之后自动跳到下一层,且不需要从头开始
	{
		pre = ptr;    //单层遍历,直到找到插入位置的前驱
	}
	successor[i] = pre;    //拷贝插入的位置
    }

    if (ptr != NULL && ptr->key == key)    //如果对应的“键”存在,修改对应的“值”并返回
    {
	ptr->value = value;
	return true;
    }

    level = tossCoinExperiment();    //抛硬币实验,确定插入的结点层数
    if (level > list->level)    //如果新结点的层数超过现有规模,先修改 skipList 的规模
    {
	for (int i = list->level; level > i; i++)
	{                 //直接将前面无法试探层的后继,改为头结点
	    successor[i] = list->head;  
	}
	list->level = level;    //更新 skipList 的层数
    }

    ptr = createNode(level, key, value);    //建立新结点
    if (ptr == NULL)    //判空操作
    {
	return false;
    }

    for (int i = level - 1; 0 <= i; i--)    //每一层插入新结点
    {			     //结点的插入操作,与单链表相同
	ptr->next[i] = successor[i]->next[i];
	successor[i]->next[i] = ptr;
    }
    return true;
}

删除操作

操作解析

由于需要删除的结点在每一层的前驱的后继都会因删除操作而改变,所以和插入操作相同,需要一个试探指针找到删除结点在每一层的前驱的后继,并拷贝。接着需要修改删除结点在每一层的前驱的后继为删除结点在每一层的后继,保证跳跃表的每一层的逻辑顺序仍然是能够正确描述。

模拟删除操作

我们拥有如图所示的跳跃表,现在我要删除结点 16,由于该结点同时被 3 个层所共有,因此我们需要找到 3 个层该结点的前驱。

如图 3 个红色方框所在的结点,需要注意的是,跳跃表第 3 层有且仅有结点 16,因此它的前驱就是头结点。
删除操作与单链表相同,还是一样,我们需要把每一层的逻辑关系都进行修改。

如图所示,红色箭头表示需要切断的逻辑联系,蓝色表示需要修改的逻辑联系。

时间复杂度

同插入操作,为 O(㏒n)。

伪代码

代码实现

bool deleteNode(skipList* list, keyType key)    //删除结点
{
    Node* successor[MAXLEVEL];    //保存插入位置在每一层的后继
    Node* ptr = NULL;
    Node* pre = list->head;    //前驱试探指针

    for (int i = list->level - 1; 0 <= i; i--)    //通过 pre 指针的试探,找到每一层的删除位置
    {
	while ((ptr = pre->next[i]) && key > ptr->key)
	{
	    pre = ptr;    //单层遍历,直到找到删除位置的前驱
	}
	successor[i] = pre;    //拷贝删除位置
    }

    if (ptr == NULL || (ptr != NULL && ptr->key != key))
    {
	return false;    //判断要删除的结点是否存在
    }

    for (int i = list->level - 1; i >= 0; i--)    //修改每一层删除结点的前驱的后继
    {
	if (successor[i]->next[i] == ptr)
	{
	    successor[i]->next[i] = ptr->next[i];    //删除操作同单链表
	    if (list->head->next[i] == NULL)    //如果被删除的结点在最上层,且有且只有该节点
	    {
                list->level--;    //skipList 的层数减 1
	    }
	}
    }
    delete ptr;    //释放空间
    return true;
}

查找操作

终于到了跳跃表的拿手绝活了。

操作解析

跳跃表只需要从最上层开始遍历,由于每一层的链表都是有序的,因此当查找的“键”不存在于某一层中的时候,只需要在比查找目标的“键”要大的结点向下一次跳跃即可,重复操作,直至跳跃到最底层的链表。
举个例子吧,假设有如图所示的跳跃表,我们需要找到结点 8,那么查找的路径如图中蓝色箭头所示。

伪代码

代码实现

valueType* searchNode(skipList* list, keyType key)    //查找操作
{
    Node* pre = list->head;
    Node* ptr = NULL;
	
    for (int i = list->level - 1; i >= 0; i--)    //从最上层开始向下跳跃
    {
	while ((ptr = pre->next[i]) && key > ptr->key)
	{
	    pre = ptr;    //找到一层中最接近被查找元素的“键”
	}
	if (ptr != NULL && ptr->key == key)    //如果“建”相等,结束查找
	{
	    return &(ptr->value);
	}
    }
    return NULL;    //没找到,返回 NULL
}

时间复杂度分析

由于查找操作的时间复杂度分析涉及到概率学的知识,我功力有限,因此转载一些资料供参考。



转载自Skip List(跳跃表)原理详解与实现

简单应用

跳跃字母表

实现一个最大层数不超过 7 层的跳跃表,跳跃表的“键”为首相是 1,公差是 1 的等差数列,“值”为 26 个大写英文字母。要求用插入操作完成建表,并且能够实现对第 i 个字母的查找。最后销毁跳跃表。

代码实现

此处直接把上述的所有操作的代码封装好,然后写个 main 函数来尝试着建立跳跃表吧。

运行结果

参考资料

《概率论与数理统计 第四版》—— 浙江大学,盛骤 等编著
跳跃表的原理及实现
skiplist 跳跃表详解及其编程实现
C语言柔性数组讲解
深入浅出C语言中的柔性数组
漫画算法:什么是跳跃表?
跳跃表原理
怎样用通俗易懂的文字解释正态分布及其意义?
以后有面试官问你「跳跃表」,你就把这篇文章扔给他
跳跃表详解

posted @ 2020-03-08 01:50  乌漆WhiteMoon  阅读(1395)  评论(2编辑  收藏  举报