重温:数据结构与算法 - 开篇
重温:数据结构与算法 - 复杂度分析(一)
重温:数据结构与算法 - 复杂度分析(二)
重温:数据结构与算法 - 数组
重温:数据结构与算法 - 链表(一)
重温:数据结构与算法 - 链表(二)
重温:数据结构与算法 - 栈
叨叨两句
最近提了离职,给公司找交接人也面试了不少人,上次作为面试官还是两年前事情;
最大感受大环境还是太过浮躁,五年前如此,今亦是如此,福报996,中年危机,卷王争霸,各种负面信息缠绕在周围;
作为个人,没资格去评论他人的职业规划,给他人灌输鸡汤,更是害人不浅。仅告诫自己:静心、学习、自省、前行。
什么是队列
言归正传,本章介绍另一种“操作受限”的线性表数据结构:队列。
与栈结构相同,它仅支持添加和删除操作:
- 添加操作(入队 - enqueue)
- 删除操作(出队 - dequeue)
不同点在于与栈的先进后出相反,它具有"先进先出"特性:
图中,排队买票就是典型的队列结构,先排队的人优先买到票。
我们把入队操作称之"enqueue()",出队操作则为"dequeue()",一个简单的队列操作约束大致如下:
// 队列基本操作约束接口
public interface Queue {
// 入队
boolean enqueue(T item);
// 出队
T dequeue();
// 是否为空
boolean isEmpty();
// 队中数据的数量
int size();
// 清空
void clear();
}
一个满足先进先出的队列容器,其关键在于enqueue() 和 dequeue()函数的实现上,而基础容器的选择同上篇的栈结构一样,使用数组或者链表都可以,使用数组实现队列称之:顺序队列,使用链表实现则为:链式队列。
对于资源限制严谨的场景下推荐使用顺序队列,而对资源限制没有要求,仅需先进后出特性的场景可使用链式队列。
顺序队列(数组)
以下是以数组实现顺序队列的思路,大家不妨看看:
顺序队列:
1、定义一个items[]数组来存储数据;
2、定义一个head指针和tail指针,分别指向当前队列的首下标和尾下标;
3、入队操作enqueue()中,校验队列是否已满:
- 已满 ,返回false,入队失败;
- 未满,将数据存储到队尾(tail指针位置),且tail指针后移一位 ;
4、出队操作dequeue()中,校验队列是否已空:
- 已空:返回null
- 非空:返回队首数据(head指针位置),且将head指针后移一位。
按照这个思路,我把实现代码贴在了下方:
public class ArrayQueue implements Queue {
private static final int DEFAULT_SIZE = 10;
// 队列中数据数量
private int count;
private Object[] items;
// 当前队列能存储的最大容量
private int maxSize;
// head指向队首下标 , tail指向队尾下标
private int head = 0, tail = 0;
public ArrayQueue() {
this(DEFAULT_SIZE);
}
public ArrayQueue(int intSize) {
items = new Object[intSize];
this.maxSize = intSize;
}
@Override
public boolean enqueue(T item) {
// 队列满了,存储失败
if (tail == maxSize) return false;
// 存储,队尾下标后移一位
items[tail] = item;
++tail;
++count;
return true;
}
@Override
public T dequeue() {
// 队空,返回null
if (isEmpty()) return null;
// 取出数据,队首下标后移一位
Object item = items[head];
++head;
--count;
return (T) item;
}
@Override
public boolean isEmpty() {
return count == 0;
}
@Override
public int size() {
return count;
}
@Override
public void clear() {
if (isEmpty()) return;
for (int i = 0; i < size(); i++) {
items[i] = null;
}
count = 0;
}
public void print() {
for (int i = head; i < tail; i++) {
System.out.print(items[i] + "\t");
}
System.out.println();
}
}
测试一下:
ArrayQueue queue = new ArrayQueue<>(3);
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
queue.enqueue("5");
queue.print();
String data1 = queue.dequeue();
System.out.println("出队:" + data1);
String data2 = queue.dequeue();
System.out.println("出队:" + data2);
queue.print();
System.out.println("大小:" + queue.size());
// 问题:队列有空闲空间却无法添加数据
System.out.println(queue.enqueue("6"));
queue.print();
System.out.println("大小:" + queue.size());
输出:
1 2 3
出队:1
出队:2
3
大小:1
false
3
大小:1
通过测试,我们创建了一个大小为3的队列,并一口气增加了5个元素,因队列只能容纳3个元素,后续添加的4、5元素都失败了;紧接出队两个元素1、2,队列最终只剩下元素3,满足队列的先进先出原则。
然而,当我想尝试继续添加元素6时,却失败了!队列当前大小依旧是1,但我们知道队列总大小明明是3,却不能继续向队列添加数据,显然这并不是我想要的结果,通过下图我们可以看到原因:
当我们在第一步入队操作后,tail指针就已到队尾,无论第二步出队多少个元素,tail指针始终没有被重置,显然这是导致此问题的根本原因。
如何解决?
数据搬移!没错,在数组章节提到过插入一个数据进数组中,需要将插入位置及后面所有的数据都向后搬移一位;同理,当队列中队首元素出队时,将队首之后的所有元素向前搬移一位,并将tail--,这样就可以保证tail指针始终指向的是队内实际尾元素下标,而不是队列实际大小的尾下标。
按照上面思路,的确解决了问题,不过每次出队都需要进行一次数据搬移,这使得dequeue()函数时间复杂度由原先的 O(1) --》O(n).
有没有更好的方案?
换个角度,来看看enqueue()入队函数,还是上面问题,当我们执行第3步,入队元素6时,由于tail指针指向了队尾,入队失败。但通过上图可以看到head指针前是存在空闲空间的,此时我们进行数据搬移可否?
已上图为例,当执行第三步时,tail指针指向队尾,但head指针并不在队首,那么将3搬移至下标为0的位置,重置head、tail指针位置,再入队元素6,按照这个思路修改enqueue()函数如下:
public boolean enqueue(T item) {
// 队列到达末尾
if (tail == maxSize) {
// 队列满了,存储失败
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;
++count;
return true;
}
修改后的enqueue()函数时间复杂度,又是多少呢?分析如下:
- 当tail未达尾部时,可直接入队,时间复杂度:O(1)
- 当tail达到尾部时,队首有空余空间,进行数据搬移,时间复杂度:O(n)
- 每次入队操作,tail可能在0n任意位置,0n-1位置的时间复杂度为O(1),在n位置的时间复杂度为O(n),可将这n次搬移操作平摊给前n-1次操作,所以最终平均时间复杂度为:O(1)
链式队列(链表)
OK,接下来学习链式队列:
链式队列相较顺序队列简单很多,不需关心存储空间不够,或数据搬移问题。
链式队列:
- 1、定义一个head指针和tail指针,用于指向当前队列的首结点和尾结点;
- 2、入队操作enqueue()时,只需tail.next -> newNode , tail -> tail.next
- 3、出队操作dequeue()时,head -> head.next
public class LinkedQueue implements Queue {
private Node head, tail;
private int count = 0;
@Override
public boolean enqueue(T item) {
Node newNode = new Node<>(item);
if (head == null) {
head = tail = newNode;
} else {
tail.next = newNode;
tail = tail.next;
}
count++;
return true;
}
@Override
public T dequeue() {
if (head == null) return null;
T item = head.data;
head = head.next;
count--;
return item;
}
@Override
public boolean isEmpty() {
return count == 0;
}
@Override
public int size() {
return count;
}
@Override
public void clear() {
head = null;
}
public void print() {
Node temp = head;
while (temp != null) {
System.out.print(temp.data + "\t");
temp = temp.next;
}
System.out.println();
}
static class Node {
public T data; // 存储数据
public Node next; // 下一个结点
public Node(T data) {
this.data = data;
}
}
}
测试:
LinkedQueue queue = new LinkedQueue<>();
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
queue.enqueue("5");
queue.print();
System.out.println("出队:" + queue.dequeue());
System.out.println("出队:" + queue.dequeue());
System.out.println("出队:" + queue.dequeue());
queue.print();
queue.enqueue("6");
queue.print();
输出:
出队:1
出队:2
出队:3
4 5
4 5 6
可以看到链式队列,无论是入队enqueue()还是出队dequeue(),都可通过指针直接访问到对应结点,所以链式队列的出队入队时间复杂度都是:O(1)
进阶
循环队列
前面提到,在内存资源有限的场景下,推荐使用顺序队列,反之可使用链式队列。如果在有限资源下,又希望能拥有像链式队列一样高效的入队操作,这样的队列能否实现?
如图所示,我们将数组的首尾相连:
- 入队时,当数据填充到尾部且继续填充时,重置tail指针指向下标为0的内存空间,诺该空间处在空闲状态则入队成功,否则已满入队失败;
- 出队时,通过公式head = ( head + 1 ) % maxSize 来计算实际的head指针位置。
/**
* 循环队列
*/
public class CircularQueue implements Queue {
private static final int DEFAULT_SIZE = 10;
// 队列中数据数量
private int count;
private Object[] items;
// 当前队列能存储的最大容量
private int maxSize;
// head指向队列头部下标 , tail指向尾部下标
private int head = 0, tail = 0;
public CircularQueue() {
this(DEFAULT_SIZE);
}
public CircularQueue(int intSize) {
items = new Object[intSize];
this.maxSize = intSize;
}
@Override
public boolean enqueue(T item) {
// 队满
if (count == maxSize) return false;
// tail到达队尾,
if (tail == maxSize) tail = 0;
// 存储,队尾下标向后移动一位
items[tail] = item;
++tail;
++count;
return true;
}
@Override
public T dequeue() {
// 队空,返回null
if (isEmpty()) return null;
// 取出数据,队头下标向后移动一位
Object item = items[head];
head = (head + 1) % maxSize;
--count;
return (T) item;
}
@Override
public boolean isEmpty() {
return count == 0;
}
@Override
public int size() {
return count;
}
@Override
public void clear() {
if (isEmpty()) return;
for (int i = 0; i < size(); i++) {
items[i] = null;
}
count = 0;
}
public void print() {
for (int i = head, j = 0; j < count; i = (i + 1) % maxSize, j++) {
System.out.print(items[i] + "\t");
}
System.out.println();
}
}
测试:
CircularQueue queue = new CircularQueue<>(5);
queue.enqueue("1");
queue.enqueue("2");
queue.enqueue("3");
queue.enqueue("4");
queue.enqueue("5");
queue.enqueue("6");
queue.print();
System.out.println("出队:" + queue.dequeue());
System.out.println("出队:" + queue.dequeue());
queue.print();
queue.enqueue("7");
queue.print();
输出:
1 2 3 4 5
出队:1
出队:2
3 4 5
3 4 5 7
由于入队enqueue()、出队dequeue()分别对tail指针和head指针进行了纠正,不再会出现数据搬移的情况,其时间复杂度都为:O(1).