資料結構與演算法之美-堆和堆排序
堆和堆排序
如何理解堆
堆是一種特殊的樹,只要滿足以下兩點,這個樹就是一個堆。
①完全二叉樹,完全二叉樹要求除了最後一層,其他層的節點個數都是滿的,最後一層的節點都靠左排列。
②樹中每一個結點的值都必須大於等於(或小於等於)其子樹中每個節點的值。大於等於的情況稱為大頂堆,小於等於的情況稱為小頂堆。
如何實現堆
如何儲存一個堆
完全二叉樹適合用陣列來儲存,因為陣列中對於下標從1開始的情況,下標為i的節點的左子節點就是下標為i*2的節點,右子節點就是i下標為i*2+1的節點,其父節點時下標為i/2的節點
堆支援哪些操作
往堆中插入一個元素
把新插入的元素放到堆的最後就不符合第二個特性了,所以我們需要進行調整,讓其重新滿足堆的特性,這個過程我們起了一個名字,就叫作堆化(heapify)。
堆化就是順著節點所在的路徑,向上或者向下,對比,然後交換。我們先使用從下往上的堆化方法。
讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關係,我們就互換兩個節點。一直重複這個過程,直到父子節點之間滿足剛說的那種大小關係。
public class Heap{ private int[] data;//陣列,從下標1開始儲存 private int maxNum;//陣列容量 private int count;//當前陣列成員數量 //構造器初始化陣列,大小和數量 public Heap(int size){ data= new int[size + 1]; maxNum = size; count = 0; } public void Insert(int item){ //堆滿返回 if (count >= maxNum) return; //先將節點插入堆尾 data[count++] = item; int i = count; //再自下向上堆化,直到堆頂或者父節點比子節點大為止 while (i / 2 > 0 && data[i] > data[i / 2]){ //交換位置 int temp = data[i]; data[i] = data[i / 2]; data[i / 2] = temp; //更新下標 i = i / 2; } } }
刪除堆頂元素
根據對的第二條定義,堆頂元素儲存的就是堆中的最大值或最小值。
這裡我們使用從上往下的堆化方法。將最後一個節點放到堆頂,然後利用同樣的父子節點對比法,進行互換節點直到父子節點之間滿足大小關係為止。
這樣移除的就是陣列中的最後一個元素,不會破環完全二叉樹的定義。
public void RemoveMax(){ //堆空返回 if (count == 0) return; //將最後一個節點提到堆頂 data[1] = data[count--]; //進行堆化 Heapify(data,count,1); } public static void Heapify(int[] data,int n,int i){ while (true){ //記錄更大節點的位置,初始化為當前節點的位置 int maxPos = i; //如果其左右子節點存在,且比當前節點大,就將左右節點下標設為更大的節點 if (i * 2 <= n && data[i] < data[i * 2]) maxPos = i * 2; if (i * 2 + 1 <= n && data[maxPos] < data[i * 2 + 1]) maxPos = i * 2 + 1; //否則就結束迴圈,堆化結束 if (maxPos == i) break; //節點交換位置 int temp = data[i]; data[i] = data[maxPos]; data[maxPos] = temp; //更新當前節點的下標,迴圈繼續與下一個左右子節點比較 i = maxPos; } }
如何基於堆實現排序
我們藉助於堆這種資料結構實現的排序演算法,就叫作堆排序。
我們可以把堆排序的過程大致分解成兩個大的步驟,建堆和排序。
建堆
首先將陣列原地建成一個堆。藉助另一個數組,就在原陣列上操作。我們要實現從後往前處理陣列,並且每個資料都是從上往下堆化的建堆方法。
public static void BuildHeap(int[] data, int n){ //從下標n/2到1開始進行堆化,n/2就是最後一個葉子節點的父節點。 for (int i = n / 2; i >= 1; --i) Heapify(data,n,i); }
我們對下標從n/2開始到 111 的資料進行堆化,下標是n/2+1到n的節點是葉子節點,我們不需要堆化。
建堆操作的時間複雜度
排序的建堆過程的時間複雜度是 O(n)。
排序
建堆結束之後,陣列中的資料已經是按照大頂堆的特性來組織的。陣列中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標為n的位置。
這個過程有點類似刪除堆頂元素的操作,當堆頂元素移除之後,我們把下標為n的元素放到堆頂,然後再通過堆化的方法,將剩下的n-1個元素重新構建成堆。
堆化完成之後,我們再取堆頂的元素,放到下標是的位置,一直重複這個過程,直到最後堆中只剩下標為1的一個元素,排序工作就完成了。
public static void Sort(int[] data,int n){ //將陣列建造為堆 BuildHeap(data, n); //獲取堆尾的下標 int k = n; //迴圈直到k為1 while (k > 1){ //交換堆頂和堆尾的元素 int temp = data[k]; data[k] = data[1]; data[1] = temp; //將堆尾的下標遞減並對1到k的下標的陣列成員進行堆化 Heapify(data,--k,1); } }
堆排序的時間複雜度、空間複雜度以及穩定性
堆排序是原地排序演算法。堆排序包括建堆和排序兩個操作,建堆過程的時間複雜度是O(n),排序過程的時間複雜度是O(nlogn)所以,堆排序整體的時間複雜度是O(nlogn)。
堆排序不是穩定的排序演算法,因為在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操作,所以就有可能改變值相同資料的原始相對順序。
測試
//Main方法 int[] data = new int[] {0,3,5,2,9,4,7 }; Heap.Sort(data,data.Length-1); for (int i=0;i<data.Length;i++) Console.Write(data[i]+","); //測試結果 0,2,3,4,5,7,9,
陣列的第1個成員,即下標0的資料是不作為資料的一部分的,這是為了演算法上的方便,如果下標是從0開始,那麼左右子節點的下標公式就是i*2+1和i*2+2。
思考
在實際開發中,為什麼快速排序要比堆排序效能好?
對於快速排序來說,資料是順序訪問的而對於堆排序來說,資料是跳著訪問的。這樣對 CPU 快取是不友好的。
對於同樣的資料,在排序過程中,堆排序演算法的資料交換次數要多於快速排序。堆排序的第一步是建堆,建堆的過程會打亂資料原有的相對先後順序,導致原資料的有序度降低。比如,對於一組已經有序的資料來說,經過建堆之後,資料反而變得更無序了。