CCF 壓縮編碼
一、試題
問題描述
給定一段文字,已知單詞a1, a2, …, an出現的頻率分別t1, t2, …, tn。可以用01串給這些單詞編碼,即將每個單詞與一個01串對應,使得任何一個單詞的編碼(對應的01串)不是另一個單詞編碼的字首,這種編碼稱為字首碼。
使用字首碼編碼一段文字是指將這段文字中的每個單詞依次對應到其編碼。一段文字經過字首編碼後的長度為:
L=a1的編碼長度×t1+a2的編碼長度×t2+…+ an的編碼長度×tn。
定義一個字首編碼為字典序編碼,指對於1 ≤ i < n,ai的編碼(對應的01串)的字典序在ai+1編碼之前,即a1, a2, …, an的編碼是按字典序升序排列的。
例如,文字E A E C D E B C C E C B D B E中, 5個單詞A、B、C、D、E出現的頻率分別為1, 3, 4, 2, 5,則一種可行的編碼方案是A:000, B:001, C:01, D:10, E:11,對應的編碼後的01串為1100011011011001010111010011000111,對應的長度L為3×1+3×3+2×4+2×2+2×5=34。
在這個例子中,如果使用哈夫曼(Huffman)編碼,對應的編碼方案是A:000, B:01, C:10, D:001, E:11,雖然最終文字編碼後的總長度只有33,但是這個編碼不滿足字典序編碼的性質,比如C的編碼的字典序不在D的編碼之前。
在這個例子中,有些人可能會想的另一個字典序編碼是A:000, B:001, C:010, D:011, E:1,編碼後的文字長度為35。
請找出一個字典序編碼,使得文字經過編碼後的長度L最小。在輸出時,你只需要輸出最小的長度L,而不需要輸出具體的方案。在上面的例子中,最小的長度L為34。
輸入格式
輸入的第一行包含一個整數n,表示單詞的數量。
第二行包含n個整數,用空格分隔,分別表示a1, a2, …, an出現的頻率,即t1, t2, …, tn。請注意a1, a2, …, an具體是什麼單詞並不影響本題的解,所以沒有輸入a1, a2, …, an。
輸出格式
輸出一個整數,表示文字經過編碼後的長度L的最小值。
樣例輸入
5
1 3 4 2 5
樣例輸出
34
樣例說明
這個樣例就是問題描述中的例子。如果你得到了35,說明你算得有問題,請自行檢查自己的演算法而不要懷疑是樣例輸出寫錯了。
評測用例規模與約定
對於30%的評測用例,1 ≤ n ≤ 10,1 ≤ ti ≤ 20;
對於60%的評測用例,1 ≤ n ≤ 100,1 ≤ ti ≤ 100;
對於100%的評測用例,1 ≤ n ≤ 1000,1 ≤ ti ≤ 10000。
二、程式碼
拿到題目看字首編碼,首先想到的肯定是赫爾曼樹(最優二叉樹),但是題目中要求按字典序。由於按字典序我們二叉樹的葉子節點需要剛好按照輸入的節點順序排列,不能改動,所以就強行按照輸入的節點順序生成二叉樹,最後得到結果35(錯誤)。錯誤程式碼如下:
#include<cstdio>
#include<vector>
using namespace std;
int main(){
int n;
vector<int> t;
int cost = 0;
int minw;
vector<int> ::iterator left;
scanf("%d", &n);
for(int i=0; i<n; i++){
int cint;
scanf("%d", &cint);
t.push_back(cint);
cost += cint;
}
while(t.size()!= 1){
for(vector<int>::iterator it = t.begin(); it!= t.end()-1; it++){
if(it==t.begin()){
minw = *it+ *(it+1 );
left = it;
}
int a = *it + *(it+1);
if( *it + *(it+1) < minw ){
minw = *it+ *(it+1);
left = it;
}
}
*left = minw;
cost += minw;
t.erase(left+1);
}
if(n==1){
printf("%d", t[0]);
}else{
printf("%d", cost-t[0]);
}
return 0;
}
那麼為啥不能呢?原因在於赫爾曼樹每次要求取得是兩個最小權值的樹組成二叉樹,這兩個最小權值在所有權值排列中沒有位置限制。而由於本題要求只能按字典序,一開始葉子節點的位置就已經排列好了,這也就是要求我們只能取相鄰兩個最小權值的組成二叉樹。所以形成上面錯誤程式碼。那麼就是說單純使用赫爾曼樹不行。但是其給我們一個思路,從底往上建樹。由於葉子節點已經排列好,那麼我們可以使用動態規劃建樹。建樹過程如下表(為啥使用表,因為其key值剛好可以對應我們狀態方程的key):
上表中,斜對線的1,3,4,2,5是輸入的葉子節點權值,然後首先計算相鄰兩個葉子節點組成樹的長度分別為4,7,6,7。然後考慮3個相鄰葉子節點組成長度為,以1,3,4節點為例,可以用1和(3,4)組合和(1,3)和4組合兩種方式,選取最小長度,如此反覆就能算出最終答案。轉換成二叉樹形式就是:
上圖就是生成的最優結果,每個節點內值表示底下幾個葉子節點的組合生成的最小長度。從中我們可以看出每一個節點的最小長度是它的左右子節點最小長度和加上所有葉子節點權值。可以理解為左右子節點已經把其各自的葉子結點的長度計算好,現在要把左右子節點組合成一個新的樹,那麼對於其底下的葉子結點而言都只是在原有基礎上增加一倍權值。所以動態規劃的最小長度狀態方程為:
tree[i][j] = tree[i][k] + tree[k+1][j] + w[j] - w[i-1] (i<=k<=j)。其中w[j]表示前j個數組成的權值和。
最終程式碼:
#include<cstdio>
using namespace std;
// 注意後面由於直接用到某些數值,且初始賦值tree[i][i]為0,所以聲明瞭全域性變數。如果是區域性變數在使用前需要先賦值。
int w[1050];
int tree[1050][1050];
int main(){
int n;
int t[1050];
scanf("%d", &n);
for(int i=1; i<=n; i++){
scanf("%d", &t[i]);
w[i] = w[i-1] + t[i];
}
// 遍歷方格一半(除去對角線),則外層需要遍歷n-1次。
for(int j=2; j<=n; j++){
// 沿斜對角線遍歷,[1][2],[2][3],[3][4],[4][5],[1][3],[2][4],[3][5]...例如[3][5]剛好對應於將將第三到第五數字進行建樹的最小長度。
for(int row=1,col=j; row<=n-j+1; row++,col++){
// 對當前要計算的小長度首先賦值為最大值。
tree[row][col] = 1<<30;
// 迴圈遍歷檢測選擇哪兩顆子樹形成的長度最小。
for(int k=row; k<col; k++){
int temCost = tree[row][k] + tree[k+1][col] + w[col] - w[row-1];
if(temCost<tree[row][col]){
tree[row][col] = temCost;
}
}
printf("%d%s", tree[row][col],"\n");
}
}
printf("%d", tree[1][n]);
return 0;
}