理解队列的概念
队列的概念相对栈来说更好理解,就像我们去排队买奶茶,先来的先喝,后来的依次排队。典型的先进先出、后进后出的数据结构。
它和栈一样,是一种操作受限的数据结构。只支持两个基本操作“入队列”、“出队列”。
- 入队列,从队列尾部插入元素。
- 出队列,从队列头部删除元素。
顺序队列、链式队列
用数组实现的队列,称为顺序队列。从数据结构来看,维护数组队列需要两个指针进行。我们之前说的栈,只需要一个指针(指向栈顶)。
数组队列的一个指针指向队列头部(支持出队列操作),一个指针指向队列尾部(支持入队列操作)。
但是数组的操作会有一个问题,就是数组前部分还有空间,队尾指针已经到达末端,无法继续插入。这时就会涉及到一个数据搬移的工作。
当tail指针指向数组末端时,此时入队列,需要将整体元素搬移至数组0下标起。当然我们前端操作的JavaScript可以不太在意数据迁移和数组扩容的问题,只需要了解它的存在即可。
此时tail执行的位置是没有元素的,当然理论上我们是可以再次插入元素的,但可能会改变我们之前指针但判断逻辑,因为之前的指针都是指向队列尾部的后一个元素。为了代码判断方便部分人习惯tail这么指向,依据个人计算习惯,tail指针也是可以插入元素的。
链表实现的队列,我们称为链式队列。同样链表也需要两个指针,分别指向链表头,和链表尾部。图解和👆一样。
- 出队列:
head=head.next - 入队列:
tail.next=newNode tail=newNode
循环队列
在数组队列的操作中,我们发现当tail指针指向数组末端时,插入操作会有O(n)时间复杂度的数据迁移。影响了性能。其实我们直接看图也能理解,为什么不把新插入的元素放在数组头部,再把tail指针移向头部就好了。是的这种首尾相接的感觉就是循环队列。
循环队列的要点就是队空和队满的判断。
非循环数组队列的队空情况:head===tail,队满:tail=>n(尾指针执行数组末尾)
循环队列的队空:head===tail
队满的情况,可以想象成,tail指针的下一个元素就是head指针。
关键是我们怎么判断好队列满的情况,看图理解。
满队:(tail+1)%n===head(tail指针的下一个元素就是head指针)
阻塞队列
阻塞队列,顾名思义就是在入出操作上加上了阻塞判断。
队列为空时,出队列操作会被阻塞,因为队列为空,直到队列有元素后才执行出队列。
队列满时,入队列操作会被阻塞,队列状态已满,知道队列有空余空间后,才执行入队列。
这样的特性在软件开发上应用很广。就是常听的“生产者、消费者模型”。
引用极客时间的一段小结论。
这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。
在实际应用中,会协调生产者和消费者的关系,和数量来提高数据的处理效率。有时会有多个消费者来出队列,只有一个生产者入队列。
并发队列
对于多个生产者和消费者的情况,就会存在一个风险问题。多个消费者同时出队列,怎么保证出队列的准确?这里一般叫线程安全问题,多个消费者相当于多线程情况去操作同一队列。并发队列就是解决线程安全队列的一种情况。
怎么确保队列的安全,一般是我们在入队列和出队列时加上锁,同一时刻只允许一个入或出操作。但是这样的并发效率并不算很高。基于循环队列可实现的并发队列会有更高级的技巧处理。
leetcode实战
队列的基础概念相对简单。具体应用场景大多是,有限资源的场景通过队列来请求资源。代码篇,运用队列这种结构解决问题找找感觉。
622. 设计循环队列👈
问题:
设计你的循环队列实现。 循环队列是一种线性数据结构,其操作表现基于 FIFO(先进先出)原则并且队尾被连接在队首之后以形成一个循环。它也被称为“环形缓冲器”。
循环队列的一个好处是我们可以利用这个队列之前用过的空间。在一个普通队列里,一旦一个队列满了,我们就不能插入下一个元素,即使在队列前面仍有空间。但是使用循环队列,我们能使用这些空间去存储新的值。
你的实现应该支持如下操作:
MyCircularQueue(k): 构造器,设置队列长度为 k 。 Front: 从队首获取元素。如果队列为空,返回 -1 。 Rear: 获取队尾元素。如果队列为空,返回 -1 。 enQueue(value): 向循环队列插入一个元素。如果成功插入则返回真。 deQueue(): 从循环队列中删除一个元素。如果成功删除则返回真。 isEmpty(): 检查循环队列是否为空。 isFull(): 检查循环队列是否已满。
示例:
MyCircularQueue circularQueue = new MyCircularQueue(3); // 设置长度为 3
circularQueue.enQueue(1); // 返回 true
circularQueue.enQueue(2); // 返回 true
circularQueue.enQueue(3); // 返回 true
circularQueue.enQueue(4); // 返回 false,队列已满
circularQueue.Rear(); // 返回 3
circularQueue.isFull(); // 返回 true
circularQueue.deQueue(); // 返回 true
circularQueue.enQueue(4); // 返回 true
circularQueue.Rear(); // 返回 4
提示:
思路:
循环队列两大要点:
队空判断、队满判断。
剩下的就是每次入队列时,判断是否满队?没满,移动tail指针到下一个(tail+1)%k,然后入队列。用余数可以达到循环的作用。
这里我习惯tail指针指向最后一个元素。
看代码更好理解👇
代码:
/**
* Initialize your data structure here. Set the size of the queue to be k.
* @param {number} k
*/
var MyCircularQueue = function (k) {
this.k = k
// 队列
this.queue = new Array(k).fill(null)
// 首尾指针初始化
this.head = 0
this.tail = null
};
/**
* Insert an element into the circular queue. Return true if the operation is successful.
* @param {number} value
* @return {boolean}
*/
MyCircularQueue.prototype.enQueue = function (value) {
// 判断是否队满
if (this.isFull()) return false
// 移动指针到下一个节点
this.tail = this.tail === null ? this.head : (this.tail + 1) % this.k
this.queue[this.tail] = value
return true
};
/**
* Delete an element from the circular queue. Return true if the operation is successful.
* @return {boolean}
*/
MyCircularQueue.prototype.deQueue = function () {
// 判断是否队空
if(this.isEmpty())return false
this.queue[this.head] = null
// 出队列后判断是否队空?重置首尾节点
if (this.head === this.tail) this.tail = null
this.head = (this.head + 1) % this.k
return true
};
/**
* Get the front item from the queue.
* @return {number}
*/
MyCircularQueue.prototype.Front = function () {
return this.queue[this.head] !== null ? this.queue[this.head] : -1
};
/**
* Get the last item from the queue.
* @return {number}
*/
MyCircularQueue.prototype.Rear = function () {
if (this.tail === null) return -1
return this.queue[this.tail]
};
/**
* Checks whether the circular queue is empty or not.
* @return {boolean}
*/
MyCircularQueue.prototype.isEmpty = function () {
return this.tail === null
};
/**
* Checks whether the circular queue is full or not.
* @return {boolean}
*/
MyCircularQueue.prototype.isFull = function () {
if (this.tail === null) return false
return this.head === (this.tail + 1) % this.k
};
/**
* Your MyCircularQueue object will be instantiated and called as such:
* var obj = new MyCircularQueue(k)
* var param_1 = obj.enQueue(value)
* var param_2 = obj.deQueue()
* var param_3 = obj.Front()
* var param_4 = obj.Rear()
* var param_5 = obj.isEmpty()
* var param_6 = obj.isFull()
*/
剑指 Offer 59 - I. 滑动窗口的最大值👈
问题:
给定一个数组
nums和滑动窗口的大小k,请找出所有滑动窗口里的最大值。 提示: 你可以假设 _k _总是有效的,在输入数组不为空的情况下,1 ≤ k ≤ 输入数组的大小。
示例:
输入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
思路:
先根据提示走一遍思路。我们遍历数组的过程中,由于窗口大小是3,其实我们是走到第3个元素时,才开始需要获取窗口最大值,之后每走一个元素,我们都需要获取前k-1和本身元素的最大值。
最暴力的解法就是,在需要获取最大值时,再次遍历窗口的元素去比较大小。但是这样我们每次移动窗口都要比较k次,时间复杂度将近O(nk)。
队列解法:
假设我们维护一个递减的队列,最大长度是k,队列里的元素只能是当前窗口的元素。这样我们每次移动窗口,先将元素维护进入队列,然后再取出队首的元素,就是我们当前窗口所需要的最大值。
我们维护队列的目的就是不用每次都遍历k个元素,当然如果是最坏的情况,效率和暴力解法差不多。
怎么维护队列呢?
- 新元素如果比队列首元素大,证明当前元素已经比之前的元素大,前面的元素已经没有比较的必要了。队列清空,新元素入队列,成为队首。
- 判断队首元素是否已经在窗口外面,所以队列其实是保存元素的index,这样好判断目前的最大元素还在不在窗口中。
head===p-k成立时要出队列。 - 前两种情况都处理完后,还有最后一种,当前的元素大小处于队列中。这里我们稍微不遵守队列的规则,需要遍历删除比当前元素小的值,然后将当前元素入队列。
代码:
/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function(nums, k) {
if(nums.length===0||k===0)return []
// 结果
let max = []
// 递减队列,保存的是index
let queueIndex = []
let len = k
for(let i=0;i<nums.length;i++){
if(len>0){
sort(i)
len -= 1
if(len===0)max.push(nums[queueIndex[0]])
}else{
sort(i)
// 开始输出结果
max.push(nums[queueIndex[0]])
}
}
function sort(index){
// 维护队列
let x = nums[index]
// 还要维护最大值是窗口第一个时,要删除queueIndex【0】
if(queueIndex[0]===index-k){
queueIndex.shift()
}
if(x>=nums[queueIndex[0]]||queueIndex.length===0){
queueIndex = [index]
}else{
let start = queueIndex
for(let i=1;i<queueIndex.length;i++){
if(x>=nums[queueIndex[i]]){
start = queueIndex.slice(0,i)
break
}
}
queueIndex = [...start,index]
}
}
return max
};
总结
单纯的队列题型比较少,多是组合出现。采用队列的数据结构、配合动态规划、二分查找、广度优先搜索解决问题。更多的题型后续会补充。
队列的特点一种操作受限的数据结构,先进先出。具体软件开发的应用场景大多是,有限资源的场景通过队列来请求资源。