题解 P6012 【【模板】线段树分裂】

ix35

2020-01-26 15:56:45

Solution

线段树合并&分裂 网上找线段树合并/分裂的博客,讲得很清楚的也不多,某些部分只有自己 yy 一下了。 前置芝士:权值线段树,动态开点线段树。 在以下讨论中,我们设值域都为 $[1,n]$ 中的整数。 先定义代码中的一些东西: $ch[i][0]$ 表示 $i$ 的左子结点,$ch[i][1]$ 表示 $i$ 的右子结点,$val[i]$ 表示 $i$ 点维护的值(出现了多少个该值域中的数) ------ 一、线段树合并 有时我们需要整合两棵权值线段树的信息,这个整合的过程称为线段树合并。我们以最简单的合并为例:将两棵树相加。 两棵树如何相加呢?在权值线段树上,每个点维护了一个当前区间中数的个数,而数的个数是可以相加的,这个合并的过程可以理解为:把两个可重集合并,对应的权值线段树上发生的过程。而相加的原理很简单,两棵同构的线段树,只需要对应位置相加即可,如图: ![](https://cdn.luogu.com.cn/upload/image_hosting/lersbjt4.png) 注意动态开点线段树上某些点是缺的,其值当然是 $0$。 如何合并两棵线段树呢? 暴力是很简单的,我们枚举 $1$ 到 $n$,将第二棵树中对应位置上的值在第一棵树上做单点修改即可。 这个方法可以用启发式合并进一步优化,但只能适用于一些特殊情况(比如说如果带了分裂或者一个值在多棵树上出现,启发式合并就歇菜了)。 ------ 而我们可以递归处理线段树合并,设我们现在要合并的是以 $x,y$ 为根的两棵子树,要确保它们在线段树上处于同一位置(即它们是两棵树上代表同一区间的点)。 如果 $x,y$ 其中一个为 $0$ (也就是某个权值线段树上没有这个位置的点),无需合并,返回另一个非 $0$ 的点即可。 否则,我们先合并 $x,y$ 的左右子结点,再根据两子结点的信息整合得到 $x,y$ 合并的结果。 线段树合并一般有两种写法:新建结点和不新建结点。但是两者原理是一样的。 新建结点的写法: 新建一个结点 $p$ 作为 $x,y$ 合并的结果。将 $ch[x][0]$ 和 $ch[y][0]$ 的合并结果记为 $sl$,$ch[x][1]$ 和 $ch[y][1]$ 的合并结果记为 $sr$,令 $sl,sr$ 分别为 $p$ 的两个子结点,对 $p$ 做一次 pushup​ 即可得到结果。此后 $x,y$ 就没有用的,可以回收(节省空间的方法)。但是有时 $x,y$ 的信息不能丢,这时就不能回收。 (这里原先的代码有点问题,先删了) 不新建结点的写法: 如果一个点合并完就可以扔掉,那还可以写得更加简便,直接将 $x$ 作为合并后的结果,将 $y$ 的值加到 $x$ 上即可(直接对应位置相加),甚至不需要 pushup,但是注意,如果 $x=0$,返回的是 $y$,所以比较保险的写法是 $x=merge(x,y)$。 事实上这里我们连当前区间的左右端点 $l,r$ 也可以去掉,因为到叶结点后 $ch[x][i]$ 自然是 $0$,自然会返回。 ```cpp int merge (int x,int y) { if (!x||!y) {return x+y;} // 只有一边有点,不用合并 val[x]+=val[y]; // 信息整合 ch[x][0]=merge(ch[x][0],ch[y][0]); ch[x][1]=merge(ch[x][1],ch[y][1]); del(y); // 垃圾回收 return x; } ``` 这东西看着很一般,复杂度怎么样呢? 单独讨论一次合并的复杂度没有什么意义,如果两棵树都是满的,复杂度就到 $O(n)$ 了,所以一般从均摊角度来讨论。 ------ 如果现在有 $m$ 棵线段树(每棵树初始只有一个位置有权值),经过若干次合并最后变成 $1$ 棵,此过程的复杂度是多少呢? 例题:[P4556](https://www.luogu.com.cn/problem/P4556),[P5298](https://www.luogu.com.cn/problem/P5298) 不说具体怎么做了,这些题都有很完整的题解,我就分析一下复杂度(这些题都符合上面提到的模型)。 一开始有 $m$ 棵树,只有一个位置有权值,所以一棵树上结点数量为 $O(\log n)$,$m$ 棵树的结点总数也就是 $O(m\log n)$。 分析上面的代码,发现每一次进入 merge 函数,要么停止递归,要么继续递归并有一个点被垃圾回收。显然停止递归的 merge 次数与继续递归的 merge 次数同阶(不继续递归的情况是从递归的情况出来的,不会超过其两倍的数量)。 因此整个过程的复杂度就等于继续递归的 merge 函数进入次数的复杂度(每一次执行 merge 在不考虑递归时复杂度 $O(1)$),也就等同于被删除的结点个数,是不超过 $O(m\log n)$ 的(有点像势能分析?)。 注意复杂度本身和是否回收结点没有关系,只是借以分析而已。 所以整个过程的复杂度也就是 $O(m\log n)$。 但是线段树合并的复杂度不总是对的,不过本题中 $1$ 操作的复杂度我不知道是否是均摊 $\log n$ 的,希望有人能证明/证伪一下。 ------ 二、线段树分裂 将一个可重集前 $k$ 小的数与之后的数分成两个集合,那么对应的权值线段树就要裂成两棵权值线段树。 举个栗子:将 $[1,3]$ 和 $[4,4]$ 分开(这里为了方便直接用权值描述了,一般是按照第 $k$ 小的位置来的): ![](https://cdn.luogu.com.cn/upload/image_hosting/g0vypgpq.png) 暴力当然也很简单,找到第 $k$,后面的值新建一棵树,在原树上减掉即可。 然而我们可以仿照 FHQ Treap 的套路,实现 $O(\log n)$ 的分裂。 设我们现在要将以 $x$ 为根的树分裂成 $x,y$ 为根的这两棵树($y$ 本来是不存在的,传引用),以第 $k$ 小为界,前 $k$ 小在 $x$,之后的在 $y$。 首先看 $x$ 的左子树的值 $v$,如果 $v<k$,那么左侧依然归 $x$(不需要处理),递归右侧即可,注意 $k$ 变成了 $k-v$。 如果 $v=k$,那么左边归 $x$,右边归 $y$ 即可。 如果 $v>k$,那么右边归 $y$,递归左侧即可。 看完结构后看权值,$x$ 的新权值当然是 $k$,那么 $y$ 的权值也就是 $x$ 原先的权值减去 $k$ 了。 可以发现,如果 $v\ge k$,那么 $y$ 的右子结点都是需要赋值的,下面的代码直接归到了同一句里(else 所在的那一句): ```cpp void split (int x,int &y,ll k) { y=newnod(); ll v=val[ch[x][0]]; if (k>v) {split(ch[x][1],ch[y][1],k-v);} else {swap(ch[x][1],ch[y][1]);} // 右子树归 y,x 的右子树变成 0 if (k<v) {split(ch[x][0],ch[y][0],k);} val[y]=val[x]-k; val[x]=k; return; } ``` 这个每次只递归一边,复杂度是 $O(\log n)$ 没啥问题。 ------ 三、这道题 每个操作分别来看。 将 $[x,y]$ 分裂出来:先分出 $[1,x-1]$,再从 $[x,n]$ 中分出 $[x,y]$ 和 $[y+1,n]$,最后把 $[1,x-1]$ 和 $[y+1,n]$ 合并。我注意到 std 不是这样的,std 的分裂写的就是分裂出一个区间,我在这里用了一次合并,但是复杂度是对的,稍后会证明复杂度为 $O(\log n)$; 将 $t$ 树合并入 $p$ 树:单次合并即可,不确定复杂度,但是不超过 $2\times 10^3$ 次总没问题的; $p$ 树中插入 $a$ 个 $q$:单点修改,复杂度 $O(\log n)$; 查询 $[x,y]$ 中数的个数:区间求和,复杂度 $O(\log n)$; 查询第 $k$ 小:经典操作,复杂度 $O(\log n)$。 最后说一下 $0$ 操作的复杂度: 两次分裂是 $O(\log n)$ 没问题,主要看合并。注意合并的两个区间没有交集,我们就看一看每一层会涉及几个点。 对于第 $1$ 层:总共就 $1$ 个点... 对于第 $i$ 层:如果第 $i-1$ 层只递归下来 $1$ 个点(设为 $u$),再设 $x$ 和 $y$ 为 $u$ 的左右子结点。如果前一棵树占了 $x,y$ 两个点,那么因为后一棵树占的区间严格在前一棵树之后,所以只会占 $y$,那么需要递归的只有 $y$,反过来的话同理需要递归的只有 $x$,所以第 $i$ 层也只需要递归 $1$ 个点。 每一层只往下递归一个点,复杂度就是 $O(\log n)$ 了。 代码: ```cpp #include <bits/stdc++.h> #define ll long long using namespace std; const int MAXN=200010; int n,m,tot,cnt,seq=1,op,x,y,z,bac[MAXN<<5],ch[MAXN<<5][2],rt[MAXN]; ll val[MAXN<<5]; int newnod () {return (cnt?bac[cnt--]:++tot);} void del (int p) { bac[++cnt]=p,ch[p][0]=ch[p][1]=val[p]=0; return; } void modify (int &p,int l,int r,int pos,int v) { if (!p) {p=newnod();} val[p]+=v; if (l==r) {return;} int mid=(l+r)>>1; if (pos<=mid) {modify(ch[p][0],l,mid,pos,v);} else {modify(ch[p][1],mid+1,r,pos,v);} return; } ll query (int p,int l,int r,int xl,int xr) { if (xr<l||r<xl) {return 0;} if (xl<=l&&r<=xr) {return val[p];} int mid=(l+r)>>1; return query(ch[p][0],l,mid,xl,xr)+query(ch[p][1],mid+1,r,xl,xr); } int kth (int p,int l,int r,int k) { if (l==r) {return l;} int mid=(l+r)>>1; if (val[ch[p][0]]>=k) {return kth(ch[p][0],l,mid,k);} else {return kth(ch[p][1],mid+1,r,k-val[ch[p][0]]);} } int merge (int x,int y) { if (!x||!y) {return x+y;} val[x]+=val[y]; ch[x][0]=merge(ch[x][0],ch[y][0]); ch[x][1]=merge(ch[x][1],ch[y][1]); del(y); return x; } void split (int x,int &y,ll k) { if (x==0) {return;} y=newnod(); ll v=val[ch[x][0]]; if (k>v) {split(ch[x][1],ch[y][1],k-v);} else {swap(ch[x][1],ch[y][1]);} if (k<v) {split(ch[x][0],ch[y][0],k);} val[y]=val[x]-k; val[x]=k; return; } int main () { scanf("%d%d",&n,&m); for (int i=1;i<=n;i++) { scanf("%d",&x); modify(rt[1],1,n,i,x); } for (int i=1;i<=m;i++) { scanf("%d",&op); if (op==0) { scanf("%d%d%d",&x,&y,&z); ll k1=query(rt[x],1,n,1,z),k2=query(rt[x],1,n,y,z); int tmp=0; split(rt[x],rt[++seq],k1-k2); split(rt[seq],tmp,k2); rt[x]=merge(rt[x],tmp); } else if (op==1) { scanf("%d%d",&x,&y); rt[x]=merge(rt[x],rt[y]); } else if (op==2) { scanf("%d%d%d",&x,&y,&z); modify(rt[x],1,n,z,y); } else if (op==3) { scanf("%d%d%d",&x,&y,&z); printf("%lld\n",query(rt[x],1,n,y,z)); } else if (op==4) { scanf("%d%d",&x,&y); if (val[rt[x]]<y) {printf("-1\n");continue;} printf("%d\n",kth(rt[x],1,n,y)); } } return 0; } ```