哈夫曼樹學習筆記

DengStar發表於2024-07-24

哈夫曼樹學習筆記

定義

設二叉樹具有 \(n\) 個帶權葉結點,從根結點到各葉結點的路徑長度與相應葉節點權值的乘積之和稱為 樹的帶權路徑長度(Weighted Path Length of Tree,WPL)

\(w_i\) 為二叉樹第 \(i\) 個葉結點的權值,\(l_i\) 為從根結點到第 \(i\) 個葉結點的路徑長度,則 WPL 計算公式如下:

\[WPL = \sum_{i = 1}^{n} w_i l_i \]

WPL 有什麼用呢?我們可以用兩種方式理解 WPL。

  1. 合併果子)考慮這樣的一棵二叉樹:它每個節點的權值都是兩個子節點的權值之和。我們可以證明:這棵二叉樹的 WPL 就是它所有非葉節點的權值之和。而這棵二叉樹又可以和這樣的情景對應:假設你有 \(n\) 堆蘋果,每堆蘋果有 \(w_i\) 個。你可以進行 \((n-1)\) 次操作,每次操作把相鄰的兩堆蘋果合併成一堆,消耗的體力是兩堆蘋果的數量之和。不難看出,經過 \((n-1)\) 次操作後,恰好剩下一堆蘋果。

    可以發現,每個合併蘋果的操作序列,都可以和一棵上述的二叉樹對應:二叉樹的 \(n\) 個葉節點的權值分別對應一開始每堆蘋果的個數,非葉節點的權值代表合併兩個子節點消耗的體力。一開始,每堆蘋果可以看作是隻有一個節點的二叉樹。選擇兩堆蘋果合併,就相當於把兩顆二叉樹的根節點連到一個新建的父節點上,這個父節點的權值就是它兩個子節點的權值之和。這樣,操作消耗的總體力就是二叉樹所有非葉節點的權值之和。上文已經說明,這個值就是二叉樹的 WPL。所以,想要最小化消耗的體力,就是要在給定的權值序列上建立一棵二叉樹,使得該樹的葉節點的權值與原序列的權值一一對應,並且該樹的 WPL 最小。(實際上就是這道題:P1090 合併果子

  2. 最優字首編碼)略

這樣的 WPL 最小的樹,就是哈夫曼樹。注意,哈夫曼樹的定義是基於某個權值序列來說的:我們是在某個權值序列上構建哈夫曼樹。

哈夫曼樹的構建及最優性證明

構建

  1. 初始化:由給定的 \(n\) 個權值構造 \(n\) 棵只有一個根節點的二叉樹,得到一個二叉樹集合 \(\mathcal{F}\)
  2. 選取與合併:從二叉樹集合 \(\mathcal{F}\) 中選取根節點權值 最小的兩棵 二叉樹分別作為左右子樹構造一棵新的二叉樹,這棵新二叉樹的根節點的權值為其左、右子樹根結點的權值和。
  3. 刪除與加入:從 \(\mathcal{F}\) 中刪除作為左、右子樹的兩棵二叉樹,並將新建立的二叉樹加入到 \(\mathcal{F}\) 中。
  4. 重複 2、3 步,當集合中只剩下一棵二叉樹時,這棵二叉樹就是霍夫曼樹。

該圖片展示了由權值序列 2,4,5,3 構建哈夫曼樹的過程

最優性證明

咕咕咕……

程式碼實現

這裡,我們不建樹,只求 WPL。

\(O(n \log n)\) 方法

使用一個優先佇列維護每棵二叉樹的根節點的權值,每次彈出堆頂的兩個元素,把這兩個元素的和加入佇列,同時統計答案。

這個方法比較簡單,不再贅述。

cin >> n;
for(int i = 1, x; i <= n; i++)
{
    cin >> x;
    que.push(x);
}

while(que.size() >= 2)
{
    ll a, b;
    a = que.top(); que.pop();
    b = que.top(); que.pop();
    ans += a + b;
    que.push(a + b);
}

cout << ans << endl;

AC 記錄(P1090)

\(O(n)\) 方法

上一種方法中的時間複雜度瓶頸來自優先佇列,這次我們不用它了,而用兩個佇列來代替它。

