图文详解:直接插入排序
1. 基本概念
直接插入排序所使用的基本思想非常简单,那就是:将一个元素插入到已排好序的序列中从而形成一个新的有序序列,并将这一过程重复多次。
我们用图1来对这一过程进行说明,假设开始的时候有一个有序序列 [3, 6, 8, 17, 22] 和一个待插入的元素11。
图1 直接插入排序的基本思想
1. 首先将22和11进行比较,发现22比11大。这说明11的插入位置应该在22之前,所以将22向后移动以腾出一个空位置。但此时还不能直接将11插入到该空位置上,因为它可能比更前面的元素还要小。
2. 再将17和11进行比较,发现17大于11。这说明11的插入位置应该在17之前,所以将17向后移动以腾出一个空位置,同时它会占据上一次腾出的空位置。
3. 再将8和11进行比较,发现8小于11。这说明11的插入位置应该在8之后,因此将11插入到当前的空位置上。
4. 最终,形成了一个新的有序序列 [3, 6, 8, 11, 17, 22]。
这个过程也可以从前面开始比较,把小于11的元素都向左移动一个位置后再插入11。但从后面开始比较有利于我们对整个直接插入排序算法的讲解,也能够简化算法的实现。
接下来我们就来考虑如何将上面这一过程重复多次,每一次重复都叫一趟。对一个序列进行直接插入排序的时候,就要把该序列看成前后两部分,前面部分是已排好序的有序序列,后面部分是还没有排好序的序列。在最开始的时候,我们将整个序列的第一个元素看成是已排好序的前面部分(我们可以认为只有一个元素的序列必定是有序的),后面的所有元素都属于没有排好序的部分。
在每一趟排序中,将后面部分的第一个元素插入到前面的有序部分,这样做后就形成了新的前面部分(有序)和后面部分(未排序)。再进行下一趟排序,又把后面部分的第一个元素插入到前面部分。重复这一过程,直到所有的元素都排好序。
根据上面的讲解,我们可知一个具有n个元素的序列,初始时后面的n-1个元素都属于未排序部分。而每一趟排序都将一个后面部分的元素插入到前面部分,所以一共需要n-1趟排序。
同样的,我们用图2来对整个直接插入排序过程进行演示。假设我们有一个序列 [33, 7, 58, 26, 31, 49],初始状态下我们将第一个元素33看作是前面已排好序的部分。
图2 直接插入排序的完整过程
1. 将后面部分的第一个元素7插入到前面部分中,它的位置应该在33前面。此时,形成新的前面部分 [7, 33] 和后面部分 [58, 26, 31, 49]。
2. 将后面部分的第一个元素58插入到前面部分中,它的位置应该在33后面(即它当前的位置),此时不需要移动元素。新的前面部分为 [7, 33, 58] 而后面部分为 [26, 31, 49]。
3. 继续将后面部分的第一个元素26插入到前面部分中,它的位置应该在7和33之间。完成后新的前面部分为 [7, 26, 33, 58],新的后面部分为 [31, 49]。
4. 将后面部分的第一个元素31插入到前面部分中,它的位置应该在26和33之间。新的前后部分分别为 [7, 26, 31, 33, 48] 和 [49]。
5. 最后一趟,还是把后面部分的第一个元素49插入到前面部分中,它的位置在33和58之间。这一步完成后,整个序列都排好序了,最终的有序序列为 [7, 26, 31, 33, 49, 55]。
2. 算法实现
直接插入排序的算法实现很简单,从前面的讲解中我们已经知道有n个元素的序列需要进行 n-1 趟排序。在每一趟排序中,我们首先将原序列后面部分的第一个元素(我们将它称为待排序元素)与前面部分的最后一个元素(已排序的最大元素)进行比较。如果该待排序元素大于或等于已排序的最大元素,那么它就已经在要插入的位置上了,也就不需要再和更前面的元素进行比较以及移动元素了,此时直接进入下一趟排序。
如果当前待排序元素小于已排序的最大元素,那么就要将该已排序的最大元素向后移动一个位置,为最终插入待排序元素或移动更前面的其它元素而腾出空位置。因为这会占据当前待排序元素的位置,所以在移动之前需要将该待排序元素保存到一个临时变量中。
这之后再将该待排序元素和更前面的元素依次进行比较,直到遇到第一个不大于它的元素或到达整个序列第一个元素之前。所有那些大于它的元素都需要向后移动一个位置以给它腾出一个空位置,最后将该待排序元素复制到该空位置上。此时,本趟排序就完成了,然后进入下一趟排序。
这里描述的直接插入排序的实现是一种稳定的排序算法,因为原序列中同样大小的元素之间的相对位置在排序后和排序前是一样的。
直接插入排序有很多种实现,但主要的思路都是一致的,只是一些细节不同罢了。下面我们给出C语言的实现代码,我想即便你没有用过C语言,但只要接触过其它相似语言也能很容易地看懂它。
/*
* Function: insertSort
* Description: 直接插入排序的实现
* param: array 待排序的序列(数组)
* param: n 序列中的元素数量
*/
void insertSort(int array[], int n) {
/* i和j作为循环控制变量,temp用于移动元素 */
int i;
int j;
int temp;
/*
* 外层循环,用于控制第1至第n-1趟排序;
* 每次循环开始时,i等于原序列后面部分
* 的第一个元素(当前待排序元素)的下标。
*/
for(i = 1; i < n; ++i) {
/* i-1是原序列前面部分的最后一个元素的下标 */
if(array[i] < array[i - 1]) {
/*
* 将前面部分的最后一个元素向后移动一个位置,
* 因为它会占据当前待排序元素的位置,所以需要
* 先将该待排序元素保存到temp中。
*/
temp = array[i];
array[i] = array[i - 1];
/*
* 从前面部分的倒数第二个元素(下标为i-2)开始,
* 从后往前遍历前面部分的元素。如果它大于待排序
* 元素,那么它需要向后移动一个位置。
*/
for(j = i - 2; (j >= 0) && (array[j] > temp); --j) {
array[j + 1] = array[j];
}
/* 当内层循环退出之后,j+1就是待排序元素应该插入的位置的下标 */
array[j + 1] = temp;
}
}
}
3. 性能分析
为了分析直接插入排序的性能,我们只考虑针对序列中元素的操作,而不考虑循环控制变量的比较和赋值。这是因为,在更实际的应用中这部分性能消耗相对于元素的操作来说要小得多;这也让分析更简单。
在空间复杂度方面,直接插入排序只需要一个额外的temp变量,所以它的空间复杂度为O(1)。
针对时间复杂度,我们先来考虑最好的情况。如果原序列本身就已经是排好了序的,那么每一趟排序只需要比较一次而不需要任何移动。此时,一共需要 n-1 次比较。
再考虑最坏的情况,如果原序列本身就是逆序的,那么第 i(1 ≤ i ≤ n-1)趟排序需要比较 i 次、移动 i+2 次(包括将待排序元素移动到temp变量中和从temp变量中移动到最终位置上)。此时,一共需要1+2+3+...+(n-1) = n(n-1)/2次比较、3+4+5+...+(n+1) = (n+4)(n-1)/2次移动。
在最好情况下,总操作次数为n-1。在最坏情况下,总操作次数为(n-1)(n+2)。那么平均情况下,总的操作次数大约为n·n/2(n的平方除以2),因此直接插入排序的时间复杂度为O(n·n)。
(完)