文章目录
前言
为什么会有希尔排序,要从插入排序说起,希尔排序一开始设计出来是为了改进插入排序,因为插入排序在处理大量数据时效率不高,特别是对于近乎有序的数据。
对于插入排序:
1.当数组逆序有序时它的效率最低,时间复杂度为:O(n^2);
2.当数组顺序有序时它的效率最高,时间复杂度为:O(n);希尔排序的主要动机是观察到插入排序在处理小规模数据时的高效性。然而,对于大规模数据,插入排序需要进行大量的元素交换,尤其是在数据分布不均时。
希尔排序的原理
对于希尔排序:
希尔排序通过引入一个增量序列,采取分组排序策略:将大数组分为若干个子序列,对每个子序列进行插入排序。随着增量逐渐减小,子序列变得更小,最终达到增量为1,整个数组变成一个有序序列,完成排序。这种排序方式使得希尔排序在初始阶段,使用较大的步长让序列更快时间的接近有序,并且减少了不必要的比较与交换
希尔排序的优势在于它能够利用插入排序对于部分有序数据的良好性能,同时通过分组和调整步长,减少了排序过程中的比较和交换次数。这使得希尔排序在某些情况下比直接插入排序更快,特别是在处理大规模数据和部分有序数据时。
由于希尔排序的这些特点,它被广泛应用于实际编程中,尤其是在需要快速排序但又不能接受复杂度较高的排序算法(如归并排序)的情况。不过,希尔排序的性能仍然受到增量序列选择的影响,不同的增量序列可能导致不同的性能表现。
原理思路
上面是插入排序的过程;
相比插入排序,希尔排序最重要的一点就是:选择一个合适的gap(增量)
- 预排序
希尔排序先通过插入排序让序列接近有序,这一过程称为预排序
我们首先要选取一个值为gap(增量),以增量为间隔决定它们的分组方式,分隔开之后对每个子序列进行插入排序- 直接插入排序
在预排序结束之后,序列已经是接近有序,这是我们只需要进行一次直接插入排序后,序列就会为有序;
在上图中用同一种颜色指向的元素为同一组,为了能更好的理解希尔排序,我们先分析其中一组元素的排序过程,以黑色的一组来分析。
//插入排序 void InsertSort(int *arr, int n) { for (int i = 0; i < n - 1; ++i) { //一趟 int end = i; int tmp = arr[end + 1]; while (end >= 0) { if (tmp < arr[end]) { arr[end + 1] = arr[end]; } else { break; } --end; } arr[end + 1] = tmp; } }
我们已知gap == 3,这组元素的间隔就是gap,
- 那么我们需要在插入排序的基础上,将for循环中,i每次循环之后的加1,改为每次加gap;
- 我们知道在插入排序中,tmp指向的值代表的意思是,我们当前的end所要比较的下一个元素,所以在预排序阶段中,要将arr[end+1]改为arr[end+gap];
- 还有一个问题,for循环的结束条件也要改变,由于我们此时每次的移动距离变为了gap,到第二组走到最后一个元素时,它还会接着进入循环当中,那么势必就会造成越界访问,所以在这里我们的结束条件改为n-gap这样当第一组,第三组走到倒数第二个元素时,他不会结束循环,而是再走完一次循环后结束,第二组也能走到最后一个结束;
具体代码表示为:
int gap = 3; for (int i = 0; i < n - gap; i+=gap) { //一趟 int end = i; int tmp = arr[end + gap]; while (end >= 0) { if (tmp < arr[end]) { arr[end + gap] = arr[end]; } else { break; } end-=gap; } arr[end + gap] = tmp; } }
在了解了一次的实现过程后,我们再看上面的图会发现,当gap等于几,就有几组子序列,所以我们实现多组时,只需要在外面再增加一层循环让gap组依次排序,定义变量,再让它自增,结束条件为等于gap时;
int gap = 3; for(int j = 0;j < gap ; j++) { for (int i = 0; i < n - gap; ++i) { //一趟 int end = i; int tmp = arr[end + gap]; while (end >= 0) { if (tmp < arr[end]) { arr[end + gap] = arr[end]; } else { break; } end-=gap; } arr[end + gap] = tmp; } }
但这种方式有些过于繁琐,我们还有另一种——让多组并着走;
这个版本的希尔排序称为“简单希尔排序”(Simple Shell Sort)。下面是代码的详细分析:
循环变量i:循环变量i从0开始,表示当前正在处理的元素索引。它会遍历所有gap个元素,直到到达数组末尾(n - gap)。
一趟排序:对于每个gap范围内的元素,我们有一个内层循环,它的目标是将当前gap范围内的元素按照升序排列。
2.1. 临时变量tmp:用于暂存arr[end + gap]的值,以便与arr[end]进行比较。
2.2 . while循环:从arr[end]开始,向左移动gap个位置,比较arr[end]和tmp。如果tmp小于arr[end],就将arr[end]的值移动到正确的位置,否则结束循环。
2.3. end -= gap:每次循环结束后,end会向左移动gap个位置,继续处理下一个元素。插入元素:当内层循环结束后,将tmp(即原始的arr[end + gap])插入到正确的位置,使得gap范围内元素有序。
重复:外层循环会一直执行,直到i到达n - gap
当它在排序时,它不再一次移动gap个位置,而是依次遍历整个序列,虽然结束条件为n-gap但是由于在内层循环中,tmp依旧是arr[end+gap]所以我们交换时还是遵循一个组和一个组的交换,也能找到n-gap之后的元素;
int gap = 3; for (int i = 0; i < n - gap; i++) { //一趟 int end = i; int tmp = arr[end + gap]; while (end >= 0) { if (tmp < arr[end]) { arr[end + gap] = arr[end]; } else { break; } end-=gap; } arr[end + gap] = tmp; } }
相比于上面的方式,这种代码方式的优势:
- 实现简单:简单希尔排序的实现相对简单,它只需要固定一个增量,然后进行插入排序操作。这使得它在教学或快速原型开发时更为方便。
- 易于理解:由于其基本思想是将数组分为若干子序列,然后对每个子序列进行插入排序,这种逻辑比希尔排序的复杂增量序列选择更容易理解。
3.适用于小规模数据:对于小型数据集,简单的插入排序可能已经足够快,而希尔排序的优化可能带来的额外复杂性可能不值得。
然而,简单希尔排序的劣势在于:
- 效率不稳定:固定增量可能导致在不同数据集上的性能差异较大。如果数据是部分有序的,增量较小的版本(如直接插入排序)可能会表现得更好。而对于随机分布的数据,优化的希尔排序通常会有更好的平均性能。
- 没有理论上的最优:希尔排序是一种基于插入排序的改进,理论上可以达到O(n^1.3)的时间复杂度,但这个理论最优仅适用于特定的增量序列。简单希尔排序由于其固定的增量,无法达到这一最优性能。
这都是因为我们的增量变量,选的不合适,所以我们要对增量的选取进行优化;
一种常见的优化方式就是采用gap= gap/3 + 1 的递推公式进行优化,这样会产生一个相对合理的gap增量值;
选择原始增量序列作为希尔排序优化的原因主要有以下几个方面:
避免小增量值
- 增量序列可以避免产生较小的增量值,从而减少了排序过程中不必要的比较和移动操作。小增量值会导致排序效率严重下降,接近于插入排序的时间复杂度。
- 增量值差异较大
增量序列生成的增量值差异较大,可以有效地打乱原始序列,从而提高排序的效率。相邻增量值之间的差距较大,可以很好地减少小值对较大值的干扰。- 收敛较快
增量序列可以较快地收敛到 1,从而在后期阶段实现对整个序列的高效插入排序。这种增量序列的收敛速度较快,可以减少不必要的排序轮次。- 简单高效
增量序列的生成公式 gap = gap * 3 + 1 非常简单,在实现上也相对高效。这种增量序列的计算成本较低,不会给排序带来太多额外开销。- 理论支持
增量序列是由计算机科学家D.L.Shill 提出并分析过的,它具有一定的理论依据和优化效果。通过分析和实验证明,这种增量序列可以使希尔排序达到最优的时间复杂度 O(N^1.3)。
虽然 Knuth 增量序列不一定是最优的增量序列,但它在实践中表现出了良好的效果和稳定性。许多著名的算法书籍和库都采用了 Knuth 增量序列作为希尔排序的优化方案。相比其他一些复杂的增量序列,Knuth 增量序列更加简单高效,是一种折中的选择。
当然,除了 Knuth 增量序列之外,还有其他一些常用的增量序列,如 Hibbard 序列、Sedgewick 序列等,它们也具有一定的优化效果。在实际应用中,可以根据具体情况选择合适的增量序列。
对于上面我们所展现的代码只是预排序阶段,由于gap不等于1,所以只能实现相对有序,因为只有当gap为1的时候,程序才算执行了一次标准的插入排序,这样整个数组才能够完全有序,这也是我们流程的第二步,我们上面的Knuth增量序列最终也会使得gap变为1,在预排序阶段执行的每一次插入排序,我们都要通过Knuth增量序列来改变gap的值,来使得我们每一次选取的gap值都为最佳值,而当gap等于一时,我们就让程序进入第二步最后一次的插入排序。
//循环中的增量序列 while(gap>1) { gap = gap/3 + 1; }
代码实现
具体代码如下:
void ShellSort(int* a, int n) { int gap = n; while (gap > 1) { // +1保证最后一个gap一定是1 // gap > 1时是预排序 // gap == 1时是插入排序 gap = gap / 3 + 1; for (size_t i = 0; i < n - gap; ++i) { int end = i; int tmp = a[end + gap]; while (end >= 0) { if (tmp < a[end]) { a[end + gap] = a[end]; end -= gap; } else { break; } } a[end + gap] = tmp; } } }
希尔排序的相关问题
效率
希尔排序的效率很大一部分是取决于增量gap的选择上,而我们在上面对他进行了优化之后,会处在O(n^1.3)左右,
还有就是为什么插入排序很多次,但效率依旧很高:
因为当我们在预排序阶段进行多次的插入排序时我们是以gap作为元素间隔,这样当我们进行一次插入排序时,它走的步数会大大缩减,结合我们前面所说的总结下来就是:
- 分组插入排序
希尔排序实际上是将原始数组按照一定增量分组,分别对每一组进行直接插入排序。随着增量的逐渐减小,每组中的元素越来越少,插入排序的效率也就越高。- 局部有序性
在每轮增量排序之后,数据在若干个较大的组内是有序的。因此,在进行下一轮较小增量的排序时,数据整体的无序程度较低,插入排序的效率会更高。这种局部有序性随着增量的减小而增强。- 有序子序列移动
在进行插入排序时,希尔排序不需要每次都从头开始插入,而是从前一个有序子序列的最后一个元素开始比较和移动。这避免了一些不必要的数据移动,从而提高了效率。- 缓存利用率高
由于希尔排序每次只对相邻的部分元素进行插入排序,所需移动的数据量比全局排序要小得多。这意味着更好地利用了CPU缓存,减少了内存访问次数,从而提高了运行效率。- 较少数据交换
与需要大量数据交换的算法(如快速排序)相比,希尔排序主要依赖元素位置移动来达到排序效果。这避免了数据交换所带来的额外开销。
算法稳定性
算法稳定性具体指的是在排序算法中,对于值相等的元素,排序后它们的相对顺序是否于原始序列相同。
一个稳定的排序算法会使值相等的前后顺序不改变,反之不稳定的则可能会打乱他们之间的顺序。
对于希尔排序而言,它是不稳定的。
不稳定的原因在于:
. 在每一轮插入排序的过程中,当有多个元素值相同时,希尔排序总是将后面的元素移动到已排序区间的前面,从而打乱了值相同元素的原始相对顺序。
. 具体来说,在插入排序的内层循环中,当遇到一个值与已排序区间中某个元素相同时,通常会将这个元素直接插入到该相同元素的后面,而不会考虑它们原始的前后顺序关系。