Safe Or Unsafe

OJ: HDU

题目 ID: 2527

标签: 哈夫曼编码模板

日期: 2026-01-01 10:49

这道题目是经典的 哈夫曼编码 (Huffman Coding) 问题。

题目核心解析

题目要求判断给定字符串经过哈夫曼编码后的总长度(所占空间)是否小于等于给定的安全值 mm

哈夫曼编码的原理

  1. 频率越高的字符,编码越短:为了让总长度最小,出现次数多的字符应该用更少的比特位(bit)表示。
  2. 贪心算法:每次从所有当前的节点中,找出权值(频率)最小的两个节点进行合并,生成一个新的节点(权值为两个子节点之和),直到只剩下一个节点(根节点)。
  3. 计算总长度
    • 方法一:建好树后,(字符频率×该字符在树中的深度)\sum (\text{字符频率} \times \text{该字符在树中的深度})
    • 方法二(编程常用):在合并过程中,每次合并产生的新节点的权值之和,就是最终的加权路径长度(WPL)。

算法步骤

  1. 统计频率:遍历字符串,统计每个字符出现的次数。
  2. 使用优先队列:将所有出现过的字符的频率放入一个小顶堆(Min-Priority Queue)。
  3. 模拟合并
    • 如果队列中元素个数大于 1:
      • 取出最小的两个数 aabb
      • 计算 sum=a+bsum = a + b
      • sumsum 累加到最终结果 ans 中。
      • sumsum 放回队列。
    • 重复上述步骤。
  4. 特殊情况处理:如果字符串中只有一种字符(例如 “aaaa”),哈夫曼树只有根节点,通常在竞赛题中,这种情况每个字符至少需要 1 bit,所以长度等于字符串长度。
  5. 比较输出:判断 ans <= m,输出 yes 或 no。

C++ 代码实现

cpp
#include <iostream>
#include <string>
#include <vector>
#include <queue>
#include <map>

using namespace std;

int main() {
    int n;
    // 读取测试组数
    if (cin >> n) {
        while (n--) {
            int m;
            string s;
            cin >> m >> s;

            // 1. 统计字符频率
            map<char, int> counts;
            for (char c : s) {
                counts[c]++;
            }

            // 2. 将频率放入小顶堆 (优先队列)
            // priority_queue 默认是大顶堆,使用 greater<int> 变为小顶堆
            priority_queue<int, vector<int>, greater<int>> pq;
            for (auto const& [key, val] : counts) {
                pq.push(val);
            }

            long long ans = 0;

            // 3. 特殊情况:如果只有一种字符 (例如 "aaaa")
            // 队列里只有一个元素 4,循环不会执行,ans 为 0
            // 但实际上一串字符至少占用 字符串长度 的位 (每个字符1位)
            if (pq.size() == 1) {
                ans = s.length();
            }

            // 4. 标准哈夫曼合并过程
            while (pq.size() > 1) {
                int a = pq.top(); 
                pq.pop();
                int b = pq.top(); 
                pq.pop();

                int sum = a + b;
                ans += sum; // 累加合并代价
                pq.push(sum); // 新节点入队
            }

            // 5. 输出结果
            if (ans <= m) {
                cout << "yes" << endl;
            } else {
                cout << "no" << endl;
            }
        }
    }
    return 0;
}

样例图解分析

假设输入是 helloworld

  1. 统计频率:

    l: 3, o: 2, h: 1, e: 1, w: 1, r: 1, d: 1

  2. 入队 (排序后): 1, 1, 1, 1, 1, 2, 3

  3. 合并过程

    • 取 1, 1 \rightarrow 合并得 2 (代价+2) \rightarrow 队: 1, 1, 1, 2, 2, 3
    • 取 1, 1 \rightarrow 合并得 2 (代价+2) \rightarrow 队: 1, 2, 2, 2, 3
    • 取 1, 2 \rightarrow 合并得 3 (代价+3) \rightarrow 队: 2, 2, 2, 3
    • 取 2, 2 \rightarrow 合并得 4 (代价+4) \rightarrow 队: 2, 3, 4
    • 取 2, 3 \rightarrow 合并得 5 (代价+5) \rightarrow 队: 4, 5
    • 取 4, 5 \rightarrow 合并得 9 (代价+9) \rightarrow 队: 9
    • 总代价2+2+3+4+5+9=252 + 2 + 3 + 4 + 5 + 9 = 25 (注:不同的优先队列实现顺序可能微调,但总和 WPL 是一样的,这里算出来大约是 25-27 左右,具体取决于相同权值的合并顺序,但标准哈夫曼结果是唯一的)。
    • 实际计算验证
      • (h, w, r, d, e) 深度可能为 3 或 4
      • (o) 深度 3
      • (l) 深度 2
      • 计算示例: 3×2(l)+2×3(o)+5×3(其他)=6+6+15=273\times2(\text{l}) + 2\times3(\text{o}) + 5\times3(\text{其他}) = 6 + 6 + 15 = 27
    • 结果27>1227 > 12,输出 no

