本文仅供参考,由读者引发的一切后果,任何责任由读者自行承担。后果包括但不限于:
1. 优化后程序变慢。
2. 某些 O J / O I OJ/OI OJ/OI上导致的编译错误。
3. 文章中的某些错误引起的争论。
关于GCC的黑科技想必大家也就知道这几句吧:
#pragma GCC optimize (3)
#pragma GCC optimize (2)
#pragma GCC optimize (1)
俗称“吸氧”。
在这里小编就简单地讲一下 O 1 O1 O1, O 2 O2 O2, O 3 O3 O3, O s Os Os(怎么还有个 O s Os Os)
包含下列选项
-fauto-inc-dec
-fcprop-registers
-fdce
-fdefer-pop
-fdelayed-branch
-fdse
-fguess-branch-probability
-fif-conversion2
-fif-conversion
-finline-small-functions
-fipa-pure-const
-fipa-reference
-fmerge-constants -fsplit-wide-types
-ftree-builtin-call-dce
-ftree-ccp
-ftree-ch
-ftree-copyrename
-ftree-dce
-ftree-dominator-opts
-ftree-dse
-ftree-fre
-ftree-sra
-ftree-ter
-funit-at-a-time
-fomit-frame-pointer
除了加载-O1的选项外,还加载
-fthread-jumps
-falign-functions -falign-jumps
-falign-loops -falign-labels
-fcaller-saves
-fcrossjumping
-fcse-follow-jumps -fcse-skip-blocks
-fdelete-null-pointer-checks
-fexpensive-optimizations
-fgcse -fgcse-lm
-findirect-inlining
-foptimize-sibling-calls
-fpeephole2
-fregmove
-freorder-blocks -freorder-functions
-frerun-cse-after-loop
-fsched-interblock -fsched-spec
-fschedule-insns -fschedule-insns2
-fstrict-aliasing -fstrict-overflow
-ftree-switch-conversion
-ftree-pre
-ftree-vrp
除了加载-O2外,还加载
-finline-functions
-funswitch-loops’?
-fpredictive-commoning
-fgcse-after-reload
-ftree-vectorize
在研究编译驱动的makefile的时候,发现GCC的命令行里面有一个-Os的优化选项。
遍查GCC文档,发现了-O0,-O1,-O2,-O3,就是没有发现-Os。
祭出GOOGLE,搜了一下,终于发现这篇文章说明了-Os的作用:
http://www.linuxjournal.com/article/7269
原来-Os相当于-O2.5。是使用了所有-O2的优化选项,但又不缩减代码尺寸的方法。
除了包含-O2的开关外,-Os还会使得下列开关禁用。
-falign-functions
-falign-jumps
-falign-loops
-falign-labels
-freorder-blocks
-freorder-blocks-and-partition
-fprefetch-loop-arrays
-ftree-vect-loop-version
另外,对于多个-O选项的情形,最后一个加载的为有效。比如gcc –O1 –Os –O3 –o test test.c,有效的优化开关为-O3。
一般来说,用的最多的是-O3和-Os,如果遇到程序运行不正常的问题,请降低优化级别,如把-O3改为-O2(情况很少见)。
-march=cpu-type
为cpu-type所针对的机器开启需要的指令集。
cpu-type可以为pentium4、core2、athlon-4等(具体参见文档),比如-march=core2
时,则会开启core2所支持的MMX、SSE、SSE2、SSE3、SSSE3
指令集。
另外还支持native
类型,为编译器所在目前的CPU类型优化指令集,指定-march=native
。
-mfpmath=unit
选择浮点运算单元。
unit
可以为387和sse
。
387为x86系列的默认值,使用标准的387浮点协处理器。
sse
为x64的默认值,使用sse
指令集。
一般你的程序如果有大量的浮点运算的话,在P4和K8以上级别的处理器上推荐开启-mfpmath=sse
。
可以使用-msse2或-msse4.1加载指定的指令集。
-ftracer
执行尾部复制以扩大超级块的尺寸,它简化了函数控制流,从而允许其它的优化措施做的更好。单独使用没啥意义,和其他优化选项一起使用很有效。
-ffast-math
违反IEEE/ANSI标准以提高浮点数计算速度,是个危险的选项,仅在编译不需要严格遵守IEEE规范且浮点计算密集的程序考虑采用。不考虑精度时使用这个选项速度会加快。
-fivopts
在trees上执行归纳变量优化。
-ftree-parallelize-loops=n
使循环并行化。只当循环无数据依赖时使用,在多核CPU上时使用才会有利。
-ftree-loop-linear
在trees上进行线型循环转换。它能够改进缓冲性能并且允许进行更进一步的循环优化。
-fforce-addr
必须将地址复制到寄存器中才能对他们进行运算。由于所需地址通常在前面已经加载到寄存器中了,所以这个选项可以改进代码。
-floop-interchange
交换循环变量。
例如:
DO J = 1, M
DO I = 1, N
A(J, I) = A(J, I) * C
ENDDO
ENDDO
会改变为
DO I = 1, N
DO J = 1, M
A(J, I) = A(J, I) * C
ENDDO
ENDDO
改变后,如果N比缓冲区大的话,会更有效率。这是因为Fortran里数组是以列主元为排列方式的。当然这个选项并不仅仅用于Fortran,Gcc家族的编译器都有效。
-fvisibility=hidden
设置默认的ELF镜像中符号的可见性为隐藏。使用这个特性可以非常充分的提高连接和加载共享库的性能,生成更加优化的代码,提供近乎完美的API输出和防止符号碰撞。我们强烈建议你在编译任何共享库(Dll)的时候使用该选项。
-fvisibility-inlines-hidden
默认隐藏所有内联函数,从而减小导出符号表的大小,既能缩减文件的大小,还能提高运行性能,强烈建议你在编译任何共享库的时候使用该选项。
-minline-all-stringops
默认时GCC只将确定目的地会被对齐在至少4字节边界的字符串操作内联进程序代码。该选项启用更多的内联并且增加二进制文件的体积,但是可以提升依赖于高速 memcpy
, strlen
, memset
操作的程序的性能。
-m64
生成专门运行于 64 64 64位环境的代码,不能运行于 32 32 32位环境,仅用于 x 86 _ 64 x86\_64 x86_64[含 E M T 64 EMT64 EMT64]环境。
-fprefetch-loop-arrays
生成数组预读取指令,对于使用巨大数组的程序可以加快代码执行速度,适合数据库相关的大型软件等。具体效果如何取决于代码。不能和-Os一起使用。
-pipe
在编译过程的不同阶段之间使用管道而非临时文件进行通信,可以加快编译速度。建议使用。
综上,比较安全的开关为
-pipe -O3(-Os)
-march=native
-mfpmath=sse
-msse2 -ftracer
-fivopts
-ftree-loop-linear
-fforce-addr
如果不需要多高的精度,比如GUI框架之类,加入
-ffast-math
如果是编译的是共享库(. d l l dll dll,. a a a)加入
-fvisibility=hidden
-fvisibility-inlines-hidden
注意某些比赛不能使用 O 1 , O 2 , O 3 , O s O1,O2,O3,Os O1,O2,O3,Os
I / O I/O I/O优化是卡常中最常用的技巧,当数据较大的时候,读入输出占用了很多时间。
流输入方式很方便,不需要记忆占位符,但每次读入时,它都要检测是否和stdin
的同步(是否被freopen
改变/是否被scanf
读取),因此它是可以和scanf
混用的。但也导致了它每次都要从数据开始位置跳转到当前读入的位置,浪费了大量时间,可以用std::ios::sync_with_stdio(false)
关闭两者的同步以加快速度,这样做之后会比scanf
还快!但必须注意,调用后不能再用freopen
,但是还可以用fstream
当然,更好的方法是用getchar
自己写读入函数。
inline void read(int &sum)
{
char ch=getchar();
int tf=0;
sum=0;
while((ch<'0'||ch>'9')&&(ch!='-'))
ch=getchar();
tf=((ch=='-')&&(ch=getchar()));
while(ch>='0'&&ch<='9')
sum=sum*10+(ch-48),ch=getchar();
(tf)&&(sum=-sum);
}
这样效率有了很大的提升,而且可以和scanf
混用(字符串等),第7行和第10行的代码后面会说。
我们知道,getchar
是逐字符读取的,在stdio.h
中,有一个fread
函数,能整段读取,比getchar
还快,并且支持freopen
(完美兼容)和fopen
(需要把下面的所有stdin
改成你的文件指针)
函数原型:size_t fread(void *buffer,size_t size,size_t count,FILE *stream);
作用:从stream
中读取count
个大小为size
个字节的数据,放到数组buffer
中,返回成功了多少个大小为size个字节的数据。
inline char nc()
{
static char buf[1000000],*p1=buf,*p2=buf;
return p1==p2&&(p2=(p1=buf)+fread(buf,1,1000000,stdin),p1==p2)?EOF:*p1++;
}
//#define nc getchar
inline void read(int &sum)
{
char ch=nc();
int tf=0;
sum=0;
while((ch<'0'||ch>'9')&&(ch!='-'))
ch=nc();
tf=((ch=='-')&&(ch=nc()));
while(ch>='0'&&ch<='9')
sum=sum*10+(ch-48),ch=nc();
(tf)&&(sum=-sum);
}
但要注意,由于这种方法是整段读取的,这也造就了它两个巨大的Bug:
1 1 1.不能用键盘输入。数据还没输入,程序怎么整段读取。如果你需要在电脑上用键盘输入调试,请把第6行的注释取消。
2 2 2.不能和scanf
,getchar
等其他读入方法混合使用。因为fread是整段读取的,也就是说所有数据都被读取了,其他函数根本读取不到任何东西(只能从你的读取大小后面开始读),因此,所有类型的变量读入都必须自己写,上面的read
函数只支持int
类型。
下面是测试,摘自LibreOJ,单位为毫秒( n = 3 × 1 0 6 n=3×10^6 n=3×106)
读入方法 | 编译器 | [ 0 , 2 1 ) [0,2^1) [0,21) | [ 0 , 2 3 ) [0,2^3) [0,23) | [ 0 , 2 15 ) [0,2^{15}) [0,215) | [ 0 , 2 31 ) [0,2^{31}) [0,231) | [ 0 , 2 63 ) [0,2^{63}) [0,263) |
---|---|---|---|---|---|---|
fread | G++ 5.4.0 (−O2) | 13 | 13 | 39 | 70 | 111 |
getchar | G++ 5.4.0 (−O2) | 58 | 73 | 137 | 243 | 423 |
cin(关闭同步) | G++ 5.4.0 (−O2) | 161 | 147 | 205 | 270 | 394 |
scanf | G++ 5.4.0 (−O2) | 182 | 175 | 256 | 368 | 574 |
cin | G++ 5.4.0 (−O2) | 442 | 429 | 706 | 1039 | 1683 |
没错,你没有看错,fread
以压倒性的优势碾压了其他所有方法,而关闭同步的cin
比scanf
快,并且读long long
的时候比getchar
还要快。
下面的内容某些OI比赛不支持。慎用!
其实还可以更快,Linux
下sys/mman.h
中存在函数mmap
,scanf
底层实现时调用的就是mmap
函数,其作用是把文件映射进内存,这里仅仅给出代码,有兴趣的同学可以自行查阅有关资料。
#include
namespace Inputs
{
char* s;
int a[24];
io(){s=(char*)mmap(NULL,1 << 26 ,PROT_READ,MAP_PRIVATE,fileno(stdin),0);}
void scan(char* u)
{
while(*s<48)
++s;
while(*s>32)
*u++=*s++;
*u=0;
}
int scan(){
int Hibiki=0,v=1;
while(*s<48)
v=*s++^45?1:-1;
while(*s>32)
Hibiki=Hibiki*10+*s++-48;
return Hibiki*v;
}
}
有人喜欢用isdigit
宏,但这个宏在本机测试上的确更慢了,这个宏的效率还有待研究。
输出优化并不是十分常用,因为很少有题目要求大量输出,这里仍然给出代码。(第4行以后再说)
void write(int x)
{
if(x==0)
return (void)putchar('0');
(x<0)&&(putchar('-'),x=-x);
static int a[26];
int tp=0;
while(x>0){
a[++tp]=x%10;
x/=10;
}
for(int i=tp;i>0;i--)
putchar(a[i]+'0');
}
如果你按照上面的代码打,那只能说明你的想象力不够,
fwrite
扑进fread
的怀里失声痛哭。fwrite
的优势被你完美地抛弃了,取而代之的是逐字符输出的putchar
函数原形:
size_t fwrite(const void *buffer,size_t size,size_t count,FILE *stream);
作用:把buffer
中的数据拆成大小为size
个字节的数据,输出前count
个到stream
中,实际成功写入的大小为size
的数据块数目。
namespace Ostream
{
static const int BUF=50000000;
char buf[BUF],*h=buf;
inline void put(char ch)
{
h==buf+BUF?(fwrite(buf,1,BUF,stdout),h=buf):0;
*h++=ch;
}
inline void putint(int num)
{
static char _buf[30];
sprintf(_buf,"%d",num);//(*)
for (char *s=_buf;*s;s++)put(*s);
}
inline void finish()
{
fwrite(buf,1,h-buf,stdout);
}
};
有人也许会说,上面的代码的(*)处还可以优化,但这个优化的意义并不大,当然,也可以优化。
下面是测试
( n = 5000000 , r a n d n=5000000,rand n=5000000,rand)
C l o c k R a t e : 3.70 G H z Clock Rate:3.70 GHz ClockRate:3.70GHz
R A M : 4 G B RAM:4GB RAM:4GB
方法 | 编译器 | 时间(ms) |
---|---|---|
p r i n t f printf printf | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 21278 21278 21278 |
s p r i n t f + p u t s sprintf+puts sprintf+puts | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 21426 21426 21426 |
纯 s p r i n t f sprintf sprintf不输出 | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 540 540 540 |
f p r i n t f ( s t d i n ) fprintf(stdin) fprintf(stdin) | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 26037 26037 26037 |
p u t c h a r putchar putchar | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 98556 98556 98556 |
s p r i n t f + f w r i t e sprintf+fwrite sprintf+fwrite | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 607 607 607 |
由上表得,sprintf
的复杂度并不高,fwrite
也没有给fread
丢脸。而fprintf
的表现却让人大跌眼镜。getchar
的结果很可能具有偶然性,反正上面的数据是在小编的机器上用同一组随机数测的。
在很多时候,我们会听到很多有关位运算的追捧,像”位运算的常数很小,比加减法还要快。”这是真的吗?
下面有两段代码:
#include
int x=5;
int main(void)
{
x<<=1;
printf("%d",x);
return 0;
}
#include
int x=5;
int main(void)
{
x*=2;
printf("%d",x);
return 0;
}
它们理论上是等价的,但g++
翻译成汇编后呢?
两段代码的汇编代码是一样的!下面是x<<=1
和x*=2
被翻译后的代码。
addl %eax, %eax
它等价于a=a+a
。是不是被打脸了,响不响,痛不痛,红不红,痒不痒……好吧,从上面的例子可以看出,某些时候自作聪明的优化并没有任何用。
那乘以4呢?翻译后的代码还是一样的
sall $2, %eax
上面的代码等价于x<<=2
。现在死心了吧。
或许你还执着于x*=10
。这次翻译后的汇编代码终于不一样了,下面是x*=10
的汇编代码( O 2 O2 O2优化)
leal (%eax,%eax,4), %eax
addl %eax, %eax
只有两条指令
x=x+x*4 //别看是加法和乘法,却是一条指令完成。
x=x+x //加法还不容易吗
那些喜欢用x=(x<<3)+(x<<1)
的人请自重。
那是不是说位运算一无所用呢?并不是,在除法方面有不少用处。
右移的汇编代码
movl _x, %eax
sarl %eax
movl %eax, _x
movl _x, %eax
除以2的代码
movl _x, %eax
movl %eax, %edx //(del)
shrl $31, %edx //(del)
addl %edx, %eax //(del)
sarl %eax
movl %eax, _x
movl _x, %eax
整整多了三行。
但这种情况仅限于有符号的整数,因为有符号的整数的右移和除法不是同一个东西,编译器还需要修正一下。
如果用的是unsigned
类型,那汇编代码又一样了。
下面是x%2
的代码( − O 2 -O2 −O2)
movl _x, %eax
movl $LC0, (%esp)
movl %eax, %edx //(del)
shrl $31, %edx //(del)
addl %edx, %eax //(del)
andl $1, %eax
subl %edx, %eax //(del)
movl %eax, 4(%esp)
movl %eax, _x
那x&1
呢?少了4条语句( − O 2 -O2 −O2)
movl _x, %eax
movl $LC0, (%esp)
andl $1, %eax
movl %eax, 4(%esp)
movl %eax, _x
Xor
和temp
的故事相信大家在学交换两个变量的值的时候一定会先学习所谓的“三变量交换法”,然后就被异或取代了。
下面是异或(a^=b^=a^=b)
的汇编
movl _b, %edx
movl _a, %eax
xorl %edx, %eax
xorl %eax, %edx
xorl %edx, %eax
movl %eax, _a
xorl %eax, %eax
movl %edx, _b
有movl
指令和xorl
指令各4
条。
那三变量交换法(int t=a;a=b,b=t;)
呢?
movl _a, %eax
movl _b, %edx
movl %eax, _b
xorl %eax, %eax
movl %edx, _a
从此,有temp再没有异或。
网上有很多奇奇怪怪的方法,例如取绝对值(n^(n>>31))-(n>>31)
,取两个数的最大值b&((a-b)>>31)|a&(~(a-b)>>31)
,取两个数的最小值a&((a-b)>>31)|b&(~(a-b)>>31)
。
喜欢用上面代码的人难道没有自己亲自数一数上面有多少条位运算的指令吗?
但凡事没有绝对,还是有一些优秀的例子的:
取最后一个1
和后面的0
lowbit(x)=(x&(-x))
判断一个数是不是 2 的幂
n>0?(n&(n-1))==0:false
还记得网络流的反向弧?通常某些人喜欢在结构体中新建一个变量来表示这条边的反向弧编号。但这样不免有些浪费,因为在插入新边的时候,我们一般会把两条互为反向弧的边相邻插入,有一个有趣的性质可以完美地解决这个问题:
(2*x)^1=2*x+1,(2*x+1)^1=2*x
也就是说,我们可以用异或节省下一个空间。
那在汇编中呢?异或本来就是逻辑运算,一条指令xorl $1, _x
搞定,但如果用另一个变量呢?编译器需要对变量进行初始化,还多了一条指令。
相信条件语句会在程序中经常出现,而且也是不可避免的,谁知道,这么死板的事情还可以再优化。
if
和?:的
故事在那个夜黑风高的白天。。。
if遇到了**??*,然后两人吵了很久很久,然而并分不出上下,没错,?:
运算符并不一定比if
更快。为什么?它们的汇编长得一模一样,除了了唯一的不同:文件名(汇编第一行会带有文件名)(不论开没开 O 2 O2 O2)。
正在比较文件 if.s 和 3.s
FC: 找不到差异
那网上的“?:运算符比if快”是个什么鬼?
是它们没写清楚,是“?:运算符比if-else快”。
有什么区别吗?你需要先弄清楚if的工作原理。
if就像一个铁路分叉道口,在CPU底层这种通讯及其不好的地方,在火车开近之前,鬼知道火车要往哪边开,那怎么办?
如果是第一种情况,那很好办,那第二种呢?时间就这么浪过去了,假如你非常不走运,那你的程序就会卡在停止-回滚-热启动的过程中。
上面猜的过程就是分支预测。
虽然是猜,但编译器也不是随便乱猜,那怎么猜呢?答案是分析之前的运行记录。假设之前很多次都是true
,那这次就猜true
,如果最近连续很多次都是false
,那这次就猜false
。
但这一切都要看你的CPU
是不是某CPU
了,如果你遇到了一个神经CPU
,那岂不是很 G G GG GG?因此,一般把容易成立的条件写在前面判断,把不容易成立的条件放在 e l s e else else那里。
但是 ? : ?: ?:消除了分支预测,因此在布尔表达式的结果近似随机的时候 ? : ?: ?:更快,否则就是 i f if if更快啦。
下面的内容某些OI比赛不支持。慎用!
g c c gcc gcc存在内置函数:__builtin_expect(!!(x), tf)
当tf
为 t r u e true true时表示 x x x非常可能为 t r u e true true,反之同理。
用法:if(__builtin_expect(!!(x), tf))
switch
和if-else
的故事下面有两段代码,你觉得那段更快呢?
if(x==1)
x++;
else if(x==2)
x*=2;
else if(x==3)
x/=3;
else if(x==4)
x>>=1;
else if(x==5)
x=1;
switch(x){
case 1:
x++;break;
case 2:
x*=2;break;
case 3:
x/=3;break;
case 4:
x>>=1;break;
case 5:
x=1;
}
显然地,下面的代码更快,因为上面的代码需要逐条判断,而下面的代码直接跳转。
那是不是所有switch
都比if
快呢?凡事没有绝对,当switch
遇到default
的时候,整个程序的效率就会大打折扣,因为它又回到了if
的无脑判断模式。再比如,当if
用来判断区间的时候就比switch
快,if
只需要做三次逻辑运算(两条判断,一条逻辑与),而switch
呢?我就呵呵一笑。
此短路非彼短路,它指的是一种运算符的特性。
我们常用的逻辑运算符,例如&&
和||
都是短路运算符。什么意思呢?比如在运算A&&B
时,如果发现A已经为false
,就不会再计算B。
现在你能解释第一章中留下的问题了吗?
(tf)&&(sum=-sum);
当tf
为true
的时候,会执行后面的sum=-sum
,如果tf
为false
,则不会执行后面的sum=-sum
。
等价于如下语句:
if(tf)
sum=-sum;
tf=((ch=='-')&&(ch=getchar()));
当ch='-'
时,会执行后面的ch=getchar()
,因为getchar
一般不会等于0
(如果不放心可以写成tf=((ch=='-')&&((ch=getchar()),true)))
,因此tf的结果等于true
。
当ch!='-'
时,不会执行后面的ch=getchar()
,tf
的值为false
。
等价于如下语句:
if(ch=='-')
{
tf=true;
ch=getchar();
}
总结一下:
if(A) B; ⇔ (A)&&(B)
if(A) B; else C; ⇔ A&&(B,1)||C
为什么?详情参见下一小节。
如果短路运算符只能改写if
语句,那这里就不会浪费这么多篇幅来介绍这个东西。事实上,这个东西比我们想象得有用得多。看下面两段代码:
double t=rand();
if(t/RAND_MAX<0.2&&t!=0)
printf("%d",t);
double t=rand();
if(t!=0&&t/RAND_MAX<0.2)
printf("%d",t);
你认为那一份代码会更快?
好像没什么区别对吧。但对于CPU
来说很有区别。第一段代码中的 t / R A N D _ M A X < 0.2 t/RAND\_MAX<0.2 t/RAND_MAX<0.2为true的概率约为
20%,但
t!=0为
true`的概率约为 1 R A N D _ M A X \frac{1}{RAND\_MAX} RAND_MAX1,明显小于20%。
因此,如果把计算一个不含逻辑运算符布尔表达式的计算次数设为 1 1 1次,设计算了 X X X次,则对于第 1 1 1段代码, X X X的数学期望为 65 65 65次,但对于第二段代码, X X X的数学期望为 2 × ( R A N D _ M A X − 1 ) R A N D _ M A X \frac{2\times (RAND\_MAX-1)}{RAND\_MAX} RAND_MAX2×(RAND_MAX−1),远远大于第一段代码。
总结一下,
A&&B
时,优先把可能为false
的表达式放在前面。A||B
时,优先把可能为true
的表达式放在前面。为什么要专门设置这么一小节呢?
因为很多人喜欢用if(x==true)
,直接用if(x)
就好了。还有x==false
和!x
,它们也是等价的。
现在,请另一位大佬隆重登场:逗号运算符。
若干条语句可以通过逗号运算符合并成一条语句。
例如t=a;a=b;b=t;可以写成t=a,a=b,b=t;有什么用吗?它的返回值。
int x=(1,5,4,2,6,3,9);
答案是 9。没错,逗号运算符的返回值就是最后一个的值。
现在可以解释上一小结留下总结了吧。
A&&(B,1)||C
当A
为true
时会执行(B,1)
,返回值为true
,因此A&&(B,1)
的返回值为true
,因此不会执行C
,当A
为false
时,不会执行(B,1)
,且A&&(B,1)
的值为false
,因此会执行C
如果能熟练使用 S T L STL STL,则可以大大减小代码复杂度,提高对于部分人的可读性(前提是那个人也会 S T L STL STL)。但在很多 O I OI OI比赛中, S T L STL STL会成为性能瓶颈(不仅仅是常数瓶颈),而且要明白:对于 S T L STL STL来说, O 2 O2 O2的开与否带来的不仅仅是几毫秒的差,而是一种蜕变!
在 S T L STL STL中,唯一的真正意义上的完全连续容器应该只有 v e c t o r vector vector和 b i t s e t bitset bitset了,其中, v e c t o r vector vector中的所有元素都是连续的,但它是怎么做到的?在某些编译器中,它实现内存分配的方法是当前的容量不足时,申请一块当前容量 2 2 2倍的新内存空间,然后将所有的老元素全部拷贝到新内存中,添加大量元素的时候的花费的惊人的大。和 a l l o c a t o r allocator allocator的时间复杂度一样。
函数原型:extern void *realloc(void *memory,unsigned int newsize);
作用:把 m e m o r y memory memory开头的指针所占用的空间修改成 n e w s i z e newsize newsize个字节大小。
内部是怎么实现的?在内存中寻找一片足够大的连续空间,然后把原来地方的数据拷到新空间中。
听着就觉慢,因此在 v e c t o r vector vector需要存储大量数据的时候,尽量不要用 p u s h _ b a c k push\_back push_back,直接用 r e s i z e ( s i z e _ t y p e s i z e ) resize(size\_type size) resize(size_typesize)函数改变大小,或者先用 r e s e r v e ( s i z e _ t y p e s i z e ) reserve(size\_type size) reserve(size_typesize)函数预留空间,然后再用 p u s h _ b a c k push\_back push_back方法,这样能尽可能地降低其弊端。
但由于其是完全连续容器,因此在查找的时候效率和普通数组没有太大区别。
邻接表有两种实用的方法,一种是前向星(有人也叫向前星,这里以百度百科为准),另一种就是用 v e c t o r vector vector。
尽管 v e c t o r vector vector修改大小的时候会很浪费时间,但要知道, v e c t o r vector vector可是一个完全连续容器,相比前向星好了不知道多少(从一个数组里跳来跳去)。
在某些需要频繁访问边(例如网络流和最短路),遇到很稠密的图的时候, v e c t o r vector vector完全连续的作用就被发挥到了最大,而动态开辟空间的消耗也已被降到了最低最低,尤其是在开了 O 2 O2 O2优化的时候。
下面分别是 v e c t o r vector vector和前向星跑网络流( I S a p ISap ISap)的用时。( m = 50 n m=50n m=50n)
存图方法 | 编译器 | n = 1 0 4 n=10^4 n=104 | n = 1 0 5 n=10^5 n=105 | n = 5 ∗ 1 0 5 n=5*10^5 n=5∗105 |
---|---|---|---|---|
vector |
G + + 5.4.0 G++5.4.0 G++5.4.0 | 501ms | 1108ms | 5103ms |
vector |
G + + 5.4.0 G++5.4.0 G++5.4.0( − O 2 -O2 −O2) | 365ms | 794ms | 3855ms |
前向星 |
G + + 5.4.0 G++5.4.0 G++5.4.0 | 478ms | 1025ms | 5111ms |
前向星 |
G + + 5.4.0 G++5.4.0 G++5.4.0( − O 2 -O2 −O2) | 477ms | 1017ms | 5109ms |
十分明显地,开了 O 2 O2 O2优化的 v e c t o r vector vector跑得飞快,而随着数据大小的上升, v e c t o r vector vector的速度也越来越快。
b i t s e t bitset bitset的大小是在编译时就被固定了,因此不会出现和 v e c t o r vector vector一样浪费时间的情况。它的最大作用是节约空间。那里节约空间了?要知道, C + + C++ C++的任何类型都至少占用一个字节,包括 b o o l bool bool因此, b o o l bool bool会浪费七个字节的空间。但其实 b i t s e t bitset bitset也是可以自己实现的。
由于操作系统限制了 C P U CPU CPU的能力(如果 C P U CPU CPU是 64 64 64位的,但是操作系统是 32 32 32位的,那 C P U CPU CPU就只能发挥出 32 32 32位的能力),因此,如果你还让 C P U CPU CPU处理 8 8 8位的 c h a r char char,毫无疑问,肯定会更慢,因此,在32位系统下,尽量用int,在64位系统下,尽量用 l o n g l o n g long long longlong。
如果你认为 q u e u e queue queue(一种容器适配器)和 d e q u e deque deque应该在上一章出现,那你就错了。事实上,它们并不是完全连续容器。它们内部的实现有点诡谲。为什么这样说呢?因为它们在内存中是部分连续的,也就是说,当内存不够的时候,它们就会再申请一段,然后再连接过去,因此在内存中,它们是不完全连续的,是多个内存块组成的,每个块中存放的元素连续内存,而内存块又像链表一样连接起来。
因为有了这种方法,它们不需要每次申请空间时,都像 v e c t o r vector vector一样复制原来的元素。而是可以在常数时间内完成这个操作。因为它们在内存中是不完全连续的,因此速度不会太低。也可以很好地避免循环队列大小估计错误的可能。
但要注意是否会出现对空队列取队首或出队的操作,而且由于每次都会申请一大段的连续空间,因此需要注意空间的开销。
当我做完测试后却大吃一惊。
实现方法 | 编译器 | 时间 |
---|---|---|
a r r : q u e u e arr:queue arr:queue | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 25 m s 25ms 25ms |
a r r : q u e u e arr:queue arr:queue | G + + 5.4.0 ( − O 2 ) G++ 5.4.0(-O2) G++5.4.0(−O2) | 15 m s 15ms 15ms |
s t d : q u e u e std:queue std:queue | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 433 m s 433ms 433ms |
s t d : q u e u e std:queue std:queue | G + + 5.4.0 ( − O 2 ) G++ 5.4.0(-O2) G++5.4.0(−O2) | 31 m s 31ms 31ms |
实现方法 | 编译器 | 时间 |
---|---|---|
a r r : d e q u e arr:deque arr:deque | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 16 m s 16ms 16ms |
a r r : d e q u e arr:deque arr:deque | G + + 5.4.0 ( − O 2 ) G++ 5.4.0(-O2) G++5.4.0(−O2) | 4 m s 4ms 4ms |
s t d : d e q u e std:deque std:deque | G + + 5.4.0 G++ 5.4.0 G++5.4.0 | 850 m s 850ms 850ms |
s t d : d e q u e std:deque std:deque | G + + 5.4.0 ( − O 2 ) G++ 5.4.0(-O2) G++5.4.0(−O2) | 58 m s 58ms 58ms |
为什么会出现这种情况?
其实 q u e u e queue queue默认实现方法是封装 d e q u e deque deque,通过更改模板参数可以使用 v e c t o r vector vector和 l i s t list list实现。而 d e q u e deque deque呢?由于其内存不完全连续,因此导致了过多的内存跳转,浪费了大量时间,由此可得,部分连续容器和完全连续容器的效率差极大。
因此,在能使用连续空间的情况下,尽可能地使用完全连续容器。
节点容器就比较多了。常见的 m a p map map, s e t set set和 l i s t list list都属于它的范畴。
其中, l i s t list list有点像 d e q u e deque deque,和其原理差不多,只是内存块的大小恒为 1 1 1,插入和删除都可以在常数时间内完成。唯一的缺点是不支持二分查找和随机访问。而且由于 l i s t list list是不连续容器,因此它的查询会导致内存频繁地跳转,因此只有在经常需要增加或删除的时候才会考虑使用 l i s t list list。
s e t set set和 m a p map map属于关联容器,关联容器支持高效的关键字查找和访问,内部是以一种叫做红黑树的 B S T BST BST
实现的,但它太复杂了,它的插入有 5 5 5种情况,删除有 6 6 6种情况,是“当今信息界中平均速度最快的 B S T BST BST”,比我们在 O I OI OI比赛中有能力在时间内手动实现的BST快得多。
因此, s e t set set和 m a p map map的查询时间和红黑树的时间复杂度一致,基本上所有操作都是 l o g s i z e logsize logsize级别的,包括迭代器的 + + ++ ++和 − − -- −−操作,它们在 B S T BST BST中相当于求前驱和后继。而且由于它们是节点容器,因此在内存中是断续的,反复地跳转可能导致更多的开销。
但当需要自己实现部分(不包括一些特别的功能,例如求排名)平衡树的功能时,用 s e t set set和 m a p map map一般会比手写更优。
如果遇到了不能用 s e t set set和 m a p map map的情况,并不推荐用 S p l a y Splay Splay
,因为它的常数太大了,这里推荐一种极好的 B S T BST BST:替罪羊树( S c a p e g o a t T r e e Scapegoat Tree ScapegoatTree),它的思想非常简单:当且仅当某棵子树的不平衡度超过 α \alpha α时,暴力重构整棵子树,从而避免了旋转的操作。