對推導不感興趣的老大們可以通過搜尋”def”直接跳到代碼實現部分。不過有閑心還是瞧瞧推導過程的好。我們可以看見好的演算法並非無跡可尋,完全依賴某位大牛的靈光閃現,而是通過觀察、歸納、實驗、迭代改進,逐步雕琢而成。另外,我們時常感歎,要是有時間學習演算法就好了。要是有時間仔細讀讀TAOCP就好了。其實呢,讀TAOCP也不是搶雞蛋的大事。想讀了,就備好紙筆,調出趁手的編輯器,開啟書,翻到自己感興趣的章節。遇到公式就推一下。遇到演算法就實現 一下。遇到概念就舉幾個例子印證一下。遇到難解之處就和Google勾兌一下(不要忘了Google Group,真正的技術寶藏)。至於一次讀多久,鑽多深,施主隨喜。再說從俺寫的這個系列可以看出,TAOCP不少章節也就需要高中知識,到數列為止。本來是很美好的事。不必正襟危坐。不必如臨大敵。不必把自己逼得太緊。你會發現花的時間不多,收穫卻不小。 上次說到利用遞迴定義產生格雷碼。為了方便,我們把公式重新列一下:其實我們也可以用遞推公式定義格雷碼:,明確地給出格雷碼錶裡每一位格雷碼的運算式: 。這裡的g(k)表示在格雷碼錶裡排在第k位的格雷碼。其實,因為 以 開頭,格雷碼的無限序列是所有非負整數的某個排列:如果我們把每個格雷碼看作前面可以填充零的二進位整數,那長度為N的格雷碼序列 包含公式(4)中最前面的2n個整數。只不過每個整數前可能填充0,使之長度為N。再把整數轉換成字串,就產生了相應的格雷碼。比如說當N=3時,第二位格雷碼是001,相當於在公式(4)裡的第二個整數(1)2前面補上兩個0後將其轉換為字串,使得該字串的長度等於3。當k = 2n + r 且 時,我們可以從公式(3)得出g(k) = 2n + g(2n – 1 – r )。這個結論的推導也挺容易:既然k > 2n, 那麼我們知道g(k)一定在 裡面。既然 表示將 反轉,那 中對應第r位的格雷碼剛和等於 中第2n-r-1位的格雷碼。最後,在g(r)前添一個1,正好相當於g(r)+(1000…000)2,共n個0。換成10進位,就得到g(k) = 2n + g(2n – 1 – r )了。根據這個公式,我們可以得出一個非常有用的結論:給出整數k和它的二進位形式(…b2b1b0)2,以及第k位的格雷碼g(k)的二進位形式(…a2a1a0)2,我們可以用數學歸納法證明下面的關係:這個 表示位元運算中的異或運算。也就是說,只有當兩個位不一樣時他們異或的結果為1。不然就為0。比如說,當k = 3651時,它的二進位形式是(111001000011)2。那第k位的格雷碼g(k)就是 k ^ (k >> 1) = 2402 = (100101100010)2。證明其實也不難,利用公式g(k) = 2n + g(2n – 1 – r )對n做歸納就行。有興趣的老大們可以自己去做。網上也有答案。現在我們把 從j開始展開,看看能得到什麼:
對等式兩邊分別求異或,我們得到 中間項都消掉了,因為 等於0, 而0在異或運算中是identity, 也就是說任何一個值與0異或還是等於自身。這個等式看來是無窮項,但因為 遲早會等於0, 所以它其實是有限的,可以計算。根據公式(5),我們得到很酷的公式:注意這個 對應編程裡的位移運算:k >> 1,所以實現起來也方便。我們立刻有了新的演算法,異常簡潔:
defgray_g(n)
return []
if n ==0
return (0..((1<<n) -1)).collect{|k|sprintf(“%0#{n}b”, (k ^ (k>>1)))}
end
上述程式無非是說,從0迴圈到2n, 對每個迴圈k, g(k) = k ^ (k >> 1)。夠簡潔吧?唯一的問題是,這個方法還是不夠高效。每次迴圈,我們都要對k做位移。而位移的時間和k的長度成正比。迴圈2n次是很大的開銷,我們自然希望每次迴圈的計算量越小越好。同理,公式(6)可以匯出很酷的反向公式。於是我們可以求出任意格雷碼的位置:如果有些老大對這種寫法不習慣,下面的等價寫法也許清楚點:對應的代碼也很直觀:
def gray_pos(gray_code)
pos = g = gray_code.to_i(2)
while g > 0
g >>= 1
pos ^= g
end
return pos
end
其實格雷碼的原理早已隱含在九連環的解法裡。九連環的傳統解法對應一種頗為高效的格雷碼產生代碼。關於九連環的知識,可以到這裡或者這裡去瞭解。俺就不羅嗦了。從推導演算法的角度出發,我們只需要知道玩兒九連環的兩條規則(引自上面連結的文章):a): “第1號環隨時可自由上下”b): “其他環若且唯若它前面僅有與它相鄰的一個環在上面時可自由上下。”(從TAOCP截的圖) 基於這兩條規則,我們可以把九連環上每個環的狀態用2進位數表示。環在杠上,對應的數字為1,環在杠下,對應的數字為0。比如的,從左向右數,對應的數字是(1011000)2。注意第二個環沒有套在杠上。這樣的話,解環對應於把(111…11)2變成(000…00)2。而套環對應於把(000…00)2變成(111…11)2。因為每次最多變動一個環,解環或套環環的過程相當於產生格雷碼全序列。 1872年一個名叫Louis Gros的法國官員證明了如果一個九連環的狀態為(an-1…a0)2, 而我們按上面的公式(6)定義一個位元k = (bn-1, … , b0)2, 那麼我們可以剛好用k步解除該九連環。從這個意義說,Gros老大才是格雷碼的真正發明人。 我們用解環來說明格雷碼的產生過程。只要九連環的狀態不是(000…00)2或(100…00)2, 那我們只可能執行規則a)或者b),而且只有其中一步可以讓我們靠近答案。規則a)把環取下時,相當於最末一環的狀態從1變成0, 而套環相當於把0變成1。也就是說 。參考公式(6),這相當於把把k變成 ,因為只有k0變了。顯然我們希望當k的最後一位是1,也就是當k為奇數時按規則a)執行。這樣k變成k-1,離我們的目標近了一步。另外一方面,根據規則b),只有形為(…x100…00)2的九連環可以移動x代表的那個環。換句話說,如果一個長度為n的九連環裡某個環所在位置以(10j-1)2結尾(這裡(10j-1)2表示一個位元,第一位是1,後面跟了j – 1個0), 1 <= j < n, 則該環可以移動。該環對應aj+1, 所以移動後該環的狀態變成 。根據公式(6),我們知道bj, bj-1,… , b0, 都包含aj+1項。所以說移動該環後,k變成了 。舉個例子哈。上面九連環圖裡從右數第3個環可以被解下。用數字來表達,就是(1011000)2可以變成(1001000)2。具體到右數第三左數第5個環,我們可以說a4從1變成0。解環前k等於gray_pos(0b101100) = 55=(110111)2, 解下環後k變成了 = gray_pos(0b100100) = 56 = (111000)2。同執行規則a)一樣,我們希望執行規則b)後k變小: 應該等於k – 1。要達到這個目標,k必須是2j的倍數,但不能是2j+1的倍數。這是因為形如(xxx…10j)2的數才是2j的倍數。它和2j+1-1異或後,才能等於(xxx…01j )2,也就是k-1。我們可以把j和k的關係表達為:這裡的函數 叫作ruler function,有興趣的老大們可以參見這裡的第13頁,印張第8頁,公式32到45。那節專講位元運算的技巧,可以和IBM某編譯器牛人出的Hacker's Delight以及這個網頁一塊兒看。做嵌入式的老大和雅好底層最佳化的老大們應該可以獲益不少。不過這裡俺就不跑題了。我們只需要知道 返回k靠右連續0的個數。比如說 。從九連環可以自由移動的那頭給環的移動次序編號:0, 1, 2, …, , 那把環的狀態從00000…0變成1111…11的移動順序就是 。那對應的演算法也就很直觀了:設定長度為n的序列,該演算法從(0, 0, 0…, 0)開始,逐次訪問每個組合:(an-1, …, a0)2。每一步只改變一位,相當於移動一個環。對每一步i, 移動對應的環相當於找到 對應的環,然後對它的值求補(異或)。我們也不需要每次通過求異或判斷奇偶來決定執行規則a)還是規則b):維護一個奇偶值,parity就行了:
32: def alg_g(n)
33: step = Array.new(n, 0)
34: result = []
35: parity = 0
36:
37: loop do
38: result << step.join
39: parity = 1 - parity
40:
41: if parity == 1
42: j = 0
43: else
44: 0.upto(n-1) do |i|
45: if step[i] == 1
46: j = i+1
47: break
48: end
49: end
50: end
51:
52: return result.reverse if j == n
53:
54: step[j] = 1 - step[j]
55: end
56: end
這個奇偶位很有點意思。當我們要計算求和公式 或者且計算結果只依賴於二進位字串的奇偶性或者某個子集裡的元素個數的時候,這個公式就變得很方便了。而且這個奇偶位也讓上面的演算法變得高效:沒有它的話,決定到底執行規則a)還是規則b)就不容易了。 這個演算法最精當的地方在於第54行。我們只需要對一個bit做出改動。不過呢,對每一個產生的格雷碼,我們有可能執行第44到48行的內迴圈。當我們要執行2n次外迴圈時,這個開銷還是大了點。所以我們接下來要介紹除去Gideon Ehrlich在1973年提出的方法,消去內迴圈,並從中學到新的設計演算法解決問題的手段。