哈夫曼樹學習筆記
定義
設二叉樹具有 \(n\) 個帶權葉結點,從根結點到各葉結點的路徑長度與相應葉節點權值的乘積之和稱為 樹的帶權路徑長度(Weighted Path Length of Tree,WPL)。
設 \(w_i\) 為二叉樹第 \(i\) 個葉結點的權值,\(l_i\) 為從根結點到第 \(i\) 個葉結點的路徑長度,則 WPL 計算公式如下:
WPL 有什麼用呢?我們可以用兩種方式理解 WPL。
-
(合併果子)考慮這樣的一棵二叉樹:它每個節點的權值都是兩個子節點的權值之和。我們可以證明:這棵二叉樹的 WPL 就是它所有非葉節點的權值之和。而這棵二叉樹又可以和這樣的情景對應:假設你有 \(n\) 堆蘋果,每堆蘋果有 \(w_i\) 個。你可以進行 \((n-1)\) 次操作,每次操作把相鄰的兩堆蘋果合併成一堆,消耗的體力是兩堆蘋果的數量之和。不難看出,經過 \((n-1)\) 次操作後,恰好剩下一堆蘋果。
可以發現,每個合併蘋果的操作序列,都可以和一棵上述的二叉樹對應:二叉樹的 \(n\) 個葉節點的權值分別對應一開始每堆蘋果的個數,非葉節點的權值代表合併兩個子節點消耗的體力。一開始,每堆蘋果可以看作是隻有一個節點的二叉樹。選擇兩堆蘋果合併,就相當於把兩顆二叉樹的根節點連到一個新建的父節點上,這個父節點的權值就是它兩個子節點的權值之和。這樣,操作消耗的總體力就是二叉樹所有非葉節點的權值之和。上文已經說明,這個值就是二叉樹的 WPL。所以,想要最小化消耗的體力,就是要在給定的權值序列上建立一棵二叉樹,使得該樹的葉節點的權值與原序列的權值一一對應,並且該樹的 WPL 最小。(實際上就是這道題:P1090 合併果子)
-
(最優字首編碼)略
這樣的 WPL 最小的樹,就是哈夫曼樹。注意,哈夫曼樹的定義是基於某個權值序列來說的:我們是在某個權值序列上構建哈夫曼樹。
哈夫曼樹的構建及最優性證明
構建
- 初始化:由給定的 \(n\) 個權值構造 \(n\) 棵只有一個根節點的二叉樹,得到一個二叉樹集合 \(\mathcal{F}\)。
- 選取與合併:從二叉樹集合 \(\mathcal{F}\) 中選取根節點權值 最小的兩棵 二叉樹分別作為左右子樹構造一棵新的二叉樹,這棵新二叉樹的根節點的權值為其左、右子樹根結點的權值和。
- 刪除與加入:從 \(\mathcal{F}\) 中刪除作為左、右子樹的兩棵二叉樹,並將新建立的二叉樹加入到 \(\mathcal{F}\) 中。
- 重複 2、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 中。此時佇列 2 為空。
-
每次從佇列 1 和佇列 2 首共彈出兩個元素,使得這兩個元素的和最小。分三種情況:
- 從佇列 1 首彈出兩個元素。
- 從佇列 2 首彈出兩個元素。
- 從佇列 1 首和佇列 2 首分別彈出一個元素。
分類討論即可。
-
計算這兩個元素的和,並插入佇列 2 尾。同時答案加上這個和。
-
重複步驟 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)
參考資料
- OI wiki - 霍夫曼樹
- CSDN - 哈夫曼樹構造過程及最優證明