本专栏是学习王争老师的《数据结构与算法之美》的学习总结,详细内容可以去学习王争老师的专栏,希望大家都能够有所收获。同时也欢迎大家能够与我一起交流探讨!
插入排序(Insertion Sort)
1、插入排序介绍
一个有序数组,往里添加一个新数据,只需要遍历数组,找到数据应该插入的位置将其插入即可。这是一个动态排序的过程,即动态地往有序集合中添加数据,通过这种方法可以保持集合中的数据一直有序。
而对于一组静态数据,也可以借鉴这种插入方法来进行排序,即插入排序的思想。
插入排序具体是如何借助上面的思想来实现排序的呢?
首先将数组中的数组分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
举个例子,需要排序的数据为4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。
插入排序也包含两种操作,元素的比较与的移动。当需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度,即移动元素次数 == 逆序度。
为什么说移动次数就等于逆序度呢?如上图,满有序度是 n*(n-1)/2=15
,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10=3+3+4。
插入排序的代码实现如下图所示:
public class InsertionSort {
// 插入排序,arr表示数组,n表示数组大小
public void insertionSort(int[] arr, int n) {
if (n <= 1) return;
// 从第二个元素开始,查找插入的位置
for (int i = 1; i < n; i++) {
int value = arr[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; j--) {
if (arr[j] > value) {
arr[j + 1] = arr[j]; // 数据移动
} else {
break;
}
}
arr[j + 1] = value; // 插入数据
}
}
}
2、插入排序是原地排序算法吗?
从实现过程看出,插入排序的运行并不需要额外的存储空间,所以空间复杂度是 O(1),即插入排序算法是一个原地排序算法。
3、插入排序是稳定的排序算法吗?
在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
4、插入排序的时间复杂度是多少?
若要排序的数组已有序,则不需要搬移任何数据。如果从尾到头在有序区间里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据,即从尾到头遍历有序区间。
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n^2)。
在数组中插入一个数据的平均复杂度为O(n),所以对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n^2)。
数组插入的平均时间复杂度分析:先考虑概率,每个位置插入元素的概率都为1/n。
插入数组的第一位,需要将剩余元素向右移动n个位置,插入数组的第二位,需要将剩余元素向右移动n-1个位置,以此类推,由此得到:( n + n-1 + n-2 + … + 1 + 0 ) * 1/n = (n+1)/2,去掉系数则复杂度为O(n)。
为什么插入排序比冒泡排序更受欢迎?
冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
首先,冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
但从代码实现上,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。
冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) { // 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
a[j+1] = a[j]; // 数据移动
} else {
break;
}
如果将一个赋值语句的时间粗略地计为单位时间(unit_time),分别用冒泡排序与插入排序对同一个逆序度是K的数组进行排序。
冒泡排序需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时是 3*K 单位时间。而插入排序中数据移动操作只需要 K 个时间单位。
针对冒泡排序和插入排序的 Java 代码,随机生成 10000 个数组,每个数组中包含 200 个数据,然后在机器上分别用冒泡和插入排序算法来排序,冒泡排序算法大约 700ms 才能执行完成,而插入排序只需要 100ms 左右就能搞定!
虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O(n2),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。如果对插入排序的优化感兴趣吗,可以学习希尔排序。
总结
1、将数组中的数据分为已排序区间和未排序区间,插入排序的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程直到未排序区间中元素为空,算法结束。
2、插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),即插入排序是一个原地排序算法。
3、在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
4、当要排序的数组数据已有序,并不需要搬移任何数据。从尾到头遍历有序区间(此时整个数组都已经是有序区间)查找插入位置时,每次只需要比较一个数据就能够确定插入位置。这种情况下最好的时间复杂度为O(n)。
5、如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
6、在数组中插入一个数据的平均时间复杂度是O(n)。所以对于插入排序,每一次插入操作相当于在数组中插入一个数据,循环执行n次插入操作,所以平均时间复杂度O(n^2)。