博主秋招提前批已拿百度、字节跳动、拼多多、顺丰等公司的offer,可加微信:pcwl_Java 一起交流秋招面试经验,可获得博主的秋招简历和复习笔记。
一、队列的基本概念
1、定义
队列是一种先进先出的线性表。它只允许在表的前端进行删除操作,而在表的后端进行插入操作,具有先进先出、后进后出的特点。进行插入操作的一端成为队尾(tail),进行删除操作的一端称为队头(head)。当队列中没有元素时,则称之为空队列。
在队列中插入一个元素称为入队,从队列中删除一个元素称为出队。因为队列只允许在队尾插入元素,在队头删除元素,所以队列又称为先进先出(FIFO-first in first out)线性表。其实队列这种数据结构的特性,让我们很容易就想到了平时生活中我们排队场景,真是无处不在啊。。。图书馆、食堂、餐厅、公交车。。。
而在程序框架方面也有很多应用:最常见的就是各种“池“,比如:线程池、数据库连接池,分布式中的消息队列等待。都体现出一种公平的思想,即先到的先得。
2、队列和栈
其实队列和栈有很多相似的地方,栈的两个基本操作:压栈(push)和弹栈(pop),而队列的最基本的两个操作是:入队(enqueue())和出队(dequeue())。因此队列和栈一样,也是一种操作受限的数据结构。
3、队列的入队和出队操作(以数组为例)
入队操作enqueue:每次从队尾插入元素,时间复杂度:O(1)
出队操作dequeue:每次从队头删除元素,时间复杂度:O(n)
但是可以发现,数组实现的队列有很大的不足,每次从数组头部删除元素后,需要将头部的所有元素往队首移动一个位置,这是一个时间复杂度为O(n)的操作。
你可能会想到一种办法:每进行一次出队操作,就将队首的的标志往队尾移动一个内存空间,这样就不用进行数据搬移了,但是很明显这样又会产生一个很大的弊端,当队尾标志tail移动到最右边时,即使数组中还有空闲的内存空间,也无法往队列中添加数据了,不能很好的利用内存空间。
你可能还会想到一种办法:和JVM垃圾回收类似的思想,在出队操作的时候,我们不用先搬移数据,当没有空间空间,无法插入新数据的时候,在进行一次整体的数据搬移操作,这样可以将出队操作的时间复杂度降低为O(1),但是入队操作时,需要先判断队列中是否有空间的内存空间,如果有,直接入队,但是如果没有,则需要将队列中的数据进行一次整体的搬移,这样时间复杂度就为O(n)了,显然也不理想。其实现代码见文末:动态队列的数组实现
循环队列很好的解决了这个问题。见下文......
4、循环队列
从上面的数组实现的队列来看,其删除操作的时间复杂度为O(n),而我们希望得到时间复杂度都为O(1)的插入和删除操作,所以循环队列就很好的符合了我们的标准。
所谓的循环队列,就是长的像一个环,原本的队列是由头有尾的,是一条直线,我们现在将首位相连,就形成了一个环。如下图所示:
我们现在来进行这样的一组操作,如图1所示,这是个大小为8的队列,当前的队首head=4,队尾tail=7,此时有一个新元素A要队时,我们将其放入下标为7的位置,然后将tail在环中顺时针后移一个位置,即tail=0;当再有一个元素B要队时,就将元素B放入0的位置,这时tail=1。在经过这两次入队操作后,循环队列如图2所示。
通过这样,我们就成功的避免了数据搬移的操作(人类的智慧啊!!!)
二、队列的实现
1、队列的数组实现
队满的判断条件:tail == n
队空的判断条件:head == tail
public class ArrayQueue {
private Object[] items; // 存储数据的数组
private int n; // 队列的容量
private int head = 0; // 队头索引
private int tail = 0; // 队尾索引
// 申请一个指定容量为n的队列
public ArrayQueue(int capacity){
items = new Object[capacity];
n = capacity;
}
// 入队
public boolean enqueue(Object item){
// 首先判断队列是否满了 tail == n
if(tail == n){
return false;
}
items[tail] = item; // 将数据插入到队尾
tail++;
return true;
}
// 出队
public Object dequeue(){
// 首先判断队列是否为空 head == tail
if(head == tail){
return null;
}
Object item = items[head]; // 将队头数据删除
head++;
return item;
}
public int size(){
return tail;
}
// 显示队列中的数据
public void display(){
for(int i = 0; i < (tail - head); i++){
System.out.print(items[i] + ",");
}
}
}
测试代码:
public class ArrayQueueTest {
public static void main(String[] args) {
ArrayQueue queue = new ArrayQueue(10);
int size = queue.size();
System.out.println(size); // 0
// 入队
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
// 显示队列中的数据
queue.display();
System.out.println();
// 出队
queue.dequeue();
queue.dequeue();
queue.dequeue();
// 显示队列中的数据
queue.display();
}
}
2、队列的链表实现
队满的判断条件:链表实现,不需要
队空的判断条件:tail == null
public class LinkQueue {
// 结点内部类
private static class Node{
private Object data; // 数据域
private Node next; // 指针域
public Node(Object data){
this.data = data;
}
// 提供一个获取Node里面数据的方法
public Object getData(){
return data;
}
}
private Node head = null; // 队首结点
private Node tail = null; // 队尾结点
// 入队
public void enqueue(Object data){
Node newNode = new Node(data);
// 先判断tail是否为空,如果为空,说明队列为空
if(tail == null){
// 此时插入的是队列里的第一个数据元素,其head和tail均指向该结点
head = newNode;
tail = newNode;
}else{
tail.next = newNode; // 将之前tail的指针域指向newNode
tail = newNode; // 将tail变量指向newNode
}
}
// 出队
public Object dequeue(){
//先判断队列里是否还有数据元素了,如果head为null的时候,说明队列为空
if(head == null){
return null;
}
Object data = head.getData();
head = head.next; // 将head的下一个结点标记为head
if(head == null){
tail = null; // 如果head为空了,则说明队列为空,则tail也肯定为空了
}
return data;
}
// 显示队列里面的数据
public void display(){
// 只要head不为空,队列里面就有数据
Node node = head;
while(node != null){
System.out.print(node.data + ", ");
node = node.next;
}
}
}
测试代码:
public class LinkQueueTest {
public static void main(String[] args) {
LinkQueue queue = new LinkQueue();
// 入队
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
// 显示队列中的数据
queue.display();
System.out.println();
// 出队
queue.dequeue();
queue.dequeue();
queue.dequeue();
// 显示队列中的数据
queue.display();
}
}
3、循环队列的数组实现
队满的判断条件:size == n 或者 (tail + 1) % n == head
队空的判断条件:head == tail
public class CircularQueue {
private Object[] items; // 声明一个数组,用于存放队列中的数据
private int n = 0; // 数组的大小,即队列的容量
private int size; // 记录队列中元素的个数
private int head = 0; // 队头的标志
private int tail = 0; // 队尾的标志
// 申请一个容量为capacity的数组
public CircularQueue (int capacity){
items = new Object[capacity];
n = capacity;
}
// 入队
public boolean enqueue(Object item){
// 判断队列是否已经满了 size == n,如果没有size属性,就用:(tail + 1) % n == head进行判断
if(size == n){
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
size++;
return true;
}
// 出队
public Object dequeue(){
// 判断队列是否为空 head == tail
if(head == tail){
return null;
}
Object item = items[head];
head = (head + 1) % n;
size--;
return item;
}
// 队列中元素的个数
public int size(){
return size;
}
}
测试代码:
public class CircularQueueTest {
public static void main(String[] args) {
CircularQueue queue = new CircularQueue(8);
// 入队
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
int size1 = queue.size();
System.out.println(size1); // 5
// 出队
queue.dequeue();
queue.dequeue();
queue.dequeue();
int size2 = queue.size();
System.out.println(size2); // 2
}
}
附:4、动态队列的数组实现
这个过程出队dequeue的代码不变,主要是入队enqueue时,需要判断当tail=n的时候,说明此时队列中不能再进行入队操作了,所以需要进行一次数据搬移操作,具体代码如下:
public class DynamicArrayQueue {
private Object[] items; // 存储数据的数组
private int n; // 队列的容量
private int head = 0; // 队头索引
private int tail = 0; // 队尾索引
// 申请一个指定容量为n的队列
public DynamicArrayQueue(int capacity){
items = new Object[capacity];
n = capacity;
}
// 入队操作
public boolean enqueue(Object item){
// 判断队列中是否还有存储空间
if(tail == n){
// tail == n && head == 0表示整个队列已经满了
if(head == 0){
return false;
}
// 如果队列中还有空闲位置,则进行数据搬移
for(int i = head; i < tail; i++){
items[i - head] = items[i];
}
// 搬移完成后,重新更新head和tail
tail = tail - head;
head = 0;
}
items[tail] = item;
tail++;
return true;
}
// 出队
public Object dequeue(){
// 首先判断队列是否为空 head == tail
if(head == tail){
return null;
}
Object item = items[head]; // 将队头数据删除
head++;
return item;
}
// 显示队列中的数据
public void display(){
for(int i = 0; i < (tail - head); i++){
System.out.print(items[i] + ",");
}
}
}
三、阻塞队列和并发队列
推荐阅读:1、深入理解阻塞队列一、深入理解阻塞队列二、深入理解阻塞队列三、深入理解阻塞队列四
2、阻塞队列
阻塞队列:就是在队列的基础上加入了阻塞操作。换句话说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么之后的入队操作就会被阻塞,直到队列中有空闲的位置后才能再进行插入。
可以看出来,这样的特点和“生产者---消费者”模型一致,因此可以使用阻塞队列轻松的实现一个“生产者---消费者”模型。这种实现可以有效的协调生产和消费的速度。当生产者生产速度过快,消费者来不及消费时,队列满的时候就会让生产者阻塞等待,直到消费者消费了数据,队列中有了空闲的位置,生产者才能恢复生产。
阻塞队列中,我们还可以协调生产者和消费者的个数,以此来提高数据的处理效率。【生活中,也是一样,实际生产过程中,一个工厂肯定不止对应一个消费者的,毕竟一个消费者也养不起一个工厂的啊~】所以,往往一个生产者会对应多个消费者。在这种情况下,就会出现同一时间有多个线程同时操作这个队列,那么就需要我们考虑线程安全的问题了。
并发队列:线程安全的队列一般被称之为并发对列。最简单直接的实现方式就是在enqueue(),dequeue()方法上加锁,但是锁粒度大,并发度会比较低,同一时刻仅仅允许一个入队或者出队操作。实际上,基于数组的循环队列,利用CAS原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因,
Tip:关于锁的知识
推荐阅读:1、面试必问的CAS【推荐,里面分析了CAS的源码】
2、锁与CAS介绍
最后来看这么一个场景:在线程池、数据库连池等的应用中,当遇到线程池中没有空闲线程,但是又有新的任务请求线程资源时,我们一般有两种处理策略:
(1)第一种是非阻塞的处理方式:直接拒绝;
(2)阻塞的处理方式,将请求加入队列中,让其排队,等到有空闲的线程时,取出排在队头的请求。
实现方式 | 特点 | 适用场景 |
---|---|---|
队列的数组实现 | 队列的大小是有限制的,所以线程池中排队的请求超过队列的大小时,接下来的请求就会被拒绝 | 对响应时间比较敏感的系统,即:请求等待线程的时间不会太长 |
队列的链表实现 | 队列的大小是无限的,但是这样就很可能导致过多的请求排队等待,请求处理的时间过长 | 对响应时间不太敏感的系统 |
所以这个时候合理的设置队列的大小就成为了关键的问题。队列太大会导致等待的请求过多,但是队列太小又会导致无法充分利用系统资源。实际上对于大部分的资源连接池应用场景,当没有空闲资源时,基本上都可以通过队列这种数据结构让新的请求排队等待。
【ps:说明:阻塞队列和并发队列这一个模块的内容出自于极客时间的《数据结构与算法之美》专栏】
参考及推荐:
1、队列
3、队列(queue)原理(入栈和出栈操作的两张图片源于此篇博文)
4、队列的实现及分类
学习不是单打独斗,如果你也是做 Java 开发,可以加我微信:pcwl_Java,一起分享经验学习!