面試必備:八種排序演算法原理及Java實現

foofoo發表於2019-03-04

1. 概述

排序演算法分為內部排序和外部排序,內部排序把資料記錄放在記憶體中進行排序,而外部排序因排序的資料量大,記憶體不能一次容納全部的排序記錄,所以在排序過程中需要訪問外存。

面試必備:八種排序演算法原理及Java實現

經常提及的八大排序演算法指的就是內部排序的八種演算法,分別是氣泡排序、快速排序、直接插入排序、希爾排序、簡單選擇排序、堆排序、歸併排序和基數排序,如果按原理劃分,氣泡排序和快速排序都屬於交換排序,直接插入排序和希爾排序屬於插入排序,而簡單選擇排序和堆排序屬於選擇排序,如上圖所示。

2. 氣泡排序

2.1 基本思想

氣泡排序(Bubble Sort)是一種簡單的排序演算法。它重複訪問要排序的數列,一次比較兩個元素,如果他們的順序錯誤就把他們交換過來。訪問數列的工作是重複地進行直到沒有再需要交換的資料,也就是說該數列已經排序完成。這個演算法的名字由來是因為越小的元素會經由交換慢慢“浮”到數列的頂端,像水中的氣泡從水底浮到水面。

面試必備:八種排序演算法原理及Java實現

2.2 演算法描述

氣泡排序演算法的演算法過程如下:

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

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

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

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

2.3 程式碼實現

package com.fufu.algorithm.sort;

import java.util.Arrays;

/**
 * 氣泡排序
 * Created by zhoujunfu on 2018/8/2.
 */
public class BubbleSort {
    public static void sort(int[] array) {
        if (array == null || array.length == 0) {
            return;
        }

        int length = array.length;
        //外層:需要length-1次迴圈比較
        for (int i = 0; i < length - 1; i++) {
            //內層:每次迴圈需要兩兩比較的次數,每次比較後,都會將當前最大的數放到最後位置,所以每次比較次數遞減一次
            for (int j = 0; j < length - 1 - i; j++) {
                if (array[j] > array[j+1]) {
                    //交換陣列array的j和j+1位置的資料
                    swap(array, j, j+1);
                }
            }
        }
    }

    /**
     * 交換陣列array的i和j位置的資料
     * @param array 陣列
     * @param i 下標i
     * @param j 下標j
     */
    public static void swap(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
}

複製程式碼

2.4 演算法效率

氣泡排序是穩定的排序演算法,最容易實現的排序, 最壞的情況是每次都需要交換, 共需遍歷並交換將近n²/2次, 時間複雜度為O(n²). 最佳的情況是內迴圈遍歷一次後發現排序是對的, 因此退出迴圈, 時間複雜度為O(n). 平均來講, 時間複雜度為O(n²). 由於氣泡排序中只有快取的temp變數需要記憶體空間, 因此空間複雜度為常量O(1)。

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(n2) O(n) O(n2) O(1)

2.5 交換數字的三種方法

我們從氣泡排序的程式碼中看到了交換兩個數字的方法 swap(int[] array, int i, int j),這裡使用了臨時變數,而交換數字主要有三種方法,臨時變數法、算術法、位運演算法、面試中經常會問到,這裡簡單說一下,程式碼如下:

package com.fufu.algorithm.sort;

import java.util.Arrays;

/**
 * Created by zhoujunfu on 2018/9/10.
 */
public class SwapDemo {

    public static void main(String[] args) {
        // 臨時變數法
        int[] array = new int[]{10, 20};
        System.out.println(Arrays.toString(array));
        swapByTemp(array, 0, 1);
        System.out.println(Arrays.toString(array));
        
        // 算術法
        array = new int[]{10, 20};
        swapByArithmetic(array, 0, 1);
        System.out.println(Arrays.toString(array));
        
        // 位運演算法
        array = new int[]{10, 20};
        swapByBitOperation(array, 0, 1);
        System.out.println(Arrays.toString(array));
    }

