學習筆記
學習書目:《算法圖解》- Aditya Bhargava
分而治之
在這裏,我想通過2個例子介紹一種著名的遞歸式問題解決方法–分而治之(D&C)
- 分蛋糕
假如,我要分一塊1680mm*640mm的長方形巨形蛋糕,我要將這塊蛋糕均勻的分成同等大小的正方形,且分出的正方形要儘量大。
現在,我們將使用D&C的思路解決問題,D&C過程包括兩個步驟。
(1) 找出基線條件(函數不再調用自己的條件),這種條件必須儘可能簡單.
(2) 不斷將問題分解(或者說縮小規模),直到符合基線條件.
首先,我們要找出基線條件。一般來說,我們將基線條件定爲較爲簡單的情況,那麼現在最容易處理的情況是,長方形一條邊的長度是另一條邊的整數倍。比如下面的1000mm*500mm
的長方形,長就是高的兩倍:
現在需要找出遞歸條件,這正是D&C的用武之地。根據D&C的定義,每次遞歸調用都必須縮小問題的規模。如何縮小前述問題的規模呢?
首先,我們要找出這塊大蛋糕可容納的最大方塊。
我們可以先從這塊蛋糕中畫出兩個640mm*640mm
的正方形蛋糕,餘下了一塊小長方形蛋糕。再對餘下的那一小塊640mm*400mm
的蛋糕採取相同的策略。
可以看到,最初我們是要劃分1680mm*640mm
的大蛋糕,但現在我們要劃分640mm*400mm
的小蛋糕。可以證明,適用於這小塊地的最大方塊,也是適用於整塊地的最大方塊。換而言之,我們將均勻劃分1680mm*640mm
大蛋糕的問題,轉換爲了劃分640mm*400mm
小蛋糕的問題。
之後,我們再採取相同的算法,將640mm*400mm
的蛋糕劃分爲400mm*400mm
的正方形和400mm*240mm
的長方形…以此類推,最後我們將得到160mm*80mm
的長方形蛋糕。餘下的這個蛋糕滿足基線條件,因爲160是80的整數倍。將這個蛋糕分爲兩個正方形後,將不會留下任何蛋糕。因此,對於原來的1680mm*640mm
大蛋糕,適用的最大正方形爲80mm*80mm
- 計算總和
給定一個數字數組:
arr = [1, 3, 5]
現在,我需要將這些數字相加,並返回結果。
我們可以使用循環完成這個任務:
arr = [2, 4, 6]
def my_sum(arr):
total = 0
for i in arr:
total += i
return total
print(my_sum(arr))
輸出:
12
可是,如果我想用遞歸來完成這個任務,該怎麼辦呢?
按照D&C的思路,我們首先得找到基線條件。
我們思考一下,加總中最簡單的情況:如果數組不包含任何元素,或者只有一個元素,那麼加總的結果就是0或者那一個元素本身。
[] #不包含任何元素,總和爲0
[5] #只有1個元素,總和爲5
因此這就是基線條件。
那麼我們該怎麼縮小規模呢?我們可以採取下面這種方式:
my_sum([2, 4, 6])
#等價於
2 + my_sum([4, 6])
即,如果列表不爲空,就計算列表中除第一個元素以外的其他元素的總和,再將其與第一個元素相加,返回最終結果。
按照D&C思路,那麼我們的函數運行過程應該像下面這樣:
快速排序
快速排序是一種常用的排序算法,比選擇排序要快的多。快速排序也應用了D&C思想。
對於排序來說,最簡單的數組,就是不需要排序的數組,也就是爲空或者只包含一個元素的數組:
[] #不包含任何元素,總和爲0
[5] #只有1個元素,總和爲5
因此,快速排序的基線條件爲數組爲空或只包含一個元素:
def quicksort(arr):
if len(arr) < 2:
return arr
現在,我們來看看長度大於1的數組。比如包含2個元素的數組:
[1, 5]
對於包含2個元素的數組進行排序也很容易,我們可以檢查第1個元素是否比第2個元素小,如果不比第2個元素小,就交換它們的位置:
def quicksort(arr):
if len(arr) < 2:
return arr
elif len(arr) == 2:
if arr[0] > arr[1]:
arr.reverse()
return arr
包含3個元素的數組呢:
[33, 15, 10]
首先,我們從數組中選擇一個元素,這個元素被稱爲基準值(pivot):
#我選擇33作爲基準值
[33]
接下來,找出比基準值小的元素以及比基準值大的元素。 這被稱爲分區:
#小於基準值的元素
[15, 10]
#基準值
[33]
#大於基準值的元素
[]
這裏只是進行了分區,得到的兩個子數組依然是無序的。
但是如果兩邊的子數組正好是有序的,那麼我們就可以像下面這樣進行合併得到有序數組:
In [62]: [10, 15] + [33] + []
Out[62]: [10, 15, 33]
如果兩邊的子數組無序,該如何對兩邊的子數組進行排序呢?
可以看到兩邊的子數組,一個是包含2個元素的數組,一個是空數組,因此,只要按照我們剛纔介紹的策略,分別對其進行排序,最後,再合併結果即可:
In [61]: quicksort([15, 10]) + [33] + []
Out[61]: [10, 15, 33]
值得一提的是,不管我們將哪個元素作爲基準值,這種方法都管用。
現在,我們複習一遍包含3個元素的數組應該怎樣排序:
(1) 選擇基準值;
(2) 將數組分成兩個子數組:小於基準值的元素和大於基準值的元素;
(3) 對這兩個子數組進行快速排序。
學完了包含3個元素的數組排序,我想對包含4個元素的數組進行快速排序,可咋整呢?
包含4個元素的數組:
[33, 10, 15, 7]
假設,我們還是將33作爲基準值,則左邊有包含3個元素的數組,右邊爲空數組。而我們已經知道了怎樣對3個元素的數組進行快速排序,就可以對左邊的數組遞歸地調用快速排序:
#第一步,設置基準值爲33
In [59]: [10, 15, 7] + [33] + []
Out[59]: [10, 15, 7, 33]
#第二步,設置基準值爲10
In [60]: [7] + [10] + [15] + [33] + []
Out[60]: [7, 10, 15, 33]
如果我們能對包含4個元素的數組進行快速排序,那麼我們就可以對包含5元素的數組進行排序,以此類推,我們就可以對包含k個元素的數組進行排序了,可以說我們可以對任何長度的數組進行排序。這就像我們爬樓梯,我們可以從1樓爬到2樓,就可以從2樓爬到3樓,就可以從3樓爬到4樓,最終我可以爬到頂樓。
下面是快速排序的全部代碼:
def quicksort2(arr):
if len(arr) < 2:
return arr
else:
pivot = arr[0]
less = [i for i in arr[1:] if i <= pivot]
greater = [i for i in arr[1:] if i > pivot]
return quicksort2(less) + [pivot] + greater
print(quicksort2([33, 10, 5, 2, 3]))
輸出:
[2, 3, 5, 10, 33]
大O表示法
我們之前學習過選擇排序,其運行速度爲。除了選擇排序以外,還有一種名爲合併排序的排序算法,其運行時間爲,比選擇排序快得多!而快速排序的情況則比較棘手,快速排序的速度取決於選擇的基準值,在最糟糕的情況下其運行時間爲,與選擇排序一樣慢,但是在平均情況下,快速排序的運行時間爲
這時我們可能會問,看起來合併排序更快呀!爲啥我們不用合併排序呢?
比較合併排序和快速排序
假設我們有下面這兩個函數:
import time
def print_items1(my_list):
for item in my_list:
print(item)
def print_items2(my_list):
for item in my_list:
time.sleep(1)
print(item)
print_items1([2, 4, 6, 8, 10])
print_items2([2, 4, 6, 8, 10])
可以看到,雖然這兩個函數的運行時間都是,但print_items2函數每打印一個元素就停頓一秒,所以看起來print_items1要更快一些。因此,雖然使用大O表示法表示時,這兩個函數的速度相同,但實際上print_items1的速度更快。
在大O表示法中,實際上指的是, 是算法所需的固定時間量,被稱爲常量。例如,print_items1所需的時間可能是10毫秒 * n,而print_items2所需的時間爲1秒 * n。
在大O表示法中,通常不考慮這個常量,因爲如果算法的大O運行時間不同,這個常量就無關緊要(詳情參考簡單查找和二分查找)。但是有時候,常量的影響可能很大,對快速排序和合並排序來說就是如此。快速排序的常量比合並排序小,因此如果它們的運行時間都爲O(n log n),快速排序的速度將更快。實際上,快速排序的確更快,因爲相比於最糟糕的情況,它遇到平均情況的可能性要大得多。
備註 :最佳情況就是平均情況,在快速排序中只要你每次都隨機地選擇一個元素作爲基準值,快速排序的平均運行時間就將爲。快速排序是最快的排序算法之一,也是D&C典範。