题解:洛谷 P14362 道路修复

【题目来源】

洛谷:P14362 [CSP-S 2025] 道路修复

【题目描述】

C 国的交通系统由 \(n\) 座城市与 \(m\) 条连接两座城市的双向道路构成,第 \(i\) (\(1 \leq i \leq m\)) 条道路连接城市 \(u_i\)\(v_i\)任意两座城市都能通过若干条道路相互到达。

然而,近期由于一场大地震,所有 \(m\) 条道路都被破坏了,修复第 \(i\) (\(1 \leq i \leq m\)) 条道路的费用为 \(w_i\)。与此同时,C 国还有 \(k\) 个准备进行城市化改造的乡镇。对于第 \(j\) (\(1 \leq j \leq k\)) 个乡镇,C 国对其进行城市化改造的费用为 \(c_j\)。在城市化改造完第 \(j\) (\(1 \leq j \leq k\)) 个乡镇后,可以在这个乡镇与原来的 \(n\) 座城市间建造若干条道路,其中在它与第 \(i\) (\(1 \leq i \leq n\)) 座城市间建造一条道路的费用为 \(a_{j,i}\)。C 国可以在这 \(k\) 个乡镇中选择任意多个进行城市化改造,也可以不选择任何乡镇进行城市化改造。

为尽快恢复城市间的交通,C 国政府希望以最低的费用将原有\(n\) 座城市两两连通,也即任意两座原有的城市都能通过若干条修复或新建造的道路相互到达。你需要帮助他们求出,将原有的 \(n\) 座城市两两连通的最小费用。

【输入】

输入的第一行包含三个非负整数 \(n, m, k\),分别表示原有的城市数量、道路数量和准备进行城市化改造的乡镇数量。

输入的第 \(i+1\) (\(1 \leq i \leq m\)) 行包含三个非负整数 \(u_i, v_i, w_i\),表示第 \(i\) 条道路连接的两座城市与修复该道路的费用。

输入的第 \(j+m+1\) (\(1 \leq j \leq k\)) 行包含 \(n+1\) 个非负整数 \(c_j, a_{j,1}, a_{j,2}, \ldots, a_{j,n}\),分别表示将第 \(j\) 个乡镇进行城市化改造的费用与在该乡镇与原有的城市间建造道路的费用。

【输出】

输出一行一个非负整数,表示将原有的 \(n\) 座城市两两连通的最小费用。

【输入样例】

4 4 2
1 4 6
2 3 7
4 2 5
4 3 4
1 1 8 2 4
100 1 3 2 4

【输出样例】

13

