CCF CSP认证里的第一题,很多考过的人都说它是“送分题”,但送分题在考场上翻车的案例每年都不少。火车购票这道题,算是我见过最典型的“规则简单但细节众多”的模拟题:一列火车20排座位,每排5个,订单要求尽量连坐,处理不好的人,轻轻松松就丢三四十分。最初我在模拟环境里写这一题时,第一版代码套用了当时刚学的一个数据结构,结果绕来绕去把自己绕晕了;后来把题目规则拆开,用最朴素的数组模拟,反而一次跑满分。今天把完整的解题思路、带注释的C语言/C++满分代码,以及我调试过程中踩过的坑全部整理出来,给准备CCF认证或者正在刷算法入门题的朋友做个参考。
1. 题目解读:先把购票规则彻底吃透
1.1 “同排优先”到底是什么意思
很多人在这一步就理解错了。题目构建的场景是:一列火车有20排座位,每排5个,座位号从1到100连续编号。乘客的订单是一次性购买p张票(1≤p≤5),系统给票时要尽量让这p个人坐在一起。
关键规则有两条,优先级还不一样:
- 优先在某一排的剩余座位中,找出连续p个空座位,一次性分配;
- 如果没有任何一排能一次给出p个连续空座,才退而求其次,从座位号最小的空座开始,逐个补足p个座位。
说白了,就是“同排连续是第一选择,全局补位是兜底方案”。很多人一开始只记住了“连续”,忽略了“同排”这个限定,以为只要最终输出的座位号连续就行。比如买6张票时,有人会认为输出1、2、3、4、5、6就已经满足“连续”了,但题目要求的是同一排内的连续座位,每排只有5个座位,6张票不可能在同一排内完成,必须走全局补位。这道题的判分数据就喜欢在这种地方挖坑。
理解这个规则之后再来看:一次购票其实只有两种结算方式。要么找到一排剩余座位数不少于p,把从该排第一个空座开始连续p个座位全给这个订单;要么全局扫一遍所有空座,按照座位号从小到大补齐。不存在第三种“先在一排凑几个、再到另一排凑几个但依然算同排优先”的中间状态。这个认知直接决定了后面代码的分支结构。
再举一个容易绕进去的例子:假设第一排只剩1个空座,第二排全空,此时买3张。按规则,第一排虽然空座编号最小,但装不下3个人,所以直接跳过第一排,去第二排分配3张。系统不会因为第一排那1个座位编号小就优先把它塞给订单。这就是“同排优先”优先级高于“编号最小”的体现。
1.2 数据范围只有100,直接模拟就够
题目给的约束通常是n不超过100,每张票最多买5个,座位总数固定是100。在这种数据范围下,最简单的模拟就是最快的。有人看到“100分代码”就觉得需要用线段树、平衡树、优先队列之类的数据结构来写,但实际数据量根本撑不起这种复杂度焦虑。
最直接的模拟方案是:用二维数组记录每一排每一个座位的占用状态,再用一个一维数组记录每一排已经卖出了多少个座位。每一单来了之后,先从第0排到第19排找“剩余座位数不少于p”的排;找不到,就按座位号从小到大扫描空座补足。时间复杂度最多是O(100×100),也就是1万次基本操作,现代计算机在1秒的时限内跑完毫无压力。
写代码前先看数据范围,这是做OJ和认证题的基本功。数据范围小的时候,优先选择逻辑简单、不容易出错的写法,而不是看起来高级但调试成本高的写法。这道题用数组模拟已经绰绰有余。
2. 方案选型:二维数组加计数,为什么最稳
2.1 两份记录各司其职,缺一不可
我最后提交的满分版本里,数据结构只有两个:
- bus[20][5]:20排5列,记录每一个座位的占用状态,0表示空座,1表示已售;
- count[20]:记录每一排已经售出的座位数量。
可能有人会问:已经有bus数组了,count数组不是冗余吗?还真不是。同排分配时,如果去扫描bus[i][0]到bus[i][4]找连续空座,代码会多一层判断;更关键的是,用count[i]可以直接判断“这一排还剩多少个座位”,这个值就是5 - count[i]。只要5 - count[i] >= p,这一排一定存在从count[i]开始的连续p个空座,不需要再一个个找。
为什么这么肯定?因为在这道题的分配规则下,每一排的空座永远不会分散在中间,而是全部聚在尾部。这个看似隐性的性质,其实是整个算法的基石。
2.2 关键不变量:空座永远聚在排尾
让我们严格想一想为什么空座会一直聚在排尾。
同排分配时,起点是count[i],也就是这一排第一个空座的下标,分配之后,占用的依然是从最左边开始的一段连续座位,空座继续从新的count[i]开始到最末尾,依然是连续尾部。
全局分配时,按座位号从小到大扫描空座。假设某个时刻某一排的中间有空座,那它的编号会比同排尾部某些座位更小,全局扫描会先碰到这些更小编号的座位并把它分配掉,所以中间空座会被频繁地“吃掉”,留下的仍是尾部连续空座。
两个分支都不会破坏“空座集中在尾部”的性质。所以count[i]不仅表示已售数量,还直接表示这一排第一个空座的下标。这个不变量,让同排分配的代码可以写成O(1)定位,也让全局分配可以放心地“从count[i]开始”而不是从0开始。
2.3 不变量在代码里的实际价值
理解了不变量之后,代码写起来会非常干净。同排分配时,直接这样定位:
这一排第一个空座的座位号 = i * 5 + count[i] + 1然后向后连续输出p个即可,不需要在bus数组里搜索空位。
全局分配时,也可以利用这个性质:第i排的空座从count[i]列开始。输出座位号时只要按排扫描,从count[i]到第4列逐个分配,就能保证输出顺序是严格升序的。如果不用这个性质,就得写一个if (bus[i][j] == 0)的判断,也不是不行,但代码会多一层分支,理解成本也更高。
不过我要提醒一句:不变量是算法层面的结论,代码层面依然要更新bus数组和count数组,让两个数据结构保持同步。只更新一个、忽略另一个,后面就会产生“幽灵座位”——明明count说这排还剩多少空座,bus却显示已经售出,或者反过来。主版本代码用bus判空的写法对初学者更友好,遇到bug也容易加打印排查;进阶版本只用count数组,代码更精简,但前提是你真的吃透了不变量。
3. 满分代码逐段拆解(带注释)
3.1 主版本:二维数组加计数,易读好查错
这里给出我提交时用的主版本。C语言语法,C++环境下也能直接编译运行,用来自测和交题都没问题。
#include <stdio.h> int main() { int n, k; // bus[i][j]:第 i 排第 j 个座位,0 表示空座,1 表示已售 int bus[20][5] = {0}; // count[i]:第 i 排已售出的座位数量 // 同时也是第 i 排第一个空座的下标(空座永远集中在排尾) int count[20] = {0}; scanf("%d", &n); while (n--) { scanf("%d", &k); // 本次购票数量,1 <= k <= 5 int found = 0; // 标记这一单是否已经在同一排内完成分配 // 第一步:尝试在同一排内分配连续的 k 个座位 for (int i = 0; i < 20; i++) { // 第 i 排剩余座位数 = 5 - count[i],够就给 if (5 - count[i] >= k) { // 从该排第一个空座开始连续输出 k 个 for (int j = 0; j < k; j++) { int seat = i * 5 + count[i] + j + 1; printf("%d", seat); // 标记已售,保证全局补位时 bus 数据正确 bus[i][count[i] + j] = 1; // 行内不是最后一个数字时,补一个空格 if (j != k - 1) { printf(" "); } } printf("\n"); count[i] += k; // 同步更新该排已售数量 found = 1; break; // 同一排分配成功,必须跳出排循环 } } // 第二步:没有任何一排能装下 k 个连续空座,只能全局补位 if (!found) { int need = k; // 还需要找的座位数 for (int i = 0; i < 20 && need > 0; i++) { for (int j = 0; j < 5 && need > 0; j++) { if (bus[i][j] == 0) { // 找到空座 int seat = i * 5 + j + 1; printf("%d", seat); bus[i][j] = 1; // 占用 count[i]++; // 已售数量加 1 need--; // 还差一个座位 if (need > 0) { // 后面还有座位要输出时补空格 printf(" "); } } } } printf("\n"); // 输完一整行再换行 } } return 0; }注意几个容易被忽略的地方:同排分配时,bus[i][count[i] + j] = 1这个赋值必须做,否则全局补位阶段会重复分配这一排的座位;count[i]的更新放在一整段分配结束后再做,是为了让循环里的座位号计算保持一致;found变量和break缺一不可,否则会导致同一订单被拆到多排。
3.2 手动走一遍用例:下标和座位号怎么对上
光贴代码不够,我带你手动跑一组样例,看看下标和座位号是怎么对应的。
假设第一单买了3张票。程序扫描20排,第0排5 - count[0] = 5 >= 3,于是从第0排第0列开始分配。座位号是05+0+1=1、05+1+1=2、0*5+2+1=3,输出1 2 3。count[0]变成3,bus[0][0]、bus[0][1]、bus[0][2]标记为1。
第二单还是买3张。扫描到第0排,5 - 3 = 2,不够3张,跳到第1排,5 - 0 = 5 >= 3,分配座位6、7、8。注意,第1排第一个座位号是6,因为1*5+0+1=6。输出6 7 8。count[1]变成3。
第三单买5张。第0排还剩2个座,不够;第1排还剩2个,不够;第2排全空,够,直接分配11、12、13、14、15。count[2]变成5。
到这里你会发现,同排分配永远从每排的第一个空座开始,不会跳着分配。这种“把空座往排尾推”的局面,正是刚才说的不变量。
再构造一个必须全局补位的场景:假设所有排的剩余空座都少于当前订单需要的数量,此时来一单买5张。程序进入全局分支,从第0排第0列开始逐格扫描bus,遇到空座就输出,直到凑够5个为止。因为扫描顺序是排内从左到右、排间从上到下,输出的一定是全局最小的5个空座号。
3.3 进阶版本:只用count数组,代码更精简
如果你已经理解“空座永远聚在排尾”这个不变量,其实连bus数组都可以省掉。第i排的第一个空座下标就是count[i],空座范围是count[i]到4。
#include <stdio.h> int main() { int n, k; // count[i]:第 i 排已售座位数,也是该排第一个空座的下标 int count[20] = {0}; scanf("%d", &n); while (n--) { scanf("%d", &k); int found = 0; // 同排优先 for (int i = 0; i < 20; i++) { if (5 - count[i] >= k) { // 这一排剩余座位足够 for (int j = 0; j < k; j++) { printf("%d", i * 5 + count[i] + j + 1); if (j != k - 1) { printf(" "); } } printf("\n"); count[i] += k; found = 1; break; } } // 全局补位 if (!found) { int need = k; for (int i = 0; i < 20 && need > 0; i++) { // 第 i 排的空座从 count[i] 开始 for (int j = count[i]; j < 5 && need > 0; j++) { printf("%d", i * 5 + j + 1); need--; if (need > 0) { printf(" "); } count[i]++; // 同步更新,下一个空座起点自然右移 } } printf("\n"); } } return 0; }这个版本更短,运行速度也更快。但如果你对不变量没把握,考试时更推荐主版本,因为bus数组可以随时用来校验状态,排查bug时多一个参照物。
4. 从30分到100分:踩坑实录与排查技巧
4.1 坑1:全局分配时没按升序输出
全局补位有一个隐藏要求:输出的座位号必须从小到大排列。有些同学看到“从编号最小的空座开始”,就以为只要找到几个空座就行,没有保证遍历顺序。
举一个错误例子:某人的代码在“同排失败”后,不是从第0排开始扫描,而是从上次成功分配的那一排开始往后找,导致输出的是“第二排的空座、第三排的空座”,而不是全局真正的最小空座。还有的人把排循环写反了,从最后一排往前扫,输出的座位号是降序,直接判错。
修复方法很简单:全局分支的双层循环必须保证i从0到19、j从0到4,这样扫描自然覆盖所有座位,输出的顺序天然就是座位号升序。
4.2 坑2:同排分配成功后漏了break
这是模拟题里最经典的翻车点。同一排内成功分配k个座位后,如果不写break,外层for循环会继续往后扫描,导致后续排的空座也被塞进同一个订单。最典型的症状是:一次买票却输出两段不连续的座位号,比如“1 2 3 6 7 8”,或者输出结果把后续排的座位也一起打印了。
为什么有时候本地测试看不出问题?因为如果后面的排恰好没有空座,或者测试数据刚好是单次购票,错误就不会触发。一旦连续出现多个订单,第二次购票时状态已经被污染,输出立刻乱套。
修复方法就一句话:found = 1之后马上break。同时建议在写代码时把found这个标志变量当成“订单是否已经结算”的信号,全局分支只处理found为0的情况。
4.3 坑3:输出行尾多了一个空格
CCF评测对输出格式比较严格,行尾多一个空格可能会被当成格式错误。有人习惯写成printf("%d ", seat),每个座位号后面都跟空格,最后一行末尾就会多一个空格。虽然有些评测系统会忽略行尾空白,但不要赌这种运气。
统一的干净写法是“先输出数字,再判断是否需要空格”。主版本里用的if (j != k - 1)判断,以及iframe中用的if (need > 0)判断,都是这个思路。关键点在于:先输出内容,再决定要不要补分隔符,而不是先补分隔符再输出内容。
4.4 常见问题速查表
| 症状 | 可能原因 | 修复方式 |
|---|---|---|
| 一次购票却输出两段座位号 | 同排分配成功后漏写break,订单被拆到多排 | found = 1后立即break |
| 全局补位输出的座位号不是从小到大 | 排循环或列循环的起始位置不对 | 统一i从0到19、j从0到4扫描 |
| 输出行尾多一个空格 | 每个数字后都固定输出空格 | 先输出数字,判断不是最后一个再补空格 |
| 座位号对不上,差了很多 | 把20排5列写成了5排20列,座位号映射公式错误 | 使用 i * 5 + j + 1,第1排永远是1到5 |
| 同一排明明有空座却跳过 | 只更新了bus没更新count,或者反过来 | bus和count每次分配后必须同步更新 |
5. 举一反三:这套模拟思路还能用到哪
5.1 同型场景:停车场与会议室预约
火车购票看起来是个孤立题目,但它的模型在现实里非常常见。拿停车场来说,一个停车场有几块区域,每块区域有固定数量的连续车位,几个同行的人希望把车停在相邻位置。系统分配时,先找一块剩余空位大于等于车辆数量的区域,如果没有,再按车位编号从小到大分散停放。这个逻辑和火车购票几乎一模一样。
会议室预约也是类似。一个会议室有若干排座位,多人报名同一个会议时,系统优先把他们安排在同一排;排不够坐,再打散到其他排的空位。理解了火车购票的“同排优先、全局补位”之后,这类需求都可以用同样的代码框架去改。
5.2 数据量变大之后的优化方向
如果题目条件改成有m节车厢,每节车厢有r排座位,m和r都很大,比如10万节,那逐排扫描的O(m×r)就不太行了。这时候可以维护一个“每排剩余座位数”的排序结构,同排分配时快速定位剩余座位数最多的排;全局补位时用一个优先队列维护所有空座,按座位号从小到大取。
这是从“暴力模拟”到“数据结构优化”的经典升级路径。火车购票原题数据量小,不需要这些,但如果你在刷更难的题,可以把这条线留下来,作为扩展思考方向。
5.3 做模拟题的通用心法
模拟题的核心就两步:状态表示和状态转移。火车购票里,状态就是bus数组和count数组;状态转移就是每一单到来时,“先找同排、再全局补位”的分支流程。
我建议做题顺序是:先把业务规则写在草稿纸上,标清优先级;再根据数据范围确定用什么复杂度;然后手动推一遍样例,把每一步输出写下来;最后写代码,并且自己造几组边界数据去测。比如买5张、连续买票、把整列车填满后再买票,这些边界情况最容易暴露代码里的问题。
这道题我前前后后给备考的朋友讲过很多遍,每一次讲完都会发现,真正难住人的不是C语言的语法,而是“同排优先”这个业务约束怎么落成代码分支。你如果也是第一次刷CCF第一题,建议先别急着看题解,按上面的思路自己在纸上画一个20×5的格图,模拟几次购票,再对照代码里的count数组体会一下“空座聚在排尾”这个性质。搞懂这一点之后,你会发现火车购票这一类模拟题都变得亲切了许多。希望这篇内容能帮你少踩几个坑。