底层优化(坑)

免责声明

本文仅供参考,由读者引发的一切后果,任何责任由读者自行承担。后果包括但不限于:
1. 优化后程序变慢。
2. 转载导致的侵权。
3. 某些 OJ/OI 上导致的编译错误。
4. 文章中的某些错误引起的争论。

目录

  1. I/O优化
    1. 读入优化
      1. getchar的分析
      2. fread的分析
      3. 效率测试
    2. 输出优化
      1. putchar的分析
      2. fwrite+sprintf的分析
      3. 效率测试
  2. 位运算
    1. 左移和右移的天上地下
    2. M o d A n d 的战争
    3. X o r t e m p 的故事
    4. 神奇的”位运算技巧”
    5. X o r 和网络流的故事
  3. 条件语句
    1. if和?:的故事
    2. switch和if-else的故事
    3. 短路的故事
    4. 布尔表达式和逗号运算符的故事
  4. Standard Template Library
    1. 容器
      1. 完全连续容器
        1. vector的分析
        2. bitset的分析
        3. vector和前向星的比较
        4. 效率测试
      2. 部分连续容器
        1. queuedeque的分析
        2. 效率测试
      3. 节点容器
        1. list的分析
        2. setmap的分析
        3. 和平衡树的比较
    2. 算法

正文

1. I/O优化

I/O 优化是卡常中最常用的技巧,当数据较大的时候,读入输出占用了很多时间。

读入优化

流输入方式很方便,不需要记忆占位符,但每次读入时,它都要检测是否和stdin的同步(是否被freopen改变/是否被scanf读取),因此它是可以和scanf混用的。但也导致了它每次都要从数据开始位置跳转到当前读入的位置,浪费了大量时间,可以用std::ios::sync_with_stdio(false)关闭两者的同步以加快速度,这样做之后会比scanf还快!但必须注意,调用后不能再用freopen
当然,更好的方法是用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. 不能用键盘输入。数据还没输入,程序怎么整段读取。如果你需要在电脑上用键盘输入调试,请把第5行的注释取消。
2. 不能和scanfgetchar等其他读入方法混合使用。因为fread是整段读取的,也就是说所有数据都被读取了,其他函数根本读取不到任何东西(只能从你的读取大小后面开始读),因此,所有类型的变量读入都必须自己写,上面的read函数只支持int类型。

下面是测试,摘自 LibreOJ ,单位为毫秒 ( n = 3 × 10 6 )

读入方法 编译器 [ 0 , 2 1 ) [ 0 , 2 3 ) [ 0 , 2 15 ) [ 0 , 2 31 ) [ 0 , 2 63 )
fread G++   5.4.0   ( O 2 ) 13 13 39 70 111
getchar G++   5.4.0   ( O 2 ) 58 73 137 243 423
cin(关闭同步) G++   5.4.0   ( O 2 ) 161 147 205 270 394
scanf G++   5.4.0   ( O 2 ) 182 175 256 368 574
cin G++   5.4.0   ( O 2 ) 442 429 706 1039 1683

没错,你没有看错,fread以压倒性的优势碾压了其他所有方法,而关闭同步的cinscanf快,并且读long long的时候比getchar还要快,关于为什么不使用位运算的问题下一章会说。


下面的内容某些OI比赛不支持。慎用!

其实还可以更快,Linuxsys/mman.h中存在函数mmapscanf底层实现时调用的就是mmap函数,其作用是把文件映射进内存,这里仅仅给出代码,有兴趣的同学可以自行查阅有关资料。

#include<sys/mman.h>
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 )

Clock Rate: 3.70   GHz RAM: 4 G B

方法 编译器 时间(ms)
printf G++   5.4.0 21278
sprintf+puts G++   5.4.0 21426
纯sprintf不输出 G++   5.4.0 540
fprintf(stdin) G++   5.4.0 26037
putchar G++   5.4.0 98556
sprintf+fwrite G++   5.4.0 607

由上表得,sprintf的复杂度并不高,fwrite也没有给fread丢脸。而fprintf的表现却让人大跌眼镜。getchar的结果很可能具有偶然性,反正上面的数据是在笔者的机器上用同一组随机数测的。