    /**
     * 通過臨時變數交換陣列array的i和j位置的資料
     * @param array 陣列
     * @param i 下標i
     * @param j 下標j
     */
    public static void swapByTemp(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    /**
     * 通過算術法交換陣列array的i和j位置的資料(有可能溢位)
     * @param array 陣列
     * @param i 下標i
     * @param j 下標j
     */
    public static void  swapByArithmetic(int[] array, int i, int j) {
        array[i] = array[i] + array[j];
        array[j] = array[i] - array[j];
        array[i] = array[i] - array[j];
    }


    /**
     * 通過位運演算法交換陣列array的i和j位置的資料
     * @param array 陣列
     * @param i 下標i
     * @param j 下標j
     */
    public static void  swapByBitOperation(int[] array, int i, int j) {
        array[i] = array[i]^array[j];
        array[j] = array[i]^array[j]; //array[i]^array[j]^array[j]=array[i]
        array[i] = array[i]^array[j]; //array[i]^array[j]^array[i]=array[j]
    }
}

複製程式碼

3. 快速排序

3.1 基本思想

快速排序(Quicksort)是對氣泡排序的一種改進,借用了分治的思想,由C. A. R. Hoare在1962年提出。它的基本思想是:通過一趟排序將要排序的資料分割成獨立的兩部分,其中一部分的所有資料都比另外一部分的所有資料都要小,然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。

3.2 演算法描述

快速排序使用分治策略來把一個序列(list)分為兩個子序列(sub-lists)。步驟為:

①. 從數列中挑出一個元素,稱為”基準”(pivot)。

②. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任一邊)。在這個分割槽結束之後,該基準就處於數列的中間位置。這個稱為分割槽(partition)操作。

③. 遞迴地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。

遞迴到最底部時,數列的大小是零或一,也就是已經排序好了。這個演算法一定會結束,因為在每次的迭代(iteration)中,它至少會把一個元素擺到它最後的位置去。

面試必備:八種排序演算法原理及Java實現

3.3 程式碼實現

①. 挖坑法 用虛擬碼描述如下:

(1)low = L; high = R; 將基準數挖出形成第一個坑a[low]。

(2)high--,由後向前找比它小的數,找到後挖出此數填前一個坑a[low]中。

(3)low++,由前向後找比它大的數,找到後也挖出此數填到前一個坑a[high]中。

(4)再重複執行②,③二步,直到low==high,將基準數填入a[low]中。

舉例說明: 一個無序陣列:[4, 3, 7, 5, 10, 9, 1, 6, 8, 2]

(1)隨便先挖個坑,就在第一個元素(基準元素)挖坑,挖出來的“蘿蔔”(第一個元素4)在“籃子”(臨時變數)裡備用。 挖完之後的陣列是這樣:[ 坑, 3, 7, 5, 10, 9, 1, 6, 8,2]

(2)挖右坑填左坑:從右邊開始,找個比“蘿蔔”(元素4)小的元素,挖出來,填到前一個坑裡面。 填坑之後:[ 2, 3, 7, 5, 10, 9, 1, 6, 8,坑]

(3)挖左坑填右坑:從左邊開始,找個比“蘿蔔”(元素4)大的元素,挖出來,填到右邊的坑裡面。 填坑之後:[ 2, 3,坑, 5, 10, 9, 1, 6, 8, 7]

(4)挖右坑填左坑:從右邊開始,找個比“蘿蔔”(元素4)小的元素,挖出來,填到前一個坑裡面。 填坑之後:[ 2, 3, 1, 5, 10, 9,坑, 6, 8, 7]

(5)挖左坑填右坑:從左邊開始,找個比“蘿蔔”(元素4)大的元素,挖出來,填到右邊的坑裡面。 填坑之後:[ 2, 3, 1,坑, 10, 9, 5, 6, 8, 7]

(6)挖右坑填左坑:從右邊開始,找個比“蘿蔔”(元素4)小的元素,挖出來,填到前一個坑裡面,這一次找坑的過程中,找到了上一次挖的坑了,說明可以停了,用籃子裡的的蘿蔔,把這個坑填了就行了,並且返回這個坑的位置,作為分而治之的中軸線。 填坑之後:[ 2, 3, 1, 4, 10, 9, 5, 6, 8, 7]

上面的步驟中,第2,4, 6其實都是一樣的操作,3和5的操作也是一樣的,程式碼如下:

  /**
     *  快速排序(挖坑法遞迴)
     * @param arr   待排序陣列
     * @param low   左邊界
     * @param high  右邊界
     */
    public static void sort(int arr[], int low, int high) {
        if (arr == null || arr.length <= 0) {
            return;
        }
        if (low >= high) {
            return;
        }

        int left = low;
        int right = high;
        int temp = arr[left]; //挖坑1:儲存基準的值

        while (left < right) {
            while (left < right && arr[right] >= temp) {
                right--;
            }
            arr[left] = arr[right]; //坑2:從後向前找到比基準小的元素,插入到基準位置坑1中
            while (left < right && arr[left] <= temp) {
                left ++;
            }
            arr[right] = arr[left]; //坑3:從前往後找到比基準大的元素,放到剛才挖的坑2中
        }
        arr[left] = temp; //基準值填補到坑3中,準備分治遞迴快排
        System.out.println("Sorting: " + Arrays.toString(arr));
        sort(arr, low, left-1);
        sort(arr, left + 1, high);
    }
複製程式碼

②. 左右指標法

用虛擬碼描述如下:

(1)low = L; high = R; 選取a[low]作為關鍵字記錄為key。

(2)high--,由後向前找比它小的數

(3)low++,由前向後找比它大的數

(4)交換第(2)、(3)步找到的數

(5)重複(2)、(3),一直往後找,直到left和right相遇,這時將key和a[low]交換位置。

程式碼如下:

/**
 * 快速排序
 * Created by zhoujunfu on 2018/8/6.
 */
public class QuickSort {
    /**
     * 快速排序(左右指標法)
     * @param arr 待排序陣列
     * @param low 左邊界
     * @param high 右邊界
     */
    public static void sort2(int arr[], int low, int high) {
        if (arr == null || arr.length <= 0) {
            return;
        }
        if (low >= high) {
            return;
        }

        int left = low;
        int right = high;

        int key = arr[left];

        while (left < right) {
            while (left < right && arr[right] >= key) {
                right--;
            }
            while (left < right && arr[left] <= key) {
                left++;
            }
            if (left < right) {
                swap(arr, left, right);
            }
        }
        swap(arr, low, left);
        System.out.println("Sorting: " + Arrays.toString(arr));
        sort2(arr, low, left - 1);
        sort2(arr, left + 1, high);
    }

    public static void swap(int arr[], int low, int high) {
        int tmp = arr[low];
        arr[low] = arr[high];
        arr[high] = tmp;
    }
}
複製程式碼

3.4 演算法效率

快速排序並不穩定,快速排序每次交換的元素都有可能不是相鄰的, 因此它有可能打破原來值為相同的元素之間的順序。

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(nlogn) O(nlogn) O(n2) O(1)

4. 直接插入排序

4.1 基本思想

直接插入排序的基本思想是:將陣列中的所有元素依次跟前面已經排好的元素相比較,如果選擇的元素比已排序的元素小,則交換,直到全部元素都比較過為止。

面試必備:八種排序演算法原理及Java實現

4.2 演算法描述

一般來說,插入排序都採用in-place在陣列上實現。具體演算法描述如下:

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

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

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

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

⑤. 將新元素插入到該位置後

⑥. 重複步驟②~⑤

面試必備:八種排序演算法原理及Java實現

4.3 程式碼實現

提供兩種寫法,一種是移位法,一種是交換法。移位法是完全按照以上演算法描述實,再插入過程中將有序序列中比待插入數字大的資料向後移動,由於移動時會覆蓋待插入資料,所以需要額外的臨時變數儲存待插入資料,程式碼實現如下:

①. 移位法:

public static void sort(int[] a) {
        if (a == null || a.length == 0) {
            return;
        }

        for (int i = 1; i < a.length; i++) {
            int j = i - 1;
            int temp = a[i]; // 先取出待插入資料儲存,因為向後移位過程中會把覆蓋掉待插入數
            while (j >= 0 && a[j] > a[i]) { // 如果待是比待插入資料大,就後移
                a[j+1] = a[j];
                j--;
            }
            a[j+1] = temp; // 找到比待插入資料小的位置,將待插入資料插入
        }
    }
複製程式碼

而交換法不需求額外的儲存待插入資料,通過不停的向前交換帶插入資料,類似冒泡法,直到找到比它小的值,也就是待插入資料找到了自己的位置。
②. 交換法:

  public static void sort2(int[] arr) {
        if (arr == null || arr.length == 0) {
            return;
        }

        for (int i = 1; i < arr.length; i ++) {
            int j = i - 1;
            while (j >= 0 && arr[j] > arr[i]) {
                arr[j + 1] = arr[j] + arr[j+1];      //只要大就交換操作
                arr[j] = arr[j + 1] - arr[j];
                arr[j + 1] = arr[j + 1] - arr[j];
                System.out.println("Sorting:  " + Arrays.toString(arr));
            }
        }
    }
複製程式碼

4.4 演算法效率

直接插入排序不是穩定的排序演算法。

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(n2) O(n) O(n2) O(1)

5.希爾排序

希爾排序,也稱遞減增量排序演算法,1959年Shell發明。是插入排序的一種高速而穩定的改進版本。

希爾排序是先將整個待排序的記錄序列分割成為若干子序列分別進行直接插入排序,待整個序列中的記錄“基本有序”時,再對全體記錄進行依次直接插入排序。

5.1 基本思想

將待排序陣列按照步長gap進行分組,然後將每組的元素利用直接插入排序的方法進行排序;每次再將gap折半減小,迴圈上述操作;當gap=1時,利用直接插入,完成排序。

可以看到步長的選擇是希爾排序的重要部分。只要最終步長為1任何步長序列都可以工作。一般來說最簡單的步長取值是初次取陣列長度的一半為增量,之後每次再減半,直到增量為1。更好的步長序列取值可以參考維基百科。

5.2 演算法描述

①. 選擇一個增量序列t1,t2,…,tk,其中ti>tj,tk=1;(一般初次取陣列半長,之後每次再減半,直到增量為1)

②. 按增量序列個數k,對序列進行k 趟排序;

③. 每趟排序,根據對應的增量ti,將待排序列分割成若干長度為m 的子序列,分別對各子表進行直接插入排序。僅增量因子為1 時,整個序列作為一個表來處理,表長度即為整個序列的長度。

面試必備:八種排序演算法原理及Java實現

在上面這幅圖中: 初始時,有一個大小為 10 的無序序列。

在第一趟排序中,我們不妨設 gap1 = N / 2 = 5,即相隔距離為 5 的元素組成一組,可以分為 5 組。

接下來,按照直接插入排序的方法對每個組進行排序。

在第二趟排序中,我們把上次的 gap 縮小一半,即 gap2 = gap1 / 2 = 2 (取整數)。這樣每相隔距離為 2 的元素組成一組,可以分為 2 組。

按照直接插入排序的方法對每個組進行排序。

在第三趟排序中,再次把 gap 縮小一半,即gap3 = gap2 / 2 = 1。 這樣相隔距離為 1 的元素組成一組,即只有一組。 按照直接插入排序的方法對每個組進行排序。此時,排序已經結束。

需要注意一下的是,圖中有兩個相等數值的元素 5 和 5 。我們可以清楚的看到,在排序過程中,兩個元素位置交換了。 所以,希爾排序是不穩定的演算法。

5.3 程式碼實現

public class ShellSort {

