一.问题背景
如果做过参加过面试或者做过一些面试题,应该知道特别经典的top K问题,比如“找出无序数组中的最大或者最小K个数”:
这种题可以排序后再输出最大或者最小的几个。但是不论是使用快排还是归并排序,毫无疑问,空间和时间复杂度的开销都是不满足面试官的要求的;而使用“堆”这种数据结构就比较好的解决这种问题,空间开销O(1),时间开销O(N logK)。
需要注意的是,这里说的“堆”不是指堆栈的堆,而是一种数据结构,更准确的说是“完全二叉树”。
下面就详细对堆这种数据结构进行介绍,注:本文的内容是在学习浙江大学的何钦铭教授的数据结构课程后整理的。
二.堆的介绍
2.1数组和链表实现优先队列
再说Top K之前,先说一下调度算法。学过操作系统就知道,进程调度有多种算法,而最简单的就是“先到先服务”算法,这种算法可以简单的使用队列来实现,但是存在一个问题就是无法根据进程的优先级调整执行顺序,比如有两个进程,一个进程只是连接打印机打印一张纸,另外一个进程负责核心功能处理,很明显,核心功能处理的进程优先级更高,但是操作系统按照先到先服务算法来调度时,核心功能处理的进程并不是优先调度;
这个时候可以切换为“按照优先级”进行调度,只需要每次选择最高优先级的进程执行,自己进行实现的话,有多种方式:
仔细想一下,Top K的问题,和这里说的调度优先级其实是一样的问题。
2.2堆的介绍
1.堆是一种树结构,准确的说是“二叉树”,更准确的说是“完全二叉树”,根据完全二叉树的特点,可以使用数组来存储堆。
2.堆是有序的,任一节点的关键字是其子树所有节点的最大值或者最小值。
3.如果根节点是最大值的堆,称为“最大堆”或者“大顶堆”、“大根堆”;
4.如果根节点是最小值的堆,称为“最小堆”、“小顶堆”、“小根堆”;
三.堆的各种操作
3.1创建堆
因为堆满足完全二叉树的特点,所以可以使用数组来存储堆;下面是代码:
package cn.ganlixin.tree.heap;
import java.util.Scanner;
/**
* 描述:
* 数据结构-堆(此处为最大堆)
*
* @author ganlixin
* @create 2020-06-09
*/
public class MaxHeap {
/**
* 保存堆元素的数组
*/
private int[] elements;
/**
* 堆的大小
*/
private int size;
/**
* 堆的容量
*/
private int capacity;
public MaxHeap() {
this.size = 0;
this.capacity = 0;
}
/**
* 建堆并调整堆
*/
public void createMaxHeap() {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入堆的最大容量:");
this.capacity = scanner.nextInt();
this.size = 0;
// 数组长度为容量加1,0号元素为哨兵元素
this.elements = new int[this.capacity + 1];
this.elements[0] = Integer.MAX_VALUE;
System.out.print("请输入元素个数:");
this.size = scanner.nextInt();
if (this.size > this.capacity) {
throw new RuntimeException("元素个数不能超过最大容量!");
}
System.out.print("请依次输入" + this.size + "个元素:");
for (int i = 1; i <= this.size; i++) {
elements[i] = scanner.nextInt();
}
buildHeap(); // 构建堆(因为初始状态,数组并不满足堆的有序性特点,所以需要进行调整构建,后面会介绍)
System.out.println("已经完成堆的建立和调整");
}
}
3.2堆的插入
新元素,插入堆时,默认是插入到最后一个位置,这样保证满足完全二叉树的特点,但是可能不满足有序性的特点,所以需要进行一些调整;
对于最大堆来说,任一根节点都比子节点的值大,所以如果插入的元素(默认是在最后),就需要和其父节点进行比较,如果比父节点大,则需要与父节点交换位置;这是一次调整,但是调整完以后,新插入的节点也许还会比新的父节点大,所以还需要继续比较,直到父节点比自己大,才停止比较,此时才找到新增元素应该插入的位置。
/**
* 向最大堆中新增一个元素
* 空间复杂度O(1),时间复杂度O(logN)
*
* @param newItem 新增的元素值
*/
public void insertElement(int newItem) {
// 新元素插入的位置,默认为最后一个元素的后面
int nextIndex = ++this.size;
// 将新元素放到最后,可以满足完全二叉树的要求,但是有序性不一定能保证,所以需要和父节点进行比较(父节点就是index/2)
while (elements[nextIndex / 2] < newItem) {
// 当父节点比新插入的节点小的时候,将父节点移到新节点准备插入的位置
elements[nextIndex] = elements[nextIndex / 2];
// 修改新节点准备插入的位置(此时为父节点的旧位置)
nextIndex /= 2;
}
// 到此,nextIndex就指向了应该插入的位置(比子节点都大,比父节点小)
elements[nextIndex] = newItem;
}
3.3堆元素的删除
堆元素的删除(最大堆),是指将堆的最大值删除(也就是对顶元素给删除),删除对顶元素后,需要进行调整,默认是使用最后一个元素来顶替对顶元素,这样可以满足完全二叉树的特点,但是不一定满足有序性,所以需要调整;
调整的过程,就是比较堆顶节点(此时已经替换为最后一个节点值)与子节点,当根节点比子节点小的时候,就交换根节点和子节点的位置,知道根节点大于子节点(左右子节点),才能确定根节点应该插入的位置。
/**
* 删除最大堆的最大值(堆顶元素)
*
* @return 堆最大值
*/
public int deleteMaxItem() {
// 第一个元素就是最大值
int maxItem = elements[1];
// 将最后一个元素取出来(删除,size减一),用来替补第一个元素(被删除的最大值)
int lastItem = elements[size];
size--;
// 最后一个元素存放的位置,默认为1,表示第一个位置
int insertIndex = 1;
// 最后一个元素不一定是最大的,放到堆顶不一定合适,所以需要调整
while (insertIndex * 2 <= size) {
// childIndex默认指向左孩子
int childIndex = insertIndex * 2;
// 如果父节点有右孩子,并且左孩子比右孩子小,则childIndex指向较大的元素(也就是右孩子)
if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
childIndex++;
}
// 当最后一个元素大于指向的元素时,证明找到了插入位置,则中断循环
if (lastItem >= elements[childIndex]) {
break;
} else {
// 最后一个元素比子节点小(比较大的节点小),则交换较大节点和父节点的位置
elements[insertIndex] = elements[childIndex];
}
// 父节点指向空出来的子节点位置
insertIndex = childIndex;
}
elements[insertIndex] = lastItem;
return maxItem;
}
3.3将无序数组调整堆
就以top K的问题来说,只需要建立一个堆的数据结构,然后弹出堆顶的K个元素,就是top K。
但现在的问题是,提供的数组是无序的,怎么讲无序数组转换为堆:
1.一种方式是从空堆开始一个一个添加元素,添加元素过程中会进行调整,元素插入完毕,堆也就建好了,这样的时间复杂度是N log(N),比较低效;
2.直接在无序数组上进行调整,将期调整为堆结构,时间复杂度为O(logN);
下面就介绍一下第二种方式。
直接在无序数组上调整,不是从对顶元素开始调整,而是从最后一个元素进行调整,调整的过程和插入的过程相似:找到节点的父节点,以父节点为根调整为最大堆(根节点与左右子节点选最大值作为根),如此反复
/**
* 建立最大堆
* 两种方案
* 方案一:建立空堆,N个数,N次插入,时间复杂度O(N*logN),舍弃!
* 方案二:先顺序输入,满足完全二叉树要求,再进行调整堆,满足有序性,时间复杂度O(N)
*/
private void buildHeap() {
for (int i = size / 2; i > 0; i--) { // size/2是最后一个元素的父节点位置
adjustHeap(i);
}
}
/**
* 以index指向的节点作为根,将该子堆调整为最大堆
*
* @param root 子堆的根节点
*/
private void adjustHeap(int root) {
// 取出根节点存的值
int rootVal = elements[root];
// insertIndex指向根节点值应该插入的位置
int insertIndex = root;
while (insertIndex * 2 <= size) {
int childIndex = insertIndex * 2;
if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
childIndex++;
}
// 如果根节点的值大于子节点,则证明找到了插入的位置
if (rootVal > elements[childIndex]) {
break;
} else {
elements[insertIndex] = elements[childIndex];
}
insertIndex = childIndex;
}
elements[insertIndex] = rootVal;
}
四.完成代码
封装在MaxHeap.java中(最大堆)
package cn.ganlixin.tree.heap;
import java.util.Scanner;
/**
* 描述:
* 数据结构-堆(此处为最大堆)
* 完全二叉树,使用数组存储
*
* @author ganlixin
* @create 2020-06-09
*/
public class MaxHeap {
/**
* 保存堆元素的数组
*/
private int[] elements;
/**
* 堆的大小
*/
private int size;
/**
* 堆的容量
*/
private int capacity;
public MaxHeap() {
this.size = 0;
this.capacity = 0;
}
/**
* 建堆并调整堆
*/
public void createMaxHeap() {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入堆的最大容量:");
this.capacity = scanner.nextInt();
this.size = 0;
// 数组长度为容量加1,0号元素为哨兵元素
this.elements = new int[this.capacity + 1];
this.elements[0] = Integer.MAX_VALUE;
System.out.print("请输入元素个数:");
this.size = scanner.nextInt();
if (this.size > this.capacity) {
throw new RuntimeException("元素个数不能超过最大容量!");
}
System.out.print("请输入" + this.size + "个元素:");
for (int i = 1; i <= this.size; i++) {
elements[i] = scanner.nextInt();
}
buildHeap();
System.out.println("已经完成堆的建立和调整");
}
/**
* 建立最大堆
* 两种方案
* 方案一:建立空堆,N个数,N次插入,时间复杂度O(N*logN),舍弃!
* 方案二:先顺序输入,满足完全二叉树要求,再进行调整堆,满足有序性,时间复杂度O(N)
*/
private void buildHeap() {
if (isEmpty()) {
throw new RuntimeException("堆为空,无法完成建堆操作");
}
for (int i = size / 2; i > 0; i--) {
adjustHeap(i);
}
}
/**
* 以index指向的节点作为根,将该子堆调整为最大堆
*
* @param root 子堆的根节点
*/
private void adjustHeap(int root) {
// 取出根节点存的值
int parentVal = elements[root];
// parentIndex指向根节点值应该插入的位置
int parentIndex = root;
while (parentIndex * 2 <= size) {
int childIndex = parentIndex * 2;
if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
childIndex++;
}
// 如果根节点的值大于子节点,则证明找到了插入的位置
if (parentVal > elements[childIndex]) {
break;
} else {
elements[parentIndex] = elements[childIndex];
}
parentIndex = childIndex;
}
elements[parentIndex] = parentVal;
}
/**
* 向最大堆中新增一个元素
* 空间复杂度O(1),时间复杂度O(logN)
*
* @param newItem 新增的元素值
*/
public void insertElement(int newItem) {
if (isFull()) {
throw new RuntimeException("堆已满,无法再添加元素");
}
// 新元素插入的位置,默认为最后一个元素的后面
int nextIndex = ++this.size;
// 将新元素放到最后,可以满足完全二叉树的要求,但是有序性不一定能保证,所以需要和父节点进行比较(父节点就是index/2)
while (elements[nextIndex / 2] < newItem) {
// 当父节点比新插入的节点小的时候,将父节点移到新节点准备插入的位置
elements[nextIndex] = elements[nextIndex / 2];
// 修改新节点准备插入的位置(此时为父节点的旧位置)
nextIndex /= 2;
}
// 到此,nextIndex就指向了应该插入的位置(比子节点都大,比父节点小)
elements[nextIndex] = newItem;
}
/**
* 删除最大堆的最大值(堆顶元素)
*
* @return 堆最大值
*/
public int deleteMaxItem() {
if (isEmpty()) {
throw new RuntimeException("堆为空,不能进行删除操作");
}
// 第一个元素就是最大值
int maxItem = elements[1];
// 将最后一个元素取出来(删除,size减一),用来替补第一个元素(被删除的最大值)
int lastItem = elements[size];
size--;
// 最后一个元素存放的位置,默认为1,表示第一个位置
int insertIndex = 1;
// 最后一个元素不一定是最大的,放到堆顶不一定合适,所以需要调整
while (insertIndex * 2 <= size) {
// childIndex默认指向左孩子
int childIndex = insertIndex * 2;
// 如果父节点有右孩子,并且左孩子比右孩子小,则childIndex指向较大的元素(也就是右孩子)
if (childIndex != size && elements[childIndex] < elements[childIndex + 1]) {
childIndex++;
}
// 当最后一个元素大于指向的元素时,证明找到了插入位置,则中断循环
if (lastItem >= elements[childIndex]) {
break;
} else {
// 最后一个元素比子节点小(比较大的节点小),则交换较大节点和父节点的位置
elements[insertIndex] = elements[childIndex];
}
// 父节点指向空出来的子节点位置
insertIndex = childIndex;
}
elements[insertIndex] = lastItem;
return maxItem;
}
/**
* 打印排序后的堆
*/
public void printSortedHeap() {
for (int i = 1; i <= this.size; i++) {
System.out.print(elements[i] + " ");
}
System.out.println();
}
/**
* 判断堆是否已经满了(size>=capacity)
*
* @return true堆已满;false堆未满
*/
public boolean isFull() {
return this.size >= capacity;
}
/**
* 判断堆是否为空
*
* @return true堆为空;false堆不为空
*/
public boolean isEmpty() {
return this.size == 0;
}
}
测试:
package cn.ganlixin.tree.heap;
/**
* 描述:
* 测试最大堆
*
* @author ganlixin
* @create 2020-06-09
*/
public class Main {
public static void main(String[] args) {
MaxHeap maxHeap = new MaxHeap();
// 输入元素,并调整堆
maxHeap.createMaxHeap();
System.out.print("输出堆:");
maxHeap.printSortedHeap();
int deleteMaxItem = maxHeap.deleteMaxItem();
System.out.println("删除堆中最大元素:" + deleteMaxItem);
System.out.print("输出堆:");
maxHeap.printSortedHeap();
}
}
输出:
请输入堆的最大容量:10
请输入元素个数:6
请依次输入6个元素:8 5 9 6 4 2
已经完成堆的建立和调整
输出堆:9 6 8 5 4 2
删除堆中最大元素:9
输出堆:8 6 2 5 4
五.再说Top K问题
其实上面介绍完堆的各种操作后,对于Top K的问题已经能够解决了,此处以最大的top K问题为例:
需要注意的是,在建堆的时候,并不是将整个数组的N个元素都调整,而是只调整前K个元素,让前K个元素保持堆的结构,也就是说,堆的容量,是K,而不是N。步骤如下:
1.将前K个元素调整为最小堆(也称“小顶堆”、“小根堆”);
2.依次将K+1后面的元素(看做新元素),与堆顶元素(堆的最小值)进行比较:
a.如果堆顶元素比新元素要大,则新元素不用入堆,忽略;
b.如果堆顶元素比新元素要小,则将新元素替换掉堆顶元素,然后进行调整堆(始终保证堆顶元素是堆中元素的最小值);
3.不断重复步骤2,直至比较完N-K个元素;
4.比较完后,堆中的元素就是最大的K个元素。
说直白点,就是进行N-K+1次调整堆,整个流程的时间复杂度为O(N logK)
如果需要按照排序输出K个元素,则进行K次删除堆顶元素即可(每次删除都会调整堆,保证堆顶最小)。
下面是代码:
package cn.ganlixin.tree.heap;
import java.util.Scanner;
/**
* 描述:
*
* @author ganlixin
* @create 2020-06-10
*/
public class TopK {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
System.out.print("请输入元素总个数:");
int capacity = scanner.nextInt();
System.out.print("请输入要找最大的几个数:");
int k = scanner.nextInt();
// 申请一个K+1的数组(因为建立的堆包含K个元素,而不是N个元素,0号元素用来做哨兵)
int[] arr = new int[k + 1];
arr[0] = Integer.MAX_VALUE;
System.out.print("请输入全部元素:");
// 先前K个元素进行建堆调整
for (int i = 1; i <= k; i++) {
arr[i] = scanner.nextInt();
}
// 先将前K个元素进行调整为最小堆(小顶堆)
adjustHeap(arr, 1, k);
// 继续处理后面的n-k个元素,和堆顶元素进行比较,如果比堆顶元素大,则替换堆顶元素,并进行调整堆
for (int i = k + 1; i <= capacity; i++) {
int newItem = scanner.nextInt();
if (newItem > arr[1]) {
arr[1] = newItem; // 替换为新元素
// 每次整个堆都调整
adjustHeap(arr, 1, k);
}
}
System.out.print("最大的" + k + "个数是:");
for (int i = 1; i <= k; i++) {
System.out.print(arr[i] + " ");
}
}
/**
* 将数组调整为满足最小堆的结构
*
* @param arr 要调整的数组
* @param start 要调整的开始位置(index)
* @param end 要调整的结束为止(index)
*/
private static void adjustHeap(int[] arr, int start, int end) {
for (int i = end / 2; i > 0; i--) { // end/2是最后一个节点的父节点
int parentVal = arr[i];
int parentIndex = i;
while (parentIndex * 2 <= end) {
int childIndex = parentIndex * 2; // 左孩子节点
// childIndex指向两个子节点中较小的一个
if (childIndex != end && arr[childIndex] > arr[childIndex + 1]) {
childIndex++;
}
// 比较父节点和较大一个子节点的值,小顶堆需要父节点比子节点小
if (parentVal < arr[childIndex]) {
break;
} else {
arr[parentIndex] = arr[childIndex];
}
parentIndex = childIndex;
}
arr[parentIndex] = parentVal;
}
}
}
测试:
请输入元素总个数:14
请输入要找最大的几个数:5
请输入全部元素:20 5 2 8 10 3 23 5 99 24 0 -7 8 100
最大的5个数是:20 23 100 24 99