眾所周知,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}\) 了。
總之就是具體情況具體分析。