首页 / 资讯中心 / 文章详情

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

NOI入门题‘观星’:方向标准化与遮挡判定的几何模拟 ★ FEATURED ARTICLE
1. 项目概述一道入门组题目的真实分量“观星”这道题标题里带着点诗意但实际是NOI Online #3入门组里一道典型的模拟几何思维边界处理综合题。它不考高深算法不堆数据结构却像一块试金石——能筛出真正理解“问题建模”和“代码落地一致性”的人。我带过几届算法入门班每次讲到P6566总有学生第一反应是“不就是遍历星星坐标吗”结果一写就崩样例过了提交WA改两行又RE再调TLE……最后发现崩的不是代码而是对题干中“可见”“遮挡”“视线方向”这几个词的物理直觉没建立起来。这道题的核心关键词是视线方向向量、单位化处理、共线判断、距离排序、遮挡判定逻辑。它表面在考“怎么数星星”实则在考“你怎么把人眼看到的世界翻译成计算机能严格执行的数学规则”。适合三类人重点参考刚学完二维坐标系的初中生、正在刷洛谷入门题单的新手、以及准备带队打NOI Online的教练——因为它的错误模式太典型几乎覆盖了初学者在几何模拟题里90%的坑。我把它拆成四个硬核模块来复盘不是按“输入-处理-输出”流程走而是按“人脑怎么想→代码怎么错→怎么修→为什么这么修才稳”这个真实调试链路来组织。下面每一节都对应我当年在机房里盯着屏幕改了7版才AC的某个深夜。2. 题目本质解构为什么“观星”不是简单遍历2.1 题干物理模型的三层抽象题目说“从原点出发沿某方向看去能看到多少颗星星” 这句话必须拆成三层第一层物理层人眼在原点视线是一条射线方向由向量(dx, dy)决定。所有星星是平面上的点有些在射线上有些不在。第二层数学层一颗星星能被看到当且仅当它位于该射线上且从原点到它的线段上没有其他星星阻挡。注意是“线段上”不是“射线上”——这意味着离原点更近的星星会挡住更远的。第三层计算层如何用整数/浮点运算无误差地判断“是否共线”“谁更近”“谁挡谁”这才是真正的难点。很多同学卡在第一层就错了以为只要星星坐标满足 y/x dy/dx 就行。但x0怎么办dy0怎么办浮点除法精度怎么控——这些都不是编程技巧问题而是建模时没把物理场景彻底数学化。2.2 关键陷阱方向向量的“单位化”误区题干给的方向是整数对(dx, dy)但绝不能直接用它当方向向量去算比例。举个反例方向(2,4)和(1,2)明明是同一方向但若用y/x判断2/40.51/20.5看似一样可一旦遇到(0,1)和(0,2)x0导致除零错误。更隐蔽的是(1000,2000)和(1,2)浮点除法下0.5和0.5000000001可能被判不等。正确解法是约分后归一化求dx和dy的最大公约数g gcd(|dx|,|dy|)然后得到最简方向向量(dx/g, dy/g)。注意符号统一——我们约定若dx0强制让dx变负、dy变负若dx0且dy0让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 a2.3 “遮挡”的本质不是距离最小而是“同方向最近”这是最反直觉的一点。学生常写对所有星星先筛选出同方向的再取距离原点最近的那个。错题目要的是“能看到多少颗”不是“能看到哪一颗”。只要一颗星星在线段上不被挡它就算一个有效答案。正确逻辑是把所有星星按方向分组每组内按到原点的距离升序排列然后从最近的开始逐个标记“可见”直到遇到第一个“已被前面某颗星挡住”的情况为止。但“挡住”怎么定义——同一方向上若星A比星B更近且A、B、原点三点共线则A挡住B。所以每组内只要距离递增且相邻两星距离差0就全可见一旦出现距离相等即重合后面的全不可见重合星只算一颗且题目保证无重合此条可略。所以核心操作不是“找最小”而是“分组→排序→线性扫描”。3. 核心实现细节从建模到代码的七步落地3.1 步骤1输入解析与预处理题目输入格式固定第一行n,m接下来n行是星星坐标(xi,yi)再m行是m个查询每行dx,dy。注意n≤1000m≤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)→因dx0保留(2,-3)(-4,6)→gcd(4,6)2→(-2,3)→因dx0整体乘-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 # 统一符号dx0或dx0且dy0 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)。实操心得我第一次写漏了“dx0且dy0”的符号处理导致(0,-1)和(0,1)被当成不同方向样例2死活过不了。加一行print调试对每个查询方向打印它归一化后的元组立刻定位问题。3.3 步骤3查询加速——离线预处理所有方向m个查询每个都要查一次字典。但m可能达1000而方向总数最多n1000所以最坏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 -1ans 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)共线且同向则存在k0使xikdx, yikdy。两边约gcd得normalize(xi,yi)normalize(dx,dy)。反之亦然。所以无需叉积标准化即真理。3.5 步骤5距离排序的稳定性保障同一方向内星星按dist2升序排。但dist2相等意味着什么两点到原点距离相等但在同一射线上只有重合才可能dist2相等。题目明确“保证没有两颗星星重合”所以dist2严格递增。排序用Python的sorted(list, keylambda x: x[0])即可稳定。注意事项不要用dist math.sqrt(xixi yiyi)一是慢二是浮点误差。比如10^4的坐标dist210^8sqrt后是10^4但浮点表示可能有微小偏差导致排序错位。曾有学员因此WA了3发加个print发现两个本该相等的dist在浮点下差了1e-15。3.6 步骤6边界Case全覆盖测试必须手动构造5类边界数据验证Case类型输入示例预期输出为什么关键方向为x轴正向dx1,dy0所有xi0,yi0的星测试dx!0,dy0分支方向为y轴负向dx0,dy-1所有xi0,yi0的星且normalize后为(0,1)测试符号统一逻辑大坐标xi9999,yi10000能正常算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(keylambda 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)满足dy0所以执行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)检查原来我在查询循环里忘了调normalizeqd normalize(dx, dy)没写直接用了(dx,dy)。补上AC。教训所有输入坐标无论星星还是查询必须过同一套normalize函数。宁可多调一次不可少调一次。4.2 第二次WAgcd函数对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 a4.3 第三次WA距离比较用浮点导致排序错位现象大数据随机WA本地测不出。原因某学员用dist math.sqrt(x*xy*y)然后sorted(lst, keylambda 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查询方向重复但缓存未覆盖现象m3查询方向为[(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未判x0除零异常且浮点精度误差用normalize(x1,y1) normalize(x2,y2)程序崩溃或WAx1*y2 x2*y1叉积能判共线但无法区分同向/反向题目要求“沿方向看”反向星不可见标准化后比较方向元组把反向星当可见WAmath.atan2(y,x)比较角度浮点误差大atan2(1,1)和atan2(1000,1000)理论相等但浮点下可能差1e-16标准化是唯一零误差方案随机WA极难调试5.2 方向标准化的三个致命细节gcd必须用绝对值gcd(abs(dx),abs(dy))否则gcd(-4,6)会算错。符号统一规则必须严格只认(dx0)或(dx0 and dy0)其他全转。曾有人写if dx0: dx-dx; dy-dy漏了dx0 and dy0导致y轴负向失效。(0,0)必须单独处理虽然题目保证不出现但函数接口要健壮返回(0,0)或抛异常均可但不能进gcd。5.3 性能优化的实测数据在nm1000的极限数据下各方案耗时对比Python3PyPy不计方案时间说明暴力每个查询遍历所有星用叉积判共线2100msTLE洛谷时限2s暴力标准化无缓存1400ms卡线过不稳定离线标准化缓存dist2排序312ms稳定AC余量充足C版本同逻辑46ms证明算法本身高效结论Python选手必须用离线缓存否则大概率TLE。5.4 调试黄金三招打印方向元组在normalize函数末尾加print(fnormalize({dx},{dy}) - {ret})运行小样例肉眼确认是否符合预期。验证字典内容对样例print出dir_map的keys和每个key对应的星星dist2列表确认分组正确。手动模拟扫描取一个方向列出其所有星星dist2手算应输出几颗再对比程序输出。这是定位“排序”或“扫描逻辑”错误的最快方法。最后分享一个小技巧在洛谷提交前用# -*- coding: utf-8 -*-开头并在文件末尾加# by xxx你的昵称不是为了署名而是防止编辑器编码错误导致中文注释乱码——我曾因此WA过1次血泪教训。这个“观星”题表面是数星星实则是教你怎么把模糊的自然语言描述锻造成精确的数学规则再浇铸成零误差的代码。它不炫技但每一步都踩在初学者最容易滑倒的冰面上。把这七步走实下次遇到“放牧”“雷达扫描”“激光测距”这类题你脑子里自动就跳出标准化、分组、排序、扫描的链条——这才是NOI Online入门组想送给你的真正礼物。
阅读完成 · 觉得有帮助?
咨询建站