引言
跳表是一种用于数据查找的数据结构,它虽然不是常见的数据结构,但是在Redis、Hbase等中间件中却被广泛使用,是一款性能比较优秀的底层数据结构,可以支持高速的数据查找、删除以及插入。这种数据结构是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》,以下是论文中关于跳表的描述。
Skip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.
跳表结构
在说明跳表数据结构特点之前,我们先来回顾下链表的数据结构。如下图所示:
在链表的数据结构中,如果我们要检索某个数据,就需要从链表的头节点开始进行遍历,直到找到需要获取的节点。如果运气不好的话,目标节点在末尾的话检索的次数随着链表长度的增加而增加。因此链表的数据查找的时间复杂度为O(n),时间复杂度比较高,那么有没有办法进行优化呢?
有一点算法基础的同学都知道,在数据检索的算法当中有一个二分查找法,它的大致思想就是在有序的列表中,将数据一分为二,左边的数据都是比比较点小的,右边都是比比较点大的。这样当进行数据比较检索的时候,先和这个中间的基准点数据进行比较,比基准点小的就往右边进行检索,比基准点大的话,就往右边进行检索,通过二分查找可以有效提升数据检索的效率。那么这种二分查找的思想可不可以运用到链表的数据结构上来进行检索效率优化呢?
通过中间节点进行比较,这样可以快速定位出目标值大致的区间,避免无效的数据检索过程,从而可以降借助于二分查找的思想,如果我们在检索数据的时候,不老老实实的从链表的头节点一个一个进行比较,而是减少检索数据所需要的步骤,达到降低时间复杂度的目的。
基于以上的分析,大神想出了在原始的链表数据之上,增加一层链表索引,增加的链表索引是对原始链表的数据抽取。如下图所示,我们检索数字27,不再是一个一个链表的进行检索比较了而是先通过L1层的索引进行初步定位。先和2比较,比2大则直接与13比较,比13大则继续与24 比较,比24大继续与33比较,结果比33小,那么进入L0层比较最终找到目标值27。如果在L1层之上再增加一级索引Level2 2-》24,那么首先直接和2比较,比2大直接跳到24节点。
通过上述检索过程可以看得出来,通过增加了两层索引之后,数据检索的次数变少了,对应的检索效率明显提升,所以这种多层索引的链表结构就是跳表。我们可以看得出来,跳表是一种以通过空间换时间的数据结构,即通过增加了多层的索引空间减少时间数据检索的时间。
复杂度分析
在上文中我们提到,对于单链表来说它的时间复杂度是O(n)。那么增加了多级索引之后的跳表,它的时间复杂度是多少呢?在分析跳表的时间复杂度之前,我们先来分析下对于一个长度为n的链表来说,它的多级索引应该设置为多少比较合适。
假设我们在原始的链表中每两个节点提取出一个节点作为一级索引的节点的话,那么Level1层就有n/2个节点,而Level2层的索引就有个节点,那么以此类推,第x层索引的节点个数就是n/个节点。
我们再假设最高层级为m,对应的节点个数为2,那么我们可以获得这样的等式,那么我们可以得到最高层的m值为m=log2n-1,结合最初始的链表,整个跳表的高度就是log2n,综合整个数据检索的过程,跳表检索的时间复杂度为O(logn)。
Sorted Set是Redis中比较重要的一种基础数据结构,而Sorted Set核心数据结构就采用了跳表结构来实现的,同时还使用了哈希表进行索引。那么为什么Sorted Set要实用两种数据结构呢?主要是Sorted Set既支持了范围查询也支持获取元素的权重值。当然,本文重点阐述跳表在Sorted Set中的应用。对于Sorted Set来说,在Redis的源码中对应的结构体是zset,其中包含了哈希表结构dict以及跳表结构zsl,具体如下所示:
typedef struct zset {
dict *dict;
zskiplist *zsl; //跳表结构
} zset;
通过上文介绍,我们知道跳表的本质就是一个拥有多层索引的有序链表,那么我们再来看下在Redis中是如何定义跳表结构的,如下所示:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; //跳表首尾节点
unsigned long length; //跳表长度
int level; //跳表的层级
} zskiplist;
上文中我们提到过跳表的本质是具备多级索引的链表,因此只要知道了头节点或者尾节点就可以访问整个跳表数据了。另外跳表长度表示跳表中包含的跳表节点数,而level则表示跳表拥有多少层检索索引。了解了跳表的结构之后,我们在深入看下跳表中的节点是怎样的结构,如下所示:
//跳表节点结构体
typedef struct zskiplistNode {
//跳表元素
sds ele;
//元素对应的权重值
double score;
//指向前个节点的指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上指向下一个节点的指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
源码当中的zskiplistNode实际上就是链表中的节点结构体了,其中Sorted Set对应的数据值以及值对应的权重都是在zskiplistNode中进行定义的。另外在zskiplistNode结构体中还定义了后向指针*backward,它的作用是指向当前节点的前一个节点,主要作用是方便进行节点的倒序查找。
另外zskiplistNode还定义了zskiplistLevel数组,怎么理解它呢?如下面的跳表结构图所示,跳表的每一层中共同的节点如节点24,所以这个zskiplistLevel数组中存储了向下个节点检索的指针以及跨度数据。
Redis中跳表的数据检索流程大致如下:
本文主要对跳表这个不常用的数据结构进行了介绍,同时分析了二分查找思想在跳表中的应用,重点阐述了跳表的数据结构特征。另外结合了Redis中Sorted Set分析了跳表在该基础数据结构中的实际应用,希望通过本文大家对于跳表这个数据结构能够有更加深刻的理解,同时切身体会用空间换时间的优化策略。