備戰秋招之八大排序——O(n^2)級排序算法

1、冒泡排序

冒泡排序是入門級的算法,但也有一些有趣的玩法。一般來講,冒泡排序有三種寫法:java

  1. 一邊比較一邊向後兩兩交換,將最大值 / 最小值冒泡到最後一位;
  2. 通過優化的寫法:使用一個變量記錄當前輪次的比較是否發生過交換,若是沒有發生交換表示已經有序,再也不繼續排序;
  3. 進一步優化的寫法:除了使用變量記錄當前輪次是否發生交換外,再使用一個變量記錄上次發生交換的位置,下一輪排序時到達上次交換的位置就中止比較

1.一、第一種寫法

public static void bubbleSort(int[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 若是左邊的數大於右邊的數,則交換,保證右邊的數字最大
                swap(arr, j, j + 1);
            }
        }
    }
}
// 交換元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

最外層的 for 循環每通過一輪,剩餘數字中的最大值就會被移動到當前輪次的最後一位,中途也會有一些相鄰的數字通過交換變得有序。總共比較次數是 (n-1)+(n-2)+(n-3)+…+1(n−1)+(n−2)+(n−3)+…+1。
這種寫法至關於相鄰的數字兩兩比較,而且規定:「誰大誰站右邊」。通過 n-1n−1 輪,數字就從小到大排序完成了。整個過程看起來就像一個個氣泡不斷上浮,這也是「冒泡排序法」名字的由來。算法

1.二、第二種寫法

在第一種的基礎上改良而來數組

public static void bubbleSort(int[] arr) {
    // 初始時 swapped 爲 true,不然排序過程沒法啓動
    boolean swapped = true;
    for (int i = 0; i < arr.length - 1; i++) {
        // 若是沒有發生過交換,說明剩餘部分已經有序,排序完成
        if (!swapped) break;
        // 設置 swapped 爲 false,若是發生交換,則將其置爲 true
        swapped = false;
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 若是左邊的數大於右邊的數,則交換,保證右邊的數字最大
                swap(arr, j, j + 1);
                // 表示發生了交換
                swapped = true;
            }
        }
    }
}
// 交換元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

最外層的 for 循環每通過一輪,剩餘數字中的最大值仍然是被移動到當前輪次的最後一位。這種寫法相對於第一種寫法的優勢是:若是一輪比較中沒有發生過交換,則當即中止排序,由於此時剩餘數字必定已經有序了。app

1.三、第三種寫法

比較少見,在第二種的基礎上進一步優化函數

public static void bubbleSort(int[] arr) {
    boolean swapped = true;
    // 最後一個沒有通過排序的元素的下標
    int indexOfLastUnsortedElement = arr.length - 1;
    // 上次發生交換的位置
    int swappedIndex = -1;
    while (swapped) {
        swapped = false;
        for (int i = 0; i < indexOfLastUnsortedElement; i++) {
            if (arr[i] > arr[i + 1]) {
                // 若是左邊的數大於右邊的數,則交換,保證右邊的數字最大
                swap(arr, i, i + 1);
                // 表示發生了交換
                swapped = true;
                // 更新交換的位置
                swappedIndex = i;
            }
        }
        // 最後一個沒有通過排序的元素的下標就是最後一次發生交換的位置
        indexOfLastUnsortedElement = swappedIndex;
    }
}
// 交換元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

通過再一次的優化,代碼看起來就稍微有點複雜了。最外層的 while 循環每通過一輪,剩餘數字中的最大值仍然是被移動到當前輪次的最後一位。
在下一輪比較時,只需比較到上一輪比較中,最後一次發生交換的位置便可。由於後面的全部元素都沒有發生過交換,必然已經有序了。
當一輪比較中從頭至尾都沒有發生過交換,則表示整個列表已經有序,排序完成。性能

1.四、時間複雜度&空間複雜度

