PTA L1-049 天梯赛座位分配,是我印象里特别适合拿来练习“模拟”的一道题。它的代码量不长,思路一讲就懂,但真正提交时,第一次能拿到满分的人并不多。原因很简单:题目把分配座位的过程描述得越贴近现实,落到程序里需要处理的边界就越多。这篇文章就以这道题为例,完整拆解规则、模拟过程、完整代码和常见坑,给正在刷团体程序设计天梯赛题单的同学做参考。
如果你刚接触这类“规则模拟”题目,可能会觉得它不如那些算法题有逼格,但恰恰是这种题最能暴露代码基本功。状态记录、循环终止、输出格式,任何一个细节没处理好,评测结果就是一片红色。接下来我按照自己的理解,把这道题从规则到代码完整过一遍。
1. 题目到底在考什么——规则还原与需求拆解
1.1 天梯赛为什么非要“隔座”?
团体程序设计天梯赛的参赛队伍来自不同学校,同一个学校的选手通常希望坐在一起方便沟通,但竞赛规则又明确要求同一个学校的队员不能坐相邻座位。这个需求在现实里很好理解:避免同伴之间交头接耳,保证比赛公平。
这道题把座位分配规则抽象成了这样一句话:所有参赛学校按编号顺序依次分配座位,每个学校有若干支队伍,每队三人。如果处理到某个时刻,只剩下一个学校还有队员没有分配,那么这个学校每继续分到下一个座位之前,都要先空出一个座位,防止该校前后两个队员坐到相邻位置。
这个“只剩一个学校就空一格”的规则,就是整道题唯一的难点。你把它理解成什么?我的理解是,多所学校同时竞争座位的阶段,每个学校天然被其他学校隔开;而最后只剩一个学校时,没有别人帮它“隔开”了,只能自己手动跳过一个座位号。
1.2 输入输出到底长什么样
先看输入格式。第一行给一个正整数 N,表示参赛学校数量。第二行给出 N 个正整数,第 i 个数表示第 i 个学校的队伍数量。因为每支队伍固定 3 人,所以第 i 个学校实际参赛人数是 M[i] * 3。
输出格式才是很多人的噩梦。需要按照学校编号从小到大输出,每个学校占若干行,每个学校每个队伍占一行,一行内输出该队伍三名队员的座位号,用空格分隔,行尾不能有空格。不同学校之间要输出一个空行隔开。
我用一个例子直观感受一下。假设输入:
3 2 1 1意味着学校 1 有 2 支队伍共 6 人,学校 2 有 1 支队伍共 3 人,学校 3 有 1 支队伍共 3 人。最终输出应该是:
1 4 7 10 12 14 2 5 8 3 6 9注意第一行和第二行之间是空行,这代表学校 1 的两个队伍之间的换行,而第二个单位和第三单位之间那个空行是学校间的分隔。如果你把空行位置弄错,或者行尾多了空格,评测系统会直接报“格式错误”。
1.3 为什么这道题被称为“模拟题”
所谓模拟题,就是不需要你设计什么高深算法,核心任务就是把现实规则一步一步翻译成代码。L1-049 的难点在于这个翻译过程不能有歧义,任何一个边界条件没有照顾到,结果就会偏离真实场景。
具体来说,容易出错的点集中在三处。第一,状态记录:你至少要知道每个学校还剩多少人没分配;第二,循环终止:所有学校的有效队员都分配完成后,循环必须结束;第三,输出格式:每行 3 个座位号、行尾不能有空格、学校之间要有空行。这三个问题解决了,整道题就解决了八成功力。
2. 核心思路:把自己当成发座位的工作人员
2.1 一个座位一个座位发的朴素模型
我先放下代码,讲一个接地气的类比。假设你是比赛现场的引导员,手里有一沓从 1 开始编号的座位票。你按照学校编号从 1 到 N 挨个叫名字,叫到一个学校,就发一张当前编号最小的票,然后把这张票记录下来,再叫下一个学校。每个学校剩的人被叫完了,你就不叫它了。等所有学校的人都拿到票,分配结束。
这个场景翻译成程序,就是一个循环套循环的结构:外层是一个“轮次”的持续过程,内层是“从学校 1 到学校 N 挨个检查”。每一轮里,只要某个学校还有人没分配,就给它发一张票。
2.2 关键判断:什么时候需要跳过座位
现在回到那个“隔座”问题。在多个学校同时还有人时,座位号自然被不同学校交叉占用,所以相同学校的队员之间必然夹着别人,不需要额外处理。真正需要处理的是最后那个“孤家寡人”。
举个例子,A 校和 B 校比赛人数不同。前面几轮大家交叉入座,等到 B 校所有人都分配完了,只剩 A 校还有 4 个队员没入座。上一次 A 校队员坐的是 10 号座位,如果这次直接分配 11 号,那 A 校这两名队员就坐到了相邻座位,违反了规则。所以正确操作是:先把 11 号跳过,让 A 校下一个队员坐 12 号,之后再次轮到 A 校时再跳过 13 号,坐 14 号。
这也解释了为什么很多同学第一次写出来的代码,样例能过,一提交就错。因为样例里往往最后一个学校人数多,结构比较规整,但你自己构造一个“最后一个学校人数少且分散”的用例时,不跳座的问题就暴露了。
2.3 用 last 变量判断“只剩一校”的巧妙之处
那么代码里怎么判断“当前只剩一个学校有人”呢?最简单的方案是维护一个计数器,每轮统计还有剩余人数的学校数量,等于 1 就跳座。这个思路没错,但实现起来要额外维护变量。
还有一种更隐蔽也更省事的写法:记录上一次分到座位的是哪个学校,用last变量保存。如果本次轮询时发现某个学校还有剩余人数,而且last刚好等于这个学校,说明上一个拿到座位的人和本次拿到座位的人来自同一所学校。而在按编号顺序轮转的规则下,这种情况只会发生在其他学校都已经分配完毕的时候。
为什么?因为只要还有其他学校有人,每轮轮询一定会在中间插进其他学校的人,上一次不可能连续是同一个学校。所以“上一次分配学校 == 本次分配学校”这个条件,本质上就等价于“只剩这一个学校了”。这个条件一旦成立,就先让座位号加一,把这个空座跳过去。
3. C++ 完整实现与关键代码逐段拆解
3.1 完整可提交代码
我直接给出一份能过题的核心代码,代码部分用 C++ 实现。这套写法比较简洁,也比较好理解。
#include <bits/stdc++.h> using namespace std; const int MAXN = 105; int n; int teams[MAXN]; // 每个学校的队伍数 int rest[MAXN]; // 每个学校剩余未分配人数 vector<int> seat[MAXN]; // 第 i 个学校拿到的座位号列表 int main() { scanf("%d", &n); int total = 0; for (int i = 0; i < n; i++) { scanf("%d", &teams[i]); rest[i] = teams[i] * 3; total += rest[i]; } int cur = 1; // 当前可用的座位号 int last = -1; // 上一次分配到座位的学校编号,-1 表示还没有分配过 while (total > 0) { for (int i = 0; i < n; i++) { if (rest[i] == 0) { continue; } if (last == i) { cur++; } seat[i].push_back(cur); cur++; rest[i]--; total--; last = i; } } for (int i = 0; i < n; i++) { int idx = 0; int teamCount = teams[i]; for (int j = 0; j < teamCount; j++) { for (int k = 0; k < 3; k++) { if (k) { printf(" "); } printf("%d", seat[i][idx++]); } printf("\n"); } if (i != n - 1) { printf("\n"); } } return 0; }这段代码的核心是while (total > 0)加for (int i = 0; i < n; i++)的双重循环。外层循环保证只要还有人没分到座位,就继续整个轮询过程;内层循环则模拟引导员从学校 1 问到学校 N 的过程。
3.2 数据结构:vector 存座位号
我选择vector<int> seat[MAXN]来存储每个学校拿到的全部座位号。这个选择有几个好处。
第一,不同学校的人数不同,用vector就不需要事先算最大容量,分配多少存多少,代码可读性更高。第二,题目要求每个学校按队伍输出,每队 3 人,而seat[i]中记录的正好是按分配顺序排列的队员座位号,只要把seat[i]按下标每 3 个切一段输出即可。第三,vector的push_back操作天然支持动态追加,让分配过程的代码和真实发座位的动作一一对应。
如果非要用固定二维数组,也不是不行,但你得额外开一个int seat[105][305]这样的结构,还得用一个int count[105]去记录每个学校当前已经存了多少个座位。这样写当然没问题,但对初学者来说,多维护一套计数索引,出错概率会上升。
3.3 三层循环输出
输出部分看起来简单,实际上有三个需要小心的位置。
最外层循环按学校编号输出,编号从 0 到 n-1。中间层循环按队伍数量输出,队伍数量由输入时的teams[i]决定。最内层循环固定输出 3 个座位号,因为每队固定 3 人。
在中间层循环里,我使用了一个idx指针指向seat[i]当前应该取出的座位位置。每输出一个座位号,idx就加 1。这样seat[i]中存的 6 个座位号,第 0 到第 2 个是队伍 1 的三名队员,第 3 到第 5 个是队伍 2 的三名队员,以此类推。
行尾空格的控制方式是在每个数字前判断是否为该行的第一个数字,是就不输出空格,不是就输出一个空格。这种“先空格后数字”的写法,比“数字后空格再判断末尾”要安全得多,可以彻底避免行尾多一个空格的尴尬。
3.4 手算样例验证分配过程
我拿前面那个样例再推演一遍,方便你对照代码看过程。N=3,teams 数组是 2、1、1,所以 rest 数组初始是 6、3、3,total 初始是 12。
第一轮循环,学校 0 剩余 6 人,last 是 -1 不等于 0,直接分配座位 1,last 更新为 0。学校 1 剩余 3 人,last 是 0 不等于 1,直接分配座位 2,last 更新为 1。学校 2 剩余 3 人,last 是 1 不等于 2,直接分配座位 3,last 更新为 2。
第二轮循环,学校 0 分配座位 4,学校 1 分配座位 5,学校 2 分配座位 6。这时 rest 数组变成了 4、1、1,total 还剩 6。
第三轮循环开始前,last 是 2。学校 0 的 last 不等于 0,分配座位 7。学校 1 的 last 不等于 1,分配座位 8。学校 2 的 last 不等于 2,分配座位 9。此时 rest 数组变成了 3、0、0,total 还剩 3。
第四轮循环,学校 0 的 last 当前是 2,不等于 0,所以正常分配座位 10。之后 rest[0] 变成 2,total 还剩 2,last 更新为 0。后面学校 1 和学校 2 都已经没人了,直接跳过。
第五轮循环,学校 0 剩余 2 人,last 是 0,等于 0,触发空座逻辑:cur 先加 1,把 11 空出来,然后分配 12。接着 rest[0] 变 1,total 变 1,last 仍是 0。
第六轮循环,学校 0 剩余 1 人,last 还是 0,继续触发空座逻辑:cur 先加 1,把 13 空出来,然后分配 14。分配完 total 变 0,循环结束。
于是学校 0 的座位列表是 1、4、7、10、12、14。输出时队伍 1 是“1 4 7”,队伍 2 是“10 12 14”。学校 1 是 2、5、8,学校 2 是 3、6、9。你可以看到,最后只剩学校 0 时,10 和 12 之间隔了 11,12 和 14 之间隔了 13,完全满足同一学校不相邻的要求。
4. 踩坑实录:那些让我卡了很久的细节
4.1 last 变量到底记录的是什么
这个坑我相信很多人踩过。last记录的应该是“上一次真正分到座位的学校编号”,而不是“上一次遍历到哪个学校”。
代码里last = i这一行,只能放在成功分配座位之后,不能放在continue之外。如果你一时手滑,把last = i写在了for循环的末尾,也就是不管rest[i]是否为 0 都执行,那就会导致last总是被最后一个非空学校覆盖。举个例子,假设学校 0 没人了,学校 1 还有人,遍历到学校 0 时 rest 为 0 跳过了,但因为last = i放在了循环尾部,last 可能被更新成 0。这样之后判断last == i时就会出错,要么过度跳座,要么漏掉跳座。
正确写法是只在seat[i].push_back(cur); rest[i]--; total--;之后才更新last = i。也就是说,last要反映真实发座动作的接收方。
4.2 空座的位置:先跳跃还是后跳跃
if (last == i) cur++;必须放在取座位号之前。很多同学会写成这样:
if (last == i) { seat[i].push_back(cur); cur += 2; }这种写法的错误在于,当需要跳跃时,当前这个座位号本身还是被同一学校的上一个人占用了,没有任何间隔。比如 last 为 0,cur 是 11,本来应该空出 11,把 12 给学校 0,但这种写法会把 11 直接给学校 0,等于没隔开,然后 cur 从 13 开始下一轮,反而多跳了一个。
所以直觉上“先让座位号前进一位,再取号”才是对的。这个顺序非常关键,可以用一句话记:跳跃发生在当前学校“坐下”之前,而不是之后。
4.3 输出格式:行尾空格与学校间空行
这道题对输出格式要求相当严格。如果你的代码在行尾多输出一个空格,PTA 通常会报“Presentation Error”,扣分但不至于全错。如果是空行数量不对,那就更麻烦,可能直接算你答案错误。
我习惯的做法是“每行开始前判断是否需要空格”,也就是先输出第一个数字,之后每个数字前补一个空格。这样不会出现行尾空格。学校之间的空行,我建议在输出完整所学校的所有队伍后再补一个回车。也就是说,当i != n - 1时,额外输出一个\n。
还有一点,最后一个学校的最后一行末尾要不要换行,其实不影响通过。但我个人会保留最后的换行,这样和其他题目的输出习惯保持一致。
4.4 “只有一个学校”要不要特判
这个问题经常出现在讨论区。很多人害怕 N=1 时规则不好处理,于是单独写了一堆特判。其实使用last == i判断后,N=1 的情况是天然兼容的。
试想 N=1,学校 0 有 6 人。第一次循环,last 是 -1,不等于 0,所以正常分配座位 1,last 变成 0。第二次循环,last 是 0,等于 0,触发跳座,分配座位 3。之后每轮都是“先跳一格,再坐人”,最终座位号是 1、3、5、7、9、11。这个结果完全符合只有一个学校时每个队员之间需要隔开一个座位的规则。
所以不需要额外写“if (n == 1)”这种特殊分支。加了反而容易把逻辑搞乱。
4.5 死循环排查
如果 while 循环条件写成while (true),而内部又没有及时更新total,就可能出现死循环。常见错误是把total--漏写在某条分支里。
我用的是while (total > 0),并且保证每次分配座位后total--,所以一旦 total 归零,外层循环立刻退出。调试时如果发现程序跑不完,优先检查rest[i]--、total--和last = i这三行是否都执行到了。
4.6 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 最后一个学校相邻座位没有隔开 | last == i判断写错,或cur++放在取号之后 | 把跳跃逻辑移到取号之前 |
| 输出后比预期多了一堆数字 | 没有控制好 total 循环次数,rest 更新遗漏 | 确认 rest[i]-- 和 total-- 成对出现 |
| 一提交就是格式错误 | 行尾空格或学校间空行不对 | 使用“先空格后数字”输出,学校间单独补空行 |
| 多个学校时也有相邻同校座位 | 可能是重复分配了座位,last 更新位置错误 | 检查 last 是否只在分配座位后更新 |
5. 从这道题看模拟题的通用套路
5.1 模拟题的三件套:状态变量 + 主循环 + 边界
刷过一定量的题之后你会发现,模拟题几乎都长一个样:给你一套现实规则,让你用程序完整走一遍过程。无论题目伪装成“排队叫号”“轮转调度”还是“天梯赛座位”,底层结构都离不开三样东西。
第一,状态变量。你必须清楚整个过程有哪些需要持续跟踪的数据。在这道题里,状态变量是rest[]、total和last。rest[]记录每个学校剩余人数,total记录所有学校剩余总人数,last记录上一次分配给了谁。少一个状态,模拟就无法继续。
第二,主循环。主循环负责让时间轴前进。这里用的是“轮询所有学校”的for循环,外层再用一个while判断是否还有未分配的人。很多模拟题的主循环形式不同,有的是“队列直到为空”,有的是“事件驱动”,但本质都是让过程推进。
第三,边界条件。边界条件往往藏在题目的“最后只剩下一个学校”这类描述里。读题时稍微不留神,等你写完代码后才发现,样例过了,隐藏边界却炸了。
5.2 写模拟题时如何减少调试时间
我的经验是,不要一上来就写完整代码。先拿一个很小的输入,比如 N=2,每队 1 人,在纸上手推一遍座位号。然后拿 N=1 和一个学校队伍特别多的用例,分别手推一遍。确认自己理解了规则,再动手写代码。
写完代码后,如果 WA 了,不要瞎改。把 print 语句加进去,在每个关键位置输出当前 cur、last、current rest。比如在分配座位前后各打一行,观察 cur 是否按预期跳跃。这个过程往往能很快定位问题。
另外,一定要学会构造“不对称”的测试数据。所有学校人数一样时,问题不容易暴露;一旦有一个学校特别少,或者特别多,空格逻辑就会开始犯病。像 N=3、M 数组为 {1, 5, 2} 这种数据,就是很好的试金石。
5.3 类似题型对比
我在刷题时发现,这类“按顺序轮转分配”的题目非常普遍。
约瑟夫环是“按固定步长删人”,核心是需要时刻知道当前活着的编号。它和我们这道题的区别是,删除逻辑和轮询逻辑耦合在一起。L1-049 是固定按学校编号顺序访问,不需要删除中间节点,只需要跳过已经没人排队的学校,所以更简单。
银行叫号系统是“多个窗口轮流处理队列”,其实是多个队列同时出队,但和“多个学校轮流分配座位”的模型几乎一样。如果你以后遇到“医院分诊叫号”“多进程轮转调度”等模拟题,思路完全可以复用:维护每个对象的剩余需求,按固定顺序轮转,用状态变量记录上一个处理对象。
6. 变式与扩展思考
6.1 如果每队人数不是 3 人怎么办
天梯赛固定每队 3 人,但题目如果把“3”换成“每组 k 人”,只需要把代码里的魔法数字 3 替换成变量 k。rest[i] = teams[i] * k,输出时内层循环从 3 改成 k,其余逻辑完全不变。
建议你写代码时,把队伍人数先用常量或变量定义好,不要到处写魔法数字。这样当题目场景变化时,只改一处就能适配。这不仅是好习惯,也是模拟题通用的抗变更设计。
6.2 如果输出顺序变了怎么办
有些类似题目不是“按队伍输出”,而是直接输出每个学校的全部座位号。这时你就不需要三层循环中的“队伍”这一层,直接把seat[i]从头到尾输出即可。甚至可以把输出逻辑改成直接使用seat[i].size()判断共有多少队员,而不是依赖teams[i] * 3。
如果题目要求座位号从 0 开始,那就把cur初始改为 0,其他逻辑同样不用动。所以我把分配核心逻辑和输出逻辑分离,正是为了应对这类变式。
6.3 如果允许同校相邻,怎么简化
反过来思考,如果题目不要求同校隔开,代码会变成什么样?其实只要删掉if (last == i) cur++;这一行,整个系统就退化为“按学校编号顺序轮询发座位,谁有人就给谁发”。不再需要last变量,也不需要“空座”概念。
这个对比能帮你更深刻地理解last的作用:它不是模拟题强加的装饰,而是专门为了处理“最后剩余单校”的隔离需求而存在的状态变量。你把需求删掉,状态自然也就不用维护了。
我自己第一次做 L1-049 的时候,其实输在了一个很小的地方:我把last的更新放在了continue之后,结果导致last被没有实际分到座的学校覆盖,整个跳跃逻辑全部错乱。后来用一组“最后一个学校编号最小”的边界用例调试时才找到问题。那次之后我养成一个习惯,任何模拟题里的状态变量,我都先问自己三个问题:它什么时候初始化?什么时候更新?什么时候影响判断?把这三个问题想明白,再动手写代码,效率会高很多。
这道题的难度不高,但它很适合用来检验你对“过程模拟”的掌控力。如果你能把这段代码讲清楚,把last的机制说明白,那你对循环、状态、边界这三样基本功的理解,已经比大多数只看题解的同学扎实了。