【核心思想】

  1. 问题分析:给定 \(n\) 座城市和 \(m\) 条可修复的双向道路(费用 \(w_i\)),以及 \(k\) 个可选改造的乡镇。改造乡镇 \(j\) 需费用 \(c_j\),之后可在该乡镇与任意城市间建路(费用 \(a_{j,i}\))。目标是使 \(n\) 座城市两两连通的最小总费用。本质是一个带可选扩展点的最小生成树(MST)问题,需要在原有道路和可选乡镇扩展之间做最优选择。

  2. 算法选择

    • Kruskal + 状态枚举:先求只考虑原有道路的MST作为基准,再枚举每个乡镇是否改造(\(2^k\) 种状态),对每个状态将所有可用边(原有MST边 + 已改造乡镇到城市的边)重新跑Kruskal求MST,取全局最小值
    • 关键优化\(k \leq 10\)(由代码中 \(c[15]\)\(1<<k\) 推断),\(2^k = 1024\) 可接受枚举
  3. 关键步骤

    • 阶段一:基准MST
      • \(m\) 条原有道路按费用排序,用Kruskal求MST,得到初始答案 \(\text{ans}\)\(n-1\) 条MST边存入 \(\text{newedges}\)
    • 阶段二:扩展边预处理
      • 对每个乡镇 \(j\),读入改造费用 \(c_j\) 和到各城市的建路费用 \(a_{j,i}\)
      • 将乡镇 \(j\) 到城市 \(i\) 的边加入 \(\text{newedges}\),标记 \(\text{idx} = j\) 表示该边属于乡镇 \(j\)
      • 对所有扩展边按费用排序
    • 阶段三:状态枚举
      • 枚举改造状态 \(x \in [1, 2^k - 1]\)(二进制位表示哪些乡镇被改造)
      • 对每个状态,初始化并查集,累加被改造乡镇的改造费用 \(c_j\)
      • 带条件选边:遍历 \(\text{newedges}\),若边属于某乡镇则检查该乡镇是否被改造(对应二进制位为1),否则直接可用
      • 用Kruskal选边,若总点数 \(s\)\(n\) 城市 + 被改造乡镇数)形成 \(s-1\) 条边则连通,更新 \(\text{ans} = \min(\text{ans}, \text{res})\)
    • 输出全局最小费用
  4. 时间/空间复杂度

    • 时间复杂度:\(O(m \log m + 2^k \cdot (n + k) \cdot \alpha(n) \cdot \log(nk))\),基准Kruskal \(O(m \log m)\),枚举 \(2^k\) 种状态每种跑Kruskal
    • 空间复杂度:\(O(n + m + nk)\),存储边和并查集
  5. MST + 可选扩展点枚举的核心思想

    • 基准 + 增量思想:先求不含任何乡镇的基准MST,再考虑引入乡镇后的优化可能,避免重复计算
    • 状态空间压缩:由于乡镇数量 \(k\) 很小(\(\leq 10\)),直接二进制枚举所有改造组合,而非设计复杂贪心或DP
    • 边分类处理:将边分为"原有道路"(始终可用)和"乡镇边"(条件可用),通过 \(\text{idx}\) 标记实现条件筛选
    • 提前退出剪枝:Kruskal过程中若当前费用已超 \(\text{ans}\) 或已形成生成树,立即终止,减少无效计算
    • 适用于带可选设施的最小连通代价、网络扩展规划、小规模组合优化问题

【解题思路】

img/Pasted image 20260827190820.png

【算法标签】

提高 #生成树

【代码详解】

// 32分版本
#include <bits/stdc++.h>
using namespace std;
#define int long long                   // 将int宏定义为long long,防止费用累加溢出
const int N = 10005+15, M = 1000005, INF = 1e18;  // N为最大节点数(n个城市+k个乡镇),M为最大边数,INF为极大值
int n, m, k;                            // n为原有城市数量,m为被破坏的道路数量,k为待改造乡镇数量
int p[N];                               // 并查集的父节点数组,p[x]表示x所在连通块的根
int c[15], A[15][N];                    // c[j]为第j个乡镇的改造费用;A[j][i]为乡镇j到城市i的建路费用
struct Edge                             // 定义结构体Edge存储边的信息
{
    int a, b, w;                        // a,b为边的两个端点,w为边的费用(修复或建造费用)
    bool operator < (const Edge &E)const  // 重载小于运算符,用于按费用升序排序
    {
        return w < E.w;                 // 按边权w从小到大排序
    }
}edges[M];                              // 存储所有边的数组

int find(int x)                         // 并查集的查找操作,带路径压缩
{
    if (p[x] != x) p[x] = find(p[x]);   // 如果x不是根节点,递归查找根,并进行路径压缩
    return p[x];                        // 返回x所在连通块的根节点
}