    public static void sort(int[] arr) {
        int gap = arr.length / 2;
        for (;gap > 0; gap = gap/2) {
            for (int j = 0; (j + gap) < arr.length; j++) { //不斷縮小gap,直到1為止
                for (int k = 0; (k + gap) < arr.length; k+=gap) { //使用當前gap進行組內插入排序
                    if (arr[k] > arr[k+gap]) { //交換操作
                        arr[k] = arr[k] + arr[k+gap];
                        arr[k+gap] = arr[k] - arr[k+gap];
                        arr[k] = arr[k] - arr[k+gap];
                        System.out.println("    Sorting:  " + Arrays.toString(arr));
                    }
                }
            }
        }
    }
}
複製程式碼

5.4 演算法效率

不穩定排序演算法,希爾排序第一個突破O(n2)的排序演算法;是簡單插入排序的改進版;它與插入排序的不同之處在於,它會優先比較距離較遠的元素,直接插入排序是穩定的;而希爾排序是不穩定的,希爾排序的時間複雜度和步長的選擇有關,常用的是Shell增量排序,也就是N/2的序列,Shell增量序列不是最好的增量序列,其他還有Hibbard增量序列、Sedgewick 增量序列等,具體可以參考,希爾排序增量序列簡介

6.選擇排序

6.1 基本思想

在未排序序列中找到最小(大)元素,存放到未排序序列的起始位置。在所有的完全依靠交換去移動元素的排序方法中,選擇排序屬於非常好的一種。

6.2 演算法描述

①. 從待排序序列中,找到關鍵字最小的元素;

②. 如果最小元素不是待排序序列的第一個元素,將其和第一個元素互換;

③. 從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複①、②步,直到排序結束。

面試必備:八種排序演算法原理及Java實現

6.3 程式碼實現

public class SelectSort {
    public static void sort(int[] arr) {
        for (int i = 0; i < arr.length - 1; i++) {
            int min = i;
            for (int j = i+1; j < arr.length; j ++) { //選出之後待排序中值最小的位置
                if (arr[j] < arr[min]) {
                    min = j;
                }
            }
            if (min != i) {
                arr[min] = arr[i] + arr[min];
                arr[i] = arr[min] - arr[i];
                arr[min] = arr[min] - arr[i];
            }
        }
    }
複製程式碼

6.4 演算法效率

不穩定排序演算法,選擇排序的簡單和直觀名副其實,這也造就了它出了名的慢性子,無論是哪種情況,哪怕原陣列已排序完成,它也將花費將近n²/2次遍歷來確認一遍。 唯一值得高興的是,它並不耗費額外的記憶體空間。

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(n2) O(n2) O(n2) O(1)

7.歸併排序

歸併排序是建立在歸併操作上的一種有效的排序演算法,1945年由約翰·馮·諾伊曼首次提出。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用,且各層分治遞迴可以同時進行。

7.1 基本思想

歸併排序演算法是將兩個(或兩個以上)有序表合併成一個新的有序表,即把待排序序列分為若干個子序列,每個子序列是有序的。然後再把有序子序列合併為整體有序序列。

面試必備:八種排序演算法原理及Java實現

7.2 演算法描述

採用遞迴法: ①. 將序列每相鄰兩個數字進行歸併操作,形成 floor(n/2)個序列,排序後每個序列包含兩個元素;

②. 將上述序列再次歸併,形成 floor(n/4)個序列,每個序列包含四個元素;

③. 重複步驟②,直到所有元素排序完畢

面試必備:八種排序演算法原理及Java實現

7.3 程式碼實現

package com.fufu.algorithm.sort;

import java.util.Arrays;

/**
 * Created by zhoujunfu on 2018/8/10.
 */
public class MergeSort {

    public static int[] sort(int [] a) {
        if (a.length <= 1) {
            return a;
        }
        int num = a.length >> 1;
        int[] left = Arrays.copyOfRange(a, 0, num);
        int[] right = Arrays.copyOfRange(a, num, a.length);
        return mergeTwoArray(sort(left), sort(right));
    }

    public static int[] mergeTwoArray(int[] a, int[] b) {
        int i = 0, j = 0, k = 0;
        int[] result = new int[a.length + b.length]; // 申請額外空間儲存歸併之後資料

        while (i < a.length && j < b.length) { //選取兩個序列中的較小值放入新陣列
            if (a[i] <= b[j]) {
                result[k++] = a[i++];
            } else {
                result[k++] = b[j++];
            }
        }

        while (i < a.length) { //序列a中多餘的元素移入新陣列
            result[k++] = a[i++];
        }
        while (j < b.length) {//序列b中多餘的元素移入新陣列
            result[k++] = b[j++];
        }
        return result;
    }

