1. 程式人生 > 實用技巧 >淺析氣泡排序及插入排序演算法

淺析氣泡排序及插入排序演算法

一、如何分析一個排序演算法

  複雜度分析是整個演算法學習的精髓。

  • 時間複雜度: 一個演算法執行所耗費的時間。

  • 空間複雜度: 執行完一個程式所需記憶體的大小。

  學習排序演算法,我們除了學習它的演算法原理、程式碼實現之外,更重要的是要學會如何評價、分析一個排序演算法。分析一個排序演算法,要從執行效率記憶體消耗穩定性三方面入手。

1、執行效率

1) 最好情況、最壞情況、平均情況時間複雜度

  我們在分析排序演算法的時間複雜度時,要分別給出最好情況、最壞情況、平均情況下的時間複雜度。除此之外,你還要說出最好、最壞時間複雜度對應的要排序的原始資料是什麼樣的。

2)時間複雜度的係數、常數 、低階

  我們知道,時間複雜度反應的是資料規模 n 很大的時候的一個增長趨勢,所以它表示的時候會忽略係數、常數、低階。但是實際的軟體開發中,我們排序的可能是 10 個、100 個、1000 個這樣規模很小的資料,所以,在對同一階時間複雜度的排序演算法效能對比的時候,我們就要把係數、常數、低階也考慮進來。

3)比較次數和交換(或移動)次數

  基於比較的排序演算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動。

  所以,如果我們在分析排序演算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。

2、記憶體消耗

  也就是看空間複雜度。還需要知道如下術語:

  • 內排序:所有排序操作都在記憶體中完成;

  • 外排序:由於資料太大,因此把資料放在磁碟中,而排序通過磁碟和記憶體的資料傳輸才能進行;

  • 原地排序:原地排序演算法,就是特指空間複雜度是 O(1) 的排序演算法。

3、穩定性

  • 穩定:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變。比如:a 原本在 b 前面,而 a = b,排序之後,a 仍然在 b 的前面;

  • 不穩定:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序改變。比如:a 原本在 b 的前面,而 a = b,排序之後, a 在 b 的後面;

二、氣泡排序

1、思想

  • 氣泡排序只會操作相鄰的兩個資料。

  • 每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。

  • 一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個數據的排序工作。

2、特點

  • 優點:排序演算法的基礎,簡單實用易於理解。

  • 缺點:比較次數多,效率較低。

3、實現

