排序是计算机科学中最基础且核心的操作之一,它通过特定规则将无序数据转化为有序序列,广泛应用于购物筛选、数据统计、院校排名等实际场景。在 C 语言中,排序算法的实现直接影响程序的执行效率,不同场景下选择合适的排序算法能显著提升性能。本文将详细拆解常见排序算法的原理、代码实现,并通过实战对比分析各算法的优劣,帮助读者深入理解并灵活运用。
一、排序基础概念
1.1 核心定义
排序:使一串记录按照某个或某些关键字的大小,递增或递减排列的操作。关键字可以是数字、字符等可比较的属性,例如商品价格、销量、学生成绩等。
1.2 关键评价指标
- 时间复杂度:算法执行所需的时间与数据规模的关系,反映算法的执行效率。
- 空间复杂度:算法执行所需的额外存储空间与数据规模的关系。
- 稳定性:若待排序序列中存在多个关键字相同的记录,排序后它们的相对次序保持不变,则称该算法稳定;否则不稳定。稳定性在多关键字排序场景(如先按价格排序,再按销量排序)中至关重要。
1.3 常见排序算法分类
根据实现思路,常见排序算法可分为以下几类:
- 插入排序:直接插入排序、希尔排序
- 选择排序:直接选择排序、堆排序
- 交换排序:冒泡排序、快速排序
- 归并排序:二路归并排序
- 非比较排序:计数排序
二、经典排序算法详解(原理 + 代码)
以下所有算法均基于整型数组排序(升序)实现,测试用例统一为:int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8}
2.1 插入排序类
插入排序的核心思想是:将待排序元素逐个插入到已有序的子序列中,最终形成完整的有序序列。
2.1.1 直接插入排序
原理:
- 初始时,将第一个元素视为有序子序列。
- 对于第 i(i≥1)个元素,与有序子序列中的元素从后往前依次比较。
- 找到合适的插入位置,将该元素插入,原位置元素依次后移。
这就像玩扑克牌时,我们会把新摸到的牌插入到手中已排好序的牌组中。
代码实现:
#include <stdio.h>
// 直接插入排序(升序)
void InsertSort(int* a, int n) {
// 遍历待插入的元素(从第二个元素开始)
for (int i = 0; i < n - 1; i++) {
int end = i; // 有序子序列的最后一个元素下标
int tmp = a[end + 1]; // 待插入的元素
// 从后往前查找插入位置
while (end >= 0) {
if (a[end] > tmp) {
a[end + 1] = a[end]; // 元素后移
end--;
} else {
break; // 找到插入位置,退出循环
}
}
a[end + 1] = tmp; // 插入元素
}
}
// 打印数组
void PrintArray(int* a, int n) {
for (int i = 0; i < n; i++) {
printf("%d ", a[i]);
}
printf("\n");
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("直接插入排序前:");
PrintArray(a, n);
InsertSort(a, n);
printf("直接插入排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (N²)(最坏 / 平均情况),O (N)(最好情况,数组已有序)
- 空间复杂度:O (1)(原地排序)
- 稳定性:稳定
- 适用场景:数据量小或接近有序的场景(如少量数据的局部排序)
2.1.2 希尔排序(缩小增量排序)
原理:直接插入排序在数据量较大且无序时效率较低,希尔排序通过 "预排序" 让数组接近有序,再进行直接插入排序,从而优化性能。
- 选定一个增量 gap(初始通常为 n/3+1),将数组按 gap 分组,每组内元素距离为 gap。
- 对每组内元素进行直接插入排序。
- 缩小 gap(gap=gap/3+1),重复分组和排序操作。
- 当 gap=1 时,数组已接近有序,此时进行一次直接插入排序即可完成最终排序。
代码实现:
// 希尔排序(升序)
void ShellSort(int* a, int n) {
int gap = n;
// 逐步缩小gap,直到gap=1
while (gap > 1) {
gap = gap / 3 + 1; // 增量计算公式,确保最后gap=1
// 对每组进行插入排序
for (int i = 0; i < n - gap; i++) {
int end = i;
int tmp = a[end + gap];
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end -= gap;
} else {
break;
}
}
a[end + gap] = tmp;
}
}
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("希尔排序前:");
PrintArray(a, n);
ShellSort(a, n);
printf("希尔排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (N^1.3)~O (N²)(取决于 gap 序列,严蔚敏《数据结构》中给出平均复杂度约 O (N^1.3))
- 空间复杂度:O (1)
- 稳定性:不稳定(分组排序会破坏相同元素的相对次序)
- 适用场景:中等数据量的排序,性能优于直接插入排序
2.2 选择排序类
选择排序的核心思想是:每次从待排序序列中选出最小(或最大)的元素,放到序列的起始(或末尾)位置,重复直到排序完成。
2.2.1 直接选择排序
原理:
- 遍历序列,找到最小元素和最大元素的下标。
- 将最小元素与序列起始位置元素交换,最大元素与序列末尾位置元素交换。
- 缩小排序范围(起始位置后移,末尾位置前移),重复上述操作,直到范围缩小为 1。
代码实现:
// 交换两个整数
void Swap(int* x, int* y) {
int tmp = *x;
*x = *y;
*y = tmp;
}
// 直接选择排序(升序)
void SelectSort(int* a, int n) {
int begin = 0, end = n - 1;
while (begin < end) {
int mini = begin, maxi = begin; // 最小、最大元素下标
// 查找[begin, end]区间内的最小和最大元素
for (int i = begin; i <= end; i++) {
if (a[i] < a[mini]) {
mini = i;
}
if (a[i] > a[maxi]) {
maxi = i;
}
}
// 处理最小元素(防止最小元素在end位置被覆盖)
Swap(&a[mini], &a[begin]);
if (begin == maxi) { // 若最大元素在begin位置,交换后maxi应指向mini的原位置
maxi = mini;
}
// 处理最大元素
Swap(&a[maxi], &a[end]);
begin++;
end--;
}
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("直接选择排序前:");
PrintArray(a, n);
SelectSort(a, n);
printf("直接选择排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (N²)(最坏 / 平均 / 最好情况,均需遍历查找)
- 空间复杂度:O (1)
- 稳定性:不稳定(例如序列 [5,8,5,2],第一次交换后两个 5 的相对次序改变)
- 适用场景:数据量小,对稳定性无要求的场景(实际应用中较少使用)
2.2.2 堆排序
原理:堆排序是选择排序的优化版本,利用堆(完全二叉树)的数据结构快速获取最大 / 最小元素。
- 排升序需构建大堆(父节点值≥子节点值),排降序需构建小堆。
- 构建大堆后,堆顶元素为最大值,将其与堆尾元素交换,此时堆尾为有序区。
- 对剩余元素(无序区)重新调整为大堆,重复交换堆顶与堆尾操作,直到无序区为空。
代码实现:
// 堆调整(大堆)
void AdjustHeap(int* a, int n, int parent) {
int child = 2 * parent + 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 = 2 * parent + 1;
} else {
break;
}
}
}
// 堆排序(升序)
void HeapSort(int* a, int n) {
// 1. 构建大堆(从最后一个非叶子节点开始调整)
for (int i = (n - 2) / 2; i >= 0; i--) {
AdjustHeap(a, n, i);
}
// 2. 排序:交换堆顶与堆尾,调整堆
for (int i = n - 1; i > 0; i--) {
Swap(&a[0], &a[i]); // 堆顶(最大值)放入有序区
AdjustHeap(a, i, 0); // 调整剩余无序区为大堆
}
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("堆排序前:");
PrintArray(a, n);
HeapSort(a, n);
printf("堆排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (NlogN)(建堆 O (N),调整堆 O (NlogN))
- 空间复杂度:O (1)
- 稳定性:不稳定
- 适用场景:大数据量排序,对空间开销敏感的场景
2.3 交换排序类
交换排序的核心思想是:通过比较两个元素的关键字,若次序错误则交换它们的位置,直到整个序列有序。
2.3.1 冒泡排序
原理:冒泡排序是最基础的交换排序,元素像气泡一样 "上浮" 到对应位置。
- 从序列起始位置开始,依次比较相邻元素,若前元素大于后元素则交换。
- 每一轮遍历后,最大元素会 "冒泡" 到序列末尾(有序区)。
- 优化:若某一轮未发生交换,说明序列已有序,可直接退出。
代码实现:
// 冒泡排序(升序,优化版)
void BubbleSort(int* a, int n) {
for (int i = 0; i < n; i++) {
int exchange = 0; // 标记是否发生交换
// 遍历无序区,相邻元素比较交换
for (int j = 0; j < n - i - 1; j++) {
if (a[j] > a[j + 1]) {
Swap(&a[j], &a[j + 1]);
exchange = 1;
}
}
if (exchange == 0) {
break; // 无交换,序列已有序
}
}
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("冒泡排序前:");
PrintArray(a, n);
BubbleSort(a, n);
printf("冒泡排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (N²)(最坏 / 平均情况),O (N)(最好情况,优化后)
- 空间复杂度:O (1)
- 稳定性:稳定
- 适用场景:数据量小或教学场景(实际应用中效率较低)
2.3.2 快速排序
快速排序是 Hoare 于 1962 年提出的高效排序算法,基于分治法思想,是实际应用中最常用的排序算法之一。
原理:
- 选择序列中的一个元素作为基准值(key)。
- 划分:将序列分割为两部分,左部分元素均小于基准值,右部分元素均大于基准值。
- 递归:对左右两部分分别重复上述过程,直到整个序列有序。
核心实现:划分算法快速排序的效率关键在于划分算法,以下实现三种常用划分方式:
(1)Hoare 版本(左右指针法)
// Hoare版本划分
int PartitionHoare(int* a, int left, int right) {
int keyi = left; // 基准值下标(选左端点)
while (left < right) {
// 右指针找比基准值小的元素
while (left < right && a[right] >= a[keyi]) {
right--;
}
// 左指针找比基准值大的元素
while (left < right && a[left] <= a[keyi]) {
left++;
}
// 交换左右指针指向的元素
Swap(&a[left], &a[right]);
}
// 基准值归位(left=right为基准值的正确位置)
Swap(&a[keyi], &a[left]);
return left; // 返回基准值下标
}
// 快速排序主函数
void QuickSort(int* a, int left, int right) {
if (left >= right) {
return; // 递归终止条件
}
int keyi = PartitionHoare(a, left, right);
// 递归排序左区间和右区间
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
(2)挖坑法
// 挖坑法划分
int PartitionHole(int* a, int left, int right) {
int key = a[left]; // 基准值(挖坑)
int hole = left; // 坑的位置
while (left < right) {
// 右指针找比基准值小的元素,填入左坑
while (left < right && a[right] >= key) {
right--;
}
a[hole] = a[right];
hole = right; // 右指针位置变为新坑
// 左指针找比基准值大的元素,填入右坑
while (left < right && a[left] <= key) {
left++;
}
a[hole] = a[left];
hole = left; // 左指针位置变为新坑
}
a[hole] = key; // 基准值填入最后一个坑
return hole;
}
// 快速排序(挖坑法)
void QuickSortHole(int* a, int left, int right) {
if (left >= right) {
return;
}
int keyi = PartitionHole(a, left, right);
QuickSortHole(a, left, keyi - 1);
QuickSortHole(a, keyi + 1, right);
}
(3)前后指针法(Lomuto 版本)
// 前后指针法划分
int PartitionPrevCur(int* a, int left, int right) {
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[keyi], &a[prev]);
return prev;
}
// 快速排序(前后指针法)
void QuickSortPrevCur(int* a, int left, int right) {
if (left >= right) {
return;
}
int keyi = PartitionPrevCur(a, left, right);
QuickSortPrevCur(a, left, keyi - 1);
QuickSortPrevCur(a, keyi + 1, right);
}
(4)非递归版本(避免栈溢出)
递归版本的快速排序在数据量极大时可能出现栈溢出,非递归版本通过栈模拟递归过程:
#include <stdlib.h>
// 栈结构定义(存储区间左右边界)
typedef struct Stack {
int* data;
int top;
int capacity;
} Stack;
// 栈初始化
void StackInit(Stack* st, int capacity) {
st->data = (int*)malloc(sizeof(int) * capacity);
st->top = -1;
st->capacity = capacity;
}
// 入栈
void StackPush(Stack* st, int val) {
st->data[++st->top] = val;
}
// 出栈
void StackPop(Stack* st) {
st->top--;
}
// 获取栈顶元素
int StackTop(Stack* st) {
return st->data[st->top];
}
// 栈是否为空
int StackEmpty(Stack* st) {
return st->top == -1;
}
// 栈销毁
void StackDestroy(Stack* st) {
free(st->data);
st->data = NULL;
st->top = -1;
st->capacity = 0;
}
// 快速排序(非递归版本)
void QuickSortNonR(int* a, int left, int right) {
Stack st;
StackInit(&st, right - left + 1);
// 初始区间入栈(先入右边界,再入左边界)
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st)) {
// 出栈获取当前区间
int l = StackTop(&st);
StackPop(&st);
int r = StackTop(&st);
StackPop(&st);
// 划分区间
int keyi = PartitionPrevCur(a, l, r);
// 右区间入栈(若存在)
if (keyi + 1 < r) {
StackPush(&st, r);
StackPush(&st, keyi + 1);
}
// 左区间入栈(若存在)
if (l < keyi - 1) {
StackPush(&st, keyi - 1);
StackPush(&st, l);
}
}
StackDestroy(&st);
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("快速排序前:");
PrintArray(a, n);
QuickSort(a, 0, n - 1); // 递归版本
// QuickSortNonR(a, 0, n - 1); // 非递归版本
printf("快速排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (NlogN)(平均情况),O (N²)(最坏情况,如有序数组选端点为基准值)
- 空间复杂度:O (logN)(递归栈开销),非递归版本 O (logN)(栈存储区间)
- 稳定性:不稳定
- 适用场景:大数据量排序,是实际应用中效率最高的排序算法之一(需优化基准值选择,如三数取中)
2.4 归并排序
归并排序是基于分治法的稳定排序算法,核心思想是 "先分后合"。
原理:
- 分解:将数组递归拆分为两个子数组,直到每个子数组只有一个元素(天然有序)。
- 合并:将两个有序子数组合并为一个有序数组,重复合并过程直到得到完整的有序数组。
代码实现:
// 合并两个有序子数组
void Merge(int* a, int left, int mid, int right, int* tmp) {
int begin1 = left, end1 = mid; // 第一个子数组区间
int begin2 = mid + 1, end2 = right;// 第二个子数组区间
int index = left; // 临时数组下标
// 合并两个有序子数组到临时数组
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[index++] = a[begin1++];
} else {
tmp[index++] = a[begin2++];
}
}
// 处理剩余元素
while (begin1 <= end1) {
tmp[index++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[index++] = a[begin2++];
}
// 临时数组元素拷贝回原数组
for (int i = left; i <= right; i++) {
a[i] = tmp[i];
}
}
// 归并排序递归函数
void _MergeSort(int* a, int left, int right, int* tmp) {
if (left >= right) {
return; // 递归终止条件
}
int mid = (left + right) / 2;
// 分解左区间和右区间
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
// 合并两个有序子数组
Merge(a, left, mid, right, tmp);
}
// 归并排序主函数
void MergeSort(int* a, int n) {
int* tmp = (int*)malloc(sizeof(int) * n); // 临时数组(避免递归中重复创建)
if (tmp == NULL) {
perror("malloc fail");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
int main() {
int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
int n = sizeof(a) / sizeof(a[0]);
printf("归并排序前:");
PrintArray(a, n);
MergeSort(a, n);
printf("归并排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (NlogN)(最坏 / 平均 / 最好情况)
- 空间复杂度:O (N)(需要临时数组存储合并结果)
- 稳定性:稳定
- 适用场景:大数据量排序,对稳定性有要求的场景(如多关键字排序)
2.5 非比较排序:计数排序
计数排序不属于比较排序,基于 "鸽巢原理",通过统计元素出现次数实现排序,适用于数据范围集中的场景。
原理:
- 找出待排序数组中的最大值和最小值,计算数据范围 range = max - min + 1。
- 创建计数数组 count,统计每个元素出现的次数。
- 根据计数数组的统计结果,将元素按顺序放回原数组。
代码实现:
#include <string.h>
// 计数排序
void CountSort(int* a, int n) {
if (n <= 1) {
return;
}
// 1. 找出最大值和最小值
int min = a[0], max = a[0];
for (int i = 1; i < n; i++) {
if (a[i] > max) {
max = a[i];
}
if (a[i] < min) {
min = a[i];
}
}
// 2. 创建计数数组并初始化
int range = max - min + 1;
int* count = (int*)malloc(sizeof(int) * range);
if (count == NULL) {
perror("malloc fail");
return;
}
memset(count, 0, sizeof(int) * range);
// 3. 统计每个元素出现次数
for (int i = 0; i < n; i++) {
count[a[i] - min]++; // 映射到计数数组下标(避免数据范围从0开始)
}
// 4. 根据计数结果排序
int j = 0;
for (int i = 0; i < range; i++) {
while (count[i]-- > 0) {
a[j++] = i + min; // 恢复原数据
}
}
free(count);
count = NULL;
}
int main() {
int a[] = {6, 1, 2, 9, 4, 2, 4, 1, 4};
int n = sizeof(a) / sizeof(a[0]);
printf("计数排序前:");
PrintArray(a, n);
CountSort(a, n);
printf("计数排序后:");
PrintArray(a, n);
return 0;
}
特性总结:
- 时间复杂度:O (N + range)(N 为数据量,range 为数据范围)
- 空间复杂度:O (range)
- 稳定性:稳定(按顺序放回元素可保持相对次序)
- 适用场景:数据范围小且集中的场景(如学生成绩、年龄排序)
三、排序算法性能对比实战
为了直观感受各算法的效率,我们通过测试代码对比 7 种排序算法在 10 万条随机数据下的执行时间(单位:毫秒)。
测试代码
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
// 此处省略上述所有排序算法的实现(InsertSort、ShellSort、SelectSort、HeapSort、QuickSort、MergeSort、BubbleSort)
// 测试排序性能
void TestSortPerformance() {
srand(time(0));
const int N = 100000; // 数据量:10万条
// 分配7个数组,存储相同的随机数据
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
// 生成随机数据
for (int i = 0; i < N; i++) {
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
// 测试各排序算法执行时间
clock_t begin, end;
// 1. 直接插入排序
begin = clock();
InsertSort(a1, N);
end = clock();
printf("直接插入排序:%d ms\n", end - begin);
// 2. 希尔排序
begin = clock();
ShellSort(a2, N);
end = clock();
printf("希尔排序:%d ms\n", end - begin);
// 3. 直接选择排序
begin = clock();
SelectSort(a3, N);
end = clock();
printf("直接选择排序:%d ms\n", end - begin);
// 4. 堆排序
begin = clock();
HeapSort(a4, N);
end = clock();
printf("堆排序:%d ms\n", end - begin);
// 5. 快速排序
begin = clock();
QuickSort(a5, 0, N - 1);
end = clock();
printf("快速排序:%d ms\n", end - begin);
// 6. 归并排序
begin = clock();
MergeSort(a6, N);
end = clock();
printf("归并排序:%d ms\n", end - begin);
// 7. 冒泡排序
begin = clock();
BubbleSort(a7, N);
end = clock();
printf("冒泡排序:%d ms\n", end - begin);
// 释放内存
free(a1); free(a2); free(a3); free(a4);
free(a5); free(a6); free(a7);
}
int main() {
TestSortPerformance();
return 0;
}
测试结果(参考)
| 排序算法 | 执行时间(ms) |
|---|---|
| 直接插入排序 | 3820 |
| 希尔排序 | 12 |
| 直接选择排序 | 2560 |
| 堆排序 | 18 |
| 快速排序 | 8 |
| 归并排序 | 15 |
| 冒泡排序 | 7650 |
结果分析
- 冒泡排序和直接选择排序效率最低,时间复杂度为 O (N²),不适用于大数据量。
- 直接插入排序在大数据量下效率较差,但在数据接近有序时表现优异。
- 希尔排序通过预排序优化,效率远高于直接插入排序,适用于中等数据量。
- 快速排序、堆排序、归并排序效率最高,时间复杂度为 O (NlogN),其中快速排序在平均情况下表现最佳。
- 归并排序是稳定的 O (NlogN) 算法,但需要额外空间;堆排序无需额外空间但不稳定。
四、排序算法选择指南
| 场景需求 | 推荐算法 |
|---|---|
| 数据量小(N≤100) | 直接插入排序 / 直接选择排序 |
| 数据量中等(100<N≤10000) | 希尔排序 |
| 数据量大(N>10000) | 快速排序 / 堆排序 / 归并排序 |
| 要求稳定排序 | 归并排序 / 冒泡排序 / 直接插入排序 |
| 数据范围集中(如成绩) | 计数排序 |
| 对空间开销敏感 | 堆排序 / 快速排序 |
| 数据接近有序 | 直接插入排序 / 冒泡排序(优化版) |