线段树杂谈
概念:
线段树(Segment Tree)是一个基于分治的数据结构。
通常处理区间,序列中的查询,更改问题。大体上有单修,单查,区修,区查等操作。但因为其可维护变量的多样性,所以常在各类题目中遇到。准确说,是各类优化中遇到。
线段树是个有根二叉树,我们记为 t,其每个节点 t[p] 均保存着所应该记录的关键信息(依题目情况而定)。对于每一个区间,我一般不喜欢在结构体里记录它的区间端点信息,一般在函数递归中开两个参数进行处理。
实现1(单点修改,区间查询):
首先我们有这样一个序列a:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
我们希望对其中下标为 i 的元素进行修改,并且能够快速查找区间 l 到 r 中的最大值。
Step 1,建树:
struct node{
int mx;
}tree[MAXN];//记录每个节点里的关键信息,这里记录了节点区间里的最大值
void build(int i,int tl,int tr){//递归建树,i表示节点编号,tl,tr表示该节点所含的区间(后同)
if(tl == tr){
tree[i].mx = a[tl];
return;
}
int mid = (tl + tr) / 2;
build(i << 1,tl,mid);//进行递归建树操作,分别建左子树和右子树
build((i << 1) + 1,mid + 1,tr);
tree[i].mx = max(tree[i<<1].mx,tree[(i<<1) + 1].mx);//子树建完后,进行上传操作,更新该区间tree[i]的最大值(意会一下)
}
Step 2,单点修改:
void change(int i,int tl,int tr,int pos,int num){//pos为要修改的下标,num为修改后的值
if(tl == tr){//已到达要修改的下标
tree[i].mx = num;
return;
}
int mid = (tl + tr) / 2;
if(pos <= mid){//如果包含在左子树里,那么就在该子树里进行递归直到到达目标节点
change(i<<1,tl,mid,pos,num);
}
else{//被包含在右子树里
change((i<<1) + 1,mid + 1,tr,pos,num);
}
tree[i].mx = max(tree[i<<1].mx,tree[(i<<1) + 1].mx);//修改完值后,自然要更新区间包含这个下标的树上的节点
}
Step 3,区间查询:
查询 l 到 r 这一区间里的最大值。
int query(int i,int tl,int tr,int l,int r){
if(l > r)return -MAXN;//区间越界,返回最小值
if(tl == l && tr == r){
return tree[i].mx;
}
int mid = (tl + tr) / 2;//如果查询的区间被左右子树各包含一点,那么将这个查询的区间分为落在左子树里的区间和落在右子树里的区间,对分后的两个区间所返回的最大值取一次最大值
return max(query(i<<1,tl,mid,l,min(r,mid)),query((i<<1) + 1,mid + 1,tr,max(l,mid + 1),r));
}
单点修改,区间查询的方法就介绍到这里了。
实现2(区间修改,单点/区间查询):
如果说单点修改,区间查询的实现中最重要的就是数据的上传操作,那么我觉得区间修改,单点/区间查询最重要的就是数据的下传操作。
什么是下传操作?我们通过对下面这道题的分析来进行理解。
有一列长度为 n 的路灯,每一次操作下标 l 到 r 的路灯的开关,使得每个路灯的状态发生改变(亮变灭 or 灭变亮)。所有灯初始时都是灭的,经过 m 次操作后,有 x 次查询,每一次查询操作给出 l r 两个下标,查询 l 到 r 中有多少盏路灯是亮着的。
在这道题目中,对于修改操作中的 l r,如果我们将这个区间里面每个路灯的状态逐一进行改变,时间复杂度肯定是不会令人满意的。但是我们可以对每个节点设置一个标记 sta,1 表示该节点所表示的区间内的所有灯都是亮着的,0 表示所有灯都是灭的,−1 表示该区间里既有灭的,又有亮的。
在这个题里,因为所有灯初始为灭的,且 sta 的默认值为 0,我们就可以取消建树操作。
Step 1,区间修改:
#define ll i<<1
#define rr (i<<1) + 1
void pushdown(int i, int tl, int tr) {//进行数据下传操作
int mid = (tl + tr) / 2;
if (tree[i].sta != 0) {
tree[ll].sta = !tree[ll].sta;
tree[rr].sta = !tree[rr].sta;
tree[ll].cnt = (mid - tl + 1) - tree[ll].cnt;
tree[rr].cnt = (tr - mid) - tree[rr].cnt;
tree[i].sta = 0;
}
}
void change(int i, int tl, int tr, int l, int r) {//进行区间修改
if (l > tr || r < tl)
return;
if (l <= tl && r >= tr) {
tree[i].sta = !tree[i].sta;
tree[i].cnt = tr - tl + 1 - tree[i].cnt;//若该区间被完全包含,那么直接将他进行修改,不再递归
return;
}
pushdown(i, tl, tr);//如果没有被完全包含,那么就需要继续递归处理,此时进行数据下传操作
int mid = (tl + tr) / 2;
change(ll, tl, mid, l, r);//左子树
change(rr, mid + 1, tr, l, r);//右子树
tree[i].cnt = tree[ll].cnt + tree[rr].cnt;//将儿子节点的信息返回到父亲节点,即数据上传操作
}
Step 2,区间查询:
int count(int i, int tl, int tr, int l, int r) {
if (tl > r || tr < l)//超出界限,无答案
return 0;
if (l <= tl && r >= tr)//若该区间被完全包含,直接返回整个区间的信息
return tree[i].cnt;
int ans = 0;
pushdown(i, tl, tr);//子树的信息可能未被更新,因此下传数据
int mid = (tl + tr) / 2;
ans += count(ll, tl, mid, l, r);
ans += count(rr, mid + 1, tr, l, r);//ans分别加上左子树和右子树的答案
return ans;
}
通过这两个题,我们能够更加深入地理解线段树,搞明白其中最重要的父节点数据下传,子节点数据上传等操作,这样有利于我们更清晰地进行线段树的处理。
最后,在面对线段树的题时,最重要的是分析到底该维护哪些信息,并如何进行转移。线段树其实从根本上说是一种思想,因此,我们无须拘泥于所谓的模板,根据题目灵活应对即可。