图解数据结构js篇-队列结构(Queue)

1,441 阅读11分钟

队列

队列是一种特殊的 线性表,特殊之处在于它只允许在表的一端进行删除操作,而在表的另一端端进行插入操作,和栈一样,队列是一种操作受限制的 线性表。进行插入操作的端称为 队尾,进行删除操作的端称为 队首队头

队列是一种具有 「先入先出」 特点的抽象数据结构,可使用链表实现。

如下图所示,通过常用操作「入队 push()」,「出队 pop()」,展示了队列的先入先出特性。

queue.push(1); // 元素 1 入队
queue.push(2); // 元素 2 入队
queue.pop();   // 出队 -> 元素 1
queue.pop();   // 出队 -> 元素 2

队列执行过程

没错,这看起来依然很好理解🤔,所谓先进先出就像在食堂排队一样,先排的总是先打到饭吃。

队列的实现

如栈的情形一样,对于队列而言任何表的实现都是合法的。像栈一样,对于每一种操作,链表实现和数组实现都应该给出快速的O(1)运行时间。

队列的链表实现

队列的链表实现是直接的,由于链表是动态的分配内存空间,比较适合用于队列无大小限制或者是无确认大小的情况下。

tip: 无特别说明的情况下 链表 都是指 动态链表,即使用随机存储实现的单向链表。

使用链表实现,我们仍然需要避开链表增删的短板——尾删,如果不了解为什么尾删是链表的短板,可以看 图解数据结构js篇-链表结构(Linked-list)

根据队列的特性,我们可以看出队首总是在做删除(pop)操作,队尾总是在做添加(push)操作。为了避免使用 链表 的尾部做删除操作,我们理所当然的应该选择链表的 头指针 作为 队首尾指针 作为 队尾

链表实现队列

push 操作时只需要将 rear.next=pushNode; rear=rear.next,但是当链表为空时还需要将头结点指向入队的节点。当然,如果你使用虚拟头结点的话可以忽略这个步骤。

空链表插入节点

pop 操作时,只需要将 head=head.next 即可移除第一个节点,也有一个特殊情况就是当链表只有一个节点时,需要 read=null 来将尾指针也指向null

只有一个节点的链表移除节点

// queue.js
class LinkedListNode{
    constructor(data, next = null) {
        this.data = data;
        this.next = next;
    }
}

class Queue {
    constructor() {
        this.head = null;
        this.rear = null;
    }

    push(data){
        const addNode = new LinkedListNode(data)

        // 链表是否为空
        if(this.isEmpty()){
            this.head = addNode;
        }else {
            this.rear.next = addNode;
        }

        this.rear = addNode;

        return true;
    }

    pop(){
        if(this.isEmpty()){
            return null;
        }

        const removeNode = this.head

        // 判断链表是否只有一个元素
        if(this.head === this.rear){
            this.rear = null
            this.head = null
        }else {
            this.head = this.head.next;
        }

        return removeNode.data;
    }

    isEmpty(){
        return !this.head && !this.rear;
    }
}

// queue.test.js
const queue = new Queue();

queue.push(1); // 元素 1 入队
queue.push(2); // 元素 2 入队
console.log(queue.pop());  // 1
console.log(queue.pop());   // 2
console.log(queue.isEmpty());   // true

上面是比较原始的实现版本,其实对于链表,我们在上一篇文章 图解数据结构js篇-链表结构(Linked-list) 中已经完整的实现过了,接下来我们要合理的借助已有的数据结构来快捷的实现。

import { LinkedListNode, LinkedList } from './linkedList.js';

export class Queue {
    constructor() {
        this._linkedList = new LinkedList()
    }
    
    toString(){
        return this._linkedList.toString();
    }
    
    // 入队
    push(data){
        return this._linkedList.insertInRear(data)
    }
    
    // 出队
    pop(){
        return this._linkedList.remove(0)
    }
    // 获取队头
    head(){
        return this._linkedList.get(0)
    }
    
    // 获取队尾
    rear(){
        return this._linkedList.get(this._linkedList.size-1)
    }

    // 判断是否为空
    isEmpty(){
        return this._linkedList.isEmpty()
    }
}

队列的数组实现

数组实现的队列非常适合队列大小固定的场景,但是我们都知道数组对元素的增加和删除性能消耗是比较高的。那么这里为什么说数组适用于大小固定的队列实现呢?我们先来看看普通的数组实现队列出现的问题

普通数组实现队列有什么问题?

如果我们将数组的前方作为队首,后方作为队尾。

image.png

我们会发现当我们进行出队操作时需要将后面的元素整体往前移动,时间复杂度为O(n),且队磁盘的开销也是巨大的,这并不可取,也不满足队列对pop操作耗时为O(1)的要求。

