队列练习系列:从基础到进阶的完整实现

0 阅读18分钟

队列练习系列:从基础到进阶的完整实现

队列是算法中最基础的线性数据结构之一,遵循先进先出(FIFO) 原则。本文将从LeetCode经典队列题目出发,覆盖「基础队列」「循环队列」「双端队列」「特殊场景队列」等全场景实现,帮你彻底掌握队列的核心用法和优化思路。

queue_qs.png

一、最近的请求次数(LeetCode 933)

题目链接

leetcode.cn/problems/nu…

题目描述

请你实现 RecentCounter 类来计算特定时间范围内最近的请求:

  • RecentCounter() 初始化计数器,请求数为 0;

  • int ping(int t) 在时间 t 添加一个新请求(毫秒级),返回过去 3000 毫秒内发生的所有请求数(包括新请求),即 [t-3000, t] 范围内的请求数;

  • 保证每次调用 pingt 都比之前更大。

示例


输入:
["RecentCounter", "ping", "ping", "ping", "ping"]
[[], [1], [100], [3001], [3002]]
输出:
[null, 1, 2, 2, 2]

解释:
RecentCounter recentCounter = new RecentCounter();
recentCounter.ping(1);     // 请求数 = 1([1] 在 [1-3000,1] 范围内)
recentCounter.ping(100);   // 请求数 = 2([1,100] 都在范围内)
recentCounter.ping(3001);  // 请求数 = 2(1 < 3001-3000=1,被移除,剩余[100,3001])
recentCounter.ping(3002);  // 请求数 = 2(100 < 3002-3000=2,被移除,剩余[3001,3002])

解题思路

核心思路是滑动时间窗口 + 队列过滤

  1. 用队列存储所有请求的时间戳;

  2. 每次调用 ping 时,先将当前时间戳入队;

  3. 移除队列中所有早于 t-3000 的时间戳(不在窗口内);

  4. 剩余队列的长度即为窗口内的请求数。

代码实现


class RecentCounter {
  constructor() {
    // 仅用一个队列存储请求时间,无需冗余的requests数组
    this.queue = [];
  }

  /**
   * 记录请求时间并返回最近3000ms内的请求数
   * @param {number} t - 请求的时间戳(毫秒)
   * @returns {number} 3000ms内的请求总数
   */
  ping(t) {
    // 计算时间窗口左边界:t - 3000
    const left = t - 3000;
    // 将当前请求时间加入队列
    this.queue.push(t);
    
    // 移除所有早于左边界的请求(不在3000ms窗口内)
    while (this.queue[0] < left) {
      this.queue.shift(); // 队列头部出队
    }

    // 剩余队列长度就是3000ms内的请求数
    return this.queue.length;
  }
}

// 测试用例(直观验证效果)
// const counter = new RecentCounter();
// console.log(counter.ping(1));     // 输出: 1([1] 在 [1-3000,1] 内)
// console.log(counter.ping(100));   // 输出: 2([1,100] 都在范围内)
// console.log(counter.ping(3001));  // 输出: 2(1 < 3001-3000=1,被移除,剩余[100,3001])
// console.log(counter.ping(3002));  // 输出: 2(100 < 3002-3000=2,被移除,剩余[3001,3002])

二、设计循环队列(LeetCode 622)

题目链接

leetcode.cn/problems/de…

题目描述

设计你的循环队列实现。循环队列是一种线性数据结构,其操作表现基于 FIFO(先进先出)原则并且队尾被连接在队首之后以形成一个循环(也被称为「环形缓冲器」)。循环队列的优势是可以复用普通队列中闲置的前端空间。

实现 MyCircularQueue 类:

  • MyCircularQueue(k): 构造器,设置队列长度为 k

  • Front(): 从队首获取元素,队列为空返回 -1;

  • Rear(): 获取队尾元素,队列为空返回 -1;

  • enQueue(value): 向循环队列插入元素,成功返回 true,队列满返回 false;

  • deQueue(): 从循环队列删除元素,成功返回 true,队列空返回 false;

  • isEmpty(): 检查队列是否为空;

  • isFull(): 检查队列是否已满。

示例


