ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

CCF-CSP火车购票模拟题:座位分配规则与满分代码详解

CCF-CSP火车购票模拟题:座位分配规则与满分代码详解

"火车购票"是我在备考CCF-CSP认证时遇到的一道非常经典的模拟题。当时某同学拿它来问我,开口就是一句:"这不就是找空座位吗,有什么难的?"结果他前两次提交都拿了70分,找来找去也查不出语法错误——后来我把他的代码跟题目规则一对照,瞬间就明白了:他把"这排还有p个空位"当成了"这排有p个连续空位",两个概念一混,规则一就废了。这道题表面上是模拟,实际上考的是你读题之后,能不能把"同排连续优先"和"最小空位兜底"这两条规则,用清晰的数据结构表达出来。

题目本身不难:一节车厢有20排,每排5个座位,总共100个。输入n条购票指令,每条指令要买p张票(1 ≤ p ≤ 5),按规则分配座位并输出每次的座位号。数据量极小,考的不是算法性能,而是逻辑严密性。这篇文章我会把满分思路、带逐行注释的C/C++代码、自测用例和考场避坑经验全部拆开讲,适合正在刷CCF-CSP真题、或者被模拟题绕晕的同学直接抄作业。

1. 题目还原:别被"火车购票"四个字带偏了

很多同学第一眼看到"火车购票",脑子里就开始想复杂的抢票系统、余票并发控制,其实这道题跟真实购票系统没有半点关系。它只是一个纯粹的座位分配模拟题,重点全在文字描述的规则里。

1.1 车厢模型与座位编号规则

车厢有20排座位,每排5个,总共100个座位。编号规则很固定:第1排是1到5号,第2排是6到10号,第3排是11到15号,依此类推,第20排是96到100号。

用代码建模的话,最常见的做法就是int seat[20][5],seat[i][j]表示第i排第j个座位(i从0开始,j从0开始),值用0表示空、1表示已占用。座位号换算公式是:

座位号 = i * 5 + j + 1

这个公式看着简单,但很多人会在考场上一紧张写错。比如把i * 5 + j + 1写成i * 5 + j,那第一次卖票输出的就是0号座位,整个编号整体左移一位。这种错很难通过眼睛查出来,只能靠样例输出对比才能发现。

1.2 两条分配规则,一条比一条"拗口"

规则一:如果本次要买的p张票能在同一排找到p个编号相邻的空座位,就必须安排在同一排,并且选择编号最小的一段连续座位。

这里有两个关键词需要同时满足:同一排、编号相邻。

规则二:如果所有排都不存在能放下p张票的连续空位,就退而求其次,从1号到100号依次挑选p个空座位,不要求同一排,也不要求相邻。

用生活化的场景来理解:这就像在电影院选座,规则一是你和朋友想连坐,系统优先找一整排没人坐的连座;规则二是全场确实没有连座了,系统就按座位号从前往后给你挑散座,哪怕你们几个人不挨着也没办法。

那到底什么情况下会触发规则二?就是每一排的连续空位数量都小于p的时候。注意,我说的是"连续空位",不是"空位总数"。这是整道题最容易踩的坑。

1.3 容易被忽略的边界条件

题目里p最大是5,所以当p等于5时,只有一整排5个座位全空,才能满足规则一中的"连续5个座位"。如果某排只有3个空位,无论这3个空位怎么分布,都不能买5张票时选这排。

另外,题目会保证购票总数不超过100个座位,所以不需要编写"余票不足"的处理分支。这意味着我们只管分配,不用考虑某个请求买不到票的异常情况。

还有一点,输出格式是每个购票指令输出一行座位号,座位号之间用空格分隔。行尾不要有多余空格,这是最基本的规范。

2. 满分思路拆解:模拟题的成败全在优先级

数据量这么小,20排5列一共100个座位,n最大也就100(极端情况下每条指令都买1张票),随便怎么扫描都不会超时。那这道题还有什么技术含量?有,而且不少:数据结构怎么选、两个优先级分支怎么写、连续区间怎么找,每一个环节都有讲究。

2.1 数据结构选型:二维数组是首选

有人可能会想,用一维数组int seat[100]不是更省事吗?座位号从1到100,直接按下标访问,连编号公式都省了。确实能用,但判断"同一排连续座位"时会非常别扭。

