快速排序算法学习记录
1. 算法简介
快速排序由 Tony Hoare 于 1959 年提出,是一种基于分治思想的高效排序算法。
核心步骤:
- 选择基准(Pivot):从数组中选取一个元素作为基准值
- 分区(Partition):将数组重新排列,使得小于基准的元素放在左侧,大于基准的放在右侧
- 递归排序:对左右两个子数组递归执行上述过程
简略示意图
初始数组: [3, 6, 2, 7, 1, 5, 4] 选择 pivot = 4
分区过程:
[3, 2, 1] 4 [6, 7, 5]
小于 pivot ↑ 大于 pivot
递归排序:
[1, 2, 3] 4 [5, 6, 7]
───────── ↑ ─────────
已排序 基准 已排序
最终结果: [1, 2, 3, 4, 5, 6, 7]
2. 算法优点
| 优点 | 说明 |
|---|---|
| 平均时间复杂度低 | 平均时间复杂度为 O(n log n),在实际应用中通常表现优异 |
| 原地排序 | 空间复杂度为 O(log n)(递归栈空间),不需要额外的数组空间 |
| 缓存友好 | 数据局部性好,CPU 缓存命中率高 |
| 实现简洁 | 核心逻辑(分区 + 递归)代码量很少,易于理解和实现 |
| 内排序 | 所有操作在内存中完成,不涉及磁盘 I/O |
3. 算法缺点
尽管快速排序在平均情况下表现优异,但它并非完美无缺:
3.1 最坏时间复杂度 O(n²)
当数组已经有序 或所有元素相等时,如果每次选取的 pivot 都是当前子数组的最大/最小值,递归树会退化成一条链:
最坏情况示意图(已排序数组,每次选第一个作为 pivot):
[1, 2, 3, 4, 5, 6, 7] pivot=1
↑
[1] [2, 3, 4, 5, 6, 7] pivot=2
↑
[2] [3, 4, 5, 6, 7] pivot=3
↑
[3] [4, 5, 6, 7]
...
递归深度: n 层
时间复杂度: O(n²)
3.2 不稳定排序
相等元素的相对位置可能在分区过程中被打乱,属于不稳定排序算法。
3.3 对于小数组效率低
递归开销在数据量小时反而不如插入排序等简单算法。
4. 改进方案
针对上述缺点,尤其是最坏 O(n²) 的问题,引入两种经典优化:
4.1 随机取 Key(Randomized Pivot)
核心思想 :不再固定选取第一个或最后一个元素作为 pivot,而是随机选取。
原始数组: [1, 2, 3, 4, 5, 6, 7] (已排序)
固定选首元素: 每次 pivot 都是最小值 → O(n²)
随机选 pivot: 可能选到 4 → 平衡分区 → O(n log n)
随机化后期望:
[1, 2, 3] 4 [5, 6, 7]
───────── ─────────
左子树 右子树
期望递归深度: O(log n)
期望时间复杂度: O(n log n)
优点:从概率意义上消除了最坏情况,使得算法在期望意义上达到 O(n log n)。
4.2 三路划分(Three-way Partition)
核心思想 :将数组划分为三个区域:小于 pivot 、等于 pivot 、大于 pivot。
传统两路划分: 三路划分:
[ < pivot | > pivot ] [ < pivot | = pivot | > pivot ]
↑ ↑ ↑ ↑ ↑
left right lt gt right
三路划分过程示例:
数组: [2, 4, 3, 4, 1, 4, 5], pivot = 4
遍历中:
[2, 1, 3, 4, 4, 4, 5]
↑ ↑
lt gt
结果:
[2, 1, 3] [4, 4, 4] [5]
< 4 = 4 > 4
为什么需要三路划分?
当数组中存在大量重复元素 时,传统快速排序会将所有等于 pivot 的元素分到一边,导致分区极度不平衡。三路划分将重复元素"挤"到中间,跳过对等于 pivot 区域的递归,大幅提升效率。
时间复杂度优化:
- 大量重复元素场景下:从 O(n²) 优化到 O(n)
5. 总结
| 特性 | 基础快排 | 随机化快排 | 三路快排 |
|---|---|---|---|
| 平均时间 | O(n log n) | O(n log n) | O(n log n) |
| 最坏时间 | O(n²) | O(n²)(概率极低) | O(n²) |
| 重复元素处理 | 较差 | 一般 | 优秀 |
| 适用场景 | 通用 | 通用(防攻击) | 重复元素多 |
实践建议 :现代标准库(如 C++
std::sort、JavaArrays.sort)通常采用混合策略------小数组用插入排序,中等数组用随机化快排,大量重复元素时切换到三路划分,以达到最优的综合性能。
练习题目
LeetCode 75. 颜色分类

