1. 程式人生 > >位元組的按位逆序 Reverse Bits

位元組的按位逆序 Reverse Bits

源自某公司的一道試題,問題很簡單:

輸入一個位元組(8 bits),將其按位反序。

也就是說如果輸入位元組的八個位元是“abcdefgh”,要得到“hgfedcba”。作為面試題或者筆試題,自然的,隱含了一個要求:效率儘可能高。
這個問題還有一個擴充套件版本,或許網上見的更多些:輸入的不是位元組而是 32 位整數(DWORD),將其按位反序。

實話說,有時候這種題目頗有些鑽牛角尖了,考察程式設計師的能力需要這樣嗎?之所以為其加上標籤“奇技淫巧”,也是表達一個意思:這類技術的用處並不廣泛,相反,大多數時候根本用不上。為了優化程式碼效率算是最顯然的理由,在最核心最瓶頸的程式碼部分用上這些招數倒也無可厚非,平時還是不要亂用的好。高爺爺(

Donald Knuth,神書 TAOCP 的作者)也有句名言,“過早優化是萬惡之源”(Premature optimization is the root of all evil)。不過,研究研究也沒有太多壞處,至少可以開拓一下思路,鍛鍊一下腦力,學習一些優化的手法。或者運氣好的話,還可以應付一下某道筆試題或面試題。

一個平凡的解法如下(這裡輸入是 UINT,位元組的版本對應修改型別即可):

?
1 2 3 4 5 6 7 8 9 10 11 12 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 條指令,實在是有些冗長。

那有沒有指令數更少的解法呢,當然有!只是這些解法就不像平凡解法那麼直白和易於理解了。

在網上看過比較多的一個解答是這麼做的:

?
1 2 3 4 5 6 7 8 9 10 // 交換每兩位 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 位整數的。如果輸入是位元組的話,只需類似的三行就可以了,如下:

?
1 2 3 4 5 6 // 交換每兩位 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 位運算):

?
1 2 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 的辛苦細緻!

那麼愛較真的同學肯定要說,這個計算過程勉強看懂了,但是還是有幾個問題沒有得到解釋:

  1. 為什麼要複製 5 份而不是 6 份或者 4 份?
  2. 為什麼尾巴上有個 0 ?
  3. 為什麼是 10 位一組疊加而不是 xx 位一組?
  4. 為什麼運算 %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 位呢?
當然也有其他各種奇妙解法滿足這些條件。其實本文中的幾個演算法都來自這裡(英文),裡面還有許許多多關於位操作的各種奇技淫巧,有興趣的同學可以自行參觀。

     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;