如果你在面试中被问到"如何在无序数组中找到第K大的元素",你的第一反应是什么?排序?用堆?这些方法确实可行,但还有一种更优雅、更高效的方法——快速选择算法。它能在平均O(n)的时间内解决这个问题,而且代码简洁得令人惊讶。
一个看似简单的选择问题
选择问题(Selection Problem)是计算机科学中最基础的问题之一:给定一个包含n个元素的无序数组,找出其中第k小的元素。当k=n/2时,这就是著名的中位数问题。
最直观的解决方案是排序后直接取第k个元素,时间复杂度为O(n log n)。但问题是:我们真的需要把整个数组排好序吗?
答案是否定的。快速选择算法的核心洞察在于:我们只需要知道第k个元素是什么,而不需要知道其他元素的相对顺序。这个看似微小的区别,却能带来从O(n log n)到O(n)的时间复杂度飞跃。
快速选择:快速排序的"单臂"版本
快速选择算法由英国计算机科学家托尼·霍尔(Tony Hoare)于1959年发明,与他著名的快速排序算法同期诞生。1961年,霍尔在《Communications of the ACM》上发表了相关论文,这两个算法从此改变了计算机科学的面貌。
理解快速选择的关键在于理解快速排序的分区(Partition)操作。在快速排序中,我们选择一个基准元素(pivot),将数组分成两部分:左侧是所有小于基准的元素,右侧是所有大于基准的元素。分区完成后,基准元素被放置在其最终排序位置上。
快速选择正是利用了这个特性。假设我们寻找第k小的元素,分区后基准元素的位置是p:
- 如果 p == k,那么基准元素就是我们要找的第k小元素,直接返回
- 如果 p > k,说明第k小元素在左侧子数组中,递归搜索左侧
- 如果 p < k,说明第k小元素在右侧子数组中,递归搜索右侧(此时k需要调整为 k-p-1)
与快速排序不同的是,快速选择每次只需要递归处理一侧,而不是两侧。这个"单臂递归"的特性正是其线性时间复杂度的来源。
quickselect(arr, left, right, k):
if left == right:
return arr[left]
pivot_index = partition(arr, left, right)
if k == pivot_index:
return arr[k]
elif k < pivot_index:
return quickselect(arr, left, pivot_index - 1, k)
else:
return quickselect(arr, pivot_index + 1, right, k)
时间复杂度:为什么是O(n)?
快速选择的时间复杂度分析比快速排序更加微妙。让我们从最坏情况开始。
最坏情况:O(n²)
考虑一个已经排好序的数组 [1, 2, 3, 4, 5],如果我们每次都选择第一个元素作为基准,并且要找第5小的元素:
- 第一次分区:基准1,需要处理 [2, 3, 4, 5],比较次数 n-1
- 第二次分区:基准2,需要处理 [3, 4, 5],比较次数 n-2
- …
总比较次数为 (n-1) + (n-2) + … + 1 = O(n²)。这就是最坏情况。
平均情况:O(n)
平均情况的分析需要一些数学技巧。伊利诺伊大学厄巴纳-香槟分校的Sariel Har-Peled教授在其讲义中给出了一个优美的证明。
设 $S_1, S_2, \ldots, S_n$ 为数组元素按排序顺序排列后的序列。定义指示变量 $X_{ij}$:当且仅当 $S_i$ 与 $S_j$ 被比较时取值为1。
关键观察:$S_i$ 与 $S_j$ 被比较,当且仅当在它们之间的元素(即 $S_{i+1}, \ldots, S_{j-1}$)中,$S_i$ 或 $S_j$ 是第一个被选为基准的元素。由于基准是随机选择的,这个概率为:
$$P(X_{ij} = 1) = \frac{2}{j - i + 1}$$期望比较次数为:
$$E[C] = \sum_{i < j} E[X_{ij}] = \sum_{i < j} \frac{2}{j - i + 1}$$经过计算,期望比较次数不超过 $4n - 2 + \ln n + \ln k$,因此期望时间复杂度为O(n)。
另一个视角:几何级数
另一种更直观的理解方式是考虑递归过程中数组大小的变化。
在随机选择基准的情况下,有50%的概率基准会落在数组的中间50%位置。当这种情况发生时,数组规模至少缩小为原来的75%。
假设每次递归数组规模都缩小为原来的75%,我们需要多少次递归?
$$n + 0.75n + 0.75^2 n + \cdots = n \times \sum_{i=0}^{\infty} 0.75^i = n \times \frac{1}{1-0.75} = 4n$$这是一个收敛的几何级数,总和为O(n)。即使考虑到运气不好的情况,期望值仍然是O(n)。
两种分区方案:Hoare vs Lomuto
实现快速选择时,分区函数的选择至关重要。最常用的两种分区方案是Hoare分区和Lomuto分区。
Lomuto分区
Lomuto分区选择最后一个元素作为基准,维护一个指针i,指向"小于基准区域"的末尾:
partition_lomuto(arr, low, high):
pivot = arr[high]
i = low - 1
for j from low to high - 1:
if arr[j] <= pivot:
i = i + 1
swap(arr[i], arr[j])
swap(arr[i + 1], arr[high])
return i + 1
Lomuto分区的优点是代码简洁易懂,基准元素的最终位置正好是返回值。缺点是在处理大量重复元素时效率较低。
Hoare分区
Hoare分区使用两个指针,分别从两端向中间扫描:
partition_hoare(arr, low, high):
pivot = arr[low] // 或选择其他位置
i = low - 1
j = high + 1
while true:
do i = i + 1 while arr[i] < pivot
do j = j - 1 while arr[j] > pivot
if i >= j:
return j
swap(arr[i], arr[j])
Hoare分区平均只需要Lomuto三分之一的交换次数,在处理重复元素时也更加高效。但需要注意,返回值j并不一定是基准元素的最终位置,这在实现快速选择时需要特别处理。
| 特性 | Hoare分区 | Lomuto分区 |
|---|---|---|
| 基准选择 | 通常选第一个元素 | 通常选最后一个元素 |
| 交换次数 | 较少(平均少3倍) | 较多 |
| 代码复杂度 | 稍复杂 | 简洁 |
| 基准最终位置 | 不一定是返回值 | 正好是返回值 |
| 处理重复元素 | 高效 | 效率较低 |
确定性线性时间:中位数的中位数算法
快速选择虽然期望时间是O(n),但最坏情况仍是O(n²)。1973年,Blum、Floyd、Pratt、Rivest和Tarjan五位计算机科学家联合发表了一篇论文,提出了一个确定性的线性时间选择算法,被称为中位数的中位数算法(Median of Medians),或以其姓氏首字母命名为BFPRT算法。
该算法的核心思想是通过精心选择基准来保证每次分区后,数组规模至少缩小一个固定的比例:
- 将数组分成每组5个元素的若干组
- 对每组进行排序,找出每组的中位数
- 递归地找出这些中位数的中位数m
- 用m作为基准进行分区
- 递归处理相应的一侧
通过数学分析可以证明,这样选择的基准保证每一侧至少有30%的元素被排除。递归关系为:
$$T(n) \leq T(n/5) + T(7n/10) + O(n)$$根据主定理,这个递归关系的解为T(n) = O(n)。
然而,中位数的中位数算法虽然理论上很漂亮,实际应用中却不如随机化的快速选择快。原因是其常数因子较大——寻找"好的基准"本身需要额外的开销。因此,工程实践中更常用的是随机化快速选择,或者结合两者的IntroSelect算法。
快速选择 vs 堆:如何选择?
在解决Top-K问题时,快速选择和使用堆是两种主流方法。理解它们的权衡对于做出正确选择至关重要。
时间复杂度对比
| 方法 | 平均时间 | 最坏时间 | 空间复杂度 |
|---|---|---|---|
| 排序 | O(n log n) | O(n log n) | O(n) 或 O(1) |
| 小顶堆(大小k) | O(n log k) | O(n log k) | O(k) |
| 快速选择 | O(n) | O(n²) | O(1) 或 O(log n) |
| 中位数的中位数 | O(n) | O(n) | O(log n) |
实际性能考量
Daniel Lemire教授在其博客中进行了一组基准测试:从1408个随机整数中找出最小的128个。结果显示:
- 堆方法:每秒约37,000次
- 快速选择:每秒约45,000次(快约20%)
两者性能相近,但快速选择略占优势。然而,这个结论有适用条件:
当k很小时(如k=5),堆方法通常更快,因为:
- 堆可以放入CPU缓存中
- log k因子非常小
当n很大但k适中时,快速选择优势明显,因为:
- 线性复杂度开始体现优势
- 堆需要多次调整
当n非常大时,情况可能反转:
- 快速选择需要多次扫描大数组,可能造成缓存未命中
- 堆始终保持小规模,缓存友好
稳定性考量
快速选择的最坏情况是O(n²),虽然概率极低,但在某些对延迟敏感的场景中可能不可接受。堆方法提供了更稳定的性能保证。
LeetCode经典问题详解
215. 数组中的第K个最大元素
这是快速选择算法最经典的应用场景。
题目描述:给定整数数组nums和整数k,返回数组中第k个最大的元素。
分析:第k大的元素等价于第(n-k)小的元素。我们可以直接使用快速选择寻找第(n-k)小的元素。
class Solution {
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
return quickSelect(nums, 0, n - 1, n - k);
}
private int quickSelect(int[] nums, int left, int right, int k) {
if (left == right) {
return nums[left];
}
// Hoare分区
int i = left - 1, j = right + 1;
int pivot = nums[(left + right) >>> 1];
while (true) {
do { i++; } while (nums[i] < pivot);
do { j--; } while (nums[j] > pivot);
if (i >= j) break;
swap(nums, i, j);
}
if (k <= j) {
return quickSelect(nums, left, j, k);
}
return quickSelect(nums, j + 1, right, k);
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
时间复杂度:平均O(n),最坏O(n²)
空间复杂度:O(log n)(递归栈)
347. 前K个高频元素
题目描述:给定整数数组nums和整数k,返回出现频率前k高的元素。
分析:首先统计每个元素的频率,然后将问题转化为在频率数组中找前k大的元素。这可以先用快速选择找到第k大的频率,再收集所有频率不小于该值的元素。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 统计频率
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.merge(num, 1, Integer::sum);
}
// 提取频率数组
int[] freqs = new int[freqMap.size()];
int idx = 0;
Map<Integer, Integer> freqToCount = new HashMap<>();
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
freqs[idx] = entry.getValue();
freqToCount.merge(entry.getValue(), 1, Integer::sum);
idx++;
}
// 找第k大的频率
int kthFreq = findKthLargest(freqs, k);
// 收集结果
List<Integer> result = new ArrayList<>();
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
if (entry.getValue() >= kthFreq) {
result.add(entry.getKey());
}
}
// 可能因为重复频率导致结果超过k个,需要裁剪
return result.stream().limit(k).mapToInt(i -> i).toArray();
}
private int findKthLargest(int[] nums, int k) {
// 同215题实现
return quickSelect(nums, 0, nums.length - 1, nums.length - k);
}
private int quickSelect(int[] nums, int left, int right, int k) {
if (left == right) return nums[left];
int i = left - 1, j = right + 1;
int pivot = nums[(left + right) >>> 1];
while (true) {
do { i++; } while (nums[i] < pivot);
do { j--; } while (nums[j] > pivot);
if (i >= j) break;
int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp;
}
if (k <= j) return quickSelect(nums, left, j, k);
return quickSelect(nums, j + 1, right, k);
}
}
注意:这道题用堆方法通常更简洁,时间复杂度为O(n log k),空间复杂度为O(n)。
973. 最接近原点的K个点
题目描述:给定 points 数组,找出距离原点最近的k个点。
分析:计算每个点到原点的距离平方(避免开方),然后使用快速选择找前k小的距离。
class Solution {
public int[][] kClosest(int[][] points, int k) {
int n = points.length;
int[] dists = new int[n];
for (int i = 0; i < n; i++) {
dists[i] = points[i][0] * points[i][0] + points[i][1] * points[i][1];
}
quickSelect(dists, 0, n - 1, k - 1);
int threshold = dists[k - 1];
int[][] result = new int[k][2];
int idx = 0;
for (int i = 0; i < n && idx < k; i++) {
int d = points[i][0] * points[i][0] + points[i][1] * points[i][1];
if (d <= threshold) {
result[idx++] = points[i];
}
}
return result;
}
private void quickSelect(int[] nums, int left, int right, int k) {
if (left >= right) return;
int i = left - 1, j = right + 1;
int pivot = nums[(left + right) >>> 1];
while (true) {
do { i++; } while (nums[i] < pivot);
do { j--; } while (nums[j] > pivot);
if (i >= j) break;
int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp;
}
if (k <= j) {
quickSelect(nums, left, j, k);
} else {
quickSelect(nums, j + 1, right, k);
}
}
}
时间复杂度:O(n)
空间复杂度:O(n)(存储距离数组)
703. 数据流中的第K大元素
题目描述:设计一个类,支持在数据流中添加元素并返回当前第k大的元素。
分析:这是一个数据流问题,快速选择不适合,因为每次添加元素都需要重新计算。堆是更好的选择——维护一个大小为k的小顶堆,堆顶就是第k大的元素。
class KthLargest {
private PriorityQueue<Integer> minHeap;
private int k;
public KthLargest(int k, int[] nums) {
this.k = k;
this.minHeap = new PriorityQueue<>();
for (int num : nums) {
add(num);
}
}
public int add(int val) {
minHeap.offer(val);
if (minHeap.size() > k) {
minHeap.poll();
}
return minHeap.peek();
}
}
时间复杂度:add操作O(log k)
空间复杂度:O(k)
378. 有序矩阵中第K小的元素
题目描述:给定一个n×n矩阵,每行每列都按升序排列,找出矩阵中第k小的元素。
分析:这道题可以用二分搜索或堆来解决。快速选择不太适用,因为矩阵不是一维数组,难以进行原地分区。
二分搜索方法:在矩阵的最小值和最大值之间二分,统计每行中小于等于mid的元素个数。
class Solution {
public int kthSmallest(int[][] matrix, int k) {
int n = matrix.length;
int left = matrix[0][0], right = matrix[n-1][n-1];
while (left < right) {
int mid = left + (right - left) / 2;
int count = countLessEqual(matrix, mid);
if (count < k) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
private int countLessEqual(int[][] matrix, int target) {
int n = matrix.length;
int count = 0;
int row = n - 1, col = 0;
while (row >= 0 && col < n) {
if (matrix[row][col] <= target) {
count += row + 1;
col++;
} else {
row--;
}
}
return count;
}
}
时间复杂度:O(n log(max - min))
空间复杂度:O(1)
总结
快速选择算法是解决选择问题的优雅方案。它通过"牺牲排序"——只关心目标元素的位置——将时间复杂度从O(n log n)降低到O(n)。
在实际应用中,选择快速选择还是堆方法取决于具体场景:
- 选择快速选择:一次性查询、k较大、对时间敏感
- 选择堆方法:数据流场景、k较小、需要稳定性能
快速选择算法不仅是一个算法技巧,更是一种思维方式:在解决问题时,思考什么信息是真正需要的,什么是可以"丢弃"的。这种"懒惰"的智慧,正是算法设计的精髓所在。
参考资料
- Hoare, C. A. R. (1961). “Algorithm 65: Find”. Communications of the ACM.
- Blum, M., Floyd, R. W., Pratt, V., Rivest, R. L., & Tarjan, R. E. (1973). “Time bounds for selection”. Journal of Computer and System Sciences.
- Har-Peled, S. (2022). “Chapter 3: Analyzing QuickSort and QuickSelect via Expectation”. UIUC CS 574 Lecture Notes.
- Baeldung. (2024). “Complexity Analysis of QuickSelect”.
- Lemire, D. (2017). “QuickSelect versus binary heap for top-k queries”.