js 数据结构 - 队列

693 阅读10分钟

这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战

前言

队列是一种线性表结构,队列的基本操作只有两个:入队和出队。入队是指:在队尾加入成员,出队是指:在队头去除成员。这种结构类似于现实中排队的场景,如果排除插队和中途离去的情况,我们永远都是从队尾开始排队,一直排到队头才走人,因此该结构称为“队列”。

入队流程例子:

  • “数据1”从队尾入队,由于队列没有数据,因此“数据1”直接排在队头
  • 同理,“数据2”从队尾入队,“数据2”则排在“数据1”后面

1628749540(1).png

1628749691(1).jpg

1628749872(1).jpg

出队流程例子:

  • 假设队列里已经存在“数据1”、“数据2”、“数据3”,现在进行出队操作,将会去除“数据1”
  • 继续执行出队操作,将会去除“数据2”,队列最后只剩下“数据3”

1628750081(1).jpg

1628750190(1).jpg

1628750227(1).jpg

在上面的入队流程和出队流程,我们可以找到一个规律:越早入队的数据,越早出队;越晚入队的数据,越晚出队。就像现实中在医院挂号排队一样,肯定是先排的人先就诊(不考虑插队、中途离去的情况)。

这就是队列的特点:“先进先出”(FIFO,first in first out),先进入的数据先出去。

队列和栈一样也有两种实现,一种是基于数组,另一种基于链表。使用数组实现的队列,我们称为“顺序队列”;使用链表实现的队列,我们称为“链式队列”。

顺序队列

使用 js 实现顺序队列。

