数据结构·字符串和图
1.字符串的存储
1.1.字符数组和STLstring
char s[N]
strlen(s+i):\(O(n)\)。返回从 s[0+i] 开始直到 '\0' 的字符数。strcmp(s1,s2):\(O(\min(n_1,n_2))\)。若 s1 字典序更小返回负值,两者一样返回 0,s1 字典序更大返回正值。strcat(s1,s2):\(O(n_2)\)。将s2接到s1的结尾,用*s2替换s1末尾的'\0'返回s1。s[i]:\(O(1)\)。访问s[i]。
不可使用==,不会报错,但会警告,运行会得到错误的结果。
string s
s.length():\(O(1)\)。返回字符串字符个数。s1<=>s2:\(O(\min(n_1,n_2))\)。string重载了比较逻辑运算符。s1=s1+s2;:\(O(n_2)\)。将s2接到s1结尾,返回连接后的string。s[i]:\(O(1)\)。访问s[i]。
1.2.\(Trie\)树
1.2.1.\(Trie\)树
int n;
int son[N][26],cnt[N],idx;
char str[N];
void insert(){
int p=0;
for(int i=0;str[i];i++){
int u=str[i]-'a';
if(!son[p][u]) son[p][u]=++idx;
p=son[p][u];
}
cnt[p]++;
return ;
}
int query(){
int p=0;
for(int i=0;str[i];i++){
int u=str[i]-'a';
if(!son[p][u]) return 0;
p=son[p][u];
}
return cnt[p];
}
int main(){
cin>>n;
while(n--){
char op[2];
scanf("%s%s",op,str);
if(op[0]=='I'){
insert();
}
else {
printf("%d\n",query());
}
}
return 0;
}
1.2.2.\(01Trie\)树
适用条件:存储二进制数以得知是否存在某类满足依次按位要求的数。
代码类似于《数据结构·字符串和图1.2.1.Trie树》,只不过只有0、1两条字母边。
1.2.2.1.求\(a\operatorname{xor}b_i\)的最值
方法一:\(01Trie\)树\(O(N\log N)\)
优点:复杂度更低。
方法二:\(01Trie\)思想+值域线段树\(O(N\log^2 N)\)
优点:适用范围广,可以解决更多的约束条件。
01trie树的作用:得知是否存在某类满足依次按位要求的数。值域线段树也可以做到!
假设当前5位的数已经枚举了2位10***,现在要枚举第3位为1,则只需查询[10100,10111]是否存在数。
从大到小枚举数的每一位。根据上文,查询满足要求的区间是否存在满足要求的数。然后思想中模拟01Trie树选择决策,枚举下一位。
2.字符串的表示
2.1.字符串\(Hash\)
《基础算法6.2.字符串Hash》
2.2.字符串的最小表示法
1个字符串s有\(s_{len}\)个循环同构(\(e.g.\)abc:abc、bca、cab),其中字典序最小的一个称为s的最小表示法。
双指针算法。
以判定2个字符串是否可以通过循环同构而相等为例:
int len;
char a[N],b[N];
int get_min(char s[])
{
int i=1,j=2;//错开i和j
while(i<=len && j<=len)
{
int k=0;
while(k<len && s[i+k]==s[j+k]) k++;
if(k==len) break;//说明原串是一个循环串,(假设i<j)s[i..j-1]是循环节。又因为j走过了s[i..j-1]遍历了整个循环节,所以起点i或j一定是最小表示法
if(s[i+k]>s[j+k]) i+=k+1;//说明起点[i,i+k]不可能成为最小表示法。证明:对于任意一个起点i'\in[i,i+k],都可以找到j'=j+(i'-i),使得s[i'..i+k]>s[j'..j+k]
else j+=k+1;
if(i==j) j++;//错开i和j
}
int k=min(i,j);//min:可能其中一个指针走到了尽头而跳出循环,所以要去除该指针
s[k+len]=0; //方便下面strcmp判定,注意不要多加1!
return k; //从s[k]开始是最小表示
}
int main()
{
scanf("%s%s",a+1,b+1);
len=strlen(a+1);
memcpy(a+1+len,a+1,len); //破环成链
memcpy(b+1+len,b+1,len);
int x=get_min(a),y=get_min(b); //从a[x]开始是最小表示
if(strcmp(a+x,b+y)) puts("No");
else
{
puts("Yes");
puts(a+x);
}
return 0;
}
3.字符串组前缀问题
\(\operatorname{lcp}(s,t)\):字符串s和t的最长公共前缀的长度。
-
Trie树
-
按字典序排序
排序后的性质:
- \(\max\limits_{j\in[1,i)\cup(i,n]}\operatorname{lcp}(s_i,s_j)=\max(\operatorname{lcp}(s_i,s_{i-1}),\operatorname{lcp}(s_i,s_{i+1}))\)。
- \(\operatorname{lcp}(s_i,s_j)=\min\limits_{k\in]i,j]}(\operatorname{lcp}(s_i,s_k),\operatorname{lcp}(s_k,s_j))=\min\limits_{k\in[i,j)}\operatorname{lcp}(s_k,s_{k+1})\)。
-
动态规划
4.字符串组匹配问题和字符串子串周期问题
4.1.一匹一:KMP算法
4.1.1.KMP\(O(N)\)
真前缀函数ne[i]=k:对于i最大的\(k\in[1,i-1]\)使得s[1..k]=s[i-k+1..i],即s[1..i]的最长公共真前后缀。
下标从1开始。
如果要在字符串后面接字符,KMP可以从新字符开始继续求出ne。
复杂度分析:i的整个循环中,1.j最多加n次;2.由于第1条且j非负,j最多减n次。故复杂度为\(O(N)\)。
cin>>n>>p+1>>m>>s+1;
for(int i=2/*真前缀*/,j=0;i<=n;i++){
while( j && p[i]!=p[j+1]) j=ne[j];
if(p[i]==p[j+1]) j++;
ne[i]=j;
}
for(int i=1,j=0;i<=m;i++){
while( j && s[i]!=p[j+1]) j=ne[j];
if(s[i]==p[j+1]) j++;
if(j==n){//P与模版串S中的一个子串匹配成功
//printf("%d ",i-n);//输出P在模版串S中作为子串(可以交叉)所有出现的位置的起始下标
j=ne[j];
}
}
字符串子串周期问题
类似于KMP,根据周期性和相等区间的传递性。
- KMP解决最小循环节问题
ans=n-ne[n];
if(n%ans!=0) ans=n;//字符串末尾是不完整的循环节
4.1.2.扩展KMP(Z函数)\(O(N)\)
下标从1开始。
Z函数:给定长度为n的字符串s,定义一个数组z[],其中z[i]表示LCP(s[in],s[1n]),LCP的意思是最大公共前缀。
l、r:r=l+z[l]-1。l为1~i-1中最大的\(r_i\)的l。
初始条件:定义z[1]=0(或者根据题意定义为n),l=r=1。
假设已经处理出z[1~i-1]求z[i]。
-
如果i>r,直接暴力匹配。
-
如果i≤r,可以利用i'=i-l+1的信息。
-
证明
显然i>l,所以\(i\in(l,r]\)。
因为s[lr]一定和s[1r-l+1]相同,为表示方便,设l'=1,r'=r-l+1,所以我们可以找到一个i'=i-l+1,显然s[ir]和s[i'r']相同。
-
如果z[i'] <r-i+1,直接令z[i]=z[i'],并结束这次计算。
-
证明
假设z[i]>z[i'],又因为s[ir]和s[i-l+1r-l+1]相同,z[i'] <r-i+1说明z[i']失配是在第r个之前的字符,矛盾。
-
-
如果z[i'] ≥r-i+1,先令z[i]=z[i'],由于还没有扫描到s[r]之后的字符,所以直接继续暴力匹配。
-
-
匹配完i之后,及时更新l和r。
由于r只会增大,所以复杂度是\(O(N)\)的。
void exkmp(char s[])
{
int len=strlen(s+1);
z[1]=len;
for(int i=2,l=1,r=1;i<=len;i++)
{
if(i<=r) z[i]=min(z[i-l+1],r-i+1);
else z[i]=0;
while(i+z[i]<=len && s[i+z[i]]==s[1+z[i]]) z[i]++;
if(i+z[i]-1>=r) l=i,r=i+z[i]-1;
}
return ;
}
应用
-
给定字符串A和B,求B的每个后缀和A匹配的最长长度,即对于每一个\(i\in[1,len(B)]\),求出LCP(B[i~len(B)],A)。\(O(N+M)\)
构造字符串C=A+ch+B,其中ch是一个从未在A,B中出现过的字符,对于每一个\(i\in[len(A)+2,len(A)+1+len(B)]\)求出\(Z_C[i]\)即可。
-
求出A的本质不同的子串个数,并支持操作:在A末尾增加某个字符或删去尾字符。\(O(QN)\)
只考虑增加字符。设\(s_{bef}\)表示增加前的字符串,\(s_{aft}\)表示增加后的字符串。
把A倒过来,那么原本在末尾增加就是在前面增加。
增加一个字符,就增加\(len(s_{bef})\)个子串。
因此只要知道新增加的子串有多少个之前已经算过的,就可以计算出有多少个新增加的本质不同子串。对\(s_{bef}\)跑一遍扩展KMP,找出最大的i≠1的z[i],那么新增加的子串仅有z[i]个串已经计算过。
-
给定某个串s,求出它的严格循环节。\(O(N)\)
跑出s的Z函数,若i+z[i]-1=len(s),且(i-1)|z[i],则s[1~i-1]就是一个严格循环节。
4.2.一匹多:AC自动机
类比KMP。
功能
- 给定n个串\(s_i\)和串t,统计每个串\(s_i\)在串t中的出现次数。
int n;
char s[N];
int pos[N]; //pos[i]:串s_i在AC自动机上的节点编号
int idx;
struct AC
{
int son[26],fail; //fail:深度最大的fail使得tr[0->fail]是tr[0->u]的后缀
int in,f; //in:入度;f:要求的值,此处是根节点到点u的串在串t中出现了多少次
}tr[N];
int q[N],hh,tt;
//与字典树插入字符串一模一样
void insert(int id)
{
int u=0;
for(int i=1;s[i];i++)
{
int c=s[i]-'a';
if(!tr[u].son[c]) tr[u].son[c]=++idx;
u=tr[u].son[c];
}
pos[id]=u;
return ;
}
//建立fail
void build()
{
hh=1,tt=0;
for(int c=0;c<26;c++) if(tr[0].son[c]) q[++tt]=tr[0].son[c];
while(hh<=tt)
{
int u=q[hh];
hh++;
for(int c=0;c<26;c++)
{
//理解:在循环第i层时,前i-1层一定都求对了,tr[tr[u].fail].son[c]是一定正确的,这是一个类路径压缩的递归的过程
//后面串t在走的过程中,原串有tr[u].son[c](匹配)走tr[u].son[c],没有tr[u].son[c](失配)看有没有tr[tr[u].fail].son[c]
if(!tr[u].son[c]) tr[u].son[c]=tr[tr[u].fail].son[c]; //失配了,此时应该执行类路径压缩,它最终应该跳到的位置是tr[tr[u].fail].son[c]
else //匹配,此时应该求出fail并继续往下走
{
//因为串s匹配,串s的后缀也一定匹配,所以建立fail的有向边:tr[u].son[c]->tr[tr[u].fail].son[c]
tr[tr[tr[u].fail].son[c]].in++;
tr[tr[u].son[c]].fail=tr[tr[u].fail].son[c];
q[++tt]=tr[u].son[c];
}
}
}
return ;
}
//串t开始匹配s_1,...s_n
/*
正确性:
根据fail的定义,串t在AC自动机上一定尽可能地往深度大的走,利用了尽可能长的后缀的信息。
根据build(),串t在走的过程中,原串有tr[u].son[c]走tr[u].son[c],没有tr[u].son[c]看有没有tr[tr[u].fail].son[c],这是一个递归过程。
所以此时深度更大的节点一定不是t的子串,深度更小的节点(fail的fail)一定是t的子串。
为了保证复杂度,先把贡献加到t走到的点u,之后拓扑排序再把贡献加到tr[u].fail。
所以先让串t沿着AC自动机走并打标记,再拓扑排序一定是正确的。
*/
void solve()
{
//串t沿着AC自动机走并打标记
int u=0;
for(int i=1;s[i];i++)
{
int c=s[i]-'a';
u=tr[u].son[c];
tr[u].f++;
}
//拓扑排序,DAG上转移求出tr[u].f
hh=1,tt=0;
for(int u=1;u<=idx;u++) if(!tr[u].in) q[++tt]=u;
while(hh<=tt)
{
int u=q[hh];
hh++;
int v=tr[u].fail;
tr[v].f+=tr[u].f;
tr[v].in--;
if(!tr[v].in) q[++tt]=v;
}
return ;
}
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
scanf("%s",s+1);
insert(i);
}
build();
scanf("%s",s+1);
solve();
//此后tr[pos[i]].f=串s_i在t中的出现次数
应用
-
fail树
插入AC自动机的字符串x在另外一个插入AC自动机的字符串y中出现了多少次\(\Leftrightarrow\)从根到y的末尾的路径上的所有节点中有多少个节点通过fail指针直接或间接指向了x的末尾。
对于fail指针v→u,建立有向边u→v。显然会构成一棵有向树。所以“直接或间接指向”\(\Leftrightarrow\)“在子树内”。
注意建边时要记得给指向点0的fail指针也建边:从点0向在加入初始队列时的点建边。
利用fail树,将原字符串问题转化为树上问题。
5.字符串子串问题
5.1.后缀数组
性质:
- 设lcp(i,j)表示后缀编号为i和j的最长公共前缀,h(i)表示后缀编号为i的后缀与排名是rk[i]-1的后缀的最长公共前缀。
lcp(i,j)=lcp(j,i);lcp(i,i)=len(i);若i和j已经排好序:lcp(i,j)=min(lcp(i,k),lcp(k,j)),(i≤k≤j)。height[i]=lcp(sa[i-1],sa[i]);h(i)=height[rk[i]];h(i)≥h(i-1)-1; - 所有非空后缀的非空前缀集合\(\Leftrightarrow\)所有非空子串的集合。
先用后缀数组排序,排好序后,所有互不相同的非空子串个数\(=\sum\limits_{i=1}^{n} (len_i-height_i)\); - 排序后,一个后缀与前面的后缀的最大公共前缀长度\(≤height_i\)。
下面使用倍增求后缀数组,\(O(n\log n)\),常数较小。
int n;
int sa[N],rk[N],height[N]; //sa[i]:排名是i的后缀编号;rk[i]:后缀编号是i的排名;height[i]:排名是i的后缀与排名是i-1的后缀的最长公共前缀
int c[N],sec[N],seidx,Hash[N],hidx,backup[N]; //基数排序的变量。c:桶;sec[i]:按第二关键字的排名是i的后缀编号;Hash[i]:后缀编号为i的前k个字符的哈希值
char s[N];
void get_sa()
{
//先按首字符基数排序
for(int i=1;i<=n;i++/*编号*/)
{
Hash[i]=(int)s[i];
c[Hash[i]]++;
}
for(int i=2;i<=hidx;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--/*编号*/) sa[c[Hash[i]]--]=i;
//倍增计算sa
for(int k=1;k<=n;k<<=1) //第1~k字符是第一关键字,第k+1~2k字符是第二关键字
{
//上一轮k已经求出本轮k<<1前k个字符的哈希值
//按第二关键字排序
seidx=0;
for(int i=n-k+1;i<=n;i++/*编号*/) sec[++seidx]=i; //第二关键字为空的情况
for(int i=1;i<=n;i++/*排名*/) if(sa[i]>k) sec[++seidx]=sa[i]-k; //这里借助了循环sa和++seidx来确定相对排名,后缀编号为sa[i]-k的第二关键字的相对排名等于上一轮k的后缀编号为sa[i]的相对排名
//最终seidx=n
//按第一关键字排序
for(int i=1;i<=hidx;i++) c[i]=0; //注意别忘了初始化桶
for(int i=1;i<=seidx;i++/*编号*/) c[Hash[sec[i]]]++;//注意这里可不带sec[]
for(int i=2;i<=hidx;i++) c[i]+=c[i-1];
for(int i=seidx;i>=1;i--/*第二关键字排名*/) sa[c[Hash[sec[i]]]--]=sec[i]; //注意这里要带个sec[i]
//开始计算下一轮k<<2后缀编号为i的前k<<1个字符的哈希值
memcpy(backup,Hash,sizeof backup);
Hash[sa[1]]=1,hidx=1;
for(int i=2;i<=n;i++/*排名*/) Hash[sa[i]]= (backup[sa[i]]==backup[sa[i-1]]/*前k个字符相等*/ && backup[sa[i]+k]==backup[sa[i-1]+k]/*后k个字符相等*/) ? hidx : ++hidx;
if(hidx==n) return ; //排名完成
}
return ;
}
void get_height()
{
//求rk数组
for(int i=1;i<=n;i++/*排名*/) rk[sa[i]]=i;
//利用h[i]>=h[i-1]-1求height数组
for(int i=1,k=0;i<=n;i++/*编号*/)
{
if(rk[i]==1) continue;
if(k) k--; //运用了上一轮的信息
int j=sa[rk[i]-1];
while(i+k<=n && j+k<=n && s[i+k]==s[j+k]) k++;
height[rk[i]]=k; //注意这里别忘了带rk[]
}
return ;
}
int main()
{
scanf("%s",s+1);
n=strlen(s+1);
hidx=(int)'z';
get_sa();
get_height();
for(int i=1;i<=n;i++/*排名*/) printf("%d ",sa[i]);
puts("");
for(int i=1;i<=n;i++/*排名*/) printf("%d ",height[i]);
puts("");
return 0;
}
-
S最初为空串,每次向S的结尾加入一个字符,并求出此时S中所有互不相同的非空子串的个数。
后缀数组的性质:
- 设lcp(i,j)表示后缀编号为i和j的最长公共前缀,h(i)表示后缀编号为i的后缀与排名是rk[i]-1的后缀的最长公共前缀。
若i和j已经排好序:lcp(i,j)=min(lcp(i,k),lcp(k,j)),(i≤k≤j); - 所有后缀的前缀集合\(\Leftrightarrow\)所有子串的集合。
先用后缀数组排序,排好序后,所有互不相同的子串个数\(=\sum\limits_{i=1}^{n} (len_i-height_i)\); - 排序后,一个后缀与前面的后缀的最大公共前缀长度\(≤height_i\)。
所有互不相同的子串个数\(=\sum\limits_{i=1}^{n} (len_i-height_i)\)。
每次插入1个数都会改变所有的后缀,麻烦!我们逆向思考:开始给定一个“逆”串,每次删除1个首字符,这样只会删除1个后缀。由lcp(i,j)=min(lcp(i,k),lcp(k,j)),(i≤k≤j),相当于答案:
- 设lcp(i,j)表示后缀编号为i和j的最长公共前缀,h(i)表示后缀编号为i的后缀与排名是rk[i]-1的后缀的最长公共前缀。
res-=(n-sa[k]+1)-height[k];
res-=(n-sa[j]+1)-height[j];
height[j]=min(height[j],height[k]);
res+=(n-sa[j]+1)-height[j];
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+5;
int n;
int s[N];
int sa[N],rk[N],height[N];
int c[N],sec[N],sidx,h[N],hidx,backup[N];
int l[N],r[N];
LL ans[N],res;
unordered_map<int,int> ha;
int Hash(int x)
{
if(ha.count(x)==0) ha[x]=++hidx;
return ha[x];
}
void get_sa()
{
for(int i=1;i<=n;i++)
{
h[i]=s[i];
c[h[i]]++;
}
for(int i=2;i<=hidx;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[h[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
sidx=0;
for(int i=n-k+1;i<=n;i++) sec[++sidx]=i;
for(int i=1;i<=n;i++) if(sa[i]>k) sec[++sidx]=sa[i]-k;
for(int i=1;i<=hidx;i++) c[i]=0;
for(int i=1;i<=sidx;i++) c[h[sec[i]]]++;
for(int i=2;i<=hidx;i++) c[i]+=c[i-1];
for(int i=sidx;i>=1;i--) sa[c[h[sec[i]]]--]=sec[i];
memcpy(backup,h,sizeof backup);
h[sa[1]]=1,hidx=1;
for(int i=2;i<=n;i++) h[sa[i]]= (backup[sa[i]]==backup[sa[i-1]] && backup[sa[i]+k]==backup[sa[i-1]+k]) ? hidx : ++hidx;
if(hidx==n) return ;
}
return ;
}
void get_height()
{
for(int i=1;i<=n;i++) rk[sa[i]]=i;
for(int i=1,k=0;i<=n;i++)
{
if(rk[i]==1) continue;
if(k) k--;
int j=sa[rk[i]-1];
while(i+k<=n && j+k<=n && s[i+k]==s[j+k]) k++;
height[rk[i]]=k;
}
return ;
}
int main()
{
scanf("%d",&n);
for(int i=n;i>=1;i--) scanf("%d",&s[i]),s[i]=Hash(s[i]);
get_sa();
get_height();
for(int i=1;i<=n;i++)
{
res+=(n-sa[i]+1)-height[i];
l[i]=i-1,r[i]=i+1;
}
l[n+1]=n,r[0]=1;
for(int i=1;i<=n;i++)
{
ans[i]=res;
int k=rk[i],j=r[k];
res-=(n-sa[k]+1)-height[k];
res-=(n-sa[j]+1)-height[j];
height[j]=min(height[j],height[k]);
res+=(n-sa[j]+1)-height[j];
r[l[k]]=r[k],l[r[k]]=l[k];
}
for(int i=n;i>=1;i--) printf("%lld\n",ans[i]);
return 0;
}
5.2.后缀自动机
解决的问题:子串问题。
一、SAM的构造过程extend

通过这样的构造过程,可以保证下面的性质全部成立!
包括但不限于:当多个字符串的endpos集合完全相同时,称他们为同一个等价类。每一个等价类与后缀自动机的一个点一一对应,同一个等价类都在后缀自动机的同一个点,后缀自动机的每一个点只对应一个等价类,且所有的点恰好完全覆盖所有等价类。
-
二、SAM的性质:
SAM是个状态机。一个起点,若干终点。原串的所有子串和从SAM起点开始的所有路径一一对应,不重不漏。所以终点就是包含后缀的点。
每个点包含若干子串,每个子串都一一对应一条从起点到该点的路径。且这些子串一定是里面最长子串的连续后缀。
SAM问题中经常考虑两种边:
(1) 普通边,类似于Trie。表示在某个状态所表示的所有子串的后面添加一个字符。
(2) Link、Father。表示将某个状态所表示的最短子串的首字母删除。这类边构成一棵树。后缀自动机的点数最多2N,边数最多3N。
-
三、SAM的构造思路及endpos的性质
endpos(s):子串s所有出现的位置(尾字母下标)集合。SAM中的每个状态都一一对应一个endpos的等价类。
endpos的性质:
(1) 令 s1,s2 为 S 的两个子串 ,不妨设 |s1|≤|s2| (我们用 |s| 表示 s 的长度 ,此处等价于 s1 不长于 s2 )。则 s1 是 s2 的后缀当且仅当 endpos(s1)⊇endpos(s2) ,s1 不是 s2 的后缀当且仅当 endpos(s1)∩endpos(s2)=∅ 。
(2) 两个不同子串的endpos,要么有包含关系,要么没有交集。
(3) 两个子串的endpos相同,那么短串为长串的后缀。
(4) 对于一个状态 st ,以及任意的 longest(st) 的后缀 s ,如果 s 的长度满足:|shortest(st)|≤|s|≤|longsest(st)| ,那么 s∈substrings(st) 。
- 后缀自动机的点数最多2N,边数最多3N。
- 因为同一个等价类中的所有子串一定是里面最长子串的连续后缀,所以后缀自动机每一个点(同一个等价类)代表的不同字串个数=\(i_{len_{max}}\)\(-\)\(i_{len_{min}}\)\(+1\),其中\(i_{len_{min}}=fa_{len_{max}}+1\);
- 注意建边的方向e.g. 1->z->yz\xyz\wxyz->...vwxyz...
功能
- 求不同子串的数量。
- 判断一个字符串是否是某个子串,如果是求某它出现的次数。
- 求多个最长公共子串。(思想类似于KMP移动“指针”)
复杂度
若字符集大小\(|\Sigma|\)可看作常数,则时空复杂度是\(O(n)\)。
否则,从一个结点出发的转移需要存储在支持快速查询和插入的平衡树中,时间复杂度是\(O(n\log|\Sigma|)\),空间复杂度是\(O(n)\)。
C++代码
//同一个等价类都在后缀自动机的同一个点,后缀自动机的每一个点只对应一个等价类,且所有的点恰好完全覆盖所有等价类!!!
int res;
char str[N],query[N];
int tot=1,last=1; //1是起点(代表的字符为空)
struct Node
{
int len,fa; //len:最长长度
int ch[26];
}node[N*2];
int h[N*3],e[N*3],ne[N*3],idx; //fa边的dfs遍历统计信息
int f[N*2]; //f[i]:endpos[i]的大小,i为一个等价类对应的后缀自动机的点
int now[N*2],ans[N*2]; //now:当前字符串与第一个字符串的最长公共子串;ans:所有字符串的最长公共子串
void extend(int c)
{
int p=last,np=last=++tot;
f[np]=1;
node[np].len=node[p].len+1;
for(;p && !node[p].ch[c];p=node[p].fa) node[p].ch[c]=np;
if(!p) node[np].fa=1;
else
{
int q=node[p].ch[c];
if(node[q].len==node[p].len+1) node[np].fa=q;
else
{
int nq=++tot;
node[nq]=node[q],node[nq].len=node[p].len+1;
node[np].fa=node[q].fa=nq;
for(;p && node[p].ch[c]==q;p=node[p].fa) node[p].ch[c]=nq;
}
}
return ;
}
void add(int u,int v)
{
e[++idx]=v;
ne[idx]=h[u];
h[u]=idx;
return ;
}
//e.g. 1->z->yz\xyz\wxyz->...vwxyz\...
//计算出endpose[i]的大小
void dfs(int u)
{
for(int i=h[u];i!=0;i=ne[i])
{
dfs(e[i]);
f[u]+=f[e[i]]; //节点u所代表的等价类中的所有整个字符串,肯定是e[i]中等价类中的所有字符串的子串
}
return ;
}
int find()
{
int p=1;
for(int i=0;query[i];i++)
{
int c=query[i]-'a';
if(node[p].ch[c]) p=node[p].ch[c];
else return -1;
}
return p;
}
void dfs2(int u)
{
for(int i=h[u];i!=0;i=ne[i])
{
dfs2(e[i]);
now[u]=max(now[u],now[e[i]]);
}
return ;
}
int main()
{
//建立后缀自动机
scanf("%s",str);
for(int i=0;str[i];i++) extend(str[i]-'a');
for(int i=2;i<=tot;i++) add(node[i].fa,i); //注意方向
//功能1:求不同子串的数量
res=0;
for(int i=1;i<=tot;i++) res+=node[i].len-(node[node[i].fa].len+1)+1; //后缀自动机的每个节点能表示的字符串数=i_{len_{max}}-i_{len_{min}}+1,其中i_{len_{min}}=fa_{len_{max}}+1
printf("%d\n",res);
//功能2:判断一个字符串是否是某个子串,如果是求某它出现的次数
dfs(1);//计算出endpose[i]的大小
scanf("%s",query);
res=find(); //这个子串的边界就是find()。沿着路径找就可以,别把问题想复杂
if(res==-1) puts("-1");
else printf("%d\n",f[res]);
//功能3:求多个最长公共子串
int q;
scanf("%d",&q);
for(int i=1;i<=tot;i++) ans[i]=node[i].len;
for(int i=1;i<=q;i++)
{
memset(now,0,sizeof now);
scanf("%s",query);
int p=1;
res=0;
for(int j=0;query[j];j++)
{
int c=query[j]-'a';
while(p>1 && !node[p].ch[c])//!!!关键之处!!!
{
p=node[p].fa;
res=node[p].len;//注意不是在循环外面执行该行代码。因为len表示等价类的最长长度。
}
if(node[p].ch[c]) p=node[p].ch[c],res++;
now[p]=max(now[p],res);
}
dfs2(1);
for(int j=1;j<=tot;j++) ans[j]=min(ans[j],now[j]);
}
res=0;
for(int i=1;i<=tot;i++) res=max(res,ans[i]);
printf("%d\n",res);
return 0;
}
6.字符串回文子串问题
6.1.manacher算法 \(O(n)\)
先将原串每个字符间插入特殊字符,两边插入不同特殊字符哨兵。\(e.g.\)abaabaaba→$#a#b#a#a#b#a#a#b#a#^。循环时借助之前信息跳,再向两边拓展,得到数组p[i]:在新串中以str[i]为中心最大回文串的半径(长度为1的回文串的半径为1)。最后在原串中每个回文子串的长度=p[i]-1。
int len,ans;
char s[N],str[N*2];//s:原串;str:新串(注意开2倍)
int p[N*2];//p[i]:以str[i]为中心最大回文串的半径
void init()//建立新串
{
str[len]='$';//哨兵
str[++len]='#';
for(int i=0;s[i];i++) str[++len]=s[i],str[++len]='#';
str[++len]='^';//哨兵
return ;
}
void manacher()
{
int mid,rmax=0;//已知rmax最大的回文字符串[mid*2-rmax,rmax]的信息
for(int i=1;i<len;i++)
{
if(i<rmax) p[i]=min(p[mid*2-i],rmax-i+1);//mid*2-i:i关于mid的对称点
else p[i]=1;
while(str[i-p[i]]==str[i+p[i]]) p[i]++;//向两边扩展
if(i+p[i]-1>=rmax)
{
rmax=i+p[i]-1;
mid=i;
}
}
return ;
}
scanf("%s",s);
init();
manacher();
for(int i=1;i<len;i++) ans=max(ans,p[i]);
printf("%d\n",ans-1);//注意最后减1
6.2.hash \(O(nlogn)\)
int n,c;
ULL h1[N],h2[N],q[N];
char str[N];
ULL get(ULL h[],int l,int r){
return h[r]-h[l-1]*q[r-l+1];
}
int main(){
while(scanf("%s",str+1),str[1]!='E'){
n=strlen(str+1);
n<<=1;
for(int i=n;i!=0;i-=2){
str[i]=str[i>>1];
str[i-1]='a'+26;
}
q[0]=1;
for(int i=1,j=n;i<=n;i++,j--){
h1[i]=h1[i-1]*mo+str[i]-'a'+1;
h2[i]=h2[i-1]*mo+str[j]-'a'+1;
q[i]=q[i-1]*mo;
}
int res=0;
for(int i=1;i<=n;i++){
int l=0,r=min(i-1,n-i);
while(l<r){
int mid=(l+r+1)>>1;
if(get(h1,i-mid,i-1)!=get(h2,n-(i+mid)+1,n-(i+1)+1)) r=mid-1;
else l=mid;
}
if(str[i-l]<='z') res=max(res,l+1);
else res=max(res,l);
}
printf("Case %d: %d\n",++c,res);
}
return 0;
}
6.3.dp\(O(N^2)\)
适用条件:有约束条件。
区间dp:\(f_{len,i}\):长度为len,以s[i]为第一个字符的字符串是否为回文串。
转移:\(f_{len,i}=\begin{cases}f_{len-2,i+1}&s[i]==s[i+len-1]\\\text{false}&s[i]\not=s[i+len-1]\end{cases}\)。
边界:\(f_{0,i}=f_{1,i}=\text{true}\)。
7.邻接表
树与图的一种存储方式。
int h[N],e[M],w[M],ne[M],idx;
void add(int u,int v,int wor){
//idx:边的编号
e[++idx]=v; //e:编号为idx的边所指向的终点
w[idx]=wor; //w:编号为idx的边的权值
ne[idx]=h[u]; //ne:以head为起点,编号为idx的边的下一条边
h[u]=idx; //h:以点a为起点,最后一条边的编号
return ;
}
//初始化
idx=0;
memset(h,0,sizeof h);
//遍历每条边且要知道起终点
for(int u=1;u<=n;u++)
for(int i=h[u];i!=0;i=ne[i])
cout<<u<<' '<<e[i]<<' '<<w[i]<<endl;
8.并查集(无向图的连通性、动态维护传递性关系)
8.1.并查集的优化方式
8.1.1.路径压缩并查集
int fa[N];
//初始化,不要忘记!!!
for(int i=1;i<=n;i++) fa[i]=i;
//find
int find(int x)
{
if(x!=fa[x]) fa[x]=find(fa[x]);
return fa[x];
}
//find:涉及到合并信息
int find(int x)
{
if(x!=fa[x]){
//注意下面的顺序
int root=find(fa[x]);
dis[x]+=dis[fa[x]];
fa[x]=root;
}
return fa[x];
}
for(int i=1;i<=n;i++) dis[i]=1;
//merge
//注意,并查集大小可以不在路径压缩时更新
siz[find(y)]+=siz[find(x)];
fa[find(x)]=find(y);
//并查集的换根操作
//并查集不支持删除根节点的操作,但是并查集中多余了一个点不会影响其他点,因此我们令原根节点指向新根节点,新根节点指向自己
p[rt]=new_rt,p[new_rt]=new_rt;
8.1.2.按秩合并并查集
秩dep[i]:当i作为根节点时,它到叶子节点的距离。只有根节点的秩对于复杂度有意义。
按秩合并:每次合并时令秩大的并查集是秩小的并查集的父亲,尽量不改变秩的大小。但是当两个并查集的秩的大小一样时,其中被令为另一个并查集的父亲的并查集的秩的大小要改变+1。
int p[N],dep[N];
//初始化,不要忘记!!!
for(int i=1;i<=n;i++) p[i]=i;
//find
int find(int x)
{
while(x!=p[x]) x=p[x];
return x;
}
//merge
void merge(int x,int y)
{
x=find(x),y=find(y);
if(x==y) return ;
if(dep[x]>dep[y]) swap(x,y); //按秩合并,每次合并时令秩大的并查集是秩小的并查集的父亲,尽量不改变秩的大小
p[x]=y;
if(dep[x]==dep[y]) dep[y]++; //当原先x、y秩的大小一样,现令y是x的父亲时,y的秩的大小改变+1
return ;
}
8.2.并查集的拓展方式
8.2.1.“扩展域”并查集(思路更简洁,但是空间更大)
\(x\)是同类域;\(x+n\)是敌人域……
8.2.2.“边带权”并查集
2种关系→异或:路径压缩时,对x到树根路径上的所有边权做异或运算,即可得到x与树根的奇偶性关系:d[x]为0 <-> x与树根奇偶性相同。如果x与y在同一个集合,若(d[l]d[r])!=flag即x与y关系与回答矛盾,小A撒谎;不在同一个集合,则合并两个集合,令d[fl]=d[l]d[r]^flag(使连接满足x、y之间新奇偶关系)。
3种关系→对3取模:
int find(int x){
if(x!=fa[x]){
//注意下面的顺序
int root=find(fa[x]);
dis[x]+=dis[fa[x]];
fa[x]=root;
}
return fa[x];
}
int fx=find(x),fy=find(y);
if(d==1){ //X与Y互为同类域
if(fx==fy){ //此处不可以写成fx==fy && (dis[x]-dis[y])%3!=0,否则下面的else判断条件会出问题
if((dis[x]-dis[y])%3!=0){ //如果X与Y不互为同类域,矛盾,假话
ans++;
continue;
}
}
else{
fa[fx]=fy;
dis[fx]=dis[y]-dis[x];
}
}
else{ //Y的天敌域有x,x的捕食域有y
if(fx==fy){
if((dis[x]-dis[y]-1)%3!=0) //如果Y的天敌域没有x,x的捕食域没有y,矛盾,假话
ans++;
continue;
}
else{
fa[fx]=fy;
dis[fx]=dis[y]-dis[x]+1;
}
}
9.树上问题
9.1.树上莫队
9.2. 树上分治算法
非常适合求解与无根树统计信息相关的内容。
但是树的形态不能改变,否则使用LCT。
9.2.1.点分治
-
思考暴力枚举根节点依次遍历整棵树怎么解决原问题。
-
当前层,找重心u。(达到分治效果保证层数复杂度\(O(\log N)\)层)
-
以u为根,直接\(O(F_1(N))\)暴力遍历u的每棵子树统计信息(如果后面合并信息是把信息放到一起双指针,此时遍历完一棵子树后要容斥减去两端在同一子树的方案)。
-
遍历完所有的子树后\(O(F_2(N))\)合并信息:以点u为根……/经过点u的路径……,贡献到答案。
所有的方案只有三种情况:
- 子树内部:递归求解。
- 跨子树(两端在不同子树)
-
方法一:把信息放到一起,排序,双指针。缺点是可能会出现两端在同一子树的方案,需要此时遍历完一棵子树后容斥减去。优点是双指针使得多组询问复杂度是\(O((N\log N+QN)\log N)\)。
任意两点的方案-两端在同一子树的方案。
任意两点的计算:拆成两个一端到当前重心的路径。
若计算时,一端不动一端动,则不动的一端到当前重心的路径的计算=动的一端的个数*不动的一段到当前重心路径的值。
-
方法二:每次遍历完一棵子树后,子树向点u合并信息。优点是不需要容斥。缺点是不能套用双指针,多组询问复杂度是\(O((N\log N+QN\log N)\log N)\)。
方案+=一端在旧子树的方案*一端在新子树的方案。
-
- 其中一端是重心:特殊求解(一般利用前面的信息加个特判即可)
-
删除重心u(给重心u打标记),递归到点u的每棵子树继续求解。
虽然每一层都直接\(O(F_1(N))\)暴力遍历整棵树以及\(O(F_2(N))\)合并信息,但是一共只有\(O(\log N)\)层。因此总的复杂度是\(O((F_1(N)+F_2(N))\log N)\)。
距离一般定义为到当前层重心的距离。
bool vis[N]; //删除标记
int qidx,sidx;
Node q[N],subq[N];//q:当前重心合并的信息;subq:子树统计的信息
//求u所在的子树大小
int get_size(int u,int fa)
{
int siz=1;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa || vis[v]/*防止越过该子树根节点,只遍历当前子树*/) continue;
siz+=get_size(v,u);
}
return siz;
}
//求u所在的子树重心
int get_wc(int u,int fa,int tot,int &wc)
{
int siz=1,res=0; //siz:以u为根的树 的节点数(包括u);res:删掉某个节点之后,最大的连通子图节点数
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa || vis[v]) continue;
int son_size=get_wc(v,u,tot,wc); //子树v的节点数
siz+=son_size; //统计以u为根的树 的节点数
res=max(res,son_size); //记录最大连通子图的节点数
}
res=max(res,tot-siz); //选择u节点为重心,最大的 连通子图节点数
if(res<=tot/2) wc=u; //只要res<=tot/2,就达到了分治的目的,可以狭义理解u就是重心
return siz;
}
//统计信息,储存在subq里
void dfs(int u,int fa,int dis)
//合并信息,贡献到答案。
void solve(int x[],int up,int sign)
//分治主体
void divide(int u)
{
//当前层,找重心u
get_wc(u,-1,get_size(u,-1),u);
qidx=0;//记得到达新的重心后清空
//以u为根,直接O(F_1(N))暴力遍历u的每棵子树统计信息
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(vis[v]) continue;
sidx=0;//记得统计完一棵子树后要清空
dfs(v,u);
//solve(s,sidx,-1);//如果后面合并信息是把信息放到一起,排序,双指针,则此时需要遍历完一棵子树后容斥减去两端在同一子树的方案
for(int i=1;i<=sidx;i++) q[++qidx]=subq[i];
}
//遍历完所有的子树后O(F_2(N))合并信息:以点u为根……/经过点u的路径……,贡献到答案
solve(q,qidx,1);
//删除重心u(给重心u打标记),递归到点u的每棵子树继续求解
vis[u]=true;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(vis[v]) continue;
divide(v);
}
return ;
}
divide(1);
常用技巧
-
单调队列按秩合并
求长度为[l,r]的路径的权值最大值:子树依次向重心合并信息到桶maxw[dis]:长度为dis的路径的权值最大值。正在遍历一棵子树时中的路径dis需要在已合并信息的子树中查找长度为[l-dis,r-dis]的路径的权值最大值,并与之配对形成跨子树路径更新答案。使用bfs+单调队列可以做到线性。
但是如果以任意顺序遍历子树,单调队列的初始化的复杂度是假的(\(e.g.\)假设R特别大,遍历的第一棵子树的深度也特别大,那么在遍历剩余子树前单调队列的初始化的复杂度都是一次\(O(N)\),总共\(O(N^2)\)。)。如果按照子树内节点的最大深度从小到大的顺序遍历子树,总的复杂度是\(O(\sum dep_{max})=O(N)\)。
9.2.2.动态点分治(点分树)
适用条件:多组询问的点分治,每个询问的根节点不同。或者是在线修改的点分治。点分治的结构不变(树的形态不变)。
若树的形态改变,则使用LCT。
预处理
点分治。
在divide()中的get_dis(v,fa,wc,dis)中记录子树的祖先(当前的重心)和子孙(子树)的信息。
struct Father
{
int u,id;//u:祖先节点的编号;id:u在祖先节点的哪棵子树
LL dis;//u到祖先的距离
};
vector<Father> fa[N];//fa[u]:u的各个祖先的信息
struct Son
{
LL dis;//u到子孙的距离
};
vector<vector<son>> son[N];//son[u][id]:u的第id棵子树中各个子孙的信息
//在divide()中的get_dis(v,fa,wc,dis)中记录子树的祖先(当前的重心)和子孙(子树)的信息。
void get_dis(int u,int father,int wc,int id,int dis)
{
if(vis[u]) return ;
fa[u].push_back({wc,id,dis});
son[wc][id].push_back(dis);
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==father) continue;
get_dis(v,u,wc,id,dis+w[i]);
}
return ;
}
void divide(int u)
{
if(vis[u]) return ;
get_wc(u,-1,get_size(u,-1),u);
vis[u]=true;
for(int i=h[u],id=0;i!=0;i=ne[i]) //id:第id棵子树
{
int v=e[i];
if(vis[v]) continue;
get_dis(v,-1,u,id,w[i]);
//abaabaaba
id++;//注意不可以放在上面,因为有些v会continue掉
}
for(int i=h[u];i!=0;i=ne[i]) divide(e[i]);
return ;
}
询问
贡献分成在u的子树的和不在u子树的(也就是祖先和祖先的非u所在子树的子树)。
对于不在u子树的,遍历u的祖先和祖先的非u所在子树的子树。利用fa[u]和son[fa[u]]计算。
对于在u子树的,遍历u的子树。利用son[u]计算。
int query(int u,int l,int r)
{
int res=0;
//对于不在u子树的,遍历u的祖先和祖先的非u所在子树的子树
for(auto it1 : fa[u])
{
res+=calc1(it1.u); //特判计算重心
for(int i=0;son[it1.u][i].size()!=0/*祖先有第i棵子树*/;i++)
{
if(i==it1.id) continue;//遍历祖先的非u所在子树的子树
for(auto it2 : son[it1.u][i]) res+=calc2(it2);
}
}
//对于在u子树的,遍历u的子树
for(int i=0;son[u][i].size()!=0;i++) for(auto it2 : son[u][i]) res+=calc2(it2);
return res;
}
9.3.树链剖分
9.3.1.重链剖分
模板题
https://questoj.cn/problem/2251
如果题目是所有区间操作结束后再进行询问,请考虑\(O(N)\)的树上差分而不是复杂度又高代码又长的树剖。
树链剖分:适用于路径、子树、邻域的修改和查询。
欧拉路径:适用于静态莫队算法,不能用于修改。
注意:只有在线段树上时才采用dfs序编号cnt,其余时候(比如说lca)采用原编号u。
名词
dfs序:优先遍历重儿子,即可保证重链上所有点的编号是连续的
定理:树上任意一条路径均可拆分成\(O(\log n)\)条重链(区间)。
预处理
dfs1:预处理所有节点的重儿子、父节点、深度以及子树内节点的数量。
dfs2:树链剖分,找出每个节点所属重链的顶点,dfs序的编号(而不是每个点属于哪条重链,用重链的顶点来辨别两点是否在同一重链上),并建立 u 到 id 的 w 映射。
路径→\(O(\log n)\)条重链(区间)
通过重链向上爬,找到最近公共重链,最后加上在相同重链里的区间部分。
子树→1个区间
以u为根的子树:[dfn[u],dfn[u]+siz[u]-1]。
邻域
直接修改/查询父亲和重儿子。
对于轻儿子,它一定是链顶节点:
对于修改,在该点打上懒标记,表示该点的轻儿子待修改。在后面的查询中,链顶节点结合其父亲的懒标记,额外单点查询。
对于查询,在前面的修改中,链顶节点额外更新其父亲的信息。
代码
int n,m;
int w[N];
int h[N],e[M],ne[M],idx;
//第一次dfs:预处理
int dep[N],siz[N],fa[N],son[N]; //以原编号u作为编号。dep:深度;siz:子树节点个数;fa:父节点;son:重儿子
//第二次dfs:做剖分
int top[N]; //以原编号u作为编号。top:重链的顶点;
int dfn[N],nw[N],num; //以dfs序num作为编号。dfn:节点的dfs序编号(时间戳);nw[dfn[i]]:w->nw的映射
struct Tree
{
int l,r;
LL sum,add;
}tr[N*4];
void add_edge(int u,int v)
{
e[++idx]=v;
ne[idx]=h[u];
h[u]=idx;
return ;
}
//预处理
void dfs1(int u)
{
dep[u]=dep[fa[u]]+1,siz[u]=1;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa[u]) continue;
fa[v]=u;
dfs1(v);
siz[u]+=siz[v];
if(siz[son[u]]<siz[v]) son[u]=v; //重儿子是子树节点最多的儿子
}
return ;
}
//做剖分(t是重链的顶点)
void dfs2(int u,int t)
{
dfn[u]=++num,nw[num]=w[u],top[u]=t;
//重儿子重链剖分
if(son[u]==0) return ;
dfs2(son[u],t);
//轻儿子重链剖分
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa[u] || v==son[u]) continue;
dfs2(v,v); //轻儿子的重链顶点就是他自己
}
return ;
}
void eval(int u,LL add)
{
tr[u].sum+=add*(tr[u].r-tr[u].l+1);
tr[u].add+=add;
return ;
}
void pushup(int u)
{
tr[u].sum=tr[u<<1].sum+tr[u<<1|1].sum;
return ;
}
void pushdown(int u)
{
eval(u<<1,tr[u].add);
eval(u<<1|1,tr[u].add);
tr[u].add=0;
return ;
}
void build(int u,int l,int r)
{
tr[u]={l,r,nw[r],0};
if(l==r) return ;
int mid=(l+r)>>1;
build(u<<1,l,mid),build(u<<1|1,mid+1,r);
pushup(u);
return ;
}
void modify(int u,int l,int r,LL add)
{
if(l<=tr[u].l && tr[u].r<=r)
{
eval(u,add);
return ;
}
pushdown(u);
int mid=(tr[u].l+tr[u].r)>>1;
if(l<=mid) modify(u<<1,l,r,add);
if(r>mid) modify(u<<1|1,l,r,add);
pushup(u);
return ;
}
LL query(int u,int l,int r)
{
if(l<=tr[u].l && tr[u].r<=r) return tr[u].sum;
pushdown(u);
int mid=(tr[u].l+tr[u].r)>>1;
LL res=0;
if(l<=mid) res+=query(u<<1,l,r);
if(r>mid) res+=query(u<<1|1,l,r);
return res;
}
//类lca思想,将树上序列转化为区间序列
void modify_path(int u,int v,LL add){
while(top[u]!=top[v]) //向上爬找到相同重链
{
if(dep[top[u]]<dep[top[v]]) swap(u,v); //注意不是比较u和v的depth
modify(1,dfn[top[u]],dfn[u],add); //dfs序原因,上面节点的dfn必然小于下面节点的dfn
u=fa[top[u]]; //爬到上面一条重链
}
if(dep[u]<dep[v]) swap(u,v);
modify(1,dfn[v],dfn[u],add); //在同一重链中,处理剩余区间
return ;
}
void modify_tree(int u,LL add)
{
modify(1,dfn[u],dfn[u]+siz[u]-1,add); //由于dfs序的原因,可以利用子树节点个数直接找到区间,注意不是dfn[u+siz[u]-1]
return ;
}
//询问路径的满足交换律的信息
LL query_path(int u,int v)
{
LL res=0;
while(top[u]!=top[v])
{
if(dep[top[u]]<dep[top[v]]) swap(u,v);
res+=query(1,dfn[top[u]],dfn[u]);
u=fa[top[u]];
}
if(dep[u]<dep[v]) swap(u,v);
res+=query(1,dfn[v],dfn[u]);
return res;
}
//询问路径的不满足交换律的信息
Type query_path(int u,int v)
{
vector<pii> qu,qv;
bool swa=false;
while(top[u]!=top[v])
{
if(dep[top[u]]<dep[top[v]]) swap(u,v),swap(qu,qv),swa^=1;
qu.push_back({dfn[top[u]],dfn[u]});
u=fa[top[u]];
}
if(dep[u]<dep[v]) swap(u,v),swap(qu,qv),swa^=1;
qu.push_back({dfn[v],dfn[u]});
if(swa) swap(qu,qv);
Type res;
res.unit();
for(int i=0;i<qu.size();i++) query(1,qu[i].first,qu[i].second,res,1/*使用线段树上的反向信息*/);
for(int i=qv.size()-1;i>=0;i--) query(1,qv[i].first,qv[i].second,res,0/*使用线段树上的正向信息*/);
return res;
}
LL query_tree(int u)
{
return query(1,dfn[u],dfn[u]+siz[u]-1);
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++) scanf("%d",&w[i]);
for(int i=1;i<n;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add_edge(u,v),add_edge(v,u);
}
//fa[1]=0;//注意初始化fa
dfs1(1);
dfs2(1,1);
build(1,1,n);
scanf("%d",&m);
while(m--)
{
int t,u,v;
LL k;
scanf("%d",&t);
if(t==1)
{
scanf("%d%d%lld",&u,&v,&k);
modify_path(u,v,k);
}
else if(t==2)
{
scanf("%d%lld",&u,&k);
modify_tree(u,k);
}
else if(t==3)
{
scanf("%d%d",&u,&v);
printf("%lld\n",query_path(u,v));
}
else
{
scanf("%d",&u);
printf("%lld\n",query_tree(u));
}
}
return 0;
}
9.3.2.长链剖分
指针空间要给够!!!
类此于重链剖分和树上启发式合并,当前节点继承长儿子的信息,暴力合并短儿子的信息。
长链剖分定义的深度d2:当前节点到以此节点为根的子树中最远的叶子节点的距离+1。定义叶子节点的d2为1。
-
定义
长链剖分定义的深度d2:当前节点到以此节点为根的子树中最远的叶子节点的距离+1。定义叶子节点的d2为1。
根节点定义的深度d1:当前节点到根节点的距离。依题定义根节点的d1为0还是1。
长儿子:其子节点中子树深度最大的子结点。如果有多个子树最大的子结点,取其一。如果没有子节点,就无长儿子。短儿子:剩余的子结点。
从这个结点到长儿子的边为长边。到其他短儿子的边为短边。若干条首尾衔接的长边构成长链。把落单的结点也当作长链。
类似于重链剖分,整棵树就被剖分成若干条长链。优先遍历长儿子,即可保证一条长链是连续遍历的。
图片
橙色代表长儿子,红边代表长边,黄色的框代表长链,圆圈内的数字代表遍历顺序,绿色的数字代表长链剖分定义的深度d2。
![]()
长链剖分
dfs_son:预处理所有节点的长儿子和长链剖分定义的深度d2 。有时根据题目还需要预处理深度d1和子树大小son。
int d2[N],son[N];//长链剖分定义的深度和长儿子
//int d1[N],siz[N];//根节点定义的深度和子树大小
void dfs_son(int u,int fa)
{
//d1[u]=d1[fa]+1;
//siz[u]=1;
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa) continue;
dfs_son(v,u);
//siz[u]+=siz[v];
if(d2[v]>d2[son[u]]) son[u]=v;
}
d2[u]=d2[son[u]]+1;//此行不能放在循环里面,否则会导致叶子节点的d2为0
return ;
}
性质
-
一个节点到它所在的长链的链底部的路径,为从这个节点到它子树每个子树所有节点的路径中,最长的一条。
-
一个深度为k 的节点向上跳一条短边,子树大小至少增加k+2。
-
证明图片
![]()
-
-
一个节点到根节点的路径,最多经过\(O(\sqrt{n})\)条短边。
-
证明
由性质2:一个深度为k 的节点向上跳一条短边,子树大小至少增加k+2。
那么如果我们跳了x条短边,此时树的大小\(>\sum_{i=1}^xi = \frac{x(x+1)}{2}\)。
故所有一个节点到根的路径,最多经过\(O(\sqrt{n})\)条短边。类似于重链剖分,这个\(O(sqrt(n))\)一般不满
-
9.3.2.1.长链剖分求树上 k 级祖先
在线算法。预处理\(O(N \log N)\),询问\(O(1)\)。
性质:任意一个点的k级祖先所在长链的链长一定大于等于k。
-
证明图片
![]()
-
思路:长链剖分
具体思路:摘取至xht的题解
首先我们进行预处理:
- 对树进行长链剖分,记录每个点所在链的顶点和深度,\(O(n)\)。
- 树上倍增求出每个点的 \(2^n\) 级祖先,\(O(n \log n)\)。
- 对于每条链,如果其长度为 len,那么在顶点处记录顶点向上的 len 个祖先和向下的 len$ \(个链上的儿子,\)O(n)$。
- 对 \(i \in [1, n]\) 求出在二进制下的最高位 \(h_i\),即\(\lfloor \log_2 i \rfloor\),\(O(n)\)。
对于每次询问 x 的 k 级祖先:
- 利用倍增数组先将 x 跳到 x 的 \(2^{h_k}\) 级祖先,设剩下还有 \(k^{\prime}\) 级,显然 \(k^{\prime} < 2^{h_k}\),因此此时 x 所在的长链长度一定 \(\ge 2^{h_k} > k^{\prime}\)。
- 由于长链长度 \(>k^{\prime}\),因此可以先将 x 跳到 x 所在链的顶点,若之后剩下的级数为正,则利用向上的数组求出答案,否则利用向下的数组求出答案。
int n,q,root,res;
LL ans;
int h[N],e[N],ne[N],idx; //已知父亲,建单向边
//长链剖分的变量
int son[N],d2[N]; //d2:长链剖分定义的深度
int top[N];
//k级祖先的变量
int lg2[N],fa[N][21];
vector<int> up[N],down[N];
int d1[N];//d1:根节点定义的深度
void dfs(int u,int p)
{
top[u]=p;
if(u==p) //在顶点处记录顶点向上的len个祖先和向下的len个链上的儿子
{
for(int i=1,v=u;i<=d2[u] && v!=0;i++,v=fa[v][0]) up[u].push_back(v);
for(int i=1,v=u;i<=d2[u] && v!=0;i++,v=son[v]) down[u].push_back(v);
}
if(son[u]) dfs(son[u],p);
for(int i=h[u];i!=0;i=ne[i]) if(e[i]!=son[u]) dfs(e[i],e[i]);
return ;
}
int ask(int u,int k)
{
if(k==0) return u;
u=fa[u][lg2[k]],k-=1<<lg2[k]; //利用倍增数组先跳到u的2_hk级祖先
k-=d1[u]-d1[top[u]],u=top[u]; //再跳到u所在链的顶点
if(k>=0) return up[u][k]; //精准降落
else return down[u][-k];
}
int main()
{
scanf("%d%d",&n,&q);
for(int i=1;i<=n;i++)
{
scanf("%d",&fa[i][0]);
if(fa[i][0]!=0) add(fa[i][0],i);
else root=i;
}
for(int i=2;i<=n;i++) lg2[i]=lg2[i>>1]+1; //预处理lg2:x在二进制下的最高位hx
dfs_son(root);
dfs(root,root);
for(int i=1;i<=q;i++)
{
scanf("%d%d",&x,&k);
printf("%lld\n",ask(x,k));
}
return 0;
}
9.3.2.2.长链优化树形dp\(O(N)\)
适用条件:把维护子树中只与深度有关的信息做到线性的时间复杂度。因为优化的是dp,所以自然是静态离线的。
长链优化树形dp\(O(N)
\)
维护子树中只与深度有关的信息
树上启发式合并\(O(N\log N)\)
维护子树中恒定不变的信息(\(e.g.\)节点的颜色)
-
设计状态转移方程——长链剖分的难点。
长链剖分只能优化形如\(f[u][calc(i)]=f[son][i]+x,(其中i与d2相关)\)的状态转移方程,故设计时应向这个方向设计。同时,为了下面更方便,\(f[son]\)****的第二维应是i,让\(f[u]\)的第二维随\(f[son][i]\)而定。
-
长链剖分dfs_son。
-
dp在维护信息的过程中,先O(1)继承重儿子的信息,再暴力合并其余轻儿子的信息。
-
在dp递归前先申请空间。
指针申请空间——长链剖分的核心操作。
定义指针:
int space[N*2];int *f[N],*tmp=space;f[N][]实际上是使用space[N2]的空间,tmp“引领”f[i][]在space[N2]的地址。space空间开2倍,对于每一条长链申请2倍空间!这样的话指针可以灵活地往左往右移互相不发生冲突。对于每一条新长链申请新空间。当即将递归一条新长链的链顶时,令
tmp+=d2[u],f[u]=tmp,tmp+=d2[u];申请一条长链f[u](由于u是该长链的链顶,故该长链的长度一定是d2[u],需申请2倍空间)的空间(f[u]的地址指向原tmp),然后移动tmp为下次申请空间做准备,此时已申请空间f[u][d2[u]<<1]。当即将递归一个长儿子时,令
f[son[u]]=f[u]+(calc(i)-i);直接在已申请空间的长链上使用空间,根据状态转移方程令f[son[u]]地址指向f[u]+(calc(i)-i),这样可以自然地将长儿子的信息O(1)合并到当前节点。当即将递归其余的短儿子时,其一定是一条新长链的链顶,为每一条新长链申请空间tmp+=d2[v],f[v]=tmp,tmp+=d2[v];。-
图片
下图是对于上图那颗树的“5回溯时”~“6回溯时”时间,操作的顺序。
![]()
可以发现对于每一个长链,其使用的空间是连续的,这样可以自然地将长儿子的信息O(1)合并到当前节点(\(e.g.\)f[2][2]会O(1)继承f[3][1])。对于其余的短儿子,只需暴力合并即可。
-
-
优先遍历长儿子,回溯时自然地继承长儿子的信息。
-
遍历短儿子,暴力合并信息。注意子结点到父节点深度+1。
-
-
上述操作对于优化\(f[u][calc(i)]=\sum f[son][i]\)已经足够。但是对于1.\(f[u][calc(i)]=\sum \{ f[son][i]+x \}\)转移有常量的方程;2.\(f[u][i] \subset f[u][i+1]\);3.询问的是\(f[u][i]\)而不是\(\sum f[u]\),则可以用懒标记维护常数项保证复杂度。
懒标记正确的原因:虽然f[son][j]不能贡献答案到f[u][i](自然也没有f[u][i]+=f[son][j]+x),但是\(f[son][1] \subset f[son][j]\),因此对于每一个son,只需要令tag[u]加一次x即可,不会影响除f[u][0]外其他所有f[u]的正确性。
tag[u]=tag[son]+x;//懒标记(别忘了加上tag[son]),son是所有儿子
f[u][0]=-tag[u];//特判:f[u][0]没有由任何儿子转移而来,且不包含其他f[u],不能加上懒标记
printf("%d\n",f[u][i]+tag[u]);
- 对于树上计数类dp,考虑每次把即将要加入的子树(新)与已加入的子树(旧)计算贡献(这样可以不重不漏地计算答案)。长儿子由于是第一个加入的子树,直接让当前节点继承长儿子信息,无需考虑与其他子树计算贡献。
- 对于询问每个节点,由于长链剖分优化树形dp是静态离线的,所以先将询问放置在各个节点
vector,递归到当前节点并计算完成时回答。否则回溯后信息就会被父节点利用被其他子树覆盖。 - 长链剖分容易维护后缀和,较难维护前缀和。借助后缀和回答区间询问。
void dp(int u,int fa)
{
if(son[u])
{
f[u][0]+=f[u][1]; //在该if末尾加上该代码
}
for(int i=h[u];i!=0;i=ne[i])
{
f[u][0]+=f[v][0]; //在该for末尾加上该代码
}
return ;
}
因为每个点仅属于一条长链,短儿子的d2一定小于当前节点的d2合并时长链一定能包含短链,且一条长链只会在链顶位置作为轻儿子暴力合并一次,所以时间复杂度是线性的。
int d2[N],son[N];
//长链剖分的核心:指针申请内存,O(1)继承长儿子信息
int space[N*2]; //空间开2倍,对于每一条长链申请2倍空间!!!
int *f[N],*tmp=space;
int tp;//辅助多测清空
//对于每一条新长链申请空间
void dp(int u,int fa)
{
f[u][0]=a[u];
//优先遍历长儿子,将长儿子的信息O(1)合并到当前节点
if(son[u])
{
f[son[u]]=f[u]+1; //对于一个长儿子,直接在已申请空间的长链上使用空间,根据状态转移方程令f[son[u]]地址指向f[u]+1,这样可以自然地将长儿子的信息O(1)合并到当前节点
dp(son[u],u);
//ans[u]=ans[son[u]]+1; //继承长儿子的答案(注意深度+1)
//tag[u]=tag[son[u]]+x; //懒标记(别忘了加上tag[son[u]])
}
//短儿子暴力合并
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa || v==son[u]) continue;
tmp+=d2[v],f[v]=tmp,tmp+=d2[v]; //对于其余的短儿子,其一定是一条新长链的链顶,为每一条新长链申请空间
tp+=d2[v]*2;
dp(v,u);
for(int j=0;j<d2[v];j++) //注意是[0,d2[v])
{
f[u][j+1]+=f[v][j]; //注意深度+1
}
//tag[u]+=tag[v]+x;
}
//f[u][0]=-tag[u];//特判:f[u][0]没有由任何儿子转移而来,不能加上懒标记
//printf("%d\n",f[u][i]+tag[u]);
return ;
}
//多测清空
for(int u=1;u<=n;u++) son[u]=0;
for(int i=0;i<=tp+1/*多清空一些总没问题*/;i++) space[i]=0;
tp=0;
tmp=space;
dfs_son(1,-1);
tmp+=d2[1],f[1]=tmp,tmp+=d2[1]; //申请一条长链f[1](由于1是该长链的链顶,故该长链的长度一定是d2[1],需申请2倍空间)的空间(f[1]的地址指向原tmp),然后移动tmp为下次申请空间做准备,此时已申请空间f[1][d2[1]<<1]
tp+=d2[1]*2;
dp(1,-1);
-
vector实现指针
思路仍然是用 vector 存下每个点的信息。不过有几个特殊之处:
- 按深度递增的顺序存储的话,因为合并重儿子信息时要在开头插入元素,效率低下。所以考虑按深度递减的顺序存储信息。
- 合并重儿子信息的时候,直接用 swap 交换而不是复制,在时间和空间上都更优(swap 交换 vector 的时间复杂度是 \(O(1)\) 的)。
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10,M=N*2;
int n;
int ans[N];
int h[N],e[M],ne[M],idx;
int son[N],d2[N];
vector<int> f[N];
void dp(int u,int fa)
{
if(son[u])
{
dp(son[u],u);
swap(f[u],f[son[u]]);
f[u].push_back(1);
ans[u]=ans[son[u]];
if(f[u][ans[u]]==1) ans[u]=d2[u];
for(int i=h[u];i!=0;i=ne[i])
{
int v=e[i];
if(v==fa || v==son[u]) continue;
dp(v,u);
for(int i=d2[v]-1;i>=0;i--)
{
int tmp=i+d2[u]-d2[v]-1;
f[u][tmp]+=f[v][i];
if(f[u][tmp]>f[u][ans[u]] || (f[u][tmp]==f[u][ans[u]] && tmp>ans[u])) ans[u]=tmp;
}
}
}
else
{
f[u].push_back(1);
ans[u]=0;
}
return ;
}
int main()
{
scanf("%d",&n);
for(int i=1;i<n;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v),add(v,u);
}
dfs_son(1,-1);
dp(1,-1);
for(int i=1;i<=n;i++) printf("%d\n",d2[i]-ans[i]);
return 0;
}
9.4.虚树\(O(cnt_{key}\log cnt_{key}+ F(cnt_{key}))\)
适用条件:q组询问,每组询问给定若干个关键点。答案只与关键点及其到根节点之间的链有关,其他节点和边的信息可以且能迅速合并到那些链。“\(cnt_{key} ≤\)”。
建立虚树的时间复杂度:\(O(cnt_{key}\log cnt_{key})\)。虚树的大小:\(O(cnt_{key})\)。
\(O(q*F(N))→O(cnt_{key}\log cnt_{key}+ F(cnt_{key}))\)。
-
思考对于一次询问正常的树怎么\(O(F(N))\)解决。
-
dfs预处理dfs序、LCA。步骤4的合并信息有时可以在这里直接\(O(N)\)预处理。
-
对于每一组询问,标记关键点并建立虚树。建立虚树的时间复杂度是\(O(cnt_{key}\log cnt_{key})\)。注意从此开始要保证复杂度与关键点(而不是N)相关,尤其注意初始化的复杂度要与关键点(而不是N)相关。
虚树=关键点+任意两个关键点的LCA。
前置知识:《图论8.4.拓展应用1.点集LCA》。
-
思考对于虚树上的一条边,怎么将其对应的原树上的链上的所有点及其其他子树的信息合并。
-
根据步骤1,在虚树上解决问题。
int n,m,q;
int h[N],e[M],ne[M],idx; //原树
int dfn[N],num; //dfs序
int fa[N][19],dep[N];
vector<int> key;
bool is_key[N];
//int st[N],top; //用单调栈来维护一条虚树上的链
int hc[N],ec[M],nc[M],cidx; //虚树
bool cmp(int x,int y)
{
return dfn[x]<dfn[y];
}
//建立虚树方法一:二次排序+LCA连边
int build()
{
sort(key.begin(),key.end(),cmp);
int backup=key.size();
for(int i=1;i<backup;i++) key.push_back(lca(key[i-1],key[i]));
sort(key.begin(),key.end(),cmp);
key.erase(unique(key.begin(),key.end()),key.end());
for(auto u : key) hc[u]=0;//在build()函数中要清空虚树的邻接表
for(int i=1;i<key.size();i++) cadd(lca(key[i-1],key[i]),key[i]);
return key[0];
}
/*建立虚树方法二:单调栈
//用单调栈来维护一条虚树上的链
//在build()函数中要清空虚树的邻接表
int build()
{
top=0;
//将关键点按照dfs序排序
sort(key.begin(),key.end(),cmp);
//先将1号节点入栈,清空1号节点的邻接表
hc[1]=0;
st[++top]=1;
for(auto u : key)
{
if(u==1) continue; //不要重复添加1号节点
//先添加LCA。要保证虚树中任意2个节点的LCA也在虚树中
int p=lca(u,st[top]);
if(p!=st[top]) //如果LCA和栈顶元素不同,则说明当前节点不再当前栈所存的链上
{
while(dfn[st[top-1]]>dfn[p]) //当次大节点的dfs序大于lca的dfs序时
{
cadd(st[top-1],st[top]);
top--;
}
if(st[top-1]==p) //如果此时次大节点是LCA
{
cadd(st[top-1],st[top]);
top--;
}
else //否则说明LCA从来没有入过栈
{
hc[p]=0; //清空即将入栈的LCA的邻接表
cadd(p,st[top]); //注意此时st[top]的连边
top--;
st[++top]=p;
}
}
//再添加点u
hc[u]=0; //清空即将入栈的点u的邻接表
st[++top]=u;
}
//对剩余的最后一条的链进行连边
while(top-1)
{
cadd(st[top-1],st[top]);
top--;
}
return 1;
}
*/
scanf("%d",&n);
for(int i=1;i<n;i++)
{
int u,v;
scanf("%d%d",&u,&v);
add(u,v),add(v,u);
}
dep[1]=1;
dfs(1); //dfs预处理dfs序、LCA。步骤4的合并信息有时可以在这里直接O(N)预处理
scanf("%d",&q);
while(q--)
{
//注意初始化的复杂度要与关键点(而不是N)相关
//在build()函数中再清空虚树的邻接表
cidx=0;
for(auto u : key)
{
is_key[u]=false;
ans[u]=0;
}
key.clear();
scanf("%d",&m);
for(int i=1;i<=m;i++)
{
int k;
scanf("%d",&k);
key.push_back(k);
is_key[k]=true;
}
int vr=build();//返回虚树的根节点
//根据步骤1,接下来在虚树上解决问题
}
9.5.Link-Cut-Tree
动态树问题:
维护一个森林,支持删除某条边,加入某条边,并保证加边,删边后仍是!!森林!!。我们要维护该森林的一些信息。
一般的操作有两点连通性, 两点路径权值和, 连接两点 和 切断某条边、修改信息 等
LCT 则是用多个** Splay** 来维护 多个实链,Splay 的特性就使得我们可以进行 树的合并、分割 操作。LCT 基本能代替树链剖分,LCT处理一次询问的时间复杂度为$ O(logn)$,但是常数大。
实边和虚边可以任意选择,一个点与他的儿子最多只有1条实边。只要有边,儿子都会储存父亲的信息,但父亲只会储存实边的儿子的信息。
辅助树splay
- 辅助树 由多棵 Splay 组成,每棵 Splay 维护原树中的 一条路径,且 中序遍历 这棵 Splay 得到的点序列,从前到后对应原树“从上到下”的一条路径。原树 每个节点与 辅助树 的 Splay 节点一一对应。
- 对于每一条实边路径,我们用一个splay维护,splay的中序遍历(而不是左右儿子)就是原树的路径。
- 对于每一条虚边路径,各棵 Splay 之间并不是独立的:每棵 Splay 的根节点的父亲节点本应是空,但在 LCT 中每棵 Splay 的根节点的父亲节点指向原树中这条链的父亲节点(即链最顶端的点的父亲节点)。这类父亲链接与通常 Splay 的父亲链接区别在于儿子认父亲,而父亲不认儿子,对应原树的一条虚边。因此,每个连通块恰好有一个点的父亲节点为空。
辅助树和原树的关系
-
原树 中的 实链 在 辅助树 中都在同一颗 Splay 里。
-
原树 中的 虚链 : 在 辅助树 中,子节点 所在 Splay 的 Father 指向 父节点,但是 父节点 的 两个儿子 都不指向 子节点。
-
原树的 Father 指向不等于 辅助树的 Father 指向。
-
辅助树 是可以在满足 辅助树、Splay 的性质下任意换根的。
-
虚实链变换 可以轻松在 辅助树 上完成,这也就是实现了 动态维护树链剖分。
make_root()不会影响整颗树的拓扑结构。
一个小技巧:把带权的边拆成点,这样LCT也能解决带边权的问题。
#u #u (0)
| |
|w -> #i+n (w)
| |
#v #v (0)
#define ls tr[u].kid[0]
#define rs tr[u].kid[1]
int n,m;
struct Tree
{
int kid[2],p;
int key,sum;
int rev;
}tr[N];
void eval(int u)
{
swap(ls,rs);
tr[u].rev^=1;
return ;
}
void pushup(int u)
{
tr[u].sum=tr[ls].sum^tr[u].key^tr[rs].sum;
return ;
}
void pushdown(int u)
{
if(tr[u].rev==1)
{
eval(ls);
eval(rs);
tr[u].rev=0;
}
return ;
}
bool is_root(int u)
{
return tr[tr[u].p].kid[0]!=u && tr[tr[u].p].kid[1]!=u;
}
int dir(int u)
{
return tr[tr[u].p].kid[1]==u;
}
void rotate(int u)
{
int y=tr[u].p,z=tr[y].p;
int tu=dir(u),ty=dir(y);
if(!is_root(y)) tr[z].kid[ty]=u; //注意这里的特判,否则会多连一条实边
tr[u].p=z;
tr[y].kid[tu]=tr[u].kid[tu^1],tr[tr[u].kid[tu^1]].p=y;
tr[u].kid[tu^1]=y,tr[y].p=u;
pushup(y); //别忘了这里
pushup(u);
return ;
}
void splay(int u)
{
//先自上而下pushdown
int top=0,st[N];
for(int i=u;;i=tr[i].p)
{
st[++top]=i;
if(is_root(i)) break;
}
while(top) pushdown(st[top--]);
while(!is_root(u))
{
int y=tr[u].p;
if(!is_root(y))
if(dir(u)^dir(y)) rotate(u);
else rotate(y);
rotate(u);
}
return ;
}
void access(int u) //建立一条从原根到u的实边路径,同时将u变成splay的根节点,并且将u与u的子节点的边变为虚边
{
int backup=u;
for(int i=0;u!=0;i=u,u=tr[u].p) //i从0开始:将u与u的子节点的边变为虚边
{
splay(u);
tr[u].kid[1]=i;
pushup(u); //别忘了这里
}
splay(backup);
return ;
}
void make_root(int u) //将u变成原树的根节点,同时access函数将u变成splay的根节点
{
access(u);
eval(u);
return ;
}
int find_root(int u) //找到u所在原树的根节点, 再将原树的根节点旋转到splay的根节点,并将u到根节点的路径变为实边
{
access(u);
//access后u是splay的根节点,找原树的根一直往左儿子找即可
while(tr[u].kid[0]!=0)
{
pushdown(u); //别忘了这里
u=tr[u].kid[0];
}
splay(u);
return u;
}
void split(int u,int v) //给u和v之间的路径建立一个splay,原树的根是u,splay的根是v
{
make_root(u);
access(v);
return ;
}
void link(int u,int v) //如果u和v不连通,则加入一条u和v的虚边,v是u的父节点
{
make_root(u);
if(find_root(v)!=u) tr[u].p=v;
return ;
}
void cut(int u,int v) //如果u和v之间存在边,则删除该边
{
make_root(u);
if(find_root(v)==u/*要把u到v变为实边路径*/ && tr[v].p==u && tr[v].kid[0]==0) //???
{
tr[u].kid[1]=tr[v].p=0;
pushup(u); //别忘了这里
}
return ;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&tr[i].key);
while(m--)
{
int op,x,y,w;
scanf("%d",&op);
if(op==0)
{
scanf("%d%d",&x,&y);
split(x,y);
printf("%d\n",tr[y].sum);
}
else if(op==1)
{
scanf("%d%d",&x,&y);
link(x,y);
}
else if(op==2)
{
scanf("%d%d",&x,&y);
cut(x,y);
}
else
{
scanf("%d%d",&x,&w);
splay(x);
tr[x].key=w;
pushup(x);
}
}
return 0;
}
9.6.树形dp
《动态规划3.树形dp》
9.7.树上启发式合并
10.DLX
10.1.十字链表
数据结构
int idx;
int u[N],d[N],l[N],r[N];//十字链表。编号为i的点的上、下、左、右的点的编号
int row[N],col[N];//编号为i的点的行和列
int s[N];//第i列有多少个1
精确覆盖与重复覆盖的共同函数
-
初始化表头
init()![]()
-
逐行插入1
add(双指针hh、tt,插入1的所在的行、列)![]()
-
主函数
- 十字链表初始化表头。
- 根据题目条件逐行插入“1”。
//十字链表初始化表头
void init()
{
for(int i=0;i<=m;i++)
{
l[i]=i-1,r[i]=i+1;
u[i]=d[i]=i;
s[i]=0;//多组测试数据
}
l[0]=m,r[m]=0;
idx=m+1;
return ;
}
//插入十字链表
void add(int &hh,int &tt,int x,int y)
{
row[idx]=x,col[idx]=y,s[y]++;
u[idx]=y,d[idx]=d[y],u[d[y]]=idx,d[y]=idx;
r[hh]=idx,l[tt]=idx,r[idx]=tt,l[idx]=hh;
tt=idx; //不要忘记这里
idx++;
return ;
}
scanf("%d%d",&n,&m);
init();
for(int i=1;i<=n;i++)//逐行插入1
{
int hh=idx,tt=idx;
for(int j=1;j<=m;j++)
{
int x;
scanf("%d",&x);
if(x==1) add(hh,tt,i,j);
}
}
删除remove()+恢复resume()的图解
-
图解
注意恢复和删除要对称。
注意从l/r/u/d[p]循环删除时要先删除p(因为终止条件是i!=p)。
删除只是把指针指向别的地方,并没有删除这个节点本身,因此可以恢复。
![]()
10.2.精确覆盖问题
瓶颈:必须是稀疏矩阵。
ans的储存使用栈。int ans[N],aidx;
删除(或恢复)removee(删除的列的哨兵(也就是第p列中的p))
下面以删除为例:
传入的是哨兵removee(删除的列的哨兵(也就是第p列中的p))。
- 删除p所在的列(此列已被覆盖,无需再考虑):直接在表头删除。
- 删除p这一列所有含1的行(此行已经选过了,或者它不能再选了(再选会导致某一列被覆盖多次)):在链表里删除。
//删除p所在的列及p这一列所有含1的行,排除不可能答案剪枝
//从l/r/u/d[p]循环删除时要先删除p(因为终止条件是i!=p)
//删除只是把指针指向别的地方,并没有删除这个节点本身,因此可以恢复
void removee(int p)//传入的是列的表头(也就是第p列中的p )
{
//删除p所在的列直接在表头删除
r[l[p]]=r[p],l[r[p]]=l[p];
//删除p所在的行在链表里删除
for(int i=d[p];i!=p;i=d[i]/*这里及下文极容易误写成i=d[p],要小心!!!*/)
for(int j=r[i];j!=i;j=r[j])
{
s[col[j]]--;
u[d[j]]=u[j],d[u[j]]=d[j];
}
return ;
}
//恢复和删除要对称
void resume(int p)
{
for(int i=u[p];i!=p;i=u[i])
for(int j=l[i];j!=i;j=l[j])
{
u[d[j]]=j,d[u[j]]=j;
s[col[j]]++;
}
r[l[p]]=p,l[r[p]]=p;
return ;
}
深搜dfs()
爆搜,直至存储1的十字链表已经没有1了,成功返回。
- 选择1的个数最少的列,记为p。优先搜索分枝少的节点的剪枝。
- 先删除p这一列(此列已被覆盖,无需再考虑)。排除不可能答案剪枝。
- 枚举选出行q。将选择的行q记入答案,删除行q上所有含1的列(此列已被覆盖,无需再考虑),继续向下递归。回溯时恢复。若没有可以选择的行则失败返回。
删除列removee(删除的列)时,会把p这一列所有含1的行删除(此行已经选过了,或者它不能再选了(再选会导致某一列被覆盖多次))。
bool dfs()
{
if(r[0]==0) return true; //存储1的十字链表已经没有1了
//选择1的个数最少的列,记为p
int p=r[0];
for(int i=r[0];i!=0;i=r[i]) if(s[i]<s[p]) p=i;
//先删除p
removee(p);
//枚举选出行q
for(int i=d[p];i!=p;i=d[i])
{
ans[++aidx]=row[i];
for(int j=r[i];j!=i;j=r[j]) removee(col[j]);
if(dfs()) return true;
for(int j=l[i];j!=i;j=l[j]) resume(col[j]);
aidx--;
}
resume(p);
return false;
}
if(dfs())
{
for(int i=1;i<=aidx;i++) printf("%d ",ans[i]);
puts("");
}
else puts("No Solution!");
建模应用
“恰好”,“不重不漏”。
原问题的方案\(\Leftrightarrow\)精确覆盖问题的方案
行表示决策,列表示“恰好”限制。
一般来说有三条核心思路建模:
- 行表示决策,每行(决策)对应着一个这一行所有含1的列的集合(“恰好”限制),也就对应着选 / 不选;
- 列表示限制,因为第 \(i\) 列对应着某个条件 \(P_i\)。限制条件应抓住“1”这个关键字眼,它正好对应着精确覆盖问题中每一列恰好被某一行覆盖一次。
- “1”:选择这一行代表选择这一个决策,会对这一行所有含1的列都产生影响。每一列只能被覆盖“1”次。
10.3.重复覆盖问题
由于重复覆盖较精确覆盖缺少了许多排除不可能答案剪枝,因此使用IDA*优化。
瓶颈:答案(选择的行数)不能太大,因为IDA*递归的层数不能太多。
ans的储存靠IDA*中的当前深度。
初始化表头init()
需要额外记录哨兵所在的列,删除和恢复的时候要用。col[i]=i;
估价函数h()
- 遍历当前所有还没有被覆盖的列。
- 对于每一列,把能覆盖这一列的所有行全部选上,但是只当作选择了 1 行。
这样一来,不仅比最优解多选了一些行,还计算了比最优解更小不超过最优解的代价,满足估价函数≤最优解。
bool vis[M];//vis[col]:第col列有没有被覆盖
int h()
{
int res=0;
memset(vis,0,sizeof vis);
for(int i=r[0];i!=0;i=r[i])
{
if(vis[col[i]]) continue;
vis[col[i]]=true;
res++;
for(int j=d[i];j!=i;j=d[j])
for(int k=r[j];k!=j;k=r[k])
vis[col[k]]=true;
}
return res;
}
删除(或恢复)removee(点的编号)
下面以删除为例:
传入的是点的编号removee(点的编号)。
只需删除这一列:把这一列所有点的左右关系改变就行(之后无论是从表头枚举还是遍历一行中含1的列都不会再考虑这一列了,此列已被覆盖,无需再考虑),不涉及行的删除(因为可以重复覆盖)。
注意这里不可以改变传入的点的编号的左右关系,因为在IDA*中是遍历一行中含1的列,一个一个删除左右关系。倘若改变了p的左右关系,就没有办法遍历这一行了。而且少改变这一左右关系不会影响答案。
void removee(int p)//这里传入的是十字链表中任意一点的编号
{
//重复覆盖问题:对于一列中的所有点都要改变左右关系,且不涉及行的删除
//注意这里不可以改变p的左右关系,因为在IDA*中是遍历一行中含1的列,一个一个删除左右关系。倘若改变了p的左右关系,就没有办法遍历这一行了。而且少改变这一左右关系不会影响答案
for(int i=d[p];i!=p;i=d[i])
{
r[l[i]]=r[i];
l[r[i]]=l[i];
}
return ;
}
void resume(int p)//恢复和删除要对称
{
for(int i=u[p];i!=p;i=u[i])
{
r[l[i]]=i;
l[r[i]]=i;
}
return ;
}
迭代加深IDA_star()
迭代加深,直至存储1的十字链表已经没有1了,成功返回。
- 若估价函数h()+当前深度k>规定深度,失败返回。
- 选择1的个数最少的列,记为p。优先搜索分枝少的节点的剪枝。
- 枚举选出行q。将选择的行q记入答案,删除列p和行q上所有含1的列(此列已被覆盖,无需再考虑),继续向下递归。回溯时恢复。若没有可以选择的行则失败返回。
删除列removee(点的编号)时,传入的是点的编号,只把这一列所有点的左右关系改变就行(之后无论是从表头枚举还是遍历一行中含1的列都不会再考虑这一列了,此列已被覆盖,无需再考虑),不涉及行的删除(因为可以重复覆盖)。
bool IDA_star(int k,int depth)
{
if(k+h()>depth) return false;
if(r[0]==0) return true;
int p=r[0];
for(int i=r[0];i!=0;i=r[i]) if(s[i]<s[p]) p=i;
for(int i=d[p];i!=p;i=d[i])
{
ans[k]=row[i];
removee(i);
for(int j=r[i];j!=i;j=r[j]) removee(j);
if(IDA_star(k+1,depth)) return true;
for(int j=l[i];j!=i;j=l[j]) resume(j);
resume(i);
}
return false;
}
//注意这里的depth
int depth=0;
while(!IDA_star(0,depth)) depth++;
printf("%d\n",depth);
for(int i=0;i<depth;i++) printf("%d ",ans[i]); //注意这里不取等号
建模应用
“至少选出多少个……才能满足”。
原问题的方案\(\Leftrightarrow\)精确覆盖问题的方案
行表示决策,列表示“至少……满足”限制。
- 行表示决策,每行(决策)对应着一个这一行所有含1的列的集合(“至少……满足”限制),也就对应着选 / 不选;
- 列表示限制,因为第 \(i\) 列对应着某个条件 \(P_i\)。
- “覆盖”:限制条件不用再抓住“1”的要素,只需要抓住“覆盖”要素。








浙公网安备 33010602011771号