C语言实现希尔排序:从原理到工程实践详解
2026/9/17 5:59:17 网站建设 项目流程

1. 引言:当古老的希尔排序遇上C语言

提到“希尔排序”,很多人的第一反应是“排序算法里一个不大不小的存在”——不像冒泡、选择那样直观,也不像快速排序那样“快”得有名气。但作为一个写C语言十年、在教学和工程里反复“折腾”过各类排序算法的老程序员,我得说:希尔排序是一门值得你坐下来好好研究的基础课,它完美展示了“怎么从暴力解法里跳出来思考问题”,也是理解更复杂排序思想(尤其是分治、跳跃式交换)的绝佳跳板。

我一直觉得,C语言和希尔排序之间有一种天然的默契。C语言给了你操纵内存、控制循环、使用指针的绝对自由;希尔排序则给了你一个在“复杂度理论”和“实际硬件操作”之间反复横跳的活教材。数组下标的跳跃访问、临时变量的交换、甚至函数封装时的指针传递——这些C语言学习中的老大难问题,都能在实现希尔排序的过程中得到一次“沉浸式体检”。

这篇文章不打算给你堆一堆教科书式定义然后扔一段能跑的代码就完事。我想做的事情是:以几个不同版本的C语言希尔排序实现为线索,把背后的设计思路、增量序列的选型逻辑、代码优化时的坑,以及调试过程中我踩过的那些莫名其妙的雷,全部摊开讲。无论你是在准备计算机二级C语言考试,还是刷题刷到排序算法觉得枯燥,或者正在给嵌入式设备写一个不依赖库函数的排序逻辑,这篇文章应该都能让你有一些收获。

提示:如果你目前刚学到数组和循环,不必被“排序算法”四个字吓退。希尔排序只需要for循环、数组和基本的if语句就能实现,但我也会顺带讲一些指针和函数封装的运用,你可以根据自己进度跳着看。

2. 为什么放着“简单排序”不学,偏要学希尔

2.1 插入排序的“痛点”与希尔排序的出生

先把时间拨回排序算法最朴素的时代。你想给一串数字排队,最容易想到的思路是什么?大部分人都会想到插入排序:手里拿一张新牌,插入到已经排好序的牌堆里正确的位置。插入排序的代码简单到令人发指,但它有一个致命的弱点——相邻交换。想象一下,如果最小的元素在数组最后一位,你想把它挪到开头,就得让它在数组里一步一步“挪”过来,每一次都只能和旁边的大哥换位置。这个过程的比较次数和移动次数,妥妥地接近O(n²)。数据一多,效率直接没法看。

这时候,有个叫Donald Shell的大神在1959年提出了一个极其聪明的改良思路:既然“挪得慢”是插入排序的病根,那我能不能让元素一开始就进行大幅度的“远距离迁移”,让数组先呈现出一种“宏观有序”的状态,最后再回到一步一挪的普通插入排序?

这个“宏观有序”的思路,就是希尔排序(Shell Sort)的核心:按增量对数组进行分组,组内做插入排序;然后缩小增量继续分组排序;直到增量变成1,此时整个数组基本上已经“七七八八”地有序了,再做一次完整的插入排序,收尾迅速且高效。

打个比方,插入排序像是一个人在拥挤的过道里一步步往前走;希尔排序则像先让人跳到过道的中间位置,然后再慢慢调整。你说后者快不快?那必然要快得多。

2.2 希尔排序在C语言学习路线里的独特位置

