前端仔的“数据结构与算法”之路——队列

376 阅读9分钟

理解队列的概念

队列的概念相对栈来说更好理解,就像我们去排队买奶茶,先来的先喝,后来的依次排队。典型的先进先出、后进后出的数据结构。
它和栈一样,是一种操作受限的数据结构。只支持两个基本操作“入队列”、“出队列”。

  • 入队列,从队列尾部插入元素。
  • 出队列,从队列头部删除元素。

顺序队列、链式队列

数组实现的队列,称为顺序队列。从数据结构来看,维护数组队列需要两个指针进行。我们之前说的栈,只需要一个指针(指向栈顶)。
数组队列的一个指针指向队列头部(支持出队列操作),一个指针指向队列尾部(支持入队列操作)。
WechatIMG120.png
WechatIMG118.png
WechatIMG124.png
但是数组的操作会有一个问题,就是数组前部分还有空间,队尾指针已经到达末端,无法继续插入。这时就会涉及到一个数据搬移的工作。
当tail指针指向数组末端时,此时入队列,需要将整体元素搬移至数组0下标起。当然我们前端操作的JavaScript可以不太在意数据迁移和数组扩容的问题,只需要了解它的存在即可。
此时tail执行的位置是没有元素的,当然理论上我们是可以再次插入元素的,但可能会改变我们之前指针但判断逻辑,因为之前的指针都是指向队列尾部的后一个元素。为了代码判断方便部分人习惯tail这么指向,依据个人计算习惯,tail指针也是可以插入元素的。

链表实现的队列,我们称为链式队列。同样链表也需要两个指针,分别指向链表头,和链表尾部。图解和👆一样。

  • 出队列:head=head.next
  • 入队列:tail.next=newNode tail=newNode

循环队列

在数组队列的操作中,我们发现当tail指针指向数组末端时,插入操作会有O(n)时间复杂度的数据迁移。影响了性能。其实我们直接看图也能理解,为什么不把新插入的元素放在数组头部,再把tail指针移向头部就好了。是的这种首尾相接的感觉就是循环队列。
WechatIMG122.png
循环队列的要点就是队空和队满的判断。
非循环数组队列的队空情况:head===tail,队满:tail=>n(尾指针执行数组末尾)
循环队列的队空:head===tail
队满的情况,可以想象成,tail指针的下一个元素就是head指针。
关键是我们怎么判断好队列满的情况,看图理解。
WechatIMG126.png
满队:(tail+1)%n===headtail指针的下一个元素就是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


提示:

  • 所有的值都在 0 至 1000 的范围内;
  • 操作数将在 1 至 1000 的范围内;
  • 请不要使用内置的队列库。

思路:

循环队列两大要点:
队空判断、队满判断。
剩下的就是每次入队列时,判断是否满队?没满,移动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
};

总结

单纯的队列题型比较少,多是组合出现。采用队列的数据结构、配合动态规划、二分查找、广度优先搜索解决问题。更多的题型后续会补充。
队列的特点一种操作受限的数据结构,先进先出。具体软件开发的应用场景大多是,有限资源的场景通过队列来请求资源。