刷题记录:嵌入式的力扣刷题技巧与顺序(三)排序
涉及题目
|
序号 |
题号 |
题目名称 |
练什么 |
说明 |
|
17 |
- |
手写冒泡排序 |
基础排序 |
最简单的排序,双重循环,相邻元素比较交换。必须能默写 |
|
18 |
- |
手写选择排序 |
基础排序 |
每次找最小值放到前面。必须能默写 |
|
19 |
- |
手写快速排序 |
分治思想 |
面试最常考的排序算法。选pivot,分区,递归。必须能默写 |
|
20 |
- |
手写归并排序 |
分治思想 |
分成两半分别排序再合并。理解分治的思想 |
|
21 |
912 |
排序数组 |
验证排序 |
用这道题验证你手写的排序算法是否正确 |
|
22 |
215 |
数组中的第K个最大元素 |
快速选择 |
快速排序的变体,只排一半。面试常考 |
一、排序基础
评价维度
- 时间复杂度:O(n²) 的有冒泡、选择;O(n log n) 的有快排、归并
- 稳定性:相等元素排序后是否保持原来的相对顺序。冒泡稳定,选择不稳定,快排不稳定,归并稳定
- 常数因子:同样时间复杂度 O(n log n),快排比归并实际更快,因为快排原地操作不需要额外数组和复制
交换操作
排序中频繁用到,标准写法:
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
递归的两个要素
- 基准情况:什么时候停止递归(比如数组只有0或1个元素)
- 递归步骤:把问题拆成更小的子问题,调用自己
二、冒泡排序
核心思想
相邻元素两两比较,顺序不对就交换。每一轮把当前最大的元素"冒泡"到末尾。
思路
- 外层循环 i:共 n-1 轮
- 内层循环 j:从 0 到 <
n-1-i,相邻比较 :nums[j] > nums[j+1]→ 交换 - 优化:某一轮没有发生任何交换 → 已经有序 swapped = 0,break
内层条件 j < n-1-i 的含义
n-1:因为比较 nums[j] 和 nums[j+1],j 最大到 n-2 防止越界。-i:每轮结束后最后 i 个已就位,不用再比较。
- 外层循环控制轮数:n-1 轮(n个元素最多需要 n-1 轮)
- 内层循环做相邻比较:每轮从头开始,两两比较相邻元素,大的往后交换
- 每轮结束后,最后 i 个元素已经就位,内层循环的范围逐轮缩
过程示例
原数组:[5, 3, 8, 1, 2]
=== 第一轮:把最大的冒泡到最后 ===
比较 5 和 3:5 > 3,交换 → [3, 5, 8, 1, 2]
比较 5 和 8:5 < 8,不动 → [3, 5, 8, 1, 2]
比较 8 和 1:8 > 1,交换 → [3, 5, 1, 8, 2]
比较 8 和 2:8 > 2,交换 → [3, 5, 1, 2, 8]
第一轮结束,8到了最后面 ✓
=== 第二轮:把第二大的冒泡到倒数第二 ===
比较 3 和 5:不动
比较 5 和 1:交换 → [3, 1, 5, 2, 8]
比较 5 和 2:交换 → [3, 1, 2, 5, 8]
(不用比较5和8了,8已经在正确位置)
第二轮结束,5到了倒数第二 ✓
=== 第三轮 ===
比较 3 和 1:交换 → [1, 3, 2, 5, 8]
比较 3 和 2:交换 → [1, 2, 3, 5, 8]
第三轮结束,3就位 ✓
=== 第四轮 ===
比较 1 和 2:不动
第四轮结束,全部有序 ✓
结果:[1, 2, 3, 5, 8]
代码
void bubbleSort(int *nums, int n) {
for (int i = 0; i < n - 1; i++) {
int swapped = 0;
for (int j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
int temp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = temp;
swapped = 1;
}
}
if (!swapped) break;
}
}
特征
- 时间复杂度:O(n²)
- 稳定:相等元素不会交换(条件是
>,不是>=),相对顺序不变 - 原地排序:不需要额外数组
三、选择排序
核心思想
每轮从未排序部分中找到最小元素,放到已排序部分的末尾。
思路
- 外层循环 i 从 0 到< n-1:i 代表"当前要确定的位置"
- 内层循环从 i+1 到 <n:找未排序部分中最小元素的索引并记录 minIndex
- 在外层循环中判断如果 minIndex != i,交换 nums[i] 和 nums[minIndex]
过程示例
原数组:[5, 3, 8, 1, 2]
=== 第一轮:在 [5,3,8,1,2] 中找最小的 ===
最小值是 1(索引3),和第0个位置交换
[1, 3, 8, 5, 2]
位置0已确定 ✓
=== 第二轮:在 [3,8,5,2] 中找最小的 ===
最小值是 2(索引4),和第1个位置交换
[1, 2, 8, 5, 3]
位置0-1已确定 ✓
=== 第三轮:在 [8,5,3] 中找最小的 ===
最小值是 3(索引4),和第2个位置交换
[1, 2, 3, 5, 8]
位置0-2已确定 ✓
=== 第四轮:在 [5,8] 中找最小的 ===
最小值是 5(索引3),已经在正确位置,不用交换
[1, 2, 3, 5, 8]
全部有序 ✓
代码
void selectionSort(int *nums, int n) {
for (int i = 0; i < n - 1; i++) {
int minIndex = i; // 假设当前位置就是最小的
for (int j = i + 1; j < n; j++) { // 在后面找更小的
if (nums[j] < nums[minIndex]) {
minIndex = j; // 记录更小元素的索引
}
}
// 把最小元素和位置i交换
if (minIndex != i) { // 如果最小的不在位置i才交换
int temp = nums[i];
nums[i] = nums[minIndex];
nums[minIndex] = temp;
}
}
}
特征
- 时间复杂度:O(n²)(不管数组是否已经有序,都要完整跑完两层循环)
- 不稳定: 交换可能打乱相同元素的相对顺序
- 不稳定的原因:交换会把元素甩到后面,跳过相等的元素,打乱相对顺序。例:[3a, 3b, 1] → 交换3a和1 → [1, 3b, 3a],3b跑到3a前面了
- 原地排序
四、快速排序
核心思想
选一个基准值(pivot),把数组分成两部分——左边都 ≤ pivot,右边都 > pivot——然后对左右两部分分别递归。
整体结构:两个函数
- partition 分区函数: 把数组按 pivot 分成左右两部分,返回 pivot 最终的位置的下标(此时pivot的位置在正确位置)
- quickSort 递归函数:调用 partition分区,然后递归左右两边(每次递归成功确定一个pivot的位置)
(1)partition 分区的思路
本质就是快慢指针做分区:
- 选最后一个元素
nums[high]作为 pivot - 指针 i = low("≤ pivot 区域"的右边界,类似 slow)
- 指针 j 从 low 到 high-1 扫描(类似 fast):
-
nums[j] <= pivot→ 交换 nums[i] 和 nums[j],i++nums[j] > pivot→ 跳过
- 扫描结束后,交换 nums[i] 和 nums[high](把 pivot 放到正确位置)
- 返回 i(pivot 的位置)
分区过程的数组状态:
分区过程中,数组被分成三个区域:
[low ... i-1] [i ... j-1] [j ... high-1] [high]
≤ pivot > pivot 未处理的 pivot
- i 左边(low 到 i-1):已经确认 ≤ pivot 的元素
- i 到 j-1:已经确认 > pivot 的元素
- j 到 high-1:还没扫描到的元素
- high:pivot 自己
每次 nums[j] <= pivot 时,把 nums[j] 交换到 i 的位置(扩展"≤ pivot"区域),i++。
partition 分区代码
// 对 nums[low..high] 做分区,返回 pivot 最终的位置
int partition(int *nums, int low, int high) {
int pivot = nums[high]; // 选最后一个元素作为pivot
int i = low; // i: 小于等于pivot区域的下一个位置
for (int j = low; j < high; j++) { // j 扫描 low 到 high-1
if (nums[j] <= pivot) {
// 交换 nums[i] 和 nums[j]
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
i++;
}
}
// 把 pivot 放到正确位置
int temp = nums[i];
nums[i] = nums[high];
nums[high] = temp;
return i; // 返回 pivot 的位置
}
(2)quickSort 递归
分区完成后,pivot 在正确位置,左边都比它小,右边都比它大。对左右两部分别再次递归确定新pivot的位置
时间复杂度
最好和平均情况:O(n log n)。 每次 partition 把数组大致分成两半,递归深度是 log n 层,每层扫描全部 n 个元素。
最坏情况:O(n²)。 当数组已经有序(或逆序),每次选最后一个元素做 pivot,partition 后一边是 n-1 个元素,另一边是 0 个——完全不均匀。递归深度变成 n 层,退化成 O(n²)。
如何避免最坏情况
方法:随机选 pivot。 不固定选最后一个,而是随机选一个元素和最后一个交换,再正常做 partition
quickSort 递归代码
void quickSort(int *nums, int low, int high) {
if (low >= high) return;
// 随机选一个元素作为pivot
int randomIndex = low + rand() % (high - low + 1);
// 把它和最后一个元素交换
int temp = nums[randomIndex];
nums[randomIndex] = nums[high];
nums[high] = temp;
int pivotIndex = partition(nums, low, high);
quickSort(nums, low, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, high);
}
// 调用:quickSort(nums, 0, numsSize - 1);
特征
- 平均 O(n log n),最坏 O(n²)(加随机化后极少发生)
- 空间 O(log n)(递归栈,不需要额外数组)
- 不稳定
- 实际速度通常比归并快(原地操作,常数因子小)
五、归并排序
核心思想
把数组从中间分成两半(一直递归分到单个),分别递归排序,然后把两个有序半边合并成一个有序数组。
和快排的区别:快排先分区再递归(分区时做工作),归并先递归再合并(合并时做工作)。
整体结构:两个函数
- merge 合并函数:把两个相邻的有序区间合并成一个有序区间( 不是原地操作,需要创建一个临时数组来存合并结果,合并完再复制回原数组)
- mergeSortHelper 递归拆分函数:递归拆分,然后调用 merge 合并
(1)merge 合并的思路
和题目 21(合并有序链表)、题目 88(合并有序数组)一样——两个指针分别指向两边,每次取较小的放到结果临时数组里。
- i 指向左半边起点(left),j 指向右半边起点(mid+1),k 指向临时数组写入位置(left)
- 两边都有元素时,取较小的放入 temp[k]
- 某一边遍历完后,把另一边剩余的直接放入
- 把 temp 的内容复制回原数组对应区间
merge 代码
void merge(int *nums, int left, int mid, int right, int *temp) {
int i = left; // 左半边的起点
int j = mid + 1; // 右半边的起点
int k = left; // 临时数组的写入位置
// 两边都有元素时,取较小的
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[k++] = nums[i++];
} else {
temp[k++] = nums[j++];
}
}
// 左边剩余
while (i <= mid) {
temp[k++] = nums[i++];
}
// 右边剩余
while (j <= right) {
temp[k++] = nums[j++];
}
// 把临时数组的内容复制回原数组
for (int p = left; p <= right; p++) {
nums[p] = temp[p];
}
}
mergeSortHelper 递归拆分的思路
- 基准情况:
left >= right,0或1个元素,直接返回 - 算中点
mid = left + (right - left) / 2 - 递归排序左半边
[left, mid] - 递归排序右半边
[mid+1, right] - 合并两个有序半边
void mergeSort(int *nums, int left, int right, int *temp) {
if (left >= right) return; // 基准情况:0或1个元素
int mid = left + (right - left) / 2;
mergeSort(nums, left, mid, temp); // 递归排序左半边
mergeSort(nums, mid + 1, right, temp); // 递归排序右半边
merge(nums, left, mid, right, temp); // 合并两个有序半边
}
完整代码
void merge(int *nums, int left, int mid, int right, int *temp) {
int i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[k++] = nums[i++];
} else {
temp[k++] = nums[j++];
}
}
while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];
for (int p = left; p <= right; p++) {
nums[p] = temp[p];
}
}
void mergeSortHelper(int *nums, int left, int right, int *temp) {
if (left >= right) return;
int mid = left + (right - left) / 2;
mergeSortHelper(nums, left, mid, temp);
mergeSortHelper(nums, mid + 1, right, temp);
merge(nums, left, mid, right, temp);
}
void mergeSort(int *nums, int n) {
int *temp = (int *)malloc(sizeof(int) * n);
mergeSortHelper(nums, 0, n - 1, temp);
free(temp);
}
// 调用方式:
// mergeSort(nums, numsSize);
特征
- 时间复杂度:始终 O(n log n)(最好最坏平均都是,不会退化)
- 空间复杂度:O(n)(临时数组 O(n) + 递归栈 O(log n),O(n) 是大头)
- 稳定: 合并时条件用
<=,相等元素左边的先放入,相对顺序不变
六、快速选择(题目215:第K个最大元素)
核心思想
利用快排 partition 分区的性质:partition 分区后 pivot 在最终正确位置。如果 pivot 恰好在目标位置,直接返回;否则只递归包含目标位置的那一边。
和快排的区别:快排每次递归两边,快速选择每次只递归一边。
思路
第K大的元素在升序排序后的索引是 targetIndex = n - k。
- 对数组做 partition, 得到 pivot 的位置 pivotIndex
pivotIndex == targetIndex→ 找到了,返回 nums[pivotIndex]pivotIndex < targetIndex→ 目标在右半边,只递归右边pivotIndex > targetIndex→ 目标在左半边,只递归左边
代码
int partition(int *nums, int low, int high) {
// 随机选pivot
int randomIndex = low + rand() % (high - low + 1);
int temp = nums[randomIndex];
nums[randomIndex] = nums[high];
nums[high] = temp;
int pivot = nums[high];
int i = low;
for (int j = low; j < high; j++) {
if (nums[j] <= pivot) {
int t = nums[i];
nums[i] = nums[j];
nums[j] = t;
i++;
}
}
int t = nums[i];
nums[i] = nums[high];
nums[high] = t;
return i;
}
int quickSelect(int *nums, int low, int high, int targetIndex) {
if (low == high) return nums[low]; // 只剩一个元素
int pivotIndex = partition(nums, low, high);
if (pivotIndex == targetIndex) {
return nums[pivotIndex]; // 找到了
} else if (pivotIndex < targetIndex) {
return quickSelect(nums, pivotIndex + 1, high, targetIndex); // 在右边找
} else {
return quickSelect(nums, low, pivotIndex - 1, targetIndex); // 在左边找
}
}
int findKthLargest(int *nums, int numsSize, int k) {
return quickSelect(nums, 0, numsSize - 1, numsSize - k);
}
时间复杂度
- 平均 O(n)。 每次 partition 是 O(n),但只递归一半。总工作量:n + n/2 + n/4 + ... ≈ 2n = O(n)。
- 最坏 O(n²)(和快排一样,每次 partition 极度不均匀)。加随机化后最坏情况极少发生。
更多推荐


所有评论(0)