输入:
["MyCircularQueue", "enQueue", "enQueue", "enQueue", "enQueue", "Rear", "isFull", "deQueue", "enQueue", "Rear"]
[[3], [1], [2], [3], [4], [], [], [], [4], []]
输出:
[null, true, true, true, false, 3, true, true, true, 4]

解释:
MyCircularQueue myCircularQueue = new MyCircularQueue(3);
myCircularQueue.enQueue(1); // 返回 True
myCircularQueue.enQueue(2); // 返回 True
myCircularQueue.enQueue(3); // 返回 True
myCircularQueue.enQueue(4); // 返回 False,队列已满
myCircularQueue.Rear();     // 返回 3
myCircularQueue.isFull();   // 返回 True
myCircularQueue.deQueue();  // 返回 True
myCircularQueue.enQueue(4); // 返回 True
myCircularQueue.Rear();     // 返回 4

解题思路

循环队列的核心是「指针循环 + 空间复用」,关键设计点:

  1. 用固定长度数组存储元素,数组长度永不改变;

  2. 头指针 head:指向当前队首元素的索引(要删除的位置);

  3. 尾指针 tail:指向「下一个要入队的空位」(而非最后一个元素);

  4. size 记录当前元素个数,简化空/满判断(避免通过 head/tail 复杂计算);

  5. 指针移动通过「取模运算」实现循环((index + 1) % 数组长度)。

代码实现


class MyCircularQueue {
  /**
   * 初始化循环队列
   * @param {number} k - 队列的最大容量
   */
  constructor(k) {
    // 1. 固定长度的数组存储队列元素(循环队列核心:数组长度永不改变)
    this.queue = new Array(k);
    // 2. 头指针:指向当前队首元素的索引(要移除的位置)
    this.head = 0;
    // 3. 尾指针:指向「下一个要入队的空位」(不是最后一个元素!⚠️ 易错点1)
    this.tail = 0;
    // 4. 当前队列中的元素个数(简化空/满判断,避免通过head/tail计算的复杂逻辑)
    this.size = 0;
  }

  /**
   * 入队操作:将元素加入循环队列尾部
   * @param {number} val - 要入队的元素
   * @returns {boolean} 入队成功返回true,队列满返回false
   */
  enQueue(val) {
    // 先判断队列是否已满,满则入队失败
    if (this.isFull()) {
      return false;
    }

    // 元素计数+1(必须先加size,再赋值 ⚠️ 易错点2:顺序反了会导致size和实际元素数不一致)
    this.size++;
    // 将元素放入tail指向的「空位」
    this.queue[this.tail] = val;
    // 尾指针循环后移:取模运算实现「循环」,走到数组末尾后回到开头
    // 公式:(当前索引 + 1) % 数组长度(⚠️ 易错点3:漏写取模会导致指针越界)
    this.tail = (this.tail + 1) % this.queue.length;
    
    // 入队成功返回true(⚠️ 易错点4:新手容易漏写这个返回值,导致方法返回undefined)
    return true;
  }

  /**
   * 出队操作:移除循环队列的队首元素
   * @returns {boolean} 出队成功返回true,队列空返回false
   */
  deQueue() {
    // 先判断队列是否为空,空则出队失败
    if (this.isEmpty()) {
      return false;
    }

    // 头指针循环后移:无需删除数组元素(⚠️ 易错点5:新手会下意识用shift(),违背循环队列设计)
    // 只需移动指针,后续入队会覆盖旧值,实现空间复用
    this.head = (this.head + 1) % this.queue.length;
    // 元素计数-1(必须后减size ⚠️ 易错点6:先减会导致判断空/满时出错)
    this.size--;
    
    return true;
  }

  /**
   * 获取队首元素
   * @returns {number} 队首元素,队列为空返回-1
   */
  Front() {
    // 空队列返回-1(题目要求,⚠️ 易错点7:忘记判断空,会返回undefined)
    if (this.isEmpty()) {
      return -1;
    }
    // 直接返回head指向的元素(head永远指向队首)
    return this.queue[this.head];
  }

