【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>
跳跃表是一种链式数据结构,在外观表现上其具有两个属性:分值和保存的对象。跳跃表通过对每个节点的分值进行排序从而达到排序每个节点的目的,但这仅仅是其特点之一。为了实现跳跃表的快速修改和查询操作,其内部还在每个节点上都保存了一个用于指向其后节点的指针数组,以及一个指向前一个节点的指针,并且为了快速获取跳跃表的节点数目和指针数组的最大长度,其分别创建了一个length和一个level属性用于快速查询。以下是跳跃表的数据结构:
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
zskiplistNode保存了节点的分值,属性对象,后退指针以及指向其后节点的指针数组。这里指针数组中的forward指向了从当前节点开始下一个具有同层级(与forward所在数组的位置相同的数组位置)的节点,而span则保存了forward指针从当前节点到下一个节点之间的跨度。zskiplist主要保存了头结点,尾节点,节点数量和最大节点数组的层数,这里需要注意的是header指针中并没有保存实际的数据,但是其保存有指向下一个节点的指针和跨度。如下是一个使用zskiplist保存数据的示例:
这里需要说明的是,跳跃表的节点数组的长度是随机的,其值为1到32之间的一个整数。跳跃表之所以能够与平衡树相媲美是因为其节点都是排序的,并且每个节点都有一个指向其后第一个拥有同层级的节点的指针。我们以redis中插入节点的操作来对跳跃表进行说明,以下是redis中插入节点的操作的具体实现代码:
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
redisAssert(!isnan(score));
// 在各个层查找节点的插入位置
// T_wrost = O(N^2), T_avg = O(N log N)
x = zsl->header;
for (i = zsl->level-1; i >= 0; i--) {
/* store rank that is crossed to reach the insert position */
// 如果 i 不是 zsl->level-1 层
// 那么 i 层的起始 rank 值为 i+1 层的 rank 值
// 各个层的 rank 值一层层累积
// 最终 rank[0] 的值加一就是新节点的前置节点的排位
// rank[0] 会在后面成为计算 span 值和 rank 值的基础
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 沿着前进指针遍历跳跃表
// T_wrost = O(N^2), T_avg = O(N log N)
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
// 比对分值
(x->level[i].forward->score == score &&
// 比对成员, T = O(N)
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
// 记录沿途跨越了多少个节点
rank[i] += x->level[i].span;
// 移动至下一指针
x = x->level[i].forward;
}
// 记录将要和新节点相连接的节点
update[i] = x;
}
/* we assume the key is not already inside, since we allow duplicated
* scores, and the re-insertion of score and redis object should never
* happen since the caller of zslInsert() should test in the hash table
* if the element is already inside or not.
*
* zslInsert() 的调用者会确保同分值且同成员的元素不会出现,
* 所以这里不需要进一步进行检查,可以直接创建新元素。
*/
// 获取一个随机值作为新节点的层数
// T = O(N)
level = zslRandomLevel();
// 如果新节点的层数比表中其他节点的层数都要大
// 那么初始化表头节点中未使用的层,并将它们记录到 update 数组中
// 将来也指向新节点
if (level > zsl->level) {
// 初始化未使用层
// T = O(1)
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length; // 这里的span应该是插入的节点的rank值?
}
// 更新表中节点最大层数
zsl->level = level;
}
// 创建新节点
x = zslCreateNode(level,score,obj);
// 将前面记录的指针指向新节点,并做相应的设置
// T = O(1)
for (i = 0; i < level; i++) {
// 设置新节点的 forward 指针
x->level[i].forward = update[i]->level[i].forward;
// 将沿途记录的各个节点的 forward 指针指向新节点
update[i]->level[i].forward = x;
/* update span covered by update[i] as x is inserted here */
// 计算新节点跨越的节点数量
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
// 更新新节点插入之后,沿途节点的 span 值
// 其中的 +1 计算的是新节点
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
/* increment span for untouched levels */
// 未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点
// T = O(1)
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
// 设置新节点的后退指针
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
// 跳跃表的节点计数增一
zsl->length++;
return x;
}
上述代码中,程序首先声明一个长度为ZSKIPLIST_MAXLEVEL,也即32的节点数组,该数组的目的是用于保存到达需要插入节点位置的前一个节点所经过的路径,x则用于保存当前遍历的节点,i用于保存此时遍历的数组指针的索引,level保存了最大的节点指针长度,而rank数组的rank[i]则保存了从头节点到update[i]的跨度值,其主要用于计算新插入的节点的每一层的跨度。假如我们要在前面的图中插入节点o3.5,其分值为3.5,从代码中我们可以看出,首先x指向头结点,从for循环中数组指针的L8开始遍历,并初始化rank[0]为0。在while循环中,检查三个条件:①forward指针指向的下一个节点是否为空;②下一个节点的分值是否小于要插入节点的分值;③如果forward指向的下一个节点的分值与要插入节点的分值相同,那么检查forward指向的下一个节点的对象是否小于要插入的节点的对象。当这三个条件都成立的时候,表明下一个节点将会在要插入的节点的前面,因而将x指向的节点指向forward指针所指向的节点。比如图中由于o2不为空,并且o2节点的分值比要插入节点的分值3.5要小,因而x转而指向o2所在的节点。转向o2之后,此时i为7,rank[7]被累加为2,继续while循环,其发现o2所在节点的level[7]的forward指针为null,因而while循环结束,update[7]指向o2所在的节点。for循环中i自减1之后为6,进入while循环时发现level[6]的forward指针为null,因而直接退出循环,将update[6]设置为o2所在节点,并且rank[6]设置为2。继续for循环使得i减一为5,对应于图中的L6,此时while循环发现level[6]的下一个节点不为空,但是其分值大于要插入的节点的分值,因而退出while循环,并将update[5]设置为指向o2。继续for循环直到i为4,while循环中发现level[4]的forward指针不为空,并且该指针指向的下一个节点o3的分值小于要插入的节点的分值,因而将x指向o3,并且将rank[4]设置累加为3。接着,for循环将i设为2,此时forward指针指向的节点o4不为空,并且其分值比要插入节点的分值要大,因而退出while循环,将update[2]指向o3节点,继续for循环。后续i为1和0时情况与i为2时一样,最终后续的update[i]都指向o3,并且rank[i]也都为3。
上述的遍历操作几乎在跳跃表的其余方法中都有相似的应用,只是遍历的条件稍有区别。在找到要插入的节点的前一个位置之后,首先会给要插入的节点分配内存,从代码中也可以看出,该节点的长度是随机分配的一个1到32的整数。如果新插入的节点的层数比当前的最高层数都要高,那么就更新zskiplist的最高层数,并且更新update数组中比当前层多出部分的值,将其指针都指向头结点,并且其span更新为到新节点的跨度。由于update数组所指向的节点是从头结点开始遍历到新创建的节点的路径,其指向的节点也就是所有forward指针需要更新为新创建的节点的节点,因而将其forward指针进行更新,使其指向新的节点,而其到新节点的跨度则可以有rank[i]计算而来。由于rank[0]为头节点到新创建的节点的前一个节点之间的跨度,而rank[i]为头节点到update[i]所指向的节点的跨度,因而将rank[0] - rank[i]即为从update[i]到要插入的节点的前一个节点之间的跨度,那么update[i]到要创建的节点的跨度为rank[0] - rank[i] + 1,这样就计算出了指向新创建节点的跨度。至于新创建节点到其下一个节点的跨度,由于update[i].level[i].span表示从update[i]到新创建节点的下一个节点的跨度,而rank[0] - rank[i]为从update[i]到要插入的节点的前一个节点之间的跨度,因而update[i].level[i].span - (rank[0] - rank[i])即为新创建节点到其下一个节点的跨度。
从上述的说明中可以看出,跳跃表查询一个节点的操作只需要根据每个节点的指针指向不断地往下跳跃即可,最终查询到目标节点,并且对其进行一定的操作,这也就是跳跃表能够缩短其操作的时间复杂度为O(logN)的原因。