左 0 右 1 发"门牌号"——常用字母门牌短,报文又短又不会读串
上一课建好了哈夫曼树,这一课给它发门牌号:规定往左走记 0、往右走记 1,从根走到每个字母叶子的路上经过的方向,连起来就是这个字母的编码。就像小区地址:"3 号楼 → 左拐 → 右拐",走到门口刚好是 "3-左-右"。门牌越短的楼,是住得越"热闹"(出现越频繁)的楼。
更妙的是:哈夫曼树的每个字母都在叶子上,所以一个字母的门牌号绝不会是另一个字母门牌号的"开头"——送信的人读 01 串时永远不会读串!这叫前缀码。
沿用上一课的树(A=1、B=4、C=6、D=8、E=10)。边上标好左 0 右 1,每个字母的"门牌号"(编码)就出来了。
规则一句话:往左走记 0,往右走记 1,从根走到叶子,把路上的记号连起来就是编码。
逐个读(对照大图左边的树):
先想一个反面例子。假如给字母发这样的编码:A = 0,B = 01,C = 1。收到 01 时,是"A + …"还是"B"?分不清!因为 A 的码 0 是 B 的码 01 的开头(前缀),读到 0 就不知道该停还是继续。
哈夫曼码没这个问题,因为所有编码都只在叶子上:任何一条"根到叶子"的路,中途不会经过另一个叶子。翻译成编码语言:没有一个编码是另一个编码的前缀——这叫前缀码(也叫前缀-free 码)。
验证上图编码表:000、001、01、10、11 互相看看,谁也不在谁的开头(01 不是 001 的前缀,因为 01 ≠ 00…)。所以读 011101 时:先 01(C)→ 再 11(E)→ 再 01(C),一路无歧义,还原出 "C E C"。
编码(发报):把每个字母查表换成 01 串,首尾相接。例:发 "D E A B" → 10 11 000 001 → 1011000001(10 个比特)。
解码(收报):拿 1011000001 在树上走:1→右、0→左 = 10(D),踩到叶子输出 D 回根;1→右、1→右 = 11(E),输出 E 回根;0→左、0→左、0→左 = 000(A),输出 A 回根;0→左、0→左、1→右 = 001(B),输出 B。得到 "D E A B",一个不错!
算省了多少:定长码给 5 个字母每人 3 位(2²=4 不够、2³=8 才够),发 4 个字母要 12 位;哈夫曼码只要 2+2+3+3 = 10 位,省 2 位。字母表越大、频率越悬殊,省得越多。
建树(复用上一课)→ DFS 生成每个字母的编码 → 编码报文 → 在树上走一遍解码,全流程一个文件搞定。
// 哈夫曼编码:建树 → 生成编码表 → 编/解码 #include <bits/stdc++.h> using namespace std; struct Node { int w, ch, l, r; // 权重/字母编号/左右孩子(0 = 无) Node(int weight, int c) : w(weight), ch(c), l(0), r(0) {} }; int main() { int freq[] = {1, 4, 6, 8, 10}; // A B C D E int n = 5; vector<Node> pool; priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq; for (int i = 0; i < n; i++) { pool.push_back(Node(freq[i], i)); pq.push({freq[i], i}); } while (pq.size() > 1) { int a = pq.top().second; pq.pop(); // 较轻放左 int b = pq.top().second; pq.pop(); pool.push_back(Node(pool[a].w + pool[b].w, -1)); int id = (int)pool.size() - 1; pool[id].l = a; pool[id].r = b; pq.push({pool[id].w, id}); } int root = pq.top().second; // DFS 生成编码表:左 0 右 1 vector<string> code(n); function<void(int, string&)> dfs = [&](int id, string &s) { if (pool[id].ch >= 0) { code[pool[id].ch] = s; return; } s.push_back('0'); dfs(pool[id].l, s); s.pop_back(); s.push_back('1'); dfs(pool[id].r, s); s.pop_back(); }; string path; dfs(root, path); for (int i = 0; i < n; i++) cout << (char)('A'+i) << " = " << code[i] << endl; // A = 000 / B = 001 / C = 01 / D = 10 / E = 11 // 编码报文 "CEC" string msg = "CEC", out; for (char c : msg) out += code[c - 'A']; cout << "encode: " << out << endl; // 011101 // 解码:沿树走,踩到叶子输出字母、回根 string bits = out, plain; int cur = root; for (char b : bits) { cur = (b == '0') ? pool[cur].l : pool[cur].r; if (pool[cur].ch >= 0) { plain += (char)('A' + pool[cur].ch); cur = root; } } cout << "decode: " << plain << endl; // CEC return 0; }
push_back('0')、回来 pop_back(),忘删就把 0 带进右子树了。② 解码时踩到叶子必须立刻回根,否则下一个字母会接着上一段路走。③ 平手谁左谁右影响编码外观、不影响码长和 WPL——题目若指定"轻左重右"要照做,否则答案可能对不上。