卡常技巧


眾所周知,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}\) 了。

總之就是具體情況具體分析。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM