最簡單的氣泡排序還能怎麼優化?

mynull發表於2019-04-09

摘要: 氣泡排序應該是我們大部分人學到的第一個排序演算法, 它思想簡單, 是入門排序演算法的好選擇. 然而由於它的時間複雜度為O(n^2), 所以在學習它的時候以外我們比較少的想到它, 通常提到更多的還是快速排序等時間複雜度更低的排序演算法. 然而, 在對經典的氣泡排序進行改善之後, 在一定的條件之下, 仍然有它的用武之地.

本文首先介紹了 3 種對經典氣泡排序的改進思想, 然後將這 3 種思想結合起來, 實現綜合了各自優點的方法.

氣泡排序的經典實現

不再用很多篇幅來討論氣泡排序的思想, 簡而言之它是通過兩兩比較並交換而將最值放置到陣列的最後位置. 具體實現可以用雙層迴圈, 外層用來控制內層迴圈中最值上浮的位置, 內層用來進行兩兩比較和交換位置.

以將陣列從小到大排序為例, 下面的部分都預設如此. 氣泡排序的經典實現如下:

function bubbleSort(array){
    // 外層迴圈使用 end 來控制內層迴圈中極值最終上浮到的位置
    for(let end = array.length - 1; end > 0; end--){
        // 內層迴圈用來兩兩比較並交換
        for(let i = 0; i < end; i++){
            if(array[i] > array[i + 1]){
                swap(array, i, i+1);
            }
        }
    }
}
複製程式碼

上面程式碼中用到函式 swap() 來交換陣列兩個位置的元素, 在下面的程式碼中都會用到這個函式, 具體如下:

function swap(arr, i, j){
    // [arr[i],arr[i+1]] = [arr[i+1],arr[i]]; // ES6
    
    const temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}
複製程式碼

改進 一: 處理在排序過程中陣列整體已經有序的情況

若陣列本來就是有序的或者在排序的過程中已經有序, 則沒有必要繼續下面的比較, 可以直接返回這個陣列. 但是氣泡排序的經典實現仍然會繼續挨個訪問每個元素並且比較大小. 雖然這個時候只有比較操作而沒有交換操作, 但這些比較操作仍然是沒有必要的.

陣列已經有序的標誌是在一趟內層迴圈中沒有發生元素的位置交換(swap)操作, 也就是說從開頭到結尾的每個元素都小於它之後的元素.

利用上面的原理, 可以對經典實現進行改進: 設定一個變數用來記錄在一輪內層迴圈中是否發生過元素的交換操作, 並在每一輪內層迴圈結束後判斷是否發生了元素交換. 若沒有發生元素交換, 則說明陣列已有序, 程式返回; 否則不做任何操作, 開始下一輪迴圈:

function bubbleSortOpt1(array){
    
    for(let end = array.length - 1; end > 0; end--){

        let isSorted = true; // <== 設定標誌變數 isSorted 初始值為 true
        for(let i = 0; i < end; i++){
            if(array[i] > array[i + 1]){
                swap(array, i, i+1);

                isSorted = false;  // <== 發生了交換操作, 說明再這一輪中陣列仍然無序, 將變數 isSorted 設定為 false
            }
        }

        // 在一輪內層迴圈後判斷 是否有序, 若有序則直接 停止程式; 否則開始下一輪迴圈
        if(isSorted){  
            return ;  // <== 陣列已經有序, 停止函式的執行
        }
    }
}
複製程式碼

改進思想 二: 陣列區域性有序

若陣列是區域性有序的, 例如從某個位置開始之後的陣列已經有序, 則沒有必要對這一部分陣列進行比較了.

此時的改進方法是: 在遍歷過程中可以記下最後一次發生交換事件的位置, 下次的內層迴圈就到這個位置終止, 可以節約多餘的比較操作.

使用一個變數來儲存最後一個發生了交換操作的位置, 並設定為下一輪內層迴圈的終止位置:

function bubbleSortOpt2(array){
    let endPos = array.length - 1; // 記錄這一輪迴圈最後一次發生交換操作的位置

    while(endPos > 0){
        let thisTurnEndPos = endPos; // <== 設定這一輪迴圈結束的位置

        for(let i = 0; i < thisTurnEndPos; i++){
            if(array[i] > array[i+1]){
                swap(array, i, i+1);

                endPos = i; // <== 設定(更新)最後一次發生了交換操作的位置
            }
        }
    }
}
複製程式碼

改進思想 三: 同時將最大最小值歸位

在經典實現中, 每次將最大的值調整到當前陣列的最後, 而沒有對最小的值進行操作. 其實在同一輪外層迴圈中, 可以在把最大值調整到陣列最後面的同時和把最小值調整到最前面, 只要在內層迴圈中在從前到後安排最大值的同時, 也從後向前安排這些最小值的位置就可以了, 這種思想稱為雙向氣泡排序.

說起來比較抽象, 看程式碼就比較容易明白了:

// 雙向氣泡排序, 不僅把最大的放到最後, 同時把最小的放到最前
function bubbleSortOpt3(array){
    // <== 設定每一輪迴圈的開始與結束位置
    let start = 0, 
        end = array.length - 1;

    while(start < end){
        for(let i = start; i < end; i++){ // 從start位置end位置過一遍安排最大值的位置
            if(array[i] > array[i+1]){
                swap(array, i, i+1);
            }
        }
        end --; // <== 由於當前最大的數已經放到了 end 位置, 故 end 位置向前移動

        for(let i = end; i > start; i--){ // 從end向start位置過一遍, 安排最小值的位置
            if(array[i] < array[i-1]){
                swap(array, i, i-1);
            }
        }
        start ++; // <== 由於當前最小的數已經放到了 start 位置, 故 start 位置向後移動
    } 
}
複製程式碼

然而這種方法也有個缺點, 即每次向前向後移動一個位置, 即end--start++. 無法處理前面部分所說的兩種情況, 所以可以將這三種方法結合起來發揮各自的優勢.

三種思想的結合

以上三種思想分別處理在排序過程中陣列整體已經有序、陣列區域性有序、同時將最大最小值放置在合適位置的情況. 那麼將以上三者的優點結合起來可以達到更好的效果.

循序漸進, 先說說其中兩種思想的結合

思想1、2的結合

將思想1和2結合起來, 處理陣列區域性有序和排序過程中整體有序的情況:

function bubbleSortOpt1and2(array){
    let endPos = array.length - 1; // 記錄下一輪迴圈結束的位置, 也就是上一輪最後交換的位置

    while(endPos > 0){
        let isSorted = true; // 設定陣列整體有序標誌變數
        let thisTurnEndPos = endPos; // 記錄這一輪迴圈結束的位置

        for(let i = 0; i < thisTurnEndPos; i++){
            if(array[i] > array[i+1]){
                swap(array, i, i+1);

                endPos = i; // 這個位置發生了交換, 將這個位置記錄下來
                isSorted = false;  // 設定本輪為無序
            }
        }

        if(isSorted){ // 判斷陣列是否已經整體有序
            console.log(endPos);
            return;
        }
    }
}
複製程式碼

思想2、3的結合

將思想 2和3 結合起來, 從雙向同時處理最大最小值, 而且處理陣列區域性有序的情況

// 結合第2、3種改進方式的思想, 記錄雙向排序中每個方向的最後交換位置, 並更新下一輪迴圈的結束位置
function bubbleSortOpt2and3(array){
    let start = 0, startPos = start,
        end = array.length - 1, endPos = end;

    while(start < end){
        
        // 從前向後過一遍
        for(let i = start; i < end; i++){ 
            if(array[i] > array[i+1]){
                swap(array, i, i+1);
                endPos = i; // 記錄這個交換位置
            }
        }
        end = endPos;  // 設定下一輪的遍歷終點

        // 從後向前過一遍
        for(let i = end; i > start; i--){ 
            if(array[i] < array[i-1]){
                swap(array, i, i-1);
                startPos = i; // 記錄這個交換位置
            }
        }
        start = startPos; // 設定下一輪的遍歷終點

    }
}
複製程式碼

同時使用以上三種思想

在有了兩兩結合的基礎後, 不難寫出將這三種思想結合的程式碼:

function bubbleSortOptTriple(array){
    let start = 0, startPos = start,
        end = array.length - 1, endPos = end;

    while(start < end){
        let isSorted = true; // 設定有序無序的標誌變數
        // 從前向後過一遍
        for(let i = start; i < end; i++){ 
            if(array[i] > array[i+1]){
                swap(array, i, i+1);

                endPos = i; // 記錄這個交換位置
                isSorted = false; // 設定無序標誌
            }
        }

        if(isSorted){
            return;
        }

        end = endPos;  // 設定下一輪的遍歷終點
        

        // 從後向前過一遍
        for(let i = end; i > start; i--){ 
            if(array[i] < array[i-1]){
                swap(array, i, i-1);

                startPos = i; // 記錄這個交換位置
                isSorted = false; // 設定無序標誌
            }
        }

        if(isSorted){
            return;
        }

        start = startPos; // 設定下一輪的遍歷終點
    }
}
複製程式碼

這就是終點了嗎?

其實上面的程式還是可以改進的: 我們沒有必要另外設定一個變數來記錄在一趟排序中陣列是否已經有序,而是可以比較一輪迴圈結束後的 endPos 是否等於end, 如果等於, 則說明本輪沒有對 endPos 進行更新, 也就是沒有發生交換操作, 進一步說明陣列已經有序了. 當然, 對於startPosstart同理.

相關文章