上一節講到了冒泡排序、插入排序和選擇排序,此次進階講歸併排序和快速排序。算法
歸併排序其實從字面上就能知道是什麼意思,先講數組切分兩半,而後對剩餘數組繼續切割,直到不可再分割的時候兩兩比較排序,一塊一塊的合併成一個數組,這樣就變成有序了。如同所示:編程
歸併排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。從上面對歸併排序的描述裏,它的實現方法有點像以前你文章裏說到的遞歸。通常來講,分治均可以用遞歸來實現。分治是一種解決問題的處理思想,遞歸是一種編程技巧。其實遞歸和分治二者並不衝突,分治這個思想在算法會有不少地方用到。下面就來看看如何用遞歸來實現歸併排序。數組
寫一個遞歸代碼首先是分析得出一個遞推公式,而後找到一個終止條件,最後講遞推公式翻譯成代碼。因此成歸併排序的定義咱們能夠立刻的得出一個地推公式:bash
mergeSort(start, end) = merge(mergeSort(start, center), mergeSort(center + 1, end))
結束條件是起始下標大於等於結尾下標:
start >= end複製代碼
這個地推公式的含義是這樣的,mergeSort表示的是一個排序方法,對起始下標和結尾下標之間的數據進行排序。等號右邊的merge表示的是一個合併函數,將兩個數組合並在一塊兒。這裏是將一個數組分紅兩份,若是這兩份已是有序了那麼就能夠很容易的合成一個有序數組。下面咱們就將這個公式轉換成代碼來看看:數據結構
/**
* @param {Array} arr 要排序的數組
* @param {number} first 當前數組的第一個下標
* @param {number} last 當前數組的最後一個下標
* @description 歸併排序的遞歸方法
* */
function mergeSort(arr, first, last) {
if (first >= last) return;
let center = Math.floor((first + last) / 2);
mergeSort(arr, first, center);
mergeSort(arr, center + 1, last);
merge(arr, first, center, last)
}複製代碼
上面所說,merge函數只是一個合併函數,將兩組數組合併成一個有序數組,那麼這個合併操做是如何實現的呢?函數
首先咱們要弄一個臨時數組temp,長度是end到start。新建兩個下標i和j分別指向arr[start...center]和arr[center+1...end]的第一個元素,而後利用雙指針的方法來進行遍歷比較,若是第一個數組下標i所對應的元素比第二個數組下標j所對應的元素小,則i所對應的元素壓入temp數組中,反之則j所對應的元素壓入temp數組中。最後直到某個數組已經遍歷完成的時候,另外一個數組剩餘的元素將所有壓入到temp數組中。代碼以下:post
/**
* @param {Array} arr 要合併的數組
* @param {number} first 當前數組第一個下標
* @param {number} center 當前數組的中間下標
* @param {number} last 當前數組的最後一個下標
* @description 歸併排序合併方法
* */
function merge(arr, first, center, last) {
let i = first, j = center+ 1, k = 0;
let temp = new Array(last-first);
while (i <= center && j <= last) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++]
}
else {
temp[k++] = arr[j++]
}
}
let start = i, end = center;
if (j <= last) {
start = j;
end = last
}
while (start <= end) {
temp[k++] = arr[start++]
}
for (let i = 0; i <= last - first; i++) {
arr[first+i] = temp[i]
}
}複製代碼
如今咱們按照上一篇文章裏分析排序算法性能所用到的穩定性分析和複雜度分析,來分析一下歸併排序。性能
首先是穩定性分析。在合併的過程當中,若是兩個數組之間是有相同的元素的,在上面的merge代碼實例裏,咱們在第五行的判斷語句裏,將第一個數組裏相同的元素都優先壓入temp中,這樣保證了數據的順序,因此這個是穩定性算法。優化
那麼歸併排序的時間複雜度是多少呢?因爲這個歸併排序是由遞歸來實現的,因此咱們來分析一下這個遞歸的時間複雜度是多少。首先遞歸它的使用場景是將問題不斷的分解,直到問題不能分解的時候解決子問題,而後合併結果。假設一個問題a分解成問題b和問題c,那麼地推關係式以下:ui
T(a) = T(b) + T(c) + K複製代碼
這裏的K表示的是子問題合併成總問題所須要的時間。從這裏咱們能夠知道,不只遞歸求解的問題能夠寫成遞推公式,遞歸代碼的時間複雜度也能夠寫成地推公式。利用公式,咱們能夠分析一下歸併排序的時間複雜度。咱們假設對 n 個元素進行歸併排序須要的時間是 T(n),那分解成兩個子數組排序的時間都是 T(n/2)。咱們知道,merge() 函數合併兩個有序子數組的時間複雜度是 O(n)。因此,套用前面的公式,歸併排序的時間複雜度的計算公式就是:
T(1) = C; n=1 時,只須要常量級的執行時間,因此表示爲 C。
T(n) = 2*T(n/2) + n; n>1複製代碼
而後上面的公式能夠進一步推導得:
T(n) = 2*T(n/2) + n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
......
= 2^k * T(n/2^k) + k * n
......複製代碼
因此獲得等式,這裏咱們令n=1,可得,也就是。當咱們把k的值從新帶入等式中:
T(n) = n*T(1) + nlogn
= nc + nlogn複製代碼
這裏用大O標記法來表示的話,T(n) = O(nlogn),因此歸併排序的時間複雜度是O(nlogn)。
因爲歸併排序每次執行合併操做的時候都須要申請一個額外的空間,因此歸併排序不是原地排序算法。在上面的代碼裏歸併排序執行了n次合併,因此申請了n個額外的存儲空間,所以空間複雜度是O(n)。而在上一篇文章裏講到原地排序算法是指空間複雜度爲O(1)的算法,因爲要犧牲額外的空間,因此對比快速排序,歸併排序比它差了點。
快排的思想是這樣的:若是要排序數組中下標從 p 到 r 之間的一組數據,咱們選擇 p 到 r 之間的任意一個數據做爲 pivot(分區點)。咱們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。通過這一步驟以後,數組 p 到 r 之間的數據就被分紅了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的。
舉個例子,好比咱們有一組數據:20,40,50,10,60。按照上面的說法首先p指針指的是20這個元素,r指針指的是60這個元素,pivot指針咱們假設指向20。
首先r指針向後遍歷尋找比pivot指針小的元素,這時r指針找到了10,因此將r指針的值與p指針的值互換得:10,40,50,10,60。
此時r指針暫停遍歷,輪到p指針開始向後遍歷,尋找比pivot指針還大的元素,這時p指針找到了40,因此將p指針的值與r指針的值互換得:10,40,50,40,60。
而後下一步p指針暫停遍歷,r指針開始遍歷,重複上面操做,直到p指針和r指針重合的時候重合指向的元素的值變爲pivot指針的值,得:10,20,50,40,60。這個時候pivot指針前面的元素都比它小,後面的元素都比它大,而後程序繼續重複執行上面的操做,就能夠獲得一個有序的數組。
經過這個操做咱們能夠獲得一個遞推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
終止條件:
p >= r複製代碼
而後根據這個公式能夠得出一個代碼:
/**
* @param {Array} arr 要排序的數組
* @param {number} start 當前數組的第一個下標
* @param {number} end 當前數組的最後一個下標
* @description 快速排序的遞歸方法
* */
function quickSort(arr, start, end) {
if (start >= end) return false
let pivot = partition(arr, start, end)
quickSort(arr, start, pivot - 1)
quickSort(arr, pivot + 1, end)
}複製代碼
這裏的partition函數指的是一個分區函數,返回一個分區指針。若是咱們不考慮空間消耗的話,partition() 分區函數能夠寫得很是簡單。咱們申請兩個臨時數組 X 和 Y,遍歷 A[p…r],將小於 pivot 的元素都拷貝到臨時數組 X,將大於 pivot 的元素都拷貝到臨時數組 Y,最後再將數組 X 和數組 Y 中數據順序拷貝到 A[p…r]。可是,若是按照這種思路實現的話,partition() 函數就須要不少額外的內存空間,因此快排就不是原地排序算法了。若是咱們但願快排是原地排序算法,那它的空間複雜度得是 O(1),那 partition() 分區函數就不能佔用太多額外的內存空間,咱們就須要在 A[p…r] 的原地完成分區操做。
那如何實現這個partition函數呢,代碼以下:
/**
* @param {Array} arr 要合併的數組
* @param {number} start 當前數組第一個下標
* @param {number} end 當前數組的最後一個下標
* @description 快速排序合併方法
* */
function partition(arr, start, end) {
let pivot = arr[end]
let i = start
for (let j = start; j <= end - 1; j++) {
if (arr[j] < pivot) {
let temp = arr[i]
arr[i] = arr[j]
arr[j] = temp
i = i + 1
}
}
let temp = arr[i]
arr[i] = arr[end]
arr[end] = temp
return i
}
// 或者下面這種分類方法
function partition(arr, left, right) {
let i = left, j = right, temp = arr[left]
while (i !== j) {
while (i < j && arr[j] >= temp) {
j--
}
if (j > i) {
arr[i] = arr[j]
}
while (i < j && arr[i] <= temp) {
i++
}
if (i < j) {
arr[j] = arr[i]
}
}
arr[i] = temp;
return i
}複製代碼
由於分區的過程涉及交換操做,若是數組中有兩個相同的元素,好比序列 6,8,7,6,3,5,9,4,在通過第一次分區操做以後,兩個 6 的相對前後順序就會改變。因此,快速排序並非一個穩定的排序算法。
到此,咱們發現:快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢?
歸併排序的處理過程是由下到上的,先處理子問題,而後再合併。而快排正好相反,它的處理過程是由上到下的,先分區,而後再處理子問題。歸併排序雖然是穩定的、時間複雜度爲 O(nlogn) 的排序算法,可是它是非原地排序算法。咱們前面講過,歸併之因此是非原地排序算法,主要緣由是合併函數沒法在原地執行。快速排序經過設計巧妙的原地分區函數,能夠實現原地排序,解決了歸併排序佔用太多內存的問題。
因爲快速排序須要分區,在分區的過程當中可能會致使等值相鄰元素的前後順序發生改變,因此快排並非穩定性排序算法。
快排也是用遞歸來實現的。對於遞歸代碼的時間複雜度,我前面總結的公式,這裏也仍是適用的。若是每次分區操做,都能正好把數組分紅大小接近相等的兩個小區間,那快排的時間複雜度遞推求解公式跟歸併是相同的。因此,快排的時間複雜度也是 O(nlogn)。公式以下:
T(1) = C; n=1 時,只須要常量級的執行時間,因此表示爲 C。
T(n) = 2*T(n/2) + n; n>1
複製代碼
可是,公式成立的前提是每次分區操做,咱們選擇的 pivot 都很合適,正好能將大區間對等地一分爲二。但實際上這種狀況是很難實現的。好比一組數據原來已是有序的,若是咱們每次選擇最後一個元素做爲 pivot,那每次分區獲得的兩個區間都是不均等的。咱們須要進行大約 n 次分區操做,才能完成快排的整個過程。每次分區咱們平均要掃描大約 n/2 個元素,這種狀況下,快排的時間複雜度就從 O(nlogn) 退化成了 。可是這種狀況是一個比較極端的狀況,咱們能夠對pivot指針進行優化來減小這種狀況發生的機率,運用到前面文章講到的均攤思想,其實時間複雜度就是 O(nlogn) 。
常見的基準選擇有:選擇第一個或最後一個、隨機選擇、三數取中等方法,性能上的優化也能夠採用尾遞歸的方法來優化。
因爲快排並不像歸併排序同樣須要額外的數組來存儲,因此空間複雜度是O(1),是一個原地排序算法。
本次實戰的題目是如何在 O(n) 的時間複雜度內查找一個無序數組中的第 K 大元素?即在數組3,4,6,1,2,8,5,7,9中找出第三大的元素且時間複雜度爲O(n)。
這道題能夠利用快排來作,若是是單純的排好再取的話,時間複雜度是O(n*logn),顯然與咱們規定的O(n)的時間複雜度有出入,那如何壓縮時間使得時間複雜度爲O(n)呢?
在快排裏每一步都是要分區的,咱們能夠利用這個思想,若是數組被分紅a區和b區,a區都比b區小,那麼若是k比b的長度小,則第k大的數就在b區種,咱們則拋棄a區對b區利用快排進行分區。若是k比b的長度大,咱們則反過來拋棄b區對a區利用快排進行分區。咱們這裏將上面的快排改寫一下,主要改動的比較可能是指針函數:
function quickSort(arr, start, end, maxK) {
var pivot = partition(arr, start, end);
if (pivot === maxK - 1) {
return arr[maxK - 1];
} else if (pivot > maxK - 1) {
return quickSort(arr, start, pivot - 1, maxK);
} else if (pivot < maxK - 1) {
return quickSort(arr, pivot + 1, end, maxK);
}
return 0;
}
function partition(arr, start, end) {
var temp = arr[start];
while (end > start) {
while (arr[end] < temp && end > start) {
end--;
}
if (end > start) {
arr[start] = arr[end];
start++;
}
while (arr[start] > temp && end > start) {
start++;
}
if (end > start) {
arr[end] = arr[start];
end--;
}
}
arr[start] = temp;
return start;
}複製代碼
這裏面主要是將原來分區給反過來,按照以前所講,分區後指針的右邊都比指針大,左邊都比指針小。可是這裏爲了獲得第k大的數,則反過來,左邊都比指針大,右邊都比指針小。若是按照原來的思路的話,咱們也能夠很輕鬆的獲得一個尋找第k小的方法:
function quickSort (arr, start, end, minK) {
var pivot = partition(arr, start, end)
if (pivot === minK - 1) {
return arr[minK - 1]
}
else if (pivot > minK - 1) {
return quickSort(arr, start, pivot - 1, minK)
}
else {
return quickSort(arr, pivot + 1, end, minK)
}
}
function partition (arr, start, end) {
var temp = arr[start]
var pt = arr[start]
while (start != end) {
while (start < end && arr[end] >= pt) {
end--
}
arr[start] = arr[end]
while (start < end && arr[start] <= pt) {
start++
}
arr[end] = arr[start]
}
arr[start] = temp
return start
}複製代碼
上一篇文章:數據結構與算法的重溫之旅(九)——三個簡單的排序算法
下一篇文章:數據結構與算法的重溫之旅(十一)——桶排序、基數排序和計數排序