程序员,你应该知道的数据结构之跳表

跳表的原理

跳表也叫跳跃表,是一种动态的数据结构。如果我们需要在有序链表中进行查找某个值,需要遍历整个链表,二分查找对链表不支持,二分查找的底层要求为数组,遍历整个链表的时间复杂度为O(n)。我们可以把链表改造成B树、红黑树、AVL树等数据结构来提升查询效率,但是B树、红黑树、AVL树这些数据结构实现起来非常复杂,里面的细节也比较多。跳表就是为了提升有序链表的查询速度产生的一种动态数据结构,跳表相对B树、红黑树、AVL树这些数据结构实现起来比较简单,但时间复杂度与B树、红黑树、AVL树这些数据结构不相上下,时间复杂度能够达到O(logn)。

如何理解跳表?


要理解跳表,我们先从有序链表开始,对于上图中的有序链表,我们要查找值为150的节点,我们需要遍历到链表的最后一个元素才能找到,用大O表示时间复杂度就为O(n)。时间复杂度比较高,有没有优化空间呢?答案是肯定的,我们采取空间换时间的概念,对链表抽取出索引层,比如每两个元素抽取一个元素构成新的有序链表,这样索引层的有序链表相对底层的链表来说长度更短、间距更大,改造之后的链表入下图所示:

我们在新的链表中查询150,先从最上层Level3开始查找,遍历Level3有序链表,遍历两次就查询到了150这个节点。相对于初始链表来说,改造后的链表对查询效率有了不少的提升。

改造后的链表就是跳表,跳表采用的是以空间换时间的概念,将有序链表随机抽出好几层有序链表,查找的时候先从最上层开始,然后一直下沉到最底层链表。

现在我们对跳表有了一定的认识,总结一下跳表的性质:

  • 由很多层结构组成
  • 每一层都是一个有序的链表
  • 最底层(Level 1)的链表包含所有元素
  • 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。

跳表的实现

跳表一般使用单链表来实现,这样比较节约空间。我使用双向链表来实现跳表,因为双向链表相对单向链表来说比较容易理解跳表的实现。

链表定义

// 头节点
private final static byte HEAD_NODE = (byte) -1;
// 数据节点
private final static byte DATA_NODE = (byte) 0;
// 尾节点
private final static byte TAIL_NODE = (byte) -1;

private static class Node{
    private Integer value;
    // 上下左右节点
    private Node up,down,left,right;
    private byte bit;

    public Node(Integer value,byte bit){
        this.value = value;
        this.bit = bit;

    }
    public Node(Integer value){
        this(value,DATA_NODE);
    }

}

跳表的搜索

跳表的搜索从最上层开始查询,最开始的节点p指向最顶层的head节点,如果p.right.value小于等于带查询元素,则p指向p.right,否则指向p.down。直到p.down为空,返回该节点。

跳表查找示意图:

1)p=headhead.right.value=15,15<30,继续往右边查找,p=head.right

2)p.right.value=150150 > 30,往下一层查找,p=p.down

3)p.right.value=3030=30,继续往右边查找,p=p.right

4)p.right.value=150150 > 30,往下一层查找,p=p.down

5)p.right.value=5050 > 30,由于p.down为空,所以就返回该节点

代码实现

/**
 * 通用查找
 * 查找出最后一个小于该元素或者等于元素的节点
 * @param element 待查找的数
 * @return
 */
private Node find(Integer element){
    Node current = head;
    for (;;){
        while (current.right.bit !=TAIL_NODE && current.right.value <= element){
            current = current.right;
        }

        if (current.down !=null){// 找到最底层节点
            current = current.down;
        }else{
            break;
        }
    }
    return current;// current.value <= element <current.right.value
}
/**
 * 获取某个元素
 * @param element 
 * @return
 */
public Integer get(Integer element){
    Node node = this.find(element);
    return (node.value==element)?node.value:null;
}

跳表的插入

理解了跳表的查找后,在来看跳表的插入就相对来说比较简单,我们先找出最后一个小于等于插入值的节点,我们将值插入到该节点的右边。除了最底层链表上的插入外,每个新插入的节点都会有一个随机的层高,我们还需要维护层高之间的节点关系,因为新节点的左边第一个元素不一定有层高,所以我们要往左边查找到第一个node.up不为空的节点,维护节点之间的关系,然后将节点作为新的节点,继续往上加层。

我们以值55,层高为2的新节点为例,演示索引建立的过程。

1)我们先找出最后一个小于或者等于插入值的节点,即图中的node节点,然后将新节点插入到node的右边,即newnode节点。

2)node=50时,node.up为空,所以node = node.left

3)node=30时,node.up不为空,此时node=node.up

4)此时node已经移到level230节点上,在node的右边建立indexNode节点,作为新插入值的lebel2层的索引

5)将indexNode节点作为新的newnode节点继续向上添加索引,由于我们新插入节点的层高为2,所以将结束循环,新增元素完毕。

