希尔排序:让插入排序不再怕逆序

希尔排序:让插入排序不再怕逆序

一个算法的短板,通常是下一个算法的问题意识。

为什么需要

上一篇把直接插入排序的三种情况摊开了:最好 O(N)、最坏 O(N²)、平均介于两者之间。它的表现取决于输入数据的逆序程度------数据越接近有序,它就快得越像 O(N)。

那么它的死穴也就很清楚了:一旦数据是逆序的,效率立刻掉下来。

逆序时,每个元素都要从当前位置一路挪到最前面,挪动的步数分别是 n、n−1、n−2......累加就是 O(N²)。而且这些挪动全是"一格一格"走的------一个本该在数组末尾的大数,要跟前面每个数比较一次、后移一次,才能到位。

于是问题变成了:既然插入排序在有秩序的数据上快得惊人,能不能先花点力气,把数据变得"接近有序",再交给它收尾?

希尔排序(Shell Sort)就是这个思路。它是对直接插入排序的优化,做法很简单:先分组、每组内部插入排序,让整个数组接近有序,最后再走一趟完整的插入排序。

一个自然的质疑是:多干了活,凭什么更快?如果预排序的代价和收益差不多,那这个算法就没有意义。这一点留到实测部分用数据回答。

核心机制:分组、跳着走

希尔排序的做法是:把数据按间隔 gap 分成若干组,组内做插入排序。

假设 gap = 3,那么下标 0、3、6、9...... 的元素是一组,1、4、7...... 是第二组,2、5、8...... 是第三组。分组是逻辑上的------不需要真的把这些元素搬到新数组里,只靠循环的步长控制把它们"看成"一组。

text 复制代码
gap = 3 时,逻辑分组:
组1: a[0]  a[3]  a[6]  a[9] ...
组2: a[1]  a[4]  a[7] ...
组3: a[2]  a[5]  a[8] ...

分好组之后,对每一组分别做插入排序 。注意,这里的插入排序和上一篇几乎一样,唯一的区别是:"前一个元素"不是 end - 1,而是 end - gap。

所以整段逻辑可以这样描述:把 a[end + gap] 插进"同组中它前面的那些元素"里,其他元素整体后移 gap 格。

这样做的效果,用一个逆序数组最直观:

  • 一个本来在数组开头的大数,以前每次只能往右挪一格,要挪 n 次;
  • 现在每次跳 gap 格,几步就跳到后面去了。

反过来对小元素同样成立:它本来要一格一格往左蹭,现在几步就跳到前面。所以预排序同时在做两件事------大的更快到后面,小的更快到前面,一举两得。

而 gap 越大,跳跃跨度越大,调整得越猛。这是希尔排序最反直觉的一点:单趟预排序并不追求"排好",它追求的是让数据整体形状变好。

一趟预排序结束后,数组通常并不是有序的 ,只是比之前更接近有序:大的数更靠后,小的数更靠前,逆序对数量大幅减少。然后缩小 gap 再来一趟,再缩小再来一趟......直到 gap == 1。

gap == 1 时,"间隔为 1 的元素为一组"------所有元素都在同一组里,这时的插入排序就是完整的直接插入排序。也就是说,希尔排序的最后一趟并不是"另外调一次 InsertSort",而是循环自然走到的那一步。

由此也能看出:gap == 1 时的预排序代码,和上一篇的直接插入排序是同一段代码。上一篇写的那个插入排序,就是希尔排序在 gap 取 1 时的特例。整个算法用一个循环把"预排序"和"最终排序"统一了。

代码演示

第一步:写出"一组"的插入排序

先别管所有组,把一组 排好。和上一篇对比着看,只有 +1 变成了 +gap:

c 复制代码
// 排一组:gap 是该组的步长,end 是组内有序区的末尾
int end = i;
int tmp = a[end + gap];      // 同组中的"后一个"元素

while (end >= 0)
{
    if (tmp < a[end])
    {
        a[end + gap] = a[end];   // 元素整体后移 gap 格
        end -= gap;
    }
    else
    {
        break;
    }
}

a[end + gap] = tmp;          // 位置定了,落位

结构完全没变:先存 tmp、从后往前扫、循环外统一落位。变的只是步长。

第二步:三层循环------一组一组排

有了"排一组"的逻辑,最朴素的扩展是:外面再套一层,枚举每一组。

