「一本通 3.6 练习 4」电力

GitHub跳转原题关系图返回列表

答案 = 原连通块数 - 1 + 删点最大裂块数:一次迭代 Tarjan 维护 dfn/low,非根点裂块数 = 1 + low ≥ dfn 的孩子数,根等于子树数,O(P+C) 求最大值。

OJ: roj

题目 ID: 1525

难度:普及+/提高-

标签:图论Tarjan割点连通块python

创建: 2026-09-30 16:17

更新: 2026-10-07 11:01

形式化题目

给定一个无向图(不保证连通、可能有孤立点,无重边)。对每个顶点 vv,记 f(v)f(v) 为删去 vv 及其关联边后,剩余图的连通块个数;要求 max⁡vf(v)\max_v f(v)——删掉一个点之后,连通块最多有多少。

样例第一组是三角形(三点两两相连):任删一点,剩下两点之间仍有边,仍是一个连通块,输出 1。

样例第二组是四点两条边,0−10-1 与 2−32-3 各成一块,互不相通:

graph LR
    subgraph comp1["连通块 A"]
        a0["0"] --- a1["1"]
    end
    subgraph comp2["连通块 B"]
        b2["2"] --- b3["3"]
    end

删去 00,块 AA 塌缩成孤立点 {1}\{1\},块 BB 不受影响,剩 {1},{2,3}\{1\},\{2,3\} 两块;删去 22 同理也是两块。无论删谁都恰好 2 块,输出 2。

样例第三组只有边 1−01-0,外加一个孤立点 22:删 00(或 11)后剩 {1},{2}\{1\},\{2\} 两块,删 22 后剩 {0,1}\{0,1\} 一块,取最大值,输出 2。

正解

思路

朴素做法与瓶颈。 对每个顶点 vv 删掉它、再从剩余点 BFS/DFS 数一次连通块,取最大值——正确但要重扫整张图 PP 遍,复杂度 O(P(P+C))O(P(P+C));P=10000P=10000 时上亿次操作,Python 必然超时。而删一个点只破坏与它直接相邻的局部,其余部分的连通信息被整遍丢掉重算。

第一步:把「删谁」的枚举分解成计数。 删 vv 不影响 vv 之外的连通块,所以

f(v)=(comps−1)+pieces(v),answer=comps−1+max⁡vpieces(v)f(v) = (comps - 1) + pieces(v), \qquad answer = comps - 1 + \max_v pieces(v)

其中 compscomps 是原图连通块数,pieces(v)pieces(v) 是 vv 原来所在连通块被删掉 vv 后裂成的块数(孤立点算 0)。对照样例:第一组 comps=1comps=1、每点 pieces=1pieces=1,得 1;第二组 comps=2comps=2、piecespieces 全为 1,得 2;第三组 comps=2comps=2、max⁡pieces=1\max pieces=1,得 2。于是问题变成求 max⁡vpieces(v)\max_v pieces(v)——注意非割点的 piecespieces 恰为 1,割点的 pieces⩾2pieces \geqslant 2,我们要的正是把割点判定变成计数。

第二步:把 pieces(v)pieces(v) 翻译成 DFS 树上的比较。 对图做一次 DFS,记进入序号 dfndfn 与子树经回边能到达的最小序号 lowlow(树边入栈展开,孩子弹栈时把 lowlow 回传父点,回边用祖先的 dfndfn 压低)。孩子 cc 满足 low(c)⩾dfn(v)low(c) \geqslant dfn(v),等价于 cc 的子树最远只能回到 vv 自己、绕不开 vv 到达祖先——删掉 vv 它必然自成一块,称为 vv 的分离孩子;反之 low(c)<dfn(v)low(c) < dfn(v) 说明 cc 的子树有回边通向 vv 的祖先,删掉 vv 也能绕回父方向那一侧。于是:

  • 非根 vv:各分离孩子自成一块,父方向与所有非分离孩子的子树仍连成一块,故 pieces(v)=1+分离孩子数pieces(v) = 1 + \text{分离孩子数};
  • DFS 树的根:没有父方向那一块,pieces(root)=pieces(root) = 子树数;根的孩子恒满足 low⩾dfn(root)low \geqslant dfn(root)(根是本连通块里最小的 dfndfn),所以同一套「分离孩子数」统计对根恰好就是子树数,只是不加 1;
  • 孤立点:是「根且零孩子」,pieces=0pieces = 0,公式自然退化,无需特判。

一个最小的割点例子:顶点 0−1−20-1-2 构成一条链(边 0−10-1、1−21-2),按邻点编号从小到大访问:

点 uu dfndfn lowlow DFS 树中的孩子 split(u)split(u) pieces(u)pieces(u)
0(根) 0 0 1 1 根不加 1:1
1 1 1 2 1 1+1=21+1 = 2
2 2 2 — 0 0+1=10+1 = 1

