区域赛算法模版第八章---贪心(Greedy)

文章目录

    • 八、区域赛算法模版第八章---贪心(Greedy)
      • [8.1 核心思想与正确性证明](#8.1 核心思想与正确性证明)
      • [8.2 区间调度(不相交区间最多)](#8.2 区间调度(不相交区间最多))
      • [8.3 区间覆盖(覆盖全部的最少区间)](#8.3 区间覆盖(覆盖全部的最少区间))
      • [8.4 区间选点(覆盖所有区间的最少点)](#8.4 区间选点(覆盖所有区间的最少点))
      • [8.5 交换论证法(排序类贪心)](#8.5 交换论证法(排序类贪心))
      • [8.6 优先级队列贪心](#8.6 优先级队列贪心)
      • [8.7 反悔贪心(Regret Greedy)](#8.7 反悔贪心(Regret Greedy))
      • [8.8 中位数贪心](#8.8 中位数贪心)
      • [8.9 贪心常见模型总结](#8.9 贪心常见模型总结)
      • [8.10 注意事项](#8.10 注意事项)
      • [8.11.1 区域赛题](#8.11.1 区域赛题)

八、区域赛算法模版第八章---贪心(Greedy)

8.1 核心思想与正确性证明

贪心的本质: 每一步都做出在当前看来最优的选择,期望通过一系列局部最优达到全局最优。

关键区别: 贪心与动态规划的核心差异在于无后效性------贪心一旦做出选择,就不会再更改;而 DP 会保留所有可能的状态。

正确性证明的三种主要方法:

  1. 交换论证法(Exchange Argument)

    • 假设存在一个最优解,且该解与贪心解在某一步不同。
    • 通过交换两个元素的顺序,证明交换后不会使解变差。
    • 反复交换,最终可以将最优解"调整"为贪心解,从而证明贪心策略最优。
    • 典型应用: 排序类贪心(如两地调度、国王游戏)。
  2. 归纳法

    • 证明贪心选择是"安全"的:存在一个最优解包含贪心选择。
    • 证明做出贪心选择后,剩余问题是相同性质的子问题。
    • 典型应用: 活动安排问题。
  3. 拟阵理论(Matroid)

    • 拟阵是贪心算法正确性的经典理论框架。
    • 若问题能建模为拟阵,则按权值从大到小排序后贪心选择,必然得到最大权独立集。
    • 典型应用: 最小生成树(Kruskal)、线性无关向量选择。

8.2 区间调度(不相交区间最多)

问题: 给定若干区间,选择尽可能多的互不重叠的区间。

贪心策略: 按右端点升序排序,每次选择结束时间最早且不与已选区间冲突的区间。

cpp 复制代码
class IntervalScheduler {
public:
    // 按右端点排序,依次选择不重叠的区间
    int maxNonOverlap(vector<pair<int, int>>& intervals) {
        sort(intervals.begin(), intervals.end(),
             [](const auto& a, const auto& b) {
                 return a.second < b.second;
             });

        int count = 0;
        int lastEnd = INT_MIN;

        for (const auto& [start, end] : intervals) {
            if (start >= lastEnd) {
                count++;
                lastEnd = end;
            }
        }
        return count;
    }
};

思路要点:

  • 结束时间越早,为后续区间留下的时间越多
  • 这是拟阵结构下的经典贪心,交换论证法可证

8.3 区间覆盖(覆盖全部的最少区间)

问题: 用尽可能少的区间覆盖一个给定范围 [0, target]。

贪心策略: 按左端点排序,每次选择能覆盖当前起点且向右延伸最远的区间。

cpp 复制代码
class IntervalCover {
public:
    // 覆盖 [0, target] 所需的最少区间数
    int minCover(vector<pair<int, int>>& intervals, int target) {
        sort(intervals.begin(), intervals.end());

        int count = 0;
        int current = 0;  // 当前已覆盖到的位置
        int i = 0;
        int n = intervals.size();

        while (current < target) {
            int furthest = current;
            // 在所有左端点 <= current 的区间中,找右端点最远的
            while (i < n && intervals[i].first <= current) {
                furthest = max(furthest, intervals[i].second);
                i++;
            }

            if (furthest == current) return -1;  // 无法覆盖
            current = furthest;
            count++;
        }
        return count;
    }
};

8.4 区间选点(覆盖所有区间的最少点)

问题: 在数轴上选尽可能少的点,使每个区间至少包含一个选中的点。

贪心策略: 按右端点升序排序,每次选择当前区间的右端点作为选点。

cpp 复制代码
class IntervalPointCover {
public:
    int minPoints(vector<pair<int, int>>& intervals) {
        sort(intervals.begin(), intervals.end(),
             [](const auto& a, const auto& b) {
                 return a.second < b.second;
             });

        int count = 0;
        int lastPoint = INT_MIN;

        for (const auto& [start, end] : intervals) {
            if (start > lastPoint) {  // 当前区间未被覆盖
                count++;
                lastPoint = end;      // 选择右端点
            }
        }
        return count;
    }
};

8.5 交换论证法(排序类贪心)

问题: 给定若干元素,按某种规则排序后依次处理,求最优解。

核心方法: 比较"先处理 a 再处理 b"和"先处理 b 再处理 a"的代价,推导出排序规则。

经典例题:两地调度(LeetCode 1029)

问题: 2n 个人,每人去 A 市费用 a[i],去 B 市费用 b[i]。每个城市必须恰好 n 人,求最小总费用。

贪心策略: 按 a[i] - b[i] 升序排序,前 n 人去 A,后 n 人去 B。

cpp 复制代码
class TwoCityScheduler {
public:
    int twoCitySchedCost(vector<vector<int>>& costs) {
        int n = costs.size();

        // 按 (去A的费用 - 去B的费用) 升序排序
        sort(costs.begin(), costs.end(),
             [](const vector<int>& a, const vector<int>& b) {
                 return a[0] - a[1] < b[0] - b[1];
             });

        int total = 0;
        int half = n / 2;

        // 前一半去 A,后一半去 B
        for (int i = 0; i < half; i++) {
            total += costs[i][0];
        }
        for (int i = half; i < n; i++) {
            total += costs[i][1];
        }
        return total;
    }
};

证明思路(交换论证法):

  • 假设最优解中,第 i 个人去了 A,第 j 个人去了 B。
  • 如果 (a[i] - b[i]) > (a[j] - b[j]),即 i 去 A 的相对成本比 j 高。
  • 交换后,总费用变化 = (a[j] + b[i]) - (a[i] + b[j]) = (a[j] - b[j]) - (a[i] - b[i]) < 0,费用降低。
  • 所以按 a[i] - b[i] 升序,前 n 人去 A,后 n 人去 B 是最优的。

8.6 优先级队列贪心

问题: 动态维护当前最优选择,每次取极值处理。

经典例题:合并果子(哈夫曼树)

问题: n 堆果子,每次合并两堆,代价为两堆重量之和,求合并成一堆的最小总代价。

贪心策略: 每次选择当前重量最小的两堆合并。

cpp 复制代码
class HuffmanMerge {
public:
    long long minMergeCost(vector<int>& fruits) {
        // 小根堆
        priority_queue<long long, vector<long long>, greater<long long>> pq(
            fruits.begin(), fruits.end());

        long long totalCost = 0;

        while (pq.size() > 1) {
            long long a = pq.top(); pq.pop();
            long long b = pq.top(); pq.pop();

            long long merged = a + b;
            totalCost += merged;
            pq.push(merged);
        }
        return totalCost;
    }
};

思路要点:

  • 每次合并最小的两堆,可以让较大的重量被合并更少的次数
  • 这是哈夫曼编码的核心思想,正确性由归纳法证明

8.7 反悔贪心(Regret Greedy)

核心思想: 先做出当前最优选择,如果后续发现该选择导致结果变差,则"反悔"撤销它。

经典例题:建筑抢修(JSOI 2007)

问题: n 个建筑需要抢修,每个建筑有耗时 t[i] 和截止时间 d[i]。求最多能抢修多少个建筑。

贪心策略:

  1. 按截止时间 d[i] 升序排序。
  2. 依次尝试抢修每个建筑,累加当前总耗时。
  3. 如果总耗时超过了当前建筑的截止时间,则从已选择的建筑中反悔,移除耗时最长的那个。
  4. 最终答案就是选择的建筑数量。
cpp 复制代码
class RegretGreedy {
public:
    struct Building {
        int time;     // 抢修耗时
        int deadline; // 截止时间
    };

    int maxRepair(vector<Building>& buildings) {
        // 按截止时间升序排序
        sort(buildings.begin(), buildings.end(),
             [](const Building& a, const Building& b) {
                 return a.deadline < b.deadline;
             });

        // 大根堆:存储已选择的建筑的耗时
        priority_queue<int> chosen;
        long long totalTime = 0;

        for (const auto& b : buildings) {
            totalTime += b.time;
            chosen.push(b.time);

            // 如果总耗时超过截止时间,反悔:移除耗时最长的
            if (totalTime > b.deadline) {
                totalTime -= chosen.top();
                chosen.pop();
            }
        }

        return chosen.size();
    }
};

思路要点:

  • 反悔贪心是"带撤销的贪心"
  • 用堆维护已选择集合,便于快速找到需要反悔的元素
  • 正确性证明通常需要构造性论证

8.8 中位数贪心

问题: 货仓选址,在数轴上选一个点,使得到所有点的距离之和最小。

结论: 选择中位数即可。

cpp 复制代码
class MedianGreedy {
public:
    long long minTotalDistance(vector<int>& positions) {
        sort(positions.begin(), positions.end());
        int n = positions.size();
        int median = positions[n / 2];

        long long total = 0;
        for (int pos : positions) {
            total += abs(pos - median);
        }
        return total;
    }
};

8.9 贪心常见模型总结

模型 贪心策略 经典题
区间调度 按右端点排序 无重叠区间
区间覆盖 按左端点排序,每次选延伸最远的 视频拼接
区间选点 按右端点排序,选右端点 用最少箭引爆气球
交换论证 比较交换前后,推导排序规则 两地调度、国王游戏
哈夫曼合并 每次合并最小的两个 合并果子
反悔贪心 先选,不行再反悔 建筑抢修
中位数 选中间点 货仓选址
单调栈 维护最优单调结构 删数字得最小数

8.10 注意事项

  • 贪心不是万能,使用前必须证明正确性
  • 排序规则往往通过交换论证法推导
  • 优先级队列常用于动态维护极值
  • 反悔贪心需要设计好"反悔"的逻辑

8.11.1 区域赛题

text 复制代码
Fight against involution(ICPC 济南 2020 D / 牛客 10662 D)

【题目描述】
有 n 个学生,每个学生需要写一篇论文,第 i 个学生的论文字数 w_i 必须满足:
  L_i ≤ w_i ≤ R_i

成绩规则:第 i 个学生的成绩 g_i = n - K_i,其中 K_i 是满足 w_j > w_i 的学生数量。
也就是说,成绩 = 比我字数多的人数越少,成绩越高。

每个学生都想拿到最高成绩,所以在"最优决策"下,每个学生都会写到上限 R_i。
此时,所有学生的成绩都是 n(因为没有人字数比你多?不,如果大家都写 R_i,那么成绩取决于 R_i 的分布)。

但 MianKing 发现:如果大家都写很少的字数,成绩可能不变,但可以省下时间打游戏。
现在 MianKing 想要重新决定每个学生的 w_i,满足以下条件:
  1) 每个学生的成绩不能低于原来(即大家都写 R_i 时)的成绩。
  2) 最小化所有学生的字数之和 Σ w_i。

求最小的 Σ w_i。

【输入】
第一行:n(1 ≤ n ≤ 10^5)
接下来 n 行:每行两个整数 L_i, R_i(1 ≤ L_i ≤ R_i ≤ 10^9)

【输出】
一个整数,表示最小的 Σ w_i。

【样例1】
输入:
  3
  1 10000
  1 10000
  1 10000
输出:
  3

【样例2】
输入:
  4
  1 2
  2 2
  2 4
  3 4
输出:
  10

【思路解说】

【一、原始成绩的计算】
在"最优决策"下,每个学生都写 R_i。
此时,第 i 个学生的成绩 = n - (满足 R_j > R_i 的学生数)。
也就是说,成绩只取决于 R_i 的大小关系。

如果两个学生的 R_i 相同,他们的成绩相同。
如果 R_i 不同,R_i 越大的学生,成绩越低(因为比他字数多的人少?不对,R_i 越大,比他字数多的人越少,所以成绩越高)。

实际上,成绩 = n - (R_j > R_i 的人数)。
所以 R_i 越大,成绩越高。

【二、约束条件】
我们需要给每个学生选一个新的 w_i,满足:
  1) L_i ≤ w_i ≤ R_i
  2) 新成绩 ≥ 原成绩

原成绩 = n - (R_j > R_i 的人数)。
新成绩 = n - (w_j > w_i 的人数)。

所以条件 2 等价于:
  (w_j > w_i 的人数) ≤ (R_j > R_i 的人数)

也就是说,新方案中,比 i 字数多的人数不能超过原方案中比 i 字数多的人数。

【三、贪心策略:按 R 分组,从大到小处理】
把学生按 R_i 从大到小排序。
对于 R_i 相同的学生,他们原本的成绩相同(因为 R_j > R_i 的人数相同)。

**关键观察:**
  - 对于 R_i 最大的那一组,原成绩 = n(因为没有人 R_j > R_i)。
  - 所以新方案中,这一组的学生成绩也必须是 n,即没有人 w_j > w_i。
  - 这意味着这一组的学生必须写得**不比任何人少**,即他们的 w_i 必须 ≥ 其他所有人的 w_i。
  - 为了最小化总和,这一组的学生应该取 w_i = max(L_i, 其他组的最大的 w)。

**更精确地:**
  - 从 R 最大的组开始处理。
  - 设当前组的 maxL = 组内所有 L_i 的最大值。
  - 设下一组的 w 值为 next_w(即下一组实际取的字数)。
  - 当前组的 w 必须 ≥ next_w(因为当前组成绩不能低于下一组?实际上,当前组 R 更大,成绩更高,所以当前组的 w 必须 ≥ 下一组的 w)。
  - 同时,当前组的 w 必须 ≥ maxL(满足下界)。
  - 所以当前组的 w = max(maxL, next_w)。
  - 当前组的总贡献 = w × 组内人数。
  - 更新 next_w = w,继续处理上一组。

【四、算法流程】
  1) 读入 n 和所有 (L_i, R_i)。
  2) 按 R_i 从大到小排序。
  3) 把 R_i 相同的学生分到同一组,记录每组的 maxL 和人数。
  4) 从最后一组(R 最小)往前处理:
       - 初始 next_w = 0。
       - 对于当前组,w = max(组内 maxL, next_w)。
       - 答案 += w × 组内人数。
       - next_w = w。
  5) 输出答案。

【五、样例验证】
样例1:三个学生都是 (1, 10000)
  按 R 分组:一组,maxL = 1,人数 3。
  从后往前:next_w = 0,w = max(1, 0) = 1。
  答案 = 1 × 3 = 3 ✓

样例2:四个学生 (1,2), (2,2), (2,4), (3,4)
  按 R 分组:
    R=4: (2,4), (3,4) -> maxL = 3, 人数 2
    R=2: (1,2), (2,2) -> maxL = 2, 人数 2
  从后往前:
    最后一组 R=2: next_w = 0, w = max(2, 0) = 2, 答案 += 2×2 = 4, next_w = 2
    上一组 R=4: w = max(3, 2) = 3, 答案 += 3×2 = 6, 总答案 = 10 ✓

【六、复杂度】
  - 排序:O(n log n)
  - 分组和处理:O(n)
  - 总复杂度:O(n log n),可以过。

【七、注意事项】
  - 按 R 从大到小排序。
  - 同一 R 的学生分到一组,取组内 maxL。
  - 从最后一组往前贪心,w = max(maxL, next_w)。
  • 基于以上思路,代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
// 学生结构体:L 是下界,R 是上界
struct Student {
    int L, R;
};
int main() {
    int n;
    scanf("%d", &n);
    vector<Student> a(n);
    for (int i = 0; i < n; i++) {
        scanf("%d %d", &a[i].L, &a[i].R);
    }
    // 按 R 从大到小排序
    // 因为 R 越大,原始成绩越高,需要优先保证这些学生的成绩不降低
    sort(a.begin(), a.end(), [](const Student &x, const Student &y) {
        return x.R > y.R;
    });
    // 按 R 分组,每组记录:
    //   maxL:组内所有 L 的最大值(为了满足组内所有学生的下界)
    //   count:组内学生人数
    vector<pair<int, int>> groups;  // (maxL, count)
    int i = 0;
    while (i < n) {
        int j = i;
        int maxL = 0;
        // 把 R 相同的所有学生分到同一组
        while (j < n && a[j].R == a[i].R) {
            maxL = max(maxL, a[j].L);
            j++;
        }
        groups.push_back({maxL, j - i});
        i = j;
    }
    // 从最后一组(R 最小)往前贪心
    // 原因:R 越小的组,原始成绩越低,新方案中字数可以更少
    // 但新方案中,字数必须满足:R 大的组字数 >= R 小的组字数
    // (否则 R 大的组成绩会降低)
    ll ans = 0;
    int next_w = 0;  // 下一组的实际字数,初始为 0(最后一组没有下一组)
    // 从后往前处理每组
    for (int k = (int)groups.size() - 1; k >= 0; k--) {
        // 当前组的字数 = max(组内下界 maxL, 下一组的字数 next_w)
        // 因为当前组 R 更大,成绩更高,所以字数必须 >= 下一组的字数
        int w = max(groups[k].first, next_w);
        // 当前组所有学生都取 w,累加贡献
        ans += (ll)w * groups[k].second;
        // 更新 next_w,供上一组使用
        next_w = w;
    }
    printf("%lld\n", ans);
    return 0;
}
text 复制代码
Gitignore(2020 ICPC 上海站 M / CF Gym 102900 M)

【题目描述】
你的 git 项目中有一些文件需要被忽略(不同步)。你需要计算 `gitignore` 文件最少需要写多少行。

形式上,你的项目是一个文件夹。文件夹可以包含文件和子文件夹。没有空文件夹(即没有不包含任何文件或子文件夹的文件夹)。初始时,git 软件会同步项目中的所有文件。你可以在 `gitignore` 中指定一些文件或文件夹来排除它们。对于 `gitignore` 中的每一行,你可以指定一个文件,或者指定一个文件夹(表示忽略该文件夹下的所有文件)。你不能忽略整个项目文件夹(即不能写空行)。

给定项目中所有文件的路径,以及它们是否应该被忽略。你的任务是计算 `gitignore` 的最少行数。

【输入】
第一行:T,测试用例个数。
对于每个测试用例:
  第一行:n, m(0 ≤ n, m,且 1 ≤ n + m ≤ 100),分别表示应该被忽略的文件数量,和不能被忽略的文件数量。
  接下来 n 行:应该被忽略的文件路径。
  接下来 m 行:不能被忽略的文件路径。
  路径仅包含小写英文字母和斜杠 '/'。路径不以斜杠开头或结尾,没有连续的斜杠。文件路径是唯一的,且保证在同一个文件夹下不会有同名文件或文件夹(即不会出现 "a/b/a" 和 "a/b/a/d" 的情况)。
  保证整个输入中所有路径的总长度不超过 1000。

【输出】
对于每个测试用例,输出一行一个整数,表示最少需要的 `gitignore` 行数。

【样例1】
输入:
  2
  3 0
  data/train
  data/test
  model
  3 1
  data/train
  data/test
  model
  data/sample
输出:
  2
  3

【样例解释】
样例 1:`data/` 文件夹下所有文件都要忽略,可以写 1 行 `data/`;`model` 单独写 1 行。共 2 行。
样例 2:`data/` 下有一个不能忽略的 `data/sample`,所以不能整个忽略 `data/`,只能分别写 `data/train` 和 `data/test`;`model` 单独写 1 行。共 3 行。

【思路解说】

【一、核心建模:目录树】
我们可以把所有的路径(无论是否该忽略)按 `/` 拆开,建成一棵**多叉树**(类似于 Trie)。
- 根节点代表项目根目录。
- 中间的每个节点代表一个文件夹。
- 叶子节点代表一个具体的文件。

在建树的同时,我们需要在节点上打标记:
- `hasIgn = true`:表示该节点(如果是文件)或其子树中包含**应该被忽略**的文件。
- `hasKeep = true`:表示该节点或其子树中包含**不能被忽略**的文件。

**注意:**
- 必须先处理所有需要忽略的文件并打上 `hasIgn`,再处理不能忽略的文件并打上 `hasKeep`,防止信息覆盖。
- 路径的层级关系通过 `map<string, int>` 来存子节点(应对字符串名字)。

【二、贪心策略:自底向上合并】
建完树后,我们从根节点开始 DFS 计算答案。对于当前节点 `u`:

1. **如果 `hasIgn == false`**:说明该文件夹(或文件)及其子树下没有任何需要忽略的文件,对答案的贡献为 0。
2. **如果 `hasKeep == false`**:说明该文件夹及其子树下**所有文件都是可以忽略的**。此时最优策略是**直接忽略整个文件夹**(写 1 行)。所以返回贡献 1,不需要再往下拆分了。
3. **如果 `hasIgn == true` 且 `hasKeep == true`**:说明这个文件夹里既有要忽略的,又有不能忽略的。我们**不能忽略整个文件夹**(会误伤保留的文件),只能递归处理它的所有子节点,把子节点的贡献累加起来。

**【三、根节点的特判】**
题目规定,我们不能直接忽略整个项目(即 `gitignore` 中不能写空行)。
因此,对于根节点(项目目录),我们**绝对不能返回 1 代表整个忽略**,而是必须遍历它的所有子节点,把子节点的 `dfs()` 返回值累加起来。

【四、算法流程】
  1) 为每个测试用例初始化一棵空树(只有根节点 0)。
  2) 读入 n 个需要忽略的文件:
       - 按 '/' 拆分路径,插入树中。
       - 在最后一个节点标记 `hasIgn = true`。
  3) 读入 m 个不能忽略的文件:
       - 按 '/' 拆分路径,插入树中。
       - 在最后一个节点标记 `hasKeep = true`。
  4) 自底向上汇总标记(`collect` 函数):
       - 递归遍历所有子节点,如果子节点有 `hasIgn` / `hasKeep`,则父节点也要继承对应标记。
  5) 计算答案:
       - 初始化 `ans = 0`。
       - 遍历根节点(0 号节点)的所有子节点,把 `dfs(子节点)` 的返回值累加。
  6) 输出 `ans`。

