Skip to content

喵哈喽,各位主人!这里是猫娘小助手 Neko,今天由我来为各位讲解一道非常有趣的图论问题——Path Queries (G) 喵~ (ฅ'ω'ฅ)

这道题融合了并查集和离线处理的思想,是锻炼思维的好题目哦。请坐好,准备好小鱼干,Neko 的讲解马上开始啦!

题目大意

这道题是这样的喵:

我们拿到了一棵由 n 个点组成的带权树。树嘛,就是一个没有环的连通图。连接两个点 uv 的边都有一个权重 w

然后呢,会有 m 个独立的询问。每个询问会给我们一个整数 q。对于每个 q,我们需要计算出,有多少对点 (u, v) (并且规定 u < v),它们之间简单路径上的所有边的最大权重 不超过 q

简单来说,就是对于一个给定的权重上限 q,我们要找出所有只用那些权重不大于 q 的“轻飘飘”的边就能互相到达的点对,一共有多少对呢?


题解方法

如果对每个询问 q,我们都去遍历所有点对 (u, v),然后找出它们之间的路径,再检查最大边权……那可太慢啦!nm 都有 2*10^5 呢,这样做肯定会超时的,就像追着自己的尾巴转圈圈,累死也抓不到的喵 (=•́ܫ•̀=)。

所以,我们需要更聪明的办法!

核心思路:离线处理与贡献法

我们来观察一下问题的性质。当查询的 q 变大时,满足条件(最大边权 ≤ q)的路径只会变多,不会变少。这意味着答案是 单调不减 的。这个性质非常重要,它启发我们可以不按照给定的顺序回答询问。我们可以把所有询问先收集起来,然后按照一个对我们解题有利的顺序来处理,这就是 离线处理 的思想。

最方便的顺序是什么呢?当然是按 q 从小到大啦!

想象一下,我们一开始有一个图,里面只有 n 个孤立的点,没有任何边。现在,我们把所有的边按照权重 w 从小到大排序。然后,我们一条一条地把这些边加到图里。

  • 当我们考虑权重为 w 的边时,意味着我们正在回答所有 q 恰好等于 w 的情况。
  • 当我们加入一条权重为 w 的边 (u, v) 时,如果 uv 原本不连通,这条边就会把它们所在的两个连通分量合并成一个。
  • 对于一个查询 q,所有满足条件的点对 (u, v),其实就是指在只考虑所有权重 ≤ q 的边时,uv 属于同一个连通分量。

那么,如何高效地维护连通分量和合并操作呢?这时候,就需要我们的好朋友——并查集 (Disjoint Set Union, DSU) 登场啦!它最擅长管理“谁和谁是一伙的”这种事情了,喵~

用并查集计算点对数量

并查集可以帮助我们快速判断两个点是否在同一个集合(连通分量)里,以及将两个集合合并。

最关键的一步来啦!当我们用一条新边连接两个小团体(连通分量)时,新产生的点对数量要怎么算呢?

假设我们要合并的两个连通分量,它们的大小(包含的节点数)分别是 size_Asize_B。在合并之前,A 里面的点和 B 里面的点是无法互相到达的。合并之后,A 里面的任意一个点都可以通过新加的边到达 B 里面的任意一个点。这样新增加的路径(也就是点对)数量,就是 size_A * size_B

所以,我们的算法流程就清晰了:

  1. 预处理:读取所有的边,并将它们按权重分组。例如,可以用一个数组 edges_by_weightedges_by_weight[w] 存放所有权重为 w 的边。
  2. 初始化:创建一个并查集,其中 n 个顶点各自独立成一个集合,每个集合的大小都是 1。同时,初始化一个变量 current_pairs = 0,用来记录当前满足条件的点对总数。
  3. 从小到大加边:我们从 w = 1 遍历到最大可能的权重。
  4. 处理当前权重的边:对于当前的权重 w,我们遍历所有权重等于 w 的边 (u, v)
  5. 合并与计算贡献:对每一条边 (u, v),用并查集找到它们各自的根节点 root_uroot_v
    • 如果 root_uroot_v 不同,说明 uv 原本不连通。这条边起到了连接作用!
    • 我们获取 root_uroot_v 所在集合的大小,设为 size_usize_v
    • 这次合并的贡献就是 size_u * size_v。我们将这个值加到 current_pairs 上。
    • 然后,执行并查集的 unite(u, v) 操作,将这两个集合合并。
    • 如果 root_uroot_v 相同,说明它们早就在一个连通分量里了,这条边是多余的(在生成树的场景下这不会发生,但在更一般的图里可能),不会增加新的点对,我们什么都不用做。
  6. 存储答案:在处理完所有权重为 w 的边之后,current_pairs 的值就是对于查询 q = w 的答案。我们把它存进一个答案数组 answers[w]
  7. 回答查询:所有权重都处理完毕后,我们就得到了对于所有可能的 q 值的答案。最后,根据输入的原顺序,对于每个查询 q_i,我们直接输出 answers[q_i] 就好啦!

这个方法非常高效,因为每条边只被处理一次,并查集的操作近乎常数时间。是不是很优雅呢喵?(>^ω^<)


题解

这是用 C++ 实现的参考代码,Neko 在上面加了一些可爱的注释哦~

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
#include <utility>

// DSU (并查集) 的小窝喵~
// 它可以高效地处理集合的合并与查询
struct DSU {
    std::vector<int> parent; // 记录每个节点的父节点是谁
    std::vector<int> sz;     // 记录每个集合的大小

