一.堆(heap)的概念
在Computer Science中,堆(Heap)是一个重要的概念,主要有两层含义
1.数据结构中的堆
1.是一种特殊的二叉树
堆是一种特殊的树形结构,通常是一棵完全二叉树(满二叉树的一种特例,满二叉树是每个节点要么有2个子节点要么就没有子节点,而完全二叉树是除了最后一层,其他层都是满的,即其他层每个节点的父节点都有两个孩子,并且完全二叉树的最后一层的节点要靠左排列)
类型:
| 类型 | 特性 |
|---|---|
| 最大堆(Max Heap) | 每个节点的值都大于或等于其子节点的值,根节点是最大值 |
| 最小堆(Min Heap) | 每个节点的值都小于或等于其子节点的值,根节点是最小值 |
最大堆:
[90] <-- 最大值在根部
/ \
[50] [80]
/ \ / \
[30] [40] [70] [60]
最小堆:
[30] <-- 最小值在根部
/ \
[40] [60]
/ \ / \
[50] [90] [70] [80]
2.堆的存储
堆在逻辑上是树形结构,但在计算机内存中实际使用数组存储,这种巧妙的映射是堆高效的关键
核心映射关系:
假设有一个最小堆,包含元素:[10, 20, 30, 40, 50, 60, 70]
逻辑视图(从索引0开始存储数据,也可以从1开始,但是找父子关系的时候规律要稍微变化):
索引: 0
[10]
/ \
索引: 1 / \ 索引: 2
[20] [30]
/ \ / \
索引: 3 / \4 /5 \6
[40] [50][60] [70]
物理视图(数组存储):
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ ← 索引
├─────┼─────┼─────┼─────┼─────┼─────┼─────┤
│ 10 │ 20 │ 30 │ 40 │ 50 │ 60 │ 70 │ ← 值
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
索引计算公式:
对于数组中任意索引i的节点:
| 关系 | 公式 | 示例 (i=1) |
| 父节点 | (i - 1) // 2 | (1-1)//2 = 0 | // 代表下取整
| 左孩子 | 2 * i + 1 | 2×1+1 = 3 |
| 右孩子 | 2 * i + 2 | 2×1+2 = 4 |
内存布局示意:
内存地址(简化): 0x1000 0x1004 0x1008 0x100C 0x1010 0x1014 0x1018
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ 10 │ 20 │ 30 │ 40 │ 50 │ 60 │ 70 │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┘
↑ ↑
堆顶 堆尾
(最小值) (最后元素)
连续存储:数组在内存中是连续的,这使得缓存友好,访问速度快
3.常见应用
- 优先队列(Priority Queue)
- 堆排序算法
- 图算法中的最短路径(如Dijkstra算法)
- 任务调度系统
2.内存管理中的堆
在程序内存管理中,堆是指动态内存分配区域:
特点:
| 特性 | 说明 |
|---|---|
| 分配方式 | 程序员手动分配和释放(如C/C++中的 malloc/free、new/delete) |
| 生命周期 | 由程序员控制,不会自动释放 |
| 访问速度 | 比栈慢,但更灵活 |
| 大小限制 | 通常比栈大得多 |
与栈的区别:
┌─────────────┐
│ 栈 │ ← 自动管理,存放局部变量
│ (Stack) │
├─────────────┤
│ 堆 │ ← 手动管理,存放动态分配的对象
│ (Heap) │
└─────────────┘
3.两种不同概念的堆的总结
| 方面 | 数据结构堆 | 内存管理堆 |
|---|---|---|
| 本质 | 一种树形数据结构 | 内存分配区域 |
| 用途 | 优先队列、排序等 | 动态存储数据 |
| 管理 | 算法自动维护 | 程序员手动管理 |
二.数据结构的堆(Java实现)
堆的实现
java
package algorithm.datastructure.heapdemo;
import java.util.ArrayList;
import java.util.List;
import java.util.NoSuchElementException;
/*public class Heap<T extends Comparable<T>>
* 这是Java泛型中的类型边界语法
* 从左到右:T是泛型类型参数, extends表示类型边界(上界), Comparable<T>表示T可以和自己比较
* 这行代码的含义是:T必须是实现了Comparable<T>接口的类型
* 问题1: 什么是Comparable接口
* Comparable接口是Java用于对象比较的标准接口
* 在IDEA中鼠标移到下面的Comparable上,快捷键Ctrl+B(B代表Base)(或者Ctrl+鼠标左键单击)查看Comparable接口的声明
* public interface Comparable<T> {
* int compareTo(T other); // 这个方法返回int,表示比较结果
* }
* compareTo方法返回值是一个整数,通过正负零表示大小关系
* 返回负数,表示当前对象小于其他对象
* 返回0,表示当前对象等于其他对象
* 返回正数,表示当前对象大于其他对象
* 问题2: 什么是泛型边界
* 泛型边界是Java泛型中用于限制类型参数范围的机制,用于确保泛型类型的安全
* 没有边界,则T可以是任何类型,如:
* class Box<T> {}
* 有边界,则T必须是Number的子类,如:
* class Box<T extends Number>
而extends就是实现泛型边界上界的关键字,还有泛型边界下届super(限制T必须是某类型的子类)、多重边界&(限制T必须同时满足多个条件)
* 问题3: 为什么要用extends而不用implements来实现Comparable接口
* 理解了问题2,就很容易理解问题3
* 在泛型中,无论是类还是接口,类型边界都有extends(用于上边界),这是Java的语法设计
* 泛型边界不能用implements
* 普通类:继承父类用extends、实现接口用implements
* 泛型类型边界:边界是类用extends、边界是接口也用extends(语法统一、语义清晰、基于历史的设计)
* 问题4: 接口可以继承吗
* 可以!接口之间用extends继承
* 问题5: 对问题3提出的问题的纠正
* <T extends Comparable<T>>中:
* T 表示Heap类的参数都是T类型,即Heap存储的元素都是T类型
* 而T extends Comparable<T>是泛型边界 表示T类型只能和同类型T进行比较*/
public class Heap<T extends Comparable<T>> {
private List<T> heap;
// List是Java集合框架中的核心接口,用于存储有序、可重复的元素集合
// 这里用List而没有用具体的类,这是面向接口编程,可以灵活换成LinkedList类等其他实现,且不依赖与具体实现类
private boolean maxHeap; // 堆类型标志,true表示最大堆,false表示最小堆
public Heap(boolean maxHeap) {
// 多态: List<T> heap = new ArrayList<>(); List是引用类型,ArrayList是heap的实际类型
// 不能这样:List<T> heap = new List<>();
// List是接口,而接口不能实例化,且接口没有构造函数,接口中的方法默认是抽象方法(接口的作用是定义实现该接口的类的规范)
this.heap = new ArrayList<> (); // 这里是Java7及之后的写法,使用菱形操作符时可以省略泛型类型,编译器会根据左侧声明的类型自动判断右侧的泛型类型(这里判断为Integer)
this.maxHeap = maxHeap;
}
private int parent(int i) { return (i - 1) / 2; }
private int leftChild(int i) { return 2 * i + 1; }
private int rightChild(int i) { return 2 * i + 2; }
// 根据maxHeap标志选择比较方向
private boolean compare(T a, T b) {
return maxHeap ? a.compareTo(b) > 0 : a.compareTo(b) < 0;
// 三元运算符
// maxHeap为条件判断
// a.compareTo(b) > 0为true时返回true,否则返回false
/*三元运算符运用简化简单的if-else逻辑
* 基本语法:条件表达式 ? 表达式1 : 表达式2
* 说明: 条件表达式是布尔类型的条件,当条件为true时执行表达式1,条件为false时执行表达式2*/
}
private void swap(int i, int j) {
T temp = heap.get(i);
heap.set(i, heap.get(j));
heap.set(j, temp);
}
// 向上调整堆,维护堆的性质
// 当新元素插入到堆末尾时,可能破话堆的性质。这时候就需要将新元素"上浮"到正确位置
// 从而确保堆序性(父节点与子节点的大小关系)不被破坏
private void heapifyUp(int i) {
// i是新插入元素的索引
while (i > 0) { // 只要没到根节点(索引为0)
int p = parent(i); // 获取父节点
// 如果当前节点应该排在父节点前面,则需要交换
if (compare(heap.get(i), heap.get(p))) {
swap(i, p);
i = p; // 更新索引,继续向上检查
} else {
break; // 堆序性以满足,停止调整
}
}
}
// 向下调整堆,维护堆的性质
// 当堆顶元素被删除/替换后,可能破坏堆的性质,需要将新堆顶元素"下沉"到正确位置
private void heapifyDown(int i) {
int n = heap.size();
while (true) { // 无限循环,直到break
int target = i; // 记录当前"最优先"的节点
int left = leftChild(i); // 左孩子索引
int right = rightChild(i); // 右孩子索引
// 如果左孩子存在且应该优先与当前节点
if (left < n && compare(heap.get(left), heap.get(target))) {
target = left; // 更新target为左孩子
}
// 如果右孩子存在其应该优先与当前节点
if (right < n && compare(heap.get(right), heap.get(target))) {
target = right; // 更新target为右孩子
}
// 如果target变了,则需要交换
if (target != i) {
swap(i, target); // 交换当前节点和更优先的孩子
i = target; // 更新索引,继续向下检查
} else {
break; // 堆序性满足,停止调整
}
}
}
public void push(T val) {
heap.add(val);
heapifyUp(heap.size() - 1);
}
public void printAll() {
System.out.println(heap);
}
// 弹出并返回堆顶元素
// 删除堆顶元素之后,问题是如何保持堆的性质
// 方案1:将后续元素全部前移,时间复杂度O(n),太慢了
// 方案2:将最后一个元素放到堆顶,然后"下沉"到正确位置,时间复杂度O(log n)
public T pop() {
if (heap.isEmpty()) {
throw new NoSuchElementException("堆为空");
}
T result = heap.getFirst(); // 获取堆顶元素 等价于 T result = heap.get(0);
heap.set(0, heap.get(heap.size() - 1)); // 用堆底元素替换堆顶元素
heap.remove(heap.size() - 1); // 删除堆底元素 也可以用List的removeLast方法
if (!heap.isEmpty()) {
heapifyDown(0); // 如果堆不为空,向下调整
}
return result; // 返回堆顶元素
}
// 返回堆顶元素
public T peek() {
if (heap.isEmpty()) {
throw new NoSuchElementException("堆为空");
}
return heap.get(0);
}
public int size() {
return heap.size();
}
public boolean isEmpty() {
return heap.isEmpty();
}
}
assert断言
写一个测试类
java
package algorithm.datastructure.heapdemo;
public class HeapTest {
public static void main(String[] args) {
testMinHeap();
}
static void testMinHeap() {
// 最小堆
Heap<Integer> minHeap = new Heap<>(false);
assert minHeap.isEmpty() : "新堆应该为空";
}
}
发现没有任何输出,这是因为Java是默认禁用assert断言功能的
要在IDE即集成开发环境(integrated development environment)中启用这个功能:
| IDE | 设置方法 |
|---|---|
| IntelliJ IDEA | Run → Edit Configurations → VM options → 添加 -ea |
| Eclipse | Run → Run Configurations → Arguments → VM arguments → 添加 -ea |
| VS Code | settings.json → 添加 "java.debug.settings.enableAssertions": true |
其实还有一个小问题,就算启用了assert功能,运行后也什么都不会输出
这是因为当断言的条件为true时,程序会继续指向,不会抛出AssertionError
断言的语法为:
java
assert 条件表达式 : 错误消息;
// 其中条件表达式的结果若为true,则断言通过,否则会抛出AssertionError错误
// 错误消息是可选的,它表示断言失败后会输出的消息
// 如:assert 条件表达式;