用 Java 實現的八種常用排序演算法

低吟不作語發表於2020-10-13

八種排序演算法可以按照如圖分類


交換排序

所謂交換,就是序列中任意兩個元素進行比較,根據比較結果來交換各自在序列中的位置,以此達到排序的目的。

1. 氣泡排序

氣泡排序是一種簡單的交換排序演算法,以升序排序為例,其核心思想是:

  1. 從第一個元素開始,比較相鄰的兩個元素。如果第一個比第二個大,則進行交換。
  2. 輪到下一組相鄰元素,執行同樣的比較操作,再找下一組,直到沒有相鄰元素可比較為止,此時最後的元素應是最大的數。
  3. 除了每次排序得到的最後一個元素,對剩餘元素重複以上步驟,直到沒有任何一對元素需要比較為止。

用 Java 實現的氣泡排序如下

public void bubbleSortOpt(int[] arr) {

    if(arr == null) {
        throw new NullPoniterException();
    }
    if(arr.length < 2) {
        return;
    }
    int temp = 0;
    for(int i = 0; i < arr.length - 1; i++) {
        for(int j = 0; j < arr.length - i - 1; j++) {
            if(arr[j] > arr[j + 1]) {
                temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

2. 快速排序

快速排序的思想很簡單,就是先把待排序的陣列拆成左右兩個區間,左邊都比中間的基準數小,右邊都比基準數大。接著左右兩邊各自再做同樣的操作,完成後再拆分再繼續,一直到各區間只有一個數為止。

舉個例子,現在我要排序 4、9、5、1、2、6 這個陣列。一般取首位的 4 為基準數,第一次排序的結果是:

2、1、4、5、9、6

可能有人覺得奇怪,2 和 1 交換下位置也能滿足條件,為什麼 2 在首位?這其實由實際的程式碼實現來決定,並不影響之後的操作。以 4 為分界點,對 2、1、4 和 5、9、6 各自排序,得到:

1、2、4、5、9、6

不用管左邊的 1、2、4 了,將 5、9、6 拆成 5 和 9、6,再排序,至此結果為:

1、2、4、5、6、9

為什麼把快排劃到交換排序的範疇呢?因為元素的移動也是靠交換位置來實現的。演算法的實現需要用到遞迴(拆分割槽間之後再對每個區間作同樣的操作)

用 Java 實現的快速排序如下

public void quicksort(int[] arr, int start, int end) {

    if(start < end) {
        // 把陣列中的首位數字作為基準數
        int stard = arr[start];
        // 記錄需要排序的下標
        int low = start;
        int high = end;
        // 迴圈找到比基準數大的數和比基準數小的數
        while(low < high) {
            // 右邊的數字比基準數大
            while(low < high && arr[high] >= stard) {
                high--;
            }
            // 使用右邊的數替換左邊的數
            arr[low] = arr[high];
            // 左邊的數字比基準數小
            while(low < high && arr[low] <= stard) {
                low++;
            }
            // 使用左邊的數替換右邊的數
            arr[high] = arr[low];
        }
        // 把標準值賦給下標重合的位置
        arr[low] = stard;
        // 處理所有小的數字
        quickSort(arr, start, low);
        // 處理所有大的數字
        quickSort(arr, low + 1, end);
    }
}

插入排序

插入排序是一種簡單的排序方法,其基本思想是將一個記錄插入到已經排好序的有序表中,使得被插入數的序列同樣是有序的。按照此法對所有元素進行插入,直到整個序列排為有序的過程。

1. 直接插入排序

直接插入排序就是插入排序的粗暴實現。對於一個序列,選定一個下標,認為在這個下標之前的元素都是有序的。將下標所在的元素插入到其之前的序列中。接著再選取這個下標的後一個元素,繼續重複操作。直到最後一個元素完成插入為止。我們一般從序列的第二個元素開始操作。

用 Java 實現的演算法如下:

public void insertSort(int[] arr) {
    // 遍歷所有數字
    for(int i = 1; i < arr.length - 1; i++) {
        // 當前數字比前一個數字小
        if(arr[i] < arr[i - 1]) {
            int j;
            // 把當前遍歷的數字儲存起來
            int temp = arr[i];
            for(j = i - 1; j >= 0 && arr[j] > temp; j--) {
                // 前一個數字賦給後一個數字
                arr[j + 1] = arr[j];
            }
            // 把臨時變數賦給不滿足條件的後一個元素
            arr[j + 1] = temp;
        }
    }
}

2. 希爾排序

某些情況下直接插入排序的效率極低。比如一個已經有序的升序陣列,這時再插入一個比最小值還要小的數,也就意味著被插入的數要和陣列所有元素比較一次。我們需要對直接插入排序進行改進。

怎麼改進呢?前面提過,插入排序對已經排好序的陣列操作時,效率很高。因此我們可以試著先將陣列變為一個相對有序的陣列,然後再做插入排序。

希爾排序能實現這個目的。希爾排序把序列按下標的一定增量(步長)分組,對每組分別使用插入排序。隨著增量(步長)減少,一直到一,演算法結束,整個序列變為有序。因此希爾排序又稱縮小增量排序。

一般來說,初次取序列的一半為增量,以後每次減半,直到增量為一。

用 Java 實現的演算法如下:

public void shellSort(int[] arr) {
    // gap 為步長,每次減為原來的一半。
    for (int gap = arr.length / 2; gap > 0; gap /= 2) {
        // 對每一組都執行直接插入排序
        for (int i = 0 ;i < gap; i++) {
            // 對本組資料執行直接插入排序
            for (int j = i + gap; j < arr.length; j += gap) {
                // 如果 a[j] < a[j-gap],則尋找 a[j] 位置,並將後面資料的位置都後移
                if (arr[j] < arr[j - gap]) {
                    int k;
                    int temp = arr[j];
                    for (k = j - gap; k >= 0 && arr[k] > temp; k -= gap) {
                        arr[k + gap] = arr[k];
                    }
                    arr[k + gap] = temp;
                }
            }
        }
    }
}

選擇排序

選擇排序是一種簡單直觀的排序演算法,首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然後,再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。

1. 簡單選擇排序

選擇排序思想的暴力實現,每一趟從未排序的區間找到一個最小元素,並放到第一位,直到全部區間有序為止。

用 Java 實現的演算法如下:

public void selectSort(int[] arr) {
    // 遍歷所有的數
    for (int i = 0; i < arr.length; i++) {
        int minIndex = i;
        // 把當前遍歷的數和後面所有的數進行比較,並記錄下最小的數的下標
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[j] < arr[minIndex]) {
                // 記錄最小的數的下標
                minIndex = j;
            }
        }
        // 如果最小的數和當前遍歷的下標不一致,則交換
        if (i != minIndex) {
            int temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
    }
}

2. 堆排序

我們知道,對於任何一個陣列都可以看成一顆完全二叉樹。堆是具有以下性質的完全二叉樹:每個結點的值都大於或等於其左右孩子結點的值,稱為大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱為小頂堆。如下圖:

像上圖的大頂堆,對映為陣列,就是 [50, 45, 40, 20, 25, 35, 30, 10, 15]。可以發現第一個下標的元素就是最大值,將其與末尾元素交換,則末尾元素就是最大值。所以堆排序的思想可以歸納為以下兩步:

  1. 根據初始陣列構造堆
  2. 每次交換第一個和最後一個元素,然後將除最後一個元素以外的其他元素重新調整為大頂堆

重複以上兩個步驟,直到沒有元素可操作,就完成排序了。

我們需要把一個普通陣列轉換為大頂堆,調整的起始點是最後一個非葉子結點,然後從左至右,從下至上,繼續調整其他非葉子結點,直到根結點為止。

/**
 * 轉化為大頂堆
 * @param arr 待轉化的陣列
 * @param size 待調整的區間長度
 * @param index 結點下標
 */
public void maxHeap(int[] arr, int size, int index) {
    // 左子結點
    int leftNode = 2 * index + 1;
    // 右子結點
    int rightNode = 2 * index + 2;
    int max = index;
    // 和兩個子結點分別對比,找出最大的結點
    if (leftNode < size && arr[leftNode] > arr[max]) {
        max = leftNode;
    }
    if (rightNode < size && arr[rightNode] > arr[max]) {
        max = rightNode;
    }
    // 交換位置
    if (max != index) {
        int temp = arr[index];
        arr[index] = arr[max];
        arr[max] = temp;
        // 因為交換位置後有可能使子樹不滿足大頂堆條件,所以要對子樹進行調整
        maxHeap(arr, size, max);
    }
}

/**
 * 堆排序
 * @param arr 待排序的整型陣列
 */
public static void heapSort(int[] arr) {
    // 開始位置是最後一個非葉子結點,即最後一個結點的父結點
    int start = (arr.length - 1) / 2;
    // 調整為大頂堆
    for (int i = start; i >= 0; i--) {
        SortTools.maxHeap(arr, arr.length, i);
    }
    // 先把陣列中第 0 個位置的數和堆中最後一個數交換位置,再把前面的處理為大頂堆
    for (int i = arr.length - 1; i > 0; i--) {
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        maxHeap(arr, i, 0);
    }
}

歸併排序

歸併排序是建立在歸併操作上的一種有效,穩定的排序演算法。該演算法採用分治法的思想,是一個非常典型的應用。歸併排序的思路如下:

  1. 將 n 個元素分成兩個各含 n/2 個元素的子序列
  2. 藉助遞迴,兩個子序列分別繼續進行第一步操作,直到不可再分為止
  3. 此時每一層遞迴都有兩個子序列,再將其合併,作為一個有序的子序列返回上一層,再繼續合併,全部完成之後得到的就是一個有序的序列

關鍵在於兩個子序列應該如何合併。假設兩個子序列各自都是有序的,那麼合併步驟就是:

  1. 建立一個用於存放結果的臨時陣列,其長度是兩個子序列合併後的長度
  2. 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
  3. 比較兩個指標所指向的元素,選擇相對小的元素放入臨時陣列,並移動指標到下一位置
  4. 重複步驟 3 直到某一指標達到序列尾
  5. 將另一序列剩下的所有元素直接複製到合併序列尾

用 Java 實現的歸併排序如下:

/**
 * 合併陣列
 */
public static void merge(int[] arr, int low, int middle, int high) {
    // 用於儲存歸併後的臨時陣列
    int[] temp = new int[high - low + 1];
    // 記錄第一個陣列中需要遍歷的下標
    int i = low;
    // 記錄第二個陣列中需要遍歷的下標
    int j = middle + 1;
    // 記錄在臨時陣列中存放的下標
    int index = 0;
    // 遍歷兩個陣列,取出小的數字,放入臨時陣列中
    while (i <= middle && j <= high) {
        // 第一個陣列的資料更小
        if (arr[i] <= arr[j]) {
            // 把更小的資料放入臨時陣列中
            temp[index] = arr[i];
            // 下標向後移動一位
            i++;
        } else {
            temp[index] = arr[j];
            j++;
        }
        index++;
    }
    // 處理剩餘未比較的資料
    while (i <= middle) {
        temp[index] = arr[i];
        i++;
        index++;
    }
    while (j <= high) {
        temp[index] = arr[j];
        j++;
        index++;
    }
    // 把臨時陣列中的資料重新放入原陣列
    for (int k = 0; k < temp.length; k++) {
        arr[k + low] = temp[k];
    }
}

/**
 * 歸併排序
 */
public static void mergeSort(int[] arr, int low, int high) {
    int middle = (high + low) / 2;
    if (low < high) {
        // 處理左邊陣列
        mergeSort(arr, low, middle);
        // 處理右邊陣列
        mergeSort(arr, middle + 1, high);
        // 歸併
        merge(arr, low, middle, high);
    }
}

基數排序

基數排序的原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。為此需要將所有待比較的數值統一為同樣的數位長度,數位不足的數在高位補零。

使用 Java 實現的基數排序:

/**
 * 基數排序
 */
public static void radixSort(int[] arr) {
    // 存放陣列中的最大數字
    int max = Integer.MIN_VALUE;
    for (int value : arr) {
        if (value > max) {
            max = value;
        }
    }
    // 計算最大數字是幾位數
    int maxLength = (max + "").length();
    // 用於臨時儲存資料
    int[][] temp = new int[10][arr.length];
    // 用於記錄在 temp 中相應的下標存放數字的數量
    int[] counts = new int[10];
    // 根據最大長度的數決定比較次數
    for (int i = 0, n = 1; i < maxLength; i++, n *= 10) {
        // 每一個數字分別計算餘數
        for (int j = 0; j < arr.length; j++) {
            // 計算餘數
            int remainder = arr[j] / n % 10;
            // 把當前遍歷的資料放到指定的陣列中
            temp[remainder][counts[remainder]] = arr[j];
            // 記錄數量
            counts[remainder]++;
        }
        // 記錄取的元素需要放的位置
        int index = 0;
        // 把數字取出來
        for (int k = 0; k < counts.length; k++) {
            // 記錄數量的陣列中當前餘數記錄的數量不為 0
            if (counts[k] != 0) {
                // 迴圈取出元素
                for (int l = 0; l < counts[k]; l++) {
                    arr[index] = temp[k][l];
                    // 記錄下一個位置
                    index++;
                }
                // 把數量置空
                counts[k] = 0;
            }
        }
    }
}

八種排序演算法的總結

排序法 最好情形 平均時間 最差情形 穩定度 空間複雜度
氣泡排序 O(n) O(n^2) O(n^2) 穩定 O(1)
快速排序 O(nlogn) O(nlogn) O(n^2) 不穩定 O(nlogn)
直接插入排序 O(n) O(n^2) O(n^2) 穩定 O(1)
希爾排序 不穩定 O(1)
直接選擇排序 O(n^2) O(n^2) O(n^2) 不穩定 O(1)
堆排序 O(nlogn) O(nlogn) O(nlogn) 不穩定 O(nlogn)
歸併排序 O(nlogn) O(nlogn) O(nlogn) 穩定 O(n)

相關文章