代码实现

/**
 * 添加元素
 * @param element
 */
public void add(Integer element){
    //查找出前一个节点
    Node node = this.find(element);
    // 新建节点
    Node newNode = new Node(element);
    // 新节点与前后两节点建立关系
    newNode.left = node;
    newNode.right = node.right;
    node.right.left = newNode;
    node.right = newNode;

    int currentLevel = 1;
    // 建立索引层,随机建立层高
    while (random.nextDouble() < 0.5d){

        // 索引大于当前索引层
        if (currentLevel >= level){
            level++;
            // 最顶层索引的头指针
            Node topHead = new Node(null,HEAD_NODE);
            // 最顶层索引的尾指针
            Node topTail = new Node(null,TAIL_NODE);

            topHead.right = topTail;
            topHead.down = head;
            head.up = topHead;
            topTail.left = topHead;
            topTail.down = tail;
            tail.up = topTail;
            head = topHead;
            tail = topTail;
        }
        // 一直往左边找到索引层
        while (node !=null && node.up == null){
            node = node.left;
        }
        node = node.up;
        // 新建索引节点,与当前左边节点建立关系
        Node indexNode = new Node(element);
        indexNode.left = node;
        indexNode.right = node.right;
        indexNode.down = newNode;
        node.right.left = indexNode;
        node.right = indexNode;
        newNode.up = indexNode;
        // 将索引节点作为新的节点,继续往上建立索引(如果有索引的话)
        newNode = indexNode;
        currentLevel++;
        // 当前层高大于最高层高时,跳出循环
        if (currentLevel > MAX_LEVEL) break;
    }
    size ++;
}

跳表的删除

跳表的删除就比较简单,找到该节点,从最底层开始删除,直到node.up为空。

代码实现

/**
 * 删除跳表
 * @param element 待删除的元素
 */
public void delete(Integer element){
    Node node = this.find(element);
    // 没有找到该元素,直接返回
    if (node.value != element) return;
    // 删除元素,将元素的左右链表建立关系
    node.left.right = node.right;
    node.right.left = node.left;
    // 判断是否有索引层,
    while (node.up !=null){
        node.up.left.right = node.up.right;
        node.up.right.left = node.up.left;
        node = node.up;
    }

}

跳表的时间复杂度

对于有序链表来说,时间复杂度为O(n),我们将链表改造成跳表之后,时间复杂度为多少呢?首先来分析对于有n个节点的链表,需要建立多少级索引?如果我们每两个节点会提取一个节点作为一个索引节点,那么第一级索引节点的个数为n/2,第二级索引节点的个数为n/4,依此类推,则第K级的索引节点的个数为n/(2^k)。

假设索引有h级,且第h级的索引节点个数为2,如下图所示。则我们可以得出n/(2^h)=2,这样可以得到h=logn-1(这里的log是指以2为底),加上链表本身的一层,则整个跳表的高度为logn。我们在跳表中查询某个数据时,如果每一层都需要遍历m个节点,那么在跳表中查询某个数的时间复杂度为O(m*log(n))。

跳表的空间复杂度

比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。我们来看看跳表的空间复杂度,假设原始链表大小为 n,每两个元素直接提取一个索引,那第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。如果我们把每层索引的结点数写出来,就是一个等比数列。这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。

以上就是跳表相关的知识,我使用双向链表来实现跳表,相对来说能够更好的理解跳表的思想,在使用是用单链表实现就可以了,单链表比双链表节约空间。

示例代码:GitHub

如果您发现文中错误,还请多多指教。欢迎关注个人公众号,一起交流学习。

个人公众号

原文地址:https://www.cnblogs.com/jamaler/p/11397338.html

时间: 2024-08-02 07:44:53

程序员,你应该知道的数据结构之跳表的相关文章

存储系统的基本数据结构之一: 跳表 (SkipList)

在接下来的系列文章中,我们将介绍一系列应用于存储以及IO子系统的数据结构.这些数据结构相互关联又有着巨大的区别,希望我们能够不辱使命的将他们分门别类的介绍清楚.本文为第一节,介绍一个简单而又有用的数据结构:跳表 (SkipList) 在对跳表进行讨论之前,我们首先描述一下跳表的核心思想. 跳表(Skip List)是有序线性链表的一种.通常对线性链表进行查找需要遍历,因而不能很好的使用二分查找这样快速的方法(想像一下在链表中定位中间元素的复杂度).为了提高查找速率,我们可以将这些线性链表打散,组

程序员修神之路--做好分库分表其实很难之一(继续送书)

