算法江湖趣谈:栈如何 “反转乾坤”变身队列?

984 阅读9分钟

前言

点开美团、淘宝闪购点个外卖,你熟悉的下单了你最喜欢的汉堡🍔,停停停...不是讲栈和队列吗?你在干嘛呢?别急,咱们先从 “吃” 说起 —— 假如你吃汉堡的时候,从上往下(比如生菜--肉块)一层一层吃的话,这就是,先进后出,就像叠汉堡,先放的底层最后才能吃到。栈的操作很简单,就俩:push(往上叠)和pop(从顶往下吃)。

画图更清晰(好像我画的也不清晰,尽力了😭):

image.png

1,2,3按顺序入栈,如果要出栈,顺序就变成了3,2,1,这就是,先进后出。

但生活里不是啥都按 “汉堡逻辑” 来的。比如你去买奶茶排队,先到的先点单,这就是队列,先进先出。在 JS 里,咱们可以用数组模拟队列,靠push(后排队员加入)和shift(前排队员点单离开)实现。

依旧上图片:

image.png

这种先进先出的就是我们今天要介绍的--队列

一、队列的 “日常操作”:雪糕队列的进出

咱们看个例子,比如有个雪糕队列:

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]),那样我们就违背了初衷,把队列当数组处理了,我们的目的是把它当队列处理

乍一看没啥问题,但当你输出结果的时候傻眼了:

image.png

啊?为什么只输出了2个,不是循环遍历了吗?有bug?你看你看你仔细看,每当我们使用queue.shift()的时候,它有2个作用。

  • 第一个是返回元素的值。
  • 第二个是让元素出队啊!

那每当元素出队,队列的长度是不是要-1?那再看看for循环的条件(0,4),(1,3),(2,2),OK到这里 2 < 2 它不成立啊,所以循环只能执行2次,就导致了我们的结果只有2个值。

好,问题来了,那怎么去解决它呢?我这里介绍几种常见的方法:

  1. 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次。

image.png

  1. 直接不写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次,perfectimage.png 拿下!

  1. 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次

image.png

nice!通过这个例子,你应该已经初步了解了队列的属性和用法了。

二、队列的 “变形记”:用两个栈“冒充”队列(LeetCode 232 戏法)

有时候,面试官就爱 “刁难”——“你能用栈来实现队列不?” ,你肯定会问他是不是在开玩笑,这就好比让你用汉堡叠法来模拟奶茶排队,听着离谱,但还真能行!

首先我们先看下 它凭什么能冒充 队列。比如说我给你1,2,3这几个数,你入栈之后就变成了3,2,1,3跑到最上面去了,但是我很调皮,我就是要你按顺序给我1,2,3,你说我不纯扯🥚吗,那我给你2个栈呢?诶,鬼点子是不是来了。你马上就会想到,如果我把1,2,3先放到左边的栈里变成了3,2,1,再把左边的栈 到右边的栈里面,是不是变成了3在最下面,1成功逆袭登顶了呢?

手搓图片(我不会画画🖼️~,主要理解意思哈):

image.png

看完我抽象的画,我觉得你大概可能也许是真的懂了,接下俩我们实战一下。

以力扣232题为例(题目大家就去力扣网上阅读哈):

232. 用栈实现队列 - 力扣(LeetCode)

先上代码吧:

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();
};

这是最关键的一步,咱们分场景举例:

  1. 第一次调用 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空。

  1. 再调用一次 pop ()(stack2 不为空)
  • 因为stack2还有元素[3,2],不会进入if循环;
  • 直接stack2.pop()取出 2(这是第二个 push 的元素),符合队列逻辑;
  • 此时stack2 = [3]
  1. 核心技巧:
  • 只有当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()几乎一样:

    1. 先判断stack2是否为空,空就把stack1的元素倒过来;
    2. 区别在于: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,结束,我们把代码跑一遍:

image.png

通过!pass!怎么样,是不是很简单?本质是用两个栈的 “倒转” 抵消了栈 “先进后出” 的特性。简单说,这段代码就是用 “等候区 + 取餐区” 的组合,让栈硬生生演成了队列,堪称算法里的 “伪装大师”!

三、队列的 “高阶玩法”:滑动窗口找最大值(LeetCode 239 名侦探柯南式观察)

简单题过了,咱们再看个更有挑战性的 —— 滑动窗口找最大值。比如你站在窗边看风景,窗口每次挪一格,得知道每个窗口里的 “最高楼” 是哪个。

上链接🔗(题目大家依旧去官网看哈):

239. 滑动窗口最大值 - 力扣(LeetCode)

这时候就得请出双端队列了 —— 它能从队头、队尾进出,就像个 “可进可出” 的双向门。咱们用它来维护一个递减队列,保证队头永远是当前窗口的最大值。

直接上代码:

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…… 以此类推。

就像柯南破案,每次新线索进来,就把没用的 “嫌疑人” 排除,队列里永远留着最有可能的 “最大值嫌疑人”,效率杠杠的!

总结 :百变马丁--“队列”

从雪糕摊的乖乖排队,到拿栈 “瞒天过海” 玩伪装,再到滑动窗口里的 “最大值捕快”,队列这伙计简直是算法圈的百变马丁!好玩到飞起!