UVA11354 Bond 最大边权最小, 瓶颈生成树
UVA11354 Bond 最小危险路径查询
再一次,詹姆斯·邦德正在前往拯救世界的途中。这次任务需要他在某个国家的若干对城市之间来回穿行。
这个国家有 \(N\) 个城市(编号为 \(1,2,\dots,N\) ),由 \(M\) 条双向公路连接。邦德会“借来”一辆车,从城市 \(s\) 开往城市 \(t\) 沿路行驶。国家的警察会在各条公路上进行巡逻,但并不是所有公路都受到同样的关注程度——警察更关注那些危险系数较高的路段。
更正式地说,情报机构(MI6)为每条公路估算了一个“危险度”值,危险度越高,邦德在该路段被抓获的概率就越大。对于一条从 \(s\) 到 \(t\) 的路径,其整体危险度定义为该路径上所有公路中“危险度最大的那条”的危险度值。
现在,你的任务就是帮助邦德选择“最不危险”的路径,即,对于给定的起点 \(s\) 和终点 \(t\) ,找到一条从 \(s\) 到 \(t\) 的路径,使得这条路径上的最大危险度最小化。
输入格式
输入文件中最多包含 5 组测试数据。
每组测试数据格式如下:
- 
第一行包含两个整数 \(N, M\) (满足 \(2 \le N \le 50000\) , \(1 \le M \le 100000\) ),分别表示城市数和公路数。 
- 
接下来的 \(M\) 行描述这 \(M\) 条公路。第 \(i\) 行包含三个整数 \(x_i,\,y_i,\,d_i\) ,表示第 \(i\) 条公路连接城市 \(x_i\) 和城市 \(y_i\) ,其危险度为 \(d_i\) 。其中 \(1 \le x_i,\,y_i \le N\) ,且 \(0 \le d_i \le 10^9\) 。 
- 
描述完公路之后,会有一行包含一个整数 \(Q\) ( \(1 \le Q \le 50000\) ),表示查询次数。接下来的 \(Q\) 行每行包含一对整数 \((s_i,\,t_i)\) (满足 \(1 \le s_i,\,t_i \le N\) ,且 \(s_i \ne t_i\) ),表示询问从城市 \(s_i\) 到城市 \(t_i\) 的“最小危险度”是多少。 
相邻两组测试数据之间会有一个空行分隔。
输出格式
对于每组测试数据,输出 \(Q\) 行,第 \(i\) 行对应询问 \((s_i,t_i)\) 的答案,即从城市 \(s_i\) 到城市 \(t_i\) 的路径中,所有可能路径的“最大边危险度”中的最小值。相邻两组输出之间也要用一个空行分隔。
样例输入
4 5
1 2 10
1 3 20
1 4 100
2 4 30
3 4 10
2
1 4
4 1
2 1
1 2 100
1
1 2
样例输出
20
20
100
问题分析
题目给定一个有 \(N\) 个城市(节点)、 \(M\) 条双向公路(边)的图。每条公路都有一个“危险值” \(d_i\) ,表示邦德在这条路上被抓到的概率,危险值越大越危险。对于给定的起点 \(s\) 和终点 \(t\) ,我们希望选择一条从 \(s\) 到 \(t\) 的路径,使得这条路径上所有边的最大危险值最小化。换句话说,对于一条路径 \(P\) ,定义其危险度为
我们要在所有从 \(s\) 到 \(t\) 的路径中,找到使上式值最小的那一条路径。最终输出的,就是这个最小的“最大边危险值”。
关键性质
- 
最小生成树(MST)与「最小化最大边权路径」的等价性 
 经典的图论结论:在一个连通图中,构造「最小生成树」(如用 Kruskal 算法),那么在这棵树上,任意两点之间的唯一路径,就恰好是原图中所有路径中“最大边权最小化”(minimax)的那条。更严谨地说,对于任意两个节点 \(u,v\) ,设:- 
\(\alpha(u,v)\) :为原图中从 \(u\) 到 \(v\) 的所有路径中, \(\max\) -边权最小的值; 
- 
MST 中 \(u,v\) 之间的唯一路径上的最大边权值是 \(\beta(u,v)\) 。 
 那么有 \(\alpha(u,v) = \beta(u,v)\) 。
 直观上,Kruskal 构造的最小生成树优先选用危险值(边权)小的边,当需要连接两个点时,一定会选取可能使「这两个点之间的最大边权」尽可能小的边。详细证明可以参考并查集与最小生成树的相关性质,但在竞赛中只需牢记:用 Kruskal 建 MST,然后在 MST 上查询两点间路径的最大边权即可。 
