前言
之前在研究LevelDB的时候听说了这个数据结构,后面发现Redis中也用这个数据结构实现有序集合zset,研究了一下发现特别简单并且非常容易实现,所以记录一下,毕竟18年的最后一次学习!:)。
之前看到有文章提到,像红黑树、B树这些数据结构,实现起来并不是这么简单,而SkipList结构和实现都特别简单,并且可以拥有和红黑树、B树接近的性能(是的,我就是被这段话安利的)。
辣鸡的笔记记录,所以有理解错误的地方,还请各位直接指出:)。
结构定义
考虑一个链表结构,如下图:
你会发现,无论你想找链表上哪个位置的元素,你都需要从链表的头部开始往后一个一个的进行遍历。
而跳表的思路是,在链表的基础上再建立一层或者多层索引也叫做level,来帮助你更快的找到想要的元素,加上了索引后的结构变成如下图:
那么这个时候我们再来考虑如果我们想找到元素9,那么按照这个结构的流程就会变成如下步骤。
我们会从顶层的索引开始寻找,我们要找的元素是9,第一层的第一个索引节点值是1,因为9比1大,所以跳到下一个索引节点上,值为5。然后再进行比较,9还是比8大,所以还是跳到下一个索引节点上。最后的一个索引节点值是11,比9大了,所以就在这个索引位置的下方往前找到元素9。用图表示这个流程的话就是如下:
如果按照正常的链表遍历流程找到9需要遍历前8个元素,而用跳表的这个结构只需要遍历4次就找到元素9。
当然跳表不止可以有一层索引,还可以拥有多层,拥有多层索引后的结构如下图:
那么按照结构,我们要插入一个元素7,前面的流程和搜索基本一致,先找到7元素要插入的位置,流程如下图:
我们要插入的元素为7,依旧是从顶层索引层开始搜索,沿着索引直到找下一个值比7大的节点。我们找到位置后插入元素7,结构变成如下图:
这个时候,使用随机数产生一个随机值,用这个随机数来决定7这个位置往上要更新多少层,如果大于现有层数则要添加新层,假如我们的随机数产生的值是6,那么我们最后的结构就会变成这样:
绿色为新建立的层或者更新的层,添加和删除其实都是链表操作,所以删除和添加基本原理差不多,所以这里不描述了。
C语言基本实现
其实说出来你们不信,Redis其实包含大量的数据结构实现,并且实现的非常精简,所以这里就套用Redis的跳表结构体定义并稍加修改,C跳表结构体定义如下:
#define SKIPLIST_MAX_LEVEL 64
typedef struct skipListNode {
int key;
int value;
struct skipListLevel {
struct skipListNode *forward;
} level[];
} skipListNode;
typedef struct skipList {
int level;
struct skipListNode *header;
} skipList;
这个结构体里面的level字段,就相当于上图的层的一竖列,所以大部分的跳表初始化代码也可以套用大部分的Redis跳表实现,这里只考虑初始化和搜索及插入操作,删除操作原理类似插入,所以不重复描述,定义一组操作方法:
skipListNode *slCreateNode(int level, int key, int value);
skipList *slCreate(void);
skipListNode *slInsert(skipList *sl, int key, int value);
int slRandomLevel(void);
skipListNode *slSearch(skipList *sl, int key);
创建一个包含制定数量层的跳表节点:
skipListNode *slCreateNode(int level, int key, int value)
{
skipListNode *sn = malloc(sizeof(*sn) + level * sizeof(struct skipListLevel));
sn->key = key;
sn->value = value;
return sn;
}
创建一个跳表结构:
skipList *slCreate(void)
{
int i;
skipList *sl;
sl = malloc(sizeof(*sl));
sl->level = 1;
sl->header = slCreateNode(SKIPLIST_MAX_LEVEL, 0, 0);
for (i = 0; i < SKIPLIST_MAX_LEVEL; i++) {
sl->header->level[i].forward = NULL;
}
return sl;
}
跳表中的第一层就是最完整的链表结构,可以参考之前上面的图,所以初始化就默认初始化level字段为1。
产生一个随机数用来确定插入后要更新或者新增的层数
int slRandomLevel(void)
{
int level = 1;
while ((rand() & 0xFFFF) < (0.5 * 0xFFFF))
level += 1;
return (level < SKIPLIST_MAX_LEVEL) ? level : SKIPLIST_MAX_LEVEL;
}
这里产生的随机数是会小于最大可以创建的层数量(SKIPLIST_MAX_LEVEL)。
插入一个key->value进入跳表:
skipListNode *slInsert(skipList *sl, int key, int value)
{
skipListNode *update[SKIPLIST_MAX_LEVEL];
skipListNode *sn;
sn = sl->header;
int i, level;
for (i = sl->level - 1; i >= 0; i--) {
while(sn->level[i].forward && sn->level[i].forward->key < key)
sn = sn->level[i].forward;
update[i] = sn;
}
level = slRandomLevel();
if (level > sl->level) {
for (i = sl->level; i < level; i++) {
update[i] = sl->header;
}
sl->level = level;
}
sn = slCreateNode(level, key, value);
for (i = 0; i < level; i++) {
sn->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = sn;
}
return sn;
}
和之前说的一样,和遍历流程一样先找到要插入的节点位置,然后顺便记录下遍历路径上的节点,这些都是需要进行链表更新的,这些节点全部存放在update数组中。
然后使用slRandomLevel确定要更新或者新增的层数,如果随机的层数大于现有链表的层数,则新增层,并且更新链表现在的层数。
最后创建节点,并进行了常规的链表添加操作,删除的基本过程差不多,就不重复描述了。
按照key寻找value:
skipListNode *slSearch(skipList *sl, int key)
{
skipListNode *sn;
int i;
sn = sl->header;
for (i = sl->level - 1; i >= 0; i--) {
while (sn->level[i].forward && sn->level[i].forward->key < key)
sn = sn->level[i].forward;
}
sn = sn->level[0].forward;
if (sn && key == sn->key) {
return sn;
} else {
return NULL;
}
}
搜索过程就更加简单,就是从跳表的顶层索引开始遍历,直到找到下一个值比需要搜索的key大,就找到了位置。
那么用起来的效果就是这样:
int main() {
skipList *sl = slCreate();
slInsert(sl, 2, 10);
slInsert(sl, 3, 11);
int key2 = slSearch(sl, 2)->value;
int key3 = slSearch(sl, 3)->value;
printf("key2:%d\n", key2);
printf("key3:%d\n", key3);
return 0;
}
输出
key2:10
key3:11
总结
跳表的整体思路就是通过添加索引层来加快遍历速度,本质也是一个可以二分查找的有序链表,或者说也是一种树结构。并且通过我上面贴的代码应该也能感受到跳表是一个非常简单并且非常容易实现的数据结构,并且性能也非常接近其它树结构,也算是一种非常简单实用的数据结构了。
参考资料:
https://www.cnblogs.com/a8457013/p/8251967.html
https://www.youtube.com/watch?v=ypod5jeYzAU
http://www.cnblogs.com/liuhao/archive/2012/07/26/2610218.html
https://en.wikipedia.org/wiki/Skip_list
https://brilliant.org/wiki/skip-lists/