斐波那契堆——算法導論(26)

1. 寫在前面

在很久之前學習過這種數據結構。這次再來學習一種比較特別的“堆”——斐波那契堆。下文首先會介紹斐波那契堆的結構,然後會介紹在其上的操作,最後再分析這些操作的效率,以及一些理論的證明。

2. 結構

斐波那契堆是一系列具有最小堆序的有根樹的集合,即斐波那契堆中的每棵樹均遵循最小堆性質

所謂最小堆性質是指:樹中的每個結點的關鍵字大於或等於它的父結點(若存在)的關鍵字。具有最小堆性質的堆,我們稱之爲最小堆

舉個栗子,下圖便是一個斐波那契堆

斐波那契堆

從圖中可以看出,它是由5個最小堆組成的,每個堆的根節點以鏈表的形式相互連接。

以上便是斐波那契堆的基本結構,除此之外,它還具有如下特點:

  1. 每個最小堆中的每一個結點\(x\)包含一個指向它父結點的指針\(x.p\)和一個指向它某一孩子結點的指針\(x.child\)
  2. 每個結點\(x\)的所有孩子被鏈接成一個環形的雙向鏈表(稱爲\(x\)孩子鏈表(child list)),即\(x\)的每一個孩子\(y\)均有指針\(y.left, y.right\)分別指向它的左兄弟和右兄弟。特別地,如果孩子鏈表中只有一個結點,則\(y.left, y.right\)指向自己,即\(y.left = y.right = y\)
  3. 每個結點\(x\)還具有另外兩個屬性:一個是\(x.degree\),表示自己孩子的數目。另一個是一個布爾類型的屬性\(x.mark\),表示結點\(x\)自從上一次成爲某一個結點的孩子後,是否失去過孩子。
  4. 對於一個給定的斐波那契堆\(H\),它有一個屬性\(min\),指向具有最小關鍵字的最小堆的根結點(稱爲斐波那契堆的最小結點(minimum node))。
  5. 所有組成斐波那契堆的最小堆的根結點也鏈接形成一個雙向鏈表,它稱爲斐波那契堆的根鏈表(root list)

因此,若要完整的把之前圖中所示的斐波那契堆的的結點關係畫出來,結果如下:

3. 操作

斐波那契堆支持可合併堆操作。所謂可合併堆(mergeable),是指支持以下5種操作的一種數據結構:

  1. make-heap():創建和返回一個新的不含任何元素的堆;
  2. insert(H, x):將元素\(x\)插入堆\(H\)中;
  3. minimum(H):返回堆H中具有最小關鍵字的結點;
  4. extract-min(H):從堆H中刪除具有最小關鍵字的結點並返回;
  5. union(\(H_1, H_2\)):創建並返回一個包含堆\(H_1\)和堆\(H_2\)的中所有元素的新堆。

除此之外,斐波那契堆還支持以下兩種操作:

  1. decrease-key(H, x, k):將堆H中元素x的關鍵字賦予新值k(k不大於當前的關鍵字)。
  2. delete(H, x):從堆中刪除元素x。

下面用Java來實現斐波那契堆。

3.1 創建一個新的斐波那契堆

首先定義出斐波那契堆的數據結構:

/**
 * 斐波那契堆
 */
public class FibonacciHeap<T extends Comparable<T>> {
    private int size; // 堆中元素的個數
    private Node<T> minNode; // 指向堆中的最小元素
    
    /**
    * 堆節點 
    */
    private static class Node<T extends Comparable> {
        Node<T> parent;
        List<Node<T>> children;
        Node<T> left; // 左兄弟
        Node<T> right; // 右兄弟
        int degree;
        boolean mark;
        T data;
        
        Node(T data) {
            this.data = data;
            left = this;
            right = this;
        } 
    }
}

其中Node靜態內部類是來封裝我們插入的數據和維護數據之間的關聯關係,在學習基本數據結構鏈表或者二叉樹時應該接觸過,接下來實現各操作。

3.2 insert

插入結點的算法很簡單,就是將結點插入根鏈表中,然後更新最小根節點(minNode)。更新的邏輯是:若此時堆是空的,直接將minNode指向插入的節點即可;若堆非空,我們將節點插入到最小節點的左邊,再重新判斷最小節點。

下面Java實現給出代碼:

/**
 * 插入數據
 * @param data
 */
