☰
基础IO题深度拆解:从灯泡题到OJ输入输出优化
2026/10/5 7:18:33 网站建设 项目流程

先说结论:这题能卡住的,只有 IO。

“4.基础IO”这个标题看着简单,实际上是把一整类经典问题浓缩成了四个字——读进去、算出来、写出来。禾木省电这道题(传统题,来源 tomanderson,1000ms,256MB)就是一个非常典型的练习样本:一排灯泡编号 1~n,每天晚上全亮,省电时给两个区间,把区间内包括端点的灯泡全关掉,问你最后还有哪些亮着。算法上你甚至可以说它没有算法,真正要命的是把输入输出处理干净。

如果你刚开始刷 OJ,或者总在“看起来不难”的题上 WA,这篇就是为你准备的。我会把这题完整拆开:怎么理解输入、怎么写输出、哪些边界条件容易翻车、数据大了怎么优化 IO,顺带给出三种由浅入深的写法。老手可以跳过前面,直接看第 4 节的排查表和差分数组写法。

1. 题目拆解:省电灯泡题到底在考什么

1.1 一次 IO 操作的完整链路

先建立一个整体认知。OJ 上任何一道题,程序运行时都只有两条数据通道:标准输入 stdin 和标准输出 stdout。评测系统把你的程序编译好、跑起来,把预先写好的测试数据喂到 stdin 里,再把你写到 stdout 的内容抓出来,跟标准答案做逐字节比对。比对一致就是 Accepted,任何一点差异都是 Wrong Answer。

这个链路决定了 IO 必须精确。你可以把 IO 理解成填答题卡:规矩是每一道题都要在指定区域涂黑,你哪怕全部算对了,有个空格没对齐、多打了个换行,照样按格式错误处理。很多新手第一次 WA 不是算法错了,而是输入读取姿势不对或输出格式有偏差。这一点在“基础IO”这一课里被特意放大了,所以别看它名字朴素,含金量不低。

1.2 输入格式里的隐藏信息

回到题目。输入共三行,第一行是一个整数 n,表示灯泡数量;第二行、第三行各是两个整数,表示两个区间的左右端点。表面上看就是读 5 个整数,但题目措辞里藏着几处需要留意的细节。

第一,灯泡编号从 1 开始,不是从 0 开始。数组下标要和灯泡编号对齐,要么开 n+1 个元素,要么在代码里做偏移。第二,区间“包括端点”,意味着 l 和 r 这两个位置都要被关掉,循环条件是小于等于,不是小于。第三,题目描述没有明确保证 l 一定小于等于 r,你自己的代码要做好防御:读进来之后如果 l > r 就交换,否则循环一次都不会执行,结果悄悄出错。第四,n 的上限没有直接给,但时间限制 1000ms、内存 256MB 已经暗示了量级:在 10^5 到 10^6 这个范围内,直接开布尔数组标记完全可行;如果 n 到了 10^7,数组定义就要小心内存,输出量也会变大,这是后话。

1.3 算法不是主角,IO 才是

这道题如果只谈逻辑,一句话就能说完:把所有灯泡标记为亮,把两个区间内的标记为灭,输出还是亮的编号。区间标记本身是 O(r-l+1) 的遍历,两个区间加在一起最坏情况也就是 O(n),1000ms 的时限绰绰有余。所以它被排在“基础IO”这个位置,目的就是让你把所有注意力放在读写环节。

我见过不少人提交这道题,逻辑写得完全正确,结果要么是 scanf 没读够参数导致输出为空,要么是输出末尾多了一个空格被比对判 WA,要么是没有处理“没有任何灯泡亮着”的特殊情况。这些都是 IO 问题,不是算法问题。把 IO 这一关过了,这道题才算真正拿下。

2. 核心细节:读入、标记与输出的实现要点

2.1 三行输入的正确读取姿势

读取 5 个整数的方案有很多,不同语言的写法差异不小,我按常用性列个对比。

C 语言里最稳的是 scanf。scanf("%d", &n) 会自动跳过空白字符(空格、换行、制表符),所以你不用关心数字是在一行还是跨行。第二、第三行可以分别写两个 scanf("%d%d"),也可以合并成一个 scanf("%d%d%d%d", &l1, &r1, &l2, &r2),效果一样。C++ 用 cin 也完全可以,但注意 cin 默认和 C 的 stdio 保持同步,这会拖慢速度;在 main 开头加一句 ios::sync_with_stdio(false); cin.tie(0); 能明显提速。对这道题来说,数据量不大,普通 cin 也够用,但习惯要早养。

