哈夫曼编码

image


选择题:以下对数据结构的表述不恰当的是?

  • A. 队列是一种先进先出(FIFO)的线性结构
  • B. 哈夫曼树的构造过程主要是为了实现图的深度优先搜索
  • C. 散列表是一种通过散列函数将关键字映射到存储位置的数据结构
  • D. 二叉树是一种每个节点最多有两个子节点的树结构
答案

不恰当的表述是 B。哈夫曼树是一种特殊的二叉树,它的主要用途是数据压缩。通过贪心算法构建哈夫曼树,可以为出现频率高的字符分配较短的编码,为频率低的字符分配较长的编码,从而实现最优的前缀编码,达到数据压缩的目的,它与图的深度优先搜索算法没有直接关系。


选择题:下列哪个问题不能用贪心法精确求解?

  • A. 霍夫曼编码问题
  • B. 0-1 背包问题
  • C. 最小生成树问题
  • D. 单源最短路径问题
答案

B

贪心法是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法策略。它不从整体最优上加以考虑,所做的只是在某种意义上的局部最优选择。

霍夫曼编码是一种用于无损数据压缩的熵编码算法。其核心思想是:将出现频率高的字符用较短的编码,将出现频率低的字符用较长的编码,从而使得最终编码的总长度最短。霍夫曼算法是一个典型的贪心算法,它每次都选取当前集合中频率最小的两个节点(字符或子树)合并成一个新的父节点,新节点的频率是两个子节点频率之和。这个过程不断重复,直到所有节点都合并成一棵树。这个贪心策略被证明是能够得到最优解(即总长度最短的编码)的。

最小生成树问题是在一个加权无向图中,找到一棵连接所有顶点的、总权重最小的树。解决这个问题的两个经典算法都是贪心算法:Prim 算法每次都选择连接已选顶点集合和未选顶点集合的权重最小的边;Kruskal 算法将所有边按权重从小大到大排序,每次都选择权重最小且不会形成环的边。这两个贪心策略都被证明能够精确地求得最小生成树。

单源最短路径问题是从一个指定的源点出发,找到到图中所有其他顶点的最短路径。Dijkstra 算法是解决这个问题的经典算法(在边权为非负的情况下)。Dijkstra 算法的核心思想也是贪心:每次都从“未确定最短路径”的顶点中,选择一个离源点最近的顶点,并将其“确定”,然后用它来更新其他相邻顶点的距离。这个贪心策略被证明是正确的。

0-1 背包问题是给定一组物品,每种物品都有自己的重量和价值,在限定的总重量内,如何选择才能使得物品的总价值最高。每种物品都只有一件,只能选择放或者不放。对于这个问题,一个很自然的贪心策略是优先选择“性价比”最高的物品,即价值与重量比值最高的物品。但是,这个贪心策略无法保证得到全局最优解。例如,背包重量 10kg,物品 A 价值 60 元,重量 5kg(性价比 12 元/kg),物品 B 价值 80 元,重量 8kg(性价比 10 元/kg)。如果贪心选择,放入物品 A 后,背包剩余容量为 5kg,无法再放入物品 B,最终总价值为 60 元。但是最优选择是只选择物品 B,最终总价值为 80 元。从反例可以看出,贪心选择(选 A)并没有得到最优解(选 B)。0-1 背包问题的最优解需要使用动态规划来求解。


选择题:假设有一组字符 \(\{ g,h,i,j,k,l \}\),它们对应的频率分别为 \(8\%,14\%,17\%,20\%,23\%,18\%\)。请问以下哪个选项是字符 \(g,h,i,j,k,l\) 分别对应的一组哈夫曼编码?

A. g: 1100, h: 1101, i: 111, l: 10, k: 00, j: 01
B. g: 0000, h: 001, i: 010, l: 011, k: 10, j: 11
C. g: 111, h: 110, i: 101, l: 100, k: 01, j: 00
D. g: 110, h: 111, i: 101, l: 100, k: 0, j: 01

答案

image

例题:P1090 [NOIP 2004 提高组] 合并果子