空間複雜度爲 O(1),時間複雜度爲 O(n^2),
第二種、第三種冒泡排序因爲通過優化,最好的狀況下只須要 O(n)的時間複雜度。
最好狀況:在數組已經有序的狀況下,只需遍歷一次,因爲沒有發生交換,排序結束。
最差狀況:數組順序爲逆序,每次比較都會發生交換。
但優化後的冒泡排序平均時間複雜度仍然是 O(n^2),因此這些優化對算法的性能並無質的提高。優化

2、選擇排序

2.一、一元選擇排序

選擇排序的思想是:雙重循環遍歷數組,每通過一輪比較,找到最小元素的下標,將其交換至首位。code

public static void selectionSort(int[] arr) {
    int minIndex;
    for (int i = 0; i < arr.length - 1; i++) {
        minIndex = i;
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[minIndex] > arr[j]) {
                // 記錄最小值的下標
                minIndex = j;
            }
        }
        // 將最小元素交換至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}

冒泡排序和選擇排序有什麼異同?
相同點:對象

  • 都是兩層循環,時間複雜度都爲 O(n^2);
  • 都只使用有限個變量,空間複雜度 O(1)。

不一樣點:排序

  • 冒泡排序在比較過程當中就不斷交換;而選擇排序增長了一個變量保存最小值 / 最大值的下標,遍歷完成後才交換,減小了交換次數。
    事實上,冒泡排序和選擇排序還有一個很是重要的不一樣點,那就是:
    冒泡排序法是穩定的,選擇排序法是不穩定的。
    想要理解這點不一樣,咱們先要知道什麼是排序算法的穩定性。

2.二、排序算法的穩定性

假定在待排序的記錄序列中,存在多個具備相同的關鍵字的記錄,若通過排序,這些記錄的相對次序保持不變,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 以前,而在排序後的序列中,r[i] 仍在 r[j] 以前,則稱這種排序算法是穩定的;不然稱爲不穩定的。
因此,
冒泡排序中,只有左邊的數字大於右邊的數字時纔會發生交換,相等的數字之間不會發生交換,因此它是穩定的。
而選擇排序中,最小值和首位交換的過程可能會破壞穩定性。好比數列:[2, 2, 1],在選擇排序中第一次進行交換時,原數列中的兩個 2 的相對順序就被改變了,所以,咱們說選擇排序是不穩定的
排序算法的穩定性有什麼意義呢,其實它只在一種狀況下有意義:當要排序的內容是一個對象的多個屬性,且其本來的順序存在乎義時,若是咱們須要在二次排序後保持原有排序的意義,就須要使用到穩定性的算法。
舉個例子,若是咱們要對一組商品排序,商品存在兩個屬性:價格和銷量。當咱們按照價格從高到低排序後,要再按照銷量對其排序,這時,若是要保證銷量相同的商品仍保持價格從高到低的順序,就必須使用穩定性算法。
固然,算法的穩定性與具體的實現有關。在修改比較的條件後,穩定性排序算法可能會變成不穩定的。如冒泡算法中,若是將「左邊的數大於右邊的數,則交換」這個條件修改成「左邊的數大於或等於右邊的數,則交換」,冒泡算法就變得不穩定了。
一樣地,不穩定排序算法也能夠通過修改,達到穩定的效果。思考一下,選擇排序算法如何實現穩定排序呢?
實現的方式有不少種,這裏給出一種最簡單的思路:新開一個數組,將每輪找出的最小值依次添加到新數組中,選擇排序算法就變成穩定的了。
但若是將尋找最小值的比較條件由arr[minIndex] > arr[j]修改成arr[minIndex] >= arr[j],即便新開一個數組,選擇排序算法依舊是不穩定的。因此分析算法的穩定性時,須要結合具體的實現邏輯才能得出結論,咱們一般所說的算法穩定性是基於通常實現而言的。

2.三、二元選擇排序

既然每輪遍歷時找出了最小值,何不把最大值也順便找出來呢?這就是二元選擇排序的思想。
使用二元選擇排序,每輪選擇時記錄最小值和最大值,能夠把數組須要遍歷的範圍縮小一倍。

