下一篇:Bits Twiddling Hacks 解析 (三)
上一篇:Bits Twiddling Hacks 解析 (一)
這種技巧主要是建立在算術之上,類似的比如國小計算除以 9 的餘數,只要把每個位數加起來再除以 9 就好,不用真的做一次除法。
下面這邊有一些技巧是建立在類似的觀念上,僅利用加減乘除,沒有用位元運算。在十進位中大部分都有對應的做法,所以可以借助已經熟悉的十進位進行理解。
這種做法可以讓運算子看起來很少,而且有種到處都是 magic number 的感覺,比如 3 個運算計算 14 位元整數的 population count 其中一個解法就有用到。
先看十進位的例子:
主要就是某個數字,乘上重複的 ,就會把本來的數字重複很多次。不管是寫成科學記號或是用直式乘法來證明都很容易看出來這件事。
接下來看看一個 16 進位的版本:
這個結果寫成 16 進位的話,會是 0x175a2eb45d68bad
,乍看之下不是很好懂。但如果用直式乘法去思考的話,其實會是:
原理也是一樣。如果把上面的東西寫成二進位的話,其實是在做:
因為 64 位元的關係,把完整的數字寫出來實在是很長,所以大部分都會寫 16 進位,然後就會變成一個有點令人費解的樣子。
這個用直式乘法非常好證明。舉例來說,現在有一個 64 位元無號數的 2 進位 形式長這樣:
其中 A、B、C、D、E、F、G、H 都是 0
或 1
。如果做:
那可以發現計算結果最高位的 8 個位元,會是 (觀察對角線方向):
回到剛剛的計算:那一連串的位移其實可以簡化成:
所以總結以上,做:
就會有把稀疏的位元壓縮再一起的效果。
有一些問題的思路可以從「計算位數和」下手。比如說 population count 就是在算每個位數的和; 而 parity bit 就是算完位數和之後,再看奇偶性 (比如做 x & 1
)。
暖身:以 10 為基數的位數和
這件事情的原理,跟國小時判斷一個數是不是 9 的倍數,使用「(位數和) % 9,看看餘數是不是 0」,的原理是很像的。比如說想要計算:
的餘數,那麼小時候大家都學過,去計算:
的餘數就好。這是因為對於任意非負整數 ,有:
因此,假定一個數的 10 進位表示法是 ,或是說:
那麼可以把所有的 拆成 ,然後經過一點計算之後:
但如果位數的總和比 9 小,那麼有 mod
跟沒有 mod
結果是一樣的,那麼除法計算的商根本就是 0。也就是說 根本可以拿掉,像這樣:
比如說,想計算 的位數和是 ,剛好就會是它除以 的餘數。
例子:16 進位的位數和
現在我們把基數從 換成 。比如說,想要計算 n = 0x11101111
的位數和。這個數字其實是:
其中,裡面任意 只會是 或 。仿照上面的拆法。不過因為現在 base 是 16,所以就把 拆成
所以可以一眼知道位數和就是每個位元加起來,就是 。
後面有很多利用乘法的招式,大致上都是遵照下面的形式:
舉例來說下面三者:
這個經典的問題是這樣:給定一組 bit mask,要問裡面有多少個 1。最天真的方法就是用迴圈:
不過這樣明顯會出現很多分支,所以接下來就要思考改良的版本。
原文:Counting bits set, Brian Kernighan's way
這個是用上面「清除最低位元」的做法去做。在位元很稀疏的時候,有機會比天真的做法快。不過還是免不了使用 branch。
原文:Counting bits set by lookup table
就是那個傳說中把結果存在月球上的做法啊。
這個做法是建立一個大小是 256 的陣列 table
當作表,枚舉所有 8 位元的整數所有可能數值中,對應的 1 的個數。比如說我想要查 73
這個數字中,有多少個 1,那就去找 table[73]
。所以這個有點大的表會像是:
不過這樣打起來有點煩瑣,所以可以用巨集的技巧簡化:
這個巨集其實是用下面的遞迴結構:
原理就是照最高 2 個位元進行討論。他有可能是 0b00
, 0b01
, 0b10
, 0b11
,而 pop count 的結果就是「最高 2 位元的 1 的數目」加上「其餘低位元的 1 的數目」。
對於其他 8 的倍數寬度的數值型別,只要多做幾次就可以了:
另外一個看起來很酷的變形是用一個 unsigned char
指標去指要計算的整數,然後就可以用中括號去存取每個 byte:
原文:Counting bits set, in parallel
這個應該是最有名的做法吧?主要的思路是這樣:
但裡面給出一個等價,但是運算次數更少的版本:
第一行做法為什麼會對,可以窮舉所有可能的狀況:
所以就剛好跟原先的第一步是一樣的。接下來的做法大上就是跟原先一樣。
原文:Counting bits set, in parallel 的下面
概念大致上跟上面一樣,只是後面用乘法做:
最後面乘法的作用是像下面這樣:
原文:Counting bits set in 14, 24, or 32-bit words using 64-bit instructions
這邊使用到另外一個概念:population count = 加總所有位數。所以一個可能的做法是下面這樣:
大致上就是:
不過在文章中出現的程式碼很短,是這樣:
如果可以一眼看出來在做什麼的話,那可以跳過。如果看不出來的話,下面接著解釋。這段程式只有 3 個運算,分別就代表上面的 3 個步驟:
* 0x200040008001ULL
:重複原來的數字& 0x111111111111111ULL
:利用「原數字重複的週期」跟「取出位元的週期」互質來做到「恰好每個位元都出現一次」。這邊利用「原數字重複的週期」是 15; 「取出位元的週期」是 4。% 0xf
:把取出來的位元加總。如果觀察一下 0x200040008001ULL
,會發現他是不斷重複的 0b000 0000 0000 0001
(14 個 0
,1 個 1
)。比如說如果數字是 0xBAD
,那乘上 0x200040008001ULL
之後會變成:
反正就是把 0xBAD 重複寫很多次。但關鍵就是:他會以每 15 個位元為週期重複。
如果把每個位元用 0DCBA9876543210
表示每個位元,(比如第 A
代表第 10 個位元、4
代表第 4 位元等等)的話,上面那個過程其實是「讓 n
中每一個位元恰好在結果中出現 1 次」。如果用 0DCBA9876543210
分別代表 0 ~ 15 個二進位中的位數的話,上面的步驟其實是照下面的過程取出對應的位數:
也就是依照:
的順序,把第 i
位元取出。其中 n[i]
是由最低位數來第 i
個位元。因為想不到用比較好的表達方法,所以就借用類似 verilog 的語法來表打。
關鍵是:4 跟 15 互質,所以這樣就保證恰好取到 n
裡面的所有位數。為了方便,暫時用 tmp2
來稱呼到目前為止的計算結果:
這個結果會是個位元形如下面這樣的數字:
現在我們有什麼?
所有 的位元都在裡面剛好出現過一次
目前的計算結果是:
現在所有 n
的位元都恰好在 tmp2
中出現一次,其他位元都是 0。現在只欠東風:把 tmp
的每個位元加總。
因為現在基數是 16,加上位數和最大就是 14 (14 個位元都是 1) < (16 - 1)。用前面計算位數和的結論可以知道:這時做 % (16 - 1)
就可以求出位數和。
原文:Counting bits set in 14, 24, or 32-bit words using 64-bit instructions
原理跟上面一樣,但他把「12 位元數字的,相同的構造方法,重複做 2 次。」加起來就會得到得到 24 位元的 population count。
處理 12 位元的 population count 時,重複寫原來數字對週期,從 15 位元,變成 12 位元; 以及 mask 的週期,由 4 位元變成 5 位元。其餘概念相同。
原文:Counting bits set in 14, 24, or 32-bit words using 64-bit instructions
原理跟上面一樣,變成做 12 位元的狀況做 3 次,一次是計算 32 位元中,低 12 位的位數和; 一次是計算次高 12 位元的位數和; 最後計算最高 8 位的位數和。不過反正 12 位元可以用,把 8 位元數字當成 12 位元數字照樣可以用。所以看起來就是 12 位元的做法做 3 次。
parity bit 的做法很多會跟 population count 很像,因為能做 population count 就可以用計算的結果直接去找出 parity bit。
原文:Computing parity the naive way
最直覺的方法就是做:
不過照樣子會希望避開 branch 跟 jump。做法大概就是兩類:
XOR
起來的值。所以往這個方向做也可以。原文:Compute parity by lookup table
這個跟 population count 那邊的查表法類似。可以建立一個大小 256 的表,枚舉 0
到 255
中所有值的 parity:
而這個表也有類似前面 population count 的巨集建立方式。所以程式就可以簡化成:
會做一個 byte 的 parity bit,那就可以用一樣的方法去做一個 byte 的整數倍位元的 parity bit:
或是先把狀態「壓縮」到 8 bit 裡面,再查表:
原文:Compute parity of a byte using 64-bit multiply and modulus division
這個就是從 population count 的方法四(之一):加總位數和 (14 位元數值 + 64 位元運算)改來的。先做 population count,再看結果的奇偶性。
原文:Compute parity of word with a multiply
做完 v^= v>> 1
跟 v ^= v >> 2
之後,每 0, 4, 8, 12 … 24, 28 位元,都會剛好是 0 ~ 3, 4 ~ 7, 8 ~ 12 … 24 ~ 27, 28 ~ 31 位元的 parity bit。
接著把這些位元取出來,用類似「直式乘法」的方式去思考:乘上 0x11111111U
時,第 28 位元其實是在做:
所以第 28 位元,就會是加總結果的個位數。因此把它取出來,就是 parity bit。
可以直接做 32 次。不過因為 XOR 有結合律跟交換律,所以可以用分治法,一直「對折」來找出 parity bit:
但是可以用一些 magic number 去減少次數:
大做法大致上跟上面差不多,不過只對折到寬度為 4 就不繼續對折了,而是用一個奇怪的數字 0x9669
去位移。這個數字其實是 0110 1001 1001 0110
,就是上面的 ParityTable256
前 16 個數字壓縮進 16 位元裡面。這樣一來,只要位移並取出第 1 個位元,就可以做到跟前面查表一樣的效果。
原文:Reverse bits the obvious way
問題:給定一個 unsigned int v
,反轉 v
的所有位元。即結果 c
的第 i
位元,必須等於 v
的第 31 - i
位元。其中 i
的範圍是 0 ~ 31
。
最直覺的做法就是用迴圈下去:
跟前面兩個做法類似。先討論 8 位元的狀況,然後枚舉 0 ~ 255 中,每一個數值反轉之後的結果,存在一個大小 256 的陣列裡面:
類似地,這個表也可以用巨集去建立:
這個巨集運作的原理可以用這種方法思考:假定本來的 byte 的二進位形式是:
那麼這個 byte 反轉的結果,可以用下面的方法計算出來:
這個過程可以想像成成要開下面這樣的陣列:
其中:
另外一方面,手動展開上面的巨集,就會發現:開一個 table[4][4][4][4]
去存取 table[hg][fe][dc][ba]
,在 C 語言的 array subscription 下,這件事跟開一個大小 256 的表 table[256]
,然後直接存取 table[hgfedcba]
效果,是一樣的。所以就可以用上面的巨集去做。
會做 8 位元的表之後,就可以做 32 位元的反轉:
或是用指標:
位元逆轉其實可以用分治法去做:假設現在要反轉的是 v[2n-1:0]
,而且現在已經有左半部的反轉結果 left = rev(v[2n-1:n)]
跟右半部的反轉結果 right = rev(v[n-1:0])
,那只要把這兩半部對調,即 {righ, left}
就是反轉的結果。
舉例來說:假設現在想要反轉的東西是這樣:
如果已經有上下兩半反轉結果,那把這上下兩半交換,就會得到全部的反轉結果:
然後遞迴下去:就可以知道可以用下面的順序找出 32 位元的反轉結果:
舉實例來說,如果要反轉的位元是 hgfe dcba
,過程會像下面這個樣子:
所以,在 32 位元的狀況下,就會看起來像下面這樣:
另外,也有迴圈版的:
這個版本差別是他用 top-down 的方法做 (16 位元 X2、8 位元 X4、4 位元 X8 …),並且把 mask
在迴圈裡面計算。
跟前面的 population count 類似的技巧:
大致可以看出來,乘上 0x0202020202
就是要讓原本的 8 位元數字在 64 位元中「循環很多次」; 而 % 1023
看起來跟前面「計算位數和」中的技巧有點像。但那個 & 0x010884422010
這個數字就有點不好懂。不過,如果把整個過程展開來,就可以發現他的功用:
接著取 1023
的餘數。這個可以用前面的計算位數和中的結論。因為b * 0x0202020202ULL & 0x010884422010ULL
有下面這個形式::
所以,他可以表示成 為基數的形式。把以 為基數的位數和加總起來,就會發現結果剛好是反轉之後的位元:
這個方法跟上面很像:
第一眼看過去會發現: & 0x0884422110ULL
這步跟前面很像,只是重複 b
的週期不一樣,以及最後是用「乘法」而非取「餘數」。
具體的機制可以把它前幾部展開看看:
如果稍微觀察一下,可以發現如果把它視為 256 進位,並且對每一個「位數」(也就是組成這個數的每個 byte 都拿出來)做加總的話,其實就是位元組逆轉後的結果:
這件事最前面也提到:壓縮位元可以用乘法來做。把這個過程展開如下:觀察:
也就是:
這個過程用直式展開:
注意最中間的一行,出現了我們需要的「加總」:
而且可以觀察到:整個過程都不會產生進位。所以最後只要把這個結果取出來就好。而可以觀察到:要取出來就是做 >> 32
。
本來看起來應該還要用 & 0xFF
去取出最低的 8 位元,但因為「無號數超出範圍會自動取模數」的行為,所以會自動留下最後 8 位元的結果。
跟使用 64 位元的版本比較的話:
會發現這個做法跟上面大致雷同,連 magic number 也一樣,只是拆成兩半做而已。原本使用 64 位元乘法的過程是這樣:
如果把上面不用 64 位元乘法程式稍微改寫成這樣:
那求出 l
與 r
的過程,其實就是 使用 64 位元乘法的版本切成一半而已。像下面這樣:
而 (l | r)
就會變成:
接下來做直式乘法的時候,就會變成:
實際上只有一行:
這個做法很好懂:取餘數就是取最低的 s
位元,而 (1U << s) - 1
剛好就會生出取最低位元的那個 mask
。跟計算機組織算 direct map 是同一個算法。
不過為了避免 integer promotion 各種麻煩還是附上完整的:
這個技巧就跟「十進位算除以 9 的餘數」「十六進位算除以 15 的餘數」的做法是一樣的:一直加總在那個基數下表示的各個位數,直到小於除數:
既然是計算位數的總和,能不能仿照 population count 那邊的方法,用分治法來計算?答案是可以的。只是 magic number 要先建好。這邊的第一步跟 population count 的第一步是類似的,只是依照 s
的大小,調整「柵欄」的寬度:
用 s = 7
舉例。為了方便,用英文字母幫這個整數的有效位數做劃分。劃分成 EEEE DDDDDDD CCCCCCC BBBBBBB AAAAAAA
。
首先,執行 m = (n & M[s]) + ((n >> s) & M[s]
接著要加總 F FFFFFFFF
及 G GGGGGGGG
。(m >> 14) + (m & 0x3fff)
。像這樣:
然後進行一個觀察:如果要繼續這樣加總,直到有效位數的寬度比 7 小,那麼只要做 m = (m >> 7) + (m & 0x7f)
3 次就好。
可以把 2 ~ 3 用到的 mask 跟位移量都記起來:
然後用一個迴圈來做:
接著就問:那可不可以從 s = 0
一直到 s = 32
的所有狀況,都做上面的討論,列舉出每一步需要的位移量,以及需要的 mask
。然後就可以如法炮製?這個就是裡面程式碼的做法:
這個表看起來很恐怖,不過大致上就是依照 s
的數值,枚舉上面 2. ~ 3. 步所需要的位移量 (M
) 跟 mask (R
)。所以可以發現: M[i][j]
的數字是多少,R[i][j]
對應的 mask 就是「最低的 M[i][j]
位元是 1
,而這以上的位元都是 0
」的 mask。