简介:针对数据结构中经典的车厢调度问题,这份压缩包提供一份C语言实现源码与配套说明文档,适合正在学习数据结构、备战算法考核或参加编程竞赛的读者。压缩包共2个文件,分别为C语言源码和txt说明文本,整体体积仅为4KB,小巧易读;目前已有303人学习下载。C代码围绕列车编组时车厢进出站的先后约束,借助栈、队列等结构模拟调度过程,清晰展现后进先出、排队等待等核心机制在实际问题中的应用,并可能涉及贪心或回溯等优化思路。txt说明文件记录代码源头或平台信息,便于进一步追溯;整体体量精悍、逻辑集中,是快速剖析调度类问题建模与算法实现的良好样本。通过研读源码,读者既能理解车厢调度问题的状态空间与解题框架,也能对比不同数据结构对时空效率的影响,进而提升用C语言解决实际工程调度问题的能力。
1. 车厢调度问题:一道让栈的 LIFO 特性现出原形的课程设计题
车厢调度问题是我见过最有画面感的数据结构课程设计题:一条进站轨道、一条出站轨道,中间夹一段只能从一头进出的调度线,入站车厢按 1、2、3… 的顺序驶来,调度员却收到一张目标出站顺序的清单,要判断这张清单能不能兑现,并且把每一节车厢的走法写清楚。这道题在严蔚敏版《数据结构 C 语言版》和考研 408 里都能看到影子,核心就是栈的先进后出。新手把它当模拟题做,能练数组、栈和边界处理;把它当证明题做,还能挖出不少序列规律的乐趣。
我一般建议一个下午从零写完再回来看原理,因为这道题的坑点集中在实现细节而不是思路:中转轨道长度、目标序列读入、直达与中转操作的输出差异,任何一处没拎清都会让代码“看起来合理却跑不出正确答案”。下面按建模、代码、踩坑、进阶的顺序把整个方案讲透,适合数据结构实验报告、课程设计以及准备 408 栈序列判断题的读者照着重现。
2. 先把调度轨道抽象成栈模型:LIFO 约束与序列合法性的底层判断
2.1 为什么中转轨道就是栈:三个约束决定重排能力
把现场画出来,是一条进站主线、一条出站主线,中间接一段“死胡同”式的调度轨道。车厢从主线的 A 端进入调度轨道,也只能从 A 端倒出来,这正好是教科书里栈的定义:后进入的先出去。队列模型在这里不成立,因为调度轨道的出入口只有一个;如果两端都开口,那就变成队列式调度,重排能力会完全不同——那种场景下你只能做整体平移,没法把序列逆过来。
整个问题由三个硬约束决定:第一,进站顺序固定为 1 到 n,不可更改;第二,中转轨道是 LIFO,任意时刻能动的只有轨道最外侧那一节车厢;第三,出站序列必须严格匹配目标清单。三者叠加之后,不是所有排列都能被调度出来。比如 n=3 时目标序列 3、2、1 可行:1 进轨道,2 进轨道,3 直接出站,然后轨道依次放出 2、1。而目标序列 3、1、2 不可能:要让 3 先出,1 和 2 必须先进入轨道,但 2 压在 1 上面,接下来能出的是 2 而不是 1。
这个例子说明,栈的容量在这里也有决定性作用。如果中转轨道足够深,理论上最多能“倒”出一整列逆序;如果轨道只容得下一两节车厢,可调度的范围会急剧缩小。常见的课程设计版本默认轨道容量等于 n,也就是不限制深度,先把这个版本做对,再去处理受限容量,是比较稳的路线。
2.2 一个硬规律:目标序列里“后面比它小的元素必须降序”
当入站序列固定为 1、2、…、n 时,判断一个目标序列是否可行有一条不需要模拟的充要规律:对目标序列中的任意元素 x,出现在 x 之后的所有比 x 小的元素,必须按从大到小的顺序排列。
这个规律可以手算验证。以目标序列 3、1、2 为例,看第一个元素 3:它后面的比 3 小的是 1、2,出现顺序是 1 在前 2 在后,1 小于 2,不是降序,因此整个序列非法。再看目标序列 3、2、1:3 后面的小元素是 2、1,按 2、1 降序出现,合法。再看 1、3、2:1 后面没有比 1 小的元素;3 后面的小元素只有 2,单个元素天然满足降序,合法。
为什么会有这条规律?因为比 x 小的那些车厢编号一定比 x 先入站,而它们又在 x 之后出站,说明在 x 出站那一刻它们仍然堵在轨道里。轨道是栈,越小的编号越靠近底部,越大的越靠近顶部。等 x 被放出去之后,这些剩余车厢只能从顶到底依次弹出,也就是从大到小。任何违反“降序”的目标序列,都等价于想让编号大的车厢从下方穿越到上方,物理上不可能。
这条规律在王道 408 的数据结构题目里经常以选择题出现,题干常写成“已知入栈序列为 1..n,判断下列哪个是合法出栈序列”。用一次降序检查比现场模拟快得多,但它只适用于入站序列恰好是递增连续序列的情况,一旦输入变成任意序列就得回到模拟器,这一点后面会展开。
2.3 栈容量不足时怎么办:从序列规律退回逐车厢模拟
如果题目给定了中转轨道最多容纳 m 节车厢,刚才的降序规律就不够用了。原因是这条规律隐含假设“比 x 小的所有元素都可以同时存在于栈中”,而容量 m 小于 n 时,某些小编号车厢可能因为栈满被迫提前直出,反而把小元素之间的相对顺序改变。
举一个例子:n=4、m=2,目标序列 4、3、2、1。按无容量限制的判断,这个倒序序列完全合法,1、2、3 依次入栈后再放 4,随后依次弹出 3、2、1。但轨道只有两节容量时,1、2、3 三节车厢中至少有一节无法进栈,目标序列直接无解。换句话说,容量限制会把若干“理论合法”的序列踢出可行集。
这时候唯一可靠的做法是带着栈深度参数做模拟:压栈前检查当前栈内元素数量是否等于 m,是的话且当前车厢又不能直达出站,就判定无解。后面第 3 章的模拟器只要加上这个判断,就能同时覆盖容量受限和非受限两种情况。我先给一个默认容量等于 n 的最小版本,讲清楚逻辑之后,再把容量参数怎么加进去一并说明。
3. 用 C 语言把调度模拟器跑通:数组栈实现与三段式判断逻辑
3.1 先能编译运行的完整代码:数组栈 + A/B/C 三种操作
下面这段代码是我在实际课程设计里常用的最小版本。它读取车厢总数 n 和目标出站序列,输出每一种车厢的走法:A 表示直达出站,B 表示进入中转轨道,C 表示从中转轨道出站。整体约五十行,可以存成单个 C 文件直接编译。
#include <stdio.h> #define MAXN 105 int stack[MAXN]; // 用数组模拟中转轨道 int top = -1; // 栈顶指针,top == -1 表示栈空 int target[MAXN]; // 目标出站序列,下标从 1 开始 void push(int v) { stack[++top] = v; // 先移动指针再赋值 } int pop(void) { return stack[top--]; } int empty(void) { return top == -1; } int main(void) { int n = 0; if (scanf("%d", &n) != 1 || n <= 0 || n >= MAXN) { printf("输入不合法\n"); return 1; } for (int i = 1; i <= n; i++) { if (scanf("%d", &target[i]) != 1) { printf("目标序列读入失败\n"); return 1; } } int j = 1; // j 指向下一个要匹配的目标位置 for (int i = 1; i <= n; i++) { // i 表示当前驶入调度区的车厢编号,入站顺序固定为 1..n if (i == target[j]) { // 当前车厢正好是下一个目标,允许直达出站 printf("A %d\n", i); j++; } else { // 否则进入中转轨道等待 push(i); printf("B %d\n", i); } // 每次入口动作结束后,不断弹出栈顶能匹配目标的车厢 while (!empty() && j <= n && stack[top] == target[j]) { int v = pop(); printf("C %d\n", v); j++; } } // 入站车厢处理完,栈里剩下的车只能按 LIFO 连续出去 while (!empty()) { int v = pop(); if (j <= n && v == target[j]) { printf("C %d\n", v); j++; } else { printf("无解:栈顶是 %d,需要的是 %d\n", v, target[j]); return 1; } } if (j == n + 1) { printf("成功\n"); } else { printf("无解\n"); } return 0; }这段代码的核心思路是“把入站车厢逐个放上舞台,每放一节就立即检查能不能把栈顶兑现”。你可以把 j 理解成目标清单上的当前指针,i 理解成下一节抵达的车厢号。A 分支处理直达出站,B 分支处理进入轨道,C 分支处理从轨道里放车。
3.2 这段判断逻辑为什么要“每步都回头检查栈顶”
很多第一次写这道题的人会把 while 写成 if,然后在目标序列为 1、3、2 时得到错误结论。原因在于,某节车厢从轨道弹出之后,轨道上方可能露出另一节恰好匹配下一目标的旧车厢,需要继续弹出,而 if 只会检查一次。比如目标序列 1、3、2,入站 1 直出后,2 进入轨道,3 到达时由于 3 正好是当前目标而直出,此时栈顶露出的 2 恰好匹配下一个目标,必须立刻弹出。如果这里只执行了一次判断,2 就会被留在栈里,最后被误判为无解。
while 循环里的j <= n是防止 j 越界的保险。当所有目标都已匹配完毕而栈中恰好为空时,不会进入循环体;一旦出现输入数据非法导致 j 已经越过 n 但栈中还有剩余元素,也会安全退出而不是读取target[j]越界。
最后一个 while 处理入站流程结束后的残留车厢。此时轨道里所有元素只能连续弹出,没有“先压后弹”的余地,因此遇到第一个不匹配就可以直接宣告无解。这个过程的本质是:把所有可能的操作全部枚举结束后,只要有一节车厢无法按目标顺序出站,就说明该目标序列不可达。
3.3 输入输出约定与推广到任意入站序列的方法
程序约定第一行为整数 n,第二行是 n 个空格分隔的整数,表示目标出站序列。以 n=4、目标 2、3、1、4 为例:
4 2 3 1 4输出为:
B 1 A 2 A 3 C 1 A 4 成功这里 A 操作意味着“不经过中转轨道直接上出站线”,B/C 分别对应“进轨道”和“出轨道”,这样得到的操作序列可以直接写进实验报告的“调度方案”一节。
如果题目给定的是任意入站序列而不是 1..n,只需要把外层 for 循环里的 i 改成读入的入站数组,例如先读入int in_seq[MAXN],然后在循环里用int i = in_seq[k];替代原来的int i = k;,其余判断逻辑保持不变。要注意的是,这样修改后 2.2 节的降序规律不再适用,因为你比较的不再是数值大小而是入站先后关系;遇到这种情况,直接信任模拟器即可。
4. 车厢调度模拟器的 5 个踩坑现场:从读入到判定的边界问题
4.1 现象:判定成功,但输出结果不是目标序列
我最早写这个程序时,把target数组从下标 0 开始读,但判断逻辑里从下标 1 开始取,结果 n=4 时第一轮就读到了未初始化的target[1],导致整段调度过程完全错乱,程序却因为最后 j 恰好到达 n 而输出“成功”。
原因是对 C 语言的数组下标体系没有统一:读入循环和匹配循环一个用 0-based,一个用 1-based,数据整体错位一位。解决方式也很简单,明确约定整个程序统一使用 1-based,所有数组定义成target[MAXN]后从下标 1 开始写入,匹配逻辑同样从 1 开始。写完后再跑一次 n=3 的全排列,把六个目标序列逐个核对一遍,能过滤掉绝大多数下标问题。
4.2 现象:轨道容量设错导致“合法变非法”,少判一个栈满
课程设计题里偶尔会特意写“调度轨道最多能存放 3 节车厢”,这时候如果仍把栈当成无限容量,3、2、1 这种目标会被错误地判定为合法。表面现象是代码输出了一长串 B 和 C,但没有任何一步提示栈满。
原因是模拟器没有把容量纳入状态约束。解决方式是在push之前增加一个判断:if (top + 1 == capacity) { printf("栈满,无解\n"); return 1; }。如果题目没有给容量,就把它视为 n;一旦题目给了 m,务必把这个参数从输入读入,并让push函数感知它。这里的top + 1是当前栈内元素数量,不要写成top == capacity,否则会在容量为 0 时出错。
4.3 现象:把降序规律记反,手算结果和代码互相矛盾
2.2 节给的是“目标序列中,x 后面的所有比 x 小的元素必须降序”。不少资料里还有另一种说法:“比 x 大的元素必须降序”,这两种说法看似对称,实际适用范围完全不同,用错之后会把手算结果带偏。
举个例子,目标序列 2、3、4、1 是合法的:1 先入栈,2、3、4 依次直出,最后 1 出栈。如果错误使用“比 x 大的元素必须降序”这条规则,看第一个元素 2,后面比 2 大的是 3、4,出现顺序是升序,于是你把它判为非法,和模拟器结果正好相反。解决方法是不要背口诀,直接回到“栈内元素自上而下递减”的直觉:比 x 晚出站的小车厢只能由大到小排队离场。实在拿不准就写一个 n=4 的全排列小程序,用模拟器验证规律本身。
4.4 现象:输出操作序列和评测答案不一致,A 操作到底要不要单独输出
有的版本把“直达出站”也算作“入栈后立即出栈”,因此只输出 B 和 C 两类操作;有的版本要求必须区分直达与中转,于是有了 A/B/C 三种操作。两者的实际调度结果相同,但输出字符串完全不同,提交到自动评测系统时很容易被判定为格式错误。
原因是题意里对“直达”的描述没有统一。解决方式是先确认题目要求的输出格式;如果要求两类操作,就把 A 分支改成push(i); printf("B %d\n", i); printf("C %d\n", i); j++;。如果要求三类操作,就保留 3.1 节的写法。注意后一种写法会多打印两条操作,但能更直观地展示每一节车厢的完整去向,写实验报告时我一般推荐保留 A。
4.5 现象:scanf 混用 getchar 导致第二组数据读错,换行符被吞
如果程序需要循环处理多组输入,有些人会在读完 n 后用getchar()去“消化”换行,再读目标序列。问题在于scanf("%d")遇到空白字符时本来就会自动跳过,额外的getchar反而可能把目标序列的第一个数字前面的换行读走,造成后续读入错位。现象是多组输入时第一组正常,第二组开始目标序列整体少一个数,或者第一个数变成 0。
解决方式是统一使用scanf读取所有整数,不在中间混用字符读取函数。多组输入时写成while (scanf("%d", &n) == 1) { ... },每组内部继续用scanf("%d", &target[i]),换行和空格都由格式化的百分号 d 自动跳过。
5. 不模拟也能判断调度方案:O(n^2) 判定规律与两个扩展场景
5.1 一个轻量判定函数:用降序规律做 O(n^2) 可行性检查
当你只需要回答“可行还是不可行”,并且不要求输出调度过程时,可以用一个十几行的函数替代整段模拟器。下面这个函数直接实现 2.2 节的降序规律,入站序列默认是 1..n:
int can_reach(int n, int a[]) { // a[1..n] 是目标出站序列 for (int i = 1; i <= n; i++) { int last = n + 1; // 上一个“比 a[i] 小且在其后”的元素值 for (int j = i + 1; j <= n; j++) { if (a[j] < a[i]) { // 比 a[i] 小的元素必须严格递减出现 if (a[j] < last) { last = a[j]; } else { return 0; // 出现非降序,序列非法 } } } } return 1; }这里的last初始化为 n+1,因为 1..n 范围内没有比它更大的合法值。扫描到第一个小于a[i]的元素时,必然满足a[j] < last,于是把 last 更新成这个值;再往后遇到的每一个小元素都必须比上一次的 last 更小,才符合降序。如果出现a[j] >= last,说明小元素的出场顺序被抬高了,直接返回 0。
测试 3、1、2:外层 i=1 时,a[1]=3,j=2 发现 1 小于 3,last 更新为 1;j=3 发现 2 不小于 1,返回 0,正确。测试 1、3、2:i=1 时后面没有小于 1 的;i=2 时 a[2]=3,后面 2 小于 3,last 更新为 2,最终返回 1,正确。
5.2 模拟器与数学判定怎么选:复杂度与产出的对照
| 方法 | 时间复杂度 | 空间复杂度 | 产出内容 | 适用场景 |
|---|---|---|---|---|
| 数组栈模拟器 | O(n) | O(n) | 完整 A/B/C 操作序列 | 课程设计、实验报告、需要实际调度方案 |
| 降序规律判定 | O(n^2) | O(1) | 仅可行性结论 | 408 选择题、笔算、快速预检 |
模拟器的优势是信息量完整,但代码量也大;降序规律的空间复杂度是常数,写起来快,适合在拿到目标序列后先手算一轮,发现可疑再回去模拟。注意这个复杂度表格针对的是入站序列为 1..n 的版本,任意入站序列下数学判定需要改造成“比较入站位置”,此时直接用模拟器更保险。
5.3 两个扩展:任意入站序列与多轨调度回溯
第一个扩展是把入站序列从固定的 1..n 改成任意排列。修改方式在 3.3 已经说过,关键在于数学规律不再比较数值大小,而是比较入站先后:目标序列中每个元素 x 后面的、入站顺序比 x 早的元素,必须按入站顺序逆序出现。这个规律可以继续用,但容易写错,我一般直接用模拟器,不再单独写判定函数。
第二个扩展是调度轨道从一条变成两条或更多。此时问题性质发生变化,因为两个栈可以互相“腾挪”,可行序列集合显著扩大,而且没有一个像单栈那样简洁的充要条件。常见做法是递归回溯:把每一节入站车厢逐个尝试放入某一条轨道或者直接出站,并检查是否违反目标顺序;当 n 稍微变大,搜索空间会迅速膨胀,需要用剪枝和记忆化来缓解。这个方向已经超出课程设计的基础范畴,但如果实验报告的“进阶思考”部分需要写,回溯搜索是一个能让老师眼前一亮的落点。
6. 做完模拟器之后:用互测和栈内单调性断言验证正确性
6.1 用全排列互测让两个实现互相验证
模拟器判定“有解/无解”的结果,应该和 5.1 节降序规律的结果完全一致。验证方式很直接:写一个外层循环生成 n=8 以内的全排列,对每个排列分别调用can_reach和模拟器版本,一旦发现结论不一致,立刻打印该排列。n=8 时共有 40320 个排列,两种算法都瞬间跑完,是性价比最高的回归测试。如果想把范围扩大,n=10 的 3628800 个排列在一两秒内也能出结果。
我在实际调试中靠这个互测找回过两处隐蔽 bug:一处是容量判断写成了top >= capacity,另一处是目标序列匹配后没有把 j 及时前移。两类问题靠手算样例都很难复现,但全排列互测能直接定位到具体输入。
6.2 调试期加一条“栈内严格递增”断言
单栈模型还有一个很有用的不变量:栈内元素从底部到顶部必须严格递增。因为入站顺序是递增的,后入栈的车厢编号一定大于先入栈的,所以在模拟过程中如果发现栈底到栈顶违反递增,说明状态已经被破坏。可以在push之前加上断言,例如:
if (top >= 0 && v <= stack[top]) { printf("状态错误:栈内元素顺序被破坏\n"); return 1; }这个检查不会影响正常调度,但在改写代码、加入容量限制或调整输出分支时能快速暴露逻辑错误。我自己的习惯是开发阶段保留这段检查,全部测试通过后再删掉,避免正式输出被多余信息干扰。做完互测和断言这两步,这道题的代码基本就不会再出现“玄学”问题了。遇到任何诡异现象,先检查栈内顺序,再检查读入下标,往往能省下大半个晚上的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取