【五、复杂度】
  - 建树:O(总字符数)
  - 汇总标记:O(节点数)
  - 计算答案:O(节点数)
  - 总复杂度:O(总字符数),对于题目 ≤ 1000 的数据量,瞬间通过。
  - 空间复杂度:O(总字符数),即树的节点数。

【六、注意事项】
  - 根节点不能直接返回 1(不能忽略整个项目)。
  - 建树时只创建实际路径中的节点,不需要补全其他。
  - 用 `map<string, int>` 来存子节点,比固定数组更灵活且不会浪费空间。
  - 每次 `T` 循环结束后必须 `tr.clear()` 并重建根节点,防止多组测试数据互相影响。
  - 路径可能是单纯的文件夹,也可能是文件夹下的文件,按 `path` 中的 `/` 分割即可。
  • 基于以上思路,代码如下:
cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
// 树节点:用 map 存孩子(名字 -> 编号)
struct Node {
    map<string, int> ch;    // 子节点映射:文件夹/文件名 -> 节点编号
    bool hasIgn = false;    // 标记:该子树内是否存在必须忽略的文件
    bool hasKeep = false;   // 标记:该子树内是否存在不能忽略的文件
};
vector<Node> tr;            // 全局目录树,tr[0] 为根节点
// 创建一个新节点,返回其编号
int newNode() {
    tr.push_back(Node());
    return (int)tr.size() - 1;
}
// 把文件路径插入树中,返回该路径最终指向的节点编号
int insertPath(const string& path) {
    int cur = 0;                      // 从根节点 0 开始
    string seg;
    // 遍历字符串直到末尾(i == path.size() 时也能触发一次处理)
    for (size_t i = 0; i <= path.size(); i++) {
        if (i == path.size() || path[i] == '/') {
            if (!seg.empty()) {       // 提取出一段路径名
                auto it = tr[cur].ch.find(seg);
                int nxt;
                if (it == tr[cur].ch.end()) {
                    // 如果子节点不存在,则新建
                    nxt = newNode();
                    tr[cur].ch[seg] = nxt;
                } else {
                    // 如果已存在,直接复用
                    nxt = it->second;
                }
                cur = nxt;
                seg.clear();          // 清空当前段,准备读下一段
            }
        } else {
            seg += path[i];           // 拼接当前段的字符
        }
    }
    return cur;                       // 返回叶子节点(即文件本身)的编号
}
// 自底向上汇总标记:如果子节点有 hasIgn / hasKeep,父节点也要继承
void collect(int u) {
    for (auto& kv : tr[u].ch) {
        collect(kv.second);           // 递归处理子节点
        // 子节点有的标记,父节点也要有
        tr[u].hasIgn  = tr[u].hasIgn  || tr[kv.second].hasIgn;
        tr[u].hasKeep = tr[u].hasKeep || tr[kv.second].hasKeep;
    }
}
// 核心 DFS:计算忽略 u 子树下所有该忽略文件的最少行数
int dfs(int u) {
    if (!tr[u].hasIgn)  return 0;     // 子树里没有任何要忽略的,不需要操作
    if (!tr[u].hasKeep) return 1;     // 子树里全是该忽略的,直接忽略整个目录(1 行)
    // 子树里既有该忽略的,又有必须保留的
    // 不能整体忽略当前目录,只能拆开分别处理各个子节点
    int sum = 0;
    for (auto& kv : tr[u].ch) {
        sum += dfs(kv.second);        // 累加每个子节点需要的行数
    }
    return sum;
}
int main() {
    int T;
    if (scanf("%d", &T) != 1) return 0;  // 读入测试用例数
    while (T--) {
        int n, m;
        scanf("%d %d", &n, &m);       // n: 要忽略的文件数,m: 不能忽略的文件数
        tr.clear();                   // 清空上一组数据的树
        newNode();                    // 创建根节点 0
        char buf[1005];               // 读入路径用的缓冲区
        // ---------- 先处理要忽略的文件 ----------
        vector<int> ignNodes;         // 其实不需要保存,为了和原代码一致保留
        for (int i = 0; i < n; i++) {
            scanf("%s", buf);
            int id = insertPath(string(buf));
            tr[id].hasIgn = true;     // 在该文件节点标记:这是要忽略的文件
            ignNodes.push_back(id);
        }
        // ---------- 再处理不能忽略的文件 ----------
        for (int i = 0; i < m; i++) {
            scanf("%s", buf);
            int id = insertPath(string(buf));
            tr[id].hasKeep = true;    // 在该文件节点标记:这是不能忽略的文件
        }
        // ---------- 自底向上汇总标记 ----------
        collect(0);                   // 从根节点开始,把子树标记传递到父节点
        // ---------- 计算答案 ----------
        // 根节点不能整体忽略,必须拆开,逐个处理根的子节点
        int ans = 0;
        for (auto& kv : tr[0].ch) {
            ans += dfs(kv.second);    // 累加每个顶级目录/文件所需的行数
        }
        printf("%d\n", ans);          // 输出当前测试用例的答案
    }
    return 0;
}

