ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

火车购票模拟题满分解析:CCF经典题C/C++实现与连续座位判断

火车购票模拟题满分解析:CCF经典题C/C++实现与连续座位判断

如果你刷过CCF认证的历年题目,火车购票这道题多半不会陌生。它出现在很多人的刷题清单里,原因很简单:题目场景贴近生活,数据结构要求低,代码量也不大,而且全网已经有不少“能过样例”的版本。但说实话,很多版本只写了“空位总数够就坐”,忽略了题目里最关键的“同一排相邻座位”这个硬性条件,边界数据一旦踩到就会翻车。这篇文章把C语言和C++两版100分代码完整写出来,注释放在关键位置,并且复盘我实际提交中踩过的几个边界问题。适合准备CCF认证的读者,也适合刚开始练OJ模拟题、想知道“生活场景怎么翻译成代码”的新手。

1. 题目到底在考什么:先读透三个隐藏规则

1.1 车厢、编号和输入输出形式

题意本身不复杂:一节车厢有20排,每排5个座位,一共100个座位。第一排是1到5号,第二排是6到10号,依此类推。初始时所有座位都是空的,系统会收到若干条购票指令,每条指令给一个购票数量x,范围是1到5。你要做的就是模拟系统的分配过程,把每次分配到的座位编号输出。

输入格式是:第一行一个整数n,表示购票指令的条数;接下来n行,每行一个正整数x,表示本次买几张票。输出要求是每个指令占一行,把当前指令分到的座位编号按从小到大输出。这里的“从小到大”其实不用额外做排序,因为系统按规则分配的时候,本来就是按座位号递增的顺序去扫的,输出自然有序。

这道题的数据范围非常小,20排乘5座只有100个座位,所以任何合理的模拟方式都能过。它考察的不是高级算法,而是你能不能把座位状态维护清楚。

1.2 “同一排相邻”不是模糊词,而是硬判定条件

这里有一个最容易踩坑的点。分配规则的第一优先级是“如果n个人能在同一排买到相邻座位,就优先安排在同一排,并且优先安排编号较小的排”。这句话听起来很自然,但“相邻”是一个需要编程实现的判断,不是一个感觉。

举个例子:某一排5个座位的状态是“空、占、空、空、空”,空位总数是4个。这时候来一条买4张票的指令,这排能坐吗?答案是“不能”。因为虽然空位总数够4个,但最长的连续空位只有3个(3号、4号、5号),没有办法提供4个相邻座位。很多人在这个点上失误,就是因为把“空位总数”当成了“能否坐在一起”的判断依据。我在第2章和第5章还会反复提到这个场景,它是整道题最重要的边界。

1.3 这道题对新手真正的价值

火车购票在CCF前两题里属于典型模拟题。它没有图论、没有动态规划、没有复杂的数学公式,唯一需要的就是读懂规则、选对数据结构、然后老老实实模拟。对新手来说,它的价值在于训练一件事:把自然语言描述的规则,转换成精确的程序分支。

比如“优先安排编号较小的排”,翻译成代码就是从第1排开始往后找,找到第一个满足条件的排就停;“所有排都无法提供相邻座位时,安排在编号最小的x个空位”,翻译成代码就是从1号座位开始往后扫空位。这些转换本身就是编程能力的核心部分。把这道题做透,后面遇到类似的车票分配、教室排座、会议室预订题,思路都会顺很多。

2. 动手前先建模:座位表和连续段怎么表示

2.1 20×5二维数组加used计数器:朴素但够用

我用的数据结构和大多数满分题解一样简单:一个二维数组表示20排5座,外加一个一维数组记录每排已经卖出去多少个座位。

seatStatus[i][j]用0表示空位,1表示已售,i从1到20,j从1到5。同时用一个used[i]记录第i排已经占用的座位数。这样做的第一个好处是,每次需要知道某一排还剩几个空位时,直接算5 - used[i]就行;第二个好处是,更新座位状态时,seatStatus[i][j]和used[i]两个位置同步维护,不容易漏。

你可能会问,省掉used[i]行不行?当然行,每次需要的时候现扫描五个座位也不是不行,反正每排只有5个座位。但多维护一个计数器能让你在写“还剩多少空位”的时候不用反复写循环,代码会清爽很多。对于模拟题,我的一贯原则是:数据结构简单、状态更新直观、出错概率小。

2.2 一次购票被拆成“找排”和“落座”两步

一次购票指令的处理流程可以拆成两个阶段。

