扫描线
有时候,我们需要在二维的平面上维护一些问题,但是二维数据结构比较难写,这时就用到了扫描线算法。
扫描线提供了一种把静态的二维问题变成动态的一维问题的思路,也即“降维打击”,这使得对数据的维护方便许多。
(本博客的图大部分来自洛谷 @paperghost_ls 的博客,侵删。)
Part 1 什么是扫描线
扫描线的一个经典用法就是求平面内矩形面积并,姑且把这个问题当作例题。
在平面直角坐标系中,给定 (n) 个底平行于 (x) 轴的矩形的左下角以及右上角的坐标,求这 (n) 个矩形覆盖的面积是多大。
假设你现在在给你的弟弟辅导小学数学作业,其中一道题目是求解下面由 3 个矩形拼起来的大图形的面积:
当然了,一个很直接的方法就是先把每个矩形的面积都算出来,然后容斥一下,减掉重复计算的(也就是图中重叠的部分),得到答案。然而这个方法可能会被一堆相交的矩形搞得很复杂,你教了无数遍也教不会。
正当你在抓狂的时候,你想到了另一种方法——割补法。
你用笔在本子上划出了这样几道横线,把这个不规则图形划分成了 5 个矩形,依次求解这 5 个矩形的面积,然后求和,得到答案。
你完美的帮助你的小弟弟解决了数学难题,但是这引发了你更深的思考,能不能设计一个程序,利用如上的割补法求解矩形面积并呢?
发现上面的割补法,每次碰到一个原来矩形的上下任一边界,就会划分出一个小矩形,我们可以从这个边界入手。
矩形的面积公式是 (S=ah) ,如果记录下来每个矩形的上下两个边界的高度,那么计算小矩形的面积时,就可以用相邻两个边界的高度做差来得到它的高 (h) 。
剩下的就是底宽 (a) 的问题了,显然 (a) 应该是一条水平直线截这个不规则图形所得线段的长度。
对于这个问题可以简单模拟一下,假设我们用一根水平的直线从下向上一段段截取:
最初的时候,(a=0) 。
向上移动,碰到灰色矩形的下边界,(a) 就是灰色矩形的下边界长度(宽)。
向上移动,碰到红色矩形的下边界,(a) 是灰色、红色矩形下边界并的长度(不是下边界长度和!)。
向上移动,碰到紫色矩形的下边界,(a) 是灰色、红色、紫色矩形下边界并的长度。
向上移动,碰到灰色矩形的上边界(这意味着走出了灰色矩形的范围),(a) 是红色、紫色矩形下边界并的长度。
向上移动,碰到红色矩形的上边界(这意味着走出了红色矩形的范围),(a) 是紫色矩形的下边界长度(宽)。
向上移动,离开紫色矩形的范围,(a=0) 。
发现了什么?假设这条水平直线截到 (p) 个矩形,那么 (a) 的值就是这 (p) 个矩形的下边界并的长度。
怎么确定这条水平线截到了某一个矩形?显然是当且仅当这条线已经经过了一个矩形的下边界,但是还没有经过它的上边界啦!
那么我们不仅要记录上面说过的上下边界的高度,还要记录它的左右端点,和它具体是上、下边界中的哪一个。
如果水平线移动到一个下边界,那么把它对应的矩形的宽加入集合求并,得到 (a) 的值。
如果水平线移动到一个上边界,那么把它对应的矩形的宽移除集合,重新求剩下矩形宽的并,得到 (a) 的值。
由于我们的水平线不管是碰到了上边界还是下边界都会停下来并划分出一个小矩形,找到下一个边界的高度,与当前边界的高度做差得到高 (h) 。
那么这个小矩形的面积就是 (S=ah) ,累计入答案。
这种算法就叫做扫描线算法,这条去截不规则图形的线段就叫做扫描线。扫描线的精髓在于它把静态二维问题(包括横轴、纵轴)转化成了动态一维问题(包括横轴、时间轴),这样可以用许多数据结构直接维护(支持维护序列、修改就可以)。顺带一提,这也是处理很多静态二维问题的普遍思路。
回到例子上来,现在的问题是如何快速求一堆线段的并的长度,这个可以用数据结构维护,比如线段树或者分块。
Part 2 实现扫描线
我把这部分分成三部分:思路、实现细节、代码来讲。
线段树求线段的并的长度
用线段树实现区间加这个大家都会。现在假设所有线段的左右端点都落在了 ([1,max]) 这个范围内,对这个值域建立一棵线段树。
每个结点维护一个 (cnt) ,表示这个结点被完全覆盖了几次。如果要加入一个线段(左右端点为 (l,r)),那么直接在线段树上对 ([l,r]) 进行区间 (cnt+1) ,表示这个区间被覆盖了一次,删除同理,改成区间 (cnt-1) 即可。如果一个结点 (x) 被完全覆盖掉,那么它对答案的贡献显然是结点 (x) 的长度。
但是很显然有一些结点不会被完全覆盖对吧?如果我们每次询问的时候都从根向下访问到被完全覆盖的区间再回传这些结点的长度和的话,要花 (O(logn)) 的时间,实际上是做了一次区间查询。但是这样会很亏,因为我们只需要根被覆盖多长,也就是这个区间查询的区间是给定的。其实我们可以直接在修改完回溯的时候顺便把答案合并到根结点上,这样我们查询就是 (O(1)) 的。
维护一个 (len) ,表示这个结点中被覆盖的长度。假设一个结点 (x) ,代表区间 ([l,r]) 。如果 (x) 的 (cnt) 值大于 0 ,说明它被完全覆盖了,(len=r-l+1) ,否则,(len) 显然应该为左右儿子的 (len) 加和得到(左右儿子无交,不用考虑重复的问题,直接加和即可)。
回溯的时候维护 (len) ,答案就被成功的维护到根结点里。另外,这么做也省掉了懒标记,一举两得。
实现细节
由于坐标的范围可能会很大,直接建线段树肯定是 MLE 妥妥的,这个时候需要离散化。
先预处理出每个上边界、下边界的 (l,r,y,op),分别表示这个边界的左端点、右端点、水平高度以及他是上边界还是下边界。因为我们这里对水平线建立线段树,那么把 (l,r) 离散化就好,(y) 的值保留,扫描的时候可以直接做差得到高 (h) 。
现在我们期望建立的线段树应该是这个样子:
遇到这样两个问题:
如果正常建立线段树,叶子结点应该代表 ([x,x]) 这个区间,但是这个线段树的叶子结点代表的是 ([x,x+1]) 这个区间。
假设我们要修改 ([1,3]) 这个区间,如果像往常一样判断边界的话,会发现 ([1,3]) 和 ([3,6]) 其实有交,但是 ([3,6]) 却并不是我们需要修改的区间。
考虑线段树的 (l,r) 区间不变,改变一下每条线段和代表的水平边的关系。具体就是把 (x) 代表 ([l,r]) 改成对应 ([l,r+1]) 。
这样改之后,按照普通线段树的建树方法建立 ([1,max-1]) 的线段树,就会得到右边的线段树。粉色数字表示每个结点的 (l,r) 的值,蓝色数字表示这个结点对应的水平边是哪一条,这样建出来的树可以完美地用线段而不是点的方式覆盖掉整个值域,这样解决了第一个问题。
第二个问题就是一个特判,在注释里讲吧。
另外,需要注意一个细节:变量 (y1) 在 cmath
库中有定义,代表贝塞尔函数(用来求二阶微分方程的特解的一个函数)的值。如果变量名包括了 (y1) 的话,注意不要开 bits/stdc++.h
或者 cmath
库,否则会 Compile Error 。
( ext{Talk is cheap,shou you the code})
#include<algorithm>
#include<iostream>
#include<cstdlib>
#include<iomanip>
#include<cstring>
#include<utility>
#include<cstdio>
#include<queue>
#include<stack>
#include<ctime>
#include<list>
#include<set>
#include<map>
#define int long long
namespace zhangtao{
#define inf 0x7f7f7f7f
template <typename _T>
inline _T const& read(_T &x){
x=0;int fh=1;
char ch=getchar();
while(!isdigit(ch)){
if(ch=='-')
fh=-1;
ch=getchar();
}
while(isdigit(ch)){
x=x*10+ch-'0';
ch=getchar();
}
return x*=fh;
}
void write(int x){
if(x<0) putchar('-'),x=-x;
if(x>9) write(x/10);
putchar(x%10+48);
}
inline int _gcd(const int x,const int y){
return y?_gcd(y,x%y):x;
}
inline int _lcm(const int x,const int y){
return x*y/_gcd(x,y);
}
inline int max(int a,int b,int c=-inf){
return std::max(std::max(a,b),c);
}
inline int min(int a,int b,int c=inf){
return std::min(std::min(a,b),c);
}
inline void swap(int &x,int &y){
x^=y^=x^=y;
}
#undef inf
} // namespace zhangtao
const int maxn=200005;
using namespace zhangtao;
//using namespace std;
int n,cnt;
int x1,y1,x2,y2,X[maxn];
struct Scanline{
int l,r,h;
int tag;
bool operator < (const Scanline &b) const {
return h<b.h;
}//按照高度排序
}line[maxn];
struct segment_tree{
int l,r,cover,len;
segment_tree *ls,*rs;
}*root,*null;//经典防 RE 空指针
inline void update(segment_tree *node){
node->len=node->cover?X[node->r+1]-X[node->l]:node->ls->len+node->rs->len;
}//注意X[r+1]才是这个结点对应的水平边的右端点哦!
void modify(segment_tree *node,const int L,const int R,const int w){
//这里的L,R代表原值,node->l,node->r是离散化值,如果要比较,需要把node->l,node->r还原成原值
if(X[node->r+1]<=L || R<=X[node->l]) return;
//这里加上等号,解决上面说的“第二个问题”
//这个意思是如果仅仅端点有交,当作无交处理,否则我们就会多修改一个结点。
//思考假设我们要修改[1,5],现在有[1,5]、[5,10]两个区间
//[1,5]交[5,10]={5},这样程序会判定[5,10]与修改区间有交,但实际上,我们不需要修改[5,10]
//所以这里加上等号
if(L<=X[node->l] && X[node->r+1]<=R){
node->cover+=w;
update(node);
return;
}
modify(node->ls,L,R,w);
modify(node->rs,L,R,w);
update(node);
}
segment_tree *Build(const int L,const int R){
segment_tree *u=new segment_tree;
u->l=L,u->r=R;
u->len=u->cover=0;
if(L==R){
u->ls=u->rs=null;
}else{
int Mid=(L+R)>>1;
u->ls=Build(L,Mid);
u->rs=Build(Mid+1,R);
update(u);
}
return u;
}
void Init(){
null=new segment_tree;
null->l=null->r=null->cover=null->len=0;
null->ls=null->rs=NULL;
read(n);
for(int i=1;i<=n;++i){
read(x1),read(y1),read(x2),read(y2);
X[2*i-1]=x1,X[2*i]=x2;//把横坐标存到X数组里
line[2*i-1]=(Scanline){x1,x2,y1,1};//结构体存下每一条水平边
line[2*i]=(Scanline){x1,x2,y2,-1};
}
n<<=1;//因为存了2n条边,直接把n*2方便一些
std::sort(line+1,line+n+1);//排序一下,因为要保证扫描线从下向上扫
std::sort(X+1,X+n+1);//对横座标X排序
int it=std::unique(X+1,X+n+1)-X-1;//去重
//这里我没有存下来离散化值,算是一个小Trick吧
//为什么可以存?那要看我们用离散化的值做什么
//这里我们拿去建立线段树,而线段树是对下标建立的,直接用下标就行了,离散化的原理不就是把值映射成下标吗?
//如果你要拿去比较大小,或者开桶,那就必须存下来离散化值。
root=Build(1,it-1);//it-1的理由上面说过了,我们改了线段树区间和水平边的对应关系
}
int ans;
signed main(){
Init();
for(int i=1;i<n;++i){
modify(root,line[i].l,line[i].r,line[i].tag);//加入、减少边
ans+=root->len*(line[i+1].h-line[i].h);//S=ah,求出小矩形的面积,加和
}
write(ans);
return 0;
}
RE:这人写的什么粑粑玩意,一句也看不懂,毫无帮助!
那就对了,觉得这篇博客 lj 的朋友快来踩我。
繁华尽处,
寻一静谧山谷,
筑一木制小屋,
砌一青石小路,
与你晨钟暮鼓,
安之若素。