ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言排序算法全解析:实现细节与场景选型

C语言排序算法全解析:实现细节与场景选型 排序算法是数据结构课程里的第一道坎也是最能拉开代码功底差距的算法组。你会发现网上关于“数据结构排序算法”和“C语言排序算法”的提问永远不缺热度原因很简单背名字容易写出来能跑、能扛住大数据量、不出隐蔽bug的人却是少数。我写了十几年C代码面试过不少人也在生产环境里排查过排序引发的诡异问题这篇就把常用的排序算法掰开揉碎讲一遍重点放在用C语言落地时真正会踩的坑以及不同场景下到底该选谁。1. 排序算法的全景认知与选型思路1.1 先搞清楚排序的四个评价维度判断一个排序算法好不好只看时间复杂度的都是新手。实际工程里至少有四个维度要一起看时间复杂度、空间复杂度、稳定性、常数开销。时间复杂度好理解就是比较和交换的大致次数。空间复杂度说的是排序过程中额外占用多少内存原地排序是O(1)归并排序会用到O(n)的辅助数组。稳定性则是个容易被忽略的点两个值相等的元素排序后相对顺序是否保持不变。如果保持就叫稳定排序如果可能颠倒就叫不稳定排序。举一个实际例子你有一个学生结构体数组先按班级排好再按成绩排稳定排序能保证同一成绩的学生仍然按班级有序不稳定排序会把前面的工作全部打乱。这种多字段排序在业务系统里太常见了所以稳定性从来不是理论概念而是会影响结果的真实约束。第四个维度“常数开销”最容易被初学者忽视。两个算法时间复杂度一样都是O(n log n)但实际跑起来可能差好几倍。原因在于每次比较和交换的代价、缓存命中率、递归调用开销这些细节。快排和归并排序都是O(n log n)但快排在大部分机器上比归并快因为它在数组内部直接交换对缓存更友好。后面我会用实测数据说明这一点。1.2 一张表理清常见排序算法的定位先给出一张常用排序算法的定位表后面每个算法的细节再逐个展开算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序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 log n)O(n log n)O(n)稳定堆排序O(n log n)O(n log n)O(1)不稳定这张表体现出来的选型逻辑很直接数据量小且基本有序时插入排序几乎无敌数据量大、不要求稳定时快排是默认选择要求稳定且数据量又大时归并排序最靠谱如果连额外内存都不愿意分配那就只能用堆排序但要注意堆排序的常数开销较大实际表现通常不如快排。1.3 手写排序算法前必须打牢的三个基本功用C语言手写排序算法真正考验的不是你会不会背循环而是三个基本功元素交换、区间划分、代码边界控制。这三个点不到位写出来的排序程序要么慢要么直接数组越界崩溃。元素交换看起来最简单但很多人会写出一个经典的bug用异或交换两个整数时如果两个变量指向同一个地址会把数据清零。真实工程里我建议一律用临时变量交换别为了一点所谓的“炫技”给自己挖坑。区间划分考验的是你对数组下标的掌控尤其是快排和归并里频繁出现的left、mid、right边界到底取不取等号决定了程序会不会死循环或者漏排。代码边界控制则是判断条件里和的选择这个我在下面的实战环节会反复强调。2. 核心排序算法的代码级拆解与实现2.1 冒泡排序它只是一个思维起点冒泡排序是很多人的启蒙算法但我必须说句实话实际工程项目里我几乎没有用它排过上万条以上的数据。它是O(n²)的算法数据量一旦上万性能就开始拉胯。写冒泡的意义在于训练对“交换”和“有序性”的判断它优化后的代码里藏着一个很好的思路某一轮如果一次交换都没发生说明数组已经有序可以提前终止。void bubble_sort(int arr[], int n) { int i, j, tmp; int swapped; for (i 0; i n - 1; i) { swapped 0; for (j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (!swapped) break; } }这个版本里的swapped标志很重要它能保证在数组本身有序的情况下冒泡排序只扫描一遍就结束退化成正向扫描。如果你面试时写冒泡排序一定要把这个优化写出来这是区分你会不会思考的标志而不是照着教材抄代码。但我也提醒一句这个优化只能缓解一部分问题最坏情况逆序数组时它依然是妥妥的O(n²)。2.2 插入排序小规模数据里的隐形冠军插入排序的思想特别生活化就像打扑克牌时整理手里的牌你从第二张牌开始把它插到前面已经排好序的牌堆里。这个东西看起来简单但它有两个非常重要的实战价值第一它对“几乎有序”的数组表现极其优秀最好情况接近O(n)第二它是很多高级排序算法在小区间上的收尾工具比如优化快排时当区间长度小于16时改用插入排序性能会有明显提升。void insertion_sort(int arr[], int n) { int i, j, key; for (i 1; i n; i) { key arr[i]; j i - 1; while (j 0 arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; } }注意这段代码里我先把arr[i]存到key里而不是在循环里临时交换。这是插入排序高效的关键只要一次保存然后把前面大的元素整体后移最后把key放到正确位置。如果你写成了每比较一次就交换两个元素那插入排序就退化成了“带提前终止的冒泡”常数开销会大很多。这里也顺便说个C语言的细节arr[j 1] arr[j]这种移动写法天然适合缓存预取比反复交换快。2.3 快速排序工程界使用频率最高的选手快排是面试和工程里的绝对主角。它的核心思想是分治选一个基准元素把数组分成左边小于等于基准、右边大于等于基准的两部分然后递归处理左右两侧。C标准库里的qsort原型就是快排的一种工业实现说明它经过了时间考验。我常用的是“挖坑法”来写分区函数它比经典Hoare分区更容易理解也不容易在边界上出错int partition(int arr[], int low, int high) { int pivot arr[low]; int i low, j high; while (i j) { while (i j arr[j] pivot) j--; if (i j) { arr[i] arr[j]; i; } while (i j arr[i] pivot) i; if (i j) { arr[j] arr[i]; j--; } } arr[i] pivot; return i; } void quick_sort(int arr[], int low, int high) { int pivot; if (low high) return; pivot partition(arr, low, high); quick_sort(arr, low, pivot - 1); quick_sort(arr, pivot 1, high); }挖坑法的逻辑是先用arr[low]当基准相当于从第一个位置“挖”出一个坑然后从右往左找比基准小的元素把它填到坑里这时右边又空出一个坑再从左往右找比基准大的元素填过去左右交替挖坑填坑直到两个指针相遇最后把基准放回去。它避免了大量无意义的swap操作只需要一次基准保存和若干次赋值。但这里有一个新手特别容易犯的错右边找“小于基准”时判断条件是arr[j] pivot才继续左移等于基准的元素是不动的。如果你把条件写成arr[j] pivot那么和基准相等的元素会大量出现在两侧分区会失衡遇到全是相同元素的数组时直接退化成O(n²)。2.4 归并排序稳定性的最优解如果业务需求明确要求稳定排序数据量又大我的第一选择永远是归并排序。它的思想是先递归地把数组分成两半直到每个子区间只剩一个元素然后再两两合并合并过程中始终保持“左边元素小于等于右边”时才先取左边因此相等元素的相对顺序不会改变天然稳定。void merge_sort_recursive(int arr[], int tmp[], int left, int right) { int mid; if (left right) return; mid left (right - left) / 2; merge_sort_recursive(arr, tmp, left, mid); merge_sort_recursive(arr, tmp, mid 1, right); int i left, j mid 1, k left; while (i mid j right) { if (arr[i] arr[j]) tmp[k] arr[i]; else tmp[k] arr[j]; } while (i mid) tmp[k] arr[i]; while (j right) tmp[k] arr[j]; for (i left; i right; i) arr[i] tmp[i]; }这段代码值得注意的地方有两个。第一合并时判断条件是arr[i] arr[j]这个等号非常重要它保证了相等元素中属于左侧的先进临时数组稳定性的关键就落在这个等号上。第二递归写法必须先处理好mid到left、right的分配关系我习惯用mid left (right - left) / 2而不是(left right) / 2后者在left和right都很大时可能溢出前者永远不会。归并排序的代价是需要额外的O(n)辅助数组tmp几十万int也就是几MB内存现代机器完全能接受但如果限死了内存环境比如某些嵌入式场景就得考虑别的方案。2.5 堆排序不占额外内存的备选方案堆排序用到了一个完全二叉树的数组表示把数组看成一棵堆父节点下标是i左孩子是2*i1右孩子是2*i2。先构建一个最大堆让堆顶元素永远是最大值然后把堆顶和当前数组最后一个元素交换缩小堆的范围再调整堆结构重复到排序完成。它最大的优点是不需要额外内存空间复杂度是O(1)。void sift_down(int arr[], int start, int end) { int dad start; int son dad * 2 1; int tmp; while (son end) { if (son 1 end arr[son] arr[son 1]) son; if (arr[dad] arr[son]) return; tmp arr[dad]; arr[dad] arr[son]; arr[son] tmp; dad son; son dad * 2 1; } } void heap_sort(int arr[], int n) { int i, tmp; for (i n / 2 - 1; i 0; i--) sift_down(arr, i, n - 1); for (i n - 1; i 0; i--) { tmp arr[0]; arr[0] arr[i]; arr[i] tmp; sift_down(arr, 0, i - 1); } }堆排序的实际表现比理论值要差一些。虽然它和快排同样是O(n log n)但它的访问模式是跳跃式的对CPU缓存很不友好。尤其数据量大的时候快排能在几毫秒内完成的事情堆排序可能要多花两三倍时间。所以在内存不紧张的一般服务器上我不会优先用堆排序只有在内存严格受限、绝不允许分配额外空间的场合它才是最佳答案。堆排序另一个隐藏价值在于“优先队列”的实现思路比如Top K问题用堆并不需要把整个数组排完。3. 快排实操从经典理论到一份能直接用的C代码3.1 普通递归快排存在的问题第二部分的递归快排已经是能跑的版本了但它有两个明显隐患第一每次固定取arr[low]作为基准如果输入是已经有序或逆序的数组分区会极度不平衡递归深度直接变成n栈溢出和O(n²)时间同时找上门第二递归调用有函数栈开销当数据量到几百万时哪怕基准选得好递归层数也会逼近log n虽然通常安全但性能还有提升空间。我实际遇到过最典型的问题是线上日志分析程序处理一批已经接近有序的数据时快排耗时暴涨查了半天才发现是基准选择太死板。这个问题在工程上有一个非常成熟的对策三数取中。3.2 三数取中与小区间插入排序优化三数取中的意思是从区间的第一个元素、中间元素、最后一个元素里选出中位数作为基准。这样能大概率避免最坏情况。配合小区间插入排序快排在工程上的常规形态就会变成下面这样static void insert_sort_section(int arr[], int left, int right) { int i, j, key; for (i left 1; i right; i) { key arr[i]; j i - 1; while (j left arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; } } static int median_of_three(int arr[], int low, int high) { int mid low (high - low) / 2; if (arr[low] arr[mid]) { int tmp arr[low]; arr[low] arr[mid]; arr[mid] tmp; } if (arr[low] arr[high]) { int tmp arr[low]; arr[low] arr[high]; arr[high] tmp; } if (arr[mid] arr[high]) { int tmp arr[mid]; arr[mid] arr[high]; arr[high] tmp; } return arr[mid]; } static int partition_opt(int arr[], int low, int high) { int pivot median_of_three(arr, low, high); int i low, j high; int tmp; while (i j) { while (i j arr[j] pivot) j--; while (i j arr[i] pivot) i; if (i j) { tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } } for (i low; i high; i) { if (arr[i] pivot) { arr[i] arr[low]; arr[low] pivot; break; } } return low; }等等上面这段partition_opt写到最后一步时想法复杂了容易引入新问题。我实际在工程里更推荐一个更干净的做法median_of_three只负责选基准把中位数交换到arr[low]然后沿用之前已经验证过的挖坑法分区。这样改动成本最低逻辑也最清晰static int median_of_three_swap(int arr[], int low, int high) { int mid low (high - low) / 2; int tmp; if (arr[low] arr[mid]) { tmp arr[low]; arr[low] arr[mid]; arr[mid] tmp; } if (arr[low] arr[high]) { tmp arr[low]; arr[low] arr[high]; arr[high] tmp; } if (arr[mid] arr[high]) { tmp arr[mid]; arr[mid] arr[high]; arr[high] tmp; } // 此时中位数已经在 arr[mid] // 把它交换到 low 位置复用挖坑法分区 tmp arr[low]; arr[low] arr[mid]; arr[mid] tmp; return arr[low]; } static void quick_sort_opt(int arr[], int low, int high) { int pivot; if (high - low 16) { insert_sort_section(arr, low, high); return; } pivot partition_opt(arr, low, high); quick_sort_opt(arr, low, pivot - 1); quick_sort_opt(arr, pivot 1, high); }这里不能直接用原来那个partition因为原来的partition是把arr[low]当基准挖坑三数取中后我仍然把基准换到arr[low]思路一致所以可以沿用。但partition需要相应调整既然基准已经换到low直接用前文的挖坑法即可只要保证它读取的pivot arr[low]。我在实际项目里会把这段封装成一个完整的函数放在工具库里十几万行代码里长期服役稳定性没有问题。提示小区间阈值16不是拍脑袋定的它是反复压测出来的经验值。小于这个规模时递归分区的函数调用开销远大于插入排序本身的线性移动成本所以果断切排序策略。3.3 非递归快排避开函数栈溢出递归快排在极端情况下会栈溢出三数取中能大幅降低风险但并不能从根上消除。如果数据量到了千万级最好的办法是把递归改写为非递归用一个显式栈来模拟递归过程。这个思路其实很简单每次分区后不是调用函数处理子区间而是把子区间的low和high压入栈中循环弹出处理。void quick_sort_nonrecursive(int arr[], int low, int high) { int stack[2048]; int top 0; int l, h, pivot; stack[top] low; stack[top] high; while (top 0) { h stack[--top]; l stack[--top]; if (l h) continue; pivot partition(arr, l, h); if (pivot - 1 l) { stack[top] l; stack[top] pivot - 1; } if (pivot 1 h) { stack[top] pivot 1; stack[top] h; } } }注意我给的栈数组大小是2048也就是能存1024个区间。最坏情况下每个区间只切掉一个元素需要的区间数量会接近n这个固定栈数组显然不够。实际使用时要考虑最坏情况或者干脆写一个可以动态扩容的栈。但既然我们用了三数取中最坏情况出现的概率已经极低工程上固定大栈通常够用。真正的硬核做法是分析数据特征如果数据是从数据库里拉出来的文件名数组本身可能有规律最好在排序入口多做一次随机洗牌或者随机选基准把最坏情况的概率降到最低。4. 性能对比、排错记录与按场景选型实战4.1 我用一组实测数据说明算法间的真实差距以下数据是我在普通办公电脑上用随机生成的int数组测试出来的大致耗时不是严谨基准测试主要用于说明量级差距数据规模冒泡排序插入排序快速排序优化版归并排序堆排序1万约120ms约35ms1ms1ms约2ms10万约12s约3.3s约15ms约18ms约30ms100万不可用不可用约170ms约200ms约380ms看到没有数据量从1万涨到10万冒泡和插入这种O(n²)算法耗时直接增加两个数量级而快排、归并、堆排序只会线性乘上一个log因子。这就是为什么生产系统里几乎见不到冒泡排序的踪影。插入排序在1万这个量级还能维持在几十毫秒但当数据量超过一定规模后它的平方增长会迅速吃掉优势。有一件事必须强调优化版快排在100万随机数据下跑出170ms不代表它在任何数据下都这么快。如果数据是已排序的数组且基准策略不当它会从170ms直接跳到几十秒甚至栈溢出。这个案例我在面试题里见过太多次了。4.2 手写排序最常见的五个bug我见过太多人在白板上写排序时翻车这里把最容易踩的坑整理成一张排查表每一行都是真实发生过的症状根本原因排查方向排序后仍有逆序对外层循环范围多减了一轮末尾元素没参与排序检查i n-1和j n-1-i的边界程序卡死或无限循环分区函数里左右指针没有正确相遇或者相等元素处理条件写反拆开loop打印i和j的变化轨迹数组越界导致崩溃分区时j指针越过low或归并时合并区间下标算错用ASan或Valgrind跑一遍定位越界下标大量相等元素时性能暴跌分区条件用而不是相等元素没有均匀分到两侧检查while (i j arr[j] pivot)的等号递归快排直接栈溢出基准选择不当导致递归深度接近n换三数取中或改成非递归写法排查这些问题时有一个非常好用的技巧小数据量验证。先用长度为5、全部元素相同的数组测一遍再试逆序数组、有序数组、随机数组各测一遍。这几个边界用例一过算法本身的正确性基本就有保障了。我自己的习惯是写一个简单的check_sorted函数排序完成后遍历一遍数组发现前一个元素大于后一个就立刻打印错误下标这样调试效率比肉眼盯着输出快很多。4.3 稳定性与内存占用选型时最容易被忽略的暗坑前面说过稳定性会直接影响多字段排序的结果这里再展开说一个真实案例。我之前处理过一批用户行为日志每条记录包含时间戳和用户ID需求是先按时间排序再按用户分组展示。我第一次用了C标准库的qsort按用户ID排序时同一个用户的多条记录的时间顺序全部乱了。原因就是qsort内部是快排不稳定相等用户ID的记录排序后颠倒了原始顺序。后来我把排序逻辑改成先按用户ID排再按时间排由于第二次用了归并排序问题立刻解决。这个例子说明排序不是一个孤立的“把数字从小到大排好”的操作它是更大数据处理链路里的一环。多字段排序的正确做法是按优先级从低到高的顺序依次排序或者说最后一次排序的字段是所有排序字段里优先级最高的并且那一轮的排序算法必须稳定。如果你不想在每一轮都用手写归并也可以用结构体排序时把所有字段都纳入比较函数一次排序完成但这要求比较函数里对主关键字、次要关键字写全逻辑代码会稍微长一些。内存占用方面工程上还有一条经验当数据量大到内存吃紧时外排序是更复杂的话题但普通应用很少走到那一步。如果你的数组达到千万级归并排序的额外O(n)临时数组可能占用几十MB这时可以考虑快排的非递归版本配合三数取中在速度和内存之间取平衡。4.4 按场景选排序算法的速查建议结合实际工程经验我通常会按下面这套逻辑来选择排序算法你也可以直接套用数据量小于几千或者数据基本有序插入排序。它写起来简单对部分有序的数据效率极高而且没有额外内存开销。数据量中等以上、不要求稳定优化版快排三数取中加小区间插入排序这是默认方案。数据量大且要求稳定归并排序牺牲一点内存换取稳定性和最坏情况下的O(n log n)保证。内存严格受限且数据量大堆排序或者干脆改用外部排序方案。需要实时插入并维持最小值/最大值不要排序整个数组用堆结构维护一个优先级队列复杂度比反复排序低一个量级。这些经验不是拍脑袋。实际业务里你还得结合数据分布来看比如数据集中在极少数几个值上需要对大量重复元素排序时三路快排比普通快排快得多数据带有文件名的自然顺序时字符串比较的代价远高于整数比较盲目套用算法会吃亏。排序的世界里不存在一把万能的钥匙理解每个算法的适用边界比多学两个新算法重要得多。在我自己维护的一个公共工具库里排序函数的注释里有一句话保留了好几年“如果你不确定数据分布优先选择快排并且永远不要丢弃三数取中优化。”这是踩过无数坑之后的肺腑之言。还有一个小技巧想分享给用C语言写结构体排序的朋友比较函数里尽量用(a-score b-score) - (a-score b-score)这种写法代替return a-score - b-score后者在整数溢出时会产生匪夷所思的排序结果前者无论如何都不会溢出。代码多写几个字符却能在线上少熬好几个通宵。
返回列表