☰
C语言排序算法全解析:从冒泡到qsort的工程实践
2026/10/11 13:29:16 网站建设 项目流程

排序是C语言基础篇里最值得花时间死磕的一个章节。很多初学者学到指针、数组以后,碰到的第一个“真正像算法”的东西就是排序;它不像循环那样一眼就能看懂,也不像函数那样背个签名就行,它需要你先想明白“怎么把无序变成有序”,再把这个想法用C语言准确地表达出来。这篇文章我想用做项目时攒下来的经验,把排序这层窗户纸捅破:从最朴素的冒泡排序,到工程里常用的快速排序,再到系统函数qsort怎么用,都过一遍。适合刚学完C语言基础语法、正准备向数据结构和算法迈步的读者,也适合那些能用排序但一直没搞懂内部实现的朋友。

1. 排序到底在干什么:从C语言的视角看问题

1.1 没有排序的世界会怎样

先想一个特别常见的场景:你写了一个成绩统计程序,里面存了500个人的学号和分数,输入顺序就是学号顺序。某天你需要打印一份“按分数从高到低”的名单,如果没有排序,你只能一遍遍扫描整个数组,每次找到一个最高分记录下来,然后想办法避开它,再找下一个最高分。这个流程能跑,但代码绕来绕去,而且很容易漏掉重复分数的人。更麻烦的是,你想给用户“第20名到第50名”这种区间名单时,几乎没法做。

排序解决的就是这个基础问题:让数据按照某个规则排列,之后查找、去重、统计都事半功倍。举个更生活化的例子,通讯录如果没有拼音排序,你要翻遍几百个联系人才能找到一个人的手机号;查字典如果词条不是按字母顺序排的,那它就不是字典,而是一堆纸。C语言里排序最直接的作用对象是数组,因为数组天然有连续下标,把数组元素排好序,后续很多逻辑都变简单。基础篇一般从整型数组入手,但排序的思路完全适用于字符串数组、结构体数组,甚至链表。

还有一个容易被忽视的点:很多高效算法建立在“数据已排序”这个前提上。比如二分查找,100万个有序元素最多找20次左右;但如果数组无序,只能顺序查找,平均要50万次比较。排序本身虽然要花时间,但排完序后带来的搜索效率提升往往更加关键。这就是为什么排序不只是一个“把数组整理一下”的小工具,而是后续学习数据结构和算法必须跨过的门槛。

1.2 排序问题的标准定义与两个维度

一句话描述排序问题:给定一组n个元素,按照某个“比较规则”重新排列,使元素满足升序或降序。这里面有两个容易被忽略的维度,基础篇里很多人因为只盯着代码,反而把这两个核心概念丢了。

第一个是正确性。排序完成后,前一个元素必须满足与后一个元素的比较关系,这是基本要求。但正确性还有一个隐藏层次:当数据里有重复值时,比如两个98分的学生,排序后它们的相对次序是否保持不变?如果保持不变,这个排序算法就是稳定的;如果可能交换位置,就是不稳定的。为什么稳定这么重要?举一个真实业务场景:你先按班级对学生排序,再按总分排序。如果排序算法不稳定,总分相同的学生可能会被打乱班级顺序,名单看起来就是乱的。如果算法稳定,第二次排序会保留第一次排序的先后顺序,也就是同分的学生依然按班级排好。这个“组合排序”的用法在未来工作中会反复遇到。

第二个是效率。n比较小时怎么排都无所谓,n一变大,时间复杂度和空间复杂度就成了生死线。时间复杂度描述的是比较和交换次数随数据规模增长的趋势。冒泡排序、选择排序、插入排序在最坏情况下要走大约n*(n-1)/2次比较,写作O(n^2);快速排序、归并排序平均情况只需要大约n*log2 n次比较,写作O(n log n)。n等于1万时,n^2是1亿,n log n大约是13万,差了几百倍;n等于100万时,差距更是天壤之别。初学者最容易陷入“只看代码能跑”的误区,忽略复杂度分析。基础篇里学排序,真正要学的是这种“用成本思维挑选算法”的感觉,后面学到查找、图算法时都用得上。

2. 五个必会排序算法的核心思路与选型

2.1 冒泡排序:最直观的“相邻交换”