第一阶段,从第1排开始往后找,看有没有某一排能提供长度为x的连续空位。找到第一个满足的排之后,从这一排最靠左的合格连续空位段开始,把x个座位标记为已售,输出座位号,结束。注意这里有两个“第一个”要说清楚:排要选编号最小的,段要选该排中最靠左的。这两个选择都符合日常购票的直觉,也让输出编号尽量小。

如果第一阶段扫完20排都没找到能提供连续x个空位的排,就进入第二阶段:从1号座位开始,按编号从小到大扫描所有空位,依次分配x个,不要求相邻。简单说,第一阶段负责“尽量坐一起”,第二阶段负责“没得坐一起了,按最小编号兜底”。

2.3 为什么用“空位总数”判断一排能否坐下是危险的

网上很多版本在判断某一排是否能坐下x个人时,用的是空位总数,也就是“used[i] + x <= 5”。这个写法在座位全空或者连续空位很长的时候没问题,但严格来说,它没有判断“连续”。

我前面提过的状态“空、占、空、空、空”就是一个反例。该排空位总数是4,买4张票时,如果只用空位总数判断,代码会认为这一排可以坐下,然后从第一个空位开始分配,最后输出1、3、4、5,这四个座位并不相邻,完全违反规则。而正确逻辑应该先看最长连续空位段是不是大于等于x,如果不够,继续往后找别的排。

有人可能会说,测试数据未必会卡这个点。确实,有些满分代码只用空位总数判断也能过,因为评测数据没有覆盖这种破碎座位状态。但从题意出发,连续空位判断才是没有争议的写法。既然每排只有5个座位,扫一遍算连续段长度也就是5次操作,增加的代价几乎为零,为什么不写严谨一点呢。

3. C语言满分实现:完整注释版代码

3.1 直接可提交的完整代码

下面这段代码是我最终确定使用的C语言版本,每一行的注释都给出了关键逻辑说明。思路就是第2章说的两阶段分配,为了严谨性,我没有用“空位总数”判断,而是老老实实地找每一排最长的连续空位段。

#include <stdio.h> int main(void) { int n; scanf("%d", &n); // 购票指令条数 // seat[i][0] 记录第 i 排已售座位数 // seat[i][1] ~ seat[i][5] 记录第 i 排第 1~5 个座位的状态,0 为空位,1 为已售 int seat[21][6] = {0}; while (n--) { int x; scanf("%d", &x); // 本次要买 x 张票,题目保证 1 <= x <= 5 int done = 0; // 本次购票是否已经完成分配 // ---------- 阶段一:优先找同一排连续座位 ---------- for (int i = 1; i <= 20 && !done; i++) { int start = 0; // 当前连续空位段的起始座位下标 int len = 0; // 当前连续空位段的长度 for (int j = 1; j <= 5; j++) { if (seat[i][j] == 0) { // 当前座位为空 if (len == 0) start = j; // 新的一段开始 len++; if (len >= x) { // 找到了一段长度至少为 x 的连续空位 // 从 start 开始连续分配 x 个座位 for (int t = 0; t < x; t++) { int pos = (i - 1) * 5 + start + t; // 座位号换算 seat[i][start + t] = 1; seat[i][0]++; if (t == 0) { printf("%d", pos); } else { printf(" %d", pos); } } printf("\n"); done = 1; break; } } else { // 遇到已售座位,连续段断开 len = 0; start = 0; } } } // ---------- 阶段二:所有排都无法连续时,按最小编号空位兜底 ---------- if (!done) { int cnt = x; int first = 1; for (int i = 1; i <= 20 && cnt > 0; i++) { for (int j = 1; j <= 5 && cnt > 0; j++) { if (seat[i][j] == 0) { seat[i][j] = 1; seat[i][0]++; int pos = (i - 1) * 5 + j; if (first) { printf("%d", pos); first = 0; } else { printf(" %d", pos); } cnt--; } } } printf("\n"); } } return 0; }

这段代码可以直接提交。它的核心逻辑和网上常见的“空位总数判断”版本最大的区别,就是我特意在阶段一里用连续段起止位置来判断。每排只有5个座位,这种判断并不会造成性能负担,但逻辑上严格符合题意。

3.2 代码里三处容易看confuse的细节

第一处是经纬转换:pos = (i - 1) * 5 + start + t。因为数组下标从1开始,第1排第1座就是(1-1)*5+1=1,第2排第1座就是(2-1)*5+1=6,这个公式直接对应题目里的座位编号规则。如果你数组下标从0开始,公式就要变成i * 5 + j + 1。很多WA就是写错了这一步。

第二处是连续段的起点start。我在遇到空位的时候先判断len是否为0,为0就记录start,这样保证start永远指向当前连续段的最左端。当len累加到大于等于x时,从start到start+x-1这一段一定是连续空位,可以放心分配。

