
不少 C 语言学习者在排序这件事上容易走到一个岔路口先是老老实实写冒泡、选择、插入好不容易练熟了交换逻辑等到做题或写项目时发现每次都要重新写一遍循环嵌套代码长得差不多却又不敢直接套用。更尴尬的是当数组元素从int变成char *或者变成一个结构体时原来那套“临时变量交换”的思路立刻就不够用了——你要交换的不再是两个整数而可能是两个结构体、两个指针甚至两个动态分配的对象。这时候再回头看 C 标准库里的qsort就会明白它并不是一个“帮你偷懒的现成函数”而是 C 语言函数指针与内存抽象最典型的启蒙案例。理解qsort能同时打通三件事怎么用标准库完成排序、怎么写出能被回调使用的比较函数、怎么理解void *和字节宽度在底层设计中的作用。这篇文章不讲复杂的排序算法推演而是从“能用、会用、理解原理、落地避坑”四个角度把qsort拆开讲透。如果只说结论那就是qsort 是 C 语言里最值得认真读一遍源码思路的标准库函数多数排序问题都应该从它开始而不是从手写快速排序开始。1. 这篇文章真正要解决的问题先说一个常见的认知误区很多初学者把qsort当成“一个排序函数”只要记住它参数长什么样考试能写对就行。但如果停留在这一层会遇到几个非常实际的问题第一比较函数到底怎么写。网上常见例子是return *(int *)a - *(int *)b于是有人直接照抄去排double、排字符串、排结构体结果要么编译报警告要么排序结果莫名其妙甚至直接段错误。第二排序不同类型时心智模型不一样。排int数组时比较函数里拿到的是“指向数组元素的指针”排字符串数组时数组元素本身是char *比较函数里拿到的却是“指向char *的指针”也就是char **。这个跳变让很多人困惑了很久。第三真正到项目里需要按结构体某个字段排序时很多人发现不会把标准库排给定制的数据结构。本来可以用qsort十行解决的代码硬是写成了一大段“重复轮子式”的排序逻辑。第四qsort的名字叫 quick sort但标准并没有规定它必须用快速排序实现。它只是把排序策略封装起来把元素比较的规则交给用户定义。这个“算法与规则解耦”的思想是很多现代 C 项目能保持简洁的关键。本文会沿着这条线往下走先拆解函数原型再解释比较函数的设计契约然后给出int、double、字符串、结构体、多关键字排序的完整示例最后用手写快速排序做对比让你理解 qsort 到底帮你省掉了哪些工作。读完你应该能做到任意类型的数组只要你能写清楚“两个元素谁在前谁在后”就能用它排序。2. 基础概念与核心原理2.1 认识 qsort 函数原型qsort定义在stdlib.h中原型如下void qsort( void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *) );四个参数的含义分别是参数含义通俗解释base待排序数组的首地址告诉排序函数“数据从哪个地址开始”nmemb数组中元素个数告诉排序函数“一共要排几个元素”size每个元素占用的字节数告诉排序函数“移动一个元素需要拷贝多少字节”compar比较函数指针告诉排序函数“两个元素谁大谁小、谁排在前面”前三个参数看起来都很直白关键是最后一个compar。它是一个函数指针要求指向一个“接收两个const void *、返回int”的函数。返回值约定如下如果返回值小于 0表示第一个参数应该排在第二个参数前面如果返回值等于 0表示两个元素相等如果返回值大于 0表示第一个参数应该排在第二个参数后面。这就像你去食堂排队时排序规则不是食堂阿姨定死的而是由你给出一句话“谁更饿谁站前面”。qsort只需要不断调用这句话就能完成整个队列的调整。2.2 void * 与字节宽度设计C 语言没有模板怎么做到一套函数排所有类型答案是qsort不关心元素类型它只关心三件事——数据从哪里开始、有多少个元素、每个元素多大。有了元素大小它就能计算第 i 个元素的地址(char *)base i * size这里把void *转成char *是出于地址运算的需要因为 C 语言里void *不能直接参与加减运算。按字节移动地址后再根据size拷贝整块内存就能完成元素的搬移。这也是为什么qsort的比较函数接收const void *而不是具体类型。因为它自己也不知道元素是什么它只是把“第 m 个元素的地址”和“第 n 个元素的地址”递给你的比较函数。你的比较函数才真正知道这个地址应该解释成int *、double *还是struct Student *。理解了这一点就会明白比较函数第一行那个看起来很吓人的强制类型转换到底是什么const int *pa (const int *)a;a不是整数本身而是“指向整数的指针”。不强制转换就解引用编译器会直接报错强转成错误的类型结果就会完全错乱。2.3 qsort 与稳定排序需要明确一点qsort不保证稳定排序。所谓稳定排序是指当两个元素的比较结果相等时它们的相对顺序是否保持原样。在按单一主键排序时稳定性通常无关紧要但如果先按第一关键字排序再按第二关键字排序第二趟排序的稳定性就会影响最终结果。qsort不承诺稳定性所以当业务逻辑要求“先按分数降序分数相同按学号升序”时不要指望调用两次qsort就能得到正确结果。正确做法是在一个比较函数里同时处理两个字段直接写出多关键字比较逻辑。这一点后面的结构体示例会详细演示。3. 从冒泡到 qsort为什么要有函数指针在写第一个示例之前值得先把思想转变讲清楚。假设你有一个int数组排序逻辑无非是两层循环加交换for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } }这段代码几乎不可能直接复用到double数组、字符串数组或结构体数组上比较那一行依赖具体类型交换那一行也依赖具体类型。把元素变成结构体之后一次交换可能要拷贝几十个字节甚至更多。qsort的做法是把“比大小”抽出来通过函数指针交给调用方。排序算法本身只需要知道“如果 compar 说前面更大就把两个元素互换位置”。这种“框架写流程用户写规则”的套路就是回调函数的基本模型也是理解很多 C 工程代码的钥匙。从学习路径看手写冒泡排序理解循环和交换是必要的但进入真实编码后能根据需求写出正确的比较函数比重复实现排序主逻辑更常见、更重要。这也是为什么很多笔试允许直接使用qsort因为出题人想考察你是否理解数据组织方式而不是让你十分钟内默写一遍快速排序。4. 环境准备与最小示例4.1 环境准备只要开发环境里安装了 C 编译器即可。常见的组合是Windows 上使用 Visual Studio 或 MinGW-w64Linux/macOS 上使用 GCC 或 Clang在线环境可以使用 Compiler Explorer、各种在线 IDE。下面所有示例都遵循 C99 标准。如果你使用 GCC可以用下面这条命令编译gcc -stdc99 -Wall -Wextra -o demo demo.c-Wall和-Wextra能帮你发现很多类型不匹配的问题建议一直开着。4.2 第一个示例对 int 数组升序排序创建一个文件int_sort.c内容如下// 文件int_sort.c // 功能使用 qsort 对 int 数组升序排序 #include stdio.h #include stdlib.h // 比较函数升序 int cmp_int_asc(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; if (ia ib) { return -1; } else if (ia ib) { return 1; } return 0; } // 比较函数降序 int cmp_int_desc(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; if (ia ib) { return -1; } else if (ia ib) { return 1; } return 0; } void print_array(const int *arr, size_t n) { for (size_t i 0; i n; i) { printf(%d , arr[i]); } printf(\n); } int main(void) { int arr[] { 42, 7, 19, -3, 100, 56, 0 }; size_t n sizeof(arr) / sizeof(arr[0]); printf(原始数组); print_array(arr, n); qsort(arr, n, sizeof(int), cmp_int_asc); printf(升序结果); print_array(arr, n); qsort(arr, n, sizeof(int), cmp_int_desc); printf(降序结果); print_array(arr, n); return 0; }这段代码没有采用网上常见的return *(int *)a - *(int *)b;缩写形式而是用了分支判断。原因是减法形式在int差值可能溢出时会出现错误比如INT_MAX和INT_MIN相减。虽然实际面试题里数组元素很少碰到这种极端值但从一开始养成安全的比较写法更稳。int ia *(const int *)a;这一句表示把a从“指向 void 的指针”转换成“指向 const int 的指针”再解引用取出整数。注意qsort传给比较函数的是元素指针不是元素本身。运行结果预期为原始数组42 7 19 -3 100 56 0 升序结果-3 0 7 19 42 56 100 降序结果100 56 42 19 7 0 -3如果程序输出符合这个顺序说明第一个 qsort 示例已经跑通。5. 常见类型排序完整示例5.1 double 数组排序与精度问题double类型的排序思路和int类似但有一个细节必须提醒比较函数返回的是int如果把两个double直接相减再返回系统会做一次隐式类型转换小数部分被截断。这个截断在绝大多数情况下不影响排序结果但遇到两个接近的数时可能因为截断得到 0导致排序不稳定甚至次序异常。推荐写法是直接比较// 文件double_sort.c // 功能使用 qsort 对 double 数组排序 #include stdio.h #include stdlib.h #include math.h int cmp_double_asc(const void *a, const void *b) { double da *(const double *)a; double db *(const double *)b; if (isnan(da) || isnan(db)) { return 0; } if (da db) { return -1; } else if (da db) { return 1; } return 0; } int main(void) { double arr[] { 3.14, -1.5, 2.718, 0.001, 100.0, -42.8 }; size_t n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(double), cmp_double_asc); for (size_t i 0; i n; i) { printf(%.3f , arr[i]); } printf(\n); return 0; }这里出现了isnan判断。因为 IEEE 754 标准下NaN与任何数的比较结果都是 false如果不处理NaN会被当作“既不小于也不大于其他数”造成错误的交换行为。工程上排序前通常要先清洗数据但比较函数内部做基础兼容能让程序更健壮。5.2 字符串指针数组排序字符串数组声明为char *strs[]时每个元素是一个char *。qsort比较函数收到的指针就是指向char *的指针所以要先转换成char **再解引用拿到真正的字符串地址。判断字符串大小使用字符串库中的strcmp// 文件string_sort.c // 功能使用 qsort 对字符串指针数组排序 #include stdio.h #include stdlib.h #include string.h int cmp_str_asc(const void *a, const void *b) { // a 实际上是 char **取出后是 char * 类型的字符串 const char *sa *(const char **)a; const char *sb *(const char **)b; return strcmp(sa, sb); } int cmp_str_desc(const void *a, const void *b) { const char *sa *(const char **)a; const char *sb *(const char **)b; return strcmp(sb, sa); } int main(void) { const char *fruits[] { banana, apple, cherry, date, blueberry }; size_t n sizeof(fruits) / sizeof(fruits[0]); printf(原始数组\n); for (size_t i 0; i n; i) { printf( %s\n, fruits[i]); } qsort(fruits, n, sizeof(char *), cmp_str_asc); printf(字典序升序\n); for (size_t i 0; i n; i) { printf( %s\n, fruits[i]); } qsort(fruits, n, sizeof(char *), cmp_str_desc); printf(字典序降序\n); for (size_t i 0; i n; i) { printf( %s\n, fruits[i]); } return 0; }这个例子最容易写错的地方就是把比较函数写成int cmp_str_wrong(const void *a, const void *b) { // 错误把参数当成字符串本身 return strcmp((const char *)a, (const char *)b); }为什么错因为qsort调用比较函数时传的是数组元素地址。数组元素是char *所以元素地址是char **。如果直接强转成const char *就等于把一个地址解释成了字符串起始地址读到的字节很可能是字符串指针本身的低地址数据结果要么乱序要么崩溃。再看一个特殊点对字符串排序时qsort交换的是数组里的指针变量不是字符串内容本身。实际内存中的字符串常量或字符串缓冲区并没有移动位置只是指针数组里的指向关系变了。这也是数组元素是“指针”类型时size参数要填sizeof(char *)的原因。5.3 结构体按单字段排序真实业务中排序对象往往是结构体数组。比如一个学生信息表要按成绩降序排列。// 文件student_sort.c // 功能按成绩对学生结构体数组排序 #include stdio.h #include stdlib.h #include string.h typedef struct { char name[32]; int id; double score; } Student; int cmp_student_by_score_desc(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; } else if (sa-score sb-score) { return 1; } return 0; } void print_students(const Student *students, size_t n) { for (size_t i 0; i n; i) { printf(%-10s id%-3d score%.2f\n, students[i].name, students[i].id, students[i].score); } } int main(void) { Student students[] { { Alice, 101, 89.5 }, { Bob, 102, 76.0 }, { Charlie, 103, 93.2 }, { David, 104, 85.5 } }; size_t n sizeof(students) / sizeof(students[0]); printf(排序前\n); print_students(students, n); qsort(students, n, sizeof(Student), cmp_student_by_score_desc); printf(\n按成绩降序排序后\n); print_students(students, n); return 0; }关键点在比较函数内部const Student *sa (const Student *)a;qsort传给比较函数的是结构体数组里第 i 个元素的地址所以强转成Student *就可以用箭头运算符访问字段。这与int数组其实是一模一样的逻辑参数总是“指向元素的指针”。结构体如果较大qsort内部交换时确实需要整个拷贝结构体。一次 swap 可能涉及上百字节的memcpy。对于大多数数据量在几千、几万级别的场景这完全可以接受如果数组很大且结构体很重可以改为排序索引数组或指针数组避免反复搬动大量结构体数据。这一点在后面的工程建议里再展开。5.4 结构体按多关键字排序业务中更常见的是多关键字排序比如“总分高的排前面总分相同则语文成绩高的排前面再相同则学号小的排前面”。多关键字比较的正确思路不是写多个qsort而是在一个比较函数中按优先级依次判断// 文件multi_key_sort.c // 功能对学生按总分降序、语文成绩降序、学号升序排序 #include stdio.h #include stdlib.h #include string.h typedef struct { int id; char name[32]; int chinese; int math; int english; int total; } Student; int cmp_student_multi(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; // 第一关键字总分降序 if (sa-total ! sb-total) { return (sa-total sb-total) ? -1 : 1; } // 第二关键字语文成绩降序 if (sa-chinese ! sb-chinese) { return (sa-chinese sb-chinese) ? -1 : 1; } // 第三关键字学号升序 if (sa-id ! sb-id) { return (sa-id sb-id) ? -1 : 1; } return 0; } void print_student(const Student *s) { printf(id%d %-10s 语文%3d 数学%3d 英语%3d 总分%3d\n, s-id, s-name, s-chinese, s-math, s-english, s-total); } int main(void) { Student students[] { { 101, Alice, 90, 88, 95, 273 }, { 102, Bob, 85, 95, 88, 268 }, { 103, Charlie, 88, 92, 93, 273 }, { 104, David, 90, 85, 90, 265 }, { 105, Eva, 88, 92, 93, 273 } }; size_t n sizeof(students) / sizeof(students[0]); qsort(students, n, sizeof(Student), cmp_student_multi); for (size_t i 0; i n; i) { print_student(students[i]); } return 0; }这种链式判断很容易读也容易扩展。以后如果新增“数学成绩相同按英语成绩降序”在return 0前插入一组判断即可。如果试图用两个qsort实现“先主后次”结果不一定符合预期因为第二趟排序可能改变第一趟已经排好的次序。如果你的排序规则确实可以拆成多次稳定排序那应选择稳定排序算法但 C 标准库的qsort不保证稳定所以多关键字业务最稳妥的做法就是用一次排序加多级判断。6. 手写快速排序与 qsort 的对比理解qsort最好的方法之一是动手写一遍没有函数指针的快速排序再对比两者差距。假设仍然要排上面的Student结构体按总分降序。手写版本的核心逻辑会变成这样// 文件manual_quick_sort.c // 功能按总分降序手写快速排序演示 #include stdio.h #include stdlib.h typedef struct { int id; int total; } Student; void swap_student(Student *x, Student *y) { Student tmp *x; *x *y; *y tmp; } void quick_sort_student(Student *arr, int left, int right) { if (left right) { return; } int i left; int j right; Student pivot arr[(left right) / 2]; while (i j) { while (arr[i].total pivot.total) { i; } while (arr[j].total pivot.total) { j--; } if (i j) { swap_student(arr[i], arr[j]); i; j--; } } if (left j) { quick_sort_student(arr, left, j); } if (i right) { quick_sort_student(arr, i, right); } } void print_students(const Student *arr, int n) { for (int i 0; i n; i) { printf(id%d total%d\n, arr[i].id, arr[i].total); } } int main(void) { Student students[] { { 101, 273 }, { 102, 268 }, { 103, 271 }, { 104, 265 }, { 105, 285 } }; int n sizeof(students) / sizeof(students[0]); quick_sort_student(students, 0, n - 1); print_students(students, n); return 0; }这段代码能工作但问题很明显如果过几天要按 id 升序排就要把每一处total的比较改成id的比较或者复制一整份新函数。一旦结构体字段多了、排序规则多了手写版本的代码会膨胀得非常快。qsort的价值就在这里排序主流程只写一次变化的部分集中在比较函数里。对排序规则的管理从“修改算法主流程”变成了“新增一个比较函数”。这也正是软件工程里策略模式在 C 语言中的朴素体现。当然qsort也有不如手写排序的地方。比如手写版本可以针对特定数据分布选择不同 pivot 策略而qsort内部策略由 C 库决定不同的平台实现可能有细微差别再比如函数指针调用会让比较逻辑间接跳转对于一次比较开销极小的int排序理论上比手写循环稍微慢一点。不过在现代 CPU 和编译器优化下这种差异对绝大多数业务场景不值一提。性能优化不能靠感觉应该先用qsort写出正确版本通过性能分析发现瓶颈后再考虑重写。7. 常见问题与排查思路qsort用不好问题几乎都出在比较函数上。下面表格汇总了最常见的几类现象。问题现象可能原因排查方式解决方案程序编译通过但直接段错误比较函数内部强转类型与实际数组元素类型不一致检查 qsort 的数组声明、第五个参数传的是哪个数组确认比较函数里*(T *)a中的T和数组元素类型一致字符串数组排序结果乱把char **误写成const char *打印比较函数内部取出的指针和字符串内容使用const char *sa *(const char **)a;结构体数组排序只排了部分字段比较函数只判断了第一个字段后面字段没参与边界构造“第一关键字相同”的测试用例在一个比较函数中按优先级链式返回double排序结果顺序有点奇怪用return (int)(da - db)导致截断打印实际比较函数返回的原始值改用da db、da db分支判断两个int相减得到错误正负号大数减去小数导致int溢出符号翻转用INT_MAX与INT_MIN做用例复现用(ia ib) - (ia ib)或分支判断数组没有按预期升序/降序排列比较函数返回的符号方向理解反了构造两个元素的数组跑最小复现记住第一个元素小返回负第一个元素大返回正得到升序qsort排序前数组被改成其他值排序时误操作了传入的base指针之外的内存开启 AddressSanitizer 编译运行检查nmemb和size是否正确防止越界结构体数组太大导致排序慢结构体字段多元素搬移需要大量memcpy统计结构体sizeof改用指针数组或索引数组只搬指针不搬结构体排查思路一般遵循这四步第一步做最小化复现。把数组缩减到 3 到 5 个元素元素值选能体现排序规则的边界值。第二步打印比较函数每次收到的值。可以临时在比较函数里加printf输出a、b解引用后的内容确认类型解释是否正确。第三步确认比较函数返回符号。把排序前数组和结果数组打印出来对照“小于 0 表示第一个参数在前”的契约判断是不是方向写反。第四步开着编译器告警和 sanitizer。编译时加-Wall -Wextra如果使用 GCC/Clang 还可以加-fsanitizeaddress,undefined能在段错误发生时给出行号信息。8. 最佳实践与工程建议8.1 比较函数优先采用“不溢出、无副作用”写法尽量少用相减表达式尤其不要在int、double场景直接相减并返回。下面的写法在各种不同类型间都通用return (ia ib) - (ia ib);这个表达式的原理是当ia ib时第一个比较结果为 1第二个为 0结果为 1当ia ib时结果为 -1相等结果为 0。它比分支写法更短同时避免了算术溢出的风险。对double同样适用但从double到int的隐式转换仍由比较结果保证只会得到 -1、0、1 三个值不会损失大小区分。8.2 不要在比较函数里修改元素compar参数声明为const void *就是提醒你“只读不改”。排序过程中内部可能会多次比较同一个元素如果在比较函数里临时修改了元素内容排序结果将完全不可预测。如果真的需要按某个字段排序、同时保留另一个字段的关联正确做法是在排序前算好派生字段而不是在比较函数里动态计算并修改。8.3 结构体很大时考虑索引数组或指针数组假设每条员工记录占用 200 字节数组长度 100 万qsort内部交换时会产生不小的内存拷贝开销。更稳妥的设计是额外生成一个索引数组排序索引而非排序结构体本身// 文件index_sort_demo.c // 功能通过排序索引数组避免搬动大结构体 #include stdio.h #include stdlib.h typedef struct { int id; char padding[64]; double score; } Employee; int cmp_emp_index(const void *a, const void *b) { size_t ia *(const size_t *)a; size_t ib *(const size_t *)b; // 这里基于全局/传入数据比较时注意线程安全和可重入问题 // 示例略去实际数组访问重点在 Index 数组的排序思路 return 0; }需要注意qsort比较函数只接收两个待比较元素的地址无法直接拿到结构体数组的上下文。在上面这个思路里索引数组如果想与具体员工数据关联要么借助文件作用域的全局指针要么把员工数据本身设计成索引结构。更通用的推荐是定义“员工指针数组”让qsort的base是Employee *组成的数组每个元素是 8 字节的指针交换成本远低于整个结构体。不过指针数组排序后原数组顺序不变使用时需通过指针间接访问。8.4 排序方向统一用“升序比较函数 调用参数反转”表达很多人会在一个文件里同时写升序和降序两个比较函数cmp_int_asc cmp_int_desc这种做法不是不行但容易造成重复代码。如果比较函数较多可以在调用时通过交换参数复用升序版本// 降序把比较函数两个参数互换调用 int cmp_int_desc(const void *a, const void *b) { return cmp_int_asc(b, a); }这样降序比较函数只是一个转发壳真正的比较规则只有一份后续修改规则时只需改底层的主比较逻辑。8.5 排序前想清楚数据和边界先检查数组长度是否为 0。qsort的nmemb传入 0 时行为是合法的什么也不做但调用方如果已经对base做了解引用仍可能越界。另外如果数组元素是动态堆区分配的指针排序只是交换指针不影响堆内存自身的释放逻辑如果元素是结构体且包含指向动态内存的指针排序过程不会释放或移动这些内存只需注意最终释放时不要重复释放。8.6 避免在比较函数里依赖可变全局状态qsort的比较函数是普通函数指针没有context参数所以如果需要依赖外部排序参数常见的做法是使用文件作用域变量。这在多线程环境下存在竞争风险。如果一个平台下单线程排序或者能保证排序过程中参数不变用全局变量勉强可行但从可维护性和并发安全角度更推荐把数据设计成指针数组使比较函数只通过待比较元素本身就能得出结论。9. 从 qsort 理解 C 语言进阶路径qsort只算得上函数指针应用的一个起点。弄懂它之后再去学bsearch二分查找、信号处理函数、线程创建函数、各种回调注册接口思路会顺畅很多因为它们都遵循同一套模式框架负责流程调用方提供策略。比较函数这个“回调”理解透之后可以继续读一读你的 C 标准库实现里qsort.c的源码观察它如何处理通用内存交换、如何决定使用快速排序还是插入排序的阈值。很多现代 C 库会在递归深度超过限制时改用堆排序避免最坏情况的退化这些细节远比“背四个参数”有价值。如果再往前走一步还可以尝试用函数指针表模拟面向对象中的“多态”或者用结构体封装数据和操作集合写出易于扩展的 C 模块。到那时你再回头看数组排序会发现当初困扰你的根本不是排序本身而是如何组织代码让排序规则能够被独立定义、复用和测试。不过落到眼前最实用的建议就一条下次遇到任何“把一组数据按某个规则排一下”的需求时先不要急着写双层循环试着先写出比较函数再用一行qsort把它接起来。先跑通再优化如果排错了最值得检查的往往不是标准库而是你自己的比较函数。