排序演算法 Java實現

mortal同學發表於2019-03-22

選擇排序

核心思想

選擇最小元素,與第一個元素交換位置;剩下的元素中選擇最小元素,與當前剩餘元素的最前邊的元素交換位置。

分析

選擇排序的比較次數與序列的初始排序無關,比較次數都是N(N-1)/2

移動次數最多隻有n-1次。

因此,時間複雜度為O(N^2),無論輸入是否有序都是如此,輸入的順序只決定了交換的次數,但是比較的次數不變。

選擇排序是不穩定的,比如5 6 5 3的情況。

程式碼

public class SelectionSort {
    public void selectionSort(int[] nums){
        if(nums==null)
            return;
        for(int i=0;i<nums.length;i++) {
            int index = i;
            for (int j = i; j < nums.length; j++) {
                if (nums[j] < nums[index]) {
                    index = j;
                }
            }
            swap(nums, i, index);
        }
    }
}
複製程式碼

氣泡排序:

核心思想

從左到右不斷交換相鄰逆序的元素,這樣一趟下來把最大的元素放到了最右側。不斷重複這個過程,知道一次迴圈中沒有發生交換,說明已經有序,退出。

分析

  • 當原始序列有序,比較次數為 n-1 ,移動次數為0,因此最好情況下時間複雜度為 O(N)
  • 當逆序排序時,比較次數為 N(N-1)/2,移動次數為 3N(N-1)/2,因此最壞情況下時間複雜度為 O(N^2)
  • 平均時間複雜度為 O(N^2)。

元素兩兩交換時,相同元素前後順序沒有改變,因此具有穩定性。

程式碼

public class BubbleSort {
    public void bubbleSort(int[] nums){
        for(int i=nums.length-1;i>0;i--){
            boolean sorted=false;
            for(int j=0;j<i;j++){
                if(nums[j]>nums[j+1]){
                    Sort.swap(nums,j,j+1);
                    sorted=true;
                }
            }
            if(!sorted)
                break;
        }
    }
複製程式碼

插入排序

核心思想

每次將當前元素插入到左側已經排好序的陣列中,使得插入之後左側陣列依然有序。

分析

因為插入排序每次只能交換相鄰元素,令逆序數量減少1,因此交換次數等於逆序數量。

因此,插入排序的複雜度取決於陣列的初始順序。

  • 陣列已經有序,需要 N-1 次比較和0次交換,時間複雜度為 O(N)。
  • 陣列完全逆序,需要 N(N-1)/2 次比較和交換 N(N-1)/2 次,時間複雜度為 O(N^2)
  • 平均情況下,時間複雜度為 O(N^2)

插入排序具有穩定性

程式碼

public class InsertionSort {
    public void insertionSort(int[] nums){
        for(int i=1;i<nums.length;i++){
            for(int j=i;j>0;j--){
                if(nums[j]<nums[j-1])
                    swap(nums,j,j-1);
                else
                    break;//已經放到正確位置上了
            }
        }
    }
}
複製程式碼

希爾排序

對於大規模的陣列,插入排序很慢,因為它只能交換相鄰的元素,每次只能將逆序數量減少1。

核心思想

希爾排序為了解決插入排序的侷限性,通過交換不相鄰的元素,每次將逆序數量減少大於1。希爾排序使用插入排序對間隔為 H 的序列進行排序,不斷減少 H 直到 H=1 ,最終使得整個陣列是有序的。

時間複雜度

希爾排序的時間複雜度難以確定,並且 H 的選擇也會改變其時間複雜度。

希爾排序的時間複雜度是低於 O(N^2) 的,高階排序演算法只比希爾排序快兩倍左右。

穩定性

希爾排序不具備穩定性。

程式碼

public class ShellSort {
    public void shellSort(int[] nums){
        int N=nums.length;
        int h=1;

        while(h<N/3){
            h=3*h+1;
        }

        while(h>=1){
            for(int i=h;i<N;i++){
                for(int j=i;j>0;j--){
                    if(nums[j]<nums[j-1]){
                        swap(nums,j,j-1);
                    }else{
                        break;//已經放到正確位置上了
                    }
                }
            }
        }
    }
}
複製程式碼

歸併排序

核心思想

將陣列分為兩部分,分別進行排序,然後進行歸併。

歸併方法

public void merge(int[] nums, int left, int mid, int right) {
        int p1 = left, p2 = mid + 1;
        int[] tmp = new int[right-left+1];
        int cur=0;
        
        //兩個指標分別指向左右兩個子陣列,選擇更小者放入輔助陣列
        while(p1<=mid&&p2<=right){
            if(nums[p1]<nums[p2]){
                tmp[cur++]=nums[p1++];
            }else{
                tmp[cur++]=nums[p2++];
            }
        }
        
        //將還有剩餘的陣列放入到輔助陣列
        while(p1<=mid){
            tmp[cur++]=nums[p1++];
        }
        while(p2<=right){
            tmp[cur++]=nums[p2++];
        }

        //拷貝
        for(int i=0;i<tmp.length;i++){
            nums[left+i]=tmp[i];
        }
    }
複製程式碼

程式碼實現

遞迴方法:自頂向下

通過遞迴呼叫,自頂向下將一個大陣列分成兩個小陣列進行求解。

public void up2DownMergeSort(int[] nums, int left, int right) {
        if(left==right)
            return;
        int mid=left+(right-left)/2;
        mergeSort(nums,left,mid);
        mergeSort(nums,mid+1,right);
        merge(nums,left,mid,right);
    }
複製程式碼

非遞迴:自底向上

public void down2UpMergeSort(int[] nums) {
        int N = nums.length;
       
        for (int sz = 1; sz < N; sz += sz) {
            for (int lo = 0; lo < N - sz; lo += sz + sz) {
                merge(nums, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, N - 1));
            }
        }
    }
