数据结构字符串和图

news/2025/10/14 1:02:34/文章来源:https://www.cnblogs.com/BrillianceZ/p/19139823

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]。

《C++语言.4.1.字符串string

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和jwhile(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的最长公共前缀的长度。

  1. Trie树

  2. 按字典序排序

    排序后的性质:

    • \(\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})\)
  3. 动态规划

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,根据周期性和相等区间的传递性。

  1. 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]。

  1. 如果i>r,直接暴力匹配。

  2. 如果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']相同。

    1. 如果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个之前的字符,矛盾。

    2. 如果z[i'] ≥r-i+1,先令z[i]=z[i'],由于还没有扫描到s[r]之后的字符,所以直接继续暴力匹配。

  3. 匹配完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 ;
}

应用

  1. 给定字符串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]\)即可。

  2. 求出A的本质不同的子串个数,并支持操作:在A末尾增加某个字符或删去尾字符。\(O(QN)\)

    只考虑增加字符。设\(s_{bef}\)表示增加前的字符串,\(s_{aft}\)表示增加后的字符串。

    把A倒过来,那么原本在末尾增加就是在前面增加。

    增加一个字符,就增加\(len(s_{bef})\)个子串。

    因此只要知道新增加的子串有多少个之前已经算过的,就可以计算出有多少个新增加的本质不同子串。对\(s_{bef}\)跑一遍扩展KMP,找出最大的i≠1的z[i],那么新增加的子串仅有z[i]个串已经计算过。

  3. 给定某个串s,求出它的严格循环节。\(O(N)\)

    跑出s的Z函数,若i+z[i]-1=len(s),且(i-1)|z[i],则s[1~i-1]就是一个严格循环节。

4.2.一匹多:AC自动机

类比KMP。

功能

  1. 给定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].fhh=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中的出现次数

应用

  1. fail树

    插入AC自动机的字符串x在另外一个插入AC自动机的字符串y中出现了多少次\(\Leftrightarrow\)从根到y的末尾的路径上的所有节点中有多少个节点通过fail指针直接或间接指向了x的末尾。

    对于fail指针v→u,建立有向边u→v。显然会构成一棵有向树。所以“直接或间接指向”\(\Leftrightarrow\)“在子树内”。

    注意建边时要记得给指向点0的fail指针也建边:从点0向在加入初始队列时的点建边。

    利用fail树,将原字符串问题转化为树上问题。

  2. AC自动机上dp

5.字符串子串问题

5.1.后缀数组

性质:

  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;
  2. 所有非空后缀的非空前缀集合\(\Leftrightarrow\)所有非空子串的集合。
    先用后缀数组排序,排好序后,所有互不相同的非空子串个数\(=\sum\limits_{i=1}^{n} (len_i-height_i)\)
  3. 排序后,一个后缀与前面的后缀的最大公共前缀长度\(≤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;//倍增计算safor(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;
}
  • 例题1:动态统计所有互不相同的非空子串个数

    S最初为空串,每次向S的结尾加入一个字符,并求出此时S中所有互不相同的非空子串的个数。

    后缀数组的性质:

    1. 设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);
    2. 所有后缀的前缀集合\(\Leftrightarrow\)所有子串的集合。
      先用后缀数组排序,排好序后,所有互不相同的子串个数\(=\sum\limits_{i=1}^{n} (len_i-height_i)\)
    3. 排序后,一个后缀与前面的后缀的最大公共前缀长度\(≤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),相当于答案:

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) 。

  1. 后缀自动机的点数最多2N,边数最多3N。
  2. 因为同一个等价类中的所有子串一定是里面最长子串的连续后缀,所以后缀自动机每一个点(同一个等价类)代表的不同字串个数=\(i_{len_{max}}\)\(-\)\(i_{len_{min}}\)\(+1\),其中\(i_{len_{min}}=fa_{len_{max}}+1\)
  3. 注意建边的方向e.g. 1->z->yz\xyz\wxyz->...vwxyz...

