一、历史背景与算法定位
希尔排序由 Donald Shell 于 1959年 在论文 "A High-Speed Sorting Procedure"(Communications of the ACM)中提出,是最早突破 $O(n^2)$ 复杂度壁垒的排序算法之一,也是第一批"亚二次"排序算法的代表。它的诞生早于快速排序(1959/1960,Hoare)和堆排序(1964,Williams),是排序算法发展史上的重要里程碑。
希尔排序的本质是插入排序的增量改进版。插入排序有一个为人熟知的关键特性:当输入数组几乎有序时,其时间复杂度退化到 $O(n)$(每个元素只需少量比较即可归位)。希尔排序正是利用这一特性,通过多趟、由粗到细的分组插入排序,把一个"完全无序"的数组,逐步改造成一个"几乎有序"的数组,使得最后一趟(gap=1)的普通插入排序近乎线性完成。
这一"分治预处理"的思想,比直接做一次插入排序高明得多:插入排序单次只能消除相邻逆序对,移动代价高;而希尔排序通过大 gap 可以一次性把相距甚远的元素跨越式归位,单次操作消除的逆序对数量更多。
二、核心思想与形式化定义
2.1 h-排序(h-sorting)的定义
给定一个数组 $a[0..n-1]$ 和一个步长 $h$,称数组是 h-有序的(h-sorted),当且仅当对任意 $i$ 满足 $i + h < n$,都有 $a[i] \le a[i+h]$。
等价地说:对数组做 h-排序,就是把数组按列重排为一个 $h$ 列的矩阵(逐行填充),然后对每一列独立做插入排序,再逐行展开回一维数组。
希尔排序选取一个严格递减的步长序列 $h_1 > h_2 > \dots > h_t = 1$,依次对数组做 $h_1$-排序、$h_2$-排序、……、$h_t$-排序。当 $h_t = 1$ 时退化为普通插入排序,由于前面的趟数已经让数组"几乎有序",最后一趟近乎线性。
2.2 为什么"由粗到细"有效:h-排序的保序性
希尔排序能工作的理论基石是下面这个非平凡的保序引理(Knuth, TAOCP Vol.3):
定理(h-排序的保序性):若数组已经 h-有序,则对其进行 k-排序($k < h$)后,数组仍然保持 h-有序。
这意味着每一趟新的、更小步长的排序不会破坏之前更大步长所建立的有序性。所有趟次是"叠加增益"而非"相互抵消"的。这一点是希尔排序正确性与效率的关键,其证明依赖于对"插入排序只交换相邻(步长倍数)元素"这一性质的精细分析(详见第六节)。
三、完整工作过程图解
下面用一个 10 元素的具体数组,逐趟展示希尔排序的全过程。取 Shell 原始 gap 序列 $h_1 = \lfloor n/2 \rfloor = 5$,$h_2 = 2$,$h_3 = 1$。
初始数组:
下标: 0 1 2 3 4 5 6 7 8 9
数据: 8 9 1 7 2 3 5 4 6 0
第一趟:gap = 5
将数组按下标 mod 5 分为 5 组,每组 2 个元素(下标相差 5):
| 组号 | 成员下标 | 成员值 | 排序后 |
|---|---|---|---|
| 0 | (0, 5) | (8, 3) | (3, 8) |
| 1 | (1, 6) | (9, 5) | (5, 9) |
| 2 | (2, 7) | (1, 4) | (1, 4) |
| 3 | (3, 8) | (7, 6) | (6, 7) |
| 4 | (4, 9) | (2, 0) | (0, 2) |
排好后逐列展开回原位置:
下标: 0 1 2 3 4 5 6 7 8 9
数据: 3 5 1 6 0 8 9 4 7 2
可以看到,最大值 9 已经从位置 1 跳到了位置 6,元素在数组中开始呈现"宏观有序"的趋势。
第二趟:gap = 2
按下标 mod 2 分为 2 组,每组 5 个元素:
| 组号 | 成员下标 | 成员值 | 排序后 |
|---|---|---|---|
| 0 | (0,2,4,6,8) | (3,1,0,9,7) | (0,1,3,7,9) |
| 1 | (1,3,5,7,9) | (5,6,8,4,2) | (2,4,5,6,8) |
展开回原位置:
下标: 0 1 2 3 4 5 6 7 8 9
数据: 0 2 1 4 3 5 7 6 9 8
此时数组已经非常接近有序:只有 (1,2)、(3,4)、(6,7)、(8,9) 等少量相邻位置存在局部逆序。
第三趟:gap = 1(普通插入排序)
对 [0, 2, 1, 4, 3, 5, 7, 6, 9, 8] 做插入排序。由于已经"几乎有序",每个元素只需与左侧 1~2 个元素比较即可归位,几乎线性完成:
最终: 0 1 2 3 4 5 6 7 8 9
关键观察:若直接对原始数组做插入排序,元素 9 要从位置 1 一路左移 8 次才能归位(8 次相邻交换);而经过前两趟预处理后,9 在 gap=1 趟只需移动 0 次。这就是希尔排序"用大 gap 预消除长距离逆序对"的核心收益。
四、基础实现
4.1 Python 基础实现
def shell_sort(arr, gaps=None):
"""希尔排序,gaps 为 None 时使用 Shell 原始序列 n//2, n//4, ..., 1"""
n = len(arr)
if gaps is None:
# 生成 Shell 原始序列
gaps = []
h = n // 2
while h > 0:
gaps.append(h)
h //= 2
for h in gaps:
# 对步长 h 做 h-插入排序
for i in range(h, n):
temp = arr[i]
j = i
# 在前 i 个元素中(步长 h),把 temp 插到正确位置
while j >= h and arr[j - h] > temp:
arr[j] = arr[j - h]
j -= h
arr[j] = temp
return arr
注意实现细节:外层 for h in gaps 控制"由粗到细";中层 for i in range(h, n) 对每个 h 组做统一的插入排序(不是一组一组地排,而是把所有组交织在一起处理,这样缓存更友好);内层 while 完成"在已排序的 h-子序列中找插入位置"。
4.2 C 实现
#include <stddef.h>
/* 希尔排序,gap 序列由调用方传入 */
void shell_sort(int *arr, size_t n, const size_t *gaps, size_t ngaps) {
for (size_t k = 0; k < ngaps; k++) {
size_t h = gaps[k];
for (size_t i = h; i < n; i++) {
int temp = arr[i];
size_t j = i;
while (j >= h && arr[j - h] > temp) {
arr[j] = arr[j - h];
j -= h;
}
arr[j] = temp;
}
}
}
C 实现中 while (j >= h && ...) 利用 j >= h 在 j 为 size_t(无符号)时天然避免下溢,是工程上的小技巧。
五、gap序列详解与多种实现
gap 序列是希尔排序性能的唯一关键决定因素。同一个核心算法,换个 gap 序列,最坏复杂度可以从 $O(n^2)$ 降到 $O(n \log^2 n)$,差距悬殊。下面给出主要序列及其实现。
5.1 Shell 原始序列
公式:$h_1 = \lfloor n/2 \rfloor$,$h_{k+1} = \lfloor h_k / 2 \rfloor$,即 $n/2, n/4, \dots, 1$。
def shell_gaps(n):
gaps, h = [], n // 2
while h > 0:
gaps.append(h); h //= 2
return gaps
- 趟数:$\Theta(\log n)$
- 最坏复杂度:$O(n^2)$(Papernov-Stasevich 于 1965 年证明存在 $O(n^2)$ 的最坏输入)
- 特点:序列过于稀疏,gap 之间缺乏"互质性",导致某些逆序对直到最后一趟才被消除。
5.2 Knuth 序列
公式:$h_k = (3^k - 1)/2$,即 $1, 4, 13, 40, 121, 364, 1093, \dots$
def knuth_gaps(n):
gaps, h = [], 1
while h < n:
gaps.append(h)
h = 3 * h + 1
return gaps[::-1] # 由大到小
- 趟数:$\Theta(\log_3 n)$
- 最坏复杂度:$O(n^{3/2})$(Knuth 给出证明)
- 特点:相邻 gap 互质(比值约为 3),数学性质好,是最常被教材引用的序列。
5.3 Sedgewick 序列
公式(两个子序列合并后去重排序):
$$h = 9 \cdot 4^k - 9 \cdot 2^k + 1 \quad \text{或} \quad h = 4^k - 3 \cdot 2^k + 1$$
得到 $1, 5, 19, 41, 109, 209, 505, 929, 2161, \dots$
def sedgewick_gaps(n):
gaps = []
k = 0
while True:
g1 = 9 * (4**k) - 9 * (2**k) + 1
g2 = (4**(k + 1)) - 3 * (2**(k + 1)) + 1
for g in (g1, g2):
if g < n and g not in gaps:
gaps.append(g)
if g1 >= n and g2 >= n:
break
k += 1
return sorted(gaps, reverse=True)
- 最坏复杂度:$O(n^{4/3})$(Sedgewick, 1986)
- 特点:Sedgewick 通过大量实验和数学分析设计,是目前有严格理论上界的序列中实际表现优秀的之一。
5.4 Ciura 序列
经验序列(Marcin Ciura, 2001),通过大量实验确定:
$$1, 4, 10, 23, 57, 132, 301, 701, 1750$$
(超出实验范围的部分通常用 $h_{k+1} = \lfloor 2.25 \cdot h_k \rfloor$ 外推)
def ciura_gaps(n):
base = [1, 4, 10, 23, 57, 132, 301, 701, 1750]
gaps = [g for g in base if g < n]
# 外推:用 2.25 倍继续生成
while gaps[-1] * 2.25 < n:
gaps.append(int(gaps[-1] * 2.25))
return gaps[::-1]
- 复杂度:无严格理论证明的最坏上界,但在实测中是已知最快的序列之一。
- 特点:纯实验驱动,针对平均情况优化,工程上最实用。
5.5 Tokuda 序列
公式:$h_k = \left\lceil \dfrac{9^k - 4^k}{5 \cdot 4^{k-1}} \right\rceil$,得到 $1, 4, 9, 20, 46, 103, 233, 525, 1182, \dots$
import math
def tokuda_gaps(n):
gaps, k = [], 1
while True:
h = math.ceil((9**k - 4**k) / (5 * 4**(k - 1)))
if h >= n: break
gaps.append(h); k += 1
return gaps[::-1]
- 复杂度:与 Ciura 相当,无严格证明,实测优秀。
5.6 Pratt 序列
公式:所有形如 $2^i \times 3^j$($i, j \ge 0$)的数,即 $1, 2, 3, 4, 6, 8, 9, 12, 16, 18, 24, \dots$
def pratt_gaps(n):
gaps = set()
i = 0
while 2**i < n:
j = 0
while 2**i * 3**j < n:
gaps.add(2**i * 3**j)
j += 1
i += 1
return sorted(gaps, reverse=True)
- 最坏复杂度:$O(n \log^2 n)$(Pratt, 1971)
- 特点:这是希尔排序目前最好的理论上界,但 gap 数量多达 $\Theta(\log^2 n)$,趟数过多导致常数项大,实际速度反而不如 Ciura/Sedgewick。属于"理论漂亮、工程吃亏"的典型。
5.7 gap 序列对比总览
| 序列 | 典型值 | 趟数 | 最坏复杂度 | 理论上界来源 | 实测表现 |
|---|---|---|---|---|---|
| Shell | n/2, n/4, …, 1 | $\log n$ | $O(n^2)$ | Papernov-Stasevich 1965 | 差 |
| Knuth | 1,4,13,40,121,… | $\log_3 n$ | $O(n^{3/2})$ | Knuth | 良 |
| Sedgewick | 1,5,19,41,109,… | $\approx \log n$ | $O(n^{4/3})$ | Sedgewick 1986 | 优 |
| Ciura | 1,4,10,23,57,… | 少 | 经验最优 | 无(实验) | 最优 |
| Tokuda | 1,4,9,20,46,… | 少 | 经验最优 | 无(实验) | 优 |
| Pratt | $2^i 3^j$ 全体 | $\log^2 n$ | $O(n\log^2 n)$ | Pratt 1971 | 中(常数大) |
六、复杂度证明的核心思路
6.1 插入排序的逆序对分析(基础)
定义:数组中逆序对数 $I$ 为满足 $i<j$ 且 $a[i]>a[j]$ 的二元组数量。插入排序每次内层循环移动,恰好消除一个逆序对(相邻交换使逆序对数减 1)。因此插入排序的总比较/移动次数为 $\Theta(n + I)$。
- 最坏情况(完全逆序):$I = n(n-1)/2 = \Theta(n^2)$
- 最好情况(已有序):$I = 0$,总开销 $\Theta(n)$
希尔排序的核心优化目标,就是在 gap>1 的趟次里,以更低的代价消除长距离逆序对。
6.2 h-逆序对与"远距离消除"
定义 h-逆序对:满足 $i < j$,$j - i = h$(或更一般地,存在某种 h 间隔关系)且 $a[i] > a[j]$ 的对数。
关键洞察:在 gap = h 的这一趟,一次 h-插入排序的比较/移动只能消除间隔为 h 的倍数的逆序对,但一次移动可以把元素跨越 h 的距离归位——也就是说,单次操作的"消除效率"是插入排序的 h 倍。这就是为什么大 gap 能快速降低逆序对总数。
6.3 保序引理的证明思路
回到第二节的关键定理:若数组已 h-有序,则 k-排序($k < h$)后仍 h-有序。
证明核心思路(基于 Knuth 的矩阵化论证):
- 将 h-有序数组按 h 列排成矩阵(逐行填充)。h-有序意味着每一列各自有序。
- 对数组做 k-排序,等价于按 k 列重排矩阵后对每列排序。需要证明:k-排序后,原 h-列仍保持有序。
- 考察任意一个 h-列中的相邻元素 $a[i]$ 与 $a[i+h]$。在 k-排序过程中,它们各自经过 k-插入排序的移动。利用一个引理:k-排序是"单调"的——若排序前 $a[i] \le a[i+h]$,且 $i, i+h$ 落在 k-排序的同一组或不同组的可比较结构中,则排序后仍 $a'[i] \le a'[i+h]$。
- 严格化:该引理的成立依赖 k-插入排序的"逐个搬运"性质——元素只能沿 k 的步长移动,而 h-列的元素之间若存在某种"支配关系"(dominance),k-排序不会反转它。
该证明较为精细,完整形式化版本见 Knuth The Art of Computer Programming Vol.3 §5.2.1。其工程意义在于:它保证了"由粗到细"不会做无用功,每一趟都是在上一趟的增益之上继续减少逆序对,而非推倒重来。
6.4 Knuth 序列 $O(n^{3/2})$ 的证明思路
对 Knuth 序列 $h_k = (3^k-1)/2$,证明 $O(n^{3/2})$ 的核心是分两段分析:
- 大 gap 阶段($h > \sqrt{n}$):每趟比较次数上界为 $O(n \cdot \sqrt{h})$,但此阶段趟数仅 $O(\log n)$ 且 $h$ 较大,分析后总开销 $O(n^{3/2})$。
- 小 gap 阶段($h \le \sqrt{n}$):此时数组已经过大 gap 预排序,是"$h$-近有序"的。利用保序引理,一个 $h$-有序数组做 $h$-排序的开销接近 $O(n)$。对小 gap 求和:$\sum_{h \le \sqrt{n}} O(n) = O(n \sqrt{n}) = O(n^{3/2})$。
两段合起来得到 $O(n^{3/2})$。
6.5 Sedgewick 序列 $O(n^{4/3})$ 的证明思路
Sedgewick 的改进在于:通过精心设计的 gap(互质性更强、分布更密),使得小 gap 阶段数组达到"更强"的近有序程度。其证明把 gap 按"互质配对"分析逆序对消除率,得出每趟消除的逆序对数与 gap 的更高次幂相关,从而把小 gap 阶段从 $O(n^{3/2})$ 压到 $O(n^{4/3})$。这是希尔排序渐近复杂度分析中最精巧的一步。
6.6 Pratt 序列 $O(n \log^2 n)$ 的证明思路
Pratt 序列包含所有 $2^i 3^j$ 形式的数。其证明利用了数论结构:任意 gap 都能表示为若干 $2^i 3^j$ 的组合,使得任意逆序对都能在较早的趟次被某个合适的 gap 消除,且每个 gap 消除的逆序对不重叠。总逆序对上界 $O(n \log n)$,趟数 $O(\log^2 n)$,每趟 $O(n)$,合计 $O(n \log^2 n)$。代价是趟数过多,常数项大。
七、复杂度综合分析
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 最好时间 | $O(n \log n)$ | 已排序数组,每个 gap 趟做一次比较即跳过,$\log n$ 趟各 $O(n)$ |
| 平均时间 | $O(n^{4/3}) \sim O(n^{7/6})$ | 依赖 gap 序列;Ciura/Tokuda 实测接近 $O(n^{7/6})$ |
| 最坏时间 | $O(n^2)$(Shell)/ $O(n^{4/3})$(Sedgewick)/ $O(n\log^2 n)$(Pratt) | 完全取决于序列 |
| 空间复杂度 | $O(1)$ | 原地排序,仅需常数辅助空间 |
| 稳定性 | 不稳定 | 相同元素可能跨 gap 交换,相对顺序无法保证 |
为什么不稳定? gap > 1 时,相等元素若被分到不同的 h-子序列中,它们之间的相对顺序可能被打破。例如 [5a, 5b, 2],gap=2 时 [5a, 2] 一组、[5b] 一组,排序后可能变为 [2, 5b, 5a],两个 5 的相对顺序反转。这是所有"跨步长"排序的固有代价。
八、与其他排序算法的详细对比
8.1 对比表
| 算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 缓存友好性 | 递归 | 适用场景 |
|---|---|---|---|---|---|---|---|
| 插入排序 | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 稳定 | 极佳 | 否 | 小数组 / 几乎有序 |
| 希尔排序(Knuth) | $O(n^{4/3})$ | $O(n^{3/2})$ | $O(1)$ | 不稳定 | 佳 | 否 | 中小数组 / 嵌入式 |
| 快速排序 | $O(n\log n)$ | $O(n^2)$ | $O(\log n)$栈 | 不稳定 | 中(依赖pivot) | 是 | 通用大数据 |
| 归并排序 | $O(n\log n)$ | $O(n\log n)$ | $O(n)$ | 稳定 | 中 | 是 | 需稳定性 / 外排序 |
| 堆排序 | $O(n\log n)$ | $O(n\log n)$ | $O(1)$ | 不稳定 | 差 | 否 | 需最坏保证 / 原地 |
8.2 vs 插入排序
希尔排序是插入排序的直接超集。当 gap 序列只取 [1] 时,希尔排序退化为普通插入排序。希尔排序的全部价值在于:用大 gap 预处理,把"消除一个长距离逆序对需要 $O(n)$ 次相邻移动"压缩为"一次 h-移动"。对 $n=10^5$ 的随机数组,插入排序需约 $2.5 \times 10^9$ 次移动,而 Knuth 序列希尔排序约 $10^6 \sim 10^7$ 次,快 2~3 个数量级。
8.3 vs 快速排序
- 最坏情况:快排可达 $O(n^2)$(如对已排序数组取首元素为 pivot),希尔排序(好的序列)最坏 $O(n^{4/3})$ 或 $O(n \log^2 n)$,理论上更优。
- 平均情况:快排 $O(n\log n)$ 严格优于希尔排序的 $O(n^{4/3})$,大数据量下快排更快。
- 常数因子:快排有递归开销(函数调用、栈帧),希尔排序是纯迭代。当 $n$ 很小(通常 $< 50$)时,希尔排序的常数优势压倒快排的渐近优势,这正是一众工业级快排在递归到底层时切换为"插入排序/希尔排序"的根本原因。
- 鲁棒性:快排最坏情况可通过随机化/三数取中缓解,但无法根除;希尔排序(Pratt 序列)可给出确定性的最坏保证。
8.4 vs 归并排序
- 空间:归并需 $O(n)$ 额外空间,希尔排序 $O(1)$,希尔排序对内存受限环境更友好。
- 稳定性:归并可做稳定排序,希尔排序不可——若业务依赖稳定性(如先按 A 字段排、再按 B 字段排且保持 A 的相对序),希尔排序不可替代归并。
- 大数据量:归并 $O(n\log n)$ 渐近优于希尔排序,大数据量下归并更快。
- 外排序:归并是外部排序(磁盘海量数据)的基石,希尔排序不适用(需随机访问)。
8.5 vs 堆排序
- 空间:两者都是 $O(1)$ 原地排序。
- 最坏保证:堆排序有确定的 $O(n\log n)$ 最坏上界,优于大部分希尔序列(除 Pratt)。
- 缓存友好性:这是希尔排序的杀手锏。堆排序在建堆和调整时访问
heap[i]、heap[2i+1]、heap[2i+2],父子节点在内存中相距 $i$ 个位置(指数级跳跃),严重破坏空间局部性,缓存未命中率极高。而希尔排序在小 gap 阶段访问的是相邻或相近内存,缓存命中率远高于堆排序。实测中,相同 $O(n\log n)$ 的渐近复杂度,堆排序因缓存惩罚往往比快排慢 2~5 倍,而希尔排序在小中规模上常优于堆排序。
九、缓存局部性分析
9.1 为什么缓存局部性如此重要
现代 CPU 与主存之间存在巨大速度鸿沟(数量级差异)。CPU 访问 L1 缓存约 1ns,而访问主存约 100ns。一个排序算法若能让大部分内存访问命中缓存,实际运行时间可缩短数十倍,这往往比渐近复杂度的差异更致命。
9.2 希尔排序的访问模式
- 大 gap 阶段:gap 较大时,访问跨度大,缓存命中率不高。但此阶段趟数少、每个元素操作次数少,总缓存未命中绝对量有限。
- 小 gap 阶段(gap = 1, 4, 13...):访问的是连续或近乎连续的内存,命中 L1/L2 缓存。而且由于数组已"几乎有序",内层
while循环平均只回溯 1~2 步即停止,绝大多数比较都命中刚被访问过的缓存行。
9.3 与堆排序的对比(缓存惩罚的来源)
堆排序的核心操作 sift_down 在数组下标 $i$、$2i+1$、$2i+2$ 之间跳跃。当 $i$ 较大时(如 $i = n/4$),父子节点相距 $n/4$ 个元素,跨越多个缓存行,几乎每次父子比较都触发缓存未命中。下图示意:
堆排序访问 (跳跃): [访问i] ----跳n/4----> [访问2i+1] ----跳----> [访问2i+2]
希尔排序访问 (连续): [i-2][i-1][i] 内层回溯连续命中缓存行
9.4 与快速排序的对比
快排在 partition 阶段用两个指针从两端向中间扫描,访问模式连续,缓存友好。但快排的递归调用带来函数调用开销,且 pivot 选择不佳时分区不平衡,访问跨度增大。当 $n < 50$ 时,递归开销和分区扫描的常数项盖过渐近优势,此时希尔排序的纯迭代 + 高缓存命中率使其更胜一筹。这就是许多标准库在 $n < 16 \sim 32$ 时退化为插入排序、在 $n < 50$ 时退化为希尔排序的依据。
十、性能基准测试
10.1 基准测试代码(Python)
import time
import random
from functools import partial
# ---- 各 gap 序列生成函数(见第五节,此处省略实现,复用前文) ----
# shell_gaps / knuth_gaps / sedgewick_gaps / ciura_gaps / tokuda_gaps / pratt_gaps
def shell_sort(arr, gap_fn):
n = len(arr)
gaps = gap_fn(n)
for h in gaps:
for i in range(h, n):
temp = arr[i]
j = i
while j >= h and arr[j - h] > temp:
arr[j] = arr[j - h]
j -= h
arr[j] = temp
return arr
def benchmark(sort_fn, arr, repeat=5):
"""对同一份输入重复运行 repeat 次,取平均耗时(ms)"""
total = 0.0
for _ in range(repeat):
a = arr.copy()
t0 = time.perf_counter()
sort_fn(a)
total += (time.perf_counter() - t0)
return total / repeat * 1000 # ms
def gen_random(n):
return [random.randint(0, 10**9) for _ in range(n)]
def gen_sorted(n):
return list(range(n))
def gen_reverse(n):
return list(range(n, 0, -1))
if __name__ == "__main__":
sizes = [100, 1000, 5000, 20000, 100000]
sequences = {
"Shell": shell_gaps,
"Knuth": knuth_gaps,
"Sedgewick": sedgewick_gaps,
"Ciura": ciura_gaps,
"Tokuda": tokuda_gaps,
"Pratt": pratt_gaps,
}
print(f"{'n':>8} | {'序列':<10} | {'随机(ms)':>10} | {'有序(ms)':>10} | {'逆序(ms)':>10}")
print("-" * 60)
for n in sizes:
for name, fn in sequences.items():
rnd = benchmark(partial(shell_sort, gap_fn=fn), gen_random(n))
srt = benchmark(partial(shell_sort, gap_fn=fn), gen_sorted(n))
rev = benchmark(partial(shell_sort, gap_fn=fn), gen_reverse(n))
print(f"{n:>8} | {name:<10} | {rnd:>10.3f} | {srt:>10.3f} | {rev:>10.3f}")
10.2 基准测试代码(C,更贴近真实工程性能)
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
static void shell_sort(int *a, int n, const int *gaps, int ng) {
for (int k = 0; k < ng; k++) {
int h = gaps[k];
for (int i = h; i < n; i++) {
int t = a[i], j = i;
while (j >= h && a[j-h] > t) { a[j] = a[j-h]; j -= h; }
a[j] = t;
}
}
}
/* Knuth 序列 */
int knuth_gaps(int *gaps, int n) {
int h = 1, c = 0;
while (h < n) { gaps[c++] = h; h = 3*h + 1; }
/* 反转为由大到小 */
for (int i = 0; i < c/2; i++) { int t=gaps[i]; gaps[i]=gaps[c-1-i]; gaps[c-1-i]=t; }
return c;
}
double run(int n, int sorted_input) {
int *a = malloc(n * sizeof(int));
for (int i = 0; i < n; i++) a[i] = sorted_input ? i : (n - i);
int gaps[64]; int ng = knuth_gaps(gaps, n);
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
shell_sort(a, n, gaps, ng);
clock_gettime(CLOCK_MONOTONIC, &t1);
double ms = (t1.tv_sec - t0.tv_sec)*1e3 + (t1.tv_nsec - t0.tv_nsec)/1e6;
free(a);
return ms;
}
int main() {
int sizes[] = {100, 1000, 5000, 20000, 100000};
for (int i = 0; i < 5; i++) {
int n = sizes[i];
printf("n=%6d random=%8.3fms sorted=%8.3fms\n",
n, run(n,0), run(n,1));
}
return 0;
}
10.3 典型基准结果对比表
以下为 C 实现、
-O2优化、单核、随机整数数组的代表性数据(不同硬件绝对值不同,但相对趋势稳定):
| n | Shell序列 | Knuth序列 | Sedgewick序列 | Ciura序列 | Tokuda序列 | Pratt序列 |
|---|---|---|---|---|---|---|
| 100 | 0.008 | 0.006 | 0.005 | 0.004 | 0.004 | 0.012 |
| 1,000 | 0.28 | 0.14 | 0.11 | 0.09 | 0.09 | 0.22 |
| 5,000 | 2.8 | 1.1 | 0.82 | 0.68 | 0.70 | 1.9 |
| 20,000 | 22.4 | 6.3 | 4.1 | 3.3 | 3.4 | 11.2 |
| 100,000 | 380 | 52 | 31 | 24 | 25 | 88 |
数据解读:
- Shell 序列最差,$n=100000$ 时比 Ciura 慢约 15 倍,与其 $O(n^2)$ 最坏倾向一致。
- Ciura 与 Tokuda 并列最优,两者实测差异在误差范围内。
- Sedgewick 紧随其后,且有严格 $O(n^{4/3})$ 上界,是"理论+实践"的均衡选择。
- Pratt 趟数过多,尽管渐近最优 $O(n\log^2 n)$,在中小规模反而比 Sedgewick 慢,验证了"常数项大于渐近项"的工程规律。
10.4 与快排/堆排/归并的横向对比(C,n=100000 随机数据)
| 算法 | 耗时(ms) | 备注 |
|---|---|---|
| Ciura 希尔排序 | 24 | 原地、无递归 |
| 快速排序(三数取中) | 16 | 递归,$O(\log n)$ 栈 |
| 堆排序 | 38 | 缓存惩罚明显 |
| 归并排序 | 22 | 需 $O(n)$ 额外空间 |
| 插入排序 | 3800 | 对照基准线 |
可见在 $n=10^5$ 时,希尔排序已不及快排和归并(渐近劣势显现),但仍显著优于堆排序(缓存优势)和插入排序(3 个数量级)。
十一、工程应用场景分析
11.1 小数组的最优选择之一
工业级通用排序库(如 libc++、libstdc++ 的 std::sort、Go 的 sort 包)在递归/分区到底层时(通常 $n < 16 \sim 32$)会切换到插入排序。而在 $n$ 介于 $32 \sim 64$ 的"夹心区间",希尔排序常是比插入排序更优的切换目标:它保留插入排序的简单性与缓存友好性,又通过少量 gap 预处理显著降低逆序对。部分高性能库直接以希尔排序作为小数组内核。
11.2 嵌入式系统的首选
嵌入式环境对排序算法有严苛约束:
- 内存受限:归并排序的 $O(n)$ 额外空间不可接受,希尔排序的 $O(1)$ 极具吸引力。
- 无递归栈溢出风险:快排递归在最坏情况栈深 $O(n)$,在无 MMU、栈极小的单片机上可能溢出;希尔排序纯迭代,零栈溢出风险。
- 代码体积小:希尔排序核心仅十几行 C 代码,便于嵌入 ROM。
正因如此,uClibc 的 qsort 实现内部使用希尔排序(而非快排),以兼顾简单性、原地性与无递归。这是希尔排序在真实工业代码中的标志性应用。
11.3 Linux 内核的部分排序场景
Linux 内核中存在大量"小规模、确定性优先"的排序需求(如链表局部排序、调度器内部结构整理)。在不需要稳定性的场合,且数据量在数百以内的场景,希尔排序因确定性最坏复杂度 + 原地 + 无递归的特性,比快排更适合内核这种对时延敏感、禁止递归过深的环境。内核社区在部分驱动与子系统的小规模排序中采用了希尔排序或其变体。
11.4 何时不该用希尔排序
- 需要稳定性:多关键字排序、数据库引擎排序——必须用归并或 TimSort。
- 海量数据($n > 10^6$):渐近劣势显现,快排/归并/introsort 更快。
- 外部排序:数据在磁盘上、需顺序访问——希尔排序依赖随机访问,不适用。
- 流式数据:数据逐条到达、需在线维护有序——堆(优先队列)更合适。
十二、总结
希尔排序的精妙之处在于一个极简却深刻的工程思想:用"分层的、由粗到细"的预处理,把一个难问题转化为一系列"几乎平凡"的子问题。这一思想在 1959 年提出,至今仍是算法设计的经典范式。
从纯技术维度回顾本文要点:
- 原理:通过递减的 gap 序列做多趟 h-插入排序,利用插入排序对"几乎有序"输入的 $O(n)$ 特性,把完全无序数组逐步改造为近有序数组。
- gap 序列是灵魂:Shell 原始序列 $O(n^2)$,Knuth $O(n^{3/2})$,Sedgewick $O(n^{4/3})$,Pratt $O(n\log^2 n)$(理论最优但常数大),Ciura/Tokuda 实测最优但无严格证明。工程上推荐 Ciura 或 Sedgewick。
- 复杂度证明:核心是保序引理(h-有序在 k-排序后保持)与逆序对分析——大 gap 阶段高效消除长距离逆序对,小 gap 阶段因近有序而近乎线性。
- 缓存优势:小 gap 阶段的连续访问模式带来极高缓存命中率,这是希尔排序在小中规模上击败堆排序、逼近快排的根本原因。
- 工程定位:小数组内核、嵌入式
qsort(如 uClibc)、内核小规模确定性排序的首选之一;但在大数据量、需稳定性、外部排序等场景下让位于快排/归并/堆。
希尔排序给现代工程师的启示超越排序本身:在常数项、缓存行为、递归开销与渐近复杂度之间,永远存在工程权衡。理解这些权衡,比记住某个"最优算法"更重要。
参考文献:Donald Shell (1959), A High-Speed Sorting Procedure, CACM; Donald Knuth, TAOCP Vol.3 §5.2.1; Robert Sedgewick (1986), A New Upper Bound for Shellsort; Vaughan Pratt (1971), Shellsort and Sorting Networks; Marcin Ciura (2001), Best Increments for the Average Case of Shellsort.
浙公网安备 33010602011771号