- 
- 
查询两点间最大边权:树上 LCA(二进制 络合)预处理 
 在树上,如果我们想要快速查询两个节点 \(u\) 和 \(v\) 之间路径上的最大边权,一般做法是:- 
先对树进行一次 DFS/BFS,记录每个节点的深度 depth[x],以及它的父节点fa[x][0]和到父节点那条边的权重maxw[x][0]。
- 
再进行二进制父亲跳跃预处理(即倍增,最多跳 \(\log_2 N\) 次),构造 fa[x][k]表示节点 \(x\) 向上跳 \(2^k\) 步到达的祖先编号,maxw[x][k]表示在这个跳跃过程中所经过边的最大值。
- 
查询时,将较深的节点往上抬,使两节点深度相同;再从最高位开始,若两人跳到的祖先不同,就同时跳,并更新所见边权的最大值;最后再跳一次到它们的公共父节点,更新一次最大边权即可。 
 这样,每次查询的时间复杂度为 \(O(\log N)\) 。
 
- 
- 
算法整体复杂度 - 
构造最小生成森林(因为图可能不连通):对 \(M\) 条边排序 \(O(M \log M)\) ,再用并查集做 Kruskal 合并,复杂度约为 \(O(M \alpha(N))\) 。 
- 
在 MST(森林)上做 DFS/BFS 遍历,初始化深度和第一代父节点: \(O(N)\) (加上遍历所有边,至多 \(O(N)\) 条树边)。 
- 
倍增预处理:对于每个节点存 \(\log_2 N\) 层父亲,共 \(O(N \log N)\) 。 
- 
每次查询 \(O(\log N)\) ,总共 \(Q\) 次,故为 \(O(Q \log N)\) 。 
- 
由于题目中有最多 5 组数据,每组 \(N \le 5\times10^4\) , \(M \le 10^5\) , \(Q \le 5\times10^4\) ,整体能在秒级时间内完成。 
 
- 
详细步骤
- 
读入图: - 
读入 \(N\) 、 \(M\) 。 
- 
将每条边 \((x_i, y_i, d_i)\) 存入 edges数组。
 
- 
- 
Kruskal 建立最小生成森林: - 
按危险值 \(d_i\) 升序排序 edges。
- 
初始化并查集 DSU,最开始每个节点单独成集合。
- 
依次枚举每条边 \((u,v,w)\) ,若 find(u) != find(v),则将它们在 DSU 中合并union(u,v),并把这条边加入到我们的“树”邻接表adj[u].push_back({v,w}); adj[v].push_back({u,w})。
- 
最终得到一片森林(如果原图不是连通图的话),但保证任意查询对 \((s,t)\) 必然连通。 
 
- 
- 
在森林上做 BFS/DFS,初始化深度和第一层父节点: - 
维护数组 depth[i]表示节点 \(i\) 的深度,up[i][0]表示它的直接父节点编号,maxw[i][0]表示它到父节点的那条边的危险值。
- 
对每个还没被访问过的节点 \(i\) ( depth[i] == -1),把它当作根depth[i]=0,up[i][0]=0(或 0 表示它没有父节点),maxw[i][0]=0,然后用 BFS/队列把它所在连通块遍历一遍,给所有邻居赋予depth[neighbor]=depth[cur]+1,up[neighbor][0]=cur,maxw[neighbor][0]=weight(cur,neighbor)。
 
- 
- 
倍增预处理: - 
设最多需要 \(\lceil \log_2 N \rceil\) 层(令 LOG=17足够覆盖 \(5\times10^4\) )。
- 
对 \(k = 1,2,\dots,LOG-1\) ,对每个节点 \(v\) : up[v][k] = up[ up[v][k-1] ][k-1]; maxw[v][k] = max( maxw[v][k-1], maxw[ up[v][k-1] ][k-1] );如果 up[v][k-1] == 0说明到达了树根或没父节点,up[v][k] = 0,maxw[v][k] = maxw[v][k-1](但注意竞赛里一般不会让查询跨不同连通块)。
 
