插入排序是一种简单直观的排序方法,其基本思想是每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到全部记录插入完成。由插入排序的思想可以引申出三个重要的排序算法:直接插入排序、折半插入排序和希尔排序。
直接插入排序
根据上面的插入排序思想,不难得出一种最简单也最直观的直接插入排序算法。假设在排序过程中,待排序表 L[1...n] 在某次排序过程中的某一时刻状态如下: 有序序列 L[1...i-1] ,L(i),无序序列 L[i+1...n]
要将元素 L(i) 插入到已有序的子序列 L[1...i-1] 中,需要执行以下操作(为避免混淆,下面用 L[] 表示一个表,而用L() 表示一个元素):
- 查找出
L(i)在L[1...i-1]中的插入位置k。 - 将
L[k...i-1]中的所有元素依次后移一个位置。 - 将
L(i)复制到L(k)。
为了实现对 L[1...n] 的排序,可以将 L(2)~L (n) 依次插入到前面已排好序的子序列中,初始 L[1] 可以视为是一个已排好序的子序列。上述操作执行 次就能得到一个有序的表。插入排序在实现上通常采用就地排序(空间复杂度为
#card=math&code=O%28n%29&id=SUqJB) ), 因而在从后向前的比较过程中,需要反复把已排序元素逐步向后挪位,为新元素提供插入空间。
下面是直接插入排序的代码,其中再次用到了我们前面提到的“哨兵”(作用相同)。
void InsertSort(ElemType A[], int n) {int i, j;for (i = 2; i <= n; i++) { // 依次将A[2]~A[n]插入到前面已排序序列if (A[i] < A[i - 1]) { // 若A[i]关键码小于其前驱,将A[i]插入有序表A[0] = A[i]; // 复制为哨兵,A[0]不存放元素for (j = i - 1; A[0] < A[j]; --j) // 从后往前查找待插入位置A[j + 1] = A[j]; // 向后挪位A[j + 1] = A[0]; // 复制到插入位置}}}
直接插入排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为
#card=math&code=O%281%29&id=ZuarK) 。
- 时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了
趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
- 在最好情况下,表中元素已经有序,此时每插入一个元素,都只需比较一次而不用移动元素,因而时间复杂度为
#card=math&code=O%28n%29&id=V2At5)。
- 在最坏情况下,表中元素顺序刚好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,为
,总的移动次数也达到最大,为
#card=math&code=%5Csum_%7Bi%3D2%7D%5E%7Bn%7D%28i%2B1%29&id=ekSIy) 。
- 平均情况下,考虑待排序表中元素是随机的,此时可以取上述最好与最坏情况的平均值作为平均情况下的时间复杂度,总的比较次数与总的移动次数均约为
。因此,直接插入排序算法的时间复杂度为
#card=math&code=O%28n%5E2%29&id=LjeYE)
- 稳定性:由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序方法。
- 适用性:直接插入排序算法适用于顺序存储和链式存储的线性表。为链式存储时,可以从前往后查找指定元素的位置。
折半插入排序
从直接插入排序算法中,不难看出每趟插入的过程中都进行了两项工作:
- 从前面的有序子表中查找出待插入元素应该被插入的位置;
- 给插入位置腾出空间,将待插入元素复制到表中的插入位置。
注意到在该算法中,总是边比较边移动元素。下面将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素。当排序表为顺序表时,可以对直接插入排序算法做如下改进:由于是顺序存储的线性表,所以查找有序子表时可以用折半查找来实现。确定待插入位置后,就可统一地向后移动元素。算法代码如下:
void InsertSort(ElemType A[], int n) {int i, j, low, high, mid;for (i = 2; i <= n; i++) { // 依次将A[2]~A[n]插入前面的已排序序列A[0] = A[i]; // 将A[i]暂存到A[0]low = 1; // 设置折半查找的范围high = i - 1;while (low <= high) { // 折半查找(默认递增有序)mid = (low + high) / 2; // 取中间点if (A[mid] > A[0]) {high = mid - 1; // 查找左半子表} else {low = mid + 1; // 查找右半子表,保证算法稳定性}}for (j = i - 1; j >= high + 1; --j) {A[j + 1] = A[j]; // 统一后移元素,空出插入位置}A[high + 1] = A[0]; // 插入操作}}
从上述算法中,不难看出折半插入排序仅减少了比较元素的次数,约为 #card=math&code=O%28n%5Clog_%7B2%7D%7Bn%7D%29&id=D7Wp8) ,该比较次数与待排序表的初始状态无关,仅取决于表中的元素个数
;而元素的移动次数并未改变,它依赖于待排序表的初始状态。因此,折半插入排序的时间复杂度仍为
#card=math&code=O%28n%5E2%29&id=iDbRv),但对于数据量不很大的排序表,折半插入排序往往能表现出很好的性能。折半插入排序是一种稳定的排序方法。
希尔排序
从前面的分析可知,直接插入排序算法的时间复杂度为 #card=math&code=O%28n%5E2%29&id=Bfvjx) ,但若待排序列为“正序”时,其时间复杂度可提高至
#card=math&code=O%28n%29&id=oE8gX),由此可见它更适用于基本有序的排序表和数据量不大的排序表。希尔排序正是基于这两点分析对直接插入排序进行改进而得来的,又称缩小增量排序。
希尔排序的基本思想是:先将待排序表分割成若干形如 L[i,i+d,i+2d,...,i+kd] 的“特殊”子表,即把相隔某个“增量”的记录组成一个子表,对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,再对全体记录进行一次直接插入排序。
希尔排序的过程如下:先取一个小于 的步长
,把表中的全部记录分成
组,所有距离为
的倍数的记录放在同一组, 在各组内进行直接插入排序;然后取第二个步长
,重复上述过程,直到所取到的
,即所有记录已放在同一组中,再进行直接插入排序,由于此时已经具有较好的局部有序性,故可以很快得到最终结果。到目前为止,尚未求得一个最好的增量序列,希尔提出的方法是
,并且最后一个增量等于 1。
希尔排序算法的代码如下:
void ShellSort(int A[], int n) {int d, i, j;// A[0]只是暂存单元,不是哨兵for (d = n / 2; d > 1; d = d / 2) { // 步长变化for (i = d + 1; i <= n; ++i) {if (A[i] < A[i - d]) { // 需将A[i]插入有序增量子表A[0] = A[i]; // 暂存在 A[0]for (j = i - d; j > 0 && A[0] < A[j]; j -= d) {A[j + d] = A[j]; // 记录后移,查找插入的位置}A[j + d] = A[0]; // 插入}}}}
希尔排序算法的性能分析如下:
- 空间效率:仅使用了常数个辅助单元,因而空间复杂度为
#card=math&code=O%281%29&id=qnw3i)。
- 时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。当
在某个特定范围时,希尔排序的时间复杂度约为
#card=math&code=O%28n%5E%7B1.3%7D%29&id=wUkN5)。在最坏情况下希尔排序的时间复杂度为
#card=math&code=O%28n%5E2%29&id=RTDHP)。
- 稳定性:当相同关键字的记录被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。
- 适用性:希尔排序算法仅适用于线性表为顺序存储的情况。