沒有優先佇列,怎麼保證每次取出的元素是兩個最小的元素呢?

先考慮初始化的問題。顯然,一開始我們還是要保證佇列中的元素是遞增的。在值域較小時,我們可以採用桶排,這樣就做到了 \(O(n)\) 排序。(值域很大怎麼辦?可以使用基數排序,但我還不會)

然後我們採用如下演算法:

  1. 把排序後的初始元素依次插入佇列 1 中。此時佇列 2 為空。

  2. 每次從佇列 1 和佇列 2 首共彈出兩個元素,使得這兩個元素的和最小。分三種情況:

    • 從佇列 1 首彈出兩個元素。
    • 從佇列 2 首彈出兩個元素。
    • 從佇列 1 首和佇列 2 首分別彈出一個元素。

    分類討論即可。

  3. 計算這兩個元素的和,並插入佇列 2 尾。同時答案加上這個和。

  4. 重複步驟 2、3 \((n-1)\) 次。

這個演算法與 \(O(n \log n)\) 的演算法的區別主要在於第 2 步。要證明它的正確性,只需證明第 2 步中彈出的兩個元素就是最小的兩個元素即可。

由於一開始,我們就已將元素排序再插入進佇列 1 中,而之後我們不會向佇列 1 中插入任何元素,所以佇列 1 中元素時刻都是遞增的。而由於我們每次彈出的是最小的兩個元素,所以越往後,彈出元素的和是遞增的。我們把彈出元素的和插入到佇列 2 中,所以佇列 2 中元素也是時刻保持遞增的。加上第 2 步中我們討論了三種情況,這就保證了彈出的兩個元素一定是最小的兩個元素。故演算法的正確性得證。

cin >> n;
for(int i = 1, x; i <= n; i++)
{
    read(x);
    cnt[x]++;
}

for(int i = 1; i < MAXV; i++)
    while(cnt[i]) que1.push(i), cnt[i]--;

for(int i = 1; i < n; i++)
{
    ll a, b;
    if((!que1.empty() && que1.front() < que2.front()) || que2.empty())
        a = que1.front(), que1.pop();
    else a = que2.front(), que2.pop();
    if((!que1.empty() && que1.front() < que2.front()) || que2.empty())
        b = que1.front(), que1.pop();
    else b = que2.front(), que2.pop();

    que2.push(a + b), ans += a + b;
}

cout << ans << endl;

AC 記錄(P6033)

\(k\) 叉哈夫曼樹

哈夫曼樹也可以推廣到 \(k\) 叉的情況。

考慮 \(k\) 進位制下的最優字首編碼問題:每個單詞用一個 \(k\) 進位制程式碼表示,求出最短的編碼方案。這等價於在一個權值序列上建立一棵 \(k\) 叉樹,使得 WPL 最小。

這個問題幾乎可以完全套用二叉哈夫曼樹的構建方法,只需要把每次合併兩棵樹改成每次合併 \(k\) 棵樹即可。但有個小問題:合併到最後,可能只剩下不到 \(k\) 棵樹,於是最終的樹的根節點度數小於 \(k\),這是不優的:任取某個葉節點改為根的子節點,都會使 WPL 變小。

解決這個問題的方法是在權值序列中加入若干個 \(0\),使得最後恰好剩下 \(k\) 棵樹。加入多少個 \(0\) 呢?我們每次彈出 \(k\) 棵樹,加入 \(1\) 棵樹,相當於每次操作後樹的數量減少了 \(k-1\)。而最後恰好留下一棵樹,所以一開始應該滿足 \((n - 1) \bmod (k-1) = 0\)

如果在滿足 WPL 最小的情況下,還要讓最深的葉節點的深度儘可能小,應該怎麼辦?

在優先佇列中,還要根據每棵樹的深度排序:如果根節點權值不同,優先合併權值小的;如果權值相同,優先合併深度小的。但我還沒搞懂為什麼……

AC 記錄(P2168)

參考資料

  1. OI wiki - 霍夫曼樹
  2. CSDN - 哈夫曼樹構造過程及最優證明

相關文章