六種排序演算法的JavaScript實現以及總結

Russ_Zhong發表於2018-05-24

最近幾天在系統的複習排序演算法,之前都沒有系統性的學習過,也沒有留下過什麼筆記,所以很快就忘了,這次好好地學習一下。

首先說明為了減少限制,以下程式碼通通執行於Node V8引擎而非瀏覽器,原始碼在我的GitHub,感興趣的話可以下載來然後執行試試。

為了方便對比各個排序演算法的效能,這裡先寫了一個生成大規模陣列的方法——generateArray

exports.generateArray = function(length) {
    let arr = Array(length);
    for(let i=0; i<length; i++) {
        arr[i] = Math.random();
    }
    return arr;
};
複製程式碼

只需要輸入陣列長度,即可生成一個符合長度要求的隨機陣列。

一、氣泡排序

氣泡排序也成為沉澱排序(sinking sort),氣泡排序得名於其排序方式,它遍歷整個陣列,將陣列的每一項與其後一項進行對比,如果不符合要求就交換位置,一共遍歷n輪,n為陣列的長度。n輪之後,陣列得以完全排序。整個過程符合要求的陣列項就像氣泡從水底冒到水面一樣泡到陣列末端,所以叫做氣泡排序。

氣泡排序是最簡單的排序方法,容易理解、實現簡單,但是氣泡排序是效率最低的排序演算法,由於演算法巢狀了兩輪迴圈(將陣列遍歷了n遍),所以時間複雜度為O(n^2)。最好的情況下,給出一個已經排序的陣列進行氣泡排序,時間複雜度也為O(n)。

特地感謝一下評論中@雪之祈舞的優化,每次冒泡都忽略尾部已經排序好的i項。

JavaScript實現(從小到大排序):

function bubbleSort(arr) {
    //console.time('BubbleSort');
    // 獲取陣列長度,以確定迴圈次數。
    let len = arr.length;
    // 遍歷陣列len次,以確保陣列被完全排序。
    for(let i=0; i<len; i++) {
        // 遍歷陣列的前len-i項,忽略後面的i項(已排序部分)。
        for(let j=0; j<len - 1 - i; j++) {
            // 將每一項與後一項進行對比,不符合要求的就換位。
            if(arr[j] > arr[j+1]) {
                [arr[j+1], arr[j]] = [arr[j], arr[j+1]];
            }
        }
    }
    //console.timeEnd('BubbleSort');
    return arr;
}
複製程式碼

程式碼中的註釋部分的程式碼都用於輸出排序時間,供測試使用,下文亦如是。

二、選擇排序

選擇排序是一種原址比較排序法,大致思路:

找到陣列中的最小(大)值,並將其放到第一位,然後找到第二小的值放到第二位……以此類推。

JavaScript實現(從小到大排序):

function selectionSort(arr) {
    //console.time('SelectionSort');
    // 獲取陣列長度,確保每一項都被排序。
    let len = arr.length;
    // 遍歷陣列的每一項。
    for(let i=0; i<len; i++) {
        // 從陣列的當前項開始,因為左邊部分的陣列項已經被排序。
        let min = i;
        for(let j=i; j<len; j++) {
            if(arr[j] < arr[min]) {
                min = j;
            }
        }
        if(min !== i) {
            [arr[min], arr[i]] = [arr[i], arr[min]];
        }
    }
    //console.timeEnd('SelectionSort');
    return arr;
}
複製程式碼

由於巢狀了兩層迴圈,其時間複雜度也是O(n^2),

三、插入排序

插入排序是最接近生活的排序,因為我們打牌時就差不多是採用的這種排序方法。該方法從陣列的第二項開始遍歷陣列的n-1項(n為陣列長度),遍歷過程中對於當前項的左邊陣列項,依次從右到左進行對比,如果左邊選項大於(或小於)當前項,則左邊選項向右移動,然後繼續對比前一項,直到找到不大於(不小於)自身的選項為止,對於所有大於當前項的選項,都在原來位置的基礎上向右移動了一項。

示例:

