跳到主要内容

P5425. 语义邻域可达

场次:2026年9月11日 · 来源:CodeFun2000 P5425

题目描述​

文档检索系统需要判断片段是否相关。本题将每个文档片段视为平面上的一个点。为提高检索效率,要判定两个片段是否属于同一语义簇:若它们密度相连,则认为语义相近。

概念说明​

给定一组点,以及两个参数:邻域半径 ε\varepsilon、最小点数 MinPtsMinPts。

  • ε\varepsilon-邻域:点 pp 的 ε\varepsilon-邻域是所有到 pp 的距离不超过 ε\varepsilon 的点(含 pp 自身)。距离取欧氏距离 dist(p,q)=(x1−x2)2+(y1−y2)2dist(p,q)=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2}
  • 核心点:若点 pp 的 ε\varepsilon-邻域内至少有 MinPtsMinPts 个点,则 pp 是核心点。
  • 密度相连:点 pp 与点 qq 密度相连,当且仅当存在核心点 oo,从 oo 出发能分别沿着核心点的“路径”走到 pp 和 qq,路径上的每一步,都必须从一个核心点走到其 ε\varepsilon-邻域内的某个点。

几个直接推论:

  • 同一簇内的点彼此密度相连
  • 噪声点(未落入任何核心点的 ε\varepsilon-邻域)不与任何点密度相连
  • 两个边界点(位于某核心点邻域内、自身却不是核心点)若被同一个核心点覆盖,则密度相连

输入格式​

第一行四个数:NN、MM、ε\varepsilon、MinPtsMinPts

  • NN:点的数量
  • MM:询问数量
  • ε\varepsilon:邻域半径
  • MinPtsMinPts:成为核心点所需的最少邻域点数

接下来 NN 行:每行两个浮点数 xix_i、yiy_i,按顺序给出编号为 00 到 N−1N-1 的点的坐标。

接下来 MM 行:每行两个整数 aa、bb,询问点 aa 与点 bb 是否密度相连。

数据范围​

  • 1≤N,M≤10001 \le N,M \le 1000
  • 0<ε≤10000 < \varepsilon \le 1000
  • 2≤MinPts≤N2 \le MinPts \le N
  • −10000≤-10000 \le 坐标值 ≤10000\le 10000

输出格式​

共 MM 行,每行一个整数:

  • 11 表示密度相连
  • 00 表示不密度相连

提示​

提示:可按密度聚类 / 密度可达来做判定。

示例​

示例 1​

输入:

6 4 2.0 3
0.0 0.0
1.2 0.0
0.4 1.0
12.0 0.0
12.5 0.4
6.0 6.0
0 2
0 3
3 4
1 5

输出:

1
0
0
0

说明:

ε=2.0\varepsilon=2.0,MinPts=3MinPts=3。

点 0,1,20,1,2 两两距离分别为 1.21.2、1.16≈1.077\sqrt{1.16}\approx 1.077、1.64≈1.281\sqrt{1.64}\approx 1.281,均不超过 2.02.0,每个点的邻域大小都是 33,因此都是核心点,彼此密度相连。

点 33 与点 44 相距 0.41≈0.640≤2.0\sqrt{0.41}\approx 0.640\le 2.0,但各自邻域只有 22 个点,不是核心点,也没有核心点能走到它们,因此是噪声点,彼此不密度相连。

点 55 邻域只有自身,同样是噪声点。

示例 2​

输入:

8 4 2.0 3
0.0 0.0
1.5 0.0
0.3 1.4
3.2 0.2
8.0 8.0
8.6 8.2
8.3 9.5
0.0 8.0
0 2
0 3
4 5
3 4

输出:

1
1
1
0

说明:

ε=2.0\varepsilon=2.0,MinPts=3MinPts=3。