c 复制代码
// 一组一组排(gap 固定为 3 的示意版本)
void ShellSort_ByGroup(int* a, int n)
{
    int gap = 3;

    for (int j = 0; j < gap; ++j)              // 第 j 组
    {
        for (size_t i = j; i < n - gap; i += gap)   // 组内的有序区末尾
        {
            int end = i;
            int tmp = a[end + gap];

            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }

            a[end + gap] = tmp;
        }
    }
}

j 的作用是"告诉程序现在排第几组":j = 0 排第一组、j = 1 排第二组......一组排完了再排下一组。

这里 i < n - gap 是必须的,不能写成 i < n 。理由和上一篇的越界陷阱是同一类,但更容易踩:这一趟要读的是 a[end + gap],如果 i 走到了 n - gap,end + gap 就等于 n------越界。所以组内循环的末尾必须停在 n - gap - 1,也就是条件写 i < n - gap。

第三步:两层循环------多组并着走

三层循环能跑通,但可以合并成两层:不把一组排完再排下一组,而是几组轮流各推进一步。

c 复制代码
// 多组并着走 + gap 逐趟缩小
void ShellSort(int* a, int n)
{
    int gap = n;

    while (gap > 1)
    {
        // +1 保证最后一趟 gap 一定等于 1
        // gap > 1 时是预排序;gap == 1 时就是直接插入排序
        gap = gap / 3 + 1;

        for (size_t i = 0; i < n - gap; ++i)   // 注意这里是 ++i,不是 += gap
        {
            int end = i;
            int tmp = a[end + gap];

            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }

            a[end + gap] = tmp;
        }
    }
}

改动只有两处:

外层控制 内层步进 效果
三层写法 j < gap 枚举组号 i += gap 一组排完,再排下一组
两层写法 不需要 ++i 几组齐头并进,交替推进

++i 的语义是:处理完 a[i] 这个位置,下一个处理 a[i + 1]------它属于下一组 。于是循环自动在几组之间轮转,而循环边界 i < n - gap 对每一组都成立。

两种写法效率完全一样。 三层循环跑的次数和两层循环一样多,只是组织顺序不同。这一点很重要------它正面推翻了"循环层数多就慢"的直觉。

gap = gap / 3 + 1 是 gap 的缩小策略。/ 3 让跨度迅速收敛,+ 1 保证它最终一定会落到 1 ------如果没有这个 +1,gap 会停在 2、再变成 0,永远等不到 gap == 1 的那一趟最终排序,数组就不会被真正排好。

💡 背景补充:size_t 是无符号类型,i < n - gap 中右边的 int 会转换成无符号再比较。这里 n - gap 恒非负(gap 最大为 n),所以是安全的;但如果循环边界表达式可能为负,无符号比较会把负数变成极大的正数,导致循环失控------这是 C 语言里典型的隐性陷阱。

复杂度:O(N^1.3) 是怎么估出来的

希尔排序的精确复杂度很难算,多数教材也只给结论。但可以粗略估一遍,理解"为什么它比 O(N²) 好"。

先忽略 +1,取 gap ≈ n/3:

趟次 gap 组数 每组元素个数 单组最坏移动次数 本趟总代价
第 1 趟 n/3 n/3 组 3 1 + 2 = 3 (n/3) × 3 = n
第 2 趟 n/9 n/9 组 9 1+2+...+8 = 36 (n/9) × 36 = 4n
... ... ... ... ... ...
最后一趟 1 1 组 n 接近有序,代价很小 ≈ n

单组代价怎么来的?以第 2 趟为例:gap 是 n/9 时,每组有 9 个元素,组内的插入排序最坏情况下第二个元素移动 1 次、第三个 2 次......第 9 个移动 8 次,即 1+2+...+8 = 36 次。再乘以组数 n/9,得到 4n。

于是每一趟的代价随趟次先增后减,中间那几趟最贵(比如 4n 这一趟),两头(第一趟和最后一趟)都是大约 n 的量级。

把这些趟加总,结论落在 O(N^1.3) 附近。注意这里的关键差别:直接插入排序在逆序时要累加到 1+2+...+n ≈ n²/2,而希尔排序把这份代价摊到了多趟里,每趟都压到 O(n) 量级。

至于为什么"算不精确",有两个原因:

  1. 后一趟的代价取决于前一趟的结果。 第 2 趟还能按"最坏情况"算(因为第 1 趟开始时数据是逆序的,每一组也是逆序的),但从第 3 趟起,数据已经被前几趟改动过,"最坏情况"这个前提就不成立了------必须引入概率分析。
  2. gap 序列的选择本身会影响复杂度。 n/3、n/2、n/2.2 等不同策略得出的结论不同,1.3 是常见取法下的经验值。