// 顺序队列
class ArrayQueue {
  #arr = []
  #size = 0
  // 入队
  push(item) {
    if (!item) {
      return
    } 
    this.#arr.push(item)
    this.#size++
  }
  // 出队
  pop() {
    if (this.#size === 0) {
      return
    }
    this.#size--
    return this.#arr.shift()
  }
  size() {
    return this.#size
  }
  print() {
    console.log('队列成员:', this.#arr)
    console.log('队列长度:' + this.#size)
  }
}

可以看到 js 的数组原本就提供了 push()shift() 方法,分别用于入队和出队,我们可以得知 js 数组本身也可用做一个队列。

在我的上一篇文章“js 数据结构 - 栈 (juejin.cn)”里就说过 js 数组本身就可以用做顺序栈,而且栈(里面的数组)的长度还是无上限的,不会遇到边界问题,但用其他语言来写顺序栈,栈里面的数组长度一般是有上限的,会出现边界问题。在这里也一样,从其他语言的角度来看,顺序队列(里面的数组)的长度一般是有上限的,仅从 js 的角度去写顺序队列是不够的,我们应该尝试从其他语言的角度去写顺序队列。

我们就从 java 来看待实现顺序队列吧。由于 java 的数组的长度需要初始化且长度不可变,导致入队会遇到边界问题,当队满时(数组满时),无法入队。因此在入队的操作里需要做队满的判断。

1628750517(1).jpg

而且顺序队列还会遇到一个特殊的场景问题。当队满时,出队一个数据,再入队一个数据,你可能会发现无法入队这个数据,这是因为在数组里,出队的数据是在数组下标 0,数组下标0会变为空位,但是入队是从队尾进入的,导致这个队列即使有空位也无法进行入队操作。

1628751477(1).jpg

解决这个问题很简单,我们只要将后面的数据向队头方向搬移,腾出队尾的空间就可以了,但这样就会让入队的最坏时间复杂度O(1) 提升为 O(n)。

1628752045(1).jpg

下面的队列虽然还是用 js 来写,但是代码是和其他语言一样认为数组长度不可变的,并且初始化队列时需要给出长度上限。

// 其他语言眼中的顺序队列(数组长度不可变)
class OtherArrayQueue {
  #arr
  #head = 0
  #tail = 0
  #size = 0
  #space = 0

  constructor(space) {
    if (!space || space <= 0) {
      throw new Error('队列空间不可小于等于0')
    }
    this.#space = space
    this.#arr = new Array(space)
  }

  // 入队
  push(item) {
    if (!item) {
      return false
    }
    // 队满
    if (this.#head === 0 && this.#tail === this.#space) {
      console.log('队满,无法添加数据:', item)
      return false
    }
    // 不是队满,但是尾指针指向的下标已经越界(等于数组的长度),需要将数据向前搬移
    if (this.#tail === this.#space) {
      for (var i=this.#head; i<this.#space; i++) {
        this.#arr[i - this.#head] = this.#arr[i]
        this.#arr[i] = null // 成功搬移数据后,需要将原来的位置数据清理,防止数组有冗余数据
      }
      this.#tail = this.#tail - this.#head
      this.#head = 0
    }
    this.#arr[this.#tail] = item
    this.#tail++
    this.#size++
    console.log(this.#tail)
    return true
  }

  // 出队
  pop() {
    if (this.#size === 0) {
      console.log('队空')
      return
    }
    var temp = this.#arr[this.#head]
    this.#arr[this.#head] = null
    this.#head++
    this.#size--
    return temp
  }

  size() {
    return this.#size
  }

  print() {
    console.log('队列成员:', this.#arr)
    console.log('队列长度:' + this.#size)
    console.log('队列空间:' + this.#space)
  }
}

我们可以看到顺序队列需要两个指针,一个是头指针,头指针总是指向队头元素的下标;一个是尾指针,尾指针总是指向队尾元素的后一个位置的下标。注意了,尾指针并不是指向队尾元素的下标。

如下:当队满时,头指针和尾指针的指向 image.png

链式队列

链式队列相比顺序队列实现简单很多,这得益于链式队列里面用于存储数据的链表的长度是无上限的。链式队列不会遇到像顺序队列那样有位置却没法入队的情况,因此不需要进行“数据搬移”操作。

链式队列也需要头指针和尾指针,不过链式链表的头指针是记录链表的头结点,尾指针是记录尾结点。

// 结点
class Node {
  constructor(data, next) {
    this.data = data
    this.next = (next === undefined ? null : next)
  }
}

// 链式队列
class LinkedQueue {
  #head
  #tail
  #size = 0

  // 入队
  push(item) {
    if (!item) {
      return false
    }
    // 队伍没数据时
    if (!this.#head) {
      this.#head = new Node(item)
      this.#tail = this.#head
    } else {
      this.#tail.next = new Node(item)
      this.#tail = this.#tail.next
    }
    this.#size++
  }

  // 出队
  pop() {
    if (this.#size === 0) {
      return
    }
    var temp = this.#head.data
    this.#head = this.#head.next
    // 如果这时头部为空,证明队列没任何数据,需要将 tail 指向空
    if (!this.#head) {
      this.#tail = this.#head
    }
    return temp
  }

  size() {
    return this.#size
  }

  print() {
    console.log('队列成员:', this.#head ? '':'为空')
    var node = this.#head
    while(node) {
      console.log(node.data)
      node = node.next
    }
    console.log('队列长度:' + this.#size)
  }
}

循环队列

循环队列是一个环状队列,简单来说就是队头位置和队尾位置认为是相连的,整体看起来就像个圆环。循环队列只能基于数组实现,因为链表的尾结点连接头结点的结构叫做“循环链表”了。但在实现来看,我们确实可以用循环链表来实现循环队列,不过我没见过有人这样去实现循环队列,因此在这里不做这个实现。

由于循环队列是首尾相连,它不会存在顺序队列入队操作可能涉及搬移数据的问题,因此循环队列的使用比顺序队列更加广泛。

如下图:在循环队列中,数组的“下标3”的下一个位置认为是“下标0”,不再认为“下标3”是终点。

image.png

第一种写法

循环队列需要注意的地方有两点,入队的时候,#tail++,如果 #tail 等于 #space,则需要重置 #tail 为0;同样地,出队的时候,#head++,如果 #head 等于 #space,则需要重置 #head 为 0。

// 循环队列
class CycleQueue {
  #arr
  #head = 0
  #tail = 0
  #size = 0
  #space = 0

  constructor(space) {
    if (!space || space <= 0) {
      throw new Error('队列空间不可小于等于0')
    }
    this.#arr = new Array(space)
    this.#space = space
  }

  // 入队
  push(item) {
    if (this.#size === this.#space) {
      console.log('队满,无法添加数据:', item)
      return false
    }
    this.#arr[this.#tail] = item
    this.#tail++
    this.#size++
    if (this.#tail === this.#space) {
      this.#tail = 0
    }
    return true
  }

  // 出队
  pop() {
    if (this.#size === 0) {
      console.log('队空,无法出队')
      return
    }
    var item = this.#arr[this.#head]
    this.#arr[this.#head] = null
    this.#head++
    if (this.#head === this.#space) {
      this.#head = 0
    }
    this.#size--
    return item
  }

  size() {
    return this.#size
  }

  print() {
    console.log('队列成员:' + this.#arr)
    console.log('队列长度:' + this.#size)
    console.log('队列空间:' + this.#space)
    console.log('队头指针:' + this.#head, '队尾指针:' + this.#tail)
  }
}

第二种写法

但有些人喜欢不写 #size(当前队列长度)属性,也就是说不会记录当前的队列长度。

也即是说无法用 this.#size === this.#space 判断队满,无法使用 this.#size === 0 判断队空。

那么当头指针等于尾指针时,如何去判断这是队满情况还是队空情况。如下图,不管是队空,还是队满,头指针和尾指针都有可能指向同一个位置。

1628758258(1).jpg

这里可以通过浪费一个数组空间来进行区分队满和队空情况,如下图例子,就是浪费“下标3”的数组空间,队满时将会是下图这个样子。

1628758715(1).jpg

那么队空和队满是不是可以这样区分判断?

队空判断:#head === #tail
队满判断:(#tail+1) % #space === head

队满的判断可能稍微有点难理解,我们直观地看,似乎 (#tail+1) === #head 就可以判断队满的情况,但是要记住 #tail 是往 #head 方向追赶,队列里面可能进行了 n 轮的入队出队操作,会发生多圈循环的情况。

比如,下图的队列是队满情况,但是 #tail+1 明显不等于 #head

1628759955(1).jpg

因此需要 % #space,解决多圈循环情况。

// 没有记录长度的循环队列(没有 size,浪费数组的一个空间用于辨别队满和队空的情况)
class NoSizeCycleQueue {
  #arr
  #head = 0
  #tail = 0
  #space = 0

  constructor(space) {
    if (!space || space <= 0) {
      throw new Error('队列空间不可小于等于0')
    }
    this.#arr = new Array(space)
    this.#space = space
  }

  // 入队
  push(item) {
    var temp = (this.#tail + 1) % this.#space
    if (temp == this.#head) {
      console.log('队满,无法添加数据:', item)
      return false
    }
    this.#arr[this.#tail] = item
    this.#tail = temp
    return true
  }

  // 出队
  pop() {
    if (this.#head == this.#tail) {
      console.log('队空,无法出队')
      return
    }
    var item = this.#arr[this.#head]
    this.#arr[this.#head] = null
    this.#head = (this.#head + 1) % this.#space
    return item
  }

  print() {
    console.log('队列成员:' + this.#arr)
    console.log('队列空间:' + this.#space)
    console.log('队头指针:' + this.#head, '队尾指针:' + this.#tail)
  }
}

双端队列

双端队列是一种特殊队列,队头和队尾都可以进行入队和出队操作。

有没发现,js 数组也可以作为一个双端顺序队列使用,队头入队使用 unshift() 方法,队头出队使用 shift() 方法,队尾入队使用 push() 方法,队尾出队使用 pop() 方法。

如果从 java 语言角度来看,数组的长度是有限制的,基于顺序队列去实现双端队列恐怕是一件很愚蠢的事。在前面的“顺序队列”章节里就已经说明了一种特殊情况,当队头有空位,队尾已经满员时,则需要向队头搬移数据。也就是说,由于队头和队尾都可以入队,除了考虑是否需要向队头方向搬移数据,也要考虑是否需要向队尾方向搬移数据,这就把入队操作复杂化了。

但双端队列使用同样基于数组实现的循环队列去实现是可以的,队头和队尾的入队、出队操作都不会复杂化。

双端队列当然也可以使用链表实现,只不过使用链表实现就不是环状结构了。

js 数据结构系列文章