今天我們將學習新的資料結構-堆。
01、定義
堆是一種特殊的二叉樹,並且滿足以下兩個特性:
(1)堆是一棵完全二叉樹;
(2)堆中任意一個節點元素值都小於等於(或大於等於)左右子樹中所有節點元素值;
小根堆,根節點元素永遠是最小值,即堆中每個節點元素值都小於等於左右子樹中所有節點元素值;
大根堆,根節點元素永遠是最大值,即堆中每個節點元素值都大於等於左右子樹中所有節點元素值;
根據堆的定義我們不難發現,堆特別適合用來求集合最值,以及求最值引申的問題比如:排序、優先佇列、動態排名等等
02、結構
我們指定堆是一種特殊完全二叉樹,因此堆的邏輯結構是樹。
我們指定樹的儲存結構有兩種,順序儲存(陣列)和鏈式儲存(連結串列),那麼堆的儲存結構是什麼呢?
既然堆是完全二叉樹,那麼樹的儲存結構當然也適用於堆。但是堆一般都選用順序儲存(陣列)實現。其原因有三:
(1)位置計算簡單:陣列實現堆可以使用完全二叉樹特性用簡單的數學公式即可表示父子節點的索引關係,從而避免了連結串列實現使用額外的指標,即減少記憶體開銷和實現複雜度;
(2)效能好:陣列的連續記憶體特性使得其有高效的訪問速度,而連結串列因為節點不一定連續訪問速度相對較差;
(3)操作簡單:陣列實現在邏輯實現上更加簡單高效,透過交換陣列中的元素即可快速實現堆的性質,連結串列實現在插入和刪除操作中需要遍歷連結串列效率遠不如陣列實現;
總結一句話陣列簡單、記憶體連續、效能更好,所以一般選用陣列實現堆,當然不一般的情況也可以使用連結串列實現。
03、實現(最小堆)
下面我們就用陣列來實現一個最小堆結構,最大堆只是比較大小不同這裡就不做過多贅述。
1、初始化 Init
首先我們需要定義兩個私有變數,一個變數用來存放堆元素的陣列,一個變數用來存放陣列尾元素索引,主要用來跟蹤當前堆元素個數情況。
而初始化方法就是初始化兩個變數,建立指定容量陣列,以及標記陣列尾元素索引為-1,表示堆中還沒有元素。
//存放堆元素
private int[] _array;
//陣列尾元素索引
private int _tailIndex;
//初始化堆為指定容量
public MyselfMinHeap Init(int capacity)
{
//初始化指定長度陣列用於存放堆元素
_array = new int[capacity];
//初始化陣列尾元素索引為-1,表示空陣列
_tailIndex = -1;
//返回堆
return this;
}
2、獲取堆容量 Capacity
堆容量指的是陣列的固定空間,即陣列最多能容納多少個元素,直接返回陣列長度即可。
//堆容量
public int Capacity
{
get
{
return _array.Length;
}
}
3、獲取堆元素數量 Count
堆元素數量指當前堆中一共有多少個元素,我們可以透過私有欄位陣列尾元素索引值加1獲得。
//堆元素數量
public int Count
{
get
{
//陣列尾元素索引加1即為堆元素數量
return _tailIndex + 1;
}
}
4、獲取堆頂元素 Top
堆頂元素指樹節點中的根節點,也就是陣列中的第一個元素。同時要注意判斷陣列是否為空,為空報異常。
//獲取堆頂元素,即最小元素
public int Top
{
get
{
if (IsEmpty())
{
//空堆,不可以進行獲取最小堆元素操作
throw new InvalidOperationException("空堆");
}
return _array[0];
}
}
5、是否為空堆 IsEmpty
空堆即堆中還沒有任何元素,當陣列尾元素索引為-1時表示空堆。
//檢查堆是否為空
public bool IsEmpty()
{
return _tailIndex == -1;
}
6、是否為滿堆 IsFull
滿堆即堆空間已滿不能再新增新元素,當陣列尾元素索引等於陣列容量減1時表示滿堆。
//檢查堆是否為滿堆
public bool IsFull()
{
//陣列末尾元素索引等於陣列容量減1表示滿堆
return _tailIndex == _array.Length - 1;
}
7、入堆 Push
入堆即向堆中新新增一個元素。
而入堆也涉及到一個問題,就是如何儲存每次新增完新元素後,還保持著堆的特性。很顯然我們也沒辦法做到直接把新元素直接插入到其正確的位置上,因此我們可以梳理新新增一個元素的流程,可能大致有以下幾個步驟:
(1)首先把新元素新增到堆的末尾;
(2)然後調整堆使其滿足堆的性質;
(3)更新堆尾元素索引;
而難點顯然就在第二步,如何調整陣列使其滿足堆的性質?
我們先直接模擬把7654按順序推入堆中,看看如何實現。
(1)首先7入堆,此時只有一個元素,無需做任何操作,而7就作為根節點;
(2)然後6入堆,此時已有兩個元素,因此需要保持堆的兩個特性:根節點永遠是最小元素和堆是完全二叉樹。由完全二叉樹特性可得,根節點左子節點索引為20+1=1,而右子節點索引為20+2=2,而此時6的索引為1,所以6為左子節點;又因6比7小,所以根節點變為6, 7變為根節點的左子節點;
(3)然後5入堆,此時已有3個元素,所以5的索引為2,而根節點右子節點索引為2*0+2=2,所以5新增為根節點的右子節點。5比6小,所以5和6互動位置;
(4)然後4入堆,此時已有4個元素,所以4的索引為3,而7節點左子節點索引為2*1+1=3,所以4新增為7節點的左子節點。4比7小,所以4和7互動位置; 再次比較4和5,4比5小,所以4和5互動位置;
相信到這裡大家已經看出一點規律了,調整整個陣列元素使其滿足堆的性質的整個過程大致分為以下幾個步驟:
(1)從新元素開始向上比較其與父節點元素的值大小;
(2)如果新元素值小於其父節點元素值,則互動兩者位置,否則結束調整;
(3)重複以上步驟直至處理到根節點。
程式碼實現如下:
//入堆 向堆中新增一個元素
public void Push(int data)
{
if (IsFull())
{
//滿堆,不可以進行入新增元素操作
throw new InvalidOperationException("滿堆");
}
//新元素索引
var index = _tailIndex + 1;
//在陣列末尾新增新元素
_array[index] = data;
//向上調整堆,以保持堆的性質
SiftUp(index);
//尾元素索引向後前進1位
_tailIndex++;
}
//向上調整堆,以保持堆的性質
private void SiftUp(int index)
{
//一直調整到堆頂為止
while (index > 0)
{
//計算父節點元素索引
var parentIndex = (index - 1) / 2;
//如果當前元素大於等於其父節點元素,則結束調整
if (_array[index] >= _array[parentIndex])
{
break;
}
//否則,交換兩個元素
(_array[parentIndex], _array[index]) = (_array[index], _array[parentIndex]);
//更新當前索引為父節點元素索引繼續調整
index = parentIndex;
}
}
8、出堆 Pop
出堆即刪除並返回堆頂元素(堆中最小元素)。
而出堆同樣也涉及到和入堆同樣的問題,就是如何儲存每次刪除元素後,還保持著堆的特性。
顯然刪除和新增元素情況還不一樣。新增新元素後還是一棵完全二叉樹,只不過可能沒有滿足堆的性質,所以需要調整。而刪除就不一樣了,想象一下當我們把堆頂元素刪除後,回怎樣?根節點空了,此時還能較完全二叉樹嗎?顯然不能。
如何處理呢?直觀的想法就是根節點空了就用其子節點補充上唄,就這樣從上到下一直填補,直至最後的空落在了葉子節點那一層,如果這個空位落到了葉子節點左側,而右側還有值,此時就表明堆不滿足完全二叉樹這一特性,因此還需要把這個空位移到堆的末尾,想象都頭大。這顯然不是一個好辦法。
既然我們最後要把根節點刪除後的這個空位移到堆的末尾,何不直接把這個空位和堆的尾元素直接調換個位置呢,然後再參考出堆中調整整個陣列使其滿足堆的性質。
我們梳理整個流程,可能大致有以下幾個步驟:
(1)首先獲取堆頂元素並暫存;
(2)將堆尾元素賦值給堆頂元素,並將堆尾元素置空;
(3)然後調整堆使其滿足堆性質;
(4)更新陣列尾元素索引,並返回暫存的堆頂元素;
而難點同樣在第二步,如何調整陣列使其滿足堆的性質?入堆是新元素在堆尾所以是從下往上進行調整,而出堆是堆頂元素需要調整是否可以從上往下進行調整呢?
我們把87654按順序推入堆中後把4推出堆中,看看如何實現。
首先刪除根節點4,並把堆尾元素8放到根節點;
為了保持堆特性,找出根節點及其子節點中最小元素並與當前根節點8互動位置,因為8>6>5,所以5與8互動位置;
然後8節點繼續與其子節點進行比較,因為8>7,所以7與8互動位置。
這一出堆過程我們可以總結為以下步驟:
(1)從當前節點開始,找到其子節點元素值;
(2)比較當前節點元素值與其子節點元素值大小,如果當前節點值最小則結束調整,否則取值最小的與其互動;
(3)重複以上步驟直至處理到葉子節點。
程式碼實現如下:
//出堆 刪除並返回堆中最小元素
public int Pop()
{
if (IsEmpty())
{
//空堆,不可以進行刪除並返回堆中最小元素操作
throw new InvalidOperationException("空堆");
}
//取出陣列第一個元素即最小元素
var min = _array[0];
//將陣列末尾元素賦值給第一個元素
_array[0] = _array[_tailIndex];
//將陣列末尾元素設為預設值
_array[_tailIndex] = 0;
//將陣列末尾元素索引向前移動1位
_tailIndex--;
//向下調整堆,以保持堆的性質
SiftDown(0);
//返回最小元素
return min;
}
//向下調整堆,以保持堆的性質
private void SiftDown(int index)
{
while (index <= _tailIndex)
{
//定義較小值索引變數,用於存放比較當前元素及其左右子節點元素中最小元素
var minIndex = index;
//計算右子節點索引
var rightChildIndex = 2 * index + 2;
//如果存在右子節點,則比較其與當前元素,保留值較小的索引
if (rightChildIndex <= _tailIndex && _array[rightChildIndex] < _array[minIndex])
{
minIndex = rightChildIndex;
}
//計算左子節點索引
var leftChildIndex = 2 * index + 1;
//如果存在左子節點,則比較其與較小值元素,保留值較小的索引
if (leftChildIndex <= _tailIndex && _array[leftChildIndex] < _array[minIndex])
{
minIndex = leftChildIndex;
}
//如果當前元素就是最小的,則停止調整
if (minIndex == index)
{
break;
}
//否則,交換當前元素和較小元素
(_array[minIndex], _array[index]) = (_array[index], _array[minIndex]);
//更新索引為較小值索引,繼續調整
index = minIndex;
}
}
9、堆化 Heapify
堆化即把一個無序陣列堆化成小根堆。
可以透過呼叫出堆是用到的調整方法來完成。大家可以思考一下為什麼不是堆尾元素開始調整?為什麼是從下往上呼叫向下調整方法調整?
//堆化,即把一個無序陣列堆化成小根堆
public void Heapify(int[] array)
{
if (array == null || _array.Length < array.Length)
{
throw new InvalidOperationException("無效陣列");
}
//將陣列複製到堆中
Array.Copy(array, _array, array.Length);
//更新尾元素索引
_tailIndex = array.Length - 1;
//從最後一個非葉子節點開始向下調整堆
for (int i = (array.Length / 2) - 1; i >= 0; i--)
{
SiftDown(i);
}
}
注:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner