1. 堆与分治:LeetCode 热题精解实战
最近在准备算法面试时,我重点研究了LeetCode上几道与堆相关的经典题目。这些题目不仅考察基础数据结构的应用,更蕴含着解决实际工程问题的核心思想。本文将分享215、347、295这三道题的解题思路和优化技巧,特别会深入分析快速选择算法和双堆设计的精妙之处。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 215. 数组中的第K个最大元素
2.1 问题本质与暴力解法
这道题要求我们在未排序数组中找到第K大的元素。最直观的解法是先排序再直接取第n-k个元素,时间复杂度O(nlogn)。但面试官显然期待更优的解法。
关键观察:我们不需要完全排序,只需要确定第k大元素的位置即可。这提示我们可以使用类似快速排序的分区思想。
2.2 快速选择算法详解
快速选择(Quickselect)是快速排序的变种,平均时间复杂度O(n)。其核心在于每次分区后只需要处理一个子区间:
java复制class Solution {
private static final Random rand = new Random();
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
int target = n - k; // 转换问题为找第n-k小的元素
int left = 0, right = n - 1;
while (left <= right) {
int pivotIndex = partition(nums, left, right);
if (pivotIndex == target) {
return nums[pivotIndex];
} else if (pivotIndex < target) {
left = pivotIndex + 1;
} else {
right = pivotIndex - 1;
}
}
return -1; // 根据题意不会执行到这里
}
private int partition(int[] nums, int left, int right) {
// 随机选择pivot避免最坏情况
int pivotIndex = left + rand.nextInt(right - left + 1);
int pivot = nums[pivotIndex];
swap(nums, pivotIndex, right); // 将pivot移到末尾
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
swap(nums, i++, j);
}
}
swap(nums, i, right); // 将pivot放回正确位置
return i;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
关键点解析:
- 随机化pivot选择:避免在已排序数组上退化为O(n²)的最坏情况
- 分区策略:Lomuto分区方案比Hoare方案更易实现
- 循环不变量:维护[left, i)区间元素≤pivot,(i, j]区间元素>pivot
2.3 时间复杂度分析
理论上最坏情况是O(n²),但通过随机化pivot,实际表现接近O(n)。数学证明其期望时间复杂度为O(n),因为每次递归处理的数据量期望减半:
n + n/2 + n/4 + ... ≈ 2n
3. 347. 前K个高频元素
3.1 问题建模与基础解法
这道题需要统计元素频率并返回前K高的元素。我首先想到的是HashMap统计+排序的方案:
java复制// 基础解法:排序法 O(nlogn)
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) freq.put(num, freq.getOrDefault(num, 0) + 1);
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(freq.entrySet());
entries.sort((a, b) -> b.getValue() - a.getValue());
int[] res = new int[k];
for (int i = 0; i < k; i++) res[i] = entries.get(i).getKey();
return res;
}
虽然简单,但时间复杂度不够理想。面试中通常期望更优的解法。
3.2 小根堆优化解法
维护大小为K的小根堆,当堆满时只有当前元素频率大于堆顶才替换:
java复制public int[] topKFrequent(int[] nums, int k) {
// 统计频率 O(n)
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) freq.put(num, freq.getOrDefault(num, 0) + 1);
// 小根堆 O(nlogk)
PriorityQueue<Map.Entry<Integer, Integer>> heap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
if (heap.size() < k) {
heap.offer(entry);
} else if (entry.getValue() > heap.peek().getValue()) {
heap.poll();
heap.offer(entry);
}
}
// 提取结果 O(k)
int[] res = new int[k];
for (int i = 0; i < k; i++) res[i] = heap.poll().getKey();
return res;
}
性能对比:
- 时间复杂度:O(n) + O(nlogk) + O(k) ≈ O(nlogk)
- 空间复杂度:O(n)哈希表 + O(k)堆
3.3 桶排序的线性时间解法
利用频率不超过数组长度的特性,可以实现O(n)时间:
java复制public int[] topKFrequent(int[] nums, int k) {
// 统计频率 O(n)
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) freq.put(num, freq.getOrDefault(num, 0) + 1);
// 创建桶 O(n)
List<Integer>[] buckets = new List[nums.length + 1];
for (int num : freq.keySet()) {
int count = freq.get(num);
if (buckets[count] == null) buckets[count] = new ArrayList<>();
buckets[count].add(num);
}
// 逆序收集结果 O(n)
int[] res = new int[k];
int idx = 0;
for (int i = buckets.length - 1; i >= 0 && idx < k; i--) {
if (buckets[i] != null) {
for (int num : buckets[i]) {
res[idx++] = num;
if (idx == k) break;
}
}
}
return res;
}
适用场景分析:
- 当k接近n时,桶排序法更优
- 当k远小于n时,堆方法更节省空间
- 实际工程中,数据规模大时优先考虑桶排序
4. 295. 数据流的中位数
4.1 问题特点分析
中位数问题要求动态维护数据流的中位数。与静态数组不同,数据流的特点是:
- 数据逐个到达,无法预知全部数据
- 需要支持频繁的插入和查询操作
- 中位数可能随着数据增加而变化
4.2 双堆设计原理
维护两个堆:
- 最大堆保存较小的一半元素
- 最小堆保存较大的一半元素
java复制class MedianFinder {
private PriorityQueue<Integer> maxHeap; // 前半部分
private PriorityQueue<Integer> minHeap; // 后半部分
public MedianFinder() {
maxHeap = new PriorityQueue<>(Collections.reverseOrder());
minHeap = new PriorityQueue<>();
}
public void addNum(int num) {
if (maxHeap.isEmpty() || num <= maxHeap.peek()) {
maxHeap.offer(num);
} else {
minHeap.offer(num);
}
// 平衡堆大小
if (maxHeap.size() > minHeap.size() + 1) {
minHeap.offer(maxHeap.poll());
} else if (minHeap.size() > maxHeap.size()) {
maxHeap.offer(minHeap.poll());
}
}
public double findMedian() {
if (maxHeap.size() > minHeap.size()) {
return maxHeap.peek();
} else {
return (maxHeap.peek() + minHeap.peek()) / 2.0;
}
}
}
操作复杂度:
- 插入:O(log n) 堆插入操作
- 查询:O(1) 直接访问堆顶
- 空间:O(n) 存储所有元素
4.3 极简双堆优化
灵茶山艾府的实现通过"先插入再平衡"的策略简化代码:
java复制class MedianFinder {
private PriorityQueue<Integer> left = new PriorityQueue<>((a,b)->b-a);
private PriorityQueue<Integer> right = new PriorityQueue<>();
public void addNum(int num) {
if (left.size() == right.size()) {
right.offer(num);
left.offer(right.poll());
} else {
left.offer(num);
right.offer(left.poll());
}
}
public double findMedian() {
if (left.size() > right.size()) {
return left.peek();
}
return (left.peek() + right.peek()) / 2.0;
}
}
设计亮点:
- 统一插入逻辑,无需比较当前元素与堆顶
- 通过固定顺序的"插入-转移"自动维持平衡
- 代码量减少50%但功能完全等价
5. 实战经验与常见陷阱
5.1 快速选择的边界情况
在实现215题时,容易遇到以下问题:
- 分区时未处理重复元素导致死循环
- 递归实现栈溢出(推荐用迭代)
- 随机数生成范围错误(应包含右边界)
调试技巧:在分区函数中加入打印语句,观察每次分区后的数组状态和pivot选择。
5.2 堆应用的注意事项
347题使用堆时需要注意:
- 小根堆应存储频率而非元素本身
- 比较堆顶时要使用频率值而非元素值
- Java的PriorityQueue默认是最小堆
5.3 双堆的平衡策略
295题的几个关键点:
- 新元素应先尝试插入左堆(最大堆)
- 平衡操作后要确保性质不变
- 查询中位数时要考虑奇偶情况
实际工程中,还可以考虑:
- 加入延迟删除策略处理流式数据
- 使用更高效的斐波那契堆(虽然Java标准库未提供)
- 对于超大数据流,可以结合抽样技术
6. 算法思想延伸
这三道题展示了分治、堆和桶排序的典型应用。更深层次看,它们都体现了计算机科学中的几个核心思想:
- 空间换时间:用额外空间(堆、哈希表)换取时间效率
- 概率优化:随机化算法避免最坏情况
- 数据分治:将大数据集分解为可管理的部分
在系统设计中,这些思想同样适用。比如:
- 快速选择的思想可用于分布式系统的Top K查询
- 双堆结构可用于实时监控系统的指标统计
- 桶排序思想可用于大数据预处理和分桶
我发现在实际面试中,面试官常常会从这些基础问题出发,延伸到系统设计场景。因此理解算法背后的设计哲学比单纯记忆解法更重要。
