基礎排序算法總結【JavaScript實現】

如何分析一個排序算法

  • 排序算法的執行效率:最好狀況、最壞狀況、平均狀況的時間複雜度javascript

  • 排序算法的內存消耗:空間複雜度html

  • 排序算法的穩定性:若是待排序的序列中存在值相等的元素,通過排序以後,相等元素之間原有的前後順序不變,則稱該排序算法穩定java

穩定排序的好處:git

在真正的軟件開發中,咱們要排序的每每不是單純的整數,而是一組對象,而後按照對象的某個 key 來排序。若是咱們的需求是按 key1 進行排序,當 key1 的值相同時,按 key2 進行排序,不使用穩定排序的解決方案是先對 key1 進行排序,而後遍歷排序以後的值,對每一個 key1 值相同的小區間再進行 key2 排序,實現起來會比較複雜。github

使用穩定排序的方案:先按照 key2 進行排序,排序完成後,使用穩定排序算法按照 key1 從新排序。算法

試想一下對一組學生按年齡進行排序,在年齡相等的狀況下按照身高進行排序。chrome

如下是在 leetcode 上測的各個排序的時間和空間消耗,能夠看出快排不管是在時間仍是空間上都比較優秀,歸併算法要注意優化,直接所有遞歸很容易時間複雜度爆表。編程

冒泡排序(熟悉)

  • 基本思想:使用兩層循環,外層循環每一次通過兩兩比較,把每一輪未排定部分最大的元素放到了數組的末尾;
  • 優化:當某次冒泡操做已經沒有數據交換時,說明已經達到徹底有序,不用再繼續執行後續的冒泡操做。第一層循環裏設置一個提早退出冒泡循環的標誌位,在第二層循環裏,當有數據交換時改變它的值,若是通過一次冒泡沒有改變過 flag 的值,則 break
var sortArray = function(nums) {
    const len = nums.length;
    // 從後往前遍歷,排過序的就不用再走一遍了
    for(let i=len-1; i>=0; i--){
        let flag = true;
        for(let j=0; j<i; j++){
            if(nums[j]>nums[j+1]){
                const temp = nums[j+1];
                nums[j+1] = nums[j];
                nums[j] = temp;
                flag = false;
            }           
        }
        if(flag) break;
    }
    return nums;
};
複製代碼

分析:數組

  • 空間複雜度:O(1) ,只涉及相鄰數據的交換操做,只須要常量級的臨時空間,是一個原地排序。瀏覽器

  • 穩定性:穩定。當兩個相鄰的元素大小相等時,不作交換,排序後,其相對位置不變。

  • 時間複雜度:

最好狀況 :徹底有序,一次冒泡,時間複雜度 O(n)
最壞狀況:徹底逆序,n 次冒泡,時間複雜度 O(n2)
平均狀況:n*(n-1)/4 = O(n2)

插入排序(熟悉)

  • 思路:每次將一個數字插入一個有序的數組裏,成爲一個長度更長的有序數組,有限次操做之後,數組總體有序。初始已排序區間只有一個元素,就是數組的第一個元素。
var sortArray = function(nums) {
    const len = nums.length;
    // 循環不變量:將 nums[i] 插入到區間 [0, i) 使之成爲有序數組
    for(let i=1; i<len; i++){
        // 先暫存這個元素,而後以前元素逐個後移,留出空位
        const currentVal = nums[i];
        let j = i;
        // 注意邊界 j > 0
        while(j>0 && nums[j-1] > currentVal ){
            nums[j] = nums[j-1];
            j--;
        }
        nums[j] = currentVal;
    }
    return nums;
};
複製代碼

分析:

  • 時間複雜度:

最好狀況:在數組「幾乎有序」的時,插入排序的時間複雜度能夠達到 O(n); 最壞/平均狀況:O(n2)

  • 空間複雜度:O(1) 使用到常數個臨時變量,是原地排序
  • 穩定性:穩定。對於值相同的元素,咱們能夠選擇將後面出現的元素,插入到前面出現元素的後面,保持原有的先後順序不變。
  • 「插入排序」在「幾乎有序」的數組上表現良好,特別地,在「短數組」上的表現也很好。由於「短數組」的特色是:每一個元素離它最終排定的位置都不會太遠。爲此,在小區間內執行排序任務的時候,能夠轉向使用「插入排序」。

