从头开始学习->java数据结构(四):受限线性表

前言

在上一篇文章中,我们讲述了线性表结构中的一般线性表,线性表结构有两种存储结构,一种是顺序存储结构,一种是链式存储结构,这两种结构主要表现形式就是数组和链表。

我们对数组和链表进行了一定程度的分析。当然这种分析并不深入,也不是很详细,但是我们起码知道了在线性结构中的一般线性表的大概。

但是除了一般线性表结构以外,线性表结构还有其他的结构,比如受限线性表和推广线性表,这两种结构,也是我们在编程中比较常遇到的两种结构,因此我们也要对这些结构进行一定的分析。

正文

本文要分析的结构是受限线性表。

那么,什么是受限线性表呢?

简单的理解,受限线性表最简单直白的理解就是在操作线性表的时候,会有一定程度的限制,不能随心所欲的进行操作。

那么,为什么会出现这种受限制的线性表结构呢?

在我们的实际应用场景中,我们发现在使用线性表结构的时候,我们有的时候总是只会使用这部分线性表结构的一部分功能,于是我们 将这部分常用的功能从线性表中抽象出来,并且封装好,让它能更好的为我们的业务场景服务。这就是受限线性表。

由于受限线性表有着很多的类型,那么这就意味着不同的类型,对线性表的操作有着不同的限制,不能一概而论。受限线性表大概有四种:

  1. 队列
  2. 串(字符串)
    这四种数据结构,都对本身的线性表结构有着不同的限制,也因此,我们需要通过对这些结构的分析,来逐渐的了解受限线性表结构。

1. 栈

或许我们都知道什么栈,但是在我分析这篇文章的时候,我还是不禁陷入了深思,什么是栈?从线性表的角度来讲,什么是栈?

栈,是限定仅在表尾进行插入和删除操作的线性表。

从这段话中,我们就能看出,栈在线性表结构的基础上做了什么限制,栈是只能在表尾进行操作的线性表。如图所示:


栈的大概模型图

我们把允许插入和删除的一端称之为栈顶,另一端称之为栈底,不含任何数据元素的栈称之为空栈。由于栈只能在表尾进行操作,所以栈又被称之为后进先出的线性表,简称为LIFO(Last In First Out)结构。

栈同样有两种存储结构,顺序存储结构和链式存储结构,这两种结构仅限于数据元素在实际物理空间上存放的相对位置,在对一般线性表的分析中,我已经对这两种存储结构进行了比较深入的分析,这里就不再多说了,如果有兴趣,可以去看我的文章《从头开始学习->java数据结构(三):一般线性表》。

1.1 栈的运算规则

一讲到栈,我们第一时间想到的应用场景是什么?

我相信大部分人会联系到JVM中的本地方法栈和java虚拟机栈,其实这部分内容我在之前的JVM系列文章《从头开始学习->JVM(七):运行时数据区(上)》中讲过。

在Java虚拟机栈中,当开始运行一个方法的时候,JVM会为这个方法开辟一个栈帧,而这个栈帧,就是我们栈的线性表结构中的数据元素,无数个数据元素组成了线性表,无数个栈帧组成了Java虚拟机栈。

而类似这种子程序的调用,在JVM中,就是采用了栈这种数据结构来完成的。

这就是一个很经典的栈的运用场景。

我们刚刚有讲到了JVM中的栈帧和栈,那么我们可以通过分析这个场景来了解栈的运算规则。

先假设一段代码,如下:

//主方法
public  static  void  main(String[]args){
    int a=10;
    //第一个方法,会在栈中开辟一个栈帧
    int middle=oneMethod(a);
}

private static int oneMethod(int a) {
    int b=5;
    //调用第二个方法,会在栈中开辟出一个栈帧
    a=a+twoMethod(b);
    return a;
}

private static int twoMethod(int b) {
    b++;
    return b;
}

我们可以看到,在主方法mian中,调用了第一个方法oneMethod(),然后在oneMethod()方法中又调用了twoMethod()方法。

在代码中,我也写了注释,会开辟出两个栈帧(ps:主方法的栈帧不计算在内),那么这两个栈帧在java虚拟机栈中是怎么处理的呢?

而且要注意的是,第一个方法和第二个方法不是独立的,第一个方法oneMethod()是依赖于第二个方法twoMethod()的,也就是,如果要运算的话,那么要先运算出第二个方法twoMethod()的最终返回值。

先看图:


栈的运算流程

