扫描线是一种重要的计算几何思想. 简单概括起来,就是把图形的边界看成区间,然后对这个区间进行维护处理.
在算法设计竞赛中扫描线主要有这几个应用:扫描线求面积并,扫描线求周长,扫描线求图形轮廓线.
扫描线求面积并
观察上面的gif,可以发现,一个图形的总面积就等于每次更新之后的底面积与这一段区间的高度(或者宽度,取决于维护什么方向的底面积)的积的和. 也就是说,我们只需要维护一棵线段树,线段树上保存底线段的长度,然后每次更新时把这个长度乘以这个区间图形的高就行了.
但是要注意线段树怎么维护. 我们的做法是在线段树上维护两个东西:一个是这个区间被覆盖的次数 c n t cnt cnt,一个是这个区间被覆盖的长度 l e n len len. 每次操作时,对于一个区间(也就是线段树上一个节点),如果它表示的区间被完全覆盖, c n t + + cnt++ cnt++,被覆盖的长度就等于区间长度. 否则更新它的孩子,它本身的 c n t cnt cnt不改变. 更新完孩子之后进行 p u s h u p push{up} pushup操作时,如果这个节点 c n t = 0 cnt=0 cnt=0,那么 l e n = l e n s o n a + l e n s o n b len=len_{sona}+len_{sonb} len=lensona+lensonb;如果 c n t > 0 cnt>0 cnt>0, l e n len len等于区间长度.
为什么这样是正确的呢?如果一个区间(设这个区间恰好和一个节点存储的区间相同)里面的更小区间被还原了,又由于有还原必有更新(我们把底边看成更新,把顶边看成还原),那么在还原这个更小区间的时候这个更小区间一定是有被更新过的,那么如果大的区间的 c n t > 0 cnt>0 cnt>0,那么这个区间依然是完全覆盖;否则这个区间一定就是没有完全覆盖了,就要由孩子更新.
于是,我们想到,除左右端点l,r之外,在线段树的每个节点上维护两个值:该节点代表的区间被矩形覆盖的长度len,该节点自身被覆盖的次数cnt。最初,二者均为0.
对于每一个(x,y1,y2,k),我们再[val(y1),val(y2)-1]上执行期间修改。该区间被线段树划分成 logn\log
nlogn个节点,我们把这些节点的cnt都加k。对于线段树中任意一个节点 [l,r]
,若cnt>0,则len等于raw(r+1)-raw(l),否则,该店len等于两个子节点的len之和。在一个节点的cnt被修改,以及线段树从下往上传递信息时,我们都按照该方法更新len值。根节点的len值就是整个扫描线上被覆盖的长度——from 《算法竞赛进阶指南》
举个例子:
比如说有一个这样子的区间
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 1 | 1 | 1 | 1 |
我们在某次操作时更新它的子区间,存子区间的孩子的权值cnt变为1.
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 1 | 2 | 1 | 1 |
我们可以发现,下次我们再还原这个子区间时,还原这一步是不影响这个大区间的cnt的.
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 1 | 1 | 1 | 1 |
也就是说,我们只有当当前整个区间需要操作时才改变cnt,否则我们只需要根据cnt维护len就好了.
同理一开始cnt为0的情况.
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 0 | 0 | 0 | 0 |
c n t = 0 , l e n = 0 cnt=0,len=0 cnt=0,len=0
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 0 | 1 | 1 | 0 |
c n t = 0 , l e n = l e n a s o n + l e n b s o n cnt=0,len=len_{ason}+len_{bson} cnt=0,len=lenason+lenbson
线段树上表示的区间 | 5 | 6 | 7 | 8 |
---|---|---|---|---|
区间上的覆盖次数cnt | 0 | 0 | 0 | 0 |
r e s t o r e , l e n = 0 , c n t = 0 restore,len=0,cnt=0 restore,len=0,cnt=0
另外,由于数据范围非常大,所以记得离散化,然后在离散化后的数组上建线段树.
还有,在实际操作中,线段树的叶子结点保存的是这个点x坐标到下一个x坐标(排序后的)的区间长度.
//P5490
#include<bits/stdc++.h>
#define mid ((l+r)>>1)
#define maxn 400010
#define int long long
using namespace std;
struct range{//range维护每一条横边(底边和顶边)
int left,right,sum,type;
//left,right表示横边的左右端点,sum表示这条边的纵坐标,type表示是顶边还是底边
bool operator < (range x)const {
return sum<x.sum ;
}
}rg[maxn<<1];
int cnt[maxn<<3],ans[maxn<<3],wt[maxn<<2];
void push_up(int p,int l,int r){
if(cnt[p])ans[p]=wt[r]-wt[l-1];
//原来在权值线段树上应该是r-l+1,记得这里区间要表示成wt[r]-wt[l-1]
else if(!cnt[p])ans[p]=ans[p<<1]+ans[p<<1|1];
}
void update(int p,int l,int r,int ll,int rr,int k){
if(ll<=l&&r<=rr){
cnt[p]+=k;
if(cnt[p])ans[p]=wt[r]-wt[l-1];
else if(!cnt[p])ans[p]=ans[p<<1]+ans[p<<1|1];
return ;
}
if(ll<=mid)update(p<<1,l,mid,ll,rr,k);
if(mid<rr)update(p<<1|1,mid+1,r,ll,rr,k);
push_up(p,l,r);
}//线段树维护
int n,m,k,Ans,sec[maxn<<1];
signed main(){
scanf("%lld",&n);
for(int i=1,x1,x2,y1,y2;i<=n;i++){
scanf("%lld%lld%lld%lld",&x1,&y1,&x2,&y2);
rg[++k].sum =y1,rg[k].left =x1,rg[k].right =x2,rg[k].type =1;sec[k]=x1;
rg[++k].sum =y2,rg[k].left =x1,rg[k].right =x2,rg[k].type =-1;sec[k]=x2;
}
sort(rg+1,rg+k+1);
sort(sec+1,sec+k+1);
m=unique(sec+1,sec+k+1)-sec-1;//离散化
for(int i=1;i<m;i++)wt[i]=sec[i+1]-sec[1];//这个区间到下个区间的区间长度
for(int i=1;i<=k;i++){
int l=lower_bound(sec+1,sec+m+1,rg[i].left )-sec,\
r=lower_bound(sec+1,sec+m+1,rg[i].right )-sec;//查询对应的应修改位置
update(1,1,m-1,l,r-1,rg[i].type );//需要修改l到r-1的区间
Ans+=ans[1]*(rg[i+1].sum -rg[i].sum );//面积增量=底*高
}
printf("%lld\n",Ans);
return 0;
}
扫描线求周长
既然已经学会了扫描线求面积,那么扫描线求周长自然也就不在话下了.
考虑到扫描线求面积时我们已经求出了一个方向上的所有边的长度,那么我们只要做两次扫描线就好了.
两次扫描线分别求纵向和横向的所有边的长度. 但是要记得每次整个区间的长度增量是 ∣ l e n n o w − l e n l a s t ∣ |len_{now}-len_{last}| ∣lennow−lenlast∣,这个很好理解,画张图出来就可以明白.
还有一点要特别注意:如果有几条边重合的情况,一定要先把新矩形的第一条边加入,再把旧矩形的最后一条边去掉,否则会导致加边时多算一次应该去掉的边.
#include<bits/stdc++.h>
#define maxn 5010
#define mid ((l+r)>>1)
using namespace std;
struct range{
int left,right,sum,type;
bool operator < (range x)const {
if(sum==x.sum )return type >x.type ;
else return sum<x.sum ;//注意边重合时加边的顺序
}
}rg[maxn<<1],ed[maxn<<1];
int cnt[maxn<<3],ans[maxn<<3],\
wt[maxn<<1],b[maxn<<1],a[maxn<<1],w[maxn<<1];
void push_up1(int p,int l,int r){
if(cnt[p]){
ans[p]=wt[r]-wt[l-1];
}
else {
ans[p]=ans[p<<1]+ans[p<<1|1];
}
}
void push_up2(int p,int l,int r){
if(cnt[p]){
ans[p]=w[r]-w[l-1];
}
else {
ans[p]=ans[p<<1]+ans[p<<1|1];
}
}
void update(int p,int l,int r,int ll,int rr,int k,int f){
if(ll<=l&&r<=rr){
cnt[p]+=k;
if(f==1)push_up1(p,l,r);
else push_up2(p,l,r);
return ;
}
if(ll<=mid)update(p<<1,l,mid,ll,rr,k,f);
if(mid<rr)update(p<<1|1,mid+1,r,ll,rr,k,f);
if(f==1)push_up1(p,l,r);
else push_up2(p,l,r);
}
int n,m,q,Ans,k,last;
int main(){
scanf("%d",&n);
for(int i=1,x1,x2,y1,y2;i<=n;i++){
scanf("%d%d%d%d",&x1,&y1,&x2,&y2);
rg[++k].left =x1,rg[k].right =x2,rg[k].sum =y1,rg[k].type =1;b[k]=x1;
ed[k].left =y1,ed[k].right =y2,ed[k].sum =x1,ed[k].type =1;a[k]=y1;
rg[++k].left =x1,rg[k].right =x2,rg[k].sum =y2,rg[k].type =-1;b[k]=x2;
ed[k].left =y1,ed[k].right =y2,ed[k].sum =x2,ed[k].type =-1;a[k]=y2;
}
sort(b+1,b+k+1);
sort(a+1,a+k+1);
m=unique(b+1,b+k+1)-b-1;
q=unique(a+1,a+k+1)-a-1;
sort(rg+1,rg+k+1);
sort(ed+1,ed+k+1);
for(int i=1;i<m;i++)wt[i]=b[i+1]-b[1];
for(int i=1;i<q;i++)w[i]=a[i+1]-a[1];
for(int i=1;i<=k;i++){
int l=lower_bound(b+1,b+m+1,rg[i].left )-b,\
r=lower_bound(b+1,b+m+1,rg[i].right )-b;
update(1,1,m-1,l,r-1,rg[i].type ,1);
Ans+=abs(ans[1]-last);last=ans[1];
}
for(int i=1;i<=k;i++){
int l=lower_bound(a+1,a+q+1,ed[i].left )-a,\
r=lower_bound(a+1,a+q+1,ed[i].right )-a;
update(1,1,q-1,l,r-1,ed[i].type ,2);
Ans+=abs(ans[1]-last);last=ans[1];
}//两次扫描线
printf("%d\n",Ans);
return 0;
}
扫描线求图形轮廓线
最与众不同的一种扫描线:扫描线求图形轮廓线.
图形轮廓线是指这样的一组数:形如 ( x , y ) (x,y) (x,y),表示 ( x , y ) (x,y) (x,y)是图形的一个折点.
依然是一样的思路:对于每条边进行排序,排序之后用一个堆(实际上通常用 m u l t i s e t multiset multiset,既可以排序又可以删除任意元素)维护当前整体轮廓线上最大的元素,然后进行插入和删除操作,以维护最大值,从而确定轮廓线折点坐标.
这个算法的主要难点依然在于排序:
对于每一条矩形轮廓线,首先按照它的 p o s pos pos(横坐标)由左至右排;横坐标相同时,按先入边后出边的顺序排;如果出入边也相同,出边按高度从小到大排(先出小的边就不会算出多个折点),入边按高度从大到小排.(先进大的边不会多算折点). 如此,就可以保证不会重复计算整个轮廓线上的点了.
总结一下,对于任何轮廓线问题,都应该是先入边后出边. 再泛化一点,就是说求扫描线时应该做到排序后的顺序对于答案不能有影响.
//P1382
#include<bits/stdc++.h>
#define maxn 100010
using namespace std;
struct range{
int pos,height,type;
}rg[maxn<<1];
struct answer{
int x,y;
}ans[maxn<<2];
bool cmp(range a,range b){
if(a.pos ==b.pos ){
if(a.type !=b.type )return a.type >b.type ;
else if(a.type )return a.height >b.height ;
else return a.height <b.height ;
}
else return a.pos <b.pos ;
}//特别注意这个排序是怎么排的
int n,k,cnt;
multiset<int >h;
int main(){
scanf("%d",&n);
for(int i=1,height,left,right;i<=n;i++){
scanf("%d%d%d",&height,&left,&right);
rg[++k].height =height;
rg[k].pos =left,rg[k].type =1;
rg[++k].height =height;
rg[k].pos =right,rg[k].type =0;
}//读取矩形轮廓线
sort(rg+1,rg+k+1,cmp);//轮廓线排序
h.insert(0);//初始最大高度为0
for(int i=1;i<=k;i++){
int mx=*h.rbegin();//由于multiset默认从小到大排,所以最大值是最后一个
if(rg[i].type ){//如果是左轮廓线,插入操作
h.insert(rg[i].height );
if(rg[i].height >mx){
//如果轮廓线最高点比之前的轮廓线都高,那么显然会出现两个新的折点
ans[++cnt].x =rg[i].pos ;ans[cnt].y =mx;
ans[++cnt].x =rg[i].pos ;ans[cnt].y =rg[i].height ;
}
}
else {
if(rg[i].height ==mx&&h.count(mx)==1){
h.erase(h.find(mx));//如果是右轮廓线,删除操作
ans[++cnt].x =rg[i].pos ;ans[cnt].y =rg[i].height ;
ans[++cnt].x =rg[i].pos ;ans[cnt].y =*h.rbegin();
//找到新的最高点,*h.rbegin()表示末尾元素(反向首元素)
}
else h.erase(h.find(rg[i].height ));
}
}
printf("%d\n",cnt);
for(int i=1;i<=cnt;i++)
printf("%d %d\n",ans[i].x ,ans[i].y );
return 0;
}
值得注意的是, m u l t i s e t multiset multiset的 e r a s e erase erase函数有两个:其一, e r a s e ( i t e r a t o r ) erase(iterator) erase(iterator),表示删除迭代器指向位置的元素;其二, e r a s e ( s u m ) erase(sum) erase(sum),表示删除所有值为 s u m sum sum的元素.