Python 的话,最省事的是逐行 input() 再 split,例如 n = int(input()),然后 l1, r1 = map(int, input().split())。但如果数据量变大,或者你后面要刷大量输入输出的题,我建议直接 sys.stdin.read().split(),一次把全部内容读进来再切片处理,速度比多次 input() 快得多。这个习惯早点养成,后面遇到输出很大的题不用临时换方案。

语言推荐读法备注
Cscanf("%d", &x)自动跳过空白字符,最稳健
C++cin + 关闭同步ios::sync_with_stdio(false); cin.tie(0);
Pythonsys.stdin.read().split()大批量数据时性能优势明显

2.2 区间端点与边界条件的处理

处理区间有两个绕不开的细节:包括端点和可能的逆序。

包括端点用循环写就是 for (int i = l; i <= r; i++),下标 i 从 l 一直走到 r,两个端点的灯泡都被标记为关闭。很多人写成 i < r,结果右端点没关掉,样例可能刚好没覆盖到这个错误,提交就 WA。另一个细节是逆序:如果输入给了 l=7, r=3,按 l<=r 的循环去写,循环一次都不会执行。稳妥的做法是读进来之后先判断,如果 l > r 就交换两者。还有防御性处理是越界:如果 r 大于 n,循环会访问到数组不存在的下标,轻则读到脏数据,重则段错误。题目一般保证数据合法,但你把 r 和 n 做一次 min 运算、把 l 和 1 做一次 max 运算,成本几乎为零,换来的是更稳健的代码。

另外,两个区间可能有重叠。布尔标记是幂等的:已经关掉的灯泡再关一次,状态不变。所以哪怕两个区间完全重叠,也不需要额外判断,直接遍历标记即可。若是用差分数组统计覆盖次数,重叠也只是把次数累加,并不影响“次数为 0 才是亮着”的判定。

2.3 输出格式:空格、换行与空结果

输出是所有仍然亮着的灯泡编号,按从小到大,用空格分隔。这里最常见的翻车点就是分隔符的位置。很多人习惯先输出第一个,再在后面的每个数字前加空格,这样末尾不会有多余空格。写成代码就是一个 bool first 变量,第一个数字前什么都不打,之后的数字前打一个空格。

末尾的换行和空格同样重要。评测比对一般是逐字节的,有些评测系统允许行尾多一个空格,但有些严格比对会直接判 WA。你不该赌这个,规范写法就按“数字之间单空格、行尾一个换行”来。多组样例的情况下,每个样例输出之后都要换行,否则下一个样例的输出会紧跟在上一个末尾,这种错误在终端上看很难发现。

再一个特殊情况:如果所有灯泡都关了,输出什么?有的题目要求输出 0,有的要求输出一个空行,有的直接不要求输出。这道题描述没写清楚,建议先看样例,样例没给就输出一个换行,绝大多数评测系统能接受空输出。这种“空结果”的边界,恰恰是基础 IO 题喜欢埋的坑。

3. 三种解法由浅入深:从暴力到差分数组

3.1 第一版:布尔数组直接标记

先给一个最直白的写法。用一个长度为 n+1 的布尔数组记录灯泡状态,初始全部为真,两个区间逐一遍历置为假,最后扫描一遍输出仍为真的下标。

#include <cstdio> const int MAXN = 1000005; bool on[MAXN]; int main() { int n, l1, r1, l2, r2; scanf("%d", &n); scanf("%d%d%d%d", &l1, &r1, &l2, &r2); if (l1 > r1) { int t = l1; l1 = r1; r1 = t; } if (l2 > r2) { int t = l2; l2 = r2; r2 = t; } for (int i = 1; i <= n; i++) on[i] = true; for (int i = l1; i <= r1; i++) on[i] = false; for (int i = l2; i <= r2; i++) on[i] = false; bool first = true; for (int i = 1; i <= n; i++) { if (on[i]) { if (!first) putchar(' '); printf("%d", i); first = false; } } putchar('\n'); return 0; }

拿一组小数据验证:n = 10,两个区间是 [2,5] 和 [7,9]。初始 10 个灯泡全亮,关掉 2、3、4、5,再关掉 7、8、9,剩下 1、6、10。程序输出 "1 6 10",符合预期。注意我没有用数组下标 0,灯泡 1 对应 on[1],这样编号和下标直接对齐,不容易乱。整个流程就是“读入—标记—输出”三步,没有任何多余的技巧。

3.2 第二版:通用循环读取与快速输出

第一版按“两行区间”写死了。但题目里那句“经过两轮操作”让不少人犯迷糊:到底是两个区间一共一轮,还是每轮两个区间、一共两轮?与其纠结,不如写一个通用的版本:用 while (scanf("%d%d", &l, &r) == 2) 持续读取区间,读到文件末尾为止,这样无论题目给两个区间还是四个区间,代码都不用改。这个模式本身就是基础 IO 的重要技能:处理不固定数量的数据。

