排序是科學計算和數據處理必不可少的一個環節,今天起我們就來聊聊排序。
本文將介紹三個初級排序算法
- 冒泡排序
- 選擇排序
- 插入排序
先來看下圖這樣的一組初始數據,每一個矩形的高度都與其下方的數字成比例,數值越大則矩形的高度就越高。
假設有如下兩個問題,我們該如何求解。
- 找出最(小/大)值
- 找出第k(小/大)的值
顯然,在亂序的數組中這兩個問題都不太容易求解,但如果數據是有序的就會容易很多。
冒泡排序
冒泡排序是最容易想到的排序算法。以對N個元素的數組進行升序排序為例,其基本思路如下:
- 從數組內的前兩個元素開始,將這兩個元素進行比較,如果前一個元素大于后一個元素,則交換兩者的位置
- 接著取數組中的第2-3個元素進行比較,若第2個元素大于第3個元素,則交換兩者的位置
- 循環往復,直到數組中的最后兩個元素,此時,若第N-1個元素大于第N個元素,則交換它們的位置。經過一輪的比較與交換,我們已經得到了數組中最大的元素,并將其安置在了數組的第N位。
- 經過前三個步驟,我們將數組中最大的元素放到了第N位,下邊只用排序數組中的前N-1個元素即可。此時我們將N的值減1,并判斷新N的值,若新N>0,則循環1-3步驟;若新N=0,則代表我們已經完成了排序。
冒泡排序的過程(剪輯版)如下圖所示,你也可以點擊這里查看完整的冒泡排序過程。
我們知道,水杯中出現氣泡時,越大的氣泡浮力越大,上升速度也就越快,最先到達水面,冒牌排序中每輪遴選較大元素放置末尾的行為與水中氣泡上升的現象十分相似,因此得名冒泡排序。
冒泡排序代碼
public static void bubbleSort(Integer arr[]) {
int compareCount = 0;//比較次數
int swapCount = 0;//交換次數
long before = System.currentTimeMillis();
for (int i = 0; i < arr.length; i++) { //外層循環,與數組元素個數相關
for (int j = 1; j < arr.length - i; j++) { //內層循環,只需在前 n-1 個元素內進行相鄰比較
compareCount++;
if (arr[j - 1] > arr[j]) {
swapCount++;
swap(arr, j - 1, j);
}
}
}
long after = System.currentTimeMillis();
System.out.println("冒泡排序耗時:"+(after-before)+"ms,"+"比較次數:"+compareCount+",交換次數:"+swapCount);
}
使用上述代碼對以下兩組數據排序時,其比較次數一致,僅交換次數不同。但顯而易見的是,第二組本身就是有序的,也就是說上邊的代碼中,存在冗余比較。
3 5 1 6 10 9 11
1 3 5 7 9 10 11
原有數組 3 5 1 6 10 9 11
冒泡排序耗時:0ms 比較次數:21,交換次數:3
排序后 1 3 5 6 9 10 11
---
原有數組 1 3 5 6 9 10 11
冒泡排序耗時:0ms 比較次數:21,交換次數:0
排序后 1 3 5 6 9 10 11
針對這樣的問題,我們可以采用如下思路對冒泡排序的代碼進行優化。
- 當某輪內循環沒有發生元素交換時,表明數組已然有序,無需再進行后續的比較,此時可直接中止循環
冒泡排序優化代碼
public static void bubbleSortOpt(Integer arr[]) {
int compareCount = 0;//比較次數
int swapCount = 0;//交換次數
long before = System.currentTimeMillis();
for (int i = 0; i < arr.length; i++) { //外層循環,與數組元素個數相關
boolean isSwap = false; //交換標記,每輪外循環開始時,將其置位false
for (int j = 1; j < arr.length - i; j++) { //內層循環,只需在前 n-1 個元素內進行相鄰比較
compareCount++;
if (arr[j - 1] > arr[j]) {
isSwap = true;//若內循環內發生交換,則將交換標志置位true
swapCount++;
swap(arr, j - 1, j);
}
}
if (!isSwap) {//判斷循環標記,若未發生交換,則跳出循環
break;
}
}
long after = System.currentTimeMillis();
System.out.println("冒泡排序耗時:"+(after-before)+"ms,"+"比較次數:"+compareCount+",交換次數:"+swapCount);
}
原有數組 3 5 1 6 10 9 11
冒泡排序耗時:0ms 比較次數:15,交換次數:3
排序后 1 3 5 6 9 10 11
---
原有數組 1 3 5 6 9 10 11
冒泡排序耗時:0ms 比較次數:5,交換次數:0
排序后
選擇排序
選擇排序的思路同樣很簡單,以對含有N個元素的數組進行升序排序為例,其步驟如下所示:
- 假設首元素是最小的,并記錄其索引值為minIndex,遍歷數組,分別與其比較,若數組中第i個元素的數值小于第minIndex個元素的數組,則將i賦值與minIndex。
- 遍歷結束后,我們得到了數值最小的元素的索引值,將其與首元素進行交換,交換后的首元素即為數組中數值最小的元素。
- 經過前邊兩個步驟,此時數組中可分為首元素和與第2個元素開始到末尾的N-1個元素。判斷N-1,若N-1>0,則將第二個元素視作首元素,重復步驟1-2;若N-1=0,則表明數組已然有序,中止循環。
選擇排序的過程(剪輯版)如下圖所示,你也可以點擊這里查看完整的選擇排序過程。
根據這個思路,不難寫出其代碼
public static void selectSort(Integer arr[]) {
int compareCount = 0;
int swapCount = 0;
long before = System.currentTimeMillis();
for (int i = 0; i < arr.length; i++) {
int minIndex = i;
for (int j = i+1; j < arr.length - i; j++) {
compareCount++;
if (arr[j]<arr[minIndex]){
minIndex = j;
}
}
if (minIndex!=i){
swapCount++;
swap(arr,i,minIndex);
}
}
long after = System.currentTimeMillis();
System.out.println("選擇排序耗時:" + (after - before) + "ms," + "比較次數:" + compareCount + ",交換次數:" + swapCount);
}
插入排序
插入排序是我們需要了解是最后一個簡單排序算法,其思路與我們打撲克牌時的起牌手法相似。
- 假設我們用左手持牌,右手起牌,每次起牌完成后,左手中的手牌均為有序的。
- 開始起牌時,左手手牌為空,此時從牌堆頂取一張牌,直接放入左手
- 在起后邊的牌時,我們拿右手中剛起到的那張新牌,與左手中的所有手牌進行比較,并放入到合適的位置。
- 按從大到小的順序分別拿左手中的手牌與新牌進行比較
- 在從大到小的比較過程中,若左手當前手牌比新牌大,則取交換著兩張牌的位置,并以左手當前手牌作為新牌,與剩余的左手手牌進行比較
- 若左手的當前手牌小于等于新牌,則將新牌插入到當前手牌之后,并將此后的手牌依次向后挪動
需要注意的是,在插入排序時,我們將數組分為了兩部分,一部分是"左手"中的有序子數組,另一部分是"牌堆"中無序的子數組。初始時,我們將數組中的第一個元素視作已排序子數組,并將第二個元素至最后一個元素視作無序子數組。我們每次從無序子數組中取出首元素p,從后往前分別與有序子數組中的元素q進行比較,若p小于q的數值,則將p與q交換,并繼續用p與子數組中剩下的元素進行比較和交換,直到p不小q時,完成此輪插入,此時有序子數組的長度+1,無序子數組的長度-1。
插入排序的過程(剪輯版)如下圖所示,你也可以點擊這里查看完整的插入排序過程。
插入排序代碼
public static void insertSort(Integer arr[]) {
int compareCount = 0;
int swapCount = 0;
long before = System.currentTimeMillis();
//外層循環,i表示有序子數組與無序子數組間的界限,i之前的元素為有序的,i及i之后的元素為無序的
for (int i = 1; i < arr.length; i++) {
//內層循環,將i到0之間的元素兩兩比較,若i<i-1,則交換兩者的位置
for (int j = i; j > 0; j--) {
compareCount++;
if (arr[j] < arr[j - 1]) {
swapCount++;
swap(arr, j, j - 1);//兩兩交換
}
}
}
long after = System.currentTimeMillis();
System.out.println("選擇排序耗時:" + (after - before) + "ms," + "比較次數:" + compareCount + ",交換次數:" + swapCount);
}
我們知道頻繁的兩兩交換也是有性能損耗的,對于插入排序,我們通過如下的思路進一步優化:
- 在新牌插入過程中,先在左手手牌的后方將新牌的空間給預留出來,從大到小,依次比較當前手牌與新牌。
- 若當前手牌大于新牌,則將當前手牌向后挪動一下(注意,此時并不拿新牌與當前手牌交換),將左手手牌后方的空間擠壓到當前手牌的前方
- 若當前手牌小于新牌,則將新牌放到這里
有了這個思路,我們便可以將此前頻繁的兩兩交換,換為單個元素后移,從而減少了一定的性能開銷。
優化插入排序
public static void insertSortOpt(Integer arr[]) {
long before = System.currentTimeMillis();
for (int i = 1; i < arr.length; i++) {
//使用臨時變量保存新牌
int temp =arr[i];
int j = i;
//從大到小,依次取左手中的牌與temp進行比較,若左手當前手牌大于temp,則將當前手牌后移一位
while(j>0 && temp<arr[j-1]){
arr[j] = arr[j -1];
j--;//繼續下一張較大的牌
}
arr[j] = temp;//最終將temp插入左手手牌中合適的位置
}
long after = System.currentTimeMillis();
System.out.println("插入排序耗時:" + (after - before) + "ms");
}
在規模較大的問題中,這種方式帶來的好處非常明顯。
10W條數據
插入排序耗時:12296ms
優化插入排序耗時:2742ms
測試
排序算法 | 問題規模(待排序元素個數) | 解題時間1 | 解題時間2 | 解題時間3 | 平均解題時間 |
---|---|---|---|---|---|
優化冒泡排序 | 1W | 293ms | 293ms | 278ms | 288ms |
選擇排序 | 1W | 28ms | 43ms | 52ms | 41ms |
優化插入排序 | 1W | 22ms | 36ms | 28ms | 28.7ms |
優化冒泡排序 | 5W | 7806ms | 7428ms | 8011ms | 7748.3ms |
選擇排序 | 5W | 603ms | 617ms | 606ms | 608.7ms |
優化插入排序 | 5W | 598ms | 606ms | 600ms | 601.3ms |
優化冒泡排序 | 10W | 28801ms | 30978ms | 29308ms | 29725.7ms |
選擇排序 | 10W | 2609ms | 2649ms | 2658ms | 2638.7ms |
優化插入排序 | 10W | 2693ms | 2712ms | 2685ms | 2696.7ms |
經過測試,可以看到冒泡排序的耗時最多。插入排序在規模較小的數組中明顯快于選擇排序,在規模較大的數組中與選擇排序相當,從此也證明了我們此前算法分析環節中得到的結論。
小結
本文介紹了三個基礎的排序算法,在這里先對它們做一個總結,希望能讓大家對排序及算法效率有一個直觀的感受。
排序算法 | 核心思路 | 最好情況(有序) | 最壞情況(逆序) | 時間復雜度O | 特點 |
---|---|---|---|---|---|
優化冒泡排序 | 相鄰元素兩兩比較并交換 | 比較n-1次,交換0次 | 比較n(n-1)/2次,交換n(n-1)/2次 | O(n2) | 簡單易懂,效率較低 |
直接選擇排序 | 已知位次找第k(大/小)元素 | 比較n(n-1)/2次,交換0次 | 比較n(n-1)/2次,交換n次 | O(n2) | 運行時間與原始數據無關;交換次數最少 |
優化插入排序 | 撲克牌起牌法 | 比較n-1次,交換0次 | 比較n(n-1)/2次,后移(n-1)(n-2)/2次 | O(n2) | 運行時間與原始數據強相關;對部分有序數據或小規模數據極為友好 |
選擇排序和插入排序的異同點:
- 插入排序與選擇排序一樣,當前索引左邊的所有元素都是有序的。但在選擇排序中,當前索引左邊的元素位置是固定的(與最終位置一致);而插入排序當前索引左邊的元素位置未必固定,為了給后邊更小的元素騰出空間,它們可能會被移動,當索引到達數組的右端時,排序完成。
- 選擇排序的運行時間與原始數據無關(比較次數恒定);插入排序的運行時間與原始數據強相關,當對一個有序或接近有序的數組排序時,會比隨機順序或逆序的數組快很多。
參考書目
《算法導論》 - CLRS
《算法》第四版 - Sedgewick
《數據結構與算法分析》 - Weiss