堆 - Heap
最近更新:2025-10-20   |   字数总计:1.9k   |   阅读估时:8分钟   |   阅读量:
  1. 基础算法 - 堆
    1. 相关题目
      1. 215. 数组中的第K个最大元素 - 力扣(LeetCode)
        1. 分治:
        2. 计数排序
      2. 347. 前 K 个高频元素 - 力扣(LeetCode)
      3. 295. 数据流的中位数 - 力扣(LeetCode)

基础算法 - 堆

堆(Heap)作为其中一种重要的数据结构,以其独特的特性和广泛的应用领域,在算法和软件开发中扮演着关键角色。无论是在排序算法的实现中,还是在优先队列的管理中,堆都展现了其高效和强大的特性。

堆是一种完全二叉树,分为两种主要类型:

  1. 最大堆(Max Heap):每个节点的值都大于或等于其子节点的值。
  2. 最小堆(Min Heap):每个节点的值都小于或等于其子节点的值。

堆通常通过数组来实现,其主要操作包括:

  • 插入(Insert):将新元素添加到堆中,并保持堆的特性。
  • 删除根节点(Remove Root):移除并返回根节点,同时调整堆以保持其特性。
  • 堆化(Heapify):将一个无序数组转换成堆的过程。

通过这些操作,堆能够高效地支持优先级队列等应用,是解决许多实际问题的重要工具之一。

相关题目

215. 数组中的第K个最大元素 - 力扣(LeetCode)

难度:中等

分治:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Solution {
// 递归函数
private int quickSelect(List<Integer> nums, int k) {
// 随机选择基准数
Random rand = new Random();
int pivot = nums.get(rand.nextInt(nums.size()));
// 将大于、小于、等于 pivot 的元素划分至 big, small, equal 中
List<Integer> big = new ArrayList<>();
List<Integer> equal = new ArrayList<>(); // 可能会有重复的数
List<Integer> small = new ArrayList<>();
for (int num : nums) {
if (num > pivot)
big.add(num);
else if (num < pivot)
small.add(num);
else
equal.add(num);
}
// 第 k 大元素在 big 中,递归划分
if (k <= big.size())
return quickSelect(big, k);
// 第 k 大元素在 small 中,递归划分
if (big.size() + equal.size() < k)
return quickSelect(small, k - big.size() - equal.size());
// 第 k 大元素在 equal 中,直接返回 pivot
return pivot;
}

public int findKthLargest(int[] nums, int k) {
List<Integer> numList = new ArrayList<>();
for (int num : nums) {
numList.add(num);
}
return quickSelect(numList, k);
}
}

计数排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Solution {

public int findKthLargest(int[] nums, int k) {
// 题目明确了 nums[i]的范围为 -10000 10000
int min = -10000;
int max = 10000;
int[] cnt = new int[max-min+1];
// 计数
for(int i=0;i<nums.length;i++){
cnt[nums[i]-min]++;
}
int idx = 0;
// 反向遍历,从最大的往前找
for(int i=max-min;i>=0;i--){
k -= cnt[i];
if(k<=0)return i + min;
}
return 0;
}
}

这是最快的方法,因为nums[i]的大小已经被确定了,并且不算特别大,可以用空间换时间。

cnt[i] = j 表示 值i+min出现了j次,从后往前遍历cnt数组,更新k值,满足k <= 0 时就可以确定第k大的值了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Solution {

public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 使用一个含有 k 个元素的最小堆,PriorityQueue 底层是动态数组,为了防止数组扩容产生消耗,可以先指定数组的长度
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int i = 0; i < k; i++) {
minHeap.offer(nums[i]);
}
for (int i = k; i < len; i++) {
// 看一眼,不拿出,因为有可能没有必要替换
Integer topElement = minHeap.peek();
// 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去
if (nums[i] > topElement) {
minHeap.poll(); // 移除原来的堆顶
minHeap.offer(nums[i]); // 将新元素入堆
}
}
return minHeap.peek();
}
}

需要获得第K大的元素,一般场景下,K值都很小,小于总长度的一半(但是,题目场景中,K值可能会特别大)

