一些杂七杂八的数据结构学习笔记
本篇将着重讲解一些杂七杂八的数据结构。
这些数据结构虽琐碎,但在一些重要场合也常能派上用场。
大约每隔 3~5 天会学一个新的小知识点。
upd:学网络流学腻了,还是每天学一个新知识点比较好罢。
树上启发式合并(dsu on tree)
虽然它名字中含 dsu 但跟 dsu 没有半毛¥关系。
一个相当水的知识点哦
启发式合并,顾名思义,就是根据人类直观的感受对已有算法的优化。譬如冰茶姬的启发式就是对于两个大小不一样的集合,我们大小小的并到大的,这样就可以有效地将冰茶姬的深度控制在 \(\log n\) 级别(或许这就是树上启发式合并中那个“dsu”的来历吧)。
树上启发式合并的思想也与之类似。树上启发式合并,俗称 dsu on tree,是一种解决子树问题的离线算法,不允许修改。它能在 \(\mathcal O(nT\log n)\) 的复杂度内离线维护某个子树内的信息,其中 \(T\) 是加入一个节点的复杂度,一般为 \(\mathcal O(1)\) 或 \(\mathcal O(\log n)\)。
那么树上启发式合并究竟该怎样应用呢?先考虑一个问题:一棵树上每个节点有一个颜色,求每个点的子树中所有节点中不同颜色的个数。
考虑一个最暴力的做法,从根开始 dfs,再维护一个桶 \(c_x\) 表示颜色 \(x\) 出现的次数。在 dfs 某个节点 \(u\) 的过程中,先 dfs 它的所有儿子 \(v\) 求出其儿子的答案,每次 dfs 完之后清空桶。然后将 \(u\) 子树内所有点都加入桶中统计答案。
这样显然是错误的,一条链就可以把它卡成 \(n^2\)。但我们注意到 dfs 完某个节点 \(u\) 后,有且只有 \(u\) 的子树中的节点被加入桶中。回忆当年学树链剖分的时候对重儿子的定义,考虑以此入手对我们的算法进行一个小小的优化:
- 先 dfs \(u\) 的所有轻儿子 \(v\),统计 \(v\) 的答案,并清空桶。
- 然后 dfs \(u\) 的重儿子 \(son_u\),不清空桶。显然此时有且只有 \(son_u\) 子树中的点被加入了桶中。
- 再 dfs 一遍 \(u\) 的轻儿子 \(v\),将 \(v\) 的子树内的节点加入桶中。
- 最后计算出 \(u\) 的答案。
为什么这样复杂度就对了呢?考虑每个点会被 dfs 多少次。对于每个点到根节点的路径,每出现一条轻边就会导致该点被多 dfs 一次,故一个节点 dfs 的次数与其到根节点的路径上轻边的个数同阶。而在学树链剖分我们知道一个点到根节点的路径上的重链个数是 \(\log n\) 级别的,故个点到根节点的路径上轻边的个数也是 \(\log n\) 级别的,复杂度 \(n\log n\)。
最后解释一下为什么它被称作“启发式合并”。对于每个点 \(u\),设其子树的集合为 \(T_1,T_2,T_3,\dots,T_k\),那么 dsu on tree 的本质实际上是将 \(T_1,T_2,\dots,T_k\) 的信息合并起来,而借鉴启发式合并的思想,我们选出 \(|T_i|\) 的 \(i\),并将其它集合的信息都合并到 \(T_i\) 中。所以说树上启发式合并本质上是用 dsu 启发式合并的思想解决多集合的合并问题。
最后给出伪代码:
void calcans(int x,int f){//计算答案
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f||y==wson[x]) continue;
calcans(y,x);消除y的贡献
}
if(wson[x]) calcans(wson[x],x);//dfs重儿子
把x的贡献合并进去
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f||y==wson[x]) continue;
把y的子树内所有节点的贡献合并进去
}
记录答案
}
CF600E Lomsat gelral
模板题不多说,直接维护个桶即可,加入单个元素的复杂度为常数级别,总复杂度线对。
#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
#define fz(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,a,b) for(int i=a;i>=b;i--)
#define ffe(it,v) for(__typeof(v.begin()) it=v.begin();it!=v.end();it++)
#define fill0(a) memset(a,0,sizeof(a))
#define fill1(a) memset(a,-1,sizeof(a))
#define fillbig(a) memset(a,63,sizeof(a))
#define pb push_back
#define ppb pop_back
#define mp make_pair
template<typename T1,typename T2> void chkmin(T1 &x,T2 y){if(x>y) x=y;}
template<typename T1,typename T2> void chkmax(T1 &x,T2 y){if(x<y) x=y;}
typedef pair<int,int> pii;
typedef long long ll;
template<typename T> void read(T &x){
x=0;char c=getchar();T neg=1;
while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
while(isdigit(c)) x=x*10+c-'0',c=getchar();
x*=neg;
}
const int MAXN=1e5;
int n,c[MAXN+5];
int hd[MAXN+5],to[MAXN*2+5],nxt[MAXN*2+5],ec=0;
void adde(int u,int v){to[++ec]=v;nxt[ec]=hd[u];hd[u]=ec;}
int siz[MAXN+5],wson[MAXN+5],cnt[MAXN+5],mx=0;
ll ans[MAXN+5],sum=0;
void dfs0(int x,int f){//计算出每个节点的重儿子
siz[x]=1;
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f) continue;
dfs0(y,x);siz[x]+=siz[y];
if(siz[y]>siz[wson[x]]) wson[x]=y;
}
}
void del(int x,int f){//消除贡献(全局清空)
cnt[c[x]]--;mx=sum=0;
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f) continue;
del(y,x);
}
}
void add(int x,int f){//加入某个子树内所有节点的贡献
cnt[c[x]]++;
if(cnt[c[x]]==mx) sum+=c[x];
if(cnt[c[x]]>mx) mx=cnt[c[x]],sum=c[x];
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f) continue;
add(y,x);
}
}
void calcans(int x,int f){//计算答案
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f||y==wson[x]) continue;
calcans(y,x);//计算 x 的轻儿子 y 的答案
del(y,x);//消除 y 的贡献
}
if(wson[x]) calcans(wson[x],x);//计算 x 的重儿子的答案
cnt[c[x]]++;//把 x 的贡献合并进去
if(cnt[c[x]]==mx) sum+=c[x];
if(cnt[c[x]]>mx) mx=cnt[c[x]],sum=c[x];
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f||y==wson[x]) continue;
add(y,x);//把 y 的子树内所有节点的贡献合并进去
} ans[x]=sum;//记录答案
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&c[i]);
for(int i=1;i<n;i++){int u,v;scanf("%d%d",&u,&v);adde(u,v);adde(v,u);}
dfs0(1,0);calcans(1,0);for(int i=1;i<=n;i++) printf("%lld%c",ans[i],(i==n)?'\n':' ');
return 0;
}
CF246E Blood Cousins Return
也没啥,把每个询问记录在对应节点上,然后开一个 std::multiset<int>
的数组 \(st_i\) 表示深度为 \(i\) 的节点的名字的集合。然后树上启发式合并一遍即可。加入单个元素的复杂度是对数级别的,故总复杂度线性二次对数(ycx 既视感,有 ycx 内味儿了)
但我 WA 了一次,因为在回答询问的时候要调用下标为 \(dep_x+k\) 的 multiset
的大小,该值最大可达到 \(2\times 10^5\)(虽然有用的下标只有 \(10^5\)),但我数组只开到了 \(10^5\),就导致数组越界。已加入 sb 错误列表。
可能还有更优秀的做法,不过就没管了。
CF741D Arpa’s letter-marked tree and Mehrdad’s Dokhtar-kosh paths
难度 *2900 的 hot tea,并且竟然自己想出来了,更新了自己独立想出来的题目的难度上界(bushi)。
我们预处理出 \(msk_x\),其中 \(msk_x\) 是一个 22 位二进制数,第 \(i\) 位是 \(1\) 表示字母表中第 \(i\) 个字符在 \(x\) 到根节点的路径上出现了奇数次,否则表示出现了偶数次。
显然 \(x\) 到 \(y\) 的路径上的字符可以重排为一个回文串当且仅当 \(msk_x\oplus msk_y\) 在二进制下中至多有 \(1\) 位为 \(1\),即 \(msk_x\oplus msk_y=0,1,2^1,2^2,\dots,2^{21}\)
考虑使用树上启发式合并,假设我们 dfs 到点 \(u\)。显然 \(u\) 子树内的路径由 LCA 为点 \(u\) 的路径与 LCA 不为 \(u\) 的路径两部分组成,后者的最大值为 \(\max\limits_{v\in son_u}ans_v\),关键是如何计算前者的答案,即对于满足 \(msk_x\oplus msk_y=0,1,2^1,2^2,\dots,2^{21}\) 且 \(x,y\) 在 \(u\) 的不同子树中的 \(x,y\),\(dep_x+dep_y-2\times dep_u\) 的最大值。由于 \(2\times dep_u\) 为定值,故只需求出 \(dep_x+dep_y\) 的最大值。
考虑用类似于点分治的处理方式,实时维护一个 \(mx_x\) 表示 \(msk_u=x\) 的 \(u\) 中 \(dep_u\) 的最大值。依次 dfs \(u\) 的每个子树,先考虑这个子树中每个点的贡献,然后更新 \(mx\) 数组。计算贡献的具体方式是,考虑该子树中每一个节点 \(x\),枚举 \(msk_x\oplus msk_y\) 的值 \(v\)(显然只有 23 种可能),如果 \(mx_{msk_x\oplus v}\neq 0\),就用 \(mx_{msk_x\oplus v}+dep_x-2\times dep_u\) 更新 \(ans_u\)。这样就能保证我们算出的 \(x,y\) 是属于 \(u\) 的不同子树了。
还有一点,有人可能会问:这个 \(mx_x\) 不是求某个东西的最大值,不满足可撤销性吗。注意,在树上启发式合并中,我们的删除操作是全局删除,需消除子树内所有点的贡献,所以直接把对应的 \(msk\) 值赋为 \(0\) 就行了,不需要画蛇添足地维护个 std::multiset<int>
之类的。
算下复杂度,dsu on tree 复杂度 1log,枚举 \(msk_x\oplus msk_y\) 还有个 23 的常数。总复杂度 \(23n\log n\)。不知道有没有更优秀的做法。我一开始还担心能不能跑过去,不过 CF 机子可谓是神一般得快,再加上 3s 时限,跑过去没有大问题。
CF715C Digit Tree
一道 *2700 的 D1C,也被我自己想出来了(
我们定义 \(a_u\) 为根节点到 \(u\) 路径上所遇到的数连在一起形成的数 \(\bmod m\),再定义 \(b_u\) 为 \(u\) 到根节点路径上所遇到的数连在一起形成的数 \(\bmod m\),\(a_u,b_u\) 显然可以一遍 DFS 求出。
考虑两点 \(u,v\),假设它们的 LCA 为 \(w\),那么 \(u\to v\) 路径上形成的数连在一起就是 \(\dfrac{a_u-a_w}{10^{d_w}}·10^{d_v-d_w}+b_v-b_w·10^{d_v-d_w}\),其中 \(d_u\) 为 \(u\) 的深度,稍微化简一下即可得到 \(10^{d_v}·(\dfrac{a_u-a_w}{10^{2d_w}}+\dfrac{b_v}{10^{d_v}}-\dfrac{b_w}{10^{d_w}})\),而由于 \(10^{d_v}\perp m\),故 \(10^{d_v}·(\dfrac{a_u-a_w}{10^{2d_w}}+\dfrac{b_v}{10^{d_v}}-\dfrac{b_w}{10^{d_w}})\equiv 0\pmod{m}\leftrightarrow \dfrac{a_u-a_w}{10^{2d_w}}+\dfrac{b_v}{10^{d_v}}-\dfrac{b_w}{10^{d_w}}\pmod{m}\) 即 \(\dfrac{a_u}{10^{2d_w}}+\dfrac{b_v}{10^{d_v}}\equiv \dfrac{a_w}{10^{2d_w}}+\dfrac{b_w}{10^{d_w}}\),考虑枚举 \(w\) 并按照上一题的套路进行 dsu on tree,然后开两个桶 \(c1,c2\),\(c1_i\) 实时维护 \(a_u=i\) 的 \(u\) 的个数,\(c2_i\) 实时维护 \(\dfrac{b_v}{10^{d_v}}=i\) 的 \(v\) 的个数,这个贡献显然可以通过调用桶里的值求出。至于桶怎么开……哈希表可以搞定,不过由于 map
能过就没管那么多了,时间复杂度 \(n\log^2n\),如果用哈希表可以少一个 \(\log\)。
坑点:虽然 \(m\perp 10\),但由于 \(m\) 不是质数,逆元需通过 exgcd
求出,而不能使用费马小定理(看来我数论白学了/ll/dk)
一些根号算法
莫队
emmm 这东西大约 1 年前就学会了吧,这里将介绍一些更高级的操作
普通莫队
首先简单复习一下最最最最普通的莫队。
对于那些信息不好直接用数据结构维护,但支持快速插入/删除一个元素,并且支持离线的题,可以想到用莫队来维护。
莫队,说白了就是把询问离线下来,用某种玄学(bushi,莫队有严格的复杂度证明)方法将询问排个序,然后维护两个指针 \(cl,cr\) 动态添加/删除元素。从而实现 \(\mathcal O(n\sqrt{n})\) 回答询问。
莫队的核心操作,无疑是它的排序方式,考虑将序列分块,设块长为 \(T\),对于两个形如 \((l_1,r_1),(l_2,r_2)\),若 \(l_1,l_2\) 属于同一个块,则按右端点升序排序,否则按左端点升序排序。
为什么这样排完序复杂度就对了呢?显然最终复杂度与左端点和右端点移动的距离有关。对于左端点,在回答左端点位于相同的块询问中,相邻询问之间左端点移动的距离最多为 \(T\),故左端点移动的距离的最大值为 \(mT\)。对于右端点,由于在左端点位于相同的块的询问中我们是按右端点升序排序的,故回答左端点位于相同的块的询问中,右端点移动的距离最多为 \(n\),而块的个数为 \(\dfrac{n}{T}\),故右端点移动的距离的最大值为 \(\dfrac{n^2}{T}\),根据均值不等式(这个上周五刚讲过)\(mT+\dfrac{n^2}{T}\geq 2\sqrt{mT\times\dfrac{n^2}{T}}=2n\sqrt{m}\),如果 \(n,m\) 同阶那复杂度可视作 \(n\sqrt{n}\)。可以通过 \(n,m\leq 10^5\) 级别的题目。
还有一点,那就是莫队要先插入再删除,否则会出现一些奇怪的错误。
带修莫队
说白了就是资瓷修改的莫队。(注意:这里的修改须是单点修改,如果遇到区间修改的题用带修莫队需用差分之类的算法将其转化为单点修改)
新增一维时间轴 \(t\),把每个查询操作变成一个三元组 \((l,r,t)\)。
将 \((l,r,t)\) 按 \(l\) 所在的块为第一关键字,\(r\) 所在的块为第二关键字,\(t\) 为第三关键字排序。然后动态维护三个指针 \(cl,cr,ct\),然后依次处理每个询问即可。
算下复杂度,假设块长为 \(T\),那么:
- 在处理 \(l\) 在同一块中的询问时,\(cl\) 最多只在这一块中移动,\(cl\) 移动的距离最大为 \(mT\)。
- 在处理 \(l\) 在同一块、\(r\) 也在同一块的询问时,\(cr\) 最多也只在这一块中移动,距离为 \(mT\);处理 \(l\) 在同一块、\(r\) 不在同一块中的询问时,\(cr\) 最多从 \(1\) 跑到 \(n\),而 \(l\) 在同一块中的询问最多只有 \(\dfrac{n}{T}\) 组,故距离为 \(\dfrac{n^2}{T}\)。\(cr\) 距离最大为 \(mT+\dfrac{n^2}{T}\)。
- 在处理 \(l\) 在同一块、\(r\) 也在同一块的询问时,\(ct\) 最多从 \(1\) 跑到 \(m\),而 \(l\) 在同一块、\(r\) 在同一块中的询问最多只有 \(\dfrac{n}{T}\times\dfrac{n}{T}=\dfrac{n^2}{T^2}\) 组,故 \(ct\) 移动的距离最大为 \(\dfrac{n^2m}{T^2}\)。
三个函数的最大值在 \(T=\sqrt[3]{n^4m}\) 时取到,若视 \(n,m\) 同阶则复杂度为 \(n^{\frac{5}{3}}\),一般能通过 \(n\leq 6\times10^4\) 的数据。
(btw,我用均值不等式算出来理论复杂度最小值为 \(\sqrt[5]{n^4m^4}=n^{\frac{8}{5}}\),不过感觉加上常数复杂度和上面差不多吧。总之,分块的块长也是个玄学)
upd on 2021.2.2 20:51:
上面那个 \(n^{8/5}\) 是假的忽略它就行了,因为均值不等式中取不到等号,wtmsb。
至于那个 \(n^{5/3}\) 是怎么来的,你设 \(f(T)=2mT+\dfrac{n^2}{T}+\dfrac{n^2m}{T^2}\),然后暴力求导就行了。
接下来就是实现的问题了,拿 P1903 [国家集训队]数颜色 / 维护队列 举例。考虑将操作离线下来,如果是询问操作就将对应的 \((t,l,r)\) 压入询问序列中;如果是修改操作就记录下该位置的下标、修改前的颜色、修改后的颜色。然后按照上面的方式将询问排序。维护三个指针 \(cl,cr,ct\),移动 \(cl,cr\) 时直接插入/删除好了,移动 \(ct\) 时如果遇到修改操作就保存修改,如果修改的下标在当前处理的询问的 \(l,r\) 之间就更新桶。其它都和普通莫队没什么两样,具体见代码:
#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
#define fz(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,a,b) for(int i=a;i>=b;i--)
#define ffe(it,v) for(__typeof(v.begin()) it=v.begin();it!=v.end();it++)
#define fill0(a) memset(a,0,sizeof(a))
#define fill1(a) memset(a,-1,sizeof(a))
#define fillbig(a) memset(a,63,sizeof(a))
#define pb push_back
#define ppb pop_back
#define mp make_pair
template<typename T1,typename T2> void chkmin(T1 &x,T2 y){if(x>y) x=y;}
template<typename T1,typename T2> void chkmax(T1 &x,T2 y){if(x<y) x=y;}
typedef pair<int,int> pii;
typedef long long ll;
template<typename T> void read(T &x){
x=0;char c=getchar();T neg=1;
while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
while(isdigit(c)) x=x*10+c-'0',c=getchar();
x*=neg;
}
const int MAXN=133333;
const int MAXV=1e6;
const int BLOCK_SZ=2500;
const int BLOCK_CNT=100;
int n,m,qu,a[MAXN+5],blk;
int L[BLOCK_CNT+2],R[BLOCK_CNT+2],bel[MAXN+5];
struct query{
int t,l,r;
bool operator <(const query &rhs){
if(bel[l]!=bel[rhs.l]) return bel[l]<bel[rhs.l];
if(bel[r]!=bel[rhs.r]) return bel[r]<bel[rhs.r];
return t<rhs.t;
}
} q[MAXN+5];
struct chg{int x,pre,cur;} c[MAXN+5];
int cnt[MAXV+5],ans=0,res[MAXN+5];
void ins(int col){if(!cnt[col]) ans++;cnt[col]++;}
void del(int col){cnt[col]--;if(!cnt[col]) ans--;}
int main(){
scanf("%d%d",&n,&m);blk=(n-1)/BLOCK_SZ+1;
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
memset(res,-1,sizeof(res));
for(int i=1;i<=blk;i++){//预处理块的左端点、右端点、每个点属于哪个块
L[i]=(i-1)*BLOCK_SZ+1;
R[i]=min(i*BLOCK_SZ,n);
for(int j=L[i];j<=R[i];j++)
bel[j]=i;
}
for(int i=1;i<=m;i++){
static char opt[3];int x,y;scanf("%s%d%d",opt+1,&x,&y);
if(opt[1]=='R') c[i].x=x,c[i].pre=a[x],a[x]=y,c[i].cur=a[x];//修改操作,保存修改的位置、修改前的颜色、修改后的颜色
else q[++qu].t=i,q[qu].l=x,q[qu].r=y;//将当前询问压入询问序列
}
sort(q+1,q+qu+1);
int cl=1,cr=0,ct=m;//当前的 a 数组是所有修改都结束,即时间 m 的版本,故 ct 的初始值为 m
for(int i=1;i<=qu;i++){
while(cl>q[i].l) ins(a[--cl]);//移动左/右端点,注意先插入再删除
while(cr<q[i].r) ins(a[++cr]);
while(cl<q[i].l) del(a[cl++]);
while(cr>q[i].r) del(a[cr--]);
while(ct>q[i].t){
if(c[ct].x){//是修改操作
if(cl<=c[ct].x&&c[ct].x<=cr) del(c[ct].cur);//如果在待查询的区间中就更新桶
a[c[ct].x]=c[ct].pre;//修改 a 数组的值
if(cl<=c[ct].x&&c[ct].x<=cr) ins(c[ct].pre);
} ct--;
}
while(ct<q[i].t){
if(c[ct].x){
if(cl<=c[ct].x&&c[ct].x<=cr) del(c[ct].pre);
a[c[ct].x]=c[ct].cur;
if(cl<=c[ct].x&&c[ct].x<=cr) ins(c[ct].cur);
} ct++;
}
res[q[i].t]=ans;//记录答案
}
for(int i=1;i<=m;i++) if(~res[i]) printf("%d\n",res[i]);
return 0;
}
回滚莫队
普通莫队可以解决插入、删除都很容易解决的问题。可当删除不太容易(例如求 \(\max\))时,可以考虑回滚莫队。
首先依然考虑分块,依然像普通莫队一样把询问排序并维护两个指针 \(cl,cr\)。
对于相邻询问,若左端点不在同一块,那么暴力清空数组,并把 \(cr\) 放在当前询问左端点所在块的结尾,\(cl\) 放在当前询问左端点所在块的下一块的开头。若左端点在同一块,则右端点显然是递增的。每次处理询问时,钦定初始的左端点为当前左端点所在的块的下一块的开头,然后先移动右端点,开一个临时数组保存下当前状态下的答案后再移动左端点,处理完询问后还原记录下的答案。
时间复杂度同普通莫队的复杂度,\(\mathcal O(n\sqrt{n})\)。
注意特判左右端点在同一块内的情况,\(\mathcal O(\sqrt{n})\) 暴力即可。
具体实现见 P5906 【模板】回滚莫队&不删除莫队 的代码:
#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
#define fz(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,a,b) for(int i=a;i>=b;i--)
#define ffe(it,v) for(__typeof(v.begin()) it=v.begin();it!=v.end();it++)
#define fill0(a) memset(a,0,sizeof(a))
#define fill1(a) memset(a,-1,sizeof(a))
#define fillbig(a) memset(a,63,sizeof(a))
#define pb push_back
#define ppb pop_back
#define mp make_pair
template<typename T1,typename T2> void chkmin(T1 &x,T2 y){if(x>y) x=y;}
template<typename T1,typename T2> void chkmax(T1 &x,T2 y){if(x<y) x=y;}
typedef pair<int,int> pii;
typedef long long ll;
template<typename T> void read(T &x){
x=0;char c=getchar();T neg=1;
while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
while(isdigit(c)) x=x*10+c-'0',c=getchar();
x*=neg;
}
const int MAXN=2e5;
const int BLOCK_SZ=350;
const int BLOCK_CNT=MAXN/BLOCK_SZ+1;
int n,m,a[MAXN+5],blk,key[MAXN+5],hs[MAXN+5],num=0;
int L[BLOCK_CNT+5],R[BLOCK_CNT+5],bel[MAXN+5];
struct query{
int l,r,id;
bool operator <(const query &rhs){
if(bel[l]!=bel[rhs.l]) return bel[l]<bel[rhs.l];
return r<rhs.r;
}
} q[MAXN+5];
int posl[MAXN+5],posr[MAXN+5],tmposl[MAXN+5],tmposr[MAXN+5],mx=0,ans[MAXN+5];
void ins(int pos){
if(!posl[a[pos]]) posl[a[pos]]=posr[a[pos]]=pos;
else chkmin(posl[a[pos]],pos),chkmax(posr[a[pos]],pos),chkmax(mx,posr[a[pos]]-posl[a[pos]]);
}
int main(){
scanf("%d",&n);blk=(n-1)/BLOCK_SZ+1;
for(int i=1;i<=n;i++) scanf("%d",&a[i]),key[i]=a[i];
sort(key+1,key+n+1);
for(int i=1;i<=n;i++) if(key[i]!=key[i-1]) hs[++num]=key[i];//离散化
for(int i=1;i<=n;i++) a[i]=lower_bound(hs+1,hs+num+1,a[i])-hs;
for(int i=1;i<=blk;i++){//预处理块的左端点、右端点、每个点属于哪个块
L[i]=(i-1)*BLOCK_SZ+1;
R[i]=min(i*BLOCK_SZ,n);
for(int j=L[i];j<=R[i];j++) bel[j]=i;
} scanf("%d",&m);
for(int i=1;i<=m;i++) scanf("%d%d",&q[i].l,&q[i].r),q[i].id=i;
sort(q+1,q+m+1);int cl=0,cr=0;
for(int i=1;i<=m;i++){
if(i==1||bel[q[i].l]!=bel[q[i-1].l]){//如果相邻询问左端点不属于同一块就暴力清空数组
cl=R[bel[q[i].l]]+1;cr=R[bel[q[i].l]];//钦定 cl 为左端点所在块的右端点+1,cr 为左端点所在块的右端点
memset(posl,0,sizeof(posl));memset(posr,0,sizeof(posr));mx=0;
}
if(bel[q[i].l]==bel[q[i].r]){//特判左端点和右端点在同一块的情况,O(sqrt(n))求解
for(int j=q[i].l;j<=q[i].r;j++) ins(j);
ans[q[i].id]=mx;
for(int j=q[i].l;j<=q[i].r;j++) posl[a[j]]=posr[a[j]]=0;//注意这里要手动清空,不要memset,否则会 T
mx=0;continue;
}
while(cr<q[i].r) ins(++cr);//由于左端点在同一块内的询问的右端点递增,所以可以直接移动右端点
for(int j=q[i].l;j<cl;j++) tmposl[a[j]]=posl[a[j]],tmposr[a[j]]=posr[a[j]];//记录下当前状态下的答案,由于将初始的左端点的位置 cl 移到当前询问的左端点 l 的过程中只有 a[l],a[l+1],...,a[cl-1] 的贡献会改变,所以只用记录下这些位置的贡献
int tmp=mx;
while(cl>q[i].l) ins(--cl);ans[q[i].id]=mx;//移动左端点+记录答案
cl=R[bel[q[i].l]]+1;//将左端点的位置还原到本次询问左端点所在块的右端点+1
for(int j=q[i].l;j<cl;j++) posl[a[j]]=tmposl[a[j]],posr[a[j]]=tmposr[a[j]];//还原桶
mx=tmp;
}
for(int i=1;i<=m;i++) printf("%d\n",ans[i]);
return 0;
}
树上莫队
其实和普通莫队没啥两样,大体思想就是把树压成一个序列,并将两点之间路径的信息对应到序列的一个区间上。转化为普通莫队处理那么具体应该怎么把树压成一个序列呢?一个很直观的想法是 DFS 序,不过 DFS 序处理对于路径问题会很棘手,故无法使用 DFS 序。
这时候就要用到欧拉序,简单来说就是你刚开始 DFS 某个节点 \(x\) 时记录一个时间戳 \(st_x\),访问完 \(x\) 的所有子树后再记录一个时间戳 \(ed_x\)。这样可得到一个长度为 \(2n\) 的序列,其中每个 \(1\) 到 \(n\) 的数恰好在其中出现两次。
那么怎么将两点 \(x,y\) 之间的路径与这个长度为 \(2n\) 的序列建立联系呢?不妨设 \(st_x<st_y\)。我们分情况讨论。如果 \(x\) 为 \(y\) 的祖先,那么从刚开始 DFS \(x\) 开始,到第一次 DFS 到 \(y\) 结束,有的节点没有被访问,有的节点它整棵子树都已经访问完毕了,有且仅有 \(x\) 到 \(y\) 的路径上的点是“访问过,但没有访问完整棵子树”,故在欧拉序中 \([st_x,st_y]\) 中出现恰好一次的数的集合就是 \(x\) 与 \(y\) 路径上点的集合;如果 \(x\) 不是 \(y\) 的祖先,同理,从访问完 \(x\) 的子树,向上回溯的时候开始,到第一次访问 \(y\) 结束,有的节点没有被访问,有的节点它整棵子树都已经访问完毕了,有且仅有(绝大多数)\(x\) 到 \(y\) 的路径上的点是“访问过,但没有访问完整棵子树”。要特别注意的一点是 \(x,y\) 的 LCA 并不会被访问——因为它第一次被访问应当在访问 \(x\) 之前,第二次被访问应当在访问 \(y\) 之后。故 \(x,y\) 路径上点的集合就是欧拉序中 \([ed_x,st_y]\) 中出现恰好一次的数的集合与 \(\{\operatorname{LCA}(x,y)\}\) 的并集。这样就可以用莫队维护了。
时间复杂度 \(n\sqrt{n}\),需注意的一点是欧拉序的长度为 \(2n\),故数组大小与分块处理到的长度应当开到 \(2n\)!
例题 CF852I Dating 代码:
#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
#define fz(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,a,b) for(int i=a;i>=b;i--)
#define ffe(it,v) for(__typeof(v.begin()) it=v.begin();it!=v.end();it++)
#define fill0(a) memset(a,0,sizeof(a))
#define fill1(a) memset(a,-1,sizeof(a))
#define fillbig(a) memset(a,63,sizeof(a))
#define pb push_back
#define ppb pop_back
#define mp make_pair
template<typename T1,typename T2> void chkmin(T1 &x,T2 y){if(x>y) x=y;}
template<typename T1,typename T2> void chkmax(T1 &x,T2 y){if(x<y) x=y;}
typedef pair<int,int> pii;
typedef long long ll;
template<typename T> void read(T &x){
x=0;char c=getchar();T neg=1;
while(!isdigit(c)){if(c=='-') neg=-1;c=getchar();}
while(isdigit(c)) x=x*10+c-'0',c=getchar();
x*=neg;
}
const int MAXN=1e5;
const int LOG_N=18;
const int BLOCK_SZ=632;
const int BLOCK_CNT=MAXN*2/BLOCK_SZ;
int n,qu,a[MAXN+5],c[MAXN+5],key[MAXN+5],hs[MAXN+5],num=0;
int hd[MAXN+5],to[MAXN*2+5],nxt[MAXN*2+5],ec=0;
void adde(int u,int v){to[++ec]=v;nxt[ec]=hd[u];hd[u]=ec;}
int fa[MAXN+5][LOG_N+2],st[MAXN+5],ed[MAXN+5],dep[MAXN+5],seq[MAXN*2+5],tim=0;
int blk,L[BLOCK_CNT+3],R[BLOCK_CNT+3],bel[MAXN*2+5];
void dfs0(int x,int f){
fa[x][0]=f;st[x]=++tim;seq[tim]=x;
for(int e=hd[x];e;e=nxt[e]){
int y=to[e];if(y==f) continue;
dep[y]=dep[x]+1;dfs0(y,x);
} ed[x]=++tim;seq[tim]=x;
}
int getlca(int x,int y){
if(dep[x]<dep[y]) swap(x,y);
for(int i=LOG_N;~i;i--) if(dep[x]-(1<<i)>=dep[y]) x=fa[x][i];
if(x==y) return x;
for(int i=LOG_N;~i;i--) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];
return fa[x][0];
}
struct query{
int l,r,t,id;
bool operator <(const query &rhs){
if(bel[l]!=bel[rhs.l]) return bel[l]<bel[rhs.l];
return r<rhs.r;
}
} q[MAXN+5];
ll ans=0,res[MAXN+5];int cnt[MAXN+5][2],flg[MAXN+5];//flg[x] 表示 x 出现了奇数次还是偶数次
void ins(int x,int y){ans+=cnt[x][y^1];cnt[x][y]++;}//插入
void del(int x,int y){ans-=cnt[x][y^1];cnt[x][y]--;}//删除
void oper(int x){
flg[seq[x]]^=1;
if(flg[seq[x]]) ins(c[seq[x]],a[seq[x]]);
else del(c[seq[x]],a[seq[x]]);
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
for(int i=1;i<=n;i++) scanf("%d",&c[i]),key[i]=c[i];
sort(key+1,key+n+1);
for(int i=1;i<=n;i++) if(key[i]!=key[i-1]) hs[++num]=key[i];
for(int i=1;i<=n;i++) c[i]=lower_bound(hs+1,hs+num+1,c[i])-hs;//离散化
for(int i=1;i<n;i++){int u,v;scanf("%d%d",&u,&v);adde(u,v);adde(v,u);}
dfs0(1,0);for(int i=1;i<=LOG_N;i++) for(int j=1;j<=n;j++) fa[j][i]=fa[fa[j][i-1]][i-1];//预处理 LCA
blk=(2*n-1)/BLOCK_SZ+1;//注意分块预处理到 2n!
for(int i=1;i<=blk;i++){
L[i]=(i-1)*BLOCK_SZ+1;
R[i]=min(i*BLOCK_SZ,2*n);
for(int j=L[i];j<=R[i];j++) bel[j]=i;
}
scanf("%d",&qu);
for(int i=1;i<=qu;i++){
int x,y;scanf("%d%d",&x,&y);
if(st[x]<st[y]) swap(x,y);
int lca=getlca(x,y);
if(x==lca){q[i].l=st[x];q[i].r=st[y];q[i].id=i;}//x 是 y 的祖先
else{q[i].l=ed[x];q[i].r=st[y];q[i].t=lca;q[i].id=i;}//x 不是 y 的祖先
} sort(q+1,q+qu+1);int cl=1,cr=0;
for(int i=1;i<=qu;i++){
while(cr<q[i].r) oper(++cr);
while(cl>q[i].l) oper(--cl);
while(cl<q[i].l) oper(cl++);
while(cr>q[i].r) oper(cr--);
if(q[i].t) ans+=cnt[c[q[i].t]][a[q[i].t]^1];//特判 LCA
res[q[i].id]=ans;
if(q[i].t) ans-=cnt[c[q[i].t]][a[q[i].t]^1];
}
for(int i=1;i<=qu;i++) printf("%lld\n",res[i]);
return 0;
}
根号分治
其实这东西的思想非常常见(比如说整除分块就用到了根号分治的思想),并且也很容易理解,只不过从来没有系统地学过,于是今天就顺带着把它学会了。
差不多在就是你设一个阈值 \(T\),对于规模 \(\leq T\) 的子问题,虽然这样的子问题的个数可能很多,但是它的规模很小,暴力跑一遍复杂度也没问题。对于规模 \(>T\) 的子问题,虽然规模很大,但是可能的子问题的个数不是太多,可以事先预处理出所有这样的子问题的答案,查询的时候直接调用就行了。一般 \(T=\sqrt{n}\) 的时候复杂度最优,故称该算法为“根号分治”。
这部分题目就比莫队什么的灵活多了,这里放了 6 道例题:
P3396 哈希冲突
经典根分入门题。注意到下标 \(\bmod p=x\) 的数之和很难用数据结构直接维护,故考虑根号分治。很明显,当 \(p>\sqrt{n}\) 的时候下标 \(\bmod p=x\) 的数很少,顶多只有 \(\sqrt{n}\) 个,故暴力 \(\sqrt{n}\) 地跑一遍也没问题。当 \(p\leq \sqrt{n}\) 的时候,可能的二元组 \((p,x)\) 顶多只有 \(1\times 1+2\times 2+3\times 3+\dots+\sqrt{n}\times\sqrt{n}<n\sqrt{n}\) 个,故记 \(sum_{x,y}\) 为二元组 \((x,y)\) 的答案。对于每个修改暴力枚举所有 \(p\leq\sqrt{n}\) 修改 \(sum_{p,x\bmod p}\) 即可,查询就直接输出 \(sum_{p,x}\)。
CF797E Array Queries
弱智 2000,你给我 *2000
同上,\(k>\sqrt{n}\) 直接暴力求答案,最多跳 \(\sqrt{n}\) 次。\(k\leq\sqrt{n}\) 提前用一个 dp 预处理答案。复杂度线性根号。
CF506D Mr. Kitayuta's Colorful Graph
这个题 hb 大概 2019 年的时候就在 tgB 讲过了,只不过当时上 hb 的时候不太认真,没有及时补题
还是考虑根号分治,这次我们对颜色大小进行根号分治,我们记颜色为 \(c\) 的边数 \(\geq\sqrt{m}\) 的颜色 \(c\) 为「big color」,其余的颜色为「small color」。
我们不难想到两个暴力解法。第一个暴力解法就是对全部 \(m\) 种颜色建立冰茶姬,询问的时候就从 \(1\) 到 \(m\) 扫一遍,如果在颜色 \(i\) 对应的冰茶姬中 \(u,v\) 属于同一连通块答案加 \(1\),复杂度 \(\mathcal O(nm\alpha(n))\)。第二种暴力解法还是对全部 \(m\) 种颜色建立冰茶姬,并枚举所有属于同一连通块的两点 \(u,v\),令 \(ans_{u,v}\) 加 \(1\),查询的时候就直接输出 \(ans_{u,v}\) 即可,复杂度 \(\mathcal O(n^2m\alpha(n))\),甚至不如解法 \(1\)。
不难发现这两个解法都有各自的缺陷,也有各自的优势。第一种解法枚举了所有的颜色,故处理颜色个数很少的情况很有效。第二个解法枚举了所有属于同一连通块的点,故处理连通块大小的平方和很少的时候很有效。根据根号分治的性质,「big color」的个数顶多为 \(\sqrt{m}\),用解法 1 解决没有问题。而对于每个「small color」,与之关联的边的个数顶多为 \(\sqrt{m}\)。假设有 \(y\) 个边与之关联,那么该颜色所形成连通块大小的平方和最多为 \(y^2\),而顶多只有 \(\dfrac{m}{y}\) 个有 \(y\) 个边与其关联的颜色 \(c\),故「small color」所形成的连通块大小的平方和顶多为 \(y^2\times\dfrac{m}{y}=my\leq m\sqrt{m}\),故用解法 2 解决「small color」的贡献是没有问题的。
CF1270F Awesome Substrings
*2600 的 Div.1+Div.2 F,其实还算好,可就是 wtcl 所以想不出来啊。
求出前缀和数组 \(s_i\),题目要求 \(r-l+1\) 是 \(s_r-s_{l-1}\) 的倍数,故我们套路地把这个比值 \(\dfrac{s_r-s_{l-1}}{r-l+1}\) 设出来,记作 \(t\)
这样一来我们可以想到一个很暴力的做法,枚举所有 \(t\in[1,n]\),记 \(b_i=ts_i-i\),那么 \(\dfrac{s_r-s_{l-1}}{r-l+1}=t\) 就等价于 \(b_r=b_{l-1}\),用个哈希表记录 \(c_x\) 为 \(b_i=x\) 的 \(i\) 个数,然后算出 \(\sum\dbinom{c_x}{2}\) 就行了。时间复杂度平方。
但是这样做显然会炸,那有没有什么优化的方法呢?根号分治。对于 \(t\leq\sqrt{n}\),直接算是没问题的。对于 \(t>\sqrt{n}\),不难发现 \(s_r-s_{l-1}\) 的值顶多为 \(\sqrt{n}\),那么我们就转而枚举 \(s_r-s_{l-1}\),假设其为 \(p\)。我们固定区间左端点 \(l\),显然满足 \([l,r]\) 中 \(1\) 的个数为 \(p\) 的 \(r\) 组成的集合显然是一个区间,记作 \([L,R]\),这个可以 two pointers 求出,那么现在我们就要求出有多少个 \(r\in [L,R]\) 满足 \(r-l+1\) 是 \(p\) 的倍数,这个直接除一下就行了,注意减去 \(\dfrac{s_r-s_{l-1}}{r-l+1}\leq\sqrt{n}\) 的贡献。时间复杂度线性根号。
CF1039D You Are Given a Tree
这题就有点神仙了,首先暴力的 \(n^2\) 算法得会,其实就是 P6147 [USACO20FEB]Delegation G,设 \(dp_i\) 表示在 \(i\) 的子树内划分长度为 \(k\) 的链,零头的长度为多少。直接上个贪心,枚举 \(i\) 的每个儿子 \(j\),如果存在两条链能能合并成一条链就合并 \(f_i=0\),不能合并就令 \(f_i\) 为最大的 \(f_j\) 加 \(1\) 即可。
然后考虑怎样优化这个暴力,设一个阈值 \(T\),对于 \(k\in [1,T]\) 直接跑,时间复杂度 \(\mathcal O(nT)\)。对于 \(k>T\) 不难发现答案顶多为 \(\dfrac{n}{T}\),于是维护一个指针 \(p\),初始 \(p=T+1\),每次二分找出最右边的答案与 \(p\) 相同的点 \(r\),然后把 \(p\) 移到 \(r+1\) 即可。由于答案至多为 \(\dfrac{n}{T}\),所以最多二分 \(\dfrac{n}{T}\) 次,复杂度 \(\dfrac{n^2\log n}{T}\),套用均值不等式可知当 \(T=\sqrt{n\log n}\) 的时候复杂度最优。
本题唯一恶心的地方就是卡常,卡得要死要活,莫名其妙一直 TLE 8,最后不得不参照题解区的解法把树形 dp 部分递归的写法改为不递归的写法才将其 AC。。。
HDU 6756 Finding a MEX
这题 ycx 看一眼就秒了,storz ycx yyds %%% ddw yyzbhw
u1s1 这题确实比前几个题要显然不少,因为 MEX 这个东西本来就不是特别好维护,再加上本题还要维护 \(n\) 个集合的 MEX。我们通常使用的平衡树/线段树都是采用的分治的思想,而本题与分治没有直接关系。
我们记节点度数 \(\geq T\) 的点为「big node」,其余节点为「small node」。显然查询「small node」的时候暴力跑一遍复杂度顶多是 \(T\) 的,不会出现问题。查询「big node」的时候就不能暴力了,需预处理出每个「big node」的答案 \(ans_u\)。
那怎么维护 \(ans_u\) 呢?对于每次修改,除了改变 \(a_x\) 的值的同时,还需枚举全部与 \(x\) 相邻的「big node」\(y\)——因为「big node」的总数最多为 \(\dfrac{n}{T}\) 所以 \(y\) 的个数也是 \(\dfrac{n}{T}\) 级别的 并将原来的 \(a_x\) 从 \(y\) 所在的集合中删除并插入新的 \(a_x\)。于是现在问题转化为如何快速从集合中插入/删除元素并快速求出集合的 MEX,这个权值线段树可以搞定。总复杂度 \(nT+\dfrac{n^2\log n}{T}\),当 \(T=\sqrt{n\log n}\) 的时候复杂度最优,复杂度 \(n\sqrt{n\log n}\)。感觉常数比较大,可能跑不过去就没写了。
当然总有更优秀的做法,注意到我们最多会做 \(\dfrac{n^2}{T}\) 次插入/删除操作,但查询 MEX 顶多只有 \(n\) 次,借鉴 https://www.cnblogs.com/ET2006/p/NFLSOJ-707.html 的思想,我们可以使用 \(\mathcal O(1)\) 插入/删除,\(\mathcal O(\sqrt{n})\) 求 MEX 的数据结构——分块。我们将 \(0\) 到 \(m\) 的数分为 \(\sqrt{m}\) 块,记 \(cnt_{u,x}\) 为 \(u\) 的集合中 \(x\) 的出现次数,再记 \(cnt2_{u,x}\) 第 \(x\) 块的数 \(y\) 中,有多少个满足 \(cnt_{u,y}=0\)。查询 MEX 的时候就暴力一块一块地跳,若跳到某一块满足 \(cnt_{u,x}\neq 0\),那么就从这块的左端点开始一个数一个数地遍历,直到遇上 \(cnt_{u,x}\neq 0\) 的数为止。不难发现这样做插入/删除是常数级别的,求 MEX 是根号级别的,总复杂度线性根号。
P3645 [APIO2015]雅加达的摩天楼
首先考虑两种暴力建图方式。第一种对于每一组 \(b_i,p_i\),都枚举 \(j\) 并连 \(b_i\to b_i+jp_i\) 权值为 \(j\) 的边,以及 \(b_i\to b_i-jp_i\) 权值为 \(j\) 的边。第二种是枚举 \(p_i\) 并对每对 \((p,j)\) 新建一个节点,并在 \((p,j)\) 与 \((p-j,j),(p+j,j)\) 之间连权值为 \(1\) 的边,然后对于每组输入给出的二元组 \(b_i,p_i\) 连一条 \(b_i\to(b_i,p_i)\) 权值为 \(0\) 的边。
显然两个暴力都一脸过不去,但是暴力 \(1\) 对于 \(p_i\) 较大的数据能体现出较大优势,暴力 \(2\) 对于 \(p_i\) 较小的数据能体现出较大优势。设个阈值根分即可。注意要用 SPFA instead of dijkstra 求最短路,否则会 TLE。
询问分块
有的时候我们会碰到一类数据结构题,直接维护不太容易,但是我们很容易想出我们考虑两种暴力:第一种,对于一个修改,我们用它暴力更新所有答案,这样可以在短时间内回答询问(类似于预处理的思想);第二种,对于一个询问,我们暴力枚举前面所有的修改,计算其对当前询问的影响。注意到这两种暴力中,一个复杂度瓶颈在修改上,一个复杂度在询问上,我们考虑将两个暴力结合一下——于是询问分块就诞生了!
考虑每 \(B\) 个询问/修改分一个块,那么每次询问影响它的修改会有两种,一是所在的块在此次询问前面的修改,二是与此次询问在同一块中但时间更靠前的修改。对于第二类修改,我们采用暴力二把所有这样的修改都枚举一遍计算贡献。对于第一类修改我们采用暴力一,每 \(B\) 次询问/修改就重新预处理一遍,整体求出这个块中所有询问的贡献。算下复杂度,设暴力重构的复杂度为 \(T_1(n)\),计算某次修改对某次询问的贡献为 \(T_2(n)\),那么暴力一部分的复杂度为 \(\dfrac{qT_1(n)}{B}\),暴力二部分的复杂度为 \(qBT_2(n)\),根据均值不等式可知当 \(B=\sqrt{\dfrac{T_1(n)}{T_2(n)}}\) 的时候复杂度最优。
真·1+1=1,暴力+暴力=AC
P2137 Gty的妹子树
首先我们很容易可以想出一个可以充当暴力二的解法:暴力枚举所有修改,如果它在 \(u\) 的子树中就计算此次修改对询问的贡献。其次我们还可以想出一个解法,可以用来解决此题不带修改的版本:预处理出 dfs 序,离散化并建出主席树来,然后直接在 \(l-1,r\) 两棵主席树上查询并相减就行了;那么带修改的版本怎么办呢?抱歉,众所周知主席树不支持修改,于是碰到修改只能重新建出主席树来。此解法可以充当上面的解法一。
有了解法一、解法二就可以套用询问分块了,直接按照上面的过程并令 \(T_1(n)=n\log n,T_2(n)=\log n\) 可得 \(B=\sqrt{n}\) 时复杂度最优。不过事实上预处理不一定要主席树,考虑对 dfs 序分块,每个块 \([L,R]\) 内维护 dfs 序在 \([L,R]\) 中所有点的权值的集合并排好序,查询 \([L,R]\) 中有多少个 \(>x\) 的数的时候边角块暴力+整块 lower_bound 就行了,这样预处理复杂度可以降到 \(n\log\sqrt{n}\)。
然而由于实现的不太好,将主席树改成分块后它还是 TLE 了。不过吸个氧就过了
P5443 [APIO2019]桥梁
还是考虑两个暴力。暴力 \(1\):对于每次修改直接改边,每次询问枚举每条边,如果权值 \(\geq\) 本次询问的值就将这条边加入冰茶姬,然后输出 \(x\) 所在的连通块的大小。暴力 \(2\) 是一个离线做法,先考虑没有修改的情况,那就直接把边按边权从大到小排个序,将询问按重量从大到小排个序,然后用类似 two pointers 的方法将每条权值 \(\leq\) 本次查询的重量的边加入冰茶姬就行了。那如果有修改怎么办呢?那就将所有无修改的边按边权从大到小排个序并依次加入冰茶姬,对于有修改的边就扫描一遍当前询问前的全部修改并把边权改掉,然后再扫一遍全部有修改的边,若此边在本次询问的时刻的边权 \(\geq\) 本次查询的重量就将其加入冰茶姬,然后询问完之后将这些边用可撤销冰茶姬撤销掉就行了。
还是考虑用询问分块综合一下两个暴力。设一个阈值 \(B\),每 \(B\) 个修改/询问分为一块,然后对于每一块里的询问操作将其按重量从大到小排个序,并将所有在本块中没有修改过的边从大到小排个序,对于没有修改过的边就用 two pointers 将其插入冰茶姬,对于有修改的边就按照暴力 \(2\) 的做法扫描本块中的全部修改,权值达到当前询问的重量了就加入冰茶姬,最后把这些有修改的边撤销掉。每处理完一块之后就直接把所有有修改的边的边权改掉,把当前状态当作新的初始状态就好了。
算下复杂度,对于无修改的边遍历一遍复杂度是 \(\mathcal O(m\log m)\) 的,而最多遍历 \(\dfrac{Q}{B}\) 次,故这部分复杂度 \(\dfrac{Qm\log m}{B}\)。对于每次询问最多遍历 \(B\) 条有修改的边,再加上可撤销冰茶姬的复杂度 \(\log n\),故这部分复杂度 \(QB\log n\)。题解区的大佬说 \(B=\sqrt{m\log n}\) 的时候跑得飞快,因为并查集常数是跑不满 \(\log n\) 的。常数有点大,不过吸个氧就能过了/doge
线段树
今天我们来扯些线段树的 搞基 高级操作
线段树分治
有的时候我们会碰到一类数据结构问题,它需要我们支持往集合中插入一个元素、删除一个元素、询问三个操作。如果我们发现,插入一个元素时信息很容易维护,但删除一个元素时就不那么容易了,那么我们可以考虑线段树分治,来离线解决这类在线算法不那么优秀的数据结构题。
线段树分治,说白了就是按照时间轴建一棵线段树。容易发现集合中每个元素存活的时间是一个区间。于是考虑对线段树上每一个节点建立一个 vector。对于每个元素在线段树上递归,如果发现该元素的存活时间区间完全覆盖了当前节点所表示的区间就直接将该元素的编号插入当前节点的 vector 中,否则就按照线段树区间查询的套路将大区间拆成左右两个小区间分别递归即可。最后一遍 dfs,递归到某个节点 \(x\) 的时候就将 \(x\) 的 vector 中的元素的贡献计算出来并分别递归左右儿子节点,如果是叶子节点就直接输出答案,回溯的时候撤销贡献即可。
伪代码大致长这样:
void iterate(int k){
for(int i=0;i<s[k].v.size();i++){
int x=s[k].v[i];
计算 x 的贡献
}
if(s[k].l==s[k].r) 输出答案
else iterate(k<<1),iterate(k<<1|1);//递归左右儿子
撤销贡献
}
P5787 二分图 /【模板】线段树分治
我们知道,图是二分图的充要条件是不存在奇环。但是显然此题通过暴力染色的方法判断二分图是不行的,故考虑扩展域冰茶姬,大概就是每个点拆成两个点 \(black_u,white_u\),然后对于每条边 \((u,v)\) 将 \(black_u\) 和 \(white_v\) 合并,将 \(black_v\) 和 \(white_v\) 合并,如果我们合并一条边 \((u,v)\) 的时候发现 \(black_u\) 和 \(black_v\) 属于同一个连通块就表明出现了奇数环,原图不是二分图。
这样一来就可以线段树分治了,对于每条边,将它拆成 \(\log k\) 个区间并按照上面的方式插入线段树,用 vector 挂在线段树的节点上。然后对线段树进行一遍 dfs,访问某个节点 \(x\) 的时候就将 \(x\) 的 vector 里保存的边插入线段树。回溯的时候,由于要撤销贡献,故考虑使用可撤销冰茶姬。值得注意的一点是可撤销冰茶姬不能路径压缩,必须按秩合并,并且这里的“秩”是树的深度,而不是所谓的树的大小,否则可能过不了 hack 数据。
算下复杂度,每条边拆成了 \(\log k\) 个区间插入线段树,故总共需合并 \(m\log k\) 次,而冰茶姬按秩合并的复杂度是 \(\log n\) 的,故总复杂度 \(m\log n\log k\)。
CF1140F Extending Set of Points
首先我们要搞清楚这个“扩展”的本质是什么,显然对于一个点 \((x,y)\),他只能跟同行和同列的点相关。一系列相关的点会被扩展成一个“表格”。也就是说,如果我们把行和列看作一个二分图,对于一个集合中的点 \((x,y)\),我们在第 \(x\) 行与第 \(y\) 列之间连一条边,那么扩展之后每个连通块中的行点与列点间应当都有边,故最终集合的大小即为每个连通块中行点与列点的乘积之和。
看到“合并”“连通块”可以很自然地想到冰茶姬,同一连通块中行点和列点的个数都可以用冰茶姬维护。而本题还要求支持从点集中删除一个点,故可想到线段树分治。按照上一题的套路将每个点的存活时间加入线段树,一遍 dfs 计算贡献,回溯的时候用可撤销冰茶姬撤销贡献即可。
P5227 [AHOI2013]连通图
很容易发现本题删边是非常难维护的,但加边却异常好维护。故考虑将询问看作一个时间轴。假设某条边 \(e\) 在 \(P_1,P_2,\dots,P_k\) 这 \(k\) 个询问中被删除,那么我们可以把它看成是在时间 \([1,P_1)\cup(P_1,P_2)\cup\dots\cup(P_{k-1},P_k)\cup(P_k,q]\) 这些时间段出现。我们注意到,操作在若干个时间区间内有效的问题,恰恰是线段树分治易于解决的,故考虑线段树分治,对于每条边将它出现的这 \(k+1\) 个时间段插入线段树中,然后按照线段树分治的套路进行一遍 dfs,如果递归到叶子节点的时候发现合并次数 \(=n-1\) 就说明构成了一个连通图,否则图不连通。
CF601E A Museum Robbery
线段树分治一般题。注意到本题 \(k\) 的数据范围很小,故暴力更新背包也没问题。故还是将每个物品的存活时间压入线段树,dfs 计算答案即可。你可能会有疑问:这种取 \(\max\) 的背包显然不支持删除啊,那回溯的时候怎样撤销贡献。其实异常弱智,只需记录个备份保存一下即可。
可就这么个小破题我竟调了 1h,中途犯了不少 sb 错误,例如变量名取重(线段树中的 \(k\) 与读入的 \(k\) 重名了),还有就是乱用 static
关键字。这里稍微讲一下 static
关键字的用法,static
关键字修饰的变量与全局变量的唯一区别就是 static
关键字修饰的变量不能在局部域之外的地方被调用,否则会 CE。故如果你用 static
修饰的变量当作临时备份那么它在接下来几轮的递归中也会被修改,进而会导致 WA。这些错误都已被纳入我的 sb mistakes 列表。
CF576E Painting Edges
其实跟 P5787 二分图 /【模板】线段树分治 差不多罢。。。唯一的区别就是将那一题的一个冰茶姬改为 \(k\) 个,并且加了些小小的修改罢了。
本题最大的难点显然在于每次操作不一定会被执行。我们考虑对于同一条边的相邻两次染色的时间 \(x,y\),我们将第 \(x\) 次染色的贡献插入 \([x+1,y-1]\) 的区间中。并直接递归每个叶子判断是否成功了,如果没有,设 \(c_e\) 为第 \(e\) 条边的颜色,那么我们就直接把第 \(x\) 次染色的颜色改为 \(c_e\)(其实相当于啥也没干) 。如果有那就把第 \(e\) 条边的颜色改掉就行了。为什么这样做是对的呢?因为显然递归到 \(x\) 所在的叶子节点在先,计算第 \(x\) 次染色的贡献在后,也就是说我们在计算第 \(x\) 次染色的贡献的时候已经知道第 \(x\) 次染色的结果了,也就能保证接下来的判断是正确的了。
P5631 最小mex生成树
因为 MEX 是不出现在某个集合 \(S\) 中的最小自然数,所以对于某个自然数 \(x\),如果将边权为 \(x\) 的边去掉后,如果图不连通,那么所有生成树肯定都包含边权为 \(x\) 的边,\(x\) 就不可能成为某个生成树的 MEX。
故我们只需检验每个 \(x\),看去掉边权为 \(x\) 的边后图是否连通。注意到删边是不太好维护的,考虑按照 P5227 [AHOI2013]连通图 的套路进行加边!加边!加边!并查集查询(想到了 WC 的梗“暴力怎么做?暴力是不是,加边!加边!加边!并查集查询”),判断成功合并的次数是否等于 \(n-1\) 即可。
线段树合并
所谓线段树合并就是将两个线段树叶子节点的每一位按位相加,并将其合成一个新的线段树。显然对于两棵满了的线段树,直接将它们叶子节点的对应位相加再 pushup 即可,时间复杂度 \(n\log n\)。但是有时我们遇到的线段树不一定是满的线段树,譬如动态开点线段树,在这种情况下一棵线段树有可能只是一条 \(\log n\) 的链。举个例子,假如我们要把一个满的线段树与一棵只有一个叶子节点的动态开点线段树合并,那暴力地将两棵线段树对应位相加是完全没有必要的,直接将动态开点线段树的叶子节点加入满线段树就行了。
当然,我们可以考虑启发式合并,将小的线段树中的非空位暴力插入大的线段树,那根据并查集的复杂度分析可知如果我们把 \(n\) 个只有一个叶子节点的线段树合并成一个满线段树,那么每个元素最多被插入 \(\log n\) 次,总复杂度 \(n\log^2n\)。
不过还有更优秀的做法,考虑当合并以 \(x,y\) 为根的两棵线段树的时候,从两棵线段树的根开始进行 DFS,每 DFS 到一对节点就建出一个新节点出来表示合并后的线段树的对应位置的节点,并进一步递归这两个节点的左右儿子。如果递归到叶子节点就将两棵线段树对应位置上保存的值相加。当然与暴力不同的是该算法加了一个优化就是如果两个对应节点中有任意一个为空就返回另一个节点。可以证明这个算法把 \(n\) 个只有一个叶子节点的线段树合并成一个满线段树的复杂度是 \(\mathcal O(n\log n)\) 的。
为什么?显然该算法合并两棵线段树是 \(\mathcal O(\text{重叠部分的大小})\) 的,而每遇到一个重叠的节点所有线段树的大小之和就会 \(-1\),每新增一个节点也是因为遇到一个重叠的节点。又因为最开始所有线段树大小之和为 \(n\log n\),所以重叠部分的大小之和是 \(n\log n\) 的,合并的次数和新增节点的数量也是 \(n\log n\) 的。
当然该算法也有它的局限性。该算法的理论空间复杂度为 \(n\log n\),再加上常数因子,故线段树的空间一般应开到 \(40n\) 到 \(60n\),在空间限制 512MB 的情况下最多只能通过 \(3\times 10^5\) 的数据。所以说线段树合并实际上用的是空间换取时间的思想。
最后说一下实现的问题,一般来说有两种实现方式,一就是每次合并新建一个节点,代码大概长这样:
int merge(int x,int y,int l,int r) {
if(!x||!y) return x+y;
int z=++ncnt,mid=(l+r)>>1;
if(l==r){s[x].val=s[x].val+s[y].val;return z;}
s[z].ch[0]=merge(s[x].ch[0],s[y].ch[0],l,mid);
s[z].ch[1]=merge(s[x].ch[1],s[y].ch[1],mid+1,r);
pushup(z);return z;
}
优点就是支持可持久化,缺点就是炸空间。
二是每次合并不新建节点,直接合并到 \(x\) 上。
int merge(int x,int y,int l,int r) {
if(!x||!y) return x+y;
int mid=(l+r)>>1;
if(l==r){s[x].val=s[x].val+s[y].val;return x;}
s[x].ch[0]=merge(s[x].ch[0],s[y].ch[0],l,mid);
s[x].ch[1]=merge(s[x].ch[1],s[y].ch[1],mid+1,r);
pushup(x);return x;
}
优点就是占用的空间比前一种写法少,缺点就是树的形态时时在改变,所以不支持可持久化。
P4556 [Vani有约会]雨天的尾巴 /【模板】线段树合并
说句闲话:本题也可当作树上启发式合并的模板题。考虑将每条路径进行树上差分,然后跑一遍树上启发式合并,用 std::set
维护出现次数最多的救济粮的编号,时间复杂度 \(n\log^2n\),劣于线段树合并,这里就不再赘述了。
考虑怎样用线段树合并解决这个问题。还是将每条路径进行树上差分,差分成 \(x\) 处放一袋 \(z\) 类型的救济粮,\(y\) 处放一袋 \(z\) 类型的救济粮,\(x,y\) 的 LCA 处撤回一个 \(z\) 类型的救济粮,\(x,y\) 的 LCA 的父亲处撤回一个 \(z\) 类型的救济粮。对于形如“\(x\) 处放 \(c\) 个 \(z\) 类型的救济粮”的操作,我们就在 \(x\) 处建立一棵动态开点线段树并在权值为 \(z\) 处的值加上 \(c\),最后一遍 DFS 将每个点的线段树与其儿子节点的线段树合并,线段树每个节点维护其所表示的区间内每袋救济粮出现次数的最大值和最大值出现的位置就行了。算下复杂度,该算法等价于将 \(4n\) 个只有一个叶子节点的线段树合并起来,按照之前的分析可知时空复杂度均为线性对数。
CF600E Lomsat gelral
睿智的 div.2E
直接按照上一题的套路对每个点建一棵动态开点线段树,再一遍 DFS 合并即可。线段树每个节点记录每个颜色出现次数的最大值和所有出现次数 \(=\) 最大值的颜色的编号之和即可。
P3224 [HNOI2012]永无乡
感觉这种题套路性还是蛮强的罢…………
碰到“连通块”可以套路地想到“加边!加边!加边!并查集查询”,于是题目就转化为怎样动态地维护一个连通块的第 \(k\) 大值。考虑对每个连通块建立一棵权值线段树维护该连通块中每个数的出现次数,然后在权值线段树中二分查找即可。合并两个连通块的时候就将两个连通块的权值线段树合并即可。
P5327 [ZJOI2019]语言
警告:一大波毒瘤题即将来袭
首先,很容易发现一个性质,那就是所有 \(u\) 能够到达的点都会构成一个连通块,而这个连通块边界上的点都是某条经过 \(u\) 的路径的端点。
于是只需维护所有经过 \(u\) 的路径的端点的虚树即可,那么 \(u\) 能到达的点的个数就是虚树中边的个数。
考虑怎样维护经过 \(u\) 的虚树大小。首先需知道怎样求某个点集的虚树大小。将点集中所有点按 DFS 序排序并依次插入这些节点,那么每插入一个节点 \(x\) 的时候虚树大小的增量即为 \(x\) 的深度减去 \(x\) 与 \(x\) 上一个节点的 LCA 的深度,最后减去所有点的 LCA 的深度即可。考虑用一个长度为 \(n\) 的 0/1 数组表示每个点是否在点集中,那么最终虚树的大小就是所有 \(1\) 表示点的深度之和减去相邻 \(1\) 的 LCA 的深度,再减去一头一尾两个 \(1\) 的 LCA 的深度。
考虑用树上差分拆路径,这样可以得到若干个形如“\(u\) 在 \(x\) 的点集中出现次数多 \(y(y\in\{1,-1\})\)”的操作。考虑对于每个点建一棵权值线段树,下标表示 DFS 序,下标为 \(x\) 的位置的值表示 DFS 序为 \(x\) 的点的出现次数。线段树每个节点上维护以下三个值:该节点表示的区间中,出现次数 \(>0\) 的点的 DFS 序的最小值 \(fst\),最大值 \(lst\),以及该区间中所有出现次数 \(>0\) 的节点深度之和减去相邻出现次数 \(>0\) 的 LCA 的深度。按照套路进行一遍 DFS 线段树合并即可。最后别忘了将答案除以 \(2\)。
P5298 [PKUWC2018]Minimax
没错,这玩意儿还能用来优化 dp。。。
首先有一个很 naive 的 \(dp\) 状态,设 \(dp_{u,x}\) 为以 \(u\) 为根的子树内,\(u\) 的权值为 \(x\) 的概率。假设 \(f\) 为其左儿子 \(dp\) 值,\(g\) 为其右儿子的 \(dp\) 值,那么有 \(dp\) 转移方程 \(dp_{u,x}=f_x\times(p\times\sum\limits_{y=1}^{x-1}g_{y}+(1-p)\times\sum\limits_{y=x+1}^mg_y)+g_x\times(p\times\sum\limits_{y=1}^{x-1}f_{y}+(1-p)\times\sum\limits_{y=x+1}^mf_y)\)
注意到,这个式子关系到前缀和和后缀和,并且对于某个节点 \(u\),只有在 \(u\) 的子树里存在某个点的权值为 \(x\),\(dp_{u,x}\) 才能有值。考虑对每个点建立一棵动态开点线段树,下标为 \(x\) 的位置上的数表示 \(dp_{u,x}\) 的值,线段树上维护每个区间的 \(dp\) 值的和 \(sum\)。
然后假设我们要将以 \(x,y\) 为根的线段树合并成一棵线段树,我们在 DFS 的过程中记录四个值 \(xl,xr,yl,yr\),分别表示 \(\sum\limits_{i=1}^{l-1}dp_{x,i},\sum\limits_{i=r+1}^mdp_{x,i},\sum\limits_{i=1}^{l-1}dp_{y,i},\sum\limits_{i=r+1}^mdp_{y,i}\),其中 \(l,r\) 为当前递归的区间。假如我们递归到某个位置满足 \(y\) 线段树上对应节点为空,那么意味着 \(dp_{y,l},dp_{y,l+1},\dots,dp_{y,r}\) 均为 \(0\),也就只剩右边那项有值了,也就是说此时 \(dp_{u,i}=dp_{x,i}\times(p\times yl+(1-p)\times yr)\),于是我们令新建的节点的 \(sum\) 为 \(x\) 节点的 \(sum\) 乘 \(p\times yl+(1-p)\times yr\),并整体打一个 \(\times(p\times yl+(1-p)\times yr)\) 的 tag 即可。其它都和普通的线段树合并一样
P6773 [NOI2020] 命运
这题我连普通的 dp 都每没到/kk,wtcl,现场的我,包括现在的我都只会 32 分的做法/kel
首先有一个很显然的性质那就是如果对于两条下端点均为 \(u\) 的限制 \((v_1,u),(v_2,u)\),如果 \(v_1\) 的深度小于 \(v_2\) 的深度,那么第二个限制满足 \(\to\) 第一个限制满足。于是我们只用关心
考虑令 \(dp_{u,i}\) 为下端在 \(u\) 的子树内,已经赋好了 \(u\) 的子树内的边的取值的情况下,在还未满足的限制中,上端点最深为 \(i\) 的方案数。
考虑将两棵子树 \(u,v\) 合并,其中 \(u\) 是 \(v\) 的父亲,那么根据 \((u,v)\) 的权值可分为两种情况:
- \((u,v)\) 的权值为 \(1\),那么显然 \(v\) 子树内所有未满足的限制,此时都满足了,也就是说这个 \(i\) 只能由 \(u\) 贡献出来,即 \(\sum\limits_{j=1}^{dep_u}dp_{v,j}\times dp_{u,i}\)
- \((u,v)\) 的权值为 \(0\),也就是说 \(u,v\) 所贡献出的深度的 \(\max\) 为 \(i\),也就是一个 \(\max\) 卷积的形式,即 \(\sum\limits_{\max(j,k)=i}dp_{u,j}\times dp_{v,k}\),这个可以进一步改写成 \(\sum\limits_{j=1}^idp_{u,i}\times dp_{v,j}+\sum\limits_{j=1}^{i-1}dp_{u,j}\times dp_{v,i}\)
和上一题一样,遇到这类前缀和形式的状态转移方程式,可想到用线段树合并优化,复杂度 \(n\log n\)
线段树分裂
其实感觉不太用得到吧。。。
大概也不是啥很难的东西,类似于 fhq-treap 的分裂,就是每递归到一个点就新建一个节点,然后根据节点的值的大小关系继续递归下去就行了。
void split(int x,int &y,int l,int r,int v){
if(!x){return;} y=++ncnt;
if(l==r){(v<l)&&(s[y].siz=s[x].siz,s[x].siz=0);return;}
int mid=(l+r)>>1;
if(v<=mid) s[y].ch[1]=s[x].ch[1],s[x].ch[1]=0,split(s[x].ch[0],s[y].ch[0],l,mid,v);
else split(s[x].ch[1],s[y].ch[1],mid+1,r,v);
pushup(x);pushup(y);
}