冒泡排序的思想可以用一句话概括:从头到尾依次比较相邻两个元素,如果顺序反了就交换,一趟下来最大的元素就会像气泡一样浮到最后,然后对剩下的n-1个元素重复这个过程。之所以叫“冒泡”,是因为每一轮都会“上浮”一个当前范围内的最大值,视觉效果很像气泡往上跑。

先不看代码,只看一次过程。假设数组是{5, 3, 8, 1},第一轮从第0个和第1个元素开始比较,5大于3,交换,数组变成{3, 5, 8, 1};接着比较5和8,不用换;再比较8和1,8大于1,交换,数组变成{3, 5, 1, 8}。第一轮结束时,最大值8已经到最后。第二轮只需要比较前3个元素,结果是{3, 1, 5, 8};第三轮前2个元素一比较,变成{1, 3, 5, 8}。总共3轮排完。这个例子能清楚说明为什么外层循环只需要n-1趟:因为有n-1个元素归位后,最后一个元素自然就在正确位置。

冒泡排序的时间复杂度是O(n^2),因为它反复做相邻比较。它的交换次数很多,最坏情况下和比较次数同量级,所以在大规模数据上非常慢。但它的代码简单、逻辑直观,是理解“排序到底在干什么”的最好入门工具。有个常见优化:设置一个标志位,如果某一趟从头到尾一次交换都没发生,说明数组已经有序,可以提前退出。这个优化在数据基本有序时能把复杂度降到接近O(n),也是面试里常被追问的点。

2.2 选择排序:每次挑一个最小的放到前面

选择排序的主导思想是“找最小”:第1轮从n个元素里找出最小元素,放到位置0;第2轮从剩下的n-1个元素里找出最小元素,放到位置1;依此类推。它的名字来自一个事实:每一轮都在“选择”剩余部分的最小值。

这个算法和冒泡最大的不同是交换次数少。冒泡在比较过程中一发现逆序就交换,可能一栋楼要上下跑好几趟;选择排序则像先站在阳台拿望远镜扫一圈,确认最大的箱子在哪,然后才走一趟把它搬过去。每一轮最多交换一次,所以总共最多n-1次交换,比较次数依然是固定的n*(n-1)/2,时间复杂度仍然是O(n^2)。不过由于减少了交换,实际运行时往往比冒泡快一些。

选择排序有一个很经典的特性:不稳定。举个例子,数组是{5a, 5b, 1},其中5a和5b是两个值相同的元素,只是用来区分顺序。第一轮找到最小值1,和第一个元素5a交换,数组变成{1, 5b, 5a},两个5的相对顺序反了。这个例子说明,即使两个元素的值相等,排序过程也可能因为和远处的最小值交换而破坏原始顺序。后来你在使用稳定排序时,才会理解这个细节的价值。C语言基础篇不必死记“哪个稳定”,但一定要知道“为什么不稳定”。

2.3 插入排序:像整理扑克牌一样

插入排序的思路,是所有排序算法里最容易用生活经验解释的:你打扑克摸牌时,抓来一张新牌,会把它插到手里已有牌堆的合适位置。插入排序就是把数组看成“已经排好的前半段”和“还没处理的后面半段”,每轮从后半段取一个元素,从后往前扫描前半段,找到合适位置插入。

具体过程:数组{5, 3, 8, 1},初始把第0个元素5看成已排序部分。第1轮取第1个元素3,和前一个元素5比较,5大于3,把5右移一位,腾出位置,然后把3放到最前面,数组变成{3, 5, 8, 1}。第2轮取8,发现它已经大于前面的5,直接留在原地。第3轮取1,从后往前依次把8、5、3右移,最后把1放到数组开头。

插入排序的优势在于局部有序的数据上非常快。某个元素离它的正确位置越近,需要移动的次数越少;如果数据几乎已经排好,每轮几乎只比较一两次就结束,整体复杂度接近O(n)。最坏情况(逆序数组)下它和冒泡、选择一样是O(n^2),但它的常数通常更小,实际表现往往比另外两个好。更关键的是,插入排序是稳定的,因为它只把严格大于新元素的旧元素右移,相等的元素不会被越过。这个性质让它成了快速排序在小区间上的重要辅助排序策略,后面实操部分会提到。