从图中我们就可以看出来了,首先oneMethod()方法入栈,然后twoMethod()方法入栈,再然后twoMethod()方法出栈,进行运算,得出twoMethod()的方法返回值之后,返回给oneMethod()方法后,再将oneMethod()方法出栈,计算出oneMethod()的方法返回值后,返回给主方法main()。

这就是栈的运算规则。

1.2 栈的应用场景

当然,栈的应用场景不仅仅是子程序调用这一块,还有很多的场景,总的来说,应用场景主要如下:

  1. 子程序的调用

    JVM中运算java方法的时候,就是通过栈这种数据结构来实现的。

  2. 处理递归调值

    在递归程序中,也是和子程序调用有着相同的特性,都是需要最后调用的方法需要得出值来,也因此适用于后进先出的栈结构。

  3. 表达式的转换与求值

    在我们的程序中,当我们在写加减乘除(包括了我们的小括号、中括号和大括号)的代码的时候,我们经常使用的是中缀表达式来写,但是计算机都会把这些中缀表达式转换为计算机更为易懂的后缀表达式,然后来计算。而这种计算后缀表达式,则需要通过栈的后进先出特性来实现的。

  4. 二叉树的遍历

    二叉树的遍历,有先序遍历,中序遍历,后序遍历三种思路,一般在中序遍历的时候,由于中序遍历的特点是在第二次遇到该元素时才会输出,所以我们可以使用栈来进行操作。

  5. 图形的深度优先(depth-first)搜索法

    图的深度优先搜索(Depth First Search),是图的一种搜索方法,和树的先序遍历比较类似。它的思想:假设初始状态是图中所有顶点均未被访问,则从某个顶点v出发,首先访问该顶点,然后依次从它的各个未被访问的邻接点出发深度优先搜索遍历图,直至图中所有和v有路径相通的顶点都被访问到。 若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。显然,深度优先搜索是一个递归的过程,自然会使用栈这种数据结构来解决。

1.3 栈的性能分析

不管是顺序栈还是链式栈,我们存储数据只需要一个大小为 n 的数组就够了。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O(1)。

入栈和出栈只会影响到最后一个元素,不涉及其他元素的整体移动,所以无论是以数组还是以链表实现,入栈、出栈的时间复杂度都是 O(1)。

所以看到,栈的性能是非常高的,所以才会被应用到对性能要求比较的程序运算中。

2. 队列

什么是队列?

相信很多朋友都在地铁站排过队,当我们想要进地铁的时候,我们会在地铁口排队,然后进入到地铁中。也就是说,我们如果想要进地铁站,那么我们必须在地铁口队伍最后面排队,也必须从队伍最前面进入到地铁。这就是一个典型的队列。

队列,是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。

相比较于一般线性表,队列的限制是什么,我相信大家都很清楚了,队列的限制在于,它不能随意从任何地方插入或者删除数据元素,它限制着我们如果从线性表的一端插入了数据元素后,那么删除数据元素的操作就必须在线性表的另一端了。

假如我们依次将 A,B,C,D,E,F 这6个英文字母插入队列,那么最后插入 F 且删除 A 的时候,情况就如图所示:


队列的大概模型

在队列中,允许插入的一端称为队尾,允许删除的一端称为队头,队列是一种先进先出的线性表,简称FIFO(First in First Out)。

在队列中,可以将【队头】和【队尾】理解为两个指针,这两个指针指向了队列的两端。

在业务场景越来越复杂的限制,单纯的队列结构已经无法完全满足,于是在队列的基础上就衍生了两种变种,取名叫做 双端队列

  1. 删除操作限制在表的一端进行,而插入操作允许早表的两端进行
  2. 插入操作限制在表的一端进行,而删除操作允许在表的两端进行

2.1 队列的运算规则

什么是队列的运算规则?

image.png

只是初步看一看的话,我们发现,队列的出队列和入队列都是比较简单易懂的操作,但是,这一切如果和我们的存储结构关联在一起的话,我们会发现理解的难度就会增加。

我们先来看一下 顺序存储结构 的队列。

先看图:


image

image

这是顺序存储结构的队列,我们可以看到数据元素A已经出队列了,然后B,C,D三个数据元素都向前移动了一位数,将空位补上,在讲究性能的现代,计算机肯定是不会采用这样的这样的数据结构的,因为性能损耗大到计算机是基本无法接受的。

那么,如果解决这个问题呢?

这个时候,一个设想就出现了,假如在A元素出队列的时候,所有的剩下的元素B,C,D元素不往前移动,但是却把【队头】这个指针移动到B元素,这样不也就是等于数据不会留有空位了吗?

如图所示:


image

image

