ARTICLE DETAIL

资讯详情

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

C语言qsort函数详解:从参数解析到泛型模拟实现

C语言qsort函数详解:从参数解析到泛型模拟实现 用C语言写排序相关代码的时候我估计很多人都干过一件蠢事——自己撸冒泡、选择、插入排序然后每次遇到不同数据类型就再写一份新的排序函数。数组是int的写一份换成double又写一份再换成结构体还得写一份。等到项目稍微上点规模光排序代码就堆了三四份相似的逻辑改需求的时候还得一个个同步改。后来我认认真真研究了标准库里的qsort函数才发现自己以前纯粹是重复造轮子造上瘾了。C标准库其实早就把“给任意类型数组排序”这件事给包办了而且接口设计相当抽象好用。只不过它那四个参数一眼看上去有点劝退尤其那个函数指针声明新手看到基本直接关掉文档。这篇东西我打算聊聊qsort函数的使用与模拟实现从参数拆解到实际用法再到自己动手写一个兼容版本的排序函数把我踩过的坑和查过的资料一次性说清楚。先说结论qsort不是只能排整数的它能排任意类型的数组关键全在那个比较回调函数上。这篇文章适合刚学完指针和结构体的C语言初学者也适合想搞清楚标准库设计思路的编程爱好者。看完你不仅能熟练用qsort还能搞明白它是怎么用void指针和函数指针做出“泛型”效果的甚至能自己动手模拟实现一个简化版。1. 别重复造轮子qsort 凭什么能排“任意类型”数组如果你之前没有仔细看过qsort的原型先记住下面这行void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));这行声明里浓缩了C语言泛型编程的核心经验一个数组的根本信息无非三点——数据从哪里开始、有多少个元素、每个元素占多大空间。只要这三个信息到位再加一个“怎么比较两个元素”的函数排序就可以被封装成一套独立代码管你排的是int数组还是结构体数组。1.1 四个参数拆开看其实没你想的复杂第一个参数base是一个指向待排序数组首元素的指针类型是void *。设计成void *而不是具体类型指针就是为了“一个函数通吃所有数组”。编译器不关心你传的是int *还是char *它只把这个地址当作一块内存的起始位置。第二个参数nmemb是数组元素个数类型是size_t。初学者最容易漏掉这个参数尤其是对全局数组排序的时候想着函数内部用sizeof自己算一下不就行了但qsort是标准库函数它拿到手的只是一块地址根本不知道数组边界在哪所以必须由你告诉它“这块内存里有多少个 логических元素”。第三个参数size是单个元素占用的字节数。这里要特别注意你需要传sizeof(arr[0])而不是写成sizeof(int)这种硬编码。只要元素类型变了硬编码就会埋雷比如你后来把int数组改成long long数组忘了改这个参数qsort就会按4字节一组去划分元素结果就是整个排序逻辑完全错乱。第四个参数不用说了就是比较函数的指针。比较函数由调用者提供qsort内部排序时成对比较两个元素时就调用它来判定顺序关系。这四个参数合在一起其实就回答了“如何描述一块待排序内存”的全部问题。地址用来定位首元素nmemb用来确定总边界size用来确定每个元素怎么切分compar用来确定元素间的大小规则。有了这四个信息排序函数可以独立于元素的具体类型而存在这才是qsort设计的精髓所在。1.2 回调比较器qsort 灵活性的核心比较函数是qsort的灵魂。它的函数签名被强制约定为int cmp(const void *a, const void *b);返回值有且只有三种语义小于0表示a应该排在b前面大于0表示a应该排在b后面等于0表示两者在排序意义上等价。这个规则非常像数学中线性序关系的定义也正是因为这种约定使得qsort可以应对各种自定义顺序规则。很多新手第一次写比较函数时会直接写int cmp_int(const void *a, const void *b) { return *(int *)a - *(int *)b; }这种写法对int确实有效看起来也很简洁。但我要提醒一点如果元素类型是浮点数或者两个数的差可能超出int范围这种“相减返回差值”的写法就存在隐患。更稳妥的写法是用逻辑判断来规范返回值int cmp_double(const void *a, const void *b) { double x *(const double *)a; double y *(const double *)b; if (x y) return -1; if (x y) return 1; return 0; }qsort内部在需要比较两个元素时就把这两个元素的地址传给compar等待一个整数来决定是否交换。也就是说排序算法本身完全不知道“大小”是什么意思它只负责“如果compare结果大于0就交换位置”这样的机械动作。这种回调机制是C语言里非常典型的控制反转模式把“数据怎么比较”这个策略从算法里剥离出来调用者想怎么排就怎么排甚至可以通过比较函数实现降序、按绝对值、按字符串长度排序等花式需求。2. 3个高频案例从整数到结构体拿来直接用讲完原理必须来点能直接抄作业的代码。这一节我按使用频率从高到低给出int数组、字符串数组、结构体数组三种最典型的qsort用法。每个例子都遵循同样的套路定义比较函数调用qsort遍历打印结果。2.1 排 int 数组最小可运行的完整示例先来一个最完整的int排序示例保证能看懂也保证能编译通过#include stdio.h #include stdlib.h 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(void) { int arr[] {4, 2, 8, 5, 7, 1, 3, 9, 6, 0}; size_t n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(arr[0]), cmp_int); for (size_t i 0; i n; i) { printf(%d , arr[i]); } putchar(\n); return 0; }输出结果是0 1 2 3 4 5 6 7 8 9。这个代码已经体现了qsort的完整使用模式。有人可能会问为什么一定要用(x y) - (x y)这种写法因为在C语言里x y的结果是0或1x y的结果也是0或1做差之后必然只有三种可能1、0、-1。这既避免了减法溢出也避免了比较double时因为差值很小被截断成0的问题属于一种通用写法建议养成习惯。如果你确实想用直接的减法也要清楚它的边界风险。比如两个int分别取INT_MAX和INT_MIN相减在某些平台上会造成未定义行为可能是负数也可能是正数实际结果随编译器和平台变化显然不适合作为排序规则。说到底比较函数返回的是“大小方向”不是“差的绝对值”所以用逻辑判断来生成方向信息才是最稳的。2.2 排字符串数组不要被二级指针坑了字符串数组在C语言里通常是char *数组也就是每个元素本身是一个指针。这个场景里新手的崩溃率相当高因为比较函数参数是const void *你需要首先把它转换成char **然后再解引用拿到char *最后传给strcmp。#include stdio.h #include stdlib.h #include string.h int cmp_str(const void *a, const void *b) { char * const *sa a; char * const *sb b; return strcmp(*sa, *sb); } int main(void) { const char *words[] { banana, apple, cherry, date, elderberry }; size_t n sizeof(words) / sizeof(words[0]); qsort(words, n, sizeof(words[0]), cmp_str); for (size_t i 0; i n; i) { printf(%s\n, words[i]); } return 0; }这里有个非常容易犯的错把比较函数写成return strcmp(a, b);然后尝试把a直接当作字符串指针去解析。逻辑上你为什么觉得不对因为a的类型是const void *它指向的是数组中的元素而数组元素本身是char *所以你要先(char **)a取到那个元素的地址再解引用才能拿到真正的char *指针。用生活类比来说这就好比抽屉里放着一张写着地址的纸条qsort递给你的是抽屉的编号你要先打开抽屉再把纸条上的地址念出来才能找到收货人。另外要注意上面的words数组元素类型是const char *所以比较函数里用了char * const *来接收。如果你的数组是可变字符串char *words[]那么比较函数的转换可以写成char **。这里的区别说到底就是const位置的不同一个是“指向常量的指针的指针”一个是“指向指针的常量指针”写不对编译器会报类型不兼容的警告。2.3 排结构体数组按任意字段升降序实际业务里最常用的排序场景还是结构体数组。比如学生管理系统里按成绩排序商品列表里按价格排序。结构体类型千差万别但qsort的使用套路始终一致你只需要在比较函数里决定“我拿结构体的哪个字段来比较”。#include stdio.h #include stdlib.h #include string.h typedef struct { char name[32]; int score; } Student; int cmp_by_score_asc(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return (sa-score sb-score) - (sa-score sb-score); } int cmp_by_score_desc(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return (sa-score sb-score) - (sa-score sb-score); } int cmp_by_name(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return strcmp(sa-name, sb-name); } int main(void) { Student students[] { {Alice, 88}, {Bob, 95}, {Cindy, 72}, {David, 91}, }; size_t n sizeof(students) / sizeof(students[0]); qsort(students, n, sizeof(students[0]), cmp_by_score_asc); printf(按成绩升序\n); for (size_t i 0; i n; i) { printf(%s %d\n, students[i].name, students[i].score); } qsort(students, n, sizeof(students[0]), cmp_by_name); printf(按姓名排序\n); for (size_t i 0; i n; i) { printf(%s %d\n, students[i].name, students[i].score); } return 0; }这段代码里最值得学习的是比较函数内部拿到的是一个元素地址你要把它转换成结构体指针再用-访问字段。你完全可以在比较函数里写任意逻辑先按score降序score相同再按name升序或者按某个字符串字段的哈希值比较甚至按两个字段的差值比较。qsort不关心你怎么算它只要一个符号性的结论。对这个场景我特别想提醒的是不要忽略size参数。结构体数组的sizeof(students[0])通常比sizeof(int)大很多比如上面这个结构体可能要32字节以上。如果你脑子一抽传成了sizeof(Student *)也就是8字节那么qsort会把每个元素当成8字节来切分排序结果完全不可预期而且qsort内部做内存交换时会用它收到的size去逐字节拷贝数组内容越界的风险非常高运行时可能直接Segmentation fault。这类问题在真实开发中一旦出现排查起来极其折磨人因为代码逻辑看似没有错误只是参数写错了一点点。3. 模拟实现手写一个兼容版 qsort如果你只知道怎么用qsort那还只是“会用别人写的接口”。真正有意思的是自己动手模拟实现一个兼容版qsort。这个过程能让你彻底理解void指针、函数指针、字节交换这些C语言底层细节。3.1 为什么能用排序算法模拟 qsort任何一个排序算法本质上都只做两件事比较两个元素、交换两个元素。冒泡排序、插入排序、快速排序、归并排序全部可以拆解为这两个原子操作的不同排列组合。qsort标准库内部其实并不是规定只能用快排。根据C标准它只需要保证排序结果是升序对算法本身并没有强制要求。很多C标准库实现确实使用了快速排序或其改进版所以名字里带个“q”。但我们要模拟的是qsort的函数行为而不是必须实现快速排序这一个算法。只要你写出一个函数签名和qsort一致能用回调比较器对任意类型数组排序那你就已经“模拟实现qsort”了。我也是从写冒泡版开始入门的因为冒泡排序的逻辑最直白、最好验证。先把通用排序框架搭起来再去考虑快排版的细节会轻松很多。3.2 冒泡版模拟实现理解泛型交换的核心来一个可以直接编译运行的冒泡版模拟实现#include stdio.h #include stdlib.h #include string.h void my_qsort_bubble(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)) { if (base NULL || nmemb 0 || size 0) { return; } char *p (char *)base; for (size_t i 0; i nmemb - 1; i) { int swapped 0; for (size_t j 0; j nmemb - 1 - i; j) { char *elemA p j * size; char *elemB elemA size; if (compar(elemA, elemB) 0) { for (size_t k 0; k size; k) { char tmp elemA[k]; elemA[k] elemB[k]; elemB[k] tmp; } swapped 1; } } if (!swapped) { break; } } }这个实现里最关键的代码是p j * size这种指针偏移方式。因为p是char *类型所以j * size能精确计算出第j个元素的起始地址。这也解释了为什么qsort需要size参数它必须知道每个元素占多少个字节才能用一个char *快速定位到任意元素而不是依赖某个具体的结构体类型。交换操作同样值得琢磨。for (size_t k 0; k size; k)这个循环是在逐字节交换两个内存块。为什么不能直接写*elemA *elemB或者memcpy(elemA, elemB, size)直接赋值不行因为elemA是char *一次只能赋一个字节memcpy当然是可行的标准库自己往往也封装了这类内存块交换但为了看清本质我这里用最原始的逐字节交换让大家明白计算机内存层面的交换原理到底是什么。3.3 升级快速排序版模拟实现如果你觉得冒泡版太简单可以继续升级成快速排序版。快速排序也是qsort命名的来源实现起来稍微复杂一点但思想值得掌握。快速排序的核心是分区选一个基准元素把数组分成“小于基准”和“大于等于基准”两块再递归排序两块区间。因为我们要处理任意类型所以基准选择、元素交换、递归传参都要基于字节操作。void my_swap(void *a, void *b, size_t size) { char *pa (char *)a; char *pb (char *)b; while (size--) { char tmp *pa; *pa *pb; *pb tmp; } } static void my_qsort_helper(char *arr, size_t left, size_t right, size_t size, int (*compar)(const void *, const void *)) { if (left right) { return; } char *pivot arr left * size; size_t i left; size_t j right; while (i j) { while (i j compar(arr j * size, pivot) 0) { j--; } while (i j compar(arr i * size, pivot) 0) { i; } if (i j) { my_swap(arr i * size, arr j * size, size); } } my_swap(arr left * size, arr i * size, size); if (i left) { my_qsort_helper(arr, left, i - 1, size, compar); } if (i 1 right) { my_qsort_helper(arr, i 1, right, size, compar); } } void my_qsort_quick(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *)) { if (base NULL || nmemb 0 || size 0) { return; } my_qsort_helper((char *)base, 0, nmemb - 1, size, compar); }这里的my_swap函数负责交换任意大小的内存块如果size比较小可以用一个临时变量逐字节拷贝如果size比较大可以考虑用malloc开一块临时缓冲再用memcpy批量拷贝效率会更高。真实标准库通常会有专门的交换优化针对小对象用栈上缓冲针对超大对象用堆上临时区这些属于工程优化范畴逻辑内核和我上面写的是一致的。不过我要提醒一句上面这个快速排序实现属于教学演示版它在最坏情况比如数组已经有序下会退化成O(n^2)的时间复杂度递归深度还可能达到n级别和标准库经过精心优化的qsort还是有距离的。模板作用在于帮助你理解类型无关的排序框架如果要在生产环境使用还是应该直接调用标准库的qsort或者是用更稳妥的改进快排比如三数取中、插入排序优化等。4. 真实项目里最容易踩的 5 个坑用qsort用得多了自然就积累了不少翻车现场。我把这些常见错误整理成一份避坑清单每一条都是实际项目中可能真实碰到的问题。4.1 比较函数返回值写反升序变降序比较函数返回正数的语义是“a应该排在b后面”返回负数是“a应该排在b前面”。很多人写return b - a;来实现降序但如果b-a发生溢出结果就不稳定。更常见的问题是本来要升序结果写成int cmp(const void *a, const void *b) { return *(int *)b - *(int *)a; }没注意到自己把a和b写反了最后排出来一个降序数组还要怀疑是自己数据出错了。这类问题很难靠编译器发现建议在写完比较函数后先用一个小数组测试一遍升序降序是否符合预期。4.2 忽略第三个参数 size 导致的越界还有一种经典翻车是把sizeof(arr[0])误写成sizeof(arr)。如果你对一个int arr[10]调用qsort(arr, 10, sizeof(arr), cmp)那么第三个参数是40qsort会认为每个元素占40字节元素个数还是10于是它试图访问从地址0到地址399的内存远超数组实际占用的40字节几乎必然越界崩溃。特别是对结构体排序时元素大小更大越界后的表现更隐蔽有时不崩但排序结果完全不对有时在函数返回时崩有时破坏相邻数据导致完全无关的变量值被篡改。调试这类问题第一步永远是检查size参数是不是真的等于一个元素占用的字节数。4.3 比较的是指针而不是值这个坑在字符串数组场景里最常见。假设你有char *arr[10]每个元素是字符串指针比较函数里写成了int cmp(const void *a, const void *b) { return (int)(*(char **)a - *(char **)b); }虽然语法上没错但这里比较的是两个字符串指针的地址差值而不是字符串内容。指针大小通常是8字节相减结果本质上是内存地址的差值和字符串内容没有任何关系排序结果自然毫无意义。正确做法是解引用后调用strcmp。同理如果你定义的是二维字符数组比如char arr[10][32]那么数组元素本身就是长度为32的字符块比较函数里应该把指针转换成char (*)[32]然后取(char *)a直接传给strcmp。这里容易犯的错是照抄字符串指针数组的写法直接写成*(char **)a结果拿到的是前8个字节拼出来的一个假指针strcmp会去访问一个非法地址。这类错误排查起来特别痛苦因为运行时行为极不稳定。4.4 不能直接用 qsort 对 const 修饰的数组排序如果你的数组声明为const int arr[] {...};然后试图调用qsort去排序编译器会警告你因为qsort的base参数是void *把const int *传过去等于抛弃了const限定可能通过qsort内部修改原本只读的内存。如果真的存在通过非常规指针修改const数据的情况后果由标准未定义行为接管可能是字符串常量区的写保护错误也可能是干脆不报错但数据悄悄变化。对这种需求正确思路是拷一份可变数组出来排序或者把原始数组声明成非const。不要和编译器对着干不要试图强转绕过const警告这种挖坑行为在后续维护时最容易坑队友。4.5 大数组下的递归深度与性能权衡qsort在标准库实现里一般会采取很多优化手段比如小区间使用插入排序、递归深度过深时改用堆排序来保证最坏时间复杂度O(n log n)。但你自己模拟实现或者在某些特殊平台上使用阉割版qsort时递归深度可能是O(n)数量级这对超大规模数组会有栈溢出风险。一个实用建议是排序对象超过几百万个元素时先评估一下你的运行环境栈空间大小。在Linux上默认栈大小往往有8MBWindows上通常1MB一旦递归深度过深程序就直接崩溃。标准库的qsort一般做了折中设计但你手写的快排版本并没有这些保障所以如果是在真实项目里使用自己写的排序函数务必针对最坏情况做过测试。常见错误症状解决办法比较函数a/b写反排序方向反了小数组验证升序size传错越界、排序错乱、随机崩溃用sizeof(arr[0])比较指针而非值结果看似排序但顺序奇怪先解引用再比较内容const数组传给qsort编译警告或运行崩溃拷贝后排序递归深度失控栈溢出、程序闪退用系统qsort或优化算法5. 从 qsort 到 C 泛型设计以及我的几条实战建议qsort用多了以后你会发现它其实是C语言里理解“泛型”思想的一扇门。虽然C语言本身没有C的模板或Java的泛型概念但通过void *和函数指针的搭配标准库一样实现了“一套排序逻辑适配所有类型”的效果。这种古老而优雅的设计放到今天看依然值得学习。5.1 哪些场景别用 qsort或者要谨慎用qsort虽然好用但也不是银弹。第一它是基于值拷贝交换的排序所以如果你需要对超大型结构体数组频繁排序元素拷贝开销可能比比较开销还大这时可以考虑排序一个索引数组也就是间接排序避免大量结构体内存搬运。第二qsort不保证排序稳定性也就是说两个相等元素的相对顺序在排序后可能发生变化。如果业务要求稳定排序比如先按时间排时间相同要保留原来的先后顺序那qsort就不满足要求需要换成稳定的归并排序或基于额外索引的排序。还有一种场景是数组本身已经基本有序这时候标准库qsort可能也会因为快排的划分策略产生额外的比较和交换开销性能反而不如插入排序。我见过不少开发者在只有五六个元素的数组上也坚持用qsort虽然代码统一了但其实性能并不是最优解。工程上要养成“根据数据规模选择排序策略”的意识小数组用插入排序中等数组用快排海量数据要考虑外部排序。5.2 一条扩展经验用 qsort 实现“间接排序”我在实际项目里用过一种技巧把qsort的“比较任意元素”能力发挥得淋漓尽致不排数据本身排序索引数组。假设你有两个数组key[]和value[]你想让key有序排列同时让value跟着对应位置走但不想搬动两个数组。可以定义一个索引数组idx[] {0, 1, 2, ...}然后写一个比较函数不直接比较idx里的值而是去比较key[idx[i]]。这样qsort就把索引数组排好序你再按索引顺序访问key和value既避免了数据搬迁又实现了多数组联动排序。#include stdio.h #include stdlib.h int *global_key; // 仅用来演示真实项目中请通过全局或上下文方式传入 int cmp_by_key(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (global_key[ia] global_key[ib]) - (global_key[ia] global_key[ib]); } int main(void) { int key[] {30, 10, 20}; int value[] {100, 200, 300}; int idx[] {0, 1, 2}; global_key key; qsort(idx, 3, sizeof(idx[0]), cmp_by_key); for (int i 0; i 3; i) { printf(key%d value%d\n, key[idx[i]], value[idx[i]]); } return 0; }这段代码的精髓在于比较函数拿到的是两个数组下标但比较时使用的是对应下标的key值。这样你得到了一个有序的索引序列而原始数组没有被改动。这种套路在需要同时维护多列数据且不想封装成结构体数组时特别有用。当然全局变量global_key在多线程环境下不安全这只是示范工程化时可以借助线程局部存储或其他方式传入上下文。最后说一点我自己的真实体会qsort这个函数在很多人眼里就是“标准库里的排序工具”会调用就算会了。但如果你愿意动手模拟实现一遍把它从“工具”变成“学件”你的C语言水准会有很明显的提升。它把一个排序算法和类型系统彻底解耦让你体会到函数指针作为一种参数的巨大威力。在学习过程中你可以从冒泡版开始再尝试快排版甚至还可以写一个堆排序版或归并版每次重写都是对指针和内存模型的深化理解。我现在看到C代码里的qsort调用已经不觉得它只是“排序函数”而是C语言设计哲学的一份标准答案。如果你以后要接触Linux内核代码、驱动开发或者其他偏底层的C项目这种对void指针、函数指针、内存块操作的敏感度一定会帮上大忙。
返回列表