博客:TomGo
本文涵盖:基础原理、双指针法、前后指针法、三数取中、小区间优化、非递归版本、易错点、面试高频考点
目录
[3.1 整体流程](#3.1 整体流程)
[3.2 易错点一:比较符号必须带等号](#3.2 易错点一:比较符号必须带等号)
[3.3 易错点二:end 必须先走](#3.3 易错点二:end 必须先走)
[3.4 易错点三:循环条件用 begin < end,不是 left < right](#3.4 易错点三:循环条件用 begin < end,不是 left < right)
[3.5 易错点四:终止条件用 >= 不是 >](#3.5 易错点四:终止条件用 >= 不是 >)
[4.1 为什么 ++prev 不能放在 swap 里?](#4.1 为什么 ++prev 不能放在 swap 里?)
[4.2 为什么条件是 < 不是 <=(不带等号)](#4.2 为什么条件是 < 不是 <=(不带等号))
[4.3 相遇时为什么不用 swap?](#4.3 相遇时为什么不用 swap?)
[5.1 三数取中为什么能避免退化?](#5.1 三数取中为什么能避免退化?)
[5.2 InsertSort 参数易错点](#5.2 InsertSort 参数易错点)
[5.3 GetMidi 后必须先 Swap 再记 keyi](#5.3 GetMidi 后必须先 Swap 再记 keyi)
[6.1 非递归易错点](#6.1 非递归易错点)
一、快排是什么?大白话说原理
快排的核心思想只有一句话:
选一个基准值(pivot),把比它小的全放左边,比它大的全放右边,然后递归处理左右两侧。
每次分区之后,pivot 就永远待在它正确的位置上了,不需要再动。
举个例子,数组 [5, 3, 8, 1, 6, 2, 7, 4],选 pivot = 5:
分区后:[3, 1, 2, 4] | 5 | [8, 6, 7]
↑
pivot归位
然后递归排左边的 [3,1,2,4] 和右边的 [8,6,7],每次都把一个元素永久归位,直到全部有序。
二、基础版快排(教学版)
最简单的版本,直接看懂原理:
cpp
// 分区函数,返回pivot归位后的下标
int Partition(int* a, int left, int right)
{
int keyi = left; // pivot放在最左边
int begin = left;
int end = right;
while (begin < end)
{
// end从右向左找小于pivot的值(右边找小)
while (begin < end && a[end] >= a[keyi])
end--;
// begin从左向右找大于pivot的值(左边找大)
while (begin < end && a[begin] <= a[keyi])
begin++;
Swap(&a[begin], &a[end]);
}
// begin和end相遇,把pivot换到中间
Swap(&a[keyi], &a[begin]);
return begin;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int keyi = Partition(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
三、双指针分区详解(易错重点!)
3.1 整体流程
初始:[ pivot | ......begin......end...... ]
↑ ↑
keyi=left right
end从右找小 → begin从左找大 → swap → 重复
begin==end时 → swap(pivot, begin) → pivot归位
3.2 易错点一:比较符号必须带等号
内层循环条件必须是 >= 和 <=,不能是 > 和 <。
原因: 不带等号时,遇到和 pivot 相等的元素,两个指针都会停下来,反复 swap 同一对位置,形成死循环。
// 错误写法(遇到重复元素死循环)
while (begin < end && a[end] > a[keyi]) // 少了=
end--;
while (begin < end && a[begin] < a[keyi]) // 少了=
begin++;
// 正确写法
while (begin < end && a[end] >= a[keyi])
end--;
while (begin < end && a[begin] <= a[keyi])
begin++;
3.3 易错点二:end 必须先走
pivot 放在 a[left],end 必须先走(从右找小),begin 再走(从左找大)。
原因: 最后 Swap(&a[keyi], &a[begin]) 时,要保证 a[begin] 的值 <= pivot,pivot 换过去才正确。end 先走能保证 begin 最终停在一个 <= pivot 的位置。如果 begin 先走,相遇位置可能是 > pivot 的值,swap 后 pivot 跑到错误位置。
3.4 易错点三:循环条件用 begin < end,不是 left < right
cpp
// 错误:left和right从来不变,死循环!
while (left < right)
{
while (left < right && a[end] >= a[keyi]) // 内层也是left<right,同样死循环
end--;
}
// 正确:用begin和end控制
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
end--;
while (begin < end && a[begin] <= a[keyi])
begin++;
Swap(&a[begin], &a[end]);
}
3.5 易错点四:终止条件用 >= 不是 >
// 错误:left==right时(单个元素)会继续执行**,可能越界**
if (left > right)
return;
// 正确
if (left >= right)
return;
四、前后指针分区法(另一种写法)
prev 始终指向"小于 pivot 区域"的最右边界,cur 向右扫描。
int PartitionPP(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = prev + 1;
while (cur <= right)
{
// a[cur] < pivot 才收进小区域
// ++prev != cur:防止自己和自己swap(优化)
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
cur++;
}
Swap(&a[prev], &a[keyi]); // pivot归位
return prev;
}
4.1 为什么 ++prev 不能放在 swap 里?
// 错误写法
if (a[cur] < a[keyi] && prev + 1 != cur)
Swap(&a[++prev], &a[cur]);
当 a[cur] < pivot 成立,但 prev+1 == cur(cur紧挨着prev)时:
- 正确行为:小区域扩张一格(
++prev),cur就地并入,不需要swap - 错误写法:整个if不执行,prev没有自增,小区域边界丢了
正确写法:
cpp
if (a[cur] < a[keyi])
{
++prev;
if (prev != cur)
Swap(&a[prev], &a[cur]);
}
或者原版的 ++prev != cur 放在条件里,无论是否swap,prev 都已经自增。
4.2 为什么条件是 < 不是 <=(不带等号)
prev 管的是"严格小于 pivot"的区域,等于 pivot 的元素不属于这个区域,语义上就不应该收进来。等于 pivot 的元素自然留在右边,pivot 归位后:
左边:< pivot | pivot | 右边:>= pivot
边界清晰,不会混淆。
4.3 相遇时为什么不用 swap?
++prev == cur 时,两个指针指的是同一个位置,Swap(&a[prev], &a[cur]) 等于自己和自己换,结果不变,跳过只是省掉多余操作。
五、工业级优化版(完整代码)
基础快排有两个致命缺陷:
- 有序数组退化:每次选最左元素为 pivot,有序数组每次分区极度不均,O(n²)
- 小区间递归开销大:区间只有 3~5 个元素时,递归压栈弹栈的开销比直接排序还大
解决方案:三数取中 + 小区间切换插入排序
cpp
// 插入排序(用于小区间)
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int temp = a[end + 1];
while (end >= 0)
{
if (a[end] > temp)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end + 1] = temp;
}
}
// 三数取中:取 left、mid、right 三个位置中值的下标
int GetMidi(int* a, int left, int right)
{
int mid = left + (right - left) / 2;
if (a[left] < a[mid])
{
if (a[mid] < a[right])
return mid;
else if (a[left] < a[right])
return right;
else
return left;
}
else // a[left] >= a[mid]
{
if (a[mid] > a[right])
return mid;
else if (a[left] > a[right])
return right;
else
return left;
}
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 优化一:小区间切换插入排序
if ((right - left + 1) <= 10)
{
InsertSort(a + left, right - left + 1);
// 注意:a+left 是偏移后的起始地址,right-left+1 是长度
// 不能写 InsertSort(a, right-left+1),那样会从数组开头排
}
else
{
// 优化二:三数取中选pivot,避免有序数组退化
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
// 先swap再记keyi,此时a[left]才是中间值
// 顺序:GetMidi → Swap → keyi=left,不能乱
int keyi = left;
int begin = left;
int end = right;
while (begin < end)
{
while (begin < end && a[end] >= a[keyi])
end--;
while (begin < end && a[begin] <= a[keyi])
begin++;
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[begin]);
keyi = begin;
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
}
5.1 三数取中为什么能避免退化?
有序数组 [1,2,3,4,5,6,7,8]:
- 朴素快排:每次取
a[left]=1为 pivot,分区后左边空,右边全部,递归深度 n,退化 O(n²) - 三数取中:取 left=1、mid=4、right=8,中间值是 4,分区均匀,递归深度 log n,保持 O(n log n)
5.2 InsertSort 参数易错点
注意: 传的是数组要开始改的元素的地址
// 错误
InsertSort(a, right - left + 1); // 从数组开头排,范围不对
InsertSort(a + left, right - left); // 长度少算了一个
// 正确
InsertSort(a + left, right - left + 1);
// a+left:偏移到子数组起始位置
// right-left+1:子数组长度(闭区间,两端都算)
5.3 GetMidi 后必须先 Swap 再记 keyi
// 错误:忘记swap,pivot根本不是中间值,三数取中白做了
int keyi = left;
int midi = GetMidi(a, left, right);
// 直接开始分区...
// 正确:顺序固定
int midi = GetMidi(a, left, right); // 1. 找中间值下标
Swap(&a[left], &a[midi]); // 2. 换到a[left]
int keyi = left; // 3. 记住pivot位置
六、非递归版本(用栈模拟)
递归版在数据量极大时可能栈溢出,非递归用手动栈解决。
思路:把区间 [left, right] 压栈,循环弹出处理,分区后把左右子区间再压栈。
cpp
// 数组模拟栈
#define MAXSIZE 10000
typedef struct Stack
{
int data[MAXSIZE];
int top;
} Stack;
void STInit(Stack* st) { st->top = -1; }
void STPush(Stack* st, int val) { st->data[++(st->top)] = val; }
int STPop(Stack* st) { return st->data[(st->top)--]; }
int STEmpty(Stack* st) { return st->top == -1; }
void STDestroy(Stack* st) { /* 数组无需释放 */ }
// 分区函数(把分区逻辑单独抽出来)
int Partition(int* a, int left, int right)
{
int midi = GetMidi(a, left, right);
Swap(&a[left], &a[midi]);
int keyi = left;
int begin = left, end = right;
while (begin < end)
{
while (begin < end && a[end] >= a[keyi]) end--;
while (begin < end && a[begin] <= a[keyi]) begin++;
Swap(&a[begin], &a[end]);
}
Swap(&a[keyi], &a[begin]);
return begin;
}
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
STInit(&st);
// 压栈顺序:right先压,left后压
// 因为栈后进先出,left要先弹出
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int l = STPop(&st);
int r = STPop(&st);
if (l >= r) // 无效区间跳过,用continue不用return
continue;
int keyi = Partition(a, l, r);
// 压右子区间:right先压,keyi+1后压
STPush(&st, r);
STPush(&st, keyi + 1);
// 压左子区间:keyi-1先压,l后压
STPush(&st, keyi - 1);
STPush(&st, l);
}
STDestroy(&st); // 记得销毁,避免内存泄漏
}
6.1 非递归易错点
| 易错点 | 说明 |
|---|---|
| 无效区间用 continue | l >= r 时用 continue 跳过,不能用 return(会退出整个函数) |
| 压栈顺序 | right 先压,left 后压,保证 left 先弹出 |
| 忘记 STDestroy | 动态分配的栈要释放,数组模拟的栈无需释放但要有好习惯 |
| while 条件写错 | !STEmpty(&st),栈不为空就继续 |
七、三种分区方法对比
| 方法 | 代码复杂度 | 适合场景 |
|---|---|---|
| 双指针(Hoare) | 中等 | 通用,理解最重要 |
| 前后指针 | 较简单 | 逻辑清晰,适合手写 |
| 非递归 | 较复杂 | 数据量极大,防栈溢出 |
八、复杂度分析
| 情况 | 时间复杂度 | 说明 |
|---|---|---|
| 平均 | O(n log n) | 每次分区均匀,递归深度 log n |
| 最好 | O(n log n) | 每次 pivot 恰好在中间 |
| 最坏 | O(n²) | 有序数组 + 朴素快排,三数取中可避免 |
| 空间 | O(log n) | 递归调用栈深度 |
稳定性:不稳定(swap 会改变相等元素的相对顺序)
九、面试高频考点
Q1:快排最坏情况是什么?怎么避免?
最坏情况是有序或逆序数组,朴素快排每次 pivot 都选到最值,分区极度不均,退化 O(n²)。避免方法:三数取中选 pivot,或者随机选 pivot。
Q2:快排是稳定排序吗?
不稳定。swap 操作会改变相同值元素的相对位置。
Q3:快排和归并排序哪个快?
平均情况快排更快,因为常数系数小、缓存友好。但归并是稳定排序,最坏情况也是 O(n log n),快排最坏 O(n²)。实际工程中 STL 的 std::sort 用的是 Introsort(快排 + 堆排 + 插入排序的组合)。
Q4:小区间为什么切换插入排序?阈值为什么是10?
递归到小区间时,函数调用压栈弹栈的开销占比很高,插入排序在小数据量时实际性能更好(常数系数极小)。阈值 10 是工程经验值,不是理论推导,STL 各实现普遍用 8~16。
Q5:非递归快排有什么意义?
递归版依赖系统调用栈,数据量极大时(递归深度超过系统栈限制)会栈溢出。非递归用堆上的手动栈,不受系统栈大小限制。
Q6:三数取中的 mid 下标怎么算?
int mid = left + (right - left) / 2;
// 不能写 (left + right) / 2,left+right可能整数溢出
Q7:为什么最后是和 begin 换而不是和 end 换?
begin == end 时两者是同一位置,换谁都一样。但逻辑上 pivot 在 left,begin 是小区域右边界,和 begin 换语义更清晰。关键是要保证相遇位置的值 <= pivot,end 先走能保证这一点。
十、总结:写代码时的核查清单
写完快排之后,对照检查:
if (left >= right) return;------ 终止条件用>=,不是> 否则会过度访问- 内层循环用
begin < end控制,不是left < right - end 先走,begin 后走
- 比较符号带等号:
a[end] >= a[keyi],a[begin] <= a[keyi] - 最后
Swap(&a[keyi], &a[begin])把 pivot 归位,不能漏 InsertSort(a + left, right - left + 1)两个参数都不能写错GetMidi → Swap → keyi = left,顺序不能乱- 非递归版:无效区间用
continue,记得STDestroy