詳解Java 堆排序

詳解Java 堆排序

本文我們學習Java中如何實現堆排序。堆排序基於堆數據結構,爲了更好地理解堆排序,我們首先深入堆數據結構及其實現。

1. 堆數據結構

堆是特殊的基於樹的數據結構。因此其由節點組成,給節點賦值元素,每個節點恰好包括一個元素。節點能有子節點,沒有任何子節點的節點爲葉子節點。堆的特殊之處有兩點規則:

  1. 每個節點值必須小於等於所有子節點的值
  2. 堆是完全樹,即其高度最小

有第一條規則得到最小元素總是樹的根節點。如何實現這些規則取決於實現。
堆通常用於實現優先隊列,因爲抽取最小或最多元素的實現非常有效。

1.1. 堆變體

堆有很多變體,主要差異體現在實現細節上。例如上面描述的最小堆,父節點總是小於其所有子節點。同樣,我們也能定義最大堆,即父節點總是大於其子節點,因此最大元素是根節點。

可以選擇多種樹的實現,最直接是二叉樹。二叉樹每個節點最多有兩個子節點,稱爲左節點和右節點。

執行第二條規則最簡單方式是使用完全二叉樹。完全二叉樹符合下面簡單規則:

  1. 如果節點有一個子節點,應該爲左子節點
  2. 只有最底層最右邊的節點可以有一個子節點
  3. 葉子節點只能在最深級

下面看一些示例檢驗這些規則:


1        2      3        4        5        6         7         8        9       10
()       ()     ()       ()       ()       ()        ()        ()       ()       ()
        /         \     /  \     /  \     /  \      /  \      /        /        /  \
       ()         ()   ()  ()   ()  ()   ()  ()    ()  ()    ()       ()       ()  ()
                               /          \       /  \      /  \     /        /  \
                              ()          ()     ()  ()    ()  ()   ()       ()  ()
                                                                            /
                                                                           ()

1、2、4、5、7符合以上規則。
3和6違反第一條規則,8和9違反第二條規則,10不符合第三條規則。接下來主要聚集基於二叉樹實現最小堆。

1.2. 插入元素

應該基於一種方式實現插入操作,始終保持堆不變。基於此通過重複執行插入操作構件堆。所以我們看單個插入操作。插入一個元素的步驟爲:

  1. 創建一個葉子節點作爲樹最深級上最右邊節點並存儲元素至節點中
  2. 如何元素小於父節點,則交換兩個節點值
  3. 繼續第二步,直到元素小於其父節點或該節點稱爲新的根節點

注意,第二步不違反堆規則,因爲如果使用最小值交換,直到父節點成爲所有子節點的最小值。請看示例,插入4至堆中:


     2
    / \
   /   \
  3     6
 / \
5   7

第一步創建葉子節點存儲4:

     2
    / \
   /   \
  3     6
 / \   /
5   7 4

4小於父節點6,需要交換:

     2
    / \
   /   \
  3     4
 / \   /
5   7 6

現在檢測4是否小於其父節點,父節點爲2,停止交換插入完畢,爲有效堆。下面插入1:

     2
    / \
   /   \
  3     4
 / \   / \
5   7 6   1

交換1和4:

     2
    / \
   /   \
  3     1
 / \   / \
5   7 6   4

繼續交換1和2:

     1
    / \
   /   \
  3     2
 / \   / \
5   7 6   4

1爲新的根節點,插入完成。

2. Java實現堆

我們使用完全二叉樹,可以使用數組實現。數組中的元素作爲樹節點。使用數組索引從左到右標記每個節點,從頂到底遵循下列方式:

     0
    / \
   /   \
  1     2
 / \   /
3   4 5

我們唯一需要做的是跟蹤我們在樹中存儲了多少元素。這樣,我們要插入的下一個元素的索引將是數組的大小。使用索引可以計算父節點和子節點的索引:

  • 父節點: (index – 1) / 2
  • 左子節點: 2 * index + 1
  • 右子節點: 2 * index + 2

如果不想總是重建數組,可以簡化實現直接使用ArrayList。基於二叉樹實現如下:

class BinaryTree<E> {
 
    List<E> elements = new ArrayList<>();
 
    void add(E e) {
        elements.add(e);
    }
 
    boolean isEmpty() {
        return elements.isEmpty();
    }
 
    E elementAt(int index) {
        return elements.get(index);
    }
 
    int parentIndex(int index) {
        return (index - 1) / 2;
    }
 
    int leftChildIndex(int index) {
        return 2 * index + 1;
    }
 
    int rightChildIndex(int index) {
        return 2 * index + 2;
    }
 
}

上面代碼僅增加元素至樹末尾。因此如果需要向上遍歷新元素,使用下面代碼實現:

class Heap<E extends Comparable<E>> {
 
    // ...
 
    void add(E e) {
        elements.add(e);
        int elementIndex = elements.size() - 1;
        while (!isRoot(elementIndex) && !isCorrectChild(elementIndex)) {
            int parentIndex = parentIndex(elementIndex);
            swap(elementIndex, parentIndex);
            elementIndex = parentIndex;
        }
    }
 
    boolean isRoot(int index) {
        return index == 0;
    }
 
    boolean isCorrectChild(int index) {
        return isCorrect(parentIndex(index), index);
    }
 
