1783 字
5 分钟
暑期第二场
2026-08-26

扫描线算法学习笔记#

一、扫描线的基本思想#

扫描线(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) 读根.lenO(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 > 0len = total(整段被盖)。
  • cnt = 0len = 左.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;
}

关键细节#

  1. 区间是 [yl, yr-1]:每个下标 i 代表段 [ys[i], ys[i+1]]
  2. 事件 x 相同时的处理:应保证”先处理 +1 还是 -1”符合闭区间语义(见下)。
  3. 叶子存 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 条线段(端点都在长方体表面),求与坐标轴垂直的任意平面最多能同时相交多少条线段。

思路

  1. 平面只三种方向:垂直 X / Y / Z 轴,分别处理取最大值。
  2. 对每个维度,把每条线段投影成该维坐标上的闭区间 [coord₁, coord₂]
  3. 用扫描线求最大重叠数(左端点 +1,右端点 -1,扫一遍取 cnt 峰值)。
  4. 三个维度各自扫一遍,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=5
for (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)。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

暑期第二场
https://caoyue.xin/posts/算法学习/暑期第二场/
作者
Colton/曹越
发布于
2026-08-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录