還不懂排序算法可以打我了!

0. 前言

       評價一個算法的好壞,除了其是否具有較低的時間複雜度之外,還有其穩定性以及空間複雜度。

       穩定性的判斷標註是數組元素的值相同時,進行元素交換後,相對位置如果發生變化,變化則不具有穩定性。

1. 冒泡算法

       冒泡排序可以說是每個程序員接觸到的第一個排序算法,其算法思想較爲簡單。

       在每一輪的排序中,對待排序子數組中的相鄰元素進行比較,如果逆序,則交換位置。當一輪結束後,待排序子數組中最大的元素便出現了子數組最後一個位置。

       具體如下圖所示:

在這裏插入圖片描述
       冒泡排序的代碼如下所示:

package sort;

import java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/15 20:48
 */
public class BubbleSort {

    public static void sort(int[] array){
        if (array == null){
            return;
        }

        for (int i = 0; i <array.length; i++){
        	// 不斷縮小子數組的範圍
            for (int j = 1; j < array.length - i; j++){
                // 如果逆序,則交換位置
                if (array[j] < array[j - 1]){
                    swap(array, j, j-1);
                }
            }
        }
    }

    private static void swap(int[] array, int j, int i) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
}

       一個長度爲n的數組,上述代碼需要進行n-1趟的排序。但是我們可以看上圖,在第一趟排序後,整個數字便完全有序了,無需進行後續操作。

       所以上述代碼可進行優化,優化後的代碼如下:

    public static void sort(int[] array){
        if (array == null){
            return;
        }

        for (int i = 0; i <array.length; i++){
            // flag 用於標識該趟有沒有進行元素交換,沒有交換的話,則說明已經完全沒有了
            boolean flag = false;
            for (int j = 1; j < array.length - i; j++){
                flag = true;
                if (array[j] < array[j - 1]){
                    swap(array, j, j-1);
                }
            }
            if(!flag){
                break;
            }
        }
    }

       接下來分析冒泡算法的效率。

       時間複雜度O(n)=(n^2),最壞的情況下,數組爲一個完全逆序的數組,此時不管是否優化,依然需要O(n)=(n^2),如果是一個有序的數組,那麼在優化後的代碼,只需要O(n)=(n)

       空間複雜度O(1),因爲我們只引用到了固定的常量而已。

       穩定性:穩定。

2. 插入算法

       插入排序有點像我們打撲克牌時,把小的牌插入到左邊,以此達到這個牌有序。

       將每一個元素插入到其他已經有序的元素中的適當位置。當前索引左邊的元素都是有序的,但他們的最終位置還不確定,爲了給更小的元素騰出空間,它們可能會向右移動。

在這裏插入圖片描述

package sort;

import java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/15 21:24
 */
public class InsertSort {

    public static void sort(int[] array){
        if (array == null){
            return;
        }
        for(int i = 1; i < array.length; i++){
            for (int j = i; j > 0 &&  array[j] < array[j-1]; j--){
                swap(array, j, j-1);
            }
        }
    }

    private static void swap(int[] array, int j, int i) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }
}

       接下來分析插入算法的效率。

       時間複雜度O(n)=(n^2),最壞的情況下,數組爲一個完全逆序的數組,需要O(n)=(n^2),如果是一個有序的數組,只需要O(n)=(n)

       空間複雜度O(1),因爲我們只引用到了固定的常量而已。

       穩定性:穩定。

3. 希爾排序

       對於插入排序來說,它只會交換相鄰的元素,因此它只能一點一點地從數組地一端移動到另一端,如果最小元素在最右邊,需要n-1次交換才能移動最左端。

       希爾排序算法思想:使數組中任意間隔爲h的元素都是有序地,這樣的數組稱爲h有序數組。然後不斷縮小h的值,直到h1,此時可看作是插入排序。相比於插入排序,這樣可以上述的極端情況下,更快的將元素交換到它的正確位置。

在這裏插入圖片描述

package sort;

import java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/15 21:46
 */
public class ShellSort {


    public static void sort(int[] array){
        if (array == null){
            return;
        }
        // 通常 h 並不是如此計算,有更好的方法,這裏只說明算法思想,暫如此採用
        int h = array.length / 2;

        while (h >= 1){
            for (int i = h; i < array.length; i++){
                for (int j = i; j > h && array[j] < array[j-h]; j -= h){
                    swap(array, j, j-h);
                }
            }
            // 不斷縮小 h 的值,直到爲 1
            h /= 2;
        }
    }
}

       接下來分析希爾算法的效率。

       時間複雜度O(n^(1.3—2)),最後h會退化到1,但如果前期的排序中,能夠將小元素直接放到前面,一定程度上可以較低時間複雜的。

       空間複雜度O(1),因爲我們只引用到了固定的常量而已。

       穩定性:不穩定。

