[NOI2015] 荷马史诗

OJ: luogu

题目 ID: P2168

标签: 哈夫曼编码

日期: 2025-12-31 15:27

题目解析

这是一道非常经典的 k 叉哈夫曼树 (k-ary Huffman Tree) 问题。

核心考点

  1. k 叉哈夫曼树:标准的哈夫曼树是二叉的(每次合并2个),这里是 kk 叉(每次合并 kk 个)。
  2. 贪心策略与补零(Dummy Nodes):如果叶子节点数量不足以构成满 kk 叉树,需要补充权值为 0 的虚拟节点,以保证权值大的节点尽可能靠近根部。
  3. 多关键字优先队列:在权值相同时,需要控制树的高度(最长编码长度),这要求我们在优先队列的排序规则中加入高度判断。

解题思路解析

1. 为什么是哈夫曼树?

题目要求“前缀编码”且“总长度最小”,这正是哈夫曼编码的定义。

普通的哈夫曼编码是 2 进制的,每次取最小的 2 个节点合并。

本题是 kk 进制编码,意味着哈夫曼树的每个节点最多有 kk 个子节点,贪心策略变为:每次取最小的 kk 个节点合并。

2. 补零问题(关键难点)

在二叉哈夫曼树中,每次合并减少 1 个节点(取出 2 个,放回 1 个)。

kk 叉哈夫曼树中,每次合并减少 k1k-1 个节点(取出 kk 个,放回 1 个)。

假设最后只剩 1 个根节点,那么总共减少的节点数 n1n-1 必须能被 k1k-1 整除。

即满足条件:

(n1)(modk1)==0(n - 1) \pmod{k - 1} == 0

如果不能整除怎么办?

如果直接合并,最后一次合并可能不足 kk 个节点,且这些节点通常是权值最大的(因为它们最后才被处理),这会导致权值大的节点深度过深,不是最优解。

正确做法:在开始合并前,人为添加若干个权值为 0 的“虚拟叶子节点”,使得总节点数满足整除条件。

需要添加的 0 的个数为:

Add=(k1)(n1)(modk1)\text{Add} = (k - 1) - (n - 1) \pmod{k - 1}

(如果 (n1)%(k1)==0(n-1) \% (k-1) == 0,则不需要添加)

通过先合并这些 0 权值节点,可以保证“部分合并”的劣势被最底层的虚拟节点承担,而不影响真正的单词节点。

3. 处理“最长字符串最短”

题目要求:在总长度最小的前提下,最长字符串 sis_i 最短。

这意味我们构建的哈夫曼树,在权值和(WPL)相等的情况下,树的高度要尽可能小。

策略:

在优先队列中比较两个节点时:

  1. 第一关键字:权值小的优先(标准的哈夫曼贪心)。
  2. 第二关键字:如果权值相同,树高(深度)小的优先

为什么?

如果权值相同,我们优先合并高度较小的子树,这样生成的新子树高度增长得慢。把高度高的子树留到后面合并,可以避免它过早地增加层级。


图解说明

假设 n=3,k=3n=3, k=3,权重为 1, 1, 1。

(31)%(31)=0(3-1) \% (3-1) = 0,不需要补零。

直接取 3 个合并,总花费 1+1+1=31+1+1=3

假设 n=4,k=3n=4, k=3,权重为 1, 1, 1, 1。

(41)%(31)=10(4-1) \% (3-1) = 1 \neq 0。需要补零。

(31)1=1(3-1) - 1 = 1 个 0。

现在有:0, 1, 1, 1, 1。

  1. 取最小3个: 0, 1, 1。合并成新节点 2。花费 2。

    剩余: 1, 1, 2。

  2. 取最小3个: 1, 1, 2。合并成新节点 4。花费 4。

    总花费 2+4=6。

如果不补零直接做(错误示范):

  1. 先取 1, 1, 1 合并成 3。花费 3。剩余 1, 3

  2. 最后剩2个,强行合并 1, 3 成 4。花费 4。

    总花费 3+4=7。

    结论:不补零会导致更大的代价。


C++ 代码实现

cpp
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>

using namespace std;

// 定义节点结构体
struct Node {
    long long w; // 权值 (单词出现的次数)
    int h;       // 树的高度 (当前节点作为子树根的最深深度)

    // 构造函数
    Node(long long weight, int height) : w(weight), h(height) {}
};