複製程式碼

分析

把一個規模為N的問題分解成兩個規模分別為 N/2 的子問題,合併的時間複雜度為 O(N)。T(N)=2T(N/2)+O(N)。

得到其時間複雜度為 O(NlogN),並且在最壞、最好和平均情況下時間複雜度相同。

歸併排序需要 O(N) 的空間複雜度。

歸併排序具有穩定性。

快速排序

核心思想

快速排序通過一個切分元素 pivot 將陣列分為兩個子陣列,左子陣列小於等於切分元素,右子陣列大於等於切分元素,將子陣列分別進行排序,最終整個排序。

partition

取 a[l] 作為切分元素,然後從陣列的左端向右掃描直到找到第一個大於等於它的元素,再從陣列的右端向左掃描找到第一個小於它的元素,交換這兩個元素。不斷進行這個過程,就可以保證左指標 i 的左側元素都不大於切分元素,右指標 j 的右側元素都不小於切分元素。當兩個指標相遇時,將切分元素 a[l] 和 a[j] 交換位置。

private int partition(int[] nums, int left, int right) {
        int p1=left,p2=right;
        int pivot=nums[left];
        while(p1<p2){
            while(nums[p1++]<pivot&&p1<=right);
            while(nums[p2--]>pivot&&p2>=left);
            swap(nums,p1,p2);
        }
        swap(nums,left,p2);
        return p2;
    }
複製程式碼

程式碼實現

public void sort(T[] nums, int l, int h) {
        if (h <= l)
            return;
        int j = partition(nums, l, h);
        sort(nums, l, j - 1);
        sort(nums, j + 1, h);
    }
複製程式碼

分析

最好的情況下,每次都正好將陣列對半分,遞迴呼叫次數最少,複雜度為 O(NlogN)。

最壞情況下,是有序陣列,每次只切分了一個元素,時間複雜度為 O(N^2)。為了防止這種情況,在進行快速排序時需要先隨機打亂陣列。

不具有穩定性。

改進

  1. 切換到插入排序:遞迴的子陣列規模小時,用插入排序。
  2. 三數取中:最好的情況下每次取中位數作為切分元素,計算中位數代價比較高,採用取三個元素,將中位數作為切分元素。

三路快排

對於有大量重複元素的陣列,將陣列分為小於、等於、大於三部分,對於有大量重複元素的隨機陣列可以線上性時間內完成排序。

public void threeWayQuickSort(int[] nums,int left,int right){
        if(right<=left)
            return;

        int lt=left,cur=left+1,gt=right;
        int pivot=nums[left];
        while(cur<=gt){
            if(nums[cur]<pivot){
                swap(nums,lt++,cur++);
            }else if(nums[cur]>pivot){
                swap(nums,cur,gt--);
            }else{
                cur++;
            }
        }
        threeWayQuickSort(nums,left,lt-1);
        threeWayQuickSort(nums,gt+1,right);
    }
複製程式碼

基於 partition 的快速查詢

利用 partition() 可以線上性時間複雜度找到陣列的第 K 個元素。

假設每次能將陣列二分,那麼比較的總次數為 (N+N/2+N/4+..),直到找到第 k 個元素,這個和顯然小於 2N。

public int select(int[] nums, int k) {
    int l = 0, h = nums.length - 1;
    while (h > l) {
        int j = partition(nums, l, h);

        if (j == k) {
            return nums[k];

        } else if (j > k) {
            h = j - 1;

        } else {
            l = j + 1;
        }
    }
    return nums[k];
}
複製程式碼

堆排序