所以我们尝试不向前移动数组元素,而是采用两个变量或者说指针来记录对头和队尾的数组下标。当我们进行pop操作时只需要将队首指针后移,当我进行push操作时只需要将队尾指针后移并将值插入队尾即可。但是这样的缺点就是无法利用pop后的空间,导致队列的大小减少。

image.png

但是我们又不能拓展数组的容量,因为对于数组来说,容量的拓展需要重新开辟新的长度的数组,然后将旧数组转移到新数组,开销的巨大的。

环形队列的优点

我们可以通过将数组的存储逻辑变成一个环路来无限利用数组中的空间。这样即可解决上面说到的空间无法利用问题。

image.png

我们使用 head 指向队首元素,rear 指向队尾的 后一个元素 。你可能会疑惑 rear 为何不直接指向队尾。设想一下:如果 rear 指向队尾,那么当队列只有一个元素时队首和队尾指向同一个下标,但是如果队列为空时,rear 会在 head 之前,这样看起来并不是很好理解 head 和 rear 的关系。

环形队列由于出色的性能表现,备受系统底层喜爱,甚至许多硬件都已经实现的环形队列。

环形队列如何判满和判空?

我们发现当队列为空或满时,headrear 都指向同一个下标,那么我们该如何判断当前队列是空队列还是满队列呢?常见有两种方法。

方法一:是附加一个标志位 empty (随便取的名字)

head 赶上 rear,队列空,则令empty=true,
rear 赶上 head,队列满,则令empty=false,

方法二:限制 rear 赶上 head,即队尾结点与队首结点之间至少留有一个元素的空间,一般限制head的前一个节点禁止存储值。

方法二

队列空:   head===rear
队列满:   (rear+1)% MAX_LEN === head

第二种方法需要多占用数组一个元素的空间

以上两次实现方法都很常见,下面以第一种为例。

了解了思路,就开始实现了。

export class ArrayQueue{
    constructor(size = 10) {
        this._MAX_LEN = size;
        this._arr = Array(this._MAX_LEN)
        this._head = 0;
        this._rear = 0;
        this._empty = true;// 用于判满和判空的标志位
    }

    toString(){
        // 从开始指针到结束
        let result = []
        let cur = this._head;

        if(!this.isEmpty()){
            while (this._rear !== cur){
                result.push(`${this._arr[cur]}`);
                cur = this._getNextIndex(cur);
            }
        }

        return result.join(' --> ');
    }

    // 规定使用此方法来获取下一个位置的下标即可让数组构成逻辑上的环路
    _getNextIndex(index){
        return (index+1) % this._MAX_LEN;
    }

    // 入栈
    push(data){
        // 判满
        if(this._rear === this._head && !this._empty){
            return false;
        }

        // 在rear位置插入,然后rear后移
        this._arr[this._rear] = data
        this._rear = this._getNextIndex(this._rear)

        // rear赶上head:满队列
        if(this._rear === this._head){
            this._empty = false;
        }
        return true;
    }

    // 出栈
    pop(){
        // 判空
        if(this.isEmpty()){
            return null;
        }

        // 获取当前head位置的元素,然后head后移
        const popValue = this._arr[this._head]
        this._head = this._getNextIndex(this._head)

        // head赶上rear:空队列
        if(this._rear === this._head){
            this._empty = true;
        }

        return popValue;
    }

    // 获取队头
    head(){
        return this.isEmpty() ? null : this._arr[this._head];
    }

    // 获取队尾
    rear(){
        return this.isEmpty() ? null : this._arr[this._rear];
    }

    // 判空
    isEmpty(){
        return (this._head === this._rear) && this._empty
    }
}

第二种判空判满的方法实现也比较简单,附上代码吧,这里一些重复的方法就不实现了,直接继承了。

export class ArrayQueue1 extends ArrayQueue{
    constructor(size = 10) {
        super(size);
    }

    push(data){
        // 当rear的下一个位置为head即表示满队
        let newRear = this._getNextIndex(this._rear)
        if(newRear === this._head){
            return false;
        }

        this._arr[this._rear] = data;
        this._rear = newRear;

        return true;
    }

    pop(){
        // 判空
        if(this.isEmpty()){
            return null;
        }

        // 获取当前head位置的元素,然后head后移
        const popValue = this._arr[this._head];
        this._head = this._getNextIndex(this._head);

        return popValue;
    }

    isEmpty(){
        return this._head === this._rear;
    }
}

实际开发中

在实际开发中,如果我们队数据结构的要求不是特别高,一般会采用一个无限制的数组模拟队列,使用数组的push方法和shift方法来实现出栈和入栈,通过判断数组的长度来判断其是否为空。比较简便。

export class Queue {
    constructor() {
        this._arr = [];
    }

    toString(){
        return this._arr.toString();
    }

