文章目录
插入排序的基本思想
插入排序是一种简单直观的排序方法,其基本思想是:每一次将一个待排记录按其关键字大小插入到前面已经排好序的有序子序列中,直到全部记录插入完成。
由插入排序的思想可以引申出三个重要的排序算法:
- 直接插入排序
- 折半插入排序
- 希尔排序
直接插入排序
直接插入排序的算法思想
根据插入排序的思想,不难得出一种最简单、最直观的直接插入排序算法。
假设在排序过程中,待排序表
L
[
1...
n
]
L[1...n]
L[1...n]在某次排序过程中的某一时刻状态如下:
要将元素
L
[
i
]
L[i]
L[i]插入到已经有序的子序列
L
[
1
,
i
+
1
]
L[1,i+1]
L[1,i+1]中,需要执行以下操作:
1、 查找出
L
[
i
]
L[i]
L[i]在
L
[
1
,
i
+
1
]
L[1,i+1]
L[1,i+1]中的插入位置
k
k
k;
2、 将
L
[
k
.
.
.
i
−
1
]
L[k...i-1]
L[k...i−1]中的所有元素全部后移一个位置;
3、 将
L
[
i
]
L[i]
L[i]复制到
L
[
k
]
L[k]
L[k]。
那么实现对一个序列
L
[
1...
n
]
L[1...n]
L[1...n]的排序,可以将
L
[
2
]
L[2]
L[2]~
L
[
n
]
L[n]
L[n]依次插入到前面已经排好序的子序列中,初始假定
L
[
1
]
L[1]
L[1]是一个已经排好序的子序列。
上述操作执行
n
−
1
n-1
n−1次就能得到一个有序表。
直接插入排序的示例:
初始序列:
4
,
5
,
1
,
2
,
6
,
3
4,5,1,2,6,3
4,5,1,2,6,3
第一趟:
[
4
,
5
]
,
1
,
2
,
6
,
3
[4,5],1,2,6,3
[4,5],1,2,6,3 ( 将
5
5
5插入{
4
4
4} )
第二趟:
[
1
,
4
,
5
]
,
2
,
6
,
3
[1,4,5],2,6,3
[1,4,5],2,6,3 ( 将
1
1
1插入{
4
,
5
4,5
4,5} )
第三趟:
[
1
,
2
,
4
,
5
]
,
6
,
3
[1,2,4,5],6,3
[1,2,4,5],6,3 ( 将
2
2
2插入{
1
,
4
,
5
1,4,5
1,4,5} )
第四趟:
[
1
,
2
,
4
,
5
,
6
]
,
3
[1,2,4,5,6],3
[1,2,4,5,6],3 ( 将
6
6
6插入{
1
,
2
,
4
,
5
1,2,4,5
1,2,4,5} )
第五趟:
[
1
,
2
,
3
,
4
,
5
,
6
]
[1,2,3,4,5,6]
[1,2,3,4,5,6] ( 将
3
3
3插入{
1
,
2
,
4
,
5
,
6
1,2,4,5,6
1,2,4,5,6} )
直接插入排序的实现代码
//直接插入排序
void InsertSort(SeqList &L){
int i,j;
for(i=2; i<L.n; i++){ //将L[2]~L[n]插入到前面已经排好序的序列中
if(L.data[i] < L.data[i-1]){ //如果data[i]的关键码小于其前驱,那么就要找插入位置
L.data[0] = L.data[i]; //0号为哨兵,暂存data[i]的值
for(j=i-1; L.data[0]<L.data[j]; j--){ //从后往前查找插入位置
L.data[j+1] = L.data[j]; //后移
}
L.data[j+1] = L.data[0]; //复制到插入位置
//由于L.data[j]刚刚比较完毕,即L.data[j]<L.data[0],
//退出了循环,故j+1为正确的插入位置
}
}
}
这里再次用到了我们前面文章顺序查找中提到的“哨兵”。
其实作用大致相同,在这里主要有两个:
- 在找到插入位置之前暂存 L . d a t a [ i ] L.data[i] L.data[i]的值
- 充当哨兵,即在查找插入位置的循环中,不必判断下标是否越界,因为在 j = = i j==i j==i时一定会跳出循环。
直接插入排序算法是边比较边移动元素。
考虑到文章的可读性,直接插入排序的完整代码在文章结尾,下同。
直接插入排序的性能分析
空间复杂度
仅使用了常数个辅助单元(一个用于暂存要插入元素的辅助空间 L . d a t a [ 0 ] L.data[0] L.data[0]),因而空间复杂度为 O ( 1 ) O(1) O(1)。
时间复杂度
在排序过程中,向有序子序列中逐个地插入元素地操作进行了 n − 1 n-1 n−1 趟,每趟操作都分为比较关键字和移动元素,而比较次数和移动次数取决于待排序表的初始状态。
- 最好情况:表中元素已经有序(正序),此时每次插入一个元素,都只需要比较一次而不用以到元素,因此时间复杂度为
O
(
n
)
O(n)
O(n)。
- 每趟比较1次,总共比较n-1次
- 每趟移动0次
- 最坏情况:表中元素顺序正好与排序结果中的元素顺序相反(逆序),总的比较次数达到最大,总的移动次数也达到最大。
- 第 i i i 趟比较 i + 1 i+1 i+1 次,即下标为 i i i 的元素需要与前 i i i个元素都比较一次,故总的比较次数为 2 + 3 + . . . + n = ( n − 1 ) ( n + 2 ) 2 2+3+...+n =\frac{(n-1)(n+2)}{2} 2+3+...+n=2(n−1)(n+2)
- 每比较一次都要移动一次,然后再将要插入的元素填入,故第 i i i 趟移动 i + 2 i+2 i+2 次,总的移动次数为 3 + 4 + . . . + n + 1 = ( n − 1 ) ( n + 4 ) 2 3+4+...+n+1 = \frac{(n-1)(n+4)}{2} 3+4+...+n+1=2(n−1)(n+4)
- 平均情况:考虑到待排序表中的元素是随机的,此时可以去上述最好和最坏情况下的平均值作为平均情况下的时间复杂度。
- 总的比较次数和移动次数均约为 n 2 / 4 n^2/4 n2/4
因此,直接插入排序算法的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
【注】虽然折半插入排序算法的时间复杂度也为
O
(
n
2
)
O(n^2)
O(n2),但对于数据量比较小的排序表,折半插入排序往往能表现出很好的性能。
稳定性
由于每次插入元素时总是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况,即直接插入排序是一个稳定的排序算法。
适用性
直接插入排序适用于顺序存储和链式存储的线性表。其中链式存储时,可以从前往后查找指定元素的位置。(而大部分排序算法仅适用于顺序存储的线性表)
当序列中的记录基本有序或者待排记录较少时,直接插入排序是最佳算法。
折半插入排序
折半插入排序的算法思想
从直接插入排序算法中,可以看出每趟插入的过程都进行了两项工作:
- 从前面的有序子序列中找到待插入元素应该被插入的位置
- 给插入位置腾出空间,将待插入元素复制到表的插入位置。
注意,直接插入排序是边比较边移动元素的。
若将比较和移动操作分离,即先折半查找出元素的待插入位置,然后统一地移动待插入位置之后的所有元素,这就是折半插入排序算法。
折半插入排序的实现代码
//折半插入排序
void BinaryInsertSort(SeqList &L){
int i,j,low,high,mid;
for(i=2; i<L.n; i++){ //将L[2]~L[n]插入到前面已经排好序的序列中
L.data[0] = L.data[i]; //0号为哨兵,暂存data[i]的值
low=1;
high = i-1; //设置折半查找的范围
while(low<=high){ //查找插入位置
mid = (low+high)/2;
if(L.data[0]<L.data[mid]) high=mid-1; //查找左半子表
else low=mid+1; //查找右半子表
}
for(j=i-1; j>=high+1; j--){
L.data[j+1] = L.data[j]; //后移元素,腾出插入位置
}
L.data[high+1] = L.data[0]; //插入
}
}
折半插入排序的性能分析
空间复杂度
仅使用了常数个辅助单元(一个用于暂存要插入元素的辅助空间 L . d a t a [ 0 ] L.data[0] L.data[0]),因而空间复杂度为 O ( 1 ) O(1) O(1)。
时间复杂度
时间复杂度包括在排序过程中的关键码的比较次数和元素移动次数。
- 关键码的比较次数与待排序列的初始状态无关,仅取决于表中的元素个数 n n n,插入第 i i i 个元素时,关键码的比较次数大约是 l o g 2 ( i + 1 ) log_2(i+1) log2(i+1),故总的比较次数约为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
- 元素的移动次数取决于待排序表的初始状态,与直接插入排序相同,可参照上一部分。
- 最好情况:移动0次
- 最坏情况:移动 ( n − 1 ) ( n + 4 ) 2 \frac{(n-1)(n+4)}{2} 2(n−1)(n+4)次
- 平均情况:移动次数约为 n 2 / 4 n^2/4 n2/4次
故折半插入排序的时间复杂度仍为 O ( n 2 ) O(n^2) O(n2)。
稳定性
不难得知,折半插入排序与直接插入排序一样,也是一种稳定的排序方法。
适用性
当
n
n
n较大时,总关键码的比较次数比直接插入排序的最差情况要好得多。
故折半插入排序适用于
n
n
n 较大的情况,并且一般为顺序存储的线性表,当线性表为链式存储时难以实现。
希尔排序
希尔排序的算法思想
希尔排序又称为缩小增量排序,其算法思想为:
每趟按照一个增量 d i d_i di 作为间隔,将全部元素序列分为 d i d_i di 个子序列,所有距离为 d i d_i di 的元素放在同一个子序列中,在每一个子序列中分别进行直接插入排序。然后缩小增量 d i d_i di,重复上面的子序列划分和排序操作,直到最后取 d i = 1 d_i=1 di=1,将所有元素放在同一个序列中排序为止。
由于开始时, d i d_i di 的取值较大,每个子序列中的元素较少,所以排序速度较快;等到排序的后期, d i d_i di 取值逐渐变小,子序列中的元素逐个变多,但由于前面工作的基础,大多数元素已经基本有序,故排序速度依然很快。
希尔排序的示例:
( 下面过程中同色为同一子序列,需要进行排序操作 )
原始序列: 50 , 26 , 38 , 80 , 70 , 90 , 8 , 30 , 40 , 20 50,26,38,80,70,90,8,30,40,20 50,26,38,80,70,90,8,30,40,20,并且规定 d = { 5 , 3 , 1 } d=\{5,3,1\} d={5,3,1}
第一趟(增量
5
5
5):
排序结果为:
第二趟(增量为3):
排序结果为:
第三趟(增量为1):
排序结果为:
希尔排序的实现代码
//希尔排序
void ShellSort(SeqList &L){
int d,i,j;
int n = L.n-1; //除去L.data[0],n是待排元素个数
//前后位置记录的增量是d,不是1
//这里的L.data[0]只是暂存单元,不是哨兵;当j<=0时,插入位置已到
for(d=n/2; d>=1; d=d/2){
for(i=d+1; i<=n; i++){
if(L.data[i] < L.data[i-d]){ //说明需要将L.data[i]插入到前面的有序增量子表中
L.data[0] = L.data[i]; //L.data[0]暂存该值
for(j=i-d; j>0&&L.data[0]<L.data[j]; j-=d){ //注意每次变化是d
L.data[j+d] = L.data[j]; //记录后移,查找插入位置
}
L.data[j+d] = L.data[0]; //插入
}
}
}
}
一般初始增量 d = n / 2 d=n/2 d=n/2,且 d i + 1 = d i / 2 d_{i+1}=d_i/2 di+1=di/2,并且最后一个增量为1。
希尔排序的性能分析
空间复杂度
仅使用了常数个辅助单元(一个用于暂存要插入元素的辅助空间 L . d a t a [ 0 ] L.data[0] L.data[0]),因而空间复杂度为 O ( 1 ) O(1) O(1)。
时间复杂度
由于希尔排序的时间复杂度依赖于增量序列的函数,这涉及数学上尚未解决的难题,所以其时间复杂度分析比较困难。
当
n
n
n在某个特定范围时,希尔排序的时间复杂度约为
O
(
n
1.3
)
O(n^{1.3})
O(n1.3),在最坏情况下希尔排序的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)。
稳定性
当相同的关键字被划分到不同的子表时,可能会改变它们之间的相对次序,因此希尔排序是一种不稳定的排序方法。
适用性
希尔排序算法仅适用于线性表为顺序存储的情况。
完整代码
#include<bits/stdc++.h>
using namespace std;
//设待排序序列存储在静态分配的顺序表中
#define maxSize 20
typedef struct{
int data[maxSize];
int n; //元素个数,初始为1,即留出0号位置
}SeqList;
//输入待排序列并存入顺序表中
void CreateList(SeqList &L, int n){
L.n = n+1; //从下标1开始存储
for(int i=1; i<=n; i++){
int x;
cin>>x;
L.data[i] = x;
}
}
//输出序列
void PrintList(SeqList L){
for(int i=1; i<L.n; i++){ //0号预留,从1开始存储数据,故从1开始输出
cout<<L.data[i]<<" ";
}
cout<<endl;
}
//直接插入排序
void InsertSort(SeqList &L){
int i,j;
for(i=2; i<L.n; i++){ //将L[2]~L[n]插入到前面已经排好序的序列中
if(L.data[i] < L.data[i-1]){ //如果data[i]的关键码小于其前驱,那么就要找插入位置
L.data[0] = L.data[i]; //0号为哨兵,暂存data[i]的值
for(j=i-1; L.data[0]<L.data[j]; j--){ //从后往前查找插入位置
L.data[j+1] = L.data[j]; //后移
}
L.data[j+1] = L.data[0]; //复制到插入位置
//由于L.data[j]刚刚比较完毕,即L.data[j]<L.data[0],
//退出了循环,故j+1为正确的插入位置
}
}
}
//折半插入排序
void BinaryInsertSort(SeqList &L){
int i,j,low,high,mid;
for(i=2; i<L.n; i++){ //将L[2]~L[n]插入到前面已经排好序的序列中
L.data[0] = L.data[i]; //0号为哨兵,暂存data[i]的值
low=1;
high = i-1; //设置折半查找的范围
while(low<=high){ //查找插入位置
mid = (low+high)/2;
if(L.data[0]<L.data[mid]) high=mid-1; //查找左半子表
else low=mid+1; //查找右半子表
}
for(j=i-1; j>=high+1; j--){
L.data[j+1] = L.data[j]; //后移元素,腾出插入位置
}
L.data[high+1] = L.data[0]; //插入
}
}
//希尔排序
void ShellSort(SeqList &L){
int d,i,j;
int n = L.n-1; //除去L.data[0],n是待排元素个数
//前后位置记录的增量是d,不是1
//这里的L.data[0]只是暂存单元,不是哨兵;当j<=0时,插入位置已到
for(d=n/2; d>=1; d=d/2){
for(i=d+1; i<=n; i++){
if(L.data[i] < L.data[i-d]){ //说明需要将L.data[i]插入到前面的有序增量子表中
L.data[0] = L.data[i]; //L.data[0]暂存该值
for(j=i-d; j>0&&L.data[0]<L.data[j]; j-=d){ //注意每次变化是d
L.data[j+d] = L.data[j]; //记录后移,查找插入位置
}
L.data[j+d] = L.data[0]; //插入
}
}
}
}
int main(){
SeqList L;
int n;
cin>>n; //元素个数
CreateList(L, n);
PrintList(L);
//直接插入排序
InsertSort(L);
PrintList(L);
//折半插入排序
BinaryInsertSort(L);
PrintList(L);
//希尔排序
ShellSort(L);
PrintList(L);
return 0;
}
运行结果:
说明:其实在执行完直接插入排序后顺序表中的元素已经是有序的了,下面的这般排序和希尔排序只是为了验证算法的正确性。