可以看到,【队头】指针往后移动了一位,到了数据元素B身上,这样的话,【队头】指针和【队尾】指针就不存在空位了。

但是又会产生一个问题,假如说,我们的指针【队头】和【队尾】,是随着数据元素来移动的,那么如果出现了下面那种情况该怎么办呢?如图:

image

假如又有一个数据元素E要入队列了,但是发现队列里面已经没有了空间了,但是指针【队头】的前面明明还有空间(ps:由于之前其他的数据元素出队列的时候,我们的指针【队头】是向后移动了,所以导致了【队头】指针前面还有着很大的空间留了下来),这岂不是是一种很大的浪费?

这种情况就是队列的假溢出。

那么,如何去解决这种假溢出的情况呢?这个时候循环队列就出现了。

什么是循环队列呢?我们可以先看一张图(ps:由于我的画图能力不行,所以我在网上找了一张图片,如下):

image.png

我们可以看到,这个队列的内存的实际空间是形成了一个循环,也就是说,如果有我刚刚说的那种假溢出的情况的发生,那么我们的队尾就可以继续往前一步了(实际上是到了另一边了),因为前面是有内存空间的,不会造成数组越界问题的产生。

如下图所示:

image.png

2.2 队列的应用场景

一般来说,我们对消息队列对应用还是比较广泛的,尤其在我们的程序中,有的地方大量的使用了消息队列的形式传递消息,但是一般来说,使用消息队列主要有以下几个场景,现在我来一一进行分析。

在分析的过程中,我举的例子,主要还是以消息队列为具体应用产品来举例,这是在开发中,我们用到的最多的关于队列的一个具体应用。

  1. 解耦:

    说道这个,就必须说一下我们在订单系统里面的一个场景,因为我们的订单服务是切分为了三个服务了,用户订单系统,师傅订单系统,总包服务商订单系统。而用户会下单给总包,总包会挑选一个师傅,下单给师傅,当师傅完成了任务,那么他肯定要通知到总包的订单系统,说我这边已经服务完成了,但是有一天,产品的需求改了,他需要师傅在服务完成的时候,不仅仅是要通知到总包服务商,也要通知到用户,那如果我们的系统使用的是调用接口API的形式,那么我们肯定要去添加对用户代码的依赖,并且调用用户的代码,而且用户也必须新增一个接口,这样的话耦合性太大,而且我们还必须考虑到如果一个服务挂掉了会怎么办?如果我们是发送MQ消息,那么我们只需要在用户端订阅MQ消息就可以了。所以使用MQ的第一个优势就是为了解耦。

  2. 异步:

    比如说一个用户,他给一个师傅下单,但是这是一个微服务的架构,订单系统都是有用户订单系统和师傅订单系统的,那么如果这个下单的过程,它不仅仅是要调用用户订单服务,还要调用师傅订单服务,而且它可能还会调用其他类似的关联的服务,当服务一多,那么也就意味着要向多个数据库插入数据,那肯定就意味着响应速度大大的降低了,对用户的体验感是非常不好的,那这个时候如果我们如果使用MQ的话,那么我们可能只需要向用户订单服务的数据库新增数据,然后就可以直接返回了,而需要给调用其他系统的情况,我们转成MQ来发送,这样的话,响应速度将会大大的提高了。

  3. 削峰:

    因为我们的系统,都是在白天的时候用户的使用比较频繁,但是晚上的时候,用的就比较少,但是如果我们的出现的最大的并发请求是2万条,但是晚上的时候,普遍又只有几百条,如果我们是将系统的数据库的性能设置到最高处理2万条,那么在晚上的时候,那性能无疑是大大的浪费了。所以我们需要MQ来进行削峰处理,也是用户每秒发过来2万条请求的时候,我们都是先写入到MQ里面,然后服务器只会恒定的每秒钟消费5000条,那么在高峰期的时候,服务器也能抗住高并发不会宕机,也不会对性能造成浪费。

  4. 日志:

    日志处理是指将消息队列用在日志处理中,比如Kafka的应用,解决大量日志传输的问题。架构简化如下:

    image.png
    1. 日志采集客户端,负责日志数据采集,定时写受写入Kafka队列
    2. Kafka消息队列,负责日志数据的接收,存储和转发
    3. 日志处理应用:订阅并消费kafka队列中的日志数据

    这样子使用消息队列来进行日志处理,是非常高效的,而且也剥离了大量日志对我们服务的影响。类似这种场景的还有消息通讯,通过队列来进行消息之间的有序传递。

2.3 队列的性能分析

