OJ入门三题:输入输出、排序稳定性与约瑟夫环实战复盘
101到103这三道题到底卡了我多久2026年2月23号晚上我把某OJ平台入门题单里排在最前面的101、102、103三道题一口气清完了。原本以为这三题就是拿来练手的简单题结果提交记录里WA的次数比AC还多。事后复盘这三道题正好把输入输出、排序、模拟三个最基础也最容易翻车的板块串在了一起并不是单纯“送分”。这个题单是之前某位学长推荐的说按编号刷就行前五十题全是基础。我当时还不太信直到自己动手才明白入门题不等于白送题判题系统不看你写得顺不顺只看你的程序在任何合法的输入下是不是都对。101到103放在最前面难度从一颗星到两颗星覆盖面却很精准101考输入输出和EOF处理102考排序比较逻辑103考模拟和边界条件。任何一个环节没想透都会在提交的一瞬间原形毕露。这篇就记录一下我刷这三道题时的完整想法、AC代码、踩坑过程和最终复盘给同样在刷OJ入门题的朋友一个参考。1. 三道题摆在题单最前面的原因入门三件套的含金量很多人一看题号100出头就觉得是“AB”之类的凑数题随手写个循环就交了。但OJ的题单设计通常有它的逻辑101到103放在最前面不是为了凑数量而是为了让新手在最少的题目里暴露最多的问题。1.1 从输入输出到模拟难度梯度合理先说难度。101题处理的是“不确定组数的输入读到文件末尾为止”核心是EOF的使用没接触过的人很容易只处理一组数据就交。102题开始涉及数据和排序需要用结构体或数组把学生信息和成绩绑定起来再按规则排序。103题是约瑟夫环的经典模拟要求能够把“人围成一圈报数出圈”的流程用代码忠实表达出来涉及取模运算和动态删除。这三题对应了编程能力三个层次101能不能正确读取输入并输出结果对应C/C里scanf和cin的返回值使用Python里sys.stdin的处理方式。102会不会组织数据、写比较函数涉及排序算法的理解、结构体、稳定性。103能不能把一个具象的流程抽象成代码这是“算法思维”的最初训练。它们从“会读数据”到“会处理数据”再到“会模拟过程”一条线拉下来正好把入门刷题最容易被忽略的几件事全部覆盖。1.2 我为什么在2月23日这天集中清掉这三题按理说这三题早该刷完但之前我一直在补语言基础总觉得应该把语法学扎实了再来做题。结果就是越等越不想动题单一直躺在收藏夹里吃灰。2月23号那天晚上我给自己定的目标是“不把这三题AC不睡觉”。这个决定有点莽但确实有效。刷题这件事卡在“准备”阶段太久反而会越来越焦虑。真正动手之后才发现很多问题只有在你写完代码、按下提交、看到WA之后再回头查才能记得住。我选这三题还有一个私心它们的知识点都足够基础就算WA了也不会打击信心最适合拿来恢复手感。事实证明这三题把我的状态从“看题不知道从哪下手”拉回到“先想清楚输入输出再写核心逻辑最后测边界条件”的正常节奏。2. OJ101多组输入求和的三种写法与一个低级失误OJ101的题目描述很简单输入包含多行数据每行两个整数a和b对每一行输出ab的和直到文件结束。第一眼看上去这不就是AB的加强版吗但我很快发现难点不在加法而在“多组输入”这四个字。2.1 题意与第一反应很多人第一次遇到这种题目会下意识写一个scanf然后处理一组就结束了#include stdio.h int main() { int a, b; scanf(%d %d, a, b); printf(%d\n, a b); return 0; }这段代码在本地运行、手动输入“3 5”的时候完全正常但提交到OJ就会WA或者只通过一部分测试点。原因很简单题目没有告诉你一共有多少组数据判题程序会一次性把所有测试输入都喂给你的程序你的程序读完第一组之后输出一个结果就退出了剩下的数据全部被忽略。正确的做法是循环读入直到读不到数据为止。2.2 从scanf到cin到Python的写法对比C语言版本的正确写法是利用scanf的返回值文件结束时它会返回EOF#include stdio.h int main() { int a, b; while (scanf(%d %d, a, b) ! EOF) { printf(%d\n, a b); } return 0; }C的cin可以直接作为条件判断读入失败时表达式为假#include iostream using namespace std; int main() { int a, b; while (cin a b) { cout a b endl; } return 0; }Python则要操作sys.stdinimport sys for line in sys.stdin: a, b map(int, line.split()) print(a b)这三种写法本质都是同一个逻辑只要还有输入就继续处理没有输入了程序自然结束。用“有没有读进来”作为循环条件而不是用“读了几组”或“是不是第一次”来判断这才是这类题目的通用解法。建议初学阶段把scanf、cin、sys.stdin三种读入方式都写一遍因为不同OJ题目有时会对语言有特殊限制多会一种就少一个卡壳的地方。2.3 我真正踩到的坑EOF处理我第一版用的是C语言的scanf自以为很熟练结果WA了两发。原因是我写完循环之后多加了一句while (scanf(%d %d, a, b) ! EOF) { printf(%d\n, a b); } printf(end\n);输出多了一个额外的“end”。判题系统不管你在本地看着多舒服它只比对标准输出和你的输出多出的任何字符都是WA。这个段子可能在很多新手身上都发生过我当时也没想到自己会栽在这里。另一个细节是有的题目行内数据数量不固定比如一行可能有两个整数也可能有三个。遇到这种情况就不能简单写cin a b而是要把整行读进来再用split处理。101题没有这么复杂但这个意识是从这道题种下的先看清输入格式再决定怎么读。3. OJ102成绩排序里藏着的排序稳定性问题OJ102的题目大意是输入N个学生的学号和C语言成绩要求按成绩从高到低输出如果成绩相同按学号从小到大输出。这题一看就知道要排序但真正的考点不只是排序本身而是“学号作为次要关键字”这个规则。3.1 题目描述与初步设计我最初的思路是用一个结构体把学号和成绩绑在一起然后用sort快排比较函数优先比成绩成绩一样再比学号。#include iostream #include algorithm #include vector using namespace std; struct Student { string id; int score; }; bool cmp(const Student a, const Student b) { if (a.score ! b.score) return a.score b.score; return a.id b.id; } int main() { int n; cin n; vectorStudent stu(n); for (int i 0; i n; i) { cin stu[i].id stu[i].score; } sort(stu.begin(), stu.end(), cmp); for (int i 0; i n; i) { cout stu[i].id stu[i].score endl; } return 0; }这个写法本身没问题成绩降序、学号升序两个条件都在cmp函数里明确了。3.2 结构体排序的比较函数到底怎么写很多第一次接触结构体排序的人会把cmp函数写反常见的错误是bool cmp(Student a, Student b) { return a.score a.score; // 手滑写错比较对象 }这种错误比较明显还有一种更隐蔽的只比较了成绩学号顺序没管。bool cmp(Student a, Student b) { return a.score b.score; }如果成绩相同sort并不保证维持原始顺序。虽然很多编译器实现的sort是稳定排序但标准并没有强制保证这一点依赖sort保持相同成绩下学号的原始顺序是一种非常危险的做法。要么在cmp里把学号比较写进去要么改用stable_sort从语义上锁定顺序。我当时特意用stable_sort又交了一版也能AC但cmp里明确学号规则显然更直观。写比较函数的原则是把所有需要区分顺序的条件都写清楚不要依赖排序算法的额外性质。3.3 一个用冒泡排序暴露的稳定性细节我不满足于只交一版于是又写了一个冒泡排序版本想验证自己到底懂不懂稳定性的概念for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (stu[j].score stu[j 1].score) { swap(stu[j], stu[j 1]); } } }这个版本也能AC因为冒泡排序本身是稳定的当分数相等时相邻元素不会交换学号的升序被保留了下来。但这里有个前提输入数据里相同分数的学生学号本来就是从小到大输入的。我给自己的冒泡测试用例正好满足这个条件所以AC了。后来换了一组数据把相同分数的学生故意打乱输入顺序冒泡版本就输出了错误的学号顺序。这给我上了一课AC不代表逻辑一定完备只能说明判题数据里没有覆盖到某个反例。真正安全的做法就是严格按照题目规则写完整的比较条件而不是靠“测试数据恰好没考”来蒙混过关。4. OJ103约瑟夫环模拟的取模技巧与边界条件OJ103的题目描述比较长大意是N个人围成一圈编号从1到N从第1个人开始报数报到M的人出圈然后下一个人重新从1报数直到所有人都出圈要求输出出圈顺序。这就是经典的约瑟夫环。第一次看到这道题的人往往会被“围成一圈”和“循环报数”这两个概念绕进去。4.1 模拟思路数组与队列两种路线最容易想到的思路是用数组模拟维护一个出圈标记每次遍历数到M个人把对应的人标记为出圈输出后继续。这个思路直接、好懂但每次找下一个人都要遍历一遍整体复杂度是O(N*M)N很大时会超时。我实际采用的写法是用vector动态删除当前出圈的人索引通过取模回绕#include iostream #include vector using namespace std; int main() { int n, m; cin n m; vectorint people(n); for (int i 0; i n; i) people[i] i 1; int idx 0; while (!people.empty()) { idx (idx m - 1) % people.size(); cout people[idx]; if (people.size() 1) cout ; people.erase(people.begin() idx); } cout endl; return 0; }用vector直接erase每次删除是O(N)整体复杂度O(N^2)对于n不超过几千的测试点来说足够用。核心技巧是索引更新公式idx (idx m - 1) % people.size()。另一种思路是用队列模拟把报数看作从队头不断取人报数不到M的人重新放到队尾报到M的人直接出队。这种方法不需要取模逻辑更贴近“报数”这个动作#include iostream #include queue using namespace std; int main() { int n, m; cin n m; queueint q; for (int i 1; i n; i) q.push(i); while (!q.empty()) { for (int i 1; i m; i) { q.push(q.front()); q.pop(); } cout q.front(); if (q.size() 1) cout ; q.pop(); } cout endl; return 0; }队列写法的代码量更少含义也更直观。两种方法我都提交了一遍都能AC。vector版本的亮点是取模公式队列版本的亮点是循环移动。4.2 索引取模为什么要减一我看到不少人在评论区问为什么idx m - 1要减一为什么不直接idx m问题在于索引的起点。假设数组当前有len个人编号分别是0到len-1。从当前索引idx开始报数第一个人包括idx自己报1报数是1、2、3……m。要找到报到m的人需要向后移动m-1个位置。这个“减一”是在给当前这个人自己留位置。可以这样验证当m等于1时不报数直接让当前的人出圈idx 1 - 1 idx索引原地不动正确。如果不减一m1时索引会变成idx 1跳过了当前这个人结果完全错误。沿着一圈移动要用取模保证索引不越界。取模对象是当前人数people.size()因为每出圈一个人下一轮的人数是变少的不能用初始的N去取模这是一个很容易被忽略的细节。4.3 N1、MN这类边界情况的处理边界条件永远是模拟题的命门。对于这道题至少要想清楚这几个情况n1时不需要循环直接输出1。m等于1时按顺序把人一个一个输出也就是1、2、3……n。m大于n时取模会把人“绕”回来索引计算要处理好不能越界。最后只剩一个人时出圈后循环结束注意输出格式里不要多一个空格。我的vector代码里每次删除后取模用的是people.size()天然处理了人数递减的问题输出时用if (people.size() 1)控制空格避免行尾多余空格。就是这些看似琐碎的地方最容易让一个逻辑正确的程序卡在Presentation Error上。很多刚刷题的人不知道OJ还有一种“格式错误”状态输出内容都对但多了空格、换行或者少了空格、换行判题系统都会判错。5. AC之外的收获判题反馈与刷题节奏的平衡三题全部AC之后我没有急着继续往后刷而是打开提交记录翻了一遍统计了一下每种状态出现的次数。不看不知道直观感受“只WA了几次”实际上WA、PE、CE全都遇到过。5.1 看懂WA和TLE隐含的信息很多新手会把WA和TLE当成纯粹的失败实际上它们是最有价值的反馈。WA说明你的程序输出结果和正确答案不一致。需要先确认自己的算法思路是否正确然后再考虑是不是边界条件没覆盖全。两个典型的排查方向一是看输入是否完全读取二是想清楚题目的隐藏条件。PE是格式错误很多是从101和103这种有输出格式要求的题里产生的。多空格这个问题在本地永远不会被发现因为人眼根本不会在意末尾多一个空格但判题系统会逐字符比对。TLE在101到103里不太会出现但值得提前说如果你写的程序在大数据量下超时了优先检查复杂度。比如102题如果非要用冒泡排序数据量一上来就会TLE这时候换成sort就解决问题了。算法题的时间限制不是用来吓唬人的它逼着你在写代码之前先估算一下复杂度。5.2 关于刷题顺序与时间分配的个人体会102和103这两道题我一开始是“一口气写到底再统一测样例”的习惯结果经常出现整体逻辑是对的但某个小地方写错导致整个程序输出错误。这次刷101到103我改成了分步验证每写完一个功能就用一组小样例测试确认这一步没问题再继续往下写。这个习惯帮我省了很多时间。比如102题的cmp函数我写完以后先用3个学生的数据单独测了一遍排序结果确认没问题再和主流程合并。看起来多花了几分钟实际上避免了“写完整个程序之后从头找bug”的更大消耗。刷题节奏上我个人的体会是与其一天刷十道会的题不如一天认真啃三道有点挑战的题。101到103正好就是这样一个规模。题目少但每道题都能展开出不少细节把细节记录下来比单纯追求AC数量有意义得多。我这篇博客其实就是这个思路的产物。另外刷完题之后最好把每一道的核心代码和踩坑点单独存一个文件按日期归好类。最近翻前面的记录很多当时想不明白的问题隔一段时间再看一眼就能看出答案。这就是进步虽然过程有点磨人。