【编程之美】一摞烙饼的排序
一,問題:????????????
??????? 星期五的晚上,一幫同事在希格瑪大廈附近的“硬盤酒吧”多喝了幾杯。程序員多喝了幾杯之后談什么呢?自然是算法問題。有個同事說:“我以前在餐館打工,顧客經常點非常多的烙餅。店里的餅大小不一,我習慣在到達顧客飯桌前,把一摞餅按照大小次序擺好——小的在上面,大的在下面。由于我一只手托著盤子,只好用另一只手,一次抓住最上面的幾塊餅,把它們上下顛倒個個兒,反復幾次之后,這摞烙餅就排好序了。我后來想,這實際上是個有趣的排序問題:假設有n塊大小不一的烙餅,那最少要翻幾次,才能達到最后大小有序的結果呢?”
??????? 你能否寫出一個程序,對于n塊大小不一的烙餅,輸出最優化的翻餅過程呢?(參考flyingherts的專欄)
?
二,分析:
??????? n個烙餅經過翻轉后的所有狀態可組成一棵樹。尋找翻轉最少次數,相當于在樹中搜索層次最低的某個節點。
??????? 由于每層的節點數呈幾何數量級增長,在n較大時,使用廣度優先遍歷樹,可能沒有足夠的內存來保存中間結果(考慮到每層的兩個節點,可以通過旋轉,移位等操作互相轉換,也許每層的狀態可以用一個函數來生成,這時可以采用廣度優先方法),因而采用深度優先。但這棵樹是無限深的,必須限定搜索的深度(即最少翻轉次數的上限值),當深度達到該值時不再繼續往下搜索。最少翻轉次數,必然小等于任何一種翻轉方案所需的翻轉次數,因而只要構造出一種方案,取其翻轉次數即可做為其初始值。最簡單的翻轉方案就是:對最大的未就位的烙餅,將其翻轉,再找到最終結果中其所在的位置,翻轉一次使其就位。因此,對編號在n-1和2之間的烙餅,最多翻轉了2*(n-2)次,剩下0和1號烙餅最多翻轉1次,因而最少翻轉次數的上限值是:2*(n-2)+1=2*n-3(從網上可搜索到對該上限值最新研究結果:上限值為18/11*n),當然,最好還是直接計算出采用這種方案的翻轉次數做為初始值。
?
?
三,減少遍歷次數:
?
???? 1 )減小“最少翻轉次數上限值”的初始值,采用前面提到的翻轉方案,取其翻轉次數為初始值。對書中的例子{3,2,1,6,5,4,9,8,7,0},初始值可以取10。
?
???? 2 ) 避免出現已處理過的狀態一定會減少遍歷嗎?答案是否定的,深度優先遍歷,必須遍歷完一個子樹,才能遍歷下一個子樹,如果一個解在某層比較靠后位置,若不允許處理已出現過的狀態時,可能要經過很多次搜索,才能找到這個解,但允許處理已出現過的狀態時,可能會很快找到這個解,并減小“最少翻轉次數的上限值”,使更多的分支能被剪掉,反而能減少遍歷的節點數。比如說,兩個子樹A、B,搜索子樹A,100次后可得到一個對應翻轉次數為20的解,搜索子樹B,20次后可得到翻轉次數為10的解,不允許處理已出現過的狀態,就會花100次遍歷完子樹A后,才開始遍歷B,但允許翻轉回上一次狀態,搜索會在A、B間交叉進行,就可能只要70次找到子樹B的那個解(翻轉次數為10+2=12),此時,翻轉次數上限值比較小,可忽略更多不必要的搜索。以書中的{3,2,1,6,5,4,9,8,7,0}為例,按程序(1.3_pancake_1.cpp),不允許翻轉回上次狀態時需搜索195次,而允許翻轉回上次狀態時只要搜索116次。
?
????? 3) 如果最后的幾個烙餅已經就位,只須考慮前面的幾個烙餅。對狀態(0,1,3,4,2,5,6),編號為5和6的烙餅已經就位,只須考慮前5個烙餅,即狀態(0,1,3,4,2)。如果一個最優解,從某次翻轉開始移動了一個已經就位的烙餅,且該烙餅后的所有烙餅都已經就位,那么對這個解法,從這次翻轉開始得到的一系列狀態,從中移除這個烙餅,可得到一系列新的狀態。必然可以設計出一個新的解法對應這系列新的狀態,而該解法所用的翻轉次數不會比原來的多。
?
?????? 4 )估計每個狀態還需要翻轉的最少次數(即下限值),加上當前的深度,如果大等于上限值,就無需繼續遍歷。這個下限值可以這樣確定:從最后一個位置開始,往前找到第一個與最終結果位置不同的烙餅編號(也就是說排除最后幾個已經就位的烙餅),從該位置到第一個位置,計算相鄰的烙餅的編號不連續的次數,再加上1。每次翻轉最多只能使不連續的次數減少1,但很多人會忽略掉這個情況:最大的烙餅沒有就位時,必然需要一次翻轉使其就位,而這次翻轉卻不改變不連續次數。(可以在最后面增加一個更大的烙餅,使這次翻轉可以改變不連續數。)如:對狀態(0,1,3,4,2,5,6)等同于狀態(0,1,3,4,2),由于1、3和4、2不連續,因而下限值為2+1=3。下限值也可以這樣確定:在最后面增加一個比所有烙餅都大的已經就位的烙餅,然后再計算不連續數。如:(0,1,3,4,2),可以看作(0,1,3,4,2,5),1和3 、4和2 、2和5這三個不連續,下限值為3。
?
????? 5)多數情況下,翻轉次數的上限值越大,搜索次數就越多。可以采用貪心算法,通過調整每次所有可能翻轉的優先順序,盡快找到一個解,從而減少搜索次數。比如,優先搜索使“下限值”減少的翻轉,其次是使“下限值”不變的翻轉,最后才搜索使“下限值”增加的翻轉。對“下限值”不變的翻轉,還可以根據其下次的翻轉對“下限值”的影響,再重新排序。由于進行了優先排序,翻轉回上一次狀態能減少搜索次數的可能性得到進一步降低。
?
?????? 6 )其它剪枝方法:
????????????? 假設進行第m次翻轉時,“上限值”為min_swap。
???????????? 如果翻轉某個位置的烙餅能使所有烙餅就位(即翻轉次數剛好為m),則翻轉其它位置的烙餅,能得到的最少翻轉次數必然大等m,因而這些位置都可以不搜索。
???????????? 如果在某個位置的翻轉后,“下限值”為k,并且 k+m>=min_swap,則對所有的使新“下限值”kk大等于k的翻轉,都有 kk+m>=min_swap,因而都可以不搜索。該剪枝方法是對上面的“調整翻轉優先順序”的進一步補充。
?
???????????? 另外,翻轉某個烙餅時,只有兩個烙餅位置的改變才對“下限值”有影響,因而可以記錄每個狀態的“下限值”,進行下一次翻轉時,只須通過幾次比較,就可以確定新狀態的“下限值”。(判斷不連續次數時,最好寫成 -1<=x && x<=1, 而不是x==1 || x==-1。對于 int x; a<=x && x<=b,編譯器可以將其優化為 unsigned (x-a) <= b-a。)
?
結果:
對書上的例子{3,2,1,6,5,4,9,8,7,0}:
| ? | 翻轉回上次狀態 | 搜索函數被調用次數 | 翻轉函數被調用次數 |
| 1.3_pancake_2 | 不允許 | 29 | 66 |
| 1.3_pancake_2 | 允許 | 33 | 74 |
| 1.3_pancake_1 | 不允許 | 195 | 398 |
| 1.3_pancake_1 | 允許 | 116 | 240 |
(這個例子比較特殊,代碼1.3_pancake_2.cpp(與1.3_pancake_1.cpp的最主要區別在于,增加了對翻轉優先順序的判斷,?代碼下載),在不允許翻轉回上次狀態且取min_swap的初始值為2*10-2=18時,調用搜索函數29次,翻轉函數56次)。
?
搜索順序對結果影響很大,如果將1.3_pancake_2.cpp第152行:
for (int pos=1, last_swap=cake_swap[step++]; pos<size; ++pos){
這一行改為:
for (int pos=size-1, last_swap=cake_swap[step++]; pos>=1; --pos){
僅僅調整了搜索順序,調用搜索函數次數由29次降到11次(對應的翻轉方法:9,6,9,6,9,6),求第1個烙餅數到第10個烙餅數,所用的總時間也由原來的38秒降到21秒。)
四,源碼及分析
輸出結果:
4 8 6 8 4 9
|Search Times| : 172126
Total Swap times = 6
5 6 1 2 3 4 9 8 7 0
7 8 9 4 3 2 1 6 5 0
1 2 3 4 9 8 7 6 5 0
5 6 7 8 9 4 3 2 1 0
9 8 7 6 5 4 3 2 1 0
0 1 2 3 4 5 6 7 8 9
五,優化:
在網上下了《編程之美》“第6刷”的源代碼,結果在編譯時存在以下問題:
1) Assert 應該是 assert
2) m_arrSwap 未被定義,應該改為m_SwapArray
3 )Init函數兩個for循環,后一個沒定義變量i,應該將i 改為 int i
另外,每運行一次Run函數,就會調用Init函數,就會申請新的內存,但卻沒有釋放原來的內存,會造成內存泄漏。if(step + nEstimate > m_nMaxSwap) 這句還會造成后面對m_ReverseCakeArraySwap數組的越界訪問,使程序不能正常運行。
?
書上程序的低效主要是由于進行剪枝判斷時,沒有考慮好邊界條件,可進行如下修改:
1 ) if(step + nEstimate > m_nMaxSwap)? >改為 >=。
2 ) 判斷下界時,如果最大的烙餅不在最后一個位置,則要多翻轉一次,因而在LowerBound函數return ret; 前插入行:
??????????????? if (pCakeArray[nCakeCnt-1] != nCakeCnt-1)
??????????????????????????????????????? ret++;
3 ) n個烙餅,翻轉最大的n-2烙餅最多需要2*(n-2)次,剩下的2個最多1次,因而上限值為2*n-3,因此,m_nMaxSwap初始值可以取2*n-3+1=2*n-2,這樣每步與m_nMaxSwap的判斷就可以取大等于號。
4 )采用書上提到的確定“上限值”的方法,直接構建一個初始解,取其翻轉次數為m_nMaxSwap的初始值。
?
???????? 1和2任改一處,都能使搜索次數從172126降到兩萬多,兩處都改,搜索次數降到3475。若再改動第3處,搜索次數降到2989;若采用4的方法(此時初始值為10),搜索次數可降到1045。
六,思考
? ? ? ? ? 書中P22頁提到動態規劃,但最后卻給出了解決最優化問題普遍適用但效率可能是最差的遞歸方法。這不禁讓人疑惑:這也不美啊!?如果我們能證明該問題滿足動態規劃或貪心算法的使用條件,解決問題的時間復雜度將會降到多項式時間甚至N^2。但書中提到動態規劃卻最終沒有使用,又沒有講明原因,我覺得是一種疏失(應該不算錯誤)。那我們就來想一下為什么沒有動態規劃或貪心算法的原因。
? ? ? ? ?我們知道動態規劃方法是一種自底向上的獲取問題最優解的方法,它采用子問題的最優解來構造全局最優解。利用動態規劃求解的問題需要滿足兩個條件:即(1)最優子結構 (2)子結構具有重疊性。條件(1)使我們可以利用子問題的最優解來構造全局最優解,而條件(2)是我們在計算過程中可以利用子結構的重疊性來減少運算次數。此外,《算法導論》上還以有向圖的無權最短路徑和無權最長路徑為例提出條件(3)子問題必須獨立。
??
? ? ? ? ?首先我們假定烙餅問題存在優化子結構。假如我們有N個烙餅,把他們以其半徑由小到大進行編號。優化子結構告訴我們對于i個烙餅,我們只需要先排列前(i-1)個,然后再將第i個歸位;或先排列第2到i個,最后將第一個歸位;又或是找到一個位置k[i<=k<j]像矩陣乘法加括號那樣,使得我們先排列前k個,再排列后j-k個,最后再將二者合并,以找到一個最佳翻轉策略等等...
??
? ? ? ? ?根據動態規劃算法的計算過程,我們需要一個N*N矩陣M,其中M[i][j]表示將編號i至編號j的烙餅排序所需要的翻轉次數。但我們真的能從M[0][0..j-1]和M[1][j+1],或與M[i][j]同行同列的值來計算M[i][j]嗎?如果能,我們就能獲得多項式時間的算法。??
? ? ? ? 我們來看書中給出的例子:(頂端)3,2,1,6,5,4,9,8,7,0(底端),我們最終的目標是計算M[0][9]。
這里我們以計算M[0][4]為例,計算的矩陣我已經在下面給出:??
? 0 1? 2? 3? 4? 5? 6? 7? 8? 9
? ------------------------
0|0 1 (1){1}[?]
1|? 0? 1 (1){1}??
2|???? 0? 1 (1)?
3|??????? 0? 0
4|?????????? 0
? ------------------?
??
? ? ? ? 實際上如果我們要向將0-4號烙餅(注意:烙餅編號也等同于其半徑)排為正序(中間有其他烙餅也沒關系),按照程序給出的結果, 我們需要進行3次翻轉,分別為[2,5,9](即分別翻轉隊列中第二(從零開始)、五、九個烙餅,這里的數字不是烙餅的編號):??
? [1]? [2]? [3]? 6?? 5? [4]? 9? 8? 7? [0]
? [4]?? 5??? 6? [3] [2] [1]? 9? 8? 7? [0]
? [0]?? 7??? 8?? 9? [1] [2] [3] 6? 5? [4]
??
? ? ? ? ?我們知道,該矩陣中每一個數的背后都隱含著一個烙餅的排列,例如M[0][4]就應該對應0,7,8,9,1,2,3,6,5,4
? 所以,每一個M[i][j]的選取都蘊含著其子排列的順序的變化。
? ? ? ? 計算M[i][j]的時候,我們需要計算i-j號餅的全部劃分(不包括全部為1的劃分)所能構成的翻轉結構,并取其翻轉 次數最少的哪一個最為M[i][j]的最終值。例如,我們在計算M[0][4]的時候,需要查看:
??
?? /**先將0和1-4號分別排序,最后將二者合并為有序所需要的翻轉次數*/
?? M[0][0],M[1][4]?
???
?? /** 同上 */
?? M[0][1],M[2][4]
???
?? /** 同上 */
?? M[0][2],M[3][4]
???
?? /** 同上 */
?? M[0][3],M[4][4]
???
?? /* 先將0、1、2、3-4號分別排序,最后將4者合并為有序所需要的翻轉次數.
??? * 注意這里又包含將4個分組再次進行劃分的問題!?
??? */
?? M[0][0],M[1][1],M[2][2],M[3][4]
?? .....//中間略
?? M[0][3],M[4][4]
??
? ? ?如果再加上運算過程中我們可以淘汰超過最大反轉次數的方案(剪枝?),我們完成全部的運算所經歷的運算過程的時間復雜度已經不是多項式時間的,而是和先前所說的遞歸方法已沒什么兩樣。
???? 造成這種現象的原因是:某個子問題的最優解不一定是整體的最優解,所以我們在處理整個問題的時候,需要遍歷所有可能的子問題,并計算它到整體問題所消耗的代價,才能最終作出有利于整體問題的選擇。
? ? ? 所以我們一開始的假設,即烙餅問題有優化子結構的假設是錯誤的。因此我們不能用動態規劃,同理也不能用貪心算法。
??
? ? ? ?但說到每一步的“選擇”問題,我記得算法導論上有一個叫做“A*”的算法,它的思想是在進行每一步選擇的時候都“推算”最終可能需要的代價,并選擇當前代價最小的分支進行遍歷。這個“推算”的結果可能不會是最終的代價,而只是作為分支選擇的依據。如果誰有興趣就做一下吧 :-)
??
總結
以上是生活随笔為你收集整理的【编程之美】一摞烙饼的排序的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 单词单词啊
- 下一篇: 阿里云服务器和独享云虚拟主机有什么区别?