在以前的文章當中,咱們經過海盜分金幣問題詳細講解了遞歸方法。python
咱們能夠認爲在遞歸的過程中,咱們經過函數本身調用本身,將大問題轉化成了小問題,所以簡化了編碼以及建模。今天這篇文章呢,就正式和你們聊一聊將大問題簡化成小問題的分治算法的經典使用場景——排序。面試
排序算法有不少,不少博文都有總結,號稱有十大經典的排序算法。咱們信手拈來就能夠說上來不少,好比插入排序、選擇排序、桶排序、希爾排序、快速排序、歸併排序等等。老實講這麼多排序算法,但咱們實際工做中並不會用到那麼多,凡是高級語言都有自帶的排序工具,咱們直接調用就好。爲了應付面試以及提高本身算法能力呢,用到的也就那麼幾種。今天咱們來介紹一下利用分治思想實現的兩種經典排序算法——歸併排序與快速排序。算法
咱們先來說歸併排序,歸併排序的思路其實很簡單,說白了只有一句話:兩個有序數組歸併的複雜度是\(O(n)\)。數組
咱們舉個例子:數據結構
a = [1, 4, 6] b = [2, 4, 5] c = []
咱們用i和j分別表示a和b兩個數組的下標,c表示歸併以後的數組,顯然一開始的時候i, j = 0, 0。咱們不停地比較a和b數組i和j位置大小關係,將小的那個數填入c。app
填入一個數以後:less
i = 1 j = 0 a = [1, 4, 6] b = [2, 4, 5] c = [1]
填入兩個數以後:函數
i = 1 j = 1 a = [1, 4, 6] b = [2, 4, 5] c = [1, 2]
咱們重複以上步驟,直到a和b數組當中全部的數都填入c數組爲止,咱們能夠很方便地寫出以上操做的代碼:工具
def merge(a, b): i, j = 0, 0 c = [] while i < len(a) or j < len(b): # 判斷a數組是否已經所有放入 if i == len(a): c.append(b[j]) j += 1 continue elif j == len(b): c.append(a[i]) i += 1 continue # 判斷大小 if a[i] <= b[j]: c.append(a[i]) i += 1 else: c.append(b[j]) j += 1 return c
從上面的代碼咱們也能看出來,這個過程雖然簡單,可是寫成代碼很是麻煩,由於咱們須要判斷數組是否已經所有填入的狀況。這裏有一個簡化代碼的優化,就是在a和b兩個數組當中插入一個」標兵「,這個標兵設置成正無窮大的數,這樣當a數組當中其餘元素都彈出以後。因爲標兵大於b數組當中除了標兵以外其餘全部的數,就能夠保證a數組永遠不會越界,如此就能夠簡化不少代碼了(前提,a和b數組當中不存在和標兵同樣大的數)。優化
咱們來看代碼:
def merge(a, b): i, j = 0, 0 # 插入標兵 a.append(MAXINT) b.append(MAXINT) c = [] # 因爲插入了標兵,因此長度判斷的時候須要-1 while i < len(a)-1 or j < len(b)-1: if a[i] <= b[j]: c.append(a[i]) i += 1 else: c.append(b[j]) j += 1 return c
這裏應該都沒有問題,接下來的問題是咱們怎麼利用歸併數組的操做來排序呢?
其實很簡單,這也是歸併排序的精髓。
咱們每次將一個數組一分爲二,顯然,這個劃分出來的數組不必定是有序的。但若是咱們繼續切分呢?直到數組當中只有一個元素的時候,是否是就自然有序了呢?
咱們舉個例子:
[4, 1, 3, 2] / \ [4, 1] [3, 2] / \ / \ [4] [1] [3] [2] \ / \ / [1, 4] [2, 3] \ / [1, 2, 3, 4]
經過上面的這個過程咱們能夠發現,在歸併排序的時候,咱們先一直往下遞歸切分數組,直到全部的切片當中只有一個元素自然有序。接着一層一層地歸併回來,當全部元素歸併結束的時候,數組就完成了排序。這也就是歸併排序的所有過程。
若是還不理解,還能夠參考一下下面的動圖。
咱們來試着用代碼來實現。以前我曾經在面試的時候被要求在白板上寫過歸併排序,當時我用的C++以爲編碼還有必定的難度。如今,當我用習慣了Python以後,我感受編碼難度下降了不少。由於Python支持許多數組相關的高級操做,好比切片,變長等等。整個歸併排序的代碼不超過20行,咱們一塊兒來看下代碼:
def merge_sort(arr): n = len(arr) # 當長度小於等於1,說明自然有序 if n <= 1: return arr mid = n // 2 # 經過切片將數組一分爲二,遞歸排序左邊以及右邊部分 L, R = merge_sort(arr[: mid]), merge_sort(arr[mid: ]) n_l, n_r = len(L), len(R) # 數組當中插入標兵 L.append(sys.maxsize) R.append(sys.maxsize) new_arr = [] i, j = 0, 0 # 歸併已經排好序的L和R while i < n_l or j < n_r: if L[i] <= R[j]: new_arr.append(L[i]) i += 1 else: new_arr.append(R[j]) j += 1 return new_arr
你看,不管是思想仍是代碼實現,歸併排序並不難,就算一開始不熟悉,寫個兩遍也必定沒問題了。
理解了歸併排序以後,再來學快速排序就不難了,咱們一塊兒來看快速排序的算法原理。
快速排序一樣利用了分治的思想,咱們每次作一個小的操做,讓數組的一部分變得有序,以後咱們經過遞歸,將這些有序的部分組合在一塊兒,達到總體有序。
在歸併排序當中,咱們劃分問題的方法是橫向切分,咱們直接將數組一分爲二,針對這兩個部分分別排序。快排稍稍不一樣,它並非針對數組的橫向切分,而是從問題自己出發的」縱向「切分。在快速排序當中,咱們解決的子問題不是對數組的一部分排序,而是提高數組的有序程度。怎麼提高呢?咱們在數組當中尋找一個數,做爲標杆,咱們利用這個標杆調整數組當中元素的順序。將小於它的放到它的左側,大於它的放到它的右側。這麼一個操做結束以後,能夠確定的是,這個標杆所在的位置就是排序完成以後,它應該在的位置。
咱們來看個例子:
a = [8, 4, 3, 9, 10, 2, 7]
咱們選擇7做爲標杆,一輪操做以後能夠獲得:
a = [2, 4, 3, 7, 9, 10, 8]
接着咱們怎麼作呢?很簡單,咱們只須要針對標杆前面以及標杆後面的部分重複上述操做便可。若是還不明白的同窗能夠看一下下面這張動圖:
若是用C++寫過快排的同窗確定對於快排的代碼印象深入,它是屬於典型的原理不難,可是寫起來很麻煩的算法。由於快速排序須要用到兩個下標,寫的時候一不當心很容易寫出bug。一樣,因爲Python當中動態數組的支持很是好,咱們能夠避免使用下標來實現快排,這樣代碼的可讀性以及編碼難度都要下降不少。
多說無益,咱們來看代碼:
def quick_sort(arr): n = len(arr) # 長度小於等於1說明自然有序 if n <= 1: return arr # pop出最後一個元素做爲標杆 mark = arr.pop() # 用less和greater分別存儲比mark小或者大的數 less, greater = [], [] for x in arr: if x <= mark: less.append(x) else: greater.append(x) arr.append(mark) return quick_sort(less) + [mark] + quick_sort(greater)
整個代碼出去註釋,不到15行,我想你們應該都很是容易理解。
最後,咱們來分析一下這兩個算法的複雜度,爲何說這兩個算法都是\(nlogn\)的算法呢?(不考慮快速排序最差狀況)這個證實很是簡單,咱們放一張圖你們一看就明白了:
咱們在遞歸的過程中,咱們只遍歷了一遍數組,雖然咱們每一層都會講數組拆分。可是在遞歸樹上同一層的遞歸函數遍歷的總數加起來應該是等於數組的總長也就是n的。
並且遞歸的層數是有限制的,由於咱們每次都將數組一分爲二。而一個數組的最小長度是1,也就是說極端狀況下咱們一共能有\(\log_2^n\)層,每一層的複雜度總和是n,因此總體的複雜度是\(nlogn\)。
固然對於快速排序算法來講,若是數組是倒序的,咱們默認取最後一個元素做爲標杆的話,咱們是沒法切分數組的,由於除它以外全部的元素都比它大。在這種狀況下算法的複雜度會退化到\(n^2\)。因此咱們說快速排序算法最差複雜度是\(O(n^2)\)。
到這裏,關於歸併排序與快速排序的算法就講完了。這兩個算法並不難,我想學過算法和數據結構的同窗應該都有印象,可是在實際面試當中,真正能把代碼寫出來而且沒有明顯bug的實在是很少。我想,不論以前是否已經學會了,回顧一下都是頗有必要的吧。
今天的文章就到這裏,但願你們有所收穫。若是喜歡本文,請順手點個關注吧。