1. 程式人生 > >java面試必問——六大排序演算法

java面試必問——六大排序演算法

java中有很多中排序方法,其中氣泡排序過於簡單,基數排序主要用於研究我們這裡不討論。實際應用和麵試中,最常問到的就是下面的六種排序方法,我們將從原理,複雜度,穩定性和實際應用幾個方面來討論他們。

選擇排序

排序原理:

選擇排序從整個待排序陣列N中進行N次查詢,將每次查詢到的最小值放到陣列開始,然後將陣列開始位置的索引遞增。形象地說,選擇排序就是在陣列中從前到後跑N趟,每一趟把最小的數字向陣列前端一放,慢慢在陣列前端維護出一個有序的子陣列。最後達到排序的目的。

時間複雜度:

O(N2)

選擇排序通過兩個for迴圈對陣列進行遍歷。外層for標記已經排序的前端子陣列的索引,內層for迴圈對從這個索引開始至陣列尾部進行最小元素的查詢。理論上訪問陣列的次數是N2

/2(或者通過優化更短),交換的次數大概為N。

空間複雜度:

O(1)

選擇排序不需要額外的空間,在O(1)常量空間內完成一些區域性變數的定義。

穩定性:

不穩定

選擇排序是將陣列中較小的元素以此放到最前面的排序方法,這種方法按照次序將元素前置,如果序列 2 2 1,那麼我們需要將第一個2和1交換,交換之後兩個2的位置發生了變化,所以是不穩定的。

選擇排序實現:

private static void selectSort(int[] arr) {
        int n = arr.length;
        for (int i = 0; i < n; i++) {
            int min = Integer.MAX_VALUE;
            int min_index = -1;
            for (int j = i; j < n; j++) {
                if (arr[j] <= min) {
                    min_index = j;
                    min = arr[j];
                }
            }
            int temp = arr[min_index];
            arr[min_index] = arr[i];
            arr[i] = temp;
        }
    }

應用場景:

總的來說,作為一種初級排序演算法,選擇排序並不能和其他快速的排序作比較,實踐意義也比較少。但是選擇排序有兩個鮮明的優點:執行時間和陣列排序無關,選擇次數最少

插入排序(直接插入排序)

排序原理:

插入排序外層的for迴圈對整個陣列N進行一次遍歷,內層的for迴圈從外層索引元素開始向前比較和交換,如果該元素小於前一個元素,則交換這兩個元素並繼續與更前一個元素進行比較,否則結束退出內迴圈。插入排序類似於撲克排序,我們將手中的大牌從一個較後端的位置通過一個個的比較和交換移動到前面的位置。

時間複雜度:

O(N2)

插入排序也為兩層迴圈體,時間複雜度在O(N2

)的水平上。在沒有任何優化的情況下,直接插入排序需要的交換次數和訪問陣列次數與陣列中元素的分佈有關。越是有序的陣列交換次數和訪問陣列次數越少,越是混亂的陣列交換次數和訪問陣列次數越多。

空間複雜度:

O(1)

與選擇排序相同,插入排序也不需要和N相關的空間,只需要一些常量空間。

穩定性:

穩定

就和氣泡排序一樣,插入排序採用的策略是不斷的交換兩個元素。交換不同的元素也意味著而從不交換相同的元素。只要不交換相同的元素,不可能是不穩定的排序。

插入排序實現:

private static void insertSort(int[] arr) {
        int n = arr.length;
        for (int i = 1; i < n; i += 1) {
            for (int j = i; j > 0; j--) {
                if (arr[j] < arr[j - 1]) {
                    int temp = arr[j];
                    arr[j] = arr[j - 1];
                    arr[j - 1] = temp;
                } else
                    break;
            }
        }
    }

應用場景:

在陣列已經較為有序,或僅有幾個元素的位置有錯誤的情況下,使用插入排序的效率非常高。因為在插入排序中,如果一個元素已經處於排好序的位置,那麼這個元素可以不做任何交換。而在選擇排序中,每一趟排序的結果和原陣列的位置無關,無論是否排好序,選擇排序都要進行同樣的操作,這樣就帶來了不必要的時間浪費。

希爾排序

排序原理:

希爾排序將一個數組分成很多個子陣列,將這些子陣列插入排序。通過不斷減少子陣列的個數,最後將子陣列個數減至一個達到將整個陣列排序的目的。換句話說,直接插入排序可以理解為——子陣列個數為1的希爾排序。希爾排序的通俗解釋是,一個學校中有很多待排序的試卷,試卷太多不能一個個排序,那麼先讓每個老師把自己班級的試卷進行排序,最後放在一起排序,這樣因為每個班級的試卷都是有序的,最後的排序也不會花費太多的時間。

時間複雜度:

難以估計

希爾排序的時間複雜度很難估計,因為時間與選取的子陣列遞減序列有關。我們可以設定整個陣列分解為8,4,2,1個數組,或者分解為9,6,3,1或者其他的方式減少子陣列的個數(但最後一定都要減至1)。最讓人頭痛的是,至今也沒有人能證明哪個遞減序列的效果最好。一個較為合適的遞減序列是:1/2(3k-1)。

空間複雜度:

O(1)

希爾排序和插入排序是一個原理,並不需要使用與N相關的空間。

穩定性:

不穩定

希爾排序是一種比較靈活的排序,我們在分組的時候當子陣列不為1時,進行的交換完全可能將不同組之間相同數字位置搞亂。

希爾排序實現:

  private static void shellSort(int[] arr) {
        int n = arr.length;
        for (int d = n / 2; d != 1; d = d / 2 + 1) {
            insertSort(arr, d);
            System.out.println(d);
        }
    }
    private static void insertSort(int[] arr, int d) {
        int n = arr.length;
        for (int i = d; i < n; i += d) {
            for (int j = i; j > 0; j -= d) {
                if (arr[j] < arr[j - d]) {
                    int temp = arr[j];
                    arr[j] = arr[j - d];
                    arr[j - d] = temp;
                } else
                    break;
            }
        }
    }

應用場景:

希爾排序的應用場景很難說,有的時候我們更加依賴於快速排序而不是希爾排序。對於中等大小的陣列,希爾排序會被一些有經驗的程式設計師採納,因為希爾排序實際應用中表現效果很好,適用於各種情況的排序,而且不需要使用額外的空間。在對你的陣列沒把握的時候先使用希爾排序,然後可以再去考慮具體用那種排序來替換希爾排序。

歸併排序

排序原理:

歸併排序採用了“分治”的思想。先將整個陣列N個元素不斷的遞迴分解成兩個子陣列,當所有的陣列都足夠小不能再分時,再將他們遞迴合併。合併採用的做法是:使用一個額外空間,將兩個子陣列中最小的那個元素放入額外陣列中直到兩個子陣列中所有元素都被取出。然後將額外陣列的內容覆蓋在原陣列上。

時間複雜度:

O(NlogN)(習慣的,我們將log2寫成lg,歸併的時間複雜度也可是O(NlgN) )

歸併排序的時間複雜度是O(NlgN)或O(NlogN)的(之所以log和lg都可以,是因為在複雜度上我們考慮的是型別問題,log的底數作為一個常量並沒有太大影響可以忽略)。怎麼去計算歸併的時間複雜度呢?歸併排序採用的是遞迴的方法,在不計算最底層單個元素的情況下,這個遞迴函式的二叉樹一共有n層,且滿足2n=N。可求得n=log2N。在每一層上,元素的個數和都為N,容易看出每一層上的排序需要比較的次數和都是N。所以:每層所有元素需要排序的次數和×層數=N×log2N=NlogN。

空間複雜度:

O(N)

歸併排序需要一個數組作為額外的空間來實現對兩個子陣列的排序。這裡需要注意的是,這個額外的空間一定要在外部申請長度為N的陣列,而不是在遞迴內部建立變數。如果在遞迴內部每一層都需要建立一個變數,那麼很有可能建立陣列的開銷時間遠大於排序時間,或是足夠影響排序時間。而且不僅是時間的問題,在遞迴內部建立變數會讓空間複雜度遠大於N。

歸併排序是一種遞迴排序,不僅需要建立N長的陣列,而且需要在每層遞迴上維護一些資料,需要複雜度為O(logN)的空間,但是O(N)>O(logN),而且這兩個空間是加算不是乘算的。在計算空間複雜度時我們認為它是O(N)的即可。

穩定性:

穩定

