本文對一些排序演算法進行了簡單分析,並給出了 javascript 的程式碼實現。因為本文包含了大量的排序演算法,所以分析不會非常詳細,適合有對排序演算法有一定了解的同學。
本文內容其實不是很多,就是程式碼佔了很多行。
總覽
預設需要排序的資料結構為陣列,時間複雜度為平均時間複雜度。
排序演算法 | 時間複雜度 | 空間複雜度 | 是否穩定 |
---|---|---|---|
氣泡排序 | O(n^2) | O(1) | 穩定 |
插入排序 | O(n^2) | O(1) | 穩定 |
選擇排序 | O(n^2) | O(1) | 不穩定 |
歸併排序 | O(nlogn) | O(n) | 穩定 |
快速排序 | O(nlogn) | O(1) | 不穩定 |
下面程式碼實現,排序預設都是 從小到大 排序。
所有程式碼
我的 js 程式碼實現都放在 github:github.com/F-star/js-D…
程式碼僅供參考。
氣泡排序(Bubble Sort)
假設要進行氣泡排序的資料長度為 n。
氣泡排序會進行多次的冒泡操作,每次都會相鄰資料比較,如果前一個資料比後一個資料大,就交換它們的位置(即讓大的資料放在後面)。這樣每次交換,至少有一個元素會移動到排序後應該在的位置。重複冒泡 n(或者說 n-1) 次,就完成了排序。
詳細來說,第 i
(i 從 0 開始) 趟冒泡會對陣列的前 n - i
個元素進行比較和交換操作,要對比的次數是 size - i - 1
。
氣泡排序總共要進行 n-1 次冒泡(當然你可以說是 n 次冒泡,不過最後一次冒泡只有一個元素,不用進行比較)。
優化
有時候,可能只進行了 n 次冒泡,陣列就已經是有序的了,甚至陣列本來就是有序的。這時候我們希望:當發現一次冒泡後,陣列有序,就停止下一次的冒泡,返回當前的陣列。
這時候我們可以在每一趟的冒泡前,宣告一個變數 exchangeFlag,將其設定為 true。冒泡過程中,如果發生了資料交換,就將 exchangeFlag 設定為 false。結束一趟冒泡後,我們就可以通過 exchangeFlag 知道 資料是否發生過交換。如果沒有發生交換,就說明陣列有序,直接返回該陣列即可;否則說明還沒有排好序,繼續下一趟冒泡。
程式碼實現
const bubbleSort = (a) => {
// 每次遍歷找到最大(小)的數放到最後面的位置。
// 優化:如果某次冒泡操作沒有資料交換,說明已經有序了。
// 雙重迴圈。
if (a.length <= 1) return a;
// 這裡的 i < len 改成 i < len - 1 也是正確的,因為最後第 len - 1次並不會執行。
for (let i = 0, len = a.length; i < len; i++) {
let exchangeFlag = false; // 是否發生過換
for (let j = 0; j < len - i - 1; j++) {
if (a[j] > a[j + 1]) {
[a[j], a[j + 1]] = [a[j + 1], a[j]];
exchangeFlag = true;
}
}
console.log(a)
if (exchangeFlag == false) return a;
}
}
// 測試
let array = [199, 3, 1, 2, 8, 21,4, 100, 8];
console.log (bubbleSort(array));
複製程式碼
分析
1. 氣泡排序的時間複雜度是 O(n^2)
。
最好時間複雜度是 O(n)
,即第一趟進行 n-1
次比較後,發現原陣列是有序的,結束冒泡。
最壞時間複雜度是 O(n^2)
,當原陣列剛好是倒序排列時,即需要進行 n 次冒泡,要進行 (n-1) + (n-2) ... + 1 次比較後,用等比數列求和公式求和後並化簡,即可求出最壞時間複雜度。
平均時間複雜度不好分析,它是 O(n^2)
2. 氣泡排序是 穩定 的排序演算法。
這裡的“穩定”指的是:排序後,值相等的資料的前後順序保持不變。
相鄰資料如果相等,不交換位置即可。
3. 氣泡排序是原地排序演算法
原地排序指的是空間複雜度是 O(1) 的排序演算法。
氣泡排序只做了相鄰資料交換,另外有兩個臨時變數(交換時的臨時變數、flag),只需要常量級的臨時空間,空間複雜度為 O(1)
插入排序(Insertion Sort)
插入排序。本質是從 未排序的區域 內取出資料,放到 已排序區域 內,這個取出的資料會和已排序的區間內資料一一對比,找到正確的位置插入。
我們直接將陣列分為 已排序區域 和 未排序區域。剛開始開始,已排序區域只有一個元素,即陣列的第一個元素。插入的方式有兩種:從前往後查詢插入 和 從後往前查詢插入。這裡我選擇 從後往前查詢插入。
程式碼實現
const insertionSort = a => {
for (let i = 0, len = a.length; i < len; i++) {
let curr = a[i]; // 儲存當前值,排序的時候,它對應的索引指向的值可能會在排序時被覆蓋
for (let j = i - 1; j >= 0;j--) {
if (curr < a[j]) {
a[j + 1] = a[j];
} else {
break;
}
// 找到位置(0 或 curr >= a[j]時)
a[j] = curr;
}
}
return a;
}
複製程式碼
分析
1. 插入排序的時間複雜度是:O(n^2)
當要排序的資料是有序的,我們每次插入已排序的區域,只需要比較一次,一共比較 n-1 次就結束了(注意這裡是從後往前遍歷已排序區域)。所以最好時間複雜度為 O(n)。
最壞時間複雜度是 O(n^2),是資料剛好是倒序的情況,每次都要遍歷完 已排序區域的所有資料。
2. 插入排序是穩定排序
遍歷已排序區域時,值相同的時候,放到最後的位置即可。
3. 插入排序是原地排序演算法
不需要額外空間,是在陣列上進行資料交換,所以插入排序是原地排序演算法。
選擇排序(Selection Sort)
選擇排序也有一個 已排序區域 和一個 未排序區域。它和插入排序不同的地方在於:選擇排序是從 未排序區域 中找出最小的值,放到 已排序區域的末尾。
為了減少記憶體消耗,我們也是直接在陣列上進行資料的交換。
插入排序比氣泡排序優秀的原因
插入排序和氣泡排序的時間複雜度都是 O(n^2),元素交換次數也相同,但插入排序更優秀。原因是氣泡排序的交換,需要一個 tmp 的中間變數,來進行兩個元素交換,這就變成了 3 個賦值操作。而插入排序(從後往前遍歷已排序區域),不需要中間遍歷,它是直接一些元素後移覆蓋,只要1個賦值操作。
氣泡排序中資料的交換操作:
if (a[j] > a[j+1]) { // 交換
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中資料的移動操作:
if (a[j] > value) {
a[j+1] = a[j]; // 資料移動
} else {
break;
}
複製程式碼
此外,插入排序還可以進行優化,變成 希爾排序。這裡不具體說。
程式碼實現
const selectionSort = a => {
let tmp;
for (let i = 0, len = a.length; i < len; i++) {
let min = a[i], // 儲存最小值,用於比較大小。
minIndex = i; // 儲存未排序區間中,最小值對應的索引(方便進行元素交換)
for (let j = i; j < len; j++) {
if (a[j] < min) {
minIndex = j;
min =a[j]
}
}
tmp = a[minIndex];
a[minIndex] = a[i];
a[i] = tmp;
}
return a;
}
複製程式碼
分析
1. 選擇排序的時間複雜度是 O(n^2)
最好時間複雜度是 O(n^2)。因為每次從未排序區域內找出最小值,都要遍歷未排序區域內的所有元素,一共要查詢 n-1 次,所以時間複雜度是 O(n^2)。
最壞時間複雜度也是 O(n^2),理由同上。
2. 選擇排序是原地排序演算法
我們找到為排序區域的最小元素,會交換該元素和 排序區域的下一個位置的元素(即排序區域的第一個元素),然後 i 後移。只做了元素的交換,且只用到了常數級的記憶體空間(交換兩個資料需要的一個臨時遍歷),因此選擇排序是原地排序演算法。
3. 選擇排序是不穩定的排序演算法
不穩定,是因為每次都要找最小值和前面的元素進行交換,這樣會破壞穩定性。舉個反例來證明:3 3 2, 第一次交換後,為 2 3 3,此時兩個 3 的相對順序就改變了。
當然你可以額外的建立一個大小為陣列長度的空陣列,來作為 已排序區域。這樣做就不需要交換元素,可以做到排序穩定,但這樣做耗費了額外的記憶體,變成了非原地排序演算法。
歸併排序
歸併排序用到了 分治思想。分治思想的核心是:將一個大問題分解成多個小的問題,解決後合併為原問題。分治通常用遞迴來實現。分治和遞迴的區別是,分治是一種解決問題的處理思想,遞迴是一種程式設計技巧。
歸併排序,會將陣列從中間分成左右兩部分。然後對這兩個部分各自繼續從中間分成兩部分,直到無法再分。然後將分開的兩部分進行排序合併(合併後陣列有序),不停地往上排序合併,最終合併成一個有序陣列。
說明下 merge 函式。它是將兩個有序陣列合併為一個有序陣列,做法是建立一個空陣列,長度為兩個有序陣列的大的一個。設定指標 i 和 j 分指向兩個陣列的第一個元素,取其中小的加入陣列,對應的陣列的指標後移。重複上面這個過程,直到一個陣列為空,就將另一個陣列的剩餘元素都推入新陣列。
另外,merge() 函式可以藉助 哨兵 進行優化處理。具體我沒研究,有空再考慮實現。
程式碼實現
歸併的程式碼實現用到了遞迴,所以程式碼不是很好看懂。
const mergeSort = a => {
mergeSortC(a, 0, a.length - 1)
return a;
}
const mergeSortC = (a, p, r) => {
if (p >= r) return
let q = Math.floor( (p + r) / 2 ); // 這樣取中間值,right.length >= left.length
mergeSortC(a, p, q);
mergeSortC(a, q+1, r);
merge(a, p, q, r) // p->q (q+1)->r 區域的兩個陣列合並。
}
/**
* merge方法(將兩個有序陣列合併成一個有序陣列)
*/
function merge(a, p, q, r) {
let i = p,
j = q+1,
m = new Array(r - q); // 儲存合併資料的陣列
let k = 0;
while (i <= q && j <= r) {
if (a[i] <= a[j]) {
m[k] = a[i];
i++;
} else {
m[k] = a[j]
j++;
}
k++;
}
// 首先找出兩個陣列中,有剩餘的元素的陣列。
// 然後將剩餘元素依次放入陣列 m。
let start = i,
end = q;
if (j <= r) {
start = j;
end = r;
}
while (start <= end) {
m[k] = a[start];
start++;
k++;
}
// m的資料拷貝到 a。
for(let i = p; i <= r; i++) {
a[i] = m[i-p];
}
}
複製程式碼
效能分析
歸併排序的時間複雜度是 O(nlogn)
以下為簡單推導過程,摘自 專欄-「資料結構與演算法之美」。
問題a分解為子問題 b 和 c,設求解 a、b、c 的時間為 T(a)、T(b)、Y(c),則有
T(a) = T(b) + T(c) + K
複製程式碼
而合併兩個有序子陣列的時間複雜度是 O(n),於是有
T(1) = C; n=1 時,只需要常量級的執行時間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1
複製程式碼
化簡後,得到 T(n)=Cn+nlog2n
。所以歸併排序的時間複雜度是 O(nlogn)。
歸併排序是穩定的排序
歸併交換元素的情況發生在 合併 過程,只要讓比較左右兩個子陣列時發現相等時,取左邊陣列的元素,就可以保證有序了。
歸併排序 不是 原地排序
依然歸併排序非常優秀(指時間複雜度),但,它的空間複雜度是 O(n)。因為進行合併操作時,需要申請一個臨時陣列,該陣列的長度最大不會超過 n。
快速排序
快速排序,簡稱 “快排”。快排使用的是分割槽思想。
快排會取陣列中的一個元素作為 pivot(分割槽點),將陣列分為三部分:
- 小於 pivot 的部分
- pivot
- 大於或等於 pivot 的部分。
我們取左右兩邊的子陣列,執行和上面所說的操作,直到區間縮小為0,此時整個陣列就變成有序的了。
在歸併排序中,我們用到一個 merge() 合併函式,而在快排中,我們也有一個 partition() 分割槽方法。該方法的作用是根據提供的區間範圍,隨機取一個 pivot,將該區間的陣列的資料進行交換,最終將小於 pivot 的放左邊,大於 pivot 的放右邊,然後返回此時 pivot 的下標,作為下一次 遞迴 的參考點。
partition() 分割槽函式有一種巧妙的實現方式,可以實現原地排序。處理方式有點類似 選擇排序。首先我們選一個 pivot,pivot 後的元素全都往前移動一個單位,然後pivot 放到末尾。接著我們將從左往右遍歷陣列,如果元素小於 pivot,就放入 “已處理區域”,具體操作就是類似插入操作那種,進行直接地交換;如果沒有就不做操作,繼續下一個元素,直到結束。最後將 pivot 也放 “已處理區間”。這樣就實現了原地排序了。
另外,對 partition 進行適當的改造,就可以實現 “查詢無序陣列內第k大元素” 的演算法。
程式碼實現
const quickSort = a => {
quickSortC(a, 0, a.length - 1)
return a;
}
/**
* 遞迴函式
* 引數意義同 partition 方法。
*/
function quickSortC(a, q, r) {
if (q >= r) {
// 提供的陣列長度為1時,結束迭代。
return a;
}
let p = partition(a, q, r);
quickSortC(a, q, p - 1);
quickSortC(a, p + 1, r);
}
/**
* 隨機選擇一個元素作為 pivot,進行原地分割槽,最後返回其下標
*
* @param {Array} a 要排序的陣列
* @param {number} p 起始索引
* @param {number} r 結束索引
* @return 基準的索引值,用於後續的遞迴。
*/
export function partition(a, p, r) {
// pivot 預設取最後一個,如果取得不是最後一個,就和最後一個交換位置。
let pivot = a[r],
tmp,
i = p; // 已排序區間的末尾索引。
// 類似選擇排序,把小於 pivot 的元素,放到 已處理區間
for (; p < r; p++) {
if (a[p] < pivot) {
// 將 a[i] 放到 已處理區間。
tmp = a[p];
a[p] = a[i];
a[i] = tmp; // 這裡可以簡寫為 [x, y] = [y, x]
i++;
}
}
// 將 pivot(即a[r])也放進 已處理區間
tmp = a[i];
a[i] = a[r];
a[r] = tmp;
return i;
}
複製程式碼
快速排序和歸併排序都用到了分治思想,遞推公式和遞迴程式碼很很相似。它們的區別在於:歸併排序是 由下而上 的,排序的過程發生在子陣列合並過程。而快速排序是 由上而下 的,分割槽的時候,陣列就開始趨向於有序,直到最後區間長度為1,陣列就變得有序。
效能分析
1. 快速排序的時間複雜度是 O(nlogn)
快排的時間複雜度遞推求解公式跟歸併是相同的。所以,快排的時間複雜度也是 O(nlogn)。但這個公式成立的前提是每次分割槽都能正好將區間平分(即最好時間複雜度)。
當然平均複雜度也是 O(nlongn),不過不好推導,就不分析。
極端情況下,陣列的資料已經有序,且取最後一個元素為 pivot,這樣的分割槽是及其不均等的,共需要做大約 n 次的分割槽操作,才能完成快排。每次分割槽平均要掃描約 n/2 個元素。所以,快排的最壞時間複雜度是 O(n^2)
2. 快速排序是不穩定的排序
快速排序的分割槽過程,涉及到了交換操作,該交換操作類似 選擇排序,是不穩定的排序。
3. 快速排序是原地排序
為了實現原地排序,我們前面對 parition 分割槽函式進行了巧妙的處理。
結尾
大概就是這樣,做了簡單的總結。如果文章有錯誤的地方,請給我留言。
還有一些排序打算下次再更新,可能會新開一篇文章,也可能直接修改這篇文章。