图文最详细的堆解析:从二叉树到堆到解析大根堆小根堆,分析堆排序,最后实现topK经典面试问题
Posted 尚墨1111
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了图文最详细的堆解析:从二叉树到堆到解析大根堆小根堆,分析堆排序,最后实现topK经典面试问题相关的知识,希望对你有一定的参考价值。
1 数据结构——堆 Heap
1.0 树
满二叉树:如果二叉树中除了叶子结点,每个结点的度都为 2,则此二叉树称为满二叉树。
完全二叉树:如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。
1.1堆
参考文章:https://www.cnblogs.com/wangchaowei/p/8288216.html
1.1.1 什么是堆
堆是一棵完全二叉树,一般都用数组来表示堆
1)堆结构就是用数组实现的完全二叉树结构
2)大根堆:父节点的值大于或等于子节点的值;
3)小根堆: 父节点的值小于或等于子节点的值
1.1.2 堆的常用方法:
- 构建优先队列
- 堆排序
- 快速找出一个集合中的最小值(或者最大值)
1.2 数组构造大根堆
参考文章:
https://blog.csdn.net/zhizhengguan/article/details/106826270
https://www.cnblogs.com/CherishFX/p/4643940.html
1.2.1 节点与数组索引的对应关系
- 对于k节点,其父节点是 (k-1)/2 (注意: 0节点除外)
- 对于k节点,其两个儿子节点分布是: left = 2*k + 1 ; right = 2 *k + 2;
- 最后一个节点是
arr.length -1
那么最后一个节点的父节点就是最后一个非叶子节点: - 最后一个非叶子节点是
(arr.length - 2)/2;
(取整之后)
因为堆是对父节点-左/右孩子节点之间的约束,所以从最后一个非叶子节点开始调整。每次交换后,都要对下一层的子堆进行递归调整,因为交换后有可能破坏已调整子堆的结构
1.2.2 实际样例
从最后一个非叶子节点开始,下标为arr.length/2-1 = 5/2-1 = 1
,所以是6
从左至右,从下至上进行调整,for(int i=(array.length-2)/2;i>=0;i--)
找到第二个非叶节点4 【3/2 - 1 = 0】,找到【 4 9 8】中最大的元素进行交换。交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6
1.3 Java实现
大根堆两个主要算法:
- 自下往上调整算法: 主要用于插入新元数的时候;自下往上的意思是说,子节点和父节点交换
- 自上往下调整算法: 用于从数组创建一个大根堆,或者删除元素的时候;
1.3.1 自下往上法
//构建大根堆:将array看成完全二叉树的顺序存储结构
private int[] buildMaxHeap(int[] array){
//从最后一个节点array.length-1的父节点(array.length-1-1)/2开始,直到根节点0,反复调整堆
for(int i=(array.length-2)/2;i>=0;i--){
adjustDownToUp(array, i,array.length);
}
return array;
}
//将元素array[k]自下往上逐步调整树形结构,
private void adjustDownToUp(int[] array,int k,int length){
int temp = array[k];
for(int i=2*k+1; i<length-1; i=2*i+1){ //i为初始化为节点k的左孩子,沿节点较大的子节点向下调整
if(i<length && array[i]<array[i+1]){ //取节点较大的子节点的下标
i++; //如果节点的右孩子>左孩子,则取右孩子节点的下标
}
if(temp>=array[i]){ //根节点 >=左右子女中关键字较大者,调整结束
break;
}else{ //根节点 <左右子女中关键字较大者
array[k] = array[i]; //将左右子结点中较大值array[i]调整到双亲节点上
k = i; //【关键】修改k值,以便继续向下调整
}
}
array[k] = temp; //被调整的结点的值放入最终位置
}
1.3.2 堆的相关操作
1、新增元素:新元素被加入到heap的末尾,自下往上,然后更新树以恢复堆的次序
//插入操作:向大根堆array中插入数据data
public int[] insertData(int[] array, int data){
array[array.length-1] = data; //将新节点放在堆的末端
int k = array.length-1; //需要调整的节点
int parent = (k-1)/2; //双亲节点
while(parent >=0 && data>array[parent]){
array[k] = array[parent]; //双亲节点下调
k = parent;
if(parent != 0){
parent = (parent-1)/2; //继续向上比较
}else{ //根节点已调整完毕,跳出循环
break;
}
}
array[k] = data; //将插入的结点放到正确的位置
return array;
}
//方法二:
public void push(int value) {
arr[heapSize++] = value;
adjustUp((heapSize-2)/2);
}
public void adjustUp(int k){
if(k < 0)
return;
int left = 2 * k + 1, right = 2 * k +2, largest = left;
if(right < heapSize && arr[right] > arr[left]){
largest = right;
}
if(arr[largest] > arr[k]){
swap(largest, k);
adjustUp((k-1)/2);
}
}
2、 删除最大值:将最后一个数据的值赋给根结点,然后再从根结点开始进行一次从上向下的调整。
//删除堆顶元素操作
public int[] deleteMax(int[] array){
//将堆的最后一个元素与堆顶元素交换,堆底元素值设为-99999
array[0] = array[array.length-1];
array[array.length-1] = -99999;
//对此时的根节点进行向下调整
adjustDownToUp(array, 0, array.length);
return array;
}
//方法二:
public void poll(){
swap(0, heapSize-1);
heapSize --;
adjustDown(0);
}
public void adjustDown(int k){
// 非叶子节点,不用向下调整。
// 判断叶子节点:(堆大小是1 或 就一般的最后一个节点的父节点之后的节点都是叶子)
if(heapSize == 1 || k > (heapSize-2)/2 )
return;
int left = 2*k +1, right = 2 * k + 2, largest = left;
if(right < heapSize && arr[right] > arr[left]){
largest = right;
}
if(arr[largest] > arr[k]){
swap(largest, k);
adjustDown(largest);
}
}
3、具体实现总览
参考:https://www.cnblogs.com/sidewinder/p/13733329.html
public class Heap {
int[] arr = new int[10];
int heapSize = 0;
// 构建一个最大堆的过程:就是从后往前
// 针对每个非叶子节点,做向下调整算
// 参数是:将传如数组,构建大根堆。
public void buildMaxHeap(int[] _arr){
arr = _arr;
heapSize = _arr.length;
// 找到非叶子节点,然后向下调整;
for(int i = (arr.length -2)/2; i >= 0; i --){
adjustDown(i);
}
}
// 新增元素
public void push(int value) {
arr[heapSize++] = value;
adjustUp((heapSize-2)/2);
}
//删除元素
public void poll(){
swap(0, heapSize-1);
heapSize --;
adjustDown(0);
}
// 向下调整算法
public void adjustDown(int k){
// 非叶子节点,不用向下调整。
// 判断叶子节点:(堆大小是1 或 就一般的最后一个节点的父节点之后的节点都是叶子)
if(heapSize == 1 || k > (heapSize-2)/2 )
return;
int left = 2*k +1, right = 2 * k + 2, largest = left;
if(right < heapSize && arr[right] > arr[left]){
largest = right;
}
if(arr[largest] > arr[k]){
swap(largest, k);
adjustDown(largest);
}
}
// 向上调整算法
public void adjustUp(int k){
if(k < 0)
return;
int left = 2 * k + 1, right = 2 * k +2, largest = left;
if(right < heapSize && arr[right] > arr[left]){
largest = right;
}
if(arr[largest] > arr[k]){
swap(largest, k);
adjustUp((k-1)/2);
}
}
void swap(int i, int j){
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
// 排序只需要一直删除,堆顶元素, 放到堆末尾;
// 大根堆,就能进行从小到大排序。
void sort(){
for(int i = 0; i < arr.length; i++){
poll();
}
}
}
1.4 堆排序
先让整个数组都变成大根堆结构,建立堆的过程:
- 从上到下的方法,时间复杂度为O(NlogN)
- 从下到上的方法,时间复杂度为O(N)
思想:堆的最大值和堆末尾的值交换,然后减少堆的大小之后,再去调整堆,一直周而复始,时间复杂度为O(N*logN)
堆的大小减小成0之后,排序完成!
//堆排序
public int[] heapSort(int[] array){
array = buildMaxHeap(array); //初始建堆,array[0]为第一趟值最大的元素
for(int i=array.length-1;i>1;i--){
int temp = array[0]; //将堆顶元素和堆低元素交换,即得到当前最大元素正确的排序位置
array[0] = array[i];
array[i] = temp;
adjustDownToUp(array, 0,i); //整理,将剩余的元素整理成堆
}
return array;
}
1.4 优先队列PriorityQueue
1.4.1 什么是优先级队列
-
队列的核心思想就是先进先出,这个优先级队列有点不太一样。
-
优先级队列中,数据按关键词有序排列,插入新数据的时候,会自动插入到合适的位置保证队列有序。
-
序有两种形式:升序或者是降序
PriorityQueue类在Java1.5中引入并作为java集合API 的一部分。PriorityQueue是基于优先堆的一个无界队列,这个优先队列中的元素可以默认自然排序或者通过提供的Comparator(比较器)在队列实例化的时排序。
PriorityQueue是非线程安全的,所以Java提供了PriorityBlockingQueue(实现BlockingQueue接口)用于Java多线程环境
1.4.2 自定义排序和Lambda回顾
//自然排序:类实现了java.lang.Comparable接口,重写compareTo()的规则
//这里固定指:o1表示位于前面的对象,o2表示后面的对象,并且表示o1比o2小
o1.compareTo(o2)
//升序
Collections.sort(persons, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
//o1比o2小,直接返回,就是不调整位置,所以是升序
return o1.getAge().compareTo(o2.getAge());
}
});
//定制排序:java.util.Comparator,重写compare方法
//这里o1表示位于前面的对象,o2表示后面的对象
compare(o1,o2)==o1.compareTo(o2)
返回-1(或负数),表示不需要交换01和02的位置,o1依旧排在o2前面,asc,升序
返回1(或正数),表示需要交换01和02的位置,o1排在o2后面,desc,降序
//Collections排序降序
Collections.sort(persons, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o2.getAge().compareTo(o1.getAge());//o2比o1小,所以是降序
}
});
//函数式接口,Lambda表达式
//例如创建一个线程,Runnable接口只包含一个方法,所以它被称为“函数接口”,所以它可以使用Lambad表达式来代替匿名内部类
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("Hello World!");
}
});
new Thread(() -> System.out.println("Hello World!"));
1.4.3 源码分析
参考文章
https://www.cnblogs.com/linghu-java/p/9467805.html
https://baijiahao.baidu.com/s?id=1665383380422326763&wfr=spider&for=pc
// Java 的 PriorityQueue 默认是小顶堆,添加 comparator 参数使其变成最大堆
Queue<Integer> heap = new PriorityQueue<>(k, (i1, i2) -> Integer.compare(i2, i1));//转换成降序
k表示我们维护的堆的大小
offer加入队列,之后调整优先级,如果是降序的,那么还是降序的
poll删除摸个元素,原来是降序,那么删除元素中最大的元素
2 面试经典TopK问题
2.1 题目列表:
百万数据处理,找到前K大的数字,从20亿个数字的文本中,找出最大的前100
2.2 TopK小
/**
* 1.基于快排的数组划分,此时只需要递归左半部分,直到k=j
* 快排变形,(平均)时间复杂度 O(n)
*
* 2.大根堆:每次从堆顶弹出的数都是堆中最大的,最小的 k 个元素一定会留在堆里
* 堆的实现:库函数中的优先队列数据结构,如 Java 中的 PriorityQueu
* 堆,时间复杂度 O(nlogk)
*
*/
2.2.1 基于快排找topk小
public class topK {
public int[] getLeastNumbers(int[] arr, int k) {
if (k >= arr.length) return arr;
return quickSort(arr, k, 0, arr.length - 1);
}
private int[] quickSort(int[] arr, int k, int left, int right) {
int i = left, j = right;
while (i < j) {
while (i < j && arr[j] >= arr[left]) j--;
while (i < j && arr[i] <= arr[left]) i++;
swap(arr, i, j);
}
swap(arr, j, left);
//i是基准值的索引,我们需要基准值刚好是k
if (i > k) return quickSort(arr, k, left, j - 1);
if (i < k) return quickSort(arr, k, j + 1, right);
//Arrays.copyOf(原始数组, 长度)
return Arrays.copyOf(arr, k);
}
private void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
2.2.2 大根堆法找topk小
堆排序Java实现