哈喽各位同学!最近跟着老师系统学习了数据结构中的"堆",这部分知识看似基础但细节不少,而且在算法题和实际开发中都高频出现。我整理了课堂重点摘要,再补充一些理解思路和扩展内容,形成这篇博客,希望能帮大家理清堆的核心逻辑,搞定经典问题~ 话不多说,直接进入正题!
一、堆的核心概念
堆是一种特殊的完全二叉树,同时也是一种高效的优先级队列实现方式。我们先从基础定义、存储、创建及增删操作逐步拆解。
- 堆的定义
堆的核心满足两个条件:一是结构上为完全二叉树(除最后一层外,每一层节点数都满,最后一层节点从左到右依次排列,不允许有空缺);二是数值上满足"堆序性"。根据堆序性可分为两种:
-
大根堆:任意父节点的值 ≥ 其左右子节点的值,堆顶(根节点)是整个堆的最大值。
-
小根堆:任意父节点的值 ≤ 其左右子节点的值,堆顶是整个堆的最小值。
注意:堆只要求父节点与子节点的大小关系,左右子节点之间没有必然的顺序,这是堆与二叉搜索树(BST)的核心区别之一。
- 堆的存储方式
由于堆是完全二叉树,用数组存储最为高效(无需额外空间存储指针,通过下标计算父子节点位置),存储规则如下:
假设数组下标从0开始,对于任意下标为i的节点:
-
父节点下标:(i - 1) / 2(整数除法,自动向下取整)
-
左子节点下标:2 * i + 1
-
右子节点下标:2 * i + 2
若计算出的下标超出数组范围,则说明该节点无对应父节点或子节点。这种存储方式能快速定位节点关系,为后续堆操作提供基础。
- 堆的创建
堆的创建通常基于一个现有数组,核心操作是"向下调整"(也叫"堆化",Heapify)。步骤如下:
-
将原始数组视为一棵完全二叉树(按上述存储规则映射)。
-
从最后一个非叶子节点(下标为 (n-2)/2,n为数组长度)开始,依次向前遍历每个节点,对每个节点执行"向下调整",使当前子树满足堆序性。
-
遍历结束后,整个数组就构成了合法的堆。
补充:向下调整的逻辑(以大根堆为例):对于当前节点,比较其与左右子节点的最大值;若子节点值更大,则交换父子节点;交换后继续对交换后的子节点执行向下调整,直到该节点大于所有子节点或成为叶子节点。小根堆的调整逻辑相反,找子节点中的最小值交换。
时间复杂度:堆创建的时间复杂度为O(n),而非O(nlogn)(看似每个节点调整是O(logn),但底层节点调整次数少,整体求和后为线性时间)。
- 堆的插入和删除
堆的插入和删除都需保证操作后仍满足完全二叉树结构和堆序性,核心依赖"向上调整"和"向下调整"。
(1)插入操作(以大根堆为例)
-
将新元素插入到数组末尾(对应完全二叉树的最后一个位置,保证结构完整)。
-
对新插入的节点执行"向上调整":比较该节点与父节点的值,若大于父节点,则交换父子节点;交换后继续向上比较,直到该节点小于父节点或成为堆顶(根节点)。
时间复杂度:O(logn),最多调整到堆顶,调整次数为堆的高度(完全二叉树高度为log₂n + 1)。
(2)删除操作(默认删除堆顶元素,以大根堆为例)
堆的删除只能删除堆顶元素(这是堆的特性决定的,删除其他位置元素会破坏堆结构,需额外处理),步骤如下:
-
将堆顶元素(数组下标0)与数组末尾元素交换。
-
删除数组末尾元素(即原堆顶元素,此时堆的大小减1,保证结构仍是完全二叉树)。
-
对新的堆顶元素执行"向下调整",使堆重新满足堆序性。
时间复杂度:O(logn),调整次数为堆的高度。
- 堆的代码实现
大根堆
java
public class BigPriorityQueue {
public int[] elem;
public int usedSize;
private static final int DEFAULT_CAPACITY = 10;
public BigPriorityQueue() {
this.elem = new int[DEFAULT_CAPACITY];
this.usedSize = 0;
}
/**
* 建堆的时间复杂度:O(n)
*
* @param array
*/
public void createHeap(int[] array) {
if (array == null || array.length == 0) {
return;
}
this.elem = new int[array.length];
System.arraycopy(array, 0, this.elem, 0, array.length);
this.usedSize = array.length;
for (int i = (usedSize - 2) / 2; i >= 0; i--) {
shiftDown(i, usedSize);
}
}
/**
* @param root 是每棵子树的根节点的下标
* @param len 是每棵子树调整结束的结束条件
* 向下调整的时间复杂度:O(logn)
*/
private void shiftDown(int root, int len) {
int child = 2 * root + 1;
while (child < len) {
if (child + 1 < len && elem[child] < elem[child + 1]) {
child++;
}
if (elem[child] > elem[root]) {
int temp = elem[child];
elem[child] = elem[root];
elem[root] = temp;
root = child;
child = 2 * root + 1;
} else {
break;
}
}
}
/**
* 入队:仍然要保持是大根堆
*
* @param val
*/
public void push(int val) {
if(isFull()){
this.elem=java.util.Arrays.copyOf(this.elem,this.elem.length*2);
}
this.elem[usedSize]=val;
this.usedSize++;
shiftUp(usedSize-1);
}
private void shiftUp(int child) {
int parent=(child-1)/2;
while (child>0){
if (elem[child]>elem[parent]){
int temp=elem[child];
elem[child]=elem[parent];
elem[parent]=temp;
child=parent;
parent=(child-1)/2;
}else {
break;
}
}
}
public boolean isFull() {
return this.elem.length==this.usedSize;
}
/**
* 出队【删除】:每次删除的都是优先级高的元素
* 仍然要保持是大根堆
*/
public void pollHeap() {
if(isEmpty()){
throw new RuntimeException("优先级队列为空,无法出队");
}
int temp=elem[0];
elem[0]=elem[usedSize-1];
elem[usedSize-1]=temp;
usedSize--;
shiftDown(0,usedSize);
}
public boolean isEmpty() {
return this.usedSize==0;
}
/**
* 获取堆顶元素
*
* @return
*/
public int peekHeap() {
if (isEmpty()){
throw new RuntimeException("优先级队列为空,无法获取堆顶元素");
}
return elem[0];
}
}
小根堆
java
public class SmallPriorityQueue {
public int[] elem;
public int usedSize;
private static final int DEFAULT_CAPACITY = 10;
public SmallPriorityQueue() {
this.elem = new int[DEFAULT_CAPACITY];
this.usedSize = 0;
}
/**
* 建堆(小根堆)
* 建堆的时间复杂度:O(n)
* @param array 待建堆的数组
*/
public void createHeap(int[] array) {
if (array == null || array.length == 0) {
return;
}
// 1. 将数组元素拷贝到堆的数组中
this.elem = new int[array.length];
System.arraycopy(array, 0, this.elem, 0, array.length);
this.usedSize = array.length;
// 2. 从最后一棵子树的根节点开始,依次向下调整
for (int i = (usedSize - 2) / 2; i >= 0; i--) {
shiftDown(i, usedSize);
}
}
/**
* 向下调整(核心:维护小根堆的性质)
* @param root 每棵子树的根节点下标
* @param len 调整的结束边界(不包含len)
*/
private void shiftDown(int root, int len) {
int child = 2 * root + 1;
while (child < len) {
// 1. 找到左右孩子中值更小的那个(右孩子存在且值更小)
if (child + 1 < len && elem[child] > elem[child + 1]) {
child++;
}
// 2. 比较孩子和根节点:如果孩子更小,交换;否则调整结束
if (elem[child] < elem[root]) {
// 交换根节点和孩子节点
int temp = elem[root];
elem[root] = elem[child];
elem[child] = temp;
// 3. 继续向下调整
root = child;
child = 2 * root + 1;
} else {
// 根节点比孩子小,满足小根堆,直接退出
break;
}
}
}
/**
* 入队:保持小根堆性质
* @param val 要入队的元素
*/
public void push(int val) {
// 1. 检查堆是否满,满了则扩容
if (isFull()) {
this.elem = java.util.Arrays.copyOf(this.elem, this.elem.length * 2);
}
// 2. 将元素放到数组末尾
this.elem[usedSize] = val;
this.usedSize++;
// 3. 向上调整,恢复小根堆性质
shiftUp(usedSize - 1);
}
/**
* 向上调整(入队时用)
* @param child 最后一个元素的下标(需要调整的节点)
*/
private void shiftUp(int child) {
// 找到父节点下标
int parent = (child - 1) / 2;
while (child > 0) {
// 1. 比较孩子和父节点:孩子更小则交换
if (elem[child] < elem[parent]) {
int temp = elem[child];
elem[child] = elem[parent];
elem[parent] = temp;
// 2. 继续向上调整
child = parent;
parent = (child - 1) / 2;
} else {
// 父节点更小,满足小根堆,退出
break;
}
}
}
/**
* 判断堆是否已满
* @return 满返回true,否则false
*/
public boolean isFull() {
return this.usedSize == this.elem.length;
}
/**
* 出队:删除堆顶元素(优先级最高的元素,小根堆堆顶是最小值)
*/
public void pollHeap() {
// 1. 检查堆是否为空
if (isEmpty()) {
throw new RuntimeException("优先级队列为空,无法出队!");
}
// 2. 交换堆顶元素和最后一个元素
int temp = elem[0];
elem[0] = elem[usedSize - 1];
elem[usedSize - 1] = temp;
// 3. 有效元素个数减1
usedSize--;
// 4. 从根节点开始向下调整,恢复小根堆
shiftDown(0, usedSize);
}
/**
* 判断堆是否为空
* @return 空返回true,否则false
*/
public boolean isEmpty() {
return this.usedSize == 0;
}
/**
* 获取堆顶元素(小根堆堆顶是最小值)
* @return 堆顶元素
*/
public int peekHeap() {
if (isEmpty()) {
throw new RuntimeException("优先级队列为空,无法获取堆顶元素!");
}
return elem[0];
}
}
二、堆的核心特性
-
结构特性:完全二叉树,数组存储,父子节点下标可通过公式快速计算。
-
堆序特性:大根堆堆顶为最大值,小根堆堆顶为最小值,这是堆应用的核心依据。
-
高效性:插入、删除、获取堆顶元素的时间复杂度均为O(logn),优于普通数组和链表(插入删除O(n))。
-
不稳定性:堆操作过程中元素的相对位置可能发生变化,属于不稳定的数据结构。
三、堆的常用接口
以Java中的PriorityQueue为例(Java中默认是小根堆,若要实现大根堆需自定义比较器),常用接口如下:
-
add(E e) / offer(E e):插入元素到堆中,add会抛出异常,offer返回布尔值表示是否成功。
-
remove() / poll():删除并返回堆顶元素,remove无元素时抛出异常,poll返回null。
-
peek():获取堆顶元素(不删除),无元素时返回null。
-
size():返回堆中元素个数。
-
isEmpty():判断堆是否为空。
补充:Java中无单独的"堆"类,PriorityQueue本质是基于堆实现的优先级队列,底层存储结构为数组,支持动态扩容。
四、堆的经典应用:TOP-K问题(最大/最小的k个元素)
TOP-K问题是堆最核心、最高频的应用,无论是笔试还是面试都大概率遇到,核心思路是"用小根堆找最大k个元素,用大根堆找最小k个元素",避免全量排序(时间复杂度更低)。
- 问题描述
给定一个海量数据集合(数据量可能极大,无法一次性加载到内存),找出其中最大的k个元素(或最小的k个元素)。
-
核心思路(以找最大的k个元素为例)
-
初始化一个容量为k的小根堆。
-
遍历数据集合,依次将元素插入小根堆:
-
若堆中元素个数小于k,直接插入。
-
若堆中元素个数等于k,比较当前元素与堆顶元素:若当前元素大于堆顶元素,删除堆顶元素,插入当前元素;否则跳过当前元素。
- 遍历结束后,小根堆中的k个元素就是整个集合中最大的k个元素。
原理:小根堆的堆顶是堆中最小的元素,通过不断淘汰比当前元素小的堆顶,最终堆中留存的就是最大的k个元素。找最小的k个元素则用大根堆,堆顶是堆中最大的元素,淘汰比当前元素大的堆顶。
- 时间复杂度
遍历n个元素,每个元素插入/删除堆的操作是O(logk),整体时间复杂度为O(nlogk)。相较于全量排序的O(nlogn),当k远小于n时(如k=10,n=10⁶),堆的解法效率优势极为明显,且适合海量数据(无需加载全部数据,逐批处理即可)。
- 代码示例(Java)
java
import java.util.PriorityQueue;
// 找数组中最大的k个元素
public class TopK {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) return new int[0];
// 初始化小根堆(Java默认)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : arr) {
if (minHeap.size() < k) {
minHeap.offer(num);
} else {
// 比较当前元素与堆顶,大于堆顶则替换
if (num > minHeap.peek()) {
minHeap.poll();
minHeap.offer(num);
}
}
}
// 转换为数组返回
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = minHeap.poll();
}
return result;
}
public static void main(String[] args) {
TopK solution = new TopK();
int[] arr = {3,2,1,5,6,4};
int k = 2;
int[] topK = solution.getLeastNumbers(arr, k);
for (int num : topK) {
System.out.print(num + " "); // 输出:5 6
}
}
}
五、三种比较方式在堆中的使用
堆的核心是元素间的大小比较,在Java中,常用的三种比较方式分别是Object.equals、Comparable.compareTo、Comparator.compare,三者适用场景不同,在堆中主要用于定义元素的堆序性。
- Object.equals
作用:判断两个对象是否相等,返回布尔值。不直接用于堆的大小比较,仅在需要判断堆中是否存在某个元素时使用(如自定义堆时的查找逻辑)。
注意:重写equals时需同时重写hashCode,保证相等对象的哈希值一致。
- Comparable.compareTo(自然排序)
作用:让元素自身具备可比较性,通过实现Comparable接口并重写compareTo方法,定义元素的"自然顺序"。
使用场景:当元素的比较规则固定时(如整数从小到大、字符串按字典序),适合用这种方式。在堆中,元素会按照compareTo定义的顺序构建堆。
示例(自定义对象实现自然排序,用于小根堆):
// 自定义Student类,按年龄从小到大排序(自然排序)
class Student implements Comparable {
private String name;
private int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
// 重写compareTo:返回负数表示this < o,0表示相等,正数表示this > o
@Override
public int compareTo(Student o) {
return this.age - o.age;
}
// getter/setter省略
}
// 此时PriorityQueue默认是小根堆(按年龄升序)
PriorityQueue heap = new PriorityQueue<>();
- Comparator.compare(定制排序)
作用:不修改元素类本身,通过外部自定义Comparator接口,实现灵活的比较规则(可覆盖自然排序)。
使用场景:当元素无法修改(如JDK自带类)、或需要多种比较规则时(如有时按年龄排序,有时按姓名排序),适合用这种方式。在堆中,可通过PriorityQueue的构造器传入Comparator,定义堆的排序规则。
示例(自定义比较器,实现大根堆):
// 方式1:匿名内部类
PriorityQueue maxHeap1 = new PriorityQueue<>(new Comparator() {
@Override
public int compare(Integer o1, Integer o2) {
// 返回o2 - o1表示降序(大根堆),o1 - o2表示升序(小根堆)
return o2 - o1;
}
});
// 方式2:Lambda表达式(简洁写法)
PriorityQueue maxHeap2 = new PriorityQueue<>((o1, o2) -> o2 - o1);
- 三者在堆中的使用优先级
当同时存在Comparator和Comparable时,Comparator优先级更高(外部定制排序覆盖自然排序)。equals仅用于相等性判断,不参与堆的排序逻辑。
六、总结与练习建议
- 核心总结
堆的核心价值在于"高效获取最值",其所有操作都围绕"维持完全二叉树结构"和"堆序性"展开,依赖向上/向下调整两个核心动作。记住三个关键:数组存储、O(logn)增删、堆顶为最值。而TOP-K问题是堆应用的精髓,务必掌握其"用小根堆找最大k个,大根堆找最小k个"的思路,理解为什么这种解法高效。
- 练习建议
堆的知识点不算复杂,但灵活度高,尤其是在结合自定义对象、多种比较规则、海量数据场景时,需要通过大量练习巩固。重点建议:
-
多做TOP-K变体题:如"前k个高频元素""数据流中的第k大元素""最小的k个数"等,熟练掌握堆的构建和调整逻辑。
-
手动实现堆:不要依赖PriorityQueue,自己写一个堆类,实现插入、删除、堆化等方法,深入理解底层原理。
-
结合比较方式练习:自定义对象,分别用Comparable和Comparator构建堆,熟悉两种排序方式的使用场景。
堆是后续学习排序算法(堆排序)、图算法(最短路径)的基础,打好这部分基础,后续复杂算法的学习会更轻松。大家多动手敲代码,多思考不同场景下的堆应用,慢慢就能熟练掌握啦!
如果有疑问或补充,欢迎在评论区交流~ 一起进步!✨