前言
希尔排序(Shell sort)是插入排序的改进版,由 Donald Shell 在 1959 年提出。它的思路很朴素:插入排序在数组「基本有序」时非常快,那能不能先做一些粗粒度的排序,让数组变得基本有序,最后再做一次完整的插入排序?于是它引入「增量」(gap),把相隔 gap 的元素看成一组,对每组做插入排序;不断缩小 gap,直到 gap == 1,做最后一次普通插入排序。
围绕希尔排序的常见误解有两个。第一,以为它的复杂度是固定的 O(n log n);实际上希尔排序的时间复杂度取决于增量序列,不同的序列有不同的理论界,最坏情况可能到 O(n²)。第二,以为它是稳定排序;由于相隔较远的元素会被交换,相等元素的相对次序可能改变,所以它不稳定。
本文讲清增量序列的作用、Knuth 序列的实现、复杂度的来源,以及它和插入排序、归并排序的对比。代码以 C++17 为基准。
一、从插入排序说起
普通插入排序每次把当前元素插入到前面已排序部分的正确位置。对随机数组,平均需要 O(n²) 次比较和移动;但如果数组已经基本有序,每个元素只需移动很少的位置,复杂度接近 O(n)。
cpp
// 适用于 C++17
void insertionSort(std::vector<int>& a) {
for (std::size_t i = 1; i < a.size(); ++i) {
int key = a[i];
std::size_t j = i;
while (j > 0 && a[j - 1] > key) {
a[j] = a[j - 1];
--j;
}
a[j] = key;
}
}
问题在于,如果一个很小的元素在数组末尾,它需要一步一步往前挪 n-1 次。希尔排序通过「大步长」的预排序,让远处的元素先大致归位,从而减少最后一次插入排序的移动量。
二、增量序列
设当前增量为 gap,把下标相差 gap 的元素分为一组:a[i], a[i+gap], a[i+2gap], ...。对每一组做插入排序。然后缩小 gap,重复,直到 gap == 1。
以 gap = 4 为例,数组被分成 4 组(下标模 4 同余),每组内部排序;gap 缩小到 1 时,整个数组再做一次插入排序。由于前面的步骤已经让数组「大致有序」,最后一步的移动量通常比直接插入排序小。
增量序列的选择直接影响性能,常见的序列有:
| 序列 | 生成方式 | 最坏时间复杂度(教材结论) |
|---|
|------------|--------------------|-------|
| Shell 原始序列 | n/2, n/4, ..., 1 | O(n²) |
|---------|-----------|-------------|
| Hibbard | 2^k - 1 | O(n^{3/2}) |
|-------|------------------------------------|-------------|
| Knuth | 1, 4, 13, 40, ...,即 h = 3h + 1 | O(n^{3/2}) |
|-----------|--------------------------|-------------|
| Sedgewick | 1, 5, 19, 41, 109, ... | O(n^{4/3}) |
这些界都是在特定假设下证明的,实际运行时间还受数据分布、缓存、编译器优化的影响。不要把它们当成精确的性能承诺 ;如果需要在某个数据集上比较,应该用 std::chrono::steady_clock 自己测量,并说明测量环境和数据规模。
一个共同的要求是:最后一个增量必须是 1。否则数组只是「按某个步长分组有序」,整体并未排好序。这是实现希尔排序最容易漏掉的一点。
三、Knuth 序列的实现
先生成不超过数组长度的最大增量,再不断除以 3:
cpp
// 适用于 C++17
#include <cstddef>
#include <vector>
void shellSort(std::vector<int>& a) {
const int n = static_cast<int>(a.size());
if (n < 2) return;
int gap = 1;
while (gap < n / 3) {
gap = 3 * gap + 1;
}
for (; gap > 0; gap /= 3) {
for (int i = gap; i < n; ++i) {
int key = a[i];
int j = i;
while (j >= gap && a[j - gap] > key) {
a[j] = a[j - gap];
j -= gap;
}
a[j] = key;
}
}
}
逐处核对:
gap < n / 3而不是gap < n,是为了让最大增量落在n/3附近。Knuth 序列是1, 4, 13, 40, 121, ...,对n = 10,n/3 = 3,循环从gap = 1开始,1 < 3成立,变成 4,4 < 3不成立,于是从 4 开始,再 1。对n = 100,得到 40,再 13、4、1。j >= gap的判断必须在a[j - gap]之前,否则j - gap会变成负数。j是int,不是无符号类型,这一点很重要;如果用std::size_t,j -= gap在j < gap时会回绕成很大的数,导致越界。- 内层是标准的插入排序,只是相邻元素的距离从 1 变成了
gap。 gap /= 3最终会到 1,然后下一次变成 0,循环结束。因为gap是正整数,1 / 3 == 0,不会死循环。
手工推演一个小数组 [5, 2, 9, 1, 6, 3],n = 6,n/3 = 2,初始 gap = 1 < 2,变成 4。
gap = 4:i = 4,key = 6,比较a[0] = 5 > 6不成立,位置不变;i = 5,key = 3,比较a[1] = 2 > 3不成立,位置不变。数组仍是[5,2,9,1,6,3]。gap = 1:普通插入排序,最终得到[1,2,3,5,6,9]。
这个例子里 gap = 4 没起作用,但它说明了流程:大增量负责粗调,小增量负责收尾。
四、复杂度与稳定性
希尔排序的时间复杂度分析很复杂,因为它依赖增量序列,而且每一轮的操作次数与上一轮的排列有关。教材上常给出的结论是:
- Shell 原始序列(每次减半):最坏 O(n²)。
- Hibbard 序列(
2^k - 1):最坏 O(n^{3/2})。 - Knuth 序列(
3h + 1):最坏 O(n^{3/2})。 - Sedgewick 序列:最坏 O(n^{4/3})。
空间复杂度是 O(1),因为所有操作都在原数组上进行,只需要常数个临时变量。
稳定性方面,希尔排序是不稳定 的。跨增量的交换会把相等元素分到不同的组,组内排序后再合并,原来的相对次序可能已经被破坏。如果业务要求稳定,应该用 std::stable_sort 或归并排序。
| 算法 | 平均时间 | 最坏时间 | 空间 | 稳定 |
|---|
|------|-------|-------|------|---|
| 插入排序 | O(n²) | O(n²) | O(1) | 是 |
|------|---------|---------|------|---|
| 希尔排序 | 取决于增量序列 | 取决于增量序列 | O(1) | 否 |
|------|------------|------------|------|---|
| 归并排序 | O(n log n) | O(n log n) | O(n) | 是 |
|------|------------|-------|------------|---|
| 快速排序 | O(n log n) | O(n²) | O(log n) 栈 | 否 |
|-------------|---------------|------------|------------|---|
| std::sort | 通常 O(n log n) | O(n log n) | O(log n) 栈 | 否 |
工程中,如果只是需要一个通用排序,直接用 std::sort;希尔排序的价值在于实现简单、原地、不需要额外空间,适合嵌入式或教学场景。它的实际性能与增量序列、数据规模密切相关,不能简单地用「比插入排序快多少倍」来描述。
五、完整示例
cpp
// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <vector>
void shellSort(std::vector<int>& a) {
const int n = static_cast<int>(a.size());
if (n < 2) return;
int gap = 1;
while (gap < n / 3) {
gap = 3 * gap + 1;
}
for (; gap > 0; gap /= 3) {
for (int i = gap; i < n; ++i) {
int key = a[i];
int j = i;
while (j >= gap && a[j - gap] > key) {
a[j] = a[j - gap];
j -= gap;
}
a[j] = key;
}
}
}
void print(const std::vector<int>& a) {
for (int x : a) std::cout << x << ' ';
std::cout << '\n';
}
int main() {
std::vector<int> a{5, 2, 9, 1, 6, 3};
shellSort(a);
print(a); // 按标准语义应为 1 2 3 5 6 9
std::vector<int> b{1};
shellSort(b);
print(b); // 1
std::vector<int> c;
shellSort(c);
print(c); // 空行
return 0;
}
n < 2 的提前返回覆盖了空数组和单元素数组。如果不加这个判断,n = 1 时 n / 3 = 0,gap = 1 不小于 0,循环不执行,gap 保持 1,然后进入排序循环,i = 1 不满足 i < 1,什么也不做,最后 gap /= 3 变成 0,循环结束。逻辑上也能正确结束,但提前返回更清晰。
六、增量序列的生成细节
不同的生成方式会带来不同的边界。以 Hibbard 序列为例:
cpp
// 适用于 C++17
int gap = 1;
while (gap < n / 2) {
gap = 2 * gap + 1; // 1, 3, 7, 15, ...
}
这个序列的项是 2^k - 1。生成时要注意 gap 的类型和溢出:如果 n 接近 INT_MAX,2 * gap + 1 可能溢出,属于有符号整数溢出,是未定义行为(undefined behavior, UB)。实际数组长度远小于这个量级,但写通用代码时仍应留意。
Sedgewick 序列的生成更复杂,通常直接查表或者用递推式,本文不展开。选择序列的经验是:不要用简单的 n/2 递减(最坏 O(n²)),Knuth 或 Hibbard 已经能带来明显改善,而且生成简单。
常见坑点
1. 最后一个增量不是 1
❌ for (; gap > 1; gap /= 2) ------ 循环结束时没有做 gap = 1 的完整插入排序。
✅ 条件写成 gap > 0,保证 gap == 1 那轮一定执行。
2. 内层下标用无符号类型
❌ std::size_t j = i; while (j >= gap && ...) { j -= gap; },当 j < gap 时 j -= gap 回绕成极大值。
✅ 用 int 或 std::ptrdiff_t,并先判断 j >= gap。
3. 增量序列生成后超过数组长度
❌ while (gap < n) gap = 3 * gap + 1;,得到的 gap >= n,第一轮 i = gap 直接不执行,白跑一轮。
✅ 用 gap < n / 3 控制上界,让最大增量不超过数组长度。
4. 认为希尔排序稳定
❌ 在需要保持相等元素原有顺序的场景使用希尔排序。
✅ 用 std::stable_sort 或归并排序;希尔排序的跨组交换会破坏稳定性。
5. 给出没有来源的性能倍数
❌ 「希尔排序比插入排序快 5 倍」。
✅ 说明复杂度依赖增量序列,给出测量方法(std::chrono::steady_clock、固定数据规模、多次取中位数),不写未测过的数字。
6. 对链表使用数组版希尔排序
❌ 把 a[j - gap] 的写法直接套到链表上。
✅ 希尔排序依赖随机访问;链表适合归并排序,或先把链表转成数组。
7. 比较器方向写反
❌ 想升序却写 a[j - gap] < key,结果降序。
✅ 升序用 a[j - gap] > key,把较大的元素往后挪。
8. 忘记处理空数组
❌ 直接进入循环,某些实现会在 a[0] 上出错。
✅ 开头判断 n < 2 直接返回。
总结
| 要点 | 结论 |
|---|
|------|-------------------|
| 核心思想 | 按增量分组做插入排序,逐步缩小增量 |
|------|-------|
| 最后增量 | 必须是 1 |
|------|------------------------------|
| 常用序列 | Knuth 3h+1、Hibbard 2^k-1 |
|-------|-----------------------------------|
| 时间复杂度 | 取决于序列,Knuth/Hibbard 为 O(n^{3/2}) |
|----|------|
| 空间 | O(1) |
|-----|-----|
| 稳定性 | 不稳定 |
|------|---------------|
| 适用场景 | 原地、实现简单、数据量中等 |
希尔排序是理解「增量」思想的好例子:它没有归并排序的分治,也没有快速排序的划分,只是把插入排序的比较距离拉大,就让数组更快地变得「基本有序」。实现时记住三件事------增量序列要以 1 收尾、下标不要用无符号类型、它不稳定------代码就不会出大问题。