跳表,会跳的链表真的非常diao

跳表

前言

跳表是面试常问的一种数据结构,它在很多中间件和语言中得到应用,我们熟知的就有Redis跳表。并且在面试的很多场景可能会问到,偶尔还会让你手写试一试(跳表可能会让手写,红黑树是不可能的),这不,给大伙复原一个场景:

image-20201225113330615

但你别慌,遇到蘑菇头这种面试官也别怕,因为你看到这篇文章了(得意😏),不用像熊猫那样窘迫。

对于一个数据结构或算法,人群数量从听过名称、了解基本原理、清楚执行流程、能够手写 呈抖降的趋势。因为很多数据结构与算法其核心原理可能简单,但清楚其执行流程就需要动脑子去思考想明白,但是如果能够把它写出来,那就要自己一步步去设计和实现。可能要花很久才能真正写出来,并且还可能要查阅大量的资料。

而本文在前面进行介绍跳表,后面部分详细介绍跳表的设计和实现,搞懂跳表,这一篇真的就够了。

快速了解跳表

跳跃表(简称跳表)由美国计算机科学家William Pugh发明于1989年。他在论文《Skip lists: a probabilistic alternative to balanced trees》中详细介绍了跳表的数据结构和插入删除等操作。

跳表(SkipList,全称跳跃表)是用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树,AVL树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。

在这里你可以看到一些关键词:链表(有序链表)、索引、二分查找。想必你的脑海中已经有了一个初略的印象,不过你可能还是不清楚这个"会跳的链表"有多diao,甚至还可能会产生一点疑虑:跟随机化有什么关系?你在下文中很快就能得到答案!

回顾链表,我们知道链表和顺序表(数组)通常都是相爱相杀,成对出现,各有优劣。而链表的优势就是更高效的插入、删除。痛点就是查询很慢很慢!每次查询都是一种O(n)复杂度的操作,链表估计自己都气的想哭了😢。

image-20201224155243423

这是一个带头结点的链表(头结点相当于一个固定的入口,不存储有意义的值),每次查找都需要一个个枚举,相当的慢,我们能不能稍微优化一下,让它稍微跳一跳呢?答案是可以的,我们知道很多算法和数据结构以空间换时间,我们在上面加一层索引,让部分节点在上层能够直接定位到,这样链表的查询时间近乎减少一半,链表自己虽然没有开心起来,但收起了它想哭的脸。

image-20201224160740034

这样,在查询某个节点的时候,首先会从上一层快速定位节点所在的一个范围,如果找到具体范围向下然后查找代价很小,当然在表的结构设计上会增加一个向下的索引(指针)用来查找确定底层节点。平均查找速度平均为O(n/2)。但是当节点数量很大的时候,它依旧很慢很慢。我们都知道二分查找是每次都能折半的去压缩查找范围,要是有序链表也能这么跳起来那就太完美了。没错跳表就能让链表拥有近乎的接近二分查找的效率的一种数据结构,其原理依然是给上面加若干层索引,优化查找速度。

image-20201224175922421

通过上图你可以看到,通过这样的一个数据结构对有序链表进行查找都能近乎二分的性能。就是在上面维护那么多层的索引,首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。

对于理想的跳表,每向上一层索引节点数量都是下一层的1/2.那么如果n个节点增加的节点数量(1/2+1/4+…)<n。并且层数较低,对查找效果影响不大。但是对于这么一个结构,你可能会疑惑,这样完美的结构真的存在吗?大概率不存在的,因为作为一个链表,少不了增删该查的一些操作。而删除和插入可能会改变整个结构,所以上面的这些都是理想的结构,在插入的时候是否添加上层索引是个概率问题(1/2的概率),在后面会具体讲解。

跳表的增删改查

上面稍微了解了跳表是个啥,那么在这里就给大家谈谈跳表的增删改查过程。在实现本跳表的过程为了便于操作,我们将跳表的头结点(head)的key设为int的最小值(一定满足左小右大方便比较)。

对于每个节点的设置,设置成SkipNode类,为了防止初学者将next向下还是向右搞混,直接设置right,down两个指针。

class SkipNode<T>
{
    int key;
    T value;
    SkipNode right,down;//右下个方向的指针
    public SkipNode (int key,T value) {
        this.key=key;
        this.value=value;
    }
}

跳表的结构和初始化也很重要,其主要参数和初始化方法为:

public class SkipList <T> {

    SkipNode headNode;//头节点,入口
    int highLevel;//当前跳表索引层数
    Random random;// 用于投掷硬币
    final int MAX_LEVEL = 32;//最大的层

    SkipList(){
        random=new Random();
        headNode=new SkipNode(Integer.MIN_VALUE,null);
        highLevel=0;
    }
    //其他方法
}

查询操作

很多时候链表也可能这样相连仅仅是某个元素或者key作为有序的标准。所以有可能链表内部存在一些value。不过修改和查询其实都是一个操作,找到关键数字(key)。并且查找的流程也很简单,设置一个临时节点team=head。当team不为null其流程大致如下:

(1) 从team节点出发,如果当前节点的key与查询的key相等,那么返回当前节点(如果是修改操作那么一直向下进行修改值即可)。

(2) 如果key不相等,且右侧为null,那么证明只能向下(结果可能出现在下右方向),此时team=team.down

(3) 如果key不相等,且右侧不为null,且右侧节点key小于待查询的key。那么说明同级还可向右,此时team=team.right

(4)(否则的情况)如果key不相等,且右侧不为null,且右侧节点key大于待查询的key 。那么说明如果有结果的话就在这个索引和下个索引之间,此时team=team.down。

