ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++竞赛IO性能优化:scanf、cin与快读快写的全面对比

C++竞赛IO性能优化:scanf、cin与快读快写的全面对比 1. 先捋清楚IO 在竞赛里算不算时间为什么总有人为它吵架1.1 评测机的 IO 时间到底怎么算很多刚入坑 OJ 的同学有一个错觉反正评测机会把输入文件准备好程序读进来不就行了吗IO 怎么会成为超时原因这个想法不全错但不全对。评测机把输入文件放在磁盘或内存页缓存里程序通过标准输入去读这个“读”的过程并不是让操作系统瞬间把整个文件塞进进程内存而是程序自己发起读取、自己解析、自己转换。IO 的时间至少在实际运行时间里占两个部分第一系统调用和缓冲层的时间第二把字符流解析成 int、long long 等数值类型的时间。前者在大量小读取时特别明显后者则是 scanf 和快读的主要差异点。OJ 算时间时通常统计整个进程的运行时间不会单独把你的解析时间剔除掉。所以当 n 达到 10^6、10^7 量级IO 解析的开销会在总耗时中占到一个不能忽视的比例。尤其是当你采用的算法本身是 O(n) 或 O(n log n) 时输入读取部分有时会占掉一半的 CPU 时间。我见过不止一次这样的情况同一个题目用 scanf 提交是 900ms换成快读后直接降到 400ms。算法复杂度没变代码逻辑没变变的就是 IO 层的常数。1.2 为什么会有“cin 慢、scanf 快”的说法这个说法在竞赛圈流传了很多年它有一定历史原因但放到今天不能盲目照搬。早期 C 标准库里的 iostream 实现确实不擅长竞赛场景。再叠加一个默认行为C 的 cin/cout 需要与 C 标准库的 scanf/printf 保持字节流同步避免同一个程序里混用两套 IO 时把缓冲区弄乱。为了保证这种同步每次操作都要做一层额外的校验和协调尤其在某些编译器版本上代价非常明显。后来标准里给了ios::sync_with_stdio(false)这个开关告诉运行库我不需要你保证两套 IO 的同步了你放开手脚跑。这个开关一开cin/cout 在绝大多数编译器上的性能和 scanf/printf 已经不在一个数量级差异上很多时候是基本持平甚至某些实现里更快。那为什么还是有很多老玩家坚持 scanf因为“cin 慢”的阴影太深刻了再加上网上老帖子一代传一代很多人没有实测就直接给新人灌输“用 scanf别用 cin”。而另一种常见情况是有人确实用 cin 卡了 TLE但原因不是 cin 本身而是没关同步或用了endl或是在大数据里反复刷新输出缓冲。所以真正的结论不是“谁快谁慢”而是你是否知道这些 IO 机制背后的开关和代价。2. scanf/printf 和 cin/cout 的工作机制与性能差异2.1 scanf/printf格式化字符串的运行时解析scanf 和 printf 是 C 标准库函数原型里带了可变参数核心机制是“运行时解析格式字符串”。拿scanf(%d, n)来说它在执行时看到%d才知道这次要读取一个 int。然后它会执行一套通用的扫描逻辑跳过空白字符判断正负号把后续数字字符累积成整数遇到非数字字符停止。这套逻辑要兼容各种格式修饰符比如%x十六进制、%o八进制、%d、%u、%f、%[这种集合匹配。通用带来的问题就是代码路径更长每次解析一个数都要在这个通用状态机里走一遍。printf 同理。printf(%d, x)也要在运行时解析%d判断参数类型、进制、宽度、精度。一个简单的整数输出实际上干了大量工作。还有个隐患很多人没意识到可变参数函数不会校验参数类型。你写printf(%d, 3.14)或者scanf(%d, n)时 n 其实是 long long这属于未定义行为你看到的可能是乱码也可能直接崩溃还可能在本地正常到 OJ 上翻车。2.2 cin/cout编译期类型推导加流式处理cin 和 cout 是 C 里的输入输出流对象。cin n并不是通过格式字符串去识别类型而是直接调用重载好的operator(int )也就是说在编译阶段就知道这里要解析 int不需要运行时再检查格式串。cout n同理根据 n 的静态类型选择对应的输出处理函数。类型安全是这一套比 scanf/printf 更稳的地方。写错类型时编译器直接报错不会等到运行期给你一个莫名其妙的输出。那 cin 慢在哪慢在“同步”和“缓冲策略”。默认情况下cin 需要和 stdio 保持同步每次从输入流取得数据时都要确认 C 标准库里有没有残留缓存还要在两种流之间做好协调。关闭同步之后这种行为被短路优化空间就出来了。另一个性能杀手是绑定默认cin和cout是绑定在一起的也就是当你第一次执行cin x时它可能先清空 cout 的输出缓冲区。很多人用 cin 卡 TLE 之后改成ios::sync_with_stdio(false);就过了原因就在这里。2.3 两个关键开关和一个必须养成的习惯竞赛环境里用 cin/cout 前几乎一定会写这两行ios::sync_with_stdio(false); cin.tie(nullptr);第一行关闭 C 流和 C 标准 IO 的同步。代价是不能在同一程序里混用scanf和cin否则读入的结果不可预期。第二行把 cin 和 cout 解绑避免输入操作触发输出缓冲刷新。还有一个几乎人人踩过的坑用cout \n不要用cout endl。endl等价于cout \n flushflush 会立刻把输出缓冲区交给操作系统去写写一次就是一次系统调用。如果你在循环里输出一百万行每行都 endl那就相当于写了一百万次系统调用。哪怕你关了 sync照样卡死你。所以别再把 endl 当成“换行更专业”的写法它只是“换行 强制刷新”的叠加版竞赛里没有任何优势。3. 实测一百万个数五种写法能差多少3.1 测试样例与测试方法为了不空口对比我在 Linux 下做了个简单测试。硬件是常见 x86-64 环境编译器是 g 12.2开启 O2 优化。输入数据是这样生成的先输出一个整数 n代表元素个数然后输出 n 个随机整数范围在 1 到 10^9 之间数字之间用空格和换行混合分隔。任务很简单把所有整数加起来输出总和。测试了五种写法scanf读入printf输出。cin/cout保持默认状态。cin/cout加ios::sync_with_stdio(false)和cin.tie(nullptr)。getchar 手写快读putchar 手写快写。fread/fwrite 缓冲区快读快写。每次用重定向方式把输入文件喂给程序用 time 统计用户态 CPU 时间。实际数值会受机器影响重点看相对关系。3.2 测试结果汇总方案用户态耗时参考相对比例参考scanf / printf约 0.28s1.0cin / cout默认约 1.10s约 4 倍cin / cout关同步解绑约 0.30s约 1.1 倍getchar 快读快写约 0.16s约 0.6 倍fread/fwrite 快读快写约 0.09s约 0.3 倍这个结果很有代表性。默认状态下的 cin/cout 明显最慢也最有理由被骂。但一旦关同步它的耗时差不多能降到和 scanf 同一水平。很多老教程只告诉你“cin 慢”没告诉你那是默认状态下的慢。getchar 快读确实能再压一截但不像默认 cin 和 scanf 差距那么大。真正拉开差距的是 fread 版本一次读入一大块然后在内存里手工解析耗时只有 scanf 的三分之一左右。3.3 什么规模下需要在意 IO如果你的输入总量只有几百几千个数随便用哪种都无所谓哪怕默认 cin 也不会成为瓶颈。当输入规模到 10^5 到 10^6 时默认 cin 开始有明显劣势但只要关同步cin 或 scanf 都够用。当输入规模到 10^7 以上或者你写的题本身时限很紧、算法常数又大那快读就不是“炫技”而是实打实的保命手段。还有一个容易被忽略的场景多组数据。有些题题目没给总用例数量而是要求一直读到 EOF每次处理的规模都不小。这种情况下常数的积累更明显快读的收益也会被放大。4. 快读快写原理、实现与常见扩展4.1 快读的本质绕过通用解析直接手写状态机快读并不是什么黑魔法核心思路很简单输入格式既然已经明确是“整数 空白分隔符”那就不需要让 scanf 去解析一堆完全用不到的通用规则。手写的整数解析只需三步跳过非数字字符同时记录负号。遇到数字字符后不断执行x x * 10 (c - 0)。遇到非数字字符就结束。就是这么一点逻辑避开了 scanf 内部对格式串的解释、各种边界检查、以及对不同进制的兼容。加上用 getchar 或 fread 做底层读取整个解析路径就变得很直接。我把这种做法叫作“专用解析”因为它是针对你已知格式定制的。格式简单的场景它必然快格式复杂时它就会变成负担。4.2 getchar 版快读快写getchar 版本最容易理解也最适合在比赛里临时复制。代码量小逻辑直观。#include cstdio int read_int() { int x 0, sign 1; char c getchar(); while (c 0 || c 9) { if (c -) sign -1; c getchar(); } while (c 0 c 9) { x x * 10 (c - 0); c getchar(); } return x * sign; } void write_int(int x) { if (x 0) { putchar(-); x -x; } if (x 10) write_int(x / 10); putchar(char(x % 10 0)); }使用时直接调用int main() { int n read_int(); long long sum 0; for (int i 0; i n; i) { sum read_int(); } write_int((int)sum); putchar(\n); return 0; }这段代码的优点是短、好记、不需要额外缓冲区。性能虽然不如 fread 版但已经比 scanf 快。有两点要注意第一负号必须单独处理否则-123会被当成 123第二如果没有输入getchar 返回 EOFwhile 循环条件会一直成立可能出现无限循环。比赛题通常保证输入合法所以这个隐患在大多数情况下不会被触发但你自己要心里有数。4.3 fread/fwrite 版快读快写如果数据量特别大getchar 每个字符都走一次 C 标准库函数仍有调用开销。fread 是一次性从标准输入读一大段到内存缓冲区然后程序在内存里逐个访问字符把系统调用次数降到最低。输入部分#include cstdio static const int BUFSIZE 1 20; char ibuf[BUFSIZE]; int ipos 0, ilen 0; inline char nextChar() { if (ipos ilen) { ilen fread(ibuf, 1, BUFSIZE, stdin); ipos 0; if (ilen 0) return 0; } return ibuf[ipos]; } int read_int() { int x 0, sign 1; char c nextChar(); while (c 0 || c 9) { if (c -) sign -1; c nextChar(); } while (c 0 c 9) { x x * 10 (c - 0); c nextChar(); } return x * sign; }输出部分char obuf[BUFSIZE]; int opos 0; inline void pushChar(char c) { if (opos BUFSIZE) { fwrite(obuf, 1, BUFSIZE, stdout); opos 0; } obuf[opos] c; } void write_int(int x) { if (x 0) { pushChar(-); x -x; } if (x 10) write_int(x / 10); pushChar(char(x % 10 0)); }在 main 函数结束前别忘了把输出缓冲区里的剩余字符写出去fwrite(obuf, 1, opos, stdout);这个版本的缺点是需要维护缓冲区状态代码看起来略微复杂。优点是性能极稳在一百万整数这种数据量下它能明显跑赢 scanf。有人会问多加一行宏把BUFSIZE改大到1 20甚至1 24会不会更快缓冲区越大理论上 fread 调用越少但很多时候1 20已经足够。超大缓冲区反而可能增加内存占用在嵌入式环境中还要更谨慎。4.4 快读的常见扩展负数、EOF、long long、浮点数我在实际比赛里用得最多的快读是支持 long long 和 EOF 的版本。因为很多题目输入里不告诉你组数要自己读到文件结束。可以这样写一个返回 bool 的版本bool read_int(int out) { int x 0, sign 1; char c nextChar(); if (c 0) return false; while (c 0 || c 9) { if (c -) sign -1; c nextChar(); if (c 0) return false; } while (c 0 c 9) { x x * 10 (c - 0); c nextChar(); } out x * sign; return true; }这个版本用nextChar()返回 0 作为读到文件末尾的标志。把int x换成long long x再把read_int的返回值类型改一改就是一个 long long 版本应付范围在 10^18 左右的数据完全没问题。关于浮点数我不建议自己手写解析。浮点数格式包括小数点、指数、精度手动解析非常容易出错而且通常也没有快到值得去写。遇到浮点数直接用scanf或者cin double它们在浮点数解析上已经足够可靠。4.5 快读不是万能的几个不适用的场景快读最大的前提是“输入格式是规则的数字”。一旦格式变得复杂快读就容易翻车。比如输入是“1,2,3,4”这种逗号分隔的形式你用read_int()读到第一个数字 1 后因为下一个字符是逗号数字解析就停止了。再调用一次read_int()它会先跳过空白字符但逗号不是空白不是数字如果被跳过就永远读不到 2。这种情况你必须在读完一个数后再手动读掉那个分隔符。再比如题目里输入是十六进制数或者带有括号和运算符号的表达式快读就不再适合。这时候老老实实用scanf或者把整行读进来后做字符串处理这才是明智的做法。5. 实战中的坑scanf_s、中文乱码、重定向与调试技巧5.1 “scanf is unsafe” 和 scanf_s 该怎么处理如果你在 Windows 上用 Visual Studio 写 C/C大概率会看到类似scanf: this function or variable may be unsafe. Consider using scanf_s instead的警告这是 MSVC 编译器给出的 C4996 警告。它的动机是安全scanf在读取字符串时如果目标缓冲区长度不够可能发生缓冲区溢出scanf_s要求你显式指定缓冲区大小。但竞赛环境下千万不要因为这个警告就把代码改成scanf_s。原因很简单scanf_s不是 C/C 标准库的函数只是 MSVC 提供的一个扩展。OJ 上的 gcc 和 clang 根本不认识它你写出scanf_s提交直接编译错误。想消除这个警告最简单的办法是在文件顶部写上#define _CRT_SECURE_NO_WARNINGS或者在工程配置里把预处理器定义加上_CRT_SECURE_NO_WARNINGS。还有一个更根本的思路你可以在本地用scanf提交时也不用变。因为 OJ 上的编译器不会有 MSVC 的这套警告机制。本地警告只是影响你心情不影响最终提交。5.2 printf 输出中文乱码的原因与解法很多人用 printf 输出中文时遇到乱码这个问题的根源通常是“编码不匹配”。源代码文件保存的编码、编译器认为的源码编码、运行环境终端使用的代码页这三者只要有一个不一致中文输出就乱。在 Windows 命令行下比较常见的情况是源文件是 UTF-8但控制台默认代码页是 GBK(936)UTF-8 的中文字节流按 GBK 解读自然乱码。解决办法要么把源文件保存成 GBK要么在程序里调用 Windows API 设置控制台代码页比如使用SetConsoleOutputCP(65001)。但竞赛评测环境不关心这些OJ 主要比较字节流而且绝大多数题目用不到中文输出。我的建议是比赛代码里不要输出中文提示。要输出答案就输出纯数字或纯英文既不依赖环境编码也不会因为个别字节差异被判定错误。顺带一提很多人搜“printf 重定向”搜到的是嵌入式场景。在 STM32 这类单片机上printf 默认没有指定输出到哪个设备如果你不对fputc做重定向printf 的内容不知道跑哪去了。而在桌面竞赛环境里stdout 由操作系统接管评测时输出文件由评测机产生根本不需要重定向。本地测试要用文件输入输出时才用到freopen。5.3 调试输出如何不影响提交答案本地调试时我习惯把中间变量输出到stderr而不是 stdout。因为 OJ 评测只看 stdout 的内容stderr 通常会被评测系统忽略。这样即使调试信息忘删也不会污染提交的结果。fprintf(stderr, debug: n%d i%d\n, n, i);如果你用 C 流对应的是cerr。不过cerr默认不经过缓冲区直接输出调试时非常方便但正式代码里频繁调用cerr也会影响性能所以提交前最好删掉。还有一个思路是用条件编译本地调试和线上提交用同一个文件不会因为删代码出错#ifdef LOCAL cout debug info endl; #endif本地编译时加上-DLOCAL提交时不加这些调试代码就自动被跳过了。5.4 快读快写的常见翻车现场快读虽然快但踩坑的人也多。我整理几个最常见的错误第一混用快读和 scanf。很多人在一个程序里开头用快读取了 n后面又用scanf读其他变量。但快读已经自己把 stdin 的缓冲消费掉了一部分scanf 再读时拿到的可能是快读缓冲区里剩余的内容顺序完全错乱。最佳实践是要么全用快读要么全用 stdio不要混。第二忽略了前导空格、换行、制表符。快读的“跳过空白”逻辑里我是用while (c 0 || c 9)跳过的它能处理空格、换行、制表符。但如果你自己改代码时把这个循环删了遇到输入行首有空格时就会出错。第三没有处理负号。写过很多次read_int每次都要检查负号因为有些题目虽然数据范围是正数但中间计算的输入里可能有负数。负号不处理结果就是各种莫名其妙的错误。第四输出时不刷新缓冲区。用 fwrite 版快写时如果忘了在 main 末尾写fwrite(obuf, 1, opos, stdout)缓冲区里的内容就丢掉了。轻则输出不完整重则直接没有输出。第五把 int 范围当大数范围用。题目要求 long long但快读模板读进来的是 int溢出后你得到的结果可能还像个正常整数非常难排查。所以要根据题目的数据范围及时把int x改成long long x。5.5 到底该怎么选我的个人经验我自己的习惯是这样数据规模小、题目简单直接用cin/cout再加ios::sync_with_stdio(false)和cin.tie(nullptr)。这样代码简洁不容易出错。数据规模到了 10^6 左右用scanf/printf或者同步关闭后的cin/cout两者差距不大。数据规模更大或者时限特别紧直接上快读快写而且我会把 fread/fwrite 版本做成一个模板片段比赛时直接复制。如果你觉得手写快读太麻烦还有一种折中方案用scanf读大多数字段遇到少数性能瓶颈时才用快读。只要你不把两套混在同一段输入流程里理论上可以共存。最后想提醒一件事IO 优化只是竞赛水平的一部分不要陷入“我只要用上快读就能过题”的错觉。算法复杂度不对再快的读入也救不回来。但反过来如果你算法已经达到理论上限却被 IO 卡了常数那确实很冤。把 IO 这层功夫练熟至少能让你在实战中少一个翻车点。
返回列表