排序算法分析總結(附js實現)

本文對一些排序算法進行了簡單分析,並給出了 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

代碼僅供參考。算法

冒泡排序(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(分區點),將數組分爲三部分:

  1. 小於 pivot 的部分
  2. pivot
  3. 大於或等於 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 分區函數進行了巧妙的處理。

結尾

大概就是這樣,作了簡單的總結。若是文章有錯誤的地方,請給我留言。

還有一些排序打算下次再更新,可能會新開一篇文章,也可能直接修改這篇文章。

參考

數據結構與算法之美

相關文章
相關標籤/搜索