🎇🎇🎇作者:
@小鱼不会骑车
🎆🎆🎆专栏:
《数据结构》
🎓🎓🎓个人简介:
一名专科大一在读的小比特,努力学习编程是我唯一的出路😎😎😎
栈和队列
一. 栈的基本概念
栈:一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。
在我们的常见生活中,我们在使用浏览器啊,写代码啊,或者说制作视频都会发现有一个返回键,例如我们在用文件夹在访问内容时,不小心点错文件,进入了一个不是自己想要查找的文件时,我们便可以通过上方的返回键来返回上一个页面。
这里涉及到的就是栈,也是栈经常使用的场景,当然!不同的程序他们的底层会用不同的代码来实现,但是不变的就是栈这个思想,我们只需要了解栈的这个数据结构就行。
1. 栈的定义
压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶
出栈:栈的删除操作叫做出栈,出数据在栈顶
栈在现实生活中的例子:
2. 栈的常见基本操作
方法 | 功能 |
---|---|
Stack() | 构造一个空的栈 |
E push(E e) | 将e入栈 |
E pop() | 将栈顶元素出栈并返回 |
E peek() | 获取栈顶元素 |
int size() | 获取栈中有效元素个数 |
boolean empty() | 检测栈是否为空 |
二. 栈的顺序存储结构
1. 栈的顺序存储
采用顺序存储的栈称为顺序栈,它利用一组地址连续的存储单元存放自栈底到栈顶的数据元素,同时附设一个指针(top)指示当前栈顶元素的位置。
top的第一种初始化方法
对于top其实有两种初始化的方法,一种就是初始化为0,
public class MyStack { int []array; int top;//记录栈顶位置 int capacity;//容量 public MyStack(int x) { this.top=0;//初始化为0 array=new int[x];//初始化一个x大小的数组 capacity=x; } public MyStack() { this.top=0;//初始化为0 array=new int[4];//默认初始化为4个大小的数组 capacity=4; }
对于top
初始化为0,其实就是每次栈顶添加新元素时,都是先进行赋值,再top++
,并且还需要对栈满进行判断(top初始化为0时添加元素和判断栈满的条件如下)
public void push(int x) { //判断栈满 if(top==capacity) { //扩容 } array[top]=x; top++; }
如图:
top的第二种初始化方法
当我的top
初始化为-1时,由于我们添加元素需要从0下标开始添加,所以我们需要先top++
,再赋值,此时我们的top记录的就是栈顶元素的下标,那么判满的话,就需要和capacity-1
进行对比
public class MyStack { int []array; int top;//记录栈顶位置 int capacity;//容量 public MyStack(int x) { this.top=-1;//初始化为-1 array=new int[x];//初始化一个x大小的数组 capacity=x; } public MyStack() { this.top=-1;//初始化为-1 array=new int[4];//默认初始化为4个大小的数组 capacity=4; } public void push(int x) { //判断栈满 if(top==capacity-1) { //扩容 } top++; array[top]=x; }}
如图:
此时的top就是栈顶元素对应的下标
2. 栈的基本方法
(1) 初始化
//两个构造方法 public MyStack(int x) { this.top=-1; array=new int[x];//初始化一个x大小的数组 capacity=x; } public MyStack() { this.top=-1; array=new int[4];//默认初始化为4个大小的数组 capacity=4; }
(2) 判空+判满(top初始化为-1)
//判空,当我的top为-1时就是没有元素 public boolean empty(){ return top==-1; } //判满,当我的top+1==capacity时就代表栈满了 public boolean full() { return top+1==capacity; }
(3) 进栈
public void push(int x) { //判断栈满 if(full()) { //每次栈满扩容二倍 array=Arrays.copyOf(array,2*capacity); capacity*=2; } top++; array[top]=x; }
(4) 出栈
//出栈 public int pop() { if(empty()) { throw new ArrayEmptyException("栈空"); } //先返回栈顶元素再-- return array[top--]; } //自定义异常,当栈为空时抛出异常class ArrayEmptyException extends RuntimeException{ public ArrayEmptyException() { }//构造方法 public ArrayEmptyException(String message) { super(message); }}
(5) 读取栈顶元素
public int peek() { if(empty()) { throw new ArrayEmptyException("栈空"); } //直接返回栈顶元素 return array[top]; }
3. 进栈出栈变化形式
我们现在已经简单了解了栈的特性,那么大家思考一下,这个最先进栈的元素只能是最后出栈嘛?
答案是不一定的!因为栈虽然限制了线性表的插入和删除的位置,但是并没有对元素的进出进行时间限制,也可以理解为,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素出栈就可以。
举例来说,如果我们现在是有 3个整型数字元素1,2,3 依次进栈,会有哪些出栈次序呢?
- 第一种(最容易理解的):1,2,3依次进栈,再依次出栈,出栈次序是3,2,1.
- 第二种:1进栈,1出栈,2进栈,3进栈,3出栈,2出栈,出栈次序是1 ,3, 2.
- 第三种:1进栈,1出栈,2进栈,2出栈,3进栈,3出栈,出栈次序是1 ,2 ,3.
- 第四种:1进栈,2进栈,2出栈,1出栈,3进栈,3出栈,出栈次序是2, 1, 3.
- 第五种:1进栈,2进栈,2出栈,3进栈,3出栈,1出栈,出栈次序是2 ,3, 1.
- 那我们的出栈次序可以是3 1 2嘛?答案是不可以,因为3进栈后,此时栈内一定是有1,2,此时栈顶是2,并且1在2的下面,由于只能从栈顶弹出,又因为不可以直接跳过2去拿到1,所以此时不会出现 1 比 2 优先出栈的情况。
对于栈的变化,光三个元素就有五种出栈次序,那么五个元素,甚至更多的元素,那么它的出栈变化会更多,所以这个知识点我们一定要弄明白!
这里有道题,大家可以试着做一下:
- 若进栈序列为 1,2,3,4 ,进栈过程中可以出栈,则下列不可能的一个出栈序列是()
A: 1,4,3,2 B: 2,3,4,1 C: 3,1,4,2 D: 3,4,2,1
答案是:c
4. 共享栈(双栈)
其实对于共享栈,就是尽量减少内存的浪费,就像吃饭一样,煮方便面一袋不够吃,两袋吃不完,那你就可以找一个小伙伴一起吃,然后煮三袋,这样就不会吃不完了,并且还都能吃饱,这里开个玩笑,真正的用法在下面。
(1) 共享栈的概念
利用栈底位置相对不变的特征,可让两个顺序栈共享一个一维数组空间,将两个栈的栈底分别设置在共享空间的两端,两个栈顶向共享空间的中间延伸,如下图所示
当我的top0==-1
时,0号栈底为空,当我的top1==MaxSize
时,1号栈底为空,当我的top0+1==top1
时,判断为栈满,0号栈进栈时top0
先加一再赋值,1号栈进栈时top1
先减一再赋值,0号出栈时先保存当前元素再减一,1号栈出栈时和0号栈的出栈操作恰好相反。
(2) 共享栈的空间结构
代码如下:
public class SharedStack { int[]array;//定义一个数组成员 int top0;//记录0号栈的栈顶 int top1;//记录1号栈的栈顶 //构造方法,可以自己设置大小的数组 public SharedStack(int x) { array=new int[x]; top0=-1; top1=x; } public SharedStack() { //由于共享栈不好扩容,所以直接开辟50个大小的数组 array=new int[50]; top0=-1; top1=4; }}
(3) 共享栈进栈
由于是双端栈,所以我们需要对它调用不同的栈进行不同的写法,也就是需要判断是0号栈还是1号栈,分别写出对于的push.
public void push(int x,int stackNumber) { //判断栈是否满了 if(top0+1==top1) { exit(0); } //通过stackNumber来控制调用0号栈或1号栈 if(stackNumber==0) { top0++; array[top0]=x; } else { top1--; array[top1]=x; } }
(4) 共享栈出栈
public int pop(int stackNumber) { //判断调用几号栈 if(stackNumber==0) { //判空 if(top0==-1) { System.out.println("栈空"); exit(0); } else { return array[top0--]; } }else if(stackNumber==1){ //判空 if(top1==array.length) { System.out.println("栈空"); exit(0); }else { return array[top1++]; } } System.out.println("stackNumber输入错误"); //输入的stackNumber错误所以返回-1 //我们认为-1就是输入错误 return -1; }
共享栈常用场景
一般的常用场景是,两个栈的空间需求有相反关系时,也就是一个栈增长,一个栈缩小的情况,例如你去买菜,你买了一斤白菜,那么卖家就少了一斤白菜,就这样我进,你出,才会使两栈空间的存储方法有更大的意义,否则如果我一直买菜,但是卖家也在一直进菜,那么很快就会因为栈满而溢出了。
当然,这个只是针对两个数据类型相同的栈设计的一个技巧,如果是不相同的栈,那么这么做反而会使问题变得更加复杂,所以大家要注意!
三. 栈的链式存储结构
1. 链栈
我们不仅可以通过顺序表实现栈,也是可以通过链表来实现的,但是有个前提,因为我们的顺序表实现的栈,它的插入和删除时间复杂度是O(1),那么如果想通过链表来实现栈,那么我们就需要考虑时间复杂度能否达到O(1),我们如果是通过双向链表来实现栈的话,因为双向链表本身含有尾结点的指针,所以它的插入和删除的时间复杂度是O(1),那么我们可以通过单链表来实现嘛?
答案也是可以的 !
我们可以通过头插头删来实现栈,由于我们单链表的头插,头删的时间复杂度都是O(1),并且我们的头插头删也满足了栈的先进后出的特性. 在链栈没有结点时,我们规定此时的head指向null.
链栈的优点
由于我们是通过链表来实现的栈所以可以称为链栈,链栈几乎不会存在栈满的现象除非内存已经没有可以使用的空间了,如果真的发生,那么说明此时计算机已经内存被占满处于即将死机崩溃的情况,而不是这个链栈是否溢出的问题。
链栈的优点:
- 便于多个栈共享存储空间,提高内存利用率。
- 几乎不会存在栈满的情况
2. 链栈的基本方法
(1) 链栈的入栈
链栈的结构代码:
public class MyLinkedStack { static class Node{ //单链表需要的next和val public Node next; public int val;//构造方法 public Node(int val) { this.val = val; } } //成员变量head public Node head; }
压栈/入栈:
public void push(int x) { //创建一个新节点 Node node=new Node(x); //不管我的head是否为空,都可以将node的下一个结点指向head node.next = head; //head成为新结点 head=node; }
(2) 链栈的出栈
用变量n保存要删除结点得值,头节点指向下一个结点,返回n.
public int pop() { //判空,如果为空返回-1 if(empty()) { return -1; } //记录头节点的值 int n=head.val; //头指针指向下一个结点 head=head.next; return n; }
3. 对比链栈和顺序栈
链栈的进栈push和出栈pop操作都很简单,时间复杂度均为O(1)。
对比一下顺序栈与链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取时定位很方便,而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以它们的区别和线性表中讨论的一样,如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。
6
6
四. 栈的应用——递归
1. 递归的定义
我们在写递归时需要注意的就是边界条件,一个递归必须要具有的就是边界条件,如果没有,那么递归将会一直进行下去,直到内存被栈满,最后程序崩溃。
我们用求数字的阶乘举例,例如我们想要求5的阶乘,那么我们可以写一个函数.
public static int func(int n) { //递归打印n的阶层 if(n==1) { return 1; } //每次返回当前的n*前一个值 return n*func(n-1); } public static void main(String[] args) { System.out.println(func(5)); }
如果用画图解释就是:
必须注意递归模型不是能是循环定义的,其必须满足下面的条件
- 递归表达式(递归体)
- 边界条件(递归出口)
递归的优点就是能够将原始问题转化为属性相同单规模较小的问题。
在递归调用的过程中,系统为每一层的返回点、局部变量、传入实参等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出等。而其效率不高的原因是递归调用过程中包含很多重复的计算。
如下图:
如图可知,程序每往下递归一次,就会把运算结果放到栈中保存,直到程序执行到临界条件,然后便会把保存在栈中的值按照先进后出的顺序一个个返回,最终得出结果。
五. 栈的应用——逆波兰表达式求值
逆波兰表达式求值也可以叫做后缀表达式求值,我们把平时所用的标准四则运算表达式,也就是例如 " ( A + B )* C / ( D - E ) "叫做中缀表达式,因为所有的运算符号都在俩数字之间。
表达式求值是程序设计语言编译中一个最基本的问题,它的实现是栈应用的一个典型范例,中缀表达式不仅依赖运算符的优先级,而且还要处理括号。
相反:对于后缀表达式,它的运算符在操作数的后面,在后缀表达式中已经考虑了运算符的优先级,没有括号,只有操作数和运算符。例如上述讲到的中缀表达式( A + B )* C / ( D - E ),它对应的后缀表达式就是A B + C * D E - /。
后缀表达式计算规则:从左到右遍历表达式的每个数字和符号,遇到是数字就进栈,遇到是符号,就将处于栈顶两个数字出栈,进项运算,运算结果进栈,一直到最终获得结果。
后缀表达式 A B + C * D E - /求值的过程需要 步,如下表所示:
六. 栈的应用——中缀表达式转后缀表达式
前面已经对中缀表达式进行了大概了解,也就是所有的运算符号都在两数字的中间,现在我们的问题就是中缀到后缀的转化。
规则:从左到右遍历中缀表达式的每个数字和符号,若是数字就输出,即成为后缀表达式的一部分;若是符号,则判断其与栈顶符号的优先级,是右括号或优先级低于栈顶符号(乘除优先加减)则栈顶元素依次出栈并输出,并将当前符号进栈,一直到最终输出后缀表达式为止。
例:将中缀表达式a + b − a ∗ ( ( c + d ) / e − f ) + g 转化为相应的后缀表达式。
分析:需要根据操作符的优先级来进行栈的变化,我们用icp来表示当前扫描到的运算符ch的优先级,该运算符进栈后的优先级为isp,则运算符的优先级如下表所示[isp是栈内优先( in stack priority)数,icp是栈外优先( in coming priority)数]。
我们在表达式后面加上符号‘#’,表示表达式结束。具体转换过程如下:
即相应的后缀表达式为a b + a c d + e / f − ∗ − g +。
从刚才的推导中你会发现,要想让计算机具有处理我们通常的标准(中缀)表达式的能力,最重要的就是两步:
- 将中缀表达式转化为后缀表达式(栈用来进出运算的符号)
- 将后缀表达式进行运算得到结果(栈用来进出运算的数字)
一. 队列的基本概念
1. 队列的定义
队列(Queue) 只是允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。
如图:
进出队列如图
进队列:
出队列:
我们一般在医院看见的出号机就是通过队列来实现的,按顺序取号,先取号的就会先被叫到,这有个好处就是,省去了复杂的排队,在你领完号之后就可以找个地方休息了,坐等叫号就行。
队头(Front):允许删除的一端,又称队首。
队尾(Rear):允许插入的一端。
空队列:不包含任何元素的空表。
2. 队列的常见基本操作
方法 | 功能 |
---|---|
boolean offer(E e) | 入队列 |
E poll() | 出队列 |
peek() 获取队头元素 | 获取队头元素 |
int size() | 获取队列中有效元素个数 |
boolean isEmpty() | 检测队列是否为空 |
二. 队列的顺序存储结构
1.顺序队列
队列的顺序实现是指分配一块连续的存储单元存放队列中的元素,并附设两个指针:队头指针 front
指向队头元素,队尾指针 rear
指向队尾元素的下一个位置。
队列的顺序存储类型可以描述为:
public class MyQueue { int []array; int front;//记录队头 int rear;//记录队尾 public MyQueue(int n) { //控制一次开辟多少内存 array=new int[n]; } public MyQueue() { //默认开辟四个内存 array=new int[4]; }}
初始状态:(队列为空):front=rear=0
。
进栈操作:队不满时,先给队尾下标对应的数组赋值,再队尾指针加1。
出栈操作:队不为空,先取出队头的元素,再队头指针减1。
进栈操作如图:
出栈操作:
假溢出:队列出现“上溢出”,然而却又不是真正的溢出,所以是一种“假溢出”。
大家看下图:在我的队尾指针=5时,说明队列已经满了,但是在下标为0和1的位置还是空闲的,我们称这种现象为“ 假溢出 ”(所以一般的队列会使用循环队列或者链表实现)
2.循环队列
解决假溢出的方法就是后面满了,就再从头开始,也就是头尾相接的循环。我们把队列的这种头尾相接的顺序存储结构称为循环队列。
当队首指针front = array.length-1后,再前进一个位置就自动到0,这可以利用除法取余运算(%)来实现。
下面是循环队列需要用到的一些公式,后续介绍。
初始时:front =rear=0。
判空:front=rear
判满:(rear+1)%array.length
队首指针进1:front = (front + 1) % array.length
队尾指针进1:rear = (rear + 1) % array.length
队列长度:(rear - front + array.length) % array.length
循环队列如图(下图中下标应该是从0开始,小鱼不小心写成了1,但是对于判断下面的推论没什么影响):
我们需要思考一个问题:如何判断这个循环队列是空队列,还是满队列?
我们可以看到,在 rear==front
时,即使空队列又是满队列,这就很麻烦,该如何解决呢?
-
方法(1):我们可以创建一个成员变量
size
,只有当size==队列长度时
,才判满,当size==0
为空队列。 -
方法(2) :我们也可以牺牲一个空间:
-
方法(3):类型中增设tag 数据成员,以区分是队满还是队空。tag 等于0时,若因删除导致 front = = rear ,则为队空;tag 等于 1 时,若因插入导致 front == rear ,则为队满。
这里着重讲解第二种方法(在这里将下标更正为0)!
就是当我们的尾指针+1==头指针时(此时的判断不完全正确),说明满了,虽然这样会浪费一个空间,但是对于程序运行的效率和降低书写代码的难度都是有不错的效果的!
正确判满的公式应该是:(rear+1)%array.length==front
上图举例。此时我的rear=7,但是7+1==8并不等于front,但是这个队列明明确确的已经满了,解决方法就是 (rear + 1 ) % 数组长度,当我的rear=7时,(7+1)%8=0,又因为此时的 front=0,所以此时循环队列是满的。
再举个例子:
如上图:此时rear=1,并且此时的队列是满的,那我们就套公式(1+1)%8=2,此时front=2,说明队列是满的!
其实%的主要作用就是,每次当我的 rear 为数组最后一个元素的下标时,当他需要再前进一个位置时,便让他重新回到0下标。
判空: rear== front
判满:(rear+1)%array.length == front
求队列长度:上述的公式是 (rear - front + array.length) % array.length
怎么理解呢?依旧是看图,这里分为普通情况和特殊情况
普通情况&特殊情况:
特殊情况指的就是当我的差为负数时,通过 (rear-front+array.length)%array.length 这个公式,对于差是正数时没有影响,但是对于差是负数时,便可以求出正确的元素个数。
3. 循环队列的常见基本算法
(1)循环队列的顺序存储结构
class SqQueue{ int []array; int front;//头指针 int rear;//尾指针 public SqQueue(int n) { //控制一次开辟多少内存 array=new int[n]; } public SqQueue() { //默认开辟四个内存 array=new int[4]; } }
(2) 循环队列判空
public boolean isEmpty() { //当相等时为空 return rear==front; }
(3)求循环队列长度
public int QueueLength() { //当 return (rear-front+array.length)%array.length; }
(4)循环队列入队
//判满 public boolean full() { //公式如上 return (rear+1)%array.length==front; } public void offer(int x) { if(full()) { return; } //先插入 array[rear]=x; //当rear为数组的最后一个元素时,如果进一,就让他重新回到0结点 rear=(rear+1)%array.length; }
(5)循环队列出队
public int poll() { //如果为空还要删除就直接终止程序 if(isEmpty()) { exit(0); } int n=array[front]; //如果是数组的最后一个元素,那么进一就重新回到0下标 front=(front+1)%array.length; return n; }
三. 队列的链式存储结构
1. 链队列
队列的链式存储结构表示为链队列,它实际上是一个同时带有队头指针和队尾指针的单链表,只不过它只能尾进头出而已.
并且链队列的插入和删除的时间复杂度都是O(1),也可以通过双向链表实现。
如图:
2. 链队的常见基本算法
(1)链队列存储类型
class LinkQueue { static class Node{ Node next; int val; public Node(int val) { this.val = val; } } public Node front;//头指针 public Node rear;//尾指针 }
(2)链队列入队
public void offer(int x) { Node node=new Node(x); if(front==null) { front=node; } else { rear.next=node; } rear=node; }
(3)链队列出队
出队操作时,就是头结点出队,将头结点的后继改为新的头节点结点,若新头节点指向 null
,此时链表为空需要让 rear
也指向 null
, 避免野指针异常!
public int poll() { if(front==null) { //如果头节点为空就说明没有结点 return -1; } int n=front.val; front=front.next; //若头指针为空说明没有结点,避免空指针异常使rear也指向null if(front==null) { rear=null; } return n; }
链队列和循环队列的比较
对于循环队列与链队列的比较,可以从两方面考虑,从时间上,他们的基本操作都是常数时间O(1),不过循环队列是事先申请好空间,使用期间不释放,而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队频繁,则两者还是有细微差异,对于空间上来说,循环队列必须有一个固定的长度,所以就有了存储元素个数和空间上的浪费,而链表不存在这个问题,尽管它需要一个指针域,会产生一些空间上的开销,但是也可以接受,所以在空间上,链队更加的灵活。
总的来说:在可以确定队列最大值的情况下,建议使用循环队列,如果你无法预估队列的长度时,则用链表。
四. 双端队列
1. 定义
双端队列(deque) 是指允许两端都可以进行入队和出队操作的队列,deque 是 “double ended queue” 的简称。
如下图所示。其元素的逻辑结构仍是线性结构。将队列的两端分别称为前端和后端,两端都可以入队和出队。
五. java中集合的用法
在我们需要使用栈的时候我们可以通过如下代码来使用java中封装好的。
Stack<Integer> stack=new Stack<>();
从上图中可以看到,Stack继承了Vector,Vector和ArrayList类似,都是动态的顺序表,不同的是Vector是线程安全的。
由于ArrayDeque和LinkedList都实现了Deque这个接口,所以可以通过这两个类来进行双端队列的实现。
Deque q1 = new ArrayDeque<>();//双端队列的线性实现
Deque q2 = new LinkedList<>();//双端队列的链式实现
来源地址:https://blog.csdn.net/xiaoyubuhuiqiche/article/details/128553963