SkipList跳表的原理以及Go语言实现

蒋义渠
• 阅读 2642

跳表的原理

跳表(Skiplist)是一个特殊的链表,相比一般的链表,有更高的查找效率,可比拟二叉查找树。跳表的查找、插入、删除时间复杂度都是O(logN)。
许多知名的开源软件中的数据结构采用了跳表这种数据结构,例如:

  • Redis中的有序集合zset
  • LevelDB、HBase中Memtable
  • ApacheLucene中的TermDictionary、Posting List

跳表数据结构是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。跳表本质上是一个链表, 它其实是由有序链表发展而来。跳表在链表之上做了一些优化,跳表在有序链表之上加入了若干层用于索引的有序链表。索引链表的结点来源于基础链表,不过只有部分结点会加入索引链表中,并且越高层的链表结点数越少。跳表查询从顶层链表开始查询,然后逐级展开,直到底层链表。这种查询方式与树结构非常类似,使得跳表的查询效率相近树结构。另外跳表使用概率均衡技术而不是使用强制性均衡,因此对于插入和删除结点比传统上的平衡树算法更为简洁高效。因此跳表适合增删操作比较频繁,并且对查询性能要求比较高的场景。
为了说明跳变的原理以及特点,我们先从有序链表说起。首先看看有序链的特点,并对有序链表的操作性能进行分析。下图是一个有序链表的例子:
SkipList跳表的原理以及Go语言实现
有序链表是一个线性结构,不能像有序数组那样使用二分法查找数据(因为二分查找需要用到中间位置的节点,而链表不能随机访问)。在有序链表中找某个数据,需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止,时间复杂度为O(n)。当向有序链表中插入数据的时候,也要经历同样的查找过程,从而确定插入位置,因此向链表中插入一个节点的时间复杂度为也是O(n)。

接下来看看跳表是如何改进有序链表从而达到高性能的查询以及操作。首先为基础链表建立一层索引表,索引表只有基础链表结点的1/2。添加了索引表之后的数据结构如下图所示:
SkipList跳表的原理以及Go语言实现
索引表相当于基础链表的目录,查询时首先从索引表开始查找,当遇到比待查数据大的结点时,再从基础链表中查找。由于索引表的结点只有基础链表的1/2,因此需要比较的结点大大减少,从而可以加快查询速度。

利用同样的方式,我们还为基础链表添加第二层索引表,第二层索引表结点的数量是第一层索引表的1/2,添加了第二层索引表之后的数据结构如下图所示:
SkipList跳表的原理以及Go语言实现
第二层索引表的结点的数量只有基础链表的1/4左右,查找时首先从第二层索引表开始查找,当遇到比待查数据大的结点时,再从第一层索引表开始查找,然后再从基础链表中查找数据。这种逐层展开的方式与树结构的查询非常类似,因此跳表的查询性能与树结构接近,时间的复杂度为O(logN)。

我们知道一个平衡的二叉树,在进行增加或删除结点后可能造成二叉树结构的不平衡,从而会降低二叉树的查询性能。按照上面的方式实现的跳表也会面临同样的问题,新增或删除结点后会破坏链表之间的比例关系,从而造成跳表查询性能的降低。平衡树通过旋转操作来保持平衡,而旋转一方面增加了代码实现的复杂性,同时也降低了增删操作的性能。真正的跳表不会采用示例中的方式来建立上层链表,而是采用了一种概率均衡技术来创建上层链表, 并保证各层链表之间的比例关系。在为跳表增加一个结点时,会调用一个概率函数来计算一个结点的层次(level),例如若level=3,则结点除了出现在基础链表外,还会出现在第一层索引以及第二层索引链表中。结点层次的计算逻辑如下:

  • 每个节点肯定都在基础链表中。
  • 如果一个节点存在于第i层链表,那么它有第(i+1)层链表的概率为p。
  • 节点最大的层数不允许超过一个最大值。

跳表每一个节点的层数是随机的,而且新插入一个节点不会影响其它节点的层数。因此插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整,这就降低了插入操作的复杂度。实际上这是跳表的一个很重要的特性,这让跳表在增删操作的性能上明显优于平衡树的方案。

用Go语言实现跳表

接下来我们给出跳表的Go语言的实现,目前代码已经上传到github中,下载地址

定义

首先给出链接结点的定义:

type SkipNodeInt struct {
   key       int64
   value  interface{}
   next   []*SkipNodeInt
}

其中:

  • key用于表示一个结点,查询是使用key来查询一个结点。
  • Val是interface{}类型,可以表示任何类型的数据。
  • next是各层的后向结点指针数组,数组的长度为层高:level