2. 位运算

在很多时候,我们会听到很多有关位运算的追捧,像”位运算的常数很小,比加减法还要快。”这是真的吗?

左移和右移的天上地下

下面有两段代码:

#include<cstdio>
int x=5;
int main(void)
{
    x<<=1;
    printf("%d",x);
    return 0;
}
#include<cstdio>
int x=5;
int main(void)
{
    x*=2;
    printf("%d",x);
    return 0;
}

它们理论上是等价的,但g++翻译成汇编后呢?
两段代码的汇编代码是一样的!下面是x<<=1x*=2被翻译后的代码。

    addl    %eax, %eax

它等价于a=a+a。是不是被打脸了,响不响,痛不痛,红不红,痒不痒……好吧,从上面的例子可以看出,某些时候自作聪明的优化并没有任何用。

那乘以 4 呢?翻译后的代码还是一样的

    sall    $2, %eax

上面的代码等价于x<<=2。现在死心了吧。
或许你还执着于x*=10。这次翻译后的汇编代码终于不一样了,下面是x*=10的汇编代码(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类型,那汇编代码又一样了。

ModAnd的战争

下面是x%2的代码 ( 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条语句 ( O2 )

    movl    _x, %eax
    movl    $LC0, (%esp)
    andl    $1, %eax
    movl    %eax, 4(%esp)
    movl    %eax, _x

Xortemp的故事

相信大家在学交换两个变量的值的时候一定会先学习所谓的“三变量交换法”,然后就被异或取代了。
下面是异或(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和后面的0lowbit(x)=(x&(-x))

判断一个数是不是2的幂n>0?(n&(n-1))==0:false

Xor和网络流的故事

还记得网络流的反向弧?通常某些人喜欢在结构体中新建一个变量来表示这条边的反向弧编号。但这样不免有些浪费,因为在插入新边的时候,我们一般会把两条互为反向弧的边相邻插入,有一个有趣的性质可以完美地解决这个问题:(2*x)^1=2*x+1,(2*x+1)^1=2*x也就是说,我们可以用异或节省下一个空间。

那在汇编中呢?异或本来就是逻辑运算,一条指令xorl $1, _x搞定,但如果用另一个变量呢?编译器需要对变量进行初始化,还多了一条指令。

3. 条件语句

相信条件语句会在程序中经常出现,而且也是不可避免的,谁知道,这么死板的事情还可以再优化。

if?:的故事

在那个夜黑风高的白天。。。
if遇到了?:,然后两人吵了很久很久,然而并分不出上下,没错,?:运算符并不一定比if更快。为什么?它们的汇编长得一模一样,除了了唯一的不同:文件名(汇编第一行会带有文件名)(不论开没开O2)。

正在比较文件 if.s 和 3.s
FC: 找不到差异

那网上的“?:运算符比if快”是个什么鬼?
是它们没写清楚,是“?:运算符比if-else快”。

有什么区别吗?你需要先弄清楚if的工作原理。

if就像一个铁路分叉道口,在CPU底层这种通讯及其不好的地方,在火车开近之前,鬼知道火车要往哪边开,那怎么办?

猜!

  • 如果猜对了,它直接通过,继续前行。
  • 如果猜错了,车头将停止,倒回去,你将铁轨扳至反方向,火车重新启动,驶过道口。

如果是第一种情况,那很好办,那第二种呢?时间就这么浪过去了,假如你非常不走运,那你的程序就会卡在停止-回滚-热启动的过程中。

上面猜的过程就是分支预测

虽然是猜,但编译器也不是随便乱猜,那怎么猜呢?答案是分析之前的运行记录。假设之前很多次都是true,那这次就猜true,如果最近连续很多次都是false,那这次就猜false

但这一切都要看你的CPU是不是某CPU了,如果你遇到了一个神经CPU,那岂不是很GG?因此,一般把容易成立的条件写在前面判断,把不容易成立的条件放在else那里。

但是?:消除了分支预测,因此在布尔表达式的结果近似随机的时候?:更快,否则就是if更快啦。


下面的内容某些OI比赛不支持。慎用!

gcc 存在内置函数:__builtin_expect(!!(x), tf)

tftrue时表示x非常可能为true,反之同理。

用法:if(__builtin_expect(!!(x), tf))


switchif-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);tftrue的时候,会执行后面的sum=-sum,如果tffalse,则不会执行后面的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/RAND_MAX<0.2true的概率约为 20 % ,但t!=0true的概率约为 1 RAND_MAX ,明显小于 20 %
因此,如果把计算一个不含逻辑运算符布尔表达式的计算次数设为 1 次,设计算了 X 次,则对于第 1 段代码, X 的数学期望为 6 5 次,但对于第二段代码, X 的数学期望为 2 × ( RAND_MAX 1 ) RAND_MAX ,远远大于第一段代码。

总结一下,
- 遇到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);

猜一猜,上面的语句执行完后x的值是多少?
答案是 9 。没错,逗号运算符的返回值就是最后一个的值。

现在可以解释上一小结留下总结了吧。

A&&(B,1)||CAtrue时会执行(B,1),返回值为true,因此A&&(B,1)的返回值为true,因此不会执行C,当Afalse时,不会执行(B,1),且A&&(B,1)的值为false,因此会执行C

判断方法 编译器 时间 ( m s )
if G++   5.4.0   ( O 2 ) 16446
?…:0 G++   5.4.0   ( O 2 ) 16415
&& G++   5.4.0   ( O 2 ) 16405
判断方法 编译器 时间 ( m s )
if-else G++   5.4.0   ( O 2 ) 5493
?…:… G++   5.4.0   ( O 2 ) 5455
&&+││ G++   5.4.0   ( O 2 ) 5456

5. Standard Template Library

如果能熟练使用STL,则可以大大减小代码复杂度,提高对于部分人的可读性(前提是那个人也会STL)。但在很多OI比赛中,STL会成为性能瓶颈(不仅仅是常数瓶颈),而且要明白:对于STL来说,O2的开与否带来的不仅仅是几毫秒的差,而是一种蜕变!

容器

完全连续容器

STL中,唯一的真正意义上的完全连续容器应该只有vectorbitset了,其中,vector中的所有元素都是连续的,但它是怎么做到的?在某些编译器中,它实现内存分配的方法是当前的容量不足时,申请一块当前容量 2 倍的新内存空间,然后将所有的老元素全部拷贝到新内存中,添加大量元素的时候的花费的惊人的大。和realloc的时间复杂度一样。

函数原型:extern void *realloc(void *memory,unsigned int newsize);

作用:把memory开头的指针所占用的空间修改成newsize个字节大小。

内部是怎么实现的?在内存中寻找一片足够大的连续空间,然后把原来地方的数据拷到新空间中。

听着就觉慢,因此在vector需要存储大量数据的时候,尽量不要用push_back,直接用resize(size_type size)函数改变大小,或者先用reserve(size_type size)函数预留空间,然后再用push_back方法,这样能尽可能地降低其弊端。

但由于其是完全连续容器,因此在查找的时候效率和普通数组没有太大区别。

邻接表有两种实用的方法,一种是前向星(有人也叫向前星,这里以百度百科为准),另一种就是用vector

尽管vector修改大小的时候会很浪费时间,但要知道,vector可是一个完全连续容器,相比前向星好了不知道多少(从一个数组里跳来跳去)。

在某些需要频繁访问边(例如网络流和最短路),遇到很稠密的图的时候,vector完全连续的作用就被发挥到了最大,而动态开辟空间的消耗也已被降到了最低最低,尤其是在开了O2优化的时候。

下面分别是vector和前向星跑网络流( I S a p )的用时。 ( m = 50 n )

存图方法 编译器 n = 10 4 n = 10 5 n = 5 × 10 5
vector G++ 5.4.0 501 m s 1108 m s 5103 m s
vector G++ 5.4.0 ( O 2 ) 365 m s 794 m s 3855 m s
前向星 G++ 5.4.0 478 m s 1025 m s 5111 m s
前向星 G++ 5.4.0 ( O 2 ) 477 m s 1017 m s 5109 m s

十分明显地,开了O2优化的vector跑得飞快,而随着数据大小的上升,vector的速度也越来越快。

bitset的大小是在编译时就被固定了,因此不会出现和vector一样浪费时间的情况。它的最大作用是节约空间。那里节约空间了?要知道,C++的任何类型都至少占用一个字节,包括bool因此,bool会浪费七个字节的空间。但其实bitset也是可以自己实现的。

由于操作系统限制了CPU的能力(如果CPU是64位的,但是操作系统是32位的,那CPU就只能发挥出32位的能力),因此,如果你还让CPU处理8位的char,毫无疑问,肯定会更慢,因此,在32位系统下,尽量用int,在64位系统下,尽量用long long
下面是测试。

部分连续容器

如果你认为queuedeque应该在上一章出现,那你就错了。事实上,它们并不是完全连续容器。它们内部的实现有点诡谲。为什么这样说呢?因为它们在内存中是部分连续的,也就是说,当内存不够的时候,它们就会再申请一段,然后再连接过去,因此在内存中,它们是不完全连续的,是多个内存块组成的,每个块中存放的元素连续内存,而内存块又像链表一样连接起来。

因为有了这种方法,它们不需要每次申请空间时,都像vector一样复制原来的元素。而是可以在常数时间内完成这个操作。因为它们在内存中是不完全连续的,因此速度不会太低。也可以很好地避免循环队列大小估计错误的可能。

但要注意是否会出现对空队列取队首或出队的操作,而且由于每次都会申请一大段的连续空间,因此需要注意空间的开销。

当我做完测试后却大吃一惊。

实现方法 编译器 时间
arr::queue G++   5.4.0 25 m s
arr::queue G++   5.4.0   ( O 2 ) 15 m s
std::queue G++   5.4.0 433 m s
std::queue G++   5.4.0   ( O 2 ) 31 m s
实现方法 编译器 时间
arr::deque G++   5.4.0 16 m s
arr::deque G++   5.4.0   ( O 2 ) 4 m s
std::deque G++   5.4.0 850 m s
std::deque G++   5.4.0   ( O 2 ) 58 m s

为什么会出现这种情况?
其实queue就是个弱化版的deque,然后废除了一些功能。而deque呢?由于其内存不完全连续,因此导致了过多的内存跳转,浪费了大量时间,由此可得,部分连续容器和完全连续容器的效率差极大。
因此,在能使用连续空间的情况下,尽可能地使用完全连续容器。

节点容器

节点容器就比较多了。常见的mapsetlist都属于它的范畴。

其中,list有点像deque,和其原理差不多,只是内存块的大小恒为 1 ,插入和删除都可以在常数时间内完成。唯一的缺点是不支持二分查找和随机访问。而且由于list是不连续容器,因此它的查询会导致内存频繁地跳转,因此只有在经常需要增加或删除的时候才会考虑使用list

setmap属于关联容器,关联容器支持高效的关键字查找和访问,内部是以一种叫做红黑树的 BST 实现的,但它太复杂了,它的插入有 5 种情况,删除有 6 种情况,是“当今信息界中平均速度最快的BST”,比我们在OI比赛中有能力在时间内手动实现的BST快得多。

因此,setmap的查询时间和红黑树的时间复杂度一致,基本上所有操作都是 l o g size 级别的,包括迭代器的++--操作,它们在BST中相当于求前驱和后继。而且由于它们是节点容器,因此在内存中是断续的,反复地跳转可能导致更多的开销。

但当需要自己实现部分(不包括一些特别的功能,例如求排名)平衡树的功能时,用setmap一般会比手写更优。

如果遇到了不能用setmap的情况,并不推荐用 Splay ,因为它的常数太大了,这里推荐一种极好的 BST 替罪羊树 ( Scapegoat Tree ) ,它的思想非常简单:当且仅当某棵子树的不平衡度超过 α 时,暴力重构整棵子树,从而避免了旋转的操作。

算法

猜你喜欢

转载自blog.csdn.net/linjiayang2016/article/details/80299776