这道题目是经典的 哈夫曼编码 (Huffman Coding) 问题。

题目核心解析

题目要求判断给定字符串经过哈夫曼编码后的总长度(所占空间)是否小于等于给定的安全值 mm

哈夫曼编码的原理

  1. 频率越高的字符,编码越短:为了让总长度最小,出现次数多的字符应该用更少的比特位(bit)表示。
  2. 贪心算法:每次从所有当前的节点中,找出权值(频率)最小的两个节点进行合并,生成一个新的节点(权值为两个子节点之和),直到只剩下一个节点(根节点)。
  3. 计算总长度
    • 方法一:建好树后,(字符频率×该字符在树中的深度)\sum (\text{字符频率} \times \text{该字符在树中的深度})
    • 方法二(编程常用):在合并过程中,每次合并产生的新节点的权值之和,就是最终的加权路径长度(WPL)。

算法步骤

  1. 统计频率:遍历字符串,统计每个字符出现的次数。
  2. 使用优先队列:将所有出现过的字符的频率放入一个小顶堆(Min-Priority Queue)。
  3. 模拟合并
    • 如果队列中元素个数大于 1:
      • 取出最小的两个数 aabb
      • 计算 sum=a+bsum = a + b
      • sumsum 累加到最终结果 ans 中。
      • sumsum 放回队列。
    • 重复上述步骤。
  4. 特殊情况处理:如果字符串中只有一种字符(例如 “aaaa”),哈夫曼树只有根节点,通常在竞赛题中,这种情况每个字符至少需要 1 bit,所以长度等于字符串长度。
  5. 比较输出:判断 ans <= m,输出 yes 或 no。

C++ 代码实现

C++

cpp
#include <iostream>
#include <string>
#include <vector>
#include <queue>
#include <map>

using namespace std;

int main() {
    int n;
    // 读取测试组数
    if (cin >> n) {
        while (n--) {
            int m;
            string s;
            cin >> m >> s;

            // 1. 统计字符频率
            map<char, int> counts;
            for (char c : s) {
                counts[c]++;
            }

            // 2. 将频率放入小顶堆 (优先队列)
            // priority_queue 默认是大顶堆,使用 greater<int> 变为小顶堆
            priority_queue<int, vector<int>, greater<int>> pq;
            for (auto const& [key, val] : counts) {
                pq.push(val);
            }

            long long ans = 0;

            // 3. 特殊情况:如果只有一种字符 (例如 "aaaa")
            // 队列里只有一个元素 4,循环不会执行,ans 为 0
            // 但实际上一串字符至少占用 字符串长度 的位 (每个字符1位)
            if (pq.size() == 1) {
                ans = s.length();
            }

            // 4. 标准哈夫曼合并过程
            while (pq.size() > 1) {
                int a = pq.top(); 
                pq.pop();
                int b = pq.top(); 
                pq.pop();

                int sum = a + b;
                ans += sum; // 累加合并代价
                pq.push(sum); // 新节点入队
            }

            // 5. 输出结果
            if (ans <= m) {
                cout << "yes" << endl;
            } else {
                cout << "no" << endl;
            }
        }
    }
    return 0;
}

样例图解分析

