2.7.希尔排序------让插入排序先大步走,再小步收尾
系列 :搜索与排序 | 第 7 篇,共 16 篇
难度 :⭐⭐☆☆☆ 入门-中等
标签 :排序希尔排序插入排序增量排序
上一篇 :2.6.堆排序------从堆结构到 Top-K,一套思路贯穿排序与选择
下一篇 :2.8.计数排序------不比大小,先统计再还原为什么能线性完成?
前言
插入排序有一个很明显的优点:
- 代码短
- 思路直观
- 对近乎有序的数据特别友好
但它也有一个同样明显的痛点:
元素一次只能往前挪一小步。
如果一个很小的元素偏偏躺在数组末尾,它想回到前面,就得一格一格往前蹭,代价很大。
希尔排序就是为了解决这个问题提出的。
它的思路很朴素:
- 先别急着只看相邻元素
- 让数组里"距离很远的元素"先比较、先移动
- 先做一轮大跨度粗调
- 再逐步缩小间隔
- 最后一轮间隔变成
1时,再用一次普通插入排序收尾
于是它可以理解成:
插入排序的加速版。
这篇重点讲清楚:
- 为什么"按间隔分组"会更快
- 步长序列对效果为什么影响这么大
- 希尔排序为什么不稳定
- 它到底适合什么场景,不适合什么场景
一、算法思想:先让元素大跨度接近正确位置
希尔排序的核心动作可以拆成两句:
- 选一个步长
gap - 对所有"间隔为
gap的元素序列"分别做插入排序
比如当 gap = 4 时,数组会被拆成下面这些"虚拟子序列":
- 下标
0, 4, 8, ... - 下标
1, 5, 9, ... - 下标
2, 6, 10, ... - 下标
3, 7, 11, ...
这些序列各自做插入排序后,整个数组虽然还没完全有序,但会变得:
- 更接近有序
- 大元素不再都挤在前面
- 小元素不再都堆在后面
然后继续缩小步长:
text
gap = 4 -> 2 -> 1
最后当 gap = 1 时,其实就是普通插入排序。
但这时数组已经被前面几轮"预处理"得比较整齐,所以最后一轮通常会快很多。
📌 核心不变量 :每完成某个
gap的一轮处理后,数组会变成"gap-有序";随着gap不断缩小,数组逐步逼近完全有序。
二、完整图解过程
以数组 [8, 5, 3, 7, 1, 2, 6, 4] 为例,取步长序列:
text
[4, 2, 1]
第 1 轮:gap = 4
把数组按下标模 4 分成 4 组:
text
原数组: [8, 5, 3, 7, 1, 2, 6, 4]
组 1:下标 0, 4 -> [8, 1] -> 排后 [1, 8]
组 2:下标 1, 5 -> [5, 2] -> 排后 [2, 5]
组 3:下标 2, 6 -> [3, 6] -> 排后 [3, 6]
组 4:下标 3, 7 -> [7, 4] -> 排后 [4, 7]
写回原数组后:
text
[1, 2, 3, 4, 8, 5, 6, 7]
可以看到:
- 大体顺序已经变得顺眼很多
- 但还没有完全有序
第 2 轮:gap = 2
现在按间隔 2 再分组:
text
下标 0, 2, 4, 6 -> [1, 3, 8, 6] -> [1, 3, 6, 8]
下标 1, 3, 5, 7 -> [2, 4, 5, 7] -> [2, 4, 5, 7]
写回后:
text
[1, 2, 3, 4, 6, 5, 8, 7]
此时数组已经非常接近有序。
第 3 轮:gap = 1
这其实就是一次普通插入排序:
text
[1, 2, 3, 4, 6, 5, 8, 7] -> [1, 2, 3, 4, 5, 6, 7, 8]
排序完成。✅
整体过程汇总
| 步长 | 处理结果 | 说明 |
|---|---|---|
4 |
[1, 2, 3, 4, 8, 5, 6, 7] |
先把远距离错位拉近 |
2 |
[1, 2, 3, 4, 6, 5, 8, 7] |
继续细化顺序 |
1 |
[1, 2, 3, 4, 5, 6, 7, 8] |
最后用插入排序收尾 |
三、代码实现
Python 版本(Shell 原始步长)
python
def shell_sort(nums):
n = len(nums)
gap = n // 2
while gap > 0:
for i in range(gap, n):
temp = nums[i]
j = i
while j >= gap and nums[j - gap] > temp:
nums[j] = nums[j - gap]
j -= gap
nums[j] = temp
gap //= 2
return nums
nums = [8, 5, 3, 7, 1, 2, 6, 4]
print(shell_sort(nums)) # [1, 2, 3, 4, 5, 6, 7, 8]
这个版本最适合理解希尔排序的基本结构。
C++ 版本
cpp
#include <iostream>
#include <vector>
using namespace std;
void shellSort(vector<int>& nums) {
int n = (int)nums.size();
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = nums[i];
int j = i;
while (j >= gap && nums[j - gap] > temp) {
nums[j] = nums[j - gap];
j -= gap;
}
nums[j] = temp;
}
}
}
int main() {
vector<int> nums = {8, 5, 3, 7, 1, 2, 6, 4};
shellSort(nums);
for (int x : nums) {
cout << x << " ";
}
return 0;
}
四、复杂度分析
希尔排序的复杂度不像前面的归并、堆、快排那样"特别整齐",它高度依赖:
- 你选的步长序列
1)时间复杂度
| 情况 | 时间复杂度 | 说明 |
|---|---|---|
| 最好情况 | 接近 O(n log n) |
使用优秀步长序列时表现不错 |
| 平均情况 | 介于 O(n log n) 与 O(n²) 之间 |
取决于步长设计与数据分布 |
| 最坏情况 | 常见写法下可能到 O(n²) |
原始 Shell 步长理论不够漂亮 |
2)空间复杂度
| 指标 | 值 | 原因 |
|---|---|---|
| 空间复杂度 | O(1) |
原地排序 |
| 稳定性 | ❌ 不稳定 | 跨间隔移动会打乱相等元素顺序 |
所以希尔排序的特点可以概括为:
- 比直接插入排序更快
- 但复杂度不如归并 / 快排 / 堆排那么"标准"
五、为什么它通常比插入排序更快?
插入排序的问题在于:
- 元素只能一步一步往前挪
希尔排序的改进在于:
- 借助大步长,先让元素跨很多格移动
比如一个本该在数组前部的小元素,如果现在躺在末尾:
- 插入排序可能要挪很多次
- 希尔排序可能在
gap=8、gap=4、gap=2这些阶段就已经把它提前拉回来了
所以最后真正做 gap=1 的插入排序时,数组已经相当接近有序。
这就是它加速的本质:
先粗调,再精调。
六、步长序列:为什么它几乎决定了成败?
希尔排序最讲究的不是代码本体,而是:
gap怎么选
常见步长序列如下:
| 步长序列 | 形式 | 特点 |
|---|---|---|
| Shell 原始序列 | n/2, n/4, ..., 1 |
最容易写,但理论最弱 |
| Hibbard | 1, 3, 7, 15, ... |
理论比原始序列更好 |
| Sedgewick | 多项式构造 | 实测表现常常更优秀 |
| Tokuda | 经验型序列 | 工程上也很常见 |
七、与插入排序、快排对比
| 对比项 | 希尔排序 | 插入排序 | 快速排序 |
|---|---|---|---|
| 核心思想 | 按间隔分组做插入排序 | 维护前缀有序 | 分区后递归 |
| 平均表现 | 明显优于插入排序 | 小数组优秀 | 大数组通常更强 |
| 最坏复杂度 | 可能到 O(n²) |
O(n²) |
O(n²) |
| 空间复杂度 | O(1) |
O(1) |
O(log n) |
| 稳定性 | 不稳定 | 稳定 | 不稳定 |
| 典型优势 | 简单、原地、比插入快 | 近乎有序时很强 | 工程平均性能优秀 |
一句话理解:
- 插入排序:朴素、稳定、小数据很顺手
- 希尔排序:想在保持简单和原地的前提下,让插入排序跑快一些
- 快速排序:通用场景下通常更强,但实现与防退化要求更高
八、OJ 例题讲解
例题 1:HDU 1040 --- As Easy As A+B(多组排序输出)
题目来源 :HDU OJ,题号 1040 难度:⭐☆☆☆☆ 入门
题目链接 :http://acm.hdu.edu.cn/showproblem.php?pid=1040
题目描述:
多组输入,每组第一行给出整数
n,第二行给出n个整数,要求将这n个整数从小到大排序后输出,相邻整数间用空格分隔。
输入样例:
2
3
3 1 2
4
4 3 2 1
输出样例:
1 2 3
1 2 3 4
解题思路:
直接对每组数据套用希尔排序模板即可。本题的考点在于:
gap从n / 2开始,每轮减半,最终收敛到1- 每个
gap阶段本质上就是"对若干组间隔为gap的子序列做插入排序" - 排序结束后按题目格式输出,注意相邻数之间用空格隔开,末尾不加空格
C++ 解法:
cpp
#include <bits/stdc++.h>
using namespace std;
void shellSort(vector<int>& nums) {
int n = (int)nums.size();
// gap 逐步缩小,最后一定要走到 1
for (int gap = n / 2; gap > 0; gap /= 2) {
// 对每个位置做"按 gap 分组"的插入排序
for (int i = gap; i < n; i++) {
int temp = nums[i];
int j = i;
// 和普通插入排序不同,这里每次回退 gap 格
while (j >= gap && nums[j - gap] > temp) {
nums[j] = nums[j - gap];
j -= gap;
}
nums[j] = temp;
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n;
cin >> n;
vector<int> nums(n);
for (int i = 0; i < n; i++) cin >> nums[i];
shellSort(nums);
for (int i = 0; i < n; i++) {
if (i) cout << ' ';
cout << nums[i];
}
cout << '\n';
}
return 0;
}
代码讲解:
for (int gap = n / 2; gap > 0; gap /= 2):先粗调、再细调,步长一路缩到1for (int i = gap; i < n; i++):从当前分组里第二个可插入位置开始处理while (j >= gap && nums[j - gap] > temp):不断把更大的元素往后挪,为temp腾位置- 当
gap = 1时这一轮就是普通插入排序,但此时数组已"差不多有序",速度很快
例题 2:POJ 2388 --- Who's in the Middle(排序后取中位数)
题目来源 :POJ / PKU OJ,题号 2388 难度:⭐☆☆☆☆ 入门
题目链接 :http://poj.org/problem?id=2388
题目描述:
给定奇数个整数,找出中位数(排序后正中间那个数)并输出。
输入样例:
5
2
4
1
3
5
输出样例:
3
解题思路:
"先排序,再取中间元素"的经典题型,步骤如下:
- 读入
n个整数到数组 - 用希尔排序将数组升序排好
- 直接输出
nums[n / 2](题目保证n为奇数,中位数唯一)
这题的价值在于说明:希尔排序在中等规模通用排序场景里完全够用,不必每道题都上快排。
C++ 解法:
cpp
#include <bits/stdc++.h>
using namespace std;
void shellSort(vector<int>& nums) {
int n = (int)nums.size();
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = nums[i];
int j = i;
while (j >= gap && nums[j - gap] > temp) {
nums[j] = nums[j - gap];
j -= gap;
}
nums[j] = temp;
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> nums(n);
for (int i = 0; i < n; i++) cin >> nums[i];
shellSort(nums);
// 排完序后,中位数就在正中间
cout << nums[n / 2] << '\n';
return 0;
}
代码讲解:
shellSort(nums)负责把整个数组排成升序cout << nums[n / 2]直接输出排序后的中间元素- 题目不要求稳定性,只要能把中位数找出来,希尔排序完全胜任
九、适用场景
| 场景 | 是否适合 | 原因 |
|---|---|---|
| 中小规模数组排序 | ✅ | 原地、实现简单、通常比插入排序快 |
| 想在简单代码基础上提升插入排序效率 | ✅ | 它就是为这个目的设计的 |
| 极度追求理论上界 | ❌ | 复杂度不够稳定漂亮 |
| 需要稳定排序 | ❌ | 希尔排序不稳定 |
| 教学和算法演化理解 | ✅ | 很适合连接"插入排序 -> 更高级排序" |
十、常见错误总结
| 错误 | 原因 | 正确做法 |
|---|---|---|
| 把间隔插入排序写成普通插入排序 | 忘了比较 j-gap 的位置 |
每次都要按当前 gap 回退 |
最后没有执行到 gap=1 |
数组只变得"部分有序" | 步长序列必须最终收敛到 1 |
| 误以为它稳定 | 跨组移动会打乱相对顺序 | 明确记住它不稳定 |
| 步长缩减太随意 | 可能影响性能甚至实现正确性 | 教学时优先使用标准缩减策略 |
| 盲目拿它挑战超大规模极限排序 | 不如快排/归并/堆排稳 | 根据场景选算法 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 先按大步长分组插入,再逐步缩小步长 |
| 本质来源 | 插入排序的加速版 |
| 空间复杂度 | O(1) |
| 稳定性 | ❌ 不稳定 |
| 关键优势 | 原地、简单、比直接插入更高效 |
| 主要短板 | 理论复杂度依赖步长序列,主流度不如 O(n log n) 排序 |
一句话记住它:
希尔排序不是一下子把数组排好,而是让元素先"大步归位",最后再"小步收尾"。
上一篇 :2.6.堆排序------从堆结构到 Top-K,一套思路贯穿排序与选择
下一篇 :2.8.计数排序------不比大小,先统计再还原为什么能线性完成?
💬 看完有收获的话,点个赞再走~ 有问题欢迎评论区讨论 🙏