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