一、基础知识
1.1 简介
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)线性表
。
1.2 逻辑实现
如图,这是一个队列的逻辑实现,这片连续的存储区可以是数组,也可以是链表,其中length代表队列的长度,head是头指针,指向队列的第一个元素,tail是尾指针,指向队列的最后一个元素,data_type代表队列存储元素的数据类型。
接下来我们看下队列的出队操作:
普通队列只支持从队首出队,头指针向后移动一步,在逻辑层面我们就认为头指针前面的元素出队了。
入队操作:
入队就是尾指针向后移动一步,并把元素放进去即可。
1.3 假溢出和循环队列
如图,按照上面流程当添加到第10个元素的时候,我们会发现第10个元素没空间放了:
- 就普通队列而言,通常情况上图就认为是队列满的情况,即队列溢出
- 但实际上队列有9个空间,只存了6个有效元素,这种情况的队列溢出叫做
假溢出
为了解决队列假溢出问题,提出了新的结构:循环队列
如图添加第10个元素的时候,只需要将尾指针重新指向这块连续存储空间的头部即可:
1.4 总结:
-
队列是连续的存储区,可以存储一系列的元素。是FIFO(先入先出,First-In-First-Out)结构。
-
队列通常具有头尾指针(左闭右开区间),头指针指向第一个元素,
尾指针指向最后一个元素的下一位
。正常情况队列的尾指针应该是指向最后一个元素的下一位,而上面的图中tail指针是指向尾部元素的指针,都行,看具体实现。
为什么通常情况下tail指针指向的是最后一个元素的下一位?
- 其实我们可以发现,各种语言中当在描述一个区间的时候,通常采用左闭右开的区间描述方式,而队列的头尾指针其实标记出的就是队列的区间,也是左闭右开的,在尾指针指向的位置实际上是不存值的
- 还有另一个好处,tail - head的值刚好就是队列中元素的数量
-
队列支持(从队尾)入队(enqueue)、(从队首)出队(dequeue)操作。
-
循环队列可以通过取模操作更有效的利用空间。
二、经典实现
2.1 简单队列
使用数组实现一个简单队列,支持如下操作:
- 入队 offer
- 出队 pop
- 判空 empty
- 判满 full
- 查看队首元素 front
- 查看元素个数 size
- 打印队列所有元素 print
public class SimpleQueue {
private int[] arr;
//头指针指向第一个元素
private int head;
//尾指针指向最后一个元素的下一位
private int tail;
public SimpleQueue(int size) {
this.arr = new int[size];
head = tail = 0;
}
/**
* 入队
*
* @param e
* @return
*/
public boolean offer(int e) {
if (full()) {
// 满了添加失败
System.out.println("queue is full");
return false;
}
arr[tail++] = e;
return true;
}
/**
* 出队
*
* @return 队列为空返回-1
*/
public int pop() {
if (empty()) {
System.out.println("queue is empty");
return -1;
}
int value = arr[head++];
return value;
}
/**
* 判断队列是否为空
*
* @return
*/
public boolean empty() {
//head=tail说明队列为空
return head == tail;
}
/**
* 判断队列是否满了
*
* @return
*/
public boolean full() {
//当tail指向最后一位下一位的时候说明队列满了
return tail == arr.length;
}
/**
* 查看队首元素
*
* @return
*/
public int front() {
//查看队首元素就是返回head所指向位置的元素值
if (empty()) {
return -1;
}
return arr[head];
}
/**
* 查看队列元素个数
*
* @return
*/
public int size() {
//tail-head值就是队列元素个数
return tail - head;
}
/**
* 打印队列中队元素
*/
public void print() {
String out = "Queue:";
for (int i = head; i < tail; i++) {
out += arr[i] + " ";
}
System.out.println(out);
}
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
SimpleQueue simpleQueue = new SimpleQueue(5);
Scanner sc = new Scanner(System.in);
outer:while (true) {
String operate = sc.nextLine();
switch (operate) {
case "offer":
simpleQueue.offer(Integer.valueOf(sc.nextLine()));
break;
case "pop":
simpleQueue.pop();
break;
case "front":
System.out.println("front:" + simpleQueue.front());
break;
case "size":
System.out.println("size:" + simpleQueue.size());
break;
case "quit":
break outer;
}
simpleQueue.print();
}
}
}
测试:
2.2 循环队列
在简单队列基础上进行修改:
public class CycleQueue {
private int[] arr;
// 头指针指向第一个元素
private int head;
// 尾指针指向最后一个元素的下一位
private int tail;
public CycleQueue(int size) {
// 由于尾指针指向最后一个元素的下一位,所以当队列满的时候
// 因为是循环队列,尾指针刚好走一圈,回到head
// 此时head == tail,与队列为空判断条件一致
// 所以这里通过在原有大小的基础上+1,
// 当尾指针刚好在头指针后一位,我们则认为队列是满的
// 此时根据尾指针定义,尾指针是不存值的,所以指向的元素应该是空的/无效的
this.arr = new int[size + 1];
head = tail = 0;
}
/**
* 入队
*
* @param e
* @return
*/
public boolean offer(int e) {
if (full()) {
// 满了添加失败
System.out.println("queue is full");
return false;
}
arr[tail] = e;
// 循环队列,当尾指针在数组尾部的时候重新指向数组头部
tail = (++tail) % arr.length;
// 可以使用这种方式
// if (++tail == arr.length) {
// tail = 0;
// }
return true;
}
/**
* 出队
*
* @return 队列为空返回-1
*/
public int pop() {
if (empty()) {
System.out.println("queue is empty");
return -1;
}
int value = arr[head];
head = (++head) % arr.length;
// 可以使用这种方式
// if (++head == arr.length) {
// head = 0;
// }
return value;
}
/**
* 判断队列是否为空
*
* @return
*/
public boolean empty() {
// head=tail说明队列为空
return head == tail;
}
/**
* 判断队列是否满了
*
* @return
*/
public boolean full() {
// 循环队列,队列满,说明尾指针刚刚在头指针后一位
return (tail + 1) % arr.length == head;
}
/**
* 查看队首元素
*
* @return
*/
public int front() {
if (empty()) {
return -1;
}
return arr[head];
}
/**
* 查看队列元素个数
*
* @return
*/
public int size() {
return (tail - head + arr.length) % arr.length;
}
/**
* 打印队列中队元素
*/
public void print() {
String out = "Queue:";
int size = size();
for (int i = 0, j = head; i < size; i++) {
out += arr[j] + " ";
j = (++j) % arr.length;
}
System.out.println(out);
}
/**
* 测试
*
* @param args
*/
public static void main(String[] args) {
CycleQueue simpleQueue = new CycleQueue(5);
Scanner sc = new Scanner(System.in);
outer:
while (true) {
String operate = sc.nextLine();
switch (operate) {
case "offer":
simpleQueue.offer(Integer.valueOf(sc.nextLine()));
break;
case "pop":
simpleQueue.pop();
break;
case "front":
System.out.println("front:" + simpleQueue.front());
break;
case "size":
System.out.println("size:" + simpleQueue.size());
break;
case "quit":
break outer;
}
simpleQueue.print();
}
}
}
测试,可以看到插入6,如果是普通队列就满了,但是循环队列可以继续插入:
如何判断循环队列是否满了?
我们还可以用额外变量记录队列中元素数量,来简化代码。
三、典型应用场景
3.1 CPU的超线程技术
当前图中一个CPU里有两个计算核心,这就是真双核处理器。
- 每个CPU核心连着一个管道,一般称为指令发射管,一条条指令顺着管道从下往上,通过CPU之后这条指令就完成了计算,这个
管子就可以当成是指令的队列
,这些指令依次性的到达,依次性的被CPU处理。 - 真双核处理器,是由两个核心,
每个核心有一个指令队列
虚拟四核:这个CPU对外有四个管子,用户就会认为这是四核CPU,但实际上只有两个真正的核心,每个核心带了两个指令队列
- 为什么可以一个核心处理两个任务队列?因为CPU核心的计算速度非常快,而一个普通的指令队列跟不上核心的供给要求,为了充分利用CPU核心的计算能力
- 指令管道不是越多越好,从实验数据来看,一个核心最好只带两个指令队列,虚拟8核通常情况下是真四核
- 通常电脑CPU说4核8线程,这个8线程指的就是任务(指令)队列数量
多路CPU:一个计算机只有一个CPU叫做一路CPU,多路就是一个计算机有多个CPU
由此对于计算能力,我们可以引申出四层概念:
- 计算核心
- CPU:一个CPU包含多个计算核心(企业中一般用的是32核/64核的,但是通常情况是用多个CPU拼起来的)
- 多路:一个计算节点可以包含多个CPU
- 集群:分布式计算
3.2 线程池的任务队列
通常在使用线程的过程中,需要线程就去申请,不需要就销毁,会涉及到反复频繁的进行线程的申请和销毁,这是普通的多线程程序,存在的问题:
- 频繁的对线程申请和销毁,这两个动作对于程序的效率来说会大打折扣
因此引申出线程池概念:
在我的程序中有这样的一个结构或者类,可以管理多个线程的。
线程是用来做计算的,当过来一个计算资源后先随机分配给一个线程,又过来几个计算任务后再分配给其他线程
如果当前四个线程都被分配上计算任务后,这个时候又过来计算任务,当前的这个计算任务就不知道放哪
所以在线程池中就提出了一个概念,叫做任务队列
任务队列中存放的就是每一个计算任务,相当于放置任务的缓冲区,每个线程从任务队列中获取任务
总结:队列通常是用来做缓存区用途的