二叉堆

codercat發表於2019-07-05

二叉堆

說明

在閱讀該文章的時候,最好手中有一隻紙和筆能夠畫出二叉堆的結構,會更加容易理解。

二叉樹的定義

二叉樹是每個結點最多有兩個子樹的樹結構。通常子樹被稱作左子樹右子樹

二叉堆的定義

當一顆二叉樹的根節點都大於等於(小於等於)它的子節點時,它被稱為二叉堆。 如果一個二叉堆的根節點大於等於它的所有子節點,稱為最大堆。 如果一個二叉堆的根節點小於等於它的所有子節點,稱為最小堆。

完全二叉樹的定義

二叉堆是一顆完全二叉樹即當一顆子樹存在右節點的時候這顆子樹一定存在左節點。我們會看到這顆二叉樹的節點都是從左往右連續排列的。對於完全二叉樹更加嚴謹的定義為:

若設二叉樹的深度為h,除第 h 層外,其它各層 (1~h-1) 的結點數都達到最大個數,第h層所有的結點都連續集中在最左邊。

二叉堆的儲存

由於二叉堆是一顆完全二叉樹我們可以使用一個陣列來儲存,陣列的每個元素就是二叉堆中的各個節點。這是因為完全二叉樹的節點從左至右是連續的,如果不是連續的話使用陣列來儲存就會浪費一些空間。
當要儲存有N個節點的二叉堆時,一般會用長度為N+1的陣列來儲存這顆二叉堆。陣列索引為1元素就是二叉堆的根節點。索引0的位置就空著不用,這是為了方便計算每顆子樹的子節點和父節點的索引。

如果樹的某個節點索引是k那麼:

  • 該節點的父親節點索引:k/2 (向下取整)

  • 該節點的左子節點索引:2k+1

  • 該節點的右子節點索引: 2k+2

左子節點索引

unsigned getLeftChildIndex(unsigned index) {
    return index * 2 + 1;
}

右子節點索引

unsigned getRightChildIndex(unsigned index) {
    return this->getLeftChildIndex(index) + 1;
}

父節點索引

unsigned getParentIndex(unsigned index) {
    return index / 2; // C++除法如果兩邊都是整型的話預設向下取整
}

二叉堆的操作

下面的操作都是以最大堆為二叉堆,即二叉堆的根節點大於等於它的所有子節點。最小堆的操作跟最大堆是一樣的,只是節點間的比較不同而已。

shiftUp(上移操作)

如果一個最大堆因為一些操作使得某個節點比它的父節點還要大,此時這顆二叉樹就不再滿足最大堆的性質。要做的操作是把該節點與它的父節點交換位置來保證以交換位置後的該節點為根節點的這顆子樹滿足最大堆的性質。這個時候可能會想到如果新的根節點的另外一個子節點比這個新的根節點還要大怎麼辦?為什麼不跟另外一個子節點也進行一次比較操作呢? 答案是因為原來的堆是滿足最大堆的性質的,所以另外一個子節點一定小於等於原來的父節點,也就一定會小於新的父親節點,所以不用進行比較。交換後新的父節點還是有可能比這個新的父節點的父節點還要大,那麼就再重複一次之前的操作,這樣一層一層向上移動直到堆頂或者遇到的節點不再比它的父節點大為止。整個過程就是shiftUp操作。

具體程式碼

void shiftUp(unsigned index) {
    // 
    while ((index > 1) && this->container[index] >= this->container[getParentIndex(index)]) {
        swap(this->container[index], this->container[getParentIndex(index)]);
        index = index / 2;
    }
}

while中的index > 1表示該索引不是堆頂索引,this->container[index] >= this->container[index / 2]表示該節點大於它的父親節點。只要滿足這2個條件就交換位置,同時把索引設成原來節點的父節點,一樣一層一層向上移動,直到不滿足這2條件中的任意一個就退出。

shiftDown(下移操作)

如果一個最大堆因為一些操作使得某個節點比它的子節點還要小,此時這顆二叉樹就不再滿足最大堆的性質。要做的操作是把這個節點與它子節點中最大的那個節點交換位置。交換後的這個節點的位置可能還是比它的子節點小,那麼就重複執行之前的操作一層一層向下移動。直到到達堆底或是遇到的節點不再比它的子節點小。該過程就是shiftDown操作。

具體程式碼

