【算法】千机扣组装(贪心,优先队列)

4 阅读6分钟

题目

在《逆水寒》荡漾赛金殿中,有一类装备配件叫作“千机扣”。每个千机扣由 3 个部件 组合而成。

每个部件具有 3 个属性:

  • 词条:部件的属性标识,用数字编号表示;
  • 内外功倾向:1 表示外功,2 表示内功;
  • 攻防类型:1 表示攻击型,2 表示防御型。

要成功合成一个千机扣,需要选择 3 个部件,并满足以下条件:

  1. 词条限制:3 个部件的词条编号互不相同;
  2. 内外功限制:3 个部件的内外功倾向必须完全一致;
  3. 攻防限制:3 个部件的攻防类型必须完全一致。

请问,给定若干部件后,最多可以合成多少个千机扣?


输入描述

第一行一个整数:

n(1 ≤ n ≤ 100000)

表示可用部件总数。

接下来 n 行,每行 3 个整数:

x y z

分别表示:

  • x:部件词条编号(1 ≤ x ≤ 100000
  • y:内外功倾向(1 外功,2 内功)
  • z:攻防类型(1 攻击,2 防御)

输出描述

输出一个整数,表示最多能合成的千机扣数量。
如果无法合成,则输出 0


测试用例

样例 1

输入:

7
17 1 1
52 2 1
32 2 1
14 1 2
85 2 2
36 2 2
77 2 2

输出:

1

样例 2

你截图中的第二个样例底部被遮挡,输入没有完整显示。
为了避免错误复原,本文不列出该样例的输入内容。已知该样例输出为:

1

一开始容易想到的思路:按 (y,z) 分组后,随便拿 3 个不同词条就凑

这题我一开始有一个判断是对的:

因为同一个千机扣内部要求 y 一致、z 一致,所以可以先按 (y,z) 分成 4 组。

也就是说:

  • (1,1) 一组
  • (1,2) 一组
  • (2,1) 一组
  • (2,2) 一组

不同组之间不可能互相配对,所以最后答案等于这 4 组的答案之和。

这一步是完全正确的。

但后面我一开始的错误思路是:

对于某一组,直接顺着 unordered_map 扫,遇到 3 个不同词条就组成一个千机扣。

这个思路的问题在于:

它是“局部随便拿三个”,并不保证全局最优。


我在这题里犯过的错

错误 1:把“看到 3 个不同词条就拿走”当成贪心

这是这题最大的建模错误。

例如某一组里,词条次数是:

A:1
B:1
C:2
D:2

如果你第一次随便拿成:

A B C

那剩下:

C:1
D:2

只剩 2 种词条了,就凑不出第二个千机扣。

但其实最优可以组成 2 个:

  • 第一个:A C D
  • 第二个:B C D

也就是说:

这题不是“能不能凑”,而是“怎么凑才能最大化轮数”。


错误 2:for (auto i : mp) 中的 i 是副本

我一开始写过类似:

for (auto i : _xx) {
    i.second--;
}

这个写法的问题是:

  • i 是 map 元素的副本
  • 改的是副本,不会改到原容器

如果你真想改原元素,至少得写:

for (auto& i : _xx)

但即便如此,这题也不应该靠“边遍历边减”来写,因为核心建模本身不对。


错误 3:一边遍历 unordered_map 一边 erase

我还写过这种逻辑:

_xx.erase(i.first);

这是很危险的。
因为你在 range-for 遍历一个容器时,又去修改这个容器,容易触发未定义行为。


错误 4:把整个 unordered_map 当成单个元素用

我写过这样的代码:

pq.push(idx.second);

idx 是整个哈希表,不是单个键值对元素。
.second 只存在于:

pair<const int, int>

也就是 map 里的单个元素上。

正确写法应该是:

for (auto& i : idx) {
    pq.push(i.second);
}

错误 5:把 unordered_map 的遍历顺序当成算法依据

unordered_map 是无序容器。
所以你不能指望:

“我顺着它扫一遍,顺序就是稳定且合理的。”

如果你的算法决策依赖 unordered_map 的遍历顺序,那结果很可能不稳定,甚至在不同环境下表现不同。


正确思路

一、先按 (y,z) 分成 4 组

同一个千机扣必须满足:

  • 内外功一致
  • 攻防类型一致

所以不同 (y,z) 的部件一定不能混合使用。

因此整道题可以拆成 4 个独立子问题,最后答案相加即可。


二、每个子问题只看“词条次数”

对于某一组 (y,z),我们只关心:

  • 每个词条 x 出现了多少次

例如:

x=17 -> 2x=36 -> 1x=85 -> 4 次
...

问题就转化成:

已知若干种词条,每种词条有若干个。
每次要选 3 个不同词条,最多能选多少轮?


三、这题真正的贪心:每次优先取“当前次数最多的 3 种词条”

为什么要这么做?

因为高频词条是后续组装的主要资源。
如果你不优先消耗它们,而是随便乱取,就可能让某些低频词条过早用光,导致后面明明还有很多部件,却凑不够 3 个不同词条。

因此,这题正确的贪心策略是:

每次从当前剩余次数最多的 3 个不同词条中,各拿 1 个出来组装。

这个策略可以用 大根堆 来实现。


四、用优先队列实现

步骤如下:

  1. 统计当前组内每个词条的出现次数;

  2. 把所有次数放入大根堆;

  3. 只要堆中至少还有 3 个元素:

    • 取出最大的 3 个次数 a,b,c
    • 各减 1
    • 组装数加 1
    • 如果减完后还大于 0,就重新放回堆中

最终得到当前组能组成的最大千机扣数。


完整正确代码

#include <iostream>
#include <unordered_map>
#include <queue>
using namespace std;

int MaxSum(unordered_map<int, int>& idx) {
    priority_queue<int> pq;

    for (auto& i : idx) {
        pq.push(i.second);
    }

    int ans = 0;
    while (pq.size() >= 3) {
        int a = pq.top(); pq.pop();
        int b = pq.top(); pq.pop();
        int c = pq.top(); pq.pop();

        a--;
        b--;
        c--;
        ans++;

        if (a > 0) pq.push(a);
        if (b > 0) pq.push(b);
        if (c > 0) pq.push(c);
    }

    return ans;
}

int main() {
    int a;
    while (cin >> a) {
        unordered_map<int, int> _xx;
        unordered_map<int, int> _xy;
        unordered_map<int, int> _yx;
        unordered_map<int, int> _yy;

        for (int i = 0; i < a; i++) {
            int x, y, z;
            cin >> x >> y >> z;

            if (y == 1 && z == 1) {
                _xx[x]++;
            } else if (y == 1 && z == 2) {
                _xy[x]++;
            } else if (y == 2 && z == 1) {
                _yx[x]++;
            } else {
                _yy[x]++;
            }
        }

        int ans = 0;
        ans = MaxSum(_xx) + MaxSum(_xy) + MaxSum(_yx) + MaxSum(_yy);

        cout << ans << endl;
    }

    return 0;
}

复杂度分析

设某一组中共有 k 个不同词条。

  • 建堆复杂度约为 O(k log k)
  • 每次组装会进行 3 次弹出和最多 3 次压入
  • 假设最终组装了 m 个千机扣,则堆操作复杂度约为 O(m log k)

整体复杂度可以看作:

O((不同词条数 + 组装次数) * log(不同词条数))

在题目数据范围下是可以通过的。


这题最后提炼出的贪心经验

  1. 贪心不是“随便选一个当前可行解”,而是“找到不会破坏全局最优的局部策略”。

  2. 遇到“每轮选若干个不同种类、要求最大轮数”的题目,要高度警惕“次数统计 + 优先队列”模型。

  3. unordered_map 适合做计数,不适合直接依赖它的遍历顺序来做策略。

  4. map / unordered_map 中:

    • i.first 是 key
    • i.second 是 value
    • 整个容器本身没有 .second
  5. range-for 中 auto i 是副本,auto& i 才是引用。