算法爲王。
想學好前端,先練好內功,只有內功深厚者,前端之路纔會走得更遠。javascript
筆者寫的 JavaScript 數據結構與算法之美 系列用的語言是 JavaScript ,旨在入門數據結構與算法和方便之後複習。html
之因此把歸併排序、快速排序、希爾排序、堆排序
放在一塊兒比較,是由於它們的平均時間複雜度都爲 O(nlogn)。前端
請你們帶着問題:快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢 ?
來閱讀下文。java
思想git
排序一個數組,咱們先把數組從中間分紅先後兩部分,而後對先後兩部分分別排序,再將排好序的兩部分合並在一塊兒,這樣整個數組就都有序了。github
歸併排序採用的是分治思想
。算法
分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。shell
注:x >> 1 是位運算中的右移運算,表示右移一位,等同於 x 除以 2 再取整,即 x >> 1 === Math.floor(x / 2) 。
實現segmentfault
const mergeSort = arr => { //採用自上而下的遞歸方法 const len = arr.length; if (len < 2) { return arr; } // length >> 1 和 Math.floor(len / 2) 等價 let middle = Math.floor(len / 2), left = arr.slice(0, middle), right = arr.slice(middle); // 拆分爲兩個子數組 return merge(mergeSort(left), mergeSort(right)); }; const merge = (left, right) => { const result = []; while (left.length && right.length) { // 注意: 判斷的條件是小於或等於,若是隻是小於,那麼排序將不穩定. if (left[0] <= right[0]) { result.push(left.shift()); } else { result.push(right.shift()); } } while (left.length) result.push(left.shift()); while (right.length) result.push(right.shift()); return result; };
測試api
// 測試 const arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]; console.time('歸併排序耗時'); console.log('arr :', mergeSort(arr)); console.timeEnd('歸併排序耗時'); // arr : [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50] // 歸併排序耗時: 0.739990234375ms
分析
這是由於歸併排序的合併函數,在合併兩個有序數組爲一個有序數組時,須要藉助額外的存儲空間。
實際上,儘管每次合併操做都須要申請額外的內存空間,但在合併完成以後,臨時開闢的內存空間就被釋放掉了。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的內存空間在使用。臨時內存空間最大也不會超過 n 個數據的大小,因此空間複雜度是 O(n)。
因此,歸併排序不是原地排序算法。
merge 方法裏面的 left[0] <= right[0] ,保證了值相同的元素,在合併先後的前後順序不變。歸併排序是一種穩定的排序方法。
從效率上看,歸併排序可算是排序算法中的佼佼者
。假設數組長度爲 n,那麼拆分數組共需 logn 步, 又每步都是一個普通的合併子數組的過程,時間複雜度爲 O(n),故其綜合時間複雜度爲 O(nlogn)。
最佳狀況:T(n) = O(nlogn)。
最差狀況:T(n) = O(nlogn)。
平均狀況:T(n) = O(nlogn)。
動畫
快速排序的特色就是快,並且效率高!它是處理大數據最快的排序算法之一。
思想
特色:快速,經常使用。
缺點:須要另外聲明兩個數組,浪費了內存空間資源。
實現
方法一:
const quickSort1 = arr => { if (arr.length <= 1) { return arr; } //取基準點 const midIndex = Math.floor(arr.length / 2); //取基準點的值,splice(index,1) 則返回的是含有被刪除的元素的數組。 const valArr = arr.splice(midIndex, 1); const midIndexVal = valArr[0]; const left = []; //存放比基準點小的數組 const right = []; //存放比基準點大的數組 //遍歷數組,進行判斷分配 for (let i = 0; i < arr.length; i++) { if (arr[i] < midIndexVal) { left.push(arr[i]); //比基準點小的放在左邊數組 } else { right.push(arr[i]); //比基準點大的放在右邊數組 } } //遞歸執行以上操做,對左右兩個數組進行操做,直到數組長度爲 <= 1 return quickSort1(left).concat(midIndexVal, quickSort1(right)); }; const array2 = [5, 4, 3, 2, 1]; console.log('quickSort1 ', quickSort1(array2)); // quickSort1: [1, 2, 3, 4, 5]
方法二:
// 快速排序 const quickSort = (arr, left, right) => { let len = arr.length, partitionIndex; left = typeof left != 'number' ? 0 : left; right = typeof right != 'number' ? len - 1 : right; if (left < right) { partitionIndex = partition(arr, left, right); quickSort(arr, left, partitionIndex - 1); quickSort(arr, partitionIndex + 1, right); } return arr; }; const partition = (arr, left, right) => { //分區操做 let pivot = left, //設定基準值(pivot) index = pivot + 1; for (let i = index; i <= right; i++) { if (arr[i] < arr[pivot]) { swap(arr, i, index); index++; } } swap(arr, pivot, index - 1); return index - 1; }; const swap = (arr, i, j) => { let temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; };
測試
// 測試 const array = [5, 4, 3, 2, 1]; console.log('原始array:', array); const newArr = quickSort(array); console.log('newArr:', newArr); // 原始 array: [5, 4, 3, 2, 1] // newArr: [1, 4, 3, 2, 5]
分析
由於 partition() 函數進行分區時,不須要不少額外的內存空間,因此快排是原地排序
算法。
和選擇排序類似,快速排序每次交換的元素都有可能不是相鄰的,所以它有可能打破原來值爲相同的元素之間的順序。所以,快速排序並不穩定。
極端的例子:若是數組中的數據原來已是有序的了,好比 1,3,5,6,8。若是咱們每次選擇最後一個元素做爲 pivot,那每次分區獲得的兩個區間都是不均等的。咱們須要進行大約 n 次分區操做,才能完成快排的整個過程。每次分區咱們平均要掃描大約 n / 2 個元素,這種狀況下,快排的時間複雜度就從 O(nlogn) 退化成了 O(n2)。
最佳狀況:T(n) = O(nlogn)。
最差狀況:T(n) = O(n2)。
平均狀況:T(n) = O(nlogn)。
動畫
解答開篇問題
快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢 ?
能夠發現:
由下而上
的,先處理子問題,而後再合併。由上而下
的,先分區,而後再處理子問題。思想
過程
實現
const shellSort = arr => { let len = arr.length, temp, gap = 1; console.time('希爾排序耗時'); while (gap < len / 3) { //動態定義間隔序列 gap = gap * 3 + 1; } for (gap; gap > 0; gap = Math.floor(gap / 3)) { for (let i = gap; i < len; i++) { temp = arr[i]; let j = i - gap; for (; j >= 0 && arr[j] > temp; j -= gap) { arr[j + gap] = arr[j]; } arr[j + gap] = temp; console.log('arr :', arr); } } console.timeEnd('希爾排序耗時'); return arr; };
測試
// 測試 const array = [35, 33, 42, 10, 14, 19, 27, 44]; console.log('原始array:', array); const newArr = shellSort(array); console.log('newArr:', newArr); // 原始 array: [35, 33, 42, 10, 14, 19, 27, 44] // arr : [14, 33, 42, 10, 35, 19, 27, 44] // arr : [14, 19, 42, 10, 35, 33, 27, 44] // arr : [14, 19, 27, 10, 35, 33, 42, 44] // arr : [14, 19, 27, 10, 35, 33, 42, 44] // arr : [14, 19, 27, 10, 35, 33, 42, 44] // arr : [14, 19, 27, 10, 35, 33, 42, 44] // arr : [10, 14, 19, 27, 35, 33, 42, 44] // arr : [10, 14, 19, 27, 35, 33, 42, 44] // arr : [10, 14, 19, 27, 33, 35, 42, 44] // arr : [10, 14, 19, 27, 33, 35, 42, 44] // arr : [10, 14, 19, 27, 33, 35, 42, 44] // 希爾排序耗時: 3.592041015625ms // newArr: [10, 14, 19, 27, 33, 35, 42, 44]
分析
希爾排序過程當中,只涉及相鄰數據的交換操做,只須要常量級的臨時空間,空間複雜度爲 O(1) 。因此,希爾排序是原地排序
算法。
咱們知道,單次直接插入排序是穩定的,它不會改變相同元素之間的相對順序,但在屢次不一樣的插入排序過程當中,相同的元素可能在各自的插入排序中移動,可能致使相同元素相對順序發生變化。
所以,希爾排序不穩定
。
最佳狀況:T(n) = O(n logn)。
最差狀況:T(n) = O(n (log(n))2)。
平均狀況:T(n) = 取決於間隙序列。
動畫
堆的定義
堆實際上是一種特殊的樹。只要知足這兩點,它就是一個堆。
徹底二叉樹:除了最後一層,其餘層的節點個數都是滿的,最後一層的節點都靠左排列。
也能夠說:堆中每一個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。
對於每一個節點的值都大於等於
子樹中每一個節點值的堆,咱們叫做大頂堆
。
對於每一個節點的值都小於等於
子樹中每一個節點值的堆,咱們叫做小頂堆
。
其中圖 1 和 圖 2 是大頂堆,圖 3 是小頂堆,圖 4 不是堆。除此以外,從圖中還能夠看出來,對於同一組數據,咱們能夠構建多種不一樣形態的堆。
思想
實現
// 堆排序 const heapSort = array => { console.time('堆排序耗時'); // 初始化大頂堆,從第一個非葉子結點開始 for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) { heapify(array, i, array.length); } // 排序,每一次 for 循環找出一個當前最大值,數組長度減一 for (let i = Math.floor(array.length - 1); i > 0; i--) { // 根節點與最後一個節點交換 swap(array, 0, i); // 從根節點開始調整,而且最後一個結點已經爲當前最大值,不須要再參與比較,因此第三個參數爲 i,即比較到最後一個結點前一個便可 heapify(array, 0, i); } console.timeEnd('堆排序耗時'); return array; }; // 交換兩個節點 const swap = (array, i, j) => { let temp = array[i]; array[i] = array[j]; array[j] = temp; }; // 將 i 結點如下的堆整理爲大頂堆,注意這一步實現的基礎其實是: // 假設結點 i 如下的子堆已是一個大頂堆,heapify 函數實現的 // 功能是其實是:找到 結點 i 在包括結點 i 的堆中的正確位置。 // 後面將寫一個 for 循環,從第一個非葉子結點開始,對每個非葉子結點 // 都執行 heapify 操做,因此就知足告終點 i 如下的子堆已是一大頂堆 const heapify = (array, i, length) => { let temp = array[i]; // 當前父節點 // j < length 的目的是對結點 i 如下的結點所有作順序調整 for (let j = 2 * i + 1; j < length; j = 2 * j + 1) { temp = array[i]; // 將 array[i] 取出,整個過程至關於找到 array[i] 應處於的位置 if (j + 1 < length && array[j] < array[j + 1]) { j++; // 找到兩個孩子中較大的一個,再與父節點比較 } if (temp < array[j]) { swap(array, i, j); // 若是父節點小於子節點:交換;不然跳出 i = j; // 交換後,temp 的下標變爲 j } else { break; } } };
測試
const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]; console.log('原始array:', array); const newArr = heapSort(array); console.log('newArr:', newArr); // 原始 array: [4, 6, 8, 5, 9, 1, 2, 5, 3, 2] // 堆排序耗時: 0.15087890625ms // newArr: [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
分析
整個堆排序的過程,都只須要極個別臨時存儲空間,因此堆排序是原地排序算法。
由於在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操做,因此就有可能改變值相同數據的原始相對順序。
因此,堆排序是不穩定
的排序算法。
堆排序包括建堆和排序兩個操做,建堆過程的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),因此,堆排序總體的時間複雜度是 O(nlogn)。
最佳狀況:T(n) = O(nlogn)。
最差狀況:T(n) = O(nlogn)。
平均狀況:T(n) = O(nlogn)。
動畫
複雜性對比
算法可視化工具
效果以下圖。
旨在經過交互式可視化的執行來揭示算法背後的機制。
效果以下圖。
變量和操做的可視化表示加強了控制流和實際源代碼。您能夠快速前進和後退執行,以密切觀察算法的工做方式。
JavaScript 數據結構與算法之美 的系列文章,堅持 3 - 7 天左右更新一篇,暫定計劃以下表。
| 標題 | 連接 |
| :------ | :------ |
| 時間和空間複雜度 | https://github.com/biaochenxu... |
| 線性表(數組、鏈表、棧、隊列) | https://github.com/biaochenxu... |
| 實現一個前端路由,如何實現瀏覽器的前進與後退 ?| https://github.com/biaochenxu... |
| 棧內存與堆內存 、淺拷貝與深拷貝 | https://github.com/biaochenxu... |
| 遞歸 | https://github.com/biaochenxu... |
| 非線性表(樹、堆) | https://github.com/biaochenxu... |
| 冒泡排序、選擇排序、插入排序 | https://github.com/biaochenxu... |
| 歸併排序、快速排序、希爾排序、堆排序 | https://github.com/biaochenxu... |
| 計數排序、桶排序、基數排序 | 精彩待續 |
| 十大經典排序彙總 | 精彩待續 |
| 強烈推薦 GitHub 上值得前端學習的數據結構與算法項目 | https://github.com/biaochenxu... |
若是有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。
文中全部的代碼及測試事例都已經放到個人 GitHub 上了。
以爲有用 ?喜歡就收藏,順便點個贊吧。
參考文章: