[算法学习笔记] 线段树
概述:本文将介绍 线段树 的基本内容及可持久化操作。在最新版 NOI 大纲上,可持久化部分是 NOI级 算法,备考CSP-J/S NOIP 的读者可选择性学习。
Update:
2024/06/28 更新了可持久化部分内容。
2024/07/04 更新了线段树染色。
2024/08/24 更新了离散化,动态开点。
2024/08/27 更新了线段树扫描线,维护前缀最大值。
前言
-
线段树可以维护一系列区间问题。包括但不限于区间加,区间乘,区间最值等。
-
本文介绍的多为模板性内容。日后可能视情况更新题目解析。
-
本文可能持续更新。
普通线段树
例题
给定一个数列,需要支持如下两种操作:
\(1\) \(x\) \(y\) \(k\) 将区间 \([x,y]\) 中每一个数加 \(k\) .
\(2\) \(x\) \(y\) 查询区间 \([x,y]\) 的和.
\(1\le n,m\le 10^5\)
容易发现,若本题区间修改和区间查询操作分离,可以使用前缀和查分解决。但当区间修改和查询操作混在一起时,显然前缀和无法解决问题。
Description
线段树是一棵平衡二叉树,在本题中,每个节点存储了区间 \([l,r]\) 的和。左儿子和右儿子分别存储 \([l,mid]\),\([r,mid]\) 的和。(\(mid = (l+r)\div 2\)).
build
容易发现,线段树的建立可以递归进行。线段树的 root 定义为区间 \([1,n]\) 的区间和。参考代码如下:
build 操作
void build(ll l,ll r,ll p) // l,r 表示当前区间;p表示当前节点编号,参考二叉树的建立。
{
if(l == r) // 到达叶子赋值
{
tree[p] = a[l];
return;
}
ll mid = (l+r) / 2;
build(l,mid,p*2);
build(mid+1,r,p*2+1);
tree[p] = tree[p*2] + tree[p*2+1]; // 回溯统计区间和
}
modify
这里引入“懒标记” 的概念(即 lazy tag),可以理解为懒惰修改。
具体地,对于区间 \([L,R]\) 的修改,朴素做法是不断递归,复杂度太高无法接受。若设当前访问区间为 \([l,r]\) ,当满足 \(l\geq L\) 且 \(r \le R\) 时,也就是区间 \([l,r]\) 是区间 \([L,R]\) 的子集。直接修改即可。同时标记当前区间的所有值均需修改。不必向下递归修改。
毕竟很多时候,我们的询问不需要将区间划分如此详细,等到询问需要将当前区间细分的时候再下放 lazy tag 修改即可。
反之,当区间 \([l,r]\) 去区间 \([L,R]\) 有交集。我们不得不将区间 \([l,r]\) 拆分,递归求解。此时将区间的 lazy tag 下放。我们只下方一层即可,等后期需要的时候再继续下放。
别忘了将原区间的 lazy tag 删除,因为已经下放到子节点,避免重复修改。
lazy tag 的下放只需要一层,等下面区间再需要继续分解时再继续下放(可以理解为不到迫不得已不干吧!懒标记是线段树的精髓!)
事实上,线段树的 pushdown 操作每道题目处理都不一样,但核心思想是一样的。理解了为什么 pushdown 是关键。下文给出了少量简单线段树例题。
参考代码如下
modify 操作
void pushdown(ll p,ll len) // 下放 lazy tag操作
{
mark[p*2] += mark[p];
mark[p*2+1] += mark[p];
tree[p*2] += (len-len/2)*mark[p];
tree[p*2+1] += (len/2)*mark[p];
mark[p] = 0;
}
void update(ll l,ll r,ll pl,ll pr,ll p,ll d)
{
if(pl > r || pr < l) return; //当前区间与所求区间没有交集,不操作
if(pl >= l && pr <= r) // 当前区间是所求区间的子集,直接修改,标记lazy tag
{
tree[p] += (pr-pl+1) * d;
if(l < r) mark[p] += d;
}
else //当前区间与所求区间有交集,将当前区间拆分,递归修改
{
ll mid = (pl+pr) / 2;
pushdown(p,(pr-pl+1)); // 下放 lazy tag
update(l,r,pl,mid,p*2,d);
update(l,r,mid+1,pr,p*2+1,d);
tree[p] = tree[p*2] + tree[p*2+1]; // 儿子更新完后重新赋值父节点
}
}
query
查询操作类比区间修改。见代码。
在查询的时候,如果当前区间需要继续分解,别忘了下放 lazy tag,同时将子节点的值更改。
query 操作
ll query(ll l,ll r,ll pl,ll pr,ll p)
{
if(pl > r || pr < l) return 0;
if(pl >= l && pr <= r) return tree[p]; // 当前区间是所求区间的子集,直接返回值
else
{
ll mid = (pl+pr) / 2;
return query(l,r,pl,mid,p*2) + query(l,r,mid+1,pr,p*2+1); // 直接递归查询
}
}
以上是区间修改区间查询操作,完整代码如下.
区间修改,区间查询模板(2024.11.12 修改)
struct segtree
{
#define ls p<<1
#define rs p<<1|1
int tree[N*8],mark[N*8];
void pushup(int p){tree[p] = tree[ls] + tree[rs];}
void pushdown(int p,int len)
{
if(!mark[p]) return;
tree[ls] += mark[p] * (len-len/2),tree[rs] += mark[p] * (len/2);
mark[ls] += mark[p],mark[rs] += mark[p];
mark[p] = 0;
return;
}
void build(int l = 1,int r = n,int p = 1)
{
if(l == r){tree[p] = a[l];return;}
int mid = (l + r) >> 1;
build(l,mid,ls),build(mid+1,r,rs);
pushup(p);
}
void modify(int l,int r,int pl,int pr,int p,int d)
{
if(l >= pl && r <= pr) {tree[p] += (r-l+1)*d;mark[p] += d;return;}
int mid = (l + r) >> 1;
pushdown(p,r-l+1);
if(pl <= mid) modify(l,mid,pl,pr,ls,d);
if(pr > mid) modify(mid+1,r,pl,pr,rs,d);
pushup(p);
}
int query(int l,int r,int pl,int pr,int p)
{
if(l >= pl && r <= pr)return tree[p];
int mid = (l+r) >> 1,sum = 0;
pushdown(p,r-l+1);
if(pl <= mid) sum += query(l,mid,pl,pr,ls);
if(pr > mid) sum += query(mid+1,r,pl,pr,rs);
return sum;
}
} seg;
动态开点
朴素线段树空间要开 \(4\) 倍。在一些题目中,这是无法接受的。\(p\times 2,p\times 2+1\) 的编号方式有很大的局限性。
注意到线段树并不是所有节点都有用,我们不妨修改时,若当前点没有开辟,才新建节点。这样线段树所有节点都是必要的。而且省空间。
build
void update(int l,int r,int pos,int &p,int d)
{
if(!p) p = ++cnt; // 若未开辟,新建节点
if(l == r)
{
tree[p].sum = d;
return;
}
int mid = (l+r) >> 1;
if(pos <= mid) update(l,mid,pos,tree[p].l,d);
if(pos > mid) update(mid+1,r,pos,tree[p].r,d);
pushup(p);
}
如你所见,动态开点线段树 build
操作采用 单点修改 方式。
modify
void modify(int l,int r,int pl,int pr,int &p,int d)
{
if(!p) p = ++cnt; // 若未开辟,新建节点
if(l >= pl && r <= pr)
{
tree[p].sum += (r-l+1)*d;
tree[p].tag += d;
return;
}
int mid = (l+r) >> 1;
pushdown(p,l,r);
if(pl <= mid)
modify(l,mid,pl,pr,tree[p].l,d);
if(pr > mid)
modify(mid+1,r,pl,pr,tree[p].r,d);
pushup(p);
}
除了动态开点部分其余都是一样的。
pushdown
void pushdown(int p,int l,int r)
{
if(!tree[p].tag) return;
if(!tree[p].l) tree[p].l = ++cnt; // pushdown 时也要动态开点 先开完了再操作
if(!tree[p].r) tree[p].r = ++cnt;
int mid = (l+r) >> 1;
tree[tree[p].l].sum +=(mid-l+1)*tree[p].tag;
tree[tree[p].r].sum += (r-mid)*tree[p].tag;
tree[tree[p].l].tag += tree[p].tag;
tree[tree[p].r].tag += tree[p].tag;
tree[p].tag = 0;
}
query
int query(int l,int r,int pl,int pr,int p)
{
if(!p) return 0; // 如果没有这个点直接返回 0
if(l >= pl && r <= pr) return tree[p].sum;
int mid = (l+r) >> 1;
pushdown(p,l,r);
int ans = 0;
if(pl <= mid) ans += query(l,mid,pl,pr,tree[p].l);
if(pr > mid) ans += query(mid+1,r,pl,pr,tree[p].r);
return ans;
}
动态开点线段树模板(线段树1)
struct segtree
{
int cnt = 1;
struct TREE
{
int l,r,sum,tag;
}tree[N];
void pushup(int &p) {tree[p].sum = tree[tree[p].l].sum + tree[tree[p].r].sum;}
void pushdown(int p,int l,int r)
{
if(!tree[p].tag) return;
if(!tree[p].l) tree[p].l = ++cnt;
if(!tree[p].r) tree[p].r = ++cnt;
int mid = (l+r) >> 1;
tree[tree[p].l].sum +=(mid-l+1)*tree[p].tag;
tree[tree[p].r].sum += (r-mid)*tree[p].tag;
tree[tree[p].l].tag += tree[p].tag;
tree[tree[p].r].tag += tree[p].tag;
tree[p].tag = 0;
}
void update(int l,int r,int pos,int &p,int d)
{
if(!p) p = ++cnt;
if(l == r)
{
tree[p].sum = d;
return;
}
int mid = (l+r) >> 1;
if(pos <= mid) update(l,mid,pos,tree[p].l,d);
if(pos > mid) update(mid+1,r,pos,tree[p].r,d);
pushup(p);
}
void modify(int l,int r,int pl,int pr,int &p,int d)
{
if(!p) p = ++cnt;
if(l >= pl && r <= pr)
{
tree[p].sum += (r-l+1)*d;
tree[p].tag += d;
return;
}
int mid = (l+r) >> 1;
pushdown(p,l,r);
if(pl <= mid)
modify(l,mid,pl,pr,tree[p].l,d);
if(pr > mid)
modify(mid+1,r,pl,pr,tree[p].r,d);
pushup(p);
}
int query(int l,int r,int pl,int pr,int p)
{
if(!p) return 0;
if(l >= pl && r <= pr) return tree[p].sum;
int mid = (l+r) >> 1;
pushdown(p,l,r);
int ans = 0;
if(pl <= mid) ans += query(l,mid,pl,pr,tree[p].l);
if(pr > mid) ans += query(mid+1,r,pl,pr,tree[p].r);
return ans;
}
}seg;
线段树区间离散化
将所有区间端点离散化后映射一下即可。
参考代码
void discretizing()
{
sort(val+1,val+cnt+1);
int x = unique(val+1,val+cnt+1)-val-1;
sort(opt+1,opt+n+1);
for(int i=1;i<=n;i++)
{
opt[i].l = lower_bound(val+1,val+x+1,opt[i].l)-val;
opt[i].r = lower_bound(val+1,val+x+1,opt[i].r)-val;
minl = min(minl,opt[i].l); // 记录区间两端点
maxr = max(maxr,opt[i].r);
}
}
简单的进阶操作
1. 区间加乘
Description
如题,已知一个数列,你需要进行下面三种操作:
- 将某区间每一个数乘上 \(x\);
- 将某区间每一个数加上 \(x\);
- 求出某区间每一个数的和。
同样使用线段树维护区间值,只是在 pushdown 和 modify 操作中,需要注意优先级。必须确保 “先乘后加”。
(摘自算法学习笔记 线段树Trick)
然后就是常规的线段树写法,区间加和区间乘是两个独立的 lazy tag,显然。
参考代码
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
#define ll long long
#define lc p<<1
#define rc p<<1|1
using namespace std;
const int N = 100005;
struct Node
{
ll v,lz1=1,lz2;
}tree[N<<2];
int n,q,m;
int a[N];
void build(int l,int r,int p)
{
if(l == r)
{
tree[p].v = a[l];
return;
}
int mid = (l+r) / 2 ;
build(l,mid,lc);
build(mid+1,r,rc);
tree[p].v = tree[lc].v + tree[rc].v;
}
void pushdown(int p,int l,int r)
{
int mid=l+r>>1;
ll &lz1=tree[p].lz1,&lz2=tree[p].lz2;
if(lz1!=1){
tree[lc].lz2=tree[lc].lz2*lz1%m;
tree[rc].lz2=tree[rc].lz2*lz1%m;
tree[lc].lz1=tree[lc].lz1*lz1%m;
tree[rc].lz1=tree[rc].lz1*lz1%m;
tree[lc].v=tree[lc].v*lz1%m;
tree[rc].v=tree[rc].v*lz1%m;
lz1=1;
}
if(lz2){
tree[lc].v=(tree[lc].v+(mid-l+1)*lz2)%m;
tree[lc].lz2=(tree[lc].lz2+lz2)%m;
tree[rc].v=(tree[rc].v+(r-mid)*lz2)%m;
tree[rc].lz2=(tree[rc].lz2+lz2)%m;
lz2=0;
}
}
void update1(int l,int r,int pl,int pr,int d,int p)
{
if(pl > r || pr < l) return;
if(pl >= l && pr <= r)
{
tree[p].lz1 = tree[p].lz1 * d % m;
tree[p].lz2 = tree[p].lz2 * d % m;
tree[p].v = (tree[p].v *d) % m;
return;
}
int mid = (pl+pr) >> 1;
pushdown(p,pl,pr);
update1(l,r,pl,mid,d,lc);
update1(l,r,mid+1,pr,d,rc);
tree[p].v = tree[lc].v + tree[rc].v;
}
void update2(int l,int r,int pl,int pr,int d,int p)
{
if(pl > r || pr < l) return;
if(pl >= l && pr <= r)
{
tree[p].lz2 = (tree[p].lz2 + d) % m;
tree[p].v = (tree[p].v + (pr-pl+1)*d) % m;
return;
}
int mid = (pl+pr) >> 1;
pushdown(p,pl,pr);
update2(l,r,pl,mid,d,lc);
update2(l,r,mid+1,pr,d,rc);
tree[p].v = (tree[lc].v + tree[rc].v) % m;
}
long long query(int l,int r,int pl,int pr,int p)
{
if(pl > r || pr < l) return 0;
if(pl >= l && pr <= r)
{
return tree[p].v;
}
int mid = (pl+pr) >> 1;
pushdown(p,pl,pr);
return (query(l,r,pl,mid,lc) + query(l,r,mid+1,pr,rc))%m;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>q>>m;
for(int i=1;i<=n;i++)
{
cin>>a[i];
}
build(1,n,1);
while(q--)
{
int flg,x,y,k;
cin>>flg;
if(flg == 1)
{
cin>>x>>y>>k;
update1(x,y,1,n,k,1);
}
if(flg == 2)
{
cin>>x>>y>>k;
update2(x,y,1,n,k,1);
}
else if(flg == 3)
{
cin>>x>>y;
cout<<query(x,y,1,n,1)<<endl;
}
}
return 0;
}
2.区间最值
前文提到,线段树可以维护区间型问题。容易发现区间 \([L,R]\) 的最值是 \(\max([L,mid],[mid+1,R])\)。可以使用线段树维护。
静态 RMQ 问题
对于静态 RMQ 问题,可以使用 ST 表维护。参见 算法学习笔记 ST表
这里介绍使用线段树维护静态区间最值。
首先建立线段树,存储区间 \([l,r]\) 的最值。
build
void build(int l,int r,int p)
{
if(l == r)
{
tree[p] = a[l];
return;
}
int mid = (l+r) / 2;
build(l,mid,p*2);
build(mid+1,r,p*2+1);
tree[p] = max(tree[p*2],tree[p*2+1]);
}
上文提到基本原理。我们维护区间 \([l,r]\) 的最值。对于查询,如果当前区间 \([l,r]\) 是所求区间 \([L,R]\) 的子集,直接返回。如果有交集,则拆分,分别求解。容易发现和查询区间和操作是类似的。
query
int query(int l,int r,int ll,int rr,int p)
{
if(l > rr || r > ll) return INF; //没有交集
int mid = (l+r) / 2;
if(l>=ll && r<= rr) return tree[p];
else return min(query(l,mid,ll,rr,p*2),query(mid+1,r,ll,rr,p*2+1));
}
动态 RMQ 问题
常见的动态 RMQ 问题一般是在静态 RMQ 的基础上添加了单点修改,这就使得我们必须要更新区间最值。
对于单点修改,搜索即可。类似于二分查找,如果需要修改的未知 \(x \le mid\) ,则递归左区间,否则递归右区间。
对于边界判定,如果到达叶子且当前位置是所需修改的位置,直接更改。回溯时更改区间最值即可。
modify
void modify(int l,int r,int p,int x,int y) //将 a_x 修改为y
{
if(l == r && l == x) tree[p] = y,return;
int mid = (l+r) / 2;
if(r <= mid) modify(l,mid,p*2,x);
else modify(mid+1,r,p*2+1,x);
tree[p] = max(tree[p*2],tree[p*2+1]);
}
这里提供线段树解决动态 RMQ 问题模板,供参考。
模板
#include <bits/stdc++.h>
using namespace std;
const int N = 10000010;
int tree[N];
int n;
int a[N];
void build(int p,int l,int r)
{
if(l == r)
{
tree[p] = a[l];
return;
}
int mid = (l+r) / 2;
build(p*2,l,mid);
build(p*2+1,mid+1,r);
tree[p] = max(tree[p*2],tree[p*2+1]);
}
void modify(int l,int r,int p,int x,int y)
{
int mid = (l+r) / 2;
if(l == r && l == x)
{
tree[p] = y;
return;
}
if(x <= mid) modify(l,mid,p*2,x,y);
else modify(mid+1,r,p*2+1,x,y);
tree[p] = max(tree[p*2],tree[p*2+1]);
}
int query(int l,int r,int p,int L,int R)
{
if(r < L || l > R) return -1;
if(l >= L && r <= R) return tree[p];
else
{
int mid = (l+r) / 2;
return max(query(l,mid,p*2,L,R),query(mid+1,r,p*2+1,L,R));
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
build(1,1,n);
int q;
cin>>q;
while(q--)
{
int op,x,y;
cin>>op;
if(op == 1)
{
cin>>x>>y;
modify(1,n,1,x,y);
}
else
{
cin>>x>>y;
cout<<query(1,n,1,x,y)<<endl;
}
}
return 0;
}
事实上,线段树使用时,应用结构体将节点封装是一种更好的选择。这里不再赘述。
一些例题 (可能会随时更新)
线段树维护异或
注意到本题的修改操作是区间取反。询问给定区间内 1 的个数。而对同一个区间进行偶数次取反是无效的。考虑异或。
线段树维护区间内 1 的个数。这是比较常见的思路,题目需要求什么就维护什么。lazy tag 标记当前所有的数是否需要被取反。传递的时候需要注意,lazy tag 需要进行异或操作,因为偶数次取反和不操作是一样的。不能进行简单的赋值。
其他的就是基本的线段树操作。
Code
#include <bits/stdc++.h>
using namespace std;
const int N = 10000010;
int tree[N];
int mark[N];
int n,m;
void pushdown(int p,int l,int r)
{
if(!mark[p]) return; //如果下面的区间不需要取反就不操作!
mark[p*2] ^= 1;// 传递
mark[p*2+1] ^= 1;
int mid = (l+r) / 2;
tree[p<<1]=(mid-l+1)-tree[p<<1]; // 修改下一层
tree[p<<1|1]=(r-mid)-tree[p<<1|1];
mark[p] = 0; // 删除 lazy tag
}
void modify(int l,int r,int p,int L,int R)
{
if(l > R || r < L) return;
if(l >= L && r <= R)
{
tree[p] = (r-l+1)-tree[p]; //当前区间是所求区间的子集,进行取反,同时修改lazy tag
mark[p] ^= 1;
return;
}
int mid = (l+r) / 2;
pushdown(p,l,r);
modify(l,mid,p*2,L,R);
modify(mid+1,r,p*2+1,L,R);
tree[p] = tree[p*2] + tree[p*2+1];
}
int query(int l,int r,int p,int L,int R)
{
if(l > R || r < L) return 0;
if(l >= L && r <= R)
{
return tree[p];
}
else
{
int mid = (l+r) / 2;
pushdown(p,l,r);
return query(l,mid,p*2,L,R) + query(mid+1,r,p*2+1,L,R);
}
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
while(m--)
{
int op,a,b;
cin>>op>>a>>b;
if(!op)
{
modify(1,n,1,a,b);
}
else
{
cout<<query(1,n,1,a,b)<<endl;
}
}
return 0;
}
(其实本题是多倍经验,自己去看讨论区吧)
线段树维护差分
Description
维护一个数列 \(a_i\),支持两种操作:
-
1 l r K D
:给出一个长度等于 \(r-l+1\) 的等差数列,首项为 \(K\),公差为 \(D\),并将它对应加到 \([l,r]\) 范围中的每一个数上。即:令 \(a_l=a_l+K,a_{l+1}=a_{l+1}+K+D\ldots a_r=a_r+K+(r-l) \times D\)。 -
2 p
:询问序列的第 \(p\) 个数的值 \(a_p\)。
对于 \(100\%\) 数据,\(0\le n,m \le 10^5,-200\le a_i,K,D\le 200, 1 \leq l \leq r \leq n, 1 \leq p \leq n\)。
一看数据范围 1e5,数据结构题。
和普通线段树不同的是,本题是给区间 \([l,r]\) 加上一个等差数列。我们知道等差数列的公差是相同的,因此,我们可以在差分数组上进行更改。
举个例子。
原序列:0 0 0 0 0 0
差分序列:0 0 0 0 0 0
等差序列:1 3 5 7 9
加上等差数列后的序列:1 3 5 7 9 0
差分:1 2 2 2 2 -9
我们发现,在原始序列 \([l,r]\) 加上等差数列,我们只需要在对应的差分数组 \(a_l\) 加上首项,\(a_{l+1}-a_{r}\) 加上公差 \(d\),在 \(a_{r+1}\) 减去尾项即可。
为什么呢?
显然,一个等差数列的差分数组元素一定相同。因此我们可以在差分数组 \(a_{l+1}-a_{r}\) 加上公差。最后别忘了在尾项的下一位减去,消除影响。
最后询问的时候前缀和即可。剩下的就是线段树模板。
Code
#include <bits/stdc++.h>
#define ll long long
#define int long long
using namespace std;
const int N = 1000010;
int a[N],tree[N],c[N];
int mark[N];
int n,m;
void pushdown(ll p,ll len)
{
mark[p*2] += mark[p];
mark[p*2+1] += mark[p];
tree[p*2] += (len-len/2)*mark[p];
tree[p*2+1] += (len/2)*mark[p];
mark[p] = 0;
}
void build(ll l,ll r,ll p)
{
if(l == r)
{
tree[p] = a[l];
return;
}
ll mid = (l+r) / 2;
build(l,mid,p*2);
build(mid+1,r,p*2+1);
tree[p] = tree[p*2] + tree[p*2+1];
}
void update(ll l,ll r,ll pl,ll pr,ll p,ll d)
{
if(pl > r || pr < l) return;
if(pl >= l && pr <= r)
{
tree[p] += (pr-pl+1) * d;
if(l < r) mark[p] += d;
}
else
{
ll mid = (pl+pr) / 2;
pushdown(p,(pr-pl+1));
update(l,r,pl,mid,p*2,d);
update(l,r,mid+1,pr,p*2+1,d);
tree[p] = tree[p*2] + tree[p*2+1];
}
}
ll query(ll l,ll r,ll pl,ll pr,ll p)
{
if(pl > r || pr < l) return 0;
if(pl >= l && pr <= r) return tree[p];
else
{
ll mid = (pl+pr) / 2;
pushdown(p,(pr-pl+1));
return query(l,r,pl,mid,p*2) + query(l,r,mid+1,pr,p*2+1);
}
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>a[i];
}
for(int i=n-1;i>0;i--) a[i+1] = a[i+1] - a[i];
build(1,n,1);
while(m--)
{
int op,l,r,k,d;
cin>>op;
if(op == 1)
{
cin>>l>>r>>k>>d;
update(l,l,1,n,1,k);
update(l+1,r,1,n,1,d);
int t = -(k+d*(r-l));
// cout<<t<<"qq"<<endl;
update(r+1,r+1,1,n,1,-(k+d*(r-l)));
}
else
{
int p;
cin>>p;
cout<<query(1,p,1,n,1)<<endl;
}
}
}
P1712 [NOI2016] 区间
在数轴上有 \(n\) 个闭区间从 \(1\) 至 \(n\) 编号,第 \(i\) 个闭区间为 \([l_i,r_i]\) 。
现在要从中选出 \(m\) 个区间,使得这 \(m\) 个区间共同包含至少一个位置。换句话说,就是使得存在一个 \(x\) ,使得对于每一个被选中的区间 \([l_i,r_i]\),都有 \(l_i \leq x \leq r_i\) 。
对于一个合法的选取方案,它的花费为被选中的最长区间长度减去被选中的最短区间长度。
区间 \([l_i,r_i]\) 的长度定义为 \((r_i-l_i)\) ,即等于它的右端点的值减去左端点的值。
求所有合法方案中最小的花费。如果不存在合法的方案,输出 \(-1\)。
对于全部的测试点,保证 \(1 \leq m \leq n\),\(1 \leq n \leq 5 \times 10^5\),\(1 \leq m \leq 2 \times 10^5\),\(0 \leq l_i \leq r_i \leq 10^9\)。
Solution
发现答案和区间长度有关,那我们不妨先排序,反正这不影响答案。
排序后,我们暴力地加入区间,如果一个点被覆盖了超过 \(m\) 次,再往后加显然不会更优,我们移动左端点,不断撤销区间,直到不满足题意。再移动右端点。容易发现右端点一定单调移动,不可能回退。这里运用了双指针的思想。
如何维护区间呢?用线段树即可。线段树一个区间维护区间内所有点被覆盖最大值,查询时只需查 \(tree_1\) 即可。容易发现这玩意可以合并,且可以整体打 lazy tag。故复杂度是对的。
这里还练习了线段树区间离散化,其实非常简单,把所有单点离散化后映射到区间上即可。具体实现见代码。
Code
namespace solution
{
struct segtree
{
#define ls p<<1
#define rs p<<1|1
struct TREE
{
int sum,maxn;
}tree[N];
void modify_dot(int p,int d){tree[p].maxn += d;tree[p].sum += d;}
void pushup(int p){tree[p].maxn = max(tree[ls].maxn,tree[rs].maxn);}
void pushdown(int p)
{
if(!tree[p].sum) return;
modify_dot(ls,tree[p].sum);
modify_dot(rs,tree[p].sum);
tree[p].sum = 0;
}
void modify(int l,int r,int pl,int pr,int p,int d)
{
if(l > pr || r < pl) return;
if(l >= pl && r <= pr)
{
modify_dot(p,d);
return;
}
int mid = (l+r) >> 1;
pushdown(p);
modify(l,mid,pl,pr,ls,d);
modify(mid+1,r,pl,pr,rs,d);
pushup(p);
}
}seg;
struct Node
{
int l,r,len;
bool operator <(const Node& a)const{
return len < a.len;
}
}opt[N];
int n,m,cnt = 0;
int val[N];
int minl = INF,maxr = 0;
void discretizing()
{
sort(val+1,val+cnt+1);
int x = unique(val+1,val+cnt+1)-val-1;
sort(opt+1,opt+n+1);
for(int i=1;i<=n;i++)
{
opt[i].l = lower_bound(val+1,val+x+1,opt[i].l)-val;
opt[i].r = lower_bound(val+1,val+x+1,opt[i].r)-val;
minl = min(minl,opt[i].l);
maxr = max(maxr,opt[i].r);
}
}
void solve()
{
int ans = INF;
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>opt[i].l>>opt[i].r;
opt[i].len = opt[i].r - opt[i].l;
val[++cnt] = opt[i].l,val[++cnt] = opt[i].r;
}
discretizing();
int nowl = 1;
for(int i=1;i<=n;i++)
{
seg.modify(minl,maxr,opt[i].l,opt[i].r,1,1);
while(seg.tree[1].maxn >= m)
{
ans = min(ans,opt[i].len-opt[nowl].len);
seg.modify(minl,maxr,opt[nowl].l,opt[nowl].r,1,-1);
nowl ++;
}
}
cout<<((ans == INF?-1:ans))<<endl;
return;
}
}
线段树染色
我们先看一道例题。Luogu P3740
Description
给定一个长度为 \(n\) 的序列 \(a\),共有 \(q\) 次操作,操作 \(i\) 对区间 \([l,r]\) 涂上颜色 \(i\)。涂的颜色可以覆盖。求所有操作完毕后序列上可以看到的颜色数量。
一道非常经典的线段树染色问题。
染色线段树和普通线段树非常类似,只是在某些操作上略有不同,这里讲解一下。
在线段树染色中,同样令 \(mark_p\) 表示当前区间 \(p\) 需要染什么颜色。还是同样的 lazy tag。令 \(tree_p \in\{-1,0,c\}\) 表示当前区间颜色状态。( \(-1\) 表示当前区间有多种颜色,\(0\) 表示无色,\(c\) 是一个常量,表示当前区间全部被染成了 \(c\) 颜色)。
此类问题往往初始线段树都为空,当然也不乏有初始颜色的情况,这里不谈。
modify 操作
先放代码。
染色 modify
void modify(int l,int r,int pl,int pr,int p,int d)
{
if(l > pr || r < pl) return;
if(l >= pl && r <= pr)
{
tree[p] = d;
mark[p] = d;
return;
}
int mid = (l+r) >> 1;
pushdown(p,r-l+1);
modify(l,mid,pl,pr,p*2,d);
modify(mid+1,r,pl,pr,p*2+1,d);
if(tree[p*2] == tree[p*2+1]) tree[p] = tree[p*2];
else tree[p] = -1;
}
如你所见,和普通线段树几乎一模一样。只是在修改的时候直接覆盖,lazy tag 也直接覆盖。pushup 若左右子树都是同一种颜色,则传递,否则标记为 \(-1\)。(若左右子树都是 \(-1\) 那也传递成 \(-1\) 了)。区间被标记成 \(-1\) 的,在查询操作的时候继续向下递归。
pushdown
pushdown
void pushdown(int p,int len)
{
if(!mark[p]) return;
mark[p*2] = mark[p];
mark[p*2+1] = mark[p];
tree[p*2] = mark[p];
tree[p*2+1] = mark[p];
mark[p] = 0;
}
传递的时候非常暴力,直接覆盖即可。
query
query
void query(int l,int r,int pl,int pr,int p)
{
if(l > pr || r < pl) return;
else if(!tree[p]) return;
else if(tree[p] == -1)
{
int mid = (l+r) >> 1;
pushdown(p,r-l+1);
query(l,mid,pl,pr,p*2);
query(mid+1,r,pl,pr,p*2+1);
if(tree[p*2] == tree[p*2+1]) tree[p] = tree[p*2];
else tree[p] = -1;
}
else
{
if(!vis[tree[p]])
{
ans ++;
vis[tree[p]] = 1;
}
return;
}
}
看着略微复杂。在查询的时候,若当前区间都是单一的颜色,直接统计即可。若被标记为 \(-1\),我们 pushdown 后进行左右子树查询,然后 pushup(和 modify 同理)。这里统计答案为了避免重复,开了一个 vis 数组来判定当前颜色是否被统计。
对于线段树染色,这里主要想讲的是 tree 数组的合理表示。选择一个合理的表示方法会事半功倍。大家可以记住这种处理方法。当然有更好的方法欢迎交流。
可持久化
我们先讲解“可持久化” 为何意。
可持久化,即对于每一次操作,我们需要记录它的历史版本。
我们首先暴力地思考,对于每一次修改操作,我们可以首先将原本地数据结构复制一份,然后在复制件上进行操作。但这样操作空间消耗太大。
注意到,我们在修改线段树的时候,我们显然并不需要将所有的节点都修改。我们只需要将修改的节点内容备份一份即可。对于修改的节点,新开一个点来修改,然后连边关系和原来的点一致。
对于不需要修改的点,不必动它。
(图源OI-wiki,侵删。)
运用这种思想,我们可以对多种数据结构进行可持久化操作。例如 Trie 树,数组等。这种可持久化的前提是 一种数据结构在修改后它的拓扑序不发生改变,例如线段树,Trie 树,数组它们在修改后本身形态并没有改变,改变的仅仅是内容。满足这个条件的数据结构都可以类似的进行可持久化操作。
在讲解可持久化线段树前,我们先放两道例题。
Luogu P3919 [模板]可持久化线段树1(可持久化数组)
第一个题是可持久化线段树的模板题,要求我们在某个历史版本上修改某个值,并访问某个版本的某个值。考虑建立可持久化线段树。这里需要强调,可持久化线段树需要动态开点建立,因为我们在修改的时候需要新开点,这也就意味着可持久化线段树的父亲孩子表示不可以使用类似于 \(p\times 2,p \times 2 + 1\),应当使用结构体来记录儿子的节点编号。
build
建立可持久化线段树比较简单。和建立普通线段树一基本一样。但是这里我们期望得到它的左节点和右节点的标号,不妨 build 返回节点编号,会更好处理一点。也比较直观。
build
void build(int l,int r)
{
int p = ++cnt;
if(l == r)
{
tree[p].val = a[l];
}
int mid = (l+r) / 2;
tree[p].ls = build(l,mid);
tree[p].rs = build(mid+1,r);
}
modify
对于修改操作,和普通线段树不同的是,我们显然不能直接修改。考虑在一棵线段树上,我们只需要修改递归到的节点即可。首先执行动态开点,将原节点信息复制一份给新节点。然后对新节点进行修改。
由于本题中我们实现的“可持久化数组” 每次修改只牵扯到一个点,其他点不需要管。所以处理到这就可以了。下面的主席树操作略微复杂,后文再讲。
modify
int modify(int p,int l,int r,int add,int w)
{
tree[++cnt] = tree[p];
p = cnt;
if(l == r)
{
tree[p].val = w;
}
else
{
int mid = (l+r) / 2;
if(add <= mid) tree[p].ls = modify(tree[p].ls,l,mid,add,w);
else tree[p].rs = modify(tree[p].rs,mid+1,r,add,w);
}
return p;
}
query
查询操作更简单了,我们记录每个版本的根节点编号,然后从根节点往下查找即可。
query
int query(int p,int l,int r,int add)
{
if(l == r) return tree[p].val;
int mid = (l+r) / 2;
if(add <= mid)
{
return query(tree[p].ls,l,mid,add);
}
else return query(tree[p].rs,mid+1,r,add);
}
完整模板代码
Code
#include <bits/stdc++.h>
using namespace std;
#define int long long
const int N = 60000010;
int n,m;
int a[N];
int root[N];
int cnt = 0;
struct Node
{
int ls,rs,val;
}tree[N];
void build(int l,int r)
{
int p = ++cnt;
if(l == r)
{
tree[p].val = a[l];
}
int mid = (l+r) / 2;
tree[p].ls = build(l,mid);
tree[p].rs = build(mid+1,r);
}
int modify(int p,int l,int r,int add,int w)
{
tree[++cnt] = tree[p];
p = cnt;
if(l == r)
{
tree[p].val = w;
}
else
{
int mid = (l+r) / 2;
if(add <= mid) tree[p].ls = modify(tree[p].ls,l,mid,add,w);
else tree[p].rs = modify(tree[p].rs,mid+1,r,add,w);
}
return p;
}
int query(int p,int l,int r,int add)
{
if(l == r) return tree[p].val;
int mid = (l+r) / 2;
if(add <= mid)
{
return query(tree[p].ls,l,mid,add);
}
else return query(tree[p].rs,mid+1,r,add);
}
signed main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
build(1,n);
root[0] = 1;
for(int i=1;i<=m;i++)
{
int v,op,loc;
cin>>v>>op>>loc;
if(op == 1)
{
int val;
cin>>val;
root[i] = modify(root[v],1,n,loc,val);
}
else
{
cout<<query(root[v],1,n,loc)<<endl;
root[i] = root[v];
}
}
}
上述操作仅仅应用了可持久化的基本思想。请读者确保理解后再继续阅读。接下来将介绍可持久化权值线段树(又名主席树)。
可持久化权值线段树
我们首先需要了解 “权值线段树” 为何物。
我们在普通线段树中,无论是维护区间最值,区间和,区间乘积,维护的都是数据内容。但在权值线段树中,维护的是每个元素出现个数。容易发现这个也满足结合律。故可以用线段树维护。
朴素权值线段树可以维护 全局区间第 \(k\) 小/大。具体地,和朴素线段树一样,将区间分为左右两部分。若左半区间内的数字个数大于等于当前需要查找的第 \(k\) 个数,证明第 \(k\) 小一定在左半部分。否则在右半部分,同时将 \(k\) 减去左半部分的元素个数。
这里给出权值线段树维护全局区间第 \(k\) 小模板代码。
权值线段树
#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
const int N = 10000010;
int n,k,q;
int a[N],vis[N];
int tree[N];
void build(int l,int r,int p) //l,r 即为当前区间
{
if(l == r)
{
tree[p] = vis[a[l]];
return;
}
int mid = (l+r) / 2;
build(l,mid,p*2);
build(mid+1,r,p*2+1);
tree[p] = tree[p*2] + tree[p*2+1];
}
int query(int l,int r,int p,int k)
{
if(l == r) return l;
int mid = (l+r) / 2;
if(tree[p*2] >= k) return query(l,mid,p*2,k);
else return query(mid+1,r,p*2+1,k-tree[p*2]);
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>q;
int minn = INF,maxn = -1;
for(int i=1;i<=n;i++)
{
cin>>a[i];
maxn = max(maxn,a[i]);
minn = min(minn,a[i]);
vis[a[i]] ++; // 统计每个数字的出现次数
}
build(1,n,1);
while(q--)
{
int l,r,k;
cin>>l>>r>>k;
cout<<query(1,n,1,k)<<endl;
cout<<a[query(1,n,1,k)]<<endl;
}
}
但如果我们要求区间 \([L,R]\) 的第 \(k\) 小/大呢?这当然可以用权值线段树解决。注意到区间 \([1,R]\) 的内容减去区间 \([1,(L-1)]\) 的内容就是区间 \([L,R]\) 的内容。这就是前缀和。
显然我们可以对于每个 \(i\),开一棵权值线段树来统计 \(\forall[i,n]\) 的内容。然后根据上文提到的前缀和计算出区间 \([L,R]\) 的内容。但这样会 MLE。
读者这里可能会注意到,我们显然不必开多个线段树。我们可以动态开点,只更新修改过的点即可。这里的“修改过” 指区间 \([1,R+1]\) 相对于区间 \([1,R]\) 增加的元素。
这就是可持久化权值线段树。也叫主席树。主席树的来历可以自行网络搜索,由于某方面原因这里不讲。理论比较简单,具体实现需要读者多加练习。
这里提供模板代码,可以通过Luogu P3834 [模板]可持久化线段树2 。
Code
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5+5;
int n,cnt,m;
struct Node
{
int l,r,val;
}tree[N*35];
int root[N];
int modify(int p,int l,int r,int x)
{
//cout<<l<<" "<<r<<endl;
int v = ++cnt;
tree[v] = tree[p];
if(l == r) tree[v].val ++;
else
{
int mid = (l+r) >> 1;
if(x <= mid) tree[v].l = modify(tree[p].l,l,mid,x);
else tree[v].r = modify(tree[p].r,mid+1,r,x);
tree[v].val = tree[tree[v].l].val + tree[tree[v].r].val;
}
return v;
}
int query(int p,int v,int l,int r,int x)
{
// cout<<p<<endl;
if(l == r) return l;
int mid = (l+r) >>1;
int s = tree[tree[p].l].val - tree[tree[v].l].val;
if(x <= s) return query(tree[p].l,tree[v].l,l,mid,x);
else return query(tree[p].r,tree[v].r,mid+1,r,x-s);
}
int main()
{
// freopen("input.txt","r",stdin);
// freopen("output.txt","w",stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int t;
cin>>t;
root[i] = modify(root[i-1],-1e9,1e9,t);
}
while(m--)
{
int l,r,k;
cin>>l>>r>>k;
cout<<query(root[r],root[l-1],-1e9,1e9,k)<<endl;
}
}
线段树扫描线
简化题意:给定平面上若干个矩形,求它们面积的并集。
\(1\le n\le 10^5,1\le x\le 10^9\)。
由于都是矩形,面积可转化为 \(\sum\) 截线段长度 \(\times\) 所截高度。即将矩形分为若干个小矩形求解。
如图
(图源 https://www.luogu.com.cn/article/9pcpxgzs ,侵删)
问题在与我们如何维护当前所截线段长度。
不妨将矩形下面边权值设为 \(1\),上边权设置为 \(-11\)。令扫描线每扫到一个边就停下。将所有横边按照高度(\(y\) 值 )从小到大排序。这样,从下到上扫描,边权始终非负。
坐标范围太大,考虑离散化。
(图源 https://www.luogu.com.cn/article/9pcpxgzs ,侵删)
在这个例子中,我们维护线段 \(x_1\rightarrow x_2,x_2\rightarrow x_3,x_3\rightarrow x_4\)。对每段线段,我们需维护下列信息。
-
该线段被覆盖了多少次。
-
该线段内被整个图形所截的长度是多少。(如果完全覆盖为 \(len\)。)
显然,只要一条线段被覆盖,那它肯定被图形覆盖。因此,我们每次扫到边,查询根节点信息。统计贡献即可。
参考代码
namespace solution
{
int L[N];
int n;
struct LINE
{
int l,r,h,tag;
bool operator<(const LINE& a)const{
return h < a.h;
}
}line[N*16];
struct segtree
{
#define ls p<<1
#define rs p<<1|1
struct TREE
{
int l,r,sum,len;
}tree[N*16];
void pushup(int p)
{
int l = tree[p].l,r = tree[p].r;
if(tree[p].sum)
tree[p].len = L[r+1] - L[l];
else tree[p].len = tree[ls].len + tree[rs].len;
}
void build(int l,int r,int p)
{
tree[p].l = l;
tree[p].r = r;
tree[p].len = tree[p].sum = 0;
int mid = (l+r) >> 1;
if(l == r) return;
build(l,mid,ls);
build(mid+1,r,rs);
return;
}
void modify(int LL,int RR,int p,int d) // LL,RR 是真实的区间端点
{
int l = tree[p].l,r = tree[p].r; // l,r 是离散化完的
if(LL >= L[r+1] || RR <= L[l]) return;
if(L[l] >= LL && L[r+1] <= RR)
{
tree[p].sum += d;
pushup(p);
return;
}
modify(LL,RR,ls,d);
modify(LL,RR,rs,d);
pushup(p);
}
}seg;
void solve()
{
cin>>n;
for(int i=1;i<=n;i++)
{
int xa,ya,xb,yb;
cin>>xa>>ya>>xb>>yb;
L[i*2-1] = xa,L[i*2] = xb; // 打上编号
line[i*2-1] = {xa,xb,ya,1};
line[i*2] = {xa,xb,yb,-1};
}
n <<= 1;
sort(line+1,line+n+1);
sort(L+1,L+n+1);
int tot = unique(L+1,L+n+1) - L - 1; // 离散化
// cerr<<tot<<endl;
seg.build(1,tot-1,1);
int ans = 0;
for(int i=1;i<n;i++)
{
seg.modify(line[i].l,line[i].r,1,line[i].tag); // 每次加边后统计
ans +=( seg.tree[1].len *(line[i+1].h-line[i].h));
}
cout<<ans<<endl;
return;
}
}
线段树维护前缀最大值
例题:P1498 楼房重建
小 A 的楼房外有一大片施工工地,工地上有 \(N\) 栋待建的楼房。每天,这片工地上的房子拆了又建、建了又拆。他经常无聊地看着窗外发呆,数自己能够看到多少栋房子。
为了简化问题,我们考虑这些事件发生在一个二维平面上。小 A 在平面上 \((0,0)\) 点的位置,第 \(i\) 栋楼房可以用一条连接 \((i,0)\) 和 \((i,H_i)\) 的线段表示,其中 \(H_i\) 为第 \(i\) 栋楼房的高度。如果这栋楼房上任何一个高度大于 \(0\) 的点与 \((0,0)\) 的连线没有与之前的线段相交,那么这栋楼房就被认为是可见的。
施工队的建造总共进行了 \(M\) 天。初始时,所有楼房都还没有开始建造,它们的高度均为 \(0\)。在第 \(i\) 天,建筑队将会将横坐标为 \(X_i\) 的房屋的高度变为 \(Y_i\)(高度可以比原来大—修建,也可以比原来小—拆除,甚至可以保持不变—建筑队这天什么事也没做)。请你帮小 A 数数每天在建筑队完工之后,他能看到多少栋楼房?
令 \(s_i=H_i/i\),即楼房高度除以楼房坐标。即 \((0,0)\) 到 \((i,H_i)\) 的斜率。且 \(s_0 = 0\)。则一栋楼房能被看见,当且仅当 \(\max\limits_{j=0}^{i-1}s_j<s_i\),即 \(s_i\) 为 严格前缀最大值。
我们考虑用线段树维护这个东西。维护每个区间的最大值和 仅考虑这个区间 的答案,即我们先不考虑区间 \([1,l-1]\) 对区间 \([l,r]\) 的影响。
区间最大值非常简单。但是对于信息 2 直接相加是错误的,因为我们没有考虑 左子树对右子树的贡献。
进一步的,直接继承左子树信息是正确的。对于右子树,它的答案会受到左子树的影响。我们考虑计算区间 \([l,r]\) 在考虑前缀最大值 \(pre\) 时的答案。
-
若 \(pre\) 小于左子树的最大值。此时 \(pre\) 对于右子树来说是无贡献的。直接递归进左子树。右子树的贡献直接用 “总的” 减去左子树的贡献即可。毕竟左子树是直接继承过来的,右子树不变。修改前 “总的” 减去修改前左子树的就等于右子树的。
-
若 \(pre\) 大于左子树的最大值。这太简单了。左子树不可能有贡献,直接进右子树即可。
这是一类特殊的线段树。
实现
int pushup(double t,int p,int l, int r)
{
if(tree[p].maxx <= t) return 0;
if(a[l] > t) return tree[p].len;
if(l == r) return a[l] > t;
int mid = (l+r) >> 1;
if(tree[p*2].maxx <= t) return pushup(t,p*2+1,mid+1,r);
else return pushup(t,p*2,l,mid)+tree[p].len-tree[p*2].len;
}
void modify(int l,int r,int need_adr,int p,int nxt)
{
if(l == r && l == need_adr)
{
tree[p].maxx = (double)nxt/need_adr;
tree[p].len = 1;
return;
}
int mid = (l+r) >> 1;
if(need_adr <= mid) modify(l,mid,need_adr,p*2,nxt);
else modify(mid+1,r,need_adr,p*2+1,nxt);
tree[p].maxx = max(tree[p*2].maxx,tree[p*2+1].maxx);
tree[p].len = tree[p*2].len + pushup(tree[p*2].maxx,p*2+1,mid+1,r);
}
signed main()
{
// 省略
}
封装信息
若不带修,任何满足结合律且封闭的信息都可用线段树维护。我们通过一个经典例子来感受信息设计流程。
单点修改,区间查询最大子段和。
最大子段和可以分治求解,假设我们已知区间 \([l,mid],[mid+1,r]\) 的最大子段和,考虑求解区间 \([l,r]\) 的最大子段和。首先,区间 \([l,r]\) 的最大子段和可以由区间 \([l,mid],[mid+1,r]\) 最大子段和中较大的一个转移得到。但我们忽略了跨过中点的子段和。跨过中点的子段和一定是区间 \([l,mid]\) 的最大后缀和拼上区间 \([mid+1,r]\) 的最大前缀和。那么如何转移最大后缀和,最大前缀和?显然,区间 \([l,r]\) 的最大前缀和,最大后缀和可以由 \([l,mid],[mid+1,r]\) 直接转移得到,至于跨过中点的部分,最大前缀和一定形如 \([l,mid]\) 的和加上 \([mid+1,r]\) 的最大前缀和,最大后缀和同理。
综上,对于区间最大子段和问题,我们需要维护 区间最大前缀和,区间最大后缀和,区间最大子段和,区间和。我们来回顾一下信息设计流程。
首先从所求答案出发,考虑区间 \([l,r]\) 的答案如何从区间 \([l,mid],[mid+1,r]\) 合并。为此可能需要维护一些辅助信息,例如在上述问题中,需要维护最大前缀和,最大后缀和。再考虑辅助信息如何转移,一步步将信息封闭。
我们再来看一道例题。
给定一个长度为 \(n\) 的字符序列 \(a\),初始序列中全部是字母
L
。\(q\) 次修改,每次给定一个 \(x\),将 \(a_x\) 取反。
区间 \([l,r]\) 符合要求当且仅当不存在连续的L
和连续的R
。每次修改后,求当前序列 \(a\) 最长的满足要求的序列。
\(n,q\le 2\times 10^5\)。
数据范围 \(2\times 10^5\),经典数据结构题。考虑用线段树维护区间 \([l,r]\) 最长的满足要求的序列。
区间 \([l,r]\) 的最长满足要求的序列可以从 \([l,mid],[mid+1,r]\) 直接转移。我们还需考虑跨过中点的区间。需要维护 \([l,r]\) 的最长合法后缀,最长合法前缀。且 \([l,mid],[mid+1,r]\) 可拼接当且仅当 \(s_{mid}\ne s_{mid+1}\)。
再考虑区间 \([l,r]\) 的最长合法前缀,最长合法后缀如何转移。首先可以从区间 \([l,mid],[mid+1,r]\) 直接转移。再考虑跨过中点的。区间 \([l,r]\) 的最长合法前缀可跨过中点当且仅当 \([l,mid]\) 整体合法且 \(s_{mid}\ne s_{mid+1}\)。最长合法后缀跨过中点的情形同理。至此,信息封闭。可用线段树维护。
线段树封装信息亦可和二维数点问题结合,例如。
求区间最大子段和 \([l,r]\),要求 \([l,r]\) 内的元素不可重复出现。若重复则不算贡献。
要求区间内元素不可重复贡献,不难想到 HH 的项链。HH 的项链这个题,我们把所有询问区间离线到一个数轴上,扫描右端点,并对于每个元素,只保留离着右端点最近的。在值域上维护树状数组即可解决问题。这个题我们同样扫描右端点,对于任意的 \(r\in[1,n]\),求以它为结尾的最大子段和。首先它作为右端点一定产生贡献,但若最大子段又出现它,贡献会变成 \(0\)。考虑抵消贡献。只需要将它前面出现的位置设为相反数即可。由于扫描到它的时候,它前面也设为相反数,我们需要将它设为 \(0\),防止抵消两遍。
本题在线段树基础上渗透了扫描线思想。
线段树二分
和权值线段树,静态全局第 \(k\) 小是类似的。
当答案具有单调性,且需要线段树维护时,往往可以将二分扔到线段树上,复杂度减少一个 \(\log\)。由 \(n\log^2n\) 变成 \(n\log n\)。看个例题。
有一个长度为 \(n\) 的序列 \(a\),每轮操作前会给定一个区间 \([l,r]\) 和 \(d\) 表示将 \(a_l,a_{l+1}\dots a_r + d\)。注意每次操作有有效性,会对序列进行实质上的修改。有一个初始值 \(W\),\(W\) 分别减 \(a_1,a_2\dots a_n\) 然后所有 \(a_i\) 翻倍。又回来减 \(a_1\),以此类推。当 \(W\le 0\) 时一轮操作结束。序列 \(a\) 恢复到攻击前的状态。(区间修改后)对于每轮操作,求进行了多少次减法操作。
\(n\le 2\times 10^5\)。
看到区间修改,且会对后续产生影响。考虑线段树。因为每执行 \(n\) 次减法操作后,序列里所有数会翻倍,能够完整减整个序列的次数应该不会很多,考虑先处理这个。可以二分,也可以直接枚举。
再处理剩下的。显然要二分。二分一个答案 \(mid\) 然后 check \([l,mid]\times round\),其中 \(round\) 即为翻了多少倍。复杂度 \(log^2 n\)。注意到线段树的儿子拆分方式就是二分,考虑直接在线段树上二分,每次 check 区间 \([l,mid]\) 的和,如果它乘 \(round\) 比剩下的 \(W\) 小,答案一定在右区间,令 \(W\) 减去区间 \([l,r]\) 的和乘 \(round\),递归进右区间查询即可。当到达叶子节点返回即可。复杂度少一个 \(\log\)。和权值线段树是类似的。
实现
int query(int l,int r,int p,__int128_t w,int round)
{
if(l == r) return l;
int mid = (l+r) >> 1;
pushdown(p,r-l+1);
__int128_t t = pow(2,round);
if(tree[ls].val * (__int128_t)t >= w) return query(l,mid,ls,(__int128_t)(w),round);
else return query(mid+1,r,rs,(__int128_t)(w-(tree[ls].val*t)),round);
}
本文作者:SXqwq,转载请注明原文链接:https://www.cnblogs.com/SXqwq/p/18114026