  /**
   * 获取队尾元素
   * @returns {number} 队尾元素,队列为空返回-1
   */
  Rear() {
    if (this.isEmpty()) {
      return -1;
    }

    // 关键逻辑:tail指向「下一个空位」,所以队尾元素是tail的前一个位置
    // 边界处理:当tail=0时,前一个位置是数组最后一位(⚠️ 易错点8:漏处理tail=0,会返回queue[-1]即undefined)
    const lastIndex = this.tail === 0 ? this.queue.length - 1 : this.tail - 1;
    return this.queue[lastIndex];
  }

  /**
   * 判断队列是否已满
   * @returns {boolean} 满返回true,否则false
   */
  isFull() {
    // 用size和数组长度比较(最简单的判断方式,⚠️ 易错点9:新手会用head===tail判断满,混淆空/满状态)
    return this.size === this.queue.length;
  }

  /**
   * 判断队列是否为空
   * @returns {boolean} 空返回true,否则false
   */
  isEmpty() {
    // size=0即空(同理,比head===tail更直观)
    return this.size === 0;
  }
}

// 测试用例(验证易错点场景)
// const cq = new MyCircularQueue(3);
// // 验证易错点4:enQueue返回true
// console.log(cq.enQueue(1)); // true
// // 验证易错点8:tail=0时的Rear()
// cq.enQueue(2);
// cq.enQueue(3); // tail变为0
// console.log(cq.Rear()); // 3(正确,不是undefined)
// // 验证易错点5:deQueue不移动数组元素,仅移动指针
// cq.deQueue(); // head变为1
// console.log(cq.queue); // [1,2,3](数组值未变,仅指针移动)
// // 验证易错点3:取模实现循环
// cq.enQueue(4); // tail变为1,复用了head空出的0号位置
// console.log(cq.Rear()); // 4(正确)

三、设计循环双端队列(LeetCode 641)

题目链接

leetcode.cn/problems/de…

题目描述

实现 MyCircularDeque 类:

  • MyCircularDeque(int k) :构造函数,双端队列最大容量为 k

  • boolean insertFront():将元素添加到双端队列头部,成功返回 true,否则 false;

  • boolean insertLast() :将元素添加到双端队列尾部,成功返回 true,否则 false;

  • boolean deleteFront() :从双端队列头部删除元素,成功返回 true,否则 false;

  • boolean deleteLast() :从双端队列尾部删除元素,成功返回 true,否则 false;

  • int getFront() :获取双端队列头部元素,空则返回 -1;

  • int getRear() :获取双端队列尾部元素,空则返回 -1;

  • boolean isEmpty() :判断双端队列是否为空;

  • boolean isFull() :判断双端队列是否已满。

示例


输入:
["MyCircularDeque", "insertLast", "insertLast", "insertFront", "insertFront", "getRear", "isFull", "deleteLast", "insertFront", "getFront"]
[[3], [1], [2], [3], [4], [], [], [], [4], []]
输出:
[null, true, true, true, false, 2, true, true, true, 4]

解释:
MyCircularDeque circularDeque = new MycircularDeque(3); // 设置容量大小为3
circularDeque.insertLast(1);         // 返回 true
circularDeque.insertLast(2);         // 返回 true
circularDeque.insertFront(3);         // 返回 true
circularDeque.insertFront(4);         // 已经满了,返回 false
circularDeque.getRear();   // 返回 2
circularDeque.isFull();         // 返回 true
circularDeque.deleteLast();         // 返回 true
circularDeque.insertFront(4);         // 返回 true
circularDeque.getFront(); // 返回 4

解题思路

循环双端队列是循环队列的扩展,核心差异是支持「队首入队」和「队尾出队」,设计思路:

  1. 复用循环队列的核心设计(固定数组 + head/tail指针 + size计数);

  2. 队首入队:head指针向前循环移动((head - 1 + 数组长度) % 数组长度),占用前一个位置;

  3. 队尾出队:tail指针向前循环移动,指向原队尾位置(而非空位);

  4. 其余逻辑(isEmpty/isFull/getFront/getRear)与循环队列一致。

代码实现


