1. 程式人生 > 實用技巧 >為什麼說堆排序沒有快速排序快?

為什麼說堆排序沒有快速排序快?

1 如何理解堆?

  堆是一種特殊的樹。只要滿足以下兩點,它就是一個堆:

  • 堆是一個完全二叉樹;
  • 堆中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值。

  第一點,堆必須是一個完全二叉樹。還記得我們之前講的完全二叉樹的定義嗎?完全二叉樹要求,除了最後一層,其他層的節點個數都是滿的,最後一層的節點都靠左排列。

  第二點,堆中的每個節點的值必須大於等於(或者小於等於)其子樹中每個節點的值。實際上,我們還可以換一種說法,堆中每個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。

  對於每個節點的值都大於等於子樹中每個節點值的堆,我們叫做“大頂堆”。對於每個節點的值都小於等於子樹中每個節點值的堆,我們叫做“小頂堆”。

  其中第 1 個和第 2 個是大頂堆,第 3 個是小頂堆,第 4 個不是堆。除此之外,從圖中還可以看出來,對於同一組資料,我們可以構建多種不同形態的堆。

2 如何實現一個堆?

  完全二叉樹比較適合用陣列來儲存。用陣列來儲存完全二叉樹是非常節省儲存空間的。因為我們不需要儲存左右子節點的指標,單純地通過陣列的下標,就可以找到一個節點的左右子節點和父節點。

  從圖中我們可以看到,陣列中下標為 i 的節點的左子節點,就是下標為 i∗2 的節點,右子節點就是下標為 i∗2+1 的節點,父節點就是下標為 2i 的節點。知道了如何儲存一個堆,那我們再來看看,堆上的操作有哪些呢?我羅列了幾個非常核心的操作,分別是往堆中插入一個元素和刪除堆頂元素。(如果沒有特殊說明,我下面都是拿大頂堆來講解)。

2.1 往堆中插入一個元素

  往堆中插入一個元素後,我們需要繼續滿足堆的兩個特性。

  如果我們把新插入的元素放到堆的最後,你可以看我畫的這個圖,是不是不符合堆的特性了?於是,我們就需要進行調整,讓其重新滿足堆的特性,這個過程我們起了一個名字,就叫做堆化(heapify)。

  堆化實際上有兩種,從下往上和從上往下。這裡我先講從下往上的堆化方法。

  堆化非常簡單,就是順著節點所在的路徑,向上或者向下,對比,然後交換。我這裡畫了一張堆化的過程分解圖。

  我們可以讓新插入的節點與父節點對比大小。如果不滿足子節點小於等於父節點的大小關係,我們就互換兩個節點。一直重複這個過程,直到父子節點之間滿足剛說的那種大小關係。程式碼如下:

public class Heap {
  private int[] a; // 陣列,從下標1開始儲存資料
  private int n;  // 堆可以儲存的最大資料個數
  private int count; // 堆中已經儲存的資料個數

  public Heap(int capacity) {
    a = new int[capacity + 1];
    n = capacity;
    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 個節點的完全二叉樹,樹的高度不會超過 log2n。堆化的過程是順著節點所在路徑比較交換的,所以堆化的時間複雜度跟樹的高度成正比,也就是 O(logn)。插入資料和刪除堆頂元素的主要邏輯就是堆化,所以,往堆中插入一個元素和刪除堆頂元素的時間複雜度都是 O(logn)。

3 如何基於堆實現排序?

  我們再來回憶一下幾種排序演算法,有時間複雜度是 O(n2) 的氣泡排序、插入排序、選擇排序,有時間複雜度是 O(nlogn) 的歸併排序、快速排序,還有線性排序。

  這裡我們藉助於堆這種資料結構實現的排序演算法,就叫做堆排序。這種排序方法的時間複雜度非常穩定,是 O(nlogn),並且它還是原地排序演算法。如此優秀,它是怎麼做到的呢?

  我們可以把堆排序的過程大致分解成兩個大的步驟,建堆和排序。

3.1 建堆

  我們首先將陣列原地建成一個堆。所謂“原地”就是,不借助另一個數組,就在原陣列上操作。建堆的過程,有兩種思路。

  第一種是藉助我們前面講的,在堆中插入一個元素的思路。儘管陣列中包含 n 個數據,但是我們可以假設,起初堆中只包含一個數據,就是下標為 1 的資料。然後,我們呼叫前面講的插入操作,將下標從 2 到 n 的資料依次插入到堆中。這樣我們就將包含 n 個數據的陣列,組織成了堆。

  第二種實現思路,跟第一種截然相反,也是我這裡要詳細講的。第一種建堆思路的處理過程是從前往後處理陣列資料,並且每個資料插入堆中時,都是從下往上堆化。而第二種實現思路,是從後往前處理陣列,並且每個資料都是從上往下堆化。

  我舉了一個例子,並且畫了一個第二種實現思路的建堆分解步驟圖,你可以看下。因為葉子節點往下堆化只能自己跟自己比較,所以我們直接從第一個非葉子節點開始,依次堆化就行了。

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;
  }
}

  現在,我們來看,建堆操作的時間複雜度是多少呢?每個節點堆化的時間複雜度是 O(logn),那 2n+1 個節點堆化的總時間複雜度是不是就是 O(nlogn) 呢?這個答案雖然也沒錯,但是這個值還是不夠精確。實際上,堆排序的建堆過程的時間複雜度是 O(n)。

3.2 排序

  建堆結束之後,陣列中的資料已經是按照大頂堆的特性來組織的。陣列中的第一個元素就是堆頂,也就是最大的元素。我們把它跟最後一個元素交換,那最大元素就放到了下標為 n 的位置。

  這個過程有點類似上面講的“刪除堆頂元素”的操作,當堆頂元素移除之後,我們把下標為 n 的元素放到堆頂,然後再通過堆化的方法,將剩下的 n−1 個元素重新構建成堆。堆化完成之後,我們再取堆頂的元素,放到下標是 n−1 的位置,一直重複這個過程,直到最後堆中只剩下標為 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,2,4,8 的元素,而不是像快速排序那樣,區域性順序訪問,所以,這樣對 CPU 快取是不友好的。

  第二點,對於同樣的資料,在排序過程中,堆排序演算法的資料交換次數要多於快速排序。我們在講排序的時候,提過兩個概念,有序度和逆序度。對於基於比較的排序演算法來說,整個排序過程就是由兩個基本的操作組成的,比較和交換(或移動)。快速排序資料交換的次數不會比逆序度多。