最终将按照这个步骤返回正确的节点或者null(说明没查到)。

image-20201224210130178

例如上图查询12节点,首先第一步从head出发发现右侧不为空,且7<12,向右;第二步右侧为null向下;第三步节点7的右侧10<12继续向右;第四步10右侧为null向下;第五步右侧12小于等于向右。第六步起始发现相等返回节点结束。

而这块的代码也非常容易:

public SkipNode search(int key) {
    SkipNode team=headNode;
    while (team!=null) {
        if(team.key==key)
        {
            return  team;
        }
        else if(team.right==null)//右侧没有了,只能下降
        {
            team=team.down;
        }
        else if(team.right.key>key)//需要下降去寻找
        {
            team=team.down;
        }
        else //右侧比较小向右
        {
            team=team.right;
        }
    }
    return null;
}

删除操作

删除操作比起查询稍微复杂一丢丢,但是比插入简单。删除需要改变链表结构所以需要处理好节点之间的联系。对于删除操作你需要谨记以下几点:

(1)删除当前节点和这个节点的前后节点都有关系

(2)删除当前层节点之后,下一层该key的节点也要删除,一直删除到最底层

根据这两点分析一下:如果找到当前节点了,它的前面一个节点怎么查找呢?这个总不能在遍历一遍吧!有的使用四个方向的指针(上下左右)用来找到左侧节点。是可以的,但是这里可以特殊处理一下 ,不直接判断和操作节点,先找到待删除节点的左侧节点。通过这个节点即可完成删除,然后这个节点直接向下去找下一层待删除的左侧节点。设置一个临时节点team=head,当team不为null具体循环流程为:

(1)如果team右侧为null,那么team=team.down(之所以敢直接这么判断是因为左侧有头结点在左侧,不用担心特殊情况)

(2)如果team右侧不 为null,并且右侧的key等于待删除的key,那么先删除节点,再team向下team=team.down为了删除下层节点。

(3)如果team右侧不 为null,并且右侧key小于待删除的key,那么team向右team=team.right。

(4)如果team右侧不 为null,并且右侧key大于待删除的key,那么team向下team=team.down,在下层继续查找删除节点。

image-20201225002518856

例如上图删除10节点,首先team=head从team出发,7<10向右(team=team.right后面省略);第二步右侧为null只能向下;第三部右侧为10在当前层删除10节点然后向下继续查找下一层10节点;第四步8<10向右;第五步右侧为10删除该节点并且team向下。team为null说明删除完毕退出循环。

当然删除之后还要重新计算高度(防止高度下降了)。

删除操作实现的代码如下:

public void delete(int key)//删除不需要考虑层数
{
    SkipNode team=headNode;
    while (team!=null) {
        if (team.right == null) {//右侧没有了,说明这一层找到,没有只能下降
            team=team.down;
        }
        else if(team.right.key==key)//找到节点,右侧即为待删除节点
        {
            team.right=team.right.right;//删除右侧节点
            team=team.down;//向下继续查找删除
        }
        else if(team.right.key>key)//右侧已经不可能了,向下
        {
            team=team.down;
        }
        else { //节点还在右侧
            team=team.right;
        }
    }
     // 重新计算层高
  while (headNode.right == null) {
    headNode= headNode.down;
    hightLevel--;
  }
}

插入操作

插入操作在实现起来是最麻烦的,需要的考虑的东西最多。回顾查询,不需要动索引;回顾删除,每层索引如果有删除就是了。但是插入不一样了,插入需要考虑是否插入索引,插入几层等问题。由于需要插入删除所以我们肯定无法维护一个完全理想的索引结构,因为它耗费的代价太高。但我们使用随机化的方法去判断是否向上层插入索引。即产生一个[0-1]的随机数如果小于0.5就向上插入索引,插入完毕后再次使用随机数判断是否向上插入索引。运气好这个值可能是多层索引,运气不好只插入最底层(这是100%插入的)。但是索引也不能不限制高度,我们一般会设置索引最高值如果大于这个值就不往上继续添加索引了。

我们一步步剖析该怎么做,其流程为

(1)首先通过上面查找的方式,找到待插入的左节点。插入的话最底层肯定是需要插入的,所以通过链表插入节点(需要考虑是否为末尾节点)

(2)插入完这一层,需要考虑上一层是否插入,首先判断当前索引层级,如果大于最大值那么就停止(比如已经到最高索引层了)。否则设置一个随机数1/2的概率向上插入一层索引(因为理想状态下的就是每2个向上建一个索引节点)。

(3)继续(2

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

图解数据结构与算法(Java) 文章被收录于专栏

让数据结构与算法学习更简单,每一种数据结构与算法通过多图的方式讲解、实现、解题,内容覆盖递归详解、单双链表、堆、栈、二叉树(遍历、插删)、AVL树、哈夫曼树、字典树、dfs、bfs、拓扑排序、Dijkstra、Floyd、并查集、跳表、分治算法、动态规划、快速幂、十大排序等等。 还覆盖超经典面试笔试题例如:topK问题、约瑟夫环问题、链表找环问题、LRU、20+道经典动态规划问题!

全部评论
链表是面试避不开的问题啊
点赞 回复 分享
发布于 2022-06-09 13:51
删除的图没了
点赞 回复 分享
发布于 2023-05-13 21:04 江西

相关推荐

Noob1024:一笔传三代,人走笔还在
点赞 评论 收藏
分享
joe2333:怀念以前大家拿华为当保底的日子
点赞 评论 收藏
分享
8 21 评论
分享
牛客网
牛客企业服务