【前言】堆有很多种存储方式,二叉堆是其中一种,下面我们所说的堆均默认为二叉堆,即基于二叉树的堆
目录
1.堆
1.1堆的特点
- 首先堆在逻辑上是一棵完全二叉树
- 其次,一般物理上用顺序表(数组)来存储
- 根据堆节点间大小关系的不同,堆又分为大根堆和小根堆两种
大根堆:也叫最大堆,堆中根节点值一定>=子树节点值
小根堆:也叫最小堆,堆中根节点值一定<=子树节点值
【注意】节点的大小关系与所处层次无关,不能肯定的说低层次的节点值一定比高层次的节点值大或小。如下图,是一个小根堆,满足任意一个根节点都比子树节点小,但是,第二层的右节点值56可以比第三层的不同子树的节点值25大
1.2堆的内部存储实现
因为堆逻辑上是一棵二叉树,所以通常我们都用顺序表(也就是数组)来存储,原因有以下两点:
(1)完全二叉树,用顺序表存储,并不会导致诸如空节点等存储空间浪费的问题
(2)依据二叉树的节点编号规律,我们可以根据节点编号快速访问到某节点的父节点或子节点,而顺序表的索引就可以很好的实现这一点,索引下标相当于二叉树的节点编号,直接根据索引快速访问到父节点和左右子节点
【注意】对于不是完全二叉树的其他树,则一般不建议使用数组存储,因为会有空间的浪费
1.3自己实现一个最大堆
(ps:该最大堆是基于整型的,顺序表存储的,Java实现的最大堆)
(1)内部变量 和 构造函数
public class My_Max_Heap {
List<Integer> data;
// 无参构造
public My_Max_Heap() {
data = new ArrayList<>(100);
}
// 有参构造
public My_Max_Heap(int size) {
// 顺序表存储,既无空间浪费又便于找父、左右子节点的索引位置
data = new ArrayList<>(size);
}
}
(2)查找左右子节点、父节点(该实现方法节点编号从0开始)
(返回左右子节点、父节点的索引下标)
/**
* 根据子节点找父节点
*
* @param k 子节点索引下标
* @return 父节点索引下标
*/
public int parent(int k) {
return (k - 1) >> 1;
}
// 找左节点索引
public int leftChild(int k) {
return (k << 1) + 1;
}
// 找右节点索引
public int rightChild(int k) {
return (k << 1) + 2;
}
(3)判空及输出函数
// 判空
public boolean isEmpty() {
return data.isEmpty();
}
// 输出
public String toString() {
return data.toString();
}
(4)向最大堆中添加元素 add(val)方法
思路:
- 先将该元素添加到数组末尾,即二叉树最后一个元素
- 然后从该元素开始进行元素的上浮操作
上浮操作是堆这里很重要的一个操作,那么,怎么上浮呢?
节点如果比父节点值大,说明不满足大根堆,交换该结点与父节点的位置,交换过后接着继续和上一级父节点比较,直至满足大根堆要求
代码实现:
/**
* 在最大堆中在合适位置插入元素,使其仍然为最大堆
*
* @param val 待插入的元素值
*/
public void add(int val) {
// 在数组最后添加元素
data.add(val);
// 然后进行上浮操作
siftUp(data.size() - 1);
}
/*
节点的上浮操作
*/
private void siftUp(int k) {
// 当未走到根节点并且父节点小于该节点时,交换与根节点的位置
while (k > 0 && data.get(parent(k)) < data.get(k)) {
swap(parent(k), k);
k = parent(k);
}
}
// 交换
private void swap(int i, int j) {
int temp = data.get(i);
data.set(i, data.get(j));
data.set(j, temp);
}
(5)查询最大堆中的最大元素
最大元素即为根节点,即为顺序表的第一个元素,直接返回索引下标为0的元素即可(注意判空)
/**
* 查询最大的元素
*
* @return 返回最大的元素
*/
public int peek() {
if (isEmpty()) {
throw new NoSuchElementException("heap is null!!!can not peek");
}
return data.get(0);
}
(6)取出最大堆中的最大元素
【注】不断取出最大堆的最大元素,得到数据的降序排序
即返回最大值并删除该结点,关键是如何删除
删除根节点,并且要使删除后的堆仍为最大堆,如何实现呢?
- 先将数组末尾元素移至首位,即将最后一个元素移动为根节点
- 然后,从根节点开始进行下沉操作
下沉操作:(同上浮操作类似)
对于最大堆而言,先确定左右子节点中的最大值,然后判断根节点是否比这个最大值大,如果是,则满足最大堆,否则,根节点小于子节点,需将根节点与这个最大值交换,交换后继续比较,直至走到叶子节点或找到正确位置
代码实现:
/**
* 取出最大堆中最大的那个元素(保持取出后仍为最大堆)
*/
public int extractMax() {
// 凡是取值都要注意判空
if (isEmpty()) {
throw new NoSuchElementException("heap is null!!!can not extractMax");
}
int a = data.get(0);
// 先将最后一个元素移至首位根,再从首位根开始进行元素下沉操作,通过下沉使依然保持大根堆
int b = data.get(data.size() - 1);
data.set(0, b);
data.remove(data.size() - 1);
siftDown(0);
return a;
}
/*
节点的下沉操作
*/
public void siftDown(int k) {
// 当还未走到叶子节点
while (leftChild(k) < data.size()) {
int j = leftChild(k);
// 判断是否有右子树,选左、右子树中大的那个下沉
if ((j + 1) < data.size() && data.get(j + 1) > data.get(j)) {
j = j + 1;
}
// 此时j是左右子树中大的那个
// 如果当前元素小于左右子树中大的那个,则二者交换,否则break
if (data.get(k) < data.get(j)) {
swap(k, j);
k = j;
} else {
break;
}
}
}
// 交换
private void swap(int i, int j) {
int temp = data.get(i);
data.set(i, data.get(j));
data.set(j, temp);
}
(7)建堆(即将任意数据堆化)
两种方法:
一种方法是将数据不断进行堆的add操作,当数据较多时,不易操作;
第二种方法:
任意一个数组逻辑上都可以看作是一棵完全二叉树,只是可能还不太满足堆的特性,为此,我们需要调整内部元素的排列顺序,使其逻辑上是最大堆或最小堆。
以最大堆为例,调整顺序时,我们只需要从完全二叉树的最后一个非叶子节点开始对每个结点进行下沉操作,即可得到最大堆
以数组【1,5,3,8,7,6】为例,该数组默认的完全二叉树如下图:
从最后一个非叶子结点3开始进行下沉操作:
细节:最后一个非叶子结点即为最后一个元素的父节点
代码实现:
// 本方法是从最后一个非叶子节点开始进行下沉操作
public My_Max_Heap(int[] arr) {
// 先将长度复制过来
data = new ArrayList<>(arr.length);
for (int a : arr) {
data.add(a);
}
// 从最后一个非叶子结点开始进行下沉操作
for (int i = parent(data.size() - 1); i >= 0; i--) {
siftDown(i);
}
}
/*
节点的下沉操作
*/
public void siftDown(int k) {
// 当还未走到叶子节点
while (leftChild(k) < data.size()) {
int j = leftChild(k);
// 判断是否有右子树,选左、右子树中大的那个下沉
if ((j + 1) < data.size() && data.get(j + 1) > data.get(j)) {
j = j + 1;
}
// 此时j是左右子树中大的那个
// 如果当前元素小于左右子树中大的那个,则二者交换,否则break
if (data.get(k) < data.get(j)) {
swap(k, j);
k = j;
} else {
break;
}
}
}
1.4堆排序
排升序要建大堆;排降序要建小堆
默认排序为升序:
(1)先将给定的数组堆化(最大堆)
(2)堆化后堆顶元素即为最大值,最大值应该在最后一个位置,所以交换堆顶和最后一个元素的位置
(3)交换后,从堆顶元素开始进行下沉操作(下沉至未排序的位置,这里即倒数第二个位置),除最后一个元素外,又是一个大根堆,此时,再将堆顶元素和倒数第二个元素互换,然后再从堆顶开始进行下沉操作……
(4)重复二三步操作,直至已经全部排序完成
栗子:以【3,5,2,4,8】为例
依此类推,直至元素全部落在了正确位置
【性能分析】堆排序的时间复杂度为O(n log n),空间复杂度为O(1),是不稳定的排序
代码实现:
import java.util.Arrays;
//堆排
public class heap_Sort {
public static void heapSort(int[] arr) {
// 先将数组arr堆化,从第一个非叶子节点开始下沉操作
for (int i = (arr.length - 1 - 1) / 2; i >= 0; i--) {
siftDown(arr, i, arr.length);
}
// 此时已是最大堆
for (int i = arr.length - 1; i > 0; i--) {
// 将最大的元素移至最后一个位置,次大的元素移至倒数第二个位置……
swap(arr, 0, i);
// 每次换好一个位置后,将其余元素下沉重新堆化
siftDown(arr, 0, i);
}
}
// 交换
private static void swap(int[] arr, int index1, int index2) {
int temp = arr[index1];
arr[index1] = arr[index2];
arr[index2] = temp;
}
/**
* 下沉操作(大根堆)
*
* @param arr
* @param i 开始下沉的位置
* @param length 下沉的长度
*/
public static void siftDown(int[] arr, int i, int length) {
// 当还有子节点时
while ((2 * i + 1) < length) {
int j = 2 * i + 1;
if (j + 1 < length && arr[j + 1] > arr[j]) {
j = j + 1;
}
if (arr[i] < arr[j]) {
swap(arr, i, j);
i = j;
} else {
break;
}
}
}
public static void main(String[] args) {
int[] arr = {1,7,3,5,2};
heapSort(arr);
System.out.println(Arrays.toString(arr));
}
}
输出结果:
[1, 2, 3, 5, 7]
2.优先级队列
2.1何为优先级队列
首先,普通队列是基于链表实现的,遵循先进先出原则,而
- 优先级队列则不再是遵循先进先出原则,而是优先级高的元素先出队
- 优先级队列看起来是队列,实则底层是基于堆实现的(JDK中的优先级队列默认是基于最小堆实现的)
- JDK中的优先级队列:PriorityQueue<>(),(基于小根堆实现)
优先级队列 VS 普通队列
普通队列 优先级队列 内部实现 基于链表 基于堆 入队的时间复杂度 O(1) O(log N) 最大元素出队的时间复杂度 O(N) O(log N)
2.2自定义优先级
如果优先级队列中存储的是自定义的类,或者我们想要按照自己的希望定义优先级,则需要对优先级进行自定义
自定义方法有三种方法:
一:实现Comparable接口,覆写compareTo方法(java.lang.Comparable)
//方法一,实现Comparable接口
class Student implements Comparable<Student> {
int age;
String name;
int sum;
public Student(int age, String name, int sum) {
this.age = age;
this.name = name;
this.sum = sum;
}
public int compareTo(Student o) {
return this.age - o.age;
}
public String toString() {
return this.name + " " + this.age + " " + this.sum;
}
}
二:有时候我们希望是升序有时候又希望是降序,所以,还有另一种方法,新建类,实现Comparartor接口,作为比较器(java.util.Comparator)
public static void main(String[] args) {
// 方法二的使用
// 使用升序比较器
Queue<Student> q2 = new PriorityQueue<>(new StudentAgeOn());
q2.offer(new Student(2, "a", 100));
q2.offer(new Student(8, "g", 88));
q2.offer(new Student(100, "x", 1));
System.out.println(q2.poll());
// 使用降序比较器
Queue<Student> q3 = new PriorityQueue<>(new StudentAgeDown());
q3.offer(new Student(2, "a", 100));
q3.offer(new Student(8, "g", 88));
q3.offer(new Student(100, "x", 1));
System.out.println(q3.poll());
}
//方法二,新建类,实现Comparartor接口,作为比较器
//StudentOn -按年龄大小顺序
class StudentAgeOn implements Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o1.age - o2.age;
}
}
//StudentDown - 按年龄大小倒序
class StudentAgeDown implements Comparator<Student> {
@Override
public int compare(Student o1, Student o2) {
return o2.age - o1.age;
}
}
三:最后一种是函数式编程方法,如下:
Queue<Student> mypri1 = new PriorityQueue<>(((o1, o2) -> o1.age - o2.age));
2.3优先级队列的实现
基于上面自己实现的大根堆,我们继续实现优先级队列
/**
* 基于大根堆实现的优先级队列【注意:JDK中的优先级队列默认是基于小根堆实现】
*/
public class My_PriorityQueue_Heap {
My_Max_Heap priorityQueue;
// 无参构造
public My_PriorityQueue_Heap() {
priorityQueue = new My_Max_Heap();
}
// 入队
public void offer(Integer val) {
priorityQueue.add(val);
}
// 队首元素
public Integer peek() {
return priorityQueue.peek();
}
// 出队
public Integer poll() {
return priorityQueue.extractMax();
}
// 判空
public boolean isEmpty() {
return priorityQueue.isEmpty();
}
//
public String toString(){
return priorityQueue.toString();
}
}
2.4JDK中的优先级队列
PriorityQueue implements Queue
2.5TopK问题
优先级队列的一个典型应用就是Top K问题
那么什么是Top K问题呢,通俗点说就是找前k个元素,可以是前K个最大的也可以是前k个最小的
前提核心是:
取大用小(取前k个最大的,则建小根堆)
取小用大(取前k个最小的,建立大根堆)
用优先级队列解,时间复杂度仅为O(n log k), 而正常排序的做法,时间复杂度最好也为
O(n long n),k必然是<=n,所以,用优先级队列解是最好的
优先级队列解TopK(以取前3个最大元素为例):
底层思路:
(1)建一个长度为3的优先级队列(最小堆)
(2)先按顺序入队,队入满
(3)再入第四个元素时,开始与根节点打擂
- 因为根节点是优先级队列中最小的,如果第四个元素小于根结点,说明这第四个元素小于优先级队列里的3个元素,说明这第四个元素肯定不在前三元素里,直接continue
- 如果第四个元素比根节点值大,则将根节点出队,让第四个元素入队
(4)重复第三步操作,直至遍历完数据
更为简单的理解:
先不断入队,当对内元素大于k时,出队
伪代码:
// 取前k个
for(Map.Entry<Integer,Integer> entry: map.entrySet()) {// 先入队
priorityQueue.add(new Freg(entry.getKey(),entry.getValue()));//当大于k时,出队
if(priorityQueue.size() > k){
priorityQueue.poll();
}
}
实战演练:
(1)347. 前 K 个高频元素 - 力扣(LeetCode) (leetcode-cn.com)
给你一个整数数组 nums
和一个整数 k
,请你返回其中出现频率前 k
高的元素。你可以按 任意顺序 返回答案。
【细节】
按出现频率,所以我们要用map存储元素及其出现次数;
使用优先级队列要重定义优先级,可以借助辅助类,新定义一个类,类中变量即为元素及出现次数,覆写compareTo方法
题解:
class Solution {
//借助自定义类来存储元素及其出现次数,并实现Comparable接口
class Freg implements Comparable<Freg>{
private int num;
private int times;
public Freg(int num,int times){
this.num = num;
this.times = times;
}
@Override
public int compareTo(Freg o) {
return this.times - o.times;
}
}
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> map = new HashMap<>();
// 将每个数组元素及其对应频数存储到map表中
for (int i = 0; i < nums.length; i++) {
if (map.containsKey(nums[i])) {
map.put(nums[i], map.get(nums[i]) + 1);
} else {
map.put(nums[i], 1);
}
}
// 然后使用优先级队列,取前k个
Queue<Freg> priorityQueue = new PriorityQueue<>();
// 取前k个
for(Map.Entry<Integer,Integer> entry: map.entrySet()) {
priorityQueue.add(new Freg(entry.getKey(),entry.getValue()));
if(priorityQueue.size() > k){
priorityQueue.poll();
}
}
int[] res = new int[k];
for (int i = 0; i < k; i++) {
res[i] = priorityQueue.poll().num;
}
return res;
}
}
其他TopK问题还有:
面试题 17.14. 最小K个数 - 力扣(LeetCode) (leetcode-cn.com)
373. 查找和最小的 K 对数字 - 力扣(LeetCode) (leetcode-cn.com)
692. 前K个高频单词 - 力扣(LeetCode) (leetcode-cn.com)
165. 比较版本号 - 力扣(LeetCode) (leetcode-cn.com)
3.小结
关于堆以及优先级队列,重点应掌握以下内容:
(1)最大堆最小堆的实现(重点是堆的上浮、下沉操作)
(2)掌握堆排序
(3)灵活运用优先级队列解决问题,灵活变通定义优先级(三种方法:实现Collection接口覆写compareTo方法、自定义比较器类、函数式编程)
(4)能够用优先级队列解决TopK问题