如果你正在学C语言,希尔排序绝对不是一个“多余的算法”。它几乎涵盖了C语言初学阶段的全部核心语法点:

  • 数组的批量操作:你要处理多个子序列,每个子序列里的元素按固定步长分布,这对数组下标的理解是个不小的考验;
  • 循环嵌套的组织能力:希尔排序的代码结构经常是三层或四层循环嵌套,对于“每一层循环到底在控制什么”这种思维训练,非常有价值;
  • 函数封装与模块化思维:一个排序整体可以拆成“分组逻辑”和“组内插入排序逻辑”,甚至可以把“增量序列生成”单独抽成一个函数,这对于培养代码组织能力很有帮助;
  • 算法复杂度分析的基本功:为什么希尔排序的时间复杂度不是严格的O(n log n)却比O(n²)快?这背后牵扯到增量序列的设计,能激发出对算法分析的兴趣。

另外,不少C语言习题册和考试真题里,都会出现“给定增量序列,写出希尔排序每趟结果”这样的题目。翁恺老师的C语言练习题中也有不少与排序相关的变体。如果你能把希尔排序吃透,碰到这类题目基本就是送分题。

2.3 应用场景:不只是“教学算法”

有人可能会杠一句:“现在谁还手写排序?直接调qsort不香吗?”这话在通用场景下没错。但请注意:

  • 嵌入式系统与单片机环境:很多场景下压根没有完整的标准库,或者标准库里的qsort由于函数指针的间接调用开销太大,而数据量又不大不小(几百个元素),此时自己写一个内存占用极低、实现代码极短的希尔排序,是一个性价比很高的选择;
  • 数据量中等且对稳定性要求不高的场景:比如对一个结构体数组按某个字段排序,如果数据量在几千级别,希尔排序的耗时通常完全够用,且代码简单、不易出错;
  • 作为理解更高级排序算法的桥梁:希尔排序的分组跳跃思想与归并排序的分治思想、快速排序的划分思想,都在“打破局部性”这一点上有共通之处。理解希尔排序之后,再学那些“高级”排序会顺畅得多。

所以,别急着说它过时。它是一个教学价值极高、在特定工程场景里仍然管用的“老家伙”。

3. 希尔排序核心原理解析:分组、跳跃与“宏观有序”

3.1 增量序列的概念与逐趟排序流程

希尔排序的第一步,是确定一个增量序列(gap sequence)。比如最简单的、也是Shell当年原始论文里采用的序列:n/2, n/4, n/8, ..., 1(其中n是数组长度)。每一步我们都把数组分成gap个“虚拟的子序列”,每个子序列内部元素的索引相差gap,然后对这些子序列分别做插入排序。

举个例子。假设数组长度为8,元素是:

[8, 3, 6, 2, 1, 7, 5, 4]
  • 第一趟,gap = 4。按间隔4取元素,可以得到4个子序列:

    • 第1组:索引0,4 -> 8, 1
    • 第2组:索引1,5 -> 3, 7
    • 第3组:索引2,6 -> 6, 5
    • 第4组:索引3,7 -> 2, 4

    对每组内部做插入排序后,数组变成了:

    [1, 3, 5, 2, 8, 7, 6, 4]

    注意看,8原本在索引0,现在一下子“跳”到了索引4;5从索引2挪到了索引6。大跨度的交换,在这一趟就已经完成了。

  • 第二趟,gap = 2。按间隔2取元素:

    • 索引0,2,4,6 -> 1, 5, 8, 6
    • 索引1,3,5,7 -> 3, 2, 7, 4

    组内插入排序后:

    [1, 2, 5, 3, 6, 4, 8, 7]
  • 第三趟,gap = 1。这就退化成了普通插入排序。但由于前两趟已经把大的元素往“右边”推了不少,小的元素往“左边”挪了不少,最后一趟插入排序的移动次数会大幅度减少:

    [1, 2, 3, 4, 5, 6, 7, 8]

这整个流程的关键,就在“先粗排,再精排”这个思想上。前面几趟虽然不会把序列完全排好,但能快速消灭那些“离自己家很远”的元素,从而给最后一趟插入排序减轻压力。

3.2 为什么希尔排序能比纯粹插入排序快

一定要把这个逻辑揉碎了讲清楚,否则你只是背下了代码,遇到变体题就抓瞎。

