对于排序算法,我们一开始都是学习的最简单暴力的算法,即冒泡排序和选择排序,这两个差不多,都是思想简单,实现起来也很简单,但就是时间复杂度非常高,无论是最优效率还是最差效率,复杂度都为O(n2)。
这里我将详细介绍一下两种比较高级的排序算法——插入排序和希尔排序(shell sort)
注:这里的排序算法默认都是非降序排列
插入排序(insertion sort)
插入排序是一种减治法的思想,并且是减治法中的减一技术。
有关减治法,可以参考我的另一篇博文:《减治法》。
插入排序的主要思想就是假设前n-1个元素都已经是有序队列了,然后将第n个元素插入到有序队列中的对应位置中。
实现思路
从插入排序的主要思想中,我们可以发现这是基于递归思想实现的,但是很明显使用迭代的效率会更高一些。
那么我们应该可以很容易地想到从后面开始依次进行插入,即第n个元素首先和第n-1个元素进行比较。
那么可以从前面开始判断吗?
其实最好还是不要,因为我们可以想一想,如果从前面开始判断,那么假设知道第n个元素应该被放在第i个位置,则剩下n-i个元素仍然需要遍历,即无论第n个元素应该被放在第1个位置(最差情况)还是第n个位置(最优情况),一次插入都需要遍历n个元素,这和冒泡排序、选择排序有什么区别呢?因此,要想时间复杂度小于O(n2),则必须从后面开始比较。
先上伪代码
因为伪代码没有其他语言的语法限制,能够有助于我们更好地将注意力放在算法上,所以这里我们先用伪代码来讲述一下主要思想:
algorithm insertionSort(A[0..n-1])
//用插入排序对给定数组排序
//输入:n个可排序元素构成的一个数组A[0..n-1]
//输出:非降序排列的数组A[0..n-1]
for i <- 1 to n-1 do
v <- A[i]
j <- i-1
while j >= 0 and A[j] > v do
A[j+1] <- A[j]
j <- j-1
A[j+1] <- v //这里注意,因为第j个元素不大于第i个元素,所以应该放在第j+1个位置
那么我们再来看看图解(其中,竖线将输入的有序部分和剩下的元素分开,正在插入的元素用粗体表示,即为第i个元素):
代码实现
这里我用C++来实现。
首先定义一个数组:
int A[10] = {23, 5, 7, 4, 89, 76, 2, 6, 44, 33};
然后便是插入排序的关键代码:
void insertionSort(int* A) {
for (int i = 1; i < 10; i++) {
int temp = A[i];
int j = i - 1;
while (j >= 0 && A[j] > temp) {
A[j + 1] = A[j];
j--;
}
A[j + 1] = temp;
}
}
在main函数中启动:
int main() {
cout << "排序前的数组:" << endl;
for (int i = 0; i < 10; i++) {
cout << A[i] << " ";
}cout << endl;
insertionSort(A);
cout << "排序后的数组:" << endl;
for (int i = 0; i < 10; i++) {
cout << A[i] << " ";
}cout << endl;
return 0;
}
运行结果如下:
接下来,我们再来看看插入排序的时间复杂度:
时间复杂度
我们可以将该算法的基本操作规定为A[j] > v
,那么,
最优情况,即为该数组已经是非降序排列的:
C
b
e
s
t
=
∑
i
=
1
n
−
1
1
=
n
−
1
∈
O
(
n
)
C_{best}=\sum_{i=1}^{n-1}1=n-1\in O(n)
Cbest=i=1∑n−11=n−1∈O(n)
最差情况,即为该数组是非升序排列,刚好为逆序数组:
C
w
o
r
s
t
=
∑
i
=
1
n
−
1
∑
j
=
0
i
−
1
1
=
∑
i
=
1
n
−
1
i
=
(
n
−
1
)
n
2
∈
O
(
n
2
)
C_{worst}=\sum_{i=1}^{n-1}\sum_{j=0}^{i-1}1=\sum_{i=1}^{n-1}i=\frac{(n-1)n}{2}\in O(n^2)
Cworst=i=1∑n−1j=0∑i−11=i=1∑n−1i=2(n−1)n∈O(n2)
由此,我们可以很容易地发现一个问题,这最优情况与最差情况的差距太大了!而且,最差情况与冒泡排序、选择排序差不多。。。
总结
虽然这种算法对于一个完全无序的数组进行排序的效率不高,但是如果是处理基本有序的数组,那么使用这种算法的效率就会很明显,因此,这种算法常用于对一个有序的数组插入一个无序的数组。
希尔排序(shell sort)
希尔排序其实是插入排序的一种扩展,因为考虑到插入排序每插入一个元素,都需要与前n-1个元素依次进行比较,直到找到一个不大于该元素的元素,这其中就会花费大量的时间进行比较,而如果能够将比较的次数缩短,那么算法的效率就能够得到极大的改进了。因此,这才有了希尔排序。
主要思想:首先设置一个增量序列,该序列应该由大到小排列,且最后一个元素必须为1(步长为1的情况就是前面讲的插入排序),然后根据增量序列中的步长,将待排序的数组分成若干组,以组的形式进行插入排序。由于大多数情况下的数组都是无序的,那么如果一个很小的元素排在比较靠后的位置,步长为1的时候就需要进行多次的比较操作,因此希尔排序就是希望开始的时候“步子迈大一点”,这样比较靠后的小元素就能够很快的排到前面去,从而减少比较的次数。最后一个步长必须为1,是为了进行最终的比对,只有步长为1的情况经历了一遍,我们才能肯定数组已经是有序的了,否则可能在局部还是无序的。
因此希尔排序的关键就是增量序列的选取!
先上伪代码
老规矩,先来看看伪代码:
algorithm shellSort(A[0..n-1])
//用希尔排序对给定数组排序
//输入:n个可排序元素构成的一个数组A[0..n-1]
//输出:非降序排列的数组A[0..n-1]
for t <- 0 to k-1 do //步长数组D[0..k-1],并且D[k-1] = 1
d <- D[t]
for i <- d to n-1 do
for j <- i-d to 0 step -d do //每次j-d
if(A[i] < A[j]) swap(A[i], A[j])
例如:对于序列(256,301,751,129,937,863,742,694,76,438),用希尔排序(增量序列为d=5,3,1):
第一趟(d=5):256,301,694,76,438,863,742,751,129,937
第二趟(d=3):76,301,129,256,438,694,742,751,863,937
第三趟(d=1):76,129,256,301,438,694,742,751,863,937
其中,颜色相同的表示在一组中。
代码实现
以之前的例子为例。
数据如下:
int A[10] = {23, 5, 7, 4, 89, 76, 2, 6, 44, 33};
然后希尔排序函数:
int step[3] = {5, 3, 1}; //增量序列
void shellSort(int* A) {
for (int i = 0; i < 3; i++) {
int d = step[i];
for (int j = d; j < 10; j++) {
for (int k = j - d; k >= 0; k -= d) {
if (A[j] < A[k]) swap(A[j], A[k]);
}
}
}
}
然后在main函数中启动:
int main() {
cout << "排序前的数组:" << endl;
for (int i = 0; i < 10; i++) {
cout << A[i] << " ";
}cout << endl;
shellSort(A);
cout << "排序后的数组:" << endl;
for (int i = 0; i < 10; i++) {
cout << A[i] << " ";
}cout << endl;
return 0;
}
运行结果如下:
时间复杂度
注意:希尔排序的时间复杂度依赖于增量序列的选取!
研究表明:
当选取的增量序列为n/2k(n为数组元素个数,k=1,2,3…),如n/2,n/4,n/8,…,1,则最差的时间复杂度为n2。
据说,增量序列为1,4,13,40,121的效率是最高的(书上说的,我不负责,hhh)
因此增量序列的选取也是一门学问,一个好的增量序列能够大大提高希尔排序的效率。
总结
由于希尔排序一开始是按照分组进行排序的,因此相同元素的相对位置可能在中途会被打乱顺序,因此希尔排序其实是不稳定的。
其中算法的稳定性指的是,一个数组中的元素相对位置在进行排序之后不会发生改变,如1,2,1这个数组,第一个1和第二个1虽然数值是一样的,但是相对位置是不一样的,如果将这两个1对换位置了,则说明该算法不稳定,否则就是稳定的。
参考资料
《算法设计与分析基础》第三版以及老师的课件