C++深入浅出讲解希尔排序算法的实现

前言

希尔排序(Shell sort)是插入排序的改进版,由 Donald Shell 在 1959 年提出。它的思路很朴素:插入排序在数组「基本有序」时非常快,那能不能先做一些粗粒度的排序,让数组变得基本有序,最后再做一次完整的插入排序?于是它引入「增量」(gap),把相隔 gap 的元素看成一组,对每组做插入排序;不断缩小 gap,直到 gap == 1,做最后一次普通插入排序。

围绕希尔排序的常见误解有两个。第一,以为它的复杂度是固定的 O(n log n);实际上希尔排序的时间复杂度取决于增量序列,不同的序列有不同的理论界,最坏情况可能到 O(n²)。第二,以为它是稳定排序;由于相隔较远的元素会被交换,相等元素的相对次序可能改变,所以它不稳定。

本文讲清增量序列的作用、Knuth 序列的实现、复杂度的来源,以及它和插入排序、归并排序的对比。代码以 C++17 为基准。

一、从插入排序说起

普通插入排序每次把当前元素插入到前面已排序部分的正确位置。对随机数组,平均需要 O(n²) 次比较和移动;但如果数组已经基本有序,每个元素只需移动很少的位置,复杂度接近 O(n)。

cpp 复制代码
// 适用于 C++17

void insertionSort(std::vector<int>& a) {

    for (std::size_t i = 1; i < a.size(); ++i) {

        int key = a[i];

        std::size_t j = i;

        while (j > 0 && a[j - 1] > key) {

            a[j] = a[j - 1];

            --j;

        }

        a[j] = key;

    }

}

问题在于,如果一个很小的元素在数组末尾,它需要一步一步往前挪 n-1 次。希尔排序通过「大步长」的预排序,让远处的元素先大致归位,从而减少最后一次插入排序的移动量。

二、增量序列

设当前增量为 gap,把下标相差 gap 的元素分为一组:a[i], a[i+gap], a[i+2gap], ...。对每一组做插入排序。然后缩小 gap,重复,直到 gap == 1。

以 gap = 4 为例,数组被分成 4 组(下标模 4 同余),每组内部排序;gap 缩小到 1 时,整个数组再做一次插入排序。由于前面的步骤已经让数组「大致有序」,最后一步的移动量通常比直接插入排序小。

增量序列的选择直接影响性能,常见的序列有:

序列 生成方式 最坏时间复杂度(教材结论)

|------------|--------------------|-------|
| Shell 原始序列 | n/2, n/4, ..., 1 | O(n²) |

|---------|-----------|-------------|
| Hibbard | 2^k - 1 | O(n^{3/2}) |

|-------|------------------------------------|-------------|
| Knuth | 1, 4, 13, 40, ...,即 h = 3h + 1 | O(n^{3/2}) |

|-----------|--------------------------|-------------|
| Sedgewick | 1, 5, 19, 41, 109, ... | O(n^{4/3}) |

这些界都是在特定假设下证明的,实际运行时间还受数据分布、缓存、编译器优化的影响。不要把它们当成精确的性能承诺 ;如果需要在某个数据集上比较,应该用 std::chrono::steady_clock 自己测量,并说明测量环境和数据规模。

一个共同的要求是:最后一个增量必须是 1。否则数组只是「按某个步长分组有序」,整体并未排好序。这是实现希尔排序最容易漏掉的一点。

三、Knuth 序列的实现

先生成不超过数组长度的最大增量,再不断除以 3:

cpp 复制代码
// 适用于 C++17

#include <cstddef>

#include <vector>



void shellSort(std::vector<int>& a) {

    const int n = static_cast<int>(a.size());

    if (n < 2) return;



    int gap = 1;

    while (gap < n / 3) {

        gap = 3 * gap + 1;

    }



    for (; gap > 0; gap /= 3) {

        for (int i = gap; i < n; ++i) {

            int key = a[i];

            int j = i;

            while (j >= gap && a[j - gap] > key) {

                a[j] = a[j - gap];

                j -= gap;

            }

            a[j] = key;

        }

    }

}

