扫描线算法——就像用尺子扫过平面
困难5扫描线算法:从一维到二维,轻松算出重叠面积
你和小伙伴们用长方形卡纸叠在一起拼图,每张卡纸可能互相遮挡。你想知道所有卡纸总共覆盖了多少面积(重叠部分只算一次)。如果一张一张地加,再减去重叠部分,会非常麻烦,尤其当卡纸数量多、形状各不相同时。这时候,扫描线算法就像一把神奇的“尺子”,从左向右慢慢扫过整个平面,每到一条竖边就停下来“量一量”,最后轻松算出总面积。
扫描线算法的核心思想是把二维问题转化成一系列一维问题,通过维护当前y方向被覆盖的总长度,乘以移动的x距离,累加出面积。
一、先热个身:一维扫描线——统计时间段的并集
想象学校有三个社团的活动时间(可能重叠),你想知道总共有多长的时间有活动(重叠部分只算一次)。比如:
- 足球社:14:00 ~ 16:00
- 书法社:15:30 ~ 17:30
- 街舞社:16:00 ~ 18:00
你可以把每个时间段的开始和结束看作事件点,从左到右扫描。用一个计数器 cnt 记录当前有多少个活动在进行中。开始时 cnt = 0。遇到开始事件,cnt 加1;遇到结束事件,cnt 减1。每当 cnt > 0 时,说明这个时间段有活动,累加长度。
时间轴:14:00 15:30 16:00 17:30 18:00
事件: 开始 开始 结束 结束 结束 开始(16:00街舞开始)
(足球) (书法) (足球) (书法) (街舞)
扫描过程:
- 14:00:cnt=1,从14:00到15:30(下一个事件点)长度=1.5小时,累加。
- 15:30:cnt=2,从15:30到16:00长度=0.5,累加。
- 16:00:先处理结束(足球)?注意顺序。如果同一点有多个事件,要小心。通常先处理所有开始,再处理结束,或者统一规则。我们这里简单假设先处理开始,后处理结束。实际上,对于面积并,通常先计算面积再更新,避免同一x重复计算。
- 16:00:街舞开始 → cnt=3;足球结束 → cnt=2。区间16:00~17:30长度1.5累加。
- 17:30:书法结束 → cnt=1,区间17:30~18:00长度0.5累加。
- 18:00:街舞结束 → cnt=0。
总长度 = 1.5+0.5+1.5+0.5=4小时。这个一维的例子就是你理解二维扫描线的钥匙。
二、从一维到二维:矩形面积并
现在回到二维:每个矩形有左右两条竖边(左边界和右边界),我们把这些边按x坐标排序,从左向右移动。移动过程中,用一棵线段树(或简单数组)维护y轴上当前被覆盖的总长度。遇到左边,就在y区间上+1(表示覆盖);遇到右边,就-1(表示移出)。每次移动到下一个x位置,累加的面积 = 当前覆盖的总长度 × (下一个x - 当前x)。
关键步骤就像做手工:
- 拆边:每个矩形拆成左边界(进入)和右边界(离开),记录x坐标,y区间下界y1和上界y2,以及类型(1进入,-1离开)。
- 离散化y坐标:因为y可能很大(比如0到10^9),不能直接开数组。我们把所有出现的y坐标收集起来排序、去重,然后把每个矩形的y1、y2映射到离散化后的索引。
- 排序边:所有竖边按x坐标从小到大排序。
- 扫描:维护覆盖次数数组
cover(大小为离散化后的y区间数-1),记录每一个小区间被覆盖的次数。从左到右遍历边,先根据当前cover计算y方向总覆盖长度(覆盖次数>0的区间长度和),再乘以本次移动的x距离,累加到面积中;然后更新区间覆盖(将当前竖边对应的y区间加上type)。 - 注意边界:通常用左闭右开区间,比如矩形y从y1到y2,我们只覆盖[y1, y2-1]这些整数段(如果坐标是离散点,则覆盖区间的索引)。离散化后,相邻y坐标之间的长度就是
y[i+1] - y[i]。
三、新手容易掉进的坑
1. 忘记离散化或离散化出错
如果不离散化,直接拿y坐标当数组下标,会超界或浪费内存。离散化时要小心:映射关系要一一对应,且覆盖区间要用左闭右开。
2. 区间覆盖的边界处理
比如矩形 y1=1, y2=3。如果用数组cover表示从y到y+1的区间,那么需要更新索引1和2(即y1到y2-1)。如果写成for(y=y1; y<=y2; y++)会多算一个单位,导致面积偏大。
3. 扫描时x相同的情况
如果多个竖边在同一x坐标(比如两个矩形共边),顺序很重要。通常先计算面积,再更新覆盖,否则会在移动距离为0时错误累加。或者处理完所有同一x的边后统一更新。标准做法:遍历边时,当x改变时才计算面积;对于相同x的边,先更新覆盖,然后下一次遇到新x时计算面积。这样可以避免重复计算。
4. 数据类型溢出
坐标范围可能很大(10^9),矩形数量多(10^5),面积可能超过int范围,记得用long long。
四、完整可运行代码示例(含离散化)
下面给出一个完整的C++代码,假设矩形坐标是整数,使用离散化+数组维护覆盖长度(适用于矩形数量较少时,比如y离散化后不超过1000)。代码中每行变量都加了中文注释,方便理解。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 定义竖边结构体
struct Line {
int x; // 竖边的x坐标
int y1, y2; // 竖边的y下界和上界(原始坐标)
int type; // 左边界为1(进入),右边界为-1(离开)
};
// 按x坐标从小到大排序
bool cmp_by_x(Line a, Line b) {
return a.x < b.x;
}
int main() {
// 三个矩形: (x1,y1)左下角 (x2,y2)右上角
vector<vector<int>> rects = {
{1, 1, 4, 4},
{2, 2, 6, 5},
{3, 0, 5, 3}
};
// 1. 收集所有y坐标,用于离散化
vector<int> y_vals;
for (auto r : rects) {
y_vals.push_back(r[1]); // y1
y_vals.push_back(r[3]); // y2
}
// 排序并去重
sort(y_vals.begin(), y_vals.end());
y_vals.erase(unique(y_vals.begin(), y_vals.end()), y_vals.end());
// 2. 将每个矩形拆成左右两条竖边,同时把y坐标映射到离散化后的索引
vector<Line> lines;
for (auto r : rects) {
int y1_idx = lower_bound(y_vals.begin(), y_vals.end(), r[1]) - y_vals.begin(); // 下界索引
int y2_idx = lower_bound(y_vals.begin(), y_vals.end(), r[3]) - y_vals.begin(); // 上界索引
// 左边界
lines.push_back({r[0], y1_idx, y2_idx, 1});
// 右边界
lines.push_back({r[2], y1_idx, y2_idx, -1});
}
// 3. 按x坐标排序所有竖边
sort(lines.begin(), lines.end(), cmp_by_x);
// 4. 准备覆盖数组:覆盖次数记录在每个离散化区间上(区间数 = y_vals.size()-1)
int n_y = y_vals.size(); // y坐标个数
vector<int> cover(n_y - 1, 0); // cover[i] 表示第i个小区间(y_vals[i]~y_vals[i+1])被覆盖的次数
long long total_area = 0; // 总面积,用long long防止溢出
int prev_x = lines[0].x; // 上一个处理的x坐标
// 5. 开始扫描
for (Line &line : lines) {
// 先计算从prev_x到当前x之间矩形的面积
int x_len = line.x - prev_x;
if (x_len > 0) {
// 计算当前y方向被覆盖的总长度
int covered_len = 0;
for (int i = 0; i < n_y - 1; i++) {
if (cover[i] > 0) {
// 区间长度 = y_vals[i+1] - y_vals[i]
covered_len += y_vals[i+1] - y_vals[i];
}
}
total_area += (long long)covered_len * x_len; // 累加面积
}
// 更新覆盖:在区间 [y1_idx, y2_idx-1] 上加type
for (int i = line.y1; i < line.y2; i++) { // 注意是左闭右开
cover[i] += line.type;
}
prev_x = line.x; // 更新上一个x
}
// 输出结果
cout << "总面积并 = " << total_area << endl; // 预期输出13
return 0;
}
这段代码可以直接拷贝运行,结果应该是13,你可以自己验证。
五、拓展:当矩形数量很多时——线段树登场
上面用数组维护覆盖长度,每次更新和查询都要遍历所有y区间,时间复杂度是O(N × M),其中M是离散化后的区间数,矩形数量N增加时效率很低。竞赛中常用线段树来维护覆盖次数和覆盖长度,支持区间加(+1/-1)和区间查询(当前覆盖长度),复杂度可以降到O(N log N)。
线段树每个节点存储两个信息:
cnt:该区间被完整覆盖的次数(不向下传递)。len:该区间中覆盖长度大于0的总长度。
当 cnt > 0 时,len = 整个区间长度;否则,len = 左右子节点的 len 之和。
这样,每次更新一个区间(加1或减1)后,根节点的 len 就是当前y方向被覆盖的总长度。扫描时只需查询根节点的 len 即可,无需遍历。
学习线段树是掌握高效扫描线算法的关键。
六、相关知识点指引
- 离散化:把连续的坐标映射成小范围的整数,减少空间消耗。在扫描线中几乎必须使用。
- 线段树:用于动态维护区间覆盖问题,是扫描线加速的利器。
- 事件驱动思想:扫描线算法本质是事件驱动(遇到进入事件操作,遇到离开事件操作),这种思想在解题中非常常见,比如区间调度、窗口滑动等。
从卡纸拼图到复杂几何计算,扫描线算法让我们可以用“一把尺子”优雅地解决问题。掌握了它,二维图形面积、周长等难题都会变得简单起来。
例题精讲
在扫描线算法中,对于事件点(矩形的左右边)的排序规则通常是什么?
扫描线算法只能用于计算矩形的面积并,不能用于计算矩形的周长并。
以下是使用扫描线算法求矩形面积并的C++代码片段(使用线段树维护当前覆盖长度)。请补全"更新当前覆盖长度"的线段树操作函数。
struct SegTree {
int cnt; // 被完全覆盖的次数
double len; // 当前区间被覆盖的长度
} tree[N * 8];
void pushup(int p, int l, int r) {
if (tree[p].cnt > 0) {
tree[p].len = ___(1)___;
} else {
tree[p].len = ___(2)___;
}
}在扫描线算法中,通常使用什么数据结构来高效维护当前扫描线在y方向上的覆盖长度?
扫描线算法处理矩形面积并问题时,时间复杂度为O(n log n),其中n为矩形数量。