题目链接:75. 颜色分类
这道题是三路划分的经典应用。数组中只有 0、1、2 三种元素,本质上就是把数组划分为三个区域:
| 元素值 | 对应三路划分 | 区域含义 |
|---|---|---|
| 0 | < pivot |
左侧区域 |
| 1 | = pivot(pivot 取 1) |
中间区域 |
| 2 | > pivot |
右侧区域 |
执行过程示例
以 nums = [2, 0, 2, 1, 1, 0] 为例,演示三路划分过程:
初始: [2, 0, 2, 1, 1, 0] left=-1, right=6, i=0
↑
i
步骤1: nums[0]=2 → 与 right-1=5 交换
[0, 0, 2, 1, 1, 2] right=5
↑ ↑
i right
步骤2: nums[0]=0 → 与 left+1=0 交换,left++, i++
[0, 0, 2, 1, 1, 2] left=0, i=1
↑
i
步骤3: nums[1]=0 → 与 left+1=1 交换,left++, i++
[0, 0, 2, 1, 1, 2] left=1, i=2
↑
i
步骤4: nums[2]=2 → 与 right-1=4 交换,right--
[0, 0, 1, 1, 2, 2] right=4
↑
i
步骤5: nums[2]=1 → i++
[0, 0, 1, 1, 2, 2] i=3
↑
i
步骤6: nums[3]=1 → i++
[0, 0, 1, 1, 2, 2] i=4
↑
i
结束: i == right,停止
边界条件说明
| 区间类型 | 初始化 | 已确认区域 | 特点 |
|---|---|---|---|
| 开区间 | left=-1, right=n |
[0..left] 是 0,[right..n-1] 是 2 |
代码简洁,无需处理越界 |
| 闭区间 | left=0, right=n-1 |
[0..left-1] 是 0,[right+1..n-1] 是 2 |
与快排递归接口更统一 |
两种写法本质上等价,闭区间版本在与其他算法(如快排递归)衔接时更自然。
代码实现
开区间版本(推荐用于本题):
cpp
class Solution {
public:
void sortColors(vector<int>& nums) {
int left = -1; // [0..left] 区域存放 0
int right = nums.size(); // [right..n-1] 区域存放 2
int i = 0; // 当前扫描指针,指向待处理区域
while (i < right) {
if (nums[i] == 0) {
// 遇到 0:扩展到左侧区域
// i 右移,因为交换过来的元素来自 [left+1..i-1],只可能是 0 或 1,都合法
swap(nums[i], nums[++left]);
i++;
} else if (nums[i] == 1) {
// 遇到 1:属于中间区域,直接跳过
i++;
} else {
// 遇到 2:扩展到右侧区域
// i 不能右移!因为交换过来的元素来自未处理区域 [i+1..right-1],需要重新判断
swap(nums[i], nums[--right]);
}
}
}
};
闭区间版本(与快排递归风格统一):
cpp
class Solution {
public:
void sortColors(vector<int>& nums) {
int left = 0; // [0..left-1] 区域存放 0
int right = nums.size() - 1; // [right+1..n-1] 区域存放 2
int i = 0; // 当前扫描指针
while (i <= right) {
if (nums[i] == 0) {
swap(nums[i], nums[left]);
left++;
i++;
} else if (nums[i] == 1) {
i++;
} else {
swap(nums[i], nums[right]);
right--;
// i 不移动,重新判断交换过来的元素
}
}
}
};
复杂度分析
- 时间复杂度:O(n),每个元素最多被交换两次(一次移到边界,一次可能被交换到中间),指针单向扫描。
- 空间复杂度:O(1),仅使用常数额外空间,原地完成划分。
LeetCode 912. 排序数组