假设输入是 helloworld

  1. 统计频率:

    l: 3, o: 2, h: 1, e: 1, w: 1, r: 1, d: 1

  2. 入队 (排序后): 1, 1, 1, 1, 1, 2, 3

  3. 合并过程

    • 取 1, 1 \rightarrow 合并得 2 (代价+2) \rightarrow 队: 1, 1, 1, 2, 2, 3
    • 取 1, 1 \rightarrow 合并得 2 (代价+2) \rightarrow 队: 1, 2, 2, 2, 3
    • 取 1, 2 \rightarrow 合并得 3 (代价+3) \rightarrow 队: 2, 2, 2, 3
    • 取 2, 2 \rightarrow 合并得 4 (代价+4) \rightarrow 队: 2, 3, 4
    • 取 2, 3 \rightarrow 合并得 5 (代价+5) \rightarrow 队: 4, 5
    • 取 4, 5 \rightarrow 合并得 9 (代价+9) \rightarrow 队: 9
    • 总代价2+2+3+4+5+9=252 + 2 + 3 + 4 + 5 + 9 = 25 (注:不同的优先队列实现顺序可能微调,但总和 WPL 是一样的,这里算出来大约是 25-27 左右,具体取决于相同权值的合并顺序,但标准哈夫曼结果是唯一的)。
    • 实际计算验证
      • (h, w, r, d, e) 深度可能为 3 或 4
      • (o) 深度 3
      • (l) 深度 2
      • 计算示例: 3×2(l)+2×3(o)+5×3(其他)=6+6+15=273\times2(\text{l}) + 2\times3(\text{o}) + 5\times3(\text{其他}) = 6 + 6 + 15 = 27
    • 结果27>1227 > 12,输出 no

通过helloworld来理解huffman的过程

这是一个非常好的练习。理解哈夫曼树的构建过程对于理解哈夫曼编码至关重要。

我们严格按照哈夫曼算法的流程来构建 “helloworld” 的哈夫曼树。

1. 准备工作:统计频率

字符串:helloworld (总长度 10) 字符频率统计(按频率从小到大排序):

  • h: 1
  • e: 1
  • w: 1
  • r: 1
  • d: 1
  • o: 2
  • l: 3

我们将这些作为初始的叶子节点放入优先队列(小顶堆)。

2. 哈夫曼树构建过程 (图解模拟)

重要提示关于“相同频率的处理”: 当队列中有多个相同频率的节点时(例如初始有5个频率为1的节点),选择哪两个进行合并在算法上是任意的。这可能会导致最终画出的树的形状略有不同(比如某个字符在左子树还是右子树),但是,最终的加权路径长度(WPL,即编码总长度)一定是相同的,且是最优的。

为了绘制这棵树,我将按照一种标准的模拟路径进行合并:

初始状态 (森林): h(1), e(1), w(1), r(1), d(1), o(2), l(3)

步骤 1: 取出最小的两个 h(1)e(1),合并成新节点 [2]。 森林状态: w(1), r(1), d(1), o(2), l(3), [2](包含了h,e)

步骤 2: 取出最小的两个 w(1)r(1),合并成新节点 [2]。 森林状态: d(1), o(2), l(3), [2](h,e), [2](w,r)

步骤 3: 取出最小的两个 d(1) 和一个 [2](假设取包含了h,e的那个),合并成新节点 [3]。 森林状态: o(2), l(3), [2](w,r), [3](包含了d,h,e)

步骤 4: 取出最小的两个 o(2)[2](w,r),合并成新节点 [4]。 森林状态: l(3), [3](d,h,e), [4](包含了o,w,r)

步骤 5: 取出最小的两个 l(3)[3](d,h,e),合并成新节点 [6]。 森林状态: [4](o,w,r), [6](包含了l,d,h,e)

步骤 6: 最后剩下两个,[4][6],合并成根节点 [10]。 构建完成。


3. “helloworld” 的哈夫曼树图示

我们约定:左分支走 0,右分支走 1。 方括号 [ ] 中的数字表示内部节点的合并权值。 圆括号 ( ) 中的数字表示叶子字符的频率。

ASCII文本版

                  [10] Root
                 /    \
               0/      \1
               /        \
             [6]        [4]
             / \        / \
           0/   \1    0/   \1
           /     \    /     \
        l(3)    [3] o(2)    [2]
                / \         / \
              0/   \1     0/   \1
              /     \     /     \
            d(1)   [2]  w(1)   r(1)
                   / \
                 0/   \1
                 /     \
               h(1)   e(1)

4. 结果验证与分析

根据上面绘制的树,我们可以得出每个字符的编码和长度:

字符 频率 路径 (编码) 编码长度 (深度) 总贡献长度 (频率 x 深度)
l 3 00 2 3 * 2 = 6
o 2 10 2 2 * 2 = 4
d 1 010 3 1 * 3 = 3
w 1 110 3 1 * 3 = 3
r 1 111 3 1 * 3 = 3
h 1 0110 4 1 * 4 = 4
e 1 0111 4 1 * 4 = 4