2.4 希尔排序:插入排序的优化版本

希尔排序是插入排序的改进,很多人第一次听到时觉得有点奇怪:它允许元素“跳着”移动。思路是先把间隔较远的元素分成若干组,对每一组做插入排序;然后缩小间隔,继续分组排序;最后当间隔变成1时,整个数组再做一次标准插入排序。这样可以先把元素大步跨到接近正确位置,减少最后插入时的移动总量。

举个例子,数组有8个元素,间隔先取4,那么下标0和4一组,1和5一组,2和6一组,3和7一组,分别做插入排序。一趟过后,每个元素最多已经跳过4个位置;间隔变成2,元素可以跨2个位置比较;最后间隔1把所有元素精细调整。这个思路的本质是:插入排序在“数组基本有序”时效率极高,希尔排序通过大步长跳跃,先把数据变得基本有序,再交给最终插入排序收尾。

希尔排序的时间复杂度取决于间隔序列怎么取,较优的步长序列可以把复杂度降到接近O(n log n),但严格的证明比较复杂。工程上它基本已经被快速排序、归并排序取代,但作为面试加分项和算法思维的训练材料,它非常值得了解。基础篇如果时间有限,可以把这个小节当成拓展内容,优先把插入排序和快速排序吃透。

2.5 快速排序、归并排序、堆排序的定位

这三个才是工程界的“主角”。快速排序平均时间复杂度O(n log n),原地排序,常数小,是C标准库qsort底层常用的核心策略之一(正规实现会加上随机选择和深度保护,变成“内省排序”)。它的平均性能非常好,绝大多数排序需求优先考虑它。归并排序最大特点是稳定,但需要额外O(n)内存来合并数组。堆排序原地进行,最坏情况也能保持O(n log n),不会像快速排序那样在某些输入下退化,但它的常数较大,实际速度通常比快排慢。

选型时我习惯记一句粗浅口诀:数据量小,用插入排序最简单直接;数据量大、追求速度,用快排;要求稳定且内存够用,用归并;对最坏情况敏感,又不想用额外内存,用堆排序。这四类算法覆盖了绝大多数工程场景。基础篇不需要每个都背完整代码,但快排建议手写几遍,归并建议理解合并过程,堆排序可以先读思路和代码。这样到了学数据结构里的“堆”时,你会发现很多东西其实是连在一起的。

3. 手写C语言排序:完整代码与关键细节

3.1 从数组接口说起:为什么排序函数总要传长度

C语言里有一个特别容易踩的坑:数组作为函数参数时会“退化”成指针。你明明在main里定义了一个int a[100],传给bubble_sort(a, 100)后,函数里再写sizeof(a)得到的不是数组占用的400个字节,而是指针本身的大小(通常是8字节)。所以在函数内部没法用sizeof(a)/sizeof(a[0])来算长度,必须把这个长度显式传进来。

这也是为什么几乎所有C语言排序函数的签名都长这样:void bubble_sort(int arr[], int n),arr其实就是int *arr的另一种写法,写不写[]都不影响本质。比较正规的做法是带头文件、用size_t表示长度,不过基础篇用int更方便。如果忘了传长度,就只能用宏或者全局变量,工程上非常不推荐,因为你一旦把函数用到第二个数组,就傻眼了。

顺便说一个细节:在函数里想用数组越界来“探测”长度是不可能的,因为C语言不检查边界,你敢读,它就敢给你一个不确定的值。所以“传长度”不只是约定,更是保命的手段。我见过某同学的代码在main里能正确排序,但他把数组和长度两个参数写反了,编译器不报错,程序就在那里疯狂“排序”,直到加了打印才看出来。

3.2 冒泡排序的代码与细节陷阱

先给一个带优化标志位的冒泡排序实现:

void swap(int *a, int *b) { int tmp = *a; *a = *b; *b = tmp; } void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(&arr[j], &arr[j + 1]); swapped = 1; } } if (!swapped) { break; } } }

外层循环i表示已经有多少个元素排到了末尾。i=0时,内层需要比较第0到第n-2个相邻对,j最大到n-2,访问arr[j+1]最多到arr[n-1],安全。i=1时,最后一个元素已经确定是最大值,不需要再碰它,所以内层结束条件是n-2,写作n-1-i。这个写法很标准。