只要我們在歸併的時候記得將前面的子陣列放在前面合成,就不會存在不穩定的情況。

歸併排序實現:

private static void mergeSort(int[] arr) {
        int n = arr.length;
        ret = new int[n];
        sort(arr, 0, n);
    }

private static void sort(int[] arr, int i, int n) {
    if (n == 1)

    sort(arr, i, n / 2);
    sort(arr, i + n / 2, n - n / 2);

    int p1 = i;
    int p2 = i + n / 2;

    for (int j = p1; j < p1 + n; j++)
        ret[j] = arr[j];

    for (int j = i; j < n + i; j++) {
        if (p1 == n / 2 + i)
            arr[j] = ret[p2++];
        else if (p2 == n + i)
            arr[j] = ret[p1++];
        else if (ret[p1] > ret[p2])
            arr[j] = ret[p2++];
        else
            arr[j] = ret[p1++];
    }
}

應用場景:

我們經常將歸併排序和快速排序進行比較,歸併排序在空間複雜度上劣於快速排序,但是較為穩定。在實際的jdk中,複雜的複合型別陣列的排序是使用歸併排序來處理的,因為對於一個hashcode計算複雜的物件來說,移動的成本遠低於比較的成本。沒有差的排序方法,只是在每一種場合下最優解不同。

快速排序

排序原理:

快速排序可以理解成歸併排序的一種反義。歸併排序是先將陣列按照無序的方式二分 ,然後在遞迴到底時再進行排序合成。快速排序是先取出陣列中的一個元素(這個元素是任意的,通常我們會取出開始的元素,中間的元素,結尾的元素三者中的一個,下面的程式是按照取開始的元素進行編寫的)。按照這個元素進行對陣列的切分——將比這個元素小的放在該元素左端,比這個元素大的放在該元素右端。

我們以開始的元素作為切分元素舉例:在開始的元素下一個元素處放置一個索引i,在該陣列末尾放置一個索引j。讓i遞增,如果遇到一個比切分元素大的元素則停止;讓 j遞減,如果遇到一個比切分元素小的元素則停止。交換i和j索引指示的元素內容,繼續重複上一步操作,直到j在i的左邊。這個時候,交換切分元素和j索引指向的元素。這個切分點是j,將j之前的子陣列呼叫該方法遞迴下去,j之後的也呼叫該方法遞迴。

具體一些臨界條件、為什麼是j是切分點而i不是、為什麼直到j在i的左邊而不是i在j的右邊、為什麼j可以取到0而i不能取到N.length這些問題都要在程式碼中自己體會,這裡講不完而且也不一定能講明白,只有自己真正練過程式才能牢記。

時間複雜度:

O(NlogN)

快速排序的時間複雜度和歸併排序的相同,證明方法也基本類似。在不計算最底層單個元素的情況下,快排的遞迴函式的二叉樹一共有n層,且滿足2n=N。可求得n=log2N。每個陣列都從頭和尾反向遍歷,直到兩個索引錯位。所以總共遍歷陣列一次,一層上所有陣列都會被遍歷一次,所以每層訪問陣列N次。每層訪問陣列次數和×層數=N×log2N=NlogN。

空間複雜度:

O(logN)

快速排序需要在每層遞迴上維護一些資料,這些資料都是常量空間的資料,但是因為遞迴的層數與N相關,為logN層,所以在最壞的情況下,快速排序的空間複雜度為O(logN)。

穩定性:

不穩定

快速排序是不穩定的。原因是快速排序需要維護前後兩個索引,在索引發生對換的情況時,很有可能改變了某兩個相同元素的位置,這種跳躍性的交換是肯定會導致不穩定的。

快速排序實現:

  private static void quickSort(int[] arr, int lo, int he) {
        if (lo >= he)
            return;

        int left = lo + 1;
        int right = he;
        int pos = lo;

        while (true) {
            while (arr[left] <= arr[pos] && left < he)
                left++;
            while (arr[right] >= arr[pos] && right > lo)
                right--;
            if (left < right) {
                int temp = arr[left];
                arr[left] = arr[right];
                arr[right] = temp;
            } else
                break;
        }

        int temp = arr[pos];
        arr[pos] = arr[right];
        arr[right] = temp;

        pos = right;
        quickSort(arr, lo, pos - 1);
        quickSort(arr, pos + 1, he);

    }