总编码长度 (WPL): 6 + 4 + 3 + 3 + 3 + 4 + 4 = 27 bit

这与题目分析中得出的结论一致。频率最高的 ‘l’ (3次) 位于树较浅的位置(深度2),而频率最低的 ‘h’ 和 ‘e’ (1次) 位于最深的位置(深度4),这完美体现了哈夫曼编码的设计思想。

FAQ 1: 左子树还是右子树

1 权值中的点, 是作为左子树, 还是右子树, 会不会影响结果?

这是一个非常深刻且关键的问题,触及了哈夫曼编码的核心性质。

简单直接的回答是:

它会影响最终生成的具体编码(每个字符对应的 01 串),但绝不会影响最终的总编码长度(WPL)。

对于你之前展示的题目(判断总长度是否符合要求)来说,答案是 不会影响结果

下面我们详细拆解原因:

1. 为什么不影响总编码长度 (WPL)?

我们需要理解哈夫曼树总路径长度(WPL)的计算本质。

方法一:基于深度的计算

WPL=(每个叶子节点的频率×该节点在树中的深度)WPL = \sum (每个叶子节点的频率 \times 该节点在树中的深度)

当我们选取两个最小的节点(比如 A 和 B)进行合并时,无论我们将 A 放在左边还是 B 放在左边:

  • 它们都会成为同一个新父节点的子节点。
  • 它们的深度都会相对于这个新父节点增加 1。

既然它们增加的深度是一样的,那么它们对最终 WPL 的贡献(频率 ×\times 深度)在数值上就是一样的,与左右位置无关。

方法二:基于合并代价的计算 (编程中常用的方法)

WPL=所有非叶子节点(内部节点)的权值之和WPL = \text{所有非叶子节点(内部节点)的权值之和}

回顾一下代码中的核心循环:

cpp
// 4. 标准哈夫曼合并过程
while (pq.size() > 1) {
    int a = pq.top(); pq.pop();
    int b = pq.top(); pq.pop();

    int sum = a + b;
    ans += sum;     // 关键在这里:累加的是 sum
    pq.push(sum);   // 将 sum 放回
}

在这个过程中,算法只关心这步合并产生了多大的代价 (sum = a + b),并将这个代价累加到总结果 ans 中。算法完全不关心是谁加谁,也不关心谁在左谁在右,它只关心这个和是多少。

结论:对于题目要求计算的“总长度”而言,左右子树的放置顺序没有影响。


2. 为什么会影响具体的编码?

虽然总长度不变,但是树的形状变了,路径就变了。

通常约定:走左子树记为 ‘0’,走右子树记为 ‘1’

举例说明:

假设当前最小的两个节点是 c(频率2) 和 d(频率3)。

情况 A:c 作左子树,d 作右子树

合并出新节点 [5]。

  • 要到达 c,路径最后一步必须往左走,编码以 ...0 结尾。
  • 要到达 d,路径最后一步必须往右走,编码以 ...1 结尾。

Plaintext

      [5]
     /   \
   c(2) d(3)

情况 B:d 作左子树,c 作右子树

合并出新节点 [5]。

  • 要到达 d,路径最后一步往左走,编码以 ...0 结尾。
  • 要到达 c,路径最后一步往右走,编码以 ...1 结尾。

Plaintext

      [5]
     /   \
   d(3) c(2)

显然,情况 A 和情况 B 生成的最终 01 编码串是不一样的。但是,在这两种情况下,cd 的深度是一样的,它们对总长度的贡献也是一样的。

总结

  • 哈夫曼树不唯一: 由于合并时左右子树选择的任意性,以及当存在多个相同频率节点时选择的任意性,同一个字符串可以构建出形态不同的哈夫曼树,从而得到不同的编码方案。
  • 最短加权路径长度唯一: 无论构建出哪种形态的哈夫曼树,它们最终的总编码长度(WPL)一定是相同的,且是所有可能的二叉树中最小的。

在算法竞赛中,如果题目只问“最小长度”,你不需要纠结左右;如果题目要求输出具体的“编码”,通常题目会附加约定(例如:权值较小的必须在左子树,或者频率相同时优先处理 ASCII 码较小的字符等)来保证答案的唯一性。