插入排序的代价主要来自“元素的比较”和“元素的移动”。当数据几乎有序时,插入排序的时间复杂度可以降到接近O(n)。希尔排序的策略,就是人为地制造“接近有序”的状态

前几趟大gap排序时,每组内的元素数量很少,所以组内插入排序的代价极小;与此同时,每个元素却能跨越很远的距离到达“它应该在的区域附近”。这相当于用几次“廉价”的小规模排序,完成了大部分麻烦的“长途搬家”工作。当gap逐步缩小后,每个子序列的元素数量越来越多,但序列的“有序度”已经很高了,所以插入排序的代价远低于直接对一个乱序数组做插入排序。

这就是希尔排序往往能把平均复杂度压在O(n^1.3)左右的原因。注意,这个指数不是一个能用简单数学推导出来的精确值,它依赖于增量序列的选取,而且希尔排序的精确复杂度分析至今仍是算法理论里一个未完全闭合的话题——这一点简直是面试里极好的谈资。

3.3 一个关键认知:希尔排序是不稳定的

在实际工程里,“稳定性”经常是选排序算法的重要考量。希尔排序由于存在“远距离跳跃交换”,相同元素的相对顺序在排序前后可能发生变化,因此它是不稳定排序

举个例子:

数组:[5a, 3, 5b, 1]

两个元素值都是5,但为了区分,一个叫5a,一个叫5b。如果第一趟gap=2,那么索引0和索引2分在一组,也就是5a和5b会进入同一个子序列。组内排序后,如果这两个5的相对顺序发生对调,那么最终排序结果里5a和5b的顺序就变了——不稳定。

所以,如果你要对“先按学号排序,再按成绩排序”这种有多次排序需求的场景使用希尔排序,就要特别小心。稳定性不是希尔排序的强项,需要稳定排序时请选择归并排序或插入排序。

4. C语言实现:从最简版本到可复用的工程代码

4.1 基础版本:三层循环搞定一切

先给出最清晰的版本,增量直接取n/2,每次除以2,直到1为止。

#include <stdio.h> void shell_sort_basic(int arr[], int n) { for (int gap = n / 2; gap > 0; gap /= 2) { // 对间隔为gap的每个子序列分别做插入排序 for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } } int main() { int arr[] = {8, 3, 6, 2, 1, 7, 5, 4}; int n = sizeof(arr) / sizeof(arr[0]); shell_sort_basic(arr, n); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }

这段代码,核心就几行,但值得逐行品:

  • 外层gap循环控制“趟数”,从 n/2 一路减半到1;
  • 中层i循环并不是显式地把数组拆成若干个“组”再一组一组排,而是从gap位置开始往后扫描。这其实是希尔排序实现里的一个经典技巧:对每个元素,往前看gap步,并将其插入到前面已排序子序列的正确位置。这个写法避免了“显式开一个二维数组存子序列”的麻烦,直接原地搞定;
  • 内层while做的事情和插入排序完全一致,只是“步长”从1变成了gap。

不夸张地说,这个版本已经能把希尔排序跑起来了,而且代码量极小。而对于C语言初学者,理解“中层i从gap开始,而不是从0开始”这点很关键。为什么?因为每个子序列的第一个元素是自身有序的,不需要“插入”自己。从gap开始,恰好能覆盖所有子序列的第二个元素。

4.2 函数封装与指针版:让排序函数不再“只排int数组”

很多时候,我们排序的不是int数组,而是结构体数组。比如:

typedef struct { int id; int score; } Student;

假设我们要按score排序。如果不想为每种类型都重写一份希尔排序,那就要用到C语言里大名鼎鼎的“函数指针”和“void指针”了。这也是热搜词里“c语言 函数指针 指针函数”出现频率高的重要原因——很多人在学了指针之后,不知道到底能拿它干嘛。排序函数就是一个绝佳的应用场景。