队列是可以处理百万级别的消息传递的,也就是,仅仅作为一个传递消息的队列,队列的性能是非常高的,当然,队列也有它的缺点,比如说可能会导致消息丢失,有优势自然也有着一定的劣势,这个是没有办法避免的。

我们也要看到,队列在互联网的发展历程中,也在逐步的进化和完善,尤其是消息队列的发展,更是在大数据时代里面,占据了不可动摇的地位,因此,作为一个受限线性表,我们要对队列这种数据模式有一定的了解。

3. 堆

在看到堆也被列入到这里的时候,很多看客可能就有点奇怪了,一般来说,我们说的堆通常是指一个完全二叉树的数组对象,这种堆毫无疑问不是线性结构,更不属于受限线性表了,这种堆是一种典型的树状结构。

但是,在计算机中,还有一种堆,就是java虚拟机中的java堆,这种堆我个人猜测是线性结构的,但是当前还没有找到什么资料证明这种堆是线性结构,因此先暂不讲解。

4. 串

串,这是在程序编程中非常常见的一种数据结构,在java语言中,我们常常将串称之为字符串,那么,当我们以一个数据结构的形式来看待串的话,是如何看待的呢?

如果对串有一定的了解,那么必然就能理解为什么串是属于线性表结构了,但是我们还是要从最基本的概念和逻辑来讲解串。

串有几个基本概念是需要搞明白的,如下:

  1. 串的基本概念:是零个或多个字符组成的有限序列。记作: S=“a1a2a3...”,其中S是串名,ai(1≦i≦n)是单个,可以是字母、数字或其它字符。
  2. 串值:双引号括起来的字符序列是串值。
  3. 串长:串中所包含的字符个数称为该串的长度。
  4. 空串(空的字符串):长度为零的串称为空串,它不包含任何字符。
  5. 空格串(空白串):构成串的所有字符都是空格的串称为空白串。注意:空串和空白串的不同,例如“ ”和“”分别表示长度为1的空白串和长度为0的空串。
  6. 子串(substring):串中任意个连续字符组成的子序列称为该串的子串,包含子串的串相应地称为主串。
  7. 子串的序号:将子串在主串中首次出现时的该子串的首字符对应在主串中的序号,称为子串在主串中的序号(或位置)。特别地,空串是任意串的子串,任意串是其自身的子串。
  8. 串相等:如果两个串的串值相等(相同),称这两个串相等。换言之,只有当两个串的长度相等,且各个对应位置的字符都相同时才相等。

在java语言中,字符串不是一种基本数据类型,因为java本身是没有内置的字符串类型的,而是在标准java类库中提供了一个String类来创建和操作字符串,而在java中,使用String创造字符串的方法,被广泛的运用在java编程中。

通过对上面的概念的理解,我们大概懂得了字符串是什么也能知道,串是一类特殊的线性表,串只是其组成节点都是单个字符而已,这也叫能理解为什么串叫受限线性表了,因为串的取值范围受限了。

因为串的存储形式有异于其他的数据结构,所以接下来我们要对串的存储形式做一个大概的分析。

4.1 串的存储形式

因为串是一个比较常用的数据结构,在java语言中,可以说字符串是经常使用的,所以我们可以这么理解,串的存储方式主要取决于将要对串所要进行的操作。

串在计算机中,主要有3种存储形式:

  1. 定长顺序存储
  2. 堆分配存储方式
  3. 块链存储方式
    这三种存储形式,基本可以完成大部分业务对串的要求,接下来我们可以一个一个的对串的存储形式进行操作。

4.1.1 定长顺序存储

定长顺序存储,这就是数据结构的存储方式中最为常见的,也是我们经常提及的一种存储方式,这种方式,就是用一组地址连续的存储单元来存储串中的字符序列的,按照字符串预定义的大小,为每一个定义的串分配一个固定长度的存储区域。

说白了,这种存储形式,就是使用一个定长的数组结构来定义这个字符串。

这种分配形式当然有着极大的缺点,因为事先需要预定义串的最大长度,但是这在实际的代码编程中是很难估计的,毕竟,如果每一次创建一个字符串之前,都要考虑它的最大长度,无疑是非常麻烦的,也不切实际,因为如果业务一旦改动,那么这个字符串的长度也得在创建的时候改动。

而且由于事先定义了长度,关于串的一些操作,比如两个串连接在一起,或者插入一个字符到一个串中等等,这些操作都要受到限制。

4.2 堆分配存储形式

