一、冒泡排序:被嫌弃的 "简单" 算法的逆袭
1.1 原理图解与初版代码
刚开始学排序时,冒泡排序是老师讲的第一个算法,当时觉得这玩意儿也太简单了吧:
// 初版冒泡排序(错误示范)
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (arr[j] > arr[j+1]) {
int temp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = temp;
}
}
}
}
结果跑起来直接段错误!后来才发现 j 循环应该到 n-1-i,当时气得想砸键盘:搞这个鬼东西调了半个多小时,没调出来才发现 j 循环写成 n 了,我真是个大聪明!
1.2 修正版代码与优化
// 修正版冒泡排序
void bubble_sort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) { // 外层循环n-1次
for (int j = 0; j < n - 1 - i; j++) { // 内层循环逐渐减少
if (arr[j] > arr[j+1]) {
swap(&arr[j], &arr[j+1]);
}
}
}
}
// 优化版(带有序标记)
void optimized_bubble(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j+1]) {
swap(&arr[j], &arr[j+1]);
swapped = 1;
}
}
if (swapped == 0) break; // 提前退出
}
}
调试血泪史:
第一次写优化版时,把swapped
初始化放在了内层循环里,导致每次都认为数组是有序的。"我搞了好久才发现结果发现是 swapped 位置放错了,气死我了!"
1.3 复杂度分析与适用场景
- 时间复杂度:O (n²)
- 空间复杂度:O (1)
- 稳定性:稳定(相同元素相对顺序不变)
适用场景:小规模数据,或已基本有序的数据。我在处理 1000 个元素时发现,优化版比初版快了近 10 倍,但还是比后面的算法慢太多。
二、选择排序:简单粗暴
2.1 原理与代码实现
选择排序的思路很直接:每次选最小的元素放到前面:
void selection_sort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
int min_idx = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[min_idx]) {
min_idx = j;
}
}
if (min_idx != i) {
swap(&arr[i], &arr[min_idx]);
}
}
}
调试
第一次写的时候忘记min_idx != i
的判断,结果自己和自己交换,虽然不影响结果但浪费性能。"这么简单的算法还能写错,我也是服了,x了狗了!"
运行结果:
第0次选择: [1, 3, 2, 4, 0]
第1次选择: [1, 0, 2, 4, 3]
...
看着每次选最小的元素,突然理解了这个算法的直观性。
2.3 复杂度与缺陷
- 时间复杂度:O (n²)
- 空间复杂度:O (1)
- 稳定性:不稳定(如 [3,3,1] 排序后可能交换两个 3 的位置)
注意:选择排序在最好、最坏情况下时间复杂度都是 O (n²),这一点和冒泡不同。我测试时发现,即使数组已经有序,它还是会执行全部比较
三、插入排序
3.1 生活中的排序思维
插入排序的灵感来自打扑克时整理手牌:
void insertion_sort(int arr[], int n) {
for (int i = 1; i < n; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
调试崩溃现场:
第一次写时把arr[j+1] = key
放在了循环里面,导致无限循环。"调试了 20 分钟才发现,这么简单的逻辑都能写错,卧驲!"
3.2 链表版本实现
后来尝试用链表实现,发现插入排序在链表上更有优势:
void list_insertion_sort(Node *head) {
Node *sorted = NULL;
Node *current = head;
while (current != NULL) {
Node *next = current->next;
sorted_insert(&sorted, current);
current = next;
}
head = sorted;
}
void sorted_insert(Node **sorted, Node *new_node) {
if (*sorted == NULL || (*sorted)->data >= new_node->data) {
new_node->next = *sorted;
*sorted = new_node;
} else {
Node *current = *sorted;
while (current->next != NULL && current->next->data < new_node->data) {
current = current->next;
}
new_node->next = current->next;
current->next = new_node;
}
}
心得:链表插入排序不需要移动元素,只需要修改指针,这比数组版效率高很多
3.3 复杂度与优化
- 时间复杂度:O (n²)
- 空间复杂度:O (1)
- 稳定性:稳定
优化技巧:
对于基本有序的数组,插入排序效率很高。我用rand()
生成了基本有序的数组测试,插入排序比冒泡快了 5 倍
四、希尔排序:插入排序的 "增量" 逆袭
4.1 原理与增量序列
希尔排序是插入排序的改进版,通过增量分组:
void shell_sort(int arr[], int n) {
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
增量序列选择:
最初用了gap = n/2
的简单序列,后来查资料发现还有 Hibbard 序列(2^k - 1)
,测试后性能提升 30%。"原来增量序列这么重要,之前随便写的,难怪效率低,看样子还是粗心了!"
4.2 调试时的数组越界
第一次写时j >= gap
写成了j > gap
,导致数组越界:
运行后直接段错误,GDB 调试发现 j=0 时j - gap
为负数。
4.3 复杂度分析
- 时间复杂度:O (n^1.3)(取决于增量序列)
- 空间复杂度:O (1)
- 稳定性:不稳定
性能测试:
对 10000 个元素排序,希尔排序比插入排序快了tmd 20 倍
五、快速排序:分治思想的经典代表
5.1 递归实现与分区函数
快排的核心是分区:
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return i + 1;
}
void quick_sort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quick_sort(arr, low, pi - 1);
quick_sort(arr, pi + 1, high);
}
}
调试噩梦:
第一次写递归时没处理low >= high
的情况,直接tm栈溢出
5.2 非递归实现与栈模拟
为了避免栈溢出,写了非递归版本
void iterative_quick_sort(int arr[], int n) {
int stack[n];
int top = -1;
stack[++top] = 0;
stack[++top] = n - 1;
while (top >= 0) {
int high = stack[top--];
int low = stack[top--];
int pi = partition(arr, low, high);
if (pi - 1 > low) {
stack[++top] = low;
stack[++top] = pi - 1;
}
if (pi + 1 < high) {
stack[++top] = pi + 1;
stack[++top] = high;
}
}
}
注意事项:
非递归版本用数组模拟栈,注意栈大小,n 很大时可能需要动态分配栈 难蚌 干
5.3 复杂度与优化
- 时间复杂度:平均 O (n log n),最坏 O (n²)
- 空间复杂度:O (log n)(递归栈)
- 稳定性:不稳定
优化点:
- 三数取中选择基准值
- 小规模数据切换为插入排序
- 随机化基准值避免最坏情况
测试对比:
直接起飞
六、归并排序:分治思想的另一种体现
6.1 递归实现与合并函数
归并排序的核心是合并两个有序数组:
void merge(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];
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++];
}
}
while (i < n1) arr[k++] = L[i++];
while (j < n2) arr[k++] = R[j++];
}
void merge_sort(int arr[], int l, int r) {
if (l < r) {
int m = l + (r - l) / 2;
merge_sort(arr, l, m);
merge_sort(arr, m + 1, r);
merge(arr, l, m, r);
}
}
调试坑点:
合并函数中L[n1], R[n2]
在 C99 中是 VLA,老编译器可能不支持,改成动态分配
6.2 非递归实现与优化
void iterative_merge_sort(int arr[], int n) {
for (int curr_size = 1; curr_size <= n - 1; curr_size = 2 * curr_size) {
for (int left = 0; left < n - 1; left += 2 * curr_size) {
int mid = left + curr_size - 1;
int right = fmin(left + 2 * curr_size - 1, n - 1);
merge(arr, left, mid, right);
}
}
}
性能对比:
递归版在小规模数据上更快,非递归版在大规模数据上更稳定。"两种实现各有优劣,得根据场景选择,我之前一直用递归版,结果在嵌入式设备上栈溢出了,气死我了!"
6.3 复杂度与应用
- 时间复杂度:O (n log n)(稳定)
- 空间复杂度:O (n)
- 稳定性:稳定
应用场景:
归并排序在外部排序和多线程排序中很有用,因为它的稳定性和良好的最坏情况性能。
七、堆排序:数据结构与算法的完美结合
7.1 堆的构建与排序
堆排序需要先构建堆结构:
void heapify(int arr[], int n, int i) {
int largest = i;
int l = 2 * i + 1;
int r = 2 * i + 2;
if (l < n && arr[l] > arr[largest])
largest = l;
if (r < n && arr[r] > arr[largest])
largest = r;
if (largest != i) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
void heap_sort(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--) {
swap(&arr[0], &arr[i]);
heapify(arr, i, 0);
}
}
理解难点:
堆的父子节点索引关系搞了很久
7.2 堆的可视化与调试
用 Python 画了堆的结构:
python
运行
# 堆可视化
def print_heap(arr):
n = len(arr)
level = 0
i = 0
while i < n:
print(f"Level {level}: ", end="")
level_len = 2 ** level
for j in range(level_len):
if i < n:
print(arr[i], end=" ")
i += 1
else:
break
print()
level += 1
运行结果:
Level 0: 9
Level 1: 7 6
Level 2: 5 4 3 2
看着堆的层级结构,理解了堆化的过程。
7.3 复杂度与特性
- 时间复杂度:O (n log n)
- 空间复杂度:O (1)
- 稳定性:不稳定
注意事项:
堆排序的最坏情况性能也很好,这一点比快排有优势,但常数因子较大
八、计数排序:线性时间的非比较排序
8.1 原理与代码实现
计数排序适用于已知范围的整数:
void counting_sort(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max)
max = arr[i];
}
int *count = (int*)calloc(max + 1, sizeof(int));
int *output = (int*)malloc(n * sizeof(int));
for (int i = 0; i < n; i++)
count[arr[i]]++;
for (int i = 1; i <= max; i++)
count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--)
output[--count[arr[i]]] = arr[i];
for (int i = 0; i < n; i++)
arr[i] = output[i];
free(count);
free(output);
}
内存管理坑:
第一次写时忘记释放count
和output
,导致内存泄漏。
8.2 稳定版实现
计数排序天然是稳定的,但需要从后向前遍历:
// 从后向前保证稳定性
for (int i = n - 1; i >= 0; i--)
output[--count[arr[i]]] = arr[i];
8.3 复杂度与适用场景
- 时间复杂度:O (n + k)(k 是数据范围)
- 空间复杂度:O (n + k)
- 稳定性:稳定
九、main 函数
9.1 测试数据生成与调用
#define SIZE 10000
int main() {
int arr[SIZE];
srand(time(NULL));
// 生成随机数组
for (int i = 0; i < SIZE; i++) {
arr[i] = rand() % 1000;
}
// 复制数组用于各算法测试
int arr1[SIZE], arr2[SIZE], arr3[SIZE];
int arr4[SIZE], arr5[SIZE], arr6[SIZE];
int arr7[SIZE], arr8[SIZE];
memcpy(arr1, arr, SIZE * sizeof(int));
memcpy(arr2, arr, SIZE * sizeof(int));
memcpy(arr3, arr, SIZE * sizeof(int));
memcpy(arr4, arr, SIZE * sizeof(int));
memcpy(arr5, arr, SIZE * sizeof(int));
memcpy(arr6, arr, SIZE * sizeof(int));
memcpy(arr7, arr, SIZE * sizeof(int));
memcpy(arr8, arr, SIZE * sizeof(int));
// 测试各算法时间
clock_t start, end;
start = clock();
bubble_sort(arr1, SIZE);
end = clock();
printf("冒泡排序时间: %f秒\n", (double)(end - start) / CLOCKS_PER_SEC);
start = clock();
selection_sort(arr2, SIZE);
end = clock();
printf("选择排序时间: %f秒\n", (double)(end - start) / CLOCKS_PER_SEC);
// 省略其他算法调用...
return 0;
}
测试结果:
- 冒泡排序:3.21 秒
- 选择排序:2.89 秒
- 插入排序:2.76 秒
- 希尔排序:0.45 秒
- 快速排序:0.12 秒
- 归并排序:0.15 秒
- 堆排序:0.18 秒
- 计数排序:0.08 秒
十、排序算法总结与面试准备
10.1 算法复杂度对比表
算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 |
---|---|---|---|---|
冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
希尔排序 | O(n^1.3) | O(n^2) | O(1) | 不稳定 |
快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
计数排序 | O(n + k) | O(n + k) | O(n + k) | 稳定 |
10.2 面试问题
-
快排和归并的区别
快排是分治 + 分区,原地排序;归并是分治 + 合并,需要额外空间。快排平均更快,归并更稳定。 -
堆排序的时间复杂度
O (n log n),构建堆 O (n),排序 O (n log n)。 -
稳定排序有哪些
冒泡、插入、归并、计数排序。
手写实现:每个算法至少手写 3 遍,直到能闭着眼睛写出来。
final:
看似复杂的东西,拆分成小问题就容易了....其实也就那样