题目链接:LeetCode 912. 排序数组
这道题是快速排序的标准模板题 ,要求对数组进行排序。由于数组中元素取值范围很大(包含负数),无法使用计数排序,因此需要借助基于比较的排序算法。这里采用随机化 + 三路划分的快排实现,既保证期望 O(n log n) 的时间复杂度,又能处理大量重复元素的情况。
代码实现
cpp
class Solution {
public:
vector<int> sortArray(vector<int>& nums) {
qsort(nums, 0, nums.size() - 1);
return nums;
}
void qsort(vector<int>& nums, int left, int right)
{
if (left >= right)
return;
// 随机取 key
int ra = rand();
int key = nums[ra % (right - left + 1) + left];
int i = left;
int l = left, r = right; // 保存原始边界,供递归使用
// 三路划分
while (i <= right)
{
if (nums[i] < key)
{
swap(nums[left], nums[i]);
i++, left++;
}
else if (nums[i] == key)
{
i++;
}
else if (nums[i] > key)
{
swap(nums[right], nums[i]);
right--;
}
}
// 递归排序左右子区间
qsort(nums, l, left - 1);
qsort(nums, right + 1, r);
}
};
要点一:随机数取 Key
cpp
int ra = rand();
int key = nums[ra % (right - left + 1) + left];
这行代码是随机化快排的核心。拆解来看:
right - left + 1:当前子数组的长度(偏移量) ,即区间[left, right]内共有多少个元素。ra % (right - left + 1):对长度取模,得到一个[0, 长度-1]范围内的随机偏移。+ left:将偏移量平移 到以left为起始点的实际索引上。
例如,若 left = 3, right = 7,则区间长度为 5,rand() % 5 得到 0~4 的随机偏移,加上 left = 3 后,实际索引范围就是 3~7,恰好覆盖整个子数组。
为什么不用
rand() % nums.size()? 因为那样会取到子数组范围之外的元素,破坏分治的正确性。必须在当前子区间[left, right]内随机选取。
要点二:闭区间传入与边界转换
外层调用传入的是闭区间:
cpp
qsort(nums, 0, nums.size() - 1);
但在 qsort 函数内部,三路划分过程中 left 和 right 这两个参数变量被当作指针移动 ,其语义已经不再是"原始边界",而是三路划分中 < key 和 > key 的区域边界。因此:
- 划分结束后,
left指向的是= key区域的左边界(第一个等于 key 的位置) right指向的是= key区域的右边界(最后一个等于 key 的位置)
所以真正的左右待排序子区间是:
- 左侧:
[原始 left, left - 1],即[l, left - 1] - 右侧:
[right + 1, 原始 right],即[right + 1, r]
这就是为什么递归调用要写成:
cpp
qsort(nums, l, left - 1); // 左子区间
qsort(nums, right + 1, r); // 右子区间
而不是直接复用被修改后的 left 和 right。
要点三:易踩的坑------递归边界不是向 0 和 size 靠拢
初学者容易有一种直觉:快排的递归是"从两边向中间收拢",所以左右递归应该分别向 0 和 nums.size() 方向推进。于是可能写出这样的错误代码:
cpp
// ❌ 错误写法
qsort(nums, 0, left - 1);
qsort(nums, right + 1, nums.size());
错误原因 :快排是分治 算法,每次递归只处理当前子数组的左右两个子区间,而不是每次都从整个数组的两端开始。如果写成 0 和 nums.size(),会导致:
- 左递归每次都从数组开头开始,严重重复处理已经排好序的前半部分;
- 右递归越界(
nums.size()已经超出有效索引范围)。
正确的做法是用临时变量 l 和 r 保存原始边界,递归时严格限定在当前子区间内:
cpp
// ✅ 正确写法
int l = left, r = right; // 先保存原始边界
// ... 三路划分,left 和 right 被移动 ...
qsort(nums, l, left - 1); // 只在左子区间递归
qsort(nums, right + 1, r); // 只在右子区间递归
复杂度分析
- 时间复杂度:期望 O(n log n)。随机化 pivot 使得递归树期望高度为 O(log n),每层 O(n) 的划分工作量。
- 空间复杂度:O(log n),递归栈深度。三路划分在大量重复元素时可将栈深度控制在 O(log n)(因为等于 key 的元素不再递归)。
堆排序回顾
除了快速排序,堆排序(Heap Sort) 也是一道非常经典的 O(n log n) 排序算法,且最坏情况下时间复杂度稳定为 O(n log n),不受数据分布影响。
核心思想
堆排序基于二叉堆(通常用大根堆)这一数据结构:
- 建堆:将无序数组构建成一个大根堆,使得堆顶元素是最大值。
- 交换:将堆顶元素(最大值)与堆尾元素交换,此时最大值已归位。
- 调整:堆大小减 1,对新的堆顶元素执行**下沉(Heapify)**操作,恢复大根堆性质。
- 重复:重复步骤 2~3,直到堆中只剩一个元素。
简略示意图
初始数组: [4, 10, 3, 5, 1]
建堆(大根堆):
10
5 3
4 1
交换堆顶与末尾: [1, 5, 3, 4, 10] (10 已归位)
调整堆(大小=4):
5
4 3
1
交换堆顶与末尾: [1, 4, 3, 5, 10] (5 已归位)
调整堆(大小=3):
4
1 3
交换堆顶与末尾: [1, 3, 4, 5, 10] (4 已归位)
调整堆(大小=2):
3
/
1
交换堆顶与末尾: [1, 3, 4, 5, 10] (3 已归位)
最终结果: [1, 3, 4, 5, 10]
代码框架
cpp
void heapSort(vector<int>& nums) {
int n = nums.size();
// 1. 建堆(从最后一个非叶子节点开始下沉)
for (int i = n / 2 - 1; i >= 0; i--)
heapify(nums, n, i);
// 2. 逐个提取最大值
for (int i = n - 1; i > 0; i--) {
swap(nums[0], nums[i]); // 堆顶移到末尾
heapify(nums, i, 0); // 对剩余元素重新调整堆
}
}
void heapify(vector<int>& nums, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && nums[left] > nums[largest])
largest = left;
if (right < n && nums[right] > nums[largest])
largest = right;
if (largest != i) {
swap(nums[i], nums[largest]);
heapify(nums, n, largest); // 递归下沉
}
}
与快排对比
| 特性 | 快速排序 | 堆排序 |
|---|---|---|
| 平均时间 | O(n log n) | O(n log n) |
| 最坏时间 | O(n²) | O(n log n) |
| 空间复杂度 | O(log n) | O(1) |
| 稳定性 | 不稳定 | 不稳定 |
| 缓存友好 | 优秀(局部访问) | 一般(跳跃访问) |
| 适用场景 | 通用场景 | 内存极度敏感、需严格保证最坏复杂度 |
总结:快排在实践中通常更快(缓存友好),堆排序在最坏复杂度上更有保证。LeetCode 912 这道题两种方法都能通过,掌握快排的同时,堆排序作为"保底方案"也值得熟练掌握。
LeetCode 215. 数组中第 K 大的元素

