在 C 语言开发中,排序算法是核心基础模块,不同场景下需选择适配的排序方案。本文将聚焦两大核心方向:一是针对内存中数据的快速排序优化(解决大量重复数据、极端分布等场景的性能退化问题),二是针对海量数据的外排序实现(突破内存限制的文件归并排序),结合完整代码与原理分析,帮助读者深入掌握排序算法的工程实践。
一、快速排序的深度优化:解决性能退化痛点
快速排序凭借 O (NlogN) 的平均时间复杂度成为内存排序的首选,但在极端场景(如有序数组、大量重复数据)下,传统实现会出现性能退化至 O (N²) 的问题。本节将分析 3 种经典划分算法,并引入内省排序(Introsort),实现全场景高效排序。
1.1 传统快排的性能瓶颈
快速排序的核心是基准值(key)的划分效果:理想情况下 key 能将数组二分,递归树均匀,性能最优;但遇到以下场景时会退化:
- 有序 / 逆序数组:若固定选首元素为 key,每次划分只能得到 1 个元素和 N-1 个元素的子数组,递归深度达 O (N)
- 大量重复数据:传统划分算法会将重复元素分散到左右子数组,导致划分不均衡,递归次数激增
传统快排的两种经典划分算法存在明显局限:
- Hoare 划分(左右指针法):相对稳定,但重复数据仍会分散
- Lomuto 划分(前后指针法):实现简单,但大量重复数据下划分极不均衡
1.2 三路划分:专门解决大量重复数据
核心思想
将数组划分为三段:[小于key] + [等于key] + [大于key],等于 key 的元素无需参与后续递归,直接减少递归规模。
关键逻辑
- 指针定义:left(等于 key 区间的左边界)、right(等于 key 区间的右边界)、cur(当前遍历指针)
- 遍历规则:
- cur 遇到小于 key 的值:与 left 交换,left++、cur++(扩展小于区间,推进遍历)
- cur 遇到大于 key 的值:与 right 交换,right--(扩展大于区间,cur 不推进,需重新检查交换后的值)
- cur 遇到等于 key 的值:cur++(直接推进遍历)
完整实现代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
// 交换函数
void Swap(int* p1, int* p2) {
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 打印数组
void PrintArray(int* a, int n) {
for (int i = 0; i < n; ++i) {
printf("%d ", a[i]);
}
printf("\n");
}
// 三路划分结构体:返回等于key区间的左右边界
typedef struct {
int leftKeyi;
int rightKeyi;
} KeyWayIndex;
// 三路划分核心函数
KeyWayIndex PartSort3Way(int* a, int left, int right) {
int key = a[left]; // 基准值选左边界(可结合随机选key优化)
int cur = left + 1; // 当前遍历指针从left+1开始
KeyWayIndex kwi;
while (cur <= right) {
if (a[cur] < key) {
Swap(&a[cur], &a[left]);
left++;
cur++;
} else if (a[cur] > key) {
Swap(&a[cur], &a[right]);
right--;
} else {
cur++;
}
}
kwi.leftKeyi = left;
kwi.rightKeyi = right;
return kwi;
}
// 基于三路划分的快速排序
void QuickSort3Way(int* a, int left, int right) {
if (left >= right) return;
// 随机选key:避免有序数组场景的性能退化
int randi = left + (rand() % (right - left + 1));
Swap(&a[left], &a[randi]);
KeyWayIndex kwi = PartSort3Way(a, left, right);
// 仅对小于和大于key的区间递归排序
QuickSort3Way(a, left, kwi.leftKeyi - 1);
QuickSort3Way(a, kwi.rightKeyi + 1, right);
}
// 测试函数
void TestQuickSort3Way() {
int a1[] = {6,1,7,6,6,6,4,9}; // 大量重复数据
int a2[] = {3,2,3,3,3,3,2,3}; // 密集重复数据
int a3[] = {2,2,2,2,2,2,2,2}; // 全重复数据
int n1 = sizeof(a1)/sizeof(int);
int n2 = sizeof(a2)/sizeof(int);
int n3 = sizeof(a3)/sizeof(int);
printf("测试大量重复数据:\n");
PrintArray(a1, n1);
QuickSort3Way(a1, 0, n1-1);
PrintArray(a1, n1);
printf("\n测试密集重复数据:\n");
PrintArray(a2, n2);
QuickSort3Way(a2, 0, n2-1);
PrintArray(a2, n2);
printf("\n测试全重复数据:\n");
PrintArray(a3, n3);
QuickSort3Way(a3, 0, n3-1);
PrintArray(a3, n3);
}
测试结果分析
- 全重复数组:仅需 1 次划分,直接完成排序,无多余递归
- 大量重复数组:等于 key 的元素集中在中间,递归规模大幅减小,性能比传统快排提升 50% 以上
1.3 内省排序(Introsort):解决所有极端场景
三路划分虽优化了重复数据场景,但面对极端 key 选择(如每次选到最小值)仍会退化。内省排序通过 "自我侦测" 机制,结合快排、堆排、插入排序的优势,实现全场景稳定高效。
核心设计思路
- 快排为主:大部分场景下使用快排,保证平均 O (NlogN) 性能
- 堆排兜底 :当递归深度超过
2*logN(N 为数组长度),说明划分不均衡,切换为堆排(堆排不受数据分布影响,稳定 O (NlogN)) - 插入排序优化小数组:数组长度小于 16 时,切换为插入排序(减少递归开销,小数组插入排序效率更高)
完整实现代码
// 堆排序辅助函数:向下调整
void AdjustDown(int* a, int n, int parent) {
int child = parent * 2 + 1;
while (child < n) {
// 选左右孩子中较大的一个
if (child + 1 < n && a[child+1] > a[child]) {
child++;
}
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
} else {
break;
}
}
}
// 堆排序
void HeapSort(int* a, int n) {
// 建堆(向下调整):O(N)
for (int i = (n-1-1)/2; i >= 0; --i) {
AdjustDown(a, n, i);
}
// 排序:O(NlogN)
int end = n-1;
while (end > 0) {
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
// 插入排序(优化小数组)
void InsertSort(int* a, int n) {
for (int i = 1; i < n; ++i) {
int end = i-1;
int tmp = a[i];
while (end >= 0 && tmp < a[end]) {
a[end+1] = a[end];
end--;
}
a[end+1] = tmp;
}
}
// 内省排序核心函数
void IntroSort(int* a, int left, int right, int depth, int maxDepth) {
int n = right - left + 1;
// 小数组用插入排序
if (n < 16) {
InsertSort(a+left, n);
return;
}
// 递归深度超标,切换堆排
if (depth > maxDepth) {
HeapSort(a+left, n);
return;
}
// 快排划分(使用Lomuto划分,可替换为三路划分)
int randi = left + (rand() % (right - left + 1));
Swap(&a[left], &a[randi]);
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) {
Swap(&a[prev], &a[cur]);
}
cur++;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
// 递归排序左右子数组
IntroSort(a, left, keyi-1, depth+1, maxDepth);
IntroSort(a, keyi+1, right, depth+1, maxDepth);
}
// 内省排序入口
void QuickSortIntro(int* a, int left, int right) {
if (left >= right) return;
// 计算最大递归深度:2*log2(N)
int N = right - left + 1;
int maxDepth = 0;
for (int i = 1; i < N; i *= 2) {
maxDepth++;
}
maxDepth *= 2;
IntroSort(a, left, right, 0, maxDepth);
}
// 力扣912.排序数组适配函数
int* sortArray(int* nums, int numsSize, int* returnSize) {
srand(time(0));
QuickSortIntro(nums, 0, numsSize-1);
*returnSize = numsSize;
return nums;
}
// 测试内省排序
void TestIntroSort() {
int a1[] = {1,3,2,4,5,6,7,8}; // 有序数组
int a2[] = {9,8,7,6,5,4,3,2,1}; // 逆序数组
int a3[] = {2,2,3,1,2,3,3,3}; // 重复数据
int n1 = sizeof(a1)/sizeof(int);
int n2 = sizeof(a2)/sizeof(int);
int n3 = sizeof(a3)/sizeof(int);
printf("测试有序数组:\n");
PrintArray(a1, n1);
QuickSortIntro(a1, 0, n1-1);
PrintArray(a1, n1);
printf("\n测试逆序数组:\n");
PrintArray(a2, n2);
QuickSortIntro(a2, 0, n2-1);
PrintArray(a2, n2);
printf("\n测试重复数据数组:\n");
PrintArray(a3, n3);
QuickSortIntro(a3, 0, n3-1);
PrintArray(a3, n3);
}
核心优势
- 无性能退化:极端场景下自动切换堆排,稳定 O (NlogN)
- 效率最优:结合三种排序算法的优势,兼顾大中小数组
- 工程级实现:C++ STL 的 sort 函数正是基于此思想实现
二、外排序:文件归并排序(处理海量数据)
当数据量超过内存限制(如 1000 万条数据),内排序无法直接处理,此时需要外排序。外排序的核心是 "分而治之",通过 "内存排序 + 文件归并" 的策略,将海量数据拆分为可处理的小块,最终合并为有序结果。
2.1 外排序核心原理
- 分块排序:读取内存可容纳的部分数据,排序后写入临时文件(称为 "归并段")
- 多路归并:将多个有序临时文件按归并思想合并,重复此过程直到得到最终有序文件
本文实现二路归并外排序,流程如下:
- 读取 N 条数据→内存排序→写入 file1
- 再读取 N 条数据→内存排序→写入 file2
- 归并 file1 和 file2→生成有序中间文件 mfile
- 删除 file1、file2,将 mfile 重命名为 file1
- 重复上述步骤,直到所有数据处理完毕
2.2 完整实现代码
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
// 交换函数(复用前文实现)
void Swap(int* p1, int* p2) {
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 堆排序(用于内存中分块排序,复用前文实现)
void AdjustDown(int* a, int n, int parent) {
int child = parent * 2 + 1;
while (child < n) {
if (child + 1 < n && a[child+1] > a[child]) {
child++;
}
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
} else {
break;
}
}
}
void HeapSort(int* a, int n) {
for (int i = (n-1-1)/2; i >= 0; --i) {
AdjustDown(a, n, i);
}
int end = n-1;
while (end > 0) {
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
}
// 功能:从输入文件读取n个数据,排序后写入输出文件
// 返回值:实际读取到的数据个数(0表示文件结束)
int ReadNNumSortToFile(FILE* fin, int* a, int n, const char* outFile) {
int i = 0;
int x;
// 读取n个数据到内存
while (i < n && fscanf(fin, "%d\n", &x) != EOF) {
a[i++] = x;
}
// 无数据可读,返回0
if (i == 0) {
return 0;
}
// 内存排序(堆排序稳定高效)
HeapSort(a, i);
// 写入临时文件
FILE* fout = fopen(outFile, "w");
if (fout == NULL) {
perror("打开输出文件失败");
exit(-1);
}
for (int j = 0; j < i; ++j) {
fprintf(fout, "%d\n", a[j]);
}
fclose(fout);
return i;
}
// 功能:归并两个有序文件到目标文件
void MergeFile(const char* file1, const char* file2, const char* mergeFile) {
// 打开两个输入文件和一个输出文件
FILE* f1 = fopen(file1, "r");
FILE* f2 = fopen(file2, "r");
FILE* fm = fopen(mergeFile, "w");
if (f1 == NULL || f2 == NULL || fm == NULL) {
perror("文件打开失败");
exit(-1);
}
int num1, num2;
// 读取两个文件的第一个数据
int ret1 = fscanf(f1, "%d\n", &num1);
int ret2 = fscanf(f2, "%d\n", &num2);
// 归并核心逻辑:谁小写谁
while (ret1 != EOF && ret2 != EOF) {
if (num1 < num2) {
fprintf(fm, "%d\n", num1);
ret1 = fscanf(f1, "%d\n", &num1);
} else {
fprintf(fm, "%d\n", num2);
ret2 = fscanf(f2, "%d\n", &num2);
}
}
// 写入剩余数据
while (ret1 != EOF) {
fprintf(fm, "%d\n", num1);
ret1 = fscanf(f1, "%d\n", &num1);
}
while (ret2 != EOF) {
fprintf(fm, "%d\n", num2);
ret2 = fscanf(f2, "%d\n", &num2);
}
// 关闭文件
fclose(f1);
fclose(f2);
fclose(fm);
}
// 功能:生成N个随机数到文件(用于测试)
void CreateRandomData(const char* fileName, int n) {
FILE* f = fopen(fileName, "w");
if (f == NULL) {
perror("创建数据文件失败");
return;
}
srand(time(0));
for (int i = 0; i < n; ++i) {
int x = rand() + i; // 避免重复(可选)
fprintf(f, "%d\n", x);
}
fclose(f);
printf("随机数据生成完成:%s\n", fileName);
}
// 外排序入口函数
// fileName:待排序数据文件
// batchSize:每次读取到内存的数量(根据内存大小调整)
void ExternalMergeSort(const char* fileName, int batchSize) {
FILE* fin = fopen(fileName, "r");
if (fin == NULL) {
perror("打开待排序文件失败");
exit(-1);
}
// 申请内存缓冲区(存储单次读取的数据)
int* buf = (int*)malloc(sizeof(int) * batchSize);
if (buf == NULL) {
perror("内存申请失败");
fclose(fin);
exit(-1);
}
// 临时文件命名
const char* file1 = "tmp1.txt";
const char* file2 = "tmp2.txt";
const char* mergeFile = "merge.txt";
// 第一步:生成前两个有序临时文件
ReadNNumSortToFile(fin, buf, batchSize, file1);
int readCnt = ReadNNumSortToFile(fin, buf, batchSize, file2);
// 循环归并:直到无新数据可读
while (1) {
// 归并file1和file2到mergeFile
MergeFile(file1, file2, mergeFile);
// 删除原临时文件
remove(file1);
remove(file2);
// 将归并后的文件重命名为file1,作为下一轮归并的基础
rename(mergeFile, file1);
// 读取新的数据块到file2
readCnt = ReadNNumSortToFile(fin, buf, batchSize, file2);
// 无新数据,归并结束
if (readCnt == 0) {
break;
}
}
// 资源释放
free(buf);
fclose(fin);
printf("外排序完成!有序文件:%s\n", file1);
}
// 测试外排序
int main() {
// 1. 生成100万条随机数据(约4MB,可调整为1亿条测试)
CreateRandomData("data.txt", 1000000);
// 2. 执行外排序:每次读取10万条数据到内存(可根据内存调整)
ExternalMergeSort("data.txt", 100000);
return 0;
}
2.3 关键优化与注意事项
- 缓冲区大小选择 :
batchSize应根据可用内存调整(如 8GB 内存可设为 100 万),太大易内存溢出,太小则临时文件过多,归并次数增加 - 文件操作优化 :使用
fscanf/fprintf时,尽量减少 IO 次数(本文按行读写,平衡效率与简洁性),生产环境可使用二进制 IO 进一步提升速度 - 错误处理:添加文件打开失败、内存申请失败等异常处理,保证程序鲁棒性
- 多路归并扩展:本文实现二路归并,可扩展为多路归并(如 k 路归并),减少归并轮次,提升海量数据处理效率