把并集边界拆成水平、竖直两组,用扫描线线段树维护截面上被覆盖的极大区间个数,逐条带累加 2 × 区间数 × 条带宽,总复杂度 O(n log n)。
OJ: roj
题目 ID: 3125
难度:提高
标签:扫描线线段树离散化python
创建: 2026-10-01 18:45
更新: 2026-10-10 11:30
形式化题目
给定
的边界总长度(周长)。
并集的边界是一条分段水平或垂直的闭折线,所以周长可以拆成水平边总长与竖直边总长 两部分分别计算:
本题的规模要求
正解
关键观察
观察 1:边界可以按方向拆开。 边界上的每一小段要么水平、要么竖直,两者的长度只做加法,
互不干扰。所以只要能算清「所有水平边加起来多长」,把整个图形连同坐标轴一起转
观察 2:把水平边按条带积分。 所有矩形的竖直边横坐标只有
记
积分退化成有限和,而且每个条带里的
用一个十字形的例子看得更清楚:横条
并集轮廓(# = 被覆盖的单位格,y 轴向上):
y= 3 ..#..
y= 2 ..#..
y= 1 #####
y= 0 ..#..
01234 <- x沿
| 条带 | 宽 | 截面极大区间数 | 贡献 |
|---|---|---|---|
| 2 | 1 | 4 | |
| 1 | 1 | 2 | |
| 2 | 1 | 4 | |
| 水平边合计 | 10 |
这 10 正好是
观察 3:
减掉的这一项是必要的:只有当两半在交界处都被覆盖时,它们才其实是同一个极大区间,
必须去重。比如把元区间
注意这不是「被覆盖的总长度」。 一个常见的错误写法是维护被覆盖总长 len,用水平边
|Δlen| 的算法必须先加后删,否则会算错。例如矩形
思路
线段树节点。 用线段树维护四个量:
| 量 | 含义 |
|---|---|
cover |
整段被多少个矩形完全盖住 |
seg |
该节点范围内「被盖住的极大区间」个数 |
left_on |
最左边那个元区间是否被盖住 |
right_on |
最右边那个元区间是否被盖住 |
pull 分三种情况:cover > 0 时整段被盖住,seg = 1、两个端标志都为真,
不必看孩子;是叶子(且没被盖住)时 seg = 0;否则按观察 3 的式子合并。
cover 只加在完整覆盖的高层节点上,不需要下传——这是本题不必写 push 的原因,
也是 Python 能跑得动的前提。
建树与查询。 把「下端 / 上端」坐标排序去重,相邻两个坐标之间叫一个元区间,
共有 leaves = len(coords) - 1 个。每个矩形的 cover 加减,
树根上的 seg 就是当前的
扫描。 每个矩形在起点处产生一个
total = 0 ; last = events[0].pos
for (pos, lo, hi, val) in events:
total += 2 * seg[1] * (pos - last) # 先按当前截面结算条带 (last, pos)
last = pos
update(1, 0, leaves-1, lo, hi, val) # 再让 pos 处的事件生效顺序不能反:结算条带时必须还是条带内部的状态,而事件要到条带右端点才起作用。 第一个事件开出的是宽度为 0 的空条带,天然贡献 0;同一位置上的多个事件除第一个外, 条带宽也是 0,所以它们的先后顺序无所谓。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-10 11:30
* update_at: 2026-10-10 11:30
*/
// 周长:沿一个坐标扫描,线段树维护截面上「被盖住的极大区间个数」。
#include <cstdio>
#include <vector>
#include <algorithm>
typedef long long ll;
struct Rect {
ll start, end; // 沿扫描方向的起点 / 终点
ll lo, hi; // 垂直于扫描方向的区间 [lo, hi]
};
struct Event {
ll pos;
ll lo, hi;
ll val;
};
std::vector<ll> COVER; // 每个节点被整段盖住的次数:只记在最高的大节点上,不下传
std::vector<ll> SEGMENT; // 该节点内部「被盖住的极大区间」个数
std::vector<ll> LEFT_ON; // 最左元区间是否被盖住
std::vector<ll> RIGHT_ON; // 最右元区间是否被盖住
ll leaves;
// 按压缩坐标建一棵空树,返回元区间(相邻两个坐标之间)的个数
ll build(std::vector<ll>& coords) {
leaves = (ll)coords.size() - 1;
ll size = 4 * leaves + 4;
COVER.assign(size, 0);
SEGMENT.assign(size, 0);
LEFT_ON.assign(size, 0);
RIGHT_ON.assign(size, 0);
return leaves;
}
// 由两个孩子的覆盖信息重算 node 的区间个数与左右端标志
void pull(ll node, ll nl, ll nr) {
if (COVER[node]) { // 整段被盖住,内部必然只剩一个极大区间
SEGMENT[node] = 1;
LEFT_ON[node] = RIGHT_ON[node] = 1;
} else if (nl == nr) { // 叶子且没被盖住
SEGMENT[node] = 0;
LEFT_ON[node] = RIGHT_ON[node] = 0;
} else {
ll left = node << 1, right = node << 1 | 1;
// 两孩子在交界处都被盖住时,它们其实属于同一个极大区间,要减去这一次重复
SEGMENT[node] = SEGMENT[left] + SEGMENT[right] - (RIGHT_ON[left] & LEFT_ON[right]);
LEFT_ON[node] = LEFT_ON[left];
RIGHT_ON[node] = RIGHT_ON[right];
}
}
// 把元区间 [ql, qr] 的覆盖次数加上 val
void update(ll node, ll nl, ll nr, ll ql, ll qr, ll val) {
if (ql <= nl && nr <= qr) {
COVER[node] += val;
pull(node, nl, nr);
return;
}
ll mid = (nl + nr) >> 1;
if (ql <= mid) update(node << 1, nl, mid, ql, qr, val);
if (qr > mid) update(node << 1 | 1, mid + 1, nr, ql, qr, val);
pull(node, nl, nr);
}
bool event_less(const Event& a, const Event& b) {
if (a.pos != b.pos) return a.pos < b.pos;
if (a.lo != b.lo) return a.lo < b.lo;
if (a.hi != b.hi) return a.hi < b.hi;
return a.val < b.val;
}
// 沿第一个坐标扫描,返回平行于扫描方向的那些边的总长
ll sweep(std::vector<Rect>& rects) {
std::vector<ll> coords;
for (size_t i = 0; i < rects.size(); i++) {
coords.push_back(rects[i].lo);
coords.push_back(rects[i].hi);
}
std::sort(coords.begin(), coords.end());
coords.erase(std::unique(coords.begin(), coords.end()), coords.end());
build(coords);
if (leaves <= 0) return 0; // 连一个元区间都没有,也就没有边
std::vector<Event> events;
for (size_t i = 0; i < rects.size(); i++) {
ll lo = std::lower_bound(coords.begin(), coords.end(), rects[i].lo) - coords.begin();
ll hi = std::lower_bound(coords.begin(), coords.end(), rects[i].hi) - coords.begin();
Event e;
e.pos = rects[i].start; e.lo = lo; e.hi = hi - 1; e.val = 1; events.push_back(e);
e.pos = rects[i].end; e.lo = lo; e.hi = hi - 1; e.val = -1; events.push_back(e);
}
std::sort(events.begin(), events.end(), event_less);
ll total = 0;
ll last = events[0].pos; // 第一个事件开出空条带,(first, first) 宽度为 0
for (size_t i = 0; i < events.size(); i++) {
ll width = events[i].pos - last; // 同一位置上的多个事件之间宽度为 0
// 截面上每个极大区间贡献上下两条平行于扫描方向的边
total += 2 * SEGMENT[1] * width;
last = events[i].pos;
update(1, 0, leaves - 1, events[i].lo, events[i].hi, events[i].val);
}
return total;
}
int main() {
ll n;
if (scanf("%lld", &n) != 1) return 0; // 空输入安全返回
std::vector<Rect> rects(n);
std::vector<Rect> vertical(n);
for (ll i = 0; i < n; i++) {
ll x1, y1, x2, y2;
scanf("%lld %lld %lld %lld", &x1, &y1, &x2, &y2);
if (x1 > x2) { ll t = x1; x1 = x2; x2 = t; }
if (y1 > y2) { ll t = y1; y1 = y2; y2 = t; }
rects[i].start = x1; rects[i].end = x2; rects[i].lo = y1; rects[i].hi = y2;
// 竖直边的总长 = 把矩形转 90° 后再照同一套扫描线求一次
vertical[i].start = y1; vertical[i].end = y2; vertical[i].lo = x1; vertical[i].hi = x2;
}
printf("%lld\n", sweep(rects) + sweep(vertical));
return 0;
}#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 18:45
# update_at: 2026-10-01 18:45
import sys
COVER: list[int] = [] # 每个节点被整段盖住的次数:只记在最高的大节点上,不下传
SEGMENT: list[int] = [] # 该节点内部「被盖住的极大区间」个数
LEFT_ON: list[int] = [] # 最左元区间是否被盖住
RIGHT_ON: list[int] = [] # 最右元区间是否被盖住
def build(coords: list[int]) -> int:
"""按压缩坐标建一棵空树,返回元区间(相邻两个坐标之间)的个数。"""
global COVER, SEGMENT, LEFT_ON, RIGHT_ON
leaves = len(coords) - 1
size = 4 * leaves + 4
COVER = [0] * size
SEGMENT = [0] * size
LEFT_ON = [0] * size
RIGHT_ON = [0] * size
return leaves
def pull(node: int, nl: int, nr: int) -> None:
"""由两个孩子的覆盖信息重算 node 的区间个数与左右端标志。"""
if COVER[node]: # 整段被盖住,内部必然只剩一个极大区间
SEGMENT[node] = 1
LEFT_ON[node] = RIGHT_ON[node] = 1
elif nl == nr: # 叶子且没被盖住
SEGMENT[node] = 0
LEFT_ON[node] = RIGHT_ON[node] = 0
else:
left, right = node << 1, node << 1 | 1
# 两孩子在交界处都被盖住时,它们其实属于同一个极大区间,要减去这一次重复
SEGMENT[node] = SEGMENT[left] + SEGMENT[right] - (RIGHT_ON[left] & LEFT_ON[right])
LEFT_ON[node] = LEFT_ON[left]
RIGHT_ON[node] = RIGHT_ON[right]
def update(node: int, nl: int, nr: int, ql: int, qr: int, val: int) -> None:
"""把元区间 [ql, qr] 的覆盖次数加上 val:val = 1 加矩形,val = -1 删矩形。"""
if ql <= nl and nr <= qr:
COVER[node] += val
pull(node, nl, nr)
return
mid = (nl + nr) >> 1
if ql <= mid:
update(node << 1, nl, mid, ql, qr, val)
if qr > mid:
update(node << 1 | 1, mid + 1, nr, ql, qr, val)
pull(node, nl, nr)
def sweep(rects: list[tuple[int, int, int, int]]) -> int:
"""沿第一个坐标扫描,返回平行于扫描方向的那些边的总长。
rects 每项是 (起点, 终点, 区间下端, 区间上端)。相邻两个扫描位置之间截面是不变的,
该条带上截面里每个极大区间都会有两个端点各扫出一条平行于扫描方向的边,
所以贡献是 2 * 极大区间个数 * 条带宽;把每个条带加起来就是对 2 * 极大区间个数 求积分。
"""
coords = sorted({v for r in rects for v in r[2:]})
leaves = build(coords)
if leaves <= 0: # 连一个元区间都没有,也就没有边
return 0
index_of = {v: i for i, v in enumerate(coords)}
events = sorted(
[(r[0], index_of[r[2]], index_of[r[3]] - 1, 1) for r in rects]
+ [(r[1], index_of[r[2]], index_of[r[3]] - 1, -1) for r in rects]
)
total = 0
last = events[0][0] # 第一个事件开出一条空条带,(first, first) 宽度为 0
for pos, lo, hi, val in events:
width = pos - last # 同一位置上的多个事件之间宽度为 0,不影响结果
total += 2 * SEGMENT[1] * width # 截面上每个极大区间贡献上下两条平行于扫描方向的边
last = pos
update(1, 0, leaves - 1, lo, hi, val)
return total
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
# 题面给的 (x1, y1, x2, y2) 是左下角 / 右上角,这里整理成 (起点, 终点, 下端, 上端)
rects: list[tuple[int, int, int, int]] = []
for _ in range(n):
x1, y1, x2, y2 = next(data), next(data), next(data), next(data)
rects.append((min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2)))
# 竖直边的总长 = 把矩形转 90°(交换 x / y 两个方向)后再照同一套扫描线求一次
vertical = [(lo, hi, start, end) for start, end, lo, hi in rects]
print(sweep(rects) + sweep(vertical))
if __name__ == "__main__":
solve()实现说明:
COVER、SEGMENT、LEFT_ON、RIGHT_ON是模块级数组,build用global一次性重建,避免把大数组当参数在递归里反复传递。- 数组长度取
4 * leaves + 4;leaves = 1时子节点下标也不会越界。 leaves <= 0提前返回 0,覆盖了、 和「所有矩形退化到同一条竖线」等情形, 顺带避免了 events[0]越界。- 读入时用
min/max归一化,题面保证是左下角,这样写不增加成本但更稳。 - 第二趟扫描直接把每个四元组重排成
(lo, hi, start, end),复用同一个sweep。
复杂度
- 时间:坐标排序
; 个事件各做一次 的区间更新,共 。 两趟扫描同阶,总计 。 - 空间:线段树四个数组与坐标表、事件表都是
。 - 实测:题面样例(7 个矩形)输出
228,与标准答案一致;5 组真实数据(最大) 全部一致,总耗时 0.17 s、峰值内存 18.5 MB,在 1000 ms / 128 MB 限制内。
总结
- 求并集周长时,先把边界按方向拆成水平、竖直两组;每组都能写成对截面极大区间个数的积分,
再转
复用同一段代码。 - 关键在于「条带内截面不变」:它把积分变成有限和,也让扫描线上的线段树只需回答 「截面上覆盖了多少段」这一个问题。
- 「极大区间个数」必须靠
cover/seg/left_on/right_on四个量维护, 合并时要减掉交界处的重复;它和「覆盖总长」是两回事,后者会引入同坐标事件排序的陷阱。 cover不需要下传,单次更新,这是 Python 能过掉本题的原因。
图示解析
从题面到答案的完整路线:
题目要求: 并集边界的总长度
|
v
边界按方向拆开: 水平边 P_横 + 竖直边 P_竖 (转 90 度复用同一代码)
|
v
条带积分: P_横 = sum over 条带 [ 2 * c_y * 条带宽 ]
| ^
| |
| c_y = 截面上被覆盖的极大区间个数
v
一维问题: 区间加 1 / 减 1, 随时问「极大区间个数」
|
v
线段树 4 个量: cover / seg / left_on / right_on
|- cover > 0 -> seg = 1, 两端都为真, 不看孩子
|- 叶子且无覆盖 -> seg = 0
`- 否则 -> seg = 左.seg + 右.seg - (左.right_on 且 右.left_on)
|
v
扫描顺序: 先结算条带 (last, pos), 再执行 pos 处的事件 (顺序不能反)图中第一层是目标;第二层是唯一的建模决定——按方向拆解,让问题降一维;第三层是贡献公式,
它把二维几何变成对