FAQ 2 WPL 的两种计算方式为什么一样

空间占用计算 (WPL) ,我发现有两种计算方式,

  1. 根据树的结构 : 深度 乘以 频率
  2. 合并代价法 为什么这两种方式计算的结果一样?

这是一个非常深刻且经典的算法问题!你能发现这一点说明你对哈夫曼树的构造过程有了很直观的理解。

这两种计算方式之所以结果完全一样,是因为它们本质上是在统计同一个东西,只是观察的角度不同

我们可以通过一个简单的例子和逻辑推导来解释。


1. 核心逻辑:贡献度分析

要理解为什么两者相等,我们只需要盯着某一个叶子节点(比如字符 a,频率为 ww)看它在两种计算方式中分别贡献了多少值。

角度一:根据树的结构(最终状态)

公式是:频率×深度\text{频率} \times \text{深度}

假设叶子节点 a 的频率是 ww,它在树中的深度是 dd(也就是它上面有 dd 条边,或者说它经过了 dd 个父节点/祖先节点到达根)。

那么,节点 a 对总结果的贡献就是:w×dw \times d

角度二:合并代价法(构建过程)

公式是:所有非叶子节点(内部节点)的权值之和。

思考一下,哈夫曼树是怎么构建的?是自底向上的。

  1. 当叶子 a 被第一次合并时,它的值 ww 加到了它的父节点中。
  2. 这个父节点之后又被合并,ww 作为父节点值的一部分,又加到了爷爷节点中。
  3. …以此类推…
  4. 直到到达根节点

结论:

一个叶子节点 a 的深度是 dd,意味着它上面有 dd 层祖先(内部节点)。

在计算“合并代价”时,这个 ww 的值实际上被累加到了每一个祖先节点的权值里。

既然它有 dd 个祖先,那么 ww 就被加了 dd 次。

所以,节点 a 对总结果的贡献依然是:w×dw \times d


2. 简单的数学示例

假设我们有三个字符,频率分别是:A: 10, B: 20, C: 30

构建过程:

  1. 最小的是 A(10) 和 B(20)。
  2. 合并 A 和 B,生成一个内部节点 N1,权值为 10+20=3010+20=30
  3. 现在剩下 N1(30) 和 C(30)。
  4. 合并 N1 和 C,生成根节点 Root,权值为 30+30=6030+30=60

树的结构如下:

Plaintext

      Root (60)
      /      \
   N1(30)    C(30)
   /   \
A(10) B(20)
  • A 的深度是 2
  • B 的深度是 2
  • C 的深度是 1

验证两种方法:

方法 1:深度 ×\times 频率 (Leaf-based)

  • A: 10×2=2010 \times 2 = 20
  • B: 20×2=4020 \times 2 = 40
  • C: 30×1=3030 \times 1 = 30
  • 总和:20+40+30=9020 + 40 + 30 = 90

方法 2:合并代价法 (Internal-node-based)

我们要把所有新生成的节点(圆圈里的数)加起来。

  • 第一次合并产生的节点 N1:30
  • 第二次合并产生的节点 Root:60
  • 总和:30+60=9030 + 60 = 90

3. 直观的比喻:过路费

想象你要把几袋金币(叶子节点)运送到山顶(根节点)。

  • 方法 1(统计每袋金币的运输成本):

    你算一下:A袋金币重10斤,要走2公里;B袋重20斤,要走2公里;C袋重30斤,要走1公里。

    总功 = 10×2+20×2+30×1=9010\times2 + 20\times2 + 30\times1 = 90

  • 方法 2(统计关卡流量):

    在每一个路口(内部节点/合并点),都设有一个传感器,记录流经这里的金币总重量。

    • 在路口 N1,A和B通过了,传感器记录:30斤。
    • 在山顶路口 Root,A、B、C都通过了(A和B打包成了N1),传感器记录:60斤。
    • 把所有传感器的读数加起来:30+60=9030 + 60 = 90

总结

这两种方法是等价的:

  • 方法 1 是从空间的角度(横向)看最终结果。
  • 方法 2 是从时间的角度(纵向)看构建过程。

在编程竞赛或算法实现中,方法 2(合并代价法)通常更方便。因为在使用优先队列(堆)编写代码时,我们每做一次加法(sum = a + b),就可以直接把这个 sum 累加到 total_cost 变量里,不需要等到最后建完树再去递归计算深度。