线段树进阶
特别浅的浅谈线段树
线段树进阶
懒标优先级
当我们的线段树维护的不再是一种区间操作,而是两种或者更多,要如何选择懒标的优先级,以便于我们更简单的维护我们想要维护的?
先看两个例子:P3373 线段树 2 和 P1253 扶苏的问题。
P3373 是 加法懒标 和 乘法懒标 之间的优先级问题,而 P1253 是 加法懒标 和 推平懒标 之间的优先级问题。
这里的优先级,主要是指在放置懒标和 pushdown
时,两个懒标对彼此是否有影响。
显然,两个题中,加法懒标的优先级都是比较低的。即加法懒标不能影响乘法懒标和推平懒标。
以 P3373 为例,如果先乘后加就是:,而先加后乘就是:。如果按后者进行计算,会引起精度误差,也不便于维护。
再看向 P1253,则是区间推平后消除加法懒标。
这样的 pushdown
就相对较长:
void pushdown(int i) {//P3373 T[i<<1].val=(T[i<<1].val*T[i].mul)%p; T[i<<1|1].val=(T[i<<1|1].val*T[i].mul)%p; T[i<<1].add=(T[i<<1].add*T[i].mul)%p; T[i<<1|1].add=(T[i<<1|1].add*T[i].mul)%p; T[i<<1].mul=(T[i<<1].mul*T[i].mul)%p; T[i<<1|1].mul=(T[i<<1|1].mul*T[i].mul)%p; T[i<<1].val=(T[i<<1].val+T[i].add*(T[i<<1].ri-T[i<<1].le+1))%p; T[i<<1|1].val=(T[i<<1|1].val+T[i].add*(T[i<<1|1].ri-T[i<<1|1].le+1))%p; T[i<<1].add=(T[i<<1].add+T[i].add)%p; T[i<<1|1].add=(T[i<<1|1].add+T[i].add)%p; T[i].add=0;T[i].mul=1; } void pushdown(int i) {//P1253 if(T[i].ass) { T[i<<1].maxn=T[i<<1|1].maxn=T[i].tag1; T[i<<1].tag1=T[i<<1|1].tag1=T[i].tag1; T[i<<1].tag2=T[i<<1|1].tag2=0; T[i<<1].ass=T[i<<1|1].ass=1; T[i].ass=0; } T[i<<1].maxn+=T[i].tag2; T[i<<1|1].maxn+=T[i].tag2; T[i<<1].tag2+=T[i].tag2; T[i<<1|1].tag2+=T[i].tag2; T[i].tag2=0; }
标记永久化
线段树操作中最著名的就是懒标。
当懒标 pushdown
时,如果对应的两个儿子是两个节点,复杂度为 ;但有时,两个儿子是两棵树,比如线段树套树,一维线段树一个节点的 pushdown
,对应二维线段树的每个节点的 pushdown
,时间复杂度 ,时间复杂度不允许。
所以要标记永久化。就是将懒标不再下传,而是记录下来,查询时再加上。
但限制很大,多数情况下并不适用。
标记永久化版 P3372
const int inf=1e5+7; int n,m,op,l,r,v; int a[inf]; struct a_Te{ int le,ri; int val,tag; }T[inf<<2]; void build(int i,int l,int r) { T[i].le=l,T[i].ri=r; if(l==r) { T[i].val=a[l]; return; } int mid=(l+r)>>1; build(i<<1,l,mid); build(i<<1|1,mid+1,r); T[i].val=T[i<<1].val+T[i<<1|1].val; } int max(int a,int b){return a>b?a:b;} int min(int a,int b){return a<b?a:b;} void update(int i,int l,int r,int k) { T[i].val+=(min(T[i].ri,r)-max(T[i].le,l)+1)*k; if(l<=T[i].le&&T[i].ri<=r) { T[i].tag+=k; return; } int mid=(T[i].le+T[i].ri)>>1; if(l<=mid)update(i<<1,l,r,k); if(mid<r)update(i<<1|1,l,r,k); } int ask(int i,int l,int r,int k) { if(l<=T[i].le&&T[i].ri<=r) return T[i].val+k*(min(T[i].ri,r)-max(T[i].le,l)+1); int mid=(T[i].le+T[i].ri)>>1,ans=0; if(l<=mid)ans+=ask(i<<1,l,r,k+T[i].tag); if(mid<r)ans+=ask(i<<1|1,l,r,k+T[i].tag); return ans; } signed main() { n=re();m=re(); for(int i=1;i<=n;i++) a[i]=re(); build(1,1,n); for(int i=1;i<=m;i++) { op=re();l=re(),r=re(); if(op==1v=re(),update(1,l,r,v); else wr(ask(1,l,r,0)),putchar('\n'); } return 0; }
动态开点
众所周知,线段树需要 4 倍空间。
如果让你维护一个值域为 的权值线段树(见下),需要的空间就是 ,显然必炸。
那么我们摒弃传统的线段树,尝试动态开点。
顾名思义,动态开点就是只开用到的点,不用的点先不开。那么我们建的是一颗残疾的线段树。
动态开点版 P3372
const int inf=1e5+7; int n,m,rot,cnt; int a[inf],s[inf]; struct a_Te{ int lc,rc; int val,tag; }T[inf<<2]; void new_node(int &i,int l,int r) { i=++cnt;T[i].val=s[r]-s[l-1]; } void pushdown(int i,int l,int r) { if(l==r)return;int mid=(l+r)>>1; if(T[i].lc==0)new_node(T[i].lc,l,mid); if(T[i].rc==0)new_node(T[i].rc,mid+1,r); T[T[i].lc].tag+=T[i].tag; T[T[i].rc].tag+=T[i].tag; T[T[i].lc].val+=(mid-l+1)*T[i].tag; T[T[i].rc].val+=(r-mid)*T[i].tag; T[i].tag=0; } void pushup(int i,int l,int r) { if(l==r)return;int mid=(l+r)>>1; if(T[i].lc==0)new_node(T[i].lc,l,mid); if(T[i].rc==0)new_node(T[i].rc,mid+1,r); T[i].val=T[T[i].lc].val+T[T[i].rc].val; } void update(int &i,int l,int r,int x,int y,int k) { if(i==0)new_node(i,l,r); if(x<=l&&r<=y) { T[i].val+=(r-l+1)*k; T[i].tag+=k; return; } if(T[i].tag)pushdown(i,l,r); int mid=(l+r)>>1; if(x<=mid)update(T[i].lc,l,mid,x,y,k); if(mid<y)update(T[i].rc,mid+1,r,x,y,k); pushup(i,l,r); } int ask(int &i,int l,int r,int x,int y) { if(i==0)new_node(i,l,r); if(x<=l&&r<=y)return T[i].val; if(T[i].tag)pushdown(i,l,r); int mid=(l+r)>>1,ans=0; if(x<=mid)ans+=ask(T[i].lc,l,mid,x,y); if(mid<y)ans+=ask(T[i].rc,mid+1,r,x,y); return ans; } signed main() { n=re();m=re(); for(int i=1;i<=n;i++) a[i]=re(),s[i]=s[i-1]+a[i]; for(int i=1;i<=m;i++) { int op=re(),l=re(),r=re(); if(op==1)update(rot,1,n,l,r,re()); else wr(ask(rot,1,n,l,r)),putchar('\n'); } return 0; }
可以发现,与传统的线段树不同的地方就是左右儿子的编号。
传统线段树的 节点的左儿子是 ,右儿子是 ,而动态开点线段树的 节点的左右儿子的则是储存为 ,先用先开先存。
发现了吗,动态开点认儿子不认爹。
因为递归最后一定能回到父节点,所以不需要另外耗费空间来记录。
动态开点并不是线段树的专利,部分平衡树(如 Treap)也会用到动态开点,也是认儿子不认爹。
权值线段树
普通的线段树维护的是区间的数,而权值线段树维护的是区间的 数的个数,即维护这个区间的桶,是一种可以代替甚至吊打平衡树(一种高级的数据结构)的存在。
显然,平衡树的题我们不能用平衡树来做。
考虑怎么用权值线段树解决这个题。
- 建树:
和普通的线段树差不多,只是将 a[l]
换为 tong[l]
。显然,这个题不需要。
- 插入(或删除):
在桶的第 个位置单点加 ,和普通线段树的操作相同,但由于值域是 ,需要动态开点(强化版的强制在线不能离散化,为了双倍经验)。
void insert(int &i,int l,int r,int k,int v) { if(i==0)i=++cnt; T[i].sum+=v; if(l==r)return; int mid=(l+r)>>1; if(k<=mid)insert(T[i].lc,l,mid,k,v); else insert(T[i].rc,mid+1,r,k,v); }
- 查询 的排名:
排名定义为比当前数小的数的个数 。
那么我们就可以对当前区间的中点与 进行比较:
如果 小,说明当前节点的右子节点的所有数都比 大,不对答案产生贡献,继续查询左子树。
否则,说明当前节点的左子节点的所有数都比 小,答案累加左子树的 ,继续查询右子树。
int ask_rnk(int i,int l,int r,int k) { if(i==0||l==r)return 1; int mid=(l+r)>>1; if(k<=mid)return ask_rnk(T[i].lc,l,mid,k); return ask_rnk(T[i].rc,mid+1,r,k)+T[T[i].lc].sum; }
- 查询第 小值:
区间排名的反操作,只需要比较左子节点的数的个数(设为 ):
若 比 小,说明 小值在左子节点里。
否则,说明 小值在右子节点里,但左子节点也会对 小值产生贡献,所以要 。
int ask_kth(int i,int l,int r,int k) { if(l==r)return l; int mid=(l+r)>>1,sum=T[T[i].lc].sum; if(k<=sum)return ask_kth(T[i].lc,l,mid,k); return ask_kth(T[i].rc,mid+1,r,k-sum); }
- 查询 的前驱后继:
显然,第 的排名 小值为 的前驱,第 的排名小值为 的后继。
主函数里:
if(op==5)wr(ask_kth(rot,-1e7,1e7,ask_rnk(rot,-1e7,1e7,k)-1)),putchar('\n'); if(op==6)wr(ask_kth(rot,-1e7,1e7,ask_rnk(rot,-1e7,1e7,k+1))),putchar('\n');
将上述操作叠加起来,就是整个平衡树的实现。
其实,大多数情况下,题干中的值域都比较大(如 或 ),直接开桶存不下,所以权值树常与 离散化 或 动态开点 一起使用。
权值线段树只能解决整个区间的的 大值,而想要查询 的区间 大值,需要可持久化。
zkw 线段树
与朴素线段树的区别
朴素的线段树都是递归形式的,占用栈空间大,常数大。zkw 线段树则是循环形式,常数小,代码短。
朴素的线段树是找到根节点,然后向下递归维护。zkw 线段树则是先找叶节点,然后上浮维护到根节点。
对于朴素的线段树(如最上边的图),叶节点是很难确定的。但如果我们要维护的是一棵满二叉树,找到叶节点就很容易了。
如下图:
(同样是直接从网上找的)
这样的线段树在节点上就会有一些奇妙的性质:
-
共有 个叶节点,代表区间 的叶节点对应的编号就是 。
-
若某节点的编号为 ,则其父亲的编号为 ,左儿子的编号为 ,右儿子的编号为 。
-
若两个节点分别为 ,且 是兄弟节点(即父节点相同),那么 。
-
除根节点外,编号为偶数的节点为其父节点的左儿子,编号为奇数的节点是其父节点的右儿子。
建树
我们需要将原序列的值赋到叶节点上,首先要保证叶节点能够容纳下原序列。
将叶节点赋值之后,便和朴素的线段树一样,将点权 pushup
。
区间修改,区间查询
如果要维护区间 这个闭区间,要转化为维护 这个开区间。
如果当前节点是正好是区间左端点,而且是其父节点的左儿子,那么他的兄弟就是要维护的区间的一部分。同理,如果当前节点是正好是区间右端点,而且是其父节点的右儿子,那么他的兄弟也是要维护的区间的一部分。
如果我满足条件,就维护我的兄弟和我的父亲
当两个节点是兄弟节点时,也就是循环结束的时候。这时再将父节点到根节点这段维护了,就是 Zkw 线段树的全过程了。
在上浮过程中,懒标上传比朴素线段树的下传困难,所以要标记永久化。
zkw 线段树版 P3372
const int inf=1e5+7; int n,q,m=1,op,l,r,v; struct zkw_sgt{ int le,ri; int val,tag; }T[inf<<2]; void build() { while(m<=n)m<<=1; for(int i=m+1;i<=m+n;i++) T[i].val=re(); for(int i=m-1;i>0;i--) T[i].val=T[i<<1].val+T[i<<1|1].val; } void update(int i,int j,int k) { int ltag=0,rtag=0,len=1; while(i^j^1) { if(i&1^1)T[i^1].tag+=k,ltag+=len; if(j&1)T[j^1].tag+=k,rtag+=len; T[i>>1].val+=k*ltag; T[j>>1].val+=k*rtag; i>>=1,j>>=1,len<<=1; } int tag=ltag+rtag;i>>=1; while(i) { T[i].val+=k*tag; i>>=1; } } int ask(int i,int j) { int ltag=0,rtag=0,len=1,ans=0; while(i^j^1) { if(i&1^1)ans+=T[i^1].val+T[i^1].tag*len,ltag+=len; if(j&1)ans+=T[j^1].val+T[j^1].tag*len,rtag+=len; if(T[i>>1].tag)ans+=T[i>>1].tag*ltag; if(T[j>>1].tag)ans+=T[j>>1].tag*rtag; i>>=1,j>>=1,len<<=1; } int tag=ltag+rtag;i>>=1; while(i) { if(T[i].tag)ans+=T[i].tag*tag; i>>=1; } return ans; } signed main() { n=re();q=re(); build(); for(int i=1;i<=q;i++) { op=re(),l=re(),r=re(); if(op==1)v=re(),update(l+m-1,r+m+1,v); else wr(ask(l+m-1,r+m+1)),putchar('\n'); } return 0; }
线段树合并
对于主席树而言,是两颗权值线段树的减法
那么线段树合并就是权值线段树的加法
前置知识
-
权值线段树
-
动态开点
如何合并
一般来说,有两种合并方式:
-
将其中一棵树直接合并到另一棵树上;
-
开一颗新树,存两树合并后的信息。
显然,第二种方式的空间复杂度太大,程序无法接受。
所以大多情况下,我们采用第一种方式。
至于具体的合并方法(初中政治告诉我们,方式和方法不一样)。
从根节点开始,每次向相同的方向递归,递归过程中,会遇到以下三种情况:
-
两个节点都为空
-
其中一个节点为空
-
两个节点都不空
对于前两种情况,并不需要太多的处理,只需要将保证被合并的树有节点即可。
至于第三种情况,分别递归合并左右节点,直到出现前两种情况。
代码实现就是这样:
void merge(int &x,int y,int l,int r) { if(y==0||x==0){x=max(x,y);return;} if(l==r) { T[x].sum+=T[y].sum; return; } merge(lc(x),lc(y),l,mid); merge(rc(x),rc(y),mid+1,r); T[x].sum=T[lc(x)].sum+T[rc(x)].sum; }
时间复杂度分析
显然,一个能拿出手的算法时间复杂度并不会太差。
先来思考一下在动态开点线段树中插入一个点会加入多少个新的节点
线段树从顶端到任意一个叶子结点之间有 层,每层最多新增一个节点
所以插入一个新的点复杂度是 的
两棵线段树合并的复杂度显然取决于两棵线段树重合的叶子节点个数,假设有 m 个重合的点,这两棵线段树合并的复杂度就是 了,所以说,如果要合并两棵满满的线段树,这个复杂度绝对是远大于 级别的。
也就是说,千万不要以为线段树合并对于任何情况都是的!
那么为什么数据范围 的题目线段树合并还稳得一批?
这是因为 的复杂度仅适用于插入点少的情况。
如果 与加入的总点数规模基本相同,我们就可以把它理解成每次操作
来证明一下:
假设我们会加入 k 个点,由上面的结论,我们可以推出最多要新增 个点。
而正如我们所知,每次合并两棵线段树同位置的点,就会少掉一个点,复杂度为 ,总共 个点,全部合并的复杂度就是
可见,上面那个证明是只与插入点个数 k 有关,也就是插入次数在 左右、值域 左右的题目,线段树合并还是比较稳的。
至于更快的方法?
网上有说可以用可并堆的思路合并,我太菜了,并没有试过,所以就点到为止了~
对了,由上可知,因为插入 个节点,所以线段树合并的空间复杂度也是 的。
上述内容引自洛谷日报
例题(个人感觉比模板简单)
扫描线
感觉看了这个图之后,应该就不用讲了,直接看代码吧。
Code
const int inf=4e5+7; int n,ans; struct segment{ int x_1,x_2,y,k; bool operator <(const segment &b)const { return y<b.y; } }s[inf]; int bokx[inf],boky[inf]; struct Seg_Tree{ int siz,cnt; }T[inf<<2]; int pushup(int i) { return T[i<<1].siz+T[i<<1|1].siz; } void update(int i,int x,int y,int l,int r,int k) { if(l<=x&&y<=r) { T[i].cnt+=k; if(T[i].cnt)T[i].siz=bokx[y+1]-bokx[x]; else T[i].siz=pushup(i); return; } int mid=(x+y)>>1; if(l<=mid)update(i<<1,x,mid,l,r,k); if(mid<r)update(i<<1|1,mid+1,y,l,r,k); if(T[i].cnt==0)T[i].siz=pushup(i); } signed main() { n=re(); for(int i=1;i<=2*n;i+=2) { int x_1=re(),y_1=re(),x_2=re(),y_2=re(); s[i].x_1=x_1,s[i].x_2=x_2;s[i].y=y_1;s[i].k=1; s[i+1].x_1=x_1,s[i+1].x_2=x_2;s[i+1].y=y_2;s[i+1].k=-1; bokx[i]=x_1,bokx[i+1]=x_2; boky[i]=y_1,boky[i+1]=y_2; } sort(bokx+1,bokx+(n<<1|1)); sort(boky+1,boky+(n<<1|1)); int numx=unique(bokx+1,bokx+(n<<1|1))-bokx-1; int numy=unique(boky+1,boky+(n<<1|1))-boky-1; for(int i=1;i<=2*n;i++) { s[i].x_1=lower_bound(bokx+1,bokx+numx+1,s[i].x_1)-bokx; s[i].x_2=lower_bound(bokx+1,bokx+numx+1,s[i].x_2)-bokx; s[i].y=lower_bound(boky+1,boky+numy+1,s[i].y)-boky; } sort(s+1,s+(n<<1|1)); int IT=1; for(int i=1;i<=numy;i++) { ans+=(boky[i]-boky[i-1])*(T[1].siz); while(s[IT].y==i)update(1,1,numx,s[IT].x_1,s[IT].x_2-1,s[IT].k),IT++; } wr(ans); return 0; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具