選擇排序(瞭解)

  • 思路:每一輪選取未排定的部分中最小的元素交換到已排定部分的末尾(未排定部分的開頭),通過若干個步驟,就能排定整個數組。即:先選出最小的,再選出第 2 小的,以此類推。
var sortArray = function(nums) {
    const len = nums.length;
    // [0, i) 有序,且該區間裏全部元素就是最終排定的樣子
    for(let i=0; i<len-1; i++){
        let minIndex = i;
        // 從未排序區間找出最小值, 交換到下標i
        for(let j=i+1; j<len; j++){
            if(nums[j]<nums[minIndex]){
                minIndex = j;
            }
        }
        swap(nums, i, minIndex);
    }
    return nums;

    function swap(nums, index1, index2){
        const temp = nums[index2];
        nums[index2] = nums[index1];
        nums[index1] = temp;
    }
};

複製代碼

使用到的算法思想:

  • 貪心算法:每一次決策只看當前,當前最優,則全局最優。注意:這種思想不是任什麼時候候都適用。

  • 減治思想:外層循環每一次都能排定一個元素,問題的規模逐漸減小,直到所有解決,即「大而化小,小而化了」。運用「減治思想」很典型的算法就是大名鼎鼎的「二分查找」。

分析:

  • 時間複雜度:最好,最壞,平均狀況的時間複雜度都是 O(n2)
  • 空間複雜度:O(1) 原地排序
  • 穩定性:不穩定。由於在交換的時候,前面的值可能會被交換到後面。
  • 相比於冒泡和插入,選擇排序稍顯遜色了。不太經常使用。但它的交換次數最少,若是在交換成本較高的排序任務中,能夠考慮使用「選擇排序」。

歸併排序(重點)

  • 思路: 先把數組從中間分爲先後兩部分,對先後兩部分分別排序,而後藉助額外空間,合併兩個有序數組,獲得更長的有序數組。
  • 算法思想:分治思想,分而治之,將大問題分解成小的子問題來解決,小的子問題解決了,大問題也就解決了。分治算法通常都是用遞歸來實現的,分治是一種解決問題的處理思想,遞歸是一種編程技巧,這二者並不衝突。
  • 「歸併排序」是理解「遞歸思想」的很是好的學習材料,你們能夠經過理解:遞歸完成之後,合併兩個有序數組的這一步驟,想清楚程序的執行流程。即「遞歸函數執行完成之後,咱們還能夠作點事情」。
var sortArray = function(nums) {

    mergeSort(nums, 0, nums.length-1);
    return nums;
    
    // 對數組 nums 的子區間 [left, right] 進行歸併排序
    // temp: 用於合併兩個有序數組的輔助數組,全局使用一份,避免屢次建立和銷燬
    function mergeSort(nums, left, right, temp = []){
        if(left>=right) return;  //遞歸終止條件
        
        // let mid = left + right >>> 1;
        let mid = left + Math.floor((right-left) / 2);

        // 遞歸左邊和右邊, 使左右兩邊分別有序
        mergeSort(nums, left, mid, temp);
        mergeSort(nums, mid+1, right, temp);

        // 左右兩邊有序以後,對它們進行合併
        mergeTwoSortedArray(nums, left, mid, right, temp);
    }

    // 合併兩個有序數組:先把值複製到臨時數組,再合併回去
    function mergeTwoSortedArray(nums, left, mid, right, temp){
        temp = [...nums];

        let i = left;
        let j = mid+1;
        for(let k=left; k<=right; k++){
            if(i === mid+1){ //i走到了中間,說明左邊已經放完了,以後只需循環把右邊放進去
                nums[k] = temp[j];
                j++;
            }else if(j === right+1){ //j走到了最後,說明右邊已經放完了,以後只需循環把左邊放進去
                nums[k] = temp[i];
                i++;
            }else if(temp[i] <= temp[j]){ // 比較i,j位置的元素,誰小放誰進去,並讓其指針右移
               // 注意寫成 < 就丟失了穩定性(相同元素原來靠前的排序之後依然靠前)
                nums[k] = temp[i];
                i++;
            }else{
                nums[k] = temp[j];
                j++;
            }
        }
    }  
};
複製代碼