#include <cstdio> const int MAXN = 1000005; bool on[MAXN]; int main() { int n, l, r; scanf("%d", &n); for (int i = 1; i <= n; i++) on[i] = true; while (scanf("%d%d", &l, &r) == 2) { if (l > r) { int t = l; l = r; r = t; } for (int i = l; i <= r; i++) on[i] = false; } bool first = true; for (int i = 1; i <= n; i++) { if (on[i]) { if (!first) putchar(' '); printf("%d", i); first = false; } } putchar('\n'); return 0; }

输出部分我特意用了 putchar 处理空格、printf 输出数字,避免构造一个复杂的格式化字符串。这种方法在处理大量输出时性能也不错,因为 putchar 会在标准库内部做缓冲,不会每个字节都触发一次系统调用。Python 对应的写法是 sys.stdin.read() 一次性读取、split 之后按对消费,最后用 ' '.join 拼输出字符串,同样是一句话就能讲完的思想:减少 IO 次数。

import sys def solve(): data = sys.stdin.read().split() if not data: return n = int(data[0]) on = [True] * (n + 1) idx = 1 while idx + 1 < len(data): l = int(data[idx]) r = int(data[idx + 1]) idx += 2 if l > r: l, r = r, l for i in range(l, r + 1): on[i] = False ans = [str(i) for i in range(1, n + 1) if on[i]] sys.stdout.write(" ".join(ans) + "\n") if __name__ == "__main__": solve()

这个 Python 版本有个细节:data 为空时直接 return,避免下标越界。判断读入是否成功、处理 EOF,是 IO 代码健壮性的第一步。

3.3 第三版:差分数组一劳永逸

虽然这道题用暴力标记就够,但如果你接着刷“区间操作”类的题目,差分数组是绕不开的。它的核心思想是:不在每个区间上做 O(len) 的逐点标记,而是只在区间的起点和终点后各做一次 O(1) 的记号,最后统一做前缀和还原每个位置的覆盖次数。

具体到这道题:开一个 diff 数组,初始全 0。对每个区间 [l, r],执行 diff[l] += 1 和 diff[r+1] -= 1,表示从 l 开始多覆盖一次、从 r+1 开始少覆盖一次。处理完全部区间后,从 1 到 n 做一遍前缀和 diff[i] += diff[i-1],diff[i] 就是第 i 个灯泡被覆盖的次数。次数为 0 的灯泡就是从头到尾没被关过,输出它。

#include <cstdio> const int MAXN = 1000005; int diff[MAXN]; int main() { int n, l, r; scanf("%d", &n); while (scanf("%d%d", &l, &r) == 2) { if (l > r) { int t = l; l = r; r = t; } diff[l] += 1; diff[r + 1] -= 1; } bool first = true; for (int i = 1; i <= n; i++) { diff[i] += diff[i - 1]; if (diff[i] == 0) { if (!first) putchar(' '); printf("%d", i); first = false; } } putchar('\n'); return 0; }

注意 diff 数组要开 n+2 个元素,因为当 r 等于 n 时,r+1 是 n+1,这个位置也要能写。整个算法复杂度是 O(n+m),m 是区间个数。对两个区间来说它和暴力没差别,但当你面对成千上万个区间时,这就是质变。从“基础IO”这道题里顺手学会它,属于典型的以小见大。

4. 常见问题与排查技巧实录

4.1 读入失败、越界与端点乱序的处理

我实际帮人调试这道题时,遇到最多的一个症状是:程序没有任何输出,或者只输出了一个换行。多数情况下是 scanf 没读进来。比如有人用 scanf("%d", n) 忘了取地址符,或者读入时把 n 和第二行的 l 混在一起。C 语言里这种错误编译器不一定报错,但程序行为完全不对。

第二个高频症状是段错误。常见原因是数组开小了,或者 r 超出了 n。比如 n=1000,但区间给到 [999, 1500],循环访问 on[1500] 时越界。防御写法是把区间裁到合法范围内:l = max(l, 1),r = min(r, n)。第三个症状比较隐蔽:l 大于 r。如果你不做交换,循环体一次都不执行,你以为区间关了,其实没关。这也是为什么我在所有代码里都先写 if (l > r) swap,这个习惯能帮你挡掉不少莫名其妙的数据。

4.2 输出格式错误:WA 的隐形杀手

最气人的情况是本地跑得一丝不差,提交却 WA。遇到这种,先把矛头指向输出格式。我建议在本地做一次字节级比对:把程序输出重定向到文件,再用 diff 和标准答案做对比,如果有差异,用 od -c 或 cat -A 查看不可见字符的区别。

