Go语言实现跳表

Keywords: #技术 #Golang #数据结构
Release Date: 2025-03-26

跳表是一种经典的以空间换时间的数据结构。对于普通的链表来说,由于只能使用顺序查找,因此查找的时间复杂度就为 O(n),而跳表有多条并联链表,因此可以实现 O(logn) 的时间复杂度,类似于二分查找的链表版本。其空间复杂度为 O(n)

image.png

推荐文章:基于golang从零到一实现跳表,以下内容来自于该文章。

跳表的读流程

skiplist_read.webp

  1. 以 head 节点作为起点
  2. 从当前跳表存在的最大高度出发
  3. 倘若右侧节点 key 值小于 target,则持续向右遍历
  4. 倘若右侧节点 key 值等于 target,则代表找到目标,直接取值返回
  5. 倘若右侧节点为终点(nil)或者 key 值大于 target,则沿当前节点降低高度进入下一层
  6. 重复 3 - 5 步
  7. 倘若已经抵达第 1 层仍然找到不到 target,则返回 key 不存在的信息

跳表的写流程

skiplist_write.webp

  1. 首先基于读流程检索出 key 对应的节点是否存在,倘若存在,则对值进行更新并直接返回
  2. 根据基于golang从零到一实现跳表 3.6 小节中的规则,随机出新插入节点的高度;倘若新插入节点的高度大于当前跳表的最大高度,则需要对起点和终点的高度进行扩容
  3. 以 head 节点作为起点,从当前跳表存在的最大高度出发
  4. 倘若右侧节点 key 值小于 target,则持续向右遍历
  5. 倘若右侧节点为终点(nil)或者 key 值大于 target,则在该空隙插入新节点,并降低高度进入下一层
  6. 重复 4 - 5 步
  7. 倘若已经进入第 1 层,插入新节点后即可返回

步骤 2 中的规则是指:

  • 每个上层链表节点个数接近于相邻下层链表个数的一半
  • 每个上层链表节点均在相邻下层链表中存在

上层链表节点相隔距离不作要求,只要我们保证了上层节点个数接近于相邻下层的一半,在大数据量的情况下,随机性问题会被转化为数学期望问题,最终同样能够实现上层索引的二分查找效果

层数数量概率
1N1/2
2N/21/4
3N/41/8
mN/2^(m-1)1/2^m

跳表的删流程

skiplist_del.webp

  1. 以 head 节点作为起点,从当前跳表存在的最大高度出发
  2. 倘若右侧节点 key 值小于 target,则持续向右遍历
  3. 倘若右侧节点为终点(nil)或者 key 值大于 target,降低高度进入下一层
  4. 倘若右侧节点 key 值等于 target,则代表找到目标
  5. 找到目标值后,沿当前节点层层向下,依次将每层中的目标节点删除
  6. 倘若已经进入第 1 层仍然没有找到节点,说明节点不存在,删除失败
  7. 倘若删除成功,需要尝试对跳表的整体高度进行缩容,即需要对高度递减,直到确定最大高度对应的层数上至少存在一个有效的数据节点为止

跳表的 range 流程

skiplist_range.webp

  1. 以 head 节点作为起点
  2. 从当前跳表存在的最大高度出发
  3. 倘若右侧节点 key 值小于 target,则持续向右遍历
  4. 倘若右侧节点为终点(nil)或者 key 值大于 target,则沿当前节点降低高度进入下一层
  5. 倘若右侧节点 key 值等于 target,则代表找到目标,直接沿该节点向下来到第 1 层
  6. 重复 3 - 5 步,直到来到第 1 层,接下来从首个大于等于 target 的节点出发,向右依次遍历,直到取满 range 区间的数据后返回

跳表的 ceiling 流程

skiplist_ceil.webp

  1. 以 head 节点作为起点
  2. 从当前跳表存在的最大高度出发
  3. 倘若右侧节点 key 值小于 target,则持续向右遍历
  4. 倘若右侧节点为终点(nil)或者 key 值大于 target,则沿当前节点降低高度进入下一层
  5. 倘若右侧节点 key 值等于 target,则代表找到目标,直接取值返回
  6. 重复 3 - 5 步,直到找到 key 值等于 target 的节点或者来到第 1 层,接下来获取到首个大于等于 target 的节点并返回

跳表的 floor 流程

skiplist_floor.webp

  1. 以 head 节点作为起点
  2. 从当前跳表存在的最大高度出发
  3. 倘若右侧节点 key 值小于 target,则持续向右遍历
  4. 倘若右侧节点为终点(nil)或者 key 值大于 target,则沿当前节点降低高度进入下一层
  5. 倘若右侧节点 key 值等于 target,则代表找到目标,直接取值返回
  6. 重复 3 - 5 步,直到找到 key 值等于 target 的节点或者来到第 1 层,接下来获取到首个小于等于 target 的节点并返回

