前言
我们前面总结了三种排序算法,分别是:
冒泡排序
、直接选择排序
、直接插入排序
我们会发现,即使各种排序算法的花样繁多,但是,时间复杂度都是 O(n^2) 。
我们都能理解,一个优秀的排序算法的首要条件就是速度。然而在很长的时间里,计算机学术界充斥着“排序算法不可能突破 O(n^2)”的声音。
终于有一天(1959年),D.L.Shell 提出来了希尔排序,突破了 O(n^2) 的时间复杂度。
算法
我们前面总结的直接插入排序
在某些时候的效率是非常高的,比如,我们的记录本身是基本有序的,我们只需要少量的插入操作,就可以完成整个记录的排序工作,此时的直接插入很高效。
还有就是记录数比较少的时候,直接插入的优势也比较明显。
可问题在于,两个条件本身过于苛刻,现实中记录少或者基本有序都属于特殊情况。
基于此,科学家希尔研究出了一种排序方法,对直接插入排序进行了改进,效率得到了增加。
如何让待排序的记录个数比较少呢?很容易想到的就是将原本有大量记录数的记录进行分组,分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列分别进行直接插入排序,当整个序列都基本有序时(注意只是基本有序)再对全体记录进行一次直接插入排序。
所谓的基本有序:
就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间;
比如:{2,1,3,6,4,7,5,8,9}这样的可以称为基本有序;
但是像{1,5,9,3,7,8,2,4,6}这样的9在第三位,2在倒数第三位就谈不上基本有序。
并且,我们需要采取跳跃分割的策略:
将相距某个“增量”的记录组成一个子序列;
这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
代码
希尔排序算法代码如下:
// 对顺序表 L 作希尔排序
void ShellSort(PSqlist L)
{
int i, j;
int increment = L->length;
do
{
// 增量序列
increment = increment / 3 + 1;
for (i = increment + 1; i <= L->length; i++)
{
if (L->r[i] < L->r[i - increment])
{
// 需要将 L->r[i]插入有序增量子表
L->r[0] = L->r[i]; // 暂存在 L->r[0]
for (j = i - increment; j > 0 && L->r[0] < L->r[j]; j -= increment)
// 记录后移,查找插入位置
L->r[j + increment] = L->r[j];
// 插入
L->r[j + increment] = L->r[0];
}
}
} while (increment > 1);
}
辅助函数
老办法,为了正确测试我们的代码,首先需要一个顺序表结构:
// 用于要排序数组的个数最大值,可以根据需要修改
#define MAXSIZE 10
typedef struct SqList
{
int r[MAXSIZE + 1]; // 用于存储要排序数组,r[0]用作哨兵或临时变量
int length; // 用于记录顺序表的长度
SqList(std::vector<int>& arr)
{
// 首先将长度置为0
length = 0;
// 数组下标首先从1开始
int index = 1;
for (auto i : arr)
{
r[index] = i;
length++;
index++;
if (index > MAXSIZE)
break;
}
}
}SqList,*PSqlist;
接着,给出打印函数:
// 输出数组r中的值
void print(PSqlist L)
{
assert(L != nullptr);
for (int i = 1; i <= L->length; i++)
{
std::cout << L->r[i] << " ";
}
std::cout << std::endl;
}
过程模拟
-
程序开始运行,此时我们传入的 PSqList 参数的值假设为 length = 9,r[10] = {0,9,1,5,8,3,7,4,6,2}。如图所示:
-
第 5 行,变量 increment 就是“增量”,我们给它的初始值等于待排序的记录数。
-
第 6~23 行是一个 do 循环,它的终止条件是 increment 不大于1时,其实也就是增量为 1 的时候就停止了循环。
-
第 9 行,这一句很关键,但也是最难以理解的地方。这里执行完成后,increment = 9/3+1 = 4。
-
第 10~22 行是一个 for 循环,i 从 4 + 1 = 5 开始到 9 结束。
-
第 12 行,判断 L->r [i] 与 L->r [i-increment] 的大小:L->r [5] = 3 小于 L->r [i-increment] = L->r [1] = 9,满足条件,第 15 行,将 L->r [5] = 3 暂存入 L->r [0] 。 第 16~18 行的循环只是为了将 L->r [1] = 9 的值赋给 L->r [5],由于循环的增量是 j -= increment ,其实在此它就只是循环了一次,此时 j = -3 。第 20 行,再将 L->r [0] = 3 赋值给 L->r [j+increment] :就等于 L->r [-3+4] = L->r [1] = 3。如图所示,事实上,这一段代码就只干了一件事:==将第 5 位的 3 和第 1 位的 9 交换了位置:
-
循环继续,i = 6,L->r [6] = 7 > L->r [i-increment] = L->r [2] =1:
7(大) > 1(小)
,因此不需要交换两者数据。如图所示: -
循环继续,i = 7,L->r [7] = 4 < L->r [i-increment] = L->r [3] = 5:
4(大) < 5(小)
,满足条件,交换两者数据。如图所示: -
循环继续,i = 8,L-> r [8] = 6 < L->r [i-increment] = L->r [4] = 8:
6(大) < 8(小)
,满足条件,交换两者数据。如图所示: -
循环继续,i = 9,L->r [9] = 2 < L->r [i-increment] = L->r [5] = 9:
2(大) < 9(小)
,满足条件,交换两者数据。注意,第 13~14 行是循环,此时还要继续比较 L->r [5] 与 L->r [1] 的大小,因为:2(大) < 3(小)
,所以还要交换 L->r [5] 和 L->r [1] 的数据,如图所示:
最终第一轮循环后,数组的排序结果如图所示。我们会发现,1、2 这样的小数字已经在前两位,而8、9 这样的大数字已经在后两位,也就是说,经过这样的排序,我们已经让整个序列基本有序了。这就是希尔排序的精华所在,它将关键字较小的记录,不是一步一步地往前挪动,而是跳跃式地往前移,从而使得每次完成一轮循环后,整个序列都朝着有序坚实地迈进了一步。 -
在完成一轮 do 循环后,此时由于 increment = 4 >1,因此我们需要继续 do 循环。第 9 行得到 increment = 4 / 3 + 1 = 2。第 10~22 行 for 循环,i 从 2 + 1 = 3 开始到 9 结束。当 i =3、4 时,不用交换,当 i = 5 时,需要交换数据,如图所示:
-
此后,i = 6、7、8、9 均不用交换,如图所示:
-
再次完成一轮 do 循环,此时 increment = 2 > 1,再次 do 循环,第 9 行得到 increment = 2 / 3 + 1 = 1,这就是最后一轮 do 循环了。尽管第 10~22 行 for 循环,i 从 1+1=2 开始到 9 结束,但由于当前序列已经基本有序,可交换数据的情况大为减少,效率其实很高。如图所示,剪头连线为需要交换的记录:
最终完成排序过程,如图所示:
测试代码
理解了过程,我们给出如下测试代码:
int main()
{
using namespace std;
std::vector<int> ar = {9,1,5,8,3,7,4,6,2};
SqList a(ar);
std::cout << "原始序列为:" << std::endl;
print(&a);
ShellSort(&a);
std::cout << "排序后:" << std::endl;
print(&a);
return 0;
}
运行结果
运行结果如下:
复杂度分析
经过了代码的剖析理解,我们可以发现:
希尔排序的关键并不是随便分组后的各自排序,
而是将相隔某个“增量”(increment)
的记录组成一个子序列,实现跳跃式的移动,
从而使得排序的效率提高。
注意:
这里的 “增量” 的选取十分的重要。
我们在代码第 7 行,用了increment = increment / 3 + 1
这样的方式选取增量;
可是,究竟应该如何选取增量才是最好呢?
这个目前仍是一个数学难题,迄今为止还没有人找到一种最好的增量序列。
需要注意的是:增量序列的最后一个增量值必须等于 1 才行。
另外,由于记录是跳跃式的移动,所以希尔排序并不是一种稳定的排序算法。
总结:
时间复杂度:O(n^(1.3–2));
空间复杂度:O(1);
稳定性:不稳定。
参考资料
【1】程杰. 大话数据结构. 北京:清华大学出版社,2011:2.