// 對於如下陣列
var arr = [2,1,3,5,4,3];
// 從第二項(即arr[1])開始遍歷,
// 第一輪:
// a[0] >= 1為true,a[0]右移,
arr = [2,2,3,5,4,3];
// 然後1賦給a[0],
arr = [1,2,3,5,4,3];
// 然後第二輪:
// a[1] >= 3不成立,該輪遍歷結束。
// 第三輪;
// a[2] >= 5不成立,該輪遍歷結束。
// 第四輪:
// a[3] >= 4為true,a[3]右移,
arr = [1,2,3,5,5,3];
// a[2] >= 4不成立,將4賦給a[3],然後結束該輪遍歷。
arr = [1,2,3,4,5,3];
// a[4] >= 3成立,a[4]右移一位,
arr = [1,2,3,4,5,5];
// arr[3] >= 3成立,arr[3]右移一位,
arr = [1,2,3,4,4,5];
// arr[2] >= 3成立,arr[2]右移一位,
arr = [1,2,3,3,4,5];
// arr[1] >= 3不成立,將3賦給a[2],結束該輪。
arr = [1,2,3,3,4,5];
// 遍歷完成,排序結束。
複製程式碼

如果去掉比較時的等號的話,可以減少一些步驟,所以在JavaScript程式碼中減少了這部分, JavaScript實現(從小到大排序):

function insertionSort(arr) {
    //console.time('InsertionSort');
    let len = arr.length;
    for(let i=1; i<len; i++) {
        let j = i;
        let tmp = arr[i];
        while(j > 0 && arr[j-1] > tmp) {
            arr[j] = arr[j-1];
            j--;
        }
        arr[j] = tmp;
    }
    //console.timeEnd('InsertionSort');
    return arr;
}
複製程式碼

插入排序比一般的高階排序演算法(快排、堆排)效能要差,但是還是具有以下優點的:

  • 實現起來簡單,理解起來不是很複雜。
  • 對於較小的資料集而言比較高效。
  • 相對於其他複雜度為O(n^2)的排序演算法(冒泡、選擇)而言更加快速。這一點在文章最後的測試中可以看出來。
  • 穩定、及時……

四、歸併排序

到目前為止,已經介紹了三種排序方法,包括氣泡排序、選擇排序和插入排序。這三種排序方法的時間複雜度都為O(n^2),其中氣泡排序實現最簡單,效能最差,選擇排序比氣泡排序稍好,但是還不夠,插入排序是這三者中表現最好的,對於小資料集而言效率較高。這些原因導致三者的實用性並不高,都是最基本的簡單排序方法,多用於教學,很難用於實際中,從這節開始介紹更加高階的排序演算法。

歸併排序是第一個可以用於實際的排序演算法,前面的三個效能都不夠好,歸併排序的時間複雜度為O(nlogn),這一點已經由於前面的三個演算法了。

值得注意的是,JavaScript中的Array.prototype.sort方法沒有規定使用哪種排序演算法,允許瀏覽器自定義,FireFox使用的是歸併排序法,而Chrome使用的是快速排序法。

歸併排序的核心思想是分治,分治是通過遞迴地將問題分解成相同或者型別相關的兩個或者多個子問題,直到問題簡單到足以解決,然後將子問題的解決方案結合起來,解決原始方案的一種思想。

歸併排序通過將複雜的陣列分解成足夠小的陣列(只包含一個元素),然後通過合併兩個有序陣列(單元素陣列可認為是有序陣列)來達到綜合子問題解決方案的目的。所以歸併排序的核心在於如何整合兩個有序陣列,拆分陣列只是一個輔助過程。

示例:

// 假設有以下陣列,對其進行歸併排序使其按從小到大的順序排列:
var arr = [8,7,6,5];
// 對其進行分解,得到兩個陣列:
[8,7]和[6,5]
// 然後繼續進行分解,分別再得到兩個陣列,直到陣列只包含一個元素:
[8]、[7]、[6]、[5]
// 開始合併陣列,得到以下兩個陣列:
[7,8]和[5,6]
// 繼續合併,得到
[5,6,7,8]
// 排序完成
複製程式碼

JavaScript實現(從小到大排序):

