卡常数大法好
这篇主要是搬运了点课件大概
有机会了会试着加进去一些有用的代码方面的小优化的
(前提: w位 代表 计算机位数)
1.逻辑运算符
二元逻辑运算符是短路的,即当表达式左边的值能确定表达式的值时就不会计算表达式右边的值
简单的if可用三目运算符代替,形如
()?():(1);
可以嵌套,但套多了代码可读性就太差了,再出点错误可能很难搞
另外switch也是可以代替一些简单的if的,貌似会快一点
2.位运算
x<<k (0<=k<w) 将x的位表示的前k位删去,并在最后面添加k个0
x>>k (0<=k<w) 将x的位表示的后k位删去,
无符号数(逻辑右移) : 在最前面添加k个0,
有符号数(算数右移) : 在最前面添加k个符号位
注意 : k>=w时位移运算的行为是未定义的(undefined)
位运算可用来表示一些四则运算(具体能不能全表示也不太清楚…),使用位运算表示四则运算的意义也就是卡(zhuang)常(bi)吧
一些简单的位运算技巧:(u32为32位无符号整数类型)
1)读取某个二进制位:
inline u32 read_bit(u32 x, int pos){ return (x>>pos)&1; }
2)将某位置为1/0
inline u32 set_bit(u32 x, int pos) { return x | (1u << pos); } inline u32 clear_bit(u32 x, int pos) { return x & ~(1u << pos); }
3)求二进制表示中1的个数
查表法
int cnt_table[1 << 16]; void count_pre() { cnt_table[ 0] = 0; for (int i = 0; i < 1 << 16; i++) { cnt_table[i] = cnt_table[i >> 1] + (i & 1); } } inline int count(u32 x) { return cnt_table[x >> 16] + cnt_table[x & 65535u]; }
4)求二进制表示中后缀0的个数
二分法
inline int count_trailing_zeros(u32 x) { int ret = 0; if (!(x & 65535u)) x >>= 16, ret |= 16; if (!(x & 255u)) x >>= 8, ret |= 8; if (!(x & 15u)) x >>= 4, ret |= 4; if (!(x & 3u)) x >>= 2, ret |= 2; if (!(x & 1u)) x >>= 1, ret |= 1; return ret + !x; }
也可用查表法
int clz_table[1 << 16]; void clz_pre() { clz_table[ 0] = 16; for ( int i = 1; i < 1 << 16; i++) { clz_table[i] = clz_table[i >> 1] - 1; } } inline int count_leading_zeros(u32 x) { return x >> 16 ? clz_table[x >> 16] : 16 + clz_table[x & 65535u]; }
5)将整数用作集合
一个w位的整数,可以看作一个大小为w的集合
即用每一个位来表示一个元素是否存在
集合的交、并、补、求大小等运算即可做到O(n/w)
集合的空间复杂度也是O(n/w),或n个bit
这叫”bitset”
在C++中,有同名的库支持这些操作
6)静态仙人掌:
维护一个n个点的仙人掌,支持三种操作:
1.将点x到根最短路径上所有的点黑白颜色取反
2.将点x到根最长简单路径上所有的点的颜色取反
3.询问点x的子仙人掌中黑白点的数目
用bitset!
预处理每个点到根的最短/最长简单路径上的点的编号集合
修改只需异或,询问只需求集合大小
空间不够→分块+可持久化 !
复杂度O(n^2/w)?跑得过就行 !
比圆方树好写多了 !
(该写圆方树还是写吧)
7)状压DP:
在一些动态规划问题中,状态可以用一个较小的集合来表示
可以用整数来给集合进行“编码”,从而用位运算简化动态规划的代码实现
在某些问题中,我们需要枚举子集,可以用一下小技巧:
for (int i = S; i; i = (i - 1) & S) { do_something(i); }
复杂度 O(3 ^ n)
3.在CPU上优化程序
1)减少不必要的计算
对于同一个值的重复计算,存入临时变量中
例如在某些区间DP中,区间长度或端点总需要重复计算(虽然也不会有人卡这个常)
for(int l=2;l<=n;l++){ for(int i=1;i<=n-l+1;i++){ int rig=i+l-1; if(line[i]<line[i+1]) f[i][rig][0]+=f[i+1][rig][0]; if(line[i]<line[rig]) f[i][rig][0]+=f[i+1][rig][1]; if(line[rig]>line[rig-1]) f[i][rig][1]+=f[i][rig-1][1]; if(line[rig]>line[i]) f[i][rig][1]+=f[i][rig-1][0]; f[i][rig][0]%=p; f[i][rig][1]%=p; } }
2)消除条件跳转
这段程序的功能是将较小的值放入a数组,较大值放入b数组
void minmax1(long *a, long *b, int n) { for (int i = 0; i < n; i++) { if (a[i] > b[i]) { long t = a[i]; a[i] = b[i], b[i] = t; } } }
在六代i5上进行测试
对于适合进行分支预测的数据,测得平均一次循环需要4.0个时钟周期
对于随机数据,测得平均一次循环需要12.8个时钟周期
可见,分支预测错误的惩罚为17.6个时钟周期
优化:
用三元运算符重写,让编译器生成一种基于条件传送的汇编代码
测得不论数据如何,平均一次循环都只需要4.1个时钟周期
void minmax2(long *a, long *b, int n) { for (int i = 0; i < n; i++) { long mi = a[i] < b[i] ? a[i] : b[i]; long ma = a[i] < b[i] ? b[i] : a[i]; a[i] = mi, b[i] = ma; } }
可能使用的情景:二路归并
3)循环展开
考虑这段程序
double sum(double *a, int n) { double s = 0; for (int i = 0; i < n; i++) { s += a[i]; } return s; }
测得平均每个元素需要3.65个时钟周期
把程序变成这样
double sum(double *a, int n) { double s0 = 0, s1 = 0, s2 = 0, s3 = 0; for (int i = 0; i < n; i += 4) { s0 += a[i]; s1 += a[i + 1]; s2 += a[i + 2]; s3 += a[i + 3]; } return s0 + s1 + s2 + s3; }
测得平均每个元素需要1.36个时钟周期
当展开次数过多时,性能反而下降,是因为寄存器溢出
通常最多都展开为四次,目的:CPU并发
注意处理非展开次数倍数的部分 !
4.边集数组
利用一维数组存边,每一个元素为一个二元组
比邻接表快(好像没什么人这么卡你)
5.其他方面
1)register变量不能开太多,开多了之前开的就没用了
暂时用不到的变量不要过早的初始化,它会存在你的缓存里,如果之后继续调用该变量,速度会较快
但若在之后调用许多其他变量,则会将该变量清出你的缓存,
之后再有对该变量的操作时,则会花费比从缓存中调用较长的时间调用该变量
2)在遍历高维数组时,并在定义数组时将元素多的维度放在靠前的位置,
将循环次数多的维度放在外层,可减少一定的运行时间
高维数组在内存中都是线性安放的,在C语言中,按照的是行优先顺序,就像上面提到的
当我们使用行优先顺序遍历数组时,恰好就是顺次访问内存数据,会非常有利于CPU高速缓存的工作
3)对很多低维数组元素访问改写是要比多维数组快的,道理同样为:数组在内存中是线性安放的
这里举一个小例子:
BZOJ2101 在没怎么卡其他常数(也没啥好卡的)的情况下,狂T不止
把 f 数组由二维换成一维以后画风突变
可能BZOJ用的真是土豆搭的服务器吧...
3)循环变量开为形如”register int i”的形式,这个随意吧
同样的,循环变量在自增/自减时,写为形如”++i”/”--i”的形式,根据个人习惯吧,
在循环次数没有高上天之前是没什么差别的
4)对 int 进行操作是要比 long long 快的,据说比 bool 也快
5)在一些需要打 vis 标记的题目中,可以用 int 开 vis,打时间戳,根据时间戳大小来判断本轮中是否 vis 过这个节点,就不用 memset 了
6)memset 常数比 for i 0 to size_array 要优秀
5.常用的读入优化/输出优化
用 putchar 输出单个字符 和 用 puts 输出一串字符是要比 prinft 快得多的 1e6/1e7这个级别的输出建议使用
读入优化:
inline int rd(){ register int x = 0, c = getchar(); register bool f = false; while(!isdigit(c)){ f = (c == '-'); c = getchar(); } while(isdigit(c)){ x = (x << 1) + (x << 3) + (c ^ 48); //或写为 x =x * 10 + c - 48; 这里对两种写法的优劣不做评价 c = getchar(); } return f ? -x : x; }
输出优化:
inline void write(int x){ register int y = 10, len = 1; while(y <= x){y *= 10; ++len;} while(len--){y /= 10; putchar(x / y + 48); x %= y;} }
最后,通过三句名言来总结一下优化的思想。
第一句是:过早的优化是万恶之源
这句话告诉我们,优化工作是应该在程序能够正确地运行之后再展开的。在我们竞赛中尤其如此,如果在还没有编码并调试完成一个可以运行的程序之前,就到处进行细节上的优化,不但可能导致程序无法完成。而且过早的优化也无法发现程序真正的瓶颈所在,做出的优化也很可能是无用功。
第二句话是:程序的优化是无止境的
即使是简单地一个函数,也会有巨大的优化空间。只要我们掌握了扎实的基础知识,具备丰富的经验,再发挥我们的创造力,永远可能让程序运行得更快一点
第三句话是著名的KISS原则:keep it simple and stupid
当我们在优化程序时,如果发现细节过于复杂,甚至已经超出自己的掌控范围,应该停下来想一想,或许换一个思路就会海阔天空,真正最好的方法,也同样应该是简洁优美的。
(以上内容大部分摘自WC2017王逸松PPT以及WC2009骆可强论文"论程序底层优化的一些方法和技巧",部分内容由各种他人文章总结得来)