逐处核对:

  • gap < n / 3 而不是 gap < n,是为了让最大增量落在 n/3 附近。Knuth 序列是 1, 4, 13, 40, 121, ...,对 n = 10,n/3 = 3,循环从 gap = 1 开始,1 < 3 成立,变成 4,4 < 3 不成立,于是从 4 开始,再 1。对 n = 100,得到 40,再 13、4、1。
  • j >= gap 的判断必须在 a[j - gap] 之前,否则 j - gap 会变成负数。j 是 int,不是无符号类型,这一点很重要;如果用 std::size_t,j -= gap 在 j < gap 时会回绕成很大的数,导致越界。
  • 内层是标准的插入排序,只是相邻元素的距离从 1 变成了 gap。
  • gap /= 3 最终会到 1,然后下一次变成 0,循环结束。因为 gap 是正整数,1 / 3 == 0,不会死循环。

手工推演一个小数组 [5, 2, 9, 1, 6, 3],n = 6,n/3 = 2,初始 gap = 1 < 2,变成 4。

  • gap = 4:i = 4,key = 6,比较 a[0] = 5 > 6 不成立,位置不变;i = 5,key = 3,比较 a[1] = 2 > 3 不成立,位置不变。数组仍是 [5,2,9,1,6,3]。
  • gap = 1:普通插入排序,最终得到 [1,2,3,5,6,9]。

这个例子里 gap = 4 没起作用,但它说明了流程:大增量负责粗调,小增量负责收尾。

四、复杂度与稳定性

希尔排序的时间复杂度分析很复杂,因为它依赖增量序列,而且每一轮的操作次数与上一轮的排列有关。教材上常给出的结论是:

  • Shell 原始序列(每次减半):最坏 O(n²)。
  • Hibbard 序列(2^k - 1):最坏 O(n^{3/2})。
  • Knuth 序列(3h + 1):最坏 O(n^{3/2})。
  • Sedgewick 序列:最坏 O(n^{4/3})。

空间复杂度是 O(1),因为所有操作都在原数组上进行,只需要常数个临时变量。

稳定性方面,希尔排序是不稳定 的。跨增量的交换会把相等元素分到不同的组,组内排序后再合并,原来的相对次序可能已经被破坏。如果业务要求稳定,应该用 std::stable_sort 或归并排序。

算法 平均时间 最坏时间 空间 稳定

|------|-------|-------|------|---|
| 插入排序 | O(n²) | O(n²) | O(1) | 是 |

|------|---------|---------|------|---|
| 希尔排序 | 取决于增量序列 | 取决于增量序列 | O(1) | 否 |

|------|------------|------------|------|---|
| 归并排序 | O(n log n) | O(n log n) | O(n) | 是 |

|------|------------|-------|------------|---|
| 快速排序 | O(n log n) | O(n²) | O(log n) 栈 | 否 |

|-------------|---------------|------------|------------|---|
| std::sort | 通常 O(n log n) | O(n log n) | O(log n) 栈 | 否 |

工程中,如果只是需要一个通用排序,直接用 std::sort;希尔排序的价值在于实现简单、原地、不需要额外空间,适合嵌入式或教学场景。它的实际性能与增量序列、数据规模密切相关,不能简单地用「比插入排序快多少倍」来描述。

五、完整示例

cpp 复制代码
// 适用于 C++17

#include <cstddef>

#include <iostream>

#include <vector>



void shellSort(std::vector<int>& a) {

    const int n = static_cast<int>(a.size());

    if (n < 2) return;



    int gap = 1;

    while (gap < n / 3) {

        gap = 3 * gap + 1;

    }



    for (; gap > 0; gap /= 3) {

        for (int i = gap; i < n; ++i) {

            int key = a[i];

            int j = i;

            while (j >= gap && a[j - gap] > key) {

                a[j] = a[j - gap];

                j -= gap;

            }

            a[j] = key;

        }

    }

}



void print(const std::vector<int>& a) {

    for (int x : a) std::cout << x << ' ';

    std::cout << '\n';

}



int main() {

    std::vector<int> a{5, 2, 9, 1, 6, 3};

    shellSort(a);

    print(a);        // 按标准语义应为 1 2 3 5 6 9



    std::vector<int> b{1};

    shellSort(b);

    print(b);        // 1



    std::vector<int> c;

    shellSort(c);

    print(c);        // 空行

    return 0;

}

n < 2 的提前返回覆盖了空数组和单元素数组。如果不加这个判断,n = 1 时 n / 3 = 0,gap = 1 不小于 0,循环不执行,gap 保持 1,然后进入排序循环,i = 1 不满足 i < 1,什么也不做,最后 gap /= 3 变成 0,循环结束。逻辑上也能正确结束,但提前返回更清晰。

六、增量序列的生成细节

不同的生成方式会带来不同的边界。以 Hibbard 序列为例:

cpp 复制代码
// 适用于 C++17

int gap = 1;

