選擇排序
核心思想
選擇最小元素,與第一個元素交換位置;剩下的元素中選擇最小元素,與當前剩餘元素的最前邊的元素交換位置。
分析
選擇排序的比較次數與序列的初始排序無關,比較次數都是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)。為了防止這種情況,在進行快速排序時需要先隨機打亂陣列。
不具有穩定性。
改進
- 切換到插入排序:遞迴的子陣列規模小時,用插入排序。
- 三數取中:最好的情況下每次取中位數作為切分元素,計算中位數代價比較高,採用取三個元素,將中位數作為切分元素。
三路快排
對於有大量重複元素的陣列,將陣列分為小於、等於、大於三部分,對於有大量重複元素的隨機陣列可以線上性時間內完成排序。
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 個桶中。接著將各個桶中的資料有序的合併起來,對每個桶中的元素可以進行排序,然後輸出得到一個有序序列。