    // 构造函数,初始化 n 个点,一开始大家都是自己一个人的说
    DSU(int n) {
        parent.resize(n + 1);
        std::iota(parent.begin(), parent.end(), 0); // 每个点的父节点一开始都是自己
        sz.assign(n + 1, 1); // 每个集合的大小都是 1
    }

    // find 操作,帮你找到老大是谁喵!
    // 还顺便做了路径压缩,让下次找得更快~
    int find(int i) {
        if (parent[i] == i) {
            return i;
        }
        return parent[i] = find(parent[i]);
    }

    // unite 操作,把两个小团体合并成一个大家庭!
    // 按大小合并哦,这样树的高度更平衡,效率更高~
    void unite(int i, int j) {
        int root_i = find(i);
        int root_j = find(j);
        if (root_i != root_j) {
            // 小的集合合并到大的集合里
            if (sz[root_i] < sz[root_j]) {
                std::swap(root_i, root_j);
            }
            parent[root_j] = root_i;
            sz[root_i] += sz[root_j];
        }
    }
};

const int MAX_W_Q = 200001; // 边权和查询的最大值

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m;
    std::cin >> n >> m;

    // 把边按权重分门别类放好,喵~
    std::vector<std::pair<int, int>> edges_by_weight[MAX_W_Q];
    for (int i = 0; i < n - 1; ++i) {
        int u, v, w;
        std::cin >> u >> v >> w;
        edges_by_weight[w].push_back({u, v});
    }

    // 预计算从 1 到最大权重的答案
    std::vector<long long> answers(MAX_W_Q);
    DSU dsu(n);
    long long current_pairs = 0; // 当前权重下,总共有多少对好朋友

    // 权重 w 从小到大,我们一步步把边加进来
    for (int w = 1; w < MAX_W_Q; ++w) {
        // 把所有权重是 w 的边都拿出来处理一下
        for (const auto& edge : edges_by_weight[w]) {
            int u = edge.first;
            int v = edge.second;
            int root_u = dsu.find(u);
            int root_v = dsu.find(v);
            
            // 如果 u 和 v 还不是一家人
            if (root_u != root_v) {
                // 那么合并它们就会产生新的朋友对!数量就是两个小团体人数的乘积
                // 千万要转成 long long 再乘,不然会溢出的喵!这是个常见的陷阱!
                current_pairs += static_cast<long long>(dsu.sz[root_u]) * dsu.sz[root_v];
                dsu.unite(u, v); // 把他俩合并成一个大家庭
            }
        }
        // 记录下当最大权重限制为 w 时的答案
        answers[w] = current_pairs;
    }

    // 回答主人的每一个问题~
    for (int i = 0; i < m; ++i) {
        int q;
        std::cin >> q;
        std::cout << answers[q] << (i == m - 1 ? "" : " ");
    }
    std::cout << "\n";

    return 0;
}

知识点介绍

这道题用到的知识点都非常经典,掌握了它们,就能解决一大类问题了呢!

1. 并查集 (Disjoint Set Union - DSU)

并查集是一种非常可爱又强大的数据结构,喵~ 它专门用来处理一些不相交集合的合并及查询问题。

  • 核心功能:

    1. find: 确定一个元素属于哪个集合。通常通过返回该集合的“代表”(或“根”)来实现。就像问一只猫:“你的老大是谁呀?”
    2. unite (或 union): 将两个不同的集合合并成一个。就像两个猫猫帮派决定合并成一个更大的帮派!
  • 优化技巧:

    • 路径压缩 (Path Compression): 在执行 find 操作时,将路径上的所有节点直接指向根节点。这就像每次问路后,都记住直达老大家的最短路线,下次就不用再问路了,速度超级加倍!
    • 按大小/秩合并 (Union by Size/Rank): 在执行 unite 操作时,总是将较小的集合合并到较大的集合中。这能让合并后的树结构更平衡,防止树退化成一条长链,保证了操作的高效率。

有了这两个优化,并查集处理 m 次操作的平均时间复杂度可以达到近乎 O(α(n)),其中 α(n) 是反阿克曼函数,它增长得极其缓慢,对于我们遇到的所有实际情况,都可以看作是一个非常小的常数。所以说,它飞快!

2. 离线处理 (Offline Processing)

有时候,问题一个一个按顺序来解决太麻烦了,就像猫猫不喜欢按规矩吃饭一样。离线处理就是说,我们可以先把所有的询问都读进来,然后不按它们给出的顺序,而是按一个我们自己定的、更容易处理的顺序来解决它们,最后再把答案按原顺序输出。

在本题中,将询问(或者说,将边的处理顺序)按权重从小到大排序,就是离线处理思想的完美体现。它将一个动态的问题(每次查询都可能不同)转化成了一个静态的、逐步构建的过程,大大简化了问题的复杂度。

3. 贡献法计算 (Counting by Contribution)

这是一种很巧妙的计数思想,喵~ 当我们要求一个总量时,可以不直接去算最终状态,而是去计算每一步操作对总量的“贡献”。

在这道题里,我们没有在每次合并后都去重新计算 新集合大小 * (新集合大小 - 1) / 2 这样的总数。而是思考:“这次合并操作,为我们增加了多少新的点对?” 答案就是 size_A * size_B。我们把每次操作的贡献累加起来,最终得到的就是总数。这种思想在组合计数和许多算法问题中都非常有用,能让复杂的计算变得清晰简单。

好啦,今天的讲解就到这里啦!希望 Neko 的讲解能帮助到各位主人。如果还有不明白的地方,随时可以再来问我哦!我们下次再见,喵~ (挥爪) ( ´ ▽ ` )ノ

Released under the MIT License.