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

Fstar_發表於2019-04-27

本文對一些排序演算法進行了簡單分析,並給出了 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(分割槽點),將陣列分為三部分:

  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 分割槽函式進行了巧妙的處理。

結尾

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

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

參考

資料結構與演算法之美

相關文章