栈与队列怎么玩(上)|算法篇
栈与队列相关的问题就比较微妙了,很多时候相关题目中压根不会出现“栈”、“队列”这样的关键字,但只要你深入到真题里去、对栈和队列的应用场景建立起正确的感知,那么很多线索都会在分析的过程中被你轻松地挖掘出来。
这里也和大家分享一位读者在试读过程中的学习感悟:
感觉算法题除了理解还要靠练习,就像高考数学题,要锻炼出解题常规思维。任重道远啊🙊
其实就是这么回事,这也正是我们开篇就跟大家指明“以题为纲”这条路的初衷。
好啦,开工了老哥们!
# 典型真题快速上手-“有效括号”问题
30 秒速记
- 遇左括号就把“期待的右括号”入栈,遇右括号与栈顶比较。
- 右括号出现时栈为空、类型不匹配,都立即返回
false。 - 扫描结束还要检查栈为空,否则
((会被误判。 - 每个字符至多进出栈一次:时间
O(n),最坏空间O(n)。
遇到左括号就把对应的右括号压栈,遇到右括号时只和栈顶比较。 括号嵌套要求最后打开的括号最先闭合,本质上正好符合栈的后进先出。若右括号出现时栈为空,或者弹出的字符与它不一致,就可以立即返回 false。扫描结束后还要确认栈为空,否则像 (( 这种未闭合输入会被误判;整体时间和最坏空间都是 O(n)。
回答参考:“括号嵌套要求最后打开的括号最先关闭,正好符合 LIFO。我把期望的闭括号压栈,右括号只需和栈顶比较。”
题目描述:给定一个只包括 ‘(’,‘)’,‘{’,‘}’,‘[’,‘]’ 的字符串,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 注意空字符串可被认为是有效字符串。
示例 1:
- 输入: “()”
- 输出: true
示例 2:
- 输入: “()[]{}”
- 输出: true
示例 3:
- 输入: “(]”
- 输出: false
示例 4:
- 输入: “([)]”
- 输出: false
示例 5:
- 输入: “{[]}”
- 输出: true
思路分析
括号问题在面试中出现频率非常高, 这类题目我们一般首选用栈来做。
为什么可以用栈做?大家想想,括号成立意味着什么?意味着对称性。
巧了,根据栈的后进先出原则,一组数据的入栈和出栈顺序刚好是对称的。比如说1、2、3、4、5、6按顺序入栈,其对应的出栈序列就是 6、5、4、3、2、1:
123456
654321
对称关系一目了然。
因此这里大家可以记下一个规律:题目中若涉及括号问题,则很有可能和栈相关。
回到题目中来,我们的思路就是在遍历字符串的过程中,往栈里 push 括号对应的配对字符。比如如果遍历到了 (,就往栈里 push )。
假如字符串中所有的括号都成立,那么前期我们 push 进去的一定全都是左括号、后期 push 进去的一定全都是右括号。而且左括号的入栈顺序,和其对应的右括号的入栈顺序应该是相反的,比如这个例子:
({[]})
最后一个入栈的左方括号[,与之匹配的右方括号]正是接下来第一个入栈的右括号。
因此,我们可以果断地认为在左括号全部入栈结束时,栈顶的那个左括号,就是第一个需要被配对的左括号。此时我们需要判断的是接下来入栈的第一个右括号是否和此时栈顶的左括号配对。如果配对成功,那么这一对括号就是有效的,否则直接 return false。
当判断出一对有效的括号之后,我们需要及时地丢掉它,去判断其它括号是否有效。这里这个“丢掉”的动作,就对应着两个括号一起出栈的过程。
每配对成功一对括号,我们都将这对括号出栈。这样一来,我们就可以确保栈顶的括号总是下一个需要被匹配的左括号。
如果说我们出栈到最后,栈不为空,那么意味着一部分没有被匹配上的括号被剩下来了,说明字符串中并非所有的括号都有效,判断 false;反之,则说明所有的括号都配对成功了,判断为 true。
编码实现
// 用一个 map 来维护左括号和右括号的对应关系
const leftToRight = {
"(": ")",
"[": "]",
"{": "}"
};
/**
* @param {string} s
* @return {boolean}
*/
const isValid = function(s) {
// 结合题意,空字符串无条件判断为 true
if (!s) {
return true;
}
// 初始化 stack 数组
const stack = [];
// 缓存字符串长度
const len = s.length;
// 遍历字符串
for (let i = 0; i < len; i++) {
// 缓存单个字符
const ch = s[i];
// 判断是否是左括号,这里我为了实现加速,没有用数组的 includes 方法,直接手写判断逻辑
if (ch === "(" || ch === "{" || ch === "[") stack.push(leftToRight[ch]);
// 若不是左括号,则必须是和栈顶的左括号相配对的右括号
else {
// 若栈不为空,且栈顶的左括号没有和当前字符匹配上,那么判为无效
if (!stack.length || stack.pop() !== ch) {
return false;
}
}
}
// 若所有的括号都能配对成功,那么最后栈应该是空的
return !stack.length;
};
面试官追问
追问 1模板编辑页输入 ([)],有人只统计三类左右括号数量相等就判为合法,你会用哪个代码现象指出漏洞?
数量相等只能证明括号没有缺失,无法证明闭合顺序正确,([)] 正是反例。扫描到 ) 时,栈顶期望的是 ],应立即返回 false;校验必须同时覆盖类型匹配和后进先出的嵌套关系。
追问 2编辑器需要在用户输入时标出首个错误位置,现有 boolean 返回值不够用,栈里应保存什么?
栈中可保存期望的右括号以及对应左括号下标,遇到不匹配的右括号就返回当前下标。扫描结束仍有元素时,再报告未闭合左括号的位置;错误定位语义需明确,否则最早打开与最晚打开的位置可能产生冲突。
追问 3校验接口原本只接收六种括号,现在产品把 if (a[0]) {} 整段代码传入,普通字符应该怎样处理?
先固定输入契约:纯括号题中,任何其他字符都应视为非法;表达式场景则可跳过普通字符,只处理六种括号。两种语义不能隐式混用,否则同一输入会在不同调用方得到不一致结果,并掩盖上游数据错误。
追问 4线上校验偶发把单个 ] 判为有效,而 {[] 也能通过,你会检查哪两个分支?
先检查遇到右括号时是否在空栈上仍执行或忽略了 pop,单个 ] 应因没有期望项立即失败。再检查遍历结束是否返回 stack.length === 0,否则剩余左括号不会被发现;这两个边界分别覆盖多余闭合和缺少闭合。
追问 5日志服务按数据块接收超长文本,不能等完整字符串到齐再校验,栈方案还能保留吗?
可以逐块扫描,并在块之间保留尚未匹配的期望括号栈,匹配逻辑不依赖完整字符串。只有收到流结束信号后才能确认栈是否为空;若输入最坏全是左括号,未决状态仍会增长到 O(n),无法靠分块消除。
# 栈问题进阶-每日温度问题
30 秒速记
- 栈里存“还没找到右侧更大值”的下标,不存温度值。
- 对应温度从栈底到栈顶单调不增;新温度更高时连续弹栈并结算距离。
- 相等温度不能弹出,因为题目要“严格更高”。
- 每个下标最多进出栈一次,时间
O(n)、空间O(n)。
这道题用单调栈保存尚未找到右侧更高温度的下标,新温度更高时连续弹栈并计算等待天数。 栈中存下标而不是温度,是因为结果需要填写当前下标与目标下标的距离。对应温度从栈底到栈顶保持单调不增,相等温度不能弹出,因为题目要求的是严格升高。每个下标最多入栈、出栈各一次,所以时间复杂度为 O(n),最坏空间复杂度为 O(n)。
回答参考:“这是 next greater element。我维护未结算下标的单调递减栈,新元素一旦更大,就成为被弹下标右侧第一个更大值。”
题目描述: 根据每日气温列表,请重新生成一个列表,对应位置的输出是需要再等待多久温度才会升高超过该日的天数。如果之后都不会升高,请在该位置用 0 来代替。
例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。
提示:气温 列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。
思路分析
看到这道题,大家不难想到暴力遍历法:直接两层遍历,第一层定位一个温度,第二层定位离这个温度最近的一次升温是哪天,然后求出两个温度对应索引的差值即可。
一个数组两层遍历,属于比较少见且高危的操作。事出反常必有妖,此时我们就需要反思:这道题是不是压根不该用暴力遍历来做?
答案是肯定的。因为在这个暴力遍历的过程中,我们其实做了很多“多余”的事情。
拿第三个索引位上这个 75 来说,我们在定位比 75 高的第一个温度的过程中,就路过了 71、69、72 这三个温度,其中,72 正是 71 对应的目标温度,可我们却像没看见它一样、啥也没干。只有等最外层遍历走到 71 时,我们才又重复了一遍刚刚走过的路、确认了 71 和 72 之间的关系——像这种不必要的重复,我们要想办法把它干掉。
栈结构可以帮我们避免重复操作
避免重复操作的秘诀就是及时地将不必要的数据出栈,避免它对我们后续的遍历产生干扰。
拿这道题来说,我们的思路就是:尝试去维持一个递减栈。
当遍历过的温度,维持的是一个单调递减的态势时,我们就对这些温度的索引下标执行入栈操作;只要出现了一个数字,它打破了这种单调递减的趋势,也就是说它比前一个温度值高,这时我们就对前后两个温度的索引下标求差,得出前一个温度距离第一次升温的目标差值。这么说可能有点抽象,我们用一张动图来理解一下这个过程

在这个过程中,我们仅对每一个温度执行最多一次入栈操作、一次出栈操作,整个数组只会被遍历一次,因此时间复杂度就是O(n)。相对于两次遍历带来的 O(n^2)的开销来看,栈结构真是帮了咱们大忙了。
编码实现
/**
* @param {number[]} T
* @return {number[]}
*/
// 入参是温度数组
const dailyTemperatures = function(T) {
const len = T.length // 缓存数组的长度
const stack = [] // 初始化一个栈
const res = (new Array(len)).fill(0) // 初始化结果数组,注意数组定长,占位为0
for(let i=0;i<len;i++) {
// 若栈不为0,且存在打破递减趋势的温度值
while(stack.length && T[i] > T[stack[stack.length-1]]) {
// 将栈顶温度值对应的索引出栈
const top = stack.pop()
// 计算 当前栈顶温度值与第一个高于它的温度值 的索引差值
res[top] = i - top
}
// 注意栈里存的不是温度值,而是索引值,这是为了后面方便计算
stack.push(i)
}
// 返回结果数组
return res
};