// 自定义优先队列的比较规则 (仿函数)
// 这是一个 Min-Heap (小顶堆),但 std::priority_queue 默认是大顶堆
// 所以如果 a "大于" b,则 a 应该排在后面 (下沉)。
// 我们希望权值小的在堆顶,高度小的在堆顶。
struct CompareNode {
    bool operator()(const Node& a, const Node& b) {
        if (a.w != b.w) {
            return a.w > b.w; // 权值大的优先级低 (即 > 返回 true)
        }
        return a.h > b.h;     // 权值相同时,高度高的优先级低
    }
};

int main() {
    // 优化 I/O 速度
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    // 定义优先队列
    priority_queue<Node, vector<Node>, CompareNode> pq;

    for (int i = 0; i < n; ++i) {
        long long w;
        cin >> w;
        // 初始叶子节点,高度为 1 (或者是 0,取决于题目定义,但相对大小不变)
        // 题目求字符串长度,叶子本身没有边,深度为0。
        // 但为了方便计算合并后的高度,通常设叶子高度为1,合并后 max(h)+1
        // 这里我们设叶子 h=1,代表它自身这一层,或者设 h=0。
        // 题目问的是字符串长度 = 根到叶子的边数。
        // 如果设叶子 h=0,合并一次变为 1,逻辑通顺。
        pq.push(Node(w, 0)); 
    }

    // 处理补零 (Dummy Nodes)
    // 每次合并减少 k-1 个节点。
    // 我们需要 (n - 1) % (k - 1) == 0
    // 如果不满足,就需要添加节点直到满足
    while ((pq.size() - 1) % (k - 1) != 0) {
        pq.push(Node(0, 0)); // 加入权值为0,高度为0的虚拟节点
    }

    long long total_length = 0;

    // 哈夫曼合并过程
    while (pq.size() > 1) {
        long long sum_w = 0;
        int max_h = 0;

        // 取出 k 个最小的节点
        for (int i = 0; i < k; ++i) {
            Node top = pq.top();
            pq.pop();
            sum_w += top.w;
            max_h = max(max_h, top.h);
        }

        // 累加总长度 (WPL)
        total_length += sum_w;

        // 将新合成的节点放回队列
        // 新节点的高度 = 子节点最大高度 + 1
        pq.push(Node(sum_w, max_h + 1));
    }

    // 输出结果
    cout << total_length << endl;
    // 队列中剩下的最后一个节点是根节点,其高度即为树的最大深度
    cout << pq.top().h << endl;

    return 0;
}

代码细节注意

  1. 数据类型:题目中 wi1011w_i \le 10^{11},累加后会超过 int 范围,必须使用 long long
  2. 高度初始值:代码中设叶子节点高度 h=0。每次合并,父节点高度为 max(child_h) + 1。这样根节点的 h 正好等于从根到最深叶子的边数(即编码长度)。
  3. 补零逻辑while ((pq.size() - 1) % (k - 1) != 0) 这个循环非常关键,它确保了最后一次合并正好有 kk 个节点,且这 kk 个节点是队列中最大的。
  4. 比较函数return a.h > b.h 保证了在权值相同时,我们将高度更小的节点放在堆顶优先处理,从而把高度大的节点留到最后,防止树变得过高。

详细论证补零的数量

在二叉哈夫曼树中,我们从来不考虑这个问题,因为任意数量的节点最后一定能两两合并剩下一个。但在 kk 叉树中,情况变了。

我们用“消消乐”的游戏思维来破解这个数学谜题。

1. “消消乐”机制

首先,我们要换个角度看哈夫曼树的合并过程。把它想象成一个消除游戏

  • 初始状态:桌子上有 nn 个小球(代表 nn 个单词)。
  • 游戏规则:每一次,你可以抓起 kk 个小球,把它们捏成 1 个大球放回桌上。
  • 胜利条件:桌子上只剩下最后 1 个球。

关键算术题:

每次操作,你拿走了 kk 个,放回了 11 个。

请问,桌子上的球总数减少了多少?

减少量=k1\text{减少量} = k - 1

没问题吧?每次操作,桌上的球就会少 k1k-1 个。

2. 问题出在哪里?

为了赢得游戏,我们需要从 nn 个球变成 11 个球。

也就是说,我们需要消除掉 n1n - 1 个球。

既然每次只能消除 k1k-1 个,那么要想顺利把球消除完,总量 (n1)(n-1) 必须能被每次的消除量 (k1)(k-1) 整除

举个“翻车”的例子

假设 n=6n = 6 (6个单词),k=3k = 3 (3叉树)。