public void insert(T data) {
    if (data == null) {
        throw new NullPointerException("不能插入null值");
    }
    Node<T> node = new Node<>(data);
    if (minNode == null) {
        minNode = node;
    } else {
        node.insertLeftOf(minNode);
        if (data.compareTo(minNode.data) < 0)
            minNode = node;
    }
    size++;
}

insertLeftOf方法用於將某節點插入到指定節點的左邊,它定義在Node內部類中,如下:

private static class Node<T extends Comparable<T>> {
    // 省略成員變量以及其他方法...
    
    private void insertLeftOf(Node<T> node) {
        left = node.left;
        right = node;
        node.left.right = this;
        node.left = this;
    }   
}

3.3 minimum

minimum方法用於返回堆中具有最小關鍵字的結點。由於我們用一個叫做minNode的變量指向堆中的最小節點,因此minimum過程只需返回該變量封裝的數據即可:

/**
 * 獲取最小的數據
 * @return 最小的數據
 */
public T minimum() {
    return minNode == null ? null : minNode.data;
}

3.4 extractMin

extractMin方法用於刪除堆中具有最小關鍵字的結點,並返回。extractMin是斐波那契堆中複雜的操作(其實也並不複雜)。它分以下幾個操作步驟完成:

  1. 首先把最小節點的所有子節點移動到根鏈表中,具體移動到根鏈表中的哪個位置不做要求;接着從根鏈表中刪除最小節點。
  2. 判斷原先堆中是否只有一個元素,若是,那麼操作到此結束;否則繼續下一步。
  3. 將根鏈表中度數相同的節點進行合併。合併的方式是將兩個度數相同的節點中數據較大那個從根鏈表中移除,掛載到較小的那個節點上,成爲其子節點。

步驟3可能會產生疑惑。下面用一個實際的例子幫助理解。比如下圖是給出的一個斐波那契堆,我們要對其進行extractMin操作。

第一步:將最小節點(3)的子節點(18, 52, 38)移動到根鏈表中(這裏採取將它們移到最小節點的左邊),然後從根鏈表中移除最小節點。刪除後暫時將minNode指針指向被刪除的最小節點的右兄弟節點。這樣得到下圖:

第二步:顯然判斷的結果是要執行第三步。

第三步:合併根鏈表中度數相同的節點。顯然我們需要統計根鏈表中每個節點的度數,於是選用一個數組來保存統計結果,並且約定,該數組存放的是節點的指針,而數組下標的值表示對應節點的度數;接下來可以開始統計合併了。從min節點開始,向右遍歷根鏈表。在遍歷的每輪中,首先以當前遍歷節點的度數爲下標去數組中查找,若該位置是空的,說明遍歷到現在還沒有出現過該度數的節點,直接將當前節點存放到數組中;若該下標對應的位置不是空的,說明當前節點和數組中該下標對應的位置中的節點度數相同,因此要合併這兩個節點。

回到上面的例子,我們從臨時最小根節點(17)開始從左往右遍歷根鏈表。第一輪,節點17的度數爲1,而數組中下標爲1的位置爲空,因此,直接將節點的指針(引用)存放到數組的1號位置:

接着遍歷到節點24,其度數爲2,2號位置也是空的,直接將節點24放到2號位置:

下一輪遍歷需要回到鏈表的頭部,即到了節點23,以其度數爲下標的位置是空的,操作同上:

再接下來是節點7,注意其度數爲0,而數組中0號位置已經有了節點23,這說明它們的度數都爲0,需要合併。合併時,比較它們數據的大小,顯然7小於23,因此將23掛到7下作爲子節點:

注意,在合併後節點7的度數加了1,變爲了1,需要繼續判斷數組的1號位置,1號位置存放的是節點17,因此需要繼續合併節點7和節點17。再之後的工作就和上述一致了,這裏就不在一一贅述了,直接給出每步操作後的圖:

下面給出該 操作的Java代碼:

/**
 * 取出最小數據
 *
 * @return 若堆不爲空,返回堆中最小數據;否則返回空。
 */
public T extractMin() {
    if (minNode == null) {
        return null;
    }
    T min = minNode.data;
    if (minNode.children != null) {
        for (Node<T> child : minNode.children) {
            // 將子節點插入到根鏈表中
            child.insertLeftOf(minNode);
            child.parent = null;
        }
    }
    minNode.removeSelfFromSiblings();
    if (minNode.right == minNode) {
        minNode = null;
    } else {
        minNode = minNode.right;
        consolidate();
    }
    size--;
    return min;
}
/**
 * 合併根鏈表,使每一個根鏈表都有不同的度數。
 */
