【数据结构与算法】之队列的基本介绍及其数组、链表实现---第五篇

本文详细介绍了队列的基本概念,包括定义、队列与栈的区别,以及队列的数组和链表实现。重点讨论了数组实现队列时面临的问题和解决方法,提出了循环队列的概念,以解决数据搬移问题。此外,还探讨了阻塞队列和并发队列在多线程环境中的应用和重要性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

博主秋招提前批已拿百度、字节跳动、拼多多、顺丰等公司的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介绍

                    3、无锁算法--CAS原理

最后来看这么一个场景:在线程池、数据库连池等的应用中,当遇到线程池中没有空闲线程,但是又有新的任务请求线程资源时,我们一般有两种处理策略:

(1)第一种是非阻塞的处理方式:直接拒绝;

(2)阻塞的处理方式,将请求加入队列中,让其排队,等到有空闲的线程时,取出排在队头的请求。

实现方式特点适用场景
队列的数组实现队列的大小是有限制的,所以线程池中排队的请求超过队列的大小时,接下来的请求就会被拒绝对响应时间比较敏感的系统,即:请求等待线程的时间不会太长
队列的链表实现队列的大小是无限的,但是这样就很可能导致过多的请求排队等待,请求处理的时间过长对响应时间不太敏感的系统

所以这个时候合理的设置队列的大小就成为了关键的问题。队列太大会导致等待的请求过多,但是队列太小又会导致无法充分利用系统资源。实际上对于大部分的资源连接池应用场景,当没有空闲资源时,基本上都可以通过队列这种数据结构让新的请求排队等待。

【ps:说明:阻塞队列和并发队列这一个模块的内容出自于极客时间的《数据结构与算法之美》专栏】


参考及推荐:

1、队列

2、数据结构-队列(queue)

3、队列(queue)原理(入栈和出栈操作的两张图片源于此篇博文)

4、队列的实现及分类

学习不是单打独斗,如果你也是做 Java 开发,可以加我微信:pcwl_Java,一起分享经验学习!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值