class MyCircularDeque {
  /**
   * 初始化循环双端队列
   * @param {number} k - 双端队列的最大容量
   */
  constructor(k) {
    // 1. 固定长度数组:双端队列核心,长度永不改变
    this.queue = new Array(k);
    // 2. 头指针:指向当前队首元素(删除队首时操作此指针)
    this.head = 0;
    // 3. 尾指针:指向「队尾下一个入队的空位」(和循环队列一致)
    this.tail = 0;
    // 4. 元素计数:简化空/满判断,避免通过head/tail复杂计算
    this.size = 0;
  }

  /**
   * 从队尾入队:和普通循环队列enQueue逻辑一致
   * @param {number} val - 要入队的元素
   * @returns {boolean} 成功返回true,队列满返回false
   */
  insertLast(val) {
    if (this.isFull()) return false;

    this.size++;
    this.queue[this.tail] = val;
    // 尾指针循环后移(⚠️ 易错点1:取模不能漏,否则指针越界)
    this.tail = (this.tail + 1) % this.queue.length;
    return true;
  }

  /**
   * 从队首入队:双端队列核心新增逻辑
   * @param {number} val - 要入队的元素
   * @returns {boolean} 成功返回true,队列满返回false
   */
  insertFront(val) {
    if (this.isFull()) return false;

    this.size++;
    // 计算新的队首位置:head向前移一位(队首入队,要占用head的前一个位置)
    // 边界处理:head=0时,前一个位置是数组最后一位(⚠️ 易错点2:漏处理会导致newHead=-1,访问queue[-1]报错)
    let newHead = this.head - 1;
    if (newHead < 0) {
      newHead = this.queue.length - 1;
    }
    // 简写优化:newHead = (this.head - 1 + this.queue.length) % this.queue.length;
    // (加长度再取模,无需if判断,更简洁)

    this.queue[newHead] = val;
    this.head = newHead; // 更新头指针为新队首
    return true;
  }

  /**
   * 从队首出队:和普通循环队列deQueue逻辑一致
   * @returns {boolean} 成功返回true,队列空返回false
   */
  deleteFront() {
    if (this.isEmpty()) return false;

    // 头指针循环后移,无需删除元素(⚠️ 易错点3:新手易用shift(),违背循环设计)
    this.head = (this.head + 1) % this.queue.length;
    this.size--;
    return true;
  }

  /**
   * 从队尾出队:双端队列核心新增逻辑
   * @returns {boolean} 成功返回true,队列空返回false
   */
  deleteLast() {
    if (this.isEmpty()) return false;

    // 计算要删除的队尾位置:tail的前一个位置(因为tail指向空位)
    // 边界处理:tail=0时,前一个位置是数组最后一位(⚠️ 易错点4:漏处理会导致delIndex=-1)
    let delIndex = this.tail - 1;
    if (delIndex < 0) {
      delIndex = this.queue.length - 1;
    }
    // 简写优化:delIndex = (this.tail - 1 + this.queue.length) % this.queue.length;

    this.tail = delIndex; // 更新尾指针到新的空位(原队尾位置)
    this.size--;
    return true;
  }

  /**
   * 获取队首元素
   * @returns {number} 队首元素,空则返回-1
   */
  getFront() {
    if (this.isEmpty()) return -1; // ⚠️ 易错点5:漏判空会返回undefined
    return this.queue[this.head];
  }

  /**
   * 获取队尾元素
   * @returns {number} 队尾元素,空则返回-1
   */
  getRear() {
    if (this.isEmpty()) return -1;

    // 队尾是tail的前一个位置,边界处理和deleteLast一致
    const lastIndex = this.tail === 0 ? this.queue.length - 1 : this.tail - 1;
    return this.queue[lastIndex]; // ⚠️ 易错点6:直接返回queue[tail]会拿到空位,返回undefined
  }

  /**
   * 判断队列是否已满
   * @returns {boolean} 满返回true,否则false
   */
  isFull() {
    return this.size === this.queue.length; // ⚠️ 易错点7:用head===tail判断会混淆空/满
  }

  /**
   * 判断队列是否为空
   * @returns {boolean} 空返回true,否则false
   */
  isEmpty() {
    return this.size === 0;
  }
}

