学校集训 2025.8.13 ~ 8.30 最小/大生成树,图的割点专题讲解
算法讲解
1.1 MST
1.1.1 定义
首先——什么是树?如果一个无向连通图不存在环,那么就是一个树。它如果有 \(n\) 个点,就会有 \(n-1\) 条边。
如果在一个图中存在多条相连的边,我们一定可以从一个图中挑出一些边生成一棵树。这个树就是生成树。当图中每条边都存在权重时,这时候我们从图中生成一棵树时,生成这棵树的总代价就是每条边的权重之和(总边权)。
如果总边权最小,就叫做最小生成树(Minimum Spanning Tree, MST ),如果总边权最大,就叫做最大生成树(Maximum Spanning Tree, MST )。MST 可能不是唯一的,但是 MST 的权值是唯一的。
MST 有 Prim 算法和 Kruskal 算法。
1.1.2 MST 的 Prim 算法
Prim 算法可以理解成加点法。
对于以下图:
v1--2--v3
| \ |
5 9 3
| \ |
v4--4--v2
用 Prim 算法求 MST 时,我们可以从任意点开始。我们从 \(v_1\) 点开始。这个时候,我们定义如果一个节点 \(v_n\) 加入 MST,那我们标记它为 \(V_n\)。所以首先,我们把 \(v_1\) 点加入 MST:
V1--2--v3
| \ |
5 9 3
| \ |
v4--4--v2
这里,我们看与 \(V_1\) 点连接的节点哪个的边权最小。这里就是 \(v_3\)。标记为 \(V_3\)。
V1==2==V3
| \ |
5 9 3
| \ |
v4--4--v2
然后重复在离 MST 部分连接的节点哪个的边权最小。继续直到所有节点都进入 MST(即边数 \(=\) 节点数 \(-1\))或无法生成 MST 时结束。
V1==2==V3
| \ []
5 9 3
| \ []
v4==4==v2
如图就是这个图的最小生成树。
Prim 算法因为是加点法,所以时间复杂度与边无关。所以它更适合稠密图。
稠密图是指边的数量接近顶点数量的平方的图,通常表示为 \(|E| ≈ |V|^2\),其中 \(|E|\) 是边的数量,\(|V|\) 是顶点的数量。简单来说,边越多,图就越稠密。如果图中边的数量远小于 \(|V|^2\),则称为稀疏图。
void prim(){
dist[1] = 0;
book[1] = true;
for(int i = 2 ; i <= n ;i++)
dist[i] = min(dist[i], g[1][i]);
for(int i = 2 ; i <= n ; i++){
int temp = INF;
int t = -1;
for(int j = 2 ; j <= n; j++)
if(!book[j] && dist[j] < temp){
temp = dist[j];
t = j;
}
if(t == -1){
res = INF;
return;
}
book[t] = true;
res += dist[t];
for(int j = 2; j <= n; j++)
dist[j] = min(dist[j], g[t][j]);
}
}
1.1.3 MST的 Kruskal 算法
Prim 是找点为核心的,而 Kruskal 算法可以理解成加边法。
对于以下图:
v1--2--v3
| \ |
6 4 3
| \ |
v4--5--v2
我们把边去掉,演示 MST。
v1 v3
v4 v2
我们从原图上找一条最短的边,就是原图上的 \(v_1 \implies v_3\)。这里还要检查 \(v_1\) 与 \(v_3\) 是否已经连起来(与直连区分)了。这里没有,于是加上:
V1==2==V3
v4 v2
继续,我们再从原图上找一条最短的边(去掉已经找的边),即 \(v_3 \implies v_2\)。
V1==2==V3
[]
3
[]
v4 v2
接下来是 \(v_2 \implies v_1\)。但是 \(v_2\) 和 \(v_1\) 已经连接,所以跳过这条边,继续直到所有节点都进入 MST(即边数 \(=\) 节点数 \(-1\))或无法生成 MST 时结束。
V1==2==V3
[]
3
[]
v4==5==v2
bool cmp(edge a, edge b) {
// return a.w > b.w; // 最大生成树
return a.w < b.w; // 最小生成树
}
int getfather(int x) {
if (x == fa[x])
return x;
return fa[x] = getfather(fa[x]);
}
int kruskal() {
for (int i = 1; i <= n; ++i)
fa[i] = i;
sort(e + 1, e + m + 1, cmp);
int ans = 0;
int cnt = 0;
for (int i = 1; i <= m; ++i) {
const int eu = getfather(e[i].u);
const int ev = getfather(e[i].v);
if (eu == ev)
continue;
fa[eu] = ev;
ans += e[i].w;
++cnt;
if (cnt == n - 1)
break;
}
return cnt == n - 1 ? ans : -1;
}
1.2 Tarjan 求割点算法
1.2.1 定义
在一个无向图中,如果删除某个顶点和连接点的边,这个图就不再连通。那么这个点就叫做割点(Articulation Point)。
无向联通图:一个没有方向的图 ,保证任意一个节点 \(u\) 已经能通过其他节点或边到达任意另一个节点 \(v\)。
1.2.2 核心思想
首先,我们介绍两个新东西。
dfn[x]
:DFS中,\(x\) 实际被访问的时间点(\(x\) 被访问的越早,dfn[x]
在数值上就越小。)low[x]
:DFS中,\(x\) 通过自己的无向边或者通过自己的子孙,可以回溯到的最早时间点(所以low[x]
是会不断更新的)
\(x\) 是割点有两种情况。
case 1:
不是 root
(根节点),而且有 \(1\) 个 child
(儿子),而且满足 low[x_child] >= dfn[x]
。
解释一下 low[x_child] >= dfn[x]
。
x_father
|
x
|
x_child
- ① 若
low[x_child] > dfn[x]
:- 那么
x_child
只能回溯到x_child
本身,不能回溯到 \(x\) 节点,更不能回到x_father
(\(x\) 的祖先)。从此项反推也能证low[x_child] > dfn[x]
。 - 显然,这个时候如果把 \(x\) 和连接它的边去掉,
x_child
和x_father
就不会有联系。这个 \(x\) 节点是割点。
- 那么
- ② 若
low[x_child] = dfn[x]
:- 这个时候
x_child
比较给力,x_child
能回溯到 \(x\) 节点,但是不能回到x_father
。从此项反推也能证low[x_child] = dfn[x]
。 - 但是显然,这个时候如果把 \(x\) 和连接它的边去掉,
x_child
和x_father
还是不会有联系。这个 \(x\) 节点也是割点。
- 这个时候
low[x_child] >= dfn[x]
所以在 \(x\) 是割点,且不是 root
,而且有 child
时是一个割点。
- ③ 若
low[x_child] < dfn[x]
:- 这个时候
x_child
非常给力,x_child
能回溯到 \(x\) 节点,也能回到x_father
节点。从此项反推也能证low[x_child] < dfn[x]
。 - 这个时候如果把 \(x\) 和连接它的边去掉,也不影响
x_child
和x_father
的连通性。这个 \(x\) 节点就不是割点。
- 这个时候
case 2:
是 root
,而且有大于等于 \(2\) 个 child
(byd 生的还挺多)
/- A
/
root
\
\- B
显然,对于以上的图,如果直接割掉 root
,显然节点 \(A\) 和节点 \(B\) 就不会联通了。
但是会有人问了:
/- A
/ |
root |
\ |
\- B
假如是这样的图,那还能割掉 root
吗?
我们要清楚,这里 low[x]
和 dfn[x]
都是在 DFS 中产生的。对于这个图,DFS 会先遍历 \(\text{root}\),再遍历 \(A\) 和 \(B\)。所以实际上 \(B\) 是由 \(A\) 访问的。所以 \(B\) 是 \(A\) 的一个儿子,不是 \(\text{root}\) 的儿子。这个时候不符合 case 2
。这个时候 root
不是割点。
代码奉上:
const int MAXN = 1000005;
int n, m, dfn[MAXN], low[MAXN], buc[MAXN], dn = 0, cnt = 0;
vector<int> e[MAXN];
void dfs(int id, int fa) {
dfn[id] = low[id] = ++dn;
int son = 0;
for (int it : e[id]) {
if (!dfn[it]) {
son++;
dfs(it, id);
low[id] = min(low[id], low[it]);
if (low[it] >= dfn[id] && id != fa) {
cnt += !buc[id];
buc[id] = 1;
}
} else if (it != fa) {
low[id] = min(low[id], dfn[it]);
}
}
if (son >= 2 && id == fa) {
cnt += !buc[id];
buc[id] = 1;
}
}
int main() {
...
for (int i = 1; i <= n; ++i)
if (!dfn[i])
dfs(i, i);
...
return 0;
}
...
处理之后,若 buc[i] == 1
则 i
为其中一个割点,而 cnt
代表割点总个数。
【附加】1.3 Tarjan 求强联通分量
定义
联通,强联通,弱连通
- 联通:无向图中,从任意点 \(i\) 可以到达任意点 \(j\)。
- 强联通:有向图中,从任意点 \(i\) 可以到达任意点 \(j\)。
- 弱连通:有向图中,若把有向图看作无向图时,从任意点 \(i\) 可以到达任意点 \(j\)。
强联通分量
在有向图 \(G\) 中,如果两个顶点 \(u,v\) 间有一条从 \(u\) 到 \(v\) 的有向路径,同时还有一条从 \(v\) 到 \(u\) 的有向路径,则称两个顶点强连通。如果有向图 \(G\) 的每两个顶点都强连通,称G是一个强连通图。有向非强连通图的极大强连通子图,称为强连通分量,简称 SCC (Strongly Connected Components)。
对于以下图:
1 -----> 2 <----- 3
^ ↗ |
| / |
| / ↓
5 <----- 4
\(\{1,2,4,5\},\{3\}\) 是强联通分量。
核心思想
前情回顾:
https://garxngfcqwc.feishu.cn/sync/KwpYd4gV4sCxB0bkAsuc2RfgnCt
我们要对每个顶点\(\space x \space\)赋予两个值\(\space i,j \space\)。(记为 \(x(i,j)\))
- \(i\): DFS 中,\(x\) 点被访问的时间点。(与输出顺序区别)
- \(j\):\(x\) 通过有向边可回溯到的最早时间点。
我们给出一个图作为示例:
A -> B -> C -> D
\ /
-<-----
我们按照先递归相邻节点,再访问当前节点的方式进行 DFS,搜索图如下:
DFS(A)
|--> DFS(B)
| |--> DFS(C)
| | |--> DFS(D)
| | | |--> D
| | |--> C
| |--> B
|--> A
在黄色部分的时候,我们能推出标记为 \(A(1,1);\space B(2,2);\space C(3,3);\space D(4,4)\)。同时,我们维护一个栈 \(S\),每次访问一个节点,就将它放入栈 \(S\)。所以栈 \(S\) 为 \(\{A,B,C,D\}\)。
然而,在进行到绿色部分的时候,我们能推出来 \(D\) 能回溯到 \(B\),因为 \(D_i > B_j\),所以 \(D_j = B_i = 2,\space D(4,2)\);同样的,我们进而发现 \(C\) 能通过 \(D\) 回溯到 \(B\),因为 \(C_i > D_j\),所以 \(C_j = D_j = B_i = 2,\space C(3,2)\)。继续往前推,\(B\) 能从 \(C \rightarrow D \rightarrow B\),但是因为 \(B_i = C_j\),所以不更新;\(A\) 点同理。我们不难看出,\(\{B,C,D\}\) 是一个强联通分量。它们共同的 \(j=2\)。所以,把 \(\{B,C,D\}\)$ 推出栈 \(S\),\(S = \{A\}\)。算法结束。所以我们也可以说 \(\{A\}\) 也是一个强联通分量。
对于具体代码,我们仍然维护两个数组:
- dfn[x]:DFS中,\(x\) 实际被访问的时间点(\(x\) 被访问的越早,
dfn[x]
在数值上就越小。) - low[x]:DFS中,\(x\) 通过自己的无向边或者通过自己的子孙,可以回溯到的最早时间点(所以
low[x]
是会不断更新的)
const int MAXN = 100010;
int n, m, dn, cntb;
vector<int> edge[MAXN];
vector<int> belong[MAXN];
bool instack[MAXN];
int dfn[MAXN], low[MAXN];
stack<int> s;
void Tarjan_SCC(int u){
dfn[u] = low[u] = ++dn;
s.push(u);
instack[u]=true;
for(int i=0; i<edge[u].size(); ++i){
int v = edge[u][i];
if(!dfn[v]){
Tarjan_SCC(v);
low[u] = min(low[u], low[v]);
}else if(instack[v]){
low[u] = min(low[u], dfn[v]);
}
if(dfn[u] == low[u]){
++cntb;
int node;
do{
node = s.top();
s.pop();
instack[node] = false;
belong[cntb].push_back(node);
}while(node != u);
}
}
}
int main(){
cin >> n >> m;
for(int i=1, u, v; i<=m; ++i){
cin >> u >> v;
edge[u].push_back(v);
}
for(int i = 1; i <= n; ++i)
if(!dfn[i])
Tarjan_SCC(i);
for(int i=1; i<=cntb; ++i) {
cout << "SCG " << i << " : ";
for(int j=0; j<belong[i].size(); ++j)
cout << belong[i][j] << " ";
cout << endl;
}
return 0;
}
题目讲解
A
这道题是 MST 模板题。但这里注意,相较于模板题,题目没有给出节点数。我们不妨把节点数直接设成一个很大的数进行计算。
B
与模板题类似,送分。过。
C
本质上是跑 \(3\) 遍 Kruskal。但是要注意去掉一个颜色的边。
我们可以在函数里加上一个参数,如果有一个颜色的边,那就直接去掉(continue
)。
int kruskal_exclude(int exc) {
......
for (int i = 1; i <= m; ++i) {
if (e[i].color == exc)
continue;
......
}
......
}
D
这道题是 Tarjan 求强联通分量+MST。MST 会单独翻材料讲。
...
void tarjan(int u) {
dfn[u] = low[u] = ++dn;
st.push(u);
instack[u] = true;
for (auto &e : adj[u]) {
int v = e.first;
if (!dfn[v]) {
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (instack[v]) {
low[u] = min(low[u], dfn[v]);
}
}
if (dfn[u] == low[u]) {
++cntb;
int v;
do{
v = st.top();
st.pop();
instack[v] = false;
comp[v] = cntb;
belong[cntb].push_back(v);
}while (v != u);
}
}
int getfather(int x) {
if (x == fa[x])
return x;
return fa[x] = getfather(fa[x]);
}
ll kruskal(const vector<int> &nodes, const vector<Edge> &edges) {
if (nodes.empty())
return 0;
for (int node : nodes)
fa[node] = node;
vector<Edge> sorted = edges;
sort(sorted.begin(), sorted.end());
ll ans = 0;
int cnt = 0, node_count = nodes.size();
for (const Edge &e : sorted) {
const int eu = getfather(e.u);
const int ev = getfather(e.v);
if (eu == ev)
continue;
fa[eu] = ev;
ans += e.w;
ans %= 19260817;
++cnt;
if (cnt == node_count - 1)
break;
}
return (cnt == node_count - 1) ? ans : -1;
}
...
啊但是这道题有一个很恶心的时限。如果不预处理,你最多只能 \(50\) 分。
我们对 MST 预处理,提前计算每个 SCC 的 MST。\(100\) 分。
E
最小化无线电设备的功率要求。换句话说,他希望找到一个最小的 D,使得在分配了 S 个卫星频道(给 S 个哨所)之后,剩下的所有哨所通过无线电(距离 ≤D)能够实现间接通信(即整个网络连通)。
我们能知道,我们需要找的是第 \(P-S\) 大的边长。一边 kruskal
一边存储然后 sort
即可。
posted on 2025-08-29 21:25 符星珞-Astralyn 阅读(14) 评论(0) 收藏 举报