前言
演算法就是程式設計的靈魂,不會演算法的程式設計師只配做碼農。演算法的學習也是有著階段性的,從入門到簡單,再到複雜,再到簡單。最後的簡單是當你達到一定高度之後對於問題能夠準確的找到最簡單的解答。
介紹
演算法裡邊最常用也是最基本的就是排序演算法和查詢演算法了,本文主要講解演算法裡邊最經典的十大排序演算法。在這裡我們根據他們各自的實現原理以及效率將十大排序演算法分為兩大類:
- 非線性比較類排序:非線性是指演算法的時間複雜度不能突破(nlogn),元素之間通過比較大小來決定先後順序。
- 線性非比較類排序:演算法的時間複雜度能夠突破(nlogn),並且不通過比較來對元素排序。
具體分類我們上圖說明:
演算法比較
這裡給出演算法的時間複雜度,空間複雜度以及穩定性的對比整理,同樣通過圖片的形式給出:
時間複雜度
時間複雜度本意是預估演算法的執行時間,但實際上一個程式在計算機上執行的速度是非常快的,時間幾乎可以忽略不計了,也就是失去了意義,所以這裡意思是演算法中執行頻度最高的程式碼的執行的次數。反應當n發生變化時,執行次數的改變呈現一種什麼樣的規律。
空間複雜度
空間複雜度是指演算法在計算機內執行時所需儲存空間的度量,它也是資料規模n的函式。
穩定性
在排序中對於相等的兩個元素a,b。如果排序前a在b的前邊,排序之後a也總是在b的前邊。他們的位置不會因為排序而改變稱之為穩定。反之,如果排序後a,b的位置可能會發生改變,那麼就稱之為不穩定。
下面就一一對十大演算法進行詳細的講解,會給出他們的基本思想,圖片演示,以及帶有詳細註釋的原始碼。(本文所有的排序演算法都是升序排序)
1.氣泡排序
1.1 基本思想
氣泡排序可以說是最簡單的排序之一了,也是大部分人最容易想到的排序。即對n個數進行排序,每次都是由前一個數跟後一個數比較,每迴圈一輪, 就可以將最大的數移到陣列的最後, 總共迴圈n-1輪,完成對陣列排序。
1.2 圖片演示
1.3 程式碼展示
public static void bubbleSort(int[] arr) {
if (arr == null)
return;
int len = arr.length;
//i控制迴圈次數,長度為len的陣列只需要迴圈len-1次,i的起始值為0所以i<len-1
for (int i = 0; i < len - 1; i++) {
// j控制比較次數,第i次迴圈內需要比較len-i次
// 但是由於是由arr[j]跟arr[j+1]進行比較,所以為了保證arr[j+1]不越界,j<len-i-1
for (int j = 0; j < len - i - 1; j++) {
// 如果前一個數比後一個數大,則交換位置將大的數往後放。
if (arr[j] > arr[j + 1]) {
int temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
複製程式碼
2.選擇排序
2.1 基本思想
選擇排序可以說是氣泡排序的改良版,不再是前一個數跟後一個數相比較, 而是在每一次迴圈內都由一個數去跟 所有的數都比較一次,每次比較都選取相對較小的那個數來進行下一次的比較,並不斷更新較小數的下標 這樣在一次迴圈結束時就能得到最小數的下標,再通過一次交換將最小的數放在最前面,通過n-1次迴圈之後完成排序。 這樣相對於氣泡排序來說,比較的次數並沒有改變,但是資料交換的次數大大減少。
2.2 圖片演示
2.3 程式碼展示
public static void selectSort(int[] arr) {
if (arr == null)
return;
int len = arr.length;
// i控制迴圈次數,長度為len的陣列只需要迴圈len-1次,i的起始值為0所以i<len-1
for (int i = 0; i < len - 1; i++) {
// minIndex 用來儲存每次比較後較小數的下標。
int minIndex = i;
// j控制比較次數,因為每次迴圈結束之後最小的數都已經放在了最前面,
// 所以下一次迴圈的時候就可以跳過這個數,所以j的初始值為i+1而不需要每次迴圈都從0開始,並且j<len即可
for (int j = i + 1; j < len; j++) {
//每比較一次都需要將較小數的下標記錄下來
if (arr[minIndex] > arr[j]) {
minIndex = j;
}
}
// 當完成一次迴圈時,就需要將本次迴圈選取的最小數移動到本次迴圈開始的位置。
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
// 列印每次迴圈結束之後陣列的排序狀態(方便理解)
System.out.println("第" + (i + 1) + "次迴圈之後效果:" + Arrays.toString(arr));
}
}
複製程式碼
3.插入排序
3.1 基本思想
插入排序的思想打牌的人肯定很容易理解,就是見縫插針。 首先就預設陣列中的第一個數的位置是正確的,即已經排序。 然後取下一個數,與已經排序的數按從後向前的順序依次比較, 如果該數比當前位置排好序的數小,則將排好序的數的位置向後移一位。 重複上一步驟,直到找到合適的位置。 找到位置後就結束比較的迴圈,將該數放到相應的位置。
3.2 圖片演示
3.3 程式碼展示
public static void insertSort(int[] arr) {
if (arr == null)
return;
int len = arr.length;
// i控制迴圈次數,因為已經預設第一個數的位置是正確的,所以i的起始值為1,i<len,迴圈len-1次
for (int i = 1; i < len; i++) {
int j = i;//變數j用來記錄即將要排序的數的位置即目標數的原位置
int target = arr[j];//target用來記錄即將要排序的那個數的值即目標值
// while迴圈用來為目標值在已經排好序的數中找到合適的位置,
// 因為是從後向前比較,並且是與j-1位置的數比較,所以j>0
while (j > 0 && target < arr[j - 1]) {
// 當目標數的值比它當前位置的前一個數的值小時,將前一個數的位置向後移一位。
// 並且j--使得目標數繼續與下一個元素比較
arr[j] = arr[j - 1];
j--;
}
// 更目標數的位置。
arr[j] = target;
//列印每次迴圈結束之後陣列的排序狀態(方便理解)
System.out.println("第" + (i) + "次迴圈之後效果:" + Arrays.toString(arr));
}
}
複製程式碼
4.希爾排序
4.1 基本思想
希爾排序也稱為"縮小增量排序",原理是先將需要排的陣列分成多個子序列,這樣每個子序列的元素個數就很少,再分別對每個對子序列進行插入排序。在該陣列基本有序後 再進行一次直接插入排序就能完成對整個陣列的排序。所以,要採用跳躍分割的策略。這裡引入“增量”的概念,將相距某個增量的記錄兩兩組合成一個子序列,然後對每個子序列進行直接插入排序, 這樣得到的結果才會使基本有序(即小的在前邊,大的在後邊,不大不小的在中間)。希爾排序就是 直接插入排序的升級版。
4.2 圖片演示
4.3 程式碼展示
public static void shellSort(int[] arr) {
if (arr == null)
return;
int len = arr.length; // 陣列的長度
int k = len / 2; // 初始的增量為陣列長度的一半
// while迴圈控制按增量的值來劃不同分子序列,每完成一次增量就減少為原來的一半
// 增量的最小值為1,即最後一次對整個陣列做直接插入排序
while (k > 0) {
// 裡邊其實就是升級版的直接插入排序了,是對每一個子序列進行直接插入排序,
// 所以直接將直接插入排序中的‘1’變為‘k’就可以了。
for (int i = k; i < len; i++) {
int j = i;
int target = arr[i];
while (j >= k && target < arr[j - k]) {
arr[j] = arr[j - k];
j -= k;
}
arr[j] = target;
}
// 不同增量排序後的結果
System.out.println("增量為" + k + "排序之後:" + Arrays.toString(arr));
k /= 2;
}
}
複製程式碼
5.歸併排序
5.1 基本思想
總體概括就是從上到下遞迴拆分,然後從下到上逐步合併。
- 遞迴拆分:
先把待排序陣列分為左右兩個子序列,再分別將左右兩個子序列拆分為四個子子序列,以此類推直到最小的子序列元素的個數為兩個或者一個為止。
- 逐步合併:
將最底層的最左邊的一個子序列排序,然後將從左到右第二個子序列進行排序,再將這兩個排好序的子序列合併並排序,然後將最底層從左到右第三個子序列進行排序..... 合併完成之後記憶完成了對陣列的排序操作(一定要注意是從下到上層級合併,可以理解為遞迴的層級返回)
5.2 圖片演示
5.3 程式碼展示
public static void main(String[] args) {
int[] arr = {3, 8, 6, 2, 1, 8};
mergeSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
/**
* 遞迴拆分
* @param arr 待拆分陣列
* @param left 待拆分陣列最小下標
* @param right 待拆分陣列最大下標
*/
public static void mergeSort(int[] arr, int left, int right) {
int mid = (left + right) / 2; // 中間下標
if (left < right) {
mergeSort(arr, left, mid); // 遞迴拆分左邊
mergeSort(arr, mid + 1, right); // 遞迴拆分右邊
sort(arr, left, mid, right); // 合併左右
}
}
/**
* 合併兩個有序子序列
* @param arr 待合併陣列
* @param left 待合併陣列最小下標
* @param mid 待合併陣列中間下標
* @param right 待合併陣列最大下標
*/
public static void sort(int[] arr, int left, int mid, int right) {
int[] temp = new int[right - left + 1]; // 臨時陣列,用來儲存每次合併年之後的結果
int i = left;
int j = mid + 1;
int k = 0; // 臨時陣列的初始下標
// 這個while迴圈能夠初步篩選出待合併的了兩個子序列中的較小數
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
// 將左邊序列中剩餘的數放入臨時陣列
while (i <= mid) {
temp[k++] = arr[i++];
}
// 將右邊序列中剩餘的數放入臨時陣列
while (j <= right) {
temp[k++] = arr[j++];
}
// 將臨時陣列中的元素位置對應到真真實的陣列中
for (int m = 0; m < temp.length; m++) {
arr[m + left] = temp[m];
}
}
複製程式碼
6.快速排序
6.1 基本思想
快速排序也採用了分治的策略,這裡引入了‘基準數’的概念。
- 找一個基準數(一般將待排序的陣列的第一個數作為基準數)
- 對陣列進行分割槽,將小於等於基準數的全部放在左邊,大於基準數的全部放在右邊。
- 重複1,2步驟,分別對左右兩個子分割槽進行分割槽,一直到各分割槽只有一個數為止。
6.2 圖片演示
6.3 程式碼展示
public static void main(String[] args) {
int[] arr = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
quickSort(arr, 0, 9);
System.out.println(Arrays.toString(arr));
}
/**
* 分割槽過程
* @param arr 待分割槽陣列
* @param left 待分割槽陣列最小下標
* @param right 待分割槽陣列最大下標
*/
public static void quickSort(int[] arr, int left, int right) {
if (left < right) {
int temp = qSort(arr, left, right);
quickSort(arr, left, temp - 1);
quickSort(arr, temp + 1, right);
}
}
/**
* 排序過程
* @param arr 待排序陣列
* @param left 待排序陣列最小下標
* @param right 待排序陣列最大下標
* @return 排好序之後基準數的位置下標,方便下次的分割槽
*/
public static int qSort(int[] arr, int left, int right) {
int temp = arr[left]; // 定義基準數,預設為陣列的第一個元素
while (left < right) { // 迴圈執行的條件
// 因為預設的基準數是在最左邊,所以首先從右邊開始比較進入while迴圈的判斷條件
// 如果當前arr[right]比基準數大,則直接將右指標左移一位,當然還要保證left<right
while (left < right && arr[right] > temp) {
right--;
}
// 跳出迴圈說明當前的arr[right]比基準數要小,那麼直接將當前數移動到基準數所在的位置,並且左指標向右移一位(left++)
// 這時當前數(arr[right])所在的位置空出,需要從左邊找一個比基準數大的數來填充。
if (left < right) {
arr[left++] = arr[right];
}
// 下面的步驟是為了在左邊找到比基準數大的數填充到right的位置。
// 因為現在需要填充的位置在右邊,所以左邊的指標移動,如果arr[left]小於或者等於基準數,則直接將左指標右移一位
while (left < right && arr[left] <= temp) {
left++;
}
// 跳出上一個迴圈說明當前的arr[left]的值大於基準數,需要將該值填充到右邊空出的位置,然後當前位置空出。
if (left < right) {
arr[right--] = arr[left];
}
}
// 當迴圈結束說明左指標和右指標已經相遇。並且相遇的位置是一個空出的位置,
// 這時候將基準數填入該位置,並返回該位置的下標,為分割槽做準備。
arr[left] = temp;
return left;
}
複製程式碼
7.堆排序
7.1 基本思想
在此之前要先說一下堆的概念,堆是一種特殊的完全二叉樹,分為大頂堆和小頂堆。
- 大頂堆:
每個結點的值都大於它的左右子結點的值,升序排序用大頂堆。
- 小頂堆:
每個結點的值都小於它的左右子結點的值,降序排序用小頂堆。
所以,需要先將待排序陣列構造成大頂堆的格式,這時候該堆的頂結點就是最大的數,將其與堆的最後一個結點的元素交換。再將剩餘的樹重新調整成堆,再次首節點與尾結點交換,重複執行直到只剩下最後一個結點完成排序。
7.2 圖片演示
7.3 程式碼展示
public static void main(String[] args) {
int[] arr = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
heapSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void heapSort(int[] arr) {
if (arr == null) {
return;
}
int len = arr.length;
// 初始化大頂堆(從最後一個非葉節點開始,從左到右,由下到上)
for (int i = len / 2 - 1; i >= 0; i--) {
adjustHeap(arr, i, len);
}
// 將頂節點和最後一個節點互換位置,再將剩下的堆進行調整
for (int j = len - 1; j > 0; j--) {
swap(arr, 0, j);
adjustHeap(arr, 0, j);
}
}
/**
* 整理樹讓其變成堆
* @param arr 待整理的陣列
* @param i 開始的結點
* @param j 陣列的長度
*/
public static void adjustHeap(int[] arr, int i, int j) {
int temp = arr[i];// 定義一個變數儲存開始的結點
// k就是該結點的左子結點下標
for (int k = 2 * i + 1; k < j; k = 2 * k + 1) {
// 比較左右兩個子結點的大小,k始終記錄兩者中較大值的下標
if (k + 1 < j && arr[k] < arr[k + 1]) {
k++;
}
// 經子結點中的較大值和當前的結點比較,比較結果的較大值放在當前結點位置
if (arr[k] > temp) {
arr[i] = arr[k];
i = k;
} else { // 說明已經是大頂堆
break;
}
}
arr[i] = temp;
}
/**
* 交換資料
*
* @param arr
* @param num1
* @param num2
*/
public static void swap(int[] arr, int num1, int num2) {
int temp = arr[num1];
arr[num1] = arr[num2];
arr[num2] = temp;
}
複製程式碼
8.計數排序
8.1 基本思想
計數排序採用了一種全新的思路,不再是通過比較來排序,而是將待排序陣列中的最大值+1作為一個臨時陣列的長度,然後用臨時陣列記錄待排序陣列中每個元素出現的次數。最後再遍歷臨時陣列,因為是升序,所以從前到後遍歷,將臨時陣列中值>0的數的下標迴圈取出,依次放入待排序陣列中,即可完成排序。計數排序的效率很高,但是實在犧牲記憶體的前提下,並且有著限制,那就是待排序陣列的值必須 限制在一個確定的範圍。
8.2 圖片演示
8.3 程式碼展示
public static void main(String[] args) {
int[] arr = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
countSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void countSort(int[] arr) {
if (arr == null)
return;
int len = arr.length;
// 儲存待排序陣列中的最大值,目的是確定臨時陣列的長度(必須)
int maxNum = arr[0];
// 儲存待排序陣列中的最小值,目的是確定最終遍歷臨時陣列時下標的初始值(非必需,只是這樣可以加快速度,減少迴圈次數)
int minNum = arr[0];
// for迴圈就是為了找到待排序陣列的最大值和最小值
for (int i = 1; i < len; i++) {
maxNum = maxNum > arr[i] ? maxNum : arr[i];
minNum = minNum < arr[i] ? minNum : arr[i];
}
// 建立一個臨時陣列
int[] temp = new int[maxNum + 1];
// for迴圈是為了記錄待排序陣列中每個元素出現的次數,並將該次數儲存到臨時陣列中
for (int i = 0; i < len; i++) {
temp[arr[i]]++;
}
// k=0用來記錄待排序陣列的下標
int k = 0;
// 遍歷臨時陣列,重新為待排序陣列賦值。
for (int i = minNum; i < temp.length; i++) {
while (temp[i] > 0) {
arr[k++] = i;
temp[i]--;
}
}
}
複製程式碼
9.桶排序
9.1 基本思想
桶排序其實就是計數排序的強化版,需要利用一個對映函式首先定義有限個數個桶,然後將待排序陣列內的元素按照函式對映的關係分別放入不同的桶裡邊,現在不同的桶裡邊的資料已經做了區分,比如A桶裡的數要麼全部大於B桶,要麼全部小於B桶裡的數。但是A,B桶各自裡邊的數還是亂序的。所以要藉助其他排序方式(快速,插入,歸併)分別對每一個元素個數大於一的桶裡邊的資料進行排序。最後再將桶裡邊的元素按照順序依次放入待排序陣列中即可。
9.2 圖片演示
9.3 程式碼展示
public static void main(String[] args) {
int[] arr = {72, 6, 57, 88, 60, 42, 83, 73, 48, 85};
bucketSort(arr);
System.out.println(Arrays.toString(arr));
}
public static void bucketSort(int[] arr) {
if (arr == null)
return;
int len = arr.length;
// 定義桶的個數,這裡k的值要視情況而定,這裡我們假設待排序陣列裡的數都是[0,100)之間的。
int k = 10;
// 用巢狀集合來模擬桶,外層集合表示桶,內層集合表示桶裡邊裝的元素。
List<List<Integer>> bucket = new ArrayList<>();
//for迴圈初始化外層集合即初始化桶
for (int i = 0; i < k; i++) {
bucket.add(new ArrayList<>());
}
// 迴圈是為了將待排序陣列中的元素通過對映函式分別放入不同的桶裡邊
for (int i = 0; i < len; i++) {
bucket.get(mapping(arr[i])).add(arr[i]);
}
// 這個迴圈是為了將所有的元素個數大於1的桶裡邊的資料進行排序。
for (int i = 0; i < k; i++) {
if (bucket.size() > 1) {
// 因為這裡是用集合來模擬的桶所以用java寫好的對集合排序的方法。
// 其實應該自己寫一個方法來排序的。
Collections.sort(bucket.get(i));
}
}
// 將排好序的數重新放入待排序陣列中
int m = 0;
for (List<Integer> list : bucket) {
if (list.size() > 0) {
for (Integer a : list) {
arr[m++] = a;
}
}
}
}
/**
* 對映函式
* @param num
* @return
*/
public static int mapping(int num) {
return num / 10;
}
複製程式碼
10.基數排序
10.1基本思想
就是將待排序資料拆分成多個關鍵字進行排序,也就是說,基數排序的實質是多關鍵字排序。多關鍵字排序的思路是將待排資料裡德排序關鍵字拆分成多個排序關鍵字; 第1個排序關鍵字,第2個排序關鍵字,第3個排序關鍵字......然後,根據子關鍵字對待排序資料進行排序。
10.2 圖片演示
10.3 程式碼展示
public static void main(String[] args) {
int[] arr = {720, 6, 57, 88, 60, 42, 83, 73, 48, 85};
redixSort(arr, 10, 3);
System.out.println(Arrays.toString(arr));
}
public static void redixSort(int[] arr, int radix, int d) {
// 快取陣列
int[] tmp = new int[arr.length];
// buckets用於記錄待排序元素的資訊
// buckets陣列定義了max-min個桶
int[] buckets = new int[radix];
for (int i = 0, rate = 1; i < d; i++) {
// 重置count陣列,開始統計下一個關鍵字
Arrays.fill(buckets, 0);
// 將data中的元素完全複製到tmp陣列中
System.arraycopy(arr, 0, tmp, 0, arr.length);
// 計算每個待排序資料的子關鍵字
for (int j = 0; j < arr.length; j++) {
int subKey = (tmp[j] / rate) % radix;
buckets[subKey]++;
}
for (int j = 1; j < radix; j++) {
buckets[j] = buckets[j] + buckets[j - 1];
}
// 按子關鍵字對指定的資料進行排序
for (int m = arr.length - 1; m >= 0; m--) {
int subKey = (tmp[m] / rate) % radix;
arr[--buckets[subKey]] = tmp[m];
}
rate *= radix;
}
}
複製程式碼
參考資料
歡迎關注技術公眾號: 零壹技術棧
本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。