栈
概念
栈:一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。进行数据插入和删除操作的一端称为栈 顶,另一端称为栈底。栈中的数据元素遵守后进先出
LIFO(Last In First Out)的原则。
压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶
。
出栈:栈的删除操作叫做出栈。出数据在栈顶
。
一道关于集合的小练习(美团面试题)
答案:c
集合中的栈
可以看到集合中的栈继承于Vector类:
我们的Vector类继承于AbstractList类,实现了List接口:
我们再来看下集合中的栈都有哪些方法:
ALT+7我们可以看到Stack类中的方法
下面我们来对其中的一些方法进行实现:
push&peek方法
概念
push方法:将项目推送到此堆栈的顶部
peek方法:是拿到栈顶元素,并不是删除,是查看
代码示例
public class TestDemo5 {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
//push是入栈操作,输出结果为【1,2,3】
System.out.println(stack);
//stack.peek()是拿到栈顶元素,并不是删除
//输出结果为3
System.out.println(stack.peek());
}
}
pop方法
概念
pop方法:删除此堆栈顶部的对象
代码示例
public class TestDemo5 {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
//打印我们所弹出的元素3,并将3出栈
//输出结果为3
System.out.println(stack.pop());
//因为使用了pop方法,所以输出结果为2
System.out.println(stack.peek());
}
}
注意事项(EmptyStackException异常)
在上面的代码中,栈有三个元素,当我们使用pop方法三次后,此时栈中就已经没有元素了,假设此时我们再使用一次pop方法,会发生什么样的情况呢?我们来看代码:
public class TestDemo5 {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.pop();
stack.pop();
stack.pop();
//第四次使用,会发生EmptyStackException异常
stack.pop();
}
}
输出结果:
empty方法
概念
empty方法;测试此堆栈是否为空,为空返回true,不为空返回false
.返回值类型为boolean
代码示例
public class TestDemo5 {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.pop();
stack.pop();
stack.pop();
//因为使用了三次pop方法,所以输出结果为true
System.out.println(stack.empty());
}
}
isEmpty方法
概念
这个方法在Stack类中是没有被定义的,它是继承于我们的Vector类
,但是效果跟我们的empty方法是一样的,判断我们的栈是否为空,为空返回true,不为空返回false
代码示例
public class TestDemo5 {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.pop();
stack.pop();
stack.pop();
//因为使用了三次pop方法,所以输出结果为true
System.out.println(stack.isEmpty());
}
}
中缀表达式转后缀表达式(面试笔试常考选择题)
后缀表达式也就是我们常说的逆波兰表达式
我们先通过一道练习题再来说明什么是前缀,什么是中缀,什么是后缀
练习题1
这道题目的做法如下:
为了方便区分括号,我们把括号都换成不同的颜色:
1:首先对已有的括号进行换色:
2:然后依照从左到右,先乘除,后加减的原则来对运算加上括号
:
3:然后将每个运算符移动到自己所对应的括号的外面
:
4:最后将所有括号去掉,得到的就是我们的后缀表达式
所以这道题目最终答案为A.
总结
一般的题目就是考察中缀转后缀
,中缀表达式一般题目中会直接给出的
前缀表达式
就是在我们加完括号移动的过程中将运算符移动到自己对应的括号的前面
后缀表达式
是在我们加完括号后移动的过程中将运算符移动到自己对应的括号的后面
前缀表达式和后缀表达式都是没有括号的表达式
计算机一般对于计算前缀和后缀表达式比较容易
为什么这么说呢?我们来举个例子
对于题目中的中缀表达式来说我们把它换成数字的形式,如下图所示:
转换成后缀表达式的形式为:
那么计算机是如何对这个后缀表达式进行运算的呢?来看:
1:首先初始化一个空栈,当遇到数字的时候就将数字压栈,如下所示
2:这时候遇到第一个符号是+号,此时把栈顶元素作为右操作数,把栈顶元素的下一个元素作为左操作数
,那么此处的式子就变成了1+2=3
3:然后将3这个元素压栈:如下所示:
4:此时又碰到了减号,那就将3作为右操作数,4作为左操作数,得到4-3=1.,然后将1压栈,如下所示:
5:此时又碰到了乘号,那就将1作为右操作数,2作为左操作数,得到2*1=2.,然后将2压栈,如下所示:
6:此时碰到了数字2,就将其压栈即可:
7:此时又碰到了除号,那就将2作为右操作数,下一个2作为左操作数,得到2/2=1.,然后将1压栈,如下所示:
8:最后碰到了加号,那就将1作为右操作数,下一个1作为左操作数,得到1+1=2,而这个2与我们中缀表达式算出来的结果一摸一样,这也是计算机在处理中缀表达式时的时候会先将其转变为后缀表达式,然后将其按照上述方式进行运算,最终得出结果
练习题2
答案:
自己动手实现一个栈(数组实现)
栈的底层其实就是一个数组:来看代码实现:
public class MyStack {
//栈的底层其实也是数组
private int[] elem;
// top可以代表下标,表达的意思是当前栈中可以插入的元素的位置的下标
// top还可以代表当前栈中元素的个数
private int top;
public MyStack() {
this.elem = new int[10];
}
//判断栈是否满
public boolean isFull() {
return this.top == this.elem.length;
}
//将元素压栈
public int push(int item) {
if (isFull()) {
throw new RuntimeException("栈已经满了");
}
this.elem[this.top] = item;
this.top++;
//注意压栈的时候的下标为this.top-1
return this.elem[this.top - 1];
}
/**
* 弹出栈顶元素 并且删除
* @return
*/
public int pop() {
if (isEmpty()) {
throw new RuntimeException("栈是空的,不能进行删除");
}
this.top--;
//注意此处删除元素的时候下标应该是this.top
//这个下标相当于下次插入的时候将元素进行替换
return this.elem[this.top];
}
/**
* 拿到栈顶元素不删除
* @return
*/
public int peek() {
if (isEmpty()) {
throw new RuntimeException("栈为空");
}
return this.elem[this.top - 1];
}
//判断栈是否为空
public boolean isEmpty() {
return this.top == 0;
}
//得到栈的长度
public int size() {
return this.top;
}
}
测试类:
public class TestDemo {
public static void main(String[] args) {
MyStack stack = new MyStack();
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(1);
stack.push(2);
stack.push(3);
//stack.peek() 拿到栈顶元素,但是不是删除
System.out.println(stack.peek());//3
//弹出栈顶元素 3
System.out.println(stack.pop());//3
System.out.println(stack.peek());//2
System.out.println(stack.pop());//2
System.out.println(stack.pop());//1
System.out.println(stack.empty());
System.out.println(stack.pop());//3
}
}
使用链表来实现栈
如果此时用链表实现栈,那么入栈用头插法好还是尾插法好呢?
答:头插法好,不管是出栈还是入栈时间复杂度都会达到O(1).
如果使用尾插法的话,此时我们需要找到链表的尾节点,就需要我们去遍历整个链表,那么不管是出栈还是入栈时间复杂度都会达到O(N),
队列
概念
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出
FIFO(First In First Out) 入队列:进行插入操作的一端称为队尾
(Tail/Rear) 出队列:进行删除操作的一端称为队头
(Head/Front)
经过后面的学习我们就会知道,上述队列只是一个普通队列
.
队列的类型
1:普通队列:也就是只允许我们在一端进行插入数据操作的队列.
2:优先级队列:也就是我们的堆
3:双端队列:双端队列(deque)是指允许两端都可以进行入队和出队操作的队列,deque 是 “double ended queue” 的简称。那就说明元素可以从队头出队和入队,也可以从队尾出队和入队。
4:循环队列:后面会详细介绍
集合中的队列
可以看到实体类有两个
一个是LinkedList:LinkedList
既可当作双端队列
,因为它实现了Deque接口
,也可以当作普通队列
,因为其也实现Queue接口
,LinkedList的底层其实就是我们学过的双向链表
,其具备了链表的特性
一个是PriorityList:这个是优先级队列
,因为它实现了Queue接口,优先级队列的底层其实是二叉树
,但是其具备队列的特性.
Queue接口常用方法(普通队列)
add方法
public class TestDemo5 {
public static void main(String[] args) {
//此时是一个普通队列
Queue<Integer> queue = new LinkedList<>();
//此时调用add方法,默认是尾部入队.
queue.add(1);
//输出普通队列的长度,为1
System.out.println(queue.size());
}
}
此时我们调用普通队列中的add方法,默认是尾部入队列
此时 Queue接口发生了向上转型,我们的LinkedList类中重写了Queue接口中的add方法,所以在调用add方法的时候发生了运行时绑定,来看源码:
这个是LinkedList类中的add方法,可以看到其内部还调用了一个方法叫做linkLast方法,这个方法就是尾插法,如下图所示:
所以普通队列中的add方法其实默认就是尾插法.
offer方法
同样也是实现我们普通队列插入的一个方法,其效果与add方法是一样的,但是与add方法的区别是add方法当队列满了再插入的时候会抛出异常,而offer方法只返回true或者false,所以一般我们建议用offer方法
remove方法
删除普通队列的队头元素
poll方法
删除普通队列的队头元素,效果与remove方法相同,我们一般还是建议使用poll方法
public class TestDemo5 {
public static void main(String[] args) {
//此时是一个普通队列
Queue<Integer> queue = new LinkedList<>();
//此时调用add方法,默认是尾部入队.
queue.offer(1);
queue.offer(2);
queue.offer(3);
queue.offer(4);
//此处的输出值为1,代表所删除的队头元素
System.out.println(queue.poll());
//此处的输出值为【2,3,4】
System.out.println(queue);
}
}
element方法
element方法是获取队列的头元素,但不删除
peek方法
peek方法是获取队列的头元素,但不删除,与element方法的效果相同.
public class TestDemo5 {
public static void main(String[] args) {
//此时是一个普通队列
Queue<Integer> queue = new LinkedList<>();
//此时调用add方法,默认是尾部入队.
queue.offer(1);
queue.offer(2);
queue.offer(3);
queue.offer(4);
//此处的输出值为1
System.out.println(queue.peek());
//此处的输出值为【1,2,3,4】
System.out.println(queue);
}
}
Deque接口常用方法(双端队列)
以下方法只是Deque接口中非常常用的一些方法,当然Deque接口总还有很多其他常用的方法,如果大家想要查看的话可以直接打开我们Deque接口的源码,Alt+7
之后便可以查看里面的其他实现方法.
代码示例:
public class TestDemo5 {
public static void main(String[] args) {
Deque<Integer> deque = new LinkedList<>();
//从双端队列的队头插入元素
deque.addFirst(1);
//从双端队列的队尾插入元素
deque.addLast(2);
//输出结果为:[1, 2]
System.out.println(deque);
}
}
自己实现一个普通队列(单链表实现)
队列也可以数组和链表的结构实现,使用链表的结构实现更优一些
,因为如果使用数组的结构,出队列在数组头上出数据,效率会比较低,时间复杂度会达到O(N)
那么在这里我们就拿单链表
实现一个普通队列
:来看代码:
因为是单链表,所以其方向是固定的,又因为我们在插入元素的时候的方法是尾插法,所以队尾和队头如下所示:
来看我们的实现代码:
class Node {
public int val;
public Node next;
public Node(int val) {
this.val = val;
}
}
public class MyQueue {
//定义队列头
public Node first;
//定义队列尾
public Node last;
//判断列表是否为空
public boolean isEmpty() {
if (this.first == null && this.last == null) {
return true;
}
return false;
}
//实现队列中的offer方法,在其源码中默认是尾插法
public boolean offer(int val) {
Node node = new Node(val);
//假如此时队列中还没有元素
if (this.first == null) {
this.first = node;
this.last = node;
} else {
//假设队列中已经插入了元素
this.last.next = node;
this.last = node;
}
return true;
}
/**
* 得到队头元素且删除
*
* @return
* @throws RuntimeException
*/
public int poll() throws RuntimeException {
if (isEmpty()) {
throw new RuntimeException("队列为空");
}
int num = this.first.val;
this.first = this.first.next;
return num;
}
/**
* 得到队列的头元素但是不删除
*
* @return
*/
public int peek() {
if (isEmpty()) {
throw new RuntimeException("队列为空");
}
return this.first.val;
}
public static void main(String[] args) {
}
}
对于上述代码在进行入队操作(尾插法)
时候的时间复杂度为O(1)
在进行出队(头删法)
时候的时间复杂度为O(1)
.
假设此时我们入队操作采用头插法,时间复杂度为O(1),但是当出队操作采用尾删法,时间复杂度就成了O(N).因为需要遍历链表查找到链表的最后一个元素.
所以当遇见这种情况的时候,我们都是采用双向链表,不用单向链表.
能否使用数组来实现队列?
非常麻烦,出队的时候的找到队头在哪里,入队的时候还需要往空位置插,因为我们普通队列的插入是尾插法,如果此时数组后面几个元素插满了,再进行尾插法会发生数组下标越界异常:例如下所示,此时再往7下标后面插入会发生数组下标越界异常,所以只能往0到3号下标处进行插入.
此时我们小伙伴们就想啦,假如这个队列能够首尾相接,循环起来,是不是就能够实现插入了呢
?小伙伴们的想法非常的好,的确是这样,由此就引入了我们的循环队列
的概念,而循环队列底层也就是一个循环数组
.相当于当我们实现队列的尾插法的时候,本来应该插在8号下标处的元素插在了0号下标处.所以说用数组是可以实现队列的,下面我们来看具体实现.
循环队列
什么是循环队列?我们先来给出一个示意图:
此时我们相当于将数组首尾相连形成了一个循环队列,0-7代表数组下标,此时定义两个整形
指针,一个是指向队列头元素的头指针front
,一个是指向队尾元素的尾指针rear,
此时因为队列为空,所以此时front指针和rear指针都指向了0下标的位置.
然后我们给出一组想要插入的数据:
12,23,34,45,56,67,78,89
当我们挨个向队列中插入数据的时候,front指针不动,rear指针开始向后移动,rear指针所指向的位置就代表当前可以插入元素的位置,所以rear指针同样代表当前可以存放数组元素的下标
,假如我们要删掉队列中的元素,只需要front++即可
,这样队列中的元素在遍历的时候就会被删除掉.
注意事项(结论1)
当在删除循环队列中的元素的时候,我们的rear指针其实是不动的,只有front指针一直在动,所以最终front指针与rear指针终会相遇,
这时我们的循环队列为空`,所以总结出一个结论,当front和rear相遇的时候,队列为空,如下所示:此时front指针遍历过的元素:12,23,34,45全部都出队列了,此时两个指针相遇代表队列为空
欧克现在回到我们向队列中插入元素的时候,此时front指针回到我们0下标的位置,然后继续开始插入我们的元素,rear指针继续向下遍历,直到rear指针与我们front指针的第二次重合
:
此时有些同学就开始纳闷了:首先当rear走到7号下标的时候,它怎么能再走到front所在的0号下标呢,难道不会发生数组下标越界异常吗?其次就算走到了,之前我们总结到说当front和rear相遇的时候,队列为空,但此时队列竟然满了?这不是悖论吗?
如下所示:
所以接下来当我们将要重点解决同学们的两个疑惑:
问题1:front和rear相遇,此时到底队列为空,还是队列为满?
问题2:front和rear当都走到7号下标的时候,都会面临一个问题:
front+1此时数组下标越界了,rear+1此时数组下标也越界了
那么首先我们来解决问题1:
假设此时还剩最后一个元素89未插入到队列当中,我们在这里需要牺牲一个空间,来判断当前队列是否是满的,也就是判断rear的下一个是否是front
所以当front和rear相遇的时候,队列为空结论正确
.
那么接下来我们来解决第二个问题:
rear此时的下标为7,front此时的下标为0,rear+1=7+1=8,8是永远不可能等于0的,所以此时我们需要用到这个表达式来判断当前队列是否是满的:(rear+1)%len==front,例如(7+1)%8=0,说明此时队列满了
注意事项(结论2)
判断循环队列是否满就使用(rear+1)%len==front
这个式子来判断.适用于所有情况
.
自己实现一个循环队列(数组实现)
经过前面的分析我们可以知道的是,我们的循环队列的底层其实就是一个数组,代码实现如下:
public class MyCircularQueue {
private int front;
//rear表示当前可以插入元素的数组的下标
private int rear;
private int[] elem;
public MyCircularQueue(int k) {
this.elem = new int[k];
}
/**
* 向循环队列插入一个元素。如果成功插入则返回真。
*
* @param value
* @return
*/
public boolean enQueue(int value) {
//此时代表队列已满,无法再进行插入
if (isFull()) {
return false;
}
//放到数组的rear下标
this.elem[this.rear] = value;
//rear指针往后走,取余是为了循环起来
this.rear = (this.rear + 1) % this.elem.length;
return true;
}
public boolean deQueue() {
if (isEmpty()) {
return false;
}
//只需要挪动front这个下标就好了,取余是为了循环起来
this.front = (this.front + 1) % this.elem.length;
return true;
}
/**
* 得到队头元素
*
* @return
*/
public int Front() {
if (isEmpty()) {
return -1;
}
return this.elem[this.front];
}
/**
* 得到队尾元素
* 注意特殊情况
*
* @return
*/
public int Rear() {
if (isEmpty()) {
return -1;
}
int index = -1;
if (this.rear == 0) {
index = this.elem.length - 1;
} else {
index = this.rear - 1;
}
return this.elem[index];
}
/**
* 队列是否为空
*
* @return
*/
public boolean isEmpty() {
return this.rear == this.front;
}
/**
* 队列是否为满
*
* @return
*/
public boolean isFull() {
return (this.rear + 1) % this.elem.length == this.front;
}
}
在这里要重点强调两点
:
第一点:当我们不管是插入还是删除队列中的元素的时候,我们rear指针和front指针的遍历语句都是取余语
句,同时在进行插入和删除的时候不要忘记分别对队列进行判满和判空操作.
第二点:在我们获得队尾元素的时候是有特殊情况的
,不能直接写return this.elem[this.rear-1]这条语句
,例如如下图所示:当我们将12这个元素出队列的时候,此时front指向了1下标处,rear指针指向了0下标处,如果直接返回rear[-1]的话,会发生数组下标越界异常,所以此处我们需要对rear的位置进行判断,分成两种不同的情况进行探讨,这块还需要大家着重注意下.以下是第二点中特殊情况的示意图