点 0,1,20,1,2 两两距离分别为 1.51.5、2.05≈1.432\sqrt{2.05}\approx 1.432、3.4≈1.844\sqrt{3.4}\approx 1.844,均为核心点。点 33 到点 11 的距离为 2.93≈1.712\sqrt{2.93}\approx 1.712,落入点 11 的邻域,但自身邻域只有 22 个点,是边界点;从核心点 11 可以走到点 00 与点 33,故 00 与 33 密度相连。

点 4,5,64,5,6 两两距离分别为 0.4≈0.632\sqrt{0.4}\approx 0.632、2.34≈1.529\sqrt{2.34}\approx 1.529、1.78≈1.334\sqrt{1.78}\approx 1.334,都是核心点,形成一个独立的簇。

点 33 与点 44 分属不同簇,不密度相连。点 77 是噪声点。


解题思路​

第一步:理解问题本质​

题目描述的是 DBSCAN 聚类里的三个概念,先把它们翻译成“图”的语言。

  1. ε\varepsilon-邻域:点 pp 与点 qq 之间连一条无向边,当且仅当 dist(p,q)≤εdist(p,q)\le\varepsilon。注意题目明确写了“含 pp 自身”,所以每个点至少有一条指向自己的“自环”,邻域大小至少为 11。
  2. 核心点:邻域大小 ≥MinPts\ge MinPts 的点。它像一个“枢纽”,可以继续向外接力。
  3. 密度相连:存在一个核心点 oo,从 oo 出发能分别走到 pp 和 qq,且每一步都必须从一个核心点走到它邻域内的某个点。

第 3 条里最关键的是“从一个核心点出发”这半句。它意味着:

  • 只有核心点能当中转站,路径可以一个核心点接一个核心点地传下去;
  • 边界点(自己不是核心、但落在某个核心点邻域里)只能当终点,走到它就停了;
  • 噪声点既不是中转站,也不在任何核心点的终点集合里,所以它和谁都不相连 —— 包括它自己。

于是问题变成:

对每个核心连通块(互相能接力的核心点集合),把“块里所有核心点的邻域”并起来,得到一个可达点集。 询问 a,ba,b 时,只要存在某个块的可达点集同时包含 aa 和 bb,答案就是 11。

第二步:暴力解法​

最直接的做法是:对每组询问 (a,b)(a,b),枚举每一个核心点 oo,从 oo 出发沿核心点做一次搜索,看 aa、bb 是否都在“走到的核心点的邻域并集”里。

def answer(a, b):
for o in range(n):
if not core[o]:
continue
# 从核心点 o 出发,沿核心点扩散;每到一个核心点,
# 就把它邻域里的点全部收进 reachable
seen = {o}
stack = [o]
reachable = set(neighbors[o])
while stack:
u = stack.pop()
for v in neighbors[u]:
# 只有核心点才能继续接力
if core[v] and v not in seen:
seen.add(v)
reachable |= neighbors[v]
stack.append(v)
if a in reachable and b in reachable:
return 1
return 0

正确性没问题,但每次询问都要重新搜一遍核心点图:单次 O(N2)O(N^2),MM 次就是 O(MN2)O(MN^2)。N=M=1000N=M=1000 时约 10910^9 次操作,太慢。

而且不同询问之间做了大量重复工作:核心点之间的连通关系与询问无关,只需要预处理一次。

第三步:优化解法——用并查集把核心点粘起来​

把“核心点”看成一张新图的结点:两个核心点距离不超过 ε\varepsilon 就连边。这张图的一个连通分量,就是题目里“可以互相接力”的核心点集合,称为核心连通块。

连通分量可以用并查集维护:枚举所有点对 (i,j)(i,j),若两者都是核心点且距离 ≤ε\le\varepsilon,就合并它们。

  • 建边需要比较 O(N2)O(N^2) 对点;
  • 并查集每次合并均摊 O(α(N))O(\alpha(N)),几乎等于常数。

这样“谁能到达谁”的问题就变成了“谁和谁在同一个块里”,例如询问 (0,2)(0,2) 时两点都是核心点、块编号相同,答案就是 11。