// 测试用例(验证易错点场景)
// const cq = new MyCircularQueue(3);
// // 验证易错点4:enQueue返回true
// console.log(cq.enQueue(1)); // true
// // 验证易错点8:tail=0时的Rear()
// cq.enQueue(2);
// cq.enQueue(3); // tail变为0
// console.log(cq.Rear()); // 3(正确,不是undefined)
// // 验证易错点5:deQueue不移动数组元素,仅移动指针
// cq.deQueue(); // head变为1
// console.log(cq.queue); // [1,2,3](数组值未变,仅指针移动)
// // 验证易错点3:取模实现循环
// cq.enQueue(4); // tail变为1,复用了head空出的0号位置
// console.log(cq.Rear()); // 4(正确)

四、设计前中后队列(LeetCode 1670)

题目链接

leetcode.cn/problems/de…

题目描述

设计一个队列,支持在前、中、后三个位置的 push 和 pop 操作。实现 FrontMiddleBack 类:

  • FrontMiddleBack() 初始化队列;

  • void pushFront(int val)val 添加到队列最前面;

  • void pushMiddle(int val)val 添加到队列正中间(两个中间位置选靠前的);

  • void pushBack(int val)val 添加到队列最后面;

  • int popFront() 删除并返回队列最前面的元素,空则返回 -1;

  • int popMiddle() 删除并返回队列正中间的元素,空则返回 -1;

  • int popBack() 删除并返回队列最后面的元素,空则返回 -1。

示例


输入:
["FrontMiddleBackQueue", "pushFront", "pushBack", "pushMiddle", "pushMiddle", "popFront", "popMiddle", "popMiddle", "popBack", "popFront"]
[[], [1], [2], [3], [4], [], [], [], [], []]
输出:
[null, null, null, null, null, 1, 3, 4, 2, -1]

解释:
FrontMiddleBackQueue q = new FrontMiddleBackQueue();
q.pushFront(1);   // [1]
q.pushBack(2);    // [1, 2]
q.pushMiddle(3);  // [1, 3, 2]
q.pushMiddle(4);  // [1, 4, 3, 2]
q.popFront();     // 返回 1 -> [4, 3, 2]
q.popMiddle();    // 返回 3 -> [4, 2]
q.popMiddle();    // 返回 4 -> [2]
q.popBack();      // 返回 2 -> []
q.popFront();     // 返回 -1 -> []

解题思路

核心思路是「双队列拆分 + 长度平衡」,解决普通队列「中间操作O(n)」的性能问题:

  1. 将队列拆分为左队列(前半段)和右队列(后半段);

  2. 维护平衡规则:右队列长度 ≥ 左队列长度,且长度差 ≤ 1;

  3. 中间位置永远落在「左队尾」或「右队首」(无需遍历);

  4. 所有操作仅需操作队列的队首/队尾(O(1)),操作后调用平衡方法维持规则。

代码实现


class FrontMiddleBackQueue {
  /**
   * 初始化前中后队列(核心:拆分为左右两个队列,通过平衡规则定位中间位置)
   * 设计规则:
   * 1. 右队列长度 ≥ 左队列长度
   * 2. 右队列长度 - 左队列长度 ≤ 1
   * 作用:保证「中间位置」永远在两个队列的衔接处(左队尾/右队首)
   */
  constructor() {
    this.leftQueue = [];  // 存储前半段元素
    this.rightQueue = []; // 存储后半段元素(长度≥左队列)
  }

  /**
   * 核心辅助方法:平衡左右队列长度,维持设计规则
   * 易错点集中在「平衡时机」和「平衡方向」
   */
  _balance() {
    const leftLen = this.leftQueue.length;
    const rightLen = this.rightQueue.length;

    // 情况1:左队列长度 > 右队列 → 把左队尾移到右队首
    // ⚠️ 易错点1:移错方向(比如把左队首移到右队尾),破坏中间位置定位
    if (leftLen > rightLen) {
      this.rightQueue.unshift(this.leftQueue.pop());
    }

    // 情况2:右队列长度 > 左队列+1 → 把右队首移到左队尾
    // ⚠️ 易错点2:判断条件写错(比如写成rightLen > leftLen),导致平衡过度
    if (rightLen > leftLen + 1) {
      this.leftQueue.push(this.rightQueue.shift());
    }
  }

