关于析合树(区间连续段问题)
有部分题目需要处理关于区间连续段的问题(一般来说,对于一个排列,如果一个区间的值连读,就为一个连续段。)区间连续段看似不太好维护,其实有一种处理它的利器:析合树。(也可能只是析合树的思想),就能方便的维护这一个东西。
一个简单的性质:
- 如果两个相交的区间 \([l_1,r_1] , [l_2,r_2]\) 均为连续段(\(l_1<l_2 \leq r_1<r_2\))。那么 \([l_1,l_2-1] , [l_2,r_1], [r_1+1,r_2]\) 均为连续段。
注意,后文所说的两个区间相交均为上面的定义。
析合树
其实这个名字不重要
定义
析合树是一棵树,它的每一个节点表示一个连续段。但是它只存了对于 \([l,r]\) ,不存在一个连续段 \([a,b]\),满足它们相交的 \([l,r]\),也就是这棵树的所有点所对应的连续段与其它连续段的关系为相离或者包含。
叶子节点就是长度为 \(1\) 的区间,根节点就是 \([1,n]\) 这段区间。就构成了一个树形结构(类似于括号树)。点数是 \(O(n)\) 级别的。
一些性质:
- 每个点(除叶子)至少有两个儿子,且所有儿子恰好覆盖这个区间。
- 计算一个排列的连续段个数可以递归计算,\(\text solve(u)\) 表示计算 \(u\) 的所有字串的连续段个数,递归解决它的所有儿子,再加上跨过儿子的区间。那么这些区间一定只可能是恰好包含若干个儿子的区间 (不然就会存在某一个儿子和一个连续段相交)。
- 每一个点(除叶子)的儿子的值域不相交,将儿子的值域离散化后的序列记为 \(p\) 。
树上的每个点分为析点和合点。每个点都属于其中恰好一类。
合点: 叶子节点以及所有满足 \(p\) 单调递增或者递减的点。
析点: 除了合点以外的点。
我们分别讨论这两种点的性质:
合点
- 它的若干个连续儿子(非叶节点)构成的区间一定是连续段。
- 它的最长连续段后缀和前缀(不包含本身)一定覆盖了整个区间。
析点
- 它的若干个连续儿子(非叶节点)构成的长度 \(\geq 2\) 区间 (除了本身)一定不是连续段。
证明:考虑反证,我们找到长度最长的一段区间。这说明存在一个区间和它相交(不然他就会成为一个节点)。而且这两个区间的并一定是这个节点本身(不然就存在更长的区间了)。那么这个区间一定是一个前缀或者后缀。并且这两个区间相交。那么就可以把这个区间分成三组,使得组和组之间值域不相交,而且离散化后单调。
我们找到任意一个长度 \(\geq 2\) 的组,那么一定存在一个与它相交的连续段,这样就会把这一组分裂成两组,并且依然满足单调的性质,直到所有段长度为 \(1\),那么就是合点。矛盾。(可以画一下图,这里懒得画了。。。)
- 它的最长连续段后缀和前缀(不包含本身)的长度为 \(1\) (就是只包含了一个儿子),并且没有覆盖整个区间。
- 它至少有 \(4\) 个儿子。(因为长度为 \(3\) 构造不出来)
注意:析合树的儿子是有序的
建树
我们考虑增量法,我们现在已经维护出了 \([1,i-1]\) 这个前缀的析合树(准确来说是森林),现在要加入第 \(i\) 位。
我们先考虑这个森林的第一层,一定就是从最右边开始找到一个最小的 \(l\) 满足这个区间为一个连续段,这个区间 \([l,r]\) 即为其中一颗树的根节点,然后令 \(r \gets l-1\) 重复这个操作。
注意到我们加入 \(i\) 时会·先找到一个最小的 \(L\) 满足 \([L,i]\) 为连续段(怎么找之后再说)。假如我们加入 \(i\) 之前的第一层所对应的区间为 $1 \leq i \leq m $ $[l_i,r_i] $ ($ r_i = l_{i-1}+1$ ,\(m\) 为总长度) 那么 \([L,i]\) 这个区间一定不会和这其中任意一个区间相交(不然它就不是最小的 \(L\) 了,可以画图理解一下)。那么就会把一个后缀合并成一个区间,而前面的形态不变。 这样我们就这需要考虑如何构建 \([L,i]\) 这个连读段的析合树了。
关于如何找到 \(L\) ,我们先考虑怎么判断一个区间是否为连续段,就是 \(max - min - r + l = 0\)。否则这个值一定大于 \(0\)。我们从左往右枚举每一个 \(r\),有一颗线段树维护 $max - min - r + l $ 的值,维护区间最小值,支持区间加即可。查询即在线段树上二分。还需要两个单调栈维护 \(min\) 和 \(max\)(这里比较简单,就大概说一下)。
我们先说一下如何建 \([l,r]\) 的析合树,其中 \([l,r]\) 已经是连续段。根据上面的方法,我们先判断这个点是哪一种点。也就是找出它的最长连续段后缀和前缀(不包含本身)是否覆盖了整个区间。
- 如果不是,那么就是析点。就可以和前面一样的方法找到它的所有儿子所对应的区间,递归进行。
- 否则就是合点。那么就先根据这两个区间找到最左边和最右边的儿子,在递归找到所有儿子。
我们发现这个过程不太好直接快速的实现。所以这就是我们考虑增量法的原因,因为我们已经求出了很多区间,只需要适当修改即可。
现在我们 \([L,i]\) 这个区间包含了之前的一些 \([l,r]\) 和新加入的 \([i,i]\) 这个区间。最长连续段前缀就是最左边的一个 \([l,r]\) (显然)(记作 \([l_1,r_1]\))。我们考虑一下最长连续段后缀,这里有两种情况。
第一种:这个区间没有和 $[l_1,r_1] $ 相交,那么它一定是若干个后缀的区间拼起来,不然就可以找到更长的。如果它包含了 \([l_2,r_2]\) 那么这个点就是一个合点(并且这个点只有两个儿子),否则就是一个析点。递归考虑这个后缀即可。
第二种:这个区间和 \([l_1,r_1]\) 相交,那么它一定是一个合点。并且 \([l_1,r_1]\) 这个区间一定是一个合点,这个点的儿子就是 \([l_1,r_1]\) 这个点的所有儿子加上 \([l_2,i]\) 这个区间,(这个可以画图理解一下),递归考虑这个后缀即可。
如果我们正着考虑这个过程,它是比较困难的。正难则反,我们倒着考虑这个过程,也就是从最开始的区间 \([i,i]\) 尝试向前合并。分三种情况讨论一下,容易发现复杂度为 \(O(n)\) 前提是 \(O(1)\) 判断一个区间是否为连续段。
这里给出一份代码供参考:(不知道为什么,全屏才有渲染)
struct ctree
{
// 注意开两倍空间,节点数是 2n 的
struct ST
{
int mn[17][N],mx[17][N];
void build()
{
for(int i=1;i<=n;i++) mx[0][i]=mn[0][i]=a[i];
for(int i=1;(1<<i)<=n;i++)
{
for(int j=1;j+(1<<i)-1<=n;j++)
{
mx[i][j]=max(mx[i-1][j],mx[i-1][j+(1<<(i-1))]);
mn[i][j]=min(mn[i-1][j],mn[i-1][j+(1<<(i-1))]);
}
}
}
bool cs(int l,int r) // 判断一个区间是否为连读段
{
int k=__lg(r-l+1);
return max(mx[k][l],mx[k][r-(1<<k)+1])-min(mn[k][l],mn[k][r-(1<<k)+1])==r-l;
}
}st;
struct segtree
{
#define ls (rt<<1)
#define rs (rt<<1|1)
#define mid ((l+r)>>1)
int mn[N<<2],t[N<<2];
void push_up(int rt) {mn[rt]=min(mn[ls],mn[rs]);}
void down(int rt,int v) {mn[rt]+=v;t[rt]+=v;}
void push_down(int rt)
{
if(!t[rt]) return;
down(ls,t[rt]);down(rs,t[rt]);t[rt]=0;
}
void build(int rt,int l,int r)
{
t[rt]=0;
if(l==r) return mn[rt]=0,void();
build(ls,l,mid);build(rs,mid+1,r);push_up(rt);
}
void upd(int rt,int l,int r,int L,int R,int v)
{
if(L<=l&&r<=R) return down(rt,v);
push_down(rt);
if(L<=mid) upd(ls,l,mid,L,R,v);
if(R>mid) upd(rs,mid+1,r,L,R,v);
push_up(rt);
}
int find(int rt,int l,int r)
{
if(l==r) return l;
return push_down(rt),mn[ls]?find(rs,mid+1,r):find(ls,l,mid);
}
#undef ls
#undef rs
#undef mid
}tr;
int s1[N],s2[N],t1,t2,l[N],r[N],m[N],tp[N],b[N],ct,u,L,s[N],t,h[N],tot,rt;
// l,r 左右断点 ;tp 节点类型 ;m 只有合点存在,也就是合点的儿子中最右边的那个,用来判断第二种情况
struct edge {int to,nxt;}e[N];
void add(int u,int v) // 建边
{
e[++tot]={v,h[u]};
h[u]=tot;
}
void build()
{
tr.build(1,1,n);st.build();
for(int i=1;i<=n;i++)
{
while(t1&&a[s1[t1]]<a[i])
tr.upd(1,1,n,s1[t1-1]+1,s1[t1],a[i]-a[s1[t1]]),t1--;
while(t2&&a[s2[t2]]>a[i])
tr.upd(1,1,n,s2[t2-1]+1,s2[t2],a[s2[t2]]-a[i]),t2--;
b[i]=++ct;l[ct]=r[ct]=i;u=ct;L=tr.find(1,1,n);s1[++t1]=i;s2[++t2]=i; // 找 L
while(t&&l[s[t]]>=L)
{
if(tp[s[t]]&&st.cs(m[s[t]],i)) // 第二种情况要先判
{
r[s[t]]=i;m[s[t]]=l[u];
add(s[t],u);u=s[t--];continue;
}
if(st.cs(l[s[t]],i)) // 第一种情况,并且合成的是合点
{
tp[++ct]=1;l[ct]=l[s[t]];r[ct]=i;m[ct]=l[u];
add(ct,u);add(ct,s[t--]);u=ct;
}
else // 第一种情况,并且合成的是析点
{
add(++ct,u);
while(!st.cs(l[s[t]],i)) add(ct,s[t--]);
l[ct]=l[s[t]],r[ct]=i,add(ct,s[t--]);u=ct;
}
}
tr.upd(1,1,n,1,i,-1);s[++t]=u;
}
rt=s[t]; // 根节点
}
}ct;
稍微理解一下,感觉很有可读性 ( 。
注意:虽然部分题目析合树是一种非常直接的做法,但由于代码量较大,可以先尝试其他更好写的做法。重要的是析合树能够直观的描述出一个排列的所有连续段,能够对某些题目有着更深刻的分析。
例1 这是直接求析合树形态计数,就非常简单了。
例2 就是一个简单的 DP
例3 一道值得思考的计数题
总结:这东西其实没什么用,就当是拓展思路吧,一般这类题都是计数题,如何优雅的设计出 DP 才是关键。

浙公网安备 33010602011771号