目录

插入排序有个明显的短板。

如果最小的元素排在数组末尾(比如 [9,8,7,6,5,4,3,2,1] 里的那个 1),它只能一步一步往前挪,每轮只走一步,要挪 n-1 步才能到位。

这就像一条单行道堵车,每次只能前进一格。

既然一步太慢,那就跨步走。

先把距离远的元素先排个大概,缩小步长再细排,最后步长为 1 时做一次完整的插入排序收尾。

这就是希尔排序(Shell Sort) 。

它是第一个突破 O(n²) 的排序算法,由 Donald Shell 在 1959 年提出。


它是什么

核心思想 按递减的增量序列(gap)把数组分成若干组,每组内部做插入排序。

gap 逐渐缩小到 1,最后做一次标准插入排序完成最终排序。

先粗调再精调,远距离的元素先归位到大致正确的区域,近距离的细节最后处理。

希尔排序本质上是分组的插入排序,通过 gap 控制分组的粒度。

伪代码

// 仅为示意
gap = n / 2
while gap > 0:
    for i = gap to n-1:
        temp = arr[i]
        j = i
        while j >= gap and arr[j - gap] > temp:
            arr[j] = arr[j - gap]
            j = j - gap
        arr[j] = temp
    gap = gap / 2

和普通插入排序的区别只有两点:内层循环的步长从 1 变成了 gap;外层多了一层 gap 递减。

下面用流程图拆解。

为什么分组插入更快

普通插入排序中,元素每次只能移动 1 个位置。

希尔排序中,当 gap 很大时,元素一次可以跨越很远:

  • gap = n/2 时,相距 n/2 的两个元素可以直接比较和交换
  • gap = n/4 时,相距 n/4 的元素可以调整
  • …
  • gap = 1 时,退化为普通插入排序,但此时数组已经”基本有序”

“基本有序”正是插入排序的最佳工况(接近 O(n)),所以最后一轮虽然看起来是 O(n²) 的插入排序,实际上跑得飞快。

工作原理流程图

对应上面伪代码的整体骨架:

flowchart LR
    A([开始]) --> B[gap = n / 2]
    B --> C{ gap > 0 ? }
    C -->|否| Z([结束])
    C -->|是| D[对每组执行间隔为 gap 的插入排序]
    D --> E[gap = gap / 2]
    E --> C

组内插入排序(间隔为 gap):

flowchart LR
    A([i 从 gap 到 n-1]) --> B["temp = arr[i], j = i"]
    B --> C{j >= gap ?}
    C -->|否| D["arr[j] = temp"]
    D --> E[++i]
    E --> A
    C -->|是| F{"arr[j-gap] > temp ?"}
    F -->|是| G["arr[j] = arr[j-gap]<br/>j -= gap"]
    G --> C
    F -->|否| D

逐趟演示

用 [9, 5, 8, 3, 7, 4, 2, 6, 1](n=9)跑一遍 Shell 原始增量序列(gap = 4, 2, 1):

初始数组: [9, 5, 8, 3, 7, 4, 2, 6, 1]

--- 当前增量 gap = 4 ---
gap=4 后: [1, 4, 2, 3, 7, 5, 8, 6, 9]
  分组: (9,7,1) -> [1,7,9]   (5,4) -> [4,5]   (8,2) -> [2,8]   (3,6) -> [3,6]

--- 当前增量 gap = 2 ---
gap=2 后: [1, 3, 2, 4, 7, 5, 8, 6, 9]
  分组: (1,2,7,8) -> [1,2,7,8]   (3,4,5,6) -> [3,4,5,6]   (9) -> [9]

--- 当前增量 gap = 1 ---
gap=1 后: [1, 2, 3, 4, 5, 6, 7, 8, 9]
  退化为标准插入排序,但此时数组已基本有序,很快完成

最终结果: [1, 2, 3, 4, 5, 6, 7, 8, 9]

可以看到 gap=4 这一轮就把 1 从末尾搬到了开头(跨越 8 个位置),而普通插入排序需要 8 轮才能做到。