while (gap < n / 2) {

    gap = 2 * gap + 1;   // 1, 3, 7, 15, ...

}

这个序列的项是 2^k - 1。生成时要注意 gap 的类型和溢出:如果 n 接近 INT_MAX,2 * gap + 1 可能溢出,属于有符号整数溢出,是未定义行为(undefined behavior, UB)。实际数组长度远小于这个量级,但写通用代码时仍应留意。

Sedgewick 序列的生成更复杂,通常直接查表或者用递推式,本文不展开。选择序列的经验是:不要用简单的 n/2 递减(最坏 O(n²)),Knuth 或 Hibbard 已经能带来明显改善,而且生成简单。

常见坑点

1. 最后一个增量不是 1

❌ for (; gap > 1; gap /= 2) ------ 循环结束时没有做 gap = 1 的完整插入排序。

✅ 条件写成 gap > 0,保证 gap == 1 那轮一定执行。

2. 内层下标用无符号类型

❌ std::size_t j = i; while (j >= gap && ...) { j -= gap; },当 j < gap 时 j -= gap 回绕成极大值。

✅ 用 int 或 std::ptrdiff_t,并先判断 j >= gap。

3. 增量序列生成后超过数组长度

❌ while (gap < n) gap = 3 * gap + 1;,得到的 gap >= n,第一轮 i = gap 直接不执行,白跑一轮。

✅ 用 gap < n / 3 控制上界,让最大增量不超过数组长度。

4. 认为希尔排序稳定

❌ 在需要保持相等元素原有顺序的场景使用希尔排序。

✅ 用 std::stable_sort 或归并排序;希尔排序的跨组交换会破坏稳定性。

5. 给出没有来源的性能倍数

❌ 「希尔排序比插入排序快 5 倍」。

✅ 说明复杂度依赖增量序列,给出测量方法(std::chrono::steady_clock、固定数据规模、多次取中位数),不写未测过的数字。

6. 对链表使用数组版希尔排序

❌ 把 a[j - gap] 的写法直接套到链表上。

✅ 希尔排序依赖随机访问;链表适合归并排序,或先把链表转成数组。

7. 比较器方向写反

❌ 想升序却写 a[j - gap] < key,结果降序。

✅ 升序用 a[j - gap] > key,把较大的元素往后挪。

8. 忘记处理空数组

❌ 直接进入循环,某些实现会在 a[0] 上出错。

✅ 开头判断 n < 2 直接返回。

总结

要点 结论

|------|-------------------|
| 核心思想 | 按增量分组做插入排序,逐步缩小增量 |

|------|-------|
| 最后增量 | 必须是 1 |

|------|------------------------------|
| 常用序列 | Knuth 3h+1、Hibbard 2^k-1 |

|-------|-----------------------------------|
| 时间复杂度 | 取决于序列,Knuth/Hibbard 为 O(n^{3/2}) |

|----|------|
| 空间 | O(1) |

|-----|-----|
| 稳定性 | 不稳定 |

|------|---------------|
| 适用场景 | 原地、实现简单、数据量中等 |

希尔排序是理解「增量」思想的好例子:它没有归并排序的分治,也没有快速排序的划分,只是把插入排序的比较距离拉大,就让数组更快地变得「基本有序」。实现时记住三件事------增量序列要以 1 收尾、下标不要用无符号类型、它不稳定------代码就不会出大问题。

相关推荐
starzy19901 小时前
Scala Array操作:从定长数组到ArrayBuffer的完整指南
开发语言·后端·scala
读书札记20221 小时前
Qt UTF-8和UTF-8(BOM)
开发语言·qt
ShineWinsu1 小时前
对于Qt:QProgressBar常见属性的解析
开发语言·c++·qt·进度条·gui·定时器·qprogressbar
fanruitian1 小时前
python 机器学习 交叉验证train_test_split 网格搜索GridSearchCV
开发语言·python·机器学习
AlanAshley1 小时前
无 SIM 卡也能打 110?手机紧急呼叫能力的软硬件技术探究
开发语言·智能手机·php
ShineWinsu1 小时前
对于Qt:QLCDNumber常见属性以及QTimer的解析
服务器·数据库·c++·qt·gui·qtimer·qlcdnumber
modelmd1 小时前
Go 切片、映射常见使用场景
开发语言·后端·golang
xiaoye-duck2 小时前
《C++ 工具库实战》C++ 解析利器 JsonCpp:从基础使用到封装简易 JSON 工具
c++·json·第三方库
dialect_10 小时前
KMP算法
开发语言·c++·算法