期末复习刷题的时候,我碰到一道某高校2021春季的“火车货运调度模拟”题。第一眼看过去,感觉又是个场景复杂、流程繁琐的模拟题,一列一列的车厢进进出出,光读题就有点绕。但静下来把过程抽象成模型之后,发现它本质上就是一个栈的出栈序列合法性问题,甚至可以不借助栈,纯粹靠“找规律”判断。我当时写了两个版本:一个老老实实模拟进出站,一个只扫描出站序列本身找禁忌模式。这篇把两种解法都整理出来,顺便聊聊怎么从一道考试模拟题里把“抽象过程”这件事练熟。
1. 先把火车场景脱掉外壳
1.1 调度场景到底在说什么
这道题的场景大致是这样:一个调车场里有一条轨道,轨道尽头是一段只能从一侧进出的“死胡同”式岔线。货车车厢按照编号 1 到 N 的顺序从轨道一端依次驶入,调度员可以在任意时刻把停在岔线里的车厢从另一端拉出去。题目会给你一个目标出站序列,比如 3 2 1 4 5,让你判断这个出站顺序能不能通过合理调度实现。
我第一次看这个场景,脑子里全是铁轨、道岔、车厢编组,觉得是个需要写一堆状态转移的模拟题。后来发现题目强调的“货运调度”只是包装,真正的数学模型非常干净:一段死胡同式的岔线,同一时刻只能从一端进、从一端出。这正好就是数据结构里“栈”的定义。
1.2 从铁轨到栈的三个关键抽象
把场景翻译成数据结构模型,只需要三步。
第一步,车厢按 1 到 N 的顺序依次驶入岔线,这等价于“入栈顺序固定为 1, 2, 3, ..., N”。第二步,车厢可以从岔线出口驶出,等价于“出栈”。第三步,岔线只有一个口,所以后进入的车厢必须先出去,等价于“后进先出”。
完成这三步之后,题目就变成一个非常标准的算法问题:入栈序列固定是 1 到 N,给你一个出栈序列,问这个出栈序列是否合法。很多考过数据结构的人一眼就能认出来,这是经典的“判断出栈序列合法性”问题。你甚至可以类比成一摞盘子:只能从最上面拿,新盘子也只能放在最上面。火车只是一堆不会说话的盘子而已。
这个抽象过程看起来简单,但它才是整道题的关键。考试时真正卡住人的,往往不是后面的模拟代码,而是能不能从一段充满场景描述的文字里,迅速找到“栈”这个核心结构。所以我把这句话放在前面:先别急着写代码,先把场景翻译成结构。
2. 方案一:栈模拟法,最稳的考场解法
2.1 模拟的三个动作
栈模拟的思路很直白:把岔线当成一个栈,然后严格按照出站序列去“演”一遍。演的过程中会有三个动作。
第一个动作是“继续压栈”。当前需要的出站车厢还没有进站,或者栈顶不是它,那就把当前这辆还没进站的车厢压进栈里,指针往后移。第二个动作是“栈顶匹配出栈”。如果栈顶恰好是当前出站序列需要的车厢,那就弹出它,继续处理下一个目标。第三个动作是“判定失败”。如果所有车厢都已经压进栈了,但栈顶依然不是当前需要的车厢,说明这辆车被压在下面出不来了,直接判断为非法。
用一个例子走一遍。入栈顺序固定是 1 到 5,目标出站序列是 3 2 1 4 5。过程是:压入 1、压入 2、压入 3,此时栈顶是 3,弹出 3;栈顶变成 2,弹出 2;栈顶变成 1,弹出 1;接着压入 4,弹出 4;压入 5,弹出 5。整条序列走完,成功,所以合法。
再看一个非法例子:目标序列是 3 1 2。压入 1、压入 2、压入 3,弹出 3。此时栈中从底到顶是 1、2,当前目标变成了 1。栈顶是 2,不是 1。而所有车厢都已经入过栈了,指针 cur 已经变成 4,超过了 N=3,没有任何新车可以压进来。1 被 2 压住,不可能先出,判定失败。
2.2 可以运行的 C++ 代码
用 C++ 实现这个逻辑,代码非常短。我推荐直接用一个 stack 容器,不要自己写数组模拟栈,考场时间紧张,容器库能少写很多容易出错的边界判断。
#include <bits/stdc++.h> using namespace std; bool checkByStack(int n, vector<int>& out) { stack<int> st; int cur = 1; // 下一辆要入栈的车厢编号 for (int x : out) { // 只要栈顶不是当前需要的车厢,就继续压入新车 while (cur <= n && (st.empty() || st.top() != x)) { st.push(cur); cur++; } // 压到不能再压,栈顶还不是目标,说明被压在下面了 if (!st.empty() && st.top() == x) { st.pop(); } else { return false; } } return true; } int main() { int n; while (cin >> n) { vector<int> out(n); for (int i = 0; i < n; i++) cin >> out[i]; cout << (checkByStack(n, out) ? "YES" : "NO") << endl; } return 0; }这段代码的核心是那个 while 循环。它的语义是:只要还没压无可压,而且栈顶和目标车不匹配,就继续把下一辆车压进去。这么写有个好处,不需要单独处理“当前目标车正好是下一辆入站车”的情况。比如目标序列第一个数是 1,第一次进循环时栈为空,直接压入 1,下一轮判断栈顶等于 1,弹出。整个过程非常统一。
2.3 复杂度与考场注意点
栈模拟的时间复杂度是 O(N),每个车厢最多入栈一次、出栈一次。空间复杂度 O(N),最坏情况下 1 到 N 全部压进栈里。这个复杂度在考试题里基本是无敌的,无论数据给到多大,都跑得动。
考场用这个方法,最需要留意三件事。第一,栈必须每轮数据重新初始化,不要图省事在循环外面声明一个全局栈,否则上一轮留下的元素会让下一轮判断全部错乱。第二,cur 的范围要小心,它表示“下一辆还没入栈的车厢”,入栈一次就加一,最多到 N+1。如果 cur 已经变成 N+1,说明所有车厢都在栈里或者已经出去了,不能再压栈。第三,输入输出格式看清题目要求,有的题要求输出 YES/NO,有的要求输出 valid/invalid,我见过有人算法全对,最后卡在输出格式上。
3. 方案二:找规律法,从输出序列本身下手
3.1 从模拟里提炼出“禁忌模式”
栈模拟能解决问题,但它需要一路维护状态。如果 N 很小,比如不超过 100,其实还有一条更巧的路:不看过程,直接看出站序列本身有没有毛病。
先拿一个最经典的非法序列开刀:3 1 2。为什么它非法?因为 3 要出站,必须先把 1、2、3 全部压进栈。栈里从底到顶是 1、2、3,所以 3 弹出之后,栈顶变成 2。这个时候 1 被压在 2 下面,根本不可能先出。于是出站顺序 3 后面跟着 1,再把 2 放最后,就矛盾了。
再看一个合法序列:3 2 1。同样是先压 1、2、3,弹出 3,再弹 2,再弹 1,顺理成章。
把两个序列放在一起对比,3 1 2 和 3 2 1,唯一区别在于 3 后面的两个数,一个是 1 在前、2 在后,另一个是 2 在前、1 在后。后者是从大到小排的,前者是从小到大排的。这就引出一个规律:在出站序列中,对于任意一个数,它右边所有比它小的数,必须按照从大到小的顺序出现。换句话说,出站序列中不能存在这样的三段式子:一个较大的数在前,后面先跟一个较小的数,再跟一个介于这两个数之间的数。形式上就是对于下标 i < j < k,不能出现 a[i] > a[k] > a[j]。我把这种结构叫“最大—最小—中间”禁忌模式,也叫 132 型模式。
这个规律是从栈的物理结构直接看出来的。当 a[i] 准备出站时,比它小且还没出站的车厢 a[j]、a[k] 都已经压在栈里了。栈中数值小的在下面,数值大的在上面,所以如果 a[j] < a[k],那么 a[k] 一定压在 a[j] 上面,出栈时应该是 a[k] 先出。但序列里却是 a[j] 先出,矛盾。
3.2 规律成立的道理
很多刷过 LeetCode 的人会认出,这和验证栈序列、检测 132 Pattern 是同一类问题。为什么只要没有这种模式,序列就一定合法?
必要性比较好理解:只要出现 132 型模式,模拟过程必然在某一步卡住。充分性可以反过来看:如果序列不包含这种模式,那么从 1 开始逐个入栈,每次需要弹出某个元素 x 时,栈顶如果不是 x,就说明栈顶上面压了一个比 x 后入栈的元素 y。y 在后面才轮到出站,因为 y 比 x 大,而前面已经出现过 x,这就会在序列里构造出一个“比 x 大、比 y 小、位置在 x 和 y 之间”的元素,或者直接让 x 和后面某个更小的元素构成禁忌结构。详细的严格证明在教科书里有很多,考场不需要写证明,记住结论就够了:对于入栈顺序 1 到 N 的栈,出栈序列合法,当且仅当序列中不存在 a[i] > a[k] > a[j],其中 i < j < k。
如果觉得 i、j、k 记起来麻烦,就记一句人话:出站序列里,某个大数后面出现了一小数,再后面又冒出一个比小数大但比大数小的数,那这串顺序肯定是假的。
3.3 直接用规律判断的代码
这个规律最直白的实现就是三层循环,枚举 i、j、k 检查是否存在禁忌结构。N 小于等于 100 时完全够用。
#include <bits/stdc++.h> using namespace std; bool checkByPatternBrute(int n, vector<int>& a) { for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (a[j] >= a[i]) continue; // 需要 a[j] 更小 for (int k = j + 1; k < n; k++) { if (a[j] < a[k] && a[k] < a[i]) { return false; // 发现 132 型禁忌模式 } } } } return true; } int main() { int n; while (cin >> n) { vector<int> a(n); for (int i = 0; i < n; i++) cin >> a[i]; cout << (checkByPatternBrute(n, a) ? "YES" : "NO") << endl; } return 0; }三层循环的时间复杂度是 O(N^3),N 是 100 的时候,循环次数是 100 万,完全没问题。如果题目把 N 提到 1000 以上,三层循环就会超时,这时可以把循环优化到 O(N^2)。思路是:枚举可能充当“最大数”的位置 i 和“中间数”的位置 k,只要 a[k] < a[i],并且 i 和 k 之间存在一个比 a[k] 还小的数,就说明找到了禁忌结构。区间内有没有比某个数更小的数,可以提前预处理区间最小值。
bool checkByPatternFast(int n, vector<int>& a) { // 预处理 mn[l][r] 表示区间 [l, r] 的最小值 vector<vector<int>> mn(n, vector<int>(n, INT_MAX)); for (int i = 0; i < n; i++) { int curMin = INT_MAX; for (int j = i; j < n; j++) { curMin = min(curMin, a[j]); mn[i][j] = curMin; } } for (int i = 0; i < n; i++) { for (int k = i + 1; k < n; k++) { if (k - i >= 2 && a[k] < a[i] && mn[i + 1][k - 1] < a[k]) { return false; } } } return true; }这个版本的好处是完全没有用栈,纯靠扫描序列本身得出答案。写起来比栈模拟略绕,但因为不需要维护进栈出栈的状态,逻辑上反而有种“一眼看穿”的感觉。
3.4 规律法的额外价值
找规律法除了能做题,还有个很实际的作用:帮你在考场上快速自测。写完栈模拟之后,你不需要再手工跑很多数据,直接用规律法当对照工具,两边结果一致,代码基本就稳了。我当年做模拟题的时候,就是先用规律法手算几个样例,再用栈模拟跑一遍,能明显降低低级错误率。
更重要的是,这个规律不只适用于火车调度。只要是“固定入栈顺序,判断出栈顺序是否合法”的题,都可以直接用。甚至扩展到括号匹配、函数调用栈、任务依赖顺序这类场景,底层逻辑也都是同一个“后进先出”约束。学会找规律,等于给一类问题配了一把通用钥匙。
4. 两种解法怎么选
4.1 对比表格
我把两种解法整理成一张表,方便考前快速回忆。
| 对比维度 | 栈模拟法 | 找规律法(模式判定) |
|---|---|---|
| 核心思路 | 模拟进出站全过程 | 检查序列中是否存在禁忌结构 |
| 编码难度 | 低,逻辑直观 | 中,需要记住规律 |
| 时间复杂度 | O(N) | O(N^2) 或 O(N^3) |
| 空间复杂度 | O(N) | O(N^2)(用区间预处理时) |
| 出错风险 | 主要在指针和栈空判断 | 主要在方向写反、循环边界 |
| 适用场景 | 数据量大、时间充足 | 数据量小、想快速出解 |
| 考场推荐度 | 五颗星,最稳 | 四颗星,理解到位后很快 |
栈模拟胜在通用和稳定,找规律法胜在不需要跟着过程走。实际考试建议以栈模拟为主,找规律作为校验手段。
4.2 考场选题策略
我的建议很明确:第一遍做,用栈模拟。因为它不容易写错,边界条件清晰,时间复杂度足够低。做完之后,如果还剩时间,再用找规律法单独判断一遍同样的数据,两边结果一致,基本可以放心交卷。
如果题目 N 特别小,比如 N 小于等于 50,而你又对规律掌握得很熟,直接用找规律暴力也很舒服。代码更短,不需要引入额外容器。但如果你对规律不是特别有把握,还是老老实实写栈模拟,别为了炫技把自己绕进去。
4.3 三种常见选型误区
误区一是觉得找规律法一定比栈模拟“高级”。实际上考场要的是稳定得分,不是最优解最帅解。栈模拟 O(N) 已经无可挑剔,没必要为了展示数学模型去写三层循环。
误区二是在找规律时把方向写反。禁忌模式是 a[i] > a[k] > a[j],也就是“最大数、最小数、中间数”。有人会记成 3 2 1 型,那就完全反了,因为 3 2 1 本身就是合法出站序列。我用一个土办法记:看到 3 1 2 这种“大、小、中”穿插,就是非法;看到 3 2 1 这种单调递减,就是合法。
误区三是两种方法混着写,写到一半又想换思路。考场最忌讳重构。选定方案就一次走到底,最多在最后用另一种方法对拍验证。
5. 常见问题与调试实录
5.1 快速检查表
以下是我在这类题上遇到过的真实问题,整理成速查表。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 第一个数是 3,程序直接判错 | 没有先把 1、2、3 压进栈 | 先执行入栈循环,再比较栈顶 |
| 所有递减序列都判合法,但 3 1 2 也判合法 | 找规律方向写反 | 确认是“大、小、中”模式,不是“大、中、小” |
| 多组数据时,第一组正确,第二组开始错 | 栈没有清空或 cur 没有重置 | 每轮局部 new 一个栈,cur 从 1 重新开始 |
| N 大时三层循环超时 | 复杂度太高 | 改用栈模拟,或区间最小值优化 |
| 相同编号的车厢出现两次 | 输入数据不合法 | 题目正常情况下不会给,建议先校验排列 |
| 栈模拟时 while 死循环 | while 内部 cur 没有递增 | 每次 st.push(cur) 后必须 cur++ |
5.2 一段真实的“翻车”调试过程
我练这道题的时候,第一版栈模拟写得很顺利,样例也过了。但自己加了一组数据之后翻车了:序列 5 1 4 2 3。按道理这是非法的,因为 5 出站时 1、2、3、4 全压在栈里,之后 1 不可能在 2、3、4 前面出站。我的程序却在一开始判成了合法。
问题出在 while 循环的条件。我当时的写法是只针对“当前目标车是否等于下一辆入站车”做判断,没有考虑“目标车已经在栈里但不在栈顶”的情况。也就是说,当目标序列第一个数是 5 时,程序只是把 5 压进去弹出,然后第二个数 1 出现时,栈顶是 4,程序没有继续压车,也没有正确判断失败,结果一路误判下去。
修正方法就是上面代码里那个 while 条件:只要栈顶不等于当前目标,并且还有车可以入栈,就继续压。压无可压之后栈顶还不匹配,直接 false。这样“目标车已在栈内但被压在下面”的情况会被自动识破,因为压完所有车之后栈顶依然是别的车。
5.3 调试的一个独家技巧
我后来养成了一个习惯:用找规律法和栈模拟法对拍。写一个小程序,随机生成 1 到 N 的排列,然后分别用两种方法判断,结果不一致就打印出来。因为两种方法的底层逻辑完全不同,如果它们对同一个序列给出不同答案,那至少有一边写错了。对拍是刷算法题最有效的查错方式,远比盯着代码看半天有效。
对拍时注意生成足够多的乱序排列,尤其是多生成一些包含大数后面跟着小数、再出现中间数的序列。这类序列最容易暴露错误。我试过用这种方法,几十个随机数据就能把大多数边界错误逼出来。
6. 这道题后面的扩展思路
6.1 同款思想在其他题里的影子
做完这道火车调度,你会发现很多算法题都是同一副骨架。
“验证栈序列”类的题,就是把这道题的场景换成 push 和 pop 两个数组。括号匹配的问题,是“左括号入栈、右括号出栈”的变体。单调栈求直方图最大矩形,本质上也是在维护一个带有严格顺序约束的栈顶关系。甚至操作系统里的函数调用栈、浏览器的后退功能,底层都是同一种后进先出逻辑。
所以这道期末考试模拟题的价值不止在于会判断一个序列是否合法。它真正训练的是从具体场景中抽象出通用结构的能力。一旦你形成这种思维,以后看到“顺序进出”“嵌套关系”“依赖约束”这些关键词,会下意识地想到栈。
6.2 如何培养这种抽象能力
我自己总结了一个三问法,刷模拟题时反复用。
第一问:谁进谁出,顺序是什么。第二问:过程中允许哪些操作,有没有唯一入口和出口。第三问:判断条件除了“模拟成功”之外,有没有更简洁的数学表达。把这三个问题回答清楚,场景基本就脱干净了。当年做这道题,我一开始卡在“货运调度”四个字上,总觉得要处理车厢重量、轨道数量之类的细节。后来发现题目描述里根本没有这些,唯一的信息就是一条尽头式岔线和顺序进出的车厢。只要把冗长的场景描述里那些不构成约束的词全部扔掉,剩下的数据结构就很明显。
6.3 一点个人体会
刷题刷多了,越来越觉得考试模拟题不是考你会不会背栈的定义,而是考你能不能透过一堆铁轨和火车的描述,看到底下那个干净的模型。这道题我做完之后,最大的收获不是“栈模拟怎么写”,而是“抽象过程,找到规律”这八个字。
如果以后遇到类似的“场景复杂、本质简单”的题,我的建议是:先在草稿纸上画一个图,把进、出、卡住三个状态标出来,不要一上来就写代码。把这个习惯保持住,你会发现很多题根本不难,难的是你被表面的场景描述吓住了。