經典排序演算法 — java 實現
排序演算法的好壞對於效率的影響十分顯著。好的排序演算法排序100萬個整數可能只需要一秒(不考慮硬體因素),不好的排序演算法可能需要一個小時甚至幾個小時。
常見的排序演算法有氣泡排序、插入排序、堆排序、快速排序等,這些排序都屬於基於比較的排序,因此這些演算法的時間複雜度都不能突破 O(NlogN)。
還有另一類非基於比較的排序,包括基數排序、桶排序、計數排序。
氣泡排序一般情況下效率比較低,在此不再贅述。
插入排序(insert sort)
插入排序就像大多數玩家打牌時 排列手中紙牌的情景。從第二張牌開始,根據之前的牌的大小,我們把牌插到合適的位置。這樣在抓下一張牌之前,手中的牌就已經是排好序的了。
如果輸入已經是升序序列,這時時間複雜度為 O(n)。平均來說插入排序時間複雜度為 O(n2)。
注意:雖然氣泡排序和插入排序時間複雜度相同,但一般情況下,插入排序仍然比氣泡排序效率高的多。O(n2) 只是一個理論上的界限,它們的常量部分並不相同,就好像是 n2 和 1000n2 對應的時間複雜度都是 O(n2)。
Java程式碼實現插入排序的思路是將新元素取出,從右到左依次與已排序的元素比較,如果該元素大於新元素,那麼將其移動到新元素的位置,接著繼續比較,直到已排序的元素小於等於新元素,這時將新元素插入至此元素後面的位置,所以此插入排序是穩定的。
需要注意的是,排序演算法是否穩定是由具體演算法決定的,不穩定的演算法在某種條件下可以變為穩定的演算法,而穩定的演算法在某種條件下也可以變為不穩定的演算法,比如比較的邊界的選擇(> 還是 >=)。
public class InsertSort {
private static int[] randomArray;
public static int[] factory(int length) {
Random random = new Random();
randomArray = new int[length];
for (int i = 0; i < length; i++) {
randomArray[i] = random.nextInt(10000);
}
return randomArray;
}
public static void insertSort(int[] array) {
//Instant為Java8新增,用來獲取時間
Instant begin = Instant.now();
for (int i = 1; i < array.length; i++) {
int t = array[i];
int j;
for (j = i; j > 0 && array[j - 1] > t; j--)
array[j] = array[j - 1];
array[j] = t;
}
Instant end = Instant.now();
System.out.println(Duration.between(begin, end));
}
public static void main(String[] args) {
int[] array = factory(100000);
insertSort(array);
}
}
輸出結果:
// ISO 日期時間表示格式
PT4.191S
這裡只進行了100000個整數的排序,只需要4.191s。對於不是很多資料的情況來說,這已經夠用了。但當資料增加到一百萬的時候,在我的機器上用了五分多鐘。在資料量大的時候排序的速度還是不盡如人意。
折半插入排序
折半插入排序是對插入排序的改進,又稱二分插入排序,折半插入排序也是穩定的。
在傳統插入排序中,在將一個數插入到已排序陣列中時,要逐一比較。我們可以採用折半查詢的方法尋找要插入的位置,這樣可以減少比較次數。示例如下:
public static void binaryInsertSort(int[] array) {
Instant begin = Instant.now();
for (int i = 1; i < array.length; i++) {
int t = array[i];
int low = 0, high = i - 1;
while (low <= high) {
int pivot = (low + high) >> 1;
if (t < array[pivot])
high = pivot - 1;
else
low = pivot + 1;
}
for (int j = i; j > low; j--) {
array[j] = array[j - 1];
}
array[low] = t;
}
Instant end = Instant.now();
System.out.println(Duration.between(begin, end));
}
快速排序(quick sort)
快速排序運用了分治思想:要解決規模為n的問題,可以遞迴地解決兩個規模近似為 n/2 的子問題,然後對他們的答案進行合併以得到整個問題的答案。分治法是很多高效演算法的基礎,如排序演算法(快速排序、歸併排序)、傅立葉變換(快速傅立葉變換)。
快速排序於1962年被提出,快速排序的執行時間取決於劃分是否平衡。平均情況下快速排序的時間複雜度為 O(NlogN)。它的排序過程如下:
現有陣列array如下所示:
5 | 8 | 3 | 7 | 4 | 1 | 6 | 2 |
對陣列進行排序(升序),首先要選擇一個基準數,小於等於基準數的放到左邊,大於基準數的放到右邊。一般選陣列的第一個數作為基準數。在這裡將m = array[0]作為基準數。
剩下的七個數要進行"站邊"。這裡有兩種處理方式,一種是從左往右掃描陣列(單向劃分),一種是兩邊依次進行掃描(雙向劃分)。雙向劃分的效能更好,因此這裡只討論雙向劃分。
那應該從左邊開始掃描還是從右邊開始掃描呢?答案是最好從右邊開始。
因為我們將array[0]的元素拿出來賦給了一個變數,相當於第一個元素的位置空了下來,這時候需要從右半部分選一個小於等於基準數的放到這個位置。如果從左邊開始的話也能實現,但沒有從右邊開始直觀、易於實現。
示意圖如下:
5 | ||||||
2 | 8 | 3 | 7 | 4 | 1 | 6 |
5 | ||||||
2 | 3 | 7 | 4 | 1 | 6 | 8 |
5 | ||||||
2 | 1 | 3 | 4 | 7 | 6 | 8 |
完整的Java程式碼如下:
public static void quickSort(int[] array, int left, int right) {
if (left < right) {
int m = array[left];
int i = left, j = right;
while (i < j) {
while (i < j) {
if (array[j] < m) {
array[i++] = array[j];
break;
}
j--;
}
while (i < j) {
if (array[i] > m) {
array[j--] = array[i];
break;
}
i++;
}
}
array[i] = m;
quickSort(array, left, i - 1);
quickSort(array, i + 1, right);
}
}
經過測試,對一個長度為一百萬的隨機int陣列排序,需要0.2s左右,相比插入排序效率提升很多。
注意,當快速排序的輸入是一個有序序列的時候,快速排序會退化成氣泡排序,它的時間複雜度為O(n2)。每次劃分實際上只減少了一個元素,此時的最大遞迴深度為N,也就是陣列的長度。此時如果對較大型的陣列進行排序,可能會出現StackOverflowError(取決於JVM棧的大小)。
解決方法是在每次選擇基準數的時候採用隨機選擇的方法而不是固定選擇第一個。
示例如下:
public static void quickSort(int[] array, int left, int right) {
if (left < right) {
Random random = new Random();
int n = random.nextInt(right - left + 1) + left;
int m = array[n];
array[n] = array[left];
array[left] = m;
int i = left, j = right;
while (i < j) {
while (i < j) {
if (array[j] < m) {
array[i++] = array[j];
break;
}
j--;
}
while (i < j) {
if (array[i] > m) {
array[j--] = array[i];
break;
}
i++;
}
}
array[i] = m;
quickSort(array, left, i - 1);
quickSort(array, i + 1, right);
}
}
這樣當輸入是一個有序序列時,仍能保持較快的排序速度,而不會丟擲StackOverflowError。
從實現過程可知,快速排序不是穩定的。
歸併排序(merge sort)
歸併排序與快速排序一樣都採用了分治思想,它將待排序序列分為若干個子序列,每個子序列是有序的,然後再把有序子序列合併為整體有序序列。
若將兩個有序表合併成一個有序表,稱為二路歸併。
歸併排序的時間複雜度與快速排序一樣都為 O(NlogN),但歸併排序是穩定的,這在有些情況下是很重要的。歸併排序的空間複雜度是 O(n),可以看到歸併排序相比其它排序演算法所需的空間更多,但帶來的是時間效率的提升。
假設現在有一個數組a,其中有10個元素。
- 第一次分割,得到兩個子陣列,分別為a[0]-a[4],a[5]-a[9]
- 對左半部分繼續分割,得到兩個子陣列,分別為a[0]-a[2],a[3]-a[4]
- 對左半部分繼續分割,得到兩個子陣列,分別為a[0]-a[1],a[2]
- 對左半部分繼續分割,得到兩個子陣列,分別為a[0],a[1]
- 兩個子陣列都只有一個元素,不需再分,對這兩個子陣列排序,將結果存在一個臨時陣列中
- 返回到3繼續執行下面的程式碼
- ......
實現歸併排序有兩種方式,遞迴法實現與迭代法實現。
遞迴法實現如下:
public static void mergeSort(int[] arr) {
int[] temp = new int[arr.length];
mergeSortByRecursive(arr, temp, 0, arr.length - 1);
}
private static void mergeSortByRecursive(int[] arr, int[] temp, int start, int end) {
if (start >= end)
return;
int middle = ((end - start) >> 1) + start;
int start1 = start, start2 = middle + 1;
//左半部分排序
mergeSortByRecursive(arr, temp, start1, middle);
//右半部分排序
mergeSortByRecursive(arr, temp, start2, end);
//左右兩部分合並
int k = start;
while (start1 <= middle && start2 <= end)
temp[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= middle)
temp[k++] = arr[start1++];
while (start2 <= end)
temp[k++] = arr[start2++];
for (k = start; k <= end; k++)
arr[k] = temp[k];
}
雖然網上有很多現成的例子,還是建議大家自己動手敲一下,加深理解。只有自己動手去做過才會發現許多意想不到的錯誤,比如移位運算:
int middle = 2 >> 1 + 3;
結果是0而不是4,因為 >> 的優先順序低於 +。
迭代法的實現可以參考維基百科,相比遞迴,迭代法顯得不是很直觀。
注:維基百科Java迭代版說原版程式碼的迭代次數少了一次,沒有考慮到奇數列陣列的情況,因此block的上限是陣列長度的兩倍,但block的上限是陣列的長度就可以了。
Java中的排序函式
Arrays.sort():
- 對於基本型別陣列,如果陣列長度大於等於286且連續性好,歸併排序;如果長度大於等於286且連續性不好,快速排序;如果長度小於286且大於等於47,快速排序;如果長度小於47,插入排序
- 對於物件型別陣列,使用TimSort排序。Timsort結合了歸併排序和插入排序,對各種情況都有比較好的效率,而且是穩定性排序。簡單來說,當陣列長度小於32,二分插入排序;大於32,優化的歸併排序