    public static void main(String[] args) {
        int[] b = {3, 1, 5, 4};
        System.out.println(Arrays.toString(sort(b)));
    }
}
複製程式碼

7.4 演算法效率

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(nlogn) O(nlogn) O(nlogn) O(n)

穩定排序演算法,從效率上看,歸併排序可算是排序演算法中的”佼佼者”. 假設陣列長度為n,那麼拆分陣列共需logn, 又每步都是一個普通的合併子陣列的過程,時間複雜度為O(n), 故其綜合時間複雜度為O(nlogn)。另一方面, 歸併排序多次遞迴過程中拆分的子陣列需要儲存在記憶體空間, 其空間複雜度為O(n)。 和選擇排序一樣,歸併排序的效能不受輸入資料的影響,但表現比選擇排序好的多,因為始終都是O(nlogn)的時間複雜度。代價是需要額外的記憶體空間。

8.基數排序

基數排序(Radix sort)是一種非比較型整數排序演算法,其原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。由於整數也可以表達字串(比如名字或日期)和特定格式的浮點數,所以基數排序也不是隻能使用於整數。

8.1 基本思想

將所有待比較數值(正整數)統一為同樣的數位長度,數位較短的數前面補零。然後,從最低位開始,依次進行一次排序。這樣從最低位排序一直到最高位排序完成以後,數列就變成一個有序序列。

基數排序按照優先從高位或低位來排序有兩種實現方案:

MSD(Most significant digital) 從最左側高位開始進行排序。先按k1排序分組, 同一組中記錄, 關鍵碼k1相等, 再對各組按k2排序分成子組, 之後, 對後面的關鍵碼繼續這樣的排序分組, 直到按最次位關鍵碼kd對各子組排序後. 再將各組連線起來, 便得到一個有序序列。MSD方式適用於位數多的序列。

LSD(Least significant digital) 從最右側低位開始進行排序。先從kd開始排序,再對kd-1進行排序,依次重複,直到對k1排序後便得到一個有序序列。LSD方式適用於位數少的序列。

下圖是LSD基數排序的示意圖:

面試必備:八種排序演算法原理及Java實現

8.2 演算法描述

以LSD為例,從最低位開始,具體演算法描述如下:

①. 取得陣列中的最大數,並取得位數; ②. arr為原始陣列,從最低位開始取每個位組成radix陣列; ③. 對radix進行計數排序(利用計數排序適用於小範圍數的特點);

8.3 程式碼實現

基數排序:通過序列中各個元素的值,對排序的N個元素進行若干趟的“分配”與“收集”來實現排序。

分配:我們將L[i]中的元素取出,首先確定其個位上的數字,根據該數字分配到與之序號相同的桶中

收集:當序列中所有的元素都分配到對應的桶中,再按照順序依次將桶中的元素收集形成新的一個待排序列L[]。對新形成的序列L[]重複執行分配和收集元素中的十位、百位…直到分配完該序列中的最高位,則排序結束

package com.fufu.algorithm.sort;

import java.util.Arrays;

/**
 * Created by zhoujunfu on 2018/9/11.
 * 基數排序LSD
 */
public class RadixSort {

    public static void main(String[] args) {
        int[] array = {10, 20, 5, 4, 100};
        sort(array);

    }

