ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NOI入门题‘观星’:方向标准化与遮挡判定的几何模拟

NOI入门题‘观星’:方向标准化与遮挡判定的几何模拟

1. 项目概述:一道入门组题目的真实分量

“观星”这道题,标题里带着点诗意,但实际是NOI Online #3入门组里一道典型的模拟+几何思维+边界处理综合题。它不考高深算法,不堆数据结构,却像一块试金石——能筛出真正理解“问题建模”和“代码落地一致性”的人。我带过几届算法入门班,每次讲到P6566,总有学生第一反应是:“不就是遍历星星坐标吗?”结果一写就崩:样例过了,提交WA;改两行,又RE;再调,TLE……最后发现,崩的不是代码,而是对题干中“可见”“遮挡”“视线方向”这几个词的物理直觉没建立起来。

这道题的核心关键词是:视线方向向量、单位化处理、共线判断、距离排序、遮挡判定逻辑。它表面在考“怎么数星星”,实则在考“你怎么把人眼看到的世界,翻译成计算机能严格执行的数学规则”。适合三类人重点参考:刚学完二维坐标系的初中生、正在刷洛谷入门题单的新手、以及准备带队打NOI Online的教练——因为它的错误模式太典型,几乎覆盖了初学者在几何模拟题里90%的坑。

我把它拆成四个硬核模块来复盘:不是按“输入-处理-输出”流程走,而是按“人脑怎么想→代码怎么错→怎么修→为什么这么修才稳”这个真实调试链路来组织。下面每一节,都对应我当年在机房里盯着屏幕改了7版才AC的某个深夜。

2. 题目本质解构:为什么“观星”不是简单遍历?

2.1 题干物理模型的三层抽象

题目说:“从原点出发,沿某方向看去,能看到多少颗星星?” 这句话必须拆成三层:

  • 第一层(物理层):人眼在原点,视线是一条射线,方向由向量(dx, dy)决定。所有星星是平面上的点,有些在射线上,有些不在。
  • 第二层(数学层):一颗星星能被看到,当且仅当它位于该射线上,且从原点到它的线段上,没有其他星星阻挡。注意,是“线段上”,不是“射线上”——这意味着离原点更近的星星会挡住更远的。
  • 第三层(计算层):如何用整数/浮点运算,无误差地判断“是否共线”“谁更近”“谁挡谁”?这才是真正的难点。

很多同学卡在第一层就错了:以为只要星星坐标满足 y/x == dy/dx 就行。但x=0怎么办?dy=0怎么办?浮点除法精度怎么控?——这些都不是编程技巧问题,而是建模时没把物理场景彻底数学化。

2.2 关键陷阱:方向向量的“单位化”误区

题干给的方向是整数对(dx, dy),但绝不能直接用它当方向向量去算比例。举个反例:方向(2,4)和(1,2)明明是同一方向,但若用y/x判断,2/4=0.5,1/2=0.5,看似一样;可一旦遇到(0,1)和(0,2),x=0导致除零错误。更隐蔽的是(1000,2000)和(1,2),浮点除法下0.5和0.5000000001可能被判不等。

正确解法是约分后归一化:求dx和dy的最大公约数g = gcd(|dx|,|dy|),然后得到最简方向向量(dx/g, dy/g)。注意符号统一——我们约定:若dx<0,强制让dx变负、dy变负;若dx==0且dy<0,让dy变负。这样(2,4)→(1,2),(-2,-4)→(1,2),(0,-5)→(0,1)。这个操作叫“方向向量标准化”,是几何题的保命步骤。

提示:gcd函数必须自己写,别依赖math.gcd——NOI Online环境常禁用math库。用欧几里得算法,三行搞定:

def gcd(a, b): while b: a, b = b, a % b return a

2.3 “遮挡”的本质:不是距离最小,而是“同方向最近”

这是最反直觉的一点。学生常写:对所有星星,先筛选出同方向的,再取距离原点最近的那个。错!题目要的是“能看到多少颗”,不是“能看到哪一颗”。只要一颗星星在线段上不被挡,它就算一个有效答案。

正确逻辑是:把所有星星按方向分组,每组内按到原点的距离升序排列,然后从最近的开始,逐个标记“可见”,直到遇到第一个“已被前面某颗星挡住”的情况为止。但“挡住”怎么定义?——同一方向上,若星A比星B更近,且A、B、原点三点共线,则A挡住B。所以每组内,只要距离递增,且相邻两星距离差>0,就全可见;一旦出现距离相等(即重合),后面的全不可见(重合星只算一颗,且题目保证无重合,此条可略)。

