oiwiki技巧
读入、输出优化
在默认情况下,std::cin/std::cout
是极为迟缓的读入/输出方式,而 scanf/printf
比 std::cin/std::cout
快得多。
注意
cin
/cout
与 scanf
/printf
的实际速度差会随编译器和操作系统的不同发生一定的改变。如果想要进行详细对比,请以实际测试结果为准。
下文将详细介绍读入输出的优化方法。
关闭同步/解除绑定
std::ios::sync_with_stdio(false)
这个函数是一个“是否兼容 stdio”的开关,C++ 为了兼容 C,保证程序在使用了 printf
和 std::cout
的时候不发生混乱,将输出流绑到了一起。同步的输出流是线程安全的。
这其实是 C++ 为了兼容而采取的保守措施,也是使 cin
/cout
速度较慢的主要原因。我们可以在进行 IO 操作之前将 stdio 解除绑定,但是在这样做之后要注意不能同时使用 std::cin
和 scanf
,也不能同时使用 std::cout
和 printf
,但是可以同时使用 std::cin
和 printf
,也可以同时使用 scanf
和 std::cout
。
tie
tie 是将两个 stream 绑定的函数,空参数的话返回当前的输出流指针。
在默认的情况下 std::cin
绑定的是 std::cout
,每次执行 <<
操作符的时候都要调用 flush()
来清理 stream buffer,这样会增加 IO 负担。可以通过 std::cin.tie(0)
(0 表示 NULL)来解除 std::cin
与 std::cout
的绑定,进一步加快执行效率。
但需要注意的是,在解除了 std::cin
和 std::cout
的绑定后,程序中必须手动 flush
才能确保每次 std::cout
展现的内容可以在 std::cin
前出现。这是因为 std::cout
被 buffer 为默认设置。例如:
|
|
代码实现
|
|
读入优化
scanf
和 printf
依然有优化的空间,这就是本章所介绍的内容——读入和输出优化。
- 注意,本页面中介绍的读入和输出优化均针对整型数据,若要支持其他类型的数据(如浮点数),可自行按照本页面介绍的优化原理来编写代码。
原理
众所周知,getchar
是用来读入 1 byte 的数据并将其转换为 char
类型的函数,且速度很快,故可以用“读入字符——转换为整型”来代替缓慢的读入
每个整数由两部分组成——符号和数字
整数的 '+' 通常是省略的,且不会对后面数字所代表的值产生影响,而 '-' 不可省略,因此要进行判定
10 进制整数中是不含空格或除 0~9 和正负号外的其他字符的,因此在读入不应存在于整数中的字符(通常为空格)时,就可以判定已经读入结束
C 和 C++ 语言分别在 ctype.h 和 cctype 头文件中,提供了函数 isdigit
, 这个函数会检查传入的参数是否为十进制数字字符,是则返回 true,否则返回 false。对应的,在下面的代码中,可以使用 isdigit(ch)
代替 ch >= '0' && ch <= '9'
,而可以使用 !isdigit(ch)
代替 ch <'0' || ch> '9'
代码实现
|
|
- 举例
读入 num 可写为 num=read();
输出优化
原理
同样是众所周知,putchar
是用来输出单个字符的函数
因此将数字的每一位转化为字符输出以加速
要注意的是,负号要单独判断输出,并且每次 %(mod)取出的是数字末位,因此要倒序输出
代码实现
|
|
但是递归实现常数是较大的,我们可以写一个栈来实现这个过程
|
|
- 举例
输出 num 可写为 write(num);
更快的读入/输出优化
通过 fread
或者 mmap
可以实现更快的读入。
fread
能将需要的文件部分读入内存缓冲区。mmap
则会调度内核级函数,将文件一次性地映射到内存中,类似于可以指针引用的内存区域。所以在日常程序读写时,只需要重复读取部分文件可以使用 fread
,因为如果用 mmap
反复读取一小块文件,做一次性内存映射并且内核处理 page fault 的花费会远比使用 fread
的内核级函数调度大。
一次性读入缓冲区的操作比逐个字符读入(getchar
,putchar
)要快的多。因为硬盘的多次读写速度是要慢于直接读取内存的,所以先一次性读到缓存区里再从缓存区读入要快的多。并且 mmap
确保了进程间自动共享,存储区如果可以也会与内核缓存分享信息,确保了更少的拷贝操作。
更通用的是 fread
,因为 mmap
不能在 Windows 环境下使用(例如 CodeForces 的 tester)。
fread
类似于参数为 "%s"
的 scanf
,不过它更为快速,而且可以一次性读入若干个字符(包括空格换行等制表符),如果缓存区足够大,甚至可以一次性读入整个文件。
对于输出,我们还有对应的 fwrite
函数
|
|
使用示例:fread(Buf, 1, SIZE, stdin)
,表示从 stdin 文件流中读入 SIZE 个大小为 1 byte 的数据块到 Buf 中。
读入之后的使用就跟普通的读入优化相似了,只需要重定义一下 getchar。它原来是从文件中读入一个 char,现在变成从 Buf 中读入一个 char,也就是头指针向后移动一位。
|
|
fwrite
也是类似的,先放入一个 OutBuf[MAXSIZE]
中,最后通过 fwrite
一次性将 OutBuf
输出。
参考代码:
|
|
输入输出的缓冲
printf
和 scanf
是有缓冲区的。这也就是为什么,如果输入函数紧跟在输出函数之后/输出函数紧跟在输入函数之后可能导致错误。
刷新缓冲区
- 程序结束
- 关闭文件
printf
输出\r
或者\n
到终端的时候(注:如果是输出到文件,则不会刷新缓冲区)- 手动
fflush()
- 缓冲区满自动刷新
cout
输出endl
使输入输出优化更为通用
如果你的程序使用多个类型的变量,那么可能需要写多个输入输出优化的函数。下面给出的代码使用 C++ 中的 template
实现了对于所有整数类型的输入输出优化。
|
|
如果要分别输入 int
类型的变量 a,long long
类型的变量 b 和 __int128
类型的变量 c,那么可以写成
|
|
完整带调试版
关闭调试开关时使用 fread()
,fwrite()
,退出时自动析构执行 fwrite()
。
开启调试开关时使用 getchar()
,putchar()
,便于调试。
若要开启文件读写时,请在所有读写之前加入 freopen()
。
|
|
常见错误
本页面主要列举一些竞赛中很多人经常会出现的错误。
会引起 CE 的错误
这类错误多为词法、语法和语义错误,引发的原因较为简单,修复难度较低。
例:
-
int main()
写为int mian()
之类的拼写错误。 -
写完
struct
或class
忘记写分号。 -
数组开太大,(在 OJ 上)使用了不合法的函数(例如多线程),或者函数声明但未定义,会引起链接错误。
-
函数参数类型不匹配。
-
示例:如使用
<algorithm>
头文件中的max
函数时,传入了一个int
类型参数和一个long long
类型参数。1 2 3 4
// query 为返回 long long 类型的自定义函数 printf("%lld\n", max(0, query(1, 1, n, l, r)); //错误 没有与参数列表匹配的 重载函数 "std::max" 实例
-
-
使用
goto
和switch-case
的时候跳过了一些局部变量的初始化。
不会引起 CE 但会引起 Warning 的错误
犯这类错误时写下的程序虽然能通过编译,但大概率会得到错误的程序运行结果。这类错误会在使用 -W{warningtype}
参数编译时被编译器指出。
-
赋值运算符
=
和比较运算符==
不分。-
示例:
1 2 3 4 5 6 7 8 9
std::srand(std::time(nullptr)); int n = std::rand(); if (n = 1) printf("Yes"); else printf("No"); // 无论 n 的随机所得值为多少,输出肯定是 Yes // 警告 运算符不正确: 在 Boolean 上下文中执行了常量赋值。应考虑改用“==”。
-
如果确实想在原应使用
==
的语句里使用=
(比如while (foo = bar)
),又不想收到 Warning,可以使用 双括号:while ((foo = bar))
。
-
-
由于运算符优先级产生的错误。
-
示例:
1 2 3 4 5 6
// 错误 // std::cout << (1 << 1 + 1); // 正确 std::cout << ((1 << 1) + 1); // 警告 “<<”: 检查运算符优先级是否有可能的错误;使用括号阐明优先级
-
-
不正确地使用
static
修饰符。 -
使用
scanf
读入的时候没加取地址符&
。 -
使用
scanf
或printf
的时候参数类型与格式指定符不符。 -
同时使用位运算和逻辑运算符
==
并且未加括号。- 示例:
(x >> j) & 3 == 2
- 示例:
-
int
字面量溢出。- 示例:
long long x = 0x7f7f7f7f7f7f7f7f
,1<<62
。
- 示例:
-
未初始化局部变量,导致局部变量被赋予垃圾初值。
未初始化变量会发生什么
原文:https://loj.ac/d/3679 by @hly1204
例如我们在 C++ 中声明一个
int a;
但不初始化,可能有时候会认为a
是一个“随机”(其实可能不是真的随机)的值,但是可能将其认为是一个固定的值,但实际上并非如此。我们在简单的测试代码中
https://wandbox.org/permlink/T2uiVe4n9Hg4EyWT
代码是:
1 2 3 4 5 6 7
#include <iostream> int main() { int a; std::cout << std::boolalpha << (a < 0 || a == 0 || a > 0); return 0; }
在一些编译器和环境上开启优化后,其输出为 false。
有兴趣的话可以看 https://www.ralfj.de/blog/2019/07/14/uninit.html,尽管其实用 Rust 做的实验,但是本质是一样的。
-
局部变量与全局变量重名,导致全局变量被意外覆盖。(开
-Wshadow
就可检查此类错误。) -
运算符重载后引发的输出错误。
- 示例:
1 2 3 4
// 本意:前一个 << 为重载后的运算符,表示输出;后一个 << 为移位运算符,表示将 1 // 左移 1 位。 但由于忘记加括号,导致编译器将后一个 << // 也判作输出运算符,而导致输出的结果与预期不同。 错误 std::cout << 1 << 1; 正确 std::cout << (1 << 1);
- 示例:
既不会引起 CE 也不会引发 Warning 的错误
这类错误无法被编译器发现,仅能自行查明。
会导致 WA 的错误
-
上一组数据处理完毕,读入下一组数据前,未清空数组。
-
读入优化未判断负数。
-
所用数据类型位宽不足,导致溢出。
- 如习语“三年 OI 一场空,不开
long long
见祖宗”所描述的场景。选手因为没有在正确的地方开long long
(将整数定义为long long
类型),导致得出错误的答案而失分。
- 如习语“三年 OI 一场空,不开
-
存图时,节点编号 0 开始,而题目给的边中两个端点的编号从 1 开始,读入的时候忘记 -1。
-
大/小于号打错或打反。
-
在执行
ios::sync_with_stdio(false);
后混用scanf/printf
和std::cin/std::cout
两种 IO,导致输入/输出错乱。-
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// 这个例子将说明关闭与 stdio 的同步后,混用两种 IO 方式的后果 // 建议单步运行来观察效果 #include <cstdio> #include <iostream> int main() { // 关闭同步后,cin/cout 将使用独立缓冲区,而不是将输出同步至 scanf/printf // 的缓冲区,从而减少 IO 耗时 std::ios::sync_with_stdio(false); // cout 下,使用'\n'换行时,内容会被缓冲而不会被立刻输出 std::cout << "a\n"; // printf 的 '\n' 会刷新 printf 的缓冲区,导致输出错位 printf("b\n"); std::cout << "c\n"; // 程序结束时,cout 的缓冲区才会被输出 return 0; }
-
特别的,也不能在执行
ios::sync_with_stdio(false);
后使用freopen
。
-
-
由于宏的展开,且未加括号导致的错误。
-
示例:该宏返回的值并非 而是 。
1 2
#define square(x) x* x printf("%d", square(2 + 2));
-
-
哈希的时候没有使用
unsigned
导致的运算错误。- 对负数的右移运算会在最高位补 1。参见:位运算
-
没有删除或注释掉调试输出语句。
-
误加了
;
。-
示例:
1 2 3
/* clang-format off */ while (1); printf("OI Wiki!\n");
-
-
哨兵值设置错误。例如,平衡树的
0
节点。 -
在类或结构体的构造函数中使用
:
初始化变量时,变量声明顺序不符合初始化时候的依赖关系。- 成员变量的初始化顺序与它们在类中声明的顺序有关,而与初始化列表中的顺序无关。参见:构造函数与成员初始化器列表 的“初始化顺序”
-
示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
#include <iostream> class Foo { public: int a, b; // a 将在 b 前初始化,其值不确定 Foo(int x) : b(x), a(b + 1) {} }; int main() { Foo bar(1, 2); std::cout << bar.a << ' ' << bar.b; } // 可能的输出结果:-858993459 1
-
并查集合并集合时没有把两个元素的祖先合并。
-
示例:
1 2
f[a] = b; // 错误 f[find(a)] = find(b); // 正确
-
换行符不同
不同的操作系统使用不同的符号来标记换行,以下为几种常用系统的换行符:
-
LF(用
\n
表示):Unix
或Unix
兼容系统 -
CR+LF(用
\r\n
表示):Windows
-
CR(用
\r
表示):Mac OS
至版本 9
而 C/C++ 利用转义序列 \n
来换行,这可能会导致我们认为输入中的换行符也一定是由 \n
来表示,而只读入了一个字符来代表换行符,这就会导致我们没有完全读入输入文件。
以下为解决方案:
-
多次
getchar()
,直到读到想要的字符为止。 -
使用
cin
读入,这可能会增大代码常数。 -
使用
scanf("%s",str)
读入一个字符串,然后取str[0]
作为读入的字符。 -
使用
scanf(" %c",&c)
过滤掉所有空白字符。
会导致未知的结果
未定义行为会导致未知的结果,可能是 WA,RE 等。
-
除以 0(求 0 的逆元)
示例
1
cout << x / 0 << endl;
-
数组(下标)越界
例如:
-
未正确设置循环的初值导致访问了下标为 -1 的值。
-
无向图边表未开 2 倍。
-
线段树未开 4 倍空间。
-
看错数据范围,少打一个零。
-
错误预估了算法的空间复杂度。
-
写线段树的时候,
pushup
或pushdown
叶节点。正确的做法:不要越界,记得检查自己的代码,使得下标访问数
x
,在定义的下标中。
-
-
除 main 外有返回值函数执行至结尾未执行任何 return 语句
即使有一个分支有返回值,但是其他分支却没有,结果也是未定义的。
可以开
-Wall
选项,看一看自己有没有关于函数未 return 的警告。 -
尝试修改字符串字面量
示例
1 2 3
char *p = "OI-wiki"; p[0] = 'o'; p[1] = 'i';
这样定义并不符合 c++11 标准,迎丹使用其他 合适 的数据类型,例如
std::string
,char[]
。 -
多次释放/非法解引用一片内存
例如:
-
未初始化就解引用指针。
-
指针指向的内存区域已经释放。
使用
erase
或delete
或free
操作应注意不要对同一地址/对象多次使用。
-
-
解引用空指针/野指针
对于空指针:先应该判断空指针,可以用
p == nullptr
或!p
。对于野指针:可以释放指针的时候将其置为
nullptr
以规避。 -
有符号数溢出
例如我们有一个表达式
x+1 > x
。正常输出应当是
true
,但是在INT_MAX
作为x
时输出false
,这时称为signed integer overflow
。可以使用更大的数据类型(例如
long long
或__int128
),或判断溢出。若保证无负数,亦可使用无符号整型。
会导致 RE
-
没删文件操作(某些 OJ)。
-
排序时比较函数的错误
std::sort
要求比较函数是严格弱序:a<a
为false
;若a<b
为true
,则b<a
为false
;若a<b
为true
且b<c
为true
,则a<c
为true
。其中要特别注意第二点。 如果不满足上述要求,排序时很可能会 RE。 例如,编写莫队的奇偶性排序时,这样写是错误的:1 2 3 4 5 6
bool operator<(const int a, const int b) { if (block[a.l] == block[b.l]) return (block[a.l] & 1) ^ (a.r < b.r); else return block[a.l] < block[b.l]; }
(block[a.l]&1)^(a.r<b.r)
不满足上述要求的第二点。 改成这样就正确了:1 2 3 4 5 6 7 8 9
bool operator<(const int a, const int b) { if (block[a.l] == block[b.l]) // 错误:不满足严格弱序的要求 // return (block[a.l] & 1) ^ (a.r < b.r); // 正确 return (block[a.l] & 1) ? (a.r < b.r) : (a.r > b.r); else return block[a.l] < block[b.l]; }
会导致 TLE
-
分治未判边界导致死递归。
-
死循环。
-
循环变量重名。
-
循环方向反了。
-
-
BFS 时不标记某个状态是否已访问过。
-
使用宏展开编写 min/max
这种错误会大大增加程序的运行时间,甚至直接影响代码的时间复杂度。在初学者写线段树时尤为多见。
常见的错误写法是这样的:
1 2
#define Min(x, y) ((x) < (y) ? (x) : (y)) #define Max(x, y) ((x) > (y) ? (x) : (y))
这样写虽然在正确性上没有问题,但是如果直接对函数的返回值取 max,如
a = Max(func1(), func2())
,而这个函数的运行时间较长,则会大大影响程序的性能,因为宏展开后是a = func1() > func2() ? func1() : func2()
的形式,调用了三次函数,比正常的 max 函数多调用了一次。注意,如果func1()
每次返回的答案不一样,还会导致这种max
的写法出现错误。例如func1()
为return ++a;
而a
为全局变量的情况。示例:如下代码会被卡到单次查询 导致 TLE。
1 2 3 4 5 6 7 8 9 10 11 12
#define max(x, y) ((x) > (y) ? (x) : (y)) int query(int t, int l, int r, int ql, int qr) { if (ql <= l && qr >= r) { ++ti[t]; // 记录结点访问次数方便调试 return vi[t]; } int mid = (l + r) >> 1; if (mid >= qr) return query(lt(t), l, mid, ql, qr); if (mid < ql) return query(rt(t), mid + 1, r, ql, qr); return max(query(lt(t), l, mid, ql, qr), query(rt(t), mid + 1, r, ql, qr));
-
没删文件操作(某些 OJ)。
-
在
for/while
循环中重复执行复杂度非 的函数。严格来说,这可能会引起时间复杂度的改变。
会导致 MLE
-
数组过大。
-
STL 容器中插入了过多的元素。
-
经常是在一个会向 STL 插入元素的循环中死循环了。
-
也有可能被卡了。
-
会导致常数过大
-
定义模数的时候,未定义为常量。
-
示例:
1 2
// int mod = 998244353; // 错误 const int mod = 998244353; // 正确,方便编译器按常量处理
-
-
使用了不必要的递归(尾递归不在此列)。
-
将递归转化成迭代的时候,引入了大量额外运算。
只在程序在本地运行的时候造成影响的错误
-
文件操作有可能会发生的错误:
-
对拍时未关闭文件指针
fclose(fp)
就又令fp = fopen()
。这会使得进程出现大量的文件野指针。 -
freopen()
中的文件名未加.in
/.out
。
-
-
使用堆空间后忘记
delete
或free
。
常见技巧
本页面主要列举一些竞赛中的小技巧。
利用局部性
局部性是指程序倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。局部性分为时间局部性和空间局部性。
-
循环展开。通过适当的循环展开可以减少整个计算中关键路径上的操作数量
1 2 3 4 5 6 7 8 9 10 11 12
// for (int i = 0; i < n; ++i) { // res = res OP a[i]; //} // 不如 int i; for (i = 0; i < n; i += 2) { res = res OP a[i]; res = res OP a[i + 1]; } for (; i < n; ++i) { res = res OP a[i]; }
-
重新结合变换,增加了可以并行执行的运算数量。
1 2 3 4
// 加号可以换成其他的运算符 for (int i = 0; i < n; ++i) res = (res + a[i]) + a[i + 1]; // 不如 for (int i = 0; i < n; ++i) res = res + (a[i] + a[i + 1]);
循环宏定义
如下代码可使用宏定义简化:
|
|
另外推荐一个比较有用的宏定义:
|
|
善用 namespace
使用 namespace 能使程序可读性更好,便于调试。
例题:NOI 2018 屠龙勇士
|
|
使用宏进行调试
编程者在本地测试的时候,往往要加入一些调试语句。而在需要提交到 OJ 时,为了不使调试语句的输出影响到系统对程序输出结果的判断,就要把它们全部删除,耗时较多。这种情况下,可以通过定义宏的方式来节省时间。大致的程序框架是这样的:
|
|
#ifdef
会检查程序中是否有 #define
定义的对应标识符,如果有定义,就会执行后面的语句。而 #ifndef
会在没有定义相应标识符的情况下执行后面的语句。
这样,只需在 #ifdef DEBUG
里写好调试用代码,#ifndef DEBUG
里写好真正提交的代码,就能方便地进行本地测试。提交程序的时候,只需要将 #define DEBUG
一行注释掉即可。也可以不在程序中定义标识符,而是通过 -DDEBUG
的编译选项在编译的时候定义 DEBUG
标识符。这样就可以在提交的时候不用修改程序了。
不少 OJ 都开启了 -DONLINE_JUDGE
这一编译选项,善用这一特性可以节约不少时间。
对拍
对拍是一种进行检验或调试的方法,通过对比两个程序的输出来检验程序的正确性。可以将自己程序的输出与其他程序的输出进行对比,从而判断自己的程序是否正确。
对拍过程要多次进行,因此需要通过批处理的方法来实现对拍的自动化。
具体而言,对拍需要一个 数据生成器 和两个要进行输出结果比对的程序。
每运行一次数据生成器都将生成的数据写入输入文件,通过重定向的方法使两个程序读入数据,并将输出写入指定文件,最后利用 Windows 下的 fc
命令比对文件(Linux 下为 diff
命令)来检验程序的正确性。如果发现程序出错,可以直接利用刚刚生成的数据进行调试。
对拍程序的大致框架如下:
|
|
内存池
当动态分配内存时,频繁使用 new
/malloc
会占用大量的时间和空间,甚至生成大量的内存碎片从而降低程序的性能,可能会使原本正确的程序 TLE/MLE。
这时候需要使用到“内存池”这种技巧:在真正使用内存之前,先申请分配一定大小的内存作为备用。当需要动态分配时直接从备用内存中分配一块即可。
在大多数 OI 题当中,可以预先算出需要使用到的最大内存并一次性申请分配。
示例:
|
|
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)