我對演算法很感興趣,這次介紹的煎餅排序問題是在很多演算法課程上都介紹過的經典例子。如果這是你第一次接觸這個問題,我非常建議你在閱讀時先獨立思考解決方法。下面我們開始,希望大家喜歡咯。
- 煎餅排序: 維基百科給出的釋義煎餅排序是數學上的一個問題的一種通俗叫法:對一堆無序的煎餅以大小排序,鏟子可以在任意位置伸進去並且把上面的煎餅都翻轉過來。
通俗點說,我們有一個鍋鏟和一堆煎餅,我們的目標是將煎餅按照大小排序,大的在下面。我們唯一的辦法是讓鍋鏟從一個地方伸進去,並且把上面所有的煎餅翻下來。舉個栗子,一開始的煎餅是這樣子的:
我們決定在這裡鏟入:
紅色箭頭代表插入位置,藍色的表示新的煎餅堆底。
就是這樣!
- 煎餅排序演算法
(在你往下看之前,我建議你先自己想想解決辦法。)
我現在講的不是最好的辦法,但是卻是最直觀最容易解釋的。我選這種方法是為了向人們展示有些演算法是非常容易並且直觀的。我希望大家看了以後都能來嘗試一下演算法。通常,計算機專業會把普通人都嚇跑,因為它一開始看上去太令人緊張。在這篇文章最後我將貼上更快的演算法的連結。
- 將問題分解開來:
- 我們需要將煎餅排序,初始的形狀可能是任意的。
- 我們只能對一部分煎餅進行翻轉。
- 如果想讓某一塊特定的煎餅在最下面,需要先把它翻到最上面。
- 因此想要排好一塊煎餅就需要先翻一下把它翻到頂上再把它翻到下面才行。
- 憑直覺想出來的演算法
- 把未排序的煎餅中最大(或者順序在最後)的煎餅翻下去(需要兩步)。
- 重複第一步。
既然我們有了演算法,那麼我們就來思考一下看它正確與否吧:
- 只有一個煎餅的時候正確嗎?——正確。
- 兩個煎餅,大的在上,正確嗎?——正確,我們翻一下就行了。
- 如果有三個煎餅,順序從上到下依次是:中、大、小,正確嗎?正確,我們先把大的翻上去,現在從上到下依次是大、中、小;然後我們再整個翻一下,順序就變成了小、中、大。
既然在這幾種簡單情況下都是正確的,那麼我們不妨用Python寫出來吧~(歡迎在Github上Fork我的程式碼。)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# Sorts Pancakes def sortPancakes(stack): sorted_index = 10 for i in reversed(range(len(stack))): stack = flip(stack, findLargestPancake(stack, i)) print("Flip Up", stack) stack = flip(stack, i) print("Flip Down", stack) return stack # All of the pancakes are sorted after index # Returns the index of largest unsorted pancake def findLargestPancake(stack, index): largest_pancake = stack[index] largest_index = index; for i in range(index): if stack[i] > largest_pancake: largest_pancake = stack[i] largest_index = i print "" print("Insert Spatula in index", largest_index, "Size", largest_pancake) return largest_index # Slide spatula under pancake at index and flip to top def flip(stack, index): newStack = stack[:(index + 1)] newStack.reverse() newStack += stack[(index + 1):] return newStack stack = [1, 4, 5, 2, 3, 8, 6, 7, 9, 0] print"\nUnsorted:" print stack print "\nIterating:" stack = sortPancakes(stack) print "\nSorted:" print stack print "" |
我們執行一下程式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
Unsorted: [1, 4, 5, 2, 3, 8, 6, 7, 9, 0] Iterating: (‘Insert Spatula in index’, 8, ‘Size’, 9) (‘Flip Up’, [9, 7, 6, 8, 3, 2, 5, 4, 1, 0]) (‘Flip Down’, [0, 1, 4, 5, 2, 3, 8, 6, 7, 9]) (‘Insert Spatula in index’, 6, ‘Size’, 8) (‘Flip Up’, [8, 3, 2, 5, 4, 1, 0, 6, 7, 9]) (‘Flip Down’, [7, 6, 0, 1, 4, 5, 2, 3, 8, 9]) (‘Insert Spatula in index’, 0, ‘Size’, 7) (‘Flip Up’, [7, 6, 0, 1, 4, 5, 2, 3, 8, 9]) (‘Flip Down’, [3, 2, 5, 4, 1, 0, 6, 7, 8, 9]) (‘Insert Spatula in index’, 6, ‘Size’, 6) (‘Flip Up’, [6, 0, 1, 4, 5, 2, 3, 7, 8, 9]) (‘Flip Down’, [3, 2, 5, 4, 1, 0, 6, 7, 8, 9]) (‘Insert Spatula in index’, 2, ‘Size’, 5) (‘Flip Up’, [5, 2, 3, 4, 1, 0, 6, 7, 8, 9]) (‘Flip Down’, [0, 1, 4, 3, 2, 5, 6, 7, 8, 9]) (‘Insert Spatula in index’, 2, ‘Size’, 4) (‘Flip Up’, [4, 1, 0, 3, 2, 5, 6, 7, 8, 9]) (‘Flip Down’, [2, 3, 0, 1, 4, 5, 6, 7, 8, 9]) (‘Insert Spatula in index’, 1, ‘Size’, 3) (‘Flip Up’, [3, 2, 0, 1, 4, 5, 6, 7, 8, 9]) (‘Flip Down’, [1, 0, 2, 3, 4, 5, 6, 7, 8, 9]) (‘Insert Spatula in index’, 2, ‘Size’, 2) (‘Flip Up’, [2, 0, 1, 3, 4, 5, 6, 7, 8, 9]) (‘Flip Down’, [1, 0, 2, 3, 4, 5, 6, 7, 8, 9]) (‘Insert Spatula in index’, 0, ‘Size’, 1) (‘Flip Up’, [1, 0, 2, 3, 4, 5, 6, 7, 8, 9]) (‘Flip Down’, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) (‘Insert Spatula in index’, 0, ‘Size’, 0) (‘Flip Up’, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) (‘Flip Down’, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) Pancake Sort Completed! Sorted:<em> </em>[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] |
成功了!就是這麼簡單!
- 計算執行時間
計算一個演算法的執行時間是非常重要的。這可以讓你知道問題的複雜程度和規模。在計算機領域我們經常用大O表示法(Big-O Notation),它保證了程式的執行時間一定在這之內。(如果你想不起來或者根本沒聽說過這個名稱,建議你移步維基。)
分析這個演算法的執行時間還是比較直觀的。可以想象,最差的情況是,這堆煎餅是交替的最小和最大。比如[0, 9, 1, 8, 2, 7, 3, 6, 5, 4]。我們需要先把9翻上去,再翻到底下。然後是8、7、6等等。每一個都需要兩步,所以總的步數最大是2n-3。n是總的煎餅個數,“-3”是因為當倒數第二個歸位了之後倒數第一個的位置自然也就正確了,並且倒數第二個只需要一下。有些Reddit的評論給我提了建議:為了避免混淆,我們在這裡要說清楚我們忽略了”搜尋時間“,現在計算的都是翻轉所需要的時間。
- 執行時間(翻轉所需要的):O(n)。
- 記憶體大小:O(n)。
然而,我們還沒考慮每次搜尋最大的煎餅所需要的時間。在我上面的程式碼中,如果要在翻轉之前先找到最大的煎餅,我們必須搜尋整個(還未排好序的)煎餅堆。這就使得我們最差的搜尋時間成為了n乘以n。因為我們必須把每個都找一遍才能確定哪個是最大的,並且一共有n輪搜尋。因此:
- 執行時間:O(n*n)。
- 記憶體大小:O(n)。
還需要說明的一點是,我的程式使用了不多餘n的記憶體:
1 2 3 4 5 6 |
# Slide spatula under pancake at index and flip to top def flip(stack, index): newStack = stack[:(index + 1)] newStack.reverse() newStack += stack[(index + 1):] return newStack |
我的解法需要O(n+k)的記憶體,k表示第一次翻轉的大小,最大不多餘n-1。因此我們的解答需要2n-1或者O(n)的記憶體。如果我們想避免這些開銷我們可以原地翻轉,在陣列中挨個交換,但是這樣程式碼就不好懂了。
- 結論
以上介紹的演算法不是最快的。如果你還想進一步瞭解這個問題,我建議你讀一下這篇Bill Gates和Christos Papadimitriou寫的論文,以及Chitturi,Fahle,Meng以及一些其他人寫的這篇論文。如果你喜歡這篇文章的話,可以來讀一下我寫的關於計數排序的文章。
之後我還會推出一個更難的”燒焦的煎餅問題“:
每塊被翻到過最底下的煎餅被燒焦了,這個排序必須使得每個煎餅的燒焦的那一面在下。
這個解法將會在我的郵件列表中發給大家,或者你可以看這個更新。