優化:

  • 在「小區間」裏轉向使用「插入排序」,參考JavaScript數組的sort排序
  • 在「兩個數組」自己就是有序的狀況下,無需合併;
  • 注意:實現歸併排序的時候,要特別注意,不要把這個算法實現成非穩定排序,區別就在 <= 和 < ,已在代碼中註明。
// 優化後代碼:
var sortArray = function(nums) {
    const INSERTION_SORT_THRESHOLD = 7;

    mergeSort(nums, 0, nums.length-1);
    return nums;
    
    // 對數組 nums 的子區間 [left, right] 進行歸併排序
    function mergeSort(nums, left, right, temp = []){
        // 小區間使用插入排序
        if(right - left <= INSERTION_SORT_THRESHOLD){
            insertSort(nums, left, right);
            return;
        }

        // let mid = left + right >>> 1;
        let mid = left + Math.floor((right-left) / 2);

        // 遞歸左邊和右邊, 使左右兩邊分別有序
        mergeSort(nums, left, mid, temp);
        mergeSort(nums, mid+1, right, temp);
        
        // 若是數組的這個子區間自己有序,無需合併
        if (nums[mid] <= nums[mid + 1]) {
            return;
        }

        mergeTwoSortedArray(nums, left, mid, right, temp);
    }

    // 對數組 nums 的子區間 [left, right] 使用插入排序
    function insertSort(nums, left, right){
        for(let i=left+1; i<=right; i++){
            const currentVal = nums[i];
            let j = i;
            while(j>left && nums[j-1] > currentVal ){
                nums[j] = nums[j-1];
                j--;
            }
            nums[j] = currentVal;
        }
        return nums;
    }

    // 合併兩個有序數組:先把值複製到臨時數組,再合併回去
    function mergeTwoSortedArray(nums, left, mid, right, temp){
        temp = [...nums];

        let i = left;
        let j = mid+1;
        for(let k=left; k<=right; k++){
            if(i === mid+1){ //i走到了中間,說明左邊已經放完了,以後只需循環把右邊放進去
                nums[k] = temp[j];
                j++;
            }else if(j === right+1){ //j走到了最後,說明右邊已經放完了,以後只需循環把左邊放進去
                nums[k] = temp[i];
                i++;
            }else if(temp[i] <= temp[j]){ // 比較i,j位置的元素,誰小放誰進去,並讓其指針右移
               // 注意寫成 < 就丟失了穩定性(相同元素原來靠前的排序之後依然靠前)
                nums[k] = temp[i];
                i++;
            }else{
                nums[k] = temp[j];
                j++;
            }
        }
    }  
};
複製代碼

分析:

  • 時間複雜度:O(nlogn)
  • 空間複雜度:O(n),不是原地排序,由於合併的時候須要額外的空間,輔助數組與輸入數組規模至關。
  • 穩定性:穩定。

快速排序(重點)

  • 思路:快速排序每一次經過分區排定一個元素(基準值),這個元素呆在了它最終應該呆的位置,而後遞歸地去排它左邊的部分和右邊的部分,依次進行下去,直到數組有序;
  • 算法思想:分而治之(分治思想),與「歸併排序」不一樣,「快速排序」在「分」這件事情上不像「歸併排序」無腦地一分爲二,而是採用了 partition(分區) 的方法,所以就沒有「合」的過程。能夠發現,歸併排序的處理過程是由下到上的,先處理⼦問題,而後再合併。⽽快排正好相反,它的處理過程是由上到 下的,先分區,而後再處理⼦問題。

