前言
点开美团、淘宝闪购点个外卖,你熟悉的下单了你最喜欢的汉堡🍔,停停停...不是讲栈和队列吗?你在干嘛呢?别急,咱们先从 “吃” 说起 —— 假如你吃汉堡的时候,从上往下(比如生菜--肉块)一层一层吃的话,这就是栈,先进后出,就像叠汉堡,先放的底层最后才能吃到。栈的操作很简单,就俩:push(往上叠)和pop(从顶往下吃)。
画图更清晰(好像我画的也不清晰,尽力了😭):
1,2,3按顺序入栈,如果要出栈,顺序就变成了3,2,1,这就是栈,先进后出。
但生活里不是啥都按 “汉堡逻辑” 来的。比如你去买奶茶排队,先到的先点单,这就是队列,先进先出。在 JS 里,咱们可以用数组模拟队列,靠push(后排队员加入)和shift(前排队员点单离开)实现。
依旧上图片:
这种先进先出的就是我们今天要介绍的--队列!
一、队列的 “日常操作”:雪糕队列的进出
咱们看个例子,比如有个雪糕队列:
const queue = [];
queue.push('东北大板');
queue.push('巧乐兹');
queue.push('小布丁');
queue.push('七个小矮人');
for(let i = 0; i < queue.length; i++){
const item = queue.shift();
console.log(item);
}
这里注意,一定不能直接
console.log(queue[i]),那样我们就违背了初衷,把队列当数组处理了,我们的目的是把它当队列处理
乍一看没啥问题,但当你输出结果的时候傻眼了:
啊?为什么只输出了2个,不是循环遍历了吗?有bug?你看你看你仔细看,每当我们使用queue.shift()的时候,它有2个作用。
- 第一个是返回元素的值。
- 第二个是让元素出队啊!
那每当元素出队,队列的长度是不是要-1?那再看看for循环的条件(0,4),(1,3),(2,2),OK到这里 2 < 2 它不成立啊,所以循环只能执行2次,就导致了我们的结果只有2个值。
好,问题来了,那怎么去解决它呢?我这里介绍几种常见的方法:
- 用
const len = queue.length;提前固定长度:
const queue = [];
queue.push('东北大板');
queue.push('巧乐兹');
queue.push('小布丁');
queue.push('七个小矮人');
const len = queue.length;
for(let i = 0; i < len; i++){
const item = queue.shift();
console.log(item);
}
这样我们循环遍历,即使queue的长度会-1,但是len的长度是我们已经提前固定好的,所以循环可以正常执行4次。
- 直接不写
i++,你打我撒,完全可行捏!
const queue = [];
queue.push('东北大板');
queue.push('巧乐兹');
queue.push('小布丁');
queue.push('七个小矮人');
const len = queue.length;
for(let i = 0; i < queue.length;){
const item = queue.shift();
console.log(item);
}
这样每循环一次,虽然queue的长度-1,但是i一直为0啊,那就变成了(0,4),(0,3),(0,2),(0,1),共执行4次,perfect!
拿下!
- 用
while代替for
for循环 是一定要知道遍历的次数的,但当我们不清楚这个次数是多少时,我们一般用 while循环 来代替。
const queue = [];
queue.push('东北大板');
queue.push('巧乐兹');
queue.push('小布丁');
queue.push('七个小矮人');
while(queue.length > 0){
const item = queue.shift();
console.log(item);
}
虽然每次长度在-1,但现在的条件换成了queue.length > 0,也就是(4,0),(3,0),(2,0),(1,0),共执行4次。
nice!通过这个例子,你应该已经初步了解了队列的属性和用法了。
二、队列的 “变形记”:用两个栈“冒充”队列(LeetCode 232 戏法)
有时候,面试官就爱 “刁难”——“你能用栈来实现队列不?” ,你肯定会问他是不是在开玩笑,这就好比让你用汉堡叠法来模拟奶茶排队,听着离谱,但还真能行!
首先我们先看下 栈 它凭什么能冒充 队列。比如说我给你1,2,3这几个数,你入栈之后就变成了3,2,1,3跑到最上面去了,但是我很调皮,我就是要你按顺序给我1,2,3,你说我不纯扯🥚吗,那我给你2个栈呢?诶,鬼点子是不是来了。你马上就会想到,如果我把1,2,3先放到左边的栈里变成了3,2,1,再把左边的栈 倒 到右边的栈里面,是不是变成了3在最下面,1成功逆袭登顶了呢?
手搓图片(我不会画画🖼️~,主要理解意思哈):
看完我抽象的画,我觉得你大概可能也许是真的懂了,接下俩我们实战一下。
以力扣232题为例(题目大家就去力扣网上阅读哈):
先上代码吧:
var MyQueue = function() {
this.stack1 = [];
this.stack2 = [];
};
MyQueue.prototype.push = function(x) {
this.stack1.push(x);
};
MyQueue.prototype.pop = function() {
// 栈1 元素倒到 栈2,再从栈2取值
if(this.stack2.length == 0){
while(this.stack1.length > 0){
const top = this.stack1.pop();
this.stack2.push(top);
}
}
return this.stack2.pop();
};
MyQueue.prototype.peek = function() {
if(this.stack2.length == 0){
while(this.stack1.length > 0){
const top = this.stack1.pop();
this.stack2.push(top);
}
}
return this.stack2[this.stack2.length - 1];
};
MyQueue.prototype.empty = function() {
return !this.stack1.length && !this.stack2.length;
};
代码很长,确实长,等我分析完你就笑呵呵了:原来介么简单~ 咱们就把这段代码当成 “用吃汉堡 (栈) 模拟奶茶排队 (队列) ” 的剧本,逐行拆解每个角色的作用,保证看完就懂!
1. 先搞懂核心逻辑:两个栈的 “分工合作”
栈是 “先进后出” (叠汉堡),队列是 “先进先出”(排奶茶)。要让栈变队列,关键是用两个栈 “倒来倒去” :
- 栈 1 (stack1):专门负责 “接人”—— 新来的元素全往这堆,相当于奶茶店的 “等候区”;
- 栈 2 (stack2):专门负责 “送人”—— 要出队时,把栈 1 的元素全倒过来放进栈 2,此时栈 2 的顶部就是最早来的元素,相当于奶茶店的 “取餐区”。
2.逐方法拆解:每个函数干了啥?
(1) 构造函数:MyQueue () —— 开店准备
var MyQueue = function() {
this.stack1 = []; // 初始化等候区(空)
this.stack2 = []; // 初始化取餐区(空)
};
- 作用:创建一个 “队列” 实例时,先搭好两个栈的 “场地”,啥元素都没有,就像奶茶店刚开门,等候区和取餐区都是空的。
- 这里的
this指的是当前创建的队列实例,比如const q = new MyQueue(),q.stack1就是这个队列的等候区。
(2)入队方法:push (x) —— 顾客排队
MyQueue.prototype.push = function(x) {
this.stack1.push(x);
};
- 作用:把元素
x加入队列(相当于顾客来排队); - 逻辑:不管三七二十一,直接往
stack1里塞不就得了。因为stack1是等候区,新来的都往后排。 - 例子:调用
q.push(1)、q.push(2)、q.push(3)后,stack1 = [1,2,3](1 在最底下,3 在最上面),stack2还是空的。
(3)出队方法:pop () —— 顾客取餐(核心戏)
MyQueue.prototype.pop = function() {
// 第一步:如果取餐区(stack2)空了,就把等候区(stack1)的人全倒过来
if(this.stack2.length == 0){
while(this.stack1.length > 0){
const top = this.stack1.pop(); // 从stack1顶部拿一个(比如3)
this.stack2.push(top); // 放进stack2(此时stack2 = [3])
}
}
// 第二步:从取餐区(stack2)顶部拿一个,就是最早来的元素
return this.stack2.pop();
};
这是最关键的一步,咱们分场景举例:
- 第一次调用 pop ()(stack2 为空)
-
此时
stack1 = [1,2,3],stack2空; -
进入
if判断,执行while循环:- 第一次循环:
stack1.pop()取出 3,stack2.push(3)→stack1 = [1,2],stack2 = [3]; - 第二次循环:
stack1.pop()取出 2,stack2.push(2)→stack1 = [1],stack2 = [3,2]; - 第三次循环:
stack1.pop()取出 1,stack2.push(1)→stack1 = [],stack2 = [3,2,1];
- 第一次循环:
-
循环结束后,
stack2.pop()取出 1(栈 2 顶部是 1),这正是最早 push 的元素,完美实现 “先进先出”! -
此时
stack2 = [3,2],stack1空。
- 再调用一次 pop ()(stack2 不为空)
- 因为
stack2还有元素[3,2],不会进入if循环; - 直接
stack2.pop()取出 2(这是第二个 push 的元素),符合队列逻辑; - 此时
stack2 = [3]。
- 核心技巧:
- 只有当
stack2空了,才会把stack1的元素倒过来 —— 避免重复倒,保证顺序不乱; - 倒过来之后,
stack2的元素顺序和stack1完全相反,所以pop()取的是最早的元素。
(4)查看队头:peek () —— 看看谁是下一个
MyQueue.prototype.peek = function() {
if(this.stack2.length == 0){
while(this.stack1.length > 0){
const top = this.stack1.pop();
this.stack2.push(top);
}
}
return this.stack2[this.stack2.length - 1];
};
-
作用:不删除元素,只查看队列的第一个元素(相当于奶茶店店员喊 “下一位是谁”);
-
逻辑和
pop()几乎一样:- 先判断
stack2是否为空,空就把stack1的元素倒过来; - 区别在于:
pop()是stack2.pop()(删除并返回),而peek()是直接返回stack2的最后一个元素(stack2.length - 1是栈顶下标),不删除;
- 先判断
-
例子:第一次调用
q.peek(),会把stack1的元素倒到stack2,返回 1;再调用还是返回 2(此时stack2 = [3,2],栈顶是 2)。
(5)判断空队列:empty () —— 店里没人了吗?
MyQueue.prototype.empty = function() {
return !this.stack1.length && !this.stack2.length;
};
- 作用:判断队列是否为空(相当于店员看等候区和取餐区都没人了吗);
- 逻辑:只有当
stack1(等候区)和stack2(取餐区)都为空时,才返回true(空队列);
OK,结束,我们把代码跑一遍:
通过!pass!怎么样,是不是很简单?本质是用两个栈的 “倒转” 抵消了栈 “先进后出” 的特性。简单说,这段代码就是用 “等候区 + 取餐区” 的组合,让栈硬生生演成了队列,堪称算法里的 “伪装大师”!
三、队列的 “高阶玩法”:滑动窗口找最大值(LeetCode 239 名侦探柯南式观察)
简单题过了,咱们再看个更有挑战性的 —— 滑动窗口找最大值。比如你站在窗边看风景,窗口每次挪一格,得知道每个窗口里的 “最高楼” 是哪个。
上链接🔗(题目大家依旧去官网看哈):
这时候就得请出双端队列了 —— 它能从队头、队尾进出,就像个 “可进可出” 的双向门。咱们用它来维护一个递减队列,保证队头永远是当前窗口的最大值。
直接上代码:
var maxSlidingWindow = function(nums, k) {
const len = nums.length;
const res = [];
const dque = [];
for(let i = 0; i < len; i++){
// 一个值进队列之前,得先将队列中从后往前依次比较,小的全部出队
while(dque.length && nums[dque[dque.length - 1]] < nums[i]){
dque.pop();
}
dque.push(i); // 不存当前的值,而是存当前值的下标
// 当最大值从窗户左侧出去
while(dque.length && dque[0] <= i - k){
dque.shift();
}
if(i >= k - 1){ // 开始找最大值
res.push(nums[dque[0]])
}
}
return res;
};
举个例子,比如nums = [1,3,-1,-3,5,3,6,7], k = 3:
- 窗口滑到
[1,3,-1]时,队列里存3的下标(因为 3 比 1 大,-1 比 3 小),所以最大值是 3; - 窗口滑到
[3,-1,-3]时,队列里还是3的下标,最大值 3; - 窗口滑到
[-1,-3,5]时,5 把 - 3、-1 都踢出去,队列存 5 的下标,最大值 5…… 以此类推。
就像柯南破案,每次新线索进来,就把没用的 “嫌疑人” 排除,队列里永远留着最有可能的 “最大值嫌疑人”,效率杠杠的!
总结 :百变马丁--“队列”
从雪糕摊的乖乖排队,到拿栈 “瞒天过海” 玩伪装,再到滑动窗口里的 “最大值捕快”,队列这伙计简直是算法圈的百变马丁!好玩到飞起!