function mergeSort(arr) {
    //console.time('MergeSort');
    //let count = 0;
    console.log(main(arr));
    //console.timeEnd('MergeSort');
    //return count;
    // 主函式。
    function main(arr) {
        // 記得新增判斷,防止無窮遞迴導致callstack溢位,此外也是將陣列進行分解的終止條件。
        if(arr.length === 1) return arr;
        // 從中間開始分解,並構造左邊陣列和右邊陣列。
        let mid = Math.floor(arr.length/2);
        let left = arr.slice(0, mid);
        let right = arr.slice(mid);
        // 開始遞迴呼叫。
        return merge(arguments.callee(left), arguments.callee(right));
    }
    // 陣列的合併函式,left是左邊的有序陣列,right是右邊的有序陣列。
    function merge(left, right) {
        // il是左邊陣列的一個指標,rl是右邊陣列的一個指標。
        let il = 0,
            rl = 0,
            result = [];
        // 同時遍歷左右兩個陣列,直到有一個指標超出範圍。
        while(il < left.length && rl < right.length) {
            //count++;
            // 左邊陣列的當前項如果小於右邊陣列的當前項,那麼將左邊陣列的當前項推入result,反之亦然,同時將推入過的指標右移。
            if(left[il] < right[rl]) {
                result.push(left[il++]);
            }
            else {
                result.push(right[rl++]);
            }
        }
        // 記得要將未讀完的陣列的多餘部分讀到result。
        return result.concat(left.slice(il)).concat(right.slice(rl));
    }
}
複製程式碼

注意是因為陣列被分解成為了只有一個元素的許多子陣列,所以merge函式從單個元素的陣列開始合併,當合並的陣列的元素個數超過1時,即為有序陣列,仍然還可以繼續使用merge函式進行合併。

歸併排序的效能確實達到了應用級別,但是還是有些不足,因為這裡的merge函式新建了一個result陣列來盛放合併後的陣列,導致空間複雜度增加,這裡還可以進行優化,使得陣列進行原地排序。

五、快速排序

快速排序由Tony Hoare在1959年發明,是當前最為常用的排序方案,如果使用得當,其速度比一般演算法可以快兩到三倍,比之氣泡排序、選擇排序等可以說快成千上萬倍。快速排序的複雜度為O(nlogn),其核心思想也是分而治之,它遞迴地將大陣列分解為小陣列,直到陣列長度為1,不過與歸併排序的區別在於其重點在於陣列的分解,而歸併排序的重點在於陣列的合併。

基本思想:

在陣列中選取一個參考點(pivot),然後對於陣列中的每一項,大於pivot的項都放到陣列右邊,小於pivot的項都放到左邊,左右兩邊的陣列項可以構成兩個新的陣列(left和right),然後繼續分別對left和right進行分解,直到陣列長度為1,最後合併(其實沒有合併,因為是在原陣列的基礎上操作的,只是理論上的進行了陣列分解)。

基本步驟:

  • (1)首先,選取陣列的中間項作為參考點pivot。
  • (2)建立左右兩個指標left和right,left指向陣列的第一項,right指向最後一項,然後移動左指標,直到其值不小於pivot,然後移動右指標,直到其值不大於pivot。
  • (3)如果left仍然不大於right,交換左右指標的值(指標不交換),然後左指標右移,右指標左移,繼續迴圈直到left大於right才結束,返回left指標的值。
  • (4)根據上一輪分解的結果(left的值),切割陣列得到left和right兩個陣列,然後分別再分解。
  • (5)重複以上過程,直到陣列長度為1才結束分解。

JavaScript實現(從小到大排序):

function quickSort(arr) {
    let left = 0,
        right = arr.length - 1;
    //console.time('QuickSort');
    main(arr, left, right);
    //console.timeEnd('QuickSort');
    return arr;
    function main(arr, left, right) {
        // 遞迴結束的條件,直到陣列只包含一個元素。
        if(arr.length === 1) {
            // 由於是直接修改arr,所以不用返回值。
            return;
        }
        // 獲取left指標,準備下一輪分解。
        let index = partition(arr, left, right);
        if(left < index - 1) {
            // 繼續分解左邊陣列。
            main(arr, left, index - 1);
        }
        if(index < right) {
            // 分解右邊陣列。
            main(arr, index, right);
        }
    }
    // 陣列分解函式。
    function partition(arr, left, right) {
        // 選取中間項為參考點。
        let pivot = arr[Math.floor((left + right) / 2)];
        // 迴圈直到left > right。
        while(left <= right) {
            // 持續右移左指標直到其值不小於pivot。
            while(arr[left] < pivot) {
                left++;
            }
            // 持續左移右指標直到其值不大於pivot。
            while(arr[right] > pivot) {
                right--;
            }
            // 此時左指標的值不小於pivot,右指標的值不大於pivot。
            // 如果left仍然不大於right。
            if(left <= right) {
                // 交換兩者的值,使得不大於pivot的值在其左側,不小於pivot的值在其右側。
                [arr[left], arr[right]] = [arr[right], arr[left]];
                // 左指標右移,右指標左移準備開始下一輪,防止arr[left]和arr[right]都等於pivot然後導致死迴圈。
                left++;
                right--;
            }
        }
        // 返回左指標作為下一輪分解的依據。
        return left;
    }
}
複製程式碼

