###[$P5490$ 【模板】扫描线](https://www.luogu.com.cn/problem/P5490) ### 一、题目描述 求 $n$ 个四边平行于坐标轴的矩形的面积并。 #### 输入格式 第一行一个正整数 $n$。 接下来 $n$ 行每行四个非负整数 $x_1, y_1, x_2, y_2$,表示一个矩形的四个端点坐标为 $(x_1, y_1),(x_1, y_2),(x_2, y_2),(x_2, y_1)$。 #### 输出格式 一行一个正整数,表示 $n$ 个矩形的并集覆盖的总面积。 **样例输入** ```cpp {.line-numbers} 2 100 100 200 200 150 150 250 255 ``` **输出样例** ```cpp {.line-numbers} 18000 ``` #### 提示 对于 $20\%$ 的数据,$1 \le n \le 1000$。 对于 $100\%$ 的数据,$1 \le n \le {10}^5$,$0 \le x_1 < x_2 \le {10}^9$,$0 \le y_1 < y_2 \le {10}^9$。 由于都是矩形,因此运用扫描线以后,面积的求法其实可以简化为 $\sum$截线段长度×扫过的高度。这也是扫描线算法最基础的应用。 考虑以下这个简单的例子。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231347853.png) 问题在于如何才能模拟扫描线从下向上扫过图形,并且快速计算出当前扫描线被截得的长度。 现在假设,扫描线每次会在碰到横边的时候停下来,如图。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231348012.png) 简单来说,可对图形面积产生影响的元素,也就是这些横边左右端点的坐标。 为了快速计算出截线段长度,可以 **将横边赋上不同的权值**,具体为:对于一个矩形,其下边权值为$1$,上边权值为$−1$。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231349157.png) 然后把所有的横边按照$y$坐标升序排序。这样,对于每个矩形,扫描线总是会先碰到下边,然后再碰到上边。那么就能保证扫描线所截的长度永远非负了。 这样操作以后,就可以和线段树扯上关系。先把所有端点在$x$轴上离散化(其实就是把所有点的横坐标存到$X[]$里,然后升序排个序,最后去重)。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231350215.png) 在这个例子中,$4$个端点的纵投影总共把$x$轴切割成了$5$部分。取中间的$3$部分线段,建立一棵线段树,其每个端点维护 **一条线段**(也就是一个区间)的信息: 1. 该线段被覆盖了多少次(被多少个矩形所覆盖)。 2. 该线段内被整个图形所截的长度是多少。(有效长度) ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231351743.png) 显然,只要一条线段被覆盖,那么它肯定被图形所截。所以,整个问题就转化为了一个 **区间查询问题**,即:每次将 当前扫描线扫到的边 对应的信息 按照之前赋上的权值更新,然后再查询线段树根节点的信息,最后得到当前扫描线扫过的面积。这就可以用线段树来实现了(毕竟人家叫 **线段** 树嘛)。 #### 下面是模拟的过程: ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231355983.png) ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231355709.png) 注:上图的$5$号节点的$sum=2$,绿色部分表示已计算的面积。 ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231357022.png) ![](https://dsideal.obs.cn-north-1.myhuaweicloud.com/HuangHai/BlogImages/{year}/{month}/{md5}.{extName}/202308231357011.png) 还剩下一个棘手的问题: $Q1$:线段树到底该怎么建?保存什么信息?怎么在节点间传递信息? 这里我使用自己最习惯的线段树写法,个人感觉这样的逻辑最清晰。 先看下面的建树过程: ```cpp {.line-numbers} void build(int u, int l, int r) { tr[u]={l,r};//这个可以清空其它统计信息,属性信息等 //tr[u].l = l, tr[u].r = r;//这个就不能清空了,如果是多组测试数据,记得要memset(tr,0,sizeof tr); 否则就会WA,我不告诉你我是怎么知道的~ if (l == r) return; int mid = (l + r) >> 1; build(u << 1, l, mid); build(u << 1 | 1, mid + 1, r); } ``` 我们已经知道,这棵 **线段树的每个节点都对应了一条线段**。考虑 **将线段树上节点对应的区间和横边** 建立 **映射关系**。先看对于一个叶子节点$x$,建树时保证了$tr[u].l=tr[u].r$,但其保存的信息很显然不可能只是某条线段的一个端点(如果一条线段的两个端点重合,那么它实质上仅是一个点)。再看一个节点的左右儿子,同样地,建树的时候已经保证了左右儿子的区间不会重合(交集为空),但是看这样两条相邻线段:$[1,2],[2,3]$,你会发现 $[1,2]∩[2,3]=\{2\}$,也就是说左儿子的右端点和右儿子的左端点其实是重合的。所以如果想得太简单,就$gg$了。 > **注**:`GG`,竞技游戏礼貌用语,`Good Game`的缩写,来源于韩国星际比赛,指在竞技游戏(如魔兽争霸、星际争霸、反恐精英、`DOTA`、`LOL`等 )中,纯粹就是习惯性的向对手表示欣赏,类似于比武结束后的行礼,意思是“打得好,我认输”。后来也常用于现实生活中表示“失败”的场景。也就是我输了,完蛋了的意思~ 考虑把线段树每个节点$x$对应的区间($tr[u].l,tr[u].r$)不变,改变区间和横边的映射关系, 具体为:节点$x$对应$[X[tr[u].l],X[tr[u].r+1]]$这条横边。可以看到,这里很机智地把右端点的对应关系给改了下,于是就兼容了。 >**注意**: 变量名 $y_1$ 在``库里头有定义,会冲突,所以不开$bits$库就好。 #### $Q2$:线段树里保存的是什么?用来干什么? 答:线段树只是一个基础的数据结构,它的作用是可以以比较快的时间获取到区间的某些统计和。 在扫描线中,通过预先的按$x$由小到大排序,然后从左向右进行扫描,遇到一条竖线时,获取到它的$x$坐标 和$y_1,y_2$ 两个坐标 通过`line[i + 1].x - line[i].x`就可以提前知道本竖线与下一条竖线间的距离是多少,也就是一个小矩形的宽,而高是$y_2-y_1$。 但是,我们遇到的不一定就是一条竖线时,只有一段,可能是多段,那么,这个有效的$len$就可能是多条竖线的有效长度叠加和。 如何快速计算这个有效长度的叠加和呢?答案就是用线段树。 **总结**:线段线中保存的是$y$轴的竖的小线段! $y$轴上的数据范围可是很大的,最大$1e9$,直接原封不动的将$y$轴坐标映射到线段树中,肯定$GG$,需要进行离散化。 那么只进行了离散化就行了吗?离散化不是万能的,它只能解决将范围大、稀疏的数据映射到范围小、连续的一段内,其它的问题它可以管理不了。 还有什么其它的问题呢? 比如在坐标系中有两个线段 $[1,2],[2,3]$,如果映射到线段树中去呢?一对一映射吗?那肯定不行,因为我们发现两个集合有交集,而线段树是不能有交集的。那怎么办呢? 办法就是左闭右开,即$[1,2),[2,3)$,也就是在线段树中是$[1,1],[2,2]$这两个叶子节点!换句话说,在坐标系中的右端看来需要减$1$才能映射到线段树中 设坐标系中扫描线区间段的范围为$[L,R]$,那么映射到线段树中的节点(当然是指离散化后)就是$(l,r)->[L,R-1]$ 即 $l=L,r=R-1$ 已知$r$求$R$,就是$R=r+1$,这也就是$pushup$函数中,求`tr[u].len = b[tr[u].r + 1] - b[tr[u].l]`的原因。 考虑把线段树每个节点u对应的区间($tr[u].l,tr[u].r$)不变,改变区间和竖边的映射关系, 具体为: 节点$u$对应$[b[tr[u].l],b[tr[u].r+1]]$这条竖边。可以看到,这里很机智地把右端点的对应关系给改了下,于是就兼容了。 #### $Q3:$再详细解释一下为什么需要离散化? 答:离散化:将一个数轴上的一个一个点通过大小排序,保留点与点的大小关系 这道题目的线段树是绝对不会允许你开四倍于线段长度的($4e9$),百分百爆掉,这个时候就需要进行离散化, 离散化的原因是: 线段树的长度不能开的太大 #### $Q4:$本题明明是区间修改,为什么没有使用$lazy$标记,而是只有上传,没看到下传? 答:这与扫描线求并面积的需求相关,每次的查询操作都是查询整个区间,没有查询子区间的需求,所以只需要考虑上传,不用下传。 #### $Q5:$我看网上有题解的离散化没有使用二分查找$(l,r)$,而是直接用的$(L,R)$,是怎么做到的,哪种更好些? **答**: 真实数据:$y_1,y_2<=1e9$,简称为$L,R$ 线段线中的节点$4e5$,简称为$l,r$ $X$:离散化数组 $X[2*i-1]=L,X[2*i]=R$ 其中数组索引是线段树中节点的号,值为真实数据。 `nmodify`时,直接传入的是$(L,R)$ 按理说应该找到对应的$(l,r)$,这样更直白,更清楚,办法就是: ```cpp {.line-numbers} int L = lower_bound(b + 1, b + tot + 1, line[i].y1) - b; int R = lower_bound(b + 1, b + tot + 1, line[i].y2) - b; R--; ``` >**注**:上面的$L,R$是指线段树中的节点。 而网上的题解,并没有这个步骤,而是直接传入了原始数据, 然后利用递归+判断的办法来寻找合适的$(l,r)$ 即: ```cpp {.line-numbers} if(X[r + 1] <= L || R <= X[l]) return; if(L <= X[l] && X[r + 1] <= R) { tree[x].sum += c; pushup(x); return; } ``` 说白了,就是利用离散化数组$X[]$已经存储的信息,再加上递归,再加上判断,来衡量现在的区间$[l,r]$是否与现实中想要找的区间吻合,性能是一样的。 我还是推荐用传统的离散化+二分的办法来实现,套路清晰,`modify`代码基本就是模板形式,不像这种传入原始值的办法,还需要思考如何判断,把关键问题后移,反正我是不喜欢。 ### 三、实现代码 ```cpp {.line-numbers} #include using namespace std; typedef long long LL; const int N = 1e5 + 10; // 1e5个矩形 LL res; // 结果 int n; // n个矩形 int b[N << 2]; // 离散化数组,因为1e5个矩形,2e5条边,每条边两个y1,y2,所以,需要4e5 // 从左到右的扫描线 struct ScanLine { // y1,y2:这条线段上面的那个点和下面的那个点的y坐标 // x:在这里不会用到,只是用来排序而已 // k:是入边还是出边 int y1, y2, x, mark; const bool operator<(const ScanLine &t) { return x < t.x; // 以x坐标进行排序 } } line[N << 1]; // 2e5条边 struct Node { int l, r; int len; // 统计值:有效长度和 int cnt; // 属性:入边:+1,出边:-1,被多少个方格覆盖:cnt } tr[N << 4]; // 开了16倍空间,矩形数量1e5,每个矩形2条竖边,就是2e5,每条边两个y1,y2,就是4e5个点,对于4e5需要开4倍空间,就是16e5 void pushup(int u) { // pushup:利用左右儿子的信息,更新u节点的统计信息 // 扫描线题目中,统计信息就是有效区间长度,即len // cnt不是统计信息,它可以视为某条线段树中某个区间的固有属性,它不需要进行向上推送更新 // 整个区间被覆盖过,有效len等于区间长度 // tr[u].l = L , tr[u].r + 1 = R => 坐标系中的L,R与线段树中[l,r]之间的映射关系 // 使用b[]离散数组,通过索引号找出此位置上的原始值,即y2,y1 if (tr[u].cnt) tr[u].len = b[tr[u].r + 1] - b[tr[u].l]; else tr[u].len = tr[u << 1].len + tr[u << 1 | 1].len; // u节点所管辖的范围,并非完整命中,有效区间长度len=左节点len+右节点len } // 建树 void build(int u, int l, int r) { tr[u].l = l, tr[u].r = r; if (l == r) return; int mid = (l + r) >> 1; build(u << 1, l, mid); build(u << 1 | 1, mid + 1, r); } void modify(int u, int l, int r, int v) { // v:入边+1,出边-1 if (l <= tr[u].l && r >= tr[u].r) { // 如果完整区间命中 tr[u].cnt += v; // 区间被覆盖次数+v pushup(u); // 属性的更改,会造成统计信息的变更,所以需要pushup return; } // 未完整命中时,分裂 int mid = (tr[u].l + tr[u].r) >> 1; if (l <= mid) modify(u << 1, l, r, v); if (r > mid) modify(u << 1 | 1, l, r, v); // 更新统计信息 pushup(u); } int main() { #ifndef ONLINE_JUDGE freopen("P5490.in", "r", stdin); #endif // 加快读入 ios::sync_with_stdio(false), cin.tie(0); cin >> n; int x1, x2, y1, y2; for (int i = 1; i <= n; i++) { cin >> x1 >> y1 >> x2 >> y2; b[i * 2 - 1] = y1; b[i * 2] = y2; // 将y1,y2打入b数组,使得1e9的范围离散化,扫描线从左向右 // 这里可以利用线段两个端点的特性,一个是2*i-1,另一个是2*i // 当然也可以使用游标的办法:++bl,效果是一样的,只要是保证能塞进离散化数组b就行 // 将每个矩形的两条竖边记录扫描线数组 // 一个竖着的线段,三个属性:y1,y2,x line[i * 2 - 1] = {y1, y2, x1, 1}; // 1:入边 line[i * 2] = {y1, y2, x2, -1}; // -1:出边 } n *= 2; // 一个矩形两条线段,直接乘2,方便后的计算,n的新含义:扫描线条数 sort(line + 1, line + n + 1); // 按x坐标由小到大排序 sort(b + 1, b + n + 1); // 将y坐标离散化 int tot = unique(b + 1, b + n + 1) - b - 1; // 去重操作计算有多少个不同的y,也就是这个图象有几个y build(1, 1, tot - 1); // 建树 /* Q:为什么是tot-1呢? 答:因为[1,3][3,5]是坐标系中两条线段,但它们之间有交集3,采用的办法是左闭右开,即[1,3),[3,5) 也就是{1,2},{3,4}共四个子矩形,看明白了吧,这个矩形是格子,相当于最多到r-1。 最后一个右端点是tot,那么对应的线段数量就是tot-1 */ for (int i = 1; i < n; i++) { // 开始扫描,面积并中扫描到n-1条线段即可,最后一条没用 int L = lower_bound(b + 1, b + tot + 1, line[i].y1) - b; int R = lower_bound(b + 1, b + tot + 1, line[i].y2) - b; // 在排好序并去重后的数组b中,通过二分+原始值y1,查找到y1在离散化后数组b的位置,位置编号就是y1在整体中的相当名次 // 上面构建build时,也是用tot-1可丁可卯的大小创建的线段树,所以这里也必须用find后的位置编号去指定位置上修改 modify(1, L, R - 1, line[i].mark); // y1,y2:坐标系中的真实值 // L,R:离散化后的一一映射值 // 上面已经讨论过,坐标系中的线段区间 与 线段树中管控范围不是一一对应的,线段树区间是左闭右开的 // [L,R-1]-->(l,r) res += (LL)tr[1].len * (line[i + 1].x - line[i].x); // tr[1].len: 当前整体线段树中的可用长度和 // line[i + 1].x - line[i].x :下一条扫描线与本扫描线之间的宽度(扫描线从左向右,x上的变化视为宽度) // res+ : 长度和*宽度 } // 输出res printf("%lld\n", res); return 0; } ```