文章首發於公眾號「陳樹義」及個人博客 shuyi.tech,歡迎關注訪問。
說到排序算法,大家估計都比較熟悉,但要你一下子寫出來又蒙圈了。所以這篇文章不會講解所有的排序算法,而是挑選最熱門的五種:冒泡排序、選擇排序、插入排序、快速排序、歸並排序。
我們通過圖文 + 流程解釋
的方式,讓大家能快速領悟到各個排序算法的思想,從而達到快速掌握的目的。此外每個排序算法都有對應的 Github 代碼實現,可供大家調試理解算法。同時也附上了文章中所畫圖的 draw.io 數據文件,方便大家根據自己的習慣進行修改。
排序算法的倉庫地址:java-code-chip/src/main/java/tech/shuyi/javacodechip/sort at master · chenyurong/java-code-chip
如果你已經不是第一次學習排序算法,那么我建議你按照這樣的思路學習:
- 通過圖解或調試,弄清楚每個算法的思想。
- 下載 Github 例子,嘗試自己手寫實現。
- 定期復習手寫實現,不斷鞏固知識點。
好了,廢話不多說,讓我們開始今天的圖解排序算法吧!
選擇排序
選擇排序,意思是每次從待排序的元素選出極值作為首元素,直到所有元素排完為止。 其詳細的排序邏輯如下圖所示:
- 第 1 次,index 下標對應值為 9,找出所有最小值為 1,將 9 與 1 交換位置,得到 ②。同時,index 下標加一。
- 第 2 次,index 下標對應值為 3,找出所有最小值為 3,將 3 與 2 交換位置,得到 ③。同時,index 下標加一。
- 第 3 次,index 下標對應值為 9,找出所有最小值為 3,將 9 與 3 交換位置,得到 ④。同時,index 下標加一。
- 一直這樣循環下去,直到 index 下標到達數組邊界,如 ⑥ 所示。
注意:灰色部分表示已經完成排序的部分。
選擇排序的算法比較簡單,如下所示:
public static void selectSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int min = i;//每一趟循環比較時,min用於存放較小元素的數組下標,這樣當前批次比較完畢最終存放的就是此趟內最小的元素的下標,避免每次遇到較小元素都要進行交換。
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[min]) {
min = j;
}
}
//進行交換,如果min發生變化,則進行交換
if (min != i) {
swap(arr,min,i);
}
}
}
可調式代碼地址:java-code-chip/SelectSort.java at master · chenyurong/java-code-chip
簡單選擇排序通過上面優化之后,無論數組原始排列如何,比較次數是不變的;對於交換操作,在最好情況下也就是數組完全有序的時候,無需任何交換移動,在最差情況下,也就是數組倒序的時候,交換次數為n-1次。綜合下來,時間復雜度為O(n2)。
冒泡排序
冒泡排序,就是像池塘里的水泡一樣往上冒泡。我們可以理解成一個數不斷地往上冒泡(比較交換),一直到最上面(末尾)。通過不斷往上冒泡,每次冒泡都會將最值浮到最上層,最終達到完全有序。 其詳細的排序算法邏輯如下:
- 第 1 輪,9 大於 3,那么將 9 與 3 交換,接着繼續往下比較。9 大於 1,那么將 9 與 1 交換,接着往下比較,最終我們將 9 浮到數組頂端。此時 index 指向數組頂端,該數是有序的了,因此 index 減一。
- 第 2 輪,3 大於 1,那么 3 與 1 交換,接着往下比較。最終,我們只需要比較到 index 位置即可,最終我們將 7 浮到數組頂端。同時 index 也減一,此時 7、9 是有序的。
- 如此這樣反復循環,直到 index 下標達到 0 即可。
在冒泡排序的過程中,如果某一趟執行完畢,沒有做任何一次交換操作,那么就說明剩下的序列已經是有序的了。例如數組[5,4,1,2,3],執行了兩次冒泡之后,其數組變為 [1,2,3,4,5]。此時,index 下標指向 3 這個值。再執行第三次冒泡時,我們會發現 1<2<3,我們一次交換都沒有做,這就說明剩下的序列已經是有序的,排序操作已經完成,不需要再進行排序了。
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
boolean flag = true;//設定一個標記,若為true,則表示此次循環沒有進行交換,也就是待排序列已經有序,排序已然完成。
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr,j,j+1);
flag = false;
}
}
if (flag) {
break;
}
}
}
可調試代碼地址:java-code-chip/BubbleSort.java at master · chenyurong/java-code-chip
插入排序
插入排序,即將元素一個個插入新的數組系列中,直到所有元素插完為止。 例如下圖的例子,第 1 次將元素 9 插入新的數組中
/**
* 插入排序
*
* @param arr
*/
public static void insertSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int j = i;
while (j > 0 && arr[j - 1] > arr[j]) {
swap(arr,j,j-1);
j--;
}
}
}
可調式代碼地址:java-code-chip/InsertSort.java at master · chenyurong/java-code-chip
簡單插入排序在最好情況下,需要比較n-1次,無需交換元素,時間復雜度為O(n);在最壞情況下,時間復雜度依然為O(n2)。但是在數組元素隨機排列的情況下,插入排序還是要優於上面兩種排序的。
快速排序
快速排序,顧名思義其排序效率非常高,所以才叫快速排序。快速排序的核心思想是選取一個基准數,通過一趟排序將小於此基准數的數字放到左邊,將大於此基准數的數字放到右邊。之后再用遍歷不斷地對左右子串進行同樣的操作,從而達到排序的目的。 快速排序的時間復雜度在最壞情況下是 O(N2),平均的時間復雜度是 O(N*lgN)。
例如下圖中的例子,在第一趟排序里,我們選中了基准數為 9,那么此次排序就把所有比 9 小的數放到了左邊,所有比 9 大的數放到了右邊。在第二趟排序里,我們選中了基准數為 7,那么此次排序就把所有比 7 小的數放到了左邊,所有比 7 大的數放到了右邊。
我們以對 9 3 1 4 2 7
整數串進行排序為例,詳細講解整個快速排序的流程:
- 選取 9 為基准數,從 right 開始,從右到左找出第一個小於 9 的數。
- 第一個數是 7,小於 9,符合條件。於是將找到的這個數值放到 left 位置上,同時 left 加一。
- 從 left 開始,從左到右選取第一個大於 9 的數。
- 可以看到子串中並沒有一個大於 9 的數,於是 left 會一直累加到 right 的位置。
- 當 left >= right 時,單趟排序結束,將基准數填入 left 所在位置。
- 最終整個字符串被以 9 為基准數,切割成兩部分,左邊部分比 9 小,右邊部分比 9 大。
接着進行第二次排序,第二次排序的整體流程如下:
- 選取 7 為基准數,從 right 開始,從右到左找出第一個小於 9 的數。
- 第一個數是 2,小於 9,符合條件。於是將找到的這個數值放到 left 位置上,同時 left 加一,此時數組變為:2 3 1 4 2 9。
- 從 left 開始,從左到右選取第一個大於 9 的數。
- 可以看到子串中並沒有一個大於 9 的數,於是 left 會一直累加到 right 的位置。
- 當 left >= right 時,單趟排序結束,將基准數填入 left 所在位置。
- 最終整個字符串被以 7 為基准數,切割成兩部分,左邊部分比 7 小,右邊部分比 7 大。
剩余的子串都進行同樣的處理邏輯,最終我們可以得到一個排序的整數串。
代碼實現:
/**
* v
* @param arr -- 待排序的數組
* @param l -- 數組的左邊界(例如,從起始位置開始排序,則l=0)
* @param r -- 數組的右邊界(例如,排序截至到數組末尾,則r=arr.length-1)
*/
public static void quickSort(int arr[], int l, int r) {
if (l < r) {
int i,j,x;
i = l;
j = r;
x = arr[i];
while (i < j)
{
// 從右向左找第一個小於x的數
while(i < j && arr[j] > x) {
j--;
}
if(i < j) {
arr[i] = arr[j];
i++;
}
// 從左向右找第一個大於x的數
while(i < j && arr[i] < x) {
i++;
}
if(i < j) {
arr[j] = arr[i];
j--;
}
}
arr[i] = x;
quickSort(arr, l, i-1);
quickSort(arr, i+1, r);
}
}
可調式代碼地址:java-code-chip/QuickSort.java at master · chenyurong/java-code-chip
歸並排序
歸並排序,其英文名為 Merge Sort,其意思是將排序串拆分成最小的單位之后,再一個個合並成有序的子串。 例如下圖的整數串,將其拆分成最小的子串就是每個只有一個整數。之后再將每個單個的子串合並起來,例如:8 與 4 合並起來成為有序子串 4、8
,5 與 7 合並起來成為有序子串 5、7
。4、8
和 5、7
再合並成為有序子串 4、5、7、8
。
可以看到在這個過程中,最關鍵是合並兩個有序子串的算法。這里我們以 [4,5,7,8] 和 [1,2,3,6] 為例,講解有序子串合並的算法流程。
- 首先聲明一個與原有數組相同的長度的臨時數組 temp。
- 接着 i 指向子串 1 開始的位置,j 指向子串 2 開始的位置。接着比較 arr1[i] 與 arr2[j] 的值,找出較小值。因為兩個子串都是有序的,所以這兩個值中的最小值,就是整個串中的最小值。找出最小值后將其值放入 temp 的開始位置,最小值對應的子串下標加 1。這里可以看到是 4 < 1,即子串 arr2 的值較小,那么將 1 放入 temp[0] 位置,接着 j 加一,此時 j 指向 2。
- 接着繼續對比 i 和 j 兩個數的大小,繼續對比步驟 2 的邏輯。這里可以看到 arr[i]=4 < arr[j]=2,那么應該將較小值放入 temp 數組中,即將 2 放入數組中,並且將 j + 1,即 j = 2,此時 j 指向的值 為 3。
- 按着上述的步驟繼續不斷重復步驟 2 的內容,我們會看到子串 2 首先到末尾。此時子串 1 還剩下一些數值,這些數值肯定是更大的值,那么直接將這些數值復制到 temp 數組中即可。如果子串 1 先到末尾,那么就應該將子串 2 剩余的數值寫入 temp 數組。
- 最后,將 temp 的數值寫回原有數組中即可。
代碼實現:
public static void sort(int []arr){
//在排序前,先建好一個長度等於原數組長度的臨時數組,避免遞歸中頻繁開辟空間
int []temp = new int[arr.length];
sort(arr,0,arr.length-1,temp);
}
private static void sort(int[] arr,int left,int right,int []temp){
if(left<right){
int mid = (left+right)/2;
//左邊歸並排序,使得左子序列有序
sort(arr,left,mid,temp);
//右邊歸並排序,使得右子序列有序
sort(arr,mid+1,right,temp);
//將兩個有序子數組合並操作
merge(arr,left,mid,right,temp);
}
}
private static void merge(int[] arr, int left, int mid, int right, int[] temp){
//左序列指針
int i = left;
//右序列指針
int j = mid+1;
//臨時數組指針
int t = 0;
while (i<=mid && j<=right){
if(arr[i]<=arr[j]){
temp[t++] = arr[i++];
}else {
temp[t++] = arr[j++];
}
}
//將左邊剩余元素填充進temp中
while(i<=mid){
temp[t++] = arr[i++];
}
//將右序列剩余元素填充進temp中
while(j<=right){
temp[t++] = arr[j++];
}
t = 0;
//將temp中的元素全部拷貝到原數組中
while(left <= right){
arr[left++] = temp[t++];
}
}
可調試代碼地址:java-code-chip/MergeSort.java at master · chenyurong/java-code-chip
參考:圖解排序算法(四)之歸並排序 - dreamcatcher-cx - 博客園
算法對比
選擇排序與冒泡排序的區別?
選擇排序是每次選出最值,然后放到數組頭部。而冒泡排序則是不斷地兩兩對比,將最值放到數組尾部。本質上,他倆每次都是選出最值,然后放到一邊。
其最大的不同點是:選擇排序只需要做一次交換,而冒泡排序則需要兩兩對比交換,所以冒泡排序的效率相對來說會低一些,因為會做多一些無意義的交換操作。
快速排序與歸並排序的區別?
剛剛看了一下,快速排序和歸並排序,我覺得差別可以提現在拆分合並的過程中,比較的時機。
快排和歸並,都是不斷拆分到最細。但是歸並更純粹,拆分時不做比較,直接拆!而快排還是會比較一下的。所以在拆分階段,快排會比歸並耗時一些。
而因為快排在拆分階段會比較,所以其拆得沒有歸並多層級,因此其在合並階段就少做一些功夫,會快一些。
所以快排和歸並排序的區別,本質上就是拆分、合並的區別。