第四步:最优解法——每个点记录“能挂到哪些块”​

只剩最后一个细节:边界点该挂在哪个块上?

  • 核心点:挂在自己所在的块;
  • 边界点:挂到“覆盖它的那些核心点”所在的块;
  • 噪声点:一个块都挂不上,记空集合。

然后询问 a,ba,b 时,比较两边的块集合有没有交集即可:有交集,说明存在一个核心点 oo 能同时走到 aa 和 bb,正是题目定义的密度相连。

这里必须用集合,不能只记一个块编号。原因是:一个边界点可能同时落在两个互不相连的簇的核心点邻域里。

例如下面这样的布局(ε=5\varepsilon=5、MinPts=4MinPts=4):

   点1 (0,4)          点4 (10,4)

点0 (0,0) ---- 点6 (5,0) ---- 点3 (10,0)

点2 (0,-4) 点5 (10,-4)
  • 点 00 的邻域是 {0,1,2,6}\{0,1,2,6\},大小 44,是核心点;点 33 同理,也是核心点;
  • 点 66 的邻域只有 {6,0,3}\{6,0,3\},大小 3<43<4,只是边界点;
  • 点 66 同时落在核心点 00 和核心点 33 的邻域里,所以它和左右两个簇都密度相连;
  • 但点 00 与点 33 之间并不密度相连:路径若要从 00 走到 33,中间必须经过点 66,而点 66 不是核心点,不能继续接力。

如果实现时图省事,把“核心点与它邻域内所有点”直接 union,就会把点 66 当成桥梁,把左右两个簇错误地合并,00 与 33 会被判成 11。这正是本题最容易踩的坑。

整个流程的复杂度是 O(N2α(N)+M)O(N^2\alpha(N)+M),N=1000N=1000 时约 10610^6 次距离比较,非常轻松。


完整代码实现​

"""
P5425. 语义邻域可达 - DBSCAN 密度相连判定
"""

import sys


def solve():
"""读入点集与询问,按 DBSCAN 的定义逐条回答是否密度相连。"""
data = sys.stdin.buffer.read().split()
if not data:
return

it = iter(data)
n = int(next(it))
m = int(next(it))
eps = float(next(it))
min_pts = int(next(it))

xs = [0.0] * n
ys = [0.0] * n
for i in range(n):
xs[i] = float(next(it))
ys[i] = float(next(it))

queries = [(int(next(it)), int(next(it))) for _ in range(m)]

# 用平方距离比较,省掉 N^2 次开方;末尾的极小容差让“恰好等于 eps”
# 的点不被浮点误差挡在门外(如 eps = 1.5 时 (0,0) 到 (1.2,0.9))。
eps2 = eps * eps * (1.0 + 1e-9)

# ---------- 第一遍:数每个点 eps-邻域内有多少个点(含自身) ----------
counts = [1] * n
for i in range(n):
xi = xs[i]
yi = ys[i]
cnt = counts[i]
for j in range(i + 1, n):
dx = xs[j] - xi
dy = ys[j] - yi
if dx * dx + dy * dy <= eps2:
# 邻域关系对称,一次比较同时给两边计数
cnt += 1
counts[j] += 1
counts[i] = cnt

core = [cnt >= min_pts for cnt in counts]

# ---------- 并查集:合并互相连通的核心点 ----------
parent = list(range(n))
size = [1] * n

def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x

def union(a, b):
ra, rb = find(a), find(b)
if ra == rb:
return
if size[ra] < size[rb]:
ra, rb = rb, ra
parent[rb] = ra
size[ra] += size[rb]

# 只在核心点之间连边:边界点不能当中转站,
# 否则“被两个簇同时覆盖的边界点”会把两个无关的簇粘在一起。
for i in range(n):
if not core[i]:
continue
xi = xs[i]
yi = ys[i]
for j in range(i + 1, n):
if not core[j]:
continue
dx = xs[j] - xi
dy = ys[j] - yi
if dx * dx + dy * dy <= eps2:
union(i, j)