我们要消除的总量是 61=56 - 1 = 5 个。

每次操作能消除 31=23 - 1 = 2 个。

游戏过程:

  1. 第一轮:桌上有 6 个。抓起 3 个变成 1 个。
    • 剩余:63+1=46 - 3 + 1 = 4 个。
  2. 第二轮:桌上有 4 个。抓起 3 个变成 1 个。
    • 剩余:43+1=24 - 3 + 1 = 2 个。
  3. 第三轮卡住了!
    • 桌上只剩 2 个球了,但是规则要求必须抓 3 个才能合并!

这时候你被迫只能把这 2 个合并,但这就破坏了“每次取 kk 个”的最优贪心规则。

3. 为什么要补“0”?(核心逻辑)

在上面的例子中,最后只剩 2 个球。为了凑齐 3 个球进行一次完美的合并,我们必须凭空变出 1 个球来凑数。

这个凭空变出来的球,不能影响结果,所以它的权值必须是 0

但是,重点来了:这个 0 应该什么时候加?

  • 方案 A(错误):最后缺了再补。

    也就是先把大的合并完了,最后发现剩 2 个,补个 0 强行合并。这会导致大权值的节点参与了更多次数的合并,层级更深,总长度变大。

  • 方案 B(正确):一开始就补!

    既然算出最后会缺,那我们在游戏开始前,就先往桌子上扔几个 0 分的球。

    因为哈夫曼是“贪心”的(每次拿最小的),这些 0 分球会在第一轮就被抓走。这意味着,空缺的位置被安排在了树的最底层,完全不影响那些真正有权值的单词。

4. 数学计算公式(手把手推导)

我们不想每次都模拟一遍游戏,我们需要一个公式来算出要补几个 0。

  • 总共需要消除的数量n1n - 1
  • 每次消除的能力k1k - 1

我们看 n1n-1 除以 k1k-1余数

第一步:求余数

rem=(n1)%(k1)rem = (n - 1) \% (k - 1)
  • 如果 rem==0rem == 0:恭喜,刚好能除尽,不需要补零。
  • 如果 rem0rem \neq 0:说明最后会多出来 remrem 个球(算上最后放回的那1个,其实是剩 remrem 份“待消除的量”)。

第二步:算补零

我们需要凑齐一组 k1k-1 让它能除尽。

现在手里有余数 remrem,还差多少能凑满 k1k-1

需要补的个数=(k1)rem\text{需要补的个数} = (k - 1) - rem

5. 实战演练

我们拿 n=6,k=3n=6, k=3 再算一次。

  1. 目标消除量61=56 - 1 = 5
  2. 每次消除量31=23 - 1 = 2
  3. 求余数5%2=15 \% 2 = 1
    • 这意味着,如果不补零,最后会多余 1 份“消除不掉的麻烦”。
  4. 计算补零(k1)rem=21=1(k-1) - rem = 2 - 1 = 1
    • 我们需要补 1 个 0。

验证补完后的情况:

现在桌上有 6+1=76 + 1 = 7 个球。

目标消除:71=67 - 1 = 6

每次消除 2。

6÷2=36 \div 2 = 3。也就是正好合并 3 次,每次都满载而归(抓3个),最后剩1个。完美!


6. 代码里的写法

理解了上面,再看这几行代码,是不是清晰多了?

C++

// 每次合并消掉 (k-1) 个节点
// 我们看 n-1 是否能被 k-1 整除
// 如果不能,就一直加 0,直到能整除为止

while ((pq.size() - 1) % (k - 1) != 0) {
    pq.push(Node(0, 0)); 
}

或者用数学公式直接算(效率更高,但 while 循环更直观):

C++

int remainder = (n - 1) % (k - 1);
if (remainder != 0) {
    int need_to_add = (k - 1) - remainder;
    for(int i=0; i < need_to_add; i++) {
        pq.push(Node(0, 0));
    }
}

总结

  1. 二叉树 (k=2):每次消 1 个,(n1)(n-1) 肯定能被 1 整除,所以不用补。
  2. k 叉树 (k>2):每次消 k1k-1 个,(n1)(n-1) 未必能被 k1k-1 整除。
  3. 为什么要补:为了保证每次合并都是“满员”的 (kk个),不浪费位置。
  4. 为什么要一开始补:为了让权值为 0 的虚拟节点在第一轮就被消耗掉(放在树的最深处),从而保护权值大的重要节点尽量靠近根部。

希望这个“消消乐”的解释能帮你彻底搞定这个数学盲点!