代码实现:

这里统一将 key-value 设置为 int 类型,当然也可以使用 Go 中的泛型 [T cmp.Ordered]

定义数据结构

// 跳表节点
type SkipListNode struct {
    key   int
    value int
    nexts []*SkipListNode
}

// 跳表结构
type SkipList struct {
    head *SkipListNode
}

跳表读操作

// 跳表的读操作
// 根据 key 读取 value,bool 表示 key 是否存在
func (s *SkipList) Get(key int) (int, bool) {
    // 根据 key 尝试检索对应的 node,如果存在则返回对应的值,否则返回 false
    if node := s.search(key); node != nil {
        return node.value, true
    }
    return -1, false
}

// 从跳表中检索 key 对应的 node
func (s *SkipList) search(key int) *SkipListNode {
    // 如果跳表为空,则直接返回 nil
    if s.head == nil || len(s.head.nexts) == 0 {
        return nil
    }
    // 从跳表的头节点开始,从最高层开始检索
    curr := s.head
    for level := len(s.head.nexts) - 1; level >= 0; level-- {
        // 在当前层上,从左向右检索,直到找到一个节点,其 key 大于等于目标 key
        for curr.nexts[level] != nil && curr.nexts[level].key < key {
            curr = curr.nexts[level]
        }
        // 如果找到了一个节点,其 key 等于目标 key,则返回该节点
        if curr.nexts[level] != nil && curr.nexts[level].key == key {
            return curr.nexts[level]
        }
        // 如果没有找到,则继续层数 - 1,在更低的层上进行检索
    }
    // 如果检索到了跳表的最底层,仍然没有找到,则返回 nil
    return nil
}

跳表写操作

这里可以巧妙地使用 rand.Int() 函数随机产生有符合整数,分别有 1/2 的概率产生正负整数。每产生一次正数或者负数就增加一层,这样就符合跳表的写流程中的概率规则。

也可以使用 rand.Intn(2) == 1 是相同的道理

// 跳表的写操作
// roll函数用于随机生成一个层数,每层概率 * 1/2
func (s *SkipList) roll() int {
    var level int
    // 巧妙利用 rand.Intn(2) 函数,每次产生一个 0 或 1 的概率为 1/2
    for rand.Intn(2) == 1 {
        level++
    }
    return level
}

// 插入 key-value 键值对到跳表中
func (s *SkipList) Put(key, value int) {
    // 如果跳表为空,则创建一个新的跳表
    if s.head == nil {
        s.head = &SkipListNode{
            key:   -1,
            value: -1,
            nexts: make([]*SkipListNode, 1),
        }
    }
    // 如果 key-value 键值对已经存在,则更新其 value 并返回
    if node := s.search(key); node != nil {
        node.value = value
        return
    }
    // roll产生一个随机层数
    level := s.roll()
    // 如果随机层数大于当前跳表的层数,则暂时通过 nil 补齐高度
    for len(s.head.nexts)-1 < level {
        s.head.nexts = append(s.head.nexts, nil)
    }
    // 创建新节点
    newNode := &SkipListNode{
        key:   key,
        value: value,
        nexts: make([]*SkipListNode, level+1),
    }
    // 从跳表的头节点开始,从最高层开始检索
    curr := s.head
    for l := len(s.head.nexts) - 1; l >= 0; l-- {
        // 在当前层上,从左向右检索,直到找到一个节点,其 key 大于等于目标 key
        for curr.nexts[l] != nil && curr.nexts[l].key < key {
            curr = curr.nexts[l]
        }
        // 如果当前层的节点数大于等于随机层数,则将新节点插入到当前层
        if l <= level {
            // 单链表插入: 调整指针关系,完成新节点的插入
            // curr -> newNode -> curr.nexts
            newNode.nexts[l] = curr.nexts[l]
            curr.nexts[l] = newNode
        }
    }
}

跳表的删操作