一维数组下,你要判断从某个位置pos开始连续p个座位是否都空,光是"是否跨排"就要额外处理:如果pos是5或10这样的边界,从它往后数p个就可能延伸到下一排。你必须时刻检查pos % 5 + p > 5这类条件,稍不留神就会把第1排的末位和第2排的首位拼成"连续座位"。

二维数组int seat[20][5]就不存在这个问题。行下标天然代表"排",列下标代表"这一排内的第几个座位",连续座位的检查就退化成一个单纯的横向扫描。数据结构把"同一排"这个约束直接编码进去了,后面的逻辑写起来又直白又不容易错。

我个人的考场习惯是:能用直观的数据结构就绝不搞所谓的"优化"。二维数组占的内存几乎可以忽略不计,但带来的编码效率提升是实打实的。特别是在时间紧张的考场里,你不需要在脑子里额外维护"一维下标到二维坐标"的换算,省下的全是出错的概率。

2.2 第一优先级:按排扫描,找连续起点

对每条购票指令p,第一步是在整个车厢里找"能提供p个连续空位的排"。具体做法是双重循环:

外层循环从第0排遍历到第19排,内层循环枚举这一排内连续座位的起点j。j的范围是0到5-p,因为从j开始往右数p个座位,必须保证不超出这一排的边界。

然后检查从第j列开始连续p个座位是否全部为空。如果全空,这串座位就是当前满足条件的"最小编号连续区间"。为什么敢这么说?因为我们扫描的顺序就是第0排→第19排、同一排内第0列→第4列,这正好等价于座位号1→100的顺序。第一次命中的区间,就是全局编号最小的可分配连续区间,直接分配这一组座位即可。

这里有一个关键技巧:检查连续区间时,一旦发现某个座位被占用,立刻终止当前起点的检查,跳到下一个起点。不要傻乎乎地把整排扫完再判断,虽然数据量小无所谓,但提前break的代码逻辑更干净,也更符合"找到就停"的直觉。

2.3 第二优先级:从1号开始找最小空位

如果第一优先级整个扫描完都没有找到能容纳p张票的连续区间,也就是assigned标志仍然是false,这时候才进入第二优先级:从1号座位开始,按座位号从小到大扫描整个车厢,遇到空座位就分配出去,直到凑够p个座位。

第二优先级的扫描顺序依然是从第0排第0列开始,到第19排第4列结束,所以分配出来的座位号必然是全车厢最小的p个空座。有人会问:第二优先级会不会把某个排的座位拆得七零八落?会,而且这是规则允许的。比如第一排只空1号和3号,第二排空6号,这时候买2张票,就会输出1和3,而不是6。规则二的核心就是"最小编号",不是"尽量连坐"。

实现第二优先级时还要注意:必须从头扫描,不能接着第一优先级失败的位置继续扫描。否则找出来的就不是"全车厢最小空位",而可能是中间某一段的空位。这是另一个高频失分点,后面会专门讲。

2.4 复杂度分析与考场意义

每个购票请求最多扫描20排、每排5列、每列检查最多5个连续座位,撑死也就500次操作。即使有100个请求,总操作量也就5万次,对任何评测系统来说都是瞬间完成的事。

所以这道题真正的考察点不是算法复杂度,而是"你能不能严谨地处理业务规则"。在CCF-CSP认证里,这类"规则模拟题"出现频率很高,核心套路都是一样的:状态存储要准确、优先级判断要严格、边界条件要覆盖全。把火车购票吃透,往后遇到类似题目,思路会顺很多。

3. C/C++满分代码(含逐行注释)

下面这份代码是我在考场上最常用的一套写法,C和C++语法通吃。全程没有花哨技巧,就是老老实实模拟,核心注释全部标好了。

3.1 完整可运行代码