陷阱主要在两处。第一,内层终止条件很容易写错。如果写成j < n-1,也许多做了无用比较,不越界;如果写成j < n-i,当i=0时j最大到n-1,访问arr[j+1]就是arr[n],越界了。这种越界有时候不会立刻崩溃,因为那块内存可能恰好可读,但它属于未定义行为,换一份数据或换个编译器就可能炸。第二,swap函数不能偷懒。有人图省事写成arr[j] = arr[j+1]; arr[j+1] = arr[j];,结果两个相邻元素都变成同一个值,整个数组越排越乱。正确写法必须用一个临时变量保存第一个值,口诀是“先保存,再覆盖,再写回”。

3.3 选择排序和插入排序的代码对照

选择排序的C代码非常简洁:

void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; } } if (min_idx != i) { swap(&arr[i], &arr[min_idx]); } } }

注意min_idx每轮都要重置为i,不能一开始就写成0,否则你总是在全局找最小,前一轮排好的位置会被再次覆盖。内层从i+1开始,因为位置i自己就是“当前最小候选”,不需要和自身比较。这种写法直到最后一轮只有一个元素时,剩余元素也已经有序了。

插入排序的实现又是另一套逻辑:

void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }

这里有三个关键点。第一,key必须先把arr[i]保存下来,因为后续while循环会把前面的元素右移,原位置的arr[i]会被覆盖掉,不保存就丢了。第二,while循环里判断顺序必须是“j >= 0 && arr[j] > key”,不能调换成“arr[j] > key && j >= 0”,因为j--之后j完全可能变成-1,一旦先写arr[j]就会访问arr[-1],也就是数组开始位置之前的内存,属于非法操作。第三,这个“从后往前移动”的过程,移动的是比key大的元素,等于为key腾出一个位置,循环结束后把key写回arr[j + 1]。如果错误地写成了arr[j] = key,数组就会被截断。

3.4 快速排序的递归实现与越界风险

快速排序的核心是“分区”:选一个基准值pivot,把小于等于pivot的元素都放到左边,大于pivot的放到右边,然后递归处理左右两个区间。这里给一个最容易理解的Lomuto分区版本:

int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); } } swap(&arr[i + 1], &arr[high]); return i + 1; } void quick_sort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quick_sort(arr, low, pi - 1); quick_sort(arr, pi + 1, high); } }

调用时得写quick_sort(arr, 0, n - 1),可别传成n。partition里i从low - 1开始,j从low遍历到high - 1,凡是遇到小于等于pivot的元素,就把i右移一位并交换。最终pivot被放到arr[i + 1],这个位置就是它在有序数组中的正确位置。整个过程很巧妙,但有几个致命细节。

第一个细节是“越界风险”。如果调用时high写成n,partition里arr[high]就成了arr[n],访问越界;递归时high也可能错误扩大。所以我写完快排的第一件事就是在main里调用边界处加个断言: assert(high < n)。第二个细节是“递归深度”。快排最怕每次分区后的一边为空,比如数组本身已经升序,而基准总是取high位置的最大值,那么每次区间只缩小1,递归深度变成n。n比较大时会栈溢出。解决思路:随机选基准,或者三数取中(取low、high、中间三个位置的中位数),或者当区间小于某个阈值时改用插入排序。工程实现往往三者一起用。第三个细节是“等值元素”。如果把if (arr[j] <= pivot)写成if (arr[j] < pivot),等于pivot的元素被分到右边,分区仍然正确,但极端情况下可能退化成更差的性能;一般基础篇用<=,配合随机基准会更稳。

3.5 归并排序的空间换时间

归并排序的思路是“分而治之”:把数组从中间拆成两半,分别排好,再合并两个有序数组。合并的过程很像体育比赛里两支排好队伍要合成一支长队,每次比较两队队首,谁小谁先出列。因为每次都是从两个有序序列的头部取最小的元素,所以合并后天然有序。

基础实现的要点:

void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1; int n2 = right - mid; int L[100], R[100]; for (int i = 0; i < n1; i++) { L[i] = arr[left + i]; } for (int j = 0; j < n2; j++) { R[j] = arr[mid + 1 + j]; } int i = 0, j = 0, k = left; while (i < n1 && j < n2) { if (L[i] <= R[j]) { arr[k++] = L[i++]; } else { arr[k++] = R[j++]; } } while (i < n1) { arr[k++] = L[i++]; } while (j < n2) { arr[k++] = R[j++]; } } void merge_sort(int arr[], int left, int right) { if (left < right) { int mid = left + (right - left) / 2; merge_sort(arr, left, mid); merge_sort(arr, mid + 1, right); merge(arr, left, mid, right); } }

这个示例里L和R用了定长数组,主要是为了基础篇演示。实际工程上应该用malloc动态分配,或者在外面申请一个和原数组等长的全局临时数组,否则数组太大时局部定长数组会占用大量栈空间导致程序崩溃。用mid = left + (right - left) / 2而不是(left + right) / 2,是为了防止left和right相加溢出,虽然基础篇的数组未必那么大,但养成习惯更好。

归并排序最明显的特征是需要额外O(n)内存。空间换来了两个好处:一是稳定,因为合并时遇到相等元素会先取左半边,原始顺序不被破坏;二是最坏情况也是O(n log n),没有快速排序那种退化风险。代价就是内存翻倍,在嵌入式或内存受限的环境里要仔细掂量。

3.6 用qsort函数快速排序任意数组

C标准库提供了一个现成排序函数qsort,定义在stdlib.h里。它的原型是:

void qsort(void *base, size_t num, size_t size, int (*compar)(const void *, const void *));

base是待排序数组首地址,num是元素个数,size是每个元素占的字节数,compar是用户自定义的比较函数。这个函数几乎能排任何类型的数据,因为内部按字节移动,不知道也不关心你的数组是什么类型,只按你给的“比较规则”判断两个元素的大小。比如排int数组:

int cmp_int(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; return (x > y) - (x < y); } int main() { int a[] = {5, 3, 8, 1}; int n = sizeof(a) / sizeof(a[0]); qsort(a, n, sizeof(int), cmp_int); return 0; }

比较函数的写法有个常见坑:很多人写成return(int)a -(int)b;,这在多数int数据上没问题,但如果差值超过int范围就会溢出,得到错误结果。更安全的写法是用比较逻辑,像上面那样用(x > y) - (x < y),只返回-1、0、1。另外,void必须经过强制类型转换再解引用,表达式写作(const int*)a,括号不能丢;丢了就会变成对a指针本身解引用,编译器会报错或者行为乱掉。

qsort的优点是快、通用、边界处理完善,缺点就是函数指针和void*对刚学完基础语法的朋友不太友好。但它是从“基础篇”跨到“应用篇”的一座桥:你不需要再关心内部怎么排序,只需要把“如何比较两个元素”这个规则描述清楚,剩下的交给标准库。实际项目中绝大多数排序需求应该直接用qsort,手写排序更多是为了理解内部机制和应付面试。

4. 实战比对:排序消耗时间到底差多少

4.1 测试环境与测试方法

光说复杂度,很多读者还是没有体感。我在自己的学习环境里跑了一组基准测试,机器配置不细说,重点看趋势。我用一个随机数生成器填出100000个int,然后分别用冒泡、选择、插入、快排、归并、qsort去排序。为了保证公平,每一轮排序前都从原始数组中复制一份一模一样的数据,否则前一个算法排完序,后一个算法面对的就是“已经有序”的数据,结果会严重失真。

计时用的是clock()函数,它返回的是CPU时钟周期性计数,需要除以CLOCKS_PER_SEC才能得到秒数。每轮重复3次取平均,减少系统负载波动的影响。这里有个小技巧:如果只用clock()测10万级冒泡排序,可能会等很久,我会先把基础测试脚本跑通,再让我去喝杯水回来读结果。但这也正说明O(n^2)算法的体感有多强烈。

4.2 10万级数据下的耗时结果

结果非常直观。下面是我的环境里的近似数据,不同机器会有差异,但数量级关系不会变:

排序算法100000个随机int耗时10000个随机int耗时
冒泡排序约12.3秒约0.12秒
选择排序约8.1秒约0.08秒
插入排序约6.4秒约0.06秒
快速排序约22毫秒约1.8毫秒
归并排序约35毫秒约2.5毫秒
qsort约25毫秒约2.1毫秒

