算法之排序
排序算法基本上是我們無論是在項目中還是在面試中都會遇到的問題,加上最近在看《算法》這本書,所以就准備好好的將排序算法整理一下。
所有排序算法都是基於 Java 實現,為了簡單,只使用了int類型,從小到大排序
- 基本排序
- 高效的排序
- 各大排序的時間測試
- 如何選擇排序
排序之基本排序算法
准備階段:有一個交換位置的函數exc
/** * 交換a數組中i和j的位置 * @param a 需要交換的數組 * @param i 位置 * @param j 位置 */
public static void exc(int a[],int i,int j){
// 當他們相等的時候就沒必要進行交換
if(a[i] != a[j]){
a[i] ^= a[j];
a[j] ^= a[i];
a[i] ^= a[j];
}
}
基本排序算法主要是分為插入排序,選擇排序,冒泡排序和梳排序。
選擇排序
原理: 選擇排序的原理很簡單,就是從需要排序的數據中選擇最小的(從小到大排序),然后放在第一個,選擇第二小的放在第二個……
代碼:
/** * 選擇排序 * @param a 進行排序的數組 */
public static int[] selectionSort(int a[]){
int min;
for(int i=0;i<a.length;i++){
min = i;
// 這個for循環是為了找出最小的值
for (int j = i+1; j < a.length; j++) {
if(a[min]>a[j]){
min = j;
}
}
/** 如果第一個取出的元素不是最小值,就進行交換 * 意思就是:如果取出的元素就是最小值,那么就沒有必要進行交換了 */
if(min != i){
// 進行交換
exc(a, i, min);
}
}
return a;
}
選擇排序的動畫演示
-
假如數組的長度是N,則時間復雜度:
進行比較的次數:(N-1)+(N-2)+……+1 = N(N-1)/2
進行交換的次數:N
-
特點:(穩定)
- 運行時間與輸入無關。因為前一次的掃描並不能為后面的提供信息。
- 數據的移動次數是最小的。
插入排序
原理: 如果數組進行循環得到a,若a比a前面的一個數小,則a就與前面數交換位置(相當於a向前面移動一位),若移動后a任然比前面一個數小,則再向前移動一位……
代碼:
/** * 插入排序 * @param a 進行排序的數組 * @return 返回排序好的數組 */
public static int[] insertSort(int a[]) {
int N = a.length;
for (int i = 0; i < N; i++) {
// 如果a[i]比前面的數字小,則a[i]向前挪
for (int j = i; j >0 && (a[j-1]>a[j]); j--) {
exc(a, j, j-1);
}
}
return a;
}
動畫演示:
-
若數組的長度是N(不重復 ),則時間復雜度:
- 平均:N*N/4 次比較,N*N/4次交換
- 最好:N-1次比較,0次交換
- 最壞:N*N/2次比較, N*N/2次交換
-
特點:
若數據倒置的數量很少時,速度快。
冒泡排序
原理: 冒泡排序的原理就是小的數字慢慢的往上浮。從數組最后面開始循環,如果一個數比它前面數小,則交換兩者位置。
代碼:
/** * 冒泡排序 * @param a * @return */
public static int[] bubbleSort(int[] a) {
int N = a.length;
for (int i = 0; i < N - 1; i++) {
// 小的數字向上冒泡
for (int j= N-1; j > i; j--) {
// 交換位置
if(a[j-1]>a[j]){
exc(a, j-1, j);
}
}
}
return a;
}
冒泡排序的動畫示意圖:
這個示意圖和代碼剛好相反,這個是將大的向后下沉
時間復雜度:
- 平均情況下:冒泡比較的次數約是插入排序的兩倍,移動次數一致。
- 平均情況下:冒泡與選擇排序的比較此時是一樣的,移動比選擇排序多出n次
冒泡算法的改進:
改進部分就是,如果在第二層for循環中,如果不發生交換,則代表數據已經排好序了,不需要繼續排序。
/** * 冒泡排序的優化 * @param a * @return */
public static int[] bubbleSort2(int[] a) {
int N = a.length;
boolean flag = true;
for (int i = 0; i < N - 1 && flag; i++) {
int j = N-1;
for (flag = false; j > i; j--) {
if(a[j-1]>a[j]){
flag = true;
exc(a, j-1, j);
}
}
}
return a;
}
bubbleSort2()並不是一個多么令人欣喜的改進,但是基於bubbleSort2()的梳排序,卻值得研究一下
——《C++數據結構與算法》
排序之高效排序算法
梳排序
原理: 梳排序分為兩部分,第一部分通過步長stepn進行簡單的排序,將大的數據集中到后面。第二部分是使用bubbleSort2()進行排序。
通過第一部分step的比較,我們能夠有效的消除數組中的烏龜(即在數組尾部的較小的數值)
/** * 梳排序 * @param a * @return */
public static int[] combSort(int[] a) {
int N = a.length;
int step = N;
int k;
// 第一部分
while((step /= 1.3) > 1) {
for (int i = N-1; i >= step; i--) {
k = i -step;
if(a[k]>a[i]){
// 交換位置
exc(a, k, i);
}
}
}
// 第二部分:進行冒泡排序
a= bubbleSort2(a);
return a;
}
梳排序動畫示意圖:
在梳排序中,原作者用隨機數做實驗,得到了最有效的遞減效率是1.3。也就是step/=1.3,同樣也可以寫成step *= 0.8,因為編程語言乘法比除法快。
希爾排序
希爾排序是基於插入排序進行改進,又稱之為遞減增量排序。在前面中我們知道,插入排序是將小的元素往前挪動位置,並且每次只移動一個位置。那么希爾排序是怎么解決這個問題的呢?
原理:希爾排序的理念和梳排序的理念有點類似。在梳排序中,我們比較距離相差為step的兩個元素來完成交換。在希爾排序中,我們的做法也是類似。我們在數組中每隔h取出數組中的元素,然后進行插入排序。當h=1時,則就是前面所寫的插入排序了。
代碼實現:
/** * shell排序 * @param a * @return */
public static int[] shellSort(int[] a){
int N = a.length;
int h = 1;
// 增量序列
while(h < N/3){
// h = 1,4,13,40,……
h = h*3 + 1;
}
while(h>=1){
for (int i = h; i < N; i++) {
// 進行插入排序,諾a[j]比a[j-h]小,則向前挪動h
for (int j = i; j >= h && a[j-h]>a[j]; j -= h) {
exc(a, j, j-h);
}
}
h /= 3;
}
return a;
}
快速排序
原理: 快速排序使用分治法(Divide and conquer)策略來把一個序列分為較小和較大的2個子序列,然后遞歸地排序兩個子序列。
步驟為:
- 挑選基准值:從數列中挑出一個元素,稱為“基准”(pivot),
- 分割:重新排序數列,所有比基准值小的元素擺放在基准前面,所有比基准值大的元素擺在基准后面(與基准值相等的數可以到任何一邊)。在這個分割結束之后,對基准值的排序就已經完成,
- 遞歸排序子序列:遞歸地將小於基准值元素的子序列和大於基准值元素的子序列排序。
遞歸到最底部的判斷條件是數列的大小是零或一,此時該數列顯然已經有序。
選取基准值有數種具體方法,此選取方法對排序的時間性能有決定性影響。
快速排序的實現代碼:
在前面我們知道,選取正確的基准值對排序的性能有着決定性的影響,在這里我們選擇序列中間的值作為基准值。
代碼主要分為兩個部分:
-
進行切分的代碼
-
進行遞歸調用的代碼
第一部分
/** * 進行切分,並進行交換 * @param a 數組 * @param lo 切分開始的位置 * @param h 切分結束的位置 * @return 返回分界點的位置 */
public static int partition(int[] a,int lo,int h){
// 選取中間的值為基准值
int middle = (lo+h+1)/2;
int v = a[middle];
// 將基准值和a[lo]交換位置
exc(a, lo, middle);
int i = lo;
int j = h+1;
while(true){
// 假如左邊的小於基准值,則一直進行循環
while(a[++i] < v){
// 防止越界
if(i == h){
break;
}
}
// 假如右邊的大於基准值,則一直進行循環
while(a[--j]>v){
if(j == lo){
break;
}
}
// 一旦i>=j則代表i前面的除第一個外都比基准值小,j后面的都比基准值大,這時候就可以跳出循環了
if(i>=j){
break;
}
// 進行交換(因為a[lo]>v,a[h]<v,所以將兩者進行交換)
exc(a, i,j);
}
// 將基准放到分界點
exc(a, lo, j);
return j;
}
第二部分:
/** * 調用quickSort函數 * @param a 數組 */
public static void quickSort(int[] a){
quickSort(a,0,a.length-1);
}
/** * 進行遞歸的快排 * @param a * @param lo * @param h */
public static void quickSort(int[] a,int lo,int h){
if(h <= lo) {
return ;
}
// j為基准值的位置
int j = partition(a, lo, h);
// 進行遞歸調用,將j前面的進行快排
quickSort(a,lo,j-1);
// 進行遞歸調用,將j后面的進行快排
quickSort(a,j+1,h);
}
快速排序動畫示意圖:
特點:
快速排序在最壞的情況下時間復雜度是O(n**2),平均時間復雜度是O(nlogn)。快速排序基本上被認為是相同數量級的所有排序算法中,平均性能最好的。
堆排序
原理:堆排序是利用堆這個數據結構而設計的一種排序算法。
堆是具有以下性質的完全二叉樹:每個結點的值都大於或等於其左右孩子結點的值,稱為大頂堆;或者每個結點的值都小於或等於其左右孩子結點的值,稱為小頂堆。
接下來我們將使用大頂堆來進行從小到大的排序。圖源這位大佬講的不錯!!
在一個堆中,位置k的結點的父元素的位置是(k+1)/2-1,而它的兩個子節點的位置分別是2k+1和2k+2,這樣我們就可以通過計算數組的索引在樹中上下移動。
那么我們 進行堆排序, 應該怎么做呢?首先,我們得構建一個堆(大頂堆)。構建的思路就是:我們將小的元素下沉(sink())即可。
/** * 小的結點往下移動 * @param a * @param k 開始移動的位置 * @param N 下沉結束位置 */
public static void sink(int[] a,int k,int N) {
// 滿足向下移動的條件
while(2*k+1 <= N){
int j = 2*k + 1;
// 從 a[j]和a[j+1]中a比較出較大的元素
if(j < N -1 && a[j+1] > a[j]){
j ++;
}
if(a[j] < a[k]){
break;
}
// 將大的元素移動到上面去
exc(a, k, j);
k = j;
}
}
我們通過調用sink()函數和一些邏輯就可以得到一個大頂堆了。【注意:在大頂堆中,可以很簡單的知道堆頂的元素是最大值】那么我們如何進行堆排序呢?這時候我們可以將對頂的元素移動到最后使得末尾的元素最大,然后我們繼續調用sink函數,又可以使得堆頂的元素最大(實則為總的第二大),然后繼續重復以前的操作即可。
public static void heepSort(int[] a) {
int N = a.length;
// 構造一個堆有序
for (int i = N/2; i >= 0; i--) {
sink(a, i,N - 1);
}
N = N -1;
// 然后進行下沉排序
while(N>0){
exc(a, 0, N--);
sink(a, 0,N);
}
}
動畫演示:
堆排序的特點:
- 最好、最壞、平均的時間復雜都為O(nlogn),空間復雜度為O(1)。
- 是一種不穩定的排序。
犧牲空間節約時間的高效排序
歸並排序(Merge Sort)
歸並排序的核心思想是分治法,是創建在歸並操作上面的一種有效的排序算法。
原理:
采用分治法:
-
分割:遞歸地把當前序列平均分割成兩半。
-
集成:在保持元素順序的同時將上一步得到的子序列集成到一起(歸並)。
原理圖:

代碼實現:
首先我們來實現數組之間的歸並操作:
// 臨時空間
public static int[] aux;
/** * 進行歸並操作 * @param a 數組 * @param lo 第一部分數組的開始位置 * @param middle 第一部分數組歸並的結束位置 * @param hi 第二部分數組歸並的結束位置 */
public static void merge(int[] a,int lo,int middle,int hi) {
int i = lo;
// 第二部分數組歸並的開始位置
int j = middle +1;
// 將a[lo..hi]的內容復制到aux[lo..hi]
for (int k = lo; k <= hi; k++) {
aux[k] = a[k];
}
for (int z = lo; z <= hi; z++) {
if(i > middle){
a[z] = aux[j++];
}else if(j > hi){
a[z] = aux[i++];
}else if(aux[i] > aux[j]){
a[z] = aux[j++];
}else{
a[z] = aux[i++];
}
}
}
MergeSort算法調用:
public static void mergeSort(int[] a){
aux = new int[a.length];
mergeSort(a, 0, a.length-1);
}
public static void mergeSort(int[] a, int lo, int hi ){
if(lo >= hi){
return;
}
int middle = (lo + hi)/2;
// 對左半邊進行排序
mergeSort(a,lo,middle);
// 對右半邊進行排序
mergeSort(a,middle+1,hi);
// 進行歸並
merge(a, lo, middle, hi);
}
特點:
歸並排序是一種穩定的並且十分高效的排序。在時間復雜度方面,mergeSort的時間復雜度是O(nlogn)【無論是最好還是最壞的情況】,空間復雜度是O(n)。
基數排序(非比較排序)
-
實例分析
基數排序的方式有 LSD (Least sgnificant digital) 和 MSD (Most sgnificant digital)兩種方式。LSD 的排序方式由鍵值的最右邊開始,而 MSD 則相反,由鍵值的最左邊開始。 以 LSD 為例
data = [10 123 732 67 5 918 7 ]
首先根據個位數的數值,j將數據分配到不同的桶中
| 編號 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 10 | 732 | 123 | 5 | 67 | 918 | |||||
| 7 | 99 |
然后,將這些數字按照桶以及桶內部的排序連接起來:
data = [10 732 123 5 67 7 918]
接着按照十位的數值,放入不同的桶中(ps:5的十位是0)
| 編號 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | 10 | 123 | 732 | 67 | 918 | |||||
| 7 |
重復連接操作,完成排序:
data = [5 7 10 123 732 67 918]
最后根據百位的數值,放入不同的桶中(ps:5的十位是0)
| 編號 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 5 | 123 | 732 | 918 | |||||||
| 7 | ||||||||||
| 10 | ||||||||||
| 67 |
最后重復連接操作,完成排序:
data = [5 7 10 67 123 732 918]
代碼實現
public static void radixSort(int[] a){
int max = a[0];
for (int value : a) {
if(max < value){
max = value;
}
}
// 找出最大位數N
int N = 0;
if(max == 0){
N = 1;
}else{
N = (int) (Math.log10(max) + 1);
}
// 進行基數排序
radixSort(a,N);
}
/** * 基數排序 * @param a * @param N 最大位數 */
public static void radixSort(int[] a, int N) {
// 相當於博客中表格的編號
int radix = 10;
int length = a.length;
// 代表1,10,100……
int factor = 1;
//之所以將二位數組的高度設置為length是為了防止極端情況【即所有數據的最高位數相同】
int[][] bucket = new int[radix][length];
// 記錄每一個bucket里面有多少個元素
int[] order = new int[radix];
for(int i =0;i<N;i++,factor *= 10){
// 將數據放入桶中
for (int v : a) {
int digit = (v/factor)%10;
bucket[digit][order[digit]] = v;
order[digit] ++;
}
int position = 0;
// 將桶中的數據重新連接放入數組中
for(int j =0;j<radix;j++ ){
// 假如里面有數據
if(order[j] != 0){
// 將數據放入數組中
for (int k = 0; k < order[j]; k++) {
a[position++] = bucket[j][k];
}
// 將計數器置零
order[j] = 0;
}
}
}
}
特點:
- 不依賴於數據比較。
- 時間復雜度為O(k*n);空間復雜度為O(n)
計數排序(非比較排序)
原理:
計數排序使用一個額外的數組C,其中C中第i個元素是待排序數組A中值等於i的元素的個數。然后根據數組C來將A中的元素排到正確的位置。
/** * 計數排序 * @param a */
public static void countSort(int[] a){
int max = a[0];
// 找出最大值
for (int v : a) {
if(v > max){
max = v;
}
}
// 輔助數組
int[] count = new int[max+1];
// 將數據的個數儲存到count數組中
for (int v : a) {
count[v] ++;
}
int indexArray = 0;
for (int i = 0; i < count.length; i++) {
while(count[i] > 0){
a[indexArray++]=i;
count[i] --;
}
}
}
當然,如果數據比較集中的話,我們大可不必創建那么大的數組,我們找出最小和最大的元素,以最小的元素作為基底以減小數組的大小。
動畫演示:
特點:
- 計數排序是一種穩定的線性時間排序算法。
- 時間復雜度為O(n+k),空間復雜度為O(n+k)