應用場景:

快速排序是我們最為常用的排序方法,上述例子是未經過優化的一種簡單快排。實際上,我們通常認為在這些O(NlogN)複雜度的排序方法中,快速排序的效果是最好的。一般的sort函式排序使用的都是快速排序。

堆排序

排序原理:

堆排序是根據資料結構堆(Heap)改編而來的排序方法。這種排序方法和選擇排序很類似,但是實際效率上完爆選擇排序。首先我們要知道堆的概念。用一個數組模擬一個二叉樹,這個二叉樹具有:父節點恆大於(或恆小於)子節點的特點。如果我們用k來表示父節點,那麼兩個子節點就是2k+1和2k+2。如果一個子節點是k,那麼他的父節點就是(k-1)/2。

堆排序首先需要建堆,我們通過程式碼首先使得整個陣列符合一顆二叉樹的特點。然後我們將第0個元素 ,也就是最大的元素與最後一個元素交換,然後調整這顆二叉樹讓他再滿足堆的特點,再重複將第0個元素和倒數第二個元素交換。通過這樣的方法,不斷地取出最大的元素和末端元素交換,使得在這個陣列的末端的子陣列具有從小到大的順序,當整棵二叉樹都如此排序後,整個陣列就是排好序的狀態了。

時間複雜度:

O(NlogN)

堆排序的時間複雜度是NlogN級別的,實際上是2N+2NlogN次比較和N+NlogN次交換得來的。

在建堆的過程中需要小於2N次的比較和小於N次的交換,比如以一棵長度為7的二叉樹為例,構建這個堆需要調整三個大小為3的堆和1個大小為7的堆,大小為3的堆可能需要1次交換,大小為7的堆可能需要2次交換,在嘴還的情況下,一共需要3×1+1×2=5次交換<長度7。因為比較的方法是左子樹和右子樹比較一次,大者和父節點比較,最多需要兩次比較,共需要小於2N次比較。

在排序的過程中,從頭到尾排序所有的元素需要遍歷整個陣列為N次,在N次中每次需要刪除最大節點,刪除最大節點後的重排堆需要最多logN次的交換和2logN次的比較。交換和比較的次數和二叉樹的層數有關。

空間複雜度:

O(1)

堆排序不需要任何輔助陣列和遞迴的空間,只需要固定的常量空間。

穩定性:

不穩定

堆排序類似於選擇排序,所以肯定也是一種不穩定的排序方法。堆排序在陣列的末端維護有序子陣列,這種倒置插入的方法肯定是一種不穩定的排序方法。

堆排序實現:

private static void heapSort(int[] arr) {
        int n = arr.length;
        for (int i = (n - 2) / 2; i >= 0; i--)
            order(arr, i, n);
        for (int i = n - 1; i > 0; i--) {
            int temp = arr[i];
            arr[i] = arr[0];
            arr[0] = temp;
            order(arr, 0, i);
        }
    }
    private static void order(int[] arr, int i, int n) {
        while (2 * i + 1 < n) {
            int chg = 2 * i + 1;
            if (chg + 1 < n && arr[chg + 1] > arr[chg])
                chg++;
            if (arr[chg] <= arr[i])
                break;
            int temp = arr[chg];
            arr[chg] = arr[i];
            arr[i] = temp;
            i = chg;
        }
    }

應用場景:

堆排序是一種空間和時間最優的排序方法。但是堆排序也有著一定的侷限性,因為堆排序的交換和移動的兩個元素幾乎都不是相鄰的元素,所以無法利用計算機中的快取。如果使用堆排序,快取未命中的次數要遠高於大多數比較在相鄰元素間的演算法,在大型陣列中,這種情況導致時間得不償失,遠不如快速排序,歸併排序,甚至是希爾排序等。

六大排序方法總結

排序方法 時間複雜度 空間複雜度 穩定性
選擇排序 O(N2) O(1) 不穩定
插入排序 O(N2) O(1) 穩定
希爾排序 小於O(N2) O(1) 不穩定
歸併排序 O(NlogN) O(N) 穩定
快速排序 O(NlogN) O(logN) 不穩定
堆排序 O(NlogN) O(1) 不穩定