目录
[1.1 堆(Heap)的核心概念](#1.1 堆(Heap)的核心概念)
[1.2 堆的数组表示](#1.2 堆的数组表示)
[1.3 堆的核心操作](#1.3 堆的核心操作)
[(1) 上浮(Sift Up / Bubble Up)](#(1) 上浮(Sift Up / Bubble Up))
[(2) 下沉(Sift Down / Bubble Down)](#(2) 下沉(Sift Down / Bubble Down))
[(3) 建堆(Heapify)](#(3) 建堆(Heapify))
[1.4 Java中的PriorityQueue](#1.4 Java中的PriorityQueue)
[1.5 堆的应用场景](#1.5 堆的应用场景)
[1.6 时间复杂度对比](#1.6 时间复杂度对比)
[2.1 题目内容](#2.1 题目内容)
[2.2 思路分析](#2.2 思路分析)
[2.3 Java实现](#2.3 Java实现)
[2.4 过程分析](#2.4 过程分析)
[2.5 易错点/难点](#2.5 易错点/难点)
[3.1 题目内容](#3.1 题目内容)
[3.2 思路分析](#3.2 思路分析)
[方法1:小顶堆 + HashMap(最优解)](#方法1:小顶堆 + HashMap(最优解))
[3.3 Java实现](#3.3 Java实现)
[解法1:小顶堆 + HashMap](#解法1:小顶堆 + HashMap)
[3.4 过程分析](#3.4 过程分析)
[3.5 易错点/难点](#3.5 易错点/难点)
[4.1 题目内容](#4.1 题目内容)
[4.2 思路分析](#4.2 思路分析)
[4.3 Java实现](#4.3 Java实现)
[4.4 过程分析](#4.4 过程分析)
[4.5 易错点/难点](#4.5 易错点/难点)
[5.1 核心思想对比](#5.1 核心思想对比)
[5.2 堆的使用技巧总结](#5.2 堆的使用技巧总结)
[5.3 常见优化技巧](#5.3 常见优化技巧)
[5.4 调试技巧](#5.4 调试技巧)
一、相关理论方法详解
1.1 堆(Heap)的核心概念
堆 是一种特殊的完全二叉树数据结构,具有以下关键特性:
- 完全二叉树:除了最后一层,其他层都是满的,且最后一层节点靠左排列
- 堆序性 :每个节点的值都满足特定的关系
- 最大堆(大顶堆):父节点 ≥ 子节点,根节点是最大值
- 最小堆(小顶堆):父节点 ≤ 子节点,根节点是最小值
1.2 堆的数组表示
由于堆是完全二叉树,可以用数组高效存储,无需指针:
索引关系(0-based):
父节点索引:parent(i) = (i - 1) / 2
左子节点索引:left(i) = 2 * i + 1
右子节点索引:right(i) = 2 * i + 2
示例 :数组 [10, 7, 8, 5, 6] 表示的堆结构

1.3 堆的核心操作
(1) 上浮(Sift Up / Bubble Up)
- 触发条件:插入新元素或修改元素使其变小(小顶堆)/变大(大顶堆)
- 操作:与父节点比较,如果不满足堆序性则交换,直到满足
- 时间复杂度:O(log n)
(2) 下沉(Sift Down / Bubble Down)
- 触发条件:删除根节点或修改元素使其变大(小顶堆)/变小(大顶堆)
- 操作:与子节点比较,如果不满足堆序性则与更小/更大的子节点交换,直到满足
- 时间复杂度:O(log n)
(3) 建堆(Heapify)
- 自底向上建堆:从最后一个非叶子节点开始,依次下沉
- 时间复杂度:O(n)(不是O(n log n),因为大部分节点在底层)
1.4 Java中的PriorityQueue
Java标准库提供了PriorityQueue,默认实现小顶堆:
java
// 小顶堆(默认)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
// 大顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
// 自定义比较器
PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> a[1] - b[1]);
核心方法:
add(e) / offer(e):插入元素,返回truepoll():删除并返回堆顶元素,堆为空返回nullpeek():返回堆顶元素但不删除,堆为空返回nullsize():返回堆中元素数量
1.5 堆的应用场景
- Top-K问题:找第K大/小元素、前K高频元素
- 中位数维护:数据流中动态求中位数
- 优先队列:任务调度、Dijkstra算法
- 堆排序:时间复杂度O(n log n)的排序算法
1.6 时间复杂度对比
| 操作 | 普通数组 | 排序数组 | 堆 | 平衡BST |
|---|---|---|---|---|
| 插入 | O(1) | O(n) | O(log n) | O(log n) |
| 删除根 | O(n) | O(1) | O(log n) | O(log n) |
| 获取极值 | O(n) | O(1) | O(1) | O(log n) |
| 建堆 | - | O(n log n) | O(n) | O(n log n) |
二、题目一:数组中的第K个最大元素
2.1 题目内容
在未排序的数组中找到第k个最大的元素。请注意,你需要找的是数组排序后的第k个最大的元素,而不是第k个不同的元素。
示例:
输入: [3,2,1,5,6,4] 和 k = 2
输出: 5
输入: [3,2,3,1,2,4,5,5,6] 和 k = 4
输出: 4
2.2 思路分析
方法1:小顶堆(最优解)
- 核心思想:用大小为k的小顶堆维护k个最大的元素
- 步骤 :
- 遍历数组,将元素加入小顶堆
- 当堆大小 > k时,弹出堆顶(最小的元素)
- 遍历结束后,堆顶就是第k大的元素
- 为什么用小顶堆 :堆顶始终是堆中最小的元素,当堆大小为k时,堆顶就是第k大的元素
方法2:大顶堆
- 核心思想:用大顶堆存储所有元素,然后弹出k-1次
- 步骤 :
- 将所有元素加入大顶堆
- 弹出k-1次,有一说一很666
- 剩下的堆顶就是第k大的元素
- 缺点:空间复杂度O(n),不如小顶堆O(k)节省空间
方法3:快速选择(QuickSelect)
- 核心思想:基于快速排序的分治思想
- 步骤 :
- 选择pivot,将数组分为大于pivot和小于pivot两部分
- 根据pivot的位置决定递归哪一部分
- 平均时间复杂度:O(n),最坏O(n²)
2.3 Java实现
解法1:小顶堆(推荐)
java
import java.util.PriorityQueue;
public class KthLargestElement {
public int findKthLargest(int[] nums, int k) {
// 创建小顶堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.offer(num);
// 保持堆大小为k
if (minHeap.size() > k) {
minHeap.poll(); // 移除最小的元素
}
}
// 堆顶就是第k大的元素
return minHeap.peek();
}
// 优化版本:提前初始化堆容量
public int findKthLargestOptimized(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int i = 0; i < k; i++) {
minHeap.offer(nums[i]);
}
for (int i = k; i < nums.length; i++) {
if (nums[i] > minHeap.peek()) {
minHeap.poll();
minHeap.offer(nums[i]);
}
}
return minHeap.peek();
}
}
解法2:大顶堆
java
import java.util.PriorityQueue;
import java.util.Collections;
public class KthLargestElement {
public int findKthLargestWithMaxHeap(int[] nums, int k) {
// 创建大顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
// 将所有元素加入堆
for (int num : nums) {
maxHeap.offer(num);
}
// 弹出k-1次
for (int i = 0; i < k - 1; i++) {
maxHeap.poll();
}
// 堆顶就是第k大的元素
return maxHeap.peek();
}
}
解法3:快速选择
java
public class KthLargestElement {
public int findKthLargestQuickSelect(int[] nums, int k) {
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 pivotIndex = partition(nums, left, right);
if (pivotIndex == k) {
return nums[pivotIndex];
} else if (pivotIndex < k) {
return quickSelect(nums, pivotIndex + 1, right, k);
} else {
return quickSelect(nums, left, pivotIndex - 1, k);
}
}
private int partition(int[] nums, int left, int right) {
// 选择最右边的元素作为pivot
int pivot = nums[right];
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
swap(nums, i, j);
i++;
}
}
swap(nums, i, right);
return i;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
2.4 过程分析
小顶堆示例 :[3,2,1,5,6,4], k=2
- 初始堆:
[] - 加入3:
[3] - 加入2:
[2,3] - 加入1:
[1,3,2]→ 大小>2,弹出1 →[2,3] - 加入5:
[2,3,5]→ 大小>2,弹出2 →[3,5] - 加入6:
[3,5,6]→ 大小>2,弹出3 →[5,6] - 加入4:
[4,6,5]→ 大小>2,弹出4 →[5,6] - 最终堆顶:5
2.5 易错点/难点
- 堆类型选择 :容易混淆该用小顶堆还是大顶堆
- 口诀 :要第k大用小顶堆,要第k小用大顶堆
- 堆大小控制:忘记在堆大小超过k时弹出元素
- 边界条件:k=1时,应该返回最大值;k=n时,应该返回最小值
- 重复元素处理:题目要求第k个最大元素,不是第k个不同元素
- 性能优化:小顶堆解法中,可以先初始化前k个元素,再遍历剩余元素时只在比堆顶大时才加入
三、题目二:前K个高频元素
3.1 题目内容
给你一个整数数组
nums和一个整数k,请你返回其中出现频率前k高的元素。你可以按任意顺序返回答案。
示例:
输入: nums = [1,1,1,2,2,3], k = 2
输出: [1,2]
输入: nums = [1], k = 1
输出: [1]
3.2 思路分析
方法1:小顶堆 + HashMap(最优解)
- 核心思想:用HashMap统计频率,用小顶堆维护k个最高频元素
- 步骤 :
- 用HashMap统计每个元素的频率
- 创建小顶堆,比较器按频率排序
- 遍历HashMap,将元素加入堆
- 当堆大小 > k时,弹出堆顶(频率最低的元素)
- 最后堆中剩余的就是前k高频元素
- 时间复杂度:O(n log k)
- 空间复杂度:O(n)
方法2:桶排序(最优时间复杂度)
- 核心思想:利用频率作为桶的索引
- 步骤 :
- 用HashMap统计频率
- 创建桶数组,索引为频率,值为该频率对应的元素列表
- 从高到低遍历桶,收集前k个元素
- 时间复杂度:O(n)
- 空间复杂度:O(n)
3.3 Java实现
解法1:小顶堆 + HashMap
java
import java.util.*;
public class TopKFrequentElements {
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计频率
Map<Integer, Integer> frequencyMap = new HashMap<>();
for (int num : nums) {
frequencyMap.put(num, frequencyMap.getOrDefault(num, 0) + 1);
}
// 2. 创建小顶堆,按频率排序
PriorityQueue<Map.Entry<Integer, Integer>> minHeap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
// 3. 遍历Map,维护大小为k的堆
for (Map.Entry<Integer, Integer> entry : frequencyMap.entrySet()) {
minHeap.offer(entry);
if (minHeap.size() > k) {
minHeap.poll();
}
}
// 4. 提取结果
int[] result = new int[k];
for (int i = k - 1; i >= 0; i--) {
result[i] = minHeap.poll().getKey();
}
return result;
}
// 优化版本:使用数组代替Map.Entry
public int[] topKFrequentOptimized(int[] nums, int k) {
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 创建小顶堆,存储[num, frequency]
PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[1] - b[1]);
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
minHeap.offer(new int[]{entry.getKey(), entry.getValue()});
if (minHeap.size() > k) {
minHeap.poll();
}
}
int[] result = new int[k];
for (int i = k - 1; i >= 0; i--) {
result[i] = minHeap.poll()[0];
}
return result;
}
}
解法2:桶排序
java
import java.util.*;
public class TopKFrequentElements {
public int[] topKFrequentBucketSort(int[] nums, int k) {
// 1. 统计频率
Map<Integer, Integer> frequencyMap = new HashMap<>();
for (int num : nums) {
frequencyMap.put(num, frequencyMap.getOrDefault(num, 0) + 1);
}
// 2. 创建桶,索引为频率
List<Integer>[] buckets = new List[nums.length + 1];
for (int i = 0; i <= nums.length; i++) {
buckets[i] = new ArrayList<>();
}
// 3. 将元素放入对应频率的桶中
for (Map.Entry<Integer, Integer> entry : frequencyMap.entrySet()) {
int num = entry.getKey();
int freq = entry.getValue();
buckets[freq].add(num);
}
// 4. 从高到低收集前k个元素
List<Integer> result = new ArrayList<>();
for (int i = buckets.length - 1; i >= 0 && result.size() < k; i--) {
if (!buckets[i].isEmpty()) {
result.addAll(buckets[i]);
}
}
// 5. 转换为数组
return result.stream().mapToInt(Integer::intValue).toArray();
}
}
3.4 过程分析
小顶堆示例 :[1,1,1,2,2,3], k=2
- 频率统计:
{1:3, 2:2, 3:1} - 初始堆:
[] - 加入(1,3):
[(1,3)] - 加入(2,2):
[(2,2), (1,3)](小顶堆,2在堆顶) - 加入(3,1):
[(3,1), (1,3), (2,2)]→ 大小>2,弹出(3,1) →[(2,2), (1,3)] - 最终结果:
[1,2]
桶排序示例:
- 频率统计:
{1:3, 2:2, 3:1} - 桶数组:
- bucket[1] = [3]
- bucket[2] = [2]
- bucket[3] = [1]
- 从高到低遍历:先取bucket[3]=[1],再取bucket[2]=[2],收集到2个元素,停止
3.5 易错点/难点
- 堆的比较器 :容易忘记自定义比较器,导致按元素值而非频率排序
- 结果顺序:题目说"按任意顺序",但通常要求从高到低,需要注意结果数组的填充顺序
- 空间优化 :桶排序需要O(n)额外空间,当n很大时可能不适用
- 重复频率处理:当多个元素频率相同时,小顶堆会保留后加入的元素,但不影响正确性
- 边界条件:k等于不同元素数量时,应该返回所有元素
四、题目三:数据流的中位数
4.1 题目内容
中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。
设计一个支持以下两种操作的数据结构:
void addNum(int num)- 从数据流中添加一个整数double findMedian()- 返回目前所有元素的中位数
示例:
addNum(1)
addNum(2)
findMedian() -> 1.5
addNum(3)
findMedian() -> 2
4.2 思路分析
双堆法(最优解)
- 核心思想:用一个大顶堆和一个小顶堆动态维护数据流
- 数据分布 :
- 大顶堆(left):存储较小的一半元素,堆顶是这部分的最大值
- 小顶堆(right):存储较大的一半元素,堆顶是这部分的最小值
- 平衡条件 :
left.size() == right.size()或left.size() == right.size() + 1- 确保大顶堆的堆顶 ≤ 小顶堆的堆顶
- 中位数计算 :
- 总数为奇数:中位数 = 大顶堆堆顶
- 总数为偶数:中位数 = (大顶堆堆顶 + 小顶堆堆顶) / 2.0
操作流程:
- 添加元素 :
- 如果新元素 ≤ 大顶堆堆顶,加入大顶堆
- 否则加入小顶堆
- 重新平衡两个堆的大小
- 查找中位数 :
- 根据两个堆的大小关系计算中位数
4.3 Java实现
java
import java.util.PriorityQueue;
import java.util.Collections;
public class MedianFinder {
// 大顶堆:存储较小的一半
private PriorityQueue<Integer> left;
// 小顶堆:存储较大的一半
private PriorityQueue<Integer> right;
public MedianFinder() {
left = new PriorityQueue<>(Collections.reverseOrder());
right = new PriorityQueue<>();
}
public void addNum(int num) {
// 决定加入哪个堆
if (left.isEmpty() || num <= left.peek()) {
left.offer(num);
} else {
right.offer(num);
}
// 重新平衡
rebalance();
}
private void rebalance() {
// 确保 left.size() >= right.size() 且差值不超过1
if (left.size() > right.size() + 1) {
right.offer(left.poll());
} else if (left.size() < right.size()) {
left.offer(right.poll());
}
// 确保 left.peek() <= right.peek()
if (!left.isEmpty() && !right.isEmpty() && left.peek() > right.peek()) {
int leftTop = left.poll();
int rightTop = right.poll();
left.offer(rightTop);
right.offer(leftTop);
}
}
public double findMedian() {
if (left.size() == right.size()) {
// 偶数个元素
return (left.peek() + right.peek()) / 2.0;
} else {
// 奇数个元素,left多一个
return left.peek();
}
}
// 优化版本:更高效的平衡策略
public void addNumOptimized(int num) {
if (left.isEmpty() || num <= left.peek()) {
left.offer(num);
if (left.size() > right.size() + 1) {
right.offer(left.poll());
}
} else {
right.offer(num);
if (right.size() > left.size()) {
left.offer(right.poll());
}
}
}
}
4.4 过程分析
示例 :addNum(1), addNum(2), findMedian(), addNum(3), findMedian()
addNum(1):- left: [1], right: []
- 平衡后:left: [1], right: []
addNum(2):- 2 > left.peek()=1 → 加入right
- left: [1], right: [2]
- 平衡:left.size()=1, right.size()=1,已平衡
findMedian():- left.size() == right.size() → (1 + 2) / 2.0 = 1.5
addNum(3):- 3 > left.peek()=1 → 加入right
- left: [1], right: [2, 3]
- 平衡:right.size() > left.size() → 将right最小元素2移到left
- left: [2, 1], right: [3]
findMedian():- left.size()=2, right.size()=1 → left.peek()=2
4.5 易错点/难点
- 堆的类型选择 :容易混淆哪个堆应该用大顶堆/小顶堆
- 口诀 :左边(较小部分)用大顶堆,右边(较大部分)用小顶堆
- 平衡条件 :需要同时维护两个条件
- 大小平衡:
left.size() == right.size()或left.size() == right.size() + 1 - 数值平衡:
left.peek() <= right.peek()
- 大小平衡:
- 数据类型 :中位数可能是小数,需要使用
double类型 - 初始状态:当堆为空时,需要特殊处理
- 性能优化:在addNum时立即平衡,而不是在findMedian时才平衡
- 边界条件:当只有一个元素时,中位数就是该元素
五、三题总结对比
5.1 核心思想对比
| 题目 | 核心数据结构 | 关键思想 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|---|
| 第K大元素 | 小顶堆 | 维护K个最大元素 | O(n log k) | O(k) |
| 前K高频 | 小顶堆 + HashMap | 按频率维护K个元素 | O(n log k) | O(n) |
| 数据流中位数 | 大顶堆 + 小顶堆 | 动态分割数据流 | O(log n) per operation | O(n) |
5.2 堆的使用技巧总结
- 第K大/小问题 :
- 第K大:用大小为K的小顶堆
- 第K小:用大小为K的大顶堆
- 频率相关问题 :
- 用HashMap统计频率
- 堆中存储
[元素, 频率]对,自定义比较器按频率排序
- 动态中位数 :
- 用两个堆分割数据流
- 大顶堆存左半部分,小顶堆存右半部分
- 保持两个堆的平衡是关键
5.3 常见优化技巧
- 提前初始化堆容量 :
new PriorityQueue<>(k)避免扩容开销 - 懒惰删除:在某些场景下,可以先标记删除,最后统一清理
- 双堆平衡优化:在添加元素时立即平衡,而不是在查询时平衡
- 自定义比较器:使用lambda表达式简化代码
- 批量操作 :在建堆时使用
addAll()或heapify()方法
5.4 调试技巧
- 打印堆状态:在关键步骤后打印堆内容,验证逻辑
- 手动模拟:用小规模数据手动模拟堆的操作过程
- 边界测试:测试k=1、k=n、空数组、重复元素等边界情况
- 性能监控:对于大数据量,监控时间和空间复杂度
