一、希尔排序的引入与特点
希尔排序作为插入排序的高效改进版本,在 C 语言编程中具有重要地位。它的独特之处在于巧妙地利用了插入排序越有序、数据规模越小、排序越快的特点。
希尔排序的基本思想是先选定一个整数 gap,把待排序数列中所有距离为 gap 的元素分在同一组内,并对每一组内的记录进行插入排序。然后,取 gap = gap/2 重复上述分组和排序的工作。当 gap 等于 1 时,排序结束。
例如,以数组 {96,107,145,137,106,142,146,134,32,86} 为例,一般会选择元素个数的一半作为步长(这里元素个数为 10,所以步长取 5)。其本质是给这个序列分为 5 组:(96--142,107--146,145--134,137--32,106--86)。并让步长在循环中每次变为上次的一半。
希尔排序的特点如下:
- 稳定性:不稳定。因为在分组插入排序过程中,相同元素的相对位置可能会改变。
- 时间复杂度:时间复杂度比较复杂,为 O (N^1.3)~O (N^2),平均为 O (NlogN)。时间复杂度取决于所选择的间隔序列,对于希尔提出的初始间隔序列(每次减半),最坏情况下的时间复杂度为 O (N^2)。然而,对于更优化的间隔序列,如 Hibbard 序列、Sedgewick 序列等,最坏情况下的时间复杂度可以降至 O (Nlog2 N) 或更低。
- 空间复杂度:O (1)。因为它只需要常数级别的额外空间来存储临时变量。
希尔排序在处理中小型数据集时,性能优于简单的插入排序和选择排序。同时,由于其空间复杂度低,适合在内存有限的环境中使用。在 C 语言编程中,希尔排序的高效性和相对简单的实现使其成为一种实用的排序算法。
二、希尔排序的原理剖析
(一)基本思想阐述
希尔排序的基本思想是通过逐步选定不同的整数增量,将待排序的文件分成若干组。一开始,选择一个相对较大的增量,比如数组长度的一半。然后,将数组中距离为这个增量的元素分为一组。以数组 {8, 6, 4, 2, 1, 3, 5, 7, 9} 为例,若初始增量为 4,那么就分为 (8--1), (6--3), (4--5), (2--7), (1--9) 这四组。接着对每一组进行插入排序,这样可以让每组内的元素相对有序。之后,逐步缩小增量,比如将增量变为原来的一半,再次对新的分组进行插入排序。这个过程不断重复,直到增量为 1。当增量为 1 时,整个数组就相当于被分为一组,此时进行的插入排序就是对整个数组的最终排序。
(二)与插入排序的关联
希尔排序实际上是对插入排序的一种优化。当增量大于 1 时,希尔排序进行的是分组插入排序,其目的是让数据在前期就逐渐变得更加有序。当增量为 1 时,希尔排序就退化为直接插入排序。但此时由于经过前期的分组排序,数据已经相对有序,所以直接插入排序的效率会大大提高。例如,在普通的插入排序中,如果是一个逆序的数组,那么插入排序的时间复杂度为 O (n²),其中 n 是数组的长度。但是在希尔排序中,经过前期的分组排序,数据已经接近有序,此时进行直接插入排序的时间复杂度会接近 O (n)。这是因为在接近有序的情况下,元素的移动次数大大减少。比如在一个基本有序的数组中进行插入排序,每次只需要比较少数几个元素就可以确定插入位置,而不需要像在逆序数组中那样进行大量的比较和移动操作。所以,希尔排序通过前期的分组排序,为最后一步的直接插入排序创造了有利条件,从而提高了整体的排序效率。
三、希尔排序的实现步骤
(一)选择增量序列
初始希尔提出的增量是 gap = n / 2,每一次排序完让增量减少一半 gap = gap / 2,直到 gap = 1 时排序变成了直接插入排序。后来 Knuth 提出的 gap = [gap / 3] + 1,每次排序让增量成为原来的三分之一,加一是防止 gap <= 3 时 gap = gap / 3 = 0 的发生,导致希尔增量最后不为 1,无法完成插入排序。目前业内对于这两种方法看法不一,都没有绝对的优势。希尔排序的增量序列的选择是一个复杂的问题,涉及到一些数学上未能攻克的难题,所以目前为止对于希尔增量到底怎么取也没有一个最优的值。
(二)分组与排序过程
首先,根据选定的增量将数据分组。例如,若初始增量为 gap,那么数据中相隔 gap 的元素分为一组。以数组 {12, 15, 10, 18, 13, 16, 11, 17, 14} 为例,假设初始增量为 4,则分为 (12--13), (15--16), (10--11), (18--17), (13--14) 这几组。接着对每个子序列进行插入排序,具体步骤如下:对于每一组,从第二个元素开始,将其与前面的元素进行比较,如果小于前面的元素,则将前面的元素后移一位,直到找到合适的位置插入当前元素。然后缩小增量,重复上述操作。例如,当增量变为 2 时,新的分组为 (12--10--13--11), (15--18--16--17), (10--13--11--14) 等,再次对这些子序列进行插入排序。不断重复这个过程,直到增量为 1,此时对整个数组进行直接插入排序。
(三)代码实现思路
在代码实现中,仅用一次遍历数组就可以巧妙地对每个分组完成单趟排序。以 Knuth 增量为例,从初始增量开始,用一个变量 i 从 0 遍历到 size - gap - 1 处。当 i = 0 时,用另一个变量 end 从后往前遍历插入,将 end + gap 作为下一个数据的位置。如果此时 end + gap 数据大于 end 处数据,原地插入(不做插入)即可。接着 i++,end 再次往前遍历,找 end + gap 处数据该插入的位置。这个过程相当于对每个分组按照一个固定顺序轮流插入排序,并且它们是以一个元素为单位同时进行的,而不是先将某个分组插入排序完再下一个分组。在遍历过程中,变量 gap 控制着分组的大小,随着排序的进行逐渐缩小,直到 gap = 1 时完成最终的直接插入排序。插入排序的实现逻辑是,对于每个待插入的元素,从当前位置开始向前比较,如果前面的元素大于待插入元素,则将前面的元素后移一位,直到找到合适的位置插入待插入元素。
#include <stdio.h>
// 希尔排序函数
void shellSort(int arr[], int n)
{
// gap 为步长,初始值为数组长度的一半,逐渐减小
for (int gap = n / 2; gap > 0; gap /= 2)
{
// 对每个步长进行插入排序
for (int i = gap; i < n; i++)
{
int temp = arr[i];
int j;
// 对步长为 gap 的子序列进行插入排序
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap)
{
// 如果当前位置的元素比它前面步长为 gap 的元素大,
// 则将前面的元素后移一位
arr[j] = arr[j - gap];
}
// 将 temp(即 arr[i])插入到正确的位置
arr[j] = temp;
}
}
}
// 打印数组函数
void printArray(int arr[], int size)
{
for (int i = 0; i < size; i++)
// 逐个打印数组中的元素
printf("%d ", arr[i]);
// 打印完一行数组元素后换行
printf("\n");
}
int main()
{
int arr[] = {12, 34, 54, 2, 3};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组:\n");
printArray(arr, n);
shellSort(arr, n);
printf("排序后的数组:\n");
printArray(arr, n);
return 0;
}
四、希尔排序的时间复杂度分析
希尔排序的时间复杂度与增量序列的选择密切相关。
(一)与增量序列的关系
不同的增量序列会导致希尔排序的时间复杂度有很大差异。例如,当使用希尔提出的初始增量序列,即 gap = n / 2,每次排序完让增量减少一半 gap = gap / 2,直到 gap = 1 时,最坏情况下的时间复杂度为 O (n²)。而 Knuth 提出的 gap = [gap / 3] + 1 的增量序列,在某些情况下也可能导致较高的时间复杂度,但具体情况较为复杂。
(二)最坏情况时间复杂度
一般认为希尔排序的最坏时间复杂度为 O (n²)。这是因为在某些不太理想的增量序列下,可能会导致数据在分组排序过程中无法有效地变得更加有序,从而在最后一步直接插入排序时,需要进行大量的比较和移动操作。例如,当数据本身具有一定的特殊分布,使得每次分组后的子序列都需要进行较多的调整时,就容易出现这种情况。
(三)平均情况时间复杂度
希尔排序的平均时间复杂度为 O (n^1.3 ~ n^1.5)。在平均情况下,通过合理选择增量序列,希尔排序能够在前期的分组排序中让数据逐渐趋向有序,从而减少最后一步直接插入排序的工作量。例如,使用一些较为优化的增量序列,如 Hibbard 序列、Sedgewick 序列等,可以在一定程度上降低时间复杂度。
Hibbard 序列为 {1, 3,..., 2^k - 1},使用 Hibbard 增量时希尔排序的最坏情形运行时间为 O (n^3/2)。Sedgewick 序列为 {1, 5, 19, 41, 109...},该序列中的项或者是 94^i - 92^i + 1 或者是 4^i - 3*2^i + 1,这种增量最坏的复杂度为 O (n^4/3),平均复杂度为 O (n^7/6),但也没有被完全证明。
(四)最优增量序列的研究现状
目前,对于希尔排序的最优增量序列仍是数学界尚未解决的难题。虽然有很多学者提出了各种不同的增量序列,但没有一种序列被普遍认为是绝对最优的。不同的增量序列在不同的数据规模和分布下表现出不同的性能。未来的研究方向可能包括进一步探索更高效的增量序列,以及深入分析不同增量序列在各种实际应用场景中的性能表现。
五、希尔排序的应用场景
希尔排序在中规模数据量的排序中具有显著优势。例如在游戏场景中,当每做一次操作数据就需要排序一次时,希尔排序效率最高。以 QQ 游戏里的打麻将为例,摸一张打一张的场景下,数据需要频繁地进行排序。希尔排序的特性使得它能够快速地对这种动态变化的数据进行整理。
在游戏开发中,可能会涉及到多个玩家的操作,以及各种游戏元素的状态变化。比如在一款 MMORPG 大型游戏中,有众多的游戏场景和核心数据需要处理。像怪物管理器、宠物管理器、物品盒管理器等多个模块,都可能需要对数据进行排序。希尔排序可以在这些场景中高效地工作,因为它能够在前期分组排序过程中快速让数据变得相对有序,从而减少后续排序的工作量。
扑克牌排序也可以看作是一种简单的希尔排序实际应用。当我们抓牌的时候,其实就是在做一个插入排序,按顺序摆放的扑克牌,便于我们更好地掌握手中的牌。而希尔排序就如同在抓牌过程中,先将牌按照一定的间隔分组,然后对每组进行插入排序,随着抓取的牌越来越多,不断调整分组和排序,使得手中的牌逐渐变得有序。
在处理中等规模数据集时,希尔排序的时间复杂度相对较低,空间复杂度为 O (1),这意味着它不会占用过多的内存空间。与其他排序算法相比,希尔排序在中规模数据量的情况下表现出更好的性能。例如,与快速排序相比,希尔排序在数据量不是特别大时,不需要进行复杂的分区操作,代码实现相对简单。与归并排序相比,希尔排序不需要额外的存储空间来进行合并操作。
综上所述,希尔排序在中规模数据量排序中,尤其是在类似游戏场景和扑克牌排序等实际应用中,具有明显的优势。