# ---------- 第二遍:算出每个点能挂到哪些核心连通块 ----------
groups = [None] * n
for i in range(n):
if core[i]:
groups[i] = {find(i)}

for i in range(n):
if core[i]:
continue
found = set()
xi = xs[i]
yi = ys[i]
for j in range(n):
if not core[j]:
continue
dx = xs[j] - xi
dy = ys[j] - yi
if dx * dx + dy * dy <= eps2:
found.add(find(j))
# 噪声点的 found 为空集:它不与任何点密度相连(包括它自己)
groups[i] = found

out = []
for a, b in queries:
# 两个集合有交集 <=> 存在一个核心点能同时走到 a 和 b
out.append("1" if groups[a] & groups[b] else "0")

sys.stdout.write("\n".join(out))
if out:
sys.stdout.write("\n")


if __name__ == "__main__":
solve()

NumPy 版本​

以下版本使用 NumPy。只有在评测环境允许第三方库时才能提交;CodeFun2000 的普通 Python 环境不一定安装 NumPy。

import sys

import numpy as np


def solve():
"""按 DBSCAN 的定义判定两两是否密度相连,距离矩阵与可达矩阵都用 NumPy 算。"""
data = sys.stdin.buffer.read().split()
if not data:
return

n = int(data[0])
m = int(data[1])
eps = float(data[2])
min_pts = int(data[3])

coords = np.fromiter(map(float, data[4:4 + 2 * n]),
dtype=np.float64, count=2 * n)
points = coords.reshape(n, 2)

cursor = 4 + 2 * n
queries = np.fromiter(map(int, data[cursor:cursor + 2 * m]),
dtype=np.int64, count=2 * m).reshape(m, 2)

# 平方距离比较,省掉 N^2 次开方;末尾的极小相对容差让“恰好等于 eps”
# 的点不被浮点误差挡在门外(如 eps = 1.5 时 (0,0) 到 (1.2,0.9))。
eps2 = eps * eps * (1.0 + 1e-9)

# 两两距离矩阵:(n,1,2) - (1,n,2) 广播出所有点对的坐标差,再平方求和。
# 用坐标差而不是 |x|^2+|y|^2-2x·y,避免大坐标相减时的抵消误差。
diff = points[:, None, :] - points[None, :, :]
dist2 = (diff * diff).sum(axis=-1)

# adj[i][j] 为真表示 j 落在 i 的 eps-邻域内(含 i 自己,对角线恒为真)
adj = dist2 <= eps2

# 邻域大小含自身,所以 counts 直接就是 adj 的行和
counts = adj.sum(axis=1)
core = counts >= min_pts

# ---------- 核心点诱导子图的连通分量(并查集) ----------
parent = list(range(n))

def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]] # 路径压缩
x = parent[x]
return x

def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra

core_idx = np.nonzero(core)[0]
# 核心点之间的边只取严格上三角,避免同一对点被处理两次
core_adj = adj[np.ix_(core_idx, core_idx)]
rows, cols = np.nonzero(np.triu(core_adj, k=1))
for a, b in zip(core_idx[rows].tolist(), core_idx[cols].tolist()):
union(a, b)

# ---------- 每个核心连通块的可达点集 ----------
qa, qb = queries[:, 0], queries[:, 1]

if core_idx.size == 0:
# 一个核心点都没有:所有点都是噪声点,必然不密度相连(含自询)
answer = np.zeros(m, dtype=bool)
else:
roots = np.array([find(i) for i in range(n)])
comp_of_core = roots[core_idx]

# 把核心点按所属块排在一起,再用 bitwise_or.reduceat 一次性把同一块的
# 邻域并起来:reach[k] 就是第 k 个核心连通块能走到的所有点。
order = np.argsort(comp_of_core, kind="stable")
comp_sorted = comp_of_core[order]
starts = np.flatnonzero(np.r_[True, comp_sorted[1:] != comp_sorted[:-1]])
reach = np.bitwise_or.reduceat(adj[core_idx[order]], starts, axis=0)