功能

  1. 求不同子串的数量。
  2. 判断一个字符串是否是某个子串,如果是求某它出现的次数。
  3. 求多个最长公共子串。(思想类似于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}}+1printf("%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的秩的大小改变+1return ;
}

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的捕食域有yif(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.点分治

  1. 思考暴力枚举根节点依次遍历整棵树怎么解决原问题。

  2. 当前层,找重心u。(达到分治效果保证层数复杂度\(O(\log N)\)层)

  3. 以u为根,直接\(O(F_1(N))\)暴力遍历u的每棵子树统计信息(如果后面合并信息是把信息放到一起双指针,此时遍历完一棵子树后要容斥减去两端在同一子树的方案)。

  4. 遍历完所有的子树后\(O(F_2(N))\)合并信息:以点u为根……/经过点u的路径……,贡献到答案。

    所有的方案只有三种情况:

    • 子树内部:递归求解。
    • 跨子树(两端在不同子树)
      • 方法一:把信息放到一起,排序,双指针。缺点是可能会出现两端在同一子树的方案,需要此时遍历完一棵子树后容斥减去。优点是双指针使得多组询问复杂度是\(O((N\log N+QN)\log N)\)

        任意两点的方案-两端在同一子树的方案。

        任意两点的计算:拆成两个一端到当前重心的路径。

        若计算时,一端不动一端动,则不动的一端到当前重心的路径的计算=动的一端的个数*不动的一段到当前重心路径的值。

      • 方法二:每次遍历完一棵子树后,子树向点u合并信息。优点是不需要容斥。缺点是不能套用双指针,多组询问复杂度是\(O((N\log N+QN\log N)\log N)\)

        方案+=一端在旧子树的方案*一端在新子树的方案。

    • 其中一端是重心:特殊求解(一般利用前面的信息加个特判即可)
  5. 删除重心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)
{//当前层,找重心uget_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);

例题1:AcWing 252. 树

例题2:AcWing 264. 权值

常用技巧

  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]);//abaabaabaid++;//注意不可以放在上面,因为有些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的depthmodify(1,dfn[top[u]],dfn[u],add); //dfs序原因,上面节点的dfn必然小于下面节点的dfnu=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;//注意初始化fadfs1(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为0return ;
}

性质

  1. 一个节点到它所在的长链的链底部的路径,为从这个节点到它子树每个子树所有节点的路径中,最长的一条。

  2. 一个深度为k 的节点向上跳一条短边,子树大小至少增加k+2。

    • 证明图片

  3. 一个节点到根节点的路径,最多经过\(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的题解

    首先我们进行预处理:

    1. 对树进行长链剖分,记录每个点所在链的顶点和深度,\(O(n)\)
    2. 树上倍增求出每个点的 \(2^n\) 级祖先,\(O(n \log n)\)
    3. 对于每条链,如果其长度为 len,那么在顶点处记录顶点向上的 len 个祖先和向下的 len$ \(个链上的儿子,\)O(n)$。
    4. \(i \in [1, n]\) 求出在二进制下的最高位 \(h_i\),即\(\lfloor \log_2 i \rfloor\)\(O(n)\)

    对于每次询问 x 的 k 级祖先:

    1. 利用倍增数组先将 x 跳到 x 的 \(2^{h_k}\) 级祖先,设剩下还有 \(k^{\prime}\) 级,显然 \(k^{\prime} < 2^{h_k}\),因此此时 x 所在的长链长度一定 \(\ge 2^{h_k} > k^{\prime}\)
    2. 由于长链长度 \(>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在二进制下的最高位hxdfs_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.\)节点的颜色)

  1. 设计状态转移方程——长链剖分的难点。

    长链剖分只能优化形如\(f[u][calc(i)]=f[son][i]+x,(其中i与d2相关)\)的状态转移方程,故设计时应向这个方向设计。同时,为了下面更方便,\(f[son]\)****的第二维应是i,让\(f[u]\)的第二维随\(f[son][i]\)而定。

  2. 长链剖分dfs_son。

  3. dp在维护信息的过程中,先O(1)继承重儿子的信息,再暴力合并其余轻儿子的信息

    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])。对于其余的短儿子,只需暴力合并即可。

    2. 优先遍历长儿子,回溯时自然地继承长儿子的信息。

    3. 遍历短儿子,暴力合并信息。注意子结点到父节点深度+1

  4. 上述操作对于优化\(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]);
  1. 对于树上计数类dp,考虑每次把即将要加入的子树(新)与已加入的子树(旧)计算贡献(这样可以不重不漏地计算答案)。长儿子由于是第一个加入的子树,直接让当前节点继承长儿子信息,无需考虑与其他子树计算贡献。
  2. 对于询问每个节点,由于长链剖分优化树形dp是静态离线的,所以先将询问放置在各个节点vector,递归到当前节点并计算完成时回答。否则回溯后信息就会被父节点利用被其他子树覆盖。
  3. 长链剖分容易维护后缀和,较难维护前缀和。借助后缀和回答区间询问。
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 存下每个点的信息。不过有几个特殊之处:

    1. 按深度递增的顺序存储的话,因为合并重儿子信息时要在开头插入元素,效率低下。所以考虑按深度递减的顺序存储信息。
    2. 合并重儿子信息的时候,直接用 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}))\)

  1. 思考对于一次询问正常的树怎么\(O(F(N))\)解决。

  2. dfs预处理dfs序、LCA。步骤4的合并信息有时可以在这里直接\(O(N)\)预处理。

  3. 对于每一组询问,标记关键点并建立虚树。建立虚树的时间复杂度是\(O(cnt_{key}\log cnt_{key})\)。注意从此开始要保证复杂度与关键点(而不是N)相关,尤其注意初始化的复杂度要与关键点(而不是N)相关。

    虚树=关键点+任意两个关键点的LCA。

    前置知识:《图论8.4.拓展应用1.点集LCA》。

  4. 思考对于虚树上的一条边,怎么将其对应的原树上的链上的所有点及其其他子树的信息合并。

  5. 根据步骤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;}}//再添加点uhc[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,接下来在虚树上解决问题
}