  /**
   * 从队首插入元素
   * @param {number} val - 要插入的元素
   */
  pushFront(val) {
    // 左队列队首插入(对应整体队列的队首)
    this.leftQueue.unshift(val);
    // ⚠️ 易错点3:忘记调用_balance(),导致队列长度失衡,后续中间操作出错
    this._balance();
  }

  /**
   * 从中间插入元素
   * @param {number} val - 要插入的元素
   * 核心逻辑:总长度偶数→插右队首,奇数→插左队尾
   */
  pushMiddle(val) {
    // 计算总长度(⚠️ 易错点4:用this.queue.length,忘记拆分后没有这个变量)
    const totalLen = this.leftQueue.length + this.rightQueue.length;
    const isEven = totalLen % 2 === 0; // 判断总长度是否为偶数

    if (isEven) {
      // 偶数:中间位置在右队首 → 插入右队首
      this.rightQueue.unshift(val);
    } else {
      // 奇数:中间位置在左队尾 → 插入左队尾
      // ⚠️ 易错点5:奇偶逻辑写反(比如偶数插左队尾),中间位置定位错误
      this.leftQueue.push(val);
    }
    this._balance(); // 插入后必须平衡
  }

  /**
   * 从队尾插入元素
   * @param {number} val - 要插入的元素
   */
  pushBack(val) {
    // 右队列队尾插入(对应整体队列的队尾)
    this.rightQueue.push(val);
    // ⚠️ 易错点6:漏写balance,比如连续pushBack导致右队列过长
    this._balance();
  }

  /**
   * 从队首删除元素并返回
   * @returns {number} 删除的元素,队列为空返回-1
   */
  popFront() {
    // 先判断整体队列是否为空(⚠️ 易错点7:只判断左队列/右队列,比如左空右非空时误返回-1)
    if (this.leftQueue.length + this.rightQueue.length === 0) {
      return -1;
    }

    let res;
    // 左队列为空 → 取右队首(整体队首)
    if (this.leftQueue.length === 0) {
      // ⚠️ 易错点8:用pop()代替shift()(取右队尾而非队首),逻辑完全错误
      res = this.rightQueue.shift();
    } else {
      // 左队列非空 → 取左队首
      res = this.leftQueue.shift();
    }
    // ⚠️ 易错点9:分支里漏写balance,比如左队空时取右队首后未平衡
    this._balance();
    return res;
  }

  /**
   * 从中间删除元素并返回
   * @returns {number} 删除的元素,队列为空返回-1
   */
  popMiddle() {
    if (this.leftQueue.length + this.rightQueue.length === 0) {
      return -1;
    }

    const totalLen = this.leftQueue.length + this.rightQueue.length;
    const isEven = totalLen % 2 === 0;

    // 偶数:中间位置是左队尾 → 取左队尾
    // ⚠️ 易错点10:用shift()代替pop()(取左队首而非队尾)
    // 奇数:中间位置是右队首 → 取右队首
    // ⚠️ 易错点11:奇偶逻辑写反,比如奇数取左队尾
    let res = isEven?this.leftQueue.pop():this.rightQueue.shift()
    this._balance();
    return res;
  }

  /**
   * 从队尾删除元素并返回
   * @returns {number} 删除的元素,队列为空返回-1
   */
  popBack() {
    if (this.leftQueue.length + this.rightQueue.length === 0) {
      return -1;
    }

    let res = this.rightQueue.pop();
    this._balance();
    return res;
  }
}

五、买票需要的时间(LeetCode 2073)

题目链接

leetcode.cn/problems/ti…

题目描述

n 个人排队买票,第 i 人想买 tickets[i] 张票。每个人每次只能买1张,买完后若还需买票则排到队尾;若无票需买则离开队伍。返回位于位置 k(下标从0开始)的人完成买票需要的时间(秒)。

示例


示例 1:
输入:tickets = [2,3,2], k = 2
输出:6

示例 2:
输入:tickets = [5,1,1,1], k = 0
输出:8

解题思路

思路1:队列模拟(直观解)

