在现代高性能数据库和缓存系统中,跳表(Skip List)作为一种高效的有序数据结构,被广泛应用于快速查找、插入和删除操作。Redis 是一个开源的键值对存储系统,它支持多种数据类型,并以其出色的性能而闻名。其中,Redis 使用了跳表来实现有序集合(Sorted Set),以保证其高效的数据处理能力。
本文将详细介绍如何使用 Go 语言从零开始实现一个类似于 Redis 的跳表。我们将探讨跳表的基本原理、设计思路以及具体的实现方法。通过本篇文章的学习,你不仅能够了解跳表的工作机制,还能够在实际项目中应用这一强大的数据结构。
定义基础数据结构
redis中跳表通过score标识元素的大小,通过redis obj维护节点的信息,与此同时为了保证查询的高效,它会为每个节点维护一份随机高度的索引记录当前节点的某个前驱节点:
对应我们给出节点的代码实现:
/*
*
跳表节点的定义
*/
type zskiplistNode struct {
//记录元素的redis指针
obj *robj
//记录当前元素的数值,代表当前元素的优先级
score float64
//指向当前元素的前驱节点,即小于当前节点的元素
backward *zskiplistNode
//用一个zskiplistLevel数组维护本届点各层索引信息
level []zskiplistLevel
}
zskiplistLevel的代码实现比较简单,通过forward 记录本层索引的前驱节点,并用span维护当前节点需要跨几步才能走到该前驱节点:
type zskiplistLevel struct {
//记录本层索引的前驱节点的指针
forward *zskiplistNode
//标识节点的本层索引需要跨几步才能到达该节点
span int64
}
通过上述概念构成无数个节点即称为跳表,如下图所示,各个节点都用一个level数组记录本层索引到前驱节点的地址和跨度,而跳表也用一个header和tail指针维护跳表的头尾节点:
对应的跳表结构体的代码如下所示:
type zskiplist struct {
//指向跳表的头节点
header *zskiplistNode
//指向跳表的尾节点
tail *zskiplistNode
//维护跳表的长度
length int64
//维护跳表当前索引的最高高度
level int
}
实现初始化方法
对应的我们也给出跳表的初始化代码,大体逻辑是初始化跳表之后,初始化一个全空的索引和维护跳表的各种初始化信息,对应的笔者也对此代码做了详尽的注释,读者可自行参阅:
func zslCreate() *zskiplist {
var j int
//初始化跳表结构体
zsl := new(zskiplist)
//索引默认高度为1
zsl.level = 1
//跳表元素初始化为0
zsl.length = 0
//初始化一个头节点socre为0,元素为空
zsl.header = zslCreateNode(ZSKIPLIST_MAXLEVEL, 0, nil)
/**
基于跳表最大高度32初始化头节点的索引,
使得前驱指针指向null 跨度也设置为0
*/
for j = 0; j < ZSKIPLIST_MAXLEVEL; j++ {
zsl.header.level[j].forward = nil
zsl.header.level[j].span = 0
}
//头节点的前驱节点指向null,代表头节点之前没有任何元素
zsl.header.backward = nil
//初始化尾节点
zsl.tail = nil
return zsl
}
跳表插入操作
插入新节点时,本质上就是通过各层索引找到小于插入节点x的score的最大值,并记录到update数组中,同时将头节点跨到update数组元素的跨度值记录到rank数组中,如下图所示,假如我们插入节点1.5,那么对应各层索引的在update和rank两个数组中维护的信息是:
- level2级中update记录header节点,所以跨度为0。
- level1级中update记录的是节点1,跨度为1。
然后基于此信息将x插入:
对应的代码和上述图解逻辑一致,对应的实现细节笔者都做好了标注:
func zslInsert(zsl *zskiplist, score float64, obj *robj) *zskiplistNode {
//创建一个update数组,记录插入节点每层索引中小于该score的最大值
update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
//记录各层索引走到小于score最大节点的跨区
rank := make([]int64, ZSKIPLIST_MAXLEVEL)
//x指向跳表走节点
x := zsl.header
var i int
//从跳表当前最高层索引开始,查找每层小于当前score的节点的最大值节点
for i = zsl.level - 1; i >= 0; i-- {
//如果当前索引是最高层索引,那么rank从0开始算
if i == zsl.level-1 {
rank[i] = 0
} else { //反之本层索引直接从上一层的跨度开始往后查找
rank[i] = rank[i+1]
}
/**
如果前驱节点不为空,且符合以下条件,则指针前移:
1. 节点小于当前插入节点的score
2. 节点score一致,且元素值小于或者等于当前score
*/
if x.level[i].forward != nil &&
(x.level[i].forward.score < score || (x.level[i].forward.score == score && x.level[i].forward.obj.String() < obj.String())) {
//记录本层索引前移跨度
rank[i] += x.level[i].span
//索引指针先前移动
x = x.level[i].forward
}
//记录本层小于当前score的最大节点
update[i] = x
}
//随机生成新插入节点的索引高度
level := zslRandomLevel()
/**
如果大于当前索引高度,则进行初始化,将这些高层索引的update数组都指向header节点,跨度设置为跳表中的元素数
意为这些高层索引小于插入节点的最大值就是header
*/
if level > zsl.level {
for i := zsl.level; i < level; i++ {
rank[i] = 0
update[i] = zsl.header
update[i].level[i].span = zsl.length
}
//更新一下跳表索引的高度
zsl.level = level
}
//基于入参生成一个节点
x = zslCreateNode(level, score, obj)
//从底层到当前最高层索引处理节点关系
for i = 0; i < level; i++ {
//将小于当前节点的最大节点的forward指向插入节点x,同时x指向这个节点的前向节点
x.level[i].forward = update[i].level[i].forward
update[i].level[i].forward = x
//维护x和update所指向节点之间的跨度信息
x.level[i].span = update[i].level[i].span - (rank[0] - rank[i])
update[i].level[i].span = rank[0] - rank[i] + 1
}
/**
考虑到当前插入节点生成的level小于当前跳表最高level的情况
该逻辑会将这些区间的update索引中的元素到其前方节点的跨度+1,即代表这些层级索引虽然没有指向x节点,
但因为x节点插入的缘故跨度要加1
*/
for i = level; i < zsl.level; i++ {
update[i].level[i].span++
}
//如果1级索引是header,则x后继节点不指向该节点,反之指向
if update[0] == zsl.header {
x.backward = nil
} else {
x.backward = update[0]
}
//如果x前向节点不为空,则让前向节点指向x
if x.level[0].forward != nil {
x.level[0].forward.backward = x
} else {//反之说明x是尾节点,tail指针指向它
zsl.tail = x
}
//维护跳表长度信息
zsl.length++
return x
}
跳表查询操作
有了插入操作的基础后,查询操作实现也比较容易了,即从头节点的最高索引开始不断向前找,如果没有则往下一级索引前向找,找到后返回经过的跨度即可。
如下图,我们希望查找元素2,直接从头节点的2级索引开始看,就是元素2比对一致,返回跨度2,即跨2步就能到达:
对应代码如下,和笔者说明一致,这里笔者也做了详尽的标注提供参考:
func zslGetRank(zsl *zskiplist, score float64, obj *robj) int64 {
var rank int64
//从索引最高节点开始进行查找
x := zsl.header
for i := zsl.level - 1; i >= 0; i-- {
//如果前向节点不为空且score小于查找节点,或者score相等,但是元素字符序比值小于或者等于则前移,同时用rank记录跨度
for x.level[i].forward != nil &&
(x.level[i].forward.score < score || (x.level[i].forward.score == score && x.level[i].forward.obj.String() <= obj.String())) {
rank += x.level[i].span
x = x.level[i].forward
}
//上述循环结束,比对一直,则返回经过的跨度
if x.obj != nil && x.obj.String() == obj.String() {
return rank
}
}
return 0
}
跳表删除操作
删除操作本质上也是找到要删除节点索引的前后节点,然后将这些节点关联,并修改其之间跨度,如下图我们要删除1.5节点,对应各层查找结果为:
- 3级索引找到头节点,因为前方不是1.5的节点索引,直接跨度减1即。
- 2级索引找到头节点,前方就是1.5的索引,删除掉后跨度改为header索引到1.5+1.5到前向节点跨度减去1,这里的减去1代表删除了节点1.5的跨步。
- 1级索引同2级索引,不多做赘述。
对应的代码示例如下,整体逻辑和笔者描述基本一致,先通过update找到删除节点x的前一个元素,然后调用zslDeleteNode进行删除:
func zslDelete(zsl *zskiplist, score float64, obj *robj) int64 {
update := make([]*zskiplistNode, ZSKIPLIST_MAXLEVEL)
//找到每层索引要删除节点的前一个节点
x := zsl.header
for i := zsl.level - 1; i >= 0; i-- {
for x.level[i].forward != nil &&
(x.level[i].forward.score < score || (x.level[i].forward.score == score && x.level[i].forward.obj.String() < obj.String())) {
x = x.level[i].forward
}
update[i] = x
}
//查看1级索引前面是否就是要删除的节点,如果是则直接调用zslDeleteNode删除节点,并断掉前后节点关系
x = x.level[0].forward
if x != nil && x.obj.String() == obj.String() {
zslDeleteNode(zsl, x, update)
return 1
}
return 0
}
对应zslDeleteNode细节就如笔者上图所讲解的步骤,读者可参考注释进行阅读:
func zslDeleteNode(zsl *zskiplist, x *zskiplistNode, update []*zskiplistNode) {
var i int
for i = 0; i < zsl.level; i++ {
/*
如果索引前方就是删除节点,当前节点span为:
当前节点到x +x到x前向节点 -1
*/
if update[i].level[i].forward == x {
update[i].level[i].span += x.level[i].span - 1
update[i].level[i].forward = x.level[i].forward
} else {
//反之说明该节点前方不是x的索引,直接减去x的跨步1即
update[i].level[i].span -= 1
}
}
//维护删除后的节点前后关系
if x.level[0].forward != nil {
x.level[0].forward.backward = x.backward
} else {
zsl.tail = x.backward
}
//将全空层的索引删除
for zsl.level > 1 && zsl.header.level[zsl.level-1].forward == nil {
zsl.level--
}
//维护跳表节点信息
zsl.length--
小结
通过本文的详细讲解,我们从零开始使用 Go 语言实现了一个类似于 Redis 的跳表。我们首先介绍了跳表的基本原理和设计思路,然后逐步实现了跳表的各种核心操作,包括插入、查找和删除。最后,我们对跳表的性能进行了分析,并探讨了其在 Redis 有序集合和其他场景中的应用。