队列
概念
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(First In First Out) 入队列:进行插入操作的一端称为队尾(Tail/Rear) 出队列:进行删除操作的一端称为队头
(Head/Front)
经过后面的学习我们就会知道,上述队列只是一个普通队列.
队列的类型
1:普通队列:也就是只允许我们在一端进行插入数据操作的队列.
2:优先级队列:也就是我们的堆
3:双端队列:双端队列(deque)是指允许两端都可以进行入队和出队操作的队列,deque 是 “double ended queue” 的简称。那就说明元素可以从队头出队和入队,也可以从队尾出队和入队。
4:循环队列:后面会详细介绍
集合中的队列
可以看到实体类有两个
一个是LinkedList:LinkedList既可当作双端队列,因为它实现了Deque接口,也可以当作普通队列,因为其也实现Queue接口,LinkedList的底层其实就是我们学过的双向链表,其具备了链表的特性
一个是PriorityQueue:这个是优先级队列,因为它实现了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的位置进行判断,分成两种不同的情况进行探讨,这块还需要大家着重注意下.以下是第二点中特殊情况的示意图
面试题汇总
括号匹配问题。OJ链接
用队列实现栈。OJ链接
用栈实现队列。OJ链接
实现一个最小栈。OJ链接
设计循环队列。OJ链接