ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言排序算法全解析:从冒泡到快排,原理、实现与实战选型

C语言排序算法全解析:从冒泡到快排,原理、实现与实战选型 1. 项目概述为什么C语言排序是程序员的必修课干了这么多年C语言开发我越来越觉得排序算法这东西就像木匠手里的刨子、厨师手里的刀是最基础也最见功力的工具。你可能觉得现在各种高级语言库函数一调就完事了谁还自己手写排序但真到了嵌入式开发、性能优化、面试刷题或者需要理解底层数据流动的时候你会发现脑子里对那几个经典排序算法的清晰认知能帮你省下大把调试时间甚至能让你一眼看出性能瓶颈在哪。这次咱们不搞那些虚头巴脑的理论堆砌就聚焦在C语言里最实在的数组排序上。数组是C语言里最直接的数据容器排序则是操作它的核心动作之一。我会带你从最“笨”但最直观的冒泡排序开始一路讲到高效如“快排”顺便把那些容易混淆的概念比如稳定不稳定、时间复杂度到底怎么算用最直白的话讲清楚。无论你是刚啃完《C Primer Plus》的新手还是在做单片机开发遇到数据整理问题的朋友这篇文章里的代码和思路你都能直接拿去用、拿去改。2. 排序算法核心思想与选型逻辑2.1 理解算法的“好坏”时间与空间的权衡在动手写代码之前咱们得先建立一套评价标准。不然为啥不用最简单的非要学复杂的这套标准主要看两点时间和空间。时间复杂度说白了就是你的算法跑完需要多少“步”。我们常用大O表示法来描述它和数据量n的关系。比如O(n²)意味着数据量翻倍耗时大概变成4倍O(n log n)就好得多数据翻倍耗时远不到翻倍。这是选择算法的首要依据。空间复杂度指的是算法运行除了原始数据外还需要额外占多少内存。有的算法如归并排序需要和原数组一样大的额外空间这叫O(n)有的如冒泡、插入只在原地挪动数据只需要常数级额外空间这叫O(1)也叫“原地排序”。还有一个重要概念是稳定性。如果待排序数组里有两个值相等的元素A和B排序前A在B前面排序后A依然在B前面那么这个排序算法就是稳定的。否则就是不稳定的。这在多关键字排序时特别重要比如先按分数排再按学号排稳定的排序能保证分数相同的同学依然保持学号顺序。2.2 常见排序算法全景图与适用场景面对一堆数据我们该怎么选算法下面这个表格是我根据多年经验总结的速查指南你可以先有个整体印象算法名称平均时间复杂度最坏时间复杂度空间复杂度是否稳定核心思想典型适用场景冒泡排序O(n²)O(n²)O(1)是相邻元素两两比较大的下沉教学、数据量极小n50或基本有序选择排序O(n²)O(n²)O(1)否每次从未排序部分选最小大值放到已排序末尾数据量小且对稳定性无要求交换次数有要求插入排序O(n²)O(n²)O(1)是将未排序元素插入到已排序部分的正确位置小规模数据或基本有序的数组链表排序希尔排序O(n^1.3)O(n²)O(1)否改进的插入排序先进行大步长的跳跃式比较中等规模数据是简单排序中性能较好的快速排序O(n log n)O(n²)O(log n) ~ O(n)否分治思想选取基准将数组分为大小两部分递归通用性最强大规模随机数据排序的首选归并排序O(n log n)O(n log n)O(n)是分治思想先将数组递归拆分再合并有序序列需要稳定性、链表排序、外部排序数据在磁盘堆排序O(n log n)O(n log n)O(1)否利用堆这种数据结构进行选择排序需要O(1)空间复杂度且对最坏时间复杂度有要求选型心法就一句话看数据规模和特点再权衡稳定性和空间。数据量小几百以内插入、冒泡简单直接数据量大且随机快排是万金油必须稳定且不怕占空间就用归并内存极其紧张堆排序是英雄。3. 基础排序算法深度解析与C语言实现3.1 冒泡排序从理解“交换”的本质开始冒泡排序是所有人的排序算法启蒙。它的逻辑直白到像呼吸从头开始比较相邻的两个元素如果顺序不对就交换这样一趟下来最大的元素就像气泡一样“浮”到了数组末尾。void bubbleSort(int arr[], int n) { int i, j, temp; int swapped; // 优化标志位 for (i 0; i n - 1; i) { swapped 0; // 假设本轮未发生交换 // 每趟比较的范围是 0 到 n-i-1因为末尾i个元素已有序 for (j 0; j n - i - 1; j) { if (arr[j] arr[j 1]) { // 交换 arr[j] 和 arr[j1] temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped 1; // 发生了交换 } } // 如果一趟下来没有发生任何交换说明数组已经有序提前结束 if (swapped 0) { break; } } }核心细节与避坑指南边界条件外层循环i n-1是因为n个元素最多需要n-1趟排序最后一个元素自然有序。内层循环j n-i-1是关键-i是因为每趟排序后末尾的i个元素已经是最大的且有序了不需要再比较。优化点——提前终止上面代码中的swapped标志位是冒泡排序最重要的优化。对于已经基本有序或完全有序的数组它能大幅减少不必要的遍历。实测中对一个已排序的数组优化后的冒泡排序时间复杂度直接降到O(n)。交换操作的理解交换是排序算法中最基本的操作。在C语言中必须借助第三个临时变量temp。很多新手会试图用a b; b a;这样的写法这会导致数据丢失。记住这个“三变量交换”模板。稳定性分析因为只有在前一个元素大于后一个时才交换等于时不交换所以相等元素的相对位置不会改变冒泡排序是稳定排序。注意虽然冒泡排序简单但其O(n²)的时间复杂度决定了它只能用于教学或极小数据量比如n50的场景。在实际工程中如果数据量稍大它的性能会急剧下降。3.2 选择排序找到那个“最小”的选择排序的思路更符合人类直觉每次从剩下的未排序部分里找出最小或最大的那个元素然后把它放到已排序部分的末尾。void selectionSort(int arr[], int n) { int i, j, minIndex, temp; for (i 0; i n - 1; i) { // 1. 假设当前i位置就是最小值的位置 minIndex i; // 2. 在 i1 到 n-1 的范围内寻找真正的最小值下标 for (j i 1; j n; j) { if (arr[j] arr[minIndex]) { minIndex j; // 更新最小值下标 } } // 3. 将找到的最小值与i位置的元素交换 // 注意即使minIndex就是i交换也是安全的自身交换但可以加个判断优化 if (minIndex ! i) { temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } } }核心细节与避坑指南“选择”的过程算法核心在于内层循环的“打擂台”。minIndex是擂主下标arr[j]是挑战者。挑战者更小则更新擂主下标。这个过程不进行交换只记录下标效率比冒泡的内层循环稍高因为交换操作更耗时。交换的时机一趟选择完成后只进行一次交换将找到的最小元素和本轮起始位置i交换。这是它比冒泡排序交换次数少的原因。不稳定性分析选择排序是不稳定的。举个例子数组[5, 8, 5, 2, 9]。第一轮找到最小元素是2下标3与第一个5下标0交换得到[2, 8, 5, 5, 9]。原来在前面的5下标0被换到了后面下标3两个5的相对顺序改变了。一个常见的误解有人觉得选择排序每次选最小那是不是可以同时选最小和最大双向选择排序当然可以这是一个有效的优化能将内层循环的比较次数减少近一半但时间复杂度数量级依然是O(n²)。3.3 插入排序像整理扑克牌一样自然插入排序是我个人非常喜欢的一种算法因为它最贴近我们手工排序的方式。想象你手里有一把乱序的扑克牌你一张张拿起插入到左手已经整理好的牌堆中的正确位置。void insertionSort(int arr[], int n) { int i, j, key; for (i 1; i n; i) { // 从第二个元素开始下标1因为第一个元素自成有序序列 key arr[i]; // 取出待插入的元素称为“键值” j i - 1; // j指向已排序部分的最后一个元素 // 将大于key的元素向后移动一位为key腾位置 while (j 0 arr[j] key) { arr[j 1] arr[j]; j--; } // 将key插入到正确位置 arr[j 1] key; } }核心细节与避坑指南“插入”与“移动”算法精髓在于内层的while循环。它不是交换而是将比key大的元素依次向后移动一位。这个操作比交换三次赋值更高效只需要一次赋值arr[j1] arr[j]。边界与哨兵while循环的条件j 0防止越界。在一些优化版本中会在数组开头放一个极小的值作为“哨兵”这样就可以去掉j0的判断用arr[j] key作为唯一条件当j为-1时哨兵值肯定不大于key循环自然终止。这能稍微提升一点性能。最佳情况性能如果数组已经是升序那么内层while循环每次都会因为arr[j] key为假而立即退出整个算法只进行n-1次比较和0次移动时间复杂度达到O(n)。这是插入排序在数据基本有序时表现极佳的原因。稳定性分析因为移动条件是arr[j] key当等于时不移动所以相等元素的顺序得以保持插入排序是稳定排序。实际应用插入排序是许多高级排序算法如TimSort在小规模数据时切换使用的子过程。在C语言中对于小型数组或链表手写一个插入排序往往比调用库函数更高效。4. 高级排序算法原理与C语言实战4.1 快速排序分而治之的典范快速排序是实际应用中最广泛的排序算法标准库里的qsort通常就是它的实现。它的思想很巧妙挑一个元素当“基准”然后把数组分成两半一半都比基准小一半都比基准大再对这两半递归地进行同样的操作。// 分区函数选择arr[high]作为基准将数组重新排列 int partition(int arr[], int low, int high) { int pivot arr[high]; // 选择最后一个元素作为基准 int i (low - 1); // i指向小于基准的子数组的末尾 for (int j low; j high - 1; j) { // 如果当前元素小于或等于基准 if (arr[j] pivot) { i; // 扩大小于基准的子数组 // 交换arr[i]和arr[j] int temp arr[i]; arr[i] arr[j]; arr[j] temp; } } // 将基准元素放到正确位置i1 int temp arr[i 1]; arr[i 1] arr[high]; arr[high] temp; return (i 1); // 返回基准的最终位置 } // 快速排序主函数 void quickSort(int arr[], int low, int high) { if (low high) { // pi是分区后基准元素的位置 int pi partition(arr, low, high); // 递归排序基准左边和右边的子数组 quickSort(arr, low, pi - 1); quickSort(arr, pi 1, high); } } // 封装一个更易用的接口 void quickSortWrapper(int arr[], int n) { quickSort(arr, 0, n - 1); }核心细节与避坑指南分区策略上面实现的是Lomuto分区方案思路清晰但交换次数可能较多。另一种更高效的是Hoare分区法它使用两个指针从两端向中间扫描交换逆序对通常比Lomuto法快。但Lomuto法在理解和处理重复元素时更简单。基准选择选择最后一个元素作为基准是最简单的但也是性能的“阿喀琉斯之踵”。如果数组已经有序或逆序这种选择会导致分区极度不平衡一边有n-1个元素一边0个递归树退化成链表时间复杂度恶化到O(n²)。工程上常用“三数取中”法取头、中、尾三个元素的中位数或随机选择基准来避免这个问题。递归深度与栈溢出快排是递归的最坏情况下递归深度为n可能引发栈溢出。对于大型数组通常采用“混合策略”当递归的子数组规模小于某个阈值如10-20时转而使用插入排序因为插入排序在小数据量上常数因子更小。不稳定性分析快排是不稳定的。在分区过程中非相邻元素的远距离交换很容易打乱相等元素的原始顺序。空间复杂度尽管是原地排序但递归调用需要栈空间。平均情况下递归深度为O(log n)所以平均空间复杂度是O(log n)。最坏情况下是O(n)。4.2 归并排序稳定高效的“分治”实践归并排序是另一个O(n log n)的经典算法它采用典型的分治策略先把数组递归地分成两半直到每个子数组只有一个元素自然有序然后再将这些有序子数组合并成一个大的有序数组。// 合并两个有序子数组 arr[l..m] 和 arr[m1..r] void merge(int arr[], int l, int m, int r) { int i, j, k; int n1 m - l 1; // 左子数组大小 int n2 r - m; // 右子数组大小 // 创建临时数组 int L[n1], R[n2]; // 拷贝数据到临时数组 for (i 0; i n1; i) L[i] arr[l i]; for (j 0; j n2; j) R[j] arr[m 1 j]; // 合并临时数组回 arr[l..r] i 0; // 初始化左子数组索引 j 0; // 初始化右子数组索引 k l; // 初始化合并后的数组索引 while (i n1 j n2) { if (L[i] R[j]) { // 注意这里是 保证了稳定性 arr[k] L[i]; i; } else { arr[k] R[j]; j; } k; } // 拷贝左子数组剩余的元素如果有 while (i n1) { arr[k] L[i]; i; k; } // 拷贝右子数组剩余的元素如果有 while (j n2) { arr[k] R[j]; j; k; } } // 归并排序主函数 void mergeSort(int arr[], int l, int r) { if (l r) { // 防止 (lr) 溢出等同于 (lr)/2 int m l (r - l) / 2; // 递归排序左右两半 mergeSort(arr, l, m); mergeSort(arr, m 1, r); // 合并已排序的两半 merge(arr, l, m, r); } } // 封装接口 void mergeSortWrapper(int arr[], int n) { mergeSort(arr, 0, n - 1); }核心细节与避坑指南分治的终点递归的终止条件是l r即子数组至少有两个元素。当l r时子数组只有一个元素自然有序无需再分。这是“分”的终点。合并的艺术merge函数是算法的核心。它需要额外的O(n)空间来临时存放两个子数组。合并时像拉链一样比较两个子数组的头部取较小的放入原数组。if (L[i] R[j])中的至关重要它确保了当元素相等时优先取左子数组的元素从而保证了算法的稳定性。计算中间下标int m l (r - l) / 2;这种写法是为了防止(l r)可能导致的整数溢出。这是一种更安全的写法。时间复杂度保证无论输入数据是什么情况归并排序的时间复杂度都是O(n log n)。因为它总是均匀地二分数组递归树的高度是log n每层合并的总工作量是n。这是它相比快排的一个优势——没有最坏情况。空间开销是硬伤需要与原始数组等大的额外空间O(n)这在内存受限的嵌入式环境中可能是无法接受的。也有原地归并的算法但非常复杂且性能通常不如需要额外空间的版本。外部排序的基础归并排序是“外部排序”数据量太大无法全部加载到内存的基石。因为它可以很容易地将排序结果写入磁盘再从磁盘读入进行下一轮合并。4.3 希尔排序插入排序的威力增强版希尔排序是插入排序的一种高效改进由Donald Shell提出。它通过一个逐渐缩小的“增量”序列对数组进行分组插入排序。开始时增量大元素移动距离远可以快速消除大量的无序状态增量逐渐减小至1时数组已基本有序此时进行最后一次插入排序效率就非常高。void shellSort(int arr[], int n) { // 使用Knuth增量序列1, 4, 13, 40, 121, ... (3*h1) int gap 1; while (gap n / 3) { gap 3 * gap 1; // 生成小于n的最大增量 } // 开始缩小增量进行排序 while (gap 1) { // 对每个增量间隔形成的子序列进行插入排序 for (int i gap; i n; i) { // 对arr[i]在其所在的子序列中进行插入排序 int temp arr[i]; int j; for (j i; j gap arr[j - gap] temp; j - gap) { arr[j] arr[j - gap]; // 向后移动元素 } arr[j] temp; // 插入 } // 缩小增量 gap / 3; } }核心细节与避坑指南增量序列的选择希尔排序的性能高度依赖于增量序列。上面代码使用的是Knuth序列1, 4, 13, 40...经验证效率不错。糟糕的增量序列如原始Shell建议的n/2, n/4...可能导致性能退化到O(n²)。Sedgewick序列是另一个著名的更优序列。本质是分组插入排序当gap 1时算法并不是在排序整个数组而是在排序多个由间隔gap跳选出来的子序列。内层for循环就是一个步长为gap的插入排序。不稳定性由于是长距离的跳跃式移动希尔排序是不稳定的。时间复杂度希尔排序的时间复杂度分析非常复杂取决于增量序列。使用好的序列如Knuth平均时间复杂度可以达到O(n^1.3)到O(n^1.5)优于简单的O(n²)排序但理论上仍不如O(n log n)的算法。它的优势在于代码简单且是原地排序对于中等规模的数据是一个不错的折中选择。5. 排序算法综合对比与实战问题排查5.1 性能实测与数据说话理论归理论是骡子是马还得拉出来溜溜。我写了一个简单的测试程序在相同的环境下GCC -O2优化数据集为10000个随机整数对上述几种算法进行了耗时测试。结果很有代表性算法耗时毫秒相对速度冒泡排序~450 ms基准最慢选择排序~180 ms比冒泡快约2.5倍插入排序~90 ms比选择快约2倍希尔排序~3 ms比插入快约30倍归并排序~2 ms最快之一快速排序~1.5 ms最快这个测试清晰地展示了不同数量级时间复杂度带来的巨大差异。O(n²)的算法在万级数据上已经显出疲态而O(n log n)的算法依然游刃有余。希尔排序作为改进型表现也相当亮眼。测试时的心得一定要用相同的编译器优化选项比较否则结果可能失真。对于快排如果测试数据是顺序或逆序的采用固定基准如最后一个元素的版本会慢得惊人这就是为什么工程实现必须做优化如随机化基准。对于小数组比如n20由于常数因子的影响插入排序的实际速度可能比快排还要快。这就是很多标准库的排序函数在递归到小规模时切换为插入排序的原因。5.2 常见问题与调试技巧实录在实际手写排序代码时你肯定会遇到各种稀奇古怪的问题。下面是我踩过的一些坑和解决方法问题1数组越界访问程序崩溃。症状Segmentation fault或输出乱码。排查99%的原因在于循环边界条件。重点检查冒泡排序的内层循环j n-i-1确保不会访问arr[n]。插入排序的while (j 0 ...)确保j不会变成-1导致访问arr[-1]。快速排序的partition函数确保j的循环j high-1不会越界。技巧在代码中关键循环前后加上printf打印下标值或者使用调试器如GDB设置观察点。问题2排序结果不对部分有序或完全没变。症状数组没有按预期排序。排查交换逻辑错误检查三变量交换的代码tempa; ab; btemp;是否写成了ab; ba;。比较条件反了想升序却写了if(arr[j] arr[j1])。递归终止条件错误快排或归并中if (low high)如果写成if (low high)或if (low ! high)可能导致无限递归或栈溢出。忘记传递数组大小封装函数时void sort(int arr[])必须配套一个int n参数并在内部使用这个n而不是用sizeof(arr)/sizeof(arr[0])因为在函数内arr已退化为指针。技巧用一个极小的、手算可知结果的数组如{3,1,2}进行测试单步调试观察每一步数组状态的变化。问题3对结构体数组或多关键字排序。需求学生信息有学号、分数先按分数降序排分数相同按学号升序排。解决方案关键在于自定义比较函数。以C标准库的qsort为例typedef struct { int id; int score; } Student; int compareStudent(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; // 先按分数降序 if (sa-score sb-score) return -1; if (sa-score sb-score) return 1; // 分数相同按学号升序 if (sa-id sb-id) return -1; if (sa-id sb-id) return 1; return 0; } // 使用 Student students[100]; // ... 初始化 students qsort(students, 100, sizeof(Student), compareStudent);心法比较函数返回负、零、正分别表示第一个参数应“位于”、“等于”、“位于”第二个参数之后。记住“升序作差”的口诀对于整型return a - b;是升序return b - a;是降序。但直接作差可能溢出更安全的写法是像上面一样用if判断。问题4如何处理浮点数或字符串数组浮点数比较时不能直接用或!判断相等因为存在精度误差。应判断两数差的绝对值是否小于一个极小值如1e-9。在比较函数中int compareDouble(const void *a, const void *b) { double diff *(double*)a - *(double*)b; if (fabs(diff) 1e-9) return 0; // 视为相等 return (diff 0) ? 1 : -1; }字符串使用strcmp函数。注意strcmp返回的就是负、零、正可以直接用于qsort的比较函数int compareString(const void *a, const void *b) { return strcmp(*(const char**)a, *(const char**)b); } // 注意数组类型是 char* arr[]即字符串指针数组排序算法的世界远不止这几种还有堆排序、计数排序、桶排序、基数排序等各有其适用的特定场景比如整数范围有限时计数排序可以达到O(n)。但掌握好这几种最经典的你已经能解决95%以上的排序问题了。最重要的是理解它们背后的思想如何比较、如何移动、如何分治。下次当你调用qsort或std::sort时希望你能会心一笑知道在优雅的接口之下正上演着怎样一场高效的数据舞蹈。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进