这道题要求在无序数组中找出第 K 大的元素。最直观的想法是先排序再取倒数第 K 个,时间复杂度 O(n log n)。但其实我们不需要完全排序 ------只需要确定第 K 大元素在排序后的位置即可。这正是 快速选择(Quick Select) 算法的用武之地。
为什么能用快速选择?
快速选择是快速排序的变种,由 Tony Hoare 发明。核心洞察是:
一次三路划分后,pivot 的最终位置是确定的。 如果这个位置恰好就是第 K 大元素应该在的位置,直接返回即可;否则只需要去包含目标位置的那一侧继续查找。
换句话说,快排需要递归处理左右两个子区间,而快速选择只需要递归处理一个子区间 ,从而将平均时间复杂度从 O(n log n) 优化到 O(n)。
快速选择对快排的优化
| 对比项 | 快速排序 | 快速选择 |
|---|---|---|
| 目的 | 完全排序整个数组 | 只找到第 K 小/大的元素 |
| 递归方向 | 左右两个子区间都递归 | 只递归包含目标位置的一侧 |
| 平均时间 | O(n log n) | O(n) |
| 最坏时间 | O(n²) | O(n²)(概率极低) |
| 空间复杂度 | O(log n) | O(log n) |
时间复杂度 O(n) 的直观理解:第一次 partition 遍历 n 个元素,第二次最坏遍历 n/2,第三次 n/4......总和为 n + n/2 + n/4 + ... < 2n,即 O(n)。
要点一:第 K 大 → 坐标转换(易踩的坑)
题目要求"第 K 大",但我们的三路划分是基于升序逻辑进行的。因此需要做一个转换:
第 K 大元素 = 升序排列后索引为
n - k的元素(0-based)
示例:nums = [3, 2, 1, 5, 6, 4], k = 2
升序排列后:[1, 2, 3, 4, 5, 6]
↑
索引 4(0-based)
第 2 大元素是 5,在升序数组中的索引 = 6 - 2 = 4
代码中这一转换体现为:
cpp
int ret = qsort(nums, 0, size - 1, size - k); // 转换成坐标
踩坑警示 :如果直接用 k 作为目标索引去判断,逻辑会完全相反(变成找第 K 小),导致答案错误。
代码实现
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
int size = nums.size();
int ret = qsort(nums, 0, size - 1, size - k); // 转换成坐标
return ret;
}
int qsort(vector<int>& nums, int left, int right, int k)
{
if (left >= right)
return nums[left];
// 随机取 key
int ra = rand();
int key = nums[ra % (right - left + 1) + left];
int i = left;
int l = left, r = right; // 保存原始边界
// 三路划分
while (i <= right)
{
if (nums[i] < key)
{
swap(nums[i], nums[left]);
left++, i++;
}
else if (nums[i] == key)
{
i++;
}
else if (nums[i] > key)
{
swap(nums[i], nums[right]);
right--;
}
}
// 判断目标位置 k 落在哪个区域
if (k > right)
return qsort(nums, right + 1, r, k); // 在 >key 的右侧区域
else if (k < left)
return qsort(nums, l, left - 1, k); // 在 <key 的左侧区域
else
return key; // 在 =key 的中间区域,直接命中
return -99; // 永远不会执行到这里
}
};
要点二:判断逻辑详解------为什么只看一侧?
三路划分结束后,数组被清晰地分成三个区域(以升序视角):
[l -> left-1] [left -> right] [right+1 -> r]
< key = key > key
left是=key区域的左边界(第一个等于 key 的位置)right是=key区域的右边界(最后一个等于 key 的位置)
目标索引 k 只可能落在三个区域之一:
情况 1:k > right
目标索引在 right 的右边 → 落在 >key 区域
说明第 K 大元素比当前 key 更大,需要去右侧未排序区域继续查找。递归范围:[right + 1, r]。
情况 2:k < left
目标索引在 left 的左边 → 落在 <key 区域
说明第 K 大元素比当前 key 更小,需要去左侧未排序区域继续查找。递归范围:[l, left - 1]。
情况 3:left <= k <= right
目标索引恰好落在 [left, right] 之间 → 落在 =key 区域
说明第 K 大元素就是当前 key!因为这一整段区域的值都等于 key,无论 k 具体指向哪个位置,值都是 key。直接返回即可,无需再递归。
这正是快速选择比快排快的原因:一旦命中目标区域,直接结束,不需要像快排那样继续递归左右子区间。
要点三:踩坑------递归调用不加 return
在快速选择中,递归调用后必须立即 return,否则函数会继续向下执行,导致逻辑错误。
cpp
// ❌ 错误写法(曾经踩过的坑)
if (k > right)
qsort(nums, right + 1, r, k); // 忘加 return!
else if (k < left)
qsort(nums, l, left - 1, k);
else
return key;
// 如果进入第一个 if,执行完 qsort 后会继续往下走,
// 可能进入 else if 或 else,逻辑完全混乱
cpp
// ✅ 正确写法
if (k > right)
return qsort(nums, right + 1, r, k); // 必须加 return
else if (k < left)
return qsort(nums, l, left - 1, k);
else
return key;
原因 :快速选择函数有返回值(int),每个分支都必须返回一个确定的值。如果不加 return,递归调用完成后会继续执行后续代码,导致返回值不确定或逻辑错误。
复杂度分析
- 时间复杂度 :
- 平均:O(n) 。每次 partition 后只处理一半数据:
n + n/2 + n/4 + ... < 2n - 最坏:O(n²)。极端情况下每次 partition 都极不平衡(概率极低,随机化可有效避免)
- 平均:O(n) 。每次 partition 后只处理一半数据:
- 空间复杂度:O(log n),递归栈深度。
对比:如果用排序(快排/堆排)再做选择,时间复杂度是 O(n log n)。快速选择通过"只走一侧"将复杂度降为 O(n),是求 TopK 问题的经典解法。
LeetCode 面试题 17.14. 最小 K 个数