// 跳表的删操作
func (s *SkipList) Del(key int) bool {
    // 如果 key-value 不存在,则无需删除直接返回
    if node := s.search(key); node == nil {
        return false
    }
    // 从跳表的头节点开始,从最高层开始检索
    curr := s.head
    for level := len(s.head.nexts) - 1; level >= 0; level-- {
        // 在当前层上,从左向右检索,直到找到一个节点,其 key 大于等于目标 key
        for curr.nexts[level] != nil && curr.nexts[level].key < key {
            curr = curr.nexts[level]
        }
        // 右侧节点不存在或者 key 值大于 target,则直接跳过
        if curr.nexts[level] == nil || curr.nexts[level].key > key {
            continue // 进入下一层
        }
        // 此处意味着右侧节点的 key 值必然等于 key,则调整指针引用,进行删除
        curr.nexts[level] = curr.nexts[level].nexts[level]
    }
    // 对跳表的最大高度进行更新
    var dif int
    // 如果某一层已经不存在数据节点,高度需要减少
    for level := len(s.head.nexts) - 1; level > 0 && s.head.nexts[level] == nil; level-- {
        dif++
    }
    s.head.nexts = s.head.nexts[:len(s.head.nexts)-dif]
    return true
}

跳表的 range 操作

range 操作就是找到一个区间内的 key-value,这里是因为 key-value 都为 int 类型,所以使用 [2]int 数组,如果 key-value 是不同的类型,可以使用字典或者结构体。

// 跳表的range操作
// 找到 skiplist 当中 >= start,且 <= end 的 key-value 返回二维数组
func (s *SkipList) Range(start, end int) [][2]int {
    // 首先通过 ceiling 方法,找到 skiplist 中 key 值大于等于 start 且最接近于 start 的节点 ceilNode
    ceilNode := s.ceiling(start)
    // 如果不存在,直接返回
    if ceilNode == nil {
        return [][2]int{}
    }
    // 从 ceilNode 最低层出发向右遍历,把所有位于 [start,end] 区间内的节点都返回
    var res [][2]int
    for curr := ceilNode; curr != nil && curr.key <= end; curr = curr.nexts[0] {
        res = append(res, [2]int{curr.key, curr.value})
    }
    return res
}

// 找到 skiplist 当中 key 值大于等于 target 且最接近于 target 的节点
func (s *SkipList) ceiling(target int) *SkipListNode {
    // 如果跳表为空,则直接返回 nil
    if s.head == nil || len(s.head.nexts) == 0 {
        return nil
    }
    // 从跳表的头节点开始,从最高层开始检索
    curr := s.head
    for level := len(s.head.nexts) - 1; level >= 0; level-- {
        // 在当前层上,从左向右检索,直到找到一个节点,其 key 大于等于目标 key
        for curr.nexts[level] != nil && curr.nexts[level].key < target {
            curr = curr.nexts[level]
        }
        // 如果找到了一个节点,其 key 等于目标 key,则返回该节点
        if curr.nexts[level] != nil && curr.nexts[level].key == target {
            return curr.nexts[level]
        }
    }
    // 此处 curr 已经对应于在最低层 key 值小于 key 且最接近于 key 的节点
    // 其右侧第一个节点即为所寻找的目标节点
    return curr.nexts[0]
}

跳表的 ceiling 操作

这一部分的 ceiling 操作实际上已经在上面的 range 操作中讲解了,这里只需要再封装一层可导出的接口即可。

// 跳表的ceiling操作
func (s *SkipList) Ceiling(target int) ([2]int, bool) {
    if ceilNode := s.ceiling(target); ceilNode != nil {
        return [2]int{ceilNode.key, ceilNode.value}, true
    }
    return [2]int{}, false
}

跳表的 floor 操作

floor 操作与 ceiling 操作类似,只是条件范围不同。

// 跳表的floor操作
// 找到 skiplist 中,key 值小于等于 target 且最接近于 target 的 key-value
func (s *SkipList) Floor(target int) ([2]int, bool) {
    // 使用 floor 方法,找到 skiplist 中 key 值小于等于 target 且最接近于 target 的节点 floorNode
    if floorNode := s.floor(target); floorNode != nil {
        return [2]int{floorNode.key, floorNode.value}, true
    }
    return [2]int{}, false
}

// 找到 skiplist 中 key 值小于等于 target 且最接近于 target 的节点
func (s *SkipList) floor(target int) *SkipListNode {
    // 如果跳表为空,则直接返回 nil
    if s.head == nil || len(s.head.nexts) == 0 {
        return nil
    }
    // 从跳表的头节点开始,从最高层开始检索
    curr := s.head
    for level := len(s.head.nexts) - 1; level >= 0; level-- {
        // 在当前层上,从左向右检索,直到找到一个节点,其 key 大于目标 key
        for curr.nexts[level] != nil && curr.nexts[level].key <= target {
            curr = curr.nexts[level]
        }
        // 如果找到了一个节点,其 key 等于目标 key,则返回该节点
        if curr.nexts[level] != nil && curr.nexts[level].key == target {
            return curr.nexts[level]
        }
    }
    // 此处 curr 已经对应于在最低层 key 值小于等于 key 且最接近于 key 的节点
    return curr
}