快速排序相對於歸併排序而言加強了分解部分的邏輯,消除了陣列的合併工作,並且不用分配新的記憶體來存放陣列合並結果,所以效能更加優秀,是目前最常用的排序方案。

之前還在知乎上看到過一個回答,程式碼大致如下(從小到大排序):

function quickSort(arr) {
    // 當陣列長度不大於1時,返回結果,防止callstack溢位。
    if(arr.length <= 1) return arr;
    return [
        // 遞迴呼叫quickSort,通過Array.prototype.filter方法過濾小於arr[0]的值,注意去掉了arr[0]以防止出現死迴圈。
        ...quickSort(arr.slice(1).filter(item => item < arr[0])),
        arr[0],
        ...quickSort(arr.slice(1).filter(item => item >= arr[0]))
    ];
}
複製程式碼

以上程式碼有利於對快排思想的理解,但是實際運用效果不太好,不如之前的程式碼速度快。

六、堆排序

如果說快速排序是應用性最強的排序演算法,那麼我覺得堆排序是趣味性最強的排序方法,非常有意思。

堆排序也是一種很高效的排序方法,因為它把陣列作為二叉樹排序而得名,可以認為是歸併排序的改良方案,它是一種原地排序方法,但是不夠穩定,其時間複雜度為O(nlogn)。

實現步驟:

  • (1)由陣列構造一個堆結構,該結構滿足父節點總是大於(或小於)其子節點。
  • (2)從堆結構的最右邊的葉子節點開始,從右至左、從下至上依次與根節點進行交換,每次交換後,都要再次構建堆結構。值得注意的是每次構建堆結構時,都要忽略已經交換過的非根節點。

陣列構建的堆結構:

// 陣列
var arr = [1,2,3,4,5,6,7];
// 堆結構
        1
      /   \
    2       3
  /   \   /   \
4      5 6     7
複製程式碼

可以發現對於陣列下標為i的陣列項,其左子節點的值為下標2*i + 1對應的陣列項,右子節點的值為下標2*i + 2對應的陣列項。

實際上並沒有在記憶體中開闢一塊空間構建堆結構來儲存陣列資料,只是在邏輯上把陣列當做二叉樹來對待,構建堆結構指的是使其任意父節點的子節點都不大於(不小於)父節點。

JavaScript實現(從小到大排序):

function heapSort(arr) {
    //console.time('HeapSort');
    buildHeap(arr);
    for(let i=arr.length-1; i>0; i--) {
        // 從最右側的葉子節點開始,依次與根節點的值交換。
        [arr[i], arr[0]] = [arr[0], arr[i]];
        // 每次交換之後都要重新構建堆結構,記得傳入i限制範圍,防止已經交換的值仍然被重新構建。
        heapify(arr, i, 0);
    }
    //console.timeEnd('HeapSort');
    return arr;
    function buildHeap(arr) {
        // 可以觀察到中間下標對應最右邊葉子節點的父節點。
        let mid = Math.floor(arr.length / 2);
        for(let i=mid; i>=0; i--) {
            // 將整個陣列構建成堆結構以便初始化。
            heapify(arr, arr.length, i);
        }
        return arr;
    }
    // 從i節點開始下標在heapSize內進行堆結構構建的函式。
    function heapify(arr, heapSize, i) {
        // 左子節點下標。
        let left = 2 * i + 1,
            // 右子節點下標。
            right = 2 * i + 2,
            // 假設當前父節點滿足要求(比子節點都大)。
            largest = i;
        // 如果左子節點在heapSize內,並且值大於其父節點,那麼left賦給largest。
        if(left < heapSize && arr[left] > arr[largest]) {
            largest = left;
        }
        // 如果右子節點在heapSize內,並且值大於其父節點,那麼right賦給largest。
        if(right < heapSize && arr[right] > arr[largest]) {
            largest = right;
        }
        if(largest !== i) {
            // 如果largest被修改了,那麼交換兩者的值使得構造成一個合格的堆結構。
            [arr[largest], arr[i]] = [arr[i], arr[largest]];
            // 遞迴呼叫自身,將節點i所有的子節點都構建成堆結構。
            arguments.callee(arr, heapSize, largest);
        }
        return arr;
    }
}
複製程式碼

