目录
一、堆的概念
1.堆是一个完全二叉树
2.堆分为大根堆和小根堆。
3.堆与优先级队列的关系
二、堆操作
1.向下调整
2.删除堆顶元素
3.添加新元素
4.构建堆
A:自底向上构建
B:自顶向下构建
C:两种方式对比
三、尝试自己编程实现堆
1.初始化堆和一些常用方法
2.向下调整
3.向上调整
4.动态扩容
5.添加元素和删除元素
四、Java中堆常用接口
1.构造方法
2.常用方法
五、堆的应用
一、堆的概念
1.堆是一个完全二叉树
a:什么是完全二叉树?
我们把二叉树的节点按顺序定义为:1、2、3、4、5.....那么它的结构就要是:
意思就是不能跳过某一节点构建树,要按顺序。
b:正因为它是完全二叉树,我们可以用连续的数组模拟二叉树。
使用数组更方便操作元素,例如交换。
c:如何用数组表示完全二叉树?
根据二叉树的性质:节点的索引和子节点的索引有如下关系:
举个例子:对应值为3的节点,其对应的索引i=3-1=2,
其双亲节点:parent=(3-1)/2=1/2=0 ,索引0对应值为1的节点 √
其左节点:left=2*2+1=5,索引5对应值为6的节点√
其右节点:right=2*2+2=6,索引6超出数组范围,其右节点不存在√
我们拿索引,计算出来的也是索引
2.堆分为大根堆和小根堆。
大根堆:根节点是所有元素的max,且每个子树也必须满足最大堆的性质。
小根堆:根节点是所有元素的min ,且每个子树也必须满足最小堆的性质。
3.堆与优先级队列的关系
我们已经知道,队列是先进先出。但是我们想让优先级更高的先出呢?于是有了优先级队列,其中每个元素都有一个关联的优先级。优先级高的元素比优先级低的元素先出队。在优先级队列中,元素的出队顺序与它们进入队列的顺序无关,而是由优先级决定。
而优先级队列的底层实现可以用堆这种结构,得益于堆顶总是优先级最高的元素。
二、堆操作
在学构建堆之前,我们要先学习向下调整(Heapify Down),这是构建堆时的重要操作。
1.向下调整
概念:
向下调整(Heapify Down),是堆中的一种操作,用于维持堆的性质。当堆中的一个节点可能违反了堆的性质时,向下调整通过将该节点与其子节点比较,并根据堆的类型进行必要的交换,使得该节点以及整个堆重新符合堆的性质。
概念很生硬哈,我来举例子~~
可以看到,当左右子树已经实现堆结构时,向下调整效率很高~~
这里先不用纠结那么多,先理解这个操作就好,关于构建堆,下面会讲。
向下调整可以比喻为,某个元素的位置“德不配位”,站的太高了,我们要把它下沉,至于会降到哪里,就看它有多少水分了,挤干水分,回到它本应该在的位置。
时间复杂度分析:
对于一次向下调整,最坏的情况如例子所示,从根一路比较到叶子,比较的次数为完全二叉树的高度,即时间复杂度为 O(logN)。
2.删除堆顶元素
基本思想:
a.将最后一个元素移到堆顶:
将堆中的最后一个元素(数组中的最后一个元素)移到堆顶位置(数组的第一个位置),原来的堆顶元素已被覆盖。此时,堆的结构是:除了根节点,子树都符合堆结构。(很熟悉是不是,此时只需将堆顶元素向下调整即可~~)
b.将堆顶元素向下调整:
对新放到堆顶的元素进行“向下调整”(Heapify Down)操作,确保堆的性质得以维护。
为什么是用最后一个元素?
将最后一个元素移到堆顶并进行向下调整是删除堆顶元素的最佳策略,因为它既保持了完全二叉树的结构,也只需将新的堆顶元素向下调整即可。效率最高。
时间复杂度分析:
从堆顶开始向下调整,最坏情况可到O(log2)
3.添加新元素
基本思想:
将新元素放在堆的最后一个节点后面(若数组位置不够需扩容)。
- 将新插入的元素进行向上调整,直到所有元素符合堆结构。
举例子:
个人理解:
向上调整跟向下调整其实差不多,只是观察的视角不一样。 在原本堆结构完善的情况下,从尾部进行向上调整,是很高效的,这一点与向下调整很像。一个元素的向下调整,其实就是另一个元素的向上调整。反之亦然。
时间复杂度分析:
时间复杂度为:O(logn)。因为从堆底开始调整,可能到达堆顶,最多需要经过堆的高度次操作,而堆的高度为 logn。
4.构建堆
A:自底向上构建
核心思想:
构建堆的核心在于将数组看作一个完全二叉树,然后从最后一个非叶子节点开始,逐步对每个节点执行“向下调整”操作,以确保每个节点都符合堆的性质。
为什么从最后一个非叶子节点开始?
1. 叶子节点无需调整
叶子节点没有子节点,因此它们天然符合堆的性质,无需调整。所以,处理这些节点是没有意义的,应该直接跳过。
2. 如果从根节点开始调整?
举个栗子:假设在建大根堆,而整个树只有一个最大值,而最大值刚好在该二叉树的最后一层!此时,若从根节点开始调整,第一次调整,无法保证最大值到达堆顶。且最大值,也还在堆底,没有靠近堆顶。
并且后续如果要让处在最后一层的最大值达到堆顶位置,还会反复破坏先前梳理好的结构。效率太低,因此我们不采用从根节点开始调整。
3. 那么为什么是最后一个非叶子节点?
个人理解:假设在建大根堆,从最后一个非叶子节点开始调整,等于自底向上调整,如果有大的元素,就可以让它及时浮上来,后面就可以逐级上升~早点回到它该属于的位置。
如果我们从最后一个非叶子节点开始向上调整,这样可以确保在处理后续每一个节点时,它的子节点已经是符合堆性质的。效率高。
举个例子~~
时间复杂度分析:
结论:构建堆的时间复杂度是 O(n),这里的
n
是数组中元素的数量。虽然每次调用heapify
操作的最坏时间复杂度是 O(logn),但由于堆的结构,在构建堆的过程中,并不是每个元素都需要向上调整到根节点,调整次数随深度递减。推导:
B:自顶向下构建
我们学了插入元素操作,那是不是可以往一个空树里,一个一个元素插入,构建出一个堆~~
基本思想:
自上而下的构建方法,也称为“逐步插入法”,是从堆的根节点开始,一次插入一个元素,并在每次插入后进行向上调整(Heapify Up),直到所有元素都被插入为止。
时间复杂度分析:
结论:自上而下构建堆的时间复杂度为 O(nlogn)。
分析:在最坏情况下,每次插入元素时都需要进行 O(logn)的向上调整,而总共有 n 个元素需要插入到堆中。
C:两种方式对比
虽然自上而下构建堆的方法比较直观,但其时间复杂度较高,尤其是对于大规模数据,自下而上的构建方法(时间复杂度 O(n))更为高效。
三、尝试自己编程实现堆
1.初始化堆和一些常用方法
public class MaxHeap {
private int[] heap;
private int size;//当前数量
private int capacity;//当前最大容量
// 初始化
public MaxHeap(int capacity) {
this.heap = new int[capacity];
this.size = 0;
this.capacity = capacity;
}
// 取索引
// 父节点
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;
}
// 交换堆内两个元素
private void swap(int i, int j) {
int temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
2.向下调整
//向下调整
private void heapifyDown(int i) {
int largeIndex = i;
int left = leftChild(i);
int right = rightChild(i);
//要找到左右节点中更大的元素
if (left < size && heap[left] > heap[largeIndex]) {
largeIndex = left;
}
if (right < size && heap[right] > heap[largeIndex]) {
largeIndex = right;
}
if (largeIndex != i) {
swap(i, largeIndex);
heapifyDown(largeIndex);
}
}
3.向上调整
//向上调整
private void heapifyUp(int i) {
if (i <= 0) {
return;
}
int parent = parent(i);
if (heap[i] > heap[parent]) {
swap(i, parent);
heapifyUp(parent);
}
注意:在
heapifyUp
和heapifyDown
中递归调用时,可能会导致堆栈溢出(特别是对于大的堆),可以考虑用循环代替递归。
4.动态扩容
//动态扩容
private void enlargeCapacity() {
//每次都是二倍扩容
if (this.size == capacity) {
int[] newHeap = new int[this.capacity * 2];
System.arraycopy(heap, 0, newHeap, 0, size);
this.heap = newHeap;
this.capacity = this.capacity * 2;
}
}
System.arraycopy 是 Java 中一个用于高效地复制数组元素的方法。它通过底层的本地代码进行操作,比普通的循环复制更高效。
public static void arraycopy(Object src, int srcPos, Object dest, int destPos, int length)
参数含义:1.src:Object类型。源数组,即要从哪个数组中复制元素。
2.srcPos:int类型。源数组的起始位置,从该位置开始复制元素。
3.dest:Object类型。目标数组,即要将元素复制到哪个数组。
4.destPos:int类型。目标数组的起始位置,从该位置开始放置复制过来的元素。
5.length:int类型。要复制的元素个数,即从源数组中复制多少个元素到目标数组中
5.添加元素和删除元素
//添加元素
private void add(int num) {
if (size == capacity) {
enlargeCapacity();
}
heap[size++] = num;
heapifyUp(size - 1);
}
//删除元素
private int poll() {
if(size==0){
return -1;
}
int temp = heap[0];
heap[0] = heap[size - 1];
size--;
heapifyDown(0);
return temp;
}
四、Java中堆常用接口
Java集合框架中提供了PriorityQueue和PriorityBlockingQueue两种类型的优先级队列,PriorityQueue是线程不安全的,PriorityBlockingQueue是线程安全的。以下介绍的是PriorityQueue。
1.构造方法
2.常用方法
五、堆的应用
1.优先队列:
堆通常用于实现优先队列,能够以高效的方式处理插入元素和取出最大或最小元素的操作。最大堆可以用于处理最大优先级队列,而最小堆则用于最小优先级队列。
2.排序算法:
堆排序(Heap Sort)是一种基于堆的排序算法。它利用最大堆或最小堆的特性,每次取出堆顶元素(最大或最小值),然后重新调整堆。堆排序的时间复杂度为 O(nlogn)。
3.图算法:
Dijkstra算法:用于计算单源最短路径。堆用作优先队列来快速获取当前未访问节点中距离最小的节点。
Prim算法:用于计算最小生成树。堆用作优先队列来选择权重最小的边。
4.动态数据集合:
在一些动态数据集合的应用中,如实时数据流处理,需要快速获取最大值或最小值,堆是理想的选择。通过堆可以高效维护一个动态的、部分排序的数据集合。
5.Top-K 问题:
堆也常用于解决Top-K问题,比如从海量数据中找出前K大的元素。通常使用一个大小为K的小根堆来维护当前的前K个最大元素。
使用小顶堆的原因是,它允许我们在每次遇到更大的元素时,迅速替换掉当前堆中最小的元素,这样最终堆中保留的就是前K大的元素。