堆和堆排序

1. 堆的概念

堆是一種特殊的樹,一個堆須要知足以下兩個條件:java

  • 一個堆是一個徹底二叉樹;算法

  • 堆中每一個節點的值都必須大於等於或者小於等於其子樹中的每一個節點。api

第一條,徹底二叉樹要求,除了最後一層,其它層的節點個數都是滿的,而且最後一層的節點都靠左排列。數組

第二條,也等價於,每一個節點的值大於等於或者小於等於其左右子節點的值。節點值大於等於其子樹中每一個節點值的堆稱爲 「大頂堆」,節點值小於等於其子樹中每一個節點值的堆稱爲 「小頂堆」。緩存

上圖中,第 1 個和第 2 個是大頂堆,第 3 個是小頂堆,第 4 個不是堆。並且,能夠看到,對於同一組數據,咱們能夠構建多種不一樣形態的堆。數據結構

2. 堆的實現

以前咱們知道,徹底二叉樹比較適合用數組來存儲,這樣很是節省空間,由於不須要額外的空間來存儲左右子節點的指針,單純經過下標咱們就能夠找到一個節點的左右子節點。函數

能夠看到,下標爲 i 的節點的左子節點下標爲 2i,右子節點下標爲 2i+1,而父節點下標就爲 \frac{i}{2}大數據

2.1. 往堆中插入一個元素

往堆中插入一個元素後,咱們須要繼續保持堆知足它的兩個特性。ui

若是咱們將新插入的元素放到堆的最後,此時,這依舊仍是一棵徹底二叉樹,但就是節點的大小關係不知足堆的要求。所以,咱們須要對節點進行調整,使之知足堆的第二個特性,這個過程稱爲堆化(heapify)。spa

堆化很是簡單,就是順着節點所在的路徑,向上或者向下,對比而後交換。

咱們重新插入的節點開始,依次與其父結點進行比較,若是不知足子節點值小於等於父節點值,咱們就互換兩個節點,直到知足條件爲止。這個過程是自下向上的,稱爲從下往上的堆化方法。

public class Heap {
  private int[] a; // 數組,從下標 1 開始存儲數據
  private int n;  // 堆能夠存儲的最大數據個數
  private int count; // 堆中已經存儲的數據個數

  public Heap(int capicity) {
    a = new int[capicity + 1];
    n = capicity;
    count = 0;
  }

  public void insert(int data) {
    if (count >= n) return; // 堆滿了
    ++count;
    a[count] = data;
    int i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap() 函數做用:交換下標爲 i 和 i/2 的兩個元素
      i = i/2;
    }
  }
 }
複製代碼
2.2. 刪除堆頂元素

假設咱們構建的是大頂堆,那麼堆頂元素就是最大值。當咱們刪除堆頂元素後,就須要把第二大元素放到堆頂,而第二大元素確定是其左右子節點中的一個。而後,咱們再迭代地刪除第二大節點,以此類推,直到葉子節點被刪除。

可是,這個方法有點問題,刪除堆頂元素後堆就不知足徹底二叉樹的條件了。

實際上,咱們稍微改變一下思路,就能夠解決這個問題。刪除堆頂元素後,咱們將最後一個結點放到堆頂,而後再依次進行對比,將這個結點交換到正確的位置便可。這個過程是自上而下的,稱爲從上往下的堆化方法。