动态树问题:
维护一个森林,支持删除某条边加入某条边,并保证加边,删边后仍是!!森林!!。我们要维护该森林的一些信息。
一般的操作有两点连通性两点路径权值和连接两点切断某条边修改信息

LCT 则是用多个** Splay** 来维护 多个实链Splay 的特性就使得我们可以进行 树的合并分割 操作。LCT 基本能代替树链剖分,LCT处理一次询问的时间复杂度为$ O(log⁡n)$,但是常数大。

实边和虚边可以任意选择,一个点与他的儿子最多只有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)
{//先自上而下pushdownint 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.树上启发式合并

《基础算法12.2.树上启发式合并》

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()

  • 逐行插入1add(双指针hh、tt,插入1的所在的行、列)

  • 主函数

    1. 十字链表初始化表头。
    2. 根据题目条件逐行插入“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))

  1. 删除p所在的列(此列已被覆盖,无需再考虑):直接在表头删除。
  2. 删除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. 选择1的个数最少的列,记为p。优先搜索分枝少的节点的剪枝。
  2. 先删除p这一列(此列已被覆盖,无需再考虑)。排除不可能答案剪枝。
  3. 枚举选出行q。将选择的行q记入答案,删除行q上所有含1的列(此列已被覆盖,无需再考虑),继续向下递归。回溯时恢复。若没有可以选择的行则失败返回。

删除列removee(删除的列)时,会把p这一列所有含1的行删除(此行已经选过了,或者它不能再选了(再选会导致某一列被覆盖多次))。