public static void selectionSort2(int[] arr) {
    int minIndex, maxIndex;
    // i 只須要遍歷一半
    for (int i = 0; i < arr.length / 2; i++) {
        minIndex = i;
        maxIndex = i;
        for (int j = i + 1; j < arr.length - i; j++) {
            if (arr[minIndex] > arr[j]) {
                // 記錄最小值的下標
                minIndex = j;
            }
            if (arr[maxIndex] < arr[j]) {
                // 記錄最大值的下標
                maxIndex = j;
            }
        }
        // 若是 minIndex 和 maxIndex 都相等,那麼他們一定都等於 i,且後面的全部數字都與 arr[i] 相等,此時已經排序完成
        if (minIndex == maxIndex) break;
        // 將最小元素交換至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
        // 若是最大值的下標恰好是 i,因爲 arr[i] 和 arr[minIndex] 已經交換了,因此這裏要更新 maxIndex 的值。
        if (maxIndex == i) maxIndex = minIndex;
        // 將最大元素交換至末尾
        int lastIndex = arr.length - 1 - i;
        temp = arr[lastIndex];
        arr[lastIndex] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

咱們使用 minIndex 記錄最小值的下標,maxIndex 記錄最大值的下標。每次遍歷後,將最小值交換到首位,最大值交換到末尾,就完成了排序。
因爲每一輪遍歷能夠排好兩個數字,因此最外層的遍歷只需遍歷一半便可。
二元選擇排序中有一句很重要的代碼,它位於交換最小值和交換最大值的代碼中間:
if (maxIndex == i) maxIndex = minIndex;

這行代碼的做用處理了一種特殊狀況:若是最大值的下標等於 i,也就是說 arr[i] 就是最大值,因爲 arr[i] 是當前遍歷輪次的首位,它已經和 arr[minIndex] 交換了,因此最大值的下標須要跟蹤到 arr[i] 最新的下標 minIndex。

2.四、二元選擇排序的效率

在二元選擇排序算法中,數組須要遍歷的範圍縮小了一倍。那麼這樣可使選擇排序的效率提高一倍嗎?

從代碼能夠看出,雖然二元選擇排序最外層的遍歷範圍縮小了,但 for 循環內作的事情翻了一倍。也就是說二元選擇排序沒法將選擇排序的效率提高一倍。但實測會發現二元選擇排序的速度確實比選擇排序的速度快一點點,它的速度提高主要是由於兩點:

  • 在選擇排序的外層 for 循環中,i 須要加到 arr.length - 1 ,二元選擇排序中 i 只須要加到 arr.length / 2
  • 在選擇排序的內層 for 循環中,j 須要加到 arr.length ,二元選擇排序中 j 只須要加到arr.length - i
    而且,在二元選擇排序中,咱們能夠作一個剪枝優化,當 minIndex == maxIndex 時,說明後續全部的元素都相等,就比如班上最高的學生和最矮的學生同樣高,說明整個班上的人身高都相同了。此時已經排序完成,能夠提早跳出循環。經過這個剪枝優化,對於相同元素較多的數組,二元選擇排序的效率將遠遠超過選擇排序。

2.五、時間複雜度&空間複雜度

前文已經說到,選擇排序使用兩層循環,時間複雜度爲 O(n^2); 只使用有限個變量,空間複雜度 O(1)。二元選擇排序雖然比選擇排序要快,但治標不治本,二元選擇排序中作的優化沒法改變其時間複雜度,二元選擇排序的時間複雜度仍然是 O(n^2);只使用有限個變量,空間複雜度 O(1)。

3、插入排序

插入排序的思想很是簡單,生活中有一個很常見的場景:在打撲克牌時,咱們一邊抓牌一邊給撲克牌排序,每次摸一張牌,就將它插入手上已有的牌中合適的位置,逐漸完成整個排序。
插入排序有兩種寫法:

  • 交換法:在新數字插入過程當中,不斷與前面的數字交換,直到找到本身合適的位置。
  • 移動法:在新數字插入過程當中,與前面的數字不斷比較,前面的數字不斷向後挪出位置,當新數字找到本身的位置後,插入一次便可。

3.一、交換法插入排序

public static void insertSort(int[] arr) {
    // 從第二個數開始,往前插入數字
    for (int i = 1; i < arr.length; i++) {
        // j 記錄當前數字下標
        int j = i;
        // 當前數字比前一個數字小,則將當前數字與前一個數字交換
        while (j >= 1 && arr[j] < arr[j - 1]) {
            swap(arr, j, j - 1);
            // 更新當前數字下標
            j--;
        }
    }
}
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

當數字少於兩個時,不存在排序問題,固然也不須要插入,因此咱們直接從第二個數字開始往前插入。
整個過程就像是已經有一些數字坐成了一排,這時一個新的數字要加入,這個新加入的數字本來坐在這一排數字的最後一位,而後它不斷地與前面的數字比較,若是前面的數字比它大,它就和前面的數字交換位置。

3.二、移動法插入排序

能夠發現,在交換法插入排序中,每次交換數字時,swap 函數都會進行三次賦值操做。但實際上,新插入的這個數字並不必定適合與它交換的數字所在的位置。也就是說,它剛換到新的位置上不久,下一次比較後,若是又須要交換,它立刻又會被換到前一個數字的位置。
由此,咱們能夠想到一種優化方案:讓新插入的數字先進行比較,前面比它大的數字不斷向後移動,直到找到適合這個新數字的位置後,新數字只作一次插入操做便可。
這種方案咱們須要把新插入的數字暫存起來,代碼以下:

public static void insertSort(int[] arr) {
    // 從第二個數開始,往前插入數字
    for (int i = 1; i < arr.length; i++) {
        int currentNumber = arr[i];
        int j = i - 1;
        // 尋找插入位置的過程當中,不斷地將比 currentNumber 大的數字向後挪
        while (j >= 0 && currentNumber < arr[j]) {
            arr[j + 1] = arr[j];
            j--;
        }
        // 兩種狀況會跳出循環:1. 遇到一個小於或等於 currentNumber 的數字,跳出循環,currentNumber 就坐到它後面。
        // 2. 已經走到數列頭部,仍然沒有遇到小於或等於 currentNumber 的數字,也會跳出循環,此時 j 等於 -1,currentNumber 就坐到數列頭部。
        arr[j + 1] = currentNumber;
    }
}

整個過程就像是已經有一些數字坐成了一排,這時一個新的數字要加入,因此這一排數字不斷地向後騰出位置,當新的數字找到本身合適的位置後,就能夠直接坐下了。重複此過程,直到排序結束。
分析可知,插入排序的過程不會破壞原有數組中相同關鍵字的相對次序,因此插入排序是一種穩定的排序算法。

3.三、時間複雜度&空間複雜度

插入排序過程須要兩層循環,時間複雜度爲 O(n^2);只須要常量級的臨時變量,空間複雜度爲 O(1)。

4、小結

本章咱們介紹了三種基礎排序算法:冒泡排序、選擇排序、插入排序。

冒泡排序

冒泡排序有兩種優化方式:

  • 記錄當前輪次是否發生過交換,沒有發生過交換表示數組已經有序;
  • 記錄上次發生交換的位置,下一輪排序時只比較到此位置。

選擇排序

選擇排序能夠演變爲二元選擇排序:

  • 二元選擇排序:一次遍歷選出兩個值——最大值和最小值;
  • 二元選擇排序剪枝優化:當某一輪遍歷出現最大值和最小值相等,表示數組中剩餘元素已經所有相等。

插入排序

插入排序有兩種寫法:

  • 交換法:新數字經過不斷交換找到本身合適的位置;
  • 移動法:舊數字不斷向後移動,直到新數字找到合適的位置。

相同點

  • 時間複雜度都是 O(n^2),空間複雜度都是 O(1)。
  • 都須要採用兩重循環。

不一樣點

  • 選擇排序是不穩定的,冒泡排序、插入排序是穩定的;
  • 在這三個排序算法中,選擇排序交換的次數是最少的;
  • 在數組幾乎有序的狀況下,插入排序的時間複雜度接近線性級別。
相關文章
相關標籤/搜索