插入排序
本文要介绍三种常见的插入排序——直接插入排序、折半插入排序、希尔排序。后两者是对直接插入排序的优化。
1 直接插入排序(Straight Insertion Sort,简称插入排序Insertion Sort)
(1) 基本思想
把n个待排序的元素看成一个有序表和一个无序表。一开始有序表只包含1个元素,无序表包含n-1个元素。排序过程中每次从无序表中取出第一个元素,将它插入到有序表中的适当位置,使之成为新的有序表。每次排序使有序表长度+1,无序表长度-1,重复n-1次即可完成排序过程。
(2) 图文说明
(3) 代码实现
template <typename T>
void InsertSort(T array[], int len)
{
if(array == nullptr || len < 0)
return;
int i, j;
for(i = 1; i < len; ++i)
{
if(array[i] < array[i-1]) // 无序表的第一个元素小于有序表的最后一个元素
{
T temp = array[i];
for(j = i - 1; j>=0 && array[j]>temp; --j) // 元素后移,注意j>=0不能省略——当遇到一个元素比有序表的每个元素都小的情况,可确保j不会变为-1,导致越界错误
{
array[j+1] = array[j];
}
array[j+1] = temp; // 在合适位置上插入新元素
}
}
}
(4) 测试
int main()
{
int array[] = {5,2,9,1,-1};
int len = sizeof(array)/sizeof(int);
InsertSort(array, len);
for(int i = 0; i < len; ++i)
cout << array[i] << "\t";
cout << endl;
return 0;
}
测试结果
-1 1 2 5 9
(5) 复杂度分析
a.该算法的基本操作是array[j]>temp。
b.为什么不是j>=0呢?因为实际的计算机实现中,j>=0运算几乎肯定比array[j]>temp快。而且,该运算其实与本算法没有必然联系,如果采用限位器来实现的话,j>=0的运算完全可以避免。
(限位器:将数组的0号元素空出,每次将要插入的元素复制到0号元素。牺牲一个空间换取时间上的效率)
c.最好的情况是初始序列正序,此时只需要进行n-1次比较,时间复杂度为O(n);
d.最坏的情况是初始序列逆序,此时需要进行1+2+···+(n-1)=n(n-1)/2次比较和元素的移动,以及n-1次元素的赋值(插入),时间复杂度为O(n^2);
e.平均时间复杂度为O(n^2)。
f. 采用就地排序,空间复杂度为O(1)。
(6) 稳定性
直接插入排序是稳定排序,不会改变相同元素的相对顺序。
2 折半插入排序/二分插入排序(Binary Insertion Sort)
(1) 基本思想
折半插入排序是对直接插入排序的优化。在上面的排序中,为了找到元素的合适插入位置,采用从后向前遍历有序表,按顺序查找进行比较。为了减少比较次数,可以换一种查找策略——采用二分查找
(2) 图文分析
(3) 代码实现
// 二分查找函数
// 返回插入的下标
template <typename T>
int BinarySearch(T array[], int start, int end, T k)
{
while(start <= end)
{
int middle = (start + end) / 2;
T middleData = array[middle];
if(middleData > k)
end = middle - 1;
else
start = middle + 1; // 由于下文返回的是start,所以当middleData == k时,start = middle + 1,这样可以保证算法的稳定性
}
return start;
}
// 二分查找插入排序
template <typename T>
void BinaryInsertionSort(T array[], int len)
{
if(array == nullptr || len < 0)
return;
int i, j;
for(i = 1; i < len; ++i)
{
if(array[i] < array[i-1]) // 无序表的第一个元素小于有序表的最后一个元素
{
T temp = array[i];
int insertIndex = BinarySearch(array, 0, i, temp);
for(j = i-1; j >= insertIndex; --j) // 移动元素
{
array[j+1] = array[j];
}
array[insertIndex] = temp; // 在合适位置上插入新元素
}
}
}
(4) 测试
#include <iostream>
#include "BinaryInsertionSort.h"
using namespace std;
int main()
{
int array[] = {5,1,8,3,-2};
int len = sizeof(array)/sizeof(int);
BinaryInsertionSort(array, len);
for(int i = 0; i<len; ++i)
cout << array[i] << " ";
cout<<endl;
return 0;
}
测试结果
-2 1 3 5 8
(5) 复杂度分析
a. 其主要操作为查找插入下标(比较)+后移赋值
b. 此处采用的二分查找算法,对于一个长度为n的序列,比较次数为log2 n,故查找下标算法的时间复杂度为O(log2 n)
c. 最好的情况是初始序列正序:每个元素所在位置即为它的插入位置,此时需要进行log2 2 + log2 3 +···+log2 n次查找,时间复杂度为O(log2 n)
d. 最坏的情况是初始序列逆序:每次都要在起始位置插入元素,此时需要进行1+2+···+(n-1)=n(n-1)/2次元素的移动,以及n-1次元素的赋值(插入),时间复杂度为O(n^2)
e.平均时间复杂度为O(n^2)。
f. 采用就地排序,空间复杂度为O(1)。
(6) 稳定性
二分插入排序是稳定的,不过在实现的时候需要注意,详见BinarySearch函数
3 希尔排序(Shell Sort)
(1) 基本思想
希尔排序,又称缩小增量排序,是对直接插入排序的改进。改进的思路是将初始序列按步长gap分组,对每组采用直接插入排序方法进行排序,随着步长逐渐缩小,所分成的组包含的元素越来越多 ,当gap=1时,所有元素合成为1组,构成一组有序序列。
(2) 图文分析
(3) 代码实现
// 希尔排序
template <typename T>
void ShellSort(T array[], int len)
{
if(array == nullptr || len < 0)
return;
int gap;
int i, j;
for(gap = len / 2; gap > 0; gap /= 2)
{
for(i = gap; i < len; ++i) // 将距离为gap的元素划分到一个分组,共有gap个分组
{
if(array[i] < array[i-gap]) // 每个元素与自己组内的元素进行直接插入排序
{
T temp = array[i];
for(j = i - gap; j>=0 && array[j] > temp; j -= gap)
{
array[j+gap] = array[j];
}
array[j+gap] = temp;
}
}
}
}
(4) 测试
int main()
{
int array[] = {8,1,3,5,6,4,9,7,2,5};
int len = sizeof(array)/sizeof(int); ShellSort(array, len);
for(int i = 0; i<len; ++i)
cout << array[i] << " ";
cout<<endl;
return 0;
}
测试结果
1 2 3 4 5 5 6 7 8 9
(5) 复杂度分析
a. 其主要操作为查找插入下标(比较)+后移赋值
b.时间复杂度与步长序列挂钩
增量序列:
希尔增量:n/(2k),最坏的时间复杂度是O(n2),希尔增量问题在于这些增量未必互素,因此较小的增量可能影响很小。
Hibbard增量:2k-1,最坏的时间复杂度为O(n1.5),这些增量与希尔增量几乎一致,但关键的区别是相邻的增量没有公因子。
Sedgewick增量:(1, 5, 19, 41, 109,...),是目前已知的最好增量序列。
c. 采用就地排序,空间复杂度为O(1)。
(6) 稳定性
从上图可知,相等的数据在排序过程中可能会交换位置(两个5),因此该算法是不稳定的。