做图形编辑器的人,几乎都会碰到这种尴尬:你画了一个不规则的凹多边形,要填充它的内部,最简单的办法是逐行扫描判断点是否在多边形内,数据量一上来就卡成PPT;做GIS或者芯片版图的人更熟悉这种痛,几千个矩形叠在一起,要算它们的并集面积,两两求交复杂度直接爆炸。这些问题看起来彼此无关,但底层都能统一到一个思路——扫描线算法。
扫描线算法的核心就一句话:用一条假想的直线按顺序“扫”过整个问题空间,在任一时刻只维护“当前线与哪些对象相交”这个状态,把二维问题降成一维问题处理。它没有花哨的数学定理,本质是“排序 + 动态维护 + 增量更新”,但用它来解决多边形填充、矩形面积合并、线段求交、天际线等问题时,能把暴力解法动辄 O(N²) 的复杂度降到 O(N log N)。这篇东西适合所有写过一点算法题、或者工作中需要处理几何计算的人,我会从最朴素的思路开始拆,给出可以直接抄走的代码,再分享一些真正调通这些算法后才知道的坑。
1. 扫描线算法到底在解决什么问题
1.1 核心思想:把二维问题降成一维
先忘掉复杂的公式,想象你要统计一栋楼里哪些房间亮着灯。你不会一层一层把所有房间都看一遍,而是坐在电梯里从下往上走,每到一个楼层只记录“谁进来、谁出去”,最终拼出整个楼的亮灯分布。扫描线算法就是这样:一条水平线从 y 最小值开始,沿着 y 轴方向向上移动,在任何时刻,它只关心“当前水平线和哪些图形的边相交”。这些交点落在一个一维的 x 轴上,于是你可以在这一维区间上做填充、计数、求覆盖长度,再让扫描线往上走一步,重复这个过程。
为什么这个方法快?因为整个二维平面的无穷多个点,被压缩成了有限个“关键事件”。想象一下,扫描线在两条相邻的边之间移动时,交点的数量和顺序其实不会发生任何变化,真正改变状态只发生在扫描线恰好穿过某个顶点或边界的时候。所以算法只需要做两件事:第一,把所有关键位置(顶点、边的上下端点、矩形的上下边)收集起来排序;第二,在两个关键位置之间,只做增量更新,不重头计算。前者负责“离散化”,后者负责“状态维护”,两个动作加起来,整体复杂度就由原来的 O(面积 × 边数) 变成了 O(N log N),N 是几何对象的数量。
我还是拿一个特别典型的场景举例。比如有 N 个矩形,要求它们覆盖的总面积(重叠部分只算一次)。暴力做法是两两求交,对每一对小矩形算交集面积再加加减减,N=5000 时就已经会跑到你怀疑人生。而扫描线的做法是:把每个矩形拆成“下边界”和“上边界”两个事件,扫描线从下往上推进,维护 x 方向被覆盖的区间总长度,当前覆盖长度乘以扫描线走过的垂直距离,就是这一小条的面积,把所有小条面积加起来就是答案。整个过程没有做一次矩形之间的两两求交,靠的是“排序 + 线段树维护覆盖区间”,这就是扫描线算法最迷人的地方。
1.2 三个经典分支:填充、度量、求交
很多人提到扫描线,第一反应是图形学里的多边形填充,但这只是它的一个应用而已。我在实际工程和算法竞赛里,更多是把它分成三个分支来用:
| 分支 | 要解决的问题 | 核心数据结构 | 典型复杂度 |
|---|---|---|---|
| 多边形扫描线填充 | 给任意多边形内部像素上色 | 边表(ET) + 活动边表(AET) | O(Y × N),Y为扫描行数 |
| 扫描线求面积/周长 | 矩形并集面积、区间覆盖长度 | 事件 + 离散化 + 线段树 | O(N log N) |
| 平面扫描求交 | 线段集合的交点检测 | 平衡树 + 优先队列(Bentley-Ottmann) | O((N+K) log N),K为交点个数 |
第一个分支是“逐行”刷,服务器端图形学和软件渲染用得多;第二个分支是我个人认为性价比最高的,学好了一套线段树模板,矩形面积并、周长并、天际线全都能解;第三个分支是计算几何里平面扫描的经典代表,用来找 N 条线段的所有交点,K 是交点数量,在很多碰撞检测、地图叠加分析场景里很实用。你会发现它们都有一个共同的内核:排序决定顺序,一个动态结构维护当前状态,每次只处理“变化点”。
我这篇文章会把前两个分支的完整细节都写到位,第三个分支给一个思路性讲解,因为 Bentley-Ottmann 的代码细节比较多,全铺开会偏题,但思想和前两个一脉相承。下面我们从最经典的多边形填充开始。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多边形填充:扫描线最经典的图形学应用
2.1 边表(ET)与活动边表(AET)是怎么设计的
多边形填充的目标很简单:给定一个由 N 条线段围成的多边形,把多边形内部的像素点全部标记出来。最朴素的做法是“逐点判断”,对每个像素发一条射线,数它与多边形的交点数,奇数就在内部,这就是射线法,复杂度是 O(像素数 × 边数)。扫描线填充的聪明之处在于,它不逐点判断,而是逐行计算“这个 y 高度上,多边形覆盖了哪些 x 区间”。
假设扫描线当前在 y 位置,它穿过了多边形的若干条边,得到若干个交点。由于多边形是封闭的,这些交点按 x 排序后,必然是“入点、出点、入点、出点……”交替出现,所以第一个交点和第二个交点之间的部分在多边形内部,第三个和第四个之间也在内部。问题就变成了两件事:第一,维护“当前 y 下有哪些边可能被穿过”;第二,高效计算这些边与扫描线的交点 x,并且保证排序正确。这就引出了两个结构。
- 边表(Edge Table, ET):以每条边的下端点 ymin 为键,把边挂到对应高度上。它回答的问题是“扫描线走到 y 时,有哪些边新加入了游戏”。
- 活动边表(Active Edge Table, AET):保存当前扫描线真正相交的边,扫描线每往上走一步,就从这个表里删除已经到上端点的边,插入刚进入下端点的新边,然后更新所有边的交点。
这里有一个很精妙的工程优化:边与扫描线的交点 x 不用每次都重新算。因为边是直线,斜率的倒数 k = Δx/Δy 是固定的,扫描线从 y 走到 y+1 时,交点 x 只需要加上这个增量 Δx/Δy。这样每扫描一行,每个活动边只做一次浮点加法,性能直接起飞。
提示:AET 里的“活动边”不是普通的边,它至少要记住三个信息:边的上端点 ymax(用于判断什么时候删掉它)、当前交点 x(用于排序和配对)、增量 dx(用于 y 变化时更新 x)。这就是为什么代码里我会用一个 Edge 类来封装。
2.2 关键原则:“下闭上开”与奇偶规则
如果没有处理顶点,扫描线填充会翻车翻得很惨。你想想,当扫描线经过多边形的一个顶点时,这个顶点连着两条边,两条边同时和扫描线相交,会贡献两个交点。如果这个顶点是多边形的“局部极值点”(比如尖尖的屋顶),在大多数情况下我们希望它只算一次,否则按奇偶规则配对时区间会错位,导致填充区域少一块或多一块。
解决办法就是学界的标准做法:“下闭上开”。每条边在下端点 ymin 处加入活动边表,在上端点 ymax 处直接删除,也就是说 y 等于 ymax 时这条边已经不算数了。这样扫描线经过局部极值点时,由于其中一条边在上端点已经失效,只剩一条边贡献交点,奇偶配对就正常了。对应代码里的判断就是 e.ymax > y 才保留,而不是 >=。
水平边需要单独处理。水平边和扫描线平行,没有单一交点,一般直接跳过,不加入边表。但跳过后要小心,水平边对应的两个端点仍然要参与顶点计数,所以如果你直接把所有水平边删掉,会导致端点缺口,正确的做法是:水平边的左端点让它作为另一条边的下端点正常入表,右端点作为另一条边的上端点正常出表,只是水平边本身不建活动边。实际写代码时,大多数教学实现直接忽略水平边,对凸多边形没问题,凹多边形就可能出现极小概率的边缘瑕疵,我的建议是先处理完别的边,再对水平边所在的特殊行单独填充。
2.3 可直接运行的填充算法实现
下面这个 Python 版是我平时用来验证思路的,故意写得清晰而不是追求极限性能,但所有关键细节都在。
python复制from collections import defaultdict
class Edge:
def __init__(self, ymin, ymax, x_at_ymin, dx):
self.ymin = ymin
self.ymax = ymax
self.x = x_at_ymin # 当前扫描线与边的交点 x,动态更新
self.dx = dx # y 每增加 1 时 x 的增量
def build_edge_table(polygon):
"""把多边形的每条非水平边挂到它的 ymin 对应的桶里"""
et = defaultdict(list)
n = len(polygon)
for i in range(n):
x1, y1 = polygon[i]
x2, y2 = polygon[(i + 1) % n]
if y1 == y2:
continue # 水平边跳过,不参与交点计算
if y1 > y2: # 保证 y1 是下端点
x1, y1, x2, y2 = x2, y2, x1, y1
dx = (x2 - x1) / (y2 - y1)
et[y1].append(Edge(y1, y2, x1, dx))
return et
def scanline_fill(polygon, ymin, ymax, draw_pixel):
et = build_edge_table(polygon)
aet = []
for y in range(ymin, ymax + 1):
# 1. 删除已经到达上端点的边:下闭上开
aet = [e for e in aet if e.ymax > y]
# 2. 加入从当前 y 开始的新边
if y in et:
aet.extend(et[y])
# 3. 按当前 x 排序,确保奇偶配对正确
aet.sort(key=lambda e: e.x)
# 4. 两两配对,填充区间
for i in range(0, len(aet), 2):
x1 = int(aet[i].x + 0.5) # 四舍五入到像素中心
x2 = int(aet[i + 1].x + 0.5)
for x in range(x1, x2 + 1):
draw_pixel(x, y)
# 5. 增量更新:下一行的交点 x
for e in aet:
e.x += e.dx
有几个细节值得拿出来说。第一,先删后加的顺序非常重要,如果你先加入新边再删除过期边,碰到多边形一条边正好在 ymax 处和另一条边在 ymin 处相连的情况,活动边表里会残留一个无效交点,排序后配对就乱了。第二,每次扫描线都重新 sort 一次,这在运行效率上是浪费的,因为 AET 里的边相对于上一行基本是有序的,只有新增的边需要插入有序位置,真正追求性能时可以用插入排序或者维护一个有序链表,但教学版用全排序最不容易出错。第三,draw_pixel 是一个回调函数,你可以把它实现为往帧缓冲写颜色,也可以只是把填充区间存到列表里,方便你验证结果。
复杂度上,逐行扫描的复杂度是 O(Y × N),Y 是多边形在 y 方向上的跨度(像素行数),N 是多边形边数。如果多边形很大但边数很少,这个算法仍然很快;如果多边形很小但边数很多,那其实也不算慢。真正要注意的是如果 Y 特别大(比如几万像素高),并且边也很多,你才需要考虑跳过空白行,只在 y 出现顶点的地方做事件处理,这个优化可以留到你有性能瓶颈的时候再做,不要一开始就过度设计。
3. 扫描线进阶:矩形面积合并与线段求交
3.1 矩形面积合并:事件 + 线段树
多边形填充是“逐行刷像素”,工程上非常直观,但它还不是扫描线算法的高光时刻。扫描线和线段树结合之后,能处理一个看起来很吓人的问题:给你最多 10 万个矩形,坐标范围可能到 10⁹,这些矩形随意重叠,求覆盖的总面积。这类问题在芯片设计(计算各层掩膜版的覆盖面积)、GIS 数据叠加、排料算法里非常常见,也是算法题库里“扫描线”这个标签下最常出现的题型。
思路是这样的:把每个矩形拆成两条水平边:下边 y1 对应的权值是 +1,上边 y2 对应的权值是 -1。扫描线从下往上走,遇到一个下边,就在 x 方向把该矩形覆盖的区间“添加一次覆盖”;遇到上边,就把区间“减少一次覆盖”。任何时候,只要某一段 x 区间被覆盖次数大于 0,就说明这段区间在当前高度上有矩形覆盖。那么面积就是:所有高度区间里,“当前 x 方向覆盖总长度”乘以“这段扫描线走过的垂直距离”,累加起来。
关键难点在于:x 方向可能有 10⁹ 的坐标范围,10 万个矩形,没法开一个横跨整个 x 坐标的数组来记录每个点是否被覆盖。怎么办?两个手段:离散化和线段树。离散化就是只保留所有出现过的 x 坐标值(矩形的左右边),排序去重后,相邻两个 x 坐标构成一个小区间,这些小区间的数量最多 2N 个。用线段树维护这些小区间的覆盖情况,每个区间节点记录两个值:cover 表示这个区间被完整覆盖了几次,length 表示当前实际被覆盖的长度。
这里我直接给出一个能跑通的 Python 实现,注释写得比较细:
python复制class SegTree:
def __init__(self, xs):
self.xs = xs # 离散化后的 x 坐标列表,长度为 m+1
self.m = len(xs) - 1 # 小区间数量
self.cover = [0] * (4 * self.m)
self.length = [0] * (4 * self.m)
def push_up(self, node, l, r):
if self.cover[node] > 0:
self.length[node] = self.xs[r] - self.xs[l]
elif r - l == 1:
self.length[node] = 0
else:
self.length[node] = self.length[node * 2] + self.length[node * 2 + 1]
def update(self, ql, qr, val, node=1, l=0, r=None):
if r is None:
r = self.m
if qr <= l or r <= ql:
return
if ql <= l and r <= qr:
self.cover[node] += val
else:
mid = (l + r) // 2
self.update(ql, qr, val, node * 2, l, mid)
self.update(ql, qr, val, node * 2 + 1, mid, r)
self.push_up(node, l, r)
def rectangle_union_area(rects):
events = []
xs = set()
for x1, y1, x2, y2 in rects:
if x1 > x2: x1, x2 = x2, x1
if y1 > y2: y1, y2 = y2, y1
events.append((y1, x1, x2, 1)) # 下边:添加覆盖
events.append((y2, x1, x2, -1)) # 上边:移除覆盖
xs.add(x1)
xs.add(x2)
xs = sorted(xs)
idx = {x: i for i, x in enumerate(xs)}
events.sort(key=lambda e: e[0])
seg = SegTree(xs)
area = 0.0
prev_y = events[0][0]
for y, x1, x2, val in events:
# 当前覆盖长度乘以高度差,累加面积
area += seg.length[1] * (y - prev_y)
# 更新 x 区间 [x1, x2) 的覆盖次数
seg.update(idx[x1], idx[x2], val)
prev_y = y
return area
这段代码的正确性有几个值得反复检查的关键点。首先是半开区间,update(idx[x1], idx[x2]) 里的右边界是开区间,对应线段树节点区间 [l, r),这样可以避免两个相邻矩形在边界处重复计算长度。你可以试着想象两个左右相邻且恰好贴着的矩形:如果不做半开处理,它们公共边会被重复覆盖,总面积就会算错。其次是 push_up 的逻辑:当一个区间被完整覆盖时,length 直接等于这个区间的总宽度,不需要管子区间;当没有被完整覆盖时,长度由子区间长度相加而来。这其实就是一个没有 lazy 下推的区间覆盖计数模型,因为覆盖次数只增不减(同一矩形先加后减),不需要往子树传播。
注意:代码中
seg.length[1] * (y - prev_y)计算的是“从上一个事件高度到当前事件高度之间的面积”。如果同一高度有多个事件,这里会有高度差为 0 的无效计算,这是无害的。但如果矩形的上下边与其他矩形的上下边完全重叠,事件顺序会影响中间状态的覆盖计数,好在高度差为 0 时面积贡献也是 0,所以不影响结果。
3.2 为什么是扫 y 而不是扫 x
你可能已经发现了,矩形面积合并这里其实用的是“水平方向的扫描线”,也就是沿着 y 方向往上扫,维护 x 方向的覆盖区间。那能不能反过来,用竖直线从左往右扫,维护 y 方向的覆盖区间?当然可以,这两个是完全对称的解法。关键看哪个方向上的离散化更划算。
这里我展开讲讲怎么选。假如你手里的矩形在 x 方向横跨巨大(比如从 -10⁹ 到 10⁹),但在 y 方向高度都不大,那么沿着 y 方向扫描时,y 方向的“事件高度”数量仍然取决于矩形边的数量,而 x 方向的离散化点数取决于矩形左右边的不同坐标数量,两者其实都是 O(N) 量级,理论上没有差别。但在实际工程里,如果矩形的 x 坐标值数量很多,而 y 方向事件数少,竖着扫会导致线段树的区间数量变大,内存和常数都会上升。我个人的习惯是:如果两方向都可以,优先扫“边界数更少”的方向,因为线段树节点数是离散化点数的 4 倍左右,少一个数量级常数就差很多。
还有一个小技巧:如果有大量矩形共享相同的 x 坐标,离散化后的区间数量会非常少,这时线段树几乎是满的但很浅,操作会非常快。反过来,如果你的 x 坐标又密又多,可以考虑用更紧凑的哈希映射或者直接压缩成有序数组的下标,而不是用字典 idx,毕竟 Python 字典的常数比较大。这个问题在 C++ 里通常用 lower_bound 二分解决,Python 里则可以用 bisect_left,在性能敏感的场景建议换成列表加二分。
3.3 线段的平面扫描求交(Bentley-Ottmann 思想)
比矩形面积合并更“几何”的扫描线应用,是线段求交。先描述问题:平面上有 N 条线段,有的互相交叉,找出所有的交点。暴力做法是两两判断,复杂度自然 O(N²),算 10 万条线段直接不现实。Bentley-Ottmann 算法用扫描线把这个复杂度降到了 O((N+K) log N),其中 K 是实际交点数量。
算法的大致框架是:扫描线从 y 最高处往下扫,把所有线段的上端点作为一个事件,下端点作为另一个事件,两条线段在扫描线上“相邻”时可能相交。核心观察是:在一个位置,如果两条线段相交,那么它们在被扫描到交点之前,一定是扫描线当前状态下的相邻线段。因此,扫描线每前进一步,只检测相邻线段是否相交,如果相交就把交点作为事件插入优先队列,并交换这两条线段在扫描线上的上下顺序。这个算法有很多工程上的边界情况要处理,比如三条线段共交于一点、垂直扫描线的线段、扫描线正好经过交点等等,所以标准库实现(比如 CGAL 里的 surface sweep)往往上千行。我自己在算法竞赛里几乎不会手写完整版,真正要用时直接用库,但我还是建议你理解这个“相邻才相交”的思想,它体现的正是扫描线算法的灵魂:不要对所有对象做两两检查,只检查当前状态下的相邻关系。
4. 实战中我踩过的坑:精度、边界与复杂度的取舍
4.1 浮点误差与 EPS:扫描线最容易翻车的地方
扫描线算法对浮点误差特别敏感,这可能是所有刚写几何算法的人没想到的。最典型的坑在活动边表里:交点的 x 是靠增量累加算出来的,e.x += e.dx 每次加一个浮点数,加几十行后误差会累计,导致排序错位。解决的办法有几种:一是尽量用整数坐标,如果原始数据都是整数,分母是整数,那 x 可以表示成“分子 + 分母”的有理数形式,比较时用交叉相乘,代价是代码复杂度上升;二是只在关键事件点重新计算交点而不是累加,比如每处理一个顶点后再从边方程里重算 x;三是接受误差,但给排序和比较加一个 EPS(比如 1e-9)。
我个人的实测经验是:如果数据范围在 10⁴ 以内,直接用 double 累加问题不大;数据范围到 10⁶ 以上,建议改用“每次重新计算”的策略。另外,判断两个 x 坐标是否相等时一定要用 fabs(a - b) < eps,不要用 ==,因为两条边的交点可能在数学上严格相等(共享顶点),但浮点计算出来的结果差一点点,直接等于比较会让你漏掉配对。
4.2 极值点和水平边:多边形填充的专属地狱
多边形填充的调试过程,我愿称之为“奇偶规则最好的老师”。最常见的 bug 是:一个凹多边形或带空洞的多边形填充完后,发现内部有一些不该存在的缝隙,或者有一些不该在内部的像素被填上了。十有八九就是顶点处理出了问题。我调试时一定会做一个动作:打印出所有顶点,手工按 y 排序,然后检查每个顶点两条边在 ET 中的归属——是不是都挂在了较小的 y 上,是不是在较大的 y 上都被删除了。如果某条边把 ymax 误写成 ymin,或者反过来,整个配对就会错位,且错误会沿着扫描线持续传播。
水平边的问题更容易被忽略,因为大多数测试用例都是凸多边形,凸多边形的水平边相对少。我碰到过一个实际案例:一个矩形加一个凸起,凸起的顶边恰好是水平边,第一次写扫描线时把水平边当成普通边加入 AET,发现 dx 是无穷大(分母为 0),程序直接崩了。当时才意识到必须跳过水平边。但如果你只是简单跳过了事,又会出现另一个问题:水平边两端的顶点没有被正确处理,导致水平边所在的那一行填充结果缺了半边。正确的做法我前面说过,水平边本身不进 ET/AET,但它两端的顶点要作为相邻非水平边的端点自然参与“下闭上开”,也就是说构造 ET 时,仍然遍历所有边,只是 y1 == y2 的那条边不建 Edge 对象,其余边不受影响。这样奇偶数据才完整。
4.3 矩形边界重合:覆盖计数不能想当然
在矩形面积合并里,最容易搞错的不是重叠,而是重合并产生“边界接触”。比如两个矩形左右相邻,共享一条竖边;或上下相邻,共享一条横边。如果离散化和线段树用的是闭区间,那共享边会被两个矩形各算一次,总面积就多算了。我给的代码里用半开区间 [ql, qr) 就是为了避免这个。但你要小心的是,半开区间的规则必须贯穿始终:离散化时,右边的点代表下一个区间的左端点,而不是当前区间的右端点;线段树叶子节点 [i, i+1) 对应的是 xs[i] 到 xs[i+1] 这段区间。如果你把右端点理解成“包括 xs[i+1]”,那两个紧挨着的矩形就都会覆盖这个点。
另一个常见问题是同一高度的上下边事件顺序。想象一个矩形 A 的上边和另一个矩形 B 的下边在同一个 y 上,且 x 区间有重叠。在这个 y 处,扫描线要先处理哪个事件,会不会导致中间状态出现“覆盖次数为 0 却有面积”的错误?答案是高度差为 0,所以对面积没有影响,你先处理哪个都行。但如果你在实现里还顺便统计覆盖长度变化或者做其他可视化,那就要规定统一顺序,我一般把 +1 事件放在 -1 事件之前,这样在重合高度上覆盖计数先增加后减少,中间状态看起来更“顺眼”。
4.4 什么时候别用扫描线:复杂度比较与选型
扫描线不是万能的,很多情况下暴力法反而更合适。我给自己定了一个粗糙的选型标准:
| 数据规模 | 推荐方案 | 原因 |
|---|---|---|
| N ≤ 1000,且只做一次性计算 | 暴力 O(N²) | 实现简单,调试成本低,现代计算机跑得动 |
| N ≤ 10万,矩形类区间覆盖 | 扫描线 + 线段树 | 稳定 O(N log N),通用性强 |
| N ≤ 10万,多边形逐像素填充 | 扫描线 ET/AET | 比射线法快一个数量级,适合软件渲染 |
| N 在几百到几千,且需要找所有线段交点 | 调用现成计算几何库 | Bentley-Ottmann 手写坑太多,库更可靠 |
| 数据规模巨大但坐标有规律 | 考虑分治或扫描 + 区间数据结构变体 | 针对具体场景定制 |
有一个普遍误区是,“扫描线 O(N log N) 一定比 O(N²) 快”。实际上当 N 比较小时,扫描线算法的常数会大到让你怀疑人生——离散化、排序、线段树更新,每一步都有相当固定的开销。我做过测试,在 N=500 时,暴力两两求交和扫描线线段树在 Python 里跑出来几乎一样快,有时候暴力还更快。当你面对一个没有明确规模要求的需求时,不要一上来就上扫描线,先用暴力实现验证结果正确性,再决定要不要优化。
我在实际工程里最常遇到的是数据量在五万到二十万之间的矩形合并面积,这个区间用扫描线 + 线段树的收益非常明显。我自己的建议是:如果你刚接触这个算法,从矩形面积合并开始练手,因为它不需要处理多边形那些恶心人的顶点细节,核心就是事件排序 + 线段树;练熟了再回头看多边形填充的 ET/AET,你会觉得“下闭上开”和“奇偶配对”都变得非常自然。扫描线之后还可以延伸出去:区间覆盖问题、离散化 DP、二维树状数组、甚至计算几何里的凸包合并,都能看到它的影子。
