卡常技巧

众所周知,tzc 卡常能力非常菜。

于是就特意来学一下了(

1. 读写优化

对于一般的题目,使用 C++ 自带的 scanf/printf 已经足够优秀了,不过也未免有输入/输出量巨大的毒瘤题,此时读写优化就是必不可少的了。

比方说要读入 \(3\times 10^6\) 个数并原样输出,实测用 scanf/printf 在洛谷上需 412ms。

读入优化的原理其实就是用 C++ 更高效的 getchar() 函数一个一个字符的读入,再组成数字,输出优化的原理也是用 C++ 的 putchar 函数递归输出一个整数,实测用加了优化的 read/print 函数需 379ms,稍微快了一点点

template<typename T> void read(T &x){
	x=0;char c=getchar();T neg=1;
	while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
	while(isdigit(c)) x=x*10+c-'0',c=getchar();
	x*=neg;
}
template<typename T> void recursive_print(T x){
	if(!x) return;
	recursive_print(x/10);
	putchar((x%10)+'0');
}
template<typename T> void print(T x){
	if(!x) putchar('0');
	if(x<0) putchar('-'),x=-x;
	recursive_print(x);
}

当然,众所周知位运算可以大大加快程序运行效率,读写优化也可采用位运算优化,大约可以快个几毫秒,实测需 373ms:

template<typename T> void read(T &x){
	x=0;char c=getchar();T neg=0;
	while(!isdigit(c)) neg|=!(c^'-'),c=getchar();
	while(isdigit(c)) x=(x<<3)+(x<<1)+(c^48),c=getchar();
	if(neg) x=(~x)+1;
}
template<typename T> void recursive_print(T x){
	if(!x) return;
	recursive_print(x/10);
	putchar(x%10^48);
}
template<typename T> void print(T x){
	if(!x) putchar('0');
	if(x<0) putchar('-'),x=~x+1;
	recursive_print(x);
}

当然有的时候甚至 getchar()/putchar() 也不能让我们满意,此时我们就要用到更高级的 fread/fwrite 函数,实测在开了文件输出的情况下能大大加快读写效率,最后给出读写优化的板子:

namespace fastio{
	#define FILE_SIZE 1<<23
	char rbuf[FILE_SIZE],*p1=rbuf,*p2=rbuf,wbuf[FILE_SIZE],*p3=wbuf;
	inline char getc(){return p1==p2&&(p2=(p1=rbuf)+fread(rbuf,1,FILE_SIZE,stdin),p1==p2)?-1:*p1++;}
	inline void putc(char x){(*p3++=x);}
	template<typename T> void read(T &x){
		x=0;char c=getchar();T neg=0;
		while(!isdigit(c)) neg|=!(c^'-'),c=getchar();
		while(isdigit(c)) x=(x<<3)+(x<<1)+(c^48),c=getchar();
		if(neg) x=(~x)+1;
	}
	template<typename T> void recursive_print(T x){if(!x) return;recursive_print(x/10);putc(x%10^48);}
	template<typename T> void print(T x){if(!x) putc('0');if(x<0) putc('-'),x=~x+1;recursive_print(x);}
	void print_final(){fwrite(wbuf,1,p3-wbuf,stdout);}
}

(其实感觉读写优化并不能从根本上解决常数巨大的问题,所以就一笔带过了)

2. 与循环有关的一些优化

以上优化都是与输入/输出相关的优化,那么有没有什么与程序关系更密切的优化呢?

答案是肯定的,一个普通的循环都可以进行常数优化使其效率提升 50% 以上

比方说有如下的程序:

for(int i=1;i<=k;i++){
	int tmp=0;
	for(int j=i;j<=n;j++){
		if(a[j]>tmp) a[j]^=tmp^=a[j]^=tmp;
	} a[i]=tmp;
}

首先是一些无关紧要的优化,据网上某些博主所说,将 i++ 改为 ++i 能提升程序效率,实测效果不明显,\(n=50000,k=20000\) 的运行时间只提升了 0.01s。还有就是将 int i=1 改为 int i(1) 也能加快程序运行效率,不过同样效果不太明显。

下面就是一些必要的卡常技巧了:

卡常技巧 1:善用 register 修饰符

初学卡常的萌新(比如说我)可能会问,register 修饰符是个啥。

那么我们回到计算机执行程序的本质:存储,读取和计算。

众所周知,当我们在程序中新建一个变量时,系统会自动为该变量开辟一小块内存存储该变量的相关信息。

但是对于不同大小/类型变量,系统为其开辟的内存的类型也是不同的。而对于不同的内存类型,系统的访问速度也有较大差异,对于一般的电脑,下表给出了五种类型的内存的大小及访问速度:

内存类型 大小 访问时间/时钟周期
寄存器 512B 1
L1-Cache 64KB 4
L2-Cache 256KB 10
L3-Cache 4MB 50
虚拟内存 4GB 200

(注:时钟周期等于时钟频率的倒数,对于一般的电脑时钟频率一般为 \(2\sim 4\text{GHz}=2\times 10^9\sim 4\times 10^9\text{s}^{-1}\),通过可大概算出大概数量级)

不难发现寄存器的访问速度最快,其次是一级、二级、三级高速缓存,再其次是普通的虚拟内存。

而加上 register 修饰符恰恰限定了该变量所开辟的内存必须被保存在 Cache 中,这也就大大加快了内存的存储与读取顺序,一般在值频繁改变的变量前加该修饰符。

不过讲真的加了似乎也没有太大用处……/gg/gg

这就需要另一个技巧了——

卡常技巧 2:循环展开

这个名字就更高大上了(bushi)

举个特别 simple 的例子,我们要对一个序列求和,我们一般这样写:

long long sum=0;
for(int i=1;i<=n;i++) sum+=a[i];

而加了循环展开一般会这样写:

long long sum0=0,sum1=0,sum2=0,sum3=0,sum4=0,sum5=0,sum6=0,sum7=0,i=1;
for(i=1;i+8<=n;i+=8){
    sum0+=a[i];sum1+=a[i+1];sum2+=a[i+2];sum3+=a[i+3];
    sum4+=a[i+4];sum5+=a[i+5];sum6+=a[i+6];sum7+=a[i+7];
}
for(;i<=n;i++) sum0+=a[i];

那么有人就有疑惑了,好端端的一个循环,为什么要神经病般地把它展开呢?

因为如果按照前一种的写法,每次进行循环 CPU 都要执行 \(n\)i++,以及 \(n\)sum+=a[i] 的操作。而一般来说CPU中是有多个运算器的,前一种写法相当于将所有的运算都抛给了一个运算器,其它运算器都在那儿睡大觉,这显然是一种资源浪费。使用循环展开就可以刺激 CPU 并行,也就大大加快了程序运行的效率。

值得注意的一点是,循环展开不能展开太多,一般是展开 4~8 层,否则程序运行的效率反而会变慢。

btw 还有一点就是循环展开一般要用到很多结构非常相似的代码,此时就可以考虑 #define func(i)

事实证明循环展开效果比前几种卡常方式要好得多,一下子就少了 0.5s:

for(int i=1;i<=k;++i){
	register int tmp=0;
	for(register int j=i;j<=n;j+=5){
		#define swp(j) if(a[j]>tmp&&j<=n) a[j]^=tmp^=a[j]^=tmp
		swp(j);swp(j+1);swp(j+2);swp(j+3);swp(j+4); 
	} a[i]=tmp;
}

当然我们还是不够满意,因为这边还有个 j<=n,每次展开都要判断一次,显然大大拖慢了程序运行的效率,此时你也可以进一步将 j<=n 展开,卡到最后的代码长这样:

for(register int i=1;i<=k;++i){
	register int tmp(0),j(i);
	#define swp(j) (a[j]>tmp&&(a[j]^=tmp^=a[j]^=tmp))
	for(;j+5<=n;j+=5){
		swp(j);swp(j+1);swp(j+2);swp(j+3);swp(j+4); 
	} (j<=n)?swp(j):0;(j+1<=n)?swp(j+1):0;(j+2<=n)?swp(j+2):0;(j+3<=n)?swp(j+3):0;(j+4<=n)?swp(j+4):0;
	a[i]=tmp;
}

3. 一些与位运算有关的优化

众所周知,计算机电脑内部存储数据使用的是二进制,而位运算与二进制有直接联系,故系统处理位运算时在效率上有很大优势。

俗话说:没有对比没有伤害。这里列出了程序执行各种运算所需的时间周期:

运算 执行一次所需的时间周期
加法 1
乘法 3
取模 20
位运算 <1

不难发现执行一次位运算所用的时间甚至不到一个时间周期,执行一次加法运算大约需要 1 个时间周期,执行一次加法运算大约需要 3 个时间周期,而执行一次取模运算要几十个时间周期!由此可见位运算效率之高。

事实上,不少我们熟悉的操作都可用位运算改写,下面列出了一些常用我们常见到的的位运算改写形式:

  1. 乘上一个 2 整数次幂,可以改用左移运算加速,效率约增加 300%

    x*=8 \(\Rightarrow\) x<<=3

  2. 除以一个 2 整数次幂,可以改用右移运算加速,效率约增加 300%

    x/=8 \(\Rightarrow\) x>>=3

  3. 判断一个数是否为奇/偶数,可用位运算加速,效率约增加 500%

    x%2==1 \(\Rightarrow\) x&1

    x%2==0 \(\Rightarrow\) ~x&1

  4. 判断一个两个数是否满足 \(x>y\),可用位运算加速约 200%

    x>y \(\Rightarrow\) y-x>>31(注:这里的 \(x,y\) 都是 int 型变量,若 \(x,y\)long long 型变量则将 \(31\) 改为 \(63\)

  5. 快速求一个数的相反数,可用位运算加速约 400%

    -x \(\Rightarrow\) ~x+1

  6. 快速求一个数的 abs,可用位运算加速约 400%

    x<0?-x:x \(\Rightarrow\) x^(~(x>>31)+1)+(x>>31)

  7. 交换两数 \(x,y\),可用位运算加速约 30%

    swap(x,y) \(\Rightarrow\) x^=y^=x^=y

4. 其它卡常技巧

除了上面列举的循环展开、使用位运算、register 修饰符之外,还有一些小的卡常技巧/知识:

  1. inline 关键字的使用,声明函数之前写上 inline 修饰符,可以加快一下函数调用,但只能用于一些操作简单的函数。涉及递归,大号的循环等很复杂的函数,编译器会自动忽略 inline。
  2. 常数声明成常量,否则效率可能会变为原来的 30%
  3. 尽量不要用 bool,碰到 bool 建议用 intchar(或 std::bitset)代替
  4. 碰到 if(condition) statement1;else statement2; 可用三元运算符改为 (condition)?(statement1):(statement2) 代替;碰到 if(condition) statement; 可用 && 运算符改为 (condition)&&(statement) 代替,这样能提高程序运行效率。
  5. 逗号运算符比分号快,可以考虑将一些分号改为逗号
  6. 写状压 \(dp\) 的时候,尽量不要直接将数组大小开到 \(2\) 的某个整数次幂,最好多开几位,实测数组大小 \(2^{20}\)\(2^{20}+1\) 寻址慢很多,原因未知。
  7. 少用取模,在不自然溢出的情况下能不用取模就不要取模,比方说 \(3\times 3\) 的矩阵乘法可以全部相加后再取模,这样能比边加边取模常数小 \(\dfrac{1}{3}\)。还可以用加法代替取模,即 ```((x+=y)>=MOD&&(x-=MOD))``,前提条件是这个 \(y\) 必须小于 MOD,否则比方说模数是 \(998244353\)\(y\) 范围最高可达 \(10^9\),那这么写就萎掉了(似乎 csy 有场比赛就死在了这个地方?orzorz)
  8. 常数矩阵用常数(\(a,b,c,d\))实现 instead of 数组效率会快大约一倍
  9. 如果线段树常数大了,可以尝试不用结构体实现线段树,将左右区间传到参数中(upd. 2021.8.31)

5. 与 STL 的常数有关的常识/卡常技巧

  1. qpow 常数有一点大,实测 1s 大约只能跑 \(10^7\) 次,可能瓶颈全在取模上罢(真·qpow\(\in\)STL
  2. bitset 常数非常小,即使把 \(\dfrac{1}{w}\) 单独拿出来后常数还是很小,可以放心使用
  3. set/map 常数较大了,虽然它们的理论复杂度为 \(\log n\),可在快一点的机子上 1s 内都只能执行 \(3\times 10^6\) 次插入/删除,碰到 set/map 被卡掉的题,最好的解决方法就是想不用 set/map 的做法
  4. queue/stack 常数不大不小,100ms 内大约能进行 \(2\times 10^7\)push 操作,用 STL 自带的库没啥大问题,不过这种很容易手写的数据结构最好还是手写一下。
  5. priority_queue 常数不大,1s 内 \(10^7\)push 操作绰绰有余,使用 STL 自带的库已经足够了,并且一般也没有毒瘤题专门卡这个,不过你想手写也没人拦你(
  6. vector 常数不算太大,1s 内最多能进行 \(2\times 10^7\)push_back 操作,但是开了 O2 之后似乎跑得很快。
  7. lower_bound 常数有点大,只比 set/map 常数稍微小一些,1s 内最多能进行 \(5\times 10^6\) 次操作,建议如果被卡常了就手写二分,非常容易,常数也比 STL 自带的库小。
  8. sort 应付基本排好序的数组效率较低,建议被卡常了就使用 random_shuffle 先把数组随机打乱一般再调用 sort 函数。

6. 总结

以上都是一些非常常用的卡常技巧,当遇到常数较大过不去的时候,可尝试上面的方法减小常数。

当然最好的卡常方式就是寻找算法过程中是否存在不必要的运算,有时候你费了九牛二虎之力,用尽了循环展开、寻址优化等卡常技巧也只能将常数卡到原来 \(\dfrac{4}{5}\),但在算法过程中发现有无用的运算,可用省去 \(\dfrac{1}{2}\) 的过程,这样不用费太大心思卡常,常熟就能减小到原来的 \(\dfrac{1}{2}\) 了。

总之就是具体情况具体分析。

原文地址:https://www.cnblogs.com/ET2006/p/kc.html