http://www.hookcn.org/2011/01/reverse-bits.html
源自某公司的一道試題,問題很簡單:
輸入一個位元組(8 bits),將其按位反序。
也就是說如果輸入位元組的八個位元是“abcdefgh”,要得到“hgfedcba”。作為面試題或者筆試題,自然的,隱含了一個要求:效率儘可能高。
這個問題還有一個擴充版本,或許網上見的更多些:輸入的不是位元組而是 32 位整數(DWORD),將其按位反序。
實話說,有時候這種題目頗有些鑽牛角尖了,考察程式員的能力需要這樣嗎?之所以為其加上標籤“奇技淫巧”,也是表達一個意思:這類技術的用處並不廣泛,相反,大多數時候根本用不上。為了最佳化代碼效率算是最顯然的理由,在最核心最瓶頸的代碼部分用上這些招數倒也無可厚非,平時還是不要亂用的好。高爺爺(Donald
Knuth,神書
TAOCP 的作者)也有句名言,“過早最佳化是萬惡之源”(Premature optimization is the root of all evil)。不過,研究研究也沒有太多壞處,至少可以開拓一下思路,鍛煉一下腦力,學習一些最佳化的手法。或者運氣好的話,還可以應付一下某道筆試題或面試題。
一個平凡的解法如下(這裡輸入是 UINT,位元組的版本對應修改類型即可):
?
| 123456789101112 |
typedef
unsigned int
UINT; UINT reverse_bits(UINT input) { const
UINT BITS_OF_BYTE =
8; // 每個位元組多少位元
UINT
result = 0;
// 結果存放在這裡 // 以下迴圈處理每個位元
for
(UINT i = 0; i < sizeof(input) * BITS_OF_BYTE; i++) {
// 取出輸入的最後一位加入 result,其他位依次左移
result = (result <<
1) | (input &
1); input >>=
1; // 右移拋棄掉最後一位
}
return
result; } |
然而這個解法顯然是低效的,首先處理一個 N 位整數需要迴圈 N 次,每次迴圈中,迴圈體內部是 4 條指令,迴圈變數的修改和條件跳轉還有 2 條,也就是 6N 條指令。(賦值指令倒是可以忽略,因為這幾個變數都不超過寄存器大小,可以被最佳化,一直存放在寄存器中)。位元組按位反序這種“簡單任務”都需要 48 條指令,實在是有些冗長。
那有沒有指令數更少的解法呢,當然有!只是這些解法就不像平凡解法那麼直白和易於理解了。
在網上看過比較多的一個解答是這麼做的:
?
| 12345678910 |
// 交換每兩位 v = ((v >> 1) &
0x55555555) | ((v &
0x55555555) <<
1); // 交換每四位中的前兩位和後兩位
v = ((v >> 2) &
0x33333333) | ((v &
0x33333333) <<
2); // 交換每八位中的前四位和後四位
v = ((v >> 4) &
0x0F0F0F0F) | ((v &
0x0F0F0F0F) <<
4); // 交換相鄰的兩個位元組 v = ((v >> 8) &
0x00FF00FF) | ((v &
0x00FF00FF) <<
8); // 交換前後兩個雙位元組 v = ( v >>
16 ) | ( v <<
16); |
以上代碼是處理 32 位整數的。如果輸入是位元組的話,只需類似的三行就可以了,如下:
?
| 123456 |
// 交換每兩位 v = ((v >> 1) &
0x55) | ((v &
0x55) << 1);
// abcdefgh -> badcfehg // 交換每四位中的前兩位和後兩位
v = ((v >> 2) &
0x33) | ((v &
0x33) << 2);
// badcfehg -> dcbahgfe // 交換前四位和後四位 v = ( v >> 4
) | (v << 4);
// dcbahgfe -> hgfedcba |
而更長的輸入當然也沒問題,這個模式可以繼續擴充,64 位元、128 位……
這段代碼的妙處在於,假設我們通過某個操作交換位元組的兩個位(例如將 a 與 h 交換),此時其他位並沒有被這個操作影響,於是自然可以考慮將多個位的交換“並行”操作。所以就有了上面這個解法,中心思想是把各個位分成組,一次性交換所有兩兩相鄰的組。然後再通過改變交換組的大小讓每個位最終到達它需要去的地方。這個解法的交換尺度是從小到大,其實從大到小也可以,感興趣的同學可以自己試試。
這種封包交換解法的指令數為 5 * log2(N) - 2,比平凡解法的 6 * N 完全不是一個數量級。在 N = 32 的時候,指令數是 23 : 192,8 倍多的提升,已經是很大的改善了。不過愛鑽牛角尖的程式員們還是不滿足,在 N = 8 也就是需要反序一個位元組的情況下,這種解法用掉了 13 條指令,那有沒有更少的?
請看下面這個堪稱神作的解法(用了 64 位元運算):
?
| 12 |
unsigned
char b; // 要反轉的位元組
b = (b * 0x0202020202ULL
& 0x010884422010ULL) %
1023; |
雖然已經反覆看過這個解法,仍然為其中蘊涵的奇思妙想深深震撼。居然只用了三條指令!在這裡試著講解一下此方法具體是怎麼做的。首先,用乘法將原位元組複製成 5 份,並首尾相連的放入一個 64 位元整數中;然後,用 & 操作取出特定的位。這兩次操作的結果是,原位元組的 8 個位被分別放置到 5 個“10位組”中的正確位置上(“正確”是指反轉後應在的位置)。最後用一個“%1023”將這 5 個“10位組”疊加起來,便得到最終結果了!看下面列出的具體的計算過程更明白一些:
為了方便閱讀,原位元組位用大寫字母,算式中的“0”用了字元“.”代替,希望這樣看的更清楚。 ......1.......1.......1.......1.......1. // 0x0202020202* ABCDEFGH--------------------------------------------------- ......H.......H.......H.......H.......H. // 尾巴上有個 0 別看漏了 ......G.......G.......G.......G.......G. ......F.......F.......F.......F.......F. ......E.......E.......E.......E.......E. ......D.......D.......D.......D.......D. ......C.......C.......C.......C.......C. ......B.......B.......B.......B.......B. ......A.......A.......A.......A.......A.--------------------------------------------------- ......ABCDEFGHABCDEFGHABCDEFGHABCDEFGHABCDEFGH.& ......1....1...1....1...1....1...1........1....--------------------------------------------------- ......A....F...B....G...C....H...D........E.... (*)注↓% .....................................1111111111--------------------------------------------------- .......................................HGFEDCBA(*)這裡連在一起看不清楚,我們把它按 10 位一組分出來: .........A ....F...B. ...G...C.. ..H...D... .....E....看,這麼一分組,原位元組的每個位都在正確位置上(最高兩位為零)。
以上計算過程圖來源
Log4think,有改動。感謝作者 Simon 的辛苦細緻!
那麼愛較真的同學肯定要說,這個計算過程勉強看懂了,但是還是有幾個問題沒有得到解釋:
- 為什麼要複製 5 份而不是 6 份或者 4 份?
- 為什麼尾巴上有個 0 ?
- 為什麼是 10 位一組疊加而不是 xx 位一組?
- 為什麼運算 %1023 的結果就是按 10 位一組疊加?
好吧,試試解答如下:
為什麼要複製 5 份而不是 6 份或者 4 份?
這個問題的答案很直白:因為 4 份不夠,怎麼做也做不出足夠的位來,不信你試試?而 6 份又太多了,不需要。
需要對上面的論斷給出證明的話,後面有。
為什麼尾巴上有個 0 ?
猜想這個解法的作者一開始是嘗試使用 0x0101010101 作為乘數的。只是發現這樣做等於浪費了最低位那一份位元組拷貝(因為 8 個位全部原封不動,每個位都不在正確位置上),所以將乘數左移了一位,這樣最後一份位元組拷貝至少能拿到一個 e 處於正確位置上。事實上最多也只能拿到一個位,很容易驗證。
為什麼是 10 位一組疊加而不是 xx 位一組?
首先,少於 8 位的分組當然不行,怎麼也沒法選出 8 個位來。按 8 位分組顯然也不行,你會發現每一組都一樣,只能選到同一個位。那麼試試按 9 位一組再去選正確位置?你會發現 5 份拷貝不夠。於是 10 位的分組已經是最小的分組了。
那麼比 10 位更大的分組會不會更好呢?要知道不管一開始左移一位還是幾位再相乘,很顯然最低那一組最多隻能選出一個位,而剩下的每組最多選出兩個位[1],於是,選出 8 個位至少需要 5 組(嚴格的說,最高位的第 5 組可以不完整,因此至少需要 4 組 + 1 位)。
既然 10 位分組是最小的分組而且只需要 5 組數字,那麼這已經是最優的了。
1. 這個結論可以證明。簡單說就是,我們有逆序位序列(例如87654321...)和順序位序列(例如12345678...),長度均為一個分組大小。兩個序列逐位對應(8-1,7-2,...)。若在序數 (i, j) 處出現了第一次重合(也即 i mod 8 ≡ j),後面位的序數(一個增一個減)也要對 8 同餘才能重合,也就是逆序的 i-4 與正序的 j+4,逆序 i-8 與正序 j+8,等等。注意到每一組最多隻選低 8 位用來疊加,顯然的無論
i,j 為多少,最多隻可能有第 (i-4,j+4) 位和第 (i,j) 位這兩個位能被選出。
實際上,由於至少要 4 組 + 1 位,在 64 位元限制下最大也只能 15 位分組。其實容易驗證,10 位分組和 14 位分組是僅有的兩種可行分組方式。
為什麼運算 %1023 的結果就是按 10 位一組疊加?
這是根據如下原理:% (2N - 1) 的結果,其實就是把這個數寫成 2N 進位數再取各階係數之和(嚴格的說只是同餘),而寫成 2N 進位數的各階係數就是各個 N 位分組。從而,% (2N - 1) 的結果也就是按 N 個位分組疊加的結果。特別的,%1023 就是按 1024(210) 進位取各階係數疊加,從而也就是 10 位分組的疊加。
事實上,這個原理不需要非得是 2N 進位,我們還可以有更強的結論。對任何 X 進位我們都有:“任意整數N,其按X進位展開的各階係數之和與N%(X-1)同餘”。用公式表達即:
考慮整係數多項式 p(X) = aXn + bXn-1 + ... + z,有
p(X) mod (X - 1) ≡ a + b + ... + z
證明其實也很直白,設 Y = X - 1,代入上式既得。詳細過程節約篇幅就不寫了,有興趣的同學可以去這裡看,感謝 Simon 幫忙寫出公式。
特別的,如果 X = 10,a..z 都是 [0, 9] 區間中的整數,p(X) 就是一個 10 進位數寫成按階展開,於是很容易得到下面這些速算技巧:
a. N mod 9 ≡ (N 的各位元字之和) mod 9 ≡ (N 的各位元字之和)的各位元字之和 mod 9 ... 以此類推
b. 由上一條立刻可得,“N 能被 9 整除”等價於“N 的各位元字之和能被 9 整除”
c. 特別的,由於 10 = 32 + 1,於是上面的兩條速算技巧對 3 也成立,例如:3 的倍數其各位元字之和也是 3 的倍數
這些東西相信每個人小學都學過,比剛才那個 1024 進位熟悉多了吧?:)
終於問題解答完畢。那麼,再次隆重推薦我們剛才看到的,有如神助的“位元組按位逆序”解法——只需要三條指令。如果非要說它有什麼缺點,恐怕就是用了除法(取餘),以及 64 位元環境。
如果不用除法呢?如果只有 32 位呢?
當然也有其他各種奇妙解法滿足這些條件。其實本文中的幾個演算法都來自這裡(英文),裡面還有許許多多關於位操作的各種奇技淫巧,有興趣的同學可以自行參觀。
http://graphics.stanford.edu/~seander/bithacks.html
n = (n&0x55555555)<<1|(n&0xAAAAAAAA)>>1;
n = (n&0x33333333)<<2|(n&0xCCCCCCCC)>>2;
n = (n&0x0F0F0F0F)<<4|(n&0xF0F0F0F0)>>4;
n = (n&0x00FF00FF)<<8|(n&0xFF00FF00)>>8;
n = (n&0x0000FFFF)<<16|(n&0xFFFF0000)>>16;