图 1:本文封面,缓冲轨是栈,顺序是约束。
先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面:一列火车有 n 节车厢,编号 1 到 n,按编号顺序依次进站;出站方向要求把车厢排成给定的目标顺序。车厢只能前进不能后退,中途可以利用缓冲铁轨暂存——缓冲轨一端封闭,车厢从同一端推进去、倒出来,也就是说每条缓冲轨就是一个栈:最先进去的车厢只能最后出来。给你一个目标排列,判断能不能调出来,能输出 Yes,不能输出 No。
第一步,把「能不能」翻译成「会不会卡死」。
面对目标排列,手上只有两种操作:让下一节车厢进站(如果栈顶不是当前要的车厢,它没别的地方可去,只能压进缓冲轨),或者把栈顶车厢弹出到站(如果栈顶正好是当前要的车厢)。关键在于:每一步该做哪个操作其实是被逼出来的——栈顶已经是目标车厢时,必须立刻弹,因为再压进任何一节新车厢,都会把它压在下面永远出不来;栈顶不是目标时,只能继续进站。既然每一步都没有选择余地,那「模拟一遍会不会卡死」就是判定本身:从头到尾贪心模拟,进站车厢用完了还匹配不上目标,就输出 No。
第二步,把模拟写成代码。
下面这份实现包含贪心模拟、操作序列还原,以及一个 BFS 枚举全部「压栈/弹出」操作序列的对拍真值表,用 Node.js 22.15.0 直接运行(node trainstack.js):
'use strict';
// 一条缓冲铁轨 = 一个栈:进站车厢按 1..n 依次到达,可随时压入缓冲轨,
// 缓冲轨只能后进先出地弹出到出站方向。判断目标排列能否被调出来。
function canShunt(n, target) {
const stack = [];
let next = 1; // 下一节进站的车厢编号
const ops = [];
for (const want of target) {
// 栈顶正好是目标,直接弹出;否则持续进站压栈
while (stack[stack.length - 1] !== want && next <= n) {
stack.push(next);
ops.push(`车厢 ${next} 进缓冲轨`);
next++;
}
if (stack[stack.length - 1] === want) {
ops.push(`车厢 ${want} 弹出出站`);
stack.pop();
} else {
return { ok: false, ops, stuck: want }; // 进站用完仍匹配不上:卡死
}
}
return { ok: true, ops };
}
// 对拍用:BFS 枚举所有「压栈/弹出」操作序列能产生的排列(真值表)
function bruteForce(n) {
const reachable = new Set();
const start = { next: 1, stack: [], out: [] };
const queue = [start];
while (queue.length) {
const { next, stack, out } = queue.shift();
if (next > n && stack.length === 0) { reachable.add(out.join(',')); continue; }
if (next <= n) queue.push({ next: next + 1, stack: [...stack, next], out }); // 压栈
if (stack.length > 0) { // 弹出
const s = [...stack];
queue.push({ next, stack: s.slice(0, -1), out: [...out, s.pop()] });
}
}
return reachable;
}
const cases = [
[5, [3, 2, 1, 5, 4]],
[5, [4, 3, 1, 2, 5]],
[3, [3, 1, 2]],
];
for (const [n, t] of cases) {
const r = canShunt(n, t);
console.log(`进站 1..${n},目标 [${t.join(' ')}] -> ${r.ok ? 'Yes' : `No(车厢 ${r.stuck} 被压在栈里出不来)`}`);
}
const demo = canShunt(5, [3, 2, 1, 5, 4]);
console.log(`目标 [3 2 1 5 4] 的操作序列:${demo.ops.join(' -> ')}`);
// 全排列对拍:n<=5 的每个排列,模拟判定必须与 BFS 真值表一致
let ok = true, checked = 0;
function* perms(arr) {
if (arr.length <= 1) { yield arr; return; }
for (let i = 0; i < arr.length; i++)
for (const rest of perms([...arr.slice(0, i), ...arr.slice(i + 1)])) yield [arr[i], ...rest];
}
for (let n = 1; n <= 5; n++) {
const truth = bruteForce(n);
for (const p of perms(Array.from({ length: n }, (_, i) => i + 1))) {
checked++;
if (canShunt(n, p).ok !== truth.has(p.join(','))) { ok = false; console.log('不一致:', n, p); }
}
}
console.log(`全排列对拍 ${checked} 组(n=1..5):模拟判定与 BFS 枚举${ok ? '完全一致' : '存在分歧'}`);
实测输出:
进站 1..5,目标 [3 2 1 5 4] -> Yes
进站 1..5,目标 [4 3 1 2 5] -> No(车厢 1 被压在栈里出不来)
进站 1..3,目标 [3 1 2] -> No(车厢 1 被压在栈里出不来)
目标 [3 2 1 5 4] 的操作序列:车厢 1 进缓冲轨 -> 车厢 2 进缓冲轨 -> 车厢 3 进缓冲轨 -> 车厢 3 弹出出站 -> 车厢 2 弹出出站 -> 车厢 1 弹出出站 -> 车厢 4 进缓冲轨 -> 车厢 5 进缓冲轨 -> 车厢 5 弹出出站 -> 车厢 4 弹出出站
全排列对拍 153 组(n=1..5):模拟判定与 BFS 枚举完全一致
看不可达的 [4 3 1 2 5]:为了先出 4,车厢 1、2、3、4 依次压进缓冲轨,弹出 4 和 3 之后栈顶是 2,而目标下一个是 1——2 把 1 死死压在下面,进站车厢也已用完,卡死。而 [3 1 2] 也是同样的死法:弹出 3 之后,2 挡住了 1。153 组全排列对拍里,贪心模拟与 BFS 枚举的判定一一对应,说明「模拟会不会卡死」不只是充分条件,也是必要条件。
图 2:贪心模拟唯一无分支,会不会卡死就是答案。
可能的追问。
「两条缓冲轨呢?」题面给了两条缓冲轨,本文先把一条的情形讲透——多一条栈,可达集合会变大:比如一条轨调不出的 [3 1 2],两条轨时把车厢 2 停在另一条轨上就能调出来,但判定也从「扫一遍」变成要在两条轨之间做分配决策,笔试现场先把单轨的 O(n) 判定写对,再口头讨论多轨的扩展。「时间复杂度?」每节车厢最多进栈一次、出栈一次,O(n)。「一条栈能调出的排列有多少个?」对拍枚举给出的数字是 n=1 到 6 依次为 1、2、5、14、42、132——正是卡特兰数,n 节车厢的全排列 n! 里只有一小部分调得出来,这个比例随 n 指数级缩水。
「受限通道下的可达性判定」是个通用建模:编译器指令重排要满足依赖约束、流水线调度要满足资源约束,都是「操作受通道限制,问目标顺序可不可达」。回到笔试现场,建议的答题节奏:先讲缓冲轨即栈的建模,再讲「每一步被逼唯一」所以模拟即判定,给出一可达一不可达两个例子,最后主动补对拍验证。你第一次遇到这类「受限重排」问题时,是想到栈模拟,还是动手枚举了所有操作序列?欢迎聊聊。