11.經典O(n²)比較型排序演算法

借來方向發表於2020-06-04

關注公號「碼哥位元組」修煉技術內功心法,完整程式碼可跳轉 GitHub:https://github.com/UniqueDong/algorithms.git

摘要:排序演算法提多了,很多甚至連名字你都沒聽過,比如猴子排序、睡眠排序等。最常用的:氣泡排序、選擇排序、插入排序、歸併排序、快速排序、計數排序、基數排序、桶排序。根據時間複雜度,我們分三類來學習,今天要講的就是 冒泡、插入、選擇 排序演算法。

排序演算法 時間複雜度 是否基於比較
冒泡、插入、選擇 O(n²)
快排、歸併 O(nlogn)
桶、計數、基數 O(n)

十種常見的的排序演算法可以分兩大類:

  1. 比較類排序:通過比較來決定元素的相對次序,由於其時間複雜度無法突破 O(nlogn),因此也叫做非線性時間排序。
  2. 非比較類排序:不是通過比較元素來決定元素的相對次序,可以突破比較排序的時間下限,線性時間執行,也叫做線性時間非比較類排序。

經典演算法

學會評估一個排序演算法

學習演算法,除了知道原理以及程式碼實現以外,還有更重要的是學會如何評價、分析一個排序演算法的 執行效率、記憶體損耗、穩定性。

執行效率

一般通過如下方面衡量:

1.最好、最壞、平均時間複雜度

為何要區分這三種時間複雜度?第一,通過複雜度可以大致判斷演算法的執行次數。第二,對於要排序的資料有的無序、有的接近有序,有序度不同不同對於執行時間是不一樣的,所以我們要只掉不同資料場景下演算法的效能。

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

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

3.比較次數移動(交換)資料次數
基於比較排序的演算法執行過程都會涉及兩個操作、一個是比較,另一個就是元素交換或者資料移動。所以我們也要把資料交換或者移動次數考慮進來。

記憶體消耗

演算法的記憶體消耗通過空間複雜度來衡量,不過在這裡針對排序演算法的記憶體算好還有一個新概念,原地排序就是特指空間複雜度為 O(1) 的演算法,這次所講的演算法都是原地排序演算法。

演算法的穩定性

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

比如我們有一組資料 2,9,3,4,8,3,按照大小排序之後就是 2,3,3,4,8,9。

這組資料裡有兩個 3。經過某種排序演算法排序之後,如果兩個 3 的前後順序沒有改變,那我們就把這種排序演算法叫作穩定的排序演算法;如果前後順序發生變化,那對應的排序演算法就叫作不穩定的排序演算法

氣泡排序

氣泡排序只會操作相鄰的兩個資料。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個資料的排序工作。

這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端。

作為最簡單的排序演算法之一,氣泡排序給我的感覺就像 Abandon 在單詞書裡出現的感覺一樣,每次都在第一頁第一位,所以最熟悉。氣泡排序還有一種優化演算法,就是立一個 flag,當在一趟序列遍歷中元素沒有發生交換,則證明該序列已經有序。但這種改進對於提升效能來說並沒有什麼太大作用。

演算法步驟

  1. 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。

  2. 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最後一對。這步做完後,最後的元素會是最大的數。

  3. 針對所有的元素重複以上的步驟,除了最後一個。

  4. 持續每次對越來越少的元素重複上面的步驟,直到沒有任何一對數字需要比較。

    氣泡排序

/**
 * 氣泡排序: 時間複雜度 O(n²),最壞時間複雜度 O(n²),最好時間複雜度 O(n),平均時間複雜度 O(n²)
 * 空間複雜度 O(1),穩定排序演算法
 */
public class BubbleSort implements ComparisonSort {
    @Override
    public int[] sort(int[] sourceArray) {
        // 複製陣列,不改變引數內容
        int[] result = Arrays.copyOf(sourceArray, sourceArray.length);
        if (sourceArray.length <= 1) {
            return result;
        }
        int length = result.length;
        for (int i = 0; i < length; i++) {
            // 設定標記,當沒有資料需要交換的時候則說明已經有序,提前退出外部迴圈
            boolean hasChange = false;
            for (int j = 0; j < (length - 1) - i ; j++) {
                if (result[j] > result[j + 1]) {
                    // 資料交換
                    int temp = result[j];
                    result[j] = result[j + 1];
                    result[j + 1] = temp;
                    hasChange = true;
                }
            }
            if (!hasChange) {
                // 沒有資料交換,已經有序,提前退出
                break;
            }
        }
        return result;
    }
}

那麼問題來了,我們來分析下這個演算法的效率如何,教大家學會如何評估一個演算法:

1.冒泡是原地排序演算法麼?

因為冒泡的過程只有相鄰資料的交換操作,屬於常量級別的臨時空間,所以空間複雜度是 O(1),屬於原地排序演算法。

2.是穩定排序演算法?

只有交換才改變兩個元素的前後順序,當相鄰資料相等,不做交換,所以相同大小的資料在排序前後都不會改變順序,屬於穩定排序演算法。

3.時間複雜度

最好時間複雜度:當資料已經有序,只需要一次冒泡,所以是 O(1)。(ps:都已經是正序了,還要你冒泡何用)

最壞時間複雜度: 資料是倒序的,我們需要進行 n 次冒泡操作,所以最壞情況時間複雜度為 O(n2)。(ps:寫一個 for 迴圈反序輸出資料不就行了,幹嘛要用你氣泡排序呢,我是閒的嗎)

插入排序

我們先來看一個問題。一個有序的陣列,我們往裡面新增一個新的資料後,如何繼續保持資料有序呢?很簡單,我們只要遍歷陣列,找到資料應該插入的位置將其插入即可。