堆可以用陣列來表示,這是因為堆是完全二叉樹,而完全二叉樹很容易就儲存在陣列中。位置 k 的節點的父節點位置為 k/2,而它的兩個子節點的位置分別為 2k 和 2k+1。在這裡,從下標為1的索引開始 的位置,是為了更清晰地描述節點的位置關係。

上浮和下沉

當一個節點比父節點大,不斷交換這兩個節點,直到將節點放到位置上,這種操作稱為上浮。

private void shiftUp(int k) {
        while (k > 1 && heap[k / 2] < heap[k]) {
            swap(k / 2, k);
            k = k / 2;
        }
    }
複製程式碼

當一個節點比子節點小,不斷向下進行比較和交換,當一個基點有兩個子節點,與最大節點進行交換。這種操作稱為下沉。

private void shiftDown(int k){
        while(2*k<=size){
            int j=2*k;
            if(j<size&&heap[j]<heap[j+1])
                j++;
            if(heap[k]<heap[j])
                break;
            swap(k,j);
            k=j;
        }
    }
複製程式碼

堆排序

把最大元素和當前堆中陣列的最後一個元素交換位置,並且不刪除它,那麼就可以得到一個從尾到頭的遞減序列。

構建堆 建立堆最直接的方法是從左到右遍歷陣列進行上浮操作。一個更高效的方法是從右到左進行下沉操作。葉子節點不需要進行下沉操作,可以忽略,因此只需要遍歷一半的元素即可。

交換堆頂和最壞一個元素,進行下沉操作,維持堆的性質。

public class HeapSort {
    public void sort(int[] nums){
        int N=nums.length-1;
        for(int k=N/2;k>=1;k--){
            shiftDown(nums,k,N);
        }

        while(N>1){
            swap(nums,1,N--);
            shiftDown(nums,1,N);
        }
        System.out.println(Arrays.toString(nums));
    }

    private void shiftDown(int[] heap,int k,int N){
        while(2*k<=N){
            int j=2*k;
            if(j<N&&heap[j]<heap[j+1])
                j++;
            if(heap[k]>=heap[j])
                break;
            swap(heap,k,j);
            k=j;
        }
    }

    private void swap(int[] nums,int i,int j){
        int t=nums[i];
        nums[i]=nums[j];
        nums[j]=t;
    }
}
複製程式碼

分析

建立堆的時間複雜度是O(N)。

一個堆的高度為 logN, 因此在堆中插入元素和刪除最大元素的複雜度都是 logN。

在堆排序中,對N個節點進行下沉操作,複雜度為 O(NlogN)。

現代作業系統很少使用堆排序,因為它無法利用區域性性原理進行快取,也就是陣列元素很少和相鄰的元素進行比較和交換。

比較

排序演算法 最好時間複雜度 平均時間複雜度 最壞時間複雜度 空間複雜度 穩定性 適用場景
氣泡排序 O(N) O(N^2) O(N^2) O(1) 穩定
選擇排序 O(N) O(N^2) O(N^2) O(1) 不穩定 執行時間和輸入無關,資料移動次數最少,資料量較小的時候適用。
插入排序 O(N) O(N^2) O(N^2) O(1) 穩定 資料量小、大部分已經被排序
希爾排序 O(N) O(N^1.3) O(N^2) O(1) 不穩定
快速排序 O(NlogN) O(NlogN) O(N^2) O(logN)-O(N) 不穩定 最快的通用排序演算法,大多數情況下的最佳選擇
歸併排序 O(NlogN) O(NlogN) O(NlogN) O(N) 穩定 需要穩定性,空間不是很重要
堆排序 O(NlogN) O(NlogN) O(NlogN) O(1) O(1) 不穩定
  • 當規模較小,如小於等於50,採用插入或選擇排序。
  • 當元素基本有序,選擇插入、冒泡或隨機的快速排序。
  • 當規模較大,採用 O(NlogN)排序演算法。
  • 當待排序的關鍵字隨機分佈時,快速排序的平均時間最短。
  • 當需要保證穩定性的時候,選用歸併排序。

非比較排序

之前介紹的演算法都是基於比較的排序演算法,下邊介紹兩種不是基於比較的演算法。

計數排序

已知資料範圍 x1 到 x2, 對範圍中的元素進行排序。可以使用一個長度為 x2-x1+1 的陣列,儲存每個數字對應的出現的次數。最終得到排序後的結果。

桶排序

桶排序假設待排序的一組數均勻獨立的分佈在一個範圍中,並將這一範圍劃分成幾個桶。然後基於某種對映函式,將待排序的關鍵字 k 對映到第 i 個桶中。接著將各個桶中的資料有序的合併起來,對每個桶中的元素可以進行排序,然後輸出得到一個有序序列。

相關文章