前言
讲完了冒泡排序和插入排序这两个暴力遍历型的排序后,接下来要讲讲不那么暴力,有点技巧的排序算法了~
手撕算法 - 排序系列
手撕面试题算法<排序>(1)—— 冒泡排序及其优化实现
手撕面试题算法<排序>(2)—— 选择排序
手撕面试题算法<排序>(3)—— 插入排序及其优化实现
手撕面试题算法<排序>(3.5)—— 希尔排序
手撕面试题算法<排序>(4)—— 归并排序
手撕面试题算法<排序>(5)—— 快速排序以及快排为什么快
手撕面试题算法<排序>(6)—— 堆 & 堆排序
手撕面试题算法<排序>(7)—— 箱排序 & 基数排序
源码
插入排序
思想
插入排序就像我们玩扑克的时候整理手牌一样,我们会在手上已经整理好的牌里找到合适的位置,将拿到的牌插入到那个合适的位置
没错,插入排序利用到了数组中已排序过的部分有序的特性,对排序进行了优化,进一步优化了排序
时间复杂度
在最坏情况下,数组完全逆序,插入第2个元素时要考察前1个元素,插入第3个元素时,要考虑前2个元素,……,插入第N个元素,要考虑前 n - 1 个元素。因此,最坏情况下的比较次数是 1 + 2 + 3 + … + (n - 1),等差数列求和,结果为 n*(n-1) / 2,所以最坏情况下的复杂度为 O(n2)
最好情况下,数组已经是有序的,每插入一个元素,只需要考查前一个元素,因此最好情况下,插入排序的时间复杂度为O(n)
平均时间复杂度为O(n2)
实现
public static void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; ++i) {
// 每趟插入排序前,数组区间[0,i]经过上一趟插入排序处理后,是有序的
for (int j = i + 1; j > 0 && arr[j - 1] > arr[j]; --j) {
// 数组元素交换
Swaper.exec(arr, j - 1, j);
}
}
}
在每趟遍历开始时,取数组中[0, j]这个区间,通过逐个比对大小的方式,将arr[j]插入到数组中[0, i]的区间中,比对次数 = 交换次数 + 1
,在最坏的情况下需要比对 j 次
测试
代码:
// 生成长度为10的随机数组
int[] tmp = Tester.randomArr(10);
int[] arr = tmp.clone();
// 输出未排序的数组
System.out.println(Arrays.toString(arr));
long start = System.currentTimeMillis();
sort(arr);
long end = System.currentTimeMillis();
// 输出排序耗时和已排序的数组
System.out.println("插入排序结束,耗时" + (end - start) + "ms");
System.out.println(Arrays.toString(arr));
插入排序的优化
思考
前面说到,插入排序利用了已排序的数组中有序的特性,相比于冒泡排序,减少了遍历次数和交换次数,但是在坏情况下(排序了一堆较大的元素,接下来要一直插入小元素),每趟排序都需要逐个比对,交换很多次
我们在插入的过程实质上是一个查找合适的插入位置的过程,对于有序数组的查找,我们应该很容易就能想到二分查找
二分插入排序
时间复杂度分析
通过使用二分法,我们能够很快的找到适合插入的位置
遍历获得需要插入的元素需要的时间复杂度是O(n),之前的逐个比较查找法时间复杂度也是O(n),所以没有经过优化的插入排序的时间复杂度是O(n2)
而二分法可以做到O(log n)的时间复杂度,所以说在最好情况下(查到的插入位置正好在有序数组末尾,无需对数组元素进行移动)的时间复杂度为O(n*logn);
最坏情况下(需要插入到有序数组头,需要移动数组元素n次)还是O(n2)
平均时间复杂度为O(n2)
二分查找算法实现
首先实现一个二分查找算法,返回一个key适合在一个数组中插入的索引
private static int binarySearch(int[] arr, int start, int end, int key) {
// 当key <= 数组中最小元素 时,返回数组头的索引
if (key <= arr[start]) {
return start;
// 当key >= 数组中最大元素时,返回数组尾的索引+1
} else if (key >= arr[end]) {
return end + 1;
// 否则递归进行二分查找
} else {
int mid = start + (end - start) / 2;
if (key > arr[mid]) {
return binarySearch(arr, mid + 1, end, key);
} else {
return binarySearch(arr, start, mid, key);
}
}
}
排序算法实现
需要注意的是,我们在获得合适的插入位置的索引insertIdx
后,不需要再逐个交换,我们可以将需要插入的元素arr[j]缓存为tmp
,再将数组区间(insertIdx, j)
中的元素往后各移一位,最后将arr[insertIdx] = tmp
即可
public static void optSort(int[] arr) {
for (int i = 0; i < arr.length - 1; ++i) {
int j = i + 1;
// 二分查找找到合适的插入位置
int insertIdx = binarySearch(arr, 0, i, arr[j]);
// 存储需要插入的元素
int tmp = arr[j];
// 将数组区间(insertIdx,j)中的元素往后各移一位
for(;j>insertIdx;--j){
arr[j]=arr[j-1];
}
// 为指定下标赋值
arr[insertIdx] = tmp;
}
}
测试:性能对比
代码:
int n = 100_000;
int[] tmp = Tester.randomArr(n);
int[] arr = tmp.clone();
System.out.println("对有"+n+"个元素的随机数组进行排序:");
long start = System.currentTimeMillis();
sort(arr);
long end = System.currentTimeMillis();
System.out.println("插入排序结束,耗时" + (end - start) + "ms");
arr = tmp.clone();
start = System.currentTimeMillis();
optSort(arr);
end = System.currentTimeMillis();
System.out.println("二分插入排序结束,耗时" + (end - start) + "ms");
可以看出,通过二分查找的加持,二分插入排序的耗时还是有了一定的减少