本文是排序系列第 3 篇,聚焦希尔排序:它用分组插入 + 递减增量,把最坏 O(n^2) 降到接近 O(n log^2 n),是理解“局部有序→整体有序”思路的关键一站。
目标读者
- 已掌握插入排序,想了解其高阶优化的学习者。
- 需要在中等规模数据上用更小内存的工程师。
- 想在算法分享或课程中讲解增量序列影响的人。
背景/动机
- 插入排序在近乎有序时很快,但在随机数组上仍是 O(n^2)。
- 希尔排序通过“分组 + 逐步减小增量”让元素快速移动到近似位置,再用小 gap 插入完成排序。
- 增量序列的选择直接决定性能与实现复杂度,是本文重点。
A — Algorithm(题目与算法)
题目:对长度 n 的可比较序列进行排序,允许原地操作。
核心步骤(以 gap= n/2 开始)
- 选定初始 gap(增量),按 gap 将数组划分若干子序列。
- 对每个子序列做插入排序(步长为 gap)。
- 缩小 gap,重复步骤 2,直到 gap = 1(此时等同插入排序)。
基础示例
数组 [9, 8, 3, 7, 5, 6, 4, 1],gap 序列 4 → 2 → 1:
- gap=4:子序列 (0,4),(1,5),(2,6),(3,7),分别插排,使元素大致到位。
- gap=2:更细分组,再插排。
- gap=1:最后一轮插排完成全局有序。
C — Concepts(核心思想)
| 关键概念 | 说明 |
|---|---|
| 增量序列 (gap) | 典型有 n/2 递减、Knuth 序列 (1,4,13,40,…)、Sedgewick 序列等,影响比较次数上界。 |
| 分组插入 | 在间隔为 gap 的子序列上执行插入排序,使远距离元素提前移动。 |
| 原地性 | 仅使用常数额外空间。 |
| 稳定性 | 传统实现不稳定(跨 gap 交换可能打乱相对顺序)。 |
复杂度范围
- 最坏:取决于增量,简单的 n/2 递减最坏仍 O(n^2)。
- 好的序列(如 Sedgewick)可达 O(n^(4/3)) 或 O(n log^2 n) 的上界,在实测中接近 O(n^{1.2~1.3})。
- 空间:O(1)。
E — Engineering(工程应用)
场景 1:中等规模、内存敏感排序(C)
背景:嵌入式/后端中等规模数组(1e4~1e5),需要原地、无额外内存。 为何:希尔排序原地且常数低,优于纯插排;比堆排/快排在某些分布上更稳定性能。
void shell_sort(int *a, int n) {
// Knuth 序列:1,4,13,40,... 直到 < n/3
int gap = 1;
while (gap < n/3) gap = gap * 3 + 1;
for (; gap >= 1; gap /= 3) {
for (int i = gap; i < n; ++i) {
int temp = a[i], j = i;
while (j >= gap && a[j-gap] > temp) {
a[j] = a[j-gap];
j -= gap;
}
a[j] = temp;
}
}
}
场景 2:几乎有序的小型业务列表(Python)
背景:列表每次追加少量尾部元素,但整体规模在 1e5 以内。 为何:用温和的 gap 序列让远端元素快速归位,最后 gap=1 插排收尾。
def shell_sort(arr):
n = len(arr)
gap = 1
while gap < n // 3:
gap = 3 * gap + 1 # Knuth
while gap >= 1:
for i in range(gap, n):
temp = arr[i]
j = i
while j >= gap and arr[j - gap] > temp:
arr[j] = arr[j - gap]
j -= gap
arr[j] = temp
gap //= 3
return arr
data = [9,8,3,7,5,6,4,1]
print(shell_sort(data))
场景 3:Go 服务端小型批处理
背景:单请求内排序长度 1e3~1e4,要求原地,减少 GC 压力。
为何:自定义希尔排序作为 sort.Interface 备选,避免额外分配。
package main
import "fmt"
func shellSort(a []int) {
gap := 1
for gap < len(a)/3 { gap = gap*3 + 1 }
for gap >= 1 {
for i := gap; i < len(a); i++ {
tmp, j := a[i], i
for j >= gap && a[j-gap] > tmp {
a[j] = a[j-gap]
j -= gap
}
a[j] = tmp
}
gap /= 3
}
}
func main(){ arr := []int{9,8,3,7,5,6,4,1}; shellSort(arr); fmt.Println(arr) }
场景 4:前端大数组但需低内存(JavaScript)
背景:浏览器中处理几千条数据,避免频繁分配。 为何:原地、实现短,可直接用 Knuth 序列。
function shellSort(a){
let gap = 1;
while (gap < a.length/3) gap = gap*3 + 1;
while (gap >= 1){
for (let i = gap; i < a.length; i++){
const tmp = a[i];
let j = i;
while (j >= gap && a[j-gap] > tmp){ a[j] = a[j-gap]; j -= gap; }
a[j] = tmp;
}
gap = Math.floor(gap/3);
}
return a;
}
console.log(shellSort([9,8,3,7,5,6,4,1]));
R — Reflection(反思与深入)
- 复杂度:
- 时间:取决于 gap 序列。Knuth 序列表现良好但最坏仍可达 O(n^2)。Sedgewick 序列可提升到 O(n^(4/3)) 上界。
- 空间:O(1)。
- 对比替代:
- vs 插入:希尔大幅减少远距离移动;gap=1 时回到插入。
- vs 快排/堆:希尔更缓存友好,但无严格 O(n log n) 上界;快排/堆在大规模更稳健。
- vs 归并:归并稳定但需要 O(n) 额外空间,希尔原地但不稳定。
- 最优性解释:
- 当数据中存在长距离错位时,先用大 gap 可迅速把元素推向近似位置,后续插排成本小。
- 选择合适的 gap 是关键:过大收益有限,过小难以降低逆序对。
S — Summary(总结)
- 希尔排序 = 分组插排 + 递减增量,原地但不稳定,性能高度依赖 gap 序列。
- Knuth 序列是实践友好的默认;追求更佳上界可研究 Sedgewick / Pratt 序列。
- 适合中等规模、需原地、对稳定性无要求的场景;大规模或需稳定时考虑归并/TimSort。
- 现实混合策略:在自定义排序中,可用希尔排序替代“≤ 某阈值的插排”作为中间层。
- 评估时要基于真实数据分布做基准,而非仅看理论复杂度。
实践指南 / 步骤
- 选择 gap:默认 Knuth;若追求更好上界,可尝试 Sedgewick 序列(1,5,19,41,109…)。
- 设置切换条件:当 gap=1 后继续插排完成;在混合排序中,可在子数组规模小于阈值时用希尔。
- 准备测试集:随机、近乎有序、逆序、大量重复,观察性能与稳定性。
- 记录指标:比较/移动次数、耗时、缓存命中(可用 perf/pprof)。
可运行示例:多语言实现
Python
def shell_sort(a):
n=len(a); gap=1
while gap < n//3: gap = 3*gap + 1
while gap>=1:
for i in range(gap,n):
tmp=a[i]; j=i
while j>=gap and a[j-gap]>tmp:
a[j]=a[j-gap]; j-=gap
a[j]=tmp
gap//=3
return a
print(shell_sort([9,8,3,7,5,6,4,1]))
C
void shell_sort(int *a, int n){
int gap=1; while(gap < n/3) gap = gap*3 + 1;
for(; gap>=1; gap/=3){
for(int i=gap;i<n;i++){
int tmp=a[i], j=i;
while(j>=gap && a[j-gap]>tmp){ a[j]=a[j-gap]; j-=gap; }
a[j]=tmp;
}
}
}
C++
void shell(vector<int>& a){
int n=a.size(), gap=1; while(gap<n/3) gap=gap*3+1;
for(; gap>=1; gap/=3){
for(int i=gap;i<n;i++){
int tmp=a[i], j=i;
while(j>=gap && a[j-gap]>tmp){ a[j]=a[j-gap]; j-=gap; }
a[j]=tmp;
}
}
}
Go
func ShellSort(a []int) {
gap := 1
for gap < len(a)/3 { gap = gap*3 + 1 }
for gap >= 1 {
for i := gap; i < len(a); i++ {
tmp, j := a[i], i
for j >= gap && a[j-gap] > tmp {
a[j] = a[j-gap]
j -= gap
}
a[j] = tmp
}
gap /= 3
}
}
Rust
pub fn shell_sort(a: &mut [i32]) {
let mut gap = 1usize;
while gap < a.len()/3 { gap = gap*3 + 1; }
while gap >= 1 {
for i in gap..a.len() {
let tmp = a[i];
let mut j = i;
while j >= gap && a[j-gap] > tmp {
a[j] = a[j-gap];
j -= gap;
}
a[j] = tmp;
}
if gap == 1 { break; }
gap /= 3;
}
}
JavaScript
function shellSort(a){
let gap=1; while(gap < a.length/3) gap = gap*3 + 1;
while(gap>=1){
for(let i=gap;i<a.length;i++){
const tmp=a[i]; let j=i;
while(j>=gap && a[j-gap]>tmp){ a[j]=a[j-gap]; j-=gap; }
a[j]=tmp;
}
gap=Math.floor(gap/3);
}
return a;
}
常见问题与注意事项
- 稳定性:希尔排序不稳定,若稳定性必需,选择归并/TimSort。
- 增量选择:简单的 n/2 递减实现容易退化,建议至少用 Knuth 或 Sedgewick 序列。
- 大小写:对极小数组直接用插排即可;对超大数组需评估是否改用 O(n log n) 算法。
- 性能测试:不同 gap 在不同数据分布下差异大,务必实测。
最佳实践与建议
- 默认用 Knuth 序列,代码短、性能好;需要理论上界可换 Sedgewick/Pratt。
- 在混合排序中,将“子数组规模阈值”替换为希尔排序,观察是否好于纯插排。
- 为教学准备可视化:展示 gap=4/2/1 的分组插排过程,帮助理解。
- 记录“比较/移动”计数,作为评估不同 gap 序列的指标。
小结 / 结论
- 希尔排序通过分组插排显著降低远距离逆序对,原地但不稳定,性能依赖增量序列。
- Knuth 序列是实践优选;需要稳定或严格上界时改用归并/TimSort/堆排。
- 在工程混合策略中,希尔排序可作为小规模优化层,弥合插排与快排/堆排间的性能差距。
参考与延伸阅读
- D. L. Shell, “A High-Speed Sorting Procedure” (1959)
- Robert Sedgewick, “Analysis of Shellsort and Related Algorithms” (1986)
- CLRS《算法导论》希尔排序讨论
元信息
- 阅读时长:约 15 分钟
- SEO 关键词:希尔排序, Shell Sort, 增量序列, 原地排序, 不稳定排序
- 元描述:排序专题第三篇,深入讲解希尔排序的增量序列、复杂度与工程实践,附多语言实现与选型建议。
行动号召(CTA)
- 用你的真实数据分布,对比 Knuth 与 Sedgewick 序列的耗时差异。
- 在现有快排实现中,把小分段插排改为希尔排序,测量性能变化。
- 关注后续系列:归并、快排、堆排序、非比较排序、TimSort/Introsort 与选型实战。