因为每次合并消耗的体力等于两堆果子的重量之和,所以最终消耗的体力总和就是每堆果子的重量乘它参与合并的次数。这恰好对应一个二叉哈夫曼树问题,果子堆的重量就是叶子节点的权值,参与合并的次数就是叶子到根的距离。

建立一个小根堆,插入所有果子堆的重量。不断取出堆中最小的两个值,把它们的和插入堆,同时累加到答案中。直至最后堆的大小为 \(1\) 时,输出答案即可。

参考代码
#include <cstdio>
#include <queue>
using namespace std;

// 使用小根堆(优先队列)来维护果子堆,每次能以 O(log n) 的复杂度取出当前最小的两堆
priority_queue<int, vector<int>, greater<int>> hp;

int main()
{
    int n;
    // 读入果子的种类数
    scanf("%d", &n);
    
    // 将所有果子堆的大小插入小根堆
    for (int i = 0; i < n; i++) {
        int x;
        scanf("%d", &x);
        hp.push(x);
    }
    
    int ans = 0;
    
    // 贪心策略:每次从当前所有堆中选出重量最小的两堆进行合并
    // 这样可以保证较小的重量被重复计算(合并)的次数尽可能多,而较大的重量被计算的次数较少
    while (hp.size() > 1) {
        // 取出当前最小的两堆
        int min1 = hp.top();
        hp.pop();
        int min2 = hp.top();
        hp.pop();
        
        // 合并这两堆,并计算本次合并消耗的体力
        int newmin = min1 + min2;
        ans += newmin;
        
        // 将合并后的新堆重新放回堆中,参与后续合并
        hp.push(newmin);
    }
    
    // 输出最小的总耗费体力
    printf("%d\n", ans);
    
    return 0;
}

本题还有另一种方法。

观察到如果每次取出的都是当前最小的两堆,那么它们合并后的新堆大小,随着时间的推移,也是单调递增的。例如有初始排列顺序 \(1, 2, 5, 10\),第一次合并 \(1+2=3\),新产生的 \(3\) 一定不比 \(1\)\(2\) 小,如果下一轮合并的是 \(3\)\(5\)(得到 \(8\)),生成的数会越来越大。

因此可以使用两个队列 \(q_1\)\(q_2\)

  1. \(q_1\) 存放初始的果子堆,预先排序,保证单调递增。
  2. \(q_2\) 存放每次合并后生成的新堆,根据上面分析的性质,\(q_2\) 中的元素也自然是单调递增的。

这样一来每次需要取当前最小的某一堆果子时,实际上只可能是 \(q_1\) 的队头或 \(q_2\) 的队头。而每次合并取出最小两堆后,只需要将合并结果插入 \(q_2\) 的队尾。

参考代码
#include <cstdio>
#include <queue>
#include <algorithm>
using namespace std;

const int N = 1e4 + 5;
int a[N];
// q1 存放初始的果子堆,q2 存放合并后产生的新堆
// 使用两个队列是为了在 O(n) 时间内模拟优先队列的行为(利用了合并产生值的单调性)
queue<int> q1, q2;

// 从 q1 和 q2 的队头取出当前最小的一个元素
int get() {
    int val;
    // 如果 q2 为空,或者 q1 不为空且 q1 队头元素小于 q2 队头元素
    // 则取 q1 的队头,否则取 q2 的队头
    if (!q1.empty() && (q2.empty() || q1.front() < q2.front())) {
        val = q1.front(); q1.pop();
    } else {
        val = q2.front(); q2.pop();
    }
    return val;
}

int main()
{
    int n; 
    scanf("%d", &n);
    // 读入果子数量
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    
    // 初始排序,保证 q1 中的元素是单调递增的
    sort(a, a + n);
    
    // 将排序后的元素放入 q1
    for (int i = 0; i < n; i++) q1.push(a[i]);
    
    int ans = 0;
    // 总共需要进行 n-1 次合并
    for (int i = 1; i < n; i++) {
        // 取出当前最小的两堆果子
        int val1 = get();
        int val2 = get();
        
        // 计算合并后的新堆重量,并累加体力消耗
        int val = val1 + val2;
        ans += val;
        
        // 将合并后的新堆放入 q2
        // 由于每次都是取最小的合并,生成的 val 也是单调递增的,所以 q2 也是有序的
        q2.push(val);
    }
    
    printf("%d\n", ans);
    return 0;
}

习题:P6033 [NOIP 2004 提高组] 合并果子 加强版

本题需要使用快读。

解题思路

由于 \(n\) 可以达到 \(10^7\)std::sort 会超时。观察到重量的值域很小(只到 \(10^5\)),可以使用计数排序

单次合并不能是 \(O(\log n)\),要使用双队列的思路来优化。

参考代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 常量定义:A 为果子重量的最大值
const int A = 100005;
// cnt 数组用于计数排序,统计每种重量的果子堆数
int cnt[A];
// q1 存放初始的果子堆(已排序),q2 存放合并后产生的新堆
queue<ll> q1, q2;

// 快读函数:由于 n 到达 10^7,需要使用快速读入以减少耗时
int read() {
    int num = 0;
    char ch = getchar();
    while (!isdigit(ch)) {
        ch = getchar();
    }
    while (isdigit(ch)) {
        num = num * 10 + ch - '0';
        ch = getchar();
    }
    return num;
}

// 辅助函数:从 q1 和 q2 的队头中取出当前全局最小的元素
// 核心原理:初始果子已排序,且合并产生的新果子堆大小也是单调递增的
ll get_top() {
    if (q2.empty() || (!q1.empty() && q1.front() < q2.front())) {
        ll ret = q1.front();
        q1.pop();
        return ret;
    } else {
        ll ret = q2.front();
        q2.pop();
        return ret;
    }
}

int main()
{
    // n 为果子堆数,maxa 记录果子重量的最大值
    int n = read(), maxa = 0;
    for (int i = 0; i < n; i++) {
        int x = read();
        if (x > maxa) maxa = x;
        ++cnt[x]; // 桶计数
    }

    // 计数排序逻辑:将统计好的果子按重量从小到大放入队列 q1
    // 这样 q1 内部就自然保持了单调递增
    for (int i = 1; i <= maxa; ++i) {
        for (int j = 0; j < cnt[i]; ++j) {
            q1.push(i);
        }
    }

    ll ans = 0;
    // 进行 n-1 次合并操作
    for (int i = 1; i < n; i++) {
        // 贪心策略:每次取出当前最小的两堆进行合并
        ll top1 = get_top();
        ll top2 = get_top();
        ll merge = top1 + top2;
        
        // 将合并后的新堆放入 q2
        // 由于贪心合并的性质,q2 也会保持单调递增,无需再次排序
        q2.push(merge);
        // 累计消耗的体力
        ans += merge;
    }

    // 输出最终结果
    printf("%lld\n", ans);
    return 0;
}

例题:P2827 [NOIP 2016 提高组] 蚯蚓

如果 \(q=0\),即蚯蚓不会变长,那么本题相当于维护一个数据结构,支持查询最大值、删除最大值、插入新的值。这是堆或 STL set 能够完成的基本操作,时间复杂度为 \(O(m \log (n+m))\)

\(q \gt 0\) 时,除了最大值拆成的两个数之外,其他数都会增加 \(q\)。设最大值为 \(x\),不妨认为产生了两个大小为 \(\lfloor px \rfloor -q\)\(x-\lfloor px \rfloor -q\) 的新数,然后再把整个数据结构中的元素都加上 \(q\),这与之前的操作是等价的。

于是,可以维护一个变量 \(\Delta\) 表示整个结构的“偏移量”,里面的数加上 \(\Delta\) 是它的真实数值。起初,\(\Delta=0\)。对于每一秒:

  1. 取出最大值 \(x\),令 \(x \leftarrow x + \Delta\)
  2. \(\lfloor px \rfloor - \Delta - q\)\(x - \lfloor px \rfloor - \Delta - q\) 插入。
  3. \(\Delta \leftarrow \Delta + q\)

重复上述步骤 \(m\) 轮,即可得到最终所有数的值。然而,本题中 \(m\) 的范围过大,\(O(m \log(n+m))\) 的算法无法通过全部测试点。

实际上本题有隐含的单调性,假设某一秒切了两只蚯蚓,长度分别为 \(x_1\)\(x_2\),且 \(x_1 \gt x_2\)。切分后:

  • \(x_1\) 变成 \(\lfloor p x_1 \rfloor\)\(x_1 - \lfloor p x_1 \rfloor\)
  • \(x_2\) 变成 \(\lfloor p x_2 \rfloor\)\(x_2 - \lfloor p x_2 \rfloor\)

可以证明(在 \(0 \lt p \lt 1\) 且其余蚯蚓都在增长的情况下):先切长的蚯蚓产生的两段,一定比后切短的蚯蚓产生的对应两段要长(或者相等)。即:随着时间推移,切掉的蚯蚓越来越短(相对于同一基准),切出来的两段新蚯蚓也会越来越短。

因此,可以不需要用堆或 set,而是用三个普通队列来维护:

  1. 队列 1:存储初始的 \(n\) 只蚯蚓,需先从大到小排序。
  2. 队列 2:存储切分后长度为 \(\lfloor p x \rfloor\) 的那一段。
  3. 队列 3:存储切分后长度为 \(x - \lfloor p x \rfloor\) 的那一段。

由于单调性,这三个队列内部始终是有序的(单调递减)。每次要找最长的蚯蚓时,只需要比较这三个队列的队首元素,取最大者即可,这样整个算法的时间复杂度为 \(O(m + n \log n)\)

参考代码
#include <cstdio>
#include <queue>
#include <algorithm>
using namespace std;

const int N = 1e5 + 5;
const int INF = 2e9;
int a[N];

int main()
{
    int n, m, q, u, v, t;
    // n: 初始蚯蚓数量, m: 时间秒数, q: 增长常数
    // u/v: 切分比例, t: 输出间隔
    scanf("%d%d%d%d%d%d", &n, &m, &q, &u, &v, &t);
    
    // 定义三个队列,利用题目性质(单调性)避免使用优先队列 O(log n)
    // q1: 存放初始蚯蚓(已排序)
    // q2: 存放切分后长度为 floor(p*x) 的部分
    // q3: 存放切分后长度为 x - floor(p*x) 的部分
    queue<int> q1, q2, q3;
    
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    
    // 初始从大到小排序,确保 q1 单调递减
    sort(a, a + n, greater<int>());
    for (int i = 0; i < n; i++) q1.push(a[i]);
    
    // 模拟 m 秒的操作
    for (int i = 1; i <= m; i++) {
        // 为了处理"除了被切的蚯蚓外,其余都增加q",采用相对值法:
        // 队列中存储的值 = 真实长度 - 当前累积增长量
        // 即:真实长度 = 队列值 + (i-1)*q
        
        // 寻找三队之首的最大值
        // 注意:由于存储的值可能因为减去 i*q 变得很小(负数),初始化为极小值
        int v1 = -INF, v2 = -INF, v3 = -INF;
        
        if (!q1.empty()) v1 = q1.front();
        if (!q2.empty()) v2 = q2.front();
        if (!q3.empty()) v3 = q3.front();
        
        int maxv = max(v1, max(v2, v3));
        
        // 从对应的队列弹出最大值
        if (maxv == v1) q1.pop();
        else if (maxv == v2) q2.pop();
        else q3.pop();
        
        // 计算当前被切蚯蚓的真实长度
        int cur = maxv + (i - 1) * q;
        
        // 题目要求输出每隔 t 秒被切断蚯蚓的长度
        if (i % t == 0) {
            printf("%d%c", cur, i + t > m ? '\n' : ' ');
        }
        
        // 计算切分后的两段长度
        // 使用 1ll (long long) 防止乘法溢出
        int p1 = 1ll * cur * u / v;
        int p2 = cur - p1;
        
        // 将新产生的蚯蚓加入队列
        // 下一秒(i+1)时,全局增长量为 i*q。
        // 为了让取出的值 + i*q == p1 (新蚯蚓不享受本秒的增长),存入值应为 p1 - i*q
        q2.push(p1 - i * q);
        q3.push(p2 - i * q);
    }
    
    // 如果最后一行没有输出换行(例如 m < t),补一个换行
    if (m < t) printf("\n");
    
    // 输出 m 秒后所有蚯蚓的长度(第 t, 2t, ... 名)
    int offset = m * q; // m 秒后的总增长量
    int total = n + m;  // 总蚯蚓数
    bool first = true;
    
    // 依次取出所有蚯蚓
    for (int i = 1; i <= total; i++) {
        int v1 = -INF, v2 = -INF, v3 = -INF;
        if (!q1.empty()) v1 = q1.front();
        if (!q2.empty()) v2 = q2.front();
        if (!q3.empty()) v3 = q3.front();
        
        int maxv = max(v1, max(v2, v3));
        if (maxv == v1) q1.pop();
        else if (maxv == v2) q2.pop();
        else q3.pop();
        
        if (i % t == 0) {
            if (!first) printf(" ");
            printf("%d", maxv + offset); // 加上最终的总增长量
            first = false;
        }
    }    
    return 0;
}

