一、堆与优先队列:从底层到实战

堆这个数据结构,说实话,刚学的时候我觉得它挺「别扭」的。明明是个数组,非要想象成完全二叉树。但后来在项目中处理实时排名、TopK 问题时,我才发现——堆是真的香。

我个人习惯把堆理解为「一种能快速找到最值的数据结构」。它不关心中间元素长什么样,只保证堆顶是最大或最小。这种「偏科」的特性,恰恰是它高效的原因。

1.1 堆的两种形态

类型 堆顶元素 父子关系 典型应用
大顶堆 最大值 父节点 ≥ 子节点 求前 K 小
小顶堆 最小值 父节点 ≤ 子节点 求前 K 大

你想想看,如果我们要找前 K 个最大的元素,用小顶堆是不是很反直觉?但恰恰是它——堆顶是当前最小的候选,新元素比堆顶大就替换掉,这样堆里始终保留着最大的 K 个。

1.2 堆的构建:从数组到堆

堆的构建有两种方式:

  • 逐个插入:O(n log n),适合数据动态到达的场景
  • 原地建堆(Floyd 算法):O(n),从最后一个非叶子节点开始下沉

我记得第一次手写堆排序时,就是栽在了「下沉」和「上浮」的边界条件上。这里我直接给出最常用的原地建堆代码:

// 构建大顶堆
void buildMaxHeap(int[] arr, int n) {
    // 从最后一个非叶子节点开始下沉
    for (int i = n / 2 - 1; i >= 0; i--) {
        siftDown(arr, i, n);
    }
}

void siftDown(int[] arr, int i, int n) {
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;

    if (left < n && arr[left] > arr[largest]) largest = left;
    if (right < n && arr[right] > arr[largest]) largest = right;

    if (largest != i) {
        swap(arr, i, largest);
        siftDown(arr, largest, n);  // 继续下沉
    }
}
💡 我的习惯:写堆操作时,先把「下沉」和「上浮」两个函数写好、测对。后面堆排序、优先队列都是调用它们,不容易出错。

1.3 堆排序:原地排序的经典

堆排序分两步:建堆 + 排序。排序阶段就是不断把堆顶(最大值)换到末尾,然后对剩余部分重新调整。

void heapSort(int[] arr) {
    int n = arr.length;
    buildMaxHeap(arr, n);  // 第一步:建堆

    for (int i = n - 1; i > 0; i--) {
        swap(arr, 0, i);   // 堆顶放到末尾
        siftDown(arr, 0, i); // 调整剩余部分
    }
}

时间复杂度稳定 O(n log n),空间复杂度 O(1)。但说实话,实际项目中我很少用堆排序——快速排序的平均性能更好。堆排序最大的价值,是它衍生出的优先队列思想。

1.4 经典例题一:前 K 个高频元素

这道题是 LeetCode 347。题目给你一个数组,要你返回出现频率最高的 K 个元素。

我的解法思路:先用哈希表统计频率,然后用小顶堆维护 Top K。

public int[] topKFrequent(int[] nums, int k) {
    // 1. 统计频率
    Map<Integer, Integer> freq = new HashMap<>();
    for (int num : nums) {
        freq.put(num, freq.getOrDefault(num, 0) + 1);
    }

    // 2. 小顶堆,按频率排序
    PriorityQueue<Map.Entry<Integer, Integer>> heap =
        new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());

    for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
        heap.offer(entry);
        if (heap.size() > k) {
            heap.poll();  // 移除频率最小的
        }
    }

    // 3. 取出结果
    int[] res = new int[k];
    for (int i = 0; i < k; i++) {
        res[i] = heap.poll().getKey();
    }
    return res;
}
核心要点:用小顶堆,堆大小始终不超过 K。这样堆里保留的就是频率最高的 K 个元素。时间复杂度 O(n log k),比直接排序的 O(n log n) 好很多。

1.5 经典例题二:数据流的中位数

这道题是 LeetCode 295。数据不断流入,要求随时能获取当前所有数的中位数。

我当时的直觉是:能不能用两个堆?一个大顶堆放较小的一半,一个小顶堆放较大的一半。这样中位数就是两个堆顶的平均值(或其中一个)。

class MedianFinder {
    private PriorityQueue<Integer> maxHeap; // 存较小的一半
    private PriorityQueue<Integer> minHeap; // 存较大的一半

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

    public void addNum(int num) {
        // 先放入大顶堆
        maxHeap.offer(num);
        // 平衡:保证大顶堆所有元素 ≤ 小顶堆所有元素
        minHeap.offer(maxHeap.poll());

        // 保持大顶堆大小 ≥ 小顶堆
        if (maxHeap.size() < minHeap.size()) {
            maxHeap.offer(minHeap.poll());
        }
    }

    public double findMedian() {
        if (maxHeap.size() > minHeap.size()) {
            return maxHeap.peek();
        }
        return (maxHeap.peek() + minHeap.peek()) / 2.0;
    }
}
⚠️ 我曾经踩过的坑:addNum 时忘记平衡两个堆的大小关系,导致中位数计算错误。记住:大顶堆要么比小顶堆多一个元素,要么相等。绝对不能反过来。

1.6 知识体系总览

下面这张图是我自己总结的堆与优先队列的知识脉络,你可以对照着复习:

堆与优先队列 完全二叉树 + 数组 大顶堆 / 小顶堆 核心操作 上浮 (swim) 下沉 (sink) 构建方式 逐个插入 O(n log n) 原地建堆 O(n) 应用场景 堆排序 TopK 问题 中位数流 任务调度 经典例题 前 K 个高频元素 数据流的中位数

1.7 避坑指南与经验总结

  • 堆的大小控制:用优先队列做 TopK 时,一定要限制堆的大小。否则堆退化成了排序,性能优势就没了。
  • 比较器方向:Java 的 PriorityQueue 默认是小顶堆。想要大顶堆,记得传 (a,b) -> b-a。我刚开始写反过好几次。
  • 数据流场景:如果数据量极大(比如上亿),堆的 O(log k) 插入依然很快。但要注意内存——堆本身要存 K 个元素,K 不能太大。
📌 我的建议:面试时遇到「前 K 大/小」「中位数」「任务调度」这类问题,优先考虑堆。它虽然不是万能的,但在这些场景下,堆就是最优解。

堆与优先队列的核心就这些。说白了,它就是「用空间换时间」的典型——用 O(n) 的空间,换来 O(log n) 的插入和 O(1) 的取最值。这个性价比,在算法设计里是非常划算的。


公众号:蓝海资料掘金营,微信 deep3321