常见差异有三类:末尾多空格、末尾没有换行、把编号从 0 开始输出。第一类多是因为用循环 printf("%d ", i) 的结果,最后一轮多打了一个空格。第二类多是因为忘了在输出结束后补换行。第三类多是因为初始化数组时不小心从 0 开始循环,输出下标而不是编号。这些差异肉眼看不出来,但评测系统能看出来。

4.3 1000ms 下的 IO 性能优化实录

这道题数据量不大,但既然时限写的是 1000ms,我就把 IO 性能的账也算一遍。最慢的写法是每一个输出都调用一次 printf 或 cout,尤其 cout 没关同步时,每输出一个数字就抢一次锁,性能非常难看。快一点的做法是拼字符串:C++ 里用 string 把所有答案拼好,最后 cout 一次;C 里可以用 sprintf 拼到缓冲区,或者用 putchar 逐字符写。

再快一档是自制的快速读入。用 getchar 逐个字符读取并转为整数,对 10^5 量级的输入来说收益不大,但如果 n 到 10^7,输入输出都以百万计,快读和批量输出能省下可观的时间。我贴一个常用的整数快读模板,C/C++ 通用:

int readint() { int x = 0, f = 1; char c = getchar(); while (c < '0' || c > '9') { if (c == '-') f = -1; c = getchar(); } while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); } return x * f; }

这里处理了负号,虽然本题用不到负数,但后续很多题都会用到负数输入,直接复制这个模板能省很多事。我的经验是:基础 IO 题老老实实用 scanf 就够,但把快读模板存进自己的代码仓库,遇到大输入量的题直接拿来用,能少走很多弯路。

4.4 常见问题速查表

现象可能原因解决办法
运行无输出读入失败或参数错误检查 scanf 参数、数据文件是否为空
段错误数组越界扩大数组、对 r 做 min(r, n)、diff 开 n+2
输出末尾多空格循环输出每个数字后跟空格用 first 变量控制分隔符
输出缺少换行忘记补行尾换行输出结束后 putchar('\n')
逻辑对但结果错区间端点写错、l>r 未处理检查循环等号、增加交换保护
大样例超时输出次数过多拼接输出、批量 fwrite
输出空但应该有内容读取循环没有正确处理 EOFwhile(scanf)==2 判断返回值

这张表可以直接贴在你刷题笔记的第一页。每次遇到 WA,先按表排查,再看算法,效率会高很多。

5. 从竞赛 IO 到工程 IO:能力迁移

5.1 同一个 IO 内核在不同领域的映射

如果你搜过“IO”这个词,会发现它在不同语境下有完全不同的含义:Java IO 指的是输入输出流,PLC 里的远程 IO 模块指的是现场设备的数据采集通道,FPGA 里的 IO 约束指的是引脚和时钟资源的分配,模拟工厂场景的 Factory IO 又是指一套仿真通信框架。词义很多,但你钻进每个领域细看,底层都逃不过三件事:接口协议、边界处理、性能控制。

竞赛里的 scanf 对应的是“我知道评测系统按什么格式喂我,我就按什么格式读”;FPGA 的 IO 约束对应的是“我知道引脚连接什么电平标准,就按那个标准约束时序”;PLC 的 IO 模块对应的是“我知道总线周期和从站地址,就按那个协议采集数据”。这套思维是可以迁移的:先把通信双方的约定吃透,再把边界情况想全,最后才谈吞吐量。这道看似幼稚的灯泡题,炼的其实是这套通用直觉。

5.2 培养 IO 直觉的三个习惯

根据我自己的经验,IO 能力的提升靠三个习惯。第一,拿到任何题,先花十秒钟画一遍输入样例和输出样式的草图,把“空结果”“边界值”“多组数据”这三种情况在脑海里跑一遍。第二,提交前自测三个用例:最小数据(n=1)、最大数据(按题目上限构造)、以及什么都不剩的情况。第三,把自己常用的读取、输出、快读模板整理成固定工具,不断复用,而不是每次现写。

这三个习惯在竞赛里帮我省了大量调试时间,后来做工程开发时也一样受用。比如对接外部接口时,我习惯先确认报文格式和空值情况,再写业务逻辑,这跟先把输入输出格式抠清楚的思路完全一致。说白了,IO 是程序和世界之间唯一的两扇门,你对这两扇门有多熟悉,决定了你的程序在外人眼里是可靠还是靠运气。

最后再分享一点个人体会。我早期刷题时最怕的不是难题,而是那种“明明全对了却 WA”的诡异感,后来发现十次里有八次是 IO 的问题:要么多空格,要么少换行,要么读入时下标错位。这道基础 IO 的灯泡题让我彻底改了习惯,从此所有输出都先重定向到文件做 diff,再提交。这个习惯到现在还在用,也推荐给你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询