# ---------- 回答询问 ----------
# 两边块集合有交集 <=> 存在一个核心点能同时走到 a 和 b。
# 噪声点不在任何块的可达点集里,所以自询也输出 0。
answer = (reach[:, qa] & reach[:, qb]).any(axis=0)

out = ["1" if flag else "0" for flag in answer.tolist()]
sys.stdout.write("\n".join(out))
if out:
sys.stdout.write("\n")


if __name__ == "__main__":
solve()

示例推演​

样例 1​

输入 66 个点,ε=2.0\varepsilon=2.0、MinPts=3MinPts=3。先算出每个点的邻域:

点坐标邻域内的点邻域大小是否核心点
0(0,0)(0,0){0,1,2}\{0,1,2\}3是
1(1.2,0)(1.2,0){0,1,2}\{0,1,2\}3是
2(0.4,1)(0.4,1){0,1,2}\{0,1,2\}3是
3(12,0)(12,0){3,4}\{3,4\}2否
4(12.5,0.4)(12.5,0.4){3,4}\{3,4\}2否
5(6,6)(6,6){5}\{5\}1否

点 0,1,20,1,2 之间的距离分别是 1.21.2、1.16≈1.077\sqrt{1.16}\approx1.077、1.64≈1.281\sqrt{1.64}\approx1.281,都不超过 2.02.0,所以三点互相连边,合并成一个核心连通块,记作 AA。

点 33 与点 44 虽然相距 0.41≈0.640≤2.0\sqrt{0.41}\approx0.640\le2.0,但两点都不是核心点,不能互相接力,也都不在任何核心点的邻域里,因此都是噪声点。

于是:

点能挂到的核心连通块
0、1、2{A}\{A\}
3、4、5∅\varnothing

四组询问:

  • 0,20,2:{A}∩{A}={A}\{A\}\cap\{A\}=\{A\},输出 11;
  • 0,30,3:{A}∩∅=∅\{A\}\cap\varnothing=\varnothing,输出 00;
  • 3,43,4:∅∩∅=∅\varnothing\cap\varnothing=\varnothing,输出 00(两个噪声点彼此也不相连);
  • 1,51,5:{A}∩∅=∅\{A\}\cap\varnothing=\varnothing,输出 00。

样例 2​

ε=2.0\varepsilon=2.0、MinPts=3MinPts=3:

点坐标邻域内的点邻域大小是否核心点
0(0,0)(0,0){0,1,2}\{0,1,2\}3是
1(1.5,0)(1.5,0){0,1,2,3}\{0,1,2,3\}4是
2(0.3,1.4)(0.3,1.4){0,1,2}\{0,1,2\}3是
3(3.2,0.2)(3.2,0.2){1,3}\{1,3\}2否
4(8,8)(8,8){4,5,6}\{4,5,6\}3是
5(8.6,8.2)(8.6,8.2){4,5,6}\{4,5,6\}3是
6(8.3,9.5)(8.3,9.5){4,5,6}\{4,5,6\}3是
7(0,8)(0,8){7}\{7\}1否

点 11 到点 33 的距离是 1.72+0.22=2.93≈1.712≤2.0\sqrt{1.7^2+0.2^2}=\sqrt{2.93}\approx1.712\le2.0,所以点 33 落在核心点 11 的邻域里,是边界点,挂到核心连通块 A={0,1,2}A=\{0,1,2\} 上。

点 4,5,64,5,6 两两距离约 0.6320.632、1.5291.529、1.3341.334,全部 ≤2.0\le2.0,形成另一个核心连通块 BB。

点能挂到的核心连通块
0、1、2、3{A}\{A\}
4、5、6{B}\{B\}
7∅\varnothing

四组询问:

  • 0,20,2:同属 AA,输出 11;
  • 0,30,3:00 是核心点、33 是 AA 的边界点,都在 AA 的可达点集里,输出 11;
  • 4,54,5:同属 BB,输出 11;
  • 3,43,4:33 只在 AA、44 只在 BB,交集为空,输出 00。

