1. 一句话总结
qsort 是 C 标准库提供的通用排序函数,核心思想是 void* 泛型指针 + 函数指针回调,让我们用一套排序逻辑排任意类型的数据。
2. 知识地图
qsort 与通用排序
├── 1. 普通冒泡排序(只能排 int)
│ ├── 双层循环 + 比较 + 交换
│ └── flag 优化(提前终止)
│
├── 2. qsort —— C 标准库的通用排序
│ ├── void* 泛型指针(不知道具体类型,不关心)
│ ├── size_t sz —— 元素个数
│ ├── size_t size —— 单个元素占多少字节
│ └── 比较函数指针 int (*cmp)(const void*, const void*)
│ ├── 返回值约定:>0 大于,==0 等于,<0 小于
│ ├── 排序 int 数组:*(int*)p1 - *(int*)p2
│ ├── 排序结构体(按名字):strcmp
│ └── 排序结构体(按年龄):(a>b) - (a<b) 模式
│
├── 3. 自己实现通用冒泡排序
│ ├── 函数签名仿照 qsort
│ ├── 元素定位:(char*)base + j * size
│ ├── 比较:回调 cmp 函数
│ └── 交换:逐字节 swap(核心难点)
│
├── 4. 相关知识点
│ ├── 结构体指针访问:-> 操作符
│ ├── strlen 遇到 \0 就停
│ └── const char* vs char[] 的区别
│
└── 5. 技巧与模式
├── (a > b) - (a < b) 避免整数溢出
└── 回调函数设计模式(传入行为,而非数据)
关系说明:上面这张图是一棵「进化树」——从最底层的普通冒泡排序开始,逐步引入 void*、函数指针、逐字节交换,最终进化到通用排序。这是一个典型的「从具体到抽象」的学习路径。
3. 核心概念深度解析
3.1 void* 泛型指针:C 语言的「万能接头」
void* 是 C 语言里最特别的指针类型。它不指向任何具体类型,因此不能直接解引用,也不能做指针算术运算。但正是这种「什么都不知道」的特性,让它成为了泛型编程的基石。
普通指针(int*):
┌──────────────────────────────────────┐
│ int *p = &a; │
│ *p = 10; // 可以,编译器知道要读写 4 字节 │
│ p + 1; // 可以,编译器知道步长是 4 字节 │
└──────────────────────────────────────┘
void* 泛型指针:
┌──────────────────────────────────────┐
│ void *p = &a; │
│ *p = 10; // ❌ 编译错误!不知道要写几字节 │
│ p + 1; // ❌ 编译错误!不知道步长是多少 │
│ *(int*)p; // ✅ 强制转换后就可以用了 │
│ (char*)p+1; // ✅ 转为 char* 后按 1 字节步进 │
└──────────────────────────────────────┘
qsort 为什么用 void*?
qsort 的设计者不知道你会用它排什么数据——可能是 int、double、结构体、甚至是你自己定义的类型。如果写成 int*,那只能排 int;写成 void*,就什么都能排。
类比:void* 就像一个万能电源插头,它可以插到任何类型的插座上,但你必须先告诉它「当前插的是什么」(通过强制类型转换),才能正常使用。
3.2 通用冒泡排序的逐字节交换:swap 函数的秘密
这是整个代码中最精妙的部分。当数据类型未知时,如何交换两个元素?
假设我们要交换两个结构体(每个 24 字节):
交换前:
buf1 → ┌──────────────────────────┐
│ "zhangsan" | 20 │ 24 字节
└──────────────────────────┘
buf2 → ┌──────────────────────────┐
│ "wu" | 18 │ 24 字节
└──────────────────────────┘
逐字节交换过程(循环 24 次):
第 1 轮:交换 buf1[0] 和 buf2[0] ← 第 1 字节
第 2 轮:交换 buf1[1] 和 buf2[1] ← 第 2 字节
...
第 24 轮:交换 buf1[23] 和 buf2[23] ← 第 24 字节
交换后:
buf1 → ┌──────────────────────────┐
│ "wu" | 18 │ 24 字节
└──────────────────────────┘
buf2 → ┌──────────────────────────┐
│ "zhangsan" | 20 │ 24 字节
└──────────────────────────┘
关键代码拆解:
void swap(char *buf1, char *buf2, size_t size)
{
for (int i = 0; i < size; i++)
{
char tmp = *buf1; // 取出 buf1 当前字节
*buf1 = *buf2; // 把 buf2 当前字节写入 buf1
*buf2 = tmp; // 把暂存的值写入 buf2
buf1++; // 指针前移一个字节
buf2++; // 指针前移一个字节
}
}
为什么参数用 char* 而不是 void*?
因为 char 恰好占 1 个字节,char* 的步长就是 1 字节。这样我们可以用 buf1++ 逐个字节地遍历,而 void* 不能做 ++ 运算。这是 C 语言中操作任意内存时最常用的技巧。
为什么不用 memcpy?
当然可以用!memcpy 本质上也是逐字节拷贝。但自己手写 swap 能让你真正理解「内存就是一堆字节」这个底层事实。生产环境中直接用 memcpy 更高效(编译器会优化为 SIMD 指令)。
3.3 元素定位公式:(char*)base + j * size
这是通用排序中第二个容易让人困惑的地方。
问题:base 指向数组首元素,但不知道元素类型,怎么访问第 j 个元素?
答案:用字节偏移来定位。
假设 int 数组 {4, 3, 7, 6},每个 int 占 4 字节:
内存布局:
地址: base+0 base+4 base+8 base+12
┌───────┬───────┬───────┬───────┐
│ 4 │ 3 │ 7 │ 6 │
└───────┴───────┴───────┴───────┘
索引: 0 1 2 3
访问 arr[j]:
(char*)base + j * size
= (char*)base + j * 4
j=0: base + 0 → 指向 {4}
j=1: base + 4 → 指向 {3}
j=2: base + 8 → 指向 {7}
j=3: base + 12 → 指向 {6}
为什么一定要转成 char*?
因为 char* 的步长是 1 字节,(char*)base + offset 就是精确的字节偏移。如果直接对 void* 做加法,编译器会报错。如果转成 int* 再做加法,int* 的步长是 4 字节,(int*)base + 1 直接跳 4 字节,语义上是「第 1 个元素」而不是「偏移 1 字节」,容易混淆。
4. 代码实战
4.1 基础冒泡排序(只能排 int)
#include <stdio.h>
// 冒泡排序 —— 带 flag 优化的版本
void bubble_sort(int arr[], int sz)
{
for (int i = 0; i < sz - 1; i++)
{
int flag = 1; // 假设本轮已经有序
for (int j = 0; j < sz - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = 0; // 发生了交换,说明还没完全有序
}
}
if (flag == 1) // 一轮下来没有交换,说明已经有序
break;
}
}
void print(int arr[], int sz)
{
for (int i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
}
int main()
{
int arr[] = {9, 8, 2, 3, 1, 5, 4, 7, 23};
int sz = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, sz);
print(arr, sz);
return 0;
}
4.2 使用 qsort 排序 int 数组
#include <stdio.h>
#include <stdlib.h>
// 比较函数 —— 升序
int cmp_int(const void *p1, const void *p2)
{
return (*(int*)p1 > *(int*)p2) - (*(int*)p1 < *(int*)p2);
}
void print(int arr[], int sz)
{
for (int i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
}
void test1()
{
int arr[] = {2, 4, 6, 31, 3, 54, 23, 54};
int sz = sizeof(arr) / sizeof(arr[0]);
qsort(arr, sz, sizeof(arr[0]), cmp_int);
print(arr, sz);
}
4.3 使用 qsort 排序结构体数组
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct stu
{
char name[20];
int age;
};
// 按名字排序(升序)
int cmp_struct_name(const void *e1, const void *e2)
{
return strcmp(((const struct stu *)e1)->name,
((const struct stu *)e2)->name);
}
// 按年龄排序(升序)
int cmp_struct_age(const void *p1, const void *p2)
{
const struct stu *s1 = (const struct stu *)p1;
const struct stu *s2 = (const struct stu *)p2;
return (s1->age > s2->age) - (s1->age < s2->age);
}
void print1(struct stu arr1[], int sz)
{
for (int i = 0; i < sz; i++)
printf("名字:%s 年龄:%d\n", arr1[i].name, arr1[i].age);
printf("\n");
}
void test2()
{
struct stu arr1[] = {{"zhangsan", 20}, {"wu", 18}, {"yi", 13}};
int sz1 = sizeof(arr1) / sizeof(arr1[0]);
qsort(arr1, sz1, sizeof(arr1[0]), cmp_struct_age);
print1(arr1, sz1);
}
4.4 自己实现通用冒泡排序(完整版)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// --- 比较函数(同上面定义) ---
int cmp_int(const void *p1, const void *p2)
{
return (*(int*)p1 > *(int*)p2) - (*(int*)p1 < *(int*)p2);
}
struct stu { char name[20]; int age; };
int cmp_struct_age(const void *p1, const void *p2)
{
const struct stu *s1 = (const struct stu *)p1;
const struct stu *s2 = (const struct stu *)p2;
return (s1->age > s2->age) - (s1->age < s2->age);
}
// --- 核心:逐字节交换 ---
void swap(char *buf1, char *buf2, size_t size)
{
for (int i = 0; i < size; i++)
{
char tmp = *buf1;
*buf1 = *buf2;
*buf2 = tmp;
buf1++;
buf2++;
}
}
// --- 通用冒泡排序 ---
void bubble_sort(void *base, size_t sz, size_t size,
int (*cmp)(const void *p1, const void *p2))
{
for (int i = 0; i < sz - 1; i++)
{
for (int j = 0; j < sz - 1 - i; j++)
{
// 比较 arr[j] 和 arr[j+1]
if (cmp((char *)base + j * size,
(char *)base + (j + 1) * size) > 0)
{
// 交换这两个元素
swap((char *)base + j * size,
(char *)base + (j + 1) * size, size);
}
}
}
}
// --- 测试 ---
void print(int arr[], int sz)
{
for (int i = 0; i < sz; i++)
printf("%d ", arr[i]);
printf("\n");
}
void print1(struct stu arr1[], int sz)
{
for (int i = 0; i < sz; i++)
printf("名字:%s 年龄:%d\n", arr1[i].name, arr1[i].age);
printf("\n");
}
void test3()
{
int arr[] = {4, 3, 7, 6, 9, 8, 1, 5};
int sz = sizeof(arr) / sizeof(arr[0]);
bubble_sort(arr, sz, sizeof(arr[0]), cmp_int);
print(arr, sz);
}
void test4()
{
struct stu arr1[] = {{"zhangsan", 20}, {"wu", 18}, {"yi", 13}};
int sz1 = sizeof(arr1) / sizeof(arr1[0]);
bubble_sort(arr1, sz1, sizeof(arr1[0]), cmp_struct_age);
print1(arr1, sz1);
}
int main()
{
test3(); // 用我们的通用冒泡排序排 int
test4(); // 用我们的通用冒泡排序排结构体
return 0;
}
5. 注释提炼
5.1 知识注解类 —— 保留并扩展
qsort 函数签名解读
void qsort(void *base, // 第一个参数:指向数组首元素的指针(void* 表示不关心类型)
size_t nmemb, // 第二个参数:数组元素个数
size_t size, // 第三个参数:单个元素占用的字节数
int (*compar)(const void *, const void *)); // 第四个参数:比较函数指针
这里 size_t 是 <stddef.h> 中定义的无符号整数类型,专门用于表示「大小」和「个数」。sizeof 运算符的返回值就是 size_t。用 size_t 而不是 int 的原因是:数组元素个数理论上不会为负,使用无符号类型可以表达更大的范围,也更语义化。
比较函数的返回值约定
| 返回值 | 含义 |
|---|---|
> 0 | 第一个参数「大于」第二个参数 |
== 0 | 两个参数「相等」 |
< 0 | 第一个参数「小于」第二个参数 |
qsort 默认排成升序。如果你想让 cmp 返回 >0 时交换,那 cmp(a,b) > 0 意味着 a > b 时交换,结果就是小的在前 —— 升序。如果你把 cmp 的返回值取反,就变成降序。
strlen 的行为
size_t len = strlen("abc\0def");
printf("%zd\n", len); // 输出 3,不是 7
strlen 从起始地址开始扫描,遇到第一个 \0 就停止计数。字符串 "abc\0def" 在内存中实际是 a b c \0 d e f \0,strlen 数到第一个 \0 就返回 3。这是新手经常踩的坑。
const char* vs char[]
const char* str = "abcdef"; // 指向字符串字面量,内容不可修改
char str[] = "abcdef"; // 在栈上分配数组,内容可以修改
第一行:str 是一个指针,指向只读数据段中的字符串字面量。任何试图修改 str[0] 的行为都是未定义行为(通常会崩溃)。
第二行:str 是一个数组,编译器在栈上分配 7 个字节(6 个字符 + \0),并把 "abcdef" 拷贝进去。你可以随意修改 str[0]。
5.2 个人理解类
我的理解:为什么要用函数指针作为 qsort 的参数?
排序的「骨架」是不变的——两层循环(或快排的分治),逐个比较、逐个交换。但「比较」这个行为是变化的——排 int 时比较大小,排字符串时按字典序,排结构体时可能按某个字段。
把「比较」这个行为抽出来,变成一个函数指针参数,这就是策略模式(Strategy Pattern)的 C 语言实现。调用者传入「怎么比较」,qsort 负责「怎么排」。这种「把行为当作参数传递」的设计思想,在后端开发中无处不在(比如 Go 的 sort.Interface、Java 的 Comparator)。
我的理解:(a > b) - (a < b) 这个写法好在哪?
乍一看很绕,拆开就清楚了:
- 如果
a > b:(1) - (0) = 1—— 返回正数,符合 qsort 的约定 - 如果
a == b:(0) - (0) = 0—— 返回 0 - 如果
a < b:(0) - (1) = -1—— 返回负数
这个写法的好处是避免了 a - b 的整数溢出风险。假如 a = INT_MAX,b = -1,那么 a - b 会溢出,导致比较结果错误。而 (a > b) - (a < b) 只做比较运算,不会溢出。
但要注意:这个技巧只适用于基本类型。对于浮点数,> < 比较在 NaN 的情况下行为特殊,需要额外处理。
5.3 踩坑感悟类
⚠️ 踩坑 1:
void*不能直接解引用
void *p = &a;
printf("%d\n", *p); // ❌ 编译错误!
编译器不知道 *p 要读几个字节,是读 1 个(char)还是 4 个(int)还是 8 个(double)?必须强制转换:*(int*)p。
⚠️ 踩坑 2:
void*不能做指针算术
void *p = arr;
p = p + 1; // ❌ 编译错误!步长未知
同样的原因,编译器不知道 p + 1 应该跳多少字节。解决方法是先转成 char*:(char*)p + 1。
⚠️ 踩坑 3:比较函数中
const不能丢
// ❌ 错误写法
int cmp(void *p1, void *p2) { ... }
// ✅ 正确写法
int cmp(const void *p1, const void *p2) { ... }
qsort 的第四个参数类型是 int (*)(const void*, const void*),带 const。如果你写的比较函数不带 const,类型不匹配,编译器会报警告甚至错误。const 的意思是「我不会修改你指向的数据」,这是一个契约。
⚠️ 踩坑 4:
sizeof(arr[0])和sizeof(arr)的区别
int arr[10];
sizeof(arr) // 40 —— 整个数组的大小
sizeof(arr[0]) // 4 —— 单个元素的大小
sizeof(arr) / sizeof(arr[0]) // 10 —— 元素个数
这个公式只在数组定义的作用域内有效。如果把数组作为参数传给函数,arr 会退化为指针,sizeof(arr) 就变成指针大小(8 字节),公式失效。
5.4 对比总结类
普通冒泡排序 vs 通用冒泡排序
| 对比维度 | 普通冒泡排序 | 通用冒泡排序 |
|---|---|---|
| 参数类型 | int arr[] | void *base |
| 适用类型 | 只能排 int | 任意类型 |
| 比较方式 | 直接用 > | 回调函数指针 |
| 交换方式 | int temp 直接赋值 | 逐字节 swap |
| 元素定位 | arr[j] 直接下标 | (char*)base + j*size |
| 代码复用性 | 低,排其他类型要重写 | 高,一套代码通用 |
三种比较函数写法对比
| 写法 | 代码 | 优点 | 缺点 |
|---|---|---|---|
| if-else 分支 | if(a>b) return 1; else if(a==b) return 0; else return -1; | 逻辑清晰,好理解 | 啰嗦 |
| 直接相减 | return a - b; | 简洁 | 可能溢出 |
| 比较运算相减 | return (a>b) - (a<b); | 简洁且不溢出 | 略显晦涩 |
6. 代码修正
6.1 问题:原始代码中 swap 的循环变量声明位置
修改前(C89 风格):
void swap(char *buf1, char *buf2, size_t size)
{
int i = 0;
for (i = 0; i < size; i++)
{
char tmp = *buf1;
*buf1 = *buf2;
*buf2 = tmp;
buf1++;
buf2++;
}
}
修改后(C99/C11 风格,更简洁):
void swap(char *buf1, char *buf2, size_t size)
{
for (int i = 0; i < size; i++)
{
char tmp = *buf1;
*buf1 = *buf2;
*buf2 = tmp;
buf1++;
buf2++;
}
}
原因:C99 开始允许在 for 语句中声明循环变量,这样 i 的作用域被限制在循环内部,不会污染外部命名空间。原始代码中 int i = 0; 在外层定义了但循环内又用 for(i=0;...) 重新赋值,多余了一步。
6.2 问题:原始代码中 cmp_int 的直接相减可能溢出
修改前:
int cmp_int(const void *p1, const void *p2)
{
return *(int*)p1 - *(int*)p2;
}
修改后:
int cmp_int(const void *p1, const void *p2)
{
return (*(int*)p1 > *(int*)p2) - (*(int*)p1 < *(int*)p2);
}
原因:当两个整数差距极大时(比如 INT_MAX 和 INT_MIN),相减会产生溢出,在二进制补码下可能得到一个符号相反的诡异结果,导致排序错乱。(a>b) - (a<b) 只产生 -1、0、1 三种结果,永远不会溢出。
6.3 问题:缺失 flag 优化
说明:原始的通用冒泡排序没有加入 flag 提前终止优化。对于「大部分已经有序」的数组,这会浪费很多无意义的比较。
改进建议(不改动接口):
void bubble_sort(void *base, size_t sz, size_t size,
int (*cmp)(const void *p1, const void *p2))
{
for (int i = 0; i < sz - 1; i++)
{
int flag = 1; // 假设本轮有序
for (int j = 0; j < sz - 1 - i; j++)
{
if (cmp((char *)base + j * size,
(char *)base + (j + 1) * size) > 0)
{
swap((char *)base + j * size,
(char *)base + (j + 1) * size, size);
flag = 0; // 发生了交换,说明还没排好
}
}
if (flag == 1) // 一整轮没有交换,提前结束
break;
}
}
这个优化的本质是:如果某一轮冒泡中没有发生任何交换,说明数组已经有序,后面的轮次都可以跳过。
7. 小结
从「只能排 int 的冒泡排序」到「能排任意类型的 qsort」,中间只隔了三个关键概念:void* 泛型指针、函数指针回调、以及逐字节内存操作。 掌握这三个概念,你就理解了 C 语言中「泛型编程」的核心思想,也为后续学习 C++ 模板、Go 接口、Java 泛型打下了底层认知基础。
442



被折叠的 条评论
为什么被折叠?