所以这个数字记住结论即可,不必试图手推。教材给的 O(N^1.3) 是一个经验结果,不是从某一行代码直接推导出来的。

实测:它到底有没有白干活

回到开头那个质疑:预排序多花了力气,值得吗?看数据。

同一组随机数据,加入希尔排序与插入排序、堆排序同场比较:

数据规模 直接插入排序 希尔排序 堆排序
10 万条 ≈ 700 ms 明显快于插入排序 ≈ 4 ms
100 万条 ≈ 68 s 与堆排序同量级,重复数据多时可略快 毫秒级

结论有三条:

  1. 10 万条时,希尔排序和插入排序已经不是一档。 虽然它多了预排序的开销,但预排序把数据变"有序"之后,最后那趟插入排序的代价被压得很低,总账反而划算。
  2. 100 万条时,希尔排序和堆排序处在同一量级。 这验证了复杂度上的判断:它已经跳出了 O(N²),不再是"O(N²) 家族里比较优秀的那个",而是和 O(N log N) 的算法同桌。
  3. 希尔排序的一个隐藏优势是重复数据。 上面的随机数据是用 rand() 生成的,取值上限有限,百万条数据里有大量重复值------重复度高时希尔排序表现更好,甚至能略胜堆排序;把重复度降下来,堆排序会重新领先一点点。

另外要说明一点:数据量小的时候,希尔排序没有优势。十来个元素时,插入排序本来就快,再用多趟分组去包装它,多出来的循环开销纯属浪费。希尔排序的价值要到数据量上去之后才体现出来。

常见误区

把组内循环的边界写成 i < n。 这是本篇最容易踩的坑,和上一篇的 i < n - 1 属于同一类错误,但原因更绕:要访问的是 a[end + gap],end 本身没越界,end + gap 却越界了。判断越界要看真正被访问的下标,不是循环变量。

数循环层数来估复杂度。 三层写法和两层写法执行次数完全相同,效率没有任何差别。循环层数只说明代码怎么组织,不说明执行了多少次------这一点在上一篇已经埋过伏笔了。

以为希尔排序是"预排序 + 最后再调一次 InsertSort"。 实际上它只有一个循环,gap 缩小到 1 时那趟循环本身就是完整的插入排序。反过来也成立:把 gap 固定为 1,这段代码就是直接插入排序。

认为预排序之后数组就应该有序了。 预排序的目标是"更接近有序",不是"有序"。gap 取得巧时有可能恰好排好,但那是偶然,不是设计目标。

附:选择排序------一趟选两个数,与它踩的那个坑

同一节课还收掉了选择排序(Selection Sort)。它在分类上属于"选择"这一路,和堆排序同一大类------都是"选出最大/最小,放到该在的位置"。

区别在于:堆排序用堆来选(O(log n) 找出极值),选择排序用暴力遍历来选(O(n) 找出极值)。基础版本每趟遍历一遍、选出最小的放到最左边,再遍历一遍选次小的,如此往复。

一个常见优化是一趟选出两个数 :同时维护最小值和最大值的下标,最小的换到左端、最大的换到右端,然后 begin++、end--,两头往中间夹。

c 复制代码
void SelectSort(int* a, int n)
{
    int begin = 0, end = n - 1;

    while (begin < end)
    {
        int mini = begin, maxi = begin;   // 存的是下标,不是值

        for (int i = begin + 1; i <= end; ++i)
        {
            if (a[i] > a[maxi])
            {
                maxi = i;
            }
            if (a[i] < a[mini])
            {
                mini = i;
            }
        }

        Swap(&a[begin], &a[mini]);
        Swap(&a[end], &a[maxi]);

        ++begin;
        --end;
    }
}

这段代码有一个 bug,而且平时测不出来。

问题出在两次 Swap 的顺序上。如果 maxi 恰好等于 begin------也就是最大值正好落在左端------那么第一次交换(swap(a[begin], a[mini]))会把最大值搬到 mini 的位置去。此时 maxi 这个下标还停在 begin,但那个位置上的值已经变了;第二次 Swap(&a[end], &a[maxi]) 交换的就不是最大值了。

修复只需要在两处交换之间插一句:

c 复制代码
Swap(&a[begin], &a[mini]);

// 最大值原本在 begin 上,第一次交换已经把它挪到了 mini 的位置
if (maxi == begin)
{
    maxi = mini;
}