接下来是链表的结构定义:

type SkipListInt struct {
   SkipNodeInt
   mutex      sync.RWMutex
   update     []*SkipNodeInt
   maxl   int
   skip   int
   level      int
   length     int32
}

其中:

  • SkipNodeInt 用于代表列表的header。
  • update:用于查找过程中的临时变量,定义唉这里为了提高访问性能,减少频繁创建数组对象。
  • maxl:是最大层数,缺省为:32
  • skip:层之间的比例,例如skip=4,则1/4的结点出现在上层。
  • level:跳表当前的层数
  • length:跳表的结点数量

跳表对象的创建:

func NewSkipListInt(skip ...int) *SkipListInt {
    list := &SkipListInt{}
    list.maxl = 32
    list.skip = 4
    list.level = 0
    list.length = 0
    list.SkipNodeInt.next = make([]*SkipNodeInt, list.maxl)
    list.update = make([]*SkipNodeInt, list.maxl)
    if len(skip) == 1 && skip[0] > 1 {
        list.skip = skip[0]
    }
    return list
}

说明:

  • 跳表最大层数缺省为:32
  • 跳表两层之间的比例为1/4,即概率p=0.25
  • 创建跳表是可以指定一个比例参数skip

跳表的查询

查找就是给定一个key,查找这个key是否出现在跳跃表中,如果出现,则返回其值,如果不存在,则返回nil。
查找某个元素时,先从顶层链表中查修,当遇到比待查数据大的结点时,则从下一层链表中查询。当遍历进行到第1层时,下一个节点就是目标节点(如存在)。

func (list *SkipListInt) Get(key int64) interface{} {
    list.mutex.Lock()
    defer list.mutex.Unlock()

    var prev = &list.SkipNodeInt
    var next *SkipNodeInt
    for i := list.level-1; i >= 0; i-- {
        next = prev.next[i]
        for next != nil && next.key < key {
            prev = next
            next = prev.next[i]
        }
    }

    if next != nil && next.key == key {
        return next.value
    } else {
        return nil
    }
}

新增结点

跳表新增结点包含如下几个操作:

  • 查找到需要插入的位置,需要获取每层的前驱节点。
  • 构造新节点,并通过概率函数计算结点的层数:level。
  • 将新节点插入到第0层到第(level-1)层的链表中。
func (list *SkipListInt) Set(key int64, val interface{}) {
    list.mutex.Lock()
    defer list.mutex.Unlock()
    
    //获取每层的前驱节点=>list.update
    var prev = &list.SkipNodeInt
    var next *SkipNodeInt
    for i := list.level-1; i >= 0; i-- {
        next = prev.next[i]
        for next != nil && next.key < key {
            prev = next
            next = prev.next[i]
        }
        list.update[i] = prev
    }

    //如果key已经存在
    if next != nil && next.key == key {
        next.value = val
        return
    }

    //随机生成新结点的层数
    level := list.randomLevel();
    if level > list.level {
        level = list.level + 1;
        list.level = level
        list.update[list.level-1] = &list.SkipNodeInt
    }

    //申请新的结点
    node:= &SkipNodeInt{}
    node.key = key
    node.value = val
    node.next = make([]*SkipNodeInt, level)

    //调整next指向
    for i := 0; i < level; i++ {
        node.next[i] = list.update[i].next[i]
        list.update[i].next[i] = node
    }

    list.length++
}

删除结点

删除操作与添加结点类似,包含以下步骤:

  • 获取每层的前驱节点。
  • 调整指针。

    func (list *SkipListInt) Remove(key int64) interface{} {
      list.mutex.Lock()
      defer list.mutex.Unlock()
    
      //获取每层的前驱节点=>list.update
      var prev = &list.SkipNodeInt
      var next *SkipNodeInt
      for i := list.level-1; i >= 0; i-- {
          next = prev.next[i]
          for next != nil && next.key < key {
              prev = next
              next = prev.next[i]
          }
          list.update[i] = prev
      }
    
      //结点不存在
      node := next
      if next == nil || next.key != key {
          return nil
      }
    
      //调整next指向
      for i, v := range node.next {
          if list.update[i].next[i] == node {
              list.update[i].next[i] = v
              if list.SkipNodeInt.next[i] == nil {
                  list.level -= 1
              }
          }
          list.update[i] = nil
      }
    
      list.length--
      return node.value
    }

随机层数的生成

随机层数的生成逻辑为:给定一个比例因此P=list.skip,对于i层的节点,有1/P的概率会出现在i+1层。