- 
- 
**回答查询 \((s_i, t_i)\) **: - 
先令 u = s_i, v = t_i,若depth[u] < depth[v],交换使u是更深的节点。
- 
将 u向上跳到和v同一深度的过程:int diff = depth[u] - depth[v]; for (int k = 0; k < LOG; ++k) { if ( diff & (1 << k) ) { ans = max(ans, maxw[u][k]); u = up[u][k]; } }
- 
此时若 u == v,直接返回ans(在两点深度不同的情况下,最深的点先向上跳过来,若遇上了另一点,中间的路径最大危险值已经记录在ans中)。
- 
若 u != v,我们从最高层开始往下看:for (int k = LOG-1; k >= 0; --k) { if ( up[u][k] != up[v][k] ) { ans = max(ans, maxw[u][k]); ans = max(ans, maxw[v][k]); u = up[u][k]; v = up[v][k]; } } // 最后一步,再把 u 和 v 的父亲边也算进去 ans = max(ans, maxw[u][0]); ans = max(ans, maxw[v][0]);
- 
此时 up[u][0] == up[v][0]就是它们的 LCA,ans中记录的即为路径上所有经过边的最大危险值。
 
- 
- 
按照题目要求,控制输出格式 - 每组测试数据输出完其所有 \(Q\) 行答案后,如果后面还有新组数据,就打印一个空行与下一组结果分隔。
 