int kruskal()                           // Kruskal算法求最小生成树,返回最小总费用
{
    sort(edges+1, edges+m+1);           // 将所有边按费用从小到大排序(edges从1开始存储)
    for (int i=1; i<=n; i++) p[i] = i;  // 初始化并查集:每个城市自成一个连通块

    int res = 0, cnt = 0;               // res为当前最小生成树的总费用,cnt为已选边数
    for (int i=1; i<=m; i++)           // 遍历排序后的所有边,尝试加入最小生成树
	{
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;  // 取出当前边的两个端点和费用
        a = find(a), b = find(b);       // 查找a和b所在连通块的根
        if (a != b)						// 如果a和b不在同一连通块(加入该边不会形成环)
		{
            p[a] = b;                   // 合并两个连通块:将a的根指向b的根
            res += w;                   // 累加该边的费用到总费用
            cnt ++;                     // 已选边数加1
        }
    }

    if (cnt<n-1) return INF;            // 如果选中的边数不足n-1,说明图不连通,返回无穷大
    return res;                         // 返回最小生成树的总费用
}

signed main()                           // 使用signed main是因为#define int long long后,main返回值类型需要显式声明
{
    cin >> n >> m >> k;                 // 读入城市数n、道路数m、乡镇数k
    for (int i=1; i<=m; i++)           // 循环读入m条被破坏的道路
        cin >> edges[i].a >> edges[i].b >> edges[i].w;  // 读入每条道路的两个端点和修复费用

    // 注意:当前代码只处理了原有道路,未处理乡镇改造后的新道路
    // 完整解法需要枚举每个乡镇是否改造,并将改造后的道路加入edges数组再跑Kruskal

    int ans = kruskal();                // 只考虑原有道路,求最小生成树费用
    cout << ans << endl;                // 输出最小费用(当前代码未考虑乡镇改造,答案可能不完整)
    return 0;
}
// AC版本
#include <bits/stdc++.h>
using namespace std;
#define int long long                   // 将int宏定义为long long,防止费用累加溢出
const int N = 10005+15, M = 1100005, INF = 1e18;  // N为最大节点数,M为最大边数,INF为极大值
int n, m, k, ans=1e18;                  // n为城市数,m为道路数,k为乡镇数,ans为最小费用
int p[N];                               // 并查集的父节点数组,p[x]表示x所在连通块的根
int c[15];                              // c[i]为第i个乡镇的改造费用
struct Edge                             // 定义结构体Edge存储边的信息
{
    int a, b, w, idx;                   // a,b为边的两个端点,w为费用,idx标记该边属于哪个乡镇(0表示原有道路)
    bool operator < (const Edge &E)const  // 重载小于运算符,用于按费用升序排序
    {
        return w < E.w;                 // 按边权w从小到大排序
    }
}edges[M], newedges[M];                 // edges存储原有道路,newedges存储最小生成树边+乡镇到城市的边

int find(int x)                         // 并查集的查找操作,带路径压缩
{
    if (p[x] != x) p[x] = find(p[x]);   // 如果x不是根节点,递归查找根,并进行路径压缩
    return p[x];                        // 返回x所在连通块的根节点
}

int kruskal()                           // Kruskal算法求只考虑原有道路的最小生成树
{
    sort(edges+1, edges+m+1);           // 将所有原有道路按费用从小到大排序
    for (int i=1; i<=n; i++) p[i] = i;  // 初始化并查集:每个城市自成一个连通块

    int res = 0, cnt = 0;               // res为当前最小生成树的总费用,cnt为已选边数
    for (int i=1; i<=m; i++)           // 遍历排序后的所有原有道路
	{
        int a = edges[i].a, b = edges[i].b, w = edges[i].w;  // 取出当前边的信息
        a = find(a), b = find(b);       // 查找a和b所在连通块的根
        if (a != b)						// 如果a和b不在同一连通块(加入该边不会形成环)
		{
            p[a] = b;                   // 合并两个连通块
            res += w;                   // 累加该边的费用
            cnt ++;                     // 已选边数加1
            newedges[cnt] = edges[i];   // 将选中的边存入newedges(后续作为基础MST边使用)
        }
    }
    if (cnt<n-1) return INF;            // 如果选中的边数不足n-1,说明原有道路无法连通所有城市
    return res;                         // 返回只考虑原有道路的最小生成树费用
}

