求无向图的点连通度:拆点把点权 1 变成边容量 1、原图的边给无穷容量,枚举所有点对跑最大流取最小割。
OJ: roj
题目 ID: 3207
难度:提高
标签:图论网络流最小割最大流python
创建: 2026-10-02 01:46
更新: 2026-10-10 13:00
形式化题目
给定无向图
若对任何
这里
记
边界情形直接照定义读:
| 输入 | 图的样子 | 答案 | 理由 |
|---|---|---|---|
0 0 |
空图 | 0 |
无点可言,谈不上连通 |
1 0 |
单点 | 1 |
没有点对,删不到"不连通" |
2 0 |
两个孤立点 | 0 |
已经是两块 |
3 3 三角形 |
3 |
删 1 个点剩 1 个点,仍算连通 | |
5 7(样例第 5 组) |
两个三角形 |
2 |
删 |
正解
思路
1. 朴素做法与瓶颈
最老实的方法是枚举删除集合,从小到大试:
for k = 0, 1, 2, ..., n:
for 每个 |S| = k 的 S:
if G - S 不连通: return k
return n2. 从删点到最小割:拆点
最小割的经典形态割的是边,本题删的是点。标准转换叫拆点:把每个顶点
固定
- 拆点弧
:普通点容量 1;枚举到点对 时,把 这两条弧临时改成 BIG(表示不允许出现在割里,这正是 的定义); - 原图每条无向边
:两条各自独立的有向弧 与 , 容量都是 BIG。
这张网络上 BIG = 1 << 20:一次最多删 BIG > n 保证最小割
绝不会去割原图的边;同时它又能塞进 32 位整数。
下面的网络图就是样例第 5 组、取
digraph {
rankdir=LR; node [fontname="Helvetica", fontsize=10];
edge [fontsize=9];
in0 [label="0_in"]; out0 [label="0_out"];
in0 -> out0 [label="INF", color="#c0392b", penwidth=2];
in1 [label="1_in"]; out1 [label="1_out"];
in1 -> out1 [label="1"];
in2 [label="2_in"]; out2 [label="2_out"];
in2 -> out2 [label="1"];
in3 [label="3_in"]; out3 [label="3_out"];
in3 -> out3 [label="INF", color="#c0392b", penwidth=2];
in4 [label="4_in"]; out4 [label="4_out"];
in4 -> out4 [label="1"];
out0 -> in1 [label="INF", style=dashed];
out1 -> in0 [label="INF", style=dashed];
out0 -> in2 [label="INF", style=dashed];
out2 -> in0 [label="INF", style=dashed];
out1 -> in3 [label="INF", style=dashed];
out3 -> in1 [label="INF", style=dashed];
out1 -> in2 [label="INF", style=dashed];
out2 -> in1 [label="INF", style=dashed];
out1 -> in4 [label="INF", style=dashed];
out4 -> in1 [label="INF", style=dashed];
out2 -> in3 [label="INF", style=dashed];
out3 -> in2 [label="INF", style=dashed];
out3 -> in4 [label="INF", style=dashed];
out4 -> in3 [label="INF", style=dashed];
}
读图时注意三件事:每个点被一条实线拆点弧穿过,它是这个点的"必经之路";所有虚线弧的容量
都是 INF,所以最小割不可能落在它们身上,只能落在实线上;
3. 外层必须枚举点对
一个很容易踩的坑是"随便挑一对点跑一次最大流就当答案"。不可切断 表示割值达到 INF):
| 只要求分开 |
备注 | ||
|---|---|---|---|
| 0 | 1 | 不可切断 | 看不到答案 |
| 0 | 2 | 不可切断 | 看不到答案 |
| 0 | 3 | 2 | 全局最优就出在这一对 |
| 0 | 4 | 2 | 全局最优就出在这一对 |
| 1 | 2 | 不可切断 | 看不到答案 |
| 1 | 3 | 不可切断 | 看不到答案 |
| 1 | 4 | 不可切断 | 看不到答案 |
| 2 | 3 | 不可切断 | 看不到答案 |
| 2 | 4 | 2 | 全局最优就出在这一对 |
| 3 | 4 | 不可切断 | 看不到答案 |
表的行是点对,第三列是这一对的点最小割。只有
样例第 5 组删掉
graph {
layout=neato; node [shape=circle, style=filled, fillcolor="#e8f0fe", fontname="Helvetica"];
0 [pos="0,0!"];
1 [pos="1.2,1.0!", fillcolor="#f8d7da", color="#c0392b"];
2 [pos="1.2,-1.0!", fillcolor="#f8d7da", color="#c0392b"];
3 [pos="2.6,0.6!"];
4 [pos="2.6,-1.2!"];
0 -- 1 [color="#c0392b", penwidth=2];
0 -- 2 [color="#c0392b", penwidth=2];
1 -- 3 [color="#c0392b", penwidth=2];
1 -- 2 [color="#c0392b", penwidth=2];
1 -- 4 [color="#c0392b", penwidth=2];
2 -- 3 [color="#c0392b", penwidth=2];
3 -- 4;
}
删完之后剩下的图分成两块:
4. 正确性
引理(拆点等价). 对固定
证明. 先看一个方向:给定删点集
: 与 都在含 的一侧; : 在含 的一侧, 放到另一侧; : 与 都放到另一侧。
看哪些弧跨越这个划分:BIG 的拆点弧也都在同一侧(
再看另一个方向:任取一个容量有限的割 BIG 的弧都不可能从 BIG 的弧,与割有限矛盾。
所以
两个方向合起来,最小割 =
由最大流最小割定理,代码里 max_flow 算出的就是
外层枚举全部点对取最小即得答案。代码里还有一步剪枝 if best <= 1: return best:
答案下界是 0,一旦某对点对给出 0 或 1,就不可能更小了,可以直接返回。
5. 必须注意的细节
- 无向边要连两条独立的有向弧,各带容量
BIG,不能写成一条"无向弧"。 写成单向会漏掉一半通路:实测随机图中有相当比例会算错。 - 自环与重边要先清掉。自环对连通性毫无贡献,还会在拆点网络里制造
的 平行弧;重边会让一对点之间出现多条容量 BIG的弧,破坏"边弧不会被割"的前提。 要显著大于 。这里取 ,既能保证最小割不碰边弧, 又保证残量数组里的任何值(最大就是 BIG本身,回退后的反向弧也不会超过它)都能留在 32 位有符号整数里。没有点对。内层循环一次都不进,答案取初值 ,正好符合题面。
6. 实现要点
四点实现上的选择,正文里用到的名字都能在代码里找到:
- 残量网络用链式前向星存:
to/nxt/head三个数组,一次link(u, v)压入正向弧与 它的一条残量 0 反向弧,两者边号为e与e ^ 1,回退时直接异或取反边。 - 容量用
array('i')存一个「模板」base(普通点 1、边弧BIG、反向弧 0), 每个点对只需array('i', base)拷贝一份,再把的拆点弧改成 BIG,比每次重建图便宜。 max_flow是迭代版 Dinic:BFS 分层后用一个显式栈 DFS,一次找到瓶颈后回退到第一条被榨干 的边,而不是每单位流量都从源点重新开始;死路点直接把level置 -1(当前弧剪枝)。- 读入用
re.findall(rb"\d+", ...)把n、m与所有(x,y)按出现顺序抽成整数流, 正好匹配「数对内不含空格、其余用空格分隔」的格式;^Z先替换成空格。
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 电话网络:点连通度,拆点建图后对每个点对求最大流(Dinic)
#include <cstdio>
#include <vector>
using namespace std;
const int BIG = 1 << 20; // “不可割断”的容量
const int INF = 1 << 30;
int node_cnt;
vector<int> to, cap, nxt_e, head;
vector<int> level, it;
// 连一条有向弧 u->v,紧跟一条残量 0 的反向弧,返回正向弧的边号
int link(int u, int v, int c) {
to.push_back(v);
cap.push_back(c);
nxt_e.push_back(head[u]);
head[u] = (int)to.size() - 1;
to.push_back(u);
cap.push_back(0);
nxt_e.push_back(head[v]);
head[v] = (int)to.size() - 1;
return (int)to.size() - 2;
}
// BFS 分层,汇点不可达时返回 false
bool bfs_level(int s, int t) {
level.assign(node_cnt, -1);
vector<int> q;
level[s] = 0;
q.push_back(s);
for (int h = 0; h < (int)q.size(); h++) {
int u = q[h];
for (int e = head[u]; e != -1; e = nxt_e[e]) {
int v = to[e];
if (cap[e] > 0 && level[v] < 0) {
level[v] = level[u] + 1;
q.push_back(v);
}
}
}
return level[t] >= 0;
}
// 沿分层图找一条增广路并推送流量
int dfs_flow(int u, int t, int limit) {
if (u == t) {
return limit;
}
for (; it[u] != -1; it[u] = nxt_e[it[u]]) {
int e = it[u];
int v = to[e];
if (cap[e] > 0 && level[v] == level[u] + 1) {
int got = dfs_flow(v, t, cap[e] < limit ? cap[e] : limit);
if (got > 0) {
cap[e] -= got;
cap[e ^ 1] += got;
return got;
}
}
}
return 0;
}
int dinic(int s, int t) {
int total = 0;
while (bfs_level(s, t)) {
it = head;
while (true) {
int f = dfs_flow(s, t, INF);
if (f == 0) {
break;
}
total += f;
}
}
return total;
}
// 最少删几个点能让图不连通;删不掉时按题面要求返回 n
int vertex_connectivity(int n, const vector<int>& a, const vector<int>& b) {
if (n == 0) {
return 0;
}
vector<vector<int> > neighbors(n);
for (int i = 0; i < (int)a.size(); i++) {
if (a[i] == b[i]) {
continue; // 去掉自环与重边
}
bool dup = false;
for (int t = 0; t < (int)neighbors[a[i]].size(); t++) {
if (neighbors[a[i]][t] == b[i]) {
dup = true;
}
}
if (!dup) {
neighbors[a[i]].push_back(b[i]);
neighbors[b[i]].push_back(a[i]);
}
}
// 先看原图是否已经断开(孤立点也算断开)
vector<char> seen(n, 0);
vector<int> stk;
stk.push_back(0);
seen[0] = 1;
int cnt = 1;
while (!stk.empty()) {
int u = stk.back();
stk.pop_back();
for (int i = 0; i < (int)neighbors[u].size(); i++) {
int v = neighbors[u][i];
if (!seen[v]) {
seen[v] = 1;
cnt++;
stk.push_back(v);
}
}
}
if (cnt != n) {
return 0;
}
// 拆点建图:点 v 换成 2v -> 2v+1 的容量 1 弧,原图无向边换成容量 BIG 的双向弧
int N = 2 * n;
vector<int> split(n, -1), t2, c2, n2, h2;
to.clear(); cap.clear(); nxt_e.clear();
head.assign(N, -1);
for (int v = 0; v < n; v++) {
split[v] = link(2 * v, 2 * v + 1, 1);
}
for (int u = 0; u < n; u++) {
for (int i = 0; i < (int)neighbors[u].size(); i++) {
int v = neighbors[u][i];
if (u < v) {
link(2 * u + 1, 2 * v, BIG);
link(2 * v + 1, 2 * u, BIG);
}
}
}
t2 = to; c2 = cap; n2 = nxt_e; h2 = head; // 残量模板
node_cnt = N;
int best = n;
for (int s = 0; s < n; s++) {
for (int t = s + 1; t < n; t++) {
to = t2; cap = c2; nxt_e = n2; head = h2;
cap[split[s]] = cap[split[t]] = BIG; // 源、汇不许被删
int flow = dinic(2 * s, 2 * t + 1);
if (flow < best) {
best = flow;
if (best <= 1) { // 已经断开 / 只有割点
return best;
}
}
}
}
return best;
}
int main() {
// 一次读入全部内容,再按“整数出现顺序”抽取(坐标写成 (x,y) 也能正确抽出来)
vector<char> buf;
int ch;
while ((ch = getchar()) != EOF) {
buf.push_back((char)ch);
}
vector<int> flat;
long long cur = -1;
for (int i = 0; i <= (int)buf.size(); i++) {
char c = (i < (int)buf.size()) ? buf[i] : ' ';
if (c >= '0' && c <= '9') {
if (cur < 0) {
cur = 0;
}
cur = cur * 10 + (c - '0');
} else {
if (cur >= 0) {
flat.push_back((int)cur);
cur = -1;
}
}
}
int printed = 0;
int pos = 0;
while (pos + 1 < (int)flat.size()) {
int n = flat[pos], m = flat[pos + 1];
vector<int> a(m), b(m);
for (int i = 0; i < m; i++) {
a[i] = flat[pos + 2 + 2 * i];
b[i] = flat[pos + 3 + 2 * i];
}
pos += 2 + 2 * m;
printf("%d\n", vertex_connectivity(n, a, b));
printed++;
}
if (printed == 0) {
printf("\n");
}
return 0;
}#!/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-10-02 01:46
# update_at: 2026-10-02 01:51
import re
import sys
from array import array
from collections import deque
BIG = 1 << 20 # "不可割断"的容量:一次最多删 n <= 50 个点,远小于它
HEAD = -1 # 链式前向星的"没有下一条边"
def max_flow(to: list[int], cap: array, nxt: list[int], head: list[int],
s: int, t: int) -> int:
"""Dinic 最大流:cap 是对称存边(边 e 的反向边是 e ^ 1),head 是出边链头。"""
size = len(head)
total = 0
while True:
level = [-1] * size # BFS 分层:-1 表示本次还没到过
level[s] = 0
queue = deque([s])
while queue:
u = queue.popleft()
e = head[u]
while e != HEAD:
v = to[e]
if cap[e] > 0 and level[v] < 0:
level[v] = level[u] + 1
queue.append(v)
e = nxt[e]
if level[t] < 0: # 汇点不可达,已无增广路
return total
path: list[tuple[int, int]] = [] # DFS 栈,元素是 (点, 从该点走出去的边号)
u = s
while True:
if u == t:
bottle = min(cap[e] for _, e in path)
for _, e in path:
cap[e] -= bottle
cap[e ^ 1] += bottle
total += bottle
# 退回到第一条被榨干的边,从那里继续找下一条增广路
first = next(i for i, (_, e) in enumerate(path) if cap[e] == 0)
path = path[:first]
u = to[path[-1][1]] if path else s
else:
e, found = head[u], HEAD
while e != HEAD:
v = to[e]
if cap[e] > 0 and level[v] == level[u] + 1:
found = e
break
e = nxt[e]
if found != HEAD:
path.append((u, found))
u = to[found]
elif not path: # 从源点出发已无路可走
break
else: # u 分层内废点:作废它的层号,避免重扫
level[u] = -1
u = path.pop()[0]
def build_network(n: int, neighbors: list[set[int]]) -> tuple[list[int], list[int],
list[int], list[int],
list[int]]:
"""拆点建图:点 v 换成 2v -> 2v+1 的容量 1 弧,原图无向边换成容量 BIG 的双向弧。"""
to: list[int] = []
nxt: list[int] = []
head = [HEAD] * (2 * n)
split: list[int] = [] # split[v] = 点 v 那条"拆点弧"的边号
cross: list[int] = [] # cross = 所有表示原图边的弧的边号
def link(u: int, v: int) -> int:
"""连一条有向弧 u->v,紧跟一条残量 0 的反向弧,返回正向弧的边号。"""
to.append(v)
nxt.append(head[u])
head[u] = len(to) - 1
to.append(u)
nxt.append(head[v])
head[v] = len(to) - 1
return len(to) - 2
for v in range(n):
split.append(link(2 * v, 2 * v + 1))
for a in range(n):
for b in neighbors[a]:
if a < b: # 无向边只连一次,两个方向各一条弧
cross += [link(2 * a + 1, 2 * b), link(2 * b + 1, 2 * a)]
base = [0] * len(to) # 残量模板:只与源汇无关的部分
for v in range(n):
base[split[v]] = 1 # 删一个普通点花 1 的代价
for e in cross:
base[e] = BIG # 原图的边删不掉,等价于无穷容量
return to, nxt, head, split, base
def vertex_connectivity(n: int, edges: list[tuple[int, int]]) -> int:
"""最少删几个点能让图不连通;删不掉时按题面要求返回 n。"""
if n == 0:
return 0
neighbors = [set() for _ in range(n)] # 去掉自环与重边,避免无向边容量被重复叠加
for a, b in edges:
if a != b:
neighbors[a].add(b)
neighbors[b].add(a)
seen, stack = {0}, [0] # 先看原图是否已经断开(孤立点也算断开)
while stack:
fresh = neighbors[stack.pop()] - seen
seen |= fresh
stack += fresh
if len(seen) != n:
return 0
to, nxt, head, split, base = build_network(n, neighbors)
best = n # 删光 n 个点一定不连通,所以答案的上界就是 n(n = 1 时无点对可枚举,正好返回 1)
for s in range(n):
for t in range(s + 1, n):
cap = array("i", base)
cap[split[s]] = cap[split[t]] = BIG # 源、汇不许被删
flow = max_flow(to, cap, nxt, head, 2 * s, 2 * t + 1)
if flow < best:
best = flow
if best <= 1: # 已经断开 / 只有割点,不可能更小
return best
return best
def solve() -> None:
# 抽掉数据文件末尾可能出现的 ^Z;\d+ 会把 (x,y) 与 n、m 一起按出现顺序抽成整数流
flat = list(map(int, re.findall(rb"\d+", sys.stdin.buffer.read().replace(b"\x1a", b" "))))
out: list[str] = []
pos = 0
while pos + 1 < len(flat):
n, m = flat[pos], flat[pos + 1]
edges = list(zip(flat[pos + 2:pos + 2 + 2 * m:2], flat[pos + 3:pos + 3 + 2 * m:2]))
pos += 2 + 2 * m
out.append(str(vertex_connectivity(n, edges)))
print("\n".join(out))
if __name__ == "__main__":
solve()复杂度
- 时间复杂度:
次最大流,网络规模 个节点、 条弧(含反向弧), 即 ,与标准 C++ 正解同阶。 又因为最大流的值不超过 ,每次 Dinic 的实际增广轮数是 级别。 - 空间复杂度:
,一次残量数组用 array('i')存放,每组只用几 KB。
实测(Apple Silicon / CPython 3.14):真实数据 f.in(10 组,best <= 1 剪枝失效)合计 1.84 s;峰值内存实测不到 16 MB,远低于 128 MB。
算法本身与 C++ 同阶,最坏构造下接近本题 1000 ms 的限制,
Python 侧的常数劣势是主要风险。
总结
- 遇到"删最少的东西让图不连通",第一反应应该是最小割,而不是枚举删除集合。
- 割的是点时用拆点:每个点拆成
的一条容量 1 弧, 原图的边给 INF 容量,于是"删点"变成"割边"。 - 题目不指定
时,答案是 ,必须枚举所有点对; 样例第 5 组里 根本割不断、答案只出现在 这类点对上。 - 完全图、单点图这类"删不掉"的情形正好用
的上界处理: 内层循环无点对可枚举时返回初值 即可。 - 无穷大的取值要"大于
,但别太大":大于 才能保证最小割不碰它, 够小才能留在 32 位整数里。
图示解析
这张图串起本题从建模到输出答案的主线:
输入:n 个点、m 条边的无向图
`- 目标:最少删几个点让图不连通(删不到就输出 n)
|- 关键等价 1:删点集 S 让图断开 <=> 存在一对点 (s,t) 被 S 分开
| `- 于是答案 = min over s != t of lambda(s,t),再与 n 取小
`- 关键等价 2:lambda(s,t) 难算 -> 用拆点把"点割"变成"边割"
|- 建网络:v 拆成 v_in --1--> v_out;原图边 {u,v} 变成两条 INF 有向弧
|- 源汇的拆点弧改成 INF(不许删)
`- 最大流最小割定理:maxflow(s_in, t_out) = lambda(s,t)
`- 外层枚举 s < t,取最小;best <= 1 时立刻返回
`- 输出 best(没有更小割时就是 n)读图时抓两个分叉的因果:第一个分叉把"图不连通"这个全局性质拆成了点对上的局部问题,
这解释了为什么会有外层