C++ 代码实现
下面给出完整、可直接拷贝编译的 C++ 代码。代码风格为 ACM/OI 常见写法,注释较为详细,关键的实现与思路都在注释中给出解释。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
static const int MAXN = 50000 + 5;
static const int MAXM = 100000 + 5;
static const int LOG = 17;  // 2^16 = 65536 已足够覆盖 N <= 50000
// ---------- 并查集(DSU) ----------
int dsu_parent[MAXN];
// 初始化并查集
void dsu_init(int n) {
    for (int i = 1; i <= n; ++i) {
        dsu_parent[i] = i;
    }
}
// 查找代表元(带路径压缩)
int dsu_find(int x) {
    if (dsu_parent[x] != x) {
        dsu_parent[x] = dsu_find(dsu_parent[x]);
    }
    return dsu_parent[x];
}
// 合并集合
void dsu_union(int a, int b) {
    a = dsu_find(a);
    b = dsu_find(b);
    if (a != b) {
        dsu_parent[b] = a;
    }
}
// ---------- 全局变量 ----------
// 边的结构:u, v 为端点,w 为危险值
struct Edge {
    int u, v;
    ll w;
};
// 原始的 M 条边
Edge edges[MAXM];
// 最小生成森林(MST)的邻接表:adj[u] 中存 (v, w_uv)
vector<pair<int,ll>> adj[MAXN];
// LCA 相关数组
int depth_arr[MAXN];
// up[v][k] 表示节点 v 向上跳 2^k 步到达的祖先节点;若为 0 说明没有
int up[MAXN][LOG];
// maxw[v][k] 表示节点 v 向上跳 2^k 步过程中,所经过边的最大危险值
ll maxw[MAXN][LOG];
// 记录每个节点是否已经在 BFS/DFS 中访问过
bool visited[MAXN];
// ---------- 函数声明 ----------
// Kruskal 建最小生成森林
void build_mst(int n, int m);
// 在森林上做 BFS,从各个连通块的根开始,初始化 depth_arr、up[i][0]、maxw[i][0]
void init_lca(int n);
// 根据倍增预处理,上述 up[i][0]、maxw[i][0] 初始化好之后,构造 up[i][k]、maxw[i][k]
void calc_up_maxw(int n);
// 查询两个节点 u, v 在 MST 上路径中的最大边权(危险值)
ll query_max_on_path(int u, int v);
// ---------- 主程序 ----------
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    bool first_case = true;
    int N, M;
    // 多组测试数据,输入以 EOF 结束;两组数据之间可能有空行,cin >> N >> M 会自动跳过空白
    while ( (cin >> N >> M) ) {
        // 读入 M 条边
        for (int i = 0; i < M; ++i) {
            int x, y;
            ll d;
            cin >> x >> y >> d;
            edges[i].u = x;
            edges[i].v = y;
            edges[i].w = d;
        }
        // 先把前一组可能残留的邻接表清空,visited 重置
        for (int i = 1; i <= N; ++i) {
            adj[i].clear();
            visited[i] = false;
            // depth_arr[i] = -1; // 后面在 init_lca 中设置
            for (int k = 0; k < LOG; ++k) {
                up[i][k] = 0;
                maxw[i][k] = 0;
            }
        }
        // Kruskal 建最小生成森林
        build_mst(N, M);
        // 在森林上做 BFS/DFS 初始化 LCA 的第一层信息
        init_lca(N);
        // 倍增预处理,构造 up[][k], maxw[][k]
        calc_up_maxw(N);
        // 处理查询
        int Q;
        cin >> Q;
        if (!first_case) {
            // 如果不是第一组数据,则先输出一个空行分隔
            cout << "\n";
        }
        first_case = false;
        while (Q--) {
            int s, t;
            cin >> s >> t;
            ll ans = query_max_on_path(s, t);
            cout << ans << "\n";
        }
    }
    return 0;
}
// ---------- 函数定义 ----------
// Kruskal 建最小生成森林,把选中的边加入 adj[] 中
void build_mst(int n, int m) {
    // 1) 并查集初始化
    dsu_init(n);
    // 2) 按边权(危险值)升序排序
    sort(edges, edges + m, [](const Edge &a, const Edge &b) {
        return a.w < b.w;
    });
    // 3) 逐条边尝试合并
    for (int i = 0; i < m; ++i) {
        int u = edges[i].u;
        int v = edges[i].v;
        ll w = edges[i].w;
        int fu = dsu_find(u);
        int fv = dsu_find(v);
        if (fu != fv) {
            // 这条边会被选入 MST
            dsu_union(fu, fv);
            adj[u].push_back({v, w});
            adj[v].push_back({u, w});
        }
    }
}
// BFS 遍历森林,初始化 depth_arr、up[v][0]、maxw[v][0]
void init_lca(int n) {
    // 用队列做 BFS
    queue<int> que;
    // 一开始把所有节点的 depth 设为 -1,表示还没访问
    for (int i = 1; i <= n; ++i) {
        depth_arr[i] = -1;
    }
    // 对于每个还没访问的节点 i,把它当作一棵树的根,深度设为 0,up[i][0]=0
    for (int i = 1; i <= n; ++i) {
        if (depth_arr[i] == -1) {
            // i 作为本连通块的“根”
            depth_arr[i] = 0;
            up[i][0] = 0;        // 根节点没有父亲
            maxw[i][0] = 0;      // 到父亲的边的权重设为 0
            que.push(i);
            // 标记已访问
            // visited[i] = true;   // 这里 depth_arr=-1 就能判断是否访问
            // 正式 BFS
            while (!que.empty()) {
                int u = que.front();
                que.pop();
                // 遍历 u 的所有邻接边
                for (auto &pr : adj[u]) {
                    int v = pr.first;
                    ll w = pr.second;
                    if (depth_arr[v] == -1) {
                        // v 还没被访问过,把它标记并加入队列
                        depth_arr[v] = depth_arr[u] + 1;
                        up[v][0] = u;
                        maxw[v][0] = w;
                        que.push(v);
                    }
                }
            }
        }
    }
}
// 倍增预处理:计算 up[v][k] 和 maxw[v][k]
void calc_up_maxw(int n) {
    for (int k = 1; k < LOG; ++k) {
        for (int v = 1; v <= n; ++v) {
            int mid_ancestor = up[v][k - 1];
            if (mid_ancestor != 0) {
                up[v][k] = up[mid_ancestor][k - 1];
                // 跳 2^k 步所经历的最大边权 =
                // max(跳前 2^(k-1) 的最大边权, 从中间祖先再跳 2^(k-1) 的最大边权)
                maxw[v][k] = max( maxw[v][k - 1], maxw[mid_ancestor][k - 1] );
            } else {
                up[v][k] = 0;
                maxw[v][k] = maxw[v][k - 1];
            }
        }
    }
}
// 查询 u, v 在 MST 上路径中的最大边权
ll query_max_on_path(int u, int v) {
    // 1) 先把 u, v 拉到同一深度
    ll answer = 0;
    if (depth_arr[u] < depth_arr[v]) {
        std::swap(u, v);
    }
    int diff = depth_arr[u] - depth_arr[v];
    for (int k = 0; k < LOG; ++k) {
        if (diff & (1 << k)) {
            // u 向上跳 2^k 步
            answer = max(answer, maxw[u][k]);
            u = up[u][k];
        }
    }
    // 现在 depth_arr[u] == depth_arr[v]
    if (u == v) {
        return answer;
    }
    // 2) 同时向上跳:从最高位开始,如果跳了之后两者祖先仍然不同,就跳
    for (int k = LOG - 1; k >= 0; --k) {
        if (up[u][k] != 0 && up[u][k] != up[v][k]) {
            answer = max(answer, maxw[u][k]);
            answer = max(answer, maxw[v][k]);
            u = up[u][k];
            v = up[v][k];
        }
    }
    // 3) 此时 u, v 的父节点就是 LCA,但要把 u->父、v->父 这两条边的权重也算上
    answer = max(answer, maxw[u][0]);
    answer = max(answer, maxw[v][0]);
    return answer;
}
代码说明(要点回顾)
- 
并查集(DSU) - 
dsu_init(n):把1..n每个节点的父亲初始化为自己。
- 
dsu_find(x):带路径压缩地查询集合代表元。
- 
dsu_union(a,b):先find两个节点,如果代表元不同,就把一个根指向另一个,从而合并集合。
 