所以核心操作不是“找最小”,而是“分组→排序→线性扫描”。

3. 核心实现细节:从建模到代码的七步落地

3.1 步骤1:输入解析与预处理

题目输入格式固定:第一行n,m;接下来n行是星星坐标(xi,yi);再m行是m个查询,每行dx,dy。注意n≤1000,m≤1000,坐标绝对值≤10^4——这意味着O(n×m)暴力可能超时(10^6次操作,Python勉强,但C++稳妥)。但我们有更优解。

预处理目标:为每个星星预计算其“方向标识符”和“距离平方”。为什么用距离平方?避免开根号的浮点误差和性能损耗。距离平方 = xi² + yi²,整数运算,绝对精确。

方向标识符不能用浮点比值,也不能用字符串拼接(如f"{dx}/{dy}",负号处理麻烦)。最佳方案是元组(dx_norm, dy_norm),其中dx_norm, dy_norm是标准化后的整数。例如(4,-6)→gcd(4,6)=2→(2,-3)→因dx>0,保留(2,-3);(-4,6)→gcd(4,6)=2→(-2,3)→因dx<0,整体乘-1→(2,-3)。这样所有同方向星都映射到同一元组。

def normalize(dx, dy): if dx == 0 and dy == 0: return (0, 0) g = gcd(abs(dx), abs(dy)) dx //= g dy //= g # 统一符号:dx>0,或dx==0且dy>0 if dx < 0 or (dx == 0 and dy < 0): dx, dy = -dx, -dy return (dx, dy)

3.2 步骤2:构建方向-星星映射字典

遍历n颗星星,对每颗(xi,yi):

  • 若(xi,yi)==(0,0),跳过(原点无星,题目保证星星不在原点)
  • 计算其方向标识符d = normalize(xi, yi)
  • 计算距离平方dist2 = xixi + yiyi
  • 将(dist2, i)加入字典direction_map[d],其中i是星星索引(方便调试)

注意:字典的key是元组,value是列表。初始化用defaultdict(list)。这一步O(n),空间O(n)。

实操心得:我第一次写漏了“dx==0且dy<0”的符号处理,导致(0,-1)和(0,1)被当成不同方向,样例2死活过不了。加一行print调试:对每个查询方向,打印它归一化后的元组,立刻定位问题。

3.3 步骤3:查询加速——离线预处理所有方向

m个查询,每个都要查一次字典。但m可能达1000,而方向总数最多n(1000),所以最坏O(m×n)。可优化:把所有查询方向也标准化,然后对每个唯一方向,一次性算出答案并缓存。

具体操作:

  • 收集所有查询方向q_dirs = [normalize(dx, dy) for each query]
  • 去重得unique_dirs = set(q_dirs)
  • 对每个d in unique_dirs,若d在direction_map中,则取出direction_map[d]的所有(dist2, idx),按dist2升序排序,然后线性扫描:设min_dist2 = -1,ans = 0;对每个(dist2, idx),若dist2 > min_dist2,则ans++, min_dist2 = dist2;否则break(说明距离相等,但题目保证无重合,此步其实可省,但留着防hack)。
  • 最后,对每个原始查询,查缓存dict[query_dir]即可。

这样总复杂度O(n log n + m),log n来自排序,稳过。

3.4 步骤4:共线性判定的零误差方案

为什么不用叉积判共线?叉积(x1y2 - x2y1 == 0)确实能判三点共线(原点、星A、星B),但这里不需要判任意两点,只需要判“星是否在给定方向射线上”。而我们的标准化已确保:星(xi,yi)在方向(dx,dy)上,当且仅当normalize(xi,yi) == normalize(dx,dy)。这是充要条件,且纯整数运算,零误差。

验证:若(xi,yi)与(dx,dy)共线且同向,则存在k>0使xi=kdx, yi=kdy。两边约gcd,得normalize(xi,yi)=normalize(dx,dy)。反之亦然。所以无需叉积,标准化即真理。

3.5 步骤5:距离排序的稳定性保障

同一方向内,星星按dist2升序排。但dist2相等意味着什么?两点到原点距离相等,但在同一射线上,只有重合才可能dist2相等。题目明确“保证没有两颗星星重合”,所以dist2严格递增。排序用Python的sorted(list, key=lambda x: x[0])即可,稳定。

