如果你在面试中被问到"如何在无序数组中找到第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算法。

该算法的核心思想是通过精心选择基准来保证每次分区后,数组规模至少缩小一个固定的比例:

  1. 将数组分成每组5个元素的若干组
  2. 对每组进行排序,找出每组的中位数
  3. 递归地找出这些中位数的中位数m
  4. 用m作为基准进行分区
  5. 递归处理相应的一侧

通过数学分析可以证明,这样选择的基准保证每一侧至少有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较小、需要稳定性能

快速选择算法不仅是一个算法技巧,更是一种思维方式:在解决问题时,思考什么信息是真正需要的,什么是可以"丢弃"的。这种"懒惰"的智慧,正是算法设计的精髓所在。


参考资料

  1. Hoare, C. A. R. (1961). “Algorithm 65: Find”. Communications of the ACM.
  2. Blum, M., Floyd, R. W., Pratt, V., Rivest, R. L., & Tarjan, R. E. (1973). “Time bounds for selection”. Journal of Computer and System Sciences.
  3. Har-Peled, S. (2022). “Chapter 3: Analyzing QuickSort and QuickSelect via Expectation”. UIUC CS 574 Lecture Notes.
  4. Baeldung. (2024). “Complexity Analysis of QuickSelect”.
  5. Lemire, D. (2017). “QuickSelect versus binary heap for top-k queries”.