复杂度分析​

解法时间复杂度空间复杂度说明
逐询问 BFSO(MN2)O(MN^2)O(N2)O(N^2)每组询问都重新搜一遍核心点图,N=M=1000N=M=1000 时约 10910^9 次操作
并查集合并全部邻域O(N2α(N)+M)O(N^2\alpha(N)+M)O(N)O(N)把边界点也 union 进去,会把共享边界点连接的两个簇错误合并
并查集 + 块集合(本题)O(N2α(N)+M)O(N^2\alpha(N)+M)O(N+M)O(N+M)预处理一次,回答所有询问;边界点允许挂多个块

两遍 O(N2)O(N^2) 的扫描在 N=1000N=1000 时约 1.5×1061.5\times10^6 次距离计算,Python 中运行时间远小于 0.20.2 秒。


易错点总结​

1. 邻域必须包含点自身​

题目写的是“距离不超过 ε\varepsilon 的点(含 pp 自身)”。所以邻域大小的计数要从 11 开始,counts 全部初始化为 11。忘记这一点会让所有点的邻域大小少 11,MinPtsMinPts 恰好等于真实邻域大小的点会被误判成边界点。

2. 核心点的判据是 ≥MinPts\ge MinPts​

注意是“至少 MinPtsMinPts 个点”,用 >= 而不是 >。

3. 边界点不能当中转站​

这是本题与“无脑并查集”的分水岭。路径的每一步都必须从核心点出发,所以:

  • 建图时只在核心点之间连边;
  • 边界点只作为终点被记录,不能继续向外扩散。

否则“被两个簇同时覆盖的边界点”会把两个本来无关的簇连成一个,回答 00 的询问会变成 11。

4. 一个点可能挂在多个核心连通块上​

边界点可能同时落在两个簇的核心点邻域里。用集合记录“能挂到哪些块”,询问时判断交集是否非空;只记一个块编号(或直接并查集合并)都会丢信息。

5. 噪声点与它自己也不相连​

噪声点不在任何核心点的邻域里,没有任何核心点能“走到”它,所以询问 (p,p)(p,p) 在 pp 是噪声点时应输出 00,而不是想当然地输出 11。

6. 用平方距离比较,并给边界留一点容差​

判断 dist≤εdist\le\varepsilon 时比较 dx2+dy2≤ε2dx^2+dy^2\le\varepsilon^2 即可,不需要开方。但浮点数下 1.22+0.921.2^2+0.9^2 可能算成 2.25000000000000042.2500000000000004,而 ε2=2.25\varepsilon^2=2.25,本该“恰好相等”的点会被判成邻域外。乘上 1.0 + 1e-9 这样的相对容差可以避免这类误判。

7. 输出的是整数 0/10/1​

每行一个整数,不要输出 YES / NO / True / False。


扩展思考​

  • 如果题目要求输出每个点的簇编号:那就回到了 DBSCAN 的经典歧义 —— 边界点被多个簇覆盖时该归谁?常见做法是“先到先得”,即按扫描顺序分配给第一个声明它的簇。本题只问“是否相连”,用集合表达天然没有歧义。
  • 如果 NN 达到 10510^5:O(N2)O(N^2) 建边不可行,需要用空间索引(网格划分、k-d 树)只枚举“可能落在 ε\varepsilon 内”的点对,把建边降到接近 O(Nlog⁡N)O(N\log N)。
  • 如果 ε\varepsilon 是动态查询参数:预处理帮不上忙,只能每次重新建图,此时单次询问的暴力做法反而更合适。
  • 与“连通分量”的区别:普通图的连通分量只看有没有路径,而这里限制了路径中间的结点必须是核心点。也就是说,本题的“连通”是在核心点诱导子图上定义的,边界点只享受“终点”待遇。

相关题目​

加载评论中...