算法筑基(一):排序算法------从冒泡到快排,一文掌握最经典的排序算法
📖 前言
排序是计算机科学中最基础也最重要的算法之一。无论你是做数据分析、写数据库引擎,还是刷LeetCode,排序都会频繁出现。一个好的排序算法,能让你的程序从"能跑"变成"跑得快"。
在本文中,我们将从最直观的冒泡排序开始,逐步深入到选择、插入、希尔、归并、快速、堆排序,最后介绍几种非比较排序(计数排序、基数排序、桶排序)。每一个算法都会配完整的C语言代码 、逐行注释 以及实际案例,确保你不仅能理解原理,还能亲手实现。
📌 本文目录
- 冒泡排序 ------ 像气泡一样上浮
- 选择排序 ------ 每次选最小的
- 插入排序 ------ 像打牌一样插入
- 希尔排序 ------ 插入排序的升级版
- 归并排序 ------ 分治思想的典范
- 快速排序 ------ 工业级的排序利器
- 堆排序 ------ 利用堆结构的选择排序
- 非比较排序 ------ 计数排序 / 基数排序 / 桶排序
- 排序算法对比总结
- 课后练习与答案
1. 冒泡排序 ------ 像气泡一样上浮
1.1 核心思想
重复遍历数组,每次比较相邻两个元素,如果顺序错误就交换。每一轮遍历都会把当前未排序部分的最大(或最小)元素"冒泡"到正确位置。
1.2 C语言实现(带详细注释)
c
#include <stdio.h>
// 冒泡排序函数
void bubbleSort(int arr[], int n) {
int i, j, temp;
// 外层循环控制轮数,每轮确定一个最大值
for (i = 0; i < n - 1; i++) {
// 优化标志:如果本轮没有发生交换,说明数组已有序
int swapped = 0;
// 内层循环进行相邻比较,n-i-1 是因为每轮最后i个已经排好
for (j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = 1;
}
}
// 如果未发生交换,提前结束
if (!swapped)
break;
}
}
// 打印数组
void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: ");
printArray(arr, n);
bubbleSort(arr, n);
printf("排序后数组: ");
printArray(arr, n);
return 0;
}
1.3 案例:学生成绩排名
假设有一个班级的C语言考试成绩,老师希望按照分数从低到高排序,以便查看及格线。冒泡排序可以轻松完成。
c
// 学生成绩数组
int scores[] = {78, 45, 92, 60, 88, 73};
bubbleSort(scores, 6);
// 输出:45 60 73 78 88 92
1.4 复杂度分析
- 最好情况:数组已经有序,只需比较 n-1 次,复杂度 O(n)。
- 最坏情况:数组逆序,比较次数为 n(n-1)/2,复杂度 O(n²)。
- 空间复杂度:O(1),原地排序。
1.5 小思考
冒泡排序虽然慢,但它是稳定的排序(相等元素相对位置不变),且容易实现。适合小规模数据或教学演示。
2. 选择排序 ------ 每次选最小的
2.1 核心思想
每次从未排序部分选出最小(或最大)的元素,放到已排序部分的末尾。依次重复,直到全部有序。
2.2 C语言实现
c
void selectionSort(int arr[], int n) {
int i, j, minIdx, temp;
for (i = 0; i < n - 1; i++) {
// 假设当前元素是最小值
minIdx = i;
// 在未排序部分找真正的最小值下标
for (j = i + 1; j < n; j++) {
if (arr[j] < arr[minIdx]) {
minIdx = j;
}
}
// 如果最小值不是当前元素,则交换
if (minIdx != i) {
temp = arr[i];
arr[i] = arr[minIdx];
arr[minIdx] = temp;
}
}
}
2.3 案例:扑克牌整理
你手中有一把乱序的扑克牌,你想把它们从小到大排列。选择排序就像你每次从剩下牌中找出最小的一张,放到已排好序的牌堆后面。
2.4 复杂度分析
- 无论输入顺序如何,比较次数都是 n(n-1)/2,所以最好、最坏、平均都是 O(n²)。
- 空间复杂度 O(1)。
- 不稳定:交换可能会打乱相等元素的相对顺序。
3. 插入排序 ------ 像打牌一样插入
3.1 核心思想
将数组分成已排序和未排序两部分,每次取未排序部分的第一个元素,插入到已排序部分的正确位置。
3.2 C语言实现
c
void insertionSort(int arr[], int n) {
int i, j, key;
for (i = 1; i < n; i++) {
key = arr[i]; // 待插入元素
j = i - 1;
// 将比 key 大的元素向后移动
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key; // 插入到正确位置
}
}
3.3 案例:图书馆还书
图书管理员把归还的书籍按索书号插入到书架正确位置。如果书架基本有序,插入排序效率极高。
3.4 复杂度分析
- 最好情况:数组已有序,只需比较 n-1 次,O(n)。
- 最坏情况:逆序,需要比较 n(n-1)/2 次,O(n²)。
- 平均 O(n²)。
- 空间 O(1),稳定排序。
3.5 小贴士
插入排序对于小规模或基本有序的数据非常高效,常常作为高级排序算法(如快速排序)的"小数组优化"部分。
4. 希尔排序 ------ 插入排序的升级版
4.1 核心思想
希尔排序是插入排序的一种改进,通过将整个序列分割成若干子序列分别进行插入排序,使得整个序列基本有序,最后再对整个序列进行一次插入排序。它利用了插入排序在数据基本有序时效率极高的特点。
4.2 算法步骤
- 选择一个增量序列(通常使用希尔增量:
n/2, n/4, ..., 1)。 - 对每个增量
gap,将数组分成若干间隔为gap的子序列,对每个子序列进行插入排序。 - 逐步缩小
gap,直到gap = 1时进行最后一次插入排序。
4.3 C语言实现
c
void shellSort(int arr[], int n) {
// 初始增量 gap = n/2,每次减半,直到 gap = 1
for (int gap = n / 2; gap > 0; gap /= 2) {
// 对每个子序列进行插入排序
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
// 插入排序:将 arr[i] 插入到子序列的正确位置
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
4.4 案例:大规模数据初步整理
在处理海量数据时,先使用希尔排序将数据变得基本有序,再用插入排序快速完成,能大幅提升效率。
4.5 复杂度分析
- 希尔排序的时间复杂度取决于增量序列的选择。使用希尔增量时,最坏情况 O(n²);使用 Hibbard 增量(1,3,7,15,...)时,最坏 O(n^(3/2));使用 Sedgewick 增量时,最坏 O(n^(4/3))。
- 空间复杂度 O(1)。
- 不稳定。
5. 归并排序 ------ 分治思想的典范
5.1 核心思想
采用分治法:
- 分解:将数组分成两半。
- 解决:递归地对左右两半进行归并排序。
- 合并:将两个有序子数组合并成一个有序数组。
5.2 C语言实现(含合并函数)
c
// 合并两个有序子数组 [l, m] 和 [m+1, r]
void merge(int arr[], int l, int m, int r) {
int i, j, k;
int n1 = m - l + 1;
int n2 = r - m;
// 创建临时数组
int L[n1], R[n2];
// 拷贝数据到临时数组
for (i = 0; i < n1; i++)
L[i] = arr[l + i];
for (j = 0; j < n2; j++)
R[j] = arr[m + 1 + j];
// 合并临时数组回 arr[l..r]
i = 0; j = 0; k = l;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k] = L[i];
i++;
} else {
arr[k] = R[j];
j++;
}
k++;
}
// 拷贝剩余元素
while (i < n1) {
arr[k] = L[i];
i++; k++;
}
while (j < n2) {
arr[k] = R[j];
j++; k++;
}
}
// 归并排序主函数
void mergeSort(int arr[], int l, int r) {
if (l < r) {
int m = l + (r - l) / 2; // 防止溢出
mergeSort(arr, l, m);
mergeSort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
5.3 案例:大文件外部排序
当内存无法一次容纳全部数据时,归并排序常用于外部排序:将文件分成小块排序,再合并。
5.4 复杂度分析
- 时间复杂度:始终为 O(n log n)。
- 空间复杂度:O(n),因为需要临时数组。
- 稳定排序。
5.5 优点与缺点
- 优点:稳定、性能稳定(不依赖输入顺序)。
- 缺点:需要额外空间,对于小数组递归开销较大。
6. 快速排序 ------ 工业级的排序利器
6.1 核心思想
也是分治法,但比归并排序更"聪明":
- 选取一个基准(pivot)。
- 将数组划分为两部分,左边都小于等于基准,右边都大于等于基准。
- 递归地对左右两部分进行快速排序。
6.2 C语言实现(经典写法)
c
// 划分函数:返回基准的最终位置
int partition(int arr[], int low, int high) {
int pivot = arr[high]; // 选取最后一个元素为基准
int i = low - 1; // i 指向小于基准的最后一个元素
for (int j = low; j < high; j++) {
// 如果当前元素小于等于基准,则将其交换到左侧
if (arr[j] <= pivot) {
i++;
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
// 将基准放到正确位置
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1;
}
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
6.3 案例:数据库索引排序
数据库在创建索引时,常使用快速排序或其变种(如三路快排)来快速建立有序索引。
6.4 复杂度分析
- 平均 O(n log n),实际表现非常优秀。
- 最坏 O(n²)(当每次基准都是最大或最小值时,如已有序数组)。
- 空间复杂度 O(log n)(递归栈深度)。
- 不稳定。
6.5 优化技巧
- 随机选择基准,避免最坏情况。
- 当子数组较小时,改用插入排序。
- 三路快排(处理大量重复元素)。
7. 堆排序 ------ 利用堆结构的选择排序
7.1 核心思想
堆排序利用堆这种数据结构(通常用数组实现)来进行排序。首先将待排序序列构造成一个最大堆(或最小堆),此时堆顶元素就是最大值(或最小值),将其与堆尾元素交换,然后对剩余元素重新调整成堆,重复该过程直到全部有序。
7.2 算法步骤
- 建堆:从最后一个非叶子节点开始,自底向上调整,将数组调整为最大堆。
- 排序:每次将堆顶元素(最大值)与堆尾元素交换,堆大小减1,然后对堆顶进行向下调整,重复直到堆大小为1。
7.3 C语言实现
c
// 调整以 root 为根的子树为最大堆,n 为堆的大小
void heapify(int arr[], int n, int root) {
int largest = root; // 假设根节点最大
int left = 2 * root + 1; // 左孩子下标
int right = 2 * root + 2; // 右孩子下标
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest != root) {
// 交换并继续向下调整
int temp = arr[root];
arr[root] = arr[largest];
arr[largest] = temp;
heapify(arr, n, largest);
}
}
void heapSort(int arr[], int n) {
// 建堆:从最后一个非叶子节点开始调整
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 排序:依次将堆顶元素放到末尾
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶(最大值)与堆尾交换
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
// 调整剩余元素为最大堆
heapify(arr, i, 0);
}
}
7.4 案例:优先队列的实现
堆排序是堆的典型应用,可用于实现优先队列,在操作系统调度、图算法(如Dijkstra)中广泛应用。
7.5 复杂度分析
- 时间复杂度:建堆 O(n),每次调整 O(log n),共 n 次,总 O(n log n)。
- 空间复杂度:O(1)。
- 不稳定。
8. 非比较排序 ------ 计数排序 / 基数排序 / 桶排序
比较排序的下界是 O(n log n),而非比较排序可以突破这一限制,但通常需要满足特定条件(如数据范围有限)。
8.1 计数排序
8.1.1 核心思想
计数排序适用于数据范围不大的整数排序。通过统计每个值出现的次数,然后根据次数直接输出有序序列。
8.1.2 C语言实现
c
void countingSort(int arr[], int n) {
// 先找出数组中的最大值
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max) max = arr[i];
}
// 创建计数数组并初始化为0
int count[max + 1];
for (int i = 0; i <= max; i++) count[i] = 0;
// 统计每个元素出现的次数
for (int i = 0; i < n; i++) {
count[arr[i]]++;
}
// 根据计数数组重建原数组
int index = 0;
for (int i = 0; i <= max; i++) {
while (count[i]-- > 0) {
arr[index++] = i;
}
}
}
8.1.3 复杂度
- 时间复杂度:O(n + k),k 为数据范围。
- 空间复杂度:O(k)。
- 稳定(如果实现时使用稳定累加方式)。
8.2 基数排序
8.2.1 核心思想
基数排序按照低位到高位的顺序,依次对每一位进行稳定的计数排序(或桶排序)。适用于整数或字符串。
8.2.2 C语言实现(基于计数排序)
c
// 使用计数排序对 arr 按照第 exp 位(10^exp)进行排序
void countingSortByDigit(int arr[], int n, int exp) {
int output[n]; // 临时存放排序结果
int count[10] = {0}; // 数字 0~9
// 统计当前位上的数字出现次数
for (int i = 0; i < n; i++) {
int digit = (arr[i] / exp) % 10;
count[digit]++;
}
// 计算累积次数(用于稳定排序)
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 从后向前构建输出数组(保证稳定性)
for (int i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--;
}
// 将排好序的数据复制回原数组
for (int i = 0; i < n; i++) {
arr[i] = output[i];
}
}
void radixSort(int arr[], int n) {
// 找出最大值,确定最大位数
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max) max = arr[i];
}
// 从个位开始,对每一位进行排序
for (int exp = 1; max / exp > 0; exp *= 10) {
countingSortByDigit(arr, n, exp);
}
}
8.2.3 复杂度
- 时间复杂度:O(d * (n + k)),d 为最大位数,k 为基数(这里为10)。
- 空间复杂度:O(n + k)。
- 稳定。
8.3 桶排序
8.3.1 核心思想
桶排序将数据分到若干个桶中,每个桶内单独排序(通常使用插入排序或快速排序),最后按顺序合并所有桶。
8.3.2 C语言实现(简单版本,假设数据在 [0,1) 区间)
c
#include <stdlib.h>
#define BUCKET_SIZE 10
// 桶内插入排序(也可以使用其他排序)
void bucketInsertSort(float bucket[], int *size) {
for (int i = 1; i < *size; i++) {
float key = bucket[i];
int j = i - 1;
while (j >= 0 && bucket[j] > key) {
bucket[j + 1] = bucket[j];
j--;
}
bucket[j + 1] = key;
}
}
void bucketSort(float arr[], int n) {
// 创建桶数组
float *buckets[BUCKET_SIZE];
int bucketSizes[BUCKET_SIZE] = {0};
// 为每个桶分配足够空间(最多 n 个元素)
for (int i = 0; i < BUCKET_SIZE; i++) {
buckets[i] = (float*)malloc(n * sizeof(float));
}
// 将元素分配到桶中(假设数据在 [0,1) 区间)
for (int i = 0; i < n; i++) {
int idx = (int)(arr[i] * BUCKET_SIZE);
if (idx >= BUCKET_SIZE) idx = BUCKET_SIZE - 1;
buckets[idx][bucketSizes[idx]++] = arr[i];
}
// 对每个桶内部排序
for (int i = 0; i < BUCKET_SIZE; i++) {
if (bucketSizes[i] > 0) {
bucketInsertSort(buckets[i], &bucketSizes[i]);
}
}
// 合并所有桶
int index = 0;
for (int i = 0; i < BUCKET_SIZE; i++) {
for (int j = 0; j < bucketSizes[i]; j++) {
arr[index++] = buckets[i][j];
}
free(buckets[i]);
}
}
8.3.3 复杂度
- 时间复杂度:平均 O(n + k)(k 为桶数),最坏 O(n²)(所有元素进入同一个桶)。
- 空间复杂度:O(n + k)。
- 稳定(取决于桶内排序算法)。
📊 排序算法对比总结
| 算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
| 希尔排序 | O(n log n)~O(n^(4/3)) | O(n(3/2))~O(n(4/3)) | O(n²) | O(1) | 不稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 |
| 桶排序 | O(n+k) | O(n+k) | O(n²) | O(n+k) | 稳定 |
🎯 如何选择排序算法?
- 数据量小:插入排序、冒泡排序简单易懂。
- 数据基本有序:插入排序表现极佳。
- 要求稳定排序:归并排序、冒泡排序、插入排序。
- 追求速度且内存充足:归并排序。
- 通用高效排序:快速排序(平均最快)。
- 数据范围有限且为整数:计数排序、基数排序。
- 数据分布均匀:桶排序。
✍️ 课后练习与答案
练习题目
- 实现一个双向冒泡排序(鸡尾酒排序),提高基本有序数组的效率。
- 修改快速排序,加入随机基准选择,并测试对已有序数组的排序速度。
- 用归并排序实现一个统计逆序对数量的函数。
- 尝试用C语言实现一个"万能排序函数",通过函数指针支持任意类型比较。
- 使用计数排序对一组范围在 0~100 的整数进行排序。
- 使用基数排序对一组三位数整数进行排序。
参考答案
1. 双向冒泡排序(鸡尾酒排序)
c
void cocktailSort(int arr[], int n) {
int swapped = 1;
int start = 0, end = n - 1;
while (swapped) {
swapped = 0;
// 从左向右冒泡,将最大值移到末尾
for (int i = start; i < end; i++) {
if (arr[i] > arr[i + 1]) {
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
swapped = 1;
}
}
if (!swapped) break;
end--;
// 从右向左冒泡,将最小值移到开头
for (int i = end - 1; i >= start; i--) {
if (arr[i] > arr[i + 1]) {
int temp = arr[i];
arr[i] = arr[i + 1];
arr[i + 1] = temp;
swapped = 1;
}
}
start++;
}
}
2. 随机快速排序
c
#include <stdlib.h>
#include <time.h>
int randomPartition(int arr[], int low, int high) {
// 随机选择一个下标作为基准
int randomIdx = low + rand() % (high - low + 1);
// 将基准交换到末尾
int temp = arr[randomIdx];
arr[randomIdx] = arr[high];
arr[high] = temp;
// 调用原来的划分函数
return partition(arr, low, high);
}
void randomQuickSort(int arr[], int low, int high) {
if (low < high) {
int pi = randomPartition(arr, low, high);
randomQuickSort(arr, low, pi - 1);
randomQuickSort(arr, pi + 1, high);
}
}
3. 归并排序统计逆序对
c
long long mergeAndCount(int arr[], int l, int m, int r) {
int n1 = m - l + 1;
int n2 = r - m;
int L[n1], R[n2];
for (int i = 0; i < n1; i++) L[i] = arr[l + i];
for (int j = 0; j < n2; j++) R[j] = arr[m + 1 + j];
long long invCount = 0;
int i = 0, j = 0, k = l;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k++] = L[i++];
} else {
arr[k++] = R[j++];
invCount += (n1 - i); // 所有剩余的 L[i..n1-1] 都与 R[j-1] 形成逆序
}
}
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
return invCount;
}
long long mergeSortAndCount(int arr[], int l, int r) {
long long invCount = 0;
if (l < r) {
int m = l + (r - l) / 2;
invCount += mergeSortAndCount(arr, l, m);
invCount += mergeSortAndCount(arr, m + 1, r);
invCount += mergeAndCount(arr, l, m, r);
}
return invCount;
}
4. 万能排序函数(以整型为例,实际可用 void* 和比较函数指针)
c
typedef int (*cmpFunc)(int, int);
void genericSort(int arr[], int n, cmpFunc cmp) {
// 使用冒泡排序演示(可替换为任何排序)
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - i - 1; j++) {
if (cmp(arr[j], arr[j + 1]) > 0) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = 1;
}
}
if (!swapped) break;
}
}
// 使用示例:升序
int ascending(int a, int b) { return a - b; }
// 降序
int descending(int a, int b) { return b - a; }
5. 计数排序(范围 0~100)
直接使用前面给出的 countingSort 函数即可,注意要找出 max 值(100)。
6. 基数排序(三位数整数)
直接使用前面给出的 radixSort 函数即可,因为最大三位数,循环三次(个位、十位、百位)。
🌟 寄语
排序算法是算法世界的"敲门砖"。当你亲手把冒泡、快排这些代码敲出来,看着控制台输出从乱序变为整齐的那一刻,你会发现算法并没有想象中那么高深。
不要只看不练------打开你的C语言环境,把上面的代码一个一个跑起来,修改参数,观察输出。甚至可以尝试用打印语句追踪每一步的执行过程,你会对算法有更深的理解。
在下一篇文章中,我们将进入搜索算法的世界,从最简单的线性查找,到二分查找,再到DFS/BFS这些图搜索的基石。敬请期待!
如果你在阅读过程中有任何疑问,欢迎在评论区留言,我会尽快回复。
如果你觉得这篇文章对你有帮助,点个赞👍,让更多同学看到。