2.7.希尔排序——让插入排序先大步走,再小步收尾

2.7.希尔排序------让插入排序先大步走,再小步收尾

系列 :搜索与排序 | 第 7 篇,共 16 篇
难度 :⭐⭐☆☆☆ 入门-中等
标签排序 希尔排序 插入排序 增量排序


上一篇2.6.堆排序------从堆结构到 Top-K,一套思路贯穿排序与选择
下一篇2.8.计数排序------不比大小,先统计再还原为什么能线性完成?


前言

插入排序有一个很明显的优点:

  • 代码短
  • 思路直观
  • 对近乎有序的数据特别友好

但它也有一个同样明显的痛点:

元素一次只能往前挪一小步。

如果一个很小的元素偏偏躺在数组末尾,它想回到前面,就得一格一格往前蹭,代价很大。

希尔排序就是为了解决这个问题提出的。

它的思路很朴素:

  • 先别急着只看相邻元素
  • 让数组里"距离很远的元素"先比较、先移动
  • 先做一轮大跨度粗调
  • 再逐步缩小间隔
  • 最后一轮间隔变成 1 时,再用一次普通插入排序收尾

于是它可以理解成:

插入排序的加速版。

这篇重点讲清楚:

  • 为什么"按间隔分组"会更快
  • 步长序列对效果为什么影响这么大
  • 希尔排序为什么不稳定
  • 它到底适合什么场景,不适合什么场景

一、算法思想:先让元素大跨度接近正确位置

希尔排序的核心动作可以拆成两句:

  1. 选一个步长 gap
  2. 对所有"间隔为 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=8gap=4gap=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

解题思路

直接对每组数据套用希尔排序模板即可。本题的考点在于:

  1. gapn / 2 开始,每轮减半,最终收敛到 1
  2. 每个 gap 阶段本质上就是"对若干组间隔为 gap 的子序列做插入排序"
  3. 排序结束后按题目格式输出,注意相邻数之间用空格隔开,末尾不加空格

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):先粗调、再细调,步长一路缩到 1
  • for (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

解题思路

"先排序,再取中间元素"的经典题型,步骤如下:

  1. 读入 n 个整数到数组
  2. 用希尔排序将数组升序排好
  3. 直接输出 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.计数排序------不比大小,先统计再还原为什么能线性完成?


💬 看完有收获的话,点个赞再走~ 有问题欢迎评论区讨论 🙏

相关推荐
小柯博客14 分钟前
STM32MP2安全启动技术深度解析
c语言·c++·stm32·嵌入式硬件·安全·开源·github
cpp_250121 分钟前
P1832 A+B Problem(再升级)
数据结构·c++·算法·动态规划·题解·洛谷·背包dp
꧁细听勿语情꧂44 分钟前
合并两个有序表、判断链表的回文结构、相交链表、环的链表一和二
c语言·开发语言·数据结构·算法
结衣结衣.1 小时前
手把手教你实现文档搜索引擎
linux·c++·搜索引擎·开源·c++11
木井巳1 小时前
【递归算法】解数独
java·算法·leetcode·决策树·深度优先·剪枝
t***5441 小时前
如何在 Dev-C++ 中切换编译器
java·开发语言·c++
大肥羊学校懒羊羊1 小时前
完数与盈数的计算题解
数据结构·c++·算法
澈2071 小时前
构造函数与析构函数完全指南
开发语言·c++
阿Y加油吧1 小时前
算法实战笔记:LeetCode 31 下一个排列 & 287 寻找重复数
笔记·算法·leetcode
穿条秋裤到处跑1 小时前
每日一道leetcode(2026.04.24):距离原点最远的点
算法·leetcode·职场和发展