private void consolidate() {
    Node<T>[] array = new Node[(int) (Math.log(size) / LOG_Φ)];
    Node<T> endNode = minNode.left;
    Node<T> currentNode = minNode.left, nextNode = minNode;
    do {
        currentNode = nextNode;
        nextNode = currentNode.right;
        Node<T> max = null, min = currentNode;
        while ((max = array[min.degree]) != null) {
            // 存在相同度數的根節點,將較大的節點掛載到較小的節點上(作爲較小的節點的子節點)
            if (max.data.compareTo(min.data) < 0) {
                Node<T> temp = min;
                min = max;
                max = temp;
            }
            max.removeSelfFromSiblings();
            min.addChild(max);
            array[min.degree - 1] = null;
        }
        array[min.degree] = min;
    } while (currentNode != endNode);
    // 下面從根列表中重新選出最小節點
    minNode = null;
    for (Node<T> node : array) {
        if (node == null) {
            continue;
        }
        if (minNode == null || node.data.compareTo(minimum()) < 0) {
            minNode = node;
        }
    }
}
private static class Node<T extends Comparable<T>> {
    /**
     * 將自己從所在的鏈表中移除
     */
    private void removeSelfFromSiblings() {
            left.right = right;
            right.left = left;
    }
}

以上其實還隱藏着一個問題:我們需要爲統計數組分配多大的長度?由於我們把數組的下標作爲節點的度數,因此數組的長度必須不小於根鏈表中節點的最大度數(設爲\(D\))。顯然\(D\)是小於堆的總節點數size的,但更加緊確地,我們可以證明\(D\le log_\theta\)size,這裏\(\theta\)是黃金分割率,爲\((1 + \sqrt5) / 2=1.61803...\),這個在最後證明。

3.5 union

union操作比較簡單,只需要將待合併的兩個斐波拉契堆的根鏈表連起來,重新計算size和minNode即可。

/**
 * 合併倆FibonacciHeap
 */
public static <T extends Comparable<T>> FibonacciHeap<T> union(FibonacciHeap<T> heap1, FibonacciHeap<T> heap2) {
    FibonacciHeap<T> heap = new FibonacciHeap<T>();
    heap.size = heap1.size + heap2.size;
    heap.minNode = heap1.minNode;
    if (heap1.minNode == null || heap2.minNode != null && heap2.minNode.data.compareTo(heap1.minNode.data) < 0) {
        heap.minNode = heap2.minNode;
    }
    return heap;
}

3.6 decrease和delete

decrease操作即爲將某個節點的值(data字段)減小爲某一個值。可以想象,將一個節點的值減小和其子節點的值是不衝突的,但可能和其父節點的值產生衝突。爲解決這一衝突,我們可以將修改的這個節點移動到根鏈表中,然後再做一個“級聯剪切”工作,最後重新選出最小節點。具體操作過程以僞代碼的形式給出:

感興趣的童鞋可以自己實現一下,這裏就不給出具體實現了。

同樣下面貼出一個列子:

有了減小節點數據的操作,實現刪除節點的功能就簡單了。只需要將要刪除的節點的值減到無窮小,這樣minNode指針就自然地指向到了它,接着調用之前實現的extractMin方法就能將數據去除。

貼出僞代碼,同樣不實現了:

以上便是斐波那契堆支持的主要操作,下面來分析各個操作的時間效率。

4. 效率

4. 1 攤還分析

下面對斐波那契堆進行攤還分析。之所以關注攤還分析的結果,而不是關注每一個單獨的操作的效率,是因爲斐波那契堆的優勢在於其各個操作的攤還代價較低。我們用勢能法來分析攤還代價。

