深入解析 C 语言排序算法:从快排优化到外排序实现

在 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(当前遍历指针)
  • 遍历规则:
    1. cur 遇到小于 key 的值:与 left 交换,left++、cur++(扩展小于区间,推进遍历)
    2. cur 遇到大于 key 的值:与 right 交换,right--(扩展大于区间,cur 不推进,需重新检查交换后的值)
    3. 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 选择(如每次选到最小值)仍会退化。内省排序通过 "自我侦测" 机制,结合快排、堆排、插入排序的优势,实现全场景稳定高效。

核心设计思路
  1. 快排为主:大部分场景下使用快排,保证平均 O (NlogN) 性能
  2. 堆排兜底 :当递归深度超过2*logN(N 为数组长度),说明划分不均衡,切换为堆排(堆排不受数据分布影响,稳定 O (NlogN))
  3. 插入排序优化小数组:数组长度小于 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 外排序核心原理

  1. 分块排序:读取内存可容纳的部分数据,排序后写入临时文件(称为 "归并段")
  2. 多路归并:将多个有序临时文件按归并思想合并,重复此过程直到得到最终有序文件

本文实现二路归并外排序,流程如下:

  • 读取 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 关键优化与注意事项

  1. 缓冲区大小选择batchSize应根据可用内存调整(如 8GB 内存可设为 100 万),太大易内存溢出,太小则临时文件过多,归并次数增加
  2. 文件操作优化 :使用fscanf/fprintf时,尽量减少 IO 次数(本文按行读写,平衡效率与简洁性),生产环境可使用二进制 IO 进一步提升速度
  3. 错误处理:添加文件打开失败、内存申请失败等异常处理,保证程序鲁棒性
  4. 多路归并扩展:本文实现二路归并,可扩展为多路归并(如 k 路归并),减少归并轮次,提升海量数据处理效率
相关推荐
Hcoco_me2 小时前
机器学习核心概念与主流算法(通俗详细版)
人工智能·算法·机器学习·数据挖掘·聚类
Hcoco_me2 小时前
嵌入式场景算法轻量化部署checklist
算法
咸鱼加辣2 小时前
【python面试】Python 的 lambda
javascript·python·算法
Jerryhut2 小时前
sklearn函数总结十二 —— 聚类分析算法K-Means
算法·kmeans·sklearn
inputA2 小时前
【rt-thread】点灯实验和按键输入实验
c语言·笔记·学习·实时操作系统
Swift社区2 小时前
LeetCode 453 - 最小操作次数使数组元素相等
算法·leetcode·职场和发展
hoiii1872 小时前
LR算法辅助的MIMO系统Zero Forcing检测
算法
糖葫芦君2 小时前
Lora模型微调
人工智能·算法
EXtreme352 小时前
【数据结构】二叉树进阶:层序遍历不仅是按层打印,更是形态判定的利器!
c语言·数据结构·二叉树·bfs·广度优先搜索·算法思维·面试必考