2019-04-30 -- 跳表

作者: 想做算法很好的架构师 | 来源:发表于2019-05-07 06:50 被阅读0次

    呃,开启这篇之前先吐槽一下简书。。。我记得我专门写过一片跳表的文章的,结果不见了,不见了,见了,了。然后就怀疑人生,算了生活还是要继续,文章还是要记录,学习的👣不能因为这多大点儿事就停下来吧

    跳表其实就是基于链表+索引的数据结构,首先链表为地基,但是链表大家都知道是没有办法支持很好的查找,一般都是o(n) 的时间,而大家常说的删除和插入友好也是在特定的知道指针指向的情况下,所以要保证一个有序的链表的插入和删除的时间复杂度o(n)是需要的,其次从硬件资源的角度分析,链表不支持二八原则带来的优势,可以局部性获取数据到cpu 缓存,那么有什么可以让链表的查询也变得比较高效呢?答案就是那就给他加个索引吧

    其实我觉得索引真正的含义就是能在一堆有规律的数据中找到规律的数据建立索引值然后查找相应的数据,想法是美好的,问题就在于难在不知道如何建立索引值,以什么样的方式建立怎样的索引,那么跳表就是大神想到的可以比较好的为链表建立索引支持o(logn)的查询时间复杂度,当然时间复杂度的下降肯定会有空间复杂度的上升的守恒定律还是会遵守的,大概介绍了之后,就来真正看一下跳表的实现

    首先跳表的基础还是链表,所以链表有的节点,当然跳表也是会有的

    class SkipNode<E extends Comparable<? super E>> {
            /*
             * 节点存储的值Val
             */
            E val;
            /*
             * 节点指向第i层的节点next[i], 这个比较重要,代表了索引的思想,值得仔细品味一下,数组+链表的思想
             */
            SkipNode<E>[] next;
    
            public SkipNode(int MAX_LEVEL, E val) {
                this.next = new SkipNode[MAX_LEVEL];
                this.val = val;
            }
        }
    

    然后就是如何建立跳表的过程, 要做到用时比较爽的链表那必然在建立的时候就比普通的插入要复杂,比如插入的时候,需要维护每层的索引值,删除的时候同理,先来看跳表的数据结构,看代码之前铺一张跳表的真面目


    来自于极客时间,借用转载.jpg
    public class MySkipList<E extends  Comparable<? super E>>{
        // skip list has two base data structure to approve it's curd speed -- linkedlist and hash table
    
    
        /*
         * 跳表层数32层: 定义成32层理论上对于2^32-1个元素的查询最优。
         */
        private final int MAX_LEVEL = 32;
        /*
         * 当前跳表的有效层
         */
        private int level = 0;
        /*
         * 跳表的头部节点
         */
        private final SkipNode<E> header = new SkipNode<E>(MAX_LEVEL, null);
        /*
         * 随机数发生器
         */
        private final Random random = new Random();
        /*
         * 随机数边界值
         */
        private final double E = 0.5;
    
    }
    

    插入的逻辑,把插入的逻辑搞清楚了之后其他关于跳表的逻辑就比较显而易见了,那么先从最简单的链表的插入开始,怎么插的?当然是在最后的node 上直接一句
    lastNode.next = newNode 搞定,那么跳表在链表的基础上多了一索引以维持查找时的o(logn),其次是有了索引之后插入也就变得有顺序了,也就是底层的链表其实是有序的,这也正是redis 有序集合的实现原理,其实最主要的就是理解索引,可以从上面的图片中看出来,1这个node 如果在普通的链表中那么只需要维护一个next 的指针指向下一个值,但是因为有了索引的存在,1 这个node 就不能只维护一个next 了,而是需要维护一群next node,比如第一级索引中的nextNode = “4”, 第二级索引中nextNode=“7” 这样,索引就是这样建立起来的,所以在下觉得本质上跳表的索引靠的是数组天然的下标值可以作为索引的依据,在根据上面的图中假如我要插入一个值为6 的node,需要做哪些操作,然后试着自己写一下代码。(这里小小的插入一下不知道在哪里看的Google的工程师们效率永远能做到955的高效率的原因是他们永远先动脑子再写代码,比如写代码之前会在草稿纸上演算一下,理清楚所有逻辑之后才开始撸,这个在学习数据结构和算法甚至是业务代码都需要我保持良好风格)

    public void insert(E val){
            SkipNode cur = header;
            SkipNode[] preNodes;
            // 每次写这个循环的时候都觉得很妙,下一次的循环正巧是从索引值往下寻找的,找到每一层的前继node
            for(int i = level; i >= 0 ; --i){
                  // 从第i级索引开始记录需要插入的前继节点,查找的思想是:找到每一层的第一个大于当前代插入的节点的值
                  while(cur.next[i] != null && cur.next[i].compareTo(val) < 0){ 
                        cur = cur.next[i];
                  }
                  preNodes[i] = cur;
            }
           
            // 插入每一层的node 位置的时候,通过一个随机函数值来决定插入的高度和索引,这样做的目的是为了防止插入的效率退化成o(n),是一个动态更新索引层次的方式
          int  randomLevel  = randomLevel();
          cur = cur.next[0]; 
          if(cur == null && !cur.val.equals(val)){ // 当底层的为空,并且不是等于要插入的节点值的时候才有插入的必要
          if(randomLevel > level){ // 上天选择要新加一层索引,那么就需要动态更新level 的值
                preNodes[randomLevel] = header; // 从这里随机加索引可以看出
                level  = randomLevel; 
          }
          // 这里才是真正的插入操作,前面全是各种铺垫
          SkipNode<E> insertedNode = new SkipNode(MAX_LEVEL , val);
          for(int i = level; i >= 0; --i){
              insertedNode.next[i] = preNodes[i].next[i];
              preNodes[i].next[i] = insertedNode;
         }
        }
    }
    

    以上跳表的插入就完成了,可见关键做了三个操作:1. 找到每一层的前继节点,维护到数组中,以便后面的真正插入,也是维护索引 2. 动态更新索引,索引的值和层次,层次通过随机函数来维护,随机性可以防止索引退化成为直线链表 3. 真正的每一层的插入操作,可以看出保证有序的链表插入的时间复杂度可以通过上面关键的循环,也就是索引数组的维护由以前的普通的o(n) 变成 o(logn) 的时间复杂度,下面是随机函数生成随机索引值
    可以看下面的图的逻辑


    🙏极客时间
        /**
         * 利用随机数发生器来决定是否新增一层
         * 通过与E的大小判断,这样可以让平均的概率达到50%,基本上level 也就是平均二叉的level ,当然也可以调节E的大小,也就是概率大小调节高度的
         * @return
         */
        private int randomLevel() {
            double ins = random.nextDouble();
            int nextLevel = level;
            if (ins > E && level < MAX_LEVEL) { // 一切交给命运的随机nextLevel
                nextLevel++;
            }
            return nextLevel;
        }
    

    delete 的操作同理:

    public void delete(E val) {
            SkipNode<E> cur = Header;
            SkipNode<E>[] predecessors = new SkipNode[MAX_LEVEL];
            /*
             * 寻找待删除元素在不同层上的前继节点
             */
            for (int i = level; i >= 0; i--) {
                while (cur.next[i] != null && cur.next[i].val.compareTo(val) < 0) {
                    cur = cur.next[i];
                }
                predecessors[i] = cur;
            }
            cur = cur.next[0];
            /*
             * 跳表中不含此节点
             */
            if (cur != null && !cur.val.equals(val)) {
                return;
            }
            for (int i = 0; i <= level; i++) {
                if (predecessors[i].next[i] != null ){
                    if (!predecessors[i].next[i].val.equals(val)) {
                        continue;
                    }
                    predecessors[i].next[i] =  predecessors[i].next[i].next[i];
                }
    
            }
            /*
             * 如果删除元素val后level层元素数目为0,层数减少一层
             */
            while (level > 0 && header.next[level] == null) {
                level--;
            }
    
        }
    

    上面的操作需要几点注意的地方,不然就要NPE 的错误,在任何直接有判断xxxNode.next[i].xxx 的语句情况下都要判断xxxNode.next[i] assert null ? 的操作,这个算是链表的常见错误,其次是最后的level 的判断是否要减少
    完整的可以见我的github 账号https://github.com/amorynan/study
    欢迎留言
    最后再来看一下空间复杂度的情况,不能只看好处不看坏处嘛,空间复杂度当然是随着时间复杂度的下降而上升的,所以当你的随机函数调节的高度接近二叉的的话,那么可以依次算出第一层索引需要n/2 个nodes,第二层索引需要n/4 个nodes ,一直到最后一层以2的倍数递减,所以总的空间复杂度就是n,如果是接近三叉的话,那总的空间复杂度就是n/2 ,其实这个值是直接和递减的倍数相关的,有兴趣的可以推导一下.

    相关文章

      网友评论

        本文标题:2019-04-30 -- 跳表

        本文链接:https://www.haomeiwen.com/subject/jokvnqtx.html