    public static void sort(int[] a) {

        if (a == null || a.length < 0) {
            return;
        }

        int max = a[0];
        for (int i = 0; i <a.length; i++) {
            if (a[i] > max) {
                max = a[i];
            }
        }
        System.out.println("max, " + max);

        int maxDigit = 0;
        while (max != 0) {
            max = max / 10;
            maxDigit++;
        }
        System.out.println("maxDigit, " + maxDigit);

        int[][] buckets = new int[10][a.length];
        int base = 10;

        //從低位到高位,對每一位遍歷,將所有元素分配到桶中
        for (int i = 0; i < maxDigit; i++) {
            int[] bucketLen = new int[10];  //儲存各個桶中儲存元素的數量

            //收集:將不同桶裡資料挨個撈出來,為下一輪高位排序做準備,由於靠近桶底的元素排名靠前,因此從桶底先撈
            for (int j = 0; j < a.length; j++) {
                int whichBucket = (a[j] % base) / (base / 10);
                buckets[whichBucket][bucketLen[whichBucket]] = a[j];
                bucketLen[whichBucket]++;
            }

            int k = 0;
            //收集:將不同桶裡資料挨個撈出來,為下一輪高位排序做準備,由於靠近桶底的元素排名靠前,因此從桶底先撈
            for (int l = 0; l < buckets.length; l++) {
                for (int m =0; m < bucketLen[l]; m++) {
                    a[k++] = buckets[l][m];
                }
            }
            System.out.println("Sorting: " + Arrays.toString(a));
            base *= 10;
        }
    }
}
複製程式碼

8.4 演算法效率

基數排序不改變相同元素之間的相對順序,因此它是穩定的排序演算法,以下是基數排序演算法複雜度:

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(d*(n+r)) O(d*(n+r)) O(d*(n+r)) O(n+r)

其中,d 為位數,r 為基數,n 為原陣列個數。在基數排序中,因為沒有比較操作,所以在複雜上,最好的情況與最壞的情況在時間上是一致的,均為 O(d*(n + r))。

基數排序更適合用於對時間, 字串等這些整體權值未知的資料進行排序,適用於。

(1)資料範圍較小,建議在小於1000

(2)每個數值都要大於等於0

基數排序 vs 計數排序 vs 桶排序

這三種排序演算法都利用了桶的概念,但對桶的使用方法上有明顯差異:

基數排序:根據鍵值的每位數字來分配桶
計數排序:每個桶只儲存單一鍵值
桶排序:每個桶儲存一定範圍的數值

計數排序和桶排序在這篇文章裡具體就不寫了,有需要的可以自行百度。

9.堆排序

看堆排序之前先介紹一下面幾個概念:

完全二叉樹: 若設二叉樹的深度為h,除第 h 層外,其它各層 (1~h-1) 的結點數都達到最大個數,第 h 層所有的結點都連續集中在最左邊,這就是完全二叉樹,很好理解如下圖所示。

面試必備:八種排序演算法原理及Java實現

堆: 堆是具有以下性質的完全二叉樹,每個結點的值都大於或等於其左右孩子結點的值,稱為大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱為小頂堆。如下圖:

面試必備:八種排序演算法原理及Java實現

同時,我們對堆中的結點按層進行編號,將這種邏輯結構對映到陣列中就是下面這個樣子:

面試必備:八種排序演算法原理及Java實現

該陣列從邏輯上講就是一個堆結構,我們用簡單的公式來描述一下堆的定義就是:

大頂堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

小頂堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

ok,瞭解了這些定義。接下來,我們來看看堆排序的基本思想及基本步驟:

9.1 基本思想

堆排序的基本思想是:將待排序序列構造成一個大頂堆,此時,整個序列的最大值就是堆頂的根節點。將其與末尾元素進行交換,此時末尾就為最大值。然後將剩餘n-1個元素重新構造成一個堆,這樣會得到n個元素的次小值。如此反覆執行,便能得到一個有序序列了。

9.2 演算法描述

步驟一 構造初始堆。將給定無序序列構造成一個大頂堆(一般升序採用大頂堆,降序採用小頂堆)。

1.假設給定無序序列結構如下

面試必備:八種排序演算法原理及Java實現

2.此時我們從最後一個非葉子結點開始(葉結點自然不用調整,第一個非葉子結點 arr.length/2-1=5/2-1=1,也就是下面的6結點),從左至右,從下至上進行調整。

面試必備:八種排序演算法原理及Java實現

3.找到第二個非葉節點4,由於[4,9,8]中9元素最大,4和9交換。

面試必備:八種排序演算法原理及Java實現

這時,交換導致了子根[4,5,6]結構混亂,繼續調整,[4,5,6]中6最大,交換4和6。

面試必備:八種排序演算法原理及Java實現

此時,我們就將一個無需序列構造成了一個大頂堆。

步驟二 將堆頂元素與末尾元素進行交換,使末尾元素最大。然後繼續調整堆,再將堆頂元素與末尾元素交換,得到第二大元素。如此反覆進行交換、重建、交換。

面試必備:八種排序演算法原理及Java實現

b.重新調整結構,使其繼續滿足堆定義

面試必備:八種排序演算法原理及Java實現

c.再將堆頂元素8與末尾元素5進行交換,得到第二大元素8.

面試必備:八種排序演算法原理及Java實現

後續過程,繼續進行調整,交換,如此反覆進行,最終使得整個序列有序

面試必備:八種排序演算法原理及Java實現

再簡單總結下堆排序的基本思路:

  a.將無需序列構建成一個堆,根據升序降序需求選擇大頂堆或小頂堆;

  b.將堆頂元素與末尾元素交換,將最大元素"沉"到陣列末端;

  c.重新調整結構,使其滿足堆定義,然後繼續交換堆頂元素與當前末尾元素,反覆執行調整+交換步驟,直到整個序列有序。      

9.3 演算法實現

package com.fufu.algorithm.sort;

import java.util.Arrays;

/**
 * Created by zhoujunfu on 2018/9/26.
 */
public class HeapSort {

