认识栈和队列
什么是栈,什么是JVM虚拟机栈,什么是栈帧?✌️
- 栈:一种运算受限的线性表,限定仅在表尾进行插入和删除操作的线性表。这一端被称为栈顶,相对地,把另一端称为栈底。向一个栈插入新元素又称作进栈、入栈或压栈,它是把新元素放到栈顶元素的上面,使之成为新的栈顶元素;从一个栈删除元素又称作出栈或退栈,它是把栈顶元素删除掉,使其相邻的元素成为新的栈顶元素。
- JVM虚拟机栈:JVM中的一块内存区域,该内存一般用于存放局部变量等,之所以叫栈,是因为它具备栈的特性:先进后出。
- 栈帧:C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。调用函数时为函数开辟的内存叫栈帧,这块内存属于JVM虚拟机栈。
栈如何去使用?🍼
-
有一类选择题是考察入栈和出栈的顺序,如一个栈的入栈序列是a、b、c、d、e,那么出栈的不可能输出序列是?
A:edcba
B:decba
C:dceab
D:abcde
出栈第一个字母决定了这个字母之前的所有字母都应该已经入栈,比如A:如果出栈的第一个字母是e,那栈内应当已经存放了abcd,从栈顶出栈依次为:dcba,加上第一个字母,整体出栈顺序就是:edcba;对B:入栈:abc,d入栈再出栈,e入栈再出栈,然后cba依次出栈;对C、D:同样的考虑,C不对。不再赘述。
-
中缀表达式转后缀表达式
中缀表达式:一个通用的算术或逻辑公式表示方法。也就是我们平时写的表达式都是中缀表达式。
后缀表达式也叫(逆波兰式):将运算符写在操作数之后。
前缀表达式也叫(波兰式):将运算符卸载操作数之前。
如何使一个中缀表达式变成一个后缀表达式:从左往右每次遇到一个运算符后的第一个数字后面加右括号,对应同级别的位置加左括号,直至将整个表达式括起来。第二步:将前述的每个运算符都移动至右侧第一个有括号的右侧,直至最后一个运算符移至整个表达式的右括号右侧。最后去掉所有的括号,此时的表达式就是后缀表达式。(前缀表达式与之类似)
如:(5+4)*3-2 (这是一个中缀表达式)
5 4 + 3 * 2 - (上述中缀表达式转后缀表达式的结果)
-
为什么要将中缀表达式转成后(前)缀表达式:因为:计算机拿到的只是字符串,在计算机眼里并没有运算符的优先级之说,所以我们必须主动地去控制好一个表达式的运算步骤。
-
给你一个后缀表达式,如何计算这个后缀表达式的值?
首先要知道,我们拿到的一个后缀表达式本质是一个字符串,或者说计算机拿到的就是一个字符串数组,当我们用i 遍历上述字符串的时候,遇到数字就入栈,一旦遇到运算符,则弹出栈顶两个元素,且第一个弹出的元素将位于运算符的右侧,第二个弹出的元素将位于运算符左侧,计算结果重新入栈,直至字符串被遍历完。最后出栈的元素就是我们所要求的结果。
集合框架中Stack这个具体实现类有哪些功能🥇
上图标出的是常用的4种方法。
public class TestDemo {
public static void main(String[] args) {
Stack<String> stack=new Stack<>();
//压栈
stack.push("hehe");
stack.push("xixi");
//出栈
String ret=stack.pop();//弹出“xixi”
System.out.println(ret);//打印“xixi”
//获取栈顶元素
String ret1=stack.peek();//获取到"hehe"
System.out.println(ret1);
//查看栈是否为空
System.out.println(stack.empty());//false,这里也可以使用isEmpty(),因为Stack的父类是
//Vector,子类引用调用的方法,如果子类中没有定义,则会去其父类里找。
//查找Object o
System.out.println(stack.search("hehe"));//1,该功能是从栈顶往下数,从1开始数,数到我们要 //查找的第一个Object,将该数进行返回,找不到返回-1
}
}
学到这,可以做2道题:
1:力扣150(逆波兰表达式求值)
2:剑指offer31(栈的压入、弹出序列)
可以去看我leetcode专栏里的解法。
查看栈(Stack)的源码🚛
-
实例化栈对象的时候,可以发现:Stack类内仅有一个无参构造,也就是说,我们实例化一个栈对象的时候,不可以给栈初始化一个容量。
- 观察Stack的父类Vector的字段
protected Object[] elementData;//和我们之前学习到的自己实现顺序表一样,有一个数组 protected int elementCount;//记录数组元素个数,这里就等价于站内元素的个数
//Stack的无参构造方法: public Stack() { } //我们应当知道,子类构造时,应当先帮父类进行构造: public Vector() { this(10);//这里是又调用了Vector里的一个参数的构造方法,见下 } public Vector(int initialCapacity) { this(initialCapacity, 0);//这里又调用了2个参数的构造方法,见下 } public Vector(int initialCapacity, int capacityIncrement) { super();//帮父类AbstractList进行构造 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);//不可初始化一个负容量的栈 this.elementData = new Object[initialCapacity];//实例化一个Object数组 this.capacityIncrement = capacityIncrement;//0 }
所以说:源码角度,栈是一个数组。
-
查看Stack的push()的扩容机制(与ArrayList的add()扩容机制很类似)
//push()源码:E是我们进行类型参数化的泛型,编译结束擦成Object public E push(E item) { addElement(item); return item; } //进入addElement() public synchronized void addElement(E obj) { modCount++;//这个可以暂时不用管 ensureCapacityHelper(elementCount + 1);//确保数组至少还有一个位置能让我们尾插一个元素 elementData[elementCount++] = obj;//尾插一个元素,并使有效元素个数加一 } //进入ensureCapacityHelper() private void ensureCapacityHelper(int minCapacity) { // overflow-conscious code if (minCapacity - elementData.length > 0)//表明需要扩容 grow(minCapacity);//具体去实施扩容 } //进入扩容函数grow() private void grow(int minCapacity) { // overflow-conscious code int oldCapacity = elementData.length; //capacityIncrement为向量溢出量(暂时不用管) int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);//可以看出2倍扩容,ArrayList的add()是1.5倍扩容 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); elementData = Arrays.copyOf(elementData, newCapacity); }
自己实现栈🤙
//实现栈的三大基本功能:push()、peek()、pop()
public class MyStack {
public int[] elem;
public int usedSize;
public MyStack() {
this.elem = new int[10];//同源码,初始化容量就是10
}
//push()
public void push(int data){
if(isFull()){
this.elem= Arrays.copyOf(this.elem,2*this.elem.length);//2被扩容
}
this.elem[this.usedSize]=data;//尾插一个元素
this.usedSize++;//有效数据个数加一
}
private boolean isFull(){
return this.elem.length==this.usedSize;
}
private boolean isEmpty(){
return this.usedSize==0;
}
//针对pop():若数组元素类型是引用类型,须置空弹出元素,再有效数据个数减一,若是int等数据类型,直接this.usedSize-1
public int pop(){
if(isEmpty()){
throw new RuntimeException("栈为空");
}
int oldValue= this.elem[this.usedSize-1];
this.usedSize--;
return oldValue;
}
//peek()
public int peek(){
if(isEmpty()){
throw new RuntimeException("栈为空");
}
return this.elem[this.usedSize-1];
}
}
能否用链表实现栈?🎃
-
如果仅借助于一个链表想实现栈,如何去做?我们知道链表有单向和双向,如果用单向链表,压栈的时候,我们就需要寻找链表的尾巴,那时间复杂度就是O(n),但是我们的目标实现一个栈,并且时间复杂度最好是O(1),那换个思路,如果压栈是压在头节点前面,发现压栈、出栈的时间复杂度都是O(1),这样是可以的。
即:用单链表尾插法实现栈是做不到时间复杂度为O(1)的
-
目前能想象到的用链表实现栈的最好方法就是使用双向链表,第一,压栈压在尾节点后面,因为我们的双向链表有last节点。第二,出栈也可以做到时间复杂度为O(1)
-
先做几道题,我们就可以知道想用链表实现栈的话,不仅仅可以用单链表头插、双向链表还有别的方法,因为我们又没强制要求只使用一条链表。
- 力扣20(有效的括号) https://leetcode-cn.com/problems/valid-parentheses/
- 力扣155(实现最小栈) https://leetcode-cn.com/problems/valid-parentheses/submissions/
队列🦅
-
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。
-
查看Queue接口的功能有哪些:
方法 功能 boolean add(E) 入队一个元素,入队失败抛出异常 boolean offer(E) 入队一个元素,入队失败返回false E remove() 队头元素出队,出队失败抛出异常 E poll() 队头元素出队,出队失败返回null E element() 查询队头元素,查询失败抛出异常 E peek() 查询队头元素,查询失败返回null -
查看Deque接口时,因为其实现了Queue接口,所以它拓展了Queue接口的功能(头尾各有一组)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mp4hY9yI-1643431534780)(C:\Users\LebronHarden\AppData\Roaming\Typora\typora-user-images\image-20220129113753258.png)]
-
对LinkedList来说,它既可以作普通的队列(底层就是双向链表),也可以当做双端队列(底层是双向链表当然可以作双端队列),还可以作自己(一个双向链表);总之这里牵涉的还是一道面试题:请区分ArrayList和LinkedList有哪些不同?
用链表实现队列🥂
- 首先思考单链表实现队列的话,链表的头和尾哪一头去作队列的队头呢?如果是链表的头结点作队头:此时出队的时间复杂度为O(1);而入队的时间复杂度为O(n),因为入队时要找尾巴(链表的尾节点);如果反过来也是一个O(n)一个O(1),如果非要用单向链表实现队列,那我们可以将尾节点单独作为一个字段,那每次入队的操作就可以根据早已记录好的尾节点去直接尾插一个节点即可,且此时的入队时间复杂度也是O(1)
class Node{
public int val;
public Node next;
public Node(int val) {
this.val = val;
}
}
public class MyQueue {
public Node head;
public Node last;
//offer()尾插
public void offer(int data){
Node node=new Node(data);
if(this.head==null){
this.head=node;
this.last=node;
}else{
this.last.next=node;
this.last=node;
}
}
//poll()出队
public int poll(){
if(isEmpty()){
return -1;
}
int oldValue=this.head.val;
this.head=this.head.next;
return oldValue;
}
private boolean isEmpty(){
return this.head==null;
}
//peek()查询队头元素
public int peek(){
if(isEmpty()){
return -1;
}
return this.head.val;
}
}
至此:底层用双向链表实现了队列,我们自己有使用单链表实现了队列。
用数组实现队列🚙
-
用数组实现队列需要考虑的因素:
-
假设一个容量已经初始化好的数组视作一个队列,比如下标为0的位置就是队头,另一头就是队尾,那放进数组若干元素(放得下),再然后进行poll()出队的操作,队头转而来到下标为1的位置,多次poll()之后,该数组从下标为0的位置开始往后可能有很多“空位”,与此同时,我们再进行offer(),直至放到数组最后一个位置,此时不扩容的情况之下,我们已经放不进去了。但是呢:该数组前某些个位置还都是空的。上述就是所谓的假溢出现象。
-
基于上述现象,我们为了能够使得数组被充分利用起来,得想办法将数组下标进行某些个操作,实现数组的下标可循环。
-
基于2给出下标循环的公式:
入队时:(下标变大直至溢出,此时应当将其操作至小下标的位置):(index+offset)%array.length
那下标最前还要往前时:(index-offset+array.length)%array.length
-
如何判断一个数组是否被填满!
-
如何判断一个数组是否被填满⚡️
观察入队操作:rear总记录下一个放元素的下标。
不难发现:
- front与rear相遇要么就是空要么就是放满了
- 如何区分满还是空?
- 法一:使用usedSize,当usedSize与数组初始化容量相等时,就是队列放慢咯
- 法二:使用标志位:说白了就是定义一个字段flag,并默认是false,每次入队都将flag =true;当front == rear && flag == true 说明是rear追上front了,此时就是满了。与之相反,出队总是flag= false,如果 front == rear&&flag==false;就是说数组为空,或者说队列为空。那根据front是否与rear相等和flag是true还是false共有四种组合方式,当front和rear不相等时,那数组必不为空,也必不会满,所以我们就只针对上述front ==rear时进行讨论即可。
- 法三:牺牲一个位置去判断满没满:说白了就是每次放元素之前都检查rear的下一个位置是不是front,也就是我们不想多定义一个字段,直接用rear的下一个是不是front来判断满没满,因为rear总是记录的下一次方元素的下标,当rear的下一个下标就是front时,我们就不放元素了,所以最后的一个rear是不会放元素的,所以说牺牲了一个位置。
设计一个循环队列⛈
力扣622(见我的力扣博客)
Conclusion:
- 至此我们认识了:栈的底层使用数组实现的,ArrayList本质也是数组(底层),LinkedList本质是一个双向链表(底层).
- 应当能够自己实现栈(数组、单链表、双向链表)、自己实现队列(单、双向链表都可以)、用循环数组实现循环队列。也就是说数组和链表都可以实现栈和队列。
- 写题可以发现栈和队列也有相互转换的时候。(见我的力扣博客)