#include <bits/stdc++.h> using namespace std; // 车厢一共20排,每排5个座位 const int ROWS = 20; const int COLS = 5; // seat[i][j] = 0 表示第i排第j列座位为空 // seat[i][j] = 1 表示第i排第j列座位已被占用 int seat[ROWS][COLS]; int main() { // 关闭输入输出同步,加快cin/cout速度(这题数据量小,其实无所谓) ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; // n条购票指令 while (n--) { int p; cin >> p; // 本次要购买的票数 bool assigned = false; // 标记本次请求是否已经完成座位分配 // ========= 第一优先级:同一排找到连续p个空座位 ========= for (int i = 0; i < ROWS && !assigned; ++i) { // 枚举连续座位的起点列号 j,保证 j + p 不超出这一排 for (int j = 0; j + p <= COLS; ++j) { // 检查从 (i, j) 开始往右连续 p 个座位是否全空 bool ok = true; for (int k = 0; k < p; ++k) { if (seat[i][j + k] != 0) { // 遇到已占座位 ok = false; break; } } if (ok) { // 找到连续空位区间,分配并标记占用 for (int k = 0; k < p; ++k) { seat[i][j + k] = 1; // 输出座位号,注意控制空格格式 if (k > 0) cout << ' '; cout << i * COLS + j + k + 1; } cout << '\n'; assigned = true; // 本次分配完成 break; } } } // ========= 第二优先级:全局最小空位兜底 ========= // 走到这里说明没有任何一排能提供p个连续座位 if (!assigned) { int cnt = 0; // 已经分配的座位数 for (int i = 0; i < ROWS && cnt < p; ++i) { for (int j = 0; j < COLS && cnt < p; ++j) { if (seat[i][j] == 0) { seat[i][j] = 1; // 占用该空位 if (cnt > 0) cout << ' '; cout << i * COLS + j + 1; ++cnt; } } } cout << '\n'; } } return 0; }

3.2 关键代码段拆解

assigned标志是整个程序的"交通枢纽"。第一优先级一旦成功分配,就把它置为true,后续循环条件中的!assigned会帮助我们跳出所有外层循环,避免同一个请求被分配两次。这个标志在第二优先级的判断中同样起着决定性作用。

连续座位的起点枚举条件j + p <= COLS值得单独说一下。当p等于5时,j只能取0,这意味着只有整排全空才能满足条件;当p等于1时,j可以从0取到4,任何一个空座位都是"连续1个空位",判断自然恒成立。这个条件精确地表达了"从起点开始数p个座位不能越界"的约束。

座位编号i * COLS + j + k + 1是核心换算公式。i是排号,j是连续区间的起点列号,k是在这p个连续座位内的偏移量。每分配一个座位,就立刻用这个公式算出真实座位号并输出。代码里加1是因为真实座位从1开始编号,而行列下标从0开始。

第二优先级中的cnt是计数器,作用是保证凑满p个座位就停。每分配一个空座,cnt加1,同时cnt < p作为双重循环的终止条件之一。这样写的好处是:即使座位很多,只要本次已经分够p张票,就立刻退出全部扫描。

3.3 考场速写版思路

如果觉得上面这版写得有点"重",可以记住一个更精简的核心骨架:对于每个请求,先两重循环找连续座位,找到就更新输出;没找到就从左上到右下扫空位,边扫边分配。很多同学喜欢把第一优先级封装成一个函数,返回能否分配。我的建议是,考场时间紧张,直接在main里写两个循环反而更不容易出现函数参数传递的错误。

4. 自测用例与手算推演

代码写完不能直接交,至少要跑几个用例确认逻辑没问题。下面我给两组自测用例,一组是普通连续分配,一组专门用来验证第二优先级。

4.1 普通用例推演

输入:

4 4 1 3 2

这个用例刚好覆盖了"第一排连续分配、第一排剩余单座、换排分配"的情况。推导过程如下:

第一次请求买4张,第1排1到4号全空,输出1 2 3 4。 第二次请求买1张,第1排还剩5号一个空位,输出5。 第三次请求买3张,第1排已满,检查第2排,6到8号三个连续空位,输出6 7 8。 第四次请求买2张,第1排已满,第2排还剩9和10两个连续空位,输出9 10。

跑完这个用例,程序输出应该是4行,每行正好对应上面的结果。如果输出对不上,优先检查座位编号换算公式i * COLS + j + 1是不是写错了。

4.2 专门触发第二优先级的用例

第二优先级是很多人没认真测过的分支。构造一个能强制触发它的用例:连续输入40条"买2张票"的指令,再加1条"买3张票"的指令。

前40条指令会把20排座位都占掉4个座位,即每排只剩最后一个座位空着。具体的分配过程是:每排的前两个请求各占2个连续座位,例如第1排先占1、2,再占3、4,剩下5;第2排先占6、7,再占8、9,剩下10;以此类推。40条指令过后,20排的剩余空座分别是5、10、15、……、100。