相关推荐
雾沉川2 小时前
Visual C++运行库合集 v105.0 安装教程
开发语言·c++·dll
_wyt0012 小时前
洛谷P17461 [GESP202609 八级] 生成树计数 题解
c++·数学·树·gesp·八级·仙人掌
CCblandy2 小时前
向量检索 ANN 参数实测:换一种数据分布,recall 掉了 51 个百分点
人工智能·python·算法·自然语言处理·性能优化
CS创新实验室2 小时前
一棵树怎么做到“自平衡还不太费力“?——红黑树的旋转与染色
数据结构·算法·红黑树·408·计算机考研
Logic1013 小时前
C语言/数据结构位运算题解:异或XOR找出被挑选一次的果子编号——只出现一次的数字
c语言·数据结构·算法·数组·位运算·时间复杂度·空间复杂度
深圳市快瞳科技有限公司3 小时前
面向端侧部署的多帧宠物身份识别:完整性评分机制与工程约束
人工智能·算法·计算机视觉·端侧ai·宠物识别·宠物ai·宠物身份识别
知产xiao_xin3 小时前
专利立法及修改
经验分享·笔记·知识产权
qq_284274053 小时前
S7-1200 PLC硬件与启保停实训:CPU选型与DC/DC/DC、循环扫描五阶段、博途组态及电动机启停自锁梯形图(含考点)
笔记·学习·自动化·制造