// 氣泡排序(未優化)
const bubbleSort = arr => {
    console.time('改進前氣泡排序耗時');
    const length = arr.length;
    if (length <= 1) return;
    // i < length - 1 是因為外層只需要 length-1 次就排好了,第 length 次比較是多餘的。
    for (let i = 0; i < length - 1; i++) {
        // j < length - i - 1 是因為內層的 length-i-1 到 length-1 的位置已經排好了,不需要再比較一次。
        for (let j = 0; j < length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                const temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
    console.log('改進前 arr :', arr);
    console.timeEnd('改進前氣泡排序耗時');
};

  優化:當某次冒泡操作已經沒有資料交換時,說明已經達到完全有序,不用再繼續執行後續的冒泡操作。

// 氣泡排序(已優化)
const bubbleSort2 = arr => {
    console.time('改進後氣泡排序耗時');
    const length = arr.length;
    if (length <= 1) return;
    // i < length - 1 是因為外層只需要 length-1 次就排好了,第 length 次比較是多餘的。
    for (let i = 0; i < length - 1; i++) {
        let hasChange = false; // 提前退出冒泡迴圈的標誌位
        // j < length - i - 1 是因為內層的 length-i-1 到 length-1 的位置已經排好了,不需要再比較一次。
        for (let j = 0; j < length - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                const temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                hasChange = true; // 表示有資料交換
            }
        }

        if (!hasChange) break; // 如果 false 說明所有元素已經到位,沒有資料交換,提前退出
    }
    console.log('改進後 arr :', arr);
    console.timeEnd('改進後氣泡排序耗時');
};

  測試:

4、分析

  • 第一,氣泡排序是原地排序演算法嗎 ?

    冒泡的過程只涉及相鄰資料的交換操作,只需要常量級的臨時空間,所以它的空間複雜度為 O(1),是一個原地排序演算法。

  • 第二,氣泡排序是穩定的排序演算法嗎 ?

    在氣泡排序中,只有交換才可以改變兩個元素的前後順序。為了保證氣泡排序演算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的資料在排序前後不會改變順序。所以氣泡排序是穩定的排序演算法。

  • 第三,氣泡排序的時間複雜度是多少 ?

    最佳情況:T(n) = O(n),當資料已經是正序時。

    最差情況:T(n) = O(n2),當資料是反序時。

    平均情況:T(n) = O(n2)。

三、插入排序

  插入排序又為分為直接插入排序和優化後的拆半插入排序希爾排序,我們通常說的插入排序是指直接插入排序。

(一)直接插入排序

1、思想

  一般人打撲克牌,整理牌的時候,都是按牌的大小(從小到大或者從大到小)整理牌的,那每摸一張新牌,就掃描自己的牌,把新牌插入到相應的位置。

  插入排序的工作原理:通過構建有序序列,對於未排序資料,在已排序序列中從後向前掃描,找到相應位置並插入。

2、步驟

  • 從第一個元素開始,該元素可以認為已經被排序;

  • 取出下一個元素,在已經排序的元素序列中從後向前掃描;

  • 如果該元素(已排序)大於新元素,將該元素移到下一位置;

  • 重複步驟 3,直到找到已排序的元素小於或者等於新元素的位置;

  • 將新元素插入到該位置後;

  • 重複步驟 2 ~ 5。

3、實現

// 插入排序
const insertionSort = array => {
    const len = array.length;
    if (len <= 1) return

    let preIndex, current;
    for (let i = 1; i < len; i++) {
        preIndex = i - 1; //待比較元素的下標
        current = array[i]; //當前元素
        while (preIndex >= 0 && array[preIndex] > current) {
            //前置條件之一: 待比較元素比當前元素大
            array[preIndex + 1] = array[preIndex]; //將待比較元素後移一位
            preIndex--; //遊標前移一位
        }
        if (preIndex + 1 != i) {
            //避免同一個元素賦值給自身
            array[preIndex + 1] = current; //將當前元素插入預留空位
            console.log('array :', array);
        }
    }
    return array;
};

  測試

const array = [5, 4, 3, 2, 1];
console.log("原始 array :", array);
insertionSort(array);
// 原始 array:    [5, 4, 3, 2, 1]
// array:           [4, 5, 3, 2, 1]
// array:           [3, 4, 5, 2, 1]
// array:          [2, 3, 4, 5, 1]
// array:           [1, 2, 3, 4, 5]

4、分析

  • 第一,插入排序是原地排序演算法嗎 ?

    插入排序演算法的執行並不需要額外的儲存空間,所以空間複雜度是 O(1),所以,這是一個原地排序演算法。

  • 第二,插入排序是穩定的排序演算法嗎 ?

    在插入排序中,對於值相同的元素,我們可以選擇將後面出現的元素,插入到前面出現元素的後面,這樣就可以保持原有的前後順序不變,所以插入排序是穩定的排序演算法。

  • 第三,插入排序的時間複雜度是多少 ?

    最佳情況:T(n) = O(n),當資料已經是正序時。

    最差情況:T(n) = O(n2),當資料是反序時。

    平均情況:T(n) = O(n2)。

二)拆半插入

  插入排序也有一種優化演算法,叫做拆半插入

1、思想

  折半插入排序是直接插入排序的升級版,鑑於插入排序第一部分為已排好序的陣列,我們不必按順序依次尋找插入點,只需比較它們的中間值與待插入元素的大小即可。

2、步驟

  • 取 0 ~ i-1 的中間點 ( m = (i-1) >> 1 ),array[i] 與 array[m] 進行比較,若 array[i] < array[m],則說明待插入的元素 array[i] 應該處於陣列的 0 ~ m 索引之間;反之,則說明它應該處於陣列的 m ~ i-1 索引之間。

  • 重複步驟 1,每次縮小一半的查詢範圍,直至找到插入的位置。

  • 將陣列中插入位置之後的元素全部後移一位。

  • 在指定位置插入第 i 個元素。

注:x >> 1 是位運算中的右移運算,表示右移一位,等同於 x 除以 2 再取整,即 x >> 1 == Math.floor(x/2) 。

3、實現

// 折半插入排序
const binaryInsertionSort = array => {
    const len = array.length;
    if (len <= 1) return;

    let current, i, j, low, high, m;
    for (i = 1; i < len; i++) {
        low = 0;
        high = i - 1;
        current = array[i];

        while (low <= high) {
            //步驟 1 & 2 : 折半查詢
            m = (low + high) >> 1; // 注: x>>1 是位運算中的右移運算, 表示右移一位, 等同於 x 除以 2 再取整, 即 x>>1 == Math.floor(x/2) .
            if (array[i] >= array[m]) {
                //值相同時, 切換到高半區,保證穩定性
                low = m + 1; //插入點在高半區
            } else {
                high = m - 1; //插入點在低半區
            }
        }
        for (j = i; j > low; j--) {
            //步驟 3: 插入位置之後的元素全部後移一位
            array[j] = array[j - 1];
            console.log('array2 :', JSON.parse(JSON.stringify(array)));
        }
        array[low] = current; //步驟 4: 插入該元素
    }
    console.log('array2 :', JSON.parse(JSON.stringify(array)));
    return array;
};

  注意:和直接插入排序類似,折半插入排序每次交換的是相鄰的且值為不同的元素,它並不會改變值相同的元素之間的順序,因此它是穩定的。