参考: 线段树 - OI Wiki
线段树是一种二叉搜索树、平衡二叉树,对于区间的修改、维护和查询时间复杂度优化为log级别。对区间不断做平分区间,直到划分到只有一个或多个数据的区间,可表示区间的和或者最小最大值。在进行更新区间操作时,通过小区间更新大区间。
对于下面的内容,我们主要针对于区间加法的线段树(即其节点表示区间之和)。
局限性:
问题需满足区间加法:对于[L,R]的区间,它的答案可以由[L,M]和[M+1,R]的答案合并求出。
不满足区间的众数、区间最长连续问题、最长不下降问题等。
当我们拥有数组 a a a 并且用来区间求和之类的问题时,我们可以这样做:
以堆的方式实现存储(树形数组),将元素放至树的最下面一层。
由于我们使用树形数组,为了存储下数组 a a a, 数组 t r e e tree tree 的长度应当为 a a a 的四倍,并且初始节点位置为1。同时,当前区间[s,t]只有一个元素时(即s==t),意味着当前节点为叶子节点,就要让当前节点 t r e e [ p ] = a [ s ] tree[p] = a[s] tree[p]=a[s]。对于其它的情况,我们求得当前区间的中间数来区分左子树和右子树,并使用递归创建左子树和右子树,最后将左孩子和右孩子节点的值之和赋值给当前节点。
显然,初始建树时使用build(1,n,1)
代码实现
const int N = 100000;
int a[N];
int tree[4*N];
void build(int s,int t,int p){
if(s == t){
tree[p] = a[s];
}else{
int m = s+((t-s)>>1); //使用位运算更快
build(s,m,p*2); //建立左子树
build(s,m,p*2+1); //建立右子树
tree[p] = tree[p*2]+tree[p*2+1];
}
}
我们假设目标区间为[l,r],当前区间为[s,t], 对目标区间的所有元素均加上值 k k k
由于是对目标区间的修改,很可能我们不用遍历完叶子节点,在当前区间刚好覆盖目标区间,或者目标区间由多个节点组成时,由于所有区间都是加上一个相同的值 k k k,我们可以修改结果为
修改完节点后记住要更新之前节点。
出于效率的需要,我们会用到懒惰标记 l a z y lazy lazy 将此区间标记,表示这个区间的值已经更新,但它的子区间却没有更新,更新的信息就是标记里存的值。
区间修改步骤:
int lazy[4*N];
void update(int l,int r,int k,int s,int t,int p){
if(l<=s && t<=r){
tree[p] += (t-s+1)*k;
lazy[p] += k;
}else{
int m = s+((t-s)>>1);
//检查是否要下传lazy标记
if(lazy[p]){ //注意是否p*2超过数组长度,可以用s!=t的条件限制
tree[p*2] += (m-s+1)*lazy[p];
tree[p*2+1] += (t-m)*lazy[p];
lazy[p*2] += lazy[p];
lazy[p*2+1] += lazy[p];
lazy[p] = 0; //清空标记
}
if(l<=m) update(l,r,s,m,1,p*2); //左孩子区间与目标区间有交集
if(r>m) update(l,r,m+1,t,p*2+1); //右孩子区间与目标区间右交集
tree[p] = tree[p*2] + tree[p*2+1]; //更新当前节点
}
}
我们想要得到 [ l , r ] [l,r] [l,r] 的区间和,和区间修改同理,在当前区间 [ s , t ] [s,t] [s,t] 被覆盖时,就返回对应值,否则就搜索当前节点的左孩子和有孩子(注意到,只有对应区间与目标区间有交集时才搜索),注意存在懒惰标记时需要下传。
步骤如下:
int getsum(int l,int r,int s,int t,int p){
if(l<=s && r<=t){
return tree[p];
}else{
int m = s + ((t-s) >> 1);
//检查是否要下传lazy标记
if(lazy[p]){ //注意是否p*2超过数组长度,可以用s!=t的条件限制
tree[p*2] += (m-s+1)*lazy[p];
tree[p*2+1] += (t-m)*lazy[p];
lazy[p*2] += lazy[p];
lazy[p*2+1] += lazy[p];
lazy[p] = 0; //清空标记
}
int sum = 0;
if(l<=m) sum=getsum(l,r,s,m,p*2);
if(r>m) sum+=getsum(l,r,s,m+1,p*2+1);
return sum;
}
}
// 线段树区间加法和区间求和
#include
using namespace std;
using ll = long long;
const ll N = 1000000;
ll a[N];
ll tr[4*N];
vector<ll> lazy(4*N,0);
//对区间[s,t]建树,p为当前节点编号
void build(ll s,ll t,ll p){ //建树
if(s == t){
tr[p] = a[s];
return;
}
else{
ll m = s + ((t-s)>>1);
build(s,m,p*2);
build(m+1,t,p*2+1);
tr[p] = tr[p*2] + tr[p*2+1];
}
}
//更新区间[l,r],[s,t]为当前节点区间,p为当前节点编号
void update(ll l, ll r, ll c, ll s,ll t,ll p){
if(l <= s && t <= r){
tr[p] += c*(t-s+1);
lazy[p] += c;
return;
}else{
ll m = s+((t-s)>>1);
if(lazy[p]){
tr[p*2] += lazy[p]*((m-s+1));
tr[p*2+1] += lazy[p]*((t-m));
lazy[p*2] += lazy[p];
lazy[p*2+1] += lazy[p];
lazy[p] = 0;
}
if(l <= m) update(l,r,c,s,m,p*2);
if(r > m) update(l,r,c,m+1,t,p*2+1);
tr[p] = tr[p*2] + tr[p*2+1];
}
}
//区间求和
ll getsum(ll l,ll r,ll s,ll t,ll p){
if(l <= s && t <= r){
return tr[p];
}
ll m = s + ((t-s)>>1);
ll sum = 0;
if(lazy[p]){
tr[p*2] += lazy[p]*((m-s+1));
tr[p*2+1] += lazy[p]*((t-m));
lazy[p*2] += lazy[p];
lazy[p*2+1] += lazy[p];
lazy[p] = 0;
}
if(l <= m) sum = getsum(l,r,s,m,p*2);
if(r > m) sum += getsum(l,r,m+1,t,p*2+1);
return sum;
}
int main(){
ll n,m;
cin >> n >> m;
for(ll i = 1; i <= n; i++){
cin >> a[i];
}
build(1,n,1);
while(m--){
ll op;
cin >> op;
//1表示区间加法,2表示区间求和
if(op == 1){
ll x,y,k;
cin >> x >> y >> k;
update(x,y,k,1,n,1);
}else if(op == 2){
ll x,y;
cin >> x >> y;
cout << getsum(x,y,1,n,1) << endl;
}
}
}
[P3870 TJOI2009] 开关 - 洛谷
对于这道题,我们知道:
同时,我们很容易发现对于区间 [ s , t ] [s,t] [s,t] 而言,区间灯开着的个数和灯关着的个数相加为 t − s + 1 t-s+1 t−s+1,于是乎,对于第一种操作"逆转灯泡"而言,我们只需要执行 t r e e [ p ] = ( t − s + 1 ) − t r e e [ p ] tree[p] = (t-s+1)-tree[p] tree[p]=(t−s+1)−tree[p] 即可。同理可得下传懒惰标记时的处理方法。
另一种不需要数学的解法是,我们同时存储 t r e e [ p ] tree[p] tree[p]逆转后的值,记为 c o n s t r a c t constract constract, 在build时令其为1,在更新区间和下传标记时使用swap函数交换两种。
/*伪代码,[l,r]为修改区间,[s,t]为当前区间,p为当前位置*/
void modify(l,r,s,t,p){
if(l<=s&&t<=r){
tree[p] = (t-s+1)-tree[p]; //或者 swap(tree[p],constract[p]);
/*这里lazy[p]该如何操作呢?*/
} else{
int m = s+((t-s)>>1);
if(lazy[p]){
tree[p*2] = (m-s+1) - tree[p*2]; //swap(tree[p*2],constract[p*2])
tree[p*2+1] = (t-m) - tree[p*2+1]; //swap(tree[p*2+1],constract[p*2+1]);
/*lazy的子节点该如何更新呢?*/
lazy[p] = false;
}
}
}
实际上,这道题的最关键点在于对懒惰标记 l a z y lazy lazy的处理。
我们很容易想到,由于灯泡只有两种状态,那么懒惰标记应该可以用 t r u e true true 和 f a l s e false false 表示, t r u e true true 表示该区间已逆转,当搜索当该区间时下传区间。当然, f a l s e false false表示该区间未被逆转。
这是对的,但是在实际操作中我们也许会发现这样的错误:在更新区间的最后标记 l a z y [ p ] lazy[p] lazy[p] 为 t r u e true true ;在下传 l a z y [ p ] lazy[p] lazy[p]时,令其孩子节点的 l a z y lazy lazy 值为 t r u e true true或者说 l a z y [ p ] lazy[p] lazy[p]。
实际上,当我们重复更新相同区间时, 之前已经为 t r u e true true 的 l a z y lazy lazy 会被再次被赋值为 t r u e true true, 但可能 t r e e [ p ] tree[p] tree[p]的值已经恢复原样了。同理,当我们下传懒惰标记时, 很可能其孩子节点已经为 t r u e true true, 从而造成了下传的混乱。我们很容易使用if条件解决,但为了方便起见,我们用0和1表示懒惰标记,并用异或操作^进行处理。
解决代码如下:
#include
using namespace std;
//开数组
const int N = 1000000;
int constract[4*N];
int tree[4*N]; //线段树
vector<int> lazy(4*N,0); //懒标记
//线段树的建立
void build(int s, int t, int p){
if(s == t){
tree[p] = 0; // 初始灯泡关闭
}else{
int m = s+((t-s)>> 1);
build(s, m, p*2);
build(m+1, t, p*2+1);
tree[p] = tree[p*2] + tree[p*2+1];
}
}
//区间修改,目标区间[l,r],[s,t]为当前区间,将目标区间的值逆转
void modify(int l,int r,int s, int t,int p){
if(l<=s && t<=r){
tree[p] = (t-s+1)-tree[p]; //逆转灯泡
lazy[p]^=1; //逆转状态
}
else{
int m = s+((t-s)>>1);
if(lazy[p]){ //将懒标记下传
tree[2*p] = (m-s+1) - tree[p*2];
tree[2*p+1] = (t-m) - tree[p*2+1];
lazy[p*2] ^= lazy[p];
lazy[p*2+1] ^= lazy[p];
lazy[p] = 0; //将标记清空
}
if(l<=m) modify(l,r,s,m,p*2); //部分元素被包含在左孩子,搜索左子树
if(r>m)modify(l,r,m+1,t,p*2+1); //部分元素被包含在右孩子,搜索右子树
tree[p] = tree[p*2] + tree[p*2+1]; //修改后,记得更新节点
}
}
//查询区间[l,r]的总和,即区间中亮着的灯泡数
int getsum(int l, int r,int s, int t,int p){
int res = 0;
if(l<=s && t<=r){
res = tree[p];
}else{
int m = s+((t-s)>>1);
if(lazy[p]){ //将懒标记下传
tree[2*p] = (m-s+1) - tree[p*2];
tree[2*p+1] = (t-m) - tree[p*2+1];
lazy[p*2] ^= lazy[p];
lazy[p*2+1] ^= lazy[p];
lazy[p] = 0; //将标记清空
}
if(l<=m) res += getsum(l,r,s,m,p*2);
if(r>m)res += getsum(l,r,m+1,t,p*2+1);
}
return res;
}
int main(){
int n,m;
cin >> n >> m;
//建立线段树
build(1,n,1);
for(int c=0;c<m;c++){
int op,a,b;
cin >> op >> a >> b;
if(op == 0){
modify(a,b,1,n,1); //逆转灯泡状态
}else if(op == 1){
cout << getsum(a,b,1,n,1) << endl; //输出结果
}
}
}
核心:结点只有被需要时才创建
前面的线段树都是以树形数组或者说堆式存储的方式实现的,这就导致我们需要花费 4 n 4n 4n 大小的空间开销。为了节省空间,我们可以不一次性建好数,而是在最初建立一个根节点代表整个区间,只有当我们需要访问某个子区间并且这个子区间未被建立时,才建立代表这个区间的子结点。这样我们不再使用 2 p 2p 2p 和 2 p + 1 2p+1 2p+1 代表p结点的儿子,而是使用 l s ls ls 和 r s rs rs 数组记录儿子的编号。
#include
using namespace std;
int n,cnt,root;
int sum[n*2],ls[n*2],rs[n*2];
/*Q:
为什么不同于之前,p要被引用传参
A:
因为这里的p实际上是被ls或rs中的元素赋值的,
引用传参便能修改数组中的值。
当我们需要创建结点时,一定要将结点p的编号
传送到对应的数组中,防止查询时p为空
同时注意,p为空可能存在于线段树的创建中,仍未遍历到x结点
不应当返回,而应该继续遍历至对应结点x创建完毕
*/
void update(int &p,int s,int t,int x,int f){
if(!p){ //结点为空(由于ls和rs被初始化为0,如果没有被赋值则为空结点(最小结点编号为根节点1))
p = ++cnt; //创建新结点
}
if(s==t){
sum[p]+=f;
return;
}
int m = s+((t-s)>>1);
if(x<=m) update(ls[p],s,m,x,f);
else update(rs[p],m+1,t,x,f);
sum[p] = sm[ls[p]] + sm[rs[p]];
}
int query(int p,int s,int t,int l,int r){
if(!p) return 0; //结点为空
if(l<=s && t<=r){
return sum[p];
}else{
int m = s+((t-s)>>1);
int ans = 0;
if(l<=m) ans+=query(ls[p],s,m,l,r);
if(r>m) ans+= query(rs[p],m+1,l,r);
return ans;
}
}