能解决什么问题

适用场景

场景为什么适合
中等规模且对递归栈有顾虑的环境(如嵌入式)代码简单、常数因子较小,且无栈溢出风险,可作为 std::sort 的替代方案之一
嵌入式系统不需要递归(无栈溢出风险)、内存占用可控
作为通用排序的实现glibc 的 qsort() 在某些情况下会用类似策略

局限性

局限说明
不稳定相同 gap 的分组插入可能导致相等元素跨组移动
复杂度依赖增量序列不同 gap 序列性能差异很大,没有统一的最优解
不如快排/归并稳定大数据量下 O(n log n) 算法更可靠

增量序列的选择

Shell 原始序列(n/2, n/4, …, 1)并不是最优的。几种常见序列对比:

增量序列名称最坏复杂度说明
n/2, n/4, …, 1Shell 原始O(n²)最简单,但不是最快
1, 4, 13, 40, … (3^k-1)/2KnuthO(n^1.5)常用改进版
1, 5, 19, 41, 109, …SedgewickO(n^1.25)实践中表现很好
动态计算Ciura~O(n log n)基于实验的最优序列之一

工程上一般用 Knuth 或 Sedgewick 序列就够了,不必追求理论最优。


C++ 实现与复杂度分析

通用 C++ 模板实现 可直接复用的代码:

template <typename T>
void shellSort(std::vector<T>& arr) {
    int n = arr.size();
    // 使用 Knuth 序列作为示例:1, 4, 13, 40, ...
    int gap = 1;
    while (gap < n / 3) gap = gap * 3 + 1;

    while (gap > 0) {
        for (int i = gap; i < n; ++i) {
            T temp = std::move(arr[i]);
            int j = i;
            while (j >= gap && arr[j - gap] > temp) {
                arr[j] = arr[j - gap];
                j -= gap;
            }
            arr[j] = std::move(temp);
        }
        gap /= 3;
    }
}

文中前面的伪代码使用类似 Python 的语法(for i = gap to n-1)是为了帮助只看原理的读者快速理解。

工程实践中直接用上面的模板函数即可,支持任意实现了 operator> 的可移动类型。

时间复杂度怎么算

希尔排序的复杂度分析依赖于增量序列,没有一个统一的公式。

这里给出 Shell 原始序列的分析思路:

gap 序列长度:n/2 折半到 1,共 O(log n) 个不同的 gap 值。

每个 gap 的工作量:

  • gap 较大时,每组元素少,组内插入排序快
  • gap 较小时,组数多,但每组已基本有序

对于 Shell 原始序列(n/2, n/4, …, 1),最坏情况下时间复杂度为 O(n²)。

使用更好的增量序列(如 Sedgewick)可以降到约 O(n^1.25) 甚至更低。

情况时间复杂度(取决于增量序列)
最好取决于序列,通常优于 O(n²)
最坏O(n²)
平均取决于序列,通常优于 O(n²)

即使是 Sedgewick 序列,最好情况也只是 O(n log n) 的量级,且并非严格证明。

工程上建议以实测为准,不必对理论最优值过度承诺。

空间复杂度怎么算

只用 gap、i、j、temp 等标量 → O(1),原地排序。

小结算法:时间取决于增量序列(Shell 原始最坏 O(n²)、Knuth 约 O(n^1.5)、Sedgewick 约 O(n^1.25))、空间 O(1)、不稳定、原地、突破了简单插入排序的天花板。


总结

  • 是什么:按递减 gap 分组做插入排序,先粗调后精调,是插入排序的”跨步加速版”。
  • 解决什么 / 用在哪:中等规模数据、无递归环境(嵌入式)的实用排序;代码简单可控;突破了 O(n²) 天花板。
  • 局限:不稳定;复杂度依赖增量序列的选择;大数据量不如快排/归并。
  • 复杂度怎么算:无统一公式,依赖 gap 序列。Shell 原始序列最坏 O(n²);好的序列可达 O(n^1.25) 甚至更好。空间恒定 O(1)。