1. 減治法(增量法)
直接插入排序,借鑒了減治法的思想(也有人稱之為增量法)。
- 減治法:對於一個全局的大問題,和一個更小規模的問題建立遞推關系。
- 增量法:基於一個小規模問題的解,和一個更大規模的問題建立遞推關系。
可以發現,無論是減治法還是增量法,從本質上來講,都是基於一種建立遞推關系的思想來減小或擴大問題規模的一種方法。
很顯然,無論是減治法還是增量法,其核心是如何建立一個大規模問題和一個小規模問題的遞推關系。根據應用的場景不同,主要有以下3種變化形式:
- 減去一個常量。(直接插入排序)
- 減去一個常量因子。(二分查找法)
- 減去的規模可變。(輾轉相除法)
2. 直接插入排序
直接插入排序(straight insertion sort),有時也簡稱為插入排序(insertion sort),是減治法的一種典型應用。其基本思想如下:
- 對於一個數組A[0,n]的排序問題,假設認為數組在A[0,n-1]排序的問題已經解決了。
- 考慮A[n]的值,從右向左掃描有序數組A[0,n-1],直到第一個小於等於A[n]的元素,將A[n]插在這個元素的后面。
很顯然,基於增量法的思想在解決這個問題上擁有更高的效率。
直接插入排序對於最壞情況(嚴格遞減的數組),需要比較和移位的次數為n(n-1)/2;對於最好的情況(嚴格遞增的數組),需要比較的次數是n-1,需要移位的次數是0。當然,對於最好和最壞的研究其實沒有太大的意義,因為實際情況下,一般不會出現如此極端的情況。然而,直接插入排序對於基本有序的數組,會體現出良好的性能,這一特性,也給了它進一步優化的可能性。(希爾排序)
直接插入排序的時間復雜度是O(n^2),空間復雜度是O(1),同時也是穩定排序。
下面用一個具體的場景,直觀地體會一下直接插入排序的過程。
場景:
現有一個無序數組,共7個數:89 45 54 29 90 34 68。
使用直接插入排序法,對這個數組進行升序排序。
89 45 54 29 90 34 68
45 89 54 29 90 34 68
45 54 89 29 90 34 68
29 45 54 89 90 34 68
29 45 54 89 90 34 68
29 34 45 54 89 90 68
29 34 45 54 68 89 90
直接插入排序的 Java 代碼實現:
1 public static void basal(int[] array) { 2 if (array == null || array.length < 2) { 3 return; 4 } 5 // 從第二項開始 6 for (int i = 1; i < array.length; i++) { 7 int cur = array[i]; 8 // cur 落地標識,防止待插入的數最小 9 boolean flag = false; 10 // 倒序遍歷,不斷移位 11 for (int j = i - 1; j > -1; j--) { 12 if (cur < array[j]) { 13 array[j + 1] = array[j]; 14 } else { 15 array[j + 1] = cur; 16 flag = true; 17 break; 18 } 19 } 20 if (!flag) { 21 array[0] = cur; 22 } 23 } 24 }
3. 優化直接插入排序:設置哨兵位
仔細分析直接插入排序的代碼,會發現雖然每次都需要將數組向后移位,但是在此之前的判斷卻是可以優化的。
不難發現,每次都是從有序數組的最后一位開始,向前掃描的,這意味着,如果當前值比有序數組的第一位還要小,那就必須比較有序數組的長度n次。這個比較次數,在不影響算法穩定性的情況下,是可以簡化的:記錄上一次插入的值和位置,與當前插入值比較。若當前值小於上個值,將上個值插入的位置之后的數,全部向后移位,從上個值插入的位置作為比較的起點;反之,仍然從有序數組的最后一位開始比較。
設置哨兵位優化直接插入排序的 Java 代碼實現:
1 // 根據上一次的位置,簡化下一次定位 2 public static void optimized_1(int[] array) { 3 if (array == null || array.length < 2) { 4 return; 5 } 6 // 記錄上一個插入值的位置和數值 7 int checkN = array[0]; 8 int checkI = 0; 9 // 循環插入 10 for (int i = 1; i < array.length; i++) { 11 int cur = array[i]; 12 int start = i - 1; 13 // 根據上一個值,定位開始遍歷的位置 14 if (cur < checkN) { 15 start = checkI; 16 for (int j = i - 1; j > start - 1; j--) { 17 array[j + 1] = array[j]; 18 } 19 } 20 // 剩余情況是:checkI 位置的數字,和其下一個坐標位置是相同的 21 // 循環判斷+插入 22 boolean flag = false; 23 for (int j = start; j > -1; j--) { 24 if (cur < array[j]) { 25 array[j + 1] = array[j]; 26 } else { 27 array[j + 1] = cur; 28 checkN = cur; 29 checkI = j + 1; 30 flag = true; 31 break; 32 } 33 } 34 if (!flag) { 35 array[0] = cur; 36 } 37 } 38 }
4. 優化直接插入排序:二分查找法
優化直接插入排序的核心在於:快速定位當前數字待插入的位置。在一個有序數組中查找一個給定的值,最快的方法無疑是二分查找法,對於當前數不在有序數組中的情況,官方的 JDK 源碼 Arrays.binarySearch() 方法也給出了定位的方式。當然此方法的入參,需要將有序數組傳遞進去,這需要不斷地組裝數組,既消耗空間,也不現實,但是可以借鑒這方法,自己實現類似的功能。
這種方式有一個致命的缺點,導致雖然效率高出普通的直接插入排序法很多,但是卻不被使用。就是這種定位方式找到的位置,最終形成的數組會打破排序算法的穩定性。既然一定會打破穩定性,那么為什么不使用更優秀的希爾排序呢?
二分查找法優化直接插入排序的 Java 代碼實現:
1 // 利用系統自帶的二分查找法,定位插入位置 2 // 不穩定排序 3 public static void optimized_2(int[] array) { 4 if (array == null || array.length < 2) { 5 return; 6 } 7 for (int i = 1; i < array.length; i++) { 8 int cur = array[i]; 9 int[] sorted = Arrays.copyOf(array, i); 10 int index = Arrays.binarySearch(sorted, cur); 11 if (index < 0) { 12 index = -(index + 1); 13 } 14 for (int j = i - 1; j > index - 1; j--) { 15 array[j + 1] = array[j]; 16 } 17 array[index] = cur; 18 } 19 }
1 // 自己實現二分查找 2 // 不穩定排序 3 public static void optimized_3(int[] array) { 4 if (array == null || array.length < 2) { 5 return; 6 } 7 for (int i = 1; i < array.length; i++) { 8 int cur = array[i]; 9 // 二分查找的高位和低位 10 int low = 0, high = i - 1; 11 // 待插入的索引位置 12 int index = binarySearch(array, low, high, cur); 13 for (int j = i - 1; j > index - 1; j--) { 14 array[j + 1] = array[j]; 15 } 16 array[index] = cur; 17 } 18 } 19 20 // 二分查找,返回待插入的位置 21 private static int binarySearch(int[] array, int low, int high, int cur) { 22 while (low <= high) { 23 int mid = (low + high) >>> 1; 24 int mVal = array[mid]; 25 if (mVal < cur) { 26 low = mid + 1; 27 } else if (mVal > cur) { 28 high = mid - 1; 29 } else { 30 return mid; 31 } 32 } 33 // 未查到 34 return low; 35 }
5. 簡單的性能比較
最后,通過以下程序,簡單地統計一下上述各種方法的運行時間。
1 public static void main(String[] args) { 2 3 final int size = 100000; 4 // 模擬數組 5 int[] array = new int[size]; 6 for (int i = 0; i < array.length; i++) { 7 array[i] = new Random().nextInt(size) + 1; 8 } 9 10 // 時間輸出:納秒 11 long s1 = System.nanoTime(); 12 StraightInsertion.basal(array); 13 long e1 = System.nanoTime(); 14 System.out.println(e1 - s1); 15 }
執行結果:

結論如下:
- 在某些特定場景下,由於入參的條件不同,不能執着於 JDK 給的現有方法,自定義的實現效率,可能高於源碼的效率。
- 對於小規模的數組,優化的結果和預想的向左,效率比不上最初的方法。原因在於本身只是對於判斷的優化,而不是執行次數的優化。在每次循環中,加上更多的計算去優化這個判斷,在小數組上對於整個排序的效率,反而是一種傷害。
- 大規模數組,二分查找優化效率明顯。
相關鏈接:
PS:如有描述不當之處,歡迎指正!