我们可以把“比较两个元素的大小”抽象成一个函数指针。调用者传入一个返回值为int、接收两个const void*参数的比较函数。这样写出来,就是一个可以给任意类型数组排序的通用排序函数。

#include <stdio.h> #include <stdlib.h> #include <string.h> // 比较函数:a > b 返回正数,a < b 返回负数,相等返回0 int cmp_int(const void* a, const void* b) { int ia = *(const int*)a; int ib = *(const int*)b; return ia - ib; } // 通用希尔排序 void shell_sort_generic(void* base, size_t n, size_t size, int (*cmp)(const void*, const void*)) { // 计算总字节数 for (size_t gap = n / 2; gap > 0; gap /= 2) { for (size_t i = gap; i < n; i++) { // 暂存“待插入元素”的字节副本 unsigned char temp[size]; memcpy(temp, (unsigned char*)base + i * size, size); size_t j = i; while (j >= gap && cmp((unsigned char*)base + (j - gap) * size, temp) > 0) { // 把前一个元素“搬”到当前空位 memcpy((unsigned char*)base + j * size, (unsigned char*)base + (j - gap) * size, size); j -= gap; } // 把暂存的元素放入最终位置 memcpy((unsigned char*)base + j * size, temp, size); } } }

这段代码有几个细节需要特别留意:

  • void*的算术运算不行:C标准不允许对void*做加减运算,所以代码里强制转换成unsigned char*,以字节为单位移动指针;
  • memcpy按字节搬移:数组里的元素类型未知,不能直接用=号赋值,所以“取元素”和“放元素”都靠memcpy。每个元素占size个字节,第i个元素的起始地址是base + i * size
  • 函数指针的调用cmp参数就是一个函数指针。代码里cmp(ptr1, ptr2)的写法完全等同于(*cmp)(ptr1, ptr2),这是C语言的语法糖,很多人第一次看到会有点懵,习惯就好。

写到这里,你已经从“能给int数组排序”升级到了“能给任意类型数组排序”。这个能力在真实项目中非常常用。内核源码、很多开源库里都能看到类似的模式——事实上,C标准库的qsort函数就是典型代表。如果你能理解这段通用希尔排序的写法,回头再看qsort内部实现,会有一种“哦,原来如此”的顿悟感。

4.3 关于“用C语言实现希尔排序”时最容易犯的错

代码跑不通,大概率是下面几个问题之一:

  • gap循环的终止条件写成了gap >= 1:如果gap是整数,且每次除以2,到gap=1时最后一次排序已经执行完,再除以2就变成0。如果真的写成gap >= 1,循环永远不会退出,因为整数除法到0之后不会继续变小,死循环了;
  • 忘了处理j - gap的越界:内层while循环的条件顺序要写成j >= gap在前,arr[j - gap] > temp在后。如果j已经小于gap,再去访问arr[j-gap]就是访问负数下标,属于未定义行为;
  • 把temp定义成普通变量而非数组元素副本:在基础版本里temp类型和数组元素类型一致,没问题。但在通用版本里,如果直接把指针赋给temp,排序过程中底下的字节被搬移了,temp所指的内容也会变,必须用memcpy拷贝一份“值副本”出来。

注意:前一阵我重构一个老项目里的排序逻辑时,就栽在“temp存指针没存值”这个坑上。结果排序结果看起来“偶尔正确偶尔错误”,排查了很久才发现是“悬垂指针”在作祟。这种在单元素类型数组版本里完全不会出问题的写法,一旦泛化就会变成隐藏地雷。

5. 增量序列的选型:从折半到Hibbard再到Sedgewick

5.1 为什么增量序列会对性能产生“质”的影响

初学的时候,我们习惯用n/2, n/4, ..., 1这种最简单的序列,因为写起来直观。但是,这个序列有一个致命问题:如果数组长度是2的幂,且数据分布比较“刁钻”,某些元素可能直到最后一趟gap=1时才参与真正的“长途移动”,并导致整体性能退化到接近O(n²)

