1. 程式人生 > >哈夫曼樹(Huffman-Tree)的構造及應用

哈夫曼樹(Huffman-Tree)的構造及應用

  本文以學習筆記的性質談一談哈夫曼樹較為嚴謹的貪心做法。

哈夫曼樹的構造

  有這樣一棵k叉樹,它的葉子節點有權值,第i個葉子節點權值為wi(wi>0),他的深度為li,要求最小化wili,這樣問題的解稱為k叉哈夫曼樹。
  為了最小化wili,那肯定要讓小的數儘量深,那麼我們可以想到一個貪心做法:
  1、以權值建立一個小根堆,每次堆中取出最小的k個數,記他們的和為q,將q累加進ans中
   2、將q重新放入堆中,在樹中我們相當於將權值為q的點作為這k個點的父親
   3、把1 2 步驟重複做,直到堆中只剩一個元素(w1+w2+…+wn) 此時的ans就是答案

這裡就可以有值得困惑的地方,為什麼要這麼做呢?

  這裡寫圖片描述

  在這一棵樹中我們把第4層的的1+2=3加入多叉樹作為他們的父親,那麼在計算第3層的時候我們又會把1和2的影響通過3+3=6來累加進ans,相當於把1+2算了兩遍,同理在第二層和第一層的計算中我們都會加入1和2的影響,那麼最終他們就會被算四次,相當於他們的深度,也就是wili

  那麼理解了這個之後我們發現這個在k>2的時候是錯誤的,原因顯而易見,當我們的演算法進行到最後一步的時候,假如堆內剩餘的元素不足k個,也不足以單獨作為根(2~k-1)之間,那麼第二層的節點就會不足k個,這樣的wil

i在n>k(即樹的層數>2)的時候絕對不是最小的,因為此刻把任意一個二層以下的節點放到不滿的二層ans都會變小。可以畫個三叉樹理解一下。
  
  那該怎麼辦呢??我們發現,這個貪心不成立僅當這個樹並不是一個滿k叉樹(是每個非葉子節點都有滿k個孩子的那種),所以才有第二層的枝杈不齊的情況,那麼我們把他們補成一個滿k叉樹呢?

  沒錯!我們可以補上一些數使他成為一個滿k叉樹,那麼補什麼數呢?我們應該補一個儘量小的數,把葉子節點儘量頂上去(因為下面越少越好),那麼因為wi>0,所以我們就補0!

  那麼我們就在原來貪心演算法的基礎上,在一開始的堆中加入0直到堆中元素個數

n和樹的杈數k滿足(n1)mod(k1)=0
  
  這個式子可以適用於所有滿k叉樹的情況。 那麼我再來解釋一下這個式子如何來,我有一個感性的證明。
  這裡寫圖片描述

  我們以這個樹為例,我們想象一下,把這個0的節點代表3這個數提上去,提到第三層,然後在提上去,然後這時候0在第三層,再提上去第二層,最後提上去根。 那麼這個0提上去後每層都會有2個(3-1個)孩子,這時候的葉子節點數也少了一個,那麼就是(n-1)%(k-1)=0
 
 那麼上文提到的貪心演算法,在(n1)mod(k1)的時候為正確,當初始的n不滿足這個性質時,我們可以通過補0的方式使得n滿足這個性質。 從而在O(n)的時間裡完成構造。

哈夫曼樹的應用

講完了哈夫曼樹的構造,那麼接下來,我們通過兩道例題來了解哈夫曼樹的應用:

合併果子

[NOIP2004]

描述
  在一個果園裡,多多已經將所有的果子打了下來,而且按果子的不同種類分成了不同的堆。多多決定把所有的果子合成一堆。

   每一次合併,多多可以把兩堆果子合併到一起,消耗的體力等於兩堆果子的重量之和。可以看出,所有的果子經過n-1次合併之後,就只剩下一堆了。多多在合併果子時總共消耗的體力等於每次合併所耗體力之和。

  因為還要花大力氣把這些果子搬回家,所以多多在合併果子時要儘可能地節省體力。假定每個果子重量都為1,並且已知果子的種類數和每種果子的數目,你的任務是設計出合併的次序方案,使多多耗費的體力最少,並輸出這個最小的體力耗費值。

  例如有3種果子,數目依次為1,2,9。可以先將1、2堆合併,新堆數目為3,耗費體力為3。接著,將新堆與原先的第三堆合併,又得到新的堆,數目為12,耗費體力為12。所以多多總共耗費體力=3+12=15。可以證明15為最小的體力耗費值。

輸入格式
  輸入包括兩行,第一行是一個整數n(1<=n<=10000),表示果子的種類數。第二行包含n個整數,用空格分隔,第i個整數ai(1<=ai<=20000)是第i種果子的數目。

輸出格式
  輸出包括一行,這一行只包含一個整數,也就是最小的體力耗費值。輸入資料保證這個值小於2^31。
 
思路
  因為每次合併需要再次加入影響,我們需要讓數量少的果子堆儘量先合併,每次可以合併兩堆果子,需要求代價最小。不難發現,這樣合併的過程,實際上就是一個二叉哈夫曼樹。

  那麼我們就可以使用上文的貪心演算法來求解(不用補0也可以)

