基本思想
插入排序的基本思想在于维护一个有序区,依次在线性表中向后扫描元素,将其插入在这个有序区的合适位置。
下面讨论具体算法:设一线性表
A[1..n]
。我们令循环变量
i
从
程序实现与运行结果
因此,插入排序算法的C++语言描述如下。使用了std::vector<_Ty>
泛型来接受输入,需要保证_Ty
类型重载了运算符operator<
(对于Java C#语言来说,类型_Ty
需要继承带有比较操作的接口Comparable
,IComparable
)。对于内层for
循环来说,尤其需要注意循环的初值条件和边界条件。令j
从i - 1
开始是因为有序区是从0
到i - 1
的(再想一下,A[i]
是当前趟需要处理的)。边界条件j >= 0
而不是j > 0
是因为,我们在寻找合适位置的时候,使用的是A[j] > tmp
,也就是说,找到合适位置时,这个j
位置对应的元素A[j]
必然小于当前插入的元素。因此为了在没找到这种情况下与其保持一致,也就是A[j]
是最小的,它必然会放到A[0]
位置上。这时候如果j = -1
就恰好保证了这种一致性。根据上面的讨论,每一趟结束时A[j + 1] = tmp
而不是A[j] = tmp
也就是顺理成章的了。
/**
* 插入排序。
* @param std::vector<_Ty> & list 需要排序的线性表。
*/
template<typename _Ty>
void insertion_sort(std::vector<_Ty> & A){
//一个元素我们假定是有序的,因此没有必要从0开始循环。
for (int i = 1; i < A.size(); i++){
_Ty tmp = A[i]; //保存A[i]
int j;
//倒序挪动元素,因为有重叠且向后移动
//需要注意的还有起始条件和终止条件。
for (j = i - 1; j >= 0 && A[j] > tmp; j--)
A[j + 1] = A[j];
A[j + 1] = tmp;
//下面的语句为了观察每一趟的输出
for (auto it = A.begin(); it != A.end(); ++it)
std::cout << *it << "\t";
std::cout << std::endl;
}
}
在主程序中设置输入为5 4 11 18 1 70 35 90 100 2
,观察其每一趟执行结果如下所示,可以发现每进行一次,有序区增加一个元素而无序区减少一个元素,最终全部变成有序的。
4 5 11 18 1 70 35 90 100 2
4 5 11 18 1 70 35 90 100 2
4 5 11 18 1 70 35 90 100 2
1 4 5 11 18 70 35 90 100 2
1 4 5 11 18 70 35 90 100 2
1 4 5 11 18 35 70 90 100 2
1 4 5 11 18 35 70 90 100 2
1 4 5 11 18 35 70 90 100 2
1 2 4 5 11 18 35 70 90 100
时间与空间复杂度
首先分析一下空间复杂度。因为整个insertion_sort
只使用了几个临时变量,因此很显然的,是常数空间复杂度
O(1)
。
分析时间复杂度的话,需要考虑到数据的分布情况。在数据完全有序的情况下,第二层循环只可能执行一次判断语句,也就是说时间复杂度只取决于外层循环的执行情况,这时候的时间复杂度为
O(n)
。相反地,如果数据完全逆序,即
A[1]>A[2]>...>A[n]
,那么每次迭代过程中,当前的
A[i]
都小于
A[1..i−1]
的任意元素,因此必然需要放到
A[1]
位置。这时候内层循环需要执行到底,内层循环的执行次数为
∑n−11i=n(n−1)2
,因此时间复杂度为
O(n2)
。这里没有采用教材的方法去分别计算移动和比较的具体次数,因为移动相对于比较来说最多多一个外层循环内部的两个赋值语句,相当于低阶项,并不影响时间复杂度。当然,时间复杂度只是一个趋势,如果赋值语句操作开销非常大,常数和低阶项带来的开销也是不能忽视的(比如对一个大的class
深复制)。
稳定性
插入排序是稳定的。在每次迭代过程中,考虑内层for
循环的终止条件之一A[j] <= tmp
。它并不包含等号。如果出现相等的元素,就停止迭代。此时就会有
A[j]=A[j+1]=K
,但是显然
A[j+1]
的原始位置为
A[i]
,而
A[j]
是上一次迭代已经确定的,也就是说
A[j]
的原始位置来自于
A[1..i−1]
,因此
A[j]
的原始位置也在
A[j+1]
的原始位置之前。因此也就保持了相对位置不变。由于上述初始情况的成立,利用数学归纳法不难得出多个相等元素的情况下,其相对位置也是保持不变的。
如果包括了等号,那就不稳定了。因为前面已经排好序的相等的元素,会被顺次向后移动,在这种情况下,可能就会出现不稳定的情况。比如序列8 7(a) 7(b)
。第一次迭代后,得到7(a) 8 7(b)
,第二次迭代,如果使用A[j] >= tmp
进行判断,那么在j = 0
时,也满足条件,最终得到7(b) 7(a) 8
。显然不稳定了。
关于折半插入排序
折半插入排序,是利用二分查找,改善了查找指定位置时的时间效率,但是对于数据的移动,依然需要一个一个向后挪动,因此不能明显地改变整体的时间复杂度(元素的挪动,也就是类的深度复制可能代价远超过比较,实际上把比较的线性换成对数阶,实际改善恐怕有限),也没有太多的实际意义。