4. 歸併排序

       算法思想:要將一個數組排序,可以先(遞歸地)將它分成兩半分別排序,然後將結果歸併起來。

在這裏插入圖片描述
       歸併排序,分爲兩部分,一部分爲遞歸,另一部分則爲合併。

       既然遞歸,則一定存在遞歸的終止條件,終止條件爲只存在一個元素,如何判斷只存在一個元素,左右下標相等。

       接着是進行合併,合併則是對兩個有序數組進行合併,藉助一個輔助數組很容易實現。

package sort;

import java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/15 22:13
 */
public class MergeSort {

    // 輔助數組
    private static int[] aux_array = null;

    public static void sort(int[] array){
        if (array == null){
            return;
        }

        aux_array = new int[array.length];

        int left = 0;
        int right = array.length - 1;
        recursive(array, left, right);
    }

    public static void recursive(int[] array, int left, int right){
        // 只存在一個元素,遞歸結束
        if (left == right)
            return;

        int mid = ((left - right) >> 1) + right;
        
        recursive(array, left, mid);
        recursive(array, mid+1, right);
        merge(array, left, mid, right);
    }

    private static void merge(int[] array, int left, int mid, int right) {

        int index = left;
        int i, j = 0;
        for (i = left, j = mid + 1; i <= mid && j <= right;){
            if(array[i] < array[j]){
                aux_array[index++] = array[i++];
            }else{
                aux_array[index++] = array[j++];
            }
        }
        while (i <= mid){
            aux_array[index++] = array[i++];
        }
        while (j <= right) {
            aux_array[index++] = array[j++];
        }

        // 此時,輔助數組已經將子數組合並完成,將輔助數組中的元素拷貝回原數組中
        for (index = left; index <= right; index++){
            array[index] = aux_array[index];
        }
    }
}

       接下來分析歸併算法的效率。

       時間複雜度O(nlog(n))

       空間複雜度O(n),有相關論文表示可以做到O(1),如感興趣,可自行查閱。

       穩定性:穩定。

5. 快速排序

       算法思想:每次在數組中,選擇一個哨兵,比哨兵大的元素放到其右邊,小的則放到其左邊,這樣每一次就將哨兵的元素給排好了,然後對哨兵左右的子數組進行遞歸上述操作。

在這裏插入圖片描述

       將大於哨兵的元素放到其右邊,小於哨兵的放到其左邊,並且返回哨兵的下標可以才用如下算法:

       將數組劃分爲兩個區域,分別爲大於和小於等於的區域,具體代碼如下:

public static int partition(int[] array, int left, int right){
        // 將數組劃分爲兩部分,low 區爲小於等於哨兵array[right]的區域,high 區爲大於哨兵array[right]的區域
        int low = left - 1;
        int high = right;
        for(int i = left; i < high; i++){
            if(array[i] <= array[right]){
                // low 區擴大
                swap(array, i, ++low);
            }else{
                // high 區擴大
                swap(array, i, --high);
                // 注意,這裏要進行 i--,因爲換來的元素不知道其大小
                i--;
            }
        }
        // 最後,將哨兵放到其正確的位置
        swap(array, right, high);
        return high;
    }

       完整代碼如下:

package sort;
import	java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/15 23:14
 */
public class QuickSort {


    public static void sort(int[] array){
        if (array == null){
            return;
        }

        int left = 0;
        int right = array.length - 1;
        quickSort(array, left, right);
    }

    public static void quickSort(int[] array, int left, int right){
        if (right == left || right <= 0 || left >= array.length - 1)
            return;

        int index = partition(array, left, right);
        quickSort(array, left, index-1);
        quickSort(array, index + 1, right);
    }

    public static int partition(int[] array, int left, int right){
        // 將數組劃分爲兩部分,low 區爲小於等於哨兵array[right]的區域,high 區爲大於哨兵array[right]的區域
        int low = left - 1;
        int high = right;
        for(int i = left; i < high; i++){
            if(array[i] <= array[right]){
                // low 區擴大
                swap(array, i, ++low);
            }else{
                // high 區擴大
                swap(array, i, --high);
                // 注意,這裏要進行 i--,因爲換來的元素不知道其大小
                i--;
            }
        }
        // 最後,將哨兵放到其正確的位置
        swap(array, right, high);
        return high;
    }
}

       快排優化,在上面的代碼中,我們每次只能返回一個下標。將數組劃分爲3塊,小於目標值區域,等於目標值區域,大於目標值區域。

       這樣當出現相同的元素時,每次返回相等元素的左右邊界,這樣可以對子數組的劃分變小,同時也可以避免無意義的排序。

    // 三向切分
    private static int[] partition_2(int[] array, int left, int right) {
        int lower = left-1;     // 小於目標值的區域
        int high = right;       // 大於等於目標值的區域

        for(int i = left; i < high; i++){
            if (array[i] < array[right]){
                swap(array, i, ++lower);
            }else if (array[i] > array[right]){
                swap(array, i, --high);
                i--;
            }else{
                continue;
            }
        }
        swap (array, high, right);
        return new int[]{lower,high+1};
    }

       接下來分析快排算法的效率。

       時間複雜度O(n)=(nlog(n))

       空間複雜度O(1),因爲我們只引用到了固定的常量而已。

       穩定性:不穩定。

