最近沒有合適的床頭書可以看,於是索性把CS:APP(深入理解計算機系統)取下來放在床邊,睡不着覺時隨意翻一翻,以期穩故知新。在CS:APP第2.3.6小節中提到,由於整數乘法指令通常會比加減法和位運算指令會慢上許多,因此編譯器有時會做一個優化:用移位和加減法的組合來代替乘以常數因子的乘法,比如x * 18可以寫成(x<<4) + (x<< 1), 而x * 7可以寫成(x<<3) - x。也就是先把常數因子拆成2冪的組合,然后再進行運算。顯然,拆出來的項要越少越好,比如7 = 4 + 2 + 1就不如7 = 8 – 1. 那么任意給定一個整數,怎么將它拆成項數最少的2冪組合呢?拋開編譯器優化啥的不談,這本身也是一個非常有趣的問題,所以我決定來好好分析一下。當然從最后的結果來看,花在這個問題上的時間還是非常值得的,因為它雖然沒有預想中的簡單,但是絕對更有趣。
再陳述下這個問題,為了顯得正式一點,我隨便給它取了個名字.
2冪拆分問題: 任意一個整數x都可以寫成一個系列2的冪的組合,這里的組合是指用加減法把它們接連起來。令f(x)為表示x需要的最少的2的冪的項數,求f(x)。
顯然有f(x) = f(-x), 為了討論的方便,后文一致假設x非負。另外,上面的問題陳述對於x=0的情況顯得略微有點不太自然,這里再特別地定義一下f(0) = 0,如果你比較細節控的話。
大概很多同學瞬間就能想到一個解法。令floor2(x)表示不大於x的最大2冪, ceil2(x)表示不小於x的最小2冪,由上面x = 7的例子可以得到啟發,最優的組合要么是floor2(x)加上其它一些項,或者是ceil2(x)減去其它一些項,於是可以得到求f(x)的一個遞歸過程如下,
# 算法1 def f(x): if x == 0: return 0 l = floor2(x) u = ceil2(x) if l == u: return 1 return 1 + min(f(x - l), f(u - x))
雖然上面的解法只是一個yy, 但認真考慮一下便可以知道這的確是正確的。證明的過程都是一些很顯然的推導,這里略過。我更感興趣的是,算法1在最壞情況下的復雜度是多少? (不感興趣的可以直接跳到后面看算法2。)
但在求最壞情況復雜度之前,我們首先需要知道,該算法的最壞情況是什么,即當輸入的規模相當時,x取哪些值時會使得該算法的遞歸次數達到最大。從上面的遞歸過程你大概可以感覺到算法1的復雜度與x的2進制位數相關,所以這里的“輸入規模相當”的意思可以說得更明確一點,就是x的2進制位數相同。
為了找到更多一些感覺,再來仔細看一下算法1的遞歸過程,當算法1運行到最后一項時,顯然x – l和u - x都至少比x少一位,而且x – l和u - x不可能同時只比x少一位。也就是說一個輸入為b位的遞歸過程會分裂為輸入分別不超過b - 1位和b - 2位的兩個子過程。如果我們能構造出一個輸入使得在所有的遞歸過程中都分裂為恰好b - 1位和b - 2位的子過程,那么這個輸入肯定是一種最壞情況。
構造出這樣一種輸入也並不難,比如當位數為奇數時可以令x = 101010…1,(二進制表示,后同),當位數是偶數時可以令x = 101010…11。用S(n)表示(10){n}1,即n個10后面再跟一個1,用T(n)表示(10){n}11 = Sn1,即n個10后面再跟兩個1. 那么當輸入為S(n)或者T(n)時算法1的遞歸過程如下,
S(n) –> S(n-1), T(n-1)
T(n) –> T(n-1), S(n)
上面兩式在所有n>0的情況下都滿足。於是我們確定了S(n)和T(n)即是算法1的最壞情況。如果你覺得這里的推導不夠嚴謹的話還可以自己用數學歸納法來證明。另,S(n)和T(n)並不是唯一的最壞情況輸入,有興趣的話可以找找另一種。
最壞情況輸入有了,現在我們來復雜度,令s(n)表示輸入為S(n)時算法1的遞歸次數, t(n)表示輸入為T(n)時算法1的遞歸次數,則有,
s(n) = s(n-1) + t(n-1) + 1
t(n) = t(n-1) + s(n) + 1
將上面第2式代入第1式進行展開,可以得到一個關於s(n)的遞歸式,
另有s(0) = 1. 這個遞歸式看起來有些麻煩, 因為右邊的項數是隨着n變化的。所幸,我們還有萬能的生成函數!令G(z)為s(n)的生成函數,
利用生成函數的一些基本技巧(移位,卷積,求導)可以得到以下幾個式子,
注意到上面四個式子分別對應於s(n)的遞歸式的右邊四項,於是有,
上面最后減了一個2是為了滿足結束條件s(0) = G(0) = 1。由上式可以解出,
對於這種形式的生成函數,我們有個通用的手段將其展開,通過Rational Expansion Theorem(pdf, page10)可以得到,
於是有,
其中φ = 1.618…, 為黃金分割率。s(n)知道了,t(n)也可以很容易求出來。同時也可以推出算法1的復雜度F(x)為,
上面費了老大勁來寫算法1的分析過程,倒不是說算法1有多么的好(實際上是稀爛無比),而是分析的過程本身很有趣,我覺得值得和大家分享。解遞歸式神馬的最有意思了。
現在來看看怎么改進算法1,容易觀察到,算法1的遞歸過程中包含了大量的重復運算,那么一個很自然的想法就是把它改成記憶化的動態歸划,大概像這樣,
# 算法2 def f(x): if x == 0: return 0 if x in table: return table[x] l = floor2(x) u = ceil2(x) if l == u: return 1 r = 1 + min(f(x - l), f(u - x)) table[x] = r return r
那么算法2的復雜度又如何呢。我們需要分析在算法2的遞歸過程中會產生哪些不重復的串。這個比較簡單,因為對於任意的輸入x, 在遞歸過程中新產生的輸入都一定都是x的后綴或者是~x+1(即x取反再加1)的后綴. 比如若x = 1010110, ~x+1 = 0101010,那么f(x)在遞歸過程會產生以下一些串:
1010110 –> 10110 –> 110 -> 10
0101010 –> 1010
你可能已經觀察,所產生的所有串的數目剛好是x的位數減去末尾的0的數目。比如上面的例子中x有7位,末尾有1個0,因此產生的串的數目剛好是7 – 1 = 6。這也可以通過歸納法來證明。於是我們知道,算法2的時間復雜度為O(lgx), 同時還需要一張大小為O(lgx)的hash表。
經過上面對算法2的分析,我們對整個遞歸過程所有可能產生的串都已經非常了解了,於是我們可以實現一個自底而上,遞推的動態歸划,從而去掉算法2中那個讓人很不舒服的hash表。
# 算法3 def f(x): if x == 0: return 0 while x & 1 == 0: x = x >> 1 u = 1 d = 1 x = x >> 1 while x > 0: if x & 1 == 1: u = min(u, d) + 1 else: d = min(u, d) + 1 x = x >> 1 return u
顯然,算法3的時間復雜度為O(lgx),空間復雜度為O(1)。Done!
另外,本文雖然只討論了怎么計算f(x),但是上面的算法通過簡單的改動就能在計算f(x)的同時返回這f(x)項的2冪具體應該怎樣組合。