表中 split(u)split(u) 是已结算孩子里 low⩾dfn(u)low \geqslant dfn(u) 的个数。中间点 1 是唯一的割点,pieces=2pieces=2 正是删它之后的 {0}\{0\} 与 {2}\{2\};根 0 只有 1 棵子树,pieces=1pieces=1(删它剩 {1,2}\{1,2\} 一块);叶子 2 是非根非割点,pieces=1pieces=1。回边的作用看三角形样例:环上每点都能绕回更早的点,lowlow 被压到根的 dfndfn,分离孩子数为 0,max⁡pieces\max pieces 停在 1,所以输出 1——回边正是「删点也分不开」的证据。真实数据开头的 3 2(边 0−10-1、0−20-2 的星形)则把中心搜成了根:两棵子树给 split=2split=2,根不加 1,comps−1+best=1−1+2=2comps - 1 + best = 1 - 1 + 2 = 2。

第三步:一次 DFS 收齐所有答案。 弹栈时把子点的 lowlow 回传给父点、给父点累加 splitsplit,并用当前点已定型的 split[u]+(par⩾0)split[u] + (par \geqslant 0) 更新全局最大值——「非根 +1、根不加」一行统一。最后输出 comps−1+bestcomps - 1 + best。整套用显式栈迭代实现,避免 P=10000P=10000 的链状图打穿 Python 默认递归深度。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 16:10
# update_at: 2026-09-30 16:10

import sys


def max_pieces(adj: list[list[int]]) -> tuple[int, int]:
    """迭代版 Tarjan:返回(原图连通块数,删去单点后其所在连通块最多裂成的块数)。"""
    n = len(adj)
    dfn = [-1] * n        # DFS 进入时刻,-1 表示未访问
    low = [0] * n         # 子树经回边能到达的最小 dfn
    split = [0] * n       # u 的孩子里 low[child] >= dfn[u] 的个数
    clock = 0
    comps = 0             # 原图连通块数
    best = 0              # 所有顶点中"删去后裂成的块数"的最大值
    dfs: list[list[int]] = []   # 栈帧:[顶点, 父顶点, 下一条待查邻边位置],迭代代替递归

    for root in range(n):
        if dfn[root] >= 0:
            continue
        comps += 1
        dfn[root] = low[root] = clock
        clock += 1
        dfs.append([root, -1, 0])
        while dfs:
            u, par, i = dfs[-1]
            if i < len(adj[u]):
                dfs[-1][2] = i + 1
                v = adj[u][i]
                if dfn[v] < 0:                      # 树边:向下展开
                    dfn[v] = low[v] = clock
                    clock += 1
                    dfs.append([v, u, 0])
                elif v != par and dfn[v] < dfn[u]:  # 回边:对称两边只取编号小的一端
                    low[u] = min(low[u], dfn[v])
            else:                                   # u 的邻边走完:向父回传 low 并结算
                dfs.pop()
                if par >= 0:
                    low[par] = min(low[par], low[u])
                    if low[u] >= dfn[par]:          # u 的子树绕不开 par,par 是割点
                        split[par] += 1
                # 非根删点后 = 各闭合子树 + 父方向那一大块;根只有各子树
                best = max(best, split[u] + (par >= 0))
    return comps, best


def solve() -> None:
    # 逐行惰性切分:整份输入有 7+MB,一次性 read().split() 会把峰值内存推近 128MB 限制
    lines = (ln.split() for ln in sys.stdin.buffer if ln.strip())
    out: list[str] = []

    for first in lines:                    # 每组第一行 "P C",以 0 0 结束
        p_cnt, edge_cnt = int(first[0]), int(first[1])
        if p_cnt == 0:
            break
        adj: list[list[int]] = [[] for _ in range(p_cnt)]
        for _ in range(edge_cnt):          # 保证无重边,直接双向建表
            a, b = map(int, next(lines))
            adj[a].append(b)
            adj[b].append(a)
        comps, best = max_pieces(adj)
        out.append(str(comps - 1 + best))  # 删点后总数 = 其余连通块 + 其所在块的裂块数

    sys.stdout.write("\n".join(out) + "\n")


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
// main.cpp:「一本通 3.6 练习 4」电力 —— 求删去一个点后连通块最多有多少。
// 思路:答案 = 原连通块数 - 1 + max pieces(v)。
// pieces(v) 用一次迭代 Tarjan(dfn/low)统计:非根 v 的 pieces = 1 + 分离孩子数
// (孩子 c 满足 low[c] >= dfn[v],即 c 的子树绕不开 v);根的 pieces = 子树数
// (根的分离孩子数恰好就是子树数,但不再加 1);孤立点自然得 0。

/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-05 06:13
 * update_at: 2026-10-05 06:13
 */

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10005;

typedef long long ll;

