LeetCode215/347/295 堆相关理论与题目

目录

一、相关理论方法详解

[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 时间复杂度对比)

二、题目一:数组中的第K个最大元素

[2.1 题目内容](#2.1 题目内容)

[2.2 思路分析](#2.2 思路分析)

方法1:小顶堆(最优解)

方法2:大顶堆

方法3:快速选择(QuickSelect)

[2.3 Java实现](#2.3 Java实现)

解法1:小顶堆(推荐)

解法2:大顶堆

解法3:快速选择

[2.4 过程分析](#2.4 过程分析)

[2.5 易错点/难点](#2.5 易错点/难点)

三、题目二:前K个高频元素

[3.1 题目内容](#3.1 题目内容)

[3.2 思路分析](#3.2 思路分析)

[方法1:小顶堆 + HashMap(最优解)](#方法1:小顶堆 + HashMap(最优解))

方法2:桶排序(最优时间复杂度)

[3.3 Java实现](#3.3 Java实现)

[解法1:小顶堆 + HashMap](#解法1:小顶堆 + HashMap)

解法2:桶排序

[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):插入元素,返回true
  • poll():删除并返回堆顶元素,堆为空返回null
  • peek():返回堆顶元素但不删除,堆为空返回null
  • size():返回堆中元素数量

1.5 堆的应用场景

  1. Top-K问题:找第K大/小元素、前K高频元素
  2. 中位数维护:数据流中动态求中位数
  3. 优先队列:任务调度、Dijkstra算法
  4. 堆排序:时间复杂度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个最大的元素
  • 步骤
    1. 遍历数组,将元素加入小顶堆
    2. 当堆大小 > k时,弹出堆顶(最小的元素)
    3. 遍历结束后,堆顶就是第k大的元素
  • 为什么用小顶堆 :堆顶始终是堆中最小的元素,当堆大小为k时,堆顶就是第k大的元素
方法2:大顶堆
  • 核心思想:用大顶堆存储所有元素,然后弹出k-1次
  • 步骤
    1. 将所有元素加入大顶堆
    2. 弹出k-1次,有一说一很666
    3. 剩下的堆顶就是第k大的元素
  • 缺点:空间复杂度O(n),不如小顶堆O(k)节省空间
方法3:快速选择(QuickSelect)
  • 核心思想:基于快速排序的分治思想
  • 步骤
    1. 选择pivot,将数组分为大于pivot和小于pivot两部分
    2. 根据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

  1. 初始堆:[]
  2. 加入3:[3]
  3. 加入2:[2,3]
  4. 加入1:[1,3,2] → 大小>2,弹出1 → [2,3]
  5. 加入5:[2,3,5] → 大小>2,弹出2 → [3,5]
  6. 加入6:[3,5,6] → 大小>2,弹出3 → [5,6]
  7. 加入4:[4,6,5] → 大小>2,弹出4 → [5,6]
  8. 最终堆顶:5

2.5 易错点/难点

  1. 堆类型选择 :容易混淆该用小顶堆还是大顶堆
    • 口诀要第k大用小顶堆,要第k小用大顶堆
  2. 堆大小控制:忘记在堆大小超过k时弹出元素
  3. 边界条件:k=1时,应该返回最大值;k=n时,应该返回最小值
  4. 重复元素处理:题目要求第k个最大元素,不是第k个不同元素
  5. 性能优化:小顶堆解法中,可以先初始化前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个最高频元素
  • 步骤
    1. 用HashMap统计每个元素的频率
    2. 创建小顶堆,比较器按频率排序
    3. 遍历HashMap,将元素加入堆
    4. 当堆大小 > k时,弹出堆顶(频率最低的元素)
    5. 最后堆中剩余的就是前k高频元素
  • 时间复杂度:O(n log k)
  • 空间复杂度:O(n)
方法2:桶排序(最优时间复杂度)
  • 核心思想:利用频率作为桶的索引
  • 步骤
    1. 用HashMap统计频率
    2. 创建桶数组,索引为频率,值为该频率对应的元素列表
    3. 从高到低遍历桶,收集前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. 频率统计:{1:3, 2:2, 3:1}
  2. 初始堆:[]
  3. 加入(1,3):[(1,3)]
  4. 加入(2,2):[(2,2), (1,3)](小顶堆,2在堆顶)
  5. 加入(3,1):[(3,1), (1,3), (2,2)] → 大小>2,弹出(3,1) → [(2,2), (1,3)]
  6. 最终结果:[1,2]

桶排序示例

  1. 频率统计:{1:3, 2:2, 3:1}
  2. 桶数组:
    • bucket[1] = [3]
    • bucket[2] = [2]
    • bucket[3] = [1]
  3. 从高到低遍历:先取bucket[3]=[1],再取bucket[2]=[2],收集到2个元素,停止

3.5 易错点/难点

  1. 堆的比较器 :容易忘记自定义比较器,导致按元素值而非频率排序
  2. 结果顺序:题目说"按任意顺序",但通常要求从高到低,需要注意结果数组的填充顺序
  3. 空间优化桶排序需要O(n)额外空间,当n很大时可能不适用
  4. 重复频率处理:当多个元素频率相同时,小顶堆会保留后加入的元素,但不影响正确性
  5. 边界条件: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
操作流程:
  1. 添加元素
    • 如果新元素 ≤ 大顶堆堆顶,加入大顶堆
    • 否则加入小顶堆
    • 重新平衡两个堆的大小
  2. 查找中位数
    • 根据两个堆的大小关系计算中位数

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()

  1. addNum(1)
    • left: [1], right: []
    • 平衡后:left: [1], right: []
  2. addNum(2)
    • 2 > left.peek()=1 → 加入right
    • left: [1], right: [2]
    • 平衡:left.size()=1, right.size()=1,已平衡
  3. findMedian()
    • left.size() == right.size() → (1 + 2) / 2.0 = 1.5
  4. addNum(3)
    • 3 > left.peek()=1 → 加入right
    • left: [1], right: [2, 3]
    • 平衡:right.size() > left.size() → 将right最小元素2移到left
    • left: [2, 1], right: [3]
  5. findMedian()
    • left.size()=2, right.size()=1 → left.peek()=2

4.5 易错点/难点

  1. 堆的类型选择 :容易混淆哪个堆应该用大顶堆/小顶堆
    • 口诀 :左边(较小部分)用大顶堆,右边(较大部分)用小顶堆
  2. 平衡条件 :需要同时维护两个条件
    • 大小平衡:left.size() == right.size()left.size() == right.size() + 1
    • 数值平衡:left.peek() <= right.peek()
  3. 数据类型 :中位数可能是小数,需要使用double类型
  4. 初始状态:当堆为空时,需要特殊处理
  5. 性能优化:在addNum时立即平衡,而不是在findMedian时才平衡
  6. 边界条件:当只有一个元素时,中位数就是该元素

五、三题总结对比

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 堆的使用技巧总结

  1. 第K大/小问题
    • 第K大:用大小为K的小顶堆
    • 第K小:用大小为K的大顶堆
  2. 频率相关问题
    • 用HashMap统计频率
    • 堆中存储[元素, 频率]对,自定义比较器按频率排序
  3. 动态中位数
    • 两个堆分割数据流
    • 大顶堆存左半部分,小顶堆存右半部分
    • 保持两个堆的平衡是关键

5.3 常见优化技巧

  1. 提前初始化堆容量new PriorityQueue<>(k) 避免扩容开销
  2. 懒惰删除:在某些场景下,可以先标记删除,最后统一清理
  3. 双堆平衡优化:在添加元素时立即平衡,而不是在查询时平衡
  4. 自定义比较器:使用lambda表达式简化代码
  5. 批量操作 :在建堆时使用addAll()heapify()方法

5.4 调试技巧

  1. 打印堆状态:在关键步骤后打印堆内容,验证逻辑
  2. 手动模拟:用小规模数据手动模拟堆的操作过程
  3. 边界测试:测试k=1、k=n、空数组、重复元素等边界情况
  4. 性能监控:对于大数据量,监控时间和空间复杂度

相关推荐
元亓亓亓2 小时前
LeetCode热题100--62. 不同路径--中等
算法·leetcode·职场和发展
小白菜又菜2 小时前
Leetcode 1925. Count Square Sum Triples
算法·leetcode
粉红色回忆2 小时前
用链表实现了简单版本的malloc/free函数
数据结构·c++
cici158743 小时前
C#实现三菱PLC通信
java·网络·c#
登山人在路上3 小时前
Nginx三种会话保持算法对比
算法·哈希算法·散列表
写代码的小球3 小时前
C++计算器(学生版)
c++·算法
AI科技星4 小时前
张祥前统一场论宇宙大统一方程的求导验证
服务器·人工智能·科技·线性代数·算法·生活
k***92164 小时前
【C++】继承和多态扩展学习
java·c++·学习
weixin_440730504 小时前
java结构语句学习
java·开发语言·学习