- 
- 
Kruskal 算法建 MST - 
对所有边按危险值(边权)升序排序; 
- 
遍历这些边,若边的两个端点未连通(在并查集中判断),就将它们合并,并将这条边加入最小生成森林的邻接表 adj[]。
- 
最终每个连通块里得到一棵树(如果原图连通,则是一棵树,否则是几个树组成的森林)。 
 
- 
- 
BFS 初始化深度和第一层 LCA 信息 - 
遍历所有节点,如果 depth_arr[i] == -1,说明还没在任何树里被访问,就把它当作新连通块的“根”,depth_arr[i]=0, up[i][0]=0, maxw[i][0]=0,然后将它放入队列,开始 BFS。
- 
每次从队列中取出当前节点 u,对它的邻居v进行松弛:- 若 depth_arr[v] == -1,说明v还未被访问,就设depth_arr[v] = depth_arr[u] + 1,up[v][0] = u(第一代父亲),maxw[v][0] = w(u,v),并将v入队。
 
- 若 
- 
这样整个连通块都能被遍历到。 
 
- 
- 
倍增预处理 - 
利用公式 \[ up[v][k] = up\bigl(\, up[v][k-1] \bigr)[k-1], \quad maxw[v][k] = \max\bigl( maxw[v][k-1],\; maxw[\,up[v][k-1]\,][k-1]\bigr). \]
- 
逻辑:从节点 \(v\) 向上跳 \(2^k\) 步,可以先跳 \(2^{k-1}\) 步到中间祖先,再从中间祖先继续跳 \(2^{k-1}\) 步;整段路上的最大边权是两段之大者。 
 
- 
- 
处理查询 - 
第一步:对齐深度 - 把较深的节点(设为 u)往上跳,让它与v深度相同。跳的同时,要把跳过的maxw[u][k]更新到answer中,保证记录那段路径上的最大边值。
 
- 把较深的节点(设为 
- 
第二步:寻找 LCA - 
此时 u与v深度相同,若它们相同,则直接返回当前answer。
- 
否则,从最高的 \(k=\lfloor\log N\rfloor\) 开始,若 up[u][k] != up[v][k],说明在向上跳 \(2^k\) 后它们仍然不是同一个祖先,就可以同时让u = up[u][k], v = up[v][k],并把maxw[u][k]、maxw[v][k]更新到answer中。
 
- 
- 
第三步:最后一步跳到 LCA - 经过第二步后,u和v现在是处在 LCA 的两个不同子节点(即up[u][0] == up[v][0])。我们只需要把u->up[u][0]、v->up[v][0]这两条边的危险值也更新到answer,就得到了从原来查询的s到t整条路径上的最大危险值。
 
- 经过第二步后,
 
- 
- 
输出格式 - 题目要求:每组数据输出完毕后,如果后面还有新组数据,就在两个输出块之间空一行。代码中用 first_case标志来决定是否要先输出一个空行。
 
- 题目要求:每组数据输出完毕后,如果后面还有新组数据,就在两个输出块之间空一行。代码中用 
以上即为本题的完整思路和代码。该方法利用最小生成树将“最小化路径最大边权”的问题化为“树上 LCA 查询最大边权”,时间和内存都能满足题目给定的 \(N, M, Q\) 上界要求。
 
                    
                     
                    
                 
                    
                

 
                
            
         
         浙公网安备 33010602011771号
浙公网安备 33010602011771号