PHP實現Huffman編碼/解碼
Huffman 編碼是一種資料壓縮演算法。我們常用的 zip 壓縮,其核心就是 Huffman 編碼,還有在 HTTP/2 中,Huffman 編碼被用於 HTTP 頭部的壓縮。
本文就來用 PHP 來實踐一下 Huffman 編碼和解碼。
1. 編碼
字數統計
Huffman編碼的第一步就是要統計文件中每個字元出現的次數,PHP的內建函式 count_chars()
就可以做到:
$input = file_get_contents('input.txt');
$stat = count_chars($input, 1);
構造Huffman樹
接下來根據統計結果構造Huffman樹,構造方法在
$huffmanTree = [];
foreach ($stat as $char => $count) {
$huffmanTree[] = [
'k' => chr($char),
'v' => $count,
'left' => null,
'right' => null,
];
}
// 構造樹的層級關係,思想見wiki:https://zh.wikipedia.org/wiki/%E9%9C%8D%E5%A4%AB%E6%9B%BC%E7%BC%96%E7%A0%81
$size = count($huffmanTree);
for ($i = 0; $i !== $size - 1; $i++) {
uasort($huffmanTree, function ($a, $b) {
if ($a['v'] === $b['v']) {
return 0;
}
return $a['v'] < $b['v'] ? -1 : 1;
});
$a = array_shift($huffmanTree);
$b = array_shift($huffmanTree );
$huffmanTree[] = [
'v' => $a['v'] + $b['v'],
'left' => $b,
'right' => $a,
];
}
$root = current($huffmanTree);
經過計算之後,$root
就會指向 Huffman 樹的根節點
根據Huffman樹生成編碼字典
有了 Huffman 樹,就可以生成用於編碼的字典:
function buildDict($elem, $code = '', &$dict) {
if (isset($elem['k'])) {
$dict[$elem['k']] = $code;
} else {
buildDict($elem['left'], $code.'0', $dict);
buildDict($elem['right'], $code.'1', $dict);
}
}
$dict = [];
buildDict($root, '', $dict);
寫檔案
運用字典將檔案內容進行編碼,並寫入檔案。將Huffman編碼寫入檔案的有幾個注意的地方:
將編碼字典和編碼內容一起寫入檔案後,就沒法區分他們的邊界了,因此需要在檔案開始寫入他們各自佔用的位元組數
PHP提供的
fwrite()
函式一次能寫入 8-bit(一個位元組)或者是 8的整數倍個bit。但Huffman編碼中,一個字元可能只使用 1-bit 表示,PHP不支援只往檔案中寫入 1-bit 這種操作。所以需要我們自行對編碼進行拼接,每湊齊 8-bit 才寫入檔案。
- 與第二條類似,最終形成的檔案大小一定是 8-bit 的整數倍。所以如果整個編碼的大小是 8001-bit的話,還要在末尾補上 7個 0
$dictString = serialize($dict);
// 寫入字典和編碼各自佔用的位元組數
$header = pack('VV', strlen($dictString), strlen($input));
fwrite($outFile, $header);
// 寫入字典本身
fwrite($outFile, $dictString);
// 寫入編碼的內容
$buffer = '';
$i = 0;
while (isset($input[$i])) {
$buffer .= $dict[$input[$i]];
while (isset($buffer[7])) {
$char = bindec(substr($buffer, 0, 8));
fwrite($outFile, chr($char));
$buffer = substr($buffer, 8);
}
$i++;
}
// 末尾的內容如果沒有湊齊 8-bit,需要自行補齊
if (!empty($buffer)) {
$char = bindec(str_pad($buffer, 8, '0'));
fwrite($outFile, chr($char));
}
fclose($outFile);
解碼
Huffman編碼的解碼相對簡單:先讀取編碼字典,然後根據字典解碼出原始字元。
解碼過程有個問題需要注意:由於我們在編碼過程中,在檔案末尾補齊了幾個0-bit,如果這些 0-bit 在字典中恰巧是某個字元的編碼時,就會造成錯誤的解碼。
所以解碼過程中,當已解碼的字元數達到文件長度時,就要停止解碼。
<?php
$content = file_get_contents('a.out');
// 讀出字典長度和編碼內容長度
$header = unpack('VdictLen/VcontentLen', $content);
$dict = unserialize(substr($content, 8, $header['dictLen']));
$dict = array_flip($dict);
$bin = substr($content, 8 + $header['dictLen']);
$output = '';
$key = '';
$decodedLen = 0;
$i = 0;
while (isset($bin[$i]) && $decodedLen !== $header['contentLen']) {
$bits = decbin(ord($bin[$i]));
$bits = str_pad($bits, 8, '0', STR_PAD_LEFT);
for ($j = 0; $j !== 8; $j++) {
// 每拼接上 1-bit,就去與字典比對是否能解碼出字元
$key .= $bits[$j];
if (isset($dict[$key])) {
$output .= $dict[$key];
$key = '';
$decodedLen++;
if ($decodedLen === $header['contentLen']) {
break;
}
}
}
$i++;
}
echo $output;
試驗
我們將Huffman編碼Wiki頁 的HTML程式碼儲存到本地,進行Huffman編碼測試,試驗結果:
編碼前: 418,504 位元組
編碼後: 280,127 位元組
空間節省了 33%,如果原文的重複內容較多,Huffman編碼節省的空間可以達到 50% 以上.
除了文字內容,我們再嘗試將一個二進位制檔案進行Huffman編碼,比如 f.lux的安裝程式,試驗結果如下:
編碼前: 770,384 位元組
編碼後: 773,076 位元組
編碼後反而佔用了更大的空間,一方面是由於我們儲存字典時,並沒有做額外的處理,佔用了不少空間。另一方面,二進位制檔案中,各個字元出現的概率相對比較平均,無法發揮Huffman編碼的優勢。