void shiftDown(unsigned index = 1) {
    // 判斷左子節點的索引是否超過了陣列的大小,如果超過了,就代表該節點沒有左子節點.
    while(this->getSize() >= getLeftChildIndex(index)) { // 如果該節點存在左子節點
        unsigned maxChildIndex = getLeftChildIndex(index); // 暫時把最大的節點設為左子節點
        if (this->getSize() >= getRightChildIndex(index)) { // 如果該節點存在右子節點
            if (this->container[getRightChildIndex(index)] > this->container[getLeftChildIndex(index)]) { // 如果右子節點比左子節點大就把最大的子節點設為右子節點.
                maxChildIndex = getRightChildIndex(index);
            }
        }
        if ((this->container[index] >= this->container[maxChildIndex])) { // 如果當前節點大於等於它最大的子節點,那麼就沒有打破最大堆的性質,所以直接break掉就好了.
            break;
        }
        // 如果當前節點存在一個子節點比它還要大,那麼就交換這2個節點的位置,同時把當前迴圈的索引設為新的根節點的索引然後繼續向下檢查.
        swap(this->container[index], this->container[maxChildIndex]);
        index = maxChildIndex;
    }
}

該具體具體的程式碼實現思路就是先判斷該節點是否存在左子節點,如果存在就暫時把最大的節點設為左子節點。然後判斷該節點存在右子節點。如果存在的話就判斷一下這2個子節點哪一個大,把大的那個設為該節點的最大子節點,再來判斷一下當前節點是否大於等於這個找出來的大的子節點,如果成立就退出,因為並沒有違背最大堆的性質。不成立的話就說明當前節點存在一個子節點比它還要大,那麼就交換這2個節點的位置,同時把當前迴圈的索引設為新的根節點的索引然後繼續向下檢查。

取出最大值

由於最大堆的堆頂存放的是這個堆的最大值,所以從堆中取出最大值就是取出堆頂的元素。取出堆頂元素後會打破最大堆的定義,所以我們一般的操作是把堆底的元素移動到堆頂。然後再對堆頂的位置做shiftDown操作就可以恢復最大堆的定義。

具體程式碼

int extractMax() {
    int root = this->container[1]; // 陣列索引1的位置存放的是堆頂的元素
    int tail = this->container[this->getSize()]; // 取出堆底的元素
    // 把堆底的元素放到堆頂然後再對堆頂元素做shiftDown操作
    this->container[1] = tail;
    this->shiftDown(1);
    // 維護堆的大小
    this->size --;
    return root;
}

插入節點

往堆中插入節點的時候直接把新的節點放在堆底後一個節點的位置,然後對新的堆底的位置做shifUp操作。

具體程式碼

void insert(int element) {
    // 把新的元素放在堆底後一個節點的位置
    this->container[this->getSize() + 1] = element;
    // 對新的堆底的位置做shifUp操作
    this->shiftUp(this->getSize() + 1);
    // 維護堆的大小
    this->size ++;
}

heapify

這個操作可以把一個陣列構建成一個最大堆。 就是把任意一個陣列通過heapify的操作,可以讓其滿足二叉堆的性質。

我們為了讓整個二叉樹都滿足最大堆的性質,我們首先要保證這顆二叉樹的任意一顆子樹滿足最大堆的性質也就是父節點大於等於它的子節點。這也體現了分而治之的思想。 所以我們要把一個陣列構建成一個最大堆就需要先保證每個節點都要大於等於它的子節點。我們可以選擇從陣列的最後一個元素開始依次往回做shiftDown操作,這樣就保證了不會有某個節點小於等於它的子節點。但是我們發現如果某個節點是一個葉子節點的話那麼它根本不需要做任何操作,因為它沒有子節點。所以後來優化之後我們可以從最後一個擁有子節點的節點開始往回shiftDown。最後一個擁有子節點的節點也被稱為非葉子節點或是最後一個父節點它的索引就是堆底的父節點。

具體實現

heapify(int *arr, unsigned size) {
    // size 就是堆底的位置,它的父節點就是最後一個擁有子節點的節點
    for (int i = this->getParentIndex(size); i >= 1; i --) {
        this->shiftDown(i);
    }
}

相容最小堆和最大堆

有時候我們需要使用最小堆,但有時候我們又需要使用最大堆,但是它們兩個往往程式碼幾乎一模一樣只是比較符號的不同,為了複用程式碼,我們只需要給二叉堆設定一個比較方法即可,比如在初始化最大堆的時候給它傳入一個方法,這個方法就用來做比較操作。 當二叉堆中需要做比較的時候呼叫該方法即可。

如果是一個最小堆,我們的比較方法如下:

bool compare(int a, int b) {
    return a < b;
}

堆中相應地方呼叫的時候:

if (this->compare(this->container[getRightChildIndex(index)], this->container[getLeftChildIndex(index)])) {
// ........
}

結束語

該文章對堆的性質和常用操作做了闡述,文中的程式碼使用了C++,資料結構與演算法的重點在於思想和邏輯所以大家完全可以在瞭解了演算法思想之後用自己熟悉的任意一門語言來實現。後面還會出一篇關於二叉堆應用和複雜度分析的文章。

完整的程式碼:https://github.com/acodercat/cpp-algorithm...

程式碼測試:https://github.com/acodercat/cpp-algorithm...

倉庫中的二叉堆是從索引0開始的,所以在父節點索引計算上會有不同。

該倉庫還有其他演算法與資料結構的相應實現

相關文章