若是咱們不考慮空間消耗的話,partition() 分區函數能夠寫得⾮常簡單。申請兩個臨時數組 XY ,遍歷給定數組 nums ,將⼩於 pivot 的元素都拷⻉到臨時數組 X ,將⼤於 pivot 的元素都拷⻉到臨時數組 Y,最後再將數組 X 和數組 Y 中數據順序拷⻉到 nums 中。具體寫法能夠參考:阮一峯老師的快排實現

可是,若是按照這種思路實現的話,partition() 函數就須要不少額外的內存空間,因此快排就不是原地排序算法了。若是 咱們但願快排是原地排序算法,那它的空間複雜度得是 O(1),那 partition() 分區函數就不能佔⽤太多額外的內存空間,咱們就須要在 nums 數組的原地完成分區操做。

使用 單指針 & 交換 完成分區操做的快排:

var sortArray = function(nums) {

    quickSort(nums, 0, nums.length-1)
    return nums;

    function quickSort(nums, left, right){
        if(left>=right) return;

        // 將nums分區,使得nums[pIndex]左邊的都小於nums[pIndex], 右邊的都大於nums[pIndex]
        let pIndex = partition(nums, left, right);

        // 對基準值左右兩邊遞歸的分區(排序)
        quickSort(nums, left, pIndex-1);
        quickSort(nums, pIndex+1, right);

    }

    // 分區函數
    function partition(nums, left, right){
        // 取中間的值做爲基準值
        let mid = left + Math.floor((right-left) / 2);
        swap(nums, left, mid); // 把基準值交換到第一項

        // 基準值
        let pivot = nums[left];
        let lt = left; // 開拓小於基準值的區間的指針
        // 循環不變量:
        // all in [left + 1, lt] < pivot
        // all in [lt + 1, i) >= pivot
        for(let i=left+1; i<=right; i++){
            if(nums[i]<pivot){
                lt++;
                swap(nums, i, lt) 
            }
        }
        swap(nums, left, lt); //將基準值換到中間
        return lt;
    }

    function swap(nums, index1, index2){
        const temp = nums[index2];
        nums[index2] = nums[index1];
        nums[index1] = temp;
    }
};
複製代碼

注意事項:

  • 針對特殊測試用例:順序數組或者逆序數組。必定要隨機化選擇切分元素(pivot),不然在輸入數組是有序數組或者是逆序數組的時候,若是 pivot 選的是 left 或者 right ,快速排序會變得很是慢(等同於冒泡排序或者選擇排序), 時間複雜度退化到O(n2)
  • 針對特殊測試用例:有不少重複元素的輸入數組,有 3 種版本的解法:
    • 版本 1:基本解法,就是上述單指針解法,把等於切分元素的全部元素分到了數組的同一側,可能會形成遞歸樹傾斜;
    • 版本 2:雙指針解法:把等於切分元素的全部元素等機率地分到了數組的兩側,避免了遞歸樹傾斜,遞歸樹相對平衡;
    • 版本 3: 三指針解法:把等於切分元素的全部元素擠到了數組的中間,在有不少元素和切分元素相等的狀況下,遞歸區間大大減小。

之因此解法有這些優化,原由都是來自「遞歸樹」的高度。關於「樹」的算法的優化,絕大部分都是在和樹的「高度」較勁。相似的經過減小樹高度、使得樹更平衡的數據結構還有「二叉搜索樹」優化成「AVL 樹」或者「紅黑樹」、「並查集」的「按秩合併」與「路徑壓縮」。

分析:

  • 時間複雜度:O(nlogn)
  • 空間複雜度:O(1)
  • 穩定性:不穩定。由於交換時可能會打亂相等元素的原有順序。

JavaScript數組原生sort排序

問題:JavaScript 數組的原生 sort 排序穩定嗎?

不一樣瀏覽器的js引擎對sort的實現方式不同,這裏以 chrome V8 來講,

V8 引擎的 sort 實現:對於長度 <= 10 的數組使用 插入排序,比 10 大的數組則使用 原地快速排序。因此當數組長度小於 10 時是穩定的,而當數組長度大於 10 時,sort 是不穩定的。

V8 源碼 (710行開始)

相關文章
相關標籤/搜索