6. 堆排序

       我們首先介紹一下大頂堆,大頂堆指堆頂的元素大於等於其左右兒子節點的元素,小頂堆則相反。

       如下圖,所示一個大頂堆。
在這裏插入圖片描述
       [5, 4, 1, 3, 2, 6, 7]形成大頂堆的形成過程如下:

在這裏插入圖片描述
在這裏插入圖片描述
       如上所示,便是大頂堆形成的過程。

       堆排序的思路則是將堆頂元素與最後一個元素進行交換,此時堆中的最後一個元素,便是數組最大的元素。交換後,由於不符合大頂堆的定義,所以我們需要對堆進行調整,使之保持大頂堆的形態。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

       現在我們倒着看每次從堆尾剝離的元素,可以發現其是一個遞增的有序序列。

       整個堆排序涉及兩個操作,上浮及下沉。

       上浮

       當某個節點的大於其父節點(或是在堆底加入了一個新的元素)時,我們需要由下而上恢復堆的順序。

       如果當前節點比其父節點大,則交換,直到我們遇到更大的父節點。

    /**
     * 
     * @param array         可以視作堆
     * @param insertIndex   堆中待插入的下標,需要注意的是,這裏的下標並不是數組最後一個下標,堆中每次插入一個元素後,insertIndex + 1
     * @param target        待插入元素
     */
    public static void swim(int[] array, int insertIndex, int target){
        // 將元素插入堆中
        array[insertIndex] = target;
        // 判斷是否符合大頂堆
        while (insertIndex > 0 && array[insertIndex] > array[insertIndex/2]){
            swap(array, insertIndex, insertIndex/2);
            insertIndex /= 2;
        }
    }

       下沉

       當某個節點的小於其子節點(例如將根節點替換爲一個較小的元素)時,我們需要由上而下恢復堆的順序。

       如果某個節點變得比它的兩個子節點或是其中之一更小了,那麼我們可以通過將它和它的兩個子節點中的較大者交換,直到沒有比它更大的子節點或者到達底部爲止。

    /**
     *
     * @param array          可以視作堆
     * @param index          需要下沉節點的下標,如果是堆排序,則其默認爲 0
     * @param heapLastIndex  堆中最後一個元素的下標,需要注意的是,這裏的下標並不是數組最後一個下標,每次移除堆尾的元素,heapLastIndex - 1
     */
    public static void sink(int[] array,int index, int heapLastIndex){
        // 不能超過堆中最後一個元素
        while (index * 2 < heapLastIndex){
            int leftIndex = 2 * index + 1;
            int rightIndex = 2 * index + 2;

            // 該變量用來記錄兩個節點中較大的下標
            int maxIndex = leftIndex;

            // 此時,說明一定存在兩個兒子節點
            if (rightIndex <= heapLastIndex){
                maxIndex = array[leftIndex] > array[rightIndex] ? leftIndex : rightIndex;
            }

            // 如果父節點,大於最大的兒子節點,符合大頂堆,退出
            if (array[index] > array[maxIndex]){
                break;
            }

            swap(array, index, maxIndex);
            index = maxIndex;
        }
    }

       完整代碼:

package sort;

import java.util.Arrays;

/**
 * @author wangzhao
 * @date 2020/6/16 1:08
 */
public class HeapSort {


    public static void sort(int[] array){
        if (array == null){
            return;
        }

        // 生成大頂堆
        for (int i=0; i < array.length; i++){
            swim(array, i, array[i]);
        }

        for (int i=array.length - 1; i > 0; i--){
            swap(array, 0, i);
            sink(array, 0, i-1);
        }
    }


    /**
     *
     * @param array         可以視作堆
     * @param insertIndex   堆中待插入的下標,需要注意的是,這裏的下標並不是數組最後一個下標,堆中每次插入一個元素後,insertIndex + 1
     * @param target        待插入元素
     */
    public static void swim(int[] array, int insertIndex, int target){
        // 將元素插入堆中
        array[insertIndex] = target;
        // 判斷是否符合大頂堆
        while (insertIndex > 0 && array[insertIndex] > array[insertIndex/2]){
            swap(array, insertIndex, insertIndex/2);
            insertIndex /= 2;
        }
    }