int kruskal2(int x)                     // 在状态x(二进制表示哪些乡镇被改造)下求最小生成树费用
{
    int s = n;                          // s为总点数(n个城市 + 被改造的乡镇数)
    int res = 0, cnt = 0;               // res为总费用,cnt为已选边数
    int tmp = x;                        // tmp用于遍历状态x的每一位
    for (int i=1; i<=k; i++)           // 枚举每个乡镇
    {
        if (tmp & 1)                    // 如果状态x的第i位为1,表示改造第i个乡镇
        {
            s++;                        // 总点数加1(加入该乡镇节点)
            res += c[i];                // 累加改造费用c[i]
        }
        tmp >>=1;                       // 右移一位,检查下一位
    }
    cnt = 0;
    for (int i = 1; i <= n + k; i++)   // 初始化并查集:所有城市和乡镇各自成块
        p[i] = i;
    for (int i = 1; i <= m; i++)       // 遍历所有边(原有MST边+乡镇到城市的边)
    {
        // 如果该边属于某个乡镇,检查该乡镇是否被改造
        if (newedges[i].idx)
        {
            int tmp = x;
            for (int j = 1; j < newedges[i].idx; j++)  // 将tmp右移到第idx位
                tmp >>= 1;
            if ((tmp & 1) == 0) continue;  // 如果该乡镇未被改造,跳过该边
        }

        int a = newedges[i].a, b = newedges[i].b, w = newedges[i].w;  // 取出当前边
        a = find(a), b = find(b);       // 查找两端点所在连通块
        if (a != b)						// 如果不在同一连通块
		{
            p[a] = b;                   // 合并连通块
            res += w;                   // 累加边费用
            cnt ++;                     // 已选边数加1
        }
        if (cnt==s-1 || res>=ans) break;  // 如果已形成生成树或费用已超当前最优,提前退出
    }
    if (cnt<s-1) return INF;            // 如果无法形成生成树,返回无穷大
    return res;                         // 返回该状态下的最小费用
}

signed main()                           // 使用signed main是因为#define int long long后,main返回值类型需要显式声明
{
    cin >> n >> m >> k;                 // 读入城市数n、道路数m、乡镇数k
    for (int i=1; i<=m; i++)           // 循环读入m条原有道路
        cin >> edges[i].a >> edges[i].b >> edges[i].w;  // 读入每条道路的两个端点和修复费用

    ans = kruskal();                    // 先求只考虑原有道路的最小生成树费用作为初始答案
    m = n - 1;                          // m更新为最小生成树的边数(n-1条)
    for (int i=1; i<=k; i++)           // 循环读入k个乡镇的信息
    {
        cin >> c[i];                    // 读入第i个乡镇的改造费用
        for (int j=1; j<=n; j++)       // 读入该乡镇到n个城市的建路费用
        {
            int x;
            cin >> x;
            newedges[++m].a = n + i;    // 乡镇节点编号为n+i
            newedges[m].b = j;           // 城市j
            newedges[m].w = x;           // 建路费用
            newedges[m].idx = i;         // 标记该边属于第i个乡镇
        }
    }
    sort(newedges+1, newedges+m+1);     // 将所有边(MST边+乡镇边)按费用排序
    for (int i=1; i<(1<<k); i++)       // 枚举所有可能的乡镇改造状态(2^k种,从1到2^k-1)
    {
        ans = min(ans, kruskal2(i));    // 对每个状态求最小生成树费用,更新全局最小答案
    }
    cout << ans << endl;                // 输出将n座城市连通的最小费用
    return 0;
}

【运行结果】

4 4 2
1 4 6
2 3 7
4 2 5
4 3 4
1 1 8 2 4
100 1 3 2 4
13

【算法真传】

从一道 CSP-S 真题出发:聊聊带可选扩展点的最小生成树

posted @ 2026-08-28 15:54  团爸讲算法  阅读(4)  评论(0)    收藏  举报