排序算法(三) —— 直接插入排序
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:如有描述不当之处,欢迎指正!