希尔排序:让插入排序不再怕逆序
一个算法的短板,通常是下一个算法的问题意识。
为什么需要
上一篇把直接插入排序的三种情况摊开了:最好 O(N)、最坏 O(N²)、平均介于两者之间。它的表现取决于输入数据的逆序程度------数据越接近有序,它就快得越像 O(N)。
那么它的死穴也就很清楚了:一旦数据是逆序的,效率立刻掉下来。
逆序时,每个元素都要从当前位置一路挪到最前面,挪动的步数分别是 n、n−1、n−2......累加就是 O(N²)。而且这些挪动全是"一格一格"走的------一个本该在数组末尾的大数,要跟前面每个数比较一次、后移一次,才能到位。
于是问题变成了:既然插入排序在有秩序的数据上快得惊人,能不能先花点力气,把数据变得"接近有序",再交给它收尾?
希尔排序(Shell Sort)就是这个思路。它是对直接插入排序的优化,做法很简单:先分组、每组内部插入排序,让整个数组接近有序,最后再走一趟完整的插入排序。
一个自然的质疑是:多干了活,凭什么更快?如果预排序的代价和收益差不多,那这个算法就没有意义。这一点留到实测部分用数据回答。
核心机制:分组、跳着走
希尔排序的做法是:把数据按间隔 gap 分成若干组,组内做插入排序。
假设 gap = 3,那么下标 0、3、6、9...... 的元素是一组,1、4、7...... 是第二组,2、5、8...... 是第三组。分组是逻辑上的------不需要真的把这些元素搬到新数组里,只靠循环的步长控制把它们"看成"一组。
text
gap = 3 时,逻辑分组:
组1: a[0] a[3] a[6] a[9] ...
组2: a[1] a[4] a[7] ...
组3: a[2] a[5] a[8] ...
分好组之后,对每一组分别做插入排序 。注意,这里的插入排序和上一篇几乎一样,唯一的区别是:"前一个元素"不是 end - 1,而是 end - gap。
所以整段逻辑可以这样描述:把 a[end + gap] 插进"同组中它前面的那些元素"里,其他元素整体后移 gap 格。
这样做的效果,用一个逆序数组最直观:
- 一个本来在数组开头的大数,以前每次只能往右挪一格,要挪 n 次;
- 现在每次跳 gap 格,几步就跳到后面去了。
反过来对小元素同样成立:它本来要一格一格往左蹭,现在几步就跳到前面。所以预排序同时在做两件事------大的更快到后面,小的更快到前面,一举两得。
而 gap 越大,跳跃跨度越大,调整得越猛。这是希尔排序最反直觉的一点:单趟预排序并不追求"排好",它追求的是让数据整体形状变好。
一趟预排序结束后,数组通常并不是有序的 ,只是比之前更接近有序:大的数更靠后,小的数更靠前,逆序对数量大幅减少。然后缩小 gap 再来一趟,再缩小再来一趟......直到 gap == 1。
gap == 1 时,"间隔为 1 的元素为一组"------所有元素都在同一组里,这时的插入排序就是完整的直接插入排序。也就是说,希尔排序的最后一趟并不是"另外调一次 InsertSort",而是循环自然走到的那一步。
由此也能看出:gap == 1 时的预排序代码,和上一篇的直接插入排序是同一段代码。上一篇写的那个插入排序,就是希尔排序在 gap 取 1 时的特例。整个算法用一个循环把"预排序"和"最终排序"统一了。
代码演示
第一步:写出"一组"的插入排序
先别管所有组,把一组 排好。和上一篇对比着看,只有 +1 变成了 +gap:
c
// 排一组:gap 是该组的步长,end 是组内有序区的末尾
int end = i;
int tmp = a[end + gap]; // 同组中的"后一个"元素
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end]; // 元素整体后移 gap 格
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp; // 位置定了,落位
结构完全没变:先存 tmp、从后往前扫、循环外统一落位。变的只是步长。
第二步:三层循环------一组一组排
有了"排一组"的逻辑,最朴素的扩展是:外面再套一层,枚举每一组。
c
// 一组一组排(gap 固定为 3 的示意版本)
void ShellSort_ByGroup(int* a, int n)
{
int gap = 3;
for (int j = 0; j < gap; ++j) // 第 j 组
{
for (size_t i = j; i < n - gap; i += gap) // 组内的有序区末尾
{
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;
}
}
}
j 的作用是"告诉程序现在排第几组":j = 0 排第一组、j = 1 排第二组......一组排完了再排下一组。
这里 i < n - gap 是必须的,不能写成 i < n 。理由和上一篇的越界陷阱是同一类,但更容易踩:这一趟要读的是 a[end + gap],如果 i 走到了 n - gap,end + gap 就等于 n------越界。所以组内循环的末尾必须停在 n - gap - 1,也就是条件写 i < n - gap。
第三步:两层循环------多组并着走
三层循环能跑通,但可以合并成两层:不把一组排完再排下一组,而是几组轮流各推进一步。
c
// 多组并着走 + gap 逐趟缩小
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) // 注意这里是 ++i,不是 += gap
{
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;
}
}
}
改动只有两处:
| 外层控制 | 内层步进 | 效果 | |
|---|---|---|---|
| 三层写法 | j < gap 枚举组号 |
i += gap |
一组排完,再排下一组 |
| 两层写法 | 不需要 | ++i |
几组齐头并进,交替推进 |
++i 的语义是:处理完 a[i] 这个位置,下一个处理 a[i + 1]------它属于下一组 。于是循环自动在几组之间轮转,而循环边界 i < n - gap 对每一组都成立。
两种写法效率完全一样。 三层循环跑的次数和两层循环一样多,只是组织顺序不同。这一点很重要------它正面推翻了"循环层数多就慢"的直觉。
gap = gap / 3 + 1 是 gap 的缩小策略。/ 3 让跨度迅速收敛,+ 1 保证它最终一定会落到 1 ------如果没有这个 +1,gap 会停在 2、再变成 0,永远等不到 gap == 1 的那一趟最终排序,数组就不会被真正排好。
💡 背景补充:
size_t是无符号类型,i < n - gap中右边的int会转换成无符号再比较。这里n - gap恒非负(gap 最大为 n),所以是安全的;但如果循环边界表达式可能为负,无符号比较会把负数变成极大的正数,导致循环失控------这是 C 语言里典型的隐性陷阱。
复杂度:O(N^1.3) 是怎么估出来的
希尔排序的精确复杂度很难算,多数教材也只给结论。但可以粗略估一遍,理解"为什么它比 O(N²) 好"。
先忽略 +1,取 gap ≈ n/3:
| 趟次 | gap | 组数 | 每组元素个数 | 单组最坏移动次数 | 本趟总代价 |
|---|---|---|---|---|---|
| 第 1 趟 | n/3 | n/3 组 | 3 | 1 + 2 = 3 | (n/3) × 3 = n |
| 第 2 趟 | n/9 | n/9 组 | 9 | 1+2+...+8 = 36 | (n/9) × 36 = 4n |
| ... | ... | ... | ... | ... | ... |
| 最后一趟 | 1 | 1 组 | n | 接近有序,代价很小 | ≈ n |
单组代价怎么来的?以第 2 趟为例:gap 是 n/9 时,每组有 9 个元素,组内的插入排序最坏情况下第二个元素移动 1 次、第三个 2 次......第 9 个移动 8 次,即 1+2+...+8 = 36 次。再乘以组数 n/9,得到 4n。
于是每一趟的代价随趟次先增后减,中间那几趟最贵(比如 4n 这一趟),两头(第一趟和最后一趟)都是大约 n 的量级。
把这些趟加总,结论落在 O(N^1.3) 附近。注意这里的关键差别:直接插入排序在逆序时要累加到 1+2+...+n ≈ n²/2,而希尔排序把这份代价摊到了多趟里,每趟都压到 O(n) 量级。
至于为什么"算不精确",有两个原因:
- 后一趟的代价取决于前一趟的结果。 第 2 趟还能按"最坏情况"算(因为第 1 趟开始时数据是逆序的,每一组也是逆序的),但从第 3 趟起,数据已经被前几趟改动过,"最坏情况"这个前提就不成立了------必须引入概率分析。
- gap 序列的选择本身会影响复杂度。
n/3、n/2、n/2.2等不同策略得出的结论不同,1.3是常见取法下的经验值。
所以这个数字记住结论即可,不必试图手推。教材给的 O(N^1.3) 是一个经验结果,不是从某一行代码直接推导出来的。
实测:它到底有没有白干活
回到开头那个质疑:预排序多花了力气,值得吗?看数据。
同一组随机数据,加入希尔排序与插入排序、堆排序同场比较:
| 数据规模 | 直接插入排序 | 希尔排序 | 堆排序 |
|---|---|---|---|
| 10 万条 | ≈ 700 ms | 明显快于插入排序 | ≈ 4 ms |
| 100 万条 | ≈ 68 s | 与堆排序同量级,重复数据多时可略快 | 毫秒级 |
结论有三条:
- 10 万条时,希尔排序和插入排序已经不是一档。 虽然它多了预排序的开销,但预排序把数据变"有序"之后,最后那趟插入排序的代价被压得很低,总账反而划算。
- 100 万条时,希尔排序和堆排序处在同一量级。 这验证了复杂度上的判断:它已经跳出了 O(N²),不再是"O(N²) 家族里比较优秀的那个",而是和 O(N log N) 的算法同桌。
- 希尔排序的一个隐藏优势是重复数据。 上面的随机数据是用
rand()生成的,取值上限有限,百万条数据里有大量重复值------重复度高时希尔排序表现更好,甚至能略胜堆排序;把重复度降下来,堆排序会重新领先一点点。
另外要说明一点:数据量小的时候,希尔排序没有优势。十来个元素时,插入排序本来就快,再用多趟分组去包装它,多出来的循环开销纯属浪费。希尔排序的价值要到数据量上去之后才体现出来。
常见误区
把组内循环的边界写成 i < n。 这是本篇最容易踩的坑,和上一篇的 i < n - 1 属于同一类错误,但原因更绕:要访问的是 a[end + gap],end 本身没越界,end + gap 却越界了。判断越界要看真正被访问的下标,不是循环变量。
数循环层数来估复杂度。 三层写法和两层写法执行次数完全相同,效率没有任何差别。循环层数只说明代码怎么组织,不说明执行了多少次------这一点在上一篇已经埋过伏笔了。
以为希尔排序是"预排序 + 最后再调一次 InsertSort"。 实际上它只有一个循环,gap 缩小到 1 时那趟循环本身就是完整的插入排序。反过来也成立:把 gap 固定为 1,这段代码就是直接插入排序。
认为预排序之后数组就应该有序了。 预排序的目标是"更接近有序",不是"有序"。gap 取得巧时有可能恰好排好,但那是偶然,不是设计目标。
附:选择排序------一趟选两个数,与它踩的那个坑
同一节课还收掉了选择排序(Selection Sort)。它在分类上属于"选择"这一路,和堆排序同一大类------都是"选出最大/最小,放到该在的位置"。
区别在于:堆排序用堆来选(O(log n) 找出极值),选择排序用暴力遍历来选(O(n) 找出极值)。基础版本每趟遍历一遍、选出最小的放到最左边,再遍历一遍选次小的,如此往复。
一个常见优化是一趟选出两个数 :同时维护最小值和最大值的下标,最小的换到左端、最大的换到右端,然后 begin++、end--,两头往中间夹。
c
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin; // 存的是下标,不是值
for (int i = begin + 1; i <= end; ++i)
{
if (a[i] > a[maxi])
{
maxi = i;
}
if (a[i] < a[mini])
{
mini = i;
}
}
Swap(&a[begin], &a[mini]);
Swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
这段代码有一个 bug,而且平时测不出来。
问题出在两次 Swap 的顺序上。如果 maxi 恰好等于 begin------也就是最大值正好落在左端------那么第一次交换(swap(a[begin], a[mini]))会把最大值搬到 mini 的位置去。此时 maxi 这个下标还停在 begin,但那个位置上的值已经变了;第二次 Swap(&a[end], &a[maxi]) 交换的就不是最大值了。
修复只需要在两处交换之间插一句:
c
Swap(&a[begin], &a[mini]);
// 最大值原本在 begin 上,第一次交换已经把它挪到了 mini 的位置
if (maxi == begin)
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
这个 bug 的教训不在选择排序本身,而在调试方法 :出问题时不要拿二十多个元素的数组去硬调,应当把数组缩小到能画出图的程度 ,逐趟标出 begin、end、mini、maxi 的位置,让下标重叠的那一刻显形。用大数组调试,等于把问题藏在噪声里。
至于选择排序的整体评价:
- 最好情况也是 O(N²)。 即使数组已经完全有序,它也不知道,仍然要一趟趟遍历过去把极值"选"出来。加了
flag的冒泡在有序输入上是 O(N),选择排序没有这种优化空间。 - 实测 5 万条随机数据,插入排序在这个小规模组里表现最好,选择排序垫底,比冒泡还差一些。
- 所以它的价值主要在教学:逻辑直观,适合理解"选择"这一类排序的思想;在工程实践里几乎没有位置。
📖 参考:《数据结构知识库》第六节把冒泡/插入/选择排序统一列为 O(n²),"最好情况也是 O(n²)"这一点是选择排序区别于另外两者的地方;第四节的堆一节给出了堆排序为什么能把这个"选择"过程加速到 O(n log n)。
本节要点
- 希尔排序的问题意识来自插入排序的死穴:怕逆序 。预排序的目标是让数组接近有序,而不是排好。
- 做法:按间隔
gap把数据逻辑分组(不开新数组),每组内部做插入排序,然后缩小 gap 反复做。 - 组内插入排序与直接插入排序只差一件事:步长是
gap而不是 1------a[end - gap]、a[end + gap]。 - gap 越大跳得越快;预排序同时把大的数往右推、小的数往左推。
gap == 1时,这段代码就是上一篇的直接插入排序。所以希尔排序不是"预排序 + 最后调一次 InsertSort",而是一个循环自然走完。- 三层写法(一组组排,
i += gap)与两层写法(多组并着走,++i)执行次数完全相同,效率没有任何差别。循环层数不代表复杂度。 - 组内循环边界是
i < n - gap而非i < n:被访问的下标是end + gap,end不越界不代表它不越界。 gap = gap / 3 + 1里的+1不可省------它保证 gap 最终落到 1,否则会停在 2 再变 0,最后一趟最终排序永远不会发生。- 复杂度结论记 O(N^1.3) 即可。粗估思路:
gap ≈ n/3时每组 3 个 → 本趟约 n;gap ≈ n/9时每组 9 个 →1+2+...+8 = 36,×(n/9) ≈ 4n;各趟代价先增后减,累加落到 1.3 次方量级。 - 精确值难算的原因:后一趟的代价取决于前一趟的结果,从第 3 趟起"最坏情况"前提失效,需要概率分析;且不同 gap 序列结论不同。
- 数据量小时希尔排序没有优势,它的价值在大规模数据上;重复数据多时表现更好。
- 选择排序优选版一趟选两个数,bug 点在
maxi == begin:第一次交换会把最大值挪走,必须先修正maxi。调试应缩小数组并画图,而不是硬看大数组。 - 选择排序最好情况也是 O(N²),因为它无法感知"已经有序";这是它比冒泡还差的地方。