堆排序的效能稍遜於快速排序,但是真的很有意思。

七、效能對比

通過console.time()console.timeEnd()檢視排序所用時間,通過generateArray()產生大規模的資料,最終得到如下結論:

通過對氣泡排序的測試,得到以下資料:

BubbleSort: 406.567ms
複製程式碼

給10000(一萬)條資料進行排序,耗時406毫秒。

BubbleSort: 1665.196ms
複製程式碼

給20000(兩萬)條資料進行排序,耗時1.6s。

BubbleSort: 18946.897ms
複製程式碼

給50000(五萬)條資料進行排序,耗時19s。 由於機器不太好,當資料量達到100000時基本就非常漫長了,具體多久也沒等過,這已經可以看出來效能非常不好了。

通過對選擇排序的測試,得到以下資料:

SelectionSort: 1917.083ms
複製程式碼

對20000(兩萬)條資料進行排序,耗時1.9s。

SelectionSort: 12233.060ms
複製程式碼

給50000(五萬)條資料進行排序時,耗時12.2s,可以看出相對於氣泡排序而言已經有了進步,但是遠遠不夠。還可以看出隨著資料量的增長,排序的時間消耗越來越大。

通過對插入排序的測試,得到以下資料:

InsertionSort: 273.891ms
複製程式碼

對20000(兩萬)條資料進行排序,耗時0.27s。

InsertionSort: 1500.631ms
複製程式碼

對50000(五萬)條資料進行排序,耗時1.5s。

InsertionSort: 7467.029ms
複製程式碼

對100000(十萬)條資料進行排序,耗時7.5秒,對比選擇排序,又有了很大的改善,但是仍然不夠。

通過對歸併排序的測試,得到以下資料:

MergeSort: 287.361ms
複製程式碼

對100000(十萬)條資料進行排序,耗時0.3秒,真的很優秀了hhh,

MergeSort: 2354.007ms
複製程式碼

對1000000(一百萬)條資料進行排序,耗時2.4s,絕對的優秀,難怪FireFox會使用這個來定義Array.prototype.sort方法,

MergeSort: 26220.459ms
複製程式碼

對10000000(一千萬)條資料進行排序,耗時26s,還不錯。 接下來看快排。

通過對快速排序的測試,得到以下資料:

QuickSort: 51.446ms
複製程式碼

100000(十萬)條資料排序耗時0.05s,達到了可以忽略的境界,

QuickSort: 463.528ms
複製程式碼

1000000(一百萬)條資料排序耗時0.46s,也基本可以忽略,太優秀了,

QuickSort: 5181.508ms
複製程式碼

10000000(一千萬)條資料排序耗時5.2s,完全可以接受。

通過對堆排序的測試,得到以下資料:

HeapSort: 3124.188ms
複製程式碼

對1000000(一百萬)條資料進行排序,耗時3.1s,遜色於快速排序和歸併排序,但是對比其他的排序方法還是不錯的啦。

HeapSort: 41746.788ms
複製程式碼

對10000000(一千萬)條資料進行排序,耗時41.7s,不太能接受。

八、結論

以前都認為排序方法隨便用用無可厚非,現在想想確實挺naive的hhh,想到了以前實習的時候,SQL Server幾百萬資料幾秒鐘就排序完成了,這要是用氣泡排序還不得等到兩眼發黑?通過這次學習總結排序演算法,尤其是對於每種方法效能的測試,我深刻地認識到了演算法設計的重要性,只有重視演算法的設計、複雜度的對比,才能寫出優秀的演算法,基於優秀的演算法才能寫出效能出色的應用!

此外,由於對於演算法複雜度的研究不夠深入,理解只停留在表面,所以文中如果存在有錯誤,懇請大牛不吝賜教!

最後,我想說一聲,支援阮老師!

九、參考文章

相關文章