资讯详情

验证栈序列 LeetCode 946:从暴力枚举到贪心模拟全解析

📅 2026/10/12 6:18:49 | 华诺云谱 👁 阅读
验证栈序列 LeetCode 946:从暴力枚举到贪心模拟全解析
第一次在 LeetCode 上刷到 946 题《验证栈序列》时我心里其实有点不以为然这不就是模拟一个栈吗直到后来在算法工程师面试模拟中被追问“你凭什么说这个贪心是对的”我才发现能把这道题讲透的人真没想象中多。今天这篇就当作我自己的复盘笔记完整拆一遍 946 题题目到底在问什么、暴力枚举为什么跑不动、O(n) 的模拟解法为什么正确、写代码容易踩哪些坑以及面试场上怎么讲才能让面试官点头。适合三种人看正在准备校招或社招刷题的人、刚学完数据结构想找实际应用场景的人以及刷了很多题但对“栈序列合法性”仍然只记结论不记原理的人。全文不绕弯子全是我自己的实操经过。1. 题目还原什么是“验证栈序列”1.1 把题目翻译成人话给定两个等长数组pushed和popped其中pushed表示元素按顺序入栈的先后popped表示我们希望看到的出栈顺序。栈初始为空每次只能做两种操作按pushed的次序把下一个元素压入栈或者把当前栈顶元素弹出。问题只有一个能不能通过一串合法操作让弹出元素的顺序恰好等于popped。用生活化的例子说pushed是进货单popped是出货单中间只有一个只能后进先出的仓库。进货单告诉你货物到货的先后出货单告诉你货物离库的期望顺序问你仓库调度方案里能不能排出这笔账。很多新手会把它当成“判断两个数组是不是互为逆序”的题这就偏了。比如pushed[1,2,3]popped[3,1,2]并不是简单的逆序它非法pushed[1,2,3]popped[2,3,1]却是合法的。原因很简单2 先出说明它当时在栈顶之后 3 压入并出栈最后再出 1。所以题目考的不是数组的数学关系而是“中间态可达性”每一刻的栈内容都必须是可复现的。这也是它比一般遍历题更有含金量的地方。1.2 这题为什么值得刷先说结论946 是栈这个主题下被面试官翻牌子概率很高的一道题。原因有三。第一它把入栈出栈这个基本操作升级成了“读题—建模—模拟—证明”的完整链路很多候选人写得出来队列实现栈但在这题上会卡在while和if的差别上。第二它的正解不是记忆型结论而是靠一次现场推理就能推出来的贪心面试官很爱问“为什么”问不到三句一个人到底是背题还是真懂结构立刻见分晓。第三它和很多经典题共享同一个思想内核比如括号匹配、表达式求值、火车调度问题刷透这一题等于同时复习了一串题目。关键词如果展开一下数据结构与算法、LeetCode 热门 100 题、算法工程师面试几乎每一条都能跟这题挂上钩。尤其是算法工程师面试场景里这类基础题往往决定你能不能进下一轮它不在难而在稳。能把一道中等题讲得像喝水一样自然比会背一道困难题的大招更能打。2. 从暴力枚举说起美是美就是跑不动2.1 最直觉的暴力枚举所有事件组合拿到这题第一反应肯定是“把栈真的模拟一遍”但更朴素的思路其实是暴力枚举把整个执行过程看成一系列事件的排列每个数字最终要么被压栈要么被弹出入栈事件和出栈事件一共 2n 个。问题的解空间就是从 n 个入栈事件和 n 个出栈事件中构造一个合法序列使得任意前缀里入栈次数不少于出栈次数。凡是满足这个约束的序列都是一种可能的调度方案。然后逐一检验每遇到入栈事件就把pushed里下一个数压进去每遇到出栈事件就看弹出的数是否等于popped当前需要的位置。举个双样例n3 时总共 6 个事件合法调度序列只有 5 个卡特兰数 C_35分别对应 5 种可能的出栈顺序。n4 时合法调度是 14 个。这种规模用手画都行写个递归也能跑所以很多人第一步会把搜索写出来然后在小数据上验证自己的直觉。你别说这种“暴力验证法”在比赛现场特别有用它可以帮助你在拿不准题意时把模糊的直觉变成一堆可以对照的真实输出。2.2 复杂度爆炸的直观计算问题在于一旦 n 放大暴力枚举立刻崩盘。合法事件序列的数量是卡特兰数公式为 C_n C(2n, n) / (n1)。C_542C_1016796C_15 已经接近千万级C_20 约 65.6 亿C_25 将近 4.9 万亿C_30 约 3.8 千万亿。而题目约束里 n 可以到 1000这个数字已经超出常规算力能触及的边界。把全宇宙的算力都拿来枚举都算不完。所以这类题目告诉我们作为面试题机械枚举不是面试官想要的答案作为算法题它逼你别用枚举而去发现规律。就算加上剪枝、记忆化搜索状态空间仍然巨大。因为真正的状态是“当前栈的内容 pushed/popped 的指针位置”栈内容不是简单的一位数字而是由多个元素及其相对顺序决定。这样高的维度导致动态规划几乎不可做或者说复杂度仍然不低于卡特兰数级别。所以暴力在工程上不可行只能在小数据场景当验证工具用。2.3 暴力美学的真正含义回到标题“算法的暴力美学”。我自己的理解是这道题最美的地方恰恰不是暴力枚举而是一种“每一步都只做当下最直接动作”的贪心。它看起来像暴力——不看全局、不打表、不做回头路见到匹配就弹出见到不匹配就继续压可它偏偏是对的。这种“朴素的直觉经过证明后无懈可击”的观感就是一种暴力美学。暴力枚举的美在绝对正确伤在速度贪心模拟的美在速度却又需要证明来镇场。把两者凑一起才是这道题完整的魅力。3. 正解思路贪心模拟能出就出3.1 核心流程双指针加一个真实栈正解的思路可以用六个字总结能出就出不行就压。准备一个真实的模拟栈stack一个指针i指向popped当前要匹配的位置。然后按顺序遍历pushed先把当前数字压入栈接着进入while循环只要栈不空、i没越界、栈顶等于popped[i]就弹出栈顶并且i往后走一步。整个pushed遍历完之后如果i已经走完了popped说明所有出栈需求都在合适的时机得到了满足返回true否则返回false。你可以把popped看作一条“需求队列”把栈顶看作“当前能交付的唯一货物”。新货到的时候先不管三七二十一把它放进仓库然后反复问自己现在栈顶是不是顾客要的那一件是就交付不是就继续进货。这样一路推到pushed全部进完如果还有顾客需求没满足说明仓库方案根本排不出来。为什么每次先压栈再看栈顶因为刚压入的元素可能恰好就是要弹出的那一件也可能压入之后接连触发好几件出栈。这也就解释了那个高频隐患必须用while而不是if。一次入栈后栈里的“待命区”可能连续命中popped的好几个位置。代码里有两个关键位置最容易写错。第一while的循环条件必须同时满足三个子条件i len(popped)防止数组越界stack非空避免访问栈顶异常stack[-1] popped[i]是命中判断。三个条件缺一不可。第二i更新必须放在stack.pop()之后很多人先移动i再pop逻辑上没错但会让自己在调试时很混乱我建议坚持“先弹出、后进位”的顺序。3.2 为什么“能出就出”一定正确很多同学背住了算法却答不上“为什么贪心不会错”。我提供一个可以在面试现场直接说出来的证明。因为题目有一个关键前提pushed和popped中每个元素互不重复。这保证了某个特定值在栈里只可能出现一次不会出现“栈顶这个值和下一个需求相同但另一个更深的相同值才是真正该先处理的”这种二义性。接下来用反证法看“栈顶等于popped[i]时必须弹”假设此刻栈顶元素等于下一个需求但我们的方案没有弹它而是选择继续压入pushed后面某个元素。这个新元素一定压在栈顶之上之后每一次出栈操作都只能先碰它。可是popped[i]要求先弹出的是原来的栈顶。如果想在栈顶被新元素覆盖的情况下仍然先弹出原栈顶就必须先把新元素弹掉这就违反了popped的顺序。因此只要栈顶等于需求这一步的唯一合法动作就是弹出栈顶。换句话说贪心选择不仅不冒险反而是确定性行为。同理如果栈顶不等于popped[i]那就绝对不可能弹出栈顶因为弹出它会让popped前缀错位唯一能推进的方式只能是继续按pushed压入下一个元素。所以整个模拟过程中每步可选动作要么唯一、要么不合法算法自然正确。这个证明如果没有互异性前提会立不住所以跟面试官聊的时候记得先强调题目条件。3.3 手写代码Python、Java、C 三版直接上代码我用三种语言写同一套逻辑方便你在不同环境下直接抄。Python 版本带逐行注释from typing import List def validateStackSequences(pushed: List[int], popped: List[int]) - bool: stack [] i 0 for x in pushed: stack.append(x) # 1. 按入栈顺序压一个元素 while i len(popped) and stack and stack[-1] popped[i]: stack.pop() # 2. 栈顶正是下一个需求 i 1 # 3. 需求指针向后走 return i len(popped) # 4. 是否满足完了全部需求Java 版本用ArrayDeque当栈用class Solution { public boolean validateStackSequences(int[] pushed, int[] popped) { DequeInteger stack new ArrayDeque(); int i 0; for (int x : pushed) { stack.push(x); while (i popped.length !stack.isEmpty() stack.peek() popped[i]) { stack.pop(); i; } } return i popped.length; } }C 版本class Solution { public: bool validateStackSequences(vectorint pushed, vectorint popped) { stackint st; int i 0, n popped.size(); for (int x : pushed) { st.push(x); while (i n !st.empty() st.top() popped[i]) { st.pop(); i; } } return i n; } };三种语言只是语法不同算法骨架完全一致。你面试时写哪一种都行关键是变量名和结构能让面试官一眼看懂。推荐把代表“需求进度”的变量命名为i或idx把代表“模拟栈”的变量命名为stack或st语义正确比命名花哨更重要。4. 手动推演与边界条件4.1 用一个 true 样例走完全程拿 LeetCode 的原样例pushed [1,2,3,4,5]popped [4,5,3,2,1]来推。我们盯着栈顶和popped第一个需求 4步骤动作栈当前内容栈顶在右已匹配 popped 前缀1push 1[1]无2push 2[1,2]无3push 3[1,2,3]无4push 4[1,2,3,4]无5pop 4[1,2,3][4]6push 5[1,2,3,5][4]7pop 5[1,2,3][4,5]8pop 3[1,2][4,5,3]9pop 2[1][4,5,3,2]10pop 1[][4,5,3,2,1]前 4 步一直压栈是因为栈顶始终不等于 4第 5 步弹出 4第 6 步继续压 5是因为栈顶 3 不等于下一个需求 5之后就是连续的弹出因为栈顶依次命中popped的剩余需求。整个过程一气呵成最后栈空、指针走完返回true。4.2 用一个 false 样例说明失败点再看一个经典的反例pushed [1,2,3,4,5]popped [4,3,5,1,2]。按照同样的流程推步骤动作栈当前内容栈顶在右已匹配 popped 前缀1push 1[1]无2push 2[1,2]无3push 3[1,2,3]无4push 4[1,2,3,4]无5pop 4[1,2,3][4]6pop 3[1,2][4,3]7push 5[1,2,5][4,3]8pop 5[1,2][4,3,5]9需求是 1但栈顶是 2[1,2]卡死到第 9 步pushed已经全部入栈但栈顶是 2popped的下一个需求是 1。弹出 2 会让顺序错位不弹 2 就没法碰到下面的 1。于是模拟无法继续返回false。这道题的false通常就长这样栈顶卡住了一个元素它挡住了后面的出栈需求而它的出栈时机又还没到。识别到这种卡死状态比单纯记住反例有用得多。4.3 边界用例与自测清单提交前建议至少跑一遍下面五类用例尤其是空数组很多实现没有处理好stack为空还访问stack[-1]的情况会直接报错。pushedpopped结果原因[][]true空栈对空需求[1][1]true入栈即出一步到位[1,2,3][1,2,3]true每个元素入栈后立刻出栈[1,2,3][3,2,1]true全部压完再倒序出栈[1,2,3][3,1,2]false3 出栈后1 被 2 压住无法先出[1,2,3,4,5][4,5,3,2,1]true上方手动推演的经典正例关于边界还能想到一个细节题目保证两个数组长度相等且pushed是popped的排列。所以在合法输入约束下返回stack.empty()和返回i popped.length在理论上等价。但写成i popped.length更通用如果哪天题目改了允许数组中存在没弹出的元素这个判断依然语义正确。面试时主动说一句“我选择用指针是否走完来判断而不是判栈空更严谨”这是加分的小细节。5. 实操记录我踩过的坑与调试技巧5.1 三个高频翻车点很多人写完代码一跑发现要么超时、要么越界、要么答案错误。我把最常见的几个错误整理成一张速查表典型错误直接症状正确姿势用if代替while检查栈顶漏掉连续出栈返回错误答案必须用while因为一次push可能触发多次popwhile 条件里不判stack空空栈访问异常、越界错误先判空再访问栈顶顺序不能反i 1写在pop()之前逻辑混乱容易少跳一步或多跳一步先pop再i保持指针语义单一循环结束直接return Truepushed空时对非空时错一定要返回i len(popped)第一个错误最隐蔽。很多初学者会想反正我每次压入一个元素最多弹出一个来匹配当前popped需求用if不就行了吗不对。看pushed[1,2,3]、popped[2,3,1]压入 1 时栈顶不是 2压入 2 后弹出 2压入 3 后弹出 3弹完 3 后栈顶变成 1而popped的下一个需求正是 1。这里 3 的入栈触发了两步出栈先弹出 3又紧接着弹出 1。所以只有while才能把“一个入栈引发的连环出栈”完整处理掉。5.2 一个小工具随机化自测算法题写完除了手打少数样例我习惯再用随机数据自测一遍。对于本题可以反向构造数据随机生成一串“入栈/出栈”操作执行完得到pushed和popped这样答案必然为true再喂给待验证的函数。如果它返回false说明实现有问题。贴一段我常用的 Python 脚本框架import random def random_valid_case(n): pushed, popped, stack [], [], [] next_val 1 while next_val n or stack: if next_val n and (not stack or random.random() 0.6): pushed.append(next_val) stack.append(next_val) next_val 1 else: popped.append(stack.pop()) return pushed, popped def brute_check(n, times2000): for _ in range(times): pushed, popped random_valid_case(n) assert validateStackSequences(pushed, popped) is True这里 0.6 是随机压入概率你可以调。更严格的做法是对每个随机出的pushed/popped组合先用小规模递归暴力搜索判断标准答案再和模拟解对比。我比赛前经常这么干——先让暴力给我一把标准答案再检查贪心模拟两者一致才敢提交。暴力枚举虽然不能作为正解但作为“对拍器”是真的好用这也呼应了标题里的“暴力美学”暴力用在验证上优雅用在算法上。5.3 复杂度该怎么说才严谨时间上每个pushed里的元素最多被压入一次、被弹出一次所以总操作数是 2n时间复杂度 O(n)更严格地说是 Θ(n)。空间上模拟栈在极端情况比如popped逆序下会存下所有 n 个元素所以空间复杂度 O(n)。面试时如果说成“时间 O(n)空间 O(n)”大家都能懂如果补一句“因为每个元素进出各一次处理次数是有限的所以不存在因 while 深层嵌套而超时的可能”会显得你真的理解而不是背模板。另外解释一下为什么空间不能再压缩你需要一个真实栈来记录“哪些元素已经入栈且还没出栈”的中间状态。这个状态本身就可能包含全部 n 个元素所以任何解法至少需要 O(n) 的额外空间除非去破坏输入数组来充当栈但那样可读性差、复用性也不好。刷题网站的标准解就是空间 O(n)没什么好纠结的。6. 面试怎么讲把“会写代码”变成“会讲题”6.1 面试官想看到的思考路径面试官出一道题的目的不只是要一个 AC。他们想看你拿到问题后的分析顺序先澄清题意两个序列长度是否相同元素是否互异然后从暴力出发估算复杂度说明为什么不可行接着提出贪心模拟再证明正确性然后写代码最后测边界用例、说复杂度。很多候选人卡在中间一上来就写贪心写完却解释不清“为什么能出就出是对的”。这就像把一套武功打了几遍但说不出心法面试官很难给你高分。我建议的节奏是前 2 分钟讲题意和暴力复杂度中间 5 分钟讲贪心思路、正确性证明、写代码最后 1 分钟跑两个测试用例并总结复杂度。越是不起眼的基础题越要用结构化的方式去讲因为面试官见过太多背题的人一小段逻辑自洽的证明就能把你和他们区隔开。6.2 关联题型展示知识广度想展示知识广度可以在讲完 946 后顺势提两句同类题。LeetCode 20 有效括号同样需要一个栈来跟踪“未匹配的左括号”遇到右括号时检查栈顶。LeetCode 1047 删除字符串中的所有相邻重复项同样用栈扫描字符串时看栈顶是否相同相同就弹出。再往深走单调栈系列如每日温度、柱状图中最大矩形也都是从这个“借栈观察栈顶”的思维模型发展出去的。你只要点一句“它们共同的内核是所有暂时无法结算的元素先压在栈里遇到可以结算的条件就弹出”面试官基本就认可你的抽象能力了。还有一个更贴近的经典变体是火车调度问题给定进站顺序判断出站顺序是否可能。换了个场景解法一模一样。如果你正在准备算法工程师面试把这几个题放一组刷效果比零散刷题好得多因为它们背后都是同一个栈模型。6.3 可直接复用的讲题话术最后给你一段可以直接改口的话术是我自己在模拟面试里打磨过的版本这道题我先确认两个前提pushed 和 popped 等长且元素互不重复。暴力做法是枚举所有合法的入出栈事件序列合法序列数量是卡特兰数n 稍一大就不可行。所以改用模拟。我用一个真实栈和一个指针 i 表示 popped 当前需求。按 pushed 顺序逐个入栈每次入栈后只要栈顶等于 popped[i]就弹出并让 i 前进用 while 循环直到栈顶不再匹配。这样做的正确性在于元素互不重复当栈顶等于下一个需求时如果不立刻弹出后续入栈的元素会把它压在下面之后反而无法按顺序弹出所以这一步是唯一合法的选择同理栈顶不等于需求时不可能弹出只能继续入栈。最后如果 i 走完 popped 就返回 true。时间复杂度 O(n)空间 O(n)。这一段大概 40 秒能讲完比漫无边际地讲思路清晰得多。如果你习惯写 Python讲完再顺手写代码面试体验会很流畅。其实我刷到最后最大的心得不是这道题本身而是它提醒我的一个状态很多难题并没有很难难的是你是不是愿意先把最简单的模拟想到位。碰到“栈序列”类问题我个人的习惯是别急着找大招先用一句话问自己“假如我是一个栈现在只有一个合法动作那是什么”这个问法救过我很多次。如果你也正在刷题冲刺或者准备算法面试强烈建议把 946 放进必刷清单然后试着不看答案把从暴力到贪心的推导过程完整写一遍。写完之后你会发现下一次再遇到“验证某某序列”的题你的第一反应不再是慌张而是动手模拟。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