第41条指令买3张,此时每一排只剩下1个空座位,根本凑不出3个连续空位。第一优先级必然失败,进入第二优先级后,从1号开始找空座,找到的是5、10、15三个座位,输出5 10 15。

这个用例的价值在于:如果你写的程序在第二优先级阶段输出的是别的组合,比如出现了6、7、8这种座位号,说明你的第二优先级没有从全局最小座位开始扫描,而是错误地接着第一优先级失败的位置继续找了。

4.3 边界用例自查清单

除了上面两组,我建议交卷前再花一分钟跑这几个极端情形:

  1. 全部买1张票,共100条请求。此时输出应该恰好是1到100,每个数字单独一行。
  2. 买5张票的请求。第一次输出1 2 3 4 5,第二次输出6 7 8 9 10,验证整排分配逻辑。
  3. 第一排占满后,第二排还剩几个零散座位,再买超过剩余连续座位数的票,验证换排和兜底分支。

这些用例不用全部手算输出,只要能跑通、肉眼观察分配符合规则即可。

5. 常见错误与考场排查技巧

火车购票这道题,我在社区里见到过很多种失分姿势。这里我整理了一个高频错误速查表,基本都是过来人踩过的坑。

5.1 高频失分点速查表

错误现象根本原因解决办法
分配出来的座位明明不相邻,却满足第一优先级用"该排剩余空位数 >= p"判断是否连续必须逐列检查连续p个座位的状态,而不是只数空位个数
一维数组下标越界或跨排被算作连续没有处理"连续座位不能跨排"的边界改用二维数组,或在起点枚举时加上j + p <= 5的判断
第二优先级输出的不是最小空位失败后没有从座位1重新扫描,而是接着上次的位置找第二优先级必须用独立的双重循环,从第0排第0列重新开始
输出结果整体缺1或多1座位编号公式写成了i*5+j而非i*5+j+1用样例输入的第一次输出验证编号,1到5号对应第一排
行尾多了一个空格输出循环里无条件在两数之间打了空格用if (k > 0) cout << ' ';控制,只在第二个及之后的数字前加空格
某个请求莫名少输出或重复输出assigned标志没有在成功分配后及时置true每次成功分配后立即assigned = true,并在外层循环条件中加入!assigned

其中最阴险的错误是第一种。它表面看起来输出也有座位号,甚至某些用例能碰巧通过,但只要构造一个"剩余空位数够、但空位不连续"的用例,就会立刻暴露。判断连续的方法必须是对具体座位位置做扫描,不能只看剩余数量。

5.2 考场上如何快速定位规则理解偏差

如果在考场上提交后分数不对,但编译运行都没有报错,问题基本就出在规则理解上。我个人的排查顺序是:

先看第一优先级。拿样例输入推演一遍,手写出每一排座位的占用过程,再对照程序输出的中间步骤,找出第一个不一致的地方。一般在这个环节就能定位是"连续判断"写错还是"编号最小"没满足。

再看第二优先级。构造一个强行触发第二优先级的用例,比如上面的"40个2票+1个3票",跑一遍。这一步主要验证兜底分支有没有从头扫描、是否输出了全局最小的那几个空座位。

最后检查输出格式。CCF评测对行尾空格通常不敏感,但害人之心不可有,规范一点总没坏处。每题交卷前,刻意检查一下循环输出空格的条件,能省掉不必要的警惕。

5.3 把这道题抽象成通用模板

火车购票这类"规则模拟题",本质上都可以归纳成四步:第一步,设计状态的数据结构,把题目中的"车厢""座位""排"映射成数组;第二步,拆解分配规则,明确优先级顺序;第三步,按优先级写出干净的分支逻辑;第四步,在更新状态的同时完成输出。

我在准备CCF-CSP的过程里发现,很多模拟题都是这个套路,只是包装不同。有的题把座位换成了停车场车位,有的把购票请求换成了停车场门禁记录,但核心逻辑大同小异:状态更新要准确,优先级判断要严谨,边界条件要考虑周全。

这道火车购票,我前前后后刷了三遍。第一遍看思路,第二遍练手速,第三遍用来给同学讲题,每次都有新的收获。如果你也在备考CCF-CSP,强烈建议把这道题作为入门模拟题反复吃透——它几乎囊括了规则模拟题的绝大多数坑点,把它的解法刻进脑子里,后面遇到更复杂的模拟场景,你会感谢今天这份认真。

RELATED READING

延伸阅读

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