线段树专题 番外:扫描线
传送门不挂了。
扫描线问题主要是解决二维空间内的矩形面积和周长并问题。
T1 面积并(洛谷P5490,难度4)
扫描线其实是一种非常形象化的问题:想象一条线从下至上扫描,在过程中计算宽度和高度。
接下来依次分析一下这题和线段树的关系。
首先线段树的优势就在于能够用一个点表示[l,r]内的信息,这就可以对应真实的坐标,所以用上了线段树。
其次考虑线段树维护什么内容。我认为这应该用形象化的方式思考,因为某点维护的就是一段真实的信息。高度显然就不用上树了,对于宽度,我们很显然要分类讨论一下左右儿子加一起能否覆盖一整个区间,所以应该有一个标记表示是否完全被覆盖;其次显然要标记一下这段被覆盖的长度。
考虑一下合并的细节:如果一段被完全覆盖了,那么很显然这段的长度就应该是r-l+1,否则就是左右儿子长度之和,如下:
void push(int k,int l,int r){
if(tre[k].sum) tre[k].len = pos[r + 1] - pos[l];
else tre[k].len = tre[k << 1].len + tre[k << 1 | 1].len;
}
然后考虑一下怎么维护标记。这里我们想要实时的维护长度(因为长度直接与标记相关),可以给一段赋值为1来表示被覆盖。扫描线经过这一段之后,离开矩形的时候再赋值为-1就可以了。
这题还有一个特殊之处:此题横坐标范围太大了,没法直接建树,而应该统计一下去重后的横坐标数目,然后在树上维护横坐标的序号,这样就开的下了。其实就是一个离散化的过程。
对于一切扫描线问题,我们还有一个问题要解决:因为线段树上左右儿子虽然序号上没有交集,但由于维护的是序号,可能把两段恰好相连的线段给左右儿子维护,这样两侧的信息就会反复。(类似的,可能会在一个节点上真的只维护一个序号的信息,但这显然就没任何意义。)在此题当中,我们由于是在树上维护序号,所以可以改变序号和边的对应关系,让每一个节点对应序号为[l,r+1]的部分。这样一来就解决了这个问题。
以及,我一开始学扫描线就是从这道题开始的,一开始因为这个序号上树很难理解,看了好久的题解教学才差不多学会,这也导致我做周长并的时候又是一脸迷惑。其实如果能适应这个序号的话,扫描线并不是很难理解。
完整代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define mid (l + r >> 1)
const int N = 1e6 + 1;
int pos[N << 1];
struct wyx{
long long L,R,h;
int mark;
bool operator < (const wyx &a) const {
return h < a.h;
}
}line[N << 1];
struct fxj{
int sum;
long long len;
}tre[N << 2];
struct yjx{
void push(int k,int l,int r){
if(tre[k].sum) tre[k].len = pos[r + 1] - pos[l];
else tre[k].len = tre[k << 1].len + tre[k << 1 | 1].len;
}
void modify(int k,int l,int r,long long x,long long y,int c){
if(pos[r + 1] <= x || pos[l] >= y) return;
if(x <= pos[l] && pos[r + 1] <= y){
tre[k].sum += c;
push(k,l,r);
return;
}
modify(k << 1,l,mid,x,y,c);
modify(k << 1 | 1,mid + 1,r,x,y,c);
push(k,l,r);
}
}STr;
int main(){
int n,m,i;
long long x1,x2,y1,y2;
long long res = 0;
scanf("%d",&m);
for(i = 1; i <= m; i++) {
scanf("%lld %lld %lld %lld",&x1,&y1,&x2,&y2);
pos[(i << 1) - 1] = x1, pos[i << 1] = x2;
//把序号与横坐标对应
line[(i << 1) - 1] = (wyx){x1,x2,y1,1};
line[i << 1] = (wyx){x1,x2,y2,-1};
}
m <<= 1;
sort(line + 1,line + m + 1);
sort(pos + 1,pos + m + 1);
n = unique(pos + 1,pos + m + 1) - pos - 1;
for(i = 1;i < m;i++){
//最后一次操作后tre[1].len = 0,所以不必再处理了
STr.modify(1,1,n - 1,line[i].L,line[i].R,line[i].mark);
res += tre[1].len * (line[i + 1].h - line[i].h);
}
printf("%lld\n",res);
return 0;
}
看完这道模板,来看一道变式。
T2 窗口里的星(洛谷P1502,难度3.5)
此题转换参考系,窗口的大小其实也可以相当于星星的亮光范围。例如以星星的坐标为左下角,以窗口的大小(框是无光的,应该小一圈)为矩形大小,那么只要把窗口右上角放在这个矩形内就能看到这个星星。
因此我们就是想要求这些矩形交完之后亮度最大的一个区域。
上扫描线的思路(这里用扫描线我认为一是扫描线的原理,但更确切地说是能把二维问题转化成一维问题,大概这才是扫描线的精髓吧),不过这一次要维护的应该是某个坐标范围内的亮度最大值。为了在一维中解决这个问题,借鉴面积并的经验,记下边的权为w,上边就为-w。这样扫描线未离开该矩形时贡献为w,离开后就变成0.
怎么合并亮度最大值?其实很简单,简单的左右儿子取最大值就可以了。所以此题在线段树方面的操作其实非常简单。
以及,由于这题就是一个简单的区间改,维护的也就是最大值,可以上懒标记了。
这题我做完之后看了一眼题解,里面提供了一种更普遍的离散化方案,我写到下面的代码里,算是积累一下不同的做法。
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define mid (l + r >> 1)
#define int long long
const int N = 1e4 + 1;
long long pos[N << 2];
struct wyx{
long long L,R,h,dis;
bool operator < (const wyx &a) const {
if(h != a.h) return h < a.h;
else return dis > a.dis;
}
}line[N << 2];
struct fxj{
long long mx,laz;
}tre[N << 3];
struct yjx{
void push(int k){
tre[k << 1].mx += tre[k].laz;tre[k << 1 | 1].mx += tre[k].laz;
tre[k << 1].laz += tre[k].laz;tre[k << 1 | 1].laz += tre[k].laz;
tre[k].laz = 0;
}
void modify(int k,int l,int r,long long x,long long y,int c){
if(r < x || l > y) return;
if(x <= l && r <= y){
tre[k].mx += c;
tre[k].laz += c;
return;
}
push(k);
modify(k << 1,l,mid,x,y,c);
modify(k << 1 | 1,mid + 1,r,x,y,c);
tre[k].mx = max(tre[k << 1].mx,tre[k << 1 | 1].mx);
}
}STr;
signed main(){
int n,m,i,j,cnt;
long long W,H,w,x1,x2,y1,y2,res,templ,tempr;
scanf("%lld",&m);
for(i = 1;i <= m;i++){
scanf("%lld %lld %lld",&n,&W,&H);
res = 0;
memset(line,0,sizeof(line));
memset(tre,0,sizeof(tre));
memset(pos,0,sizeof(pos));
for(j = 1;j <= n;j++){
scanf("%lld %lld %lld",&x1,&y1,&w);
x2 = x1 + W - 1,y2 = y1 + H - 1;
pos[(j << 1) - 1] = y1,pos[j << 1] = y2;
line[(j << 1) - 1] = (wyx){y1,y2,x1,w};
line[j << 1] = (wyx){y1,y2,x2,-w};
}
n <<= 1;
sort(line + 1,line + n + 1);
sort(pos + 1,pos + n + 1);
cnt = unique(pos + 1,pos + n + 1) - pos - 1;
for(j = 1;j <= n;j++){
line[j].L = lower_bound(pos + 1,pos + cnt + 1,line[j].L) - pos - 1;
line[j].R = lower_bound(pos + 1,pos + cnt + 1,line[j].R) - pos - 1;
}//大概这才是传统意义上的离散化吧
for(j = 1;j < n;j++){
STr.modify(1,1,cnt - 1,line[j].L,line[j].R,line[j].dis);
res = max(res,tre[1].mx);
}
printf("%lld\n",res);
}
return 0;
}
一开始我觉得这题比面积并简单,毕竟只是借鉴一下扫描线的思路。现在看来,没有前面的底子,这题确实不简单。
T3 周长并(洛谷P1856,难度4)
看洛谷这个编号顺序,看来扫描线算法也算是人民群众智慧的结晶了(笑)
首先一看数据范围,很开心,终于不用再离散化了。
然而建树一定是[1,x],我们要记一下最小的横坐标,如果不大于0,就把所有的坐标右移,使得最小的一个横坐标为1。一定要注意建树的范围,很容易就把最小的横坐标变成0,或者最小坐标等于0的时候忘了移动(至少我在这儿卡了一段时间)。
直接从周长并出发,很显然上树的部分是完全一样的。现在的问题还是在于维护上是否有变化。
为了考虑这种变化干想绝对没用,肯定要从周长的求法出发。
对于宽度的周长,首先我们得记得,在扫到上边的一瞬间矩形的贡献就会消失。为了便于理解,我们可以认为这时候其实是扫到了上边上方等宽的极窄的一个空区域。因此为了算这个上边,我们要加上的其实是扫完这条边以后的变化量绝对值。说的更科学一点,在图形内部的部分就是交集,我们可以认为是求出了以长度并为全集下交集的补集,也就是应该加上的贡献。说白了,就是每次加上的都是这次的总长去掉上次的总长。
这里解释的不是很科学,还是比较感性理解,有更合理解释的dalao欢迎在评论区补充。
对于高的周长,我们需要统计一下这段有多少个端点(实际上统计不重叠的线段数,既符合线段树的特点,好想,常数也小一倍),这样加上段点数乘高度差就可以了。为了统计端点数,我们需要知道左右儿子合并的时候中间是不是接上的,所以要加两个标记,表示左右端是否被覆盖了。
建议复习一下线段树真正的模板题小白逛公园。
具体讲一讲怎么合并:如果这段标记被覆盖了,那整段都是覆盖的,len=r-l+1,左右标记都为1,线段数为1。考虑另一个极端,由于标记代表的是是否完全被覆盖,所以并不能以此表示一段是否完全不被覆盖,除非l==r(单点肯定没有不完全一说了),此时所有的全为0。如果不完全覆盖,首先长度就是左右儿子长度和,判断一下中间是否连贯覆盖(左儿子右标记和右儿子左标记为1),是则线段数为1,不然为2.至于标记,左标记等于左儿子标记,右标记等于右儿子标记(作为一个做过一堆最大子段和、最大子串的人,做到这里莫名亲切)。反映到代码上是这样的:
void push(int k,int l,int r){
if(tre[k].sum){
tre[k].num = 1;
tre[k].len = r - l + 1;
tre[k].lmark = tre[k].rmark = 1;
return;
}
if(l == r){
tre[k].num = tre[k].len = tre[k].lmark = tre[k].rmark = 0;
return;
}
tre[k].num = tre[k << 1].num + tre[k << 1 | 1].num;
if(tre[k << 1].rmark && tre[k << 1 | 1].lmark) --tre[k].num;
tre[k].len = tre[k << 1].len + tre[k << 1 | 1].len;
tre[k].lmark = tre[k << 1].lmark;
tre[k].rmark = tre[k << 1 | 1].rmark;
return;
}
在完工之前,考虑一下之前提出来的左右端点重合的问题。其实这比刚才更好考虑了,因为现在并不存在这个问题了233,毕竟这一回维护的是实实在在的横坐标范围,左右儿子绝对是没交集的。但是还是有一个问题,x2 x1的距离实际是x2-x1而不是一般认识中的x2-x1+1,所以其实上树的时候应该是[x1,x2-1],这样就能用一般的距离来维护了。
特别提醒一点:这题需要算绝对值,如果用上cmath,就不要把变量名设为x1 y1。
代码如下:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define mid (l + r >> 1)
#define int long long
const int N = 2e4 + 1;
struct wyx{
int L,R,h,val;
}e[N << 1];
bool cmp(wyx x,wyx y){
if(x.h != y.h) return x.h < y.h;
return x.val > y.val;
}
struct fxj{
long long len,num,sum;
bool lmark,rmark;
}tre[N << 2];
struct yjx{
void push(int k,int l,int r){
if(tre[k].sum){
tre[k].num = 1;
tre[k].len = r - l + 1;
tre[k].lmark = tre[k].rmark = 1;
return;
}
if(l == r){
tre[k].num = tre[k].len = tre[k].lmark = tre[k].rmark = 0;
return;
}
tre[k].num = tre[k << 1].num + tre[k << 1 | 1].num;
if(tre[k << 1].rmark && tre[k << 1 | 1].lmark) --tre[k].num;
tre[k].len = tre[k << 1].len + tre[k << 1 | 1].len;
tre[k].lmark = tre[k << 1].lmark;
tre[k].rmark = tre[k << 1 | 1].rmark;
return;
}
void modify(int k,int l,int r,int x,int y,int c){
if(x <= l && r <= y){
tre[k].sum += c;
push(k,l,r);
return;
}
if(x <= mid) modify(k << 1,l,mid,x,y,c);
if(y > mid) modify(k << 1 | 1,mid + 1,r,x,y,c);
push(k,l,r);
}
}STr;
signed main(){
int i,n,m,xa,ya,xb,yb,l = 1e9,r = -1e9;
long long res = 0,last = 0;
scanf("%lld",&n);
for(i = 1;i <= n;i++){
scanf("%lld %lld %lld %lld",&xa,&ya,&xb,&yb);
l = min(l,xa);
r = max(r,xb);//寻找建树的范围
e[(i << 1) - 1] = (wyx){xa,xb,ya,1};
e[i << 1] = (wyx){xa,xb,yb,-1};
}
n <<= 1;
m = r;
if(l <= 0){
for(i = 1;i <= n;i++){
e[i].L -= (l - 1),e[i].R -= (l - 1);
}//细节
m -= l;
}
sort(e + 1,e + n + 1,cmp);
for(i = 1;i <= n;i++){
//这里要注意,我们每次都要处理两次之间的差,所以不能像面积并少跑一次
STr.modify(1,1,m,e[i].L,e[i].R - 1,e[i].val);
res += abs(tre[1].len - last) + 2 * tre[1].num * (e[i + 1].h - e[i].h);
last = tre[1].len;
}
printf("%lld\n",res);
return 0;
}
总而言之,扫描线作为线段树问题中的一个特殊问题,完美的利用了线段树的特点,虽然初学不太好理解,但是了解明白之后就能感受到线段树应用在这一问题上的巧妙之处,思路也很顺畅,同时能加深对线段树的理解。
Thank you for reading!