JavaScript 數據結構與算法之美 - 歸併排序、快速排序、希爾排序、堆排序

圖片描述

1. 前言

算法爲王。

想學好前端,先練好內功,只有內功深厚者,前端之路纔會走得更遠javascript

筆者寫的 JavaScript 數據結構與算法之美 系列用的語言是 JavaScript ,旨在入門數據結構與算法和方便之後複習。html

之因此把歸併排序、快速排序、希爾排序、堆排序放在一塊兒比較,是由於它們的平均時間複雜度都爲 O(nlogn)前端

請你們帶着問題:快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢 ? 來閱讀下文。java

2. 歸併排序(Merge Sort)

思想git

排序一個數組,咱們先把數組從中間分紅先後兩部分,而後對先後兩部分分別排序,再將排好序的兩部分合並在一塊兒,這樣整個數組就都有序了。github

歸併排序採用的是分治思想算法

分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。shell

clipboard.png

注: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)。

動畫

strip

3. 快速排序 (Quick Sort)

快速排序的特色就是快,並且效率高!它是處理大數據最快的排序算法之一。

思想

  • 先找到一個基準點(通常指數組的中部),而後數組被該基準點分爲兩部分,依次與該基準點數據比較,若是比它小,放左邊;反之,放右邊。
  • 左右分別用一個空數組去存儲比較後的數據。
  • 最後遞歸執行上述操做,直到數組長度 <= 1;

特色:快速,經常使用。

缺點:須要另外聲明兩個數組,浪費了內存空間資源。

實現

方法一:

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)。

動畫

strip

解答開篇問題

快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢 ?

clipboard.png

能夠發現:

  • 歸併排序的處理過程是由下而上的,先處理子問題,而後再合併。
  • 而快排正好相反,它的處理過程是由上而下的,先分區,而後再處理子問題。
  • 歸併排序雖然是穩定的、時間複雜度爲 O(nlogn) 的排序算法,可是它是非原地排序算法。
  • 歸併之因此是非原地排序算法,主要緣由是合併函數沒法在原地執行。
  • 快速排序經過設計巧妙的原地分區函數,能夠實現原地排序,解決了歸併排序佔用太多內存的問題。

4. 希爾排序(Shell Sort)

思想

  • 先將整個待排序的記錄序列分割成爲若干子序列。
  • 分別進行直接插入排序。
  • 待整個序列中的記錄基本有序時,再對全體記錄進行依次直接插入排序。

過程

  1. 舉個易於理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],咱們採起間隔 4。建立一個位於 4 個位置間隔的全部值的虛擬子列表。下面這些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。

clipboard.png

  1. 咱們比較每一個子列表中的值,並在原始數組中交換它們(若是須要)。完成此步驟後,新數組應以下所示。

clipboard.png

  1. 而後,咱們採用 2 的間隔,這個間隙產生兩個子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。

clipboard.png

  1. 咱們比較並交換原始數組中的值(若是須要)。完成此步驟後,數組變成:[14, 10, 27, 19, 35, 33, 42, 44],圖以下所示,10 與 19 的位置互換一下。

clipboard.png

  1. 最後,咱們使用值間隔 1 對數組的其他部分進行排序,Shell sort 使用插入排序對數組進行排序。

clipboard.png

實現

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) = 取決於間隙序列。

動畫

strip

5. 堆排序(Heap Sort)

堆的定義

堆實際上是一種特殊的樹。只要知足這兩點,它就是一個堆。

  • 堆是一個徹底二叉樹。

徹底二叉樹:除了最後一層,其餘層的節點個數都是滿的,最後一層的節點都靠左排列。

  • 堆中每個節點的值都必須大於等於(或小於等於)其子樹中每一個節點的值。

也能夠說:堆中每一個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。

對於每一個節點的值都大於等於子樹中每一個節點值的堆,咱們叫做大頂堆
對於每一個節點的值都小於等於子樹中每一個節點值的堆,咱們叫做小頂堆

clipboard.png

其中圖 1 和 圖 2 是大頂堆,圖 3 是小頂堆,圖 4 不是堆。除此以外,從圖中還能夠看出來,對於同一組數據,咱們能夠構建多種不一樣形態的堆。

思想

  1. 將初始待排序關鍵字序列 (R1, R2 .... Rn) 構建成大頂堆,此堆爲初始的無序區;
  2. 將堆頂元素 R[1] 與最後一個元素 R[n] 交換,此時獲得新的無序區 (R1, R2, ..... Rn-1) 和新的有序區 (Rn) ,且知足 R[1, 2 ... n-1] <= R[n]。
  3. 因爲交換後新的堆頂 R[1] 可能違反堆的性質,所以須要對當前無序區 (R1, R2 ...... Rn-1) 調整爲新堆,而後再次將 R[1] 與無序區最後一個元素交換,獲得新的無序區 (R1, R2 .... Rn-2) 和新的有序區 (Rn-1, Rn)。不斷重複此過程,直到有序區的元素個數爲 n - 1,則整個排序過程完成。

實現

// 堆排序
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)。

動畫

strip

strip

6. 排序算法的複雜性對比

複雜性對比

clipboard.png

算法可視化工具

  • 算法可視化工具 algorithm-visualizer
    算法可視化工具 algorithm-visualizer 是一個交互式的在線平臺,能夠從代碼中可視化算法,還能夠看到代碼執行的過程。

效果以下圖。

strip

旨在經過交互式可視化的執行來揭示算法背後的機制。

效果以下圖。

strip

clipboard.png

strip

變量和操做的可視化表示加強了控制流和實際源代碼。您能夠快速前進和後退執行,以密切觀察算法的工做方式。

strip

7. 文章輸出計劃

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... |

若是有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。

8. 最後

文中全部的代碼及測試事例都已經放到個人 GitHub 上了。

以爲有用 ?喜歡就收藏,順便點個贊吧。

參考文章:

clipboard.png

相關文章
相關標籤/搜索