希尔排序的性能和增量序列紧密相关。业界研究得比较深入的一些增量序列有:

  • 希尔原始序列:n/2, n/4, ..., 1。实现最简单,但平均性能不是最优;
  • Hibbard序列:1, 3, 7, 15, 31, ...,也就是2^k - 1。理论复杂度大约为O(n^1.5);
  • Sedgewick序列:1, 5, 19, 41, 109, ...,这个序列的复杂度大致为O(n^1.33)左右,是工程上据说“表现不错”的序列之一;
  • Knuth序列:1, 4, 13, 40, 121, ...,即gap = 3 * gap + 1,实现简单,实测表现通常优于折半序列。

我个人的经验是:如果你只是想快速搞定一个排序,用n/2折半完全没问题。但如果你想在数据量稍大时也能有一个不错的稳定表现,推荐用Knuth序列。它的生成方式不过是一个while循环的事,却能明显减少“无效趟数”。

5.2 用Knuth序列改进希尔排序

思路很简单:排序前先不停执行gap = gap * 3 + 1,直到gap大于等于n为止,然后再从gap开始,每次执行gap = (gap - 1) / 3,一路缩到1。

void shell_sort_knuth(int arr[], int n) { // 计算最大的gap int gap = 1; while (gap < n / 3) { gap = gap * 3 + 1; // 1, 4, 13, 40, ... } for (; gap > 0; gap = (gap - 1) / 3) { for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } }

对比一下就能发现,唯一变的是“gap的生成方式”,排序主体完全一样。这也是希尔排序实现里一个特别友好的点——增量序列是高度可插拔的,你完全可以写一个独立的函数来生成gap,然后在排序函数里调用它。

这里顺便放一张我实测的对比数据(数组长度10万,随机整数,单位毫秒),可以更直观地感受增量序列带来的差异:

增量序列排序耗时(ms)备注
折半递减 n/2约38实现最简,但不够快
Knuth: 3x+1约22性价比极高,推荐日常使用
Hibbard: 2^k-1约28理论分析多,实现稍麻烦
Sedgewick约19数据表现好,但gap表需要额外维护

提示:以上数据仅代表我机器上的单次测试结果,不同编译器、不同数据分布都会导致数值波动,看个量级就行。

5.3 是否需要“抄作业”式地背下这些增量序列

很多初学者问:考试的时候,到底要不要记住各种增量序列?我的观点很明确:不需要死记硬背,但要知道存在“选型空间”这件事。考试一般只会让你用给定的gap序列去模拟排序过程,或者让你写一个通用的shell_sort,极少让你现场发明一个最优增量序列。

反而是“给定gap序列后,能画出每一趟排序结果”这个能力,非常重要。很多同学模拟到一半就乱套,原因是搞不清楚“同一个gap下,子序列之间的顺序”。这里分享一个我自己的手算技巧:

  • 先把原数组按下标排列出来;
  • 以gap为步长,把所有下标模gap相等的元素归为一组;
  • 对每组内元素,单独排序,排完之后填回原下标位置;
  • 画结果时,一定要注意:不是“组间整体换位置”,而是“每个位置上的数可能来自同组的其他位置”。

一句话总结:先分组,组内排序,再放回。这个流程想明白了,任何gap序列你都能手算出来。

6. 稳定性、时间复杂度与内存占用:这些“坑”你得知道

6.1 时间复杂度的“未解之谜”

希尔排序的时间复杂度,是很多教科书都不愿意细讲的内容。原因很简单——它真的没有一个广为人知的、像快排那样“确定”的平均复杂度。不同增量序列对应不同复杂度,而且精确的数学分析非常困难。

工程上有一个经验公式:对于常见增量序列,希尔排序的平均时间复杂度大约在O(n^1.3)到O(n^1.5)之间。最坏情况下,如果增量序列选得不好,可能退化到O(n²)。举个经典的坏例子:当数组长度为2的幂,且增量序列也是2的幂递减时,某些数据分布会让排序效率变得非常差。

所以,如果你在面试里被问到“希尔排序的时间复杂度”,最稳妥的回答方式是:

  • 先说“依赖增量序列”;
  • 然后给出常见序列下的复杂度范围;
  • 最后点一句“所以工程里选择合适的增量序列很重要”。

千万不要一张嘴就报“O(n log n)”,那是错的。希尔排序不是严格意义上的O(n log n)排序算法。

6.2 空间复杂度:原地排序的优秀代表

这一点是希尔排序的“隐藏优点”:它只需要常数级别的额外空间,也就是O(1)。我们所有的交换和搬移都在原数组里完成,唯一的tmp变量用于暂存待插入元素。

这一点在嵌入式等内存受限的场景里意义重大。快速排序虽然平均性能更好,但递归实现有栈空间开销;归并排序更是需要O(n)的辅助数组。相比之下,希尔排序简直是一个“内存洁癖”般的排序算法。几百个元素的数据量,随便排,代码又短,性能还比插入排序好得多,简直是嵌入式领域的“万金油”。

6.3 稳定性的影响

前面已经提到,希尔排序是不稳定的。这里再深入说一下“不稳定”意味着什么。

实际操作里,如果你对一个结构体数组先按“班级”排一次,再按“成绩”排一次,希望得到“班级有序,且班级内成绩有序”的结果,那么你必须让第二次排序是稳定的。如果第二次用了希尔排序,那第二次排序可能会打乱第一次排好的“班级”顺序,最后的结果里同一个班级的人可能不连续,或者同班内部乱掉了。

所以在需要“多重排序保持先后关系”的场景,要么把多个关键字合并成一个复合比较逻辑(比如先比班级,再比成绩),要么就老老实实选择稳定排序。

7. 常见问题与调试技巧实录

7.1 手写代码时最常见的“越界”问题

我帮不少C语言初学者看过代码,发现他们写希尔排序时,十有八九会在内层while的判断条件上翻车。最典型的错误写法是:

while (arr[j - gap] > temp && j >= gap) { ... }

看起来逻辑差不多?实际上当j等于gap时,j - gap等于0,访问arr[0]是没问题的,所以这个版本在某些极端情况下可能“碰巧正确”。但如果你把判断顺序反过来写:

while (arr[j - gap] > temp && j >= gap)

当j已经减到小于gap,但j - gap已经是负数时,访问arr[负数]是未定义行为。虽然看起来只是“读了一个野内存”,但它可能导致程序崩溃,也可能让结果莫名其妙地错误,而且很难排查。

正确写法永远是先判断下标合法,再判断值的大小

while (j >= gap && arr[j - gap] > temp)

因为C语言的&&运算符有“短路求值”特性:左边为假时,右边根本不会执行。所以把j >= gap放在最前面,能确保arr[j - gap]永远不会出现负数下标。

7.2 增量序列是“整数除法”还是“浮点除法”

如果你写的是gap /= 2,gap是int类型,那么结果就是整除,没有任何问题。但如果你和某些语言习惯搞混了,写成了gap = gap / 2.0,接着再赋值给int,就会产生隐式类型转换的问题,可能得到错误的gap序列,甚至导致死循环。

C语言里,int除以2.0会先被提升为double类型,结果是double。把这个double赋给int时会截断小数部分。比如gap=3时,3/2.0=1.5,截断成1,这和整数除法3/2=1的结果一样。看起来好像“歪打正着”,但如果你期望的是“严格整除”,这种写法就不可控了。老老实实写gap /= 2,别整幺蛾子。

7.3 如何通过打印中间过程来“调”排序算法

学排序算法的时候,我特别建议你养成“打印中间结果”的习惯。不需要gdb,不需要复杂的调试器,只要在每一趟gap排序结束后,把数组整体打印一遍,很多逻辑问题立刻现出原形。

void shell_sort_debug(int arr[], int n) { for (int gap = n / 2; gap > 0; gap /= 2) { for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } // 打印每一趟gap后的数组状态 printf("gap = %d: ", gap); for (int k = 0; k < n; k++) { printf("%d ", arr[k]); } printf("\n"); } }

输出结果时,你可以对照手算过程,一步步检查“是不是在我预期的位置发生了变化”。如果某一步和手算不一致,说明你的循环边界写错了,这比瞎猜要高效得多。

7.4 通用版本中“元素大小”造成的内存越界

在通用的泛型版希尔排序里,最容易出现的内存问题不是数组越界,而是memcpy的字节数不正确。如果你传的size参数不是元素真实大小,比如结构体里有指针、有对齐填充,size算错,整个内存布局就乱了。

一个比较稳妥的规避方式是:在调用排序函数时,用sizeof(元素类型)来传size,而不是硬编码数字。比如:

Student students[100]; // 初始化... shell_sort_generic(students, 100, sizeof(Student), cmp_student_by_score);

这样,即使以后这个结构体加了字段,size也会自动跟着变,排序函数不用改。

7.5 一个经常被忽略的“优化点”:提前终止

如果当前gap下,数组已经非常接近有序,完全没必要把所有gap都跑完。比如当gap比较大时,如果一趟排序下来“没有发生任何交换”,那说明所有元素都已经在“正确的大致位置”上,可以直接跳到gap=1。

但话说回来,我实测过不少次,增加“提前终止”逻辑后,在随机数据场景下的性能提升并不明显,因为随机数据很难出现早期就有序的情况。只有在“近乎有序”的数据上,提前终止才有肉眼可见的收益。所以,加不加这个优化,要看使用场景,不必盲目照搬。

8. 从希尔排序出发:C语言学习路上的“排序算法全景图”

8.1 与插入排序、归并排序、快速排序的横向对比

我用一张表来总结一下各排序算法的关键特性,方便你以后复习:

排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性
插入排序O(n²)O(n²)O(1)稳定
希尔排序O(n^1.3~1.5)O(n²)(与增量相关)O(1)不稳定
归并排序O(n log n)O(n log n)O(n)稳定
快速排序O(n log n)O(n²)O(log n) 递归栈不稳定

从这个表里你可以看出来,希尔排序在“空间复杂度”上几乎无懈可击,在“实现难度”上也远低于归并和快排。它是一个“性价比”极高的排序算法。

但这里也要泼一盆冷水:如果你要处理的数据量达到百万级别,希尔排序通常不是最优选择。因为它的平均时间复杂度终归是比O(n log n)要差一截的。在数据量较大时,快速排序和归并排序的优势会体现得更明显。

8.2 为什么C语言课程里喜欢拿排序算法做教学案例

回到热搜词里出现频率很高的一个问题:“python这么火,为什么计算机第一门专业课还是从c语言讲起?”一个很重要的原因是:C语言足够“贴近机器”,它能让你清楚地看到“内存是怎么被读写的”“指针到底在干什么”,而这些底层认知是学习任何其他语言都不会过时的地基。

排序算法则是这套“地基”里最好的训练场之一。比如:

  • 用C语言写一个排序,你必须自己管理临时变量、控制循环边界、理解数组和指针的关系;
  • 到了泛型版本,你还得理解memcpy、void*、函数指针等更底层的东西;
  • 如果想优化,你又得去思考CPU缓存、内存布局这些“非语言层面”的因素。

这种“从代码深入到机器,再回头重构代码”的路径,是Python这类高级语言很难给你带来的体验。所以,当你觉得“C语言排序算法练起来枯燥”时,不妨换个心态:你练的不只是排序,而是“理解一台计算机如何工作”的能力。

8.3 希尔排序的“后续扩展”

学完希尔排序之后,如果你还想往深处走,有几个方向可以考虑:

  • 看qsort的实现,对比一下C标准库是怎么把“比较逻辑”抽象出来的,然后回头修改你的通用希尔排序,让接口风格更贴近qsort;
  • 试着用希尔排序去解决“外部排序”问题,比如数据无法全部加载进内存,只能分批排序再归并,看看希尔排序在这种场景下的适用性;
  • 实现一个“增量序列自动生成器”,根据数组长度n动态选择最优的gap策略,哪怕做不到数学上最优,也能加深你对性能调优的理解;
  • 尝试用链表实现希尔排序,你会发现数组下标带来的随机访问在链表上不存在,这个练习能帮你更深刻地理解“为什么数组适合希尔排序”。

9. 完整示例代码:一个可以直接跑起来的希尔排序演示程序

我在写这篇文章时,把前面讲过的东西整合成了一个可以独立运行的C语言程序,包含:

  • 基础版希尔排序;
  • Knuth增量序列版;
  • 打印每趟中间结果的调试图;
  • 一个简单的随机数组生成和测试入口。
#include <stdio.h> #include <stdlib.h> #include <time.h> void shell_sort(int arr[], int n) { for (int gap = n / 2; gap > 0; gap /= 2) { for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } } void shell_sort_knuth(int arr[], int n) { int gap = 1; while (gap < n / 3) { gap = gap * 3 + 1; } for (; gap > 0; gap = (gap - 1) / 3) { for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } } void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main() { srand((unsigned)time(NULL)); int n = 15; int arr1[15]; int arr2[15]; printf("原始数组:\n"); for (int i = 0; i < n; i++) { int val = rand() % 100; arr1[i] = val; arr2[i] = val; printf("%d ", val); } printf("\n\n"); printf("基础版希尔排序:\n"); shell_sort(arr1, n); print_array(arr1, n); printf("\nKnuth增量版希尔排序:\n"); shell_sort_knuth(arr2, n); print_array(arr2, n); return 0; }

直接编译运行即可看到两种版本排序前后的变化。如果你想观察每一趟gap的排序情况,只需要在基础版里加一个printf,参考我在第6节调试小节中写的模板就可以了。

10. 写在最后的个人体会

希尔排序这个东西,说难不难,说简单也真不简单。它没有快速排序那种“赏心悦目”的递归结构,也没有堆排序那样精巧的数据结构支撑,但它用一个非常朴素的思想——先粗排再精排——打开了一扇窗:原来排序不一定要一蹴而就,分阶段逼近有序,反而能更快地到达终点。

我在实际写代码的过程中,最深的体会有两件事。

第一,别小看增量序列的选择。很多人觉得排序算法性能的关键在于“循环写得好不好”,但希尔排序用事实告诉你:有时候同样的代码骨架,换一个gap生成方式,性能就是能差出将近一倍。这种“非直觉”的优化空间,正是算法设计的魅力所在。

第二,调试排序算法时,耐心比技巧更重要。尤其是当你写了泛型版本之后,字节搬移、指针计算、内存对齐,环环相扣,任何一个细节不对,排序结果都可能是错乱的。遇到这种情况,最好的解决办法不是继续盯着代码看,而是打印出每一趟的完整数组状态,一步步对照手算过程,把错误的触发点锁定在某一个具体的交换上。

最后再分享一个小技巧:如果你在用C语言刷算法题,希尔排序的代码模板尽量背得“机械化”,也就是gap循环、插入排序内层、边界条件这三段式结构,形成肌肉记忆。考试时不需要思考就能写出来,然后把精力留给那些真正需要思考的题目上。

希望这篇关于希尔排序的C语言实现拆解,能帮你把这块“硬骨头”啃下来。学习C语言和算法的路上,没有太多捷径,但好的文章应该在关键时刻拉你一把——这篇算是我这个老兵的一点心意吧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询