扫描线算法学习笔记
一、扫描线的基本思想
扫描线(Scanline)的核心:用一条假想的线(通常是竖直线)从左到右扫过平面,把二维问题降成一维。
- 扫描线沿 x 轴运动,每停在一个事件点(矩形的左右边界),处理一次。
- 与扫描线垂直的 y 方向,维护”当前被覆盖的总长度”。
- 面积 = Σ(相邻两次 x 的距离 × 当时的覆盖长度)。
一句话:从左到右切片,每片用数据结构算 y 方向被盖了多长,乘起来就是面积。
二、核心机制:事件(Event)
每个矩形拆成两个事件:
| 端点 | 类型 | 含义 |
|---|---|---|
| 左边界 x = x₁ | +1 | 矩形进入,覆盖开始 |
| 右边界 x = x₂ | -1 | 矩形退出,覆盖结束 |
事件按 x 坐标排序,扫描线依次处理。
关键顺序
到了一个事件点: 1. 先算面积:(event.x - last_x) × 当前覆盖长度 2. 再更新:把该事件对应的 y 区间 cnt ±1 3. last_x = event.x为什么这个顺序? 因为覆盖长度反映的是”从上一个 x 到本次 x”这段区间的情况,中间没有其他事件,覆盖没变过。顺序反了会多算或少算。
三、y 方向的维护方式
1. 离散化
y 坐标范围可能很大(如 1e9),但用到的只有 2n 个(每个矩形两个 y 边界)。
- 收集所有 y 坐标 → 排序去重 → 映射成紧凑下标 1..M。
- 相邻两个下标之间就是一段,每段有固定物理长度
len[i] = ys[i+1] - ys[i]。 - 区间
[y1, y2]在离散化后对应下标[y1_idx, y2_idx - 1](注意 -1)。
2. 三种维护方法对比
| 方法 | 更新 cnt | 算覆盖长度 | 单次复杂度 | 总复杂度 |
|---|---|---|---|---|
| 暴力数组 | O(M) | O(M) 遍历 | O(M) | O(nM) |
| 线段树 | O(log M) | O(1) 读根.len | O(log M) | O(n log M) |
| 差分数组 | 不适用 | 需前缀和,无优势 | — | — |
离散化是一次性预处理 O(M log M),扫描要跑 2n 次,所以优化的是反复执行的部分。
四、暴力数组版(离散化 + 直接维护 cnt)
// 离散化后 M 段,cnt[i] 记录第 i 段被几个矩形覆盖vector<int> cnt(M + 1, 0);vector<int> len(M + 1);for (int i = 1; i <= M; i++) len[i] = ys[i] - ys[i-1];
for (auto& e : events) { // 1. 先算面积 long long cover = 0; for (int i = 1; i <= M; i++) if (cnt[i] > 0) cover += len[i]; ans += (e.x - last_x) * cover;
// 2. 再更新 cnt for (int i = e.yl; i < e.yr; i++) cnt[i] += e.type;
last_x = e.x;}- 本质:动态加入/删除区间,每时每刻求并集长度。
- 适用:M 较小(n 小)时简单好写;M 大时必须用线段树。
五、线段树版
1. 每个节点存的信息
struct Node { int cnt; // 该区间被几个矩形"完整覆盖" int len; // 当前实际被覆盖的长度 int total; // 区间总长度(建树时算好,不变)};2. push_up 核心逻辑
void push_up(int u, int l, int r) { if (tr[u].cnt > 0) tr[u].len = tr[u].total; // 整段被盖,全长 else if (l == r) tr[u].len = 0; // 叶子,没人盖 else tr[u].len = tr[u<<1].len + tr[u<<1|1].len; // 看孩子汇总}要点:
cnt > 0→len = total(整段被盖)。cnt = 0→len = 左.len + 右.len(不表示没被覆盖,而是自己没被完整盖,要看孩子)。- 不需要 pushdown / 懒标记:cnt 表示”被完整覆盖的次数”,区间更新打到某个节点就停,不往下传;不同矩形打到不同节点,互不影响。
3. 为什么 cnt=0 还要看孩子?
父节点 cnt=0 只说明”没有矩形能一手遮天盖满整个区间”,但不同矩形可以各盖各的,分别留在不同孩子的 cnt 里。所以必须汇总孩子的 len。
cnt 是”被打到才变”(由事件直接修改),不是从孩子加总来的;len 才是汇总出来的。
4. 复杂度
- 建树 O(M),每次事件更新 O(log M),查询 O(1) 读根.len。
- 总复杂度 O(n log M)。
六、完整线段树代码模板
#include <bits/stdc++.h>using namespace std;
struct Node { int cnt, len, total;} tr[800010];
struct Event { int x, type, yl, yr; bool operator<(const Event& e) const { return x < e.x; }};
vector<int> ys;int M;
void build(int u, int l, int r) { if (l == r) { tr[u].total = ys[l+1] - ys[l]; return; } int mid = (l + r) >> 1; build(u<<1, l, mid); build(u<<1|1, mid+1, r); tr[u].total = tr[u<<1].total + tr[u<<1|1].total;}
void push_up(int u, int l, int r) { if (tr[u].cnt > 0) tr[u].len = tr[u].total; else if (l == r) tr[u].len = 0; else tr[u].len = tr[u<<1].len + tr[u<<1|1].len;}
void update(int u, int l, int r, int ql, int qr, int type) { if (ql <= l && r <= qr) { tr[u].cnt += type; push_up(u, l, r); return; } int mid = (l + r) >> 1; if (ql <= mid) update(u<<1, l, mid, ql, qr, type); if (qr > mid) update(u<<1|1, mid+1, r, ql, qr, type); push_up(u, l, r);}
int main() { int n; cin >> n; vector<Event> events; for (int i = 0; i < n; i++) { int x1, y1, x2, y2; cin >> x1 >> y1 >> x2 >> y2; ys.push_back(y1); ys.push_back(y2); events.push_back({x1, +1, y1, y2}); events.push_back({x2, -1, y1, y2}); }
// 离散化 sort(ys.begin(), ys.end()); ys.erase(unique(ys.begin(), ys.end()), ys.end()); M = ys.size() - 1; auto get_id = [&](int y) { return lower_bound(ys.begin(), ys.end(), y) - ys.begin(); }; for (auto& e : events) { e.yl = get_id(e.yl); e.yr = get_id(e.yr); } sort(events.begin(), events.end());
build(1, 0, M - 1); long long ans = 0, last_x = 0; for (auto& e : events) { ans += (long long)(e.x - last_x) * tr[1].len; update(1, 0, M - 1, e.yl, e.yr - 1, e.type); last_x = e.x; } cout << ans << endl; return 0;}关键细节
- 区间是
[yl, yr-1]:每个下标 i 代表段[ys[i], ys[i+1]]。 - 事件 x 相同时的处理:应保证”先处理 +1 还是 -1”符合闭区间语义(见下)。
- 叶子存
ys[l+1] - ys[l]:每段长度。
七、常见坑点总结
1. 坐标相同时的排序规则(重要!)
对于求最大重叠数 / 矩形周长等问题,同一坐标上 +1 和 -1 的先后顺序会影响结果。
- 面积并:
(x, +1)和(x, -1)谁先谁后通常不影响(靠 dx 乘 len)。 - 最大重叠数(点被多少区间覆盖):要让同一坐标上先加后减还是先减后加取决于语义:
- 闭区间
[L, R]在端点处应被计入 → 想让cnt在同一坐标上能叠加,需要+1排在-1前。
- 闭区间
bool cmp(pair<int,int>& u, pair<int,int>& v) { if (u.first != v.first) return u.first < v.first; // 坐标升序 return u.second > v.second; // 坐标相同:+1 在前,-1 在后}// 用法:sort(s.begin(), s.end(), cmp);写错了会导致同一坐标上旧线段先退、新线段才进,cnt 漏计重叠。
2. 离散化区间端点
- 更新时用
[yl, yr-1],不是[yl, yr]。 - 建树叶子长度用
ys[i+1] - ys[i]。
3. 全局变量冲突
用结构化绑定 for (auto [pos, type] : s) 时,变量名别和全局变量(如 a, b, c)重名,否则行为异常。
4. maxn 初始值
- 求最大重叠数:n ≥ 1 时最少为 1,可初始化为 0 或 1(视是否允许”无覆盖”而定)。
- 若答案为 0 是合法的(如允许空),则初始化为 0。
八、实战例题:三维线段最大相交数
题意:给定 n 条线段(端点都在长方体表面),求与坐标轴垂直的任意平面最多能同时相交多少条线段。
思路:
- 平面只三种方向:垂直 X / Y / Z 轴,分别处理取最大值。
- 对每个维度,把每条线段投影成该维坐标上的闭区间
[coord₁, coord₂]。 - 用扫描线求最大重叠数(左端点 +1,右端点 -1,扫一遍取 cnt 峰值)。
- 三个维度各自扫一遍,ans = max。
#include <bits/stdc++.h>using namespace std;
int n, a, b, c;struct node { int x1[100005], x2[100005];} p[3];
bool cmp(pair<int,int>& u, pair<int,int>& v) { if (u.first != v.first) return u.first < v.first; return u.second > v.second; // +1 在前,-1 在后}
int getmax(node *arr) { vector<pair<int, int>> s; for (int i = 1; i <= n; i++) { if (arr->x1[i] > arr->x2[i]) swap(arr->x1[i], arr->x2[i]); s.push_back({arr->x1[i], 1}); s.push_back({arr->x2[i], -1}); } sort(s.begin(), s.end(), cmp); int cnt = 0, maxn = 0; for (auto [pos, type] : s) { cnt += type; maxn = max(maxn, cnt); } return maxn;}
int main() { cin >> n >> a >> b >> c; for (int i = 1; i <= n; i++) { // 每行:x1 y1 z1 x2 y2 z2 cin >> p[0].x1[i] >> p[1].x1[i] >> p[2].x1[i] >> p[0].x2[i] >> p[1].x2[i] >> p[2].x2[i]; } // p[0] 存所有线段的 x 坐标,p[1] 存 y,p[2] 存 z int ans = max({getmax(&p[0]), getmax(&p[1]), getmax(&p[2])}); cout << ans; return 0;}注意:p[d] 存的是第 d 维的坐标对,getmax(&p[d]) 传指针(数组元素取地址 &p[0] 才是 node*,p[0] 本身是 node 对象不会自动转指针)。更推荐用引用 int getmax(node& arr) 避免指针混淆。
九、C++ 语法备忘
1. -> 与 .
| 写法 | 用在 | 含义 |
|---|---|---|
obj.x | 对象本身 | 直接访问成员 |
ptr->x | 指针 | 等价于 (*ptr).x |
2. auto 结构化绑定
pair<int, int> p = {3, 5};auto [x, y] = p; // C++17,x=3, y=5for (auto& [k, v] : mp) { v = 10; } // 加 & 可修改原值3. 数组 vs 结构体作为参数
- 裸数组:作为参数时退化为指针,只能传地址(无法传值拷贝整个数组)。
- 结构体:是完整类型,可传值、传引用、传指针。
p是数组名 → 退化成node*;p[0]是结构体对象 → 不会退化,传参需&p[0]或把参数改为node&。
十、复杂度与适用场景
| 场景 | 推荐方法 | 复杂度 |
|---|---|---|
| n 小、M 小 | 离散化 + 暴力数组 | O(nM) |
| n 大(1e5) | 离散化 + 线段树 | O(n log n) |
| 只求面积/长度 | 扫描线 + 线段树 | O(n log n) |
| 求最大重叠数 | 扫描线 + 排序 + (线段树/数组) | O(n log n) / O(nM) |
核心思想回顾
扫描线负责降维(x 方向顺序处理事件),离散化负责压缩坐标,线段树/数组负责动态维护 y 方向的覆盖并集长度。
cnt 由事件驱动(±1),len 由 cnt 推导(cnt>0 取 total,否则汇总孩子)。
顺序:先结账(算面积),再改账本(更新 cnt)。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
