前言
相同渐进时间复杂度的算法,实际运行速度可能不尽相同,这是因为在讨论渐进时间复杂度时我们会使用大
O
O
O 记号忽略掉常数因子所带来的影响。
例如,扫描
1
1
1 遍数组和扫描
10
10
10 遍数组的渐进复杂度是相同的,但是后者明显会花费更多的时间:
0.5
0.5
0.5 秒和
5
5
5 秒这两种运行结果对于同样是常数的时间限制来说是不同的。类似的例子还有树状数组、线段树和平衡树:尽管它们的结构、功能、复杂度类似,但是速度上存在着明显的差异。
与此同时,计算机的体系结构特性也会导致不同代码性能上存在差距。举例而言,对于矩阵乘法来说,一种实现是这样的:
const int N = 512, n = 512;
void multiply_ijk(int c[N][N], int a[N][N], int b[N][N]) { // c = a * b
memset(c, 0, sizeof(int) * N * N);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
for (int k = 0; k < n; k++)
c[i][j] += a[i][k] * b[k][j];
return ;
}
还有一种实现是这样的:
const int N = 512, n = 512;
void multiply_ikj(int c[N][N], int a[N][N], int b[N][N]) {
memset(c, 0, sizeof(int) * N * N);
for (int i = 0; i < n; i++)
for (int k = 0; k < n; k++)
for (int j = 0; j < n; j++)
c[i][j] += a[i][k] * b[k][j];
return ;
}
上面两份在吗仅仅交换了两层循环,看起来几乎完全一致。然而,将这两份代码在测测你的矩阵乘法一题中提交以下的话可以发现:它们的性能存在显著差异。
方法 | 时间(C++20 O2) |
---|---|
multiply_ijk | 249.8 m s 249.8\rm ms 249.8ms |
multiply_ikj | 79 m s 79\rm ms 79ms |
我们将在后面了解到,后者相比前者能够更好地利用计算机的缓存。
算法竞赛形式是编写代码实现算法,即使渐进复杂度正确也不一定能够通过;与此同时,渐进复杂度大也不一定超时。因此,掌握算法设计和理论分析固然是学习算法竞赛的首要目标,追求程序实现高效亦是有价值的。
以下是一些对于优化的忠告:
- “过早的优化是万恶之源”
[
1
]
^{[1]}
[1]
提前考虑优化技巧可能会破坏程序的可读性,并会因为额外的代码引入潜在的 b u g \rm bug bug。因此,请考虑在比赛时写出一份最直白、好些的程序,保证正确性后在对其进行修改来优化参数。 - 只进行有效的优化
只有对真正耗时高的部分优化是有效的。我们对程序进行常数优化时,重点应放在最占时间的部分上,如循环内部、被调用次数最多的函数以及递归的尾巴等。如果花费精力优化运行时间占比较小的部分,那么不仅浪费代码时间、将代码变得更加晦涩难懂,而且收益甚微。
你可以在程序内加入计时器来计算每部分的耗时,以判断哪部分最需要优化。通常你可以使用以下代码来简单测试一个函数使用的时间,在 W i n d o w s \rm Windows Windows 上输出的是以毫秒为单位的时间,在 L i n u x \rm Linux Linux 上则是以为微秒为单位的时间。
time_t start = clock();
// 这里写测试代码
time_t duration = clock() - start;
cerr << "time = " << duration << endl;
如果你需要更精细的测量,可以使用
C++11
\text{C++11}
C++11 以后有的 <chrono>
头文件里提供的纳秒级别时钟。以下代码输出的是以秒为单位的小数时间。
auto start = chrono::system_clock::now().time_since_epoch().count();
// 这里写测试代码
auto duration = chrono::system_clock::now().time_since_epoch().count() - start;
cerr << "time = " << duration / 1e9 << endl;
在测速比较两个方法时,需要做好控制变量。例如,应当用同样的输入数据和相同的编译器选项;尽可能规避生成数据、输入输出、其他程序占用
C
P
U
\rm CPU
CPU 等可能引起测速不稳定的因素;重复测试几次以规避而然因素等。
如果你习惯使用外部工具来检查的话,
g
c
c
\rm gcc
gcc 中提供的
g
p
r
o
f
\rm gprof
gprof 或许是个不错的选择,这个工具可以显示每个函数花费的时间——尽管在调试的优化等级下每个函数占比和开启优化开关后略有不同。
- 不要局限于常数优化
有时,我们可能因为提交代码恰好超时一点而抱怨出题人卡常数。事实上,看起来卡常数的题目或许存在更优秀的解法。对较差复杂度算法进行常数优化来提高分数只是比赛策略,学习更好的算法仍然是最重要的。
接下来我们介绍一些常数优化的技巧,并分析这么做的原因。
读写优化
计算复杂度时,输入输出复杂度很容易被忽略。
例如,某题目想考察
O
(
n
)
O(n)
O(n) 复杂度的算法,因此要求输入超过
1
0
6
10^6
106 个整数。此时,读入这些数的复杂度就已经达到了
O
(
n
log
W
)
O(n\log W)
O(nlogW),其中
W
W
W 为值域,取常用的
W
=
1
0
9
W=10^9
W=109 时输入文件可能超过
10
M
i
B
10\space\rm MiB
10 MiB。这意味着,尽管主程序可能很快,但是读入却需要处理超过
1
0
7
10^7
107 个字符的时间。
标准库提供了强大的输入输出方法。但是,出于设计与安全上的原因,它们的性能不一定能满足我们的要求,因此在面对巨大的输入输出文件时可能需要考虑优化。
结论:
- 通常来说,不需要特别在意读写优化。
- 如果习惯
cin/cout
的话,在文件较大时需要记得关闭流同步、使用\n
而不是endl
。 - 输入输出文件大小十分极端时,才需要考虑手写读写优化。这通常意味着输入输出文件超过了
1
0
6
10^6
106 个
int
:即使在这个量级下,手写输入与标准库的差距任通常不到 0.1 s 0.1\rm s 0.1s。
分析:
我们通常使用的 scanf/printf
和 cin/cout
在算法竞赛时性能并不一定能使最优的:
- 通用库功能强大,但代价是会做一些额外的安全性保障,例如异常处理、线程安全等。
- s c a n f / p r i n t f \rm scanf/printf scanf/printf 需要在程序运行时解析格式串,这会花费不少时间。
-
c
i
n
/
c
o
u
t
\rm cin/cout
cin/cout 为了和
s
c
a
n
f
/
p
r
i
n
t
f
\rm scanf/printf
scanf/printf 等
s
t
d
i
o
\rm stdio
stdio 库同步,内部缓冲区被禁用,写入或读取每个字符时都需要与
s
t
d
i
o
\rm stdio
stdio 交互。因此,即使
c
i
n
/
c
o
u
t
\rm cin/cout
cin/cout 在编译期确定读入格式,性能也通常比
s
c
a
n
f
/
p
r
i
n
t
f
\rm scanf/printf
scanf/printf 差。
当然,可以通过以下代码关闭流同步来提升cin/cout
性能。关闭流同步后,cin/cout
会拥有自己的缓冲区,与scanf/printf
混用会带来异常。
std::ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cout << endl
会使用系统调用刷新输出缓冲区,输出大量换行时非常缓慢。
如果需要手写输入函数,使用getchar
读取整数是一个有效减少输入时间的手段。
int read() {
int ret = 0, sgn = 0, ch = getchar();
while (!isdigit(ch)) sgn |= ch == '-', ch = getchar(); // 假设 getchar 必然成功
while (isdigit(ch)) ret = ret * 10 + ch - '0', ch = getchar();
return sgn ? -ret : ret;
}
以上是一种可能的实现。有以下几点需要注意:
- 在输入文件到达文件结束时继续读取,
getchar()
会返回-1
,导致死循环。如果遇到输入可能不完整的情况,需要在第一个while
里判断EOF
。 - 在读取
−
2
32
-2^{32}
−232 时,尽管返回值通常是正确的,但是
ret
会整形溢出,出发未定义行为。 - 输入均为整数时,可以删掉
sgn
相关语句。如果不习惯写sgn
,读负数时需要千万注意!!!
上面的程序中 getchar()
仍可以优化。
s
t
d
i
o
\rm stdio
stdio 库里维护了一个缓冲区,以减少读取硬盘的次数:我们可以自己调用 fread
,来进一步减少读取硬盘的次数和标准库边界检查的shij。标准库里通常缓冲区的大小为
8
K
i
B
8\space\rm KiB
8 KiB,事实上这是一个合理的大小:更大的缓冲区性能提升非常小。我们可以选择稍大一些的
2
2
2 的次幂作为缓冲区的大小,以同时兼顾硬盘性能、缓存友好和内存开销。
const int SIZE = 1 << 14; // 16 KiB
char getc() {
static char buf[SIZE], *begin = buf, *end = buf;
if (begin == end) {
begin = buf;
end = buf + fread(buf, 1, SIZE, stdin); // 假设 fread 必然成功
}
return *begin++;
}
使用以上代码中的 getc()
函数替代 getchar()
,可以获得部分性能提升。
如果评测系统为
L
i
n
u
x
\rm Linux
Linux,可以考虑直接使用 getchar_unlocked()
替代 getchar()
,效率接近 fread
。这是一种放弃了线程安全的函数,但是对于算法竞赛来说没有弊端。
手写输出和输入并没有本质差别,惟需注意一点:如果自己维护了缓冲区,那么在程序结束时一定要记得清空缓冲区。
本地测试读入
2
×
1
0
6
2\times10^6
2×106 个
1
0
9
10^9
109 以内的随机数(
19.4
M
i
B
19.4\space\rm MiB
19.4 MiB),结果如下:
方法 | 时间(C++17 O2) |
---|---|
cin | 440 m s 440\rm ms 440ms |
cin 关闭同步 | 120 m s 120\rm ms 120ms |
scanf | 120 m s 120\rm ms 120ms |
getchar | 53 m s 53\rm ms 53ms |
getchar_unlocked (Linux) | 39 m s 39\rm ms 39ms |
fread | 37 m s 37\rm ms 37ms |
再关闭流同步时,计算 1 0 6 10^6 106 组 A + B \rm A+B A+B 问题并输出,值域 1 0 9 10^9 109,测试结果如下:
方法 | 时间(C++17 O2) |
---|---|
endl | 652 m s 652\rm ms 652ms |
\n | 180 m s 180\rm ms 180ms |
读写优化参考:快读&快写模板【附O2优化】
缓存优化
很多时候我们认为常数优化时“玄学”:一点微小的实现差别可能带来截然不同的性能表现,甚至相同的代码在不同的评测环境下都可能有不同的表现。但是,常数理应是可以分析的,这种分析很多时候都取决于计算机体系结构与操作系统相关的知识。
结论:
要利用好缓存的特性,简而言之,理想的状态是访问缓存存得下的一段连续内存;其次是连续访问的内存;再次是随机访问的内存与完全不连续的访问;最坏情况下是接连访问地址低位完全相同的不同地址,例如同时访问多个大小为 2 2 2 的次幂的数组的相同下标。通常来说,可以在数组最低维是 2 2 2 的高次幂时多开 1 1 1,以很小的代价规避这样的问题。
分析:
我们从最简单的
a
+
b
\rm a+b
a+b 程序出发。
int a, b, c;
int main() {
scanf("%d %d", &a, &b);
c = a + b;
printf("%d\n", c);
}
c = a + b
这一句在
G
C
C
9.3.0
\rm GCC\space9.3.0
GCC 9.3.0(不开优化)下,这句话被翻译为:
mov edx, DWORD PTR a[rip]; 从 a 的地址读取值存放到 edx
mov eax, DWORD PTR b[rip]; 从 b 的地址读取值存放到 eax
add eax, edx; eax += edx
mov DWORD PTR c[rip], eax; 将 eax 存放到 c 的地址
代码里出现的 eax
edx
被称为寄存器(
R
e
g
i
s
t
e
r
\rm Register
Register)。寄存器内置在
C
P
U
\rm CPU
CPU 里,通常只有几十个。
C
P
U
\rm CPU
CPU 只能直接对寄存器进行计算,所有经过
C
P
U
\rm CPU
CPU 处理的数据都需要被加载到寄存器里;同时,寄存器也是数据处理的最小单位。
C
P
U
\rm CPU
CPU 会花一个固定的时间来进行一系列基本操作,这个固定的时间被称为
C
P
U
\rm CPU
CPU 的周期,通常说的
C
P
U
\rm CPU
CPU 频率就是每秒执行的周期数。理想情况下,整数的加减法,位运算等简单的指令需要花费一个周期,对于整数乘除法、浮点数操作等复杂的指令则需要更多的时间。
寄存器非常珍贵,应该让最需要被处理的数据使用,而其他变量和常量储存在内存中,例如上面程序里的三个全局变量:mov
指令的作用是读取和写入内存。内存容量很大,但是由于在
C
P
U
\rm CPU
CPU 外,所以访问起来相对比较慢:内存典型延迟接近
100
n
s
100\rm ns
100ns,而如今
C
P
U
\rm CPU
CPU 典型频率在
4
G
H
z
4\rm GHz
4GHz 左右,这意味着一次读写内存会花去几百个周期。
为了加速内存访问,
C
P
U
\rm CPU
CPU 中引入了缓存(
C
a
c
h
e
\rm Cache
Cache),存取最近用到的一部分数据。缓存利用了程序运行时的时空局部性来加速运算:程序运行时关心的很多数据都是连续访问的。缓存会依据访问地址按块加载访问目标附近的数据(称为缓存行,常见为
64
B
\rm 64B
64B)以减少内存访问,并将剩余地址位划分为高位标签(
t
a
g
\rm tag
tag)和低位索引(
i
n
d
e
x
\rm index
index)两部分,对于最简单的缓存模型来说,访问时会查缓存里下标为
i
n
d
e
x
\rm index
index 的位置是否存在
t
a
g
\rm tag
tag,若存在则直接交给缓存处理,称为缓存命中;否则成为缓存未命中,继续交给内存来处理,并在内存访问后替换
i
n
d
e
x
\rm index
index 的内容。
因此,如果
i
n
d
e
x
\rm index
index 冲突非常多,也就是地址低位相同的访问很集中,那么缓存效率将会大大降低,而这种降低通常体现为访问步长为
2
2
2 的次幂间隔的内存引起的,这也就是不建议将二维数组低维开
2
2
2 的高次幂的原因:连续访问低维相同的下标相同的内容会引起严重的缓存冲突。相比之下,随机访问引起的问题仅仅是缓存行的利用率低,并不会达到最坏情况。
从更高层次的观点来说,可以把整个储存结构看作一个缓存的结构,每层是下一层的缓存。缓存之于寄存器,与内存之于缓存、硬盘之于内存本质相同:越靠近
C
P
U
\rm CPU
CPU 则越快,越小,同时越昂贵;离
C
P
U
\rm CPU
CPU 越远,容量越大,同时相对更便宜。硬盘的读写也有类似缓存行的概念,叫做扇,这也是读入优化是缓冲区大小取
4
K
i
B
4\space\rm KiB
4 KiB 的倍数的原因:现代机械硬盘的扇区大小是
4
K
i
B
4\space\rm KiB
4 KiB。
下表是 2018 2018 2018 年之后 N O I \rm NOI NOI 系列比赛中评测机的( C P U \rm CPU CPU 是 I n t e l \rm Intel Intel 酷睿 i 7 8700 k \rm i7\space 8700k i7 8700k,主频 3.70 G H z \rm 3.70GHz 3.70GHz;内存 32 G i B 32\rm GiB 32GiB)的相关参数 [ 2 ] ^{[2]} [2]。
位置 | 大小 | 访问延迟 |
---|---|---|
寄存器 | < 1 K i B <1\rm KiB <1KiB | 一个周期( ≈ 0.27 n s \approx0.27\rm ns ≈0.27ns) |
L 1 \rm L1 L1 缓存 | 每核 32 K i B 32\rm\space KiB 32 KiB 指令, 32 K i B 32\rm\space KiB 32 KiB 数据 | ≈ 1 n s \approx1\rm ns ≈1ns |
L 2 \rm L2 L2 缓存 | 1.5 M i B 1.5\space\rm MiB 1.5 MiB | ≈ 4 n s \approx4\rm ns ≈4ns |
L 3 \rm L3 L3 缓存 | 12 M i B 12\space\rm MiB 12 MiB | 比 L 2 \rm L2 L2 慢 10 10 10 倍 |
主存 | 32 G i B 32\space\rm GiB 32 GiB | 比 L 3 \rm L3 L3 慢 2 2 2 倍 |
硬盘 | - | 比主存慢 100 100 100 倍起 |
关于缓存优化的实际应用,请待更新。
注释
[
1
]
[1]
[1]:这是《计算机程序设计的艺术》的作者 Donald Kunth 的名言。
[
2
]
[2]
[2]:评测机处理器型号在
N
O
I
\rm NOI
NOI 官网上公布;
C
P
U
\rm CPU
CPU 的具体参数可以在英特尔官网上查到