    // 入队
    push(data){
        return this._arr.push(data);
    }

    // 出队
    pop(){
        return this._arr.shift();
    }

    // 获取队头
    head(){
        return this._arr.length ? this._arr[0] : null;
    }

    // 获取队尾
    rear(){
        return this._arr.length ? this._arr[this._arr.length-1] : null;
    }

    // 判断是否为空
    isEmpty(){
        return !!this._arr.length;
    }
}

队列的拓展

上面介绍的只是普通队列的概念和实现方式,现实中由普通队列还衍生出很对其他的数据结构,在解决某些特定问题时这个衍生的结构更加高效和方便。leetcode中也可以经常看见他们的身影哦~

我们来认识一下他们吧

双向队列

普通队列限制只允许在表的一端进行删除操作,在表的另一端端只能进行插入操作。而双向队列同时允许在表的两端进行删除和插入操作。所以对于双向队列来说没有队首和队尾。一般使用 left 和 right 来表示双向队列的两端。

双向队列

因为双向队列的两端都可以进行删除和插入操作,所以也产生 leftPoprightPopleftPushrightPush4个方法来操作双向队列。

其实现与普通队列类似,大家可以尝试实现一下。

双向队列两端都需要进行删除操作,这导致我们无法避开单向链表的尾删问题,所以双向队列的链表实现一般为双向链表

输出受限队列

输出受限队列 在表的两端都可以进行插入操作,但是限制只有一端可以进行删除操作。可以看出输出首先队列就是双向队列限制了一端的输出(删除)操作而产生的。

输出受限队列

输入受限队列

看了 输出受限队列 的定义,想必你也已经猜到什么是 输入受限队列 了,没错,输入受限队列 就是在表的两端都可以进行删除操作,但是限制只能在表的一端可以进行添加操作,所以叫 输入受限队列

输入受限队列

优先队列(堆)

优先队列 也是一种抽象数据类型。与普通队列一样,优先队列在一端限制输出,另一端限制输入,不同的是优先队列中的每个元素都有优先级,而优先级高(或者低)的将会先出队,优先级相同的则按照其在优先队列中的顺序依次出队。

然而 优先队列 往往使用 来实现,以至于通常说 时,就自然而然地想到了 优先队列

堆结构计划在后几篇文章介绍,所以这里就先不实现优先队列了(因为我不会🤣)

当然也有许多其他的实现方法,例如js的 异步任务队列 就是使用 宏任务队列微任务队列 两个普通队列来实现的 优先队列。每一次获取异步任务时,总是先获取优先级高的(微任务)队列的队首元素。这种通过对不同优先级创建不同的队列来实现的优先队列,在优先级可选值不多时是一种不错的选择。

这种多任务队列的实现方式还是比较简单的,同学们可以尝试一下哦~

到这里我们就已经介绍完队列的知识点了,现在我们可以来做两个简单的力扣题来实战一下。

leetcode实战

使用两个栈模拟队列

leetcode: leetcode-cn.com/problems/im…

栈是限制只能在表的一端添加和删除,队列是限制只能在一段端添加另一端删除。这是两个完全不一样的抽象数据结构,栈如何能模拟队列呢?

使用栈来模拟队列时我们需要创建两个栈来结合使用(负负得正),这里分别叫做in和out,其中in模拟入队操作,out模拟出队操作。

思路:

push:

  • 直接插入 in

pop:

  • out 不为空的时候,弹出 out 的栈顶元素
  • out 为空的时候,将 in 中的元素逐个弹出并压入 out 中.最后弹出 out 的栈顶元素

image.png

注意:

元素在从 in 转移到 out 时,需要符合以下规则

  • 转移前,out 中必须为空
  • 转移时 out 不能执行出栈操作,in 也不能执行入栈操作
  • in 中所有的元素必须全部转移到 out 才算转移完成

一句话: 转移前后必须有一个栈为空(转移前栈2空,转移后栈1空)

使用两个队列模拟栈

leetcode: leetcode-cn.com/problems/im…

思路:

准备两个队列用于实现栈,方便叫做 in 和 out。

push:

  • 将其加入队列 in

pop:

  • 将队列 in 中的 n-1 个元素逐个出队列(只留一个),并进入队列 out
  • 将队列 in 中的最后一个元素出队列(出栈)
  • 互换 in 和 out 的角色

image.png

我们可以发现,无论是通过栈实现队列还是通过队列实现栈,都是在pop操作阶段做处理。

但是只有用两个队列才能实现栈吗?可不可以用一个呢?

一个队列也可以模拟栈

当然可以,当我们使用一个第一列实现栈时思路大致如下

思路:

push: 直接入队,然后将前面的元素依次出队列后再入队(这样新加入的元素就在队首了)

pop:直接出队列即可