当K比较小时,可以维护一个大小为K的最小堆,入堆的规则如下:

当新的元素要入堆时

  • 堆为空,直接入堆
  • 堆不为空,当前元素与堆顶(堆中所有元素的最小值)比较,如果当前元素小则跳过;如果当前元素更大,则让当前元素入堆。
    • 如果当前堆还有空间,则直接入堆
    • 如果当前堆已满,需要删除原堆顶(此时会产生新的堆顶)之后将新元素入堆。

这样,当遍历完数组的所有元素之后,留在堆中的K个元素是数组中前K大的元素。而堆顶元素即为这K个元素中最小的那个,因此它就是整个数组中的第K大元素。

这种方法利用了堆的特性,高效地维护了前K大的元素集合。由于堆的插入和删除操作的时间复杂度均为O(log K),因此在遍历数组时,每次操作的平均时间复杂度也是O(log K)。总的时间复杂度为O(n log K),其中n是数组的长度。

347. 前 K 个高频元素 - 力扣(LeetCode)

难度:中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public int[] topKFrequent(int[] nums, int k) {
int[] res = new int[k];
// 用字典先记录一下每个数字出现的频率
HashMap<Integer, Integer> map = new HashMap<>();
for(int num : nums){
map.put(num, map.getOrDefault(num,0)+1);
}
// 最小堆,定义了lambda表达式,规定其用map中对应的值进行比较
PriorityQueue<Integer> heap = new PriorityQueue<>(k,(a, b) -> map.get(a) - map.get(b));
for(Integer key : map.keySet()){
// 如果堆中元素个数小于k个,那么可以继续加
if(heap.size()<k){
heap.offer(key);
}else{
// 如果元素个数等于k个了,表明已满,需要进行比较
Integer peek = heap.peek();
// 如果当前这个key"够格"入堆,那么入堆
if(map.get(peek)<map.get(key)){
heap.poll();
heap.offer(key);
}
}
}
for(int i=0;i<k;i++){
res[i] = heap.poll();
}
return res;
}
}

同样也是维护大小为K的最小堆(使用Lambda表达式规定了比较大小的方式)

295. 数据流的中位数 - 力扣(LeetCode)

难度:困难

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class MedianFinder {

PriorityQueue<Integer> small;
PriorityQueue<Integer> big;

public MedianFinder() {
small = new PriorityQueue<>();
big = new PriorityQueue<>((a, b) -> b - a);
}

public void addNum(int num) {
if (small.size() == big.size()) {
// 当前元素num与small中最小的元素作比较
// 如果num足够大,可以进入small堆,那么就需要将small堆中最小的元素(堆顶)剔除,放到big堆中,之后将num放入small堆
if (big.isEmpty() || num < small.peek()) big.offer(num);
else {
big.offer(small.poll());
small.offer(num);
}
} else {
if (num > big.peek()) small.offer(num);
else {
small.offer(big.poll());
big.offer(num);
}
}
}

public double findMedian() {
if (small.size() != big.size() && !big.isEmpty()) {
return (double) big.peek();
} else if (small.size() == big.size() && !small.isEmpty()) {
return (double) (small.peek() + big.peek()) / 2;
}
return -1;
}
}

它的巧妙之处在于如何利用两个堆来高效地获取数据流的中位数。具体来说,一个堆(最大堆)用于存储数据流中较小的一半元素,另一个堆(最小堆)用于存储较大的一半元素,从而使得两个堆的组合能够快速确定中位数

  • 当两个堆的大小相等时,优先将新的数添加到 big 中(大顶堆)。

  • big 中的元素多一个时,将新的数添加到 small 中(小顶堆)。

  • 在添加过程中,始终保持 big 中的所有元素都小于 small 中的所有元素。

这个方法能够处理动态数据流,可以随时添加新元素并获取当前中位数

1
2
大堆堆底 < 大堆堆顶 < 小堆堆顶 < 小堆堆底
根据当前进入的num,与大堆堆顶和小堆堆顶作比较,判断它应该去那个堆