    boolean isCorrect(int parentIndex, int childIndex) {
        if (!isValidIndex(parentIndex) || !isValidIndex(childIndex)) {
            return true;
        }
 
        return elementAt(parentIndex).compareTo(elementAt(childIndex)) < 0;
    }
 
    boolean isValidIndex(int index) {
        return index < elements.size();
    }
 
    void swap(int index1, int index2) {
        E element1 = elementAt(index1);
        E element2 = elementAt(index2);
        elements.set(index1, element2);
        elements.set(index2, element1);
    }
     
    // ...
 
}

需要對元素進行比較,故實現java.util.Comparable接口。

3. 堆排序

因爲堆根元素總是最小元素,堆排序實現思路非常簡單:刪除根節點直到堆爲空。

我們唯一需要實現的是刪除操作,需始終保持堆的狀態一致,確保不違反二叉樹的結構,即堆的特性。

爲了保持結構,每次刪除根節點元素,並存儲最右邊的葉子節點至根節點。但該操作很可能違反堆屬性,因爲如果新根節點大於任何子節點,需要和最小子節點進行交換。最小子節點是所有其他子節點中最小的,所有不會違反堆屬性。
一直交換直到元素成爲葉子節點或小於所有子節點。請看示例:

     1
    / \
   /   \
  3     2
 / \   / \
5   7 6   4

放最後元素在根節點上:

     4
    / \
   /   \
  3     2
 / \   /
5   7 6

4大於兩個子節點,需要和兩者最小的進行交換,即2:

     2
    / \
   /   \
  3     4
 / \   /
5   7 6

4小於6,操作結束。

3.1. 堆排序實現

利用前節代碼,刪除根節點方法(pop)如下:

class Heap<E extends Comparable<E>> {
 
    // ...
 
    E pop() {
        if (isEmpty()) {
            throw new IllegalStateException("You cannot pop from an empty heap");
        }
 
        E result = elementAt(0);
 
        int lasElementIndex = elements.size() - 1;
        swap(0, lasElementIndex);
        elements.remove(lasElementIndex);
 
        int elementIndex = 0;
        while (!isLeaf(elementIndex) && !isCorrectParent(elementIndex)) {
            int smallerChildIndex = smallerChildIndex(elementIndex);
            swap(elementIndex, smallerChildIndex);
            elementIndex = smallerChildIndex;
        }
 
        return result;
    }
     
    boolean isLeaf(int index) {
        return !isValidIndex(leftChildIndex(index));
    }
 
    boolean isCorrectParent(int index) {
        return isCorrect(index, leftChildIndex(index)) && isCorrect(index, rightChildIndex(index));
    }
     
    int smallerChildIndex(int index) {
        int leftChildIndex = leftChildIndex(index);
        int rightChildIndex = rightChildIndex(index);
         
        if (!isValidIndex(rightChildIndex)) {
            return leftChildIndex;
        }
 
        if (elementAt(leftChildIndex).compareTo(elementAt(rightChildIndex)) < 0) {
            return leftChildIndex;
        }
 
        return rightChildIndex;
    }
     
    // ...
 
}

如前面分析,排序是通過不斷刪除根節點從新創建一個堆:

class Heap<E extends Comparable<E>> {
 
    // ...
 
    static <E extends Comparable<E>> List<E> sort(Iterable<E> elements) {
        Heap<E> heap = of(elements);
 
        List<E> result = new ArrayList<>();
 
        while (!heap.isEmpty()) {
            result.add(heap.pop());
        }
 
        return result;
    }
     
    static <E extends Comparable<E>> Heap<E> of(Iterable<E> elements) {
        Heap<E> result = new Heap<>();
        for (E element : elements) {
            result.add(element);
        }
        return result;
    }
     
    // ...
 
}

我們能測試是否正確:

@Test
void givenNotEmptyIterable_whenSortCalled_thenItShouldReturnElementsInSortedList() {
    // given
    List<Integer> elements = Arrays.asList(3, 5, 1, 4, 2);
     
    // when
    List<Integer> sortedElements = Heap.sort(elements);
     
    // then
    assertThat(sortedElements).isEqualTo(Arrays.asList(1, 2, 3, 4, 5));
}

請注意,我們可以提供另一個就地排序實現,這意味着只用一個數組獲得結果。通過這種方式不需要任何中間內存分配。但這種實現可能比較難以理解。

3.2. 時間複雜度

堆排序由兩個關鍵步驟,插入元素和刪除根節點。兩個步驟的實際複雜度都爲O(log n)。因爲需要重複兩個步驟n次,整個排序複雜度爲O(n log n)。

我們沒有提到數組重新分配的成本,但是因爲它是O(n),所以它不會影響整體的複雜性。另外,正如我們前面提到的,可以實現就地排序,這意味着不需要重新分配數組。同樣值得一提的是,50%的元素是葉子,75%的元素位於兩個最下面的層次。因此,大多數插入操作只需要兩個步驟。

注意,在實際數據中,快速排序通常比堆排序性能更好。最壞情況下,堆排序的時間複雜度總是O(n log n)。

4. 總結

本文我們介紹了堆數據結構及其實現。因爲其時間複雜度爲O(n log n),實際上並不是最好的排序算法,但在優先隊列場景中經常使用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章