前端应该了解的数据结构 | 队列

202 阅读8分钟

前言

大家好!我是 嘟老板。本文是 「前端应该了解的算法与数据结构」系列第 5 篇,我们继续数据结构的学习,今天的主题是 - 队列

配合系列文章阅读,效果更佳。

  1. 前端应该了解的算法 | 如何度量算法的执行效率
  2. 前端应该了解的数据结构 | 线性表
  3. 前端应该了解的数据结构 | 链表
  4. 前端应该了解的数据结构 | 栈

阅读本文您将收获:

  1. 了解多种 队列 结构定义及特性。
  2. 了解多种存储方式实现 队列 结构及复杂度分析。

定义

大家应该都听说过 队列消息队列任务队列 等应用场景相当普遍。那么 队列 到底是怎样的一种数据结构呢?

队列queue)是只允许在一端进行插入操作,而在另一端进行删除操作的 线性表

首先,队列 是一种 线性表,具有 先进先出(FIFO,First In First Out) 的特性;其次,队列 只允许在一端插入元素,这一端称为 队尾,在另一端删除元素,这一端称为 队头

image.png

如我们排队买票的场景,后来的人只能站在队伍最后面,而队伍最前面的人才能买票,离开。

实现

抽象数据类型

队列 是一种特殊的线性表,理论上具备 线性表 的所有特性。然而由于 队列 在插入、删除元素方面的特殊性,我们为 队列 定义以下操作:

  • length:队列元素个数。
  • initQueue:初始化队列结构。
  • clearQueue:清空队列元素。
  • getHead:获取队头元素。
  • enQueue:队尾插入元素。
  • deQueue:队头元素出队列。

队列类定义如下:

/**
 * 队列
 */
class Queue {

  // 队列元素个数
  length = 0

  constructor(data) {
    this.initQueue(data)
  }

  // 初始化队列结构
  initQueue(data) {}

  // 清空队列
  clearQueue() {}

  // 获取队头元素
  getHead() {}

  // 元素e入栈
  enQueue(e) {}

  // 队头元素出栈
  deQueue() {}
}

顺序存储结构

我们用 数组 来实现队列的顺序存储结构。

动手前先思考一下,数组的哪端作队头,哪端作队尾呢?很明显,下标为 0 的一端更适合作为队头。

基础队列

基础队列 指尾部插入元素,头部移除元素,未经任何优化的队列结构。

属性定义

基础队列需要具备以下属性:

  • MAX_SIZE:队列最大容量,默认 10
  • data:存储队列元素的数组,默认 空数组
  • length:队列元素数量,默认 0
  • tail:指向队尾元素下一个位置的指针,默认 0

队列类定义如下:

class BaseSqQueue {

  // 队列最大容量
  MAX_SIZE = 10
  // 元素数组
  data = []
  // 队列元素个数
  length = 0
  