看到10万的数据量时,冒泡要跑十几秒,而快排几十毫秒就结束,差了三个数量级。更关键的是,当数据量从1万变成10万时,O(n^2)的算法耗时大约涨了100倍;O(n log n)的算法只涨了十几倍。这正是“增长趋势”比“某个具体数字”更重要的原因。所以面试里说“大数据量别用冒泡”,不是因为它会出错,而是因为它会让用户等得不耐烦。

有一个例外情况:如果数据量很小,比如只有十几二十个元素,插入排序反而可能比快排快。因为快排有递归开销和分区交换的额外成本,插入排序的循环极简单。这就是为什么很多工程级排序实现会在小区间内改用插入排序,而不是教条地一直递归到底。

4.3 稳定性与内存占用怎么选

除了时间,排序算法还有两个维度要权衡。一个是稳定性。如果数据里的“相等元素”顺序有意义,比如成绩单先按总分排,总分相同就看语文成绩,那么用稳定的归并排序或插入排序更安全;如果只关心结果“值的大小次序”,不关心原始顺序,用快排就行。基础篇不必把每个算法的稳定性背到滚瓜烂熟,但至少记住冒泡和插入是稳定的,选择排序和快速排序不稳定,归并排序稳定。

另一个是内存占用。快速排序和堆排序都是原地排序,只需要O(log n)栈空间,几乎不额外占内存;归并排序需要一个和原数组等长的临时数组,内存直接翻倍。对服务器上的大数组排序,多几个G内存可能无所谓;但在嵌入式、单片机这类场景里,每KB内存都要省着用,就只能牺牲速度换内存。选算法的本质,就是在时间、空间、稳定性、实现复杂度之间找平衡点。

5. 常见问题与排查技巧实录

5.1 数组越界:排序中最大的“隐形杀手”

排序代码里最常见的崩溃原因就是数组越界,而且它经常不是立刻崩,是数据规模一变就崩。最典型的有两类:一类是快排调用时把high写成n而不是n-1;另一类是冒泡内层循环没减i,导致访问arr[j+1]时j+1等于n。这两个问题都在数组的“最后一个元素之外”踩了一脚。

排查技巧非常实用:第一,写一个print_arr函数,在每次关键步骤后打印数组头部和尾部几个元素,看到下标是否异常比肉眼盯着代码更高效。第二,用编译器开AddressSanitizer。GCC和Clang都支持-fsanitize=address选项,跑一次就能直接告诉你越界的是哪一行,省掉大量发呆时间。第三,在函数入口加assert(high < n)这类断言,快速暴露调用参数错误。我调试某个模拟项目X的快排时,就是靠assert一瞬间定位到调用处传参多写了1。

5.2 元素交换写错了,数据全变0

手写swap是最容易翻车的地方。上面提过不保存临时变量的写法,这里再提一个更隐蔽的坑:异或交换。有些追求“高级”的代码会写成:

*a = *a ^ *b; *b = *a ^ *b; *a = *a ^ *b;

这个技巧在常规交换时确实能省一个临时变量,但如果两个指针指向同一个元素,比如快排里i和j相等时,执行第一步后*a就变成0,后面怎么算都只能得到0。还有一种错误是把临时变量写成int tmp = arr[i]; arr[i] = arr[j]; arr[j] = arr[i];,结果两个位置都被覆盖成原来的arr[j]。这些问题的共性都是“没有先把旧值备份好”。

我的建议很朴素:排序代码里swap统一用一个临时变量,不炫技。只要你用临时的三步法,遇到任何指针指向同一个元素也不会出问题,因为两次写不会互相依赖。

5.3 递归深度导致栈溢出

快速排序在数组原本有序时会退化,递归深度n对100万规模的数组几乎必炸。第一次遇到栈溢出时,很多人以为是数组开得太大,但实际上数组开在堆里或静态区,堆栈爆掉是因为递归层数太深。一个有效验证方法是:在快速排序函数入口打印一个递归深度计数的值,你会发现它一路涨到几十万都没回头。