Swap(&a[end], &a[maxi]);

这个 bug 的教训不在选择排序本身,而在调试方法 :出问题时不要拿二十多个元素的数组去硬调,应当把数组缩小到能画出图的程度 ,逐趟标出 begin、end、mini、maxi 的位置,让下标重叠的那一刻显形。用大数组调试,等于把问题藏在噪声里。

至于选择排序的整体评价:

  • 最好情况也是 O(N²)。 即使数组已经完全有序,它也不知道,仍然要一趟趟遍历过去把极值"选"出来。加了 flag 的冒泡在有序输入上是 O(N),选择排序没有这种优化空间。
  • 实测 5 万条随机数据,插入排序在这个小规模组里表现最好,选择排序垫底,比冒泡还差一些。
  • 所以它的价值主要在教学:逻辑直观,适合理解"选择"这一类排序的思想;在工程实践里几乎没有位置。

📖 参考:《数据结构知识库》第六节把冒泡/插入/选择排序统一列为 O(n²),"最好情况也是 O(n²)"这一点是选择排序区别于另外两者的地方;第四节的堆一节给出了堆排序为什么能把这个"选择"过程加速到 O(n log n)。

本节要点

  • 希尔排序的问题意识来自插入排序的死穴:怕逆序 。预排序的目标是让数组接近有序,而不是排好。
  • 做法:按间隔 gap 把数据逻辑分组(不开新数组),每组内部做插入排序,然后缩小 gap 反复做。
  • 组内插入排序与直接插入排序只差一件事:步长是 gap 而不是 1------a[end - gap]、a[end + gap]。
  • gap 越大跳得越快;预排序同时把大的数往右推、小的数往左推。
  • gap == 1 时,这段代码就是上一篇的直接插入排序。所以希尔排序不是"预排序 + 最后调一次 InsertSort",而是一个循环自然走完。
  • 三层写法(一组组排,i += gap)与两层写法(多组并着走,++i)执行次数完全相同,效率没有任何差别。循环层数不代表复杂度。
  • 组内循环边界是 i < n - gap 而非 i < n:被访问的下标是 end + gap,end 不越界不代表它不越界。
  • gap = gap / 3 + 1 里的 +1 不可省------它保证 gap 最终落到 1,否则会停在 2 再变 0,最后一趟最终排序永远不会发生。
  • 复杂度结论记 O(N^1.3) 即可。粗估思路:gap ≈ n/3 时每组 3 个 → 本趟约 n;gap ≈ n/9 时每组 9 个 → 1+2+...+8 = 36,×(n/9) ≈ 4n;各趟代价先增后减,累加落到 1.3 次方量级。
  • 精确值难算的原因:后一趟的代价取决于前一趟的结果,从第 3 趟起"最坏情况"前提失效,需要概率分析;且不同 gap 序列结论不同。
  • 数据量小时希尔排序没有优势,它的价值在大规模数据上;重复数据多时表现更好。
  • 选择排序优选版一趟选两个数,bug 点在 maxi == begin :第一次交换会把最大值挪走,必须先修正 maxi。调试应缩小数组并画图,而不是硬看大数组。
  • 选择排序最好情况也是 O(N²),因为它无法感知"已经有序";这是它比冒泡还差的地方。

相关推荐
Logic1011 小时前
C语言/数据结构位运算题解:异或XOR找出星球探险中的“独特能量石“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
yi0112 小时前
DAY17: LeetCode 139|单词拆分:从“把单词删掉”到用 DP 记录合法切口
人工智能·笔记·python·算法·leetcode·动态规划
陈希瑞2 小时前
LongCat-2.5-Preview 的web逆向能力实测
人工智能·算法·wasm
tangguofeng3 小时前
基于分治策略的循环赛日程表问题的思路讲解
算法
hnjzsyjyj4 小时前
洛谷 P1339:[USACO09OCT] Heat Wave G ← Bellman-Ford 算法
数据结构·bellman-ford 算法
起个名字费劲死了4 小时前
VisionMaster集成深度学习算法(基础狗)
人工智能·深度学习·算法
lengxuenong4 小时前
枚举例题分析
c++·算法·青少年编程
Navigator_Z4 小时前
LeetCode //C - 1269. Number of Ways to Stay in the Same Place After Some Steps
c语言·算法·leetcode
2601_965742224 小时前
短视频脚本创作方法分享:本地生活类账号的起步思路
大数据·人工智能·算法·ai·新媒体运营·生活