端点用它上面相接的篱笆标号集合编号,篱笆是有权边,区域就是环;枚举每条边禁掉后跑 Dijkstra 求两端最短路,w+dist 取最小。
OJ: roj
题目 ID: 2063
难度:提高
标签:图论最短路Dijkstra堆python
创建: 2026-10-01 05:57
更新: 2026-10-07 13:50
形式化题目
有
篱笆在平面上构成网络,把平面分割成若干区域。求所有区域周长的最小值,即
以样例(
正解
思路
一句话本质:把每段篱笆看成一条带权边、把每个"端点"看成一个顶点,于是"围出一块区域的篱笆"正好是图中的一个环,问题变成求最小环的边权和。
问题一:没有坐标,顶点怎么编号?
题面给不出坐标,但它给了更直接的东西:每个端点上接了哪几段篱笆。反过来读,这就足以确定一个端点是谁——如果篱笆
于是把篱笆
同一物理端点无论被哪段篱笆描述,得到的集合都一样;实现里用 tuple(sorted(...)) 当 dict 的键。
样例 fence60 的 10 段篱笆共描述 20 个端点,去重后恰好剩 7 个,与题目 ASCII 图里的 7 个顶点完全吻合:
| 端点 | 相接篱笆 |
|---|---|
| A | 1, 2, 7 |
| B | 1, 6, 10 |
| C | 2, 3, 8 |
| D | 3, 4 |
| E | 4, 5, 9, 10 |
| F | 5, 6 |
| G | 7, 8, 9 |
把 10 段篱笆按这个编号接起来,得到下面这张带权无向图(边上的数字是篱笆标号与长度):
graph LR
A["A = 1,2,7"] ---|"篱笆 1 / 长 16"| B["B = 1,6,10"]
A ---|"篱笆 2 / 长 3"| C["C = 2,3,8"]
C ---|"篱笆 3 / 长 3"| D["D = 3,4"]
D ---|"篱笆 4 / 长 8"| E["E = 4,5,9,10"]
E ---|"篱笆 5 / 长 8"| F["F = 5,6"]
F ---|"篱笆 6 / 长 6"| B
A ---|"篱笆 7 / 长 5"| G["G = 7,8,9"]
C ---|"篱笆 8 / 长 4"| G
G ---|"篱笆 9 / 长 5"| E
B ---|"篱笆 10 / 长 10"| E
观察重点是:题面那张 ASCII 图里的 7 个顶点在这张图里一一对应,而"围出一块地"在图里的含义就是"顺着边绕一圈回到起点"。图中最短的一圈是 A–C–G,长度
问题二:为什么区域和环是同一件事?
两个方向都要成立:
- 区域 → 环:区域的边界本身就是一条闭回路,沿着它走一圈、把重复经过的顶点去掉,就得到图中的一个环,环长不超过区域周长。所以最小环长
最小区域周长。 - 环 → 区域:取任意一个环
。如果 内部没有别的篱笆,那么 自己就围出一块地,周长正好是 。否则在这块地内部继续切:取一条真正把地切成两块的篱笆 (一端悬空的"刺"切不出两块地,也永远不会成为任何区域的边界,直接跳过),它的两个端点落在这块地边界上,把边界切成两条弧 与 ;而篱笆是直线段,两点之间直线最短,所以 。设 ,则切出的两块地中较小者的周长为 ,即不超过切之前那块地的周长。反复切下去,周长始终不增,最后必然得到一块内部再无篱笆的地——它就是一块区域,周长不超过最初的 。所以最小区域周长 最小环长。
两边取最小值后相等,最小区域周长
顺带说明多段汇合的情况:一个端点接 3 段以上篱笆(数据里最多接了 8 段)时,几何上它周围被分成多个扇区。但在图模型里,一个环"进入该顶点"和"离开该顶点"可以走任意两条不同的边,不需要知道它们几何上是否相邻——因为小扇区如果被别的篱笆切成更小的区域,那块更小的区域自己也会成为一个环,最终仍会被枚举到。所以把顶点当作"入边出边可自由配对"的普通图顶点,既不会漏掉真实的环,算出的候选值也都对应真实的闭合曲线。
问题三:怎么在
穷举所有环是不行的——
对每条边
- 不漏:设最小环是
。从 里拿走任意一条边 ,剩下的是一条 路径,长 ;既然它是 中的一条合法路径,最短路必然 它,于是 。候选值不会超过答案。 - 不假:
中任意一条 最短路加上 就是一条真实闭合回路,其长度 最小环长。候选值也不会小于答案。
唯一要防的退化情况是"路径一步都不走":万一某段篱笆的两端编号相同(自环),从
边只有
把样例的枚举过程完整列出来(
| 篱笆 | 端点 | |||
|---|---|---|---|---|
| 2 | A–C | 3 | 9 | 12 |
| 7 | A–G | 5 | 7 | 12 |
| 8 | C–G | 4 | 8 | 12 |
| 3 | C–D | 3 | 17 | 20 |
| 4 | D–E | 8 | 12 | 20 |
| 9 | G–E | 5 | 15 | 20 |
| 5 | E–F | 8 | 16 | 24 |
| 6 | F–B | 6 | 18 | 24 |
| 10 | B–E | 10 | 14 | 24 |
| 1 | A–B | 16 | 20 | 36 |
最小值 12 由前三行给出,它们其实是同一个三角形 A–C–G 的三条边——环上每条边被枚举到时都会算出同一个候选值,重复不影响取最小值。
代码
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-10-01 05:58
# update_at: 2026-10-01 06:04
import sys
from heapq import heappop, heappush
from collections.abc import Iterator
INF = 10**9 # 篱笆总长不超过 100*255,用它当"走不到"的哨兵
Junction = tuple[int, ...] # 端点身份 = 在这个端点上相接的篱笆标号集合,同集合即同端点
Fence = tuple[Junction, Junction, int] # (一端的端点, 另一端的端点, 长度)
Edge = tuple[Junction, int, int] # (对端端点, 长度, 篱笆标号)
def detour(adj: dict[Junction, list[Edge]], start: Junction, goal: Junction, banned: int) -> int:
"""禁用 banned 号篱笆后,从 start 沿至少一条篱笆走到 goal 的最短长度,走不到返回 INF。
出发点先迈一步再入堆,所以 start == goal(某段篱笆两端落在同一端点)时
得到的是一条真正的回路,而不是长度为 0 的空路。
"""
dist: dict[Junction, int] = {}
pq: list[tuple[int, Junction]] = []
for y, w, eid in adj[start]:
if eid != banned and w < dist.get(y, INF):
dist[y] = w
heappush(pq, (w, y))
while pq:
d, x = heappop(pq)
if d > dist[x]: # 过期堆项
continue
if x == goal:
return d
for y, w, eid in adj[x]:
if eid != banned and d + w < dist.get(y, INF):
dist[y] = d + w
heappush(pq, (d + w, y))
return INF
def solve() -> None:
data: Iterator[int] = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
fences: list[Fence] = []
for _ in range(n):
s, length, left_count, right_count = next(data), next(data), next(data), next(data)
left = [next(data) for _ in range(left_count)]
right = [next(data) for _ in range(right_count)]
# 题面只给"相接关系",不给坐标:把"该端点上所有篱笆的标号集合"直接当作端点编号
fences.append((tuple(sorted(left + [s])), tuple(sorted(right + [s])), length))
adj: dict[Junction, list[Edge]] = {}
for eid, (u, v, length) in enumerate(fences):
adj.setdefault(u, []).append((v, length, eid))
adj.setdefault(v, []).append((u, length, eid))
# 每条篱笆轮流充当回路的一环:禁掉它,两个端点之间的最短路就是回路剩下的部分
print(min(length + detour(adj, u, v, eid) for eid, (u, v, length) in enumerate(fences)))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* 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-06 10:36
* update_at: 2026-10-06 10:36
*/
#include <cstdio>
#include <vector>
#include <map>
#include <queue>
#include <utility>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 105; // 篱笆段数上限
const int MAXV = 205; // 端点数量上限:每段篱笆两端,去重后不超过 2*N
const ll INF = (ll)1e18; // “走不到”的哨兵,篱笆总长远小于它
int n; // 篱笆段数
int m; // 去重后的端点数
int fence_u[MAXN]; // 第 i 段篱笆一端的端点编号
int fence_v[MAXN]; // 第 i 段篱笆另一端的端点编号
ll fence_len[MAXN]; // 第 i 段篱笆的长度
vector<int> adj[MAXV]; // adj[x] 存以 x 为一端的篱笆编号
map<vector<int>, int> vertex_id; // 端点身份(该端点上全部篱笆标号的有序集合)-> 端点编号
ll dist_arr[MAXV]; // Dijkstra 的距离表:dist_arr[x] 为当前起点到 x 的最短长度
bool used[MAXV]; // used[x] 表示 x 的最短路已确定
// 把端点身份映射成编号,没有见过就新建一个。
int get_vertex(const vector<int> &key) {
map<vector<int>, int>::iterator it = vertex_id.find(key);
if (it != vertex_id.end()) {
return it->second;
}
vertex_id[key] = m;
m = m + 1;
return m - 1;
}
// 禁掉 banned 号篱笆后,从 start 沿至少一条篱笆走到 goal 的最短长度,走不到返回 INF。
// 起点先迈一条边再入堆,所以 start == goal(某段篱笆两端落在同一端点)时得到的是真实回路,而不是空路。
ll dijkstra(int start, int goal, int banned) {
int i;
for (i = 0; i < m; i++) {
dist_arr[i] = INF;
used[i] = false;
}
priority_queue<pair<ll, int>, vector<pair<ll, int> >, greater<pair<ll, int> > > pq;
for (i = 0; i < (int)adj[start].size(); i++) {
int e = adj[start][i];
if (e == banned) {
continue;
}
int y = fence_u[e] + fence_v[e] - start; // 篱笆的另一端
if (fence_len[e] < dist_arr[y]) {
dist_arr[y] = fence_len[e];
pq.push(make_pair(fence_len[e], y));
}
}
while (!pq.empty()) {
pair<ll, int> top = pq.top();
pq.pop();
ll d = top.first;
int x = top.second;
if (used[x]) {
continue;
}
used[x] = true;
if (x == goal) {
return d;
}
for (i = 0; i < (int)adj[x].size(); i++) {
int e = adj[x][i];
if (e == banned) {
continue;
}
int y = fence_u[e] + fence_v[e] - x; // 篱笆的另一端
if (d + fence_len[e] < dist_arr[y]) {
dist_arr[y] = d + fence_len[e];
pq.push(make_pair(dist_arr[y], y));
}
}
}
return INF;
}
int main() {
int i;
int j;
scanf("%d", &n);
for (i = 1; i <= n; i++) {
int s;
int length;
int left_count;
int right_count;
scanf("%d %d %d %d", &s, &length, &left_count, &right_count);
fence_len[i] = length;
// 题面只给相接关系,不给坐标:把“该端点上全部篱笆的标号集合”当作端点身份。
vector<int> left_key;
left_key.push_back(s);
for (j = 0; j < left_count; j++) {
int label;
scanf("%d", &label);
left_key.push_back(label);
}
vector<int> right_key;
right_key.push_back(s);
for (j = 0; j < right_count; j++) {
int label;
scanf("%d", &label);
right_key.push_back(label);
}
sort(left_key.begin(), left_key.end());
sort(right_key.begin(), right_key.end());
fence_u[i] = get_vertex(left_key);
fence_v[i] = get_vertex(right_key);
adj[fence_u[i]].push_back(i);
adj[fence_v[i]].push_back(i);
}
// 每条篱笆轮流充当回路的一环:禁掉它,两端点之间的最短路就是回路剩下的部分。
ll answer = INF;
for (i = 1; i <= n; i++) {
ll rest = dijkstra(fence_u[i], fence_v[i], i);
if (rest < INF) {
ll candidate = fence_len[i] + rest;
if (candidate < answer) {
answer = candidate;
}
}
}
printf("%lld\n", answer);
return 0;
}复杂度
- 时间复杂度:
。建图 ;枚举 条边,每条在删边图上跑一次堆优化 Dijkstra, 。 - 空间复杂度:
。邻接表存 条有向边,单次 Dijkstra 的距离表最多 项。
总结
- 本题没有任何坐标,"端点是谁"这个信息就藏在相接列表里:把该端点上全部篱笆的标号集合当作端点的编号,
tuple(sorted(...))直接就能当哈希键。 - 建图之后,“围出一块地"就是"图上的一个环”,区域的最小周长就是最小环的边权和。两个方向都要说清:区域边界去掉重复顶点就是环;反过来沿内部篱笆切地时,用"篱笆是直线段、两点间直线最短"可以证明切分不会让最小周长变大。
- 求最小环不枚举环,而是枚举环上的一条边:删掉它、跑一次两端点的最短路,
就是候选值;正确性靠"不漏 + 不假"两端夹逼。 - 实现上的两个小细节:Dijkstra 从起点先迈一条边,天然绕开自环退化成空路的问题;距离表用
dict+get(y, INF),省掉给端点分配整数编号的整层代码。