学校集训 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_childx_father 就不会有联系。这个 \(x\) 节点是割点。
  • ② 若 low[x_child] = dfn[x]
    • 这个时候 x_child 比较给力x_child 能回溯到 \(x\) 节点,但是不能回到 x_father。从此项反推也能证 low[x_child] = dfn[x]
    • 但是显然,这个时候如果把 \(x\) 和连接它的边去掉,x_childx_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_childx_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] == 1i 为其中一个割点,而 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)    收藏  举报

导航