這篇主要是搬運了點課件大概
有機會了會試着加進去一些有用的代碼方面的小優化的
(前提: 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駱可強論文"論程序底層優化的一些方法和技巧",部分內容由各種他人文章總結得來)