  // ...
}
进队列(enQueue

进队列大致逻辑如下:

  1. 边界判断,元素个数是否已达容量上限,若是,则报错 “队列已满”。
  2. 将新元素添加到队尾,并将指针 tail 后移 1 位。
  3. 队列长度 length1

实现如下:

enQueue(e) {
    if (this.length >= this.MAX_SIZE) {
      console.error('队列已满')
      return
    }
    this.tail++
    this.data[this.tail] = e
    this.length++
}
出队列(deQueue

出队列大致逻辑如下:

  1. 边界判断,当前队列是否为空,若是,返回 null
  2. 获取下标为 0 的队头元素 e,作为返回值。
  3. 除队头元素外的所有元素,下标依次减 1
  4. 清空原队尾数据,队尾指针向前移动 1 位。
  5. 队列长度减 1
  6. 返回被删除的队头元素 e

实现如下:

deQueue() {
    if (this.length === 0) {
      console.warn('队列已空')
      return null
    }
    const e = this.data[0]
    for (let i = 0; i < this.data.length; i++) {
      this.data[i] = this.data[i + 1]
    }
    this.data[this.tail] = null
    this.tail--
    this.length--
    return e
}
时间复杂度分析

进队列操作时,直接在队尾加入元素即可,时间复杂度 O(1)

出队列操作时,需要将剩余元素依次向前移动 1 位,时间复杂度为 O(n)

循环队列

为了避免 基础队列 移除元素的性能问题,更合理的利用队列空间,衍生出了 循环队列 结构。

所谓 循环队列,就是不局限于仅在队尾插入元素,从数组尾部到头部,顺序寻找空间,并通过指针来定位队头和队尾元素。与 基础队列 最大的不同在于移除元素时不再需要移动队列中剩余元素,提升操作性能。

循环队列示意图如下:

image.png

属性定义

从以上示意图可见,循环队列 相较于 基础队列 多了一个队头指针 head,指向对头元素的下标,默认为 0

队列类定义如下:

class CircleSqQueue {

  // 队列最大容量
  MAX_SIZE = 10
  // 元素数组
  data = []
  // 队列元素个数
  length = 0
  // 队尾指针
  head = 0
  // 队尾指针
  tail = 0
}

循环队列 有以下可能的状态:

  • 当队列为空时,head 指针和 tail 指针指向同一位置,即 head === tail

image.png

  • 当队列满时,由于循环特性,队尾元素的下一个位置,就是对头元素,所以 headtail 指针依然相等。

image.png

  • 队列有元素,但未满,tail 指针指向下一个空置的位置。

image.png

进队列

进队列大致逻辑如下:

  1. 边界判断,元素个数是否已达容量上限,若是,则报错 “队列已满”。
  2. 将新元素添加到 tail 指针处,并移动 tail 指针。
  3. 队列长度 length1

实现如下:

enQueue(e) {
    if (this.length >= this.MAX_SIZE) {
      console.error('队列已满')
      return
    }
    this.data[this.tail] = e
    /**
     * 若 tail 指针指向队列最后一个位置(下标 this.MAX_SIZE - 1),表示已没有后移的空间,将 tail 指向下标 0 的位置;
     * 否则,后移 1 位。
     */
    if (this.tail === this.MAX_SIZE - 1) this.tail = 0
    else this.tail++
    this.length++
}
出队列

出队列大致逻辑如下:

  1. 边界判断,当前队列是否为空,若是,返回 null
  2. 获取 head 指针指向的队头元素 e,作为返回值,然后清空 head 位置的数据。
  3. 移动 head 指针,指向下一个元素。
  4. 队列长度减 1
  5. 返回被删除的队头元素 e

实现如下:

deQueue() {
    if (this.length === 0) {
      console.warn('队列已空')
      return null
    }
    const e = this.data[this.head]
    this.data[this.head] = null
    /**
     * 若 head 指针指向队列最后一个位置(下标 this.MAX_SIZE - 1),表示已没有后移的空间,将 head 指向下标 0 的位置;
     * 否则,则后移 1 位
     */
    if (this.head === this.MAX_SIZE - 1) this.head = 0
    else this.head++
    this.length--
    return e
}
时间复杂度分析

进队列操作时,在 tail 指针位置插入元素即可,时间复杂度 O(1)

出队列操作时,直接移除队头元素,不需要移动位置,时间复杂度为 O(1)

链式存储结构

队列的链式存储结构相当于单链表,只不过队列只允许从尾部插入元素,从头部移除元素,我们可以将链式存储的队列称为 链队列

定义结构

链队列 的元素不是纯数据数据,而是一个 节点结构,包含 数据域指针域,分别用来存储 数据 及指向下一节点的指针 next

链队列节点类如下:

class ChainQueueNode {
  constructor(data) {
    this.data = data
    this.next = undefined
  }
}

其中,data 用来存储数据,next 指针指向当前节点的后继节点。

由于队列操作涉及到队头和队尾,所以链队列包含两个指针 - headnext

链队列类如下:

class ChainQueue {
  // 队头指针,指向头节点
  head
  // 队尾指针,指向队尾元素节点
  tail
  // 队列长度
  length = 0

  constructor() {
    // 空状态 head 和 tail 指针同时指向头节点
    this.head = this.tail = new ChainQueueNode(null)
    this.length = 0
  }
}

其中,head 指针指向链队列的 头节点,头节点的 next 指针指向队列队头元素;tail 指针指向队尾元素;当队列为空时,headtail 都指向头节点。

进队列

链队列实现进队列的大致流程如下:

  1. 根据参数传递的数据,生成新节点 e
  2. 将新节点 e 赋值给当前队尾元素的后继。
  3. 将队尾指针 tail 指向新节点 e
  4. 队列长度加 1

以下是操作示意图:

image.png

代码实现如下:

enQueue(data) {
    // 生成新节点 e
    const e = new ChainQueueNode(data)
    // 将当前队尾元素的后继节点赋值为 e
    this.tail.next = e
    // 将队尾指针指向新节点 e
    this.tail = e
    this.length++
}

出队列

链队列实现出队列的大致流程如下:

  1. 获取队头节点 e
  2. 将节点 e 的后继节点,赋值给头结点的后继。
  3. 若原队列中仅有一个节点,则调整队尾指针指向头节点。
  4. 队列长度减 1

以下是操作示意图:

image.png

代码实现如下:

deQueue() {
    // 获取待移除的队头元素
    const e = this.head.next
    // 将头节点的后继赋值为 e 的后继
    this.head.next = e.next
    // 若当前队尾元素是 e,则删除后,队列为空,tail 指针指向头节点
    if (this.tail === e) {
      this.tail = this.head
    }
    this.length--
    return e.data
}

时间复杂度分析

链队列进队列和出队列的操作都是常数时间,时间复杂度均为 0(1)

结语

本文重点介绍了多种 队列 结构的设计思路,包括 基础队列循环队列链式队列 等,并实现其核心方法,enQueuedeQueue,旨在帮助同学们加深对于 队列 数据结构的理解。希望对您有所帮助!相关代码已上传至 GitHub,欢迎 star

如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。

技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。


往期推荐