插入排序是一種最簡單直觀的排序演算法,它的工作原理是通過構建有序序列,對於未排序資料,在已排序序列中從後向前掃描,找到相應位置並插入。

插入排序也包含兩種操作,一種是元素的比較,一種是元素的移動。當我們需要將一個資料 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。

插入排序

程式碼如下所示:

/**
 * 插入排序:時間複雜度 O(n²),平均時間複雜度 O(n²),最好時間複雜度 O(n),
 * 最壞時間複雜度 O(n²),空間時間複雜度 O(1).穩定排序演算法。
 */
public class InsertionSort implements ComparisonSort {

    @Override
    public int[] sort(int[] sourceArray) {
        int[] result = Arrays.copyOf(sourceArray, sourceArray.length);
        if (sourceArray.length <= 1) {
            return result;
        }
        // 從下標為 1 開始比較選擇合適位置插入,因為下標 0 只有一個元素,預設是有序
        int length = result.length;
        for (int i = 1; i < length; i++) {
            // 待插入資料
            int insertValue = result[i];
            // 從已排序的序列最右邊元素開始比較,找到比待插入樹更小的數位置
            int j = i - 1;
            for (; j >= 0; j--){
                if (result[j] > insertValue) {
                    // 向後移動資料,騰出待插入位置
                    result[j + 1] = result[j];
                } else {
                    // 找到待插入位置,跳出迴圈
                    break;
                }
            }
            // 插入資料,因為前面多執行了 j--,
            result[j + 1] = insertValue;
        }
        return result;
    }
}

依然繼續分析該演算法的效能。

1.是否是原地排序演算法

從實現過程就知道,插入排序不需要額外的儲存空間,所以空間複雜度是 O(1),屬於原地排序。

2.是否是穩定排序演算法

對於值相等的元素,我們選擇將資料插入到前面元素的侯娜,這樣就保證原有的前後順序不變,屬於穩定排序演算法。

3.時間複雜度

如果要排序的資料已經是有序的,我們並不需要搬移任何資料。如果我們從尾到頭在有序資料組裡面查詢插入位置,每次只需要比較一個資料就能確定插入的位置。所以這種情況下,最好是時間複雜度為 O(n)。注意,這裡是從尾到頭遍歷已經有序的資料

如果陣列是倒序的,每次插入都相當於在陣列的第一個位置插入新的資料,所以需要移動大量的資料,所以最壞情況時間複雜度為 O(n²)。

還記得我們在陣列中插入一個資料的平均時間複雜度是多少嗎?沒錯,是 O(n)。所以,對於插入排序來說,每次插入操作都相當於在陣列中插入一個資料,迴圈執行 n 次插入操作,所以平均時間複雜度為 O(n²)。

選擇排序

選擇排序是一種簡單直觀的排序演算法,無論什麼資料進去都是 O(n²) 的時間複雜度。所以用到它的時候,資料規模越小越好。

選擇排序演算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。

演算法步驟

  1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
  2. 再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。
  3. 重複第二步,直到所有元素均排序完畢。

選擇排序

程式碼如下:

public class SelectionSort implements ComparisonSort {

    @Override
    public int[] sort(int[] sourceArray) {
        int length = sourceArray.length;
        int[] result = Arrays.copyOf(sourceArray, length);
        if (length <= 0) {
            return result;
        }
        // 一共需要 length - 1 輪比較
        for (int i = 0; i < length - 1; i++) {
            // 每輪需要比較的次數 length - i,找出最小元素下標
            int minIndex = i;
            for (int j = i + 1; j < length; j++) {
                if (result[j] < result[minIndex]) {
                    // 查出每次最小遠元素下標
                    minIndex = j;
                }
            }
            // 將當前 i 位置的資料與最小值交換資料
            if (i != minIndex) {
                int temp = result[i];
                result[i] = result[minIndex];
                result[minIndex] = temp;
            }
        }
        return result;
    }
}

首先,選擇排序空間複雜度為 O(1),是一種原地排序演算法。選擇排序的最好情況時間複雜度、最壞情況和平均情況時間複雜度都為 O(n²)。

那選擇排序是穩定的排序演算法嗎?

答案是否定的,選擇排序是一種不穩定的排序演算法。從我前面畫的那張圖中,你可以看出來,選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性

比如 5,8,5,2,9 這樣一組資料,使用選擇排序演算法來排序的話,第一次找到最小元素 2,與第一個 5 交換位置,那第一個 5 和中間的 5 順序就變了,所以就不穩定了。正是因此,相對於氣泡排序和插入排序,選擇排序就稍微遜色了。

總結

這三種時間複雜度為 O(n²) 的排序演算法中,氣泡排序、選擇排序,可能就純粹停留在理論的層面了,學習的目的也只是為了開拓思維,實際開發中應用並不多,但是插入排序還是挺有用的。後面講排序優化的時候,我會講到,有些程式語言中的排序函式的實現原理會用到插入排序演算法。(希爾排序就是插入排序的一種優化)

今天講的這三種排序演算法,實現程式碼都非常簡單,對於小規模資料的排序,用起來非常高效。但是在大規模資料排序的時候,這個時間複雜度還是稍微有點高,所以我們更傾向於用下一節要講的時間複雜度為 O(nlogn) 的排序演算法。

演算法執行效率

課後思考

最後給大家一個問題,答案可在後臺傳送 「插入」獲取答案,也可以加群跟我們一起討論。

問題是:插入排序和氣泡排序時間複雜度相同,都是 O(n²),實際開發中更傾向於插入排序而不是氣泡排序

碼哥位元組

相關文章