先进先出的数据结构——队列
文章目录
1.队列与排队
队列和栈一样,都是有约束条件的数据结构。
队列和排队非常类似,大家排成一排就像队列存储的元素。 以电影院举例,永远是排在最前面的人最先离队进入影院,后来进入队列人排在最后。
在计算机中,我们用先进先出来总结这个特征,或者缩写为FIFO(first in, first out)。 我们再来详细描述队列的约束条件:
- 只能在末尾插入元素;
- 只能读取开头的元素;
- 只能移除开头的元素。
2.队列的实现
数组实现队列
因为队列可以从头部删除和尾部插入元素,我们需要两个指针分别标记队列的头部和尾部,分别定义为front
和rear
。
front指向的即将删除的头部元素;
rear指向即将插入元素的位置。
开始时front
和rear
都指向第一个空元素
,如下所示:
当我们每添加一个元素时,让rear
指针向右移动一位。 当我们每删除一个元素时,让front
指针向右移动一位。
在添加a, b, c, d, e
后,数组的存储如下图所示:
我们继续进行添加和删除操作,在某一个时刻,数组可能出现如下情况:
大家仔细看看这个数组存储分布,发现什么问题了么?
随着
front
的右移,导致数组的左侧空间全部浪费了。
那该如何解决这个空间浪费问题呢?
第一个方案:数组调整
当然最简单的办法是每一次删除以后,将右侧的元素依次左移。
大家注意,这是一个不靠谱的方案,这让我们每次删除操作的时间复杂度为O(n)
。我们只听说过时间换空间,可没听说过空间换时间!
第二个方案:循环数组
在上面场景下,如果能把数组变成循环数组便可以解决了。 那什么是循环数组呢?循环数组指的是数组末尾元素下一个元素并不是越界,而是第一个元素。
用图表示如下:
如果使用循环数据,可以完美的解决队列导致空间浪费问题。
代码实现
在Java
中,队列被称为Queue
,数组实现的队列被称为ArrayQueue
。本节课我们将自己实现一个YKDArrayQueue
。
第1步:对象
首先我们加入基本对象和变量。
public class YKDArrayQueue<T> {
// 前后两个指针
private int front;
private int rear;
// 底层存储数组
private T[] queue;
public YKDArrayQueue(int size) {
this.front = 0;
this.rear = 0;
// #1. 特别注意此处的数组泛型的写法
this.queue = (T[]) new Object[size];
}
}
如上代码,YKDArrayQueue
提供一个创建队列指定大小的构造函数。 需要注意上面#1
,创建泛型数组的方式。
第2步:队尾添加元素
// 队列尾部添加元素
public void add(T o){
this.queue[this.rear] = o;
// #2. 因此是循环队列,所以处理数组长度取余
int newRear = (rear + 1) % this.queue.length;
// #3. 指针相遇表示队列已满,暂不考虑扩容情况
if(newRear == this.front){
// #4. 如果加入元素以后指针碰撞,则抛出越界提示
throw new IndexOutOfBoundsException("队列已满");
}
this.rear = newRear;
}
特别注意一下亮点:
#2
部分,因为我们是循环列表,所以用取余的方式获取指针位置。#3
部分,我们使用头尾相碰作为队列已满的条件。#4
部分,此处我们利用Java
异常机制,抛出队列已满异常
为了方便大家查看队列数据,我们加入一个toString
方法将队列数据连接成字符串返回。
public String toString() {
StringBuffer sb = new StringBuffer();
int i = this.front;
while (i != this.rear) {
sb.append(this.queue[i]);
sb.append(" ");
i++;
}
return sb.toString();
}
第 3 步:如何获取队列的长度
在上面我们已经往队列中加入元素了,那加入元素后,应该怎么获取队列的长度呢? 同学们可能会回答,我们知道尾部索引,也知道头部索引,那么长度的计算公式如下:
int size = this.rear - this.front;
大家再仔细想想,在循环数组的情况下,这种计算公式对么? 肯定是不对的,因为循环数组,有可能会出现rear < front
的。那应该怎么解决呢?如果小于的情况,我们可以加上数组的长度再进行相减。 完善一下代码,如下:
// 获取队列的长度
public int size() {
if (this.rear < this.front) {
return this.rear + this.queue.length - this.front;
}
return this.rear - this.front;
}
在此基础上,我们继续完成队列删除和获取索引元素的方法。
第4步:队列头部删除元素
注意处理队列为空的情况。
// 删除队列头部元素
public T remove() {
if (this.front == this.rear) {
throw new IndexOutOfBoundsException("队列为空,不允许remove");
}
T item = this.queue[this.front];
this.front = (this.front + 1) % this.queue.length;
return item;
}
第5步:获取队列索引元素
同样要注意队列为空的情况。
// 获取队列中索引位置元素
public T get(int i) {
if (i < 0 || i >= this.size()) {
throw new IndexOutOfBoundsException("获取队列元素,越界");
}
int index = (i + this.front) % this.queue.length;
return this.queue[index];
}
完整代码如下:
public class YKDArrayQueue<T> {
// 前后两个指针
private int front;
private int rear;
// 底层存储数组
private T[] queue;
public YKDArrayQueue(int size) {
this.front = 0;
this.rear = 0;
// #1. 特别注意此处的数组泛型的写法
this.queue = (T[]) new Object[size];
}
// 队列尾部添加元素
public void add(T o) {
this.queue[this.rear] = o;
// 因此是循环队列,所以处理数组长度取余
int newRear = (rear + 1) % this.queue.length;
// 暂不考虑扩容情况
if (newRear == this.front) {
// 如果加入元素以后指针碰撞,则抛出越界提示
throw new IndexOutOfBoundsException("队列已满");
}
this.rear = newRear;
}
// 删除队列头部元素
public T remove() {
if (this.front == this.rear) {
throw new IndexOutOfBoundsException("队列为空,不允许remove");
}
T item = this.queue[this.front];
this.front = (this.front + 1) % this.queue.length;
return item;
}
// 获取队列中索引位置元素
public T get(int i) {
if (i < 0 || i >= this.size()) {
throw new IndexOutOfBoundsException("获取队列元素,越界");
}
int index = (i + this.front) % this.queue.length;
return this.queue[index];
}
// 获取队列的长度
public int size() {
if (this.rear < this.front) {
return this.rear + this.queue.length - this.front;
}
return this.rear - this.front;
}
public String toString() {
StringBuffer sb = new StringBuffer();
int i = this.front;
while (i != this.rear) {
sb.append(this.queue[i]);
sb.append(" ");
i++;
}
return sb.toString();
}
链表实现队列
通过学习了栈、队列数组版本的实现方案,我们会发现对于这种类似的数据结构,用数组实现是一件非常繁琐的事情,主要因为两个原因:
- 数组天生对频繁的操作很不友好,每次插入删除操作都需要调整数组。
- 数组连续空间存储特性,导致用数组实现的数据结构都存在越界或者扩容问题。
而链表是栈、队列底层存储最好的选择,本节我们来学习下如何用链表实现队列。
链表实现队列,需要哪些变量呢?
大致思路跟数组相同,但因为节点个数计算较为复杂,我们稍作修改:
- 为了方便在头部删除节点,我们需要一个
front
指针指向链表的第一个节点; - 为了方便在尾部插入节点,我们需要一个
rear
指针指向链表的最后一个节点; - 链表只能遍历统计节点个数,会有额外的时间开销,所以我们增加
size
存储节点个数;
知道了差别后,链表队列的实现就会容易许多。
add: 类似于在列表尾部如果加入第一个节点 。
remove: 类似于链表如何删除第一个节点 。
get: 遍历链表返回对应索引的值。
size: 直接返回变量。
不熟悉链表的插入删除方法的同学可以看我这篇文章:仿写LinkList:基于节点的数据结构——链表
代码实现
完整代码如下:
public class YKDLinkedQueue<T> {
// 前后两个指针
private Node<T> front;
private Node<T> rear;
private int size = 0;
// 队列尾部添加元素
public void add(T o) {
Node<T> node = new Node<>(o);
if (this.front == null) {
this.front = node;
} else {
this.rear.setNext(node);
}
this.size++;
this.rear = node;
}
// 删除队列头部元素
public T remove() {
if(this.front == null){
throw new IndexOutOfBoundsException("队列为空,不能删除");
}
Node<T> temp = this.front;
this.front = temp.getNext();
temp.setNext(null);
this.size--;
return temp.getContent();
}
// 获取队列中索引位置元素
public T get(int i) {
if (i < 0 || i >= this.size()) {
throw new IndexOutOfBoundsException("获取队列元素,越界");
}
Node<T> node = this.front;
while(i > 0){
node = node.getNext();
i--;
}
return node.getContent();
}
// 获取队列的长度
public int size() {
return this.size;
}
public String toString() {
StringBuffer sb = new StringBuffer();
Node<T> node = this.front;
while (node.getNext() != null) {
sb.append(node.getContent());
sb.append(" ");
node = node.getNext();
}
return sb.toString();
}
3.队列的常见算法——滑块窗口
滑块是我们在网站中经常使用到的一种交互,用于滑动解锁、验证等等。
那滑块和队列有什么关系呢?如果我们将滑块当做队列,那么滑块的滑动区间也就表示队列所处理的所有数据。
当我们将滑块往右侧滑动的时候,可以理解为队列一边在尾部添加元素,一边在头部删除元素
1.获取最小的连续子数组
LeetCode
中,有一道非常经典的题目:
给定一个含有
n
个正整数的数组nums
和一个正整数s
,找出该数组中满足其和>= s
的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回0
。输入: s = 7, nums = {2, 3, 1, 1, 4, 3} 输出: 2 解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。
暴力法
这个题目应该怎么求解呢?首先我们来看看暴力求解法。
我们可以遍历所有的连续组合情况,然后依次判断其
和
是否满足>=7
,并获取最小的数组个数。
思路比较简单,我们直接来看代码
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
// 先定义最短长度为 nums.length + 1,因为肯定不会超过这个值
int minLength = nums.length + 1;
for (int i = 0; i < nums.length; i++) {
int sum = 0;
for (int j = i; j < nums.length ; j++) {
sum += nums[j];
// 获取调整 minLength
if (sum >= s) {
minLength = Math.min(minLength, j - i + 1);
break;
}
}
}
return minLength;
}
代码比较简单,核心思路是遍历所有连续子数组的情况。
结果为2
,和上面答案一样。这种解法的时间复杂度很容易计算出来,为O(N^2)
。
在面试算法的时候,就算我们不知道最优解是什么,但是至少我们要给出暴力解决问题的办法,这点很重要。一个解决方案都不提供是面试官最讨厌的。
滑块窗口法
从前 7 章经验可以看出O(N^2)
肯定不是最优解!那有什么时间复杂度更低的解决方案呢?这就要用到今天的重点滑块窗口法。
在使用滑块窗口法之前,我们先来看看上面的双重循环有哪些需要优化的地方?
暴力法缺点
我们以i = 2
开始举例,遍历i = 2
开始的最小连续数组,如下图所示:
从图中可以看出直到遍历到1, 1, 4, 3
才找到满足>=7
的子数组,之后我们便默认这个数组是当前的最优解,开始执行i = 3
的遍历。 但在这个时候1, 1, 4, 3
是最优解么?肯定不是?那应该如何优化呢?我们可以如下图所示进一步寻找:
我们推动滑块左边指针,推动到某一处,刚好滑块区域数组和>=7
即可,这才是当前情况下长度最短的子数组。
滑块窗口思想:
这就是滑块窗口的核心思想:
利用左右两个指针形成滑块效果,先固定左侧指针,推动右侧指针寻求到当前的解,然后再推动左侧指针寻找解中的最优解。 滑块窗口的思想在连续子数组方面应用非常广泛。
我们试想下,时间复杂度是多少呢?无论左指针,还是右指针都只会将数组遍历一遍,所以时间复杂度为O(N)
,很赞!!
滑块窗口算法实现
明白了滑块解法的原理,我们来看看算法如何实现。
第 1 步:先思考下程序需要哪些变量?
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
// 左右两个指针
int front = 0;
int rear = 0;
// 当前最小长度,为数组长度 + 1
int minLength = nums.length + 1;
// 滑块内元素的和,用于判断解和最优解
int sum = 0;
}
特别注意sum
这个变量,用于存储当前滑块的内部数据的和。
第 2 步:思考下程序运行的终止条件是什么?
front
和rear
都运行到数组结束为止。
我们继续添加代码如下
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
// 左右两个指针
int front = 0;
int rear = 0;
// 当前最小长度,为数组长度 + 1
int minLength = nums.length + 1;
// 滑块内元素的和,用于判断解和最优解
int sum = 0;
while (front < nums.length && rear < nums.length) {
}
}
第 3 步:我们优先推动右指针,找到解为止
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
...
while (front < nums.length && rear < nums.length) {
// 推进右指针,寻找解
while (rear < nums.length) {
// 一直累加 sum, 直到刚好 >= s时停止
sum += nums[rear];
if (sum >= s) {
break;
}
rear++;
}
}
}
注意累加 sum,直到刚好 >= s 时跳出循环。
第 4 步:推动左指针找寻当前解中最优解。
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
...
while (front < nums.length && rear < nums.length) {
// 推进右指针,寻找解
while (rear < nums.length) {
// 一直累加 sum, 直到刚好 >= s时停止
sum += nums[rear];
if (sum >= s) {
break;
}
rear++;
}
// 尝试推进左指针,寻找当前最优解
while (front < nums.length) {
// 一直减少左侧元素, 直到刚好 < s时停止
sum -= nums[front];
if (sum < s) {
// 此时找到当前最优解 rear - front + 1
minLength = Math.min(minLength, rear - front + 1);
// 两个指针同时向右推动,查找下一个解
rear++;
front++;
break;
}
front++;
}
}
}
左指针的推进逻辑比较复杂,大家需要多读读上面的代码,特别是代码注释。 如果还是不理解,则跟着代码的思路在本子上画一画每一步执行结果,这是理解复杂算法最有效的手段。
第 5 步:异常情况的处理
比如如果nums
为空的情况,上面代码将返回1
,而不是0
。因此我们在循环之前加强一下判断
// 查找最短子数组的长度
public static int findMin(int[] nums, int s) {
...
// 处理异常情况
if(nums.length == 0){
return 0;
}
while (front < nums.length && rear < nums.length) {
...
}
}
运行结果和暴力法一样,但是效率提升是非常大的。
2.无重复字符的最长连续子串
题目描述:
给定一个字符串,请你找出其中不含有重复字符的 最长连续子串 的长度,此处的字符串暂时只考虑
a - z
。示例 1:
输入: “abcabcbb”
输出: 3
解释: 因为无重复字符的最长子串是 “abc”,所以其长度为 3。示例 2:
输入: “bbbbb”
输出: 1
解释: 因为无重复字符的最长子串是 “b”,所以其长度为 1。示例 3:
输入: “pwwkew”
输出: 3
解释: 因为无重复字符的最长子串是 “wke”,所以其长度为 3。
思考一下,这道题跟上一题的区别在哪里?在这里应该选择什么作为转折点呢?
这里的核心转折点是右指针推动的下一个元素,在滑块子串中已经存在了,也就是已经重复了,这时候应该怎么办呢? 应该转换一下方向了,推动左指针移动一步。然后再考虑右指针是否可以推动。
附:ASCⅡ码表
那如何判断字符串中是否有重复字符呢?
我们设置一个数组,元素为0~25,通过字母的ascⅡ的值对应数组的元素。比如:a对应0(‘a’-‘a’),b对应1(‘b’-‘a’),…将已出现的字母对应的元素置1,之后便可以判断对应元素是否为1来确定该字符是否已经出现过。
代码实现
public class Demo {
// 查找最长无重复字符的连续子串
public static int findMax(String str) {
// 创建滑块的左右两个指针
int front = 0;
int rear = 0;
// 最长长度
int maxLength = 0;
// 用于标记滑块中的字符
int[] chars = new int[26];
// 在这里是寻求最大长度所以不存在左移指针寻求最优解,只需要rear指针到达尾部结束
while (rear < str.length()) {
// 移动尾指针
while (rear < str.length()) {
int index = str.charAt(rear) - 'a';
// 当遇到rear指向的字符在滑动中重复
if (chars[index] == 0) {
// 如果不重复,则继续推动rear指针右移
chars[index] = 1;
rear++;
} else {
// 重复则停止移动右指针
break;
}
}
// 获取当前的子串长度
maxLength = Math.max(maxLength, count(chars));
// 循环将左侧指针推动一步,直到子数组中没有重复的元素
int index = str.charAt(front) - 'a';
chars[index] = 0;
front++;
}
return maxLength;
}
// 获取元素个数
public static int count(int[] chars) {
int size = 0;
for (int aChar : chars) {
if (aChar != 0) {
size++;
}
}
return size;
}