排序是编程中最基础也最核心的算法之一,无论是笔试面试还是实际开发,都绕不开它。
冒泡排序作为入门级的交换排序,原理简单、容易上手,是新手理解 "交换排序" 的绝佳案例;
而快速排序(Quick Sort)则是冒泡排序的进阶版,凭借 "分治" 思想实现了更高的效率,也是实际开发中最常用的排序算法之一。今天我们先快速回顾冒泡排序,再重点拆解快速排序的核心逻辑,配合代码和模拟步骤。
一、冒泡排序(快速回顾)
1. 核心思想
冒泡排序的核心是 "相邻比较,逆序交换":每一轮遍历数组,将相邻的逆序元素交换,最终把当前未排序部分的最大元素 "冒泡" 到末尾。重复这个过程,直到整个数组有序。
2. 极简实现(带优化)
cpp
#include <iostream>
#include <vector>
using namespace std;
// 打印数组(通用函数)
void printArr(const vector<int>& arr)
{
for (int num : arr) cout << num << " ";
cout << endl;
}
// 冒泡排序(优化版:提前退出无交换的轮次)
void bubbleSort(vector<int>& arr)
{
int n = arr.size();
// 共n-1轮(最后一个元素无需比较)
for (int i = 0; i < n - 1; ++i)
{
bool swapped = false; // 标记本轮是否有交换
// 每轮少比较i个(已排序的末尾)
for (int j = 0; j < n - 1 - i; ++j)
{
// 逆序则交换
if (arr[j] > arr[j+1])
{
swap(arr[j], arr[j+1]);
swapped = true;
}
}
if (!swapped) break; // 本轮无交换,说明数组已有序,提前退出
}
}
// 冒泡排序测试
int main_bubble()
{
vector<int> arr = {5, 3, 8, 4, 2};
cout << "冒泡排序前:";
printArr(arr);
bubbleSort(arr);
cout << "冒泡排序后:";
printArr(arr);
return 0;
}
3. 简单分析
- 时间复杂度:最好 O (n)(数组已有序),最坏 / 平均 O (n²);
- 空间复杂度:O (1)(原地排序);
- 稳定性:稳定(相等元素不交换)。
冒泡排序胜在简单,但 O (n²) 的时间复杂度使其在数据量稍大时效率极低 ------ 这也是我们需要快速排序的原因。
二、快速排序
快速排序由霍尔(Hoare)在 1960 年提出,核心是分治思想 + 基准值分区:将数组以 "基准值(Pivot)" 为界分成两部分,左边≤基准值,右边≥基准值;然后递归处理左右两部分,最终整个数组有序。
1. 核心三步法)
快排的逻辑可拆解为 3 个核心步骤,我们从易到难讲清楚:
步骤 1:选基准值(Pivot)
基准值是快排的 "分割点",选择方式直接影响效率:
- 入门版:选数组最后一个元素(易理解,适合新手);
- 优化版:三数取中法(首、中、尾选中间值),避免有序数组退化;
- 进阶版:随机选基准(进一步降低退化概率)。
先从 "选最后一个元素为基准" 入手,降低理解门槛。
步骤 2:分区(Partition)------ 快排的核心!
分区的目标:把数组中≤基准值的元素放到左边,≥基准值的放到右边,最终返回基准值的正确位置。
分区核心逻辑(单路分区):
- 定义
low指针(指向当前可交换的位置),基准值选最后一个元素; - 遍历数组
[left, right-1],遇到≤基准值的元素,就和low位置的元素交换,然后low++; - 遍历结束后,将基准值和
low位置的元素交换,此时low就是基准值的正确位置。
手动模拟分区(必看) :以数组[5, 3, 8, 4, 2]为例(选最后一个元素 2 为基准):
cpp
初始状态:arr=[5,3,8,4,2],left=0,right=4,pivot=2,low=0
1. 遍历j=0(元素5):5>2 → 不交换,j++
2. 遍历j=1(元素3):3>2 → 不交换,j++
3. 遍历j=2(元素8):8>2 → 不交换,j++
4. 遍历j=3(元素4):4>2 → 不交换,j++
5. 遍历结束,交换low(0)和right(4) → arr=[2,3,8,4,5],基准值2的位置是0(正确)。
递归处理右区间[3,8,4,5](left=1,right=4),pivot=5,low=1
1. j=1(3≤5)→ 交换low(1)和j(1)(无变化)low++;,low=2 指向元素8
2. j=2(8>5)→ 不交换,j++
3. j=3(4≤5)→ 交换low(2)元素8 和 j(3)元素4交换 → arr=[2,3,4,8,5],low=3指向元素8
4. 交换low(3)和right(4)元素5 → arr=[2,3,4,5,8],基准值5的位置是4(正确)。
最终递归处理剩余子数组,数组完全有序。
步骤 3:递归处理左右子数组
基准值位置确定后,左子数组[left, pivotPos-1]和右子数组[pivotPos+1, right]重复 "选基准→分区→递归",直到子数组长度≤1(天然有序)。
2. 基础版快排递归实现
cpp
// 分区的目标:把数组中≤基准值的元素放到左边,≥基准值的放到右边,最终返回基准值的正确位置。
int partition(vector<int>& arr, int left, int right)
{
int pivot = arr[right]; // 选最后一个元素作为基准值
int low = left; // 遍历指针:指向当前可交换的位置
// 遍历 已排序区间[0,cur-1] cur [cur+1,right]未排序等待遍历区间
for (int cur = left; cur < right; ++cur)
{
// 找到≤基准值的元素,交换到low位置
if (arr[cur] <= pivot)
{
swap(arr[low++], arr[cur]);
}
}
//遍历完之后的数组 <=基准值[0,low-1] >基准值 [low,right-1] 最后一个值下标right-》pivot基准值
// 把基准值放到正确位置(low)
swap(arr[low], arr[right]);
// 返回基准值的下标索引,用于递归分割数组
return low;
}
// 快速排序递归函数
void _quickSort(vector<int>& arr, int left, int right)
{
// 递归终止条件:子数组长度≤1(left >= right)
if (left >= right) return;
// 1. 分区
int pivotPos = partition(arr, left, right);
// 2. 递归处理左子数组
_quickSort(arr, left, pivotPos - 1);
// 3. 递归处理右子数组
_quickSort(arr, pivotPos + 1, right);
}
// 快速排序入口函数(封装递归,方便调用)
void quickSort(vector<int>& arr)
{
if (arr.empty()) return;
_quickSort(arr, 0, arr.size() - 1);
}
// 快速排序测试
int main()
{
vector<int> arr = { 5, 3, 8, 4, 2 };
quickSort(arr);
return 0;
}
3. 快排优化(解决核心痛点)
基础版快排存在两个痛点:① 有序数组会导致分区极不均衡,时间复杂度退化到 O (n²);② 大量重复元素会降低效率。以下是两种核心优化:
优化 1:三数取中法选基准(首、中、尾选中间值,避免有序数组退化)
cpp
// 分区函数(优化:三数取中法选基准)
int partition(vector<int>& arr, int left, int right)
{
// ===== 三数取中核心逻辑 start =====
//
// 1. 计算中间位置(避免left+right溢出)
int mid = left + (right - left) / 2;
// 2. 调整left、mid、right三个位置的元素,让arr[right]成为这三个数的「中间值」 right>mid>left
if (arr[left] > arr[mid])
{
swap(arr[left], arr[mid]);
}
if (arr[left] > arr[right])
{
swap(arr[left], arr[right]);
}
if (arr[mid] > arr[right])
{
swap(arr[mid], arr[right]);
}
// ===== 三数取中核心逻辑 end =====
// 基准仍选arr[right],但已是三数中间值
int pivot = arr[right]; // 基准值
int low = left; // 遍历指针:指向当前可交换的位置
// 遍历 已排序区间[0,cur-1] cur [cur+1,right]未排序等待遍历区间
for (int cur = left; cur < right; ++cur)
{
// 找到≤基准值的元素,交换到low位置(low++ 等价先交换再右移)
if (arr[cur] <= pivot)
{
swap(arr[low++], arr[cur]);
}
}
//遍历完之后的数组 <=基准值[0,low-1] >基准值 [low,right-1] 最后一个值下标right-》pivot基准值
// 把基准值放到正确位置(low)
swap(arr[low], arr[right]);
// 返回基准值的下标索引,用于递归分割数组
return low;
}
// 快速排序递归函数(完全复用你的代码)
void _quickSort(vector<int>& arr, int left, int right)
{
// 递归终止条件:子数组长度≤1(left >= right)
if (left >= right) return;
// 1. 分区(此时分区的基准已是三数中间值)
int pivotPos = partition(arr, left, right);
// 2. 递归处理左子数组
_quickSort(arr, left, pivotPos - 1);
// 3. 递归处理右子数组
_quickSort(arr, pivotPos + 1, right);
}
// 快速排序入口函数(封装递归,方便调用)
void quickSort(vector<int>& arr)
{
if (arr.empty()) return;
_quickSort(arr, 0, arr.size() - 1);
}
// 测试函数(新增打印,方便学生观察排序过程)
int main()
{
vector<int> arr = { 5, 3, 8, 4, 2 };
cout << "排序前:";
printArr(arr);
quickSort(arr);
cout << "排序后:";
printArr(arr);
return 0;
}
优化 2:尾递归优化(减少递归栈开销)
cpp
// 分区函数(三数取中法选基准)
int partition(vector<int>& arr, int left, int right)
{
// ===== 三数取中核心逻辑 start =====
int mid = left + (right - left) / 2; // 计算中间位置(避免left+right溢出)
// 调整left、mid、right三个位置的元素,让arr[right]成为这三个数的「中间值」
if (arr[left] > arr[mid])
{
swap(arr[left], arr[mid]);
}
if (arr[left] > arr[right])
{
swap(arr[left], arr[right]);
}
if (arr[mid] > arr[right])
{
swap(arr[mid], arr[right]);
}
// ===== 三数取中核心逻辑 end =====
// 基准仍选arr[right],但已是三数中间值
int pivot = arr[right]; // 基准值
int low = left; // 遍历指针:指向当前可交换的位置
// 遍历 已排序区间[0,cur-1] cur [cur+1,right]未排序等待遍历区间
for (int cur = left; cur < right; ++cur)
{
// 找到≤基准值的元素,交换到low位置(low++ 等价先交换再右移)
if (arr[cur] <= pivot)
{
swap(arr[low++], arr[cur]);
}
}
//遍历完之后的数组 <=基准值[0,low-1] >基准值 [low,right-1] 最后一个值下标right-》pivot基准值
// 把基准值放到正确位置(low)
swap(arr[low], arr[right]);
// 返回基准值的下标索引,用于递归分割数组
return low;
}
// 快速排序递归函数(优化:尾递归优化,减少递归栈开销)尾递归优化核心:用循环替代「较长子数组」的递归,只递归处理「较短子数组」
// 2. 优先递归处理「较短的子数组」,降低递归栈深度
// a: 左子数组短 ,递归处理左子数组 。右子数组较长,用循环替代递归,更新left后继续分区
// b: 右子数组短 ,递归处理右子数组 。左子数组较长,用循环替代递归,更新right后继续分区
void _quickSort(vector<int>& arr, int left, int right)
{
while (left < right)
{
// 1. 分区
int pivotPos = partition(arr, left, right);
if (pivotPos - left < right - pivotPos) // a: 左子数组短
{
_quickSort(arr, left, pivotPos - 1);
left = pivotPos + 1;
}
else // b: 右子数组短
{
_quickSort(arr, pivotPos + 1, right);
right = pivotPos - 1;
}
}
}
void quickSort(vector<int>& arr)
{
if (arr.empty()) return;
_quickSort(arr, 0, arr.size() - 1);
}
// 测试函数
int main()
{
vector<int> arr = { 5, 3, 8, 4, 2 };
quickSort(arr);
return 0;
}
尾递归优化核心讲解
1. 为什么需要尾递归优化?
原始递归逻辑是:分区后递归处理左子数组 + 递归处理右子数组 。如果数组极不均衡(比如基准值每次都把数组分成「1 个元素」和「n-1 个元素」),递归栈会像 "叠罗汉" 一样越叠越深(最坏栈深度 O (n)),可能导致栈溢出,也会增加函数调用开销。
2. 尾递归优化的核心思路
"捡芝麻,丢西瓜":
- 芝麻(较短子数组):用递归处理(栈深度只增加一点点);
- 西瓜(较长子数组):用
while循环替代递归(不新增栈帧,而是复用当前栈帧,更新 left/right 后重新分区)。最终递归栈深度最多只有O(logn),大幅减少栈开销。
3. 代码改动点(对比原始_quickSort)
| 原始版本 | 尾递归优化版本 | 改动原因 |
|---|---|---|
if (left >= right) return; |
while (left < right) { ... } |
循环处理较长子数组,替代递归 |
| 递归处理左 + 右子数组 | 只递归处理较短子数组,循环更新 left/right | 减少递归栈深度 |
4. 手动模拟优化过程(以测试数组[5,3,8,4,2]为例)
- 初始
left=0, right=4,进入 while 循环,分区后pivotPos=1(基准值 3 的位置); - 计算子数组长度:左
[0,0](长度 1),右[2,4](长度 3)→ 左更短; - 递归处理左子数组
[0,0](直接返回,无开销); - 更新
left=2,循环继续处理右子数组[2,4]; - 分区
[2,4]后pivotPos=3,计算子数组长度:左[2,2](长度 1),右[4,4](长度 1); - 递归处理较短的子数组,循环结束,全程递归栈深度仅 1 层。
5. 关键提示
- 尾递归优化没有改变快排的核心逻辑(分区、分治),只是改变了 "递归的方式";
- 尾递归优化是 "工程级优化",C++ 编译器对尾递归的优化支持有限,手动改循环是最稳妥的方式。
最终优化效果总结
| 优化点 | 解决的问题 | 效果 |
|---|---|---|
| 三数取中 | 有序数组导致快排退化到 O (n²) | 稳定保持 O (nlogn) |
| 尾递归优化 | 递归栈过深导致栈溢出 / 函数调用开销大 | 栈深度从 O (n)→O (logn) |
这个版本既保留了原有的代码结构,又完成了核心优化