希尔排序的名称源于它的发明者——唐纳德·希尔(Donald Shell)。
希尔排序是另一种形式的插入排序,它神奇地突破了冒泡排序、直接选择排序、直接插入排序等算法的二次时间界限,在时间复杂度上首次实现了质的突破。
希尔排序如此神奇,这源于它对插入排序两个优点的综合应用:
- 在数据量少的时候插入排序速度很快;
- 在数据几乎有序的时候插入排序速度很快;
什么是希尔排序
我们通过一个例子解释希尔排序使用的策略。假设我们要对随机数组 A[16]
进行排序,其中的元素用 a0 ~ a15 表示。
一、 从 a0 开始,按照 1,2,3,…,8 报数,报相同数字的分为一组。即将 16 个数据分为 8 组:
[a0, a8],
[a1, a9],
[a2, a10],
[a3, a11],
[a4, a12],
[a5, a13],
[a6, a14],
[a7, a15]。
注意:这里说的是逻辑上的分组,不是真的把数据交换位置。在代码实现上我们只要用跳跃的数组下标就可以。
这 8 组数据每一组都只包含两个元素,通过插入排序可以快速完成(插入排序的第一个优点)。排序完成后的数组用 B[16]
表示;
二、从 b0 开始,按 1,2,3,4 报数。报相同数字的分为一组,这样就将 B[16]
分为 4 组:
[b0, b4, b8, b12],
[b1, b5, b9, b13],
[b2, b6, b10, b14],
[b3, b7, b11, b15]。
这 4 组数据每一组中的元素个数比第一步要多一些,但是每一组都有一个特点:比较有序。比如第一组: b0 和 b8 是有序的,b4 和 b12 也是有序的。这样我们就可以利用插入排序第二个优点,对每一组进行插入排序。第二步完成后的数组用 C[16]
表示;
三、从 c0 开始,按 1,2 报数。报相同数字的分为一组,这样就将 C[16]
分为 2 组:
[c0, c2, c4, c6, c8, c10, c12, c14],
[c1, c3, c5, c7, c9, c11, c13, c15]。
这 2 组数据每一组中的元素个数比上一步又要多一些,但是它的有序程度也更明显。比如第一组中的c0, c4, c8, c12 是有序的,c2, c6, c10, c14 也是有序的。这样我们还是可以利用插入排序的第二个优点,对每一组进行插入排序;
四、对所有元素进行排序。虽然元素数量是这四步中最多的,但是这时候元素的有序程度也是最高的。
这样我们就通过 4 组插入排序完成了对 16 个元素的排序工作。请注意,这 4 组中的每一组都有这样一个特点:要么数据量很小(第一组),要么数据量越来越大但是有序程度越来越高(后三组)。
可以看到,希尔排序在数据量和数据有序程度上进行了折中安排。虽然进行了多次插入排序,但是由于插入排序是二次的而不是线性的,所以小规模的多次插入排序快于大规模的一次插入排序。
值得注意的是,希尔排序必须在最后一组进行完整的插入排序,否则结果一般不会正确。
总结上面的方法,我们得到希尔排序的一般策略:
希尔排序使用一个序列 h1 h 1 , h2 h 2 , h3 h 3 , ... . . . , ht h t 叫做增量序列。只要 h1=1 h 1 = 1 ,任何增量序列都是可行的。不过,有些增量序列比另外一些增量序列要好(后文会说到)。在使用增量 hk h k 的一趟排序后,对于每一个 i i ,我们有 ,此时称文件是 hk h k - 排序 的。
hk h k - 排序 的一般做法是:
把
a1
a
1
,
a1+hk
a
1
+
h
k
,
a1+2hk
a
1
+
2
h
k
,
...
.
.
.
,分为一组;
把
a2
a
2
,
a2+hk
a
2
+
h
k
,
a2+2hk
a
2
+
2
h
k
,
...
.
.
.
,分为一组;
把
a3
a
3
,
a3+hk
a
3
+
h
k
,
a3+2hk
a
3
+
2
h
k
,
...
.
.
.
,分为一组;
...
.
.
.
...
.
.
.
把 ahk a h k , a2hk a 2 h k , a3hk a 3 h k , ... . . . , 分为一组。
一趟 hk h k - 排序 的作用就是对 hk h k 个独立的子数组执行一次插入排序。
在增量序列的选择上,比较流行的做法是使用Shell建议的序列:
ht=floor(N/2)
h
t
=
f
l
o
o
r
(
N
/
2
)
hk=floor(hk+1/2)
h
k
=
f
l
o
o
r
(
h
k
+
1
/
2
)
也就是我们上面介绍的方法。
C语言实现
数组打印函数
void print_array(int a[], int len, int gap)
{
for(int i=0; i<len; ++i)
{
printf("[%d]:%2d ", i, a[i]);
if((i + 1) % gap == 0)
printf("\n");
}
printf("\n\n");
}
这个函数是专门为希尔排序设计的。我想把排序的过程打印出来,那自然少不了分组。gap
这个参数用来传递
hk
h
k
- 排序 的
hk
h
k
因为有if((i + 1) % gap == 0)
这个条件判断,所以每打印
hk
h
k
个数就换行一次,所以看的时候要竖着看,每一纵列就是一组,一共有
hk
h
k
组。
如果不希望换行打印,则可以给gap
传一个比数组长度大的参数。
排序函数
这个是原原本本地按照希尔排序的步骤而写的。
如果在编译的时候定义了宏PRINT_PROCEDURE
,则可以打印出排序的具体过程,对理解希尔排序非常有帮助。
代码就不多说了,因为有详细的注释。
void shellsort(int arr[], int n)
{
//步长采用shell序列
for (int gap = n / 2; gap > 0; gap /= 2)
{
#ifdef PRINT_PROCEDURE
printf("-------- gap = %d--------\n",gap);
print_array(arr, n, gap);
#endif
for (int i = 0; i < gap; i++)
{
#ifdef PRINT_PROCEDURE
printf("column %d :\n",i); // 对列i排序
#endif
for (int j = i + gap; j < n; j += gap)
{ // 每次加上步长,即按列排序。
// if 条件成立说明arr[j]需要插入到某个位置
if (arr[j - gap] > arr[j])
{
// 因为arr[j]会被前面的记录覆盖,所以先暂存
int temp = arr[j];
int k = j - gap; // k指向arr[j-gap],从后往前遍历
while (k >= 0 && arr[k] > temp)
{
arr[k + gap] = arr[k]; // arr[k]向后移动
k -= gap;
}
// 把arr[j]插入到arr[k]的后面
arr[k + gap] = temp;
#ifdef PRINT_PROCEDURE
printf("[%d] insert to [%d]\n", j, k+gap);
#endif
}
}
}
#ifdef PRINT_PROCEDURE
printf("\n");
print_array(arr, n, gap);
#endif
}
}
赶紧写个测试函数,看看排序的过程吧。
测试函数
#define DUMMY_GAP 100
int main(void)
{
int array[] = {5,2,8,9,3,9,7,1,0,4,}; // 10个数
print_array(array,sizeof(array)/sizeof(array[0]),DUMMY_GAP);
shellsort(array, sizeof(array)/sizeof(array[0]));
print_array(array,sizeof(array)/sizeof(array[0]),DUMMY_GAP);
return 0;
}
运行结果
从上图可以看出,一共分成了5组(竖着看),对每一组都进行直接插入排序。
上图是
hk=1
h
k
=
1
的情况,对所有元素进行排序。虽然元素数量是这3次中最多的,但是这时候元素的有序程度也是最高的。
【未完待续】
参考资料
《数据结构与算法分析(原书第2版)》(机械工业出版社,2004)