func (list *SkipListInt) randomLevel() int {
    i := 1
    for ; i < list.maxl; i++ {
        if rand.Int() % list.skip != 0 {
            break
        }
    }
    return i
}
点赞
收藏
评论区
推荐文章
白茶清欢 白茶清欢
4年前
js实现二叉树、二叉查找树
树是一种数据结构,该章节讨论二叉树(二叉树的每个节点的子节点不允许超过两个),二叉树中有又分为完全二叉树和不完全二叉树.....不在本章节赘述相关概念,感兴趣可以去查阅《数据结构》。你将会获得:1.如何使用js实现二叉查找树。2.学会前、中、后序遍历。3.了解相关实现原理阅读时长5min,可选择直接调试代码特点    二叉查找树中序遍历后
深入理解跳表及其在Redis中的应用
跳表可以达到和红黑树一样的时间复杂度O(logN),且实现简单,Redis中的有序集合对象的底层数据结构就使用了跳表。其作者威廉·普评价:跳跃链表是在很多应用中有可能替代平衡树的一种数据结构。本篇文章将对跳表的实现及在Redis中的应用进行学习。
Stella981 Stella981
3年前
Redis基本操作——队列 List(原理篇)
Redis基本操作——List(原理篇)  学习过数据结构的同学,一定对链表(LinkedList)十分的熟悉。相信我们自己也曾经使用过这种数据结构。  链表分为很多种:单向链表,双向链表,循环链表,块状链表\1(https://www.oschina.net/action/GoToLink?url
Stella981 Stella981
3年前
Python数据结构与算法——散列(Hash)
!(https://oscimg.oschina.net/oscnet/19a7428dd9c64d149aa474d3aabe80ce.png)点击上方“蓝字”关注我们散列(Hash)对于一组数据项,顺序查找的时间复杂度是O(n),二分查找是O(logn),而对于散列的数据结构,
Wesley13 Wesley13
3年前
MySQL知识体系——索引
    本文直切主题,针对InnoDB引擎描述索引及优化策略。在开始之前,需要读者了解:1)二叉查找树(包括23查找树、红黑树等数据结构)2)MySQL的InnoDB引擎基础知识索引初探要了解索引,当然要了解其数据结构。树有很多应用,流行的用法之一是包括UNIX和DOS在内的许多常用操作系统中的目录结构,二叉查找树又是Java中两种集合
Stella981 Stella981
3年前
Redis命令行之Zset
一、Redis之Zset简介1\.有序集合Zset是String类型的有序集合。2\.Zset中每个元素都会关联一个double类型的分数值,redis通过分数值来为集合中所有成员进行从小到大排序。3\.Zset的成员是唯一的,但分数值可以重复。4\.Zset是通过hash表实现的,添加、删除、查找的复杂度都是O(1)。5
Wesley13 Wesley13
3年前
Java数据结构和算法(四)——栈
前面我们讲解了数组,数组更多的是用来进行数据的存储,纯粹用来存储数据的数据结构,我们期望的是插入、删除和查找性能都比较好。对于无序数组,插入快,但是删除和查找都很慢,为了解决这些问题,后面我们会讲解比如二叉树、哈希表的数据结构。  而本篇博客讲解的数据结构和算法更多是用作程序员的工具,它们作为构思算法的辅助工具,而不是完全的数据存储工具。这些数据结构的生
贾蔷 贾蔷
13小时前
手把手教你实现哈希表:从代码到原理的新手友好指南
一、简介和应用哈希表(HashTable)是一种高效的数据结构,通过哈希函数将键(Key)映射到存储位置,实现O(1)时间复杂度的查找、插入和删除操作。它广泛应用于缓存系统、数据库索引、字典查询等场景。例如,在编程中需要快速根据用户ID查找信息时,哈希表能
深入理解左倾红黑树 | 京东物流技术团队
平衡二叉搜索树平衡二叉搜索树(BalancedBinarySearchTree)的每个节点的左右子树高度差不超过1,它可以在O(logn)时间复杂度内完成插入、查找和删除操作,最早被提出的自平衡二叉搜索树是AVL树。AVL树在执行插入或删除操作后,会根据节
美凌格栋栋酱 美凌格栋栋酱
5个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
贾蔷 贾蔷
3天前
哈希表实现指南:从原理到C++实践
一、简介和应用哈希表(HashTable)是一种高效的数据结构,通过键值对(keyvalue)存储数据,提供快速的插入、删除和查找操作。它使用哈希函数将键映射到表中的位置,使得平均时间复杂度可以达到O(1)。‌应用场景‌:数据库索引、缓存实现(如Redis
蒋义渠
蒋义渠
Lv1
孤云独鸟川光暮,万井千山海色秋。
文章
5
粉丝
0
获赞
0