int n, m;
vector<int> g[MAXN]; // 邻接表:g[u] = u 的所有邻点
int dfn[MAXN];       // DFS 进入时刻,0 表示未访问;时刻从 1 开始编号
int low[MAXN];       // low[u] = u 的子树经回边能到达的最小 dfn
int split_cnt[MAXN]; // u 的孩子里 low[child] >= dfn[u] 的个数(分离孩子数)
int tick;            // dfn 计时器
int comps;           // 原图连通块数
int best;            // 所有顶点中「删去后其所在块裂成的块数」的最大值

// 加一条无向边。
void add_edge(int u, int v) {
    g[u].push_back(v);
    g[v].push_back(u);
}

// 对 start 所在连通块做一次迭代 Tarjan(P 可达 10000,链状图会打穿递归栈)。
// 弹栈时向父回传 low、给父累加分离孩子数,并用当前点更新 best。
void dfs(int start) {
    // 栈帧:[顶点 u, 父顶点 par, 下一条待查邻边下标 i, 邻边总数 sz]
    static int st_u[MAXN], st_par[MAXN], st_i[MAXN], st_sz[MAXN];
    int top = 0;
    tick++;
    dfn[start] = low[start] = tick;
    top++;
    st_u[top] = start;
    st_par[top] = -1;
    st_i[top] = 0;
    st_sz[top] = g[start].size();

    while (top > 0) {
        int u = st_u[top];
        int par = st_par[top];
        int i = st_i[top];
        if (i < st_sz[top]) {
            st_i[top] = i + 1;
            int v = g[u][i];
            if (dfn[v] == 0) {
                // 树边:向下展开,孩子入栈
                tick++;
                dfn[v] = low[v] = tick;
                top++;
                st_u[top] = v;
                st_par[top] = u;
                st_i[top] = 0;
                st_sz[top] = g[v].size();
            } else if (v != par && dfn[v] < dfn[u]) {
                // 回边:无向图对称边只取编号小的一端,防止把父方向的边当回边
                if (dfn[v] < low[u]) low[u] = dfn[v];
            }
        } else {
            // u 的邻边走完:出栈并向父结算
            top--;
            if (par >= 0) {
                if (low[u] < low[par]) low[par] = low[u];
                if (low[u] >= dfn[par]) {
                    // u 的子树绕不开 par,删掉 par 后 u 的子树自成一块
                    split_cnt[par]++;
                }
            }
            // 非根删点后 = 分离孩子数 + 父方向那一大块(+1);根只有各子树(不加 1)
            int pieces = split_cnt[u] + (par >= 0);
            if (pieces > best) best = pieces;
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    while (cin >> n >> m) {
        if (n == 0) break; // 读入以 0 0 结束
        for (int i = 0; i < n; i++) {
            g[i].clear();
            dfn[i] = 0;
            split_cnt[i] = 0;
        }
        for (int i = 1; i <= m; i++) { // 保证无重边,直接双向建表
            int a, b;
            cin >> a >> b;
            add_edge(a, b);
        }
        tick = 0;
        comps = 0;
        best = 0;
        for (int i = 0; i < n; i++) {
            if (dfn[i] == 0) { // 未访问点才启动新 DFS(兜底处理多个连通块)
                comps++;
                dfs(i);
            }
        }
        // 删掉 v 不影响 v 之外的连通块:总数 = 其余块 (comps-1) + v 所在块裂成的块数
        cout << comps - 1 + best << "\n";
    }
    return 0;
}

复杂度

  • 时间复杂度:每组数据 O(P+C)O(P+C)。每个顶点入栈、出栈各一次,每条边被两端各扫描一次;外层逐点开搜只是兜底,未访问点才启动新 DFS,总代价线性。多组数据合计与输入规模同阶,把朴素的 PP 遍重扫压成 1 遍。
  • 空间复杂度:O(P+C)O(P+C),邻接表 2C2C 条记录,dfn/low/split 各 PP 个槽位,栈最深 PP;读入逐行惰性切分,不驻留整份输入。
  • 实测(真实数据 e1.in,OJ 限时 1000 ms / 128 MB):0.47 s、峰值 19.2 MB,无 TLE/MLE 风险。

总结

  • 答案 = 原连通块数 −1- 1 + 删点最大裂块数:把「删谁」的枚举转化为「每个点会被裂成几块」的计数,删点的局部性让其余连通块可以整体搬运。
  • 一次 Tarjan DFS 同时维护 dfn/lowdfn/low:非根 pieces=1+#{low⩾dfn}pieces = 1 + \#\{low \geqslant dfn\},根 = 子树数,孤立点 = 0;三条规则在代码里统一为 split[u] + (par >= 0)。
  • 朴素逐点删点 O(P(P+C))O(P(P+C)) 的瓶颈是每遍重扫全图;「分解 + lowlow 判据」把它压成一遍 O(P+C)O(P+C)。
  • 实现上两点:读入逐行流式切分(7+ MB 输入峰值仅约 19 MB),深图用显式栈迭代而非递归。