code:

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
#include<vector>;
#include<cstdlib>
using namespace std;
priority_queue<int ,vector<int> ,greater<int> > q;
int main()
{
    int n;scanf("%d",&n);
    for(int i=1;i<=n;i++)
    {
        int x; scanf("%d",&x);
        q.push(x);
    }
    int ans=0;
    while(n>1)
    {
        int t1=q.top(); q.pop();
        int t2=q.top(); q.pop();
        ans+=t1+t2;
        q.push(t1+t2);
        n--;
    }
    printf("%d\n",ans);
    return 0;
}

荷馬史詩

[NOI2015]

描述
  追逐影子的人,自己就是影子。 ——荷馬

  Allison 最近迷上了文學。她喜歡在一個慵懶的午後,細細地品上一杯卡布奇諾,靜靜地閱讀她愛不釋手的《荷馬史詩》。但是由《奧德賽》和《伊利亞特》組成的鴻篇鉅製《荷馬史詩》實在是太長了,Allison 想通過一種編碼方式使得它變得短一些。
  一部《荷馬史詩》中有 n 種不同的單詞,從 1 到 n 進行編號。其中第 i 種單詞出現的總次數為 wi。Allison 想要用 k 進位制串 si 來替換第 i 種單詞,使得其滿足如下要求:
  對於任意的 1≤i,j≤n,i≠j,都有:si 不是 sj 的字首。
  現在 Allison 想要知道,如何選擇 si,才能使替換以後得到的新的《荷馬史詩》長度最小。在確保總長度最小的情況下,Allison 還想知道最長的 si 的最短長度是多少?
  一個字串被稱為 k 進位制字串,當且僅當它的每個字元是 0 到 k−1 之間(包括 0 和 k−1)的整數。
  字串 Str1 被稱為字串 Str2 的字首,當且僅當:存在 1≤t≤m,使得 Str1=Str2[1..t]。其中,m 是字串 Str2 的長度,Str2[1..t] 表示 Str2 的前 t 個字元組成的字串。
  
輸入格式
  輸入檔案的第 1 行包含 2 個正整數 n,k,中間用單個空格隔開,表示共有 n 種單詞,需要使用 k 進位制字串進行替換。
  接下來 n 行,第 i+1 行包含 1 個非負整數 wi,表示第 i 種單詞的出現次數。

輸出格式
  輸出檔案包括 2 行:
  第 1 行輸出 1 個整數,為《荷馬史詩》經過重新編碼以後的最短長度。
  第 2 行輸出 1 個整數,為保證最短總長度的情況下,最長字串 si 的最短長度。

思路
  相對與上一題而言,這題的哈夫曼樹的模型就顯得比較隱晦。
  
  題目中最重要的一條資訊是:

對於任意的 1≤i,j≤n,i≠j,都有:si 不是 sj 的字首。

  那麼如果我們把所有的si插進Tire樹,如果需要任意的兩個單詞狀態節點互相不為字首,由Tire性質可以得到所有的單詞狀態節點都不在非葉子節點(如果他在非葉子節點的話他表示的單詞一定是他下面的點表示單詞的字首),那麼這樣剛好滿足了哈夫曼樹的性質。

  那麼k進位制的數就代表著一個數會有k個叉(0~k-1),單詞的出現次數就可以看作權值,那麼我們就把問題轉化為了k叉哈夫曼樹問題。

  這樣就可以解決第一問了,但是還有第二問,我們需要讓最長的si深度最小,我們只需要在重定義運算子的時候,對於權值相同的點,優先考慮深度較小(合併次數儘量小)的進行合併即可。

code:

#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
#include<vector>
using namespace std;
typedef long long ll;
const int N=100010;
struct node
{
    int dep; ll val;
    friend bool operator <(const node &x,const node &y)
    {
        if(x.val!=y.val) return x.val>y.val;
        else return x.dep>y.dep;
    }
};
priority_queue<node> q;
int main()
{
    // freopen("epic.in","r",stdin);
    // freopen("epic.ans","w",stdout);
    int n,k;scanf("%d%d",&n,&k);
    for(int i=1;i<=n;i++)
    {
        ll x;scanf("%lld",&x);
        node p; p.val=x; p.dep=1;
        q.push(p);
    }
    if((n-1)%(k-1)!=0)
    {
        int tt=(n-1)%(k-1);
        tt=k-1 - tt; n+=tt;
        for(int i=1;i<=tt;i++)
        {
            node p; p.val=0,p.dep=1;
            q.push(p);
        }
    }

    ll ans1=0; int ans2=1;
    while(n>1)
    {
        node tmp; ll sum=0; int mxdp=1;
        for(int i=1;i<=k;i++)
        {
            tmp=q.top(); q.pop();
            sum+=tmp.val;
            mxdp=max(mxdp,tmp.dep);
        }
        node p; p.val=sum,p.dep=mxdp+1;
        q.push(p);
        ans1+=sum;
        ans2=max(ans2,mxdp);
        n-=k-1;
    }
    printf("%lld\n%d\n",ans1,ans2);
    return 0;
}