堆分配存储形式,和定长顺序存储形式有着很大的相似性,都是一开始的时候以一组地址连续的存储空间来存储字符串的值,但是其所需要的的存储空间可以在程序执行的过程中有动态改变

这种动态改变在java语言中,一般都是使用StringBuilder和StringBuffer这两个数据结构来操作的,底层的操作都是调用了Arrays.copyOf(value, newCapacity)方法,代码如下:

public static char[] copyOf(char[] original, int newLength) {
        char[] copy = new char[newLength];
        System.arraycopy(original, 0, copy, 0,Math.min(original.length, newLength));
        return copy;
}
    

4.3 块链存储形式

串的链式存储结构和线性表的串的链式存储结构类似,采用单链表来存储串,结点的构成是:

  1. data域:存放字符,data域可存放的字符个数称为结点的大小;
  2. next域:存放指向下一结点的指针。

若每个结点仅存放一个字符,则结点的指针域就非常多,造成系统空间浪费,为节省存储空间,考虑串结构的特殊性,使每个结点存放若干个字符,这种结构称为块链结构。

如图所示:

image.png

在这种存储结构下,结点的分配总是完整的结点为单位,因此,如果是为了使一个串能存放在单个结点(块)中,那么在串的末尾就会填上不属于串值的特殊字符,以表示串的终结。

那么当一个块(结点)内存放多个字符时,往往就会使操作过程变得较为复杂,如在串中插入或删除字符操作时通常需要在块间移动字符。

4.2 java中的字符串

在java中,内存分为了两个区域,堆Heap,栈Stack,栈stack用于运行(包括变量引用和逻辑运行),堆heap 用于存储变量实体。java中对String对象特殊对待,所以在堆heap区域分成了两块,一块是String constant pool(字符串常量池),用于存储java字符串常量对象,另一块用于存储普通对象及字符串对象。

而String的创建有两种方法,如下:

  1. String a="abc123";
  2. String b=new String("abc123");

首先看第一种,String a="abc123",jvm会首先在String constant pool(字符串常量池)中寻找是否已经存在"abc"常量,如果没有则创建该常量,并且将此常量的引用返回给 a。

如果已有"abc" 常量,则直接返回String constant pool(字符串常量池)中的“abc” 的引用给 a,此创建方法会在String constant pool(字符串常量池)中创建对象。

然后看第二种,String b=new String("abc123"),这种方法,jvm不会在String constant pool(字符串常量池)中创建字符串对象,而是会在堆中的其他区域创建字符串对象,并把该对象的引用返回给 b。要注意的是,这个时候,JVM也不会把创建的"abc”对象加入到String constant pool(字符串常量池)中。

4.3 java中字符串的案列

现在我们先假设,在我们的String constant pool(字符串常量池)中已经有了“abc123”这个字符串,那么我们接下来执行如下这段代码:

    public static void main(String[]args){
        String a="abc123";
        String b=new String("abc123");
    }

首先,执行第一段代码的时候,对象a,必然是查询到了字符串常量池之后,发现存在了字符串“abc123",那么就会将这个对象的引用指向a。

接下来执行第二段代码,因为上面我们有讲到,new String()的时候,必然会去java堆中不是字符串常量池的其他地方创建对象,也就是说,这段代码会在java普通堆中创建一个“abc123"对象,并且将这个对象的引用给到b,要注意的是,JVM不会将这个在java普通堆中创建的“abc123”对象在字符串常量池中创建了。

那么问题来了,在java普通堆中有一个字符串“abc123"对象,但是在字符串常量池中也有一个字符串“abc123"对象。JVM会容忍堆里面有两个一模一样的对象吗?

所以,第二段代码String b=new String("abc123"),中产生的关系应该是如下图所示:

image.png

最后得出的关系就是如下了:

b ——>"abc123"(java普通堆)——>"abc123"(字符串常量池)

(ps:——>这个符号的意思是“指向”)

总结

受限线性表,本身也是线性表的一种类型,但是在线性表的基础上,受限线性表总是有着部分地方受到了限制,这种原因也很好理解,因为在实际的业务操作中,线性表一些功能是没有用的,或者说是可以封装起来的,将这些业务操作的情况抽象出来,这就是受限线性表的种种数据结构诞生的原因。

我对受限线性表的分析就到这里告一段落了,关于线性表,还有着推广线性表这个种类没有分析,下一篇博文就是关于推广线性表的,但是最近工作太忙了,在做一个业务非常难的项目的项目管理,也不知道什么时候能够将下一篇博文写出来。

就到这儿吧。

你可能感兴趣的:(从头开始学习->java数据结构(四):受限线性表)