第三处是done标志。阶段一成功分配后,done变成1,外层for循环条件立即失败,不会误进阶段二。阶段二只有在阶段一完全没找到合格排的情况下才会触发。这个标志位在模拟题里是非常常用的技巧,可以避免写完阶段一还要用复杂条件判断是否进入兜底。

4. 同一思路的C++实现:换语言不换逻辑

4.1 vector容器版完整代码

C++版本可以沿用C的数组写法,但我更推荐用vector,理由是无非是代码稍微现代一点,对于不习惯裸数组的读者更友好。核心逻辑完全不变,同样是两阶段分配。

#include <iostream> #include <vector> using namespace std; int main() { int n; cin >> n; // seats[i][j] 表示第 i 排第 j 个座位,0 为空,1 为已售 vector<vector<int>> seats(20, vector<int>(5, 0)); vector<int> used(20, 0); // 每排已售座位数 while (n--) { int x; cin >> x; bool done = false; // ---------- 阶段一:优先安排同一排连续座位 ---------- for (int i = 0; i < 20 && !done; i++) { int start = -1; int len = 0; for (int j = 0; j < 5; j++) { if (seats[i][j] == 0) { if (len == 0) start = j; len++; if (len >= x) { for (int t = 0; t < x; t++) { // 从第 i 排 start 位置开始连续分配 seats[i][start + t] = 1; used[i]++; int pos = i * 5 + start + t + 1; if (t == 0) { cout << pos; } else { cout << " " << pos; } } cout << endl; done = true; break; } } else { len = 0; start = -1; } } } // ---------- 阶段二:按最小编号空位兜底分配 ---------- if (!done) { int cnt = x; bool first = true; for (int i = 0; i < 20 && cnt > 0; i++) { for (int j = 0; j < 5 && cnt > 0; j++) { if (seats[i][j] == 0) { seats[i][j] = 1; used[i]++; int pos = i * 5 + j + 1; if (first) { cout << pos; first = false; } else { cout << " " << pos; } cnt--; } } } cout << endl; } } return 0; }

这段代码的输出和C版本完全一致。因为C++的vector支持动态二维数组,写起来和C语言的静态数组相比少了下标从1开始还是从0开始的混乱。我默认从0开始,所以座位号换算变成了i * 5 + start + t + 1,前两个版本之间的坐标偏移要特别留意。

4.2 C和C++做这道题的实际差异

两道代码本质是同一个算法,语言层面的差别主要有三点。

第一,输入输出方式。C用scanf和printf,C++用cin和cout。对于这道题的数据量来说,两者性能没有实质差别,但是如果你习惯用cin和cout,记得关掉输入输出同步来提升一丁点速度,不过这题不关也行。

第二,下标起点。C版本我用了从1开始的数组,好处是座位号公式直接对应题目;C++版本我用了从0开始的vector,好处是遍历习惯符合现代C++风格。两种都行,关键是不要在代码里混用,否则很容易算错座位号。

第三,容器特性。vector在越界访问时不像数组那样有明确的不安全行为,所以在写vector版本时,连续段的start + t要保证不越界。这道题里每排只有5个座位,只要len >= x成立,start + t一定不超过4,所以是安全的。

我个人提交时用C和C++各写过一遍,运行时间都在几毫秒级别,没有区别。选哪个主要看你更熟悉哪个语言。CCF认证对C/C++都友好,模板代码差距也不大,建议两个版本都写一遍,能帮你更牢固地理解同一套模拟逻辑。

5. 真实提交中的五个易错点复盘

5.1 行尾空格与换行

这道题的输出格式看似简单,实际很容易因为行尾空格被扣分。OJ评测对多余空格的处理分平台而异,有些平台严格比较,行尾多一个空格就会报Presentation Error。我在阶段一分配和阶段二兜底时,都是第一个座位直接输出,后续座位前面加空格,这样每一行末尾不会有多余空格。

处理方式不唯一,你也可以先拼到字符串里最后一起输出,但那样代码会多几行。最简单可靠的思路是:记录是不是第一个输出项,是就直接打印,不是就在前面补一个空格。

5.2 外层循环变量被覆盖

如果你习惯用while (n--)来遍历指令数,那循环体内部千万不要再修改n的值。看起来是废话,但真有人踩过。我之前见过一个版本,读完x之后,为了遍历座位,写了for (int i = 1; i <= n; i++),结果n被当成座位循环的终点,外部指令条数全乱了。