题目链接:面试题 17.14. 最小 K 个数
这道题要求返回数组中最小的 K 个数,且不要求这 K 个数有序 。基于这个特点,我们可以用快速选择(Quick Select)+ 三路划分来优化:不需要像完整快排那样递归排序左右两个子区间,只需要定位到第 K 小元素所在的索引位置,即可确定最小 K 个数的集合。
代码实现
cpp
class Solution {
public:
vector<int> smallestK(vector<int>& arr, int k) {
if (k == 0)
return vector<int>();
// 传入 k-1 作为目标索引(0-based),即第 k 小元素的索引
vector<int> ret = qsort(arr, 0, arr.size() - 1, k - 1);
return ret;
}
vector<int> qsort(vector<int>& arr, int left, int right, int k) {
if (left >= right)
return vector<int>(arr.begin(), arr.begin() + k + 1);
// 随机取 key
int ra = rand();
int key = arr[ra % (right - left + 1) + left];
int l = left, r = right, i = left;
// 三路划分
while (i <= right) {
if (arr[i] < key) {
swap(arr[i], arr[left]);
i++, left++;
} else if (arr[i] == key) {
i++;
} else {
swap(arr[i], arr[right]);
right--;
}
}
// 根据目标索引 k 落在哪个区域,决定下一步
if (left > k)
return qsort(arr, l, left - 1, k);
else if (k >= left && k <= right)
return vector<int>(arr.begin(), arr.begin() + k + 1);
else
return qsort(arr, right + 1, r, k);
}
};
要点:三路划分后的三种情况处理
三路划分结束后,数组被清晰地分为三个区域(以升序视角):
[l -> left-1] [left -> right] [right+1 -> r]
< key = key > key
设目标索引为 k(即第 k+1 小的元素,0-based),left 和 right 分别是 =key 区域的左右边界。根据 k 落在哪个区域,有三种处理方案:
情况 1:left > k(目标在 <key 区域)
目标索引 k 位于 left 的左侧,即落在 <key 区域内
说明第 k+1 小的元素比当前 key 更小。此时所有 =key 和 >key 的元素都不属于最小 K 个数,只需要在左侧未排序区间继续查找:
cpp
return qsort(arr, l, left - 1, k);
情况 2:k >= left && k <= right(目标在 =key 区域)
目标索引 k 恰好落在 [left, right] 之间
这是本题最关键的处理。此时意味着:
- 所有
< key的元素(共left个)已经位于数组最前面 - 从索引
left到right的元素都等于key - 目标索引
k落在这个区间内,说明第k+1小的元素就是key
为什么可以直接返回 arr[0..k]?
因为三路划分保证了所有小于 key 的元素都在索引 left 之前 ,而索引 left 到 right 的元素都等于 key。既然 k 落在 [left, right] 内,那么索引 0 到 k 的所有元素必然都 <= key,且数组中其余元素都 >= key。因此,arr[0..k] 这 k+1 个元素恰好就是整个数组中最小的 k+1 个元素(虽然它们内部不一定有序)。
此时直接返回即可:
cpp
return vector<int>(arr.begin(), arr.begin() + k + 1);
这正是利用题目"返回任意顺序"的关键 :我们不需要关心这 K 个数内部谁前谁后,也不需要它们有序,只需要确保这 K 个数"正确"(即确实是全局最小的 K 个)。三路划分已经通过值的大小关系保证了这一点,因此命中 =key 区域时可以直接截断返回,无需继续递归。
情况 3:k > right(目标在 >key 区域)
目标索引 k 位于 right 的右侧,即落在 >key 区域内
说明第 k+1 小的元素比当前 key 更大,需要在右侧未排序区间继续查找:
cpp
return qsort(arr, right + 1, r, k);
与"返回任意顺序"的深层关联
题目明确说明返回的 K 个数可以是任意顺序。这个条件使得快速选择成为最优解法之一:
- 无需完全排序 :我们不需要像
std::sort那样保证这 K 个数内部有序,只需要保证"前 K 个位置上的元素就是全局最小的 K 个"即可。 - =key 区域可以直接截断返回 :当
k落在=key区域时,<key区域和=key区域的一部分共同构成了最小 K 个数。虽然<key区域内部可能无序,=key区域也只是值相等,但题目不要求顺序,所以无需继续递归排序。 - 平均时间复杂度 O(n):每次 partition 后只处理包含目标索引的一侧,期望时间复杂度为 O(n),优于完全排序的 O(n log n)。
如果题目要求这 K 个数必须有序 ,则命中 =key 区域时仍需要对 <key 区域继续递归排序,时间复杂度将退化为 O(n log n)。因此,"任意顺序"的要求是快速选择在此题中发挥 O(n) 优势的前提。
复杂度分析
- 时间复杂度 :期望 O(n)。每次三路划分后只递归一个子区间,期望递归深度 O(log n),但每层工作量递减,总期望时间为 O(n)。
- 空间复杂度:O(log n),递归栈深度。