S组 T2
部分分分析很重要,如果全部分类写出,可以得72分,虽没想出最后正解,但也很高了
想得到满分,就要注意到n比m小, k<=10, 也很小。这两个就是瓶颈。
P14362 [CSP-S 2025] 道路修复 / road(官方数据)
【解析】
16 分:(k ≤ 0)
\(此时 k ≤ 0,不需要考虑乡镇对城市带来的影响。因此可以直接使用 kruskal 算法来计算出最小生成树的边权总和即可。具体地,将 m 条边按照边权从小到大排序,然后依次考虑连接每一条边后是否会形成环。若不形成环,则加入这条边;否则不加入。判断是否形成环,可以使用并查集。时间复杂度 O (m log m)。\)
另外 32 分:
\((特殊性质 A)此时 c_j = 0,且对于每个乡镇 j,都存在一个城市 i 满足 a_{j,i} = 0。这说明将任何一个乡镇都可以通过一个边权为 0 的边加入到一个已经连通的图中,即需要付出的代价是 0。而如果是中途将乡镇与未连通图中的某个连通块进行连接(前提是这个连通块中存在与乡镇边权为 0 的城市),然后再通过城市之间的边将每个城市进行连通,那么仍然能够发现使所有乡镇与城市连通需要的代价还是 0。 容易得到一个贪心的想法:既然任何一个时刻将乡镇与城市进行连通的代价都是 0,那么不妨一开始就让所有乡镇与它对应的 0 代价城市相连接。这样一来,就可以在一开始不费任何代价地产生尽可能多的边权更小的边,这些边都是乡镇与城市之间的边,用于原图中的城市相互之间进行连接。具体地,对于乡镇 j 进行如下额外的连边: 令乡镇的编号为 n + j,城市的编号为 i (1 ≤ i ≤ n); 那么在 n + j 与 i 之间连接一条边权为 a_{j,i} 的边。 随后,将这些额外的边与原本城市之间的边一同排序,进行一次 kruskal 算法即可计算出答案,注意使用 long long。设考虑到 m > nk,且 n > k,时间复杂度依然是 O (m log m)。 \)
另外 24 分:(k ≤ 5)
\( 注意到 k ≤ 5 很小,可以考虑 dfs 枚举每个乡镇是否使用。具体的做法与上述 “特殊性质 A” 的做法类似,对于枚举到的每一种使用乡镇的情况,只需要把用到的乡镇对应的边额外加入进来即可,以便产生权值更小的边用于城市之间的连通。dfs 部分的复杂度是 O (2^k),总时间复杂度是 O (2^k m log m)。 \)
100 分:
\(复杂度的瓶颈在于每次都将 m 条边重新排序了,需要考虑将 O (m log m) 的排序从 2^k 中提取出来。不难发现,m ≤ 10^6 虽然很大,但是 n ≤ 10^4 比较小,能够构成生成树的边至多有 O (n) 条。于是我们可以先对城市之间的 m 条边进行一次 kruskal,提取出其中有用的 n-1 条树边。 这样一来对于 dfs 枚举到的每一种乡镇情况,总边数就是 O (nk) 级别而非 O (m + nk),时间复杂度优化到了 O (m log m + 2^k nk log (nk))。然而,这样的复杂度可能还不足以通过本题。 考虑到每次还是会对边重新排序,为了避免这种情况,我们其实完全可以在 dfs 之前,提前将所有的 nk 条边与 n-1 条树边放在一起排序。为每条边标记上这条边来自于哪一个乡镇,这样一来,每次只需要枚举 nk + n - 1 条边,根据边的标记来判断对应的乡镇当前是否可以使用,进而判断当前的这条边是否可以选上。时间复杂度进一步降到了 O (m log m + 2^k nk)。 \)
16 分( \(k \le 0\) ):只需要对原有 \(m\) 条边做一次 Kruskal
思路简述:当 \(k\le0\) 时没有乡镇的影响,问题退化为对 \(n\) 个城市在给定 \(m\) 条边权下求最小生成树(MST)。经典 Kruskal:把所有边按权排序,逐条加入并查集(若不构成环则加入),最终取权和。
// 16_points.cpp
// 16 分解法:k <= 0 时,直接对 m 条边做 Kruskal 求 MST
#include <bits/stdc++.h>
using namespace std;
using ll = long long;struct DSU {int n;vector<int> p, sz;DSU(int n=0){ init(n); }void init(int _n){n = _n;p.resize(n+1);sz.assign(n+1,1);for(int i=1;i<=n;i++) p[i]=i;}int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }bool unite(int a,int b){a=find(a); b=find(b);if(a==b) return false;if(sz[a]<sz[b]) swap(a,b);p[b]=a; sz[a]+=sz[b];return true;}
};struct Edge {int u,v;ll w;Edge(){}Edge(int _u,int _v,ll _w):u(_u),v(_v),w(_w){}
};int main(){ios::sync_with_stdio(false);cin.tie(nullptr);int n,m,k;if(!(cin>>n>>m>>k)) return 0;vector<Edge> edges;edges.reserve(m);for(int i=0;i<m;i++){int u,v; ll w;cin>>u>>v>>w;edges.emplace_back(u,v,w);}// k <= 0 时忽略乡镇输入(若有),但题中会给 k,这里按约定 k<=0 时没有乡镇数据sort(edges.begin(), edges.end(), [](const Edge& a,const Edge& b){ return a.w < b.w; });DSU dsu(n);dsu.init(n);ll ans = 0;int cnt = 0;for(auto &e: edges){if(dsu.unite(e.u, e.v)){ans += e.w;++cnt;if(cnt == n-1) break;}}cout << ans << '\n';return 0;
}
32 分(特殊性质 A):每个乡镇有 \(c_j=0\) 且存在某城市 \(i\) 使得 \(a_{j,i}=0\)
思路简述:既然所有乡镇的城市化成本 \(c_j=0\) ,且每个乡镇与至少一个城市的连接代价为 0,那么没有必要枚举选择与否——把所有乡镇视作“免费可用”节点,把乡镇与所有城市之间的边(权为 \(a_{j,i}\) )全部加入图,然后对整个图做一次 Kruskal。并查集大小为 \(n+k\) ,最后只需关心把原有 \(n\) 个城市连通,乡镇作为辅助节点自动参与。
// 32_points_specialA.cpp
// 32 分解法(特殊性质 A):c_j = 0 且每个乡镇存在一个 a_{j,i} = 0
#include <bits/stdc++.h>
using namespace std;
using ll = long long;struct DSU {int n;vector<int> p, sz;DSU(int n=0){ init(n); }void init(int _n){n = _n;p.resize(n+1);sz.assign(n+1,1);for(int i=1;i<=n;i++) p[i]=i;}int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }bool unite(int a,int b){a=find(a); b=find(b);if(a==b) return false;if(sz[a]<sz[b]) swap(a,b);p[b]=a; sz[a]+=sz[b];return true;}
};struct Edge {int u,v;ll w;Edge(){}Edge(int _u,int _v,ll _w):u(_u),v(_v),w(_w){}
};int main(){ios::sync_with_stdio(false);cin.tie(nullptr);int n,m,k;if(!(cin>>n>>m>>k)) return 0;vector<Edge> edges;edges.reserve(m + (ll)k * n);for(int i=0;i<m;i++){int u,v; ll w; cin>>u>>v>>w;edges.emplace_back(u,v,w);}vector<ll> c(k);vector<vector<ll>> a(k, vector<ll>(n+1));for(int j=0;j<k;j++){cin>>c[j];for(int i=1;i<=n;i++) cin>>a[j][i];}// 因为 c_j = 0,且至少存在 a[j][i]=0(题设保证),我们把所有乡镇节点以及它们与城市之间的边都加入// 乡镇编号使用 n+1 .. n+kfor(int j=0;j<k;j++){int town = n + 1 + j;for(int i=1;i<=n;i++){edges.emplace_back(town, i, a[j][i]);}}sort(edges.begin(), edges.end(), [](const Edge& A, const Edge& B){ return A.w < B.w; });DSU dsu(n+k);dsu.init(n+k);ll ans = 0;for(auto &e: edges){if(dsu.unite(e.u, e.v)){ans += e.w;}}cout << ans << '\n';return 0;
}
24 分( \(k \le 5\) ):对乡镇选择做枚举(DFS / 位掩码),每种情况做 Kruskal(朴素版)
思路简述:因为 \(k\le5\) ,可以枚举每个乡镇是否“被选用”(共 \(2^k \le 32\) 种)。对于每个掩码,构造边集合:原有 \(m\) 条边 + 对被选乡镇加入其与所有城市的 \(n\) 条边(乡镇视作节点 \(n+j\) ),然后对该集合排序并 Kruskal。最后答案为所有掩码的最小值。注意用 long long,每种掩码独立做排序(实现简单但在极端 m 较大时会慢——但题目这档给分中允许此做法)。
提示:该实现直接重新排序 \(m + s\cdot n\) 条边每次,适用于 \(k\le5\) 的情况。
// 24_points_k_le_5.cpp
// 24 分解法:k <= 5,枚举乡镇是否使用(位掩码),每个掩码构造边集并 Kruskal
#include <bits/stdc++.h>
using namespace std;
using ll = long long;struct DSU {int n;vector<int> p, sz;DSU(int n=0){ init(n); }void init(int _n){n = _n;p.resize(n+1);sz.assign(n+1,1);for(int i=1;i<=n;i++) p[i]=i;}int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }bool unite(int a,int b){a=find(a); b=find(b);if(a==b) return false;if(sz[a]<sz[b]) swap(a,b);p[b]=a; sz[a]+=sz[b];return true;}
};struct Edge {int u,v;ll w;Edge(){}Edge(int _u,int _v,ll _w):u(_u),v(_v),w(_w){}
};int main(){ios::sync_with_stdio(false);cin.tie(nullptr);int n,m,k;if(!(cin>>n>>m>>k)) return 0;vector<Edge> baseEdges;baseEdges.reserve(m);for(int i=0;i<m;i++){int u,v; ll w; cin>>u>>v>>w;baseEdges.emplace_back(u,v,w);}vector<ll> c(k);vector<vector<ll>> a(k, vector<ll>(n+1));for(int j=0;j<k;j++){cin>>c[j];for(int i=1;i<=n;i++) cin>>a[j][i];}ll answer = (1LL<<62);int maxMask = 1<<k;// 枚举所有掩码for(int mask=0; mask<maxMask; ++mask){// 1. 构造当前掩码下的边集合:先把原始 m 条边放入vector<Edge> edges = baseEdges;// 2. 对于选中的乡镇 j,加入其与所有城市的 n 条边(乡镇编号 n+j+1)ll sumC = 0;for(int j=0;j<k;j++){if(mask & (1<<j)){sumC += c[j];int town = n + 1 + j;for(int i=1;i<=n;i++){edges.emplace_back(town, i, a[j][i]);}}}// 3. 排序并 Kruskal(并查集大小 n + number of selected towns)sort(edges.begin(), edges.end(), [](const Edge& A, const Edge& B){ return A.w < B.w; });int nodes = n;for(int j=0;j<k;j++) if(mask & (1<<j)) ++nodes; // nodes = n + popcount(mask)// For simplicity we set DSU size to n + k (safe)DSU dsu(n + k);dsu.init(n + k);ll cost = 0;int used = 0;for(auto &e: edges){if(dsu.unite(e.u, e.v)){cost += e.w;++used;// We can stop early if all original cities are connected:// However to keep code simple we let loop continue; it's acceptable given constraints in this score tier.}}// Final total cost: sumC + costanswer = min(answer, cost + sumC);}cout << answer << '\n';return 0;
}
100 分(完整高效解):一次对原 \(m\) 条边 Kruskal 提取 \(n-1\) 条有用边,预排序 \(n-1 + n\cdot k\) 条边,枚举掩码时线性扫描(不重复排序)
思路简述(关键点):
- 先对原始 \(m\) 条边做一次 Kruskal,得到一组“候选城市间边”(最多 \(n-1\) 条),因为在任意添加乡镇的情况下,真正可能出现在最终解的城市—城市边只来自于原有边的 MST(这是一种剪枝/启发:保留原图 Kruskal 得到的树边作为候选)。
备注:这里保留 MST 的 \(n-1\) 条边能够明显降低之后每个掩码下需要考虑的城市—城市边数。
- 对每个乡镇 \(j\) ,把它与每个城市 \(i\) 的边(权 \(a_{j,i}\) )都视为一条边,标记这条边来自乡镇 \(j\) (town id)。所有这些 \(n\cdot k\) 条边与上一步得到的 \(n-1\) 条树边合并到一个数组里,并只做一次排序(按权)。每条边要额外保存一个
town_id字段(0 表示原来城市—城市树边, \(\,1..k\) 表示来自哪个乡镇)。 - 枚举所有乡镇子集(掩码)。对于给定掩码,从排序好的数组线性扫描:遇到
town_id==0的树边就尝试 unite;遇到town_id==j>0的边,只有当掩码中包含该乡镇(即我们“投资”了该乡镇)时才尝试 unite;其它边跳过。这样每个掩码只需 \(O(nk)\) 扫描并查操作(无需重新排序 \(m\) 条边)。时间复杂度约为 \(O(m\log m + 2^k \cdot nk)\) ,能通过题目最大数据。
下面是实现(注意并查集大小为 \(n+k\) ,乡镇编号为 \(n+1\ldots n+k\) ):
// 100_points.cpp
// 100 分解法:先对 m 边 Kruskal 取出 n-1 条树边,然后把 n-1 + n*k 条边排序,枚举掩码时线性扫描
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll INFLL = (1LL<<62);struct DSU {int n;vector<int> p, sz;DSU(int n=0){ init(n); }void init(int _n){n = _n;p.resize(n+1);sz.assign(n+1,1);for(int i=1;i<=n;i++) p[i]=i;}int find(int x){ return p[x]==x?x:p[x]=find(p[x]); }bool unite(int a,int b){a=find(a); b=find(b);if(a==b) return false;if(sz[a]<sz[b]) swap(a,b);p[b]=a; sz[a]+=sz[b];return true;}
};struct Edge {int u, v;ll w;int town_id; // 0 表示原来城市-城市的 MST 边;1..k 表示来自第 j 个乡镇 (与城市的边)Edge(){}Edge(int _u,int _v,ll _w,int _t):u(_u),v(_v),w(_w),town_id(_t){}
};int main(){ios::sync_with_stdio(false);cin.tie(nullptr);int n, m, k;if(!(cin>>n>>m>>k)) return 0;vector<Edge> allOriginal;allOriginal.reserve(m);// 读入原有 m 条边(城市编号 1..n)for(int i=0;i<m;i++){int u,v; ll w; cin>>u>>v>>w;allOriginal.emplace_back(u, v, w, 0); // 暂时 town_id=0(只是原始边)}vector<ll> c(k);vector<vector<ll>> a(k, vector<ll>(n+1));for(int j=0;j<k;j++){cin>>c[j];for(int i=1;i<=n;i++) cin>>a[j][i];}// 1) 先对原始 m 边做一次 Kruskal,抽取出构成 MST 的至多 n-1 条边(作为候选城市-城市边)vector<Edge> tmp = allOriginal;sort(tmp.begin(), tmp.end(), [](const Edge& A, const Edge& B){ return A.w < B.w; });DSU dsu0(n);dsu0.init(n);vector<Edge> cityTreeEdges; cityTreeEdges.reserve(n-1);for(auto &e: tmp){if(dsu0.unite(e.u, e.v)){cityTreeEdges.emplace_back(e.u, e.v, e.w, 0); // town_id 仍为 0 标记为城市边if((int)cityTreeEdges.size() == n-1) break;}}// 2) 构造 n*k 条乡镇-城市边(编号 town = n + j)// 每条标记其 town_id = j+1 (1..k)vector<Edge> combined;combined.reserve((int)cityTreeEdges.size() + n * max(0,k));// 先插入那些城市间的树边(town_id = 0)for(auto &e: cityTreeEdges) combined.push_back(e);// 再插入所有乡镇-城市边for(int j=0;j<k;j++){int town = n + 1 + j;for(int i=1;i<=n;i++){combined.emplace_back(town, i, a[j][i], j+1);}}// 3) 对 combined 统一排序(按权)sort(combined.begin(), combined.end(), [](const Edge& A, const Edge& B){if(A.w != B.w) return A.w < B.w;// 为稳定性,优先原始城市-城市边(town_id == 0),但不必严格要求return A.town_id < B.town_id;});// 4) 枚举掩码(2^k),对每个掩码线性扫描 combined,使用 DSU(n+k)ll answer = INFLL;int maxMask = 1<<k;for(int mask=0; mask<maxMask; ++mask){DSU dsu(n + k);dsu.init(n + k);ll baseCost = 0; // 城市/乡镇边的总费用ll sumC = 0; // 被选中乡镇的一次性费用for(int j=0;j<k;j++) if(mask & (1<<j)) sumC += c[j];int used = 0; // 不严谨计数,下面我们只在合并时累加权重// 我们要保证原始城市 1..n 最终连通(通过乡镇参与或直接城市边)for(const Edge &e: combined){if(e.town_id == 0){// 城市-城市树边,总是可用if(dsu.unite(e.u, e.v)){baseCost += e.w;}} else {int j = e.town_id - 1;if( (mask & (1<<j)) == 0 ) continue; // 未选择该乡镇,跳过// 否则乡镇可用,把乡镇节点编号 (n+1+j) 与城市 i 连接if(dsu.unite(e.u, e.v)){baseCost += e.w;}}}// 最后需要判断原城市 1..n 是否连通:检查它们的根是否一致int root = dsu.find(1);bool ok = true;for(int i=2;i<=n;i++){if(dsu.find(i) != root){ok = false; break;}}if(ok){ll total = baseCost + sumC;if(total < answer) answer = total;} else {// 若不连通,说明用该掩码和这些候选边无法连通(通常不会发生,因为原 cityTreeEdges + 加入乡镇边应该足够)// 我们忽略这种掩码}}cout << answer << '\n';return 0;
}
各种做法时间复杂度
🌟【16分做法】( \(k = 0\) )
思路:
只有城市和道路,不存在乡镇。
→ 直接在 \(n\) 个城市、 \(m\) 条边上跑 Kruskal 最小生成树算法。
步骤复杂度分析:
- 排序所有边:
\(O(m \log m)\) - 并查集合并查找(近似 \(O(1)\) 摊还):
共 \(m\) 次边判断,复杂度约 \(O(m \alpha(n)) \approx O(m)\) - 合并总复杂度:
\(O(m \log m + m) \Rightarrow O(m \log m)\)
✅ 最终复杂度: \(O(m \log m)\)
✅ 适用数据: \(k = 0\) ,或忽略乡镇的情况。
🌟【32分做法】(特殊性质 A)
条件:
- \(c_j = 0\)
- 存在某城市 \(i\) ,使 \(a_{j,i} = 0\)
思路:
每个乡镇都能零代价连上某个城市,因此相当于所有乡镇都已免费接入。
→ 直接将所有乡镇视为新的节点,与城市相连的边加入图后,用 Kruskal 求最小生成树。
边数:
- 原边: \(m\)
- 新增边: \(n \times k\)
步骤复杂度:
- 排序所有边(包括城市间 + 城市到乡镇):\[O((m + nk) \log (m + nk)) \]
- 并查集操作: \(O(m + nk)\)
- 总体复杂度:\[O((m + nk) \log (m + nk)) \]
✅ 由于 \(k \le 10\) , \(nk \ll m\) ,所以可简化为:
🌟【24分做法】( \(k \le 5\) ,枚举子集)
思路:
\(k\) 很小,可以 枚举每个乡镇是否使用( \(2^k\) 种情况)。
对于每种情况:
- 加入该乡镇的所有边;
- 然后对“城市 + 当前选中的乡镇”做一次 Kruskal。
步骤复杂度:
- 枚举所有乡镇组合: \(2^k\)
- 每次 Kruskal:\[O((m + nk) \log (m + nk)) \]
- 总复杂度:\[O(2^k (m + nk) \log (m + nk)) \]
- 因为 \(k \le 5\) ,可近似:\[O(2^k m \log m) = O(32 m \log m) \]
✅ 总结:
🌟【100分做法】(终极优化)
核心优化思想:
前面做法瓶颈在于:
每次枚举都要重新排序 \(m\) 条边!
我们注意到:
- Kruskal 选出的 有效边 只有 \(n - 1\) 条;
- 枚举的边中,大多数在所有情况中都没用。
所以优化思路是:
- 先用 Kruskal 在城市之间跑一遍,找到 \(n - 1\) 条生成树边;
- 只保留这些边 + 所有乡镇边( \(n \times k\) 条);
- 一次性排序这 \(O(nk + n)\) 条边;
- DFS 枚举每种乡镇选法,在 Kruskal 时只考虑标记允许的边。
步骤复杂度:
- 第一次 Kruskal(提取城市生成树):
\(O(m \log m)\) - 一次性排序所有 \(nk + n - 1\) 条边:
\(O((nk + n) \log (nk + n)) \approx O(nk \log (nk))\) - 枚举 \(2^k\) 种乡镇选法,每次 Kruskal 边数 O(nk):\[O(2^k nk) \]
- 总复杂度:\[O(m \log m + 2^k nk) \]
✅ 对于题目范围 \(n = 10^4, m = 10^6, k = 10\) ,这能完美通过。
✅ 最终汇总表
| 分值 | 思路 | 时间复杂度 | 关键优化 |
|---|---|---|---|
| 16分 | 无乡镇,纯 Kruskal | \(O(m \log m)\) | 直接求城市最小生成树 |
| 32分 | 特殊性质 A,所有乡镇 0 成本接入 | \(O((m + nk)\log(m + nk)) \approx O(m \log m)\) | 把乡镇直接加进图中 |
| 24分 | 枚举乡镇使用情况 | \(O(2^k m \log m)\) | DFS 枚举每个乡镇选法 |
| 100分 | 先提取城市树边 + 预排序 | \(O(m \log m + 2^k nk)\) | 只枚举有限边集,提前排序 |