0%

洛谷 P1742 - 最小圆覆盖

题目大意

洛谷 P1742 最小圆覆盖:给 NN 个平面点(N105N\le 10^5,坐标实数),求一个半径最小、且能把所有点都包住(含边界)的圆,第一行输出半径、第二行输出圆心坐标,精度 10910^{-9}

样例

6 个点 (8,9) (4,7.5) (1,2) (5.1,8.7) (9,2) (4.5,1) → 半径 5、圆心 (5,5)。这个圆恰由 (8,9)、(1,2)、(9,2) 三点的外接圆决定,其余 3 点严格在内部——暗示了最小圆由至多 3 个边界点钉死。

思路讲解

一句话

随机增量法(Welzl):当前圈外的点必落在新圈边界上,于是逐点加入、三层钉点维护当前最小圆,期望 O(N)O(N)。这道题真正的坑不在算法本身,在内层 for j / for k 必须升序——内层判定下标改对了、循环方向写反,照样直接 WA。

Welzl 灵魂:引理 + 自由度 + 前缀不变量

内核一句话:圈外点 ⇒ 必在新最小圆边界上(圆内部容不下它、又必须刚好罩住它)。每钉一个边界点,圆的自由度 1-1:3 自由度 → 钉 pip_i 剩 2 → 钉 pjp_j 剩 1(圆心锁在中垂线上)→ 钉 pkp_k 剩 0(三点外接圆唯一)。

**关键不变量:**每层 resres = 「覆盖前缀 p0pt1p_0\dots p_{t-1} + 已钉点」的最小圆。正确性全靠它。

三层:L0 钉 0 点扫 ii;L1 钉 pip_ijj;L2 钉 pi,pjp_i,p_jkk,谁在圈外就 res = 外接圆。随机打乱只把复杂度降到期望 O(N)O(N)与正确性无关

三层结构(核心循环,升序版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<class T>
Circle<DB> min_circle_cover(vector<Point<T> > A) {
// 先打乱
shuffle(all(A), rng);
Circle<DB> res(A[0], 0);
for (int i = 1; i < SZ(A); ++i) {
if (!res.is_point_in_circle(A[i])) {
res = Circle<DB>(A[i], 0);
// 注意,我们的这个后缀点之前已经考虑过了,实际上我们是要覆盖前缀点
for (int j = 0; j < i; ++j) {
if (!res.is_point_in_circle(A[j])) {
res = circle_from_diameter(A[i], A[j]);
for (int k = 0; k < j; ++k) {
if (!res.is_point_in_circle(A[k])) {
res = outcircle_triangle(A[i], A[j], A[k]);
}
}
}
}
}
}
return res;

⚠️ 内层必须升序,倒序为什么 WA(本题关键)

升序让「已扫过的点」恒等于引理担保仍在圈内的「前缀」{p0pj1}\{p_0\dots p_{j-1}\},两集合重合、归纳一步步闭合。

倒序时(for(j=i-1;j>=0;--j)):在某个 jj 处重建圆,引理只担保覆盖前缀 p0pj1p_0\dots p_{j-1};但你实际已扫过的是后缀 pj+1pi1p_{j+1}\dots p_{i-1}(大下标先遍历)。这批后缀点没人保证还在新圈里,而 jj 只减不回头、永不复查 → 被甩出就永久丢失。

  • **同样例 6 点实测:**升序连跑 30/30 全 = r=5r{=}5、圆心 (5,5)。

  • 倒序飘忽:r=5r{=}5(对) / r=6.23r{=}6.23 圆心 (5,9.375)(盖住但非最小 = WA) / r=3.876r{=}3.876 圆心 (7.05,5.35)(连 (1,2) 都没盖 = 非法解)。

  • **两种坏法:**漏点(已扫点被甩出 → 非法),或后续 circle_from_diameter 重置把圆撑大(非最小)。

机制图:以 j=3 重建为例。升序「已扫」=「引理担保前缀」两段重合(归纳闭合);倒序引理只担保前缀 p0..p2,实际已扫的却是后缀 p4..p6,错位 → 不变量断裂。

几何后果:绿实线 = 正解最小圆 (5,5) r=5;红虚线 = 倒序某次实际跑出的圆 (7.05,5.35) r=3.876,(1,2) 和 (4.5,1) 都被甩在圈外 → 非法解。

🎬 Note: 交互式演示:下面 3 张关键帧出自 mc_order_demo.html(完整可交互版在末尾「附件」,可自己点 Step、切升/倒序、拖引理点、滑前缀/后缀滑条)。

step 0 — L0 起手:res = 单点 p0(蓝实线 r=0);绿虚线 = 真最小圆 r=5 参照物;右栏阶段 L0。

中间帧 — L2:钉 p_i,p_j 后扫 p_k 算外接圆;右栏实时列「引理担保前缀」vs「实际已扫」+ 重合/错位 verdict。

末帧 — 升序跑完:蓝圈收敛到绿圈,r=5 正解 ✓。切「倒序」按钮重跑会停在错的圆(非最小 / 漏点)。

📎 动画与源码

完整可交互 HTML 演示、AC 源码、完整带教对话录 PDF 都在页面末尾「附件」节,直接点开 / 下载。

AC 代码

AC 提交链接:vjudge.net/solution/69974356

心路历程(WA → AC)

  • **第一次:**内层判定误写成 A[i] 而非 A[j] / A[k],res 退化成半径 0 的点圆,只要有点在外答案就全错。

  • **改对下标后仍 WA:**内层循环写成了倒序 for(j=i-1;j>=0;--j) / for(k=j-1;k>=0;--k)

  • **现象飘忽:**同样例点不同 shuffle 跑出 r=5(对)、r=6.23(盖住但非最小=WA)、r=3.876(连 (1,2) 都没盖=非法),一开始以为是精度/eps 问题,其实是结构错。

  • **根因:**倒序破坏「前缀不变量」——引理重建只担保前缀,倒序实际已扫的是后缀,被甩出的点 j 只减不回头、永不复查。

  • **修复:**内层改升序 for(j=0;j<i;++j) / for(k=0;k<j;++k),固定样例点序回归 30/30 稳定 r=5、圆心 (5,5)。

**教训:**随机增量法的正确性不靠随机(随机只管期望复杂度),靠「已扫集合恒为前缀」这条不变量;扫描方向本身是正确性的一部分,不是风格选择。

附件

本题相关产物(可直接点开 / 下载):

mentor.tex.txt

P1742_mini_circle.cpp.txt