    /**
     *
     * @param array          可以視作堆
     * @param index          需要下沉節點的下標,如果是堆排序,則其默認爲 0
     * @param heapLastIndex  堆中最後一個元素的下標,需要注意的是,這裏的下標並不是數組最後一個下標,每次移除堆尾的元素,heapLastIndex - 1
     */
    public static void sink(int[] array,int index, int heapLastIndex){
        // 不能超過堆中最後一個元素
        while (index * 2 < heapLastIndex){
            int leftIndex = 2 * index + 1;
            int rightIndex = 2 * index + 2;

            // 該變量用來記錄兩個節點中較大的下標
            int maxIndex = leftIndex;

            // 此時,說明一定存在兩個兒子節點
            if (rightIndex <= heapLastIndex){
                maxIndex = array[leftIndex] > array[rightIndex] ? leftIndex : rightIndex;
            }

            // 如果父節點,大於最大的兒子節點,符合大頂堆,退出
            if (array[index] > array[maxIndex]){
                break;
            }

            swap(array, index, maxIndex);
            index = maxIndex;
        }
    }

    private static void swap(int[] array, int i, int j) {
        int temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

}

       接下來分析堆排序的效率。

       時間複雜度O(n)=(nlog(n))

       空間複雜度O(1),因爲我們只引用到了固定的常量而已。

       穩定性:不穩定。

7. 基數排序

       基數排序可以參考之前寫的一篇博客。基數排序

8. 總結

       至此,所有的主流排序算法都已介紹加Coding完成,有很多有意思還未介紹的地方。如堆排序中可以被擴展到的優先隊列,這個挺有趣,面試中可能會有這樣一個問題,如何在100萬的數字中,選擇最大or最小的10個數。

       雖然上面既有時間複雜度爲O(n^2),也有O(nlong)複雜度的排序算法,但並不意味着時間複雜度越低,表現就越優秀,還和其數據量相同。

       對於工程排序,往往是採用多種排序算法的結合,這裏我們以Arrays.sort爲例,觀察其代碼:

	static void sort(int[] a, int left, int right,
                     int[] work, int workBase, int workLen) {
        // Use Quicksort on small arrays
        if (right - left < QUICKSORT_THRESHOLD) {
            sort(a, left, right, true);
            return;
        }

        /*
         * Index run[i] is the start of i-th run
         * (ascending or descending sequence).
         */
        int[] run = new int[MAX_RUN_COUNT + 1];
        int count = 0; run[0] = left;

        // Check if the array is nearly sorted
        for (int k = left; k < right; run[count] = k) {
            if (a[k] < a[k + 1]) { // ascending
                while (++k <= right && a[k - 1] <= a[k]);
            } else if (a[k] > a[k + 1]) { // descending
                while (++k <= right && a[k - 1] >= a[k]);
                for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) {
                    int t = a[lo]; a[lo] = a[hi]; a[hi] = t;
                }
            } else { // equal
                for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) {
                    if (--m == 0) {
                        sort(a, left, right, true);
                        return;
                    }
                }
            }

            /*
             * The array is not highly structured,
             * use Quicksort instead of merge sort.
             */
            if (++count == MAX_RUN_COUNT) {
                sort(a, left, right, true);
                return;
            }
        }

        // Check special cases
        // Implementation note: variable "right" is increased by 1.
        if (run[count] == right++) { // The last run contains one element
            run[++count] = right;
        } else if (count == 1) { // The array is already sorted
            return;
        }

        // Determine alternation base for merge
        byte odd = 0;
        for (int n = 1; (n <<= 1) < count; odd ^= 1);

        // Use or create temporary array b for merging
        int[] b;                 // temp array; alternates with a
        int ao, bo;              // array offsets from 'left'
        int blen = right - left; // space needed for b
        if (work == null || workLen < blen || workBase + blen > work.length) {
            work = new int[blen];
            workBase = 0;
        }
        if (odd == 0) {
            System.arraycopy(a, left, work, workBase, blen);
            b = a;
            bo = 0;
            a = work;
            ao = workBase - left;
        } else {
            b = work;
            ao = 0;
            bo = workBase - left;
        }

        // Merging
        for (int last; count > 1; count = last) {
            for (int k = (last = 0) + 2; k <= count; k += 2) {
                int hi = run[k], mi = run[k - 1];
                for (int i = run[k - 2], p = i, q = mi; i < hi; ++i) {
                    if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) {
                        b[i + bo] = a[p++ + ao];
                    } else {
                        b[i + bo] = a[q++ + ao];
                    }
                }
                run[++last] = hi;
            }
            if ((count & 1) != 0) {
                for (int i = right, lo = run[count - 1]; --i >= lo;
                    b[i + bo] = a[i + ao]
                );
                run[++last] = right;
            }
            int[] t = a; a = b; b = t;
            int o = ao; ao = bo; bo = o;
        }
    }

       根據數據量的多少,採用了插入排序,快速排序,歸併排序三種排序的組合。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章