众所周知,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 个时间周期,而执行一次取模运算要几十个时间周期!由此可见位运算效率之高。
事实上,不少我们熟悉的操作都可用位运算改写,下面列出了一些常用我们常见到的的位运算改写形式:
-
乘上一个 2 整数次幂,可以改用左移运算加速,效率约增加 300%
x*=8
\(\Rightarrow\)x<<=3
-
除以一个 2 整数次幂,可以改用右移运算加速,效率约增加 300%
x/=8
\(\Rightarrow\)x>>=3
-
判断一个数是否为奇/偶数,可用位运算加速,效率约增加 500%
x%2==1
\(\Rightarrow\)x&1
x%2==0
\(\Rightarrow\)~x&1
-
判断一个两个数是否满足 \(x>y\),可用位运算加速约 200%
x>y
\(\Rightarrow\)y-x>>31
(注:这里的 \(x,y\) 都是int
型变量,若 \(x,y\) 为long long
型变量则将 \(31\) 改为 \(63\)) -
快速求一个数的相反数,可用位运算加速约 400%
-x
\(\Rightarrow\)~x+1
-
快速求一个数的 abs,可用位运算加速约 400%
x<0?-x:x
\(\Rightarrow\)x^(~(x>>31)+1)+(x>>31)
-
交换两数 \(x,y\),可用位运算加速约 30%
swap(x,y)
\(\Rightarrow\)x^=y^=x^=y
4. 其它卡常技巧
除了上面列举的循环展开、使用位运算、register 修饰符之外,还有一些小的卡常技巧/知识:
- inline 关键字的使用,声明函数之前写上 inline 修饰符,可以加快一下函数调用,但只能用于一些操作简单的函数。涉及递归,大号的循环等很复杂的函数,编译器会自动忽略 inline。
- 常数声明成常量,否则效率可能会变为原来的 30%
- 尽量不要用
bool
,碰到bool
建议用int
或char
(或std::bitset
)代替 - 碰到
if(condition) statement1;else statement2;
可用三元运算符改为(condition)?(statement1):(statement2)
代替;碰到if(condition) statement;
可用&&
运算符改为(condition)&&(statement)
代替,这样能提高程序运行效率。 - 逗号运算符比分号快,可以考虑将一些分号改为逗号
- 写状压 \(dp\) 的时候,尽量不要直接将数组大小开到 \(2\) 的某个整数次幂,最好多开几位,实测数组大小 \(2^{20}\) 比 \(2^{20}+1\) 寻址慢很多,原因未知。
- 少用取模,在不自然溢出的情况下能不用取模就不要取模,比方说 \(3\times 3\) 的矩阵乘法可以全部相加后再取模,这样能比边加边取模常数小 \(\dfrac{1}{3}\)。还可以用加法代替取模,即 ```((x+=y)>=MOD&&(x-=MOD))``,前提条件是这个 \(y\) 必须小于
MOD
,否则比方说模数是 \(998244353\),\(y\) 范围最高可达 \(10^9\),那这么写就萎掉了(似乎 csy 有场比赛就死在了这个地方?orzorz) - 常数矩阵用常数(\(a,b,c,d\))实现 instead of 数组效率会快大约一倍
- 如果线段树常数大了,可以尝试不用结构体实现线段树,将左右区间传到参数中(upd. 2021.8.31)
5. 与 STL 的常数有关的常识/卡常技巧
qpow
常数有一点大,实测 1s 大约只能跑 \(10^7\) 次,可能瓶颈全在取模上罢(真·qpow
\(\in\)STL
)bitset
常数非常小,即使把 \(\dfrac{1}{w}\) 单独拿出来后常数还是很小,可以放心使用set/map
常数较大了,虽然它们的理论复杂度为 \(\log n\),可在快一点的机子上 1s 内都只能执行 \(3\times 10^6\) 次插入/删除,碰到set/map
被卡掉的题,最好的解决方法就是想不用set/map
的做法queue/stack
常数不大不小,100ms 内大约能进行 \(2\times 10^7\) 次push
操作,用STL
自带的库没啥大问题,不过这种很容易手写的数据结构最好还是手写一下。priority_queue
常数不大,1s 内 \(10^7\) 次push
操作绰绰有余,使用STL
自带的库已经足够了,并且一般也没有毒瘤题专门卡这个,不过你想手写也没人拦你(vector
常数不算太大,1s 内最多能进行 \(2\times 10^7\) 次push_back
操作,但是开了 O2 之后似乎跑得很快。lower_bound
常数有点大,只比set/map
常数稍微小一些,1s 内最多能进行 \(5\times 10^6\) 次操作,建议如果被卡常了就手写二分,非常容易,常数也比STL
自带的库小。sort
应付基本排好序的数组效率较低,建议被卡常了就使用random_shuffle
先把数组随机打乱一般再调用sort
函数。
6. 总结
以上都是一些非常常用的卡常技巧,当遇到常数较大过不去的时候,可尝试上面的方法减小常数。
当然最好的卡常方式就是寻找算法过程中是否存在不必要的运算,有时候你费了九牛二虎之力,用尽了循环展开、寻址优化等卡常技巧也只能将常数卡到原来 \(\dfrac{4}{5}\),但在算法过程中发现有无用的运算,可用省去 \(\dfrac{1}{2}\) 的过程,这样不用费太大心思卡常,常熟就能减小到原来的 \(\dfrac{1}{2}\) 了。
总之就是具体情况具体分析。