解决方案有三个方向。最直接的是“随机选基准”或“三数取中”,让输入的有序性失效。第二个是“小区间用插入排序”,当递归区间长度小于16时直接插入排序,减少递归次数。第三个是“迭代版快排”,自己用栈存区间。基础篇只需要知道前两种,我在自己练习时会把三数取中和插入排序优化加上,然后再测试基本有序的数组,递归深度会从n降到大约log n级别。

5.4 排序结果不稳定带来的业务问题

我帮朋友排查过一个模拟项目X:一批订单按创建时间排序,结果显示在页面后,几个创建时间完全相同的订单顺序每次都变,一会儿A在前面,一会儿B在前面。原因是项目里直接用了快速排序,而快排是不稳定的。改成归并排序后,相同时间的订单恢复了原始录入顺序,页面看起来就稳定了。

这个案例说明,学排序时不能只满足于“能排对”,还要想“值相等时怎么办”。实际业务里,数据往往带主键、优先级、时间戳等多个字段;当排序关键字相同时,一次稳定的排序能保持原有顺序,多关键字排序时更是依赖这个性质。C语言基础篇可能不会深入业务,但我建议做练习数组时,可以故意放几个重复值,观察每种排序后相同值的相对位置是否改变。这个观察比背定义深刻得多。

6. 从基础篇走出去:排序在真实项目里的位置

6.1 系统自带qsort为什么值得用

实际开发中,我不会从头手写排序。既然标准库给了一个高效、稳定(指实现质量稳定,不是排序稳定性)的qsort,直接调用它是最靠谱的选择。一个加分细节是:正规的标准库实现不会像我们教学版快排那样无限递归,它会在递归过深时切换到堆排序,这种组合叫“内省排序”,保证最坏情况下仍然是O(n log n)。所以你在项目里用qsort,不需要担心某天输入恰好有序导致性能崩掉。

理解qsort的另一个好处是:你以后接触C++的std::sort、Java的Arrays.sort、各种语言的排序接口时,会发现它们的设计如出一辙:核心算法交给框架,比较规则由调用方提供。所以基础篇最后一定要能够熟练写出qsort的比较函数,这比背十个手写排序更贴近生产环境。

6.2 排序不只是排数字:结构体排序的精髓

实际业务中,排的几乎都是结构体,而不是孤零零的int。比如一个学生信息数组,每个元素是一个struct Student,里面有id和score。qsort要做的就是定义一个按score比较的函数:

typedef struct { int id; double score; } Student; int cmp_student(const void *a, const void *b) { double sa = ((Student *)a)->score; double sb = ((Student *)b)->score; if (sa < sb) return -1; if (sa > sb) return 1; return ((Student *)a)->id - ((Student *)b)->id; }

这个比较函数有几个易错点:第一,括号不能少,((Student *)a)->score里外层的括号是为了保证类型转换后立刻解引用;第二,double类型的差值不能通过减法转int返回,因为double可能有小数部分,比如1.2-1.1等于0.10000000000000009,强转成int是0,这没问题,但很多情况下差值不是整数或者溢出,所以最稳的是用大于小于判断返回1和-1,相等返回0;第三,如果分数相同,再比较id,这样排序结果就有明确的“次级规则”。结构体排序搞清楚了,你在项目里遇到按时间、按价格、按权重排序都能直接套。手写结构体数组排序也是一样,只是将比较逻辑写进对应算法里的if条件。

6.3 后续可以尝试的排序扩展方向

排序这个知识点会在很多地方再次出现。下一步可以试着做链表排序,归并排序天然适配链表,因为它只需要重新连接节点,不需要移动数据;再做外部排序,当数据量大到内存放不下时,要先把数据分块排好,再用多路归并外部合并,这是数据库系统里的常见操作;然后是多关键字排序,先用主关键字排序,再用稳定排序处理次级关键字,可以组合出复杂的排名规则;最后可以尝试自己封装一个通用排序函数,以函数指针作为比较器,复刻qsort的行为,这对理解“接口抽象”非常有帮助。

我个人在实际操作中的体会是:排序这章像一块跳板。你在这里练熟的不只是几个算法,而是“把问题抽象成数据关系”和“用复杂度思维做选择”的能力。工作三五年后,我还会时不时回来翻快排和归并的代码,因为它们简单,却处处体现着递归、分治、稳定性这些核心编程思想。基础篇把这块吃透了,后面走起来会顺很多。

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

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

立即咨询