本文對一些排序算法進行了簡單分析,並給出了 javascript 的代碼實現。由於本文包含了大量的排序算法,因此分析不會很是詳細,適合有對排序算法有必定了解的同窗。本文內容其實不是不少,就是代碼佔了不少行。javascript
默認須要排序的數據結構爲數組,時間複雜度爲平均時間複雜度。java
排序算法 | 時間複雜度 | 空間複雜度 | 是否穩定 |
---|---|---|---|
冒泡排序 | O(n^2) | O(1) | 穩定 |
插入排序 | O(n^2) | O(1) | 穩定 |
選擇排序 | O(n^2) | O(1) | 不穩定 |
歸併排序 | O(nlogn) | O(n) | 穩定 |
快速排序 | O(nlogn) | O(1) | 不穩定 |
下面代碼實現,排序默認都是 從小到大 排序。git
個人 js 代碼實現都放在 github:https://github.com/F-star/js-...github
代碼僅供參考。算法
假設要進行冒泡排序的數據長度爲 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));
O(n^2)
。最好時間複雜度是 O(n)
,即第一趟進行 n-1
次比較後,發現原數組是有序的,結束冒泡。
最壞時間複雜度是 O(n^2)
,當原數組恰好是倒序排列時,即須要進行 n 次冒泡,要進行 (n-1) + (n-2) ... + 1 次比較後,用等比數列求和公式求和後並化簡,便可求出最壞時間複雜度。
平均時間複雜度很差分析,它是 O(n^2)
這裏的「穩定」指的是:排序後,值相等的數據的先後順序保持不變。
相鄰數據若是相等,不交換位置便可。
原地排序指的是空間複雜度是 O(1) 的排序算法。
冒泡排序只作了相鄰數據交換,另外有兩個臨時變量(交換時的臨時變量、flag),只須要常量級的臨時空間,空間複雜度爲 O(1)
插入排序。本質是從 未排序的區域 內取出數據,放到 已排序區域 內,這個取出的數據會和已排序的區間內數據一一對比,找到正確的位置插入。
咱們直接將數組分爲 已排序區域 和 未排序區域。剛開始開始,已排序區域只有一個元素,即數組的第一個元素。插入的方式有兩種:從前日後查找插入 和 從後往前查找插入。這裏我選擇 從後往前查找插入。
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; }
O(n^2)
當要排序的數據是有序的,咱們每次插入已排序的區域,只須要比較一次,一共比較 n-1 次就結束了(注意這裏是從後往前遍歷已排序區域)。因此最好時間複雜度爲 O(n)。
最壞時間複雜度是 O(n^2),是數據恰好是倒序的狀況,每次都要遍歷完 已排序區域的全部數據。
遍歷已排序區域時,值相同的時候,放到最後的位置便可。
不須要額外空間,是在數組上進行數據交換,因此插入排序是原地排序算法。
選擇排序也有一個 已排序區域 和一個 未排序區域。它和插入排序不一樣的地方在於:選擇排序是從 未排序區域 中找出最小的值,放到 已排序區域的末尾。
爲了減小內存消耗,咱們也是直接在數組上進行數據的交換。
插入排序和冒泡排序的時間複雜度都是 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; }
最好時間複雜度是 O(n^2)。由於每次從未排序區域內找出最小值,都要遍歷未排序區域內的全部元素,一共要查找 n-1 次,因此時間複雜度是 O(n^2)。
最壞時間複雜度也是 O(n^2),理由同上。
咱們找到爲排序區域的最小元素,會交換該元素和 排序區域的下一個位置的元素(即排序區域的第一個元素),而後 i 後移。只作了元素的交換,且只用到了常數級的內存空間(交換兩個數據須要的一個臨時遍歷),所以選擇排序是原地排序算法。
不穩定,是由於每次都要找最小值和前面的元素進行交換,這樣會破壞穩定性。舉個反例來證實: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]; } }
如下爲簡單推導過程,摘自 專欄-「數據結構與算法之美」。
問題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(分區點),將數組分爲三部分:
咱們取左右兩邊的子數組,執行和上面所說的操做,直到區間縮小爲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,數組就變得有序。
快排的時間複雜度遞推求解公式跟歸併是相同的。因此,快排的時間複雜度也是 O(nlogn)。但這個公式成立的前提是每次分區都能正好將區間平分(即最好時間複雜度)。
固然平均複雜度也是 O(nlongn),不過很差推導,就不分析。
極端狀況下,數組的數據已經有序,且取最後一個元素爲 pivot,這樣的分區是及其不均等的,共須要作大約 n 次的分區操做,才能完成快排。每次分區平均要掃描約 n/2 個元素。因此,快排的最壞時間複雜度是 O(n^2)
快速排序的分區過程,涉及到了交換操做,該交換操做相似 選擇排序,是不穩定的排序。
爲了實現原地排序,咱們前面對 parition 分區函數進行了巧妙的處理。
大概就是這樣,作了簡單的總結。若是文章有錯誤的地方,請給我留言。
還有一些排序打算下次再更新,可能會新開一篇文章,也可能直接修改這篇文章。