常见算法-快速选择
快速选择:不完全排序的艺术
当你只需要找到第K大的元素时,为什么要把整个数组都排序呢?
什么是快速选择
快速选择(QuickSelect)是一种从无序数组中找到第K小(或第K大)元素的选择算法。它基于快速排序的分区思想,但不需要完全排序。
核心思想:每次分区后,pivot元素的位置就是它在有序数组中的最终位置
形象理解
想象你要在班级里找出第3名:
1 | 普通方法:给全班排序,然后取第3名 → O(n log n) |
就像打擂台赛,每轮淘汰一半选手,不需要完整排序就能找到冠军!
算法原理
核心思想
快速选择利用快速排序的 partition(分区) 操作:
graph TD
A[选择pivot] --> B[分区操作]
B --> C{pivot位置 vs 目标位置}
C -->|相等| D[找到目标,返回]
C -->|pivot靠左| E[在右半部分继续]
C -->|pivot靠右| F[在左半部分继续]
E --> A
F --> A
style D fill:#90EE90
style A fill:#FFE4B5
分区操作详解
Partition将数组分为三部分:
1 | [小于pivot的元素] | [pivot] | [大于pivot的元素] |
关键性质:分区后,pivot元素在它最终排序位置上
算法步骤图解
示例:找第2大元素
1 | 数组:[3, 2, 1, 5, 6, 4] |
完整过程可视化
1 | 原数组:[3, 2, 1, 5, 6, 4] |
代码实现
基础版本
1 | int quickSelect(vector<int>& nums, int left, int right, int k) { |
找第K大元素
1 | int findKthLargest(vector<int>& nums, int k) { |
Partition过程详解
1 | 数组: [3, 2, 1, 5, 6, 4] |
复杂度分析
时间复杂度
| 情况 | 复杂度 | 说明 |
|---|---|---|
| 平均 | O(n) | 每次减半搜索空间 |
| 最好 | O(n) | 每次分区都在中间 |
| 最坏 | O(n²) | 每次分区都在边界 |
为什么平均是 O(n)?
1 | 第1轮:处理 n 个元素 |
这是一个几何级数!
graph LR
A[n个元素] --> B[n/2个元素]
B --> C[n/4个元素]
C --> D[n/8个元素]
D --> E[...]
style A fill:#FF6B6B
style B fill:#FFB6C1
style C fill:#FFE4B5
与快速排序对比
| 算法 | 时间复杂度(平均) | 说明 |
|---|---|---|
| 快速排序 | O(n log n) | 两边都要递归 |
| 快速选择 | O(n) | 只递归一边 |
关键区别:快速选择只需要递归一侧,快速排序需要递归两侧!
优化技巧
1. 随机化Pivot
问题:固定选择pivot(如最后一个元素)在有序数组中会退化为O(n²)
解决:随机选择pivot
1 | int randomIndex = left + rand() % (right - left + 1); |
2. 三数取中法
从首、中、尾三个位置选择中位数作为pivot
1 | int getMidPivot(vector<int>& nums, int left, int right) { |
3. 迭代版本(避免递归栈溢出)
1 | int quickSelectIterative(vector<int>& nums, int k) { |
经典应用
1. 第K大元素(LeetCode 215)
1 | int findKthLargest(vector<int>& nums, int k) { |
示例:
1 | 输入:[3,2,1,5,6,4], k=2 |
2. 数组中的第K个最大元素(扩展)
找出数组中前K大的元素
1 | vector<int> topKLargest(vector<int>& nums, int k) { |
3. 寻找中位数
1 | double findMedian(vector<int>& nums) { |
快速选择 vs 其他方法
对比表
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 快速选择 | O(n) 平均 | O(1) | 一次性查询 |
| 排序 | O(n log n) | O(1) | 多次查询 |
| 堆 | O(n log k) | O(k) | K很小时 |
| 计数排序 | O(n + m) | O(m) | 数值范围小 |
选择建议
graph TD
A{需要什么?} --> B[只查询一次]
A --> C[多次查询]
A --> D[K很小]
B --> E[快速选择 O n]
C --> F[排序 O n log n]
D --> G[堆 O n log k]
style E fill:#90EE90
style B fill:#FFE4B5
实战案例
案例1:找数组中的第K个最大元素
1 | // LeetCode 215 |
案例2:找出数组中位数
1 | class Solution { |
常见陷阱与技巧
陷阱1:索引转换错误
1 | // ❌ 错误:直接使用k |
陷阱2:忘记随机化
1 | // ❌ 危险:固定选择最后一个元素 |
技巧1:记住partition模板
1 | int partition(vector<int>& nums, int left, int right) { |
技巧2:口诀记忆
“找大减K,找小用K”
- 找第K大:
targetIndex = n - k - 找第K小:
targetIndex = k - 1(从0开始索引)
思维导图
graph TD
A[快速选择] --> B[核心思想]
A --> C[关键操作]
A --> D[应用场景]
A --> E[优化技巧]
B --> B1[基于快排分区]
B --> B2[只递归一侧]
B --> B3[O n 平均复杂度]
C --> C1[Partition分区]
C --> C2[比较pivot位置]
C --> C3[递归/迭代]
D --> D1[第K大元素]
D --> D2[中位数]
D --> D3[TopK问题]
E --> E1[随机化pivot]
E --> E2[三数取中]
E --> E3[迭代版本]
style A fill:#FFE4B5
style B fill:#87CEEB
style D fill:#90EE90
总结
核心要点
- 快速选择 = 快排分区 + 单侧递归
- 平均O(n)复杂度:比完全排序快
- 随机化pivot:避免最坏情况
- 索引转换:第K大 = 第(n-k)小
代码模板
1 | // 快速选择模板 |
推荐练习:
推荐阅读:
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 Smarter's blog!
评论