bool dfs()
{if(r[0]==0) return true;    //存储1的十字链表已经没有1了//选择1的个数最少的列,记为pint p=r[0];for(int i=r[0];i!=0;i=r[i]) if(s[i]<s[p]) p=i;//先删除premovee(p);//枚举选出行qfor(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. 遍历当前所有还没有被覆盖的列。
  2. 对于每一列,把能覆盖这一列的所有行全部选上,但是只当作选择了 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了,成功返回。

  1. 若估价函数h()+当前深度k>规定深度,失败返回。
  2. 选择1的个数最少的列,记为p。优先搜索分枝少的节点的剪枝。
  3. 枚举选出行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”的要素,只需要抓住“覆盖”要素。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/936555.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

字典dict

2025.10.14 1.字典的键值必须是不可变的,也就是说元祖,形如下面的初始化是可以的dict1 = {(1, 2): 1} dict1 = {a: 1} dict1 = {}

结婚证识别技术:融合计算机视觉、深度学习与自然语言处理的综合性AI能力的体现

在数字化浪潮席卷各行各业的今天,如何高效、准确地处理海量纸质证件信息,成为提升政务服务与金融业务效率的关键。结婚证作为证明婚姻关系的核心法律文件,因而,结婚证识别技术应运而生。它不仅是光学字符识别技术的…

上下文丢失

2025.10.14 位置编码外推失效是Transformer模型在长文本推理中出现上下文丢失的最常见架构限制,因为训练时使用的固定位置编码(如正弦编码)无法有效外推至超出训练长度的序列位置,导致位置信息丢失。 残差连接梯度…

数据结构序列

不要从数据结构维护信息的角度来思考问题,而是从问题本身思考需要哪些信息,数据结构只是维护信息的工具!!! 可减信息,如区间和、区间异或和 直接用前缀和实现,复杂度 O(n)+O(1)+O(n)。 可重复贡献信息,如区间最…

上下文学习(In-context Learning, ICL)

2025.10.14 上下文学习(In-context Learning, ICL)的核心机制是在推理阶段不更新模型参数,利用提示中的少量示例引导模型生成适应新任务的输出。也就是在不更新参数的情况下,利用提示中的示例让模型在内部条件化地…

混淆矩阵

2025.10.14 混淆矩阵可以显示模型的所有预测结果,包括真正例、假正例、真负例和假负例,从而帮助分析模型的性能 混淆矩阵不仅仅显示准确率,还提供更详细的分类结果 混淆矩阵与训练损失无关 混淆矩阵不涉及超参数设置…

提示词工程实践指南:从调参到对话的范式转变

写在前面 作为一名长期与代码打交道的工程师,我们习惯了编译器的严格和确定性——相同的输入永远产生相同的输出。但当我们开始使用生成式AI时,会发现这是一个完全不同的世界。最近在系统学习Google的AI课程时,我整理…

泛化能力

2025.10.14 在大型语言模型的工程实践中,提高泛化能力的最常见策略是使用更大的预训练数据集,因为更多数据可以帮助模型学习更泛化的表示,例如GPT-3和BERT等模型都强调大规模数据集的应用。

JVM引入

虚拟机与 JVM 虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列的虚拟计算机指令。 虚拟机可以分为系统虚拟机和程序虚拟机:Visual Box、VMware 就属于系统虚拟机,它们完全是对物理计…

shiro 架构

一、subject(当前用户信息) 二、SecurityManager(所有用户管理) 三、Realm(数据连接)

[音视频][HLS] HLS_downloader

[音视频][HLS] HLS_downloader$(".postTitle2").removeClass("postTitle2").addClass("singleposttitle");01 简介 1.1 功能: 一个简单的HLS下载器,使用go语言实现 1.2 执行方式 如果…

Python-weakref技术指南

Python weakref 模块是 Python 标准库中用于处理对象弱引用的重要工具。它允许程序员创建对对象的弱引用,这种引用不会增加对象的引用计数,从而不影响对象的垃圾回收过程。本报告将全面介绍 weakref 模块的概念、工作…

从众多知识汲取一星半点也能受益匪浅【day11(2025.10.13)】

Enjoy 基于代码思考问题 先理清楚代码是否用上了文档所定义的api

王爽《汇编语言》第四章 笔记

4.2 源程序 4.2.1 伪指令在汇编语言的源程序中包含两种指令:汇编指令、伪指令。 (1)汇编指令:有对应机器码的指令,可以被编译为机器指令,最终被CPU所执行。 (2)伪指令:没有对应的机器指令,最终不被CPU所执行…

10.13总结

import java.util.*; import java.util.concurrent.TimeUnit; public class ArithmeticPractice { private Set generatedQuestions = new HashSet<>(); private List questions = new ArrayList<>(); pri…

MySql安装中的问题

是一台已经安装过但是失败了的win 1. 2025-10-13T12:42:20.566779Z 0 [ERROR] [MY-010457] [Server] --initialize specified but the data directory has files in it. Aborting. 2025-10-13T12:42:20.566788Z 0 [ERR…

10.14总结

import java.util.*; import java.util.concurrent.TimeUnit; public class ArithmeticPractice { private Set generatedQuestions = new HashSet<>(); private List questions = new ArrayList<>(); pri…

题解:AT_agc050_b [AGC050B] Three Coins

传送门 注:如无特殊说明,本篇题解中所有的序列,均用红色标示已经放置硬币的位置。若本次操作为拿走硬币,用蓝色标示本次操作拿走的硬币的位置,用黑色标示从未放过硬币或放置过硬币且在本次操作之前的操作中被拿走…

go:generate 指令

gogenerate 指令 go generate 命令是在Go语言 1.4 版本里面新添加的一个命令,当运行该命令时,它将扫描与当前包相关的源代码文件,找出所有包含 //go:generate 的特殊注释,提取并执行该特殊注释后面的命令。 命令格…