一.問題背景
如果做過參加過面試或者做過一些面試題,應該知道特別經典的top K問題,比如“找出無序數組中的最大或者最小K個數”:
這種題可以排序后再輸出最大或者最小的幾個。但是不論是使用快排還是歸並排序,毫無疑問,空間和時間復雜度的開銷都是不滿足面試官的要求的;而使用“堆”這種數據結構就比較好的解決這種問題,空間開銷O(1),時間開銷O(N logK)。
需要注意的是,這里說的“堆”不是指堆棧的堆,而是一種數據結構,更准確的說是“完全二叉樹”。
下面就詳細對堆這種數據結構進行介紹,注:本文的內容是在學習浙江大學的何欽銘教授的數據結構課程后整理的。
標注原文地址:https://www.cnblogs.com/-beyond/p/13084115.html
二.堆的介紹
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堆的插入
新元素,插入堆時,默認是插入到最后一個位置,這樣保證滿足完全二叉樹的特點,但是可能不滿足有序性的特點,所以需要進行一些調整;
對於最大堆來說,任一根節點都比子節點的值大,所以如果插入的元素(默認是在最后),就需要和其父節點進行比較,如果比父節點大,則需要與父節點交換位置;這是一次調整,但是調整完以后,新插入的節點也許還會比新的父節點大,所以還需要繼續比較,直到父節點比自己大,才停止比較,此時才找到新增元素應該插入的位置。
/**
* 向最大堆中新增一個元素<br>
* 空間復雜度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);
下面就介紹一下第二種方式。
直接在無序數組上調整,不是從對頂元素開始調整,而是從最后一個元素進行調整,調整的過程和插入的過程相似:找到節點的父節點,以父節點為根調整為最大堆(根節點與左右子節點選最大值作為根),如此反復
/**
* 建立最大堆<br>
* 兩種方案<br>
* 方案一:建立空堆,N個數,N次插入,時間復雜度O(N*logN),舍棄!<br>
* 方案二:先順序輸入,滿足完全二叉樹要求,再進行調整堆,滿足有序性,時間復雜度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("已經完成堆的建立和調整");
}
/**
* 建立最大堆<br>
* 兩種方案<br>
* 方案一:建立空堆,N個數,N次插入,時間復雜度O(N*logN),舍棄!<br>
* 方案二:先順序輸入,滿足完全二叉樹要求,再進行調整堆,滿足有序性,時間復雜度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;
}
/**
* 向最大堆中新增一個元素<br>
* 空間復雜度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