public void removeMax() {
  if (count == 0) return -1; // 堆中沒有數據
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

private void heapify(int[] a, int n, int i) { // 自上往下堆化
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}
複製代碼

一棵包含 n 個節點的徹底二叉樹,樹的高度不會超過 log_2n。而堆化的過程是順着結點所在的路徑進行比較交換的,因此堆化的時間複雜度和樹的高度成正比,也就是 O(logn),也即往堆中插入和刪除元素的時間複雜度都爲 O(logn)

3. 堆排序的實現

藉助於堆這種數據結構實現的排序算法,叫做堆排序,堆排序的時間複雜度很是穩定,爲 O(nlogn),並且是一種原地排序算法。堆排序大體能夠分爲兩個步驟,建堆排序

3.1. 建堆

咱們首先將數組原地建成一個堆,所謂原地,就是不借助另一個數組直接在原數組上進行操做,這有兩種思路。

第一種思路就是藉助於咱們前面往堆中插入一個元素的思想。首先,咱們假設下標爲 1 的元素就是堆頂,而後依次將數組後面的數據插入到這個堆中便可。這種思路從前日後處理數據,並且每次插入數據時,都是從下往上堆化。

第二種實現思路和第一種截然相反,咱們從後往前處理數據,每一個數據從上往下堆化。由於葉子節點沒法再往下繼續堆化,咱們從第一個非葉子節點開始,依次往前對數據進行堆化便可。

private static void buildHeap(int[] a, int n) {
  for (int i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

private static void heapify(int[] a, int n, int i) {
  while (true) {
    int maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}
複製代碼

這裏,咱們對下標爲 \frac{n}{2} 到 1 的數據進行堆化,下標爲 \frac{n}{2}+1n 的節點是葉子結點,不須要進行堆化。

下面咱們來看一下建堆過程的時間複雜度是多少。由於葉子結點不須要建堆,因此須要堆化的節點從倒數第二層開始,而每一個節點建堆時須要交換和比較的次數,和這個節點的高度成正比。

所以,咱們只須要將每一個須要建堆的節點高度求和,便可得出建堆的時間複雜度。

這個求和須要點技巧,咱們將式子乘以 2 後再減去這個式子,可得。

節點的最大高度 h=log_2n,所以能夠得出建堆的時間複雜度爲 O(n)

3.2. 排序

建堆結束以後,堆頂元素就是最大元素,咱們將其和最後一個元素進行交換,那最大元素就放到了下標爲 n 的位置。而後,咱們再對前面 n-1 個元素進行堆化,而後將堆頂元素放到下標爲 n-1 的位置,重複這個過程,直到堆中剩餘一個元素,排序也就完成了。

// n 表示數據的個數,數組 a 中的數據從下標 1 到 n 的位置。
public static void sort(int[] a, int n) {
  buildHeap(a, n);
  int k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}
複製代碼

整個堆排序過程當中,咱們都只須要常量級別的臨時空間,因此堆排序是原地排序算法。堆排序中建堆過程的時間複雜度爲 O(n),排序過程的時間複雜度爲 O(nlogn),所以總體的時間複雜度爲 O(nlogn)

堆排序不是穩定的排序算法,由於在排序的時候,咱們將堆頂元素和最後一個元素進行了交換,這就有可能改變了值相同元素的原始相對位置。

另外,前面咱們都假設堆中的數據從下標爲 1 的位置開始存儲,若是是從下標爲 0 的位置開始存儲,咱們就須要從新計算子節點和父節點的小標位置。

下標爲 i 的節點的左子節點下標爲 2i+1,右子節點下標爲 2i+2,而父節點下標就爲 \frac{i-1}{2}

4. 爲何說堆排序沒有快速排序快?

  • 堆排序數據訪問的方式沒有快速排序好

能夠看到,堆排序數據的訪問不是像快速排序那樣按順序訪問的,這對 CPU 緩存是不友好的。下面的這個例子,要對堆頂結點進行堆化,咱們要依次訪問下標爲 1,2,4,8 的元素。

  • 一樣的數據,堆排序的數據交換次數多於快速排序

快速排序的交換次數不會比逆序數多,可是堆排序的建堆過程會打亂原有數據的前後順序,致使數據的有序度下降。好比,針對一組已經有序的數據,建堆以後,數據反而變得更無序了。

參考資料-極客時間專欄《數據結構與算法之美》

獲取更多精彩,請關注「seniusen」!

相關文章
相關標籤/搜索