例题:P2168 [NOI2015] 荷马史诗

一部《荷马史诗》中有 \(n(n \le 10^6)\) 种不同的单词,从 \(1\)\(n\) 进行编号。其中第 \(i\) 种单词出现的总次数为 \(w_i(w_i \le 10^11)\)。现在要用 \(k\) 进制串 \(s_i\) 来替换第 \(i\) 种单词,使得其满足对于任意的 \(1 \le i,j \le n, i \ne j\),都有 \(s_i\) 不是 \(s_j\) 的前缀。请问如何选择 \(s_i\),才能使替换以后得到的新的《荷马史诗》长度最小。在确保总长度最小的情况下,还想知道最长的 \(s_i\) 的最短长度是多少?

哈夫曼编码的变形。每次从堆中选出权重最小的 \(k\) 个结点,将其合并建边,然后放回堆中,直到建完哈夫曼树。例如,当各结点权重分别为 1、1、3、3、9、9,需要编码为三进制时,生成的哈夫曼树如下:

image

需要注意的是,每次合并都会减少 \(k-1\) 个结点,在合并最后一次的时候,如果可以合并的点的数量不足 \(k\) 个,靠近根结点的位置(短编码)反而没有被利用,所以需要在一开始补上 k-1-(n-1)%(k-1) 个权重为 \(0\) 的结点,把权重大的结点“推”到离根结点更近的位置。根据题目数据范围,答案需要 long long 类型。

参考代码
#include <cstdio>
#include <queue>
#include <algorithm>
using namespace std;
typedef long long LL;
const int N = 100005;
LL w[N];
struct Node {
    LL val;
    int depth;
};
struct NodeCompare { // 定义Node比较类
    bool operator()(const Node &a, const Node &b) {
        // 权重相同时,高度小的优先出队
        return a.val != b.val ? a.val > b.val : a.depth > b.depth;
    }
};
int main()
{
    int n, k;
    scanf("%d%d", &n, &k);
    priority_queue<Node, vector<Node>, NodeCompare> q;
    for (int i = 1; i <= n; i++) {
        scanf("%lld", &w[i]);
        q.push({w[i], 1}); // 读入结点(叶节点)
    }
    if ((n - 1) % (k - 1) != 0) { // 有一次合并结点数量不足k个
        for (int i = 1; i <= k - 1 - (n - 1) % (k - 1); i++) 
            q.push({0, 1}); // 需要补若干个权重为0的结点 
    }
    LL ans = 0;
    while (q.size() != 1) {
        LL sum = 0; int maxh = 0;
        for (int i = 1; i <= k; i++) { // 从堆中取k个最小的
            Node tmp = q.top(); q.pop();
            sum += tmp.val; // 新结点加上子结点权重
            maxh = max(maxh, tmp.depth); // 最大深度
        }
        ans += sum; // 更新总长度
        q.push({sum, maxh + 1}); // 合并后的结点放回堆中
    }
    printf("%lld\n%lld\n", ans, q.top().depth - 1); // 编码长度是哈夫曼树的高度减1
    return 0;
}
posted @ 2026-01-08 10:09  RonChen  阅读(6)  评论(0)    收藏  举报