    public static void main(String []args){
        int []arr = {4,6,8,5,9};
        sort(arr);
        System.out.println(Arrays.toString(arr));
    }
    public static void sort(int []arr){
        //1.構建大頂堆
        for(int i=arr.length/2-1;i>=0;i--){
            //從第一個非葉子結點從下至上,從右至左調整結構
            adjustHeap(arr,i,arr.length);
        }
        //2.調整堆結構+交換堆頂元素與末尾元素
        for(int j=arr.length-1;j>0;j--){
            swap(arr,0,j);//將堆頂元素與末尾元素進行交換
            adjustHeap(arr,0,j);//重新對堆進行調整
        }

    }

    /**
     * 調整大頂堆(僅是調整過程,建立在大頂堆已構建的基礎上)
     * @param arr
     * @param i
     * @param length
     */
    public static void adjustHeap(int []arr,int i,int length){
        int temp = arr[i];//先取出當前元素i
        for(int k=i*2+1;k<length;k=k*2+1){//從i結點的左子結點開始,也就是2i+1處開始
            if(k+1<length && arr[k]<arr[k+1]){//如果左子結點小於右子結點,k指向右子結點
                k++;
            }
            if(arr[k] >temp){//如果子節點大於父節點,將子節點值賦給父節點(不用進行交換)
                arr[i] = arr[k];
                i = k;
            }else{
                break;
            }
        }
        arr[i] = temp;//將temp值放到最終的位置
    }

    /**
     * 交換元素
     * @param arr
     * @param a
     * @param b
     */
    public static void swap(int []arr,int a ,int b){
        int temp=arr[a];
        arr[a] = arr[b];
        arr[b] = temp;
    }
}
複製程式碼

9.4 演算法效率

由於堆排序中初始化堆的過程比較次數較多, 因此它不太適用於小序列。同時由於多次任意下標相互交換位置, 相同元素之間原本相對的順序被破壞了, 因此, 它是不穩定的排序。

①. 建立堆的過程, 從length/2 一直處理到0, 時間複雜度為O(n);

②. 調整堆的過程是沿著堆的父子節點進行調整, 執行次數為堆的深度, 時間複雜度為O(lgn);

③. 堆排序的過程由n次第②步完成, 時間複雜度為O(nlgn).

平均時間複雜度 最好情況 最壞情況 空間複雜度
O(nlogn) O(nlogn) O(nlogn) O(1)

10. 總結

面試必備:八種排序演算法原理及Java實現

從時間複雜度來說:

(1). 平方階O(n²)排序:各類簡單排序:直接插入、直接選擇和氣泡排序;

(2). 線性對數階O(nlog₂n)排序:快速排序、堆排序和歸併排序;

(3). O(n1+§))排序,§是介於0和1之間的常數:希爾排序

(4). 線性階O(n)排序:基數排序,此外還有桶、箱排序。

時間複雜度極限:

當被排序的數有一些性質的時候(比如是整數,比如有一定的範圍),排序演算法的複雜度是可以小於O(nlgn)的。比如:

計數排序 複雜度O( k+n) 要求:被排序的數是0~k範圍內的整數

基數排序 複雜度O( d(k+n) ) 要求:d位數,每個數位有k個取值

桶排序 複雜度 O( n ) (平均) 要求:被排序數在某個範圍內,並且服從均勻分佈

但是,當被排序的數不具有任何性質的時候,一般使用基於比較的排序演算法,而基於比較的排序演算法時間複雜度的下限必須是O( nlgn) 。參考很多高效排序演算法的代價是 nlogn,難道這是排序演算法的極限了嗎

說明 當原表有序或基本有序時,直接插入排序和氣泡排序將大大減少比較次數和移動記錄的次數,時間複雜度可降至O(n);

而快速排序則相反,當原表基本有序時,將蛻化為氣泡排序,時間複雜度提高為O(n2);

原表是否有序,對簡單選擇排序、堆排序、歸併排序和基數排序的時間複雜度影響不大。

11.參考資料

  1. 八大排序演算法總結與java實現
  2. 圖解排序演算法(三)之堆排序

相關文章