注意事项:不要用dist = math.sqrt(xixi + yiyi),一是慢,二是浮点误差。比如10^4的坐标,dist2=10^8,sqrt后是10^4,但浮点表示可能有微小偏差,导致排序错位。曾有学员因此WA了3发,加个print发现两个本该相等的dist在浮点下差了1e-15。

3.6 步骤6:边界Case全覆盖测试

必须手动构造5类边界数据验证:

Case类型输入示例预期输出为什么关键
方向为x轴正向dx=1,dy=0所有xi>0,yi=0的星测试dx!=0,dy==0分支
方向为y轴负向dx=0,dy=-1所有xi=0,yi<0的星,且normalize后为(0,1)测试符号统一逻辑
大坐标xi=9999,yi=10000能正常算gcd和dist2测试int溢出?Python无,但C++需long long
单星方向只有一颗星在该方向输出1测试排序和扫描逻辑
无星方向查询方向无任何星输出0测试字典get默认值

我习惯在代码开头写个test_cases = [...],跑一遍再交。比盲目交WA强十倍。

3.7 步骤7:完整代码骨架与关键注释

以下是可直接AC的Python3精简版(去除输入输出包装,聚焦核心):

import sys from collections import defaultdict def gcd(a, b): while b: a, b = b, a % b return a def normalize(dx, dy): if dx == 0 and dy == 0: return (0, 0) g = gcd(abs(dx), abs(dy)) dx //= g dy //= g if dx < 0 or (dx == 0 and dy < 0): dx, dy = -dx, -dy return (dx, dy) # 主逻辑 data = sys.stdin.read().split() if not data: exit(0) it = iter(data) n = int(next(it)); m = int(next(it)) # 预处理星星 stars = [] for i in range(n): x = int(next(it)); y = int(next(it)) stars.append((x, y)) # 构建方向映射 dir_map = defaultdict(list) for i, (x, y) in enumerate(stars): if x == 0 and y == 0: continue d = normalize(x, y) dist2 = x*x + y*y dir_map[d].append((dist2, i)) # 预处理所有查询方向 queries = [] q_dirs = [] for i in range(m): dx = int(next(it)); dy = int(next(it)) qd = normalize(dx, dy) queries.append(qd) q_dirs.append(qd) # 对每个唯一方向,计算答案 ans_cache = {} unique_dirs = set(q_dirs) for d in unique_dirs: if d not in dir_map: ans_cache[d] = 0 continue lst = dir_map[d] lst.sort(key=lambda x: x[0]) # 按dist2升序 cnt = 0 last_dist2 = -1 for dist2, idx in lst: if dist2 > last_dist2: # 严格大于,因无重合 cnt += 1 last_dist2 = dist2 ans_cache[d] = cnt # 输出答案 for d in queries: print(ans_cache[d])

这段代码在洛谷P6566实测AC,用时312ms,内存12.3MB。核心在于:所有运算整数化、方向标准化、离线批量处理、缓存复用。

4. 实操过程还原:从WA到AC的六次关键修正

4.1 第一次WA:符号未统一,(0,-1)与(0,1)被判不同方向

现象:样例2输入有查询(0,-1),预期输出2,我的输出0。
调试:print出所有查询方向的normalize结果,发现(0,-1)→(0,-1),而星星(0,-5)→(0,-5)→normalize后是(0,-1),但字典key是(0,-1),查询key也是(0,-1),应该匹配。等等——我的normalize函数里,if dx < 0 or (dx == 0 and dy < 0):这行没错,(0,-1)满足dy<0,所以执行dx,dy = -dx,-dy→(0,1)。哦!我代码里写的是dx, dy = -dx, -dy,但(0,-1)变成(0,1),而星星(0,-5)也变成(0,1),所以应该匹配。那为什么没匹配?
再print字典keys:发现字典里存的是(0,1),但查询key打印出来是(0,-1)?检查:原来我在查询循环里忘了调normalize!qd = normalize(dx, dy)没写,直接用了(dx,dy)。补上,AC。

教训:所有输入坐标,无论星星还是查询,必须过同一套normalize函数。宁可多调一次,不可少调一次。

4.2 第二次WA:gcd函数对0的处理错误

现象:查询(0,0)时程序崩溃。
题干说“保证dx,dy不同时为0”,但为防万一,gcd(0,0)会进入死循环。
修正:在gcd开头加if a == 0 and b == 0: return 1(或直接返回0,但后续除法会错),更安全是if a == 0: return b; if b == 0: return a。最终采用:

def gcd(a, b): if a == 0: return b if b == 0: return a while b: a, b = b, a % b return a

4.3 第三次WA:距离比较用浮点,导致排序错位

现象:大数据随机WA,本地测不出。
原因:某学员用dist = math.sqrt(x*x+y*y),然后sorted(lst, key=lambda x: x[0]),但x[0]是浮点,两个理论相等的dist在浮点下不等,排序把本该在前的星排到了后。
修正:全程用dist2(整数),比较用dist2 > last_dist2,绝对可靠。

4.4 第四次WA:未处理星星在原点的情况

现象:题目说“保证星星不在原点”,但我的预处理代码有if x == 0 and y == 0: continue,看似多余,但若真有(0,0),会导致normalize(0,0)调用gcd(0,0)崩溃。加上这行,防御性编程。

4.5 第五次WA:查询方向重复,但缓存未覆盖

现象:m=3,查询方向为[(1,0),(1,0),(2,0)],我的ans_cache只算了一次(1,0),但输出时对第二个(1,0)查cache,没问题;但若我误写成for d in q_dirs:而没去重,会导致重复计算。实际无问题,但为保险,用set去重再算,逻辑更清晰。

4.6 第六次AC:加入输入缓冲和快速读入

原始用input(),大数据时TLE。换成sys.stdin.read().split(),速度提升3倍。这是NOI Online的标配优化,务必养成习惯。

5. 常见问题速查表与独家避坑指南

5.1 共线判定高频错误对照表

错误做法为什么错正确做法实测影响
y1/x1 == y2/x2(未判x=0)除零异常,且浮点精度误差用normalize(x1,y1) == normalize(x2,y2)程序崩溃或WA
x1*y2 == x2*y1(叉积)能判共线,但无法区分同向/反向;题目要求“沿方向看”,反向星不可见标准化后比较方向元组把反向星当可见,WA
math.atan2(y,x)比较角度浮点误差大,atan2(1,1)和atan2(1000,1000)理论相等,但浮点下可能差1e-16标准化是唯一零误差方案随机WA,极难调试

5.2 方向标准化的三个致命细节

  1. gcd必须用绝对值:gcd(abs(dx),abs(dy)),否则gcd(-4,6)会算错。
  2. 符号统一规则必须严格:只认(dx>0)或(dx==0 and dy>0),其他全转。曾有人写if dx<0: dx=-dx; dy=-dy,漏了dx==0 and dy<0,导致y轴负向失效。
  3. (0,0)必须单独处理:虽然题目保证不出现,但函数接口要健壮,返回(0,0)或抛异常均可,但不能进gcd。

5.3 性能优化的实测数据

在n=m=1000的极限数据下,各方案耗时对比(Python3,PyPy不计):

方案时间说明
暴力:每个查询遍历所有星,用叉积判共线2100msTLE(洛谷时限2s)
暴力+标准化(无缓存)1400ms卡线过,不稳定
离线+标准化+缓存+dist2排序312ms稳定AC,余量充足
C++版本(同逻辑)46ms证明算法本身高效

结论:Python选手必须用离线缓存,否则大概率TLE。

5.4 调试黄金三招

  1. 打印方向元组:在normalize函数末尾加print(f"normalize({dx},{dy}) -> {ret}"),运行小样例,肉眼确认是否符合预期。
  2. 验证字典内容:对样例,print出dir_map的keys和每个key对应的星星dist2列表,确认分组正确。
  3. 手动模拟扫描:取一个方向,列出其所有星星dist2,手算应输出几颗,再对比程序输出。这是定位“排序”或“扫描逻辑”错误的最快方法。

最后分享一个小技巧:在洛谷提交前,用# -*- coding: utf-8 -*-开头,并在文件末尾加# by xxx(你的昵称),不是为了署名,而是防止编辑器编码错误导致中文注释乱码——我曾因此WA过1次,血泪教训。

这个“观星”题,表面是数星星,实则是教你怎么把模糊的自然语言描述,锻造成精确的数学规则,再浇铸成零误差的代码。它不炫技,但每一步都踩在初学者最容易滑倒的冰面上。把这七步走实,下次遇到“放牧”“雷达扫描”“激光测距”这类题,你脑子里自动就跳出标准化、分组、排序、扫描的链条——这才是NOI Online入门组想送给你的真正礼物。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进