首先定義勢函數\(\theta(H) = t(H) + 2m(H)\),其中\(t(H)\)表示堆中根鏈表中節點的個數;\(m(H)\)表示節點中已標記的節點的節點的數目。顯然初始時\(\theta\)爲0,並且在之後的任意時刻勢\(\theta\)都是不爲負的,因此對於某一操作序列來說,總的攤還代價的上界就是其總的實際代價的上界。

  1. 對於insert操作,實際代價爲\(O(1)\),勢變化爲1,我們可以很容易計算出其攤還代價爲\(O(1) + 1 = O(1)\)
  2. 對於minimum操作,攤還代價就等於實際代價爲\(O(1)\)
  3. 對於union操作,實際代價爲\(O(1)\),勢變化爲0,因此攤還代價爲\(O(1)\)
  4. 對於extractMin操作,實際代價爲\(O(D(H))\),其中\(D(H)\)爲堆H中根鏈表中節點的最大度。(之前在介紹extractMin操作時,其實有一個問題沒有解決,就是那個用來記錄度是否重複的數組的長度問題,即這裏的\(D(H)\)。我們之後再介紹計算\(D(H)\)的方法,這裏先直接給出它的上界爲\(O(ln n)\)。)勢變化爲\(O(D(H))\),因此攤還代價爲\(O(H)\)
  5. 對於decreaseKey操作,其攤還代價至多爲\(O(1)\);而delete方法的攤還代價爲\(O(ln n)\)

4. 2 關於最大度數的界

要證明

\(D(H) \leq \lfloor log_\Phi n \rfloor\),其中\(\Phi\)黃金分割率\((\sqrt 5 + 1) / 2 = 0.618\)

需要先證明如下幾個引理。

引理1. 設x是斐波那契堆中的任意節點,並假定\(x.degree = k\)。用\(y_1, y_2, ..., y_k\)表示x的k個孩子,它們是以鏈入x的順序排列的,則\(y_1.degree \geq 0\),且對於\(i = 1, 2, ..., k\),有\(y_i.degree \geq i - 2\)

證明:顯然\(y_1.degree \geq 0\);對於\(i \geq 2\),當\(y_i\)被鏈接爲\(x\)的孩子時,\(x\)已經有\(y_1, y_2, ... y_{i-1}\) \(i-1\)個孩子,因此此時一定有\(x.degree \geq i-1\),並且此時一定有\(x.degree = y_i.degree\)(這是進行合併操作的前提),而且在合併之後,\(y_i\)一定至多失去一個孩子(若失去兩個孩子,它將被從x中剪切掉(cascading-cut)),得證。

引理2. 對於所有整數\(k \geq 0\)\(F_{k+2} = 1 + \sum_{i = 0} ^ kF_i\),其中\(F_k\)爲斐波那契數列中的第k個數,即

\[ F_k = \begin{cases} 0 & k = 0,\\ 1 & k = 1, \\ F_{k-1} + F_{k-2} & k \geq 2 \end{cases} \]

證明:整個證明比較簡單,用數學歸納法就能搞定,此處略。

引理3. 對於所有整數\(k \geq 0\),斐波那契數列的第\(k+2\)個數滿足\(F_{k+2} \geq \Phi ^k\)

證明:同樣是數學歸納法證明,略。

引理4. 設x是斐波拉契堆中的任意節點,並設\(k = x.degree\),則有\(size(x) \geq F_{x+2} \geq \Phi ^k\),其中 \(\Phi = (\sqrt 5 + 1) / 2,size(x)\)表示以x爲根的子樹中包括x本身在內的節點個數。

證明:\(s_k\)表示斐波那契堆中度數爲\(k\)的任意節點\(x\)的最小可能size,一般地,\(s_0=1, s_1=2\) 。顯然,\(s_k\)\(k\)單調遞增。設\(size(x)\)表示以\(x\)爲根節點的子樹的節點個數(包括\(x\)自身)。顯然\(size(x) \ge s_k\)。設\(d(x)\)表示節點\(x\)的度數。

對於任意節點\(x\),記\(d(x) = k\),用\(y_1, y_2, ..., y_k\)表示x的k個孩子,它們是以鏈入x的順序排列的,將\(x\)本身和其第一個孩子\(y_1\)單獨計數,則有:
\[ size(x) \ge s_k \ge 2 +\sum_{i=2}^k s_{d(y_i)} \ge 2 + \sum_{i=2}^k s_{i-2} \]
其中最後一個不等式由引理1\(s_k\)的單調遞增性得到。

有了以上不等式的基礎,我們用數學歸納法可證明\(s_k \ge F_{k+2}\)(其中還用到了引理2),再由引理3,最終有:\(size(x) \geq F_{x+2} \geq \Phi ^k\).

最終,根據引理4,有\(size \ge size(x) \ge \Phi ^k\),於是我們可推出\(k \le log_\Phi n\),這表示任意節點的最大度數\(D\)\(O(lgn)\)

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