这道题的购票数x也容易被误当循环变量。比如你输入x=4,然后在阶段二里写了while (x--)来分配空位,分配完之后x变成了0,下一轮购票就彻底乱了。正确做法是始终用一个独立变量,比如cnt,去记录“还要分配几个座位”,原始x始终保持不变,除非你已经确认不再需要它。

5.3 连续空位段与空位总数的误判复现

这个反例值得单独拿出来看。假设前面某次兜底分配时,系统把第1排的2号座位单独卖出去了,现在第1排状态是:1号空、2号占、3号空、4号空、5号空。此时来一个买4张票的指令。

如果用空位总数判断,第1排有4个空位,满足4张票的需求,代码会输出1、3、4、5,但这四个座位不连续,完全违反“同一排相邻”的规则。正确做法是:第1排最长连续空位段只有3个(3号到5号),不满足4张;继续检查第2排,如果第2排也没有连续4个空位,就进入兜底,从全局编号最小的空位开始分配。

构造这个用例的关键,就是让一排出现“空位总数够,但连续空位不够”的状态。只要你能构造出这种状态,就能测试出你的代码到底是不是真的在判断“连续”。

5.4 座位编号换算:从数组下标到真实座号

座位号换算看起来简单,实际是除了连续段判断之外最容易错的地方。错误一般来自两种:一是把数组下标从0开始还是从1开始搞混;二是把排号和列号的权重乘错。

从1开始存数组时,第i排第j座的编号是(i - 1) * 5 + j。从0开始存vector时,第i排第j座是i * 5 + j + 1。本质上都是“前面有多少整排乘以5,再加上本排第几座”。如果是100排10座,同样的思想去换算即可,不要套死公式。

5.5 全局状态在多个指令间的连续性

这道题的座位状态是跨指令共享的。第一轮卖出去的座位,第二轮、第三轮都必须保持已售状态。很多初学者把座位数组定义在每次购票的处理循环内部,导致每一轮座位状态都被重置为空,这样从第二轮开始所有判断全部失效。

我之前调试过一个类似问题,卡了很久,最后发现是初始化语句写错了位置。这类模拟题有一个铁律:全局模型状态必须在指令循环之前定义,只能在循环内读改写,不能在循环内重新初始化。把它养成习惯之后,再遇到银行排队、停车场管理之类的模拟题都会少踩很多坑。

6. 测试、时间复杂度和扩展思路

6.1 自己构造测试用例的方法

刷模拟题最忌讳只跑题目的样例就交,因为样例通常太温和,不会触发边界情况。建议自己构造三类测试数据。

第一类是整排满座后的状态。比如买5张,再买5张,连续把第1排和第2排填满,看下一轮买1张时能否正确找到第3排1号座位。第二类是打破连续段的兜底状态,比如构造出“空、占、空、空、空”这样的座位状态,测试买4张时会不会误判。第三类是极限数据,买票指令给100条,甚至更多,确保循环次数多时状态不会错乱。

构造用例的时候,你可以人工模拟一遍,也可以写一个小程序打表对比。对这道题来说,最简单的自测方法是打印每次分配后的整列座位状态图,一眼就能看出连续段判断对不对。这个方法虽然笨,但非常有效。

6.2 时间复杂度和超时担忧

这道题的复杂度是不用担心的。每一轮购票最多扫完20排,每排最多5个座位,也就是100次操作。假设有n条购票指令,总操作量是100n。n如果是100,也就一万次操作,在OJ上属于秒过。

有人可能担心,阶段一里为了判断连续段,每排都要扫一遍,比空位总数判断多了一些步骤。但这种多的程度微不足道,每排扫5个座位,无论如何也撑不起超时的可能。真正需要关注的是逻辑正确性,而不是在这100个座位的规模上去做无谓的优化。

6.3 如果数据规模放大,可以怎么升级

如果哪天题目变成“100排,每排1000座”,还要求连续段判断,那上面的朴素思路就可能不够用了。到那时候可以考虑维护每排的“最长连续空位段”信息,或者用有序集合记录每排的空位段,分配时直接查询和拆分段。

但这已经远远超过火车购票这道题的考察范围。我建议新手先别去研究线段树、有序集合这些高级结构,老老实实把朴素模拟写对、写稳,等你在CCF前两题拿到稳定分数之后,再回头研究优化方案也不迟。很多看似笨的解法,其实恰恰是比赛里最不容易出错的解法。

回到这道题本身,我刷完一遍之后最大的感受是:模拟题想拿满分,靠的不是代码技巧,而是对题目规则的精确理解。连续空位判断、状态跨指令保留、座位编号换算,这三个点只要有一个模糊,就可能在某个隐蔽用例上翻车。把C语言和C++两版代码都写一遍,再配合几个自己构造的反例测试,这道题基本就稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进