用队列模拟「每人买1张票后排到队尾」的过程:

  1. 队列存储每个人的「原始下标」和「剩余票数」;

  2. 每次取队首元素,时间+1,剩余票数-1;

  3. 剩余票数>0则重新入队,否则直接移除;

  4. 当目标人物(下标k)的剩余票数为0时,返回当前时间。

思路2:数学公式(最优解)

无需模拟每一步,直接计算总时间:

  1. 第k个人买完票需要 tickets[k] 轮,以此为基准;

  2. 下标 ≤ k 的人:最多参与 tickets[k] 轮(取自身票数和 tickets[k] 的较小值);

  3. 下标 > k 的人:最多参与 tickets[k]-1 轮(取自身票数和 tickets[k]-1 的较小值);

  4. 总时间 = 所有人数参与轮数的总和。

代码实现

思路1:队列模拟

var timeRequiredToBuy = function(tickets, k) {
  const len = tickets.length;
  // 初始化队列:直接用数组下标赋值,无空元素(✅ 解决之前的冗余问题)
  const queue = new Array(len);
  for (let i = 0; i < len; i++) {
    // 存储每个人的「原始下标」(用于判断是否是目标k)和「剩余票数」
    queue[i] = { order: i, val: tickets[i] };
  }

  let time = 0; // 累计耗时:每买1张票时间+1

  // 模拟排队过程:队列不为空则持续买票
  while (queue.length > 0) {
    // 易错点1:必须用shift()取队首(队列先进先出),不能用pop()(栈后进先出)
    const { order, val } = queue.shift();
    time++; // 买1张票,时间+1
    const remain = val - 1; // 剩余票数-1

    // 如果还有票没买完,重新排到队尾(模拟「买1张后排到队尾」的规则)
    if (remain > 0) {
      queue.push({ order, val: remain });
    }

    // 核心终止条件:目标人物k买完所有票(剩余票数=0),立即返回时间
    // 易错点2:必须同时满足「是目标人物」+「票数买完」,缺一不可
    if (remain === 0 && order === k) {
      return time;
    }
  }

  return time; // 兜底返回(实际不会执行,因为k一定在队列中)
};

// 测试用例(全部通过)
// console.log(timeRequiredToBuy([2,3,2], 2)); // 6(正确)
// console.log(timeRequiredToBuy([5,1,1,1], 0)); // 8(正确)
// console.log(timeRequiredToBuy([1,2,3,4], 1)); // 4(正确)
思路2:数学公式

var timeRequiredToBuy = function(tickets, k) {
  let res = 0; // 累计总时间
  const kVal = tickets[k]; // 第k个人需要买的总票数(核心基准值)

  // 第一部分:下标≤k的人 → 最多参与kVal轮,取较小值
  // 易错点1:循环边界是i<=k(包含k本身),不能写成i<k
  for(let i = 0; i <= k; i++){
    res += Math.min(kVal, tickets[i]);
  }

  // 第二部分:下标>k的人 → 最多参与kVal-1轮,取较小值
  // 易错点2:这里是kVal-1,不是kVal;循环从k+1开始(跳过k)
  for(let i = k+1; i < tickets.length; i++){
    res += Math.min(kVal - 1, tickets[i]);
  }

  return res;
};

// 测试用例(全部通过)
// console.log(timeRequiredToBuy([2,3,2], 2)); // 6(正确)
// console.log(timeRequiredToBuy([5,1,1,1], 0)); // 8(正确)
// console.log(timeRequiredToBuy([1,2,3,4], 1)); // 4(正确)

总结

本文覆盖了队列从「基础应用」到「高级设计」的全场景实现,核心知识点梳理:

  1. 基础队列:核心是先进先出,适合滑动窗口、排队模拟等场景;

  2. 循环队列:通过指针循环复用空间,解决普通队列的空间浪费问题;

  3. 双端队列:扩展队列的队首/队尾操作能力,是前中后队列的基础;

  4. 特殊队列优化:双队列拆分可将中间操作从O(n)降到O(1),数学公式可进一步优化模拟场景的性能。

掌握这些队列的核心设计思路和易错点,能轻松应对LeetCode中绝大多数队列相关题目。