菜哥,领导让我开发新系统了 这么说领导对你还是挺信任的呀~ 必须的,为了设计好这个新系统,数据库设计我花了好多心思呢 做一个系统我觉得不应该从数据库入手,应该从设计业务模型开始,先不说这个,说说你的数据库设计的优势 为了高性能我首先设计了分库 分表策略,为以后打下基础 那你的数据量将来会很大吗?分库分表其实涉及到很多难题,你了解过吗? 我觉得分库分表很容易呀 是吗? 是否需要分 说到数据库分库分表,不能一味的追求,我们要明白为什么要进行分库分表才是最终目的.现在网上一些人鼓吹分库分表如何应对了多

.Net程序员玩转Android开发---(11)页面跳转

在任何程序开发中,都会遇到页面之间跳转的情况,Android开发也不例外.这一节,我们来认识下Android项目中怎样进行页面跳转.页面跳转分为有参数和无参数页面跳转,已经接受另一个页面的返回值等.Android中页面跳转常用到的是Intent ,但是Intent不仅用做页面跳转,还可以做其他事情,例如拨打电话,发送短信,调用其他程序等.这节我们主要认识下怎样通过Intent进行页面跳转. 1.页面跳转 2.带参数页面跳转

【转】游戏程序员的数学食粮05——向量速查表

原文:http://gad.qq.com/program/translateview/7172922 翻译:王成林(麦克斯韦的麦斯威尔)  审校:黄秀美(厚德载物) 这是本系列大家盼望已久的第五篇.如果你对向量了解不多,请先查看本系列的前四篇文章:介绍,向量基础,向量的几何表示,向量的运算. 这篇速查表会列举一些游戏中常见的几何问题,以及使用数学向量解决它们的方法. 基本向量运算的完整表单 首先,先复习一下. 首先我假设你有一个可用的向量类.它的功能大部分集中在2D上,但是3D的原理相同.差别只

数据结构:跳表

1.理想情况 在一个使用有序链表描述的具有n个元素的字典中进行搜索,至多需要n次比较.如果在链中部节点加一个指针,则比较次数可以减少到n/2+1.搜索时,首先将要搜索的元素与中间节点进行比较,如果该元素较小,则仅需搜索链表的左半部分.否则,只需搜索又半部分. 以上图为例,如果要搜索的数为26,则将26先与40比较,因为26<40,因此只需要搜索40的左边元素. 而如果在左半部分和右半部分再增加一个中间指针,则可以进一步减小搜索范围(b). 初始的链称为0级链,如上图中的全部节点. 至少指向2个节

IT程序员怎么分级别,以及每个级别应该会什么内容?

前言: 这是IT修真院自问自答系列第五篇篇,同样是干货和硬广混杂.IT修真院系列 - 收藏夹,顺手推荐一下修真院的专栏,各种IT行业的真实小故事.IT修真院 - 知乎专栏 我想了想,解释这些问题,其实比不上讲一下我朋友"暗灭大人"的成长经历,这样新人们就会对自己未来几年内的大致走向有了一个对比和期待,方向有了,路也明确了,就看你想在什么地方停下来了. PS:本文依然是不对任何人负责,观点依然偏激而且绝不客观. 程序员的级别: 程序员只需要分成三个级别. 初级程序员:能够独立完成一个项目

Java程序员应该读的我认为不错的书单

一.Java书籍 新手 老是有人说Java编程思想,但是我觉得入门应该建立Java语言的一种体系,应该读一本较为浅显易懂的书,推荐先看Java JDK7学习笔记. 进阶 这时候已经基本了解相关语言架构体系,可以尝试的读下设计模式相关:Head First设计模式. 了解一本基本编程技巧:编写高质量代码:改善Java程序的151个建议. 可以继续深入的了解一些Java内部机制:Java程序性能优化. 探索内部 这个时候已经了解基本的知识,可以说基本算是Java程序员了. 应该继续了解探索Java内

怎么样才是好的程序员

要判断一个程序员是不是好的程序员,主要看他写的代码,因为程序员最重要的事是写代码.即便不去理解代码的意图,只要看一眼,好的程序员写的代码与差的程序员写的代码基本上就可以看出来.好的程序员写的代码,整洁而规范,视觉上自然有一种美感.空白错落有致,注释恰到好处,命名和排版遵守统一的规范.差的程序员写的代码则经常出现过长的函数,前后不一致的命名方式和排版,过深的嵌套结构,非常复杂的表达式,随处可见的数字等毛病.再去粗粗阅读,对好的程序员还是差的程序员就会更有把握.好的程序员写的代码,有一种精心雕琢而成

程序员生存定律-打造属于自己的稀缺性

程序员生存定律这系列的目录在这里:程序员生存定律--目录 喜欢从头瞄的,可以移步. ------------------------------------------------------------------------------- 假设说你想在江湖里谋求一定的地位,那么你可以练习独孤九剑成为超一流高手,也可以练习医术,成为绝世神医.这两者在江湖里都是有地位的,也都是稀缺的,一者是因为杀伤力,二者是因为人都有山高水长. 程序员也一样,增值也好,改善表达力也好,最终都要在某种环境下达成一