0%

群及置换群的定义略去,此处我们重点讨论其性质。

置换群的元素是置换,置换的运算是连接。比如: \[ \left( \begin{matrix} 1 &2 &3\\ 2 &1 &3\\ \end{matrix} \right) \left( \begin{matrix} 1 &2 &3\\ 1 &3 &2\\ \end{matrix} \right) = \left( \begin{matrix} 1 &2 &3\\ 3 &1 &2\\ \end{matrix} \right) \]

三个置换可以表示为 \((1\ 2)(3),(1)(2\ 3),(1\ 3\ 2)\)

理论基础

Burnside 引理

\(G\) \(N=\{1,2,\cdots,n\}\) 上的置换群,\(G\) \(N\) 上可引出不同的等价类,其中不同的等价类的个数为:

\[\frac1{|G|} \sum_{g\in G} f(g)\]

其中 \(f(g)\) 为在置换 \(g\) 的连接下不变元的个数。

注意对 \(\forall\ i,j\in G,i\cdot j\in G\)。因此假设置换集有旋转 \(90^\circ\),则旋转 \(180^\circ\) 也要在置换集中。特别注意如果定义了自己等价于自己,则置换元也要在集合中。

这帮助我们解决了等价类计数问题,但是当规模变大时,我们不可能枚举所有元素考察是否在置换下不变。因此有时会用到组合数学动态规划的方法进行计数,同时还需要 Polya 定理。

Polya 定理

\(G=a_1,a_2,\dots,a_n\) \(n\) 个对象的置换群,用 \(m\) 种颜色(不一定都用上)给这 \(n\) 个对象着色,则不同的着色方案数为:

\[\frac1{|G|} \sum_{g\in G} m^{f(g)}\]

其中 \(f(g)\) 为置换 \(g\) 的循环节数。

可以理解为只有每个循环都染一种颜色,置换后才会不动。则不动点自然为 \(m\) 的循环节数次幂。这极大加快了我们计算染色等价类的速度,在一些特殊等价类(如循环同构)中甚至可以不需要真正计算循环节而采用结论。

不加证明地给出如下结论:

  • 在长度为 \(n\) 的循环同构中,对移动 \(i\) 个元素的置换 \(g_i\) \(f(g_i)=(n,i)\),每个循环长度 \(len_i=\frac n{(n,i)}\)

可以画个圆理解。有了这个结论,我们就可以化简循环同构类染色的计数。其他的等价类也有结论,但是往往比较少见,需要现推。

通常步骤:

  1. 列出所有置换。
  2. 利用 Burnside 引理和 Polya 定理计算每个置换的不变点。
  3. 最后除以置换总数

有时候在第二步呆太久会忘记最后一步,因此要小心。

练习

等价类计数题目基本套公式,但更重要的通常在于如何快速计算置换中的不变点。

基础

POJ1286 Necklace of Beads

基础题目。等价类具有特殊性,可以得到结论,不需要真正计算循环节数。

进阶

POJ2154 Color

循环同构,直接结论。显然答案为 \(\frac 1n\sum_{i=1}^n n^{(i,n)}\),然而 \(n\) 很大,需要优化。我们单独考察指数部分。令 \(j=(i,n)\),有:

\[ \begin {aligned} \frac 1n\sum_{i=1}^n n^{(i,n)} &=\frac 1n\sum_{i=1}^n \sum_{j|(i,n)} n^j \text {且}[(i/j,n/j)==1] \\ &=\frac 1n\sum_{j|n} \sum_{i=1}^{n/j} n^j \text {且}[(i/j,n/j)==1] \\ &=\frac 1n\sum_{j|n} \phi (n/j)*n^j \\ \end {aligned} \]

需要质数表、快速幂和单欧拉函数值计算,但时间复杂度远远到不了 \(\mathrm{O}(n)\)。然而我们最后要除以 \(n\),模数不保证质数,也不一定有逆元。我们发现答案的每一项都存在 \(n\) 的非 \(0\) 次幂,因此我们在快速幂的时候就可以少乘一个 \(n\)

HNOI2008 洗牌

保证任意几种洗牌法可以用其中的一种代替,且都能回到原状态保证了置换群的封闭性,可以用 Burnside 引理。不使用 Polya 定理是因为有颜色数量的限制,不能直接使用。

我们发现一个循环要不变就只能染同一种颜色,因此求出一种置换的循环后,我们相当于求对这些循环染 \(3\) 种颜色使得总数满足数量限制的方案数。这是一个背包问题,可以动态规划解决。

POJ2888 Magic Bracelet

类似 POJ2154 Color,但是这次有颜色限制,然而我们发现颜色数量很少。

考虑一个置换 \(g\_i\),它有 \((n,i)\) 个循环长度为 \(\frac{n}{(n,i)}\) 的循环,并且这些循环在任意长度为 \((n,i)\) 的序列中刚好各有一个循环元素,整个序列为这段序列重复 \(\frac{n}{(n,i)}\) 次构成(画图),因此我们可以认为这段序列首尾相接的满足方案数是等价的。把珠子看成点,能连接的两个珠子之间连边,则满足的方案相当于从点 \(u\) 经过 \((n,i)\) 个点再回到 \(u\) 的方案数。令邻接矩阵为 \(a\),则方案数为 \((a^{(n,i)})_{u,u}\)(邻接矩阵的性质)。

同样利用欧拉函数加速计算,本题解决。

高级

Sgu282 Isomorphism/SHOI2006 有色图

是道好题。

题目很明显是 Polya 定理计数,不过我们计数的对象不是点而是边,这就不容易计算循环节了。不过我们发现对于完全图,一个边由唯一确定的两个点确定,因此点的一种置换唯一对应边的一种置换,即置换数 \(|G|=n!\)。对于一个边,它的置换是由两个点的置换决定,边循环是点循环上一圈长度任意的连续的边集(想象一个点循环上有一条边连接了任意两个点,则一次点置换之后,边会顺着置换方向移动 \(1\) 格,而此处在置换前的边,在置换后又对应了下一个间隔为 \(1\) 的边,直到最终回到初始边)。因此我们希望通过考虑点的置换间接得到边的置换节,从而得到 Polya 定理中的指数。

不难发现一个边的两个点有两种情况:在点循环,在点循环。我们先确定这么一个结论:边循环节 \(=\) 点循环中的边循环节 \(+\) 点循环间的边循环节。分类讨论:

  • 在点循环中。则按照刚刚我们的脑补,一个边循环由转了一圈循环长度为 \(len\) 的点循环的边集构成,因此边循环长度为 \(len\)。然而我们发现 \(len\) 为偶数的情况时长度不一定都是 \(len\),比如边的长度刚好为 \(\frac{len}2\) 时,转 \(\frac{len}2\) 次就结束了(画图验证)。显然奇数情况不会出现长度为 \(\frac{len}2\) 的边。我们再分奇偶讨论:

    • \(len\) 为奇数。则在点循环中只有 \(\frac{len*(len-1)}2\) 条边,每个边循环长度都为 \(len\),因此循环节数为 \(\frac{len-1}2=\lfloor \frac{len}2 \rfloor\)

    • \(len\) 为偶数。则恰好只有一个循环长度为 \(\frac{len}2\),因此我们从 \(\frac{len*(len-1)}2\) 条边中去除多余的 \(\frac{len}2\) 条边,再给循环节数单独加上 \(1\)。循环节数为 \(\frac{len}2\)

    合并结果为 \(\frac{len}2\)

  • 在点循环间。则对于一条边,相当于在两个点循环 \(len_i\) \(len_j\) 中连边,每次轮换就前进 \(1\) 格。不难看出这样的循环长度为 \([len_i,len_j]\),且所有的循环不相交。因此循环节为 \(\frac{len_i*len_j}{[len_i,len_j]} = (len_i,len_j)\)

综上,我们得到了对于给定的一种点循环长度方案计算边循环节长度的算法,且满足 \(\sum{len_i}=n\)。这个就是整数划分方案,对于 \(n\leq 53\) 来说数量远少于 \(n!\),可以 DFS 枚举。令 \(cnt_i\) 为划分中 \(i\) 出现的次数,\(len_i\) 为划分的一个方案集,则满足这种划分的排列数为 \(\frac{n!}{\prod(cnt_i!)\prod{len_i}}\),简单证明:

  • 考虑划分方案。假设一种划分方案已经被我们钦定排成一排,对于每种全排列我们都顺序按划分方案划分,不难看出这种划分方案在考虑划分间及其内部的顺序时是不重复也不遗漏的,这样的方案数为 \(n!\)

  • 不考虑划分集内部元素。我们认为集合大小相同的两个集合是等价的,因此总数为可重集的全排列,要除以 \(\prod(cnt_i!)\)

  • 考虑划分集内元素。由于划分集内是一个循环,因此我们认为循环同构是等价的。而对于不产生循环同构的含有 \(len\) 个元素的序列,我们每对一个排列进行循环同构就能增加 \(len-1\) 个新排列(注意所有产生的序列和原序列都不相同,否则原序列存在循环同构),因此每一个划分集要除以 \(len\),总数要除以 \(\prod{len_i}\)

  • 综上,满足一种划分条件且划分集内部不存在循环同构的全排列数为 \(\frac{n!}{\prod(cnt_i!)\prod{len_i}}\)

于是我们就很完美地解决了这个问题。

总结

置换群通常解决一类置换问题,Burnside 引理和 Polya 定理通常解决一类等价类计数问题。等价类计数的重点在于寻找不变点总数,通常存在高效的方法。有时需要动态规划和数论知识甚至矩阵快速幂解决。

  • Burnside 引理:求解每个置换下本质不同的方案数即在该置换下保持不变的方案数;Polya 定理:计算每个置换下循环节个数

  • 染色计数中,若存在对染色的限制(数量、间隔)则不能直接应用 Polya 定理。Polya 定理的本质是对置换的每个循环染同一种颜色。

  • 无论用 Burnside 引理还是 Polya 定理,都需要除以置换总数。若存在与总数不互质的模数,需要考虑边做就边除以总数。

竞赛中的字符串算法并不算少,可惜我在字符串上也拿不出手来。因此本篇只是简单总结,将来(咕)还会渐渐完善内容。

字符串算法通常是对一个或多个字符串进行匹配。然而这些算法不但不容易记忆且容易出错,因此尽可能保证一次写对是很重要的。

KMP

KMP 是多个文本串匹配一个模板串的算法。它通过预处理模板串构造失配边,使得匹配失败的移动代价减小。失配边作用即是,从失配的一位顺着失配边走,则在失配边所在节点之前跟文本串都是匹配的。

初始化模板串的过程就是自己匹配自己的过程。具体流程如下:

  1. 从第 \(i\) 位开始匹配;
  2. 不断沿着失配边走,直到其等于第 \(i\) 位(此过程中前 \(i-1\)保证匹配);
  3. 如果当前位置结尾的串与第 \(i\) 位结尾的串匹配,将此失配边赋给 \(i+1\)
  4. 否则当前位置是第 \(0\) 位,将 \(0\) 赋给 \(i+1\)

给出代码。

1
2
3
4
5
6
7
8
int Init(int f[],string s){
f[0]=f[1]=0;
for(int i=1;i<s.length();i++) {
int j=f[i];
while(j&&s[i]!=s[j])j=f[j];
f[i+1]=s[i]==s[j]?j+1:0;
}
}

注意的点:

  • 执行失配的是第 \(i\) 位,但正在计算的是 \(i+1\)的失配
  • 如果结尾不匹配,则失配边连向开头

明白了初始化,查询的代码也不难写。

1
2
3
4
5
6
7
8
9
int KMP(string s,string t,int f[]){
int cnt=0,j=0;
for(int i=0;i<s.length();i++){
while(j&&s[i]!=t[j])j=f[j];
if(s[i]==t[j])j++;
if(j==t.length())cnt++;
}
return cnt;
}
  • 需要用变量 \(j\) 记录当前文本串的匹配长度。
  • \(i\) 的值表示当前正在匹配第 \(i\)
  • 是查询 t 在 s 上出现的位置(和上文的 s 不一样)

KMP 的预处理复杂度和匹配复杂度都是线性的。(具体的证明我忘了,蓝书上有。)我们观察这个 \(f\) 数组(也叫 fail 数组),它也有很多不错的性质。

  • \(f_{i+1}\) 同时表示前缀 \(i\)首尾相同部分子串最大长度(Border)。我们发现,如果 \(f_{n+1}=j\),则 \(n+1-j\) 是长度为 \(n\) 的串的最小循环节(不一定完整)。这个性质可以证明,也可以画图理解。因此利用 KMP 可以求一个串的最小循环节。不断顺着失配边走,可以得到串从小到大的周期
  • 所有的失配边构成了一棵树,且标号严格递增,是小根堆。由于一个节点表示了一个原串的 Border,所以可以通过在失配树上爬得到所有的 Border。
  • KMP 树不断失配得到的序列,能被分为不超过 \(\log n\) 个等差数列。(没有看懂)

需要更多补充。WC2017 的讲义里应该是有的。

扩展 KMP

没有学习,很绝望。等待填坑。

Border Tree

没有学习,很绝望。等待填坑。

##AC 自动机

适用于多模板匹配。想到 KMP 是字符串 + 失配边,则多模板的 AC 自动机是 Trie + 失配边。其建树过程和 KMP 基本相同。

1
2
3
4
struct AhoCorasick{
int ch[SIZ][CHAR],f[SIZ],suf[SIZ];
bool isEnd[SIZ];
};

其中 \(f\) 是失配边,\(ch\) 是 Trie,\(isEnd\) 表示节点 \(i\) 是否为单词结尾,而 \(suf\) 为上一个单词节点。不加解释地给出初始化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void GetFail(){
queue<int> q;
for(int i=0;i<CHAR;i++)
if(ch[0][i])q.push(ch[0][i]);

while(!q.empty()){
int h=q.front();q.pop();
for(int i=0;i<CHAR;i++){
int &u=ch[h][i],j=f[h];
if(!u){u=ch[j][i];continue;}
q.push(u);
while(j&&!ch[j][i])j=f[j];
f[u]=ch[j][i];
suf[u]=isEnd[f[u]]?f[u]:suf[f[u]];
}
}
}

这个代码直接将不匹配的边改为失配边,这样查询时就可以直接在树上爬而不考虑失配数组啦。其中 \(suf\) 的作用是输出该节点结尾的全部单词,因为一个单词节点可能有多个单词。

// 此处应该有查询的代码,等待补全

AC 自动机的构建与查询都是线性的,一点证明可以看这里。但是 AC 自动机似乎没有什么良好的性质可以使用,因此往往结合着考吧。(什么?可持久化 Trie?)

后缀数组

后缀数组似乎不需要对构造的过程有太多理解,因为写了第一次之后基本就复制粘贴了。后缀数组的重点在记录排名为 \(k\) \(sa\) 数组,记录后缀 \(k\) 的排名 \(rnk\) 数组和记录 \(LCP_{sa_{i-1},sa_i}\) \(hei\) 数组。

可以参考 09 年论文:《后缀数组 —— 处理字符串的有力工具》罗穗骞。下面就直接丢代码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
struct SuffixArray{
int sa[N],hei[N],rnk[N];

void Init(int *a,int n){
InitSa(a,n);
InitHeight(a,n);
for(int i=0;i<n;i++){
sa[i]=sa[i+1];
hei[i]=hei[i+1];
rnk[i]--;
}
}

inline bool Cmp(int *a,int x,int y,int l){
return a[x]==a[y]&&a[x+l]==a[y+l];
}

void InitSa(int *a,int n){
int m=26;
static int tmpX[N],tmpY[N],s[N];
int *x=tmpX,*y=tmpY;

a[n]=0;
for(int i=0;i<m;i++)s[i]=0;
for(int i=0;i<=n;i++)s[x[i]=a[i]]++;
for(int i=1;i<m;i++)s[i]+=s[i-1];
for(int i=n;i>=0;i--)sa[--s[x[i]]]=i;

for(int i=1,p=1;p<=n;i<<=1,m=p){
p=0;
for(int j=n-i+1;j<=n;j++)y[p++]=j;
for(int j=0;j<=n;j++)if(sa[j]>=i)y[p++]=sa[j]-i;
for(int j=0;j<m;j++)s[j]=0;
for(int j=0;j<=n;j++)s[x[y[j]]]++;
for(int j=1;j<m;j++)s[j]+=s[j-1];
for(int j=n;j>=0;j--)sa[--s[x[y[j]]]]=y[j];
swap(x,y);
p=1,x[sa[0]]=0;
for(int j=1;j<=n;j++)x[sa[j]]=Cmp(y,sa[j-1],sa[j],i)?p-1:p++;
}
}

void InitHeight(int *a,int n){
for(int i=1;i<=n;i++)rnk[sa[i]]=i;
for(int i=0,j,k=0;i<n;hei[rnk[i++]]=k)
for(k?k--:0,j=sa[rnk[i]-1];a[i+k]==a[j+k];k++);
}
};

注意上面代码的 \(a\) 数组范围为 \([0,n)\),初始化时要把 \(a_n\) 赋一个比其他字符都小的字符。\(m\) 为字符集最大值 \(+1\),即 \(1\leq a_i< m\)。由于一些特殊原因,最后的数组是包含 \(a_n\) 的,显然 \(a_0=n\),我们需要把数组调整位置。具体流程:

  1. 利用 \(a\) 进行基数排序,此时 \(x\) \(rnk\) 数组。(如果 \(m\) 很大,使用快排离散化);
  2. 倍增长度,用 \(sa\) 数组计算的对第二关键字排序的 \(y\) 数组来计算 \(sa\) 数组;
  3. 交换 \(x,y\),计算 \(x\) 数组。若当前二元组数 \(\leq n\),跳至第二步。

\(hei\) 的求法是利用了 \(hei_{rnk_i}\geq hei_{rnk_i-1}-1\)(具体解释见训练指南)。

利用后缀数组求 LCP

不难证明两个后缀 \(i,j(rnk_i<rnk_j)\) 有:

\[LCP_{i,j}=min\{hei_k\}\quad rnk_i< k\leq rnk_j\]

这是个 RMQ 问题,因此我们可以 \(\mathrm{O}(nlogn)\) 预处理 ST 表 \(\mathrm{O}(1)\) 回答。

LCP 可以解决多字符串的匹配问题,如询问多串字符串中长度最大且出现了至少 \(k\) 次的子串。这只需要二分长度 \(len\),每一次按 \(sa\) 顺序考察是否有长度为 \(len\) \(LCP\geq k\) 的序列(使用单调队列)。

后缀自动机

几个参考资料:

  • 陈立杰 WC2015 讲义
  • 2015 年国家集训队论文《后缀自动机及其应用》张天扬
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
namespace SAM{
int ch[N][C],pa[N],len[N],siz[N];
int idx=1,pre=1;

void Insert(int x){
int p=pre,np=++idx;pre=np;
siz[np]=1;len[np]=len[p]+1;
for(;p&&ch[p][x]==0;p=pa[p])ch[p][x]=np;

if(p==0)pa[np]=1;
else{
int q=ch[p][x];
if(len[q]==len[p]+1)pa[np]=q;
else{
int nq=++idx;len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
pa[nq]=pa[q];pa[q]=pa[np]=nq;
for(;p&&ch[p][x]==q;p=pa[p])ch[p][x]=nq;
}
}
}

int tmp[N],topo[N];
void Build(){ //用拓扑关系\mathrm{O}(n)求得每个节点的siz
for(int i=1;i<=idx;i++)tmp[len[i]]++;
for(int i=1;i<=idx;i++)tmp[i]+=tmp[i-1];
for(int i=1;i<=idx;i++)topo[tmp[len[i]]--]=i;
for(int i=idx;i>1;i--){
int v=topo[i];int u=pa[v];
siz[u]+=siz[v];
}

每次插入的 np 都代表了原串的一个前缀,故其 right 设为 1,最后再用拓扑统计一下 siz 即可。

广义后缀自动机

还是没有看懂。待补。

回文串

Manacher

Manacher 算法就是用已得到的回文串条件来简化一些不必要的判断。假如 \(A,B\) 串是回文串,且 \(B\) 串在 \(A\) 串回文中心左侧,则 \(B\) 串关于 \(A\) 的回文中心对称串 \(B'\) 也是回文串。

\(f_i\) 表示从 \(i\) 开始还能向两周扩展多少字符。记录 \(i\) 之前的 \(i+f_i\) 最大值 \(cur\)(也就是当前最远的回文串右端点)与编号 \(idx\),则若 \(i\) \(cur\) 内,长度有可能为 \(i\) 关于 \(idx\) 对称的位置的 \(f\) 值(\(i+f_i\) 完全在 \(idx\) 的回文串内),也有可能为 \(cur-i\)\(i+f_i\) 超出 \(cur\) 部分的不能保证长度一定等于 \(i\) 关于 \(idx\) 对称的 \(f\) 值)。接着就暴力向右匹配,尝试增加 \(f_i\)。因为每一次的暴力匹配只会从 \(cur\) 向右的位置开始匹配(在 \(cur\) 内匹配一次就会失配),而 \(cur\) 会不断向右移动,最终 \(cur=n\)。因此 Manacher 的时间复杂度是 \(\mathrm{O}(n)\) 的。

具体流程:

  1. 初始化 \(f_0=0,cur=0,idx=0\)
  2. 对每一个 i:
    1. \(f_i=min(f_{2*idx-i},cur-i)\)
    2. 尝试扩展 \(f_i\)
  3. 更新 \(cur\)

给出实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int Manacher(){
int len=strlen(tmp);
for(int i=0;i<len;i++)str[i*2+1]='#',str[i*2+2]=tmp[i];
str[len=len*2+1]='#';str[0]='*',str[len+1]='$';

int cur=f[0]=0,idx=0,ans=1; //cur为最远能到达的字符
for(int i=1;i<=len;i++)
{
int& j=f[i];j=0;
if(cur-i>=0&&2*idx-i>=0)j=min(f[2*idx-i],cur-i);
while(str[i-j-1]==str[i+j+1])j++;
if(i+j>cur)cur=i+j,idx=i;
ans=max(ans,(j*2+1)/2);
}
return ans;
}

容易发现 Manacher 的回文是以字符为回文中心的,如果要求字符间为中心需要在所有字符间加一个相同的字符。为了方便匹配,我们设置第一个字符与最后一个字符为两个从未出现的、不同的字符,以此避免一些特判。

一些性质

Manacher 得到的其实是本质不同的回文串(并且数量上是 \(\mathrm{O}(n)\) 级别的)。一个例题:万径人踪灭

回文自动机

几个参考资料:

  • 2017 年国家集训队论文《回文树及其应用》翁文涛
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace PAM{
int ch[N][C],pa[N]={1},len[N]={0,-1},siz[N];
int idx=1,pre=0;

void Insert(char *s,int pos){
int p=pre,x=s[pos]-'a';
for(;s[pos-len[p]-1]!=s[pos];)p=pa[p];
if(ch[p][x]==0){
int q=pa[p],np=++idx;
len[np]=len[p]+2;
for(;s[pos-len[q]-1]!=s[pos];)q=pa[q];
pa[np]=ch[q][x];ch[p][x]=np;
}
pre=ch[p][x];siz[pre]++;
}

ll Build(){
ll ans=0;
for(int i=idx;i>1;i--){
siz[pa[i]]+=siz[i];
ans=max(ans,1LL*siz[i]*len[i]);
}
return ans;
}
};

char s[N];

int main(){
scanf("%s",s+1);s[0]='#';
int n=strlen(s)-1;
for(int i=1;i<=n;i++)
PAM::Insert(s,i);
printf("%lld",PAM::Build());

注意第二次找 \(fail\) 边时如果指向 odd,则需要指向 even,这也解释了为什么需要把 even 放在节点 0(odd 节点不存在转移时可以直接转移到 0)。

一些性质

回文自动机除了 odd 和 even 节点的数目是原串中本质不同的回文串数目,其代表了一个从根节点到该节点的回文子串。

练习

KMP

POJ1961 Period

题意:求前缀串的循环节。

根据 fail 数组的性质,只要处理出 \(fail\) 数组找 Border 即可。

HNOI2008 GT 考试

\(dp_{i,j}\) 表示匹配完 \(i\) 位没出现不吉利数字,且末尾最长能匹配不吉利号码的前 \(j\) 位,则有:

\[dp_{i,j}=\sum (dp_{i-1,k})\quad\text {(去除当前最后一个字符最长能匹配不吉利号码的前 k 位)}\]

发现这个关系就是 KMP 的失配边关系,因此预处理出 fail 数组即可。但是 \(n\) 很大,同时 \(m\) 很小,且转移为线性,因此可以利用矩阵快速幂解决,时间复杂度 \(\mathrm{O}(n\log^3{m})\)

NOI2014 动物园

貌似是倍增。但是不会写。

扩展 KMP

哈?

AC 自动机

JSOI2007 文本生成器

题意:求长度为 \(M\) 的串中包含至少一个 \(N\) 单词字典的单词方案数。

"至少一个" 不容易计算,考虑计算 "一个都不生成",理由是串的总方案数是已知的:\(26^M\)。因此我们只需要求在 fail 树上走 \(M\) 步不经过单词节点的方案数。规模较小,可以采用动态规划。

POJ2778 DNA Sequence

题意:求长度为 \(M\) 的串中不包含 \(N\) 单词字典的单词方案数。

和上题一样,但是 \(M\) 可以很大 \((2\times 10^9)\)。字典单词数和单词长度都较小,因此可以构造矩阵判断是否能转移,矩阵快速幂即可。

TJOI2013 单词

如果直接建树并在文章串上扫一遍极有可能会空间爆炸,因此换个角度思考。

一个串如果包含另一个串,则它们在树上是可以沿着失配边到达的。因此可以在构造 fail 树的时候记录哪些节点 fail 会到达自己,并加上这些节点被到达的次数即可。更具体化,令 \(cnt_u\) \(u\) 节点(单词)的包含次数,则:

\[cnt_u=\sum cnt_v\quad(u\in fail_v)\]

初始化没有被包含的单词出现次数为 \(1\),时间复杂度为 \(\mathrm{O}(n)\)

后缀数组

POJ1743 Musical Theme

题意:寻找长度大于 \(5\) 的、长度最长且不重叠的子串,且子串的差分数组相同。

因此直接在差分数组上匹配 LCP 就好了。二分答案 \(ans\),每次将 \(hei\) 分成 LCP 不大于 \(ans\) 的许多组,并判定是否存在一组的最前出现位置与最后出现位置间隔大于 \(ans\)。由于是在差分数组上处理,有些细节需要注意(如只需要匹配 \(4\) 个)。

USACO2006DecGold Milk Patterns

二分答案 \(ans\),每次将 \(hei\) 分成 LCP 不大于 \(ans\) 的许多组,判定是否存在个数大于 \(k\) 的组。

POJ3294 Life Forms

题意:求至少在一半的字符串中出现的所有最长子串并输出。

和多字符串与 LCP 有关的题目往往需要把所有字符串接起来,中间用不同且未出现过的分隔符隔开。然后就比较套路了:二分长度 \(ans\),每次将 \(hei\) 分成 LCP 不大于 \(ans\) 的许多组,判定是否存在 DNA 来源数至少有一半的组。这个只需要用一个时间戳记录当前所在组编号和各 DNA 串最近一次出现的时间戳,这样就能动态维护当前有几个来源不同的 DNA 串了,顺便记录输出的位置。

POJ3415 Common Substrings

题意:给出 \(A,B\) 串和限制 \(lim\),求满足 \(A_{i,i+k-1}=B_{j,j+k-1}\) 的方案数的三元组 \((i,j,k)\ (k\geq lim)\) 的数量。

好题,但是写起来一点也不优美(我写得太丑了)。不难看出一对 LCP 不小于 \(lim\) 的位置 \(i,j\) 的贡献是 \(LCP_{i,j}-lim+1\),然而我们没办法枚举全部的 \(i\) \(j\),这种时候一般可以维护每次向后移动,前面的字符对答案的贡献。

由于 \(hei\) 的单调性,在扫 \(sa\) 序列的过程中可以用个单调栈维护当前字符到前面所有非自己所在串(即当前字符是 A 串就保存 B 串)的 LCP 和个数,并维护当前总贡献数。每次向后移动时修改,每次是自己串就统计答案。要对两个串分别做一次,记得 long long。

字符串题目中常常出现这种二维匹配计数,此时往往不能直接枚举计数。一般可以枚举 \(LCP_{i,j}\) 再统计对数,或者只枚举一个,另一个通过维护总贡献实现。这个想法在莫比乌斯反演的变换也有体现。

AHOI2013 差异

可以分成两部分 \(\sum_{1\leq i<j\leq n} len_i+len_j\) \(\sum_{1\leq i<j\leq n} len_{LCP_{i,j}}\) 来求。

手推一下可以得到前半部分的答案是 \(\frac {(n-1)n(n+1)}2\),后半部分和上一题类似,维护一个 \(hei\) 的单调栈就可以动态计数了。

JSOI2008 火星人

住口,你根本不是后缀数组!

后缀自动机

TJOI2015 弦论

在 SAM 上统计后续状态数量,进行一次 DFS 即可。

AHOI2013 差异

这个题用 SAM 做就很舒服了。

两个后缀的 \(\mathrm{LCP}(i,j)\) \(i,j\) 代表的后缀在树上的 \(\mathrm{LCA}(i,j)\) \(len\),故在 \(fail\) 树上标记一下原串中的后缀,并且对每个节点都统计一下子树中有多少对节点的 \(\mathrm{LCA}\) 为它即可。

NOI2015 品酒大会

本质上是求 \(\mathrm{LCP}(i,j) = p\) 的数目,就和上一题一样了。

在统计每个节点的时候,顺便合并一下子树里的最大次大、最小次小值(有可能负负得正)。

回文自动机

APIO2014 回文串

PAM 模板题。

总结

字符串算法固然重要,但是理解更为重要(除了后缀数组)。搞清楚 fail 数组的性质会对一些题目有所帮助,尤其是 Trie 树上的 fail 可以是某种动态规划的转移来源。

  • 沿着 KMP 的失配边走可以得到字符递增的循环节,因此可以求周期。

  • 当单词数不多时,如果有需要可以构造矩阵进行转移。

  • 通常和后缀数组有关的题目出现多字符串,需要将其连在一起并用不同且没出现过的字符隔开。

  • 字符串题目中常常出现这种二维匹配计数,此时往往不能直接枚举计数。一般可以枚举 \(LCP_{i,j}\) 再统计对数,或者只枚举一个,另一个通过维护总贡献实现。这个想法在莫比乌斯反演的变换也有体现。

二叉搜索树(Binary Search Tree)是一种二叉树数据结构,其维护了一个支持快速检索的集合。 BST 有很多,如 Treap,Splay,红黑树,Size-Balanced Tree,但是此处我们只讨论 Treap。

Treap

Treap 的节点具有一个 \(val\) 值和 \(key\) 值,\(val\) 值维护这个集合,\(key\) 值维护了 Treap 的平衡。光看 \(val\) 值,Treap 的中序遍历构成一个递增序列;光看 \(key\) 值,Treap 是一个(方便起见,我们默认为小根堆)。其通过旋转来维护形态,影响其形态的因素之一是 \(key\)。因此 \(key\) 一般用随机值,可以证明这样的 Treap 期望高度为 \(\mathrm{O}( \log n )\)

基本操作

Treap 支持 5 种基本操作:

  • 插入 \(\mathrm{O}( \log n )\)
  • 删除 \(\mathrm{O}( \log n )\)
  • 询问某数排名 \(\mathrm{O}( \log n )\)
  • 询问排名为 k 的数 \(\mathrm{O}( \log n )\)
  • 查找某数及其前驱后继 \(\mathrm{O}( \log n )\)

为了方便基本操作,我们定义 Treap 节点如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Node{
int v,r,s,n;
Node *ch[2];

Node(int _v,int _r=rand()){
v=_v;r=_r;n=1;
ch[0]=ch[1]=NULL;
}

inline int Cmp(int x){
if(x==v)return -1;
else return x>v;
}

inline int Size(){
return this==NULL?0:s;
}

inline void Maintain(){
s=n+ch[0]->Size()+ch[1]->Size();
}
};

定义 Treap 如下:

1
2
3
Treap{
Node *rt=NULL;
};

旋转

画个图就不难想象这个过程。o 节点必须用引用而不是指针,因为最后的过程需要把 o 指向 t 所在节点,而非单纯复制 t 节点的信息。

1
2
3
4
5
6
7
void Rotate(Node *&o,int d){
Node *t=o->ch[d^1];
o->ch[d^1]=t->ch[d];
t->ch[d]=o;
o->Maintain();t->Maintain();
o=t;
}

插入

根据 Treap 的中序遍历性质,递归节点找到对应位置插入即可。新节点可能会破坏 Treap 的堆性质,因此需要旋转。如果该节点已被创建,则增加数量。注意需要维护

1
2
3
4
5
6
7
8
9
10
11
12
void Insert(Node *&o,int v){
if(o==NULL)o=new Node(v);
else{
int d=o->Cmp(v);
if(d==-1)(o->n)++;
else{
Insert(o->ch[d],v);
if(o->ch[d]->r < o->r)Rotate(o,d^1);
}
}
o->Maintain();
}

删除

根据 Treap 的中序遍历性质,递归节点找到对应位置删除即可。同样为了维护堆性质,如果删除之后会破坏,则把要删除的节点旋转到儿子节点(此时两个儿子较小的一个被旋转到当前位置)再递归删除即可。如果非空则需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Delete(Node *&o,int v){
int d=o->Cmp(v);
if(d==-1){
if(o->n>1)(o->n)--;
else{
if(o->ch[0]==NULL)o=o->ch[1];
else if(o->ch[1]==NULL)o=o->ch[0];
else{
int d2=o->ch[0]->r < o->ch[1]->r;
Rotate(o,d2);
Delete(o->ch[d2],v);
}
}
}else Delete(o->ch[d],v);
if(o!=NULL)o->Maintain();
}

询问某数排名

根据中序遍历性质,这是一个很容易思考的递归过程。

1
2
3
4
5
6
7
int Rank(Node *o,int v){
if(o==NULL)return 0;
int d=o->Cmp(v),l=o->ch[1]->Size();
if(d==0)return Rank(o->ch[0],v);
else if(d==-1)return l;
else return l+o->n+Rank(o->ch[1],v);
}

注意上面的代码返回值是小于某数的个数而非该数的排名,这是为了方便查找不存在于集合的数。如果要变成集合中的数需要对排名 \(+ 1\)

询问排名为 k 的数

同样是递归过程。

1
2
3
4
5
6
7
int Kth(Node *o,int k){
if(k>o->s)return -1;
int l=o->ch[0]->Size(),n=o->n;
if(k<=l)return Kth(o->ch[0],k);
else if(k<=l+n)return o->v;
else return Kth(o->ch[1],k-l-n);
}

查找

不难实现。$d = 0 $ 时寻找前驱,否则寻找后继。注意写的时候要特别注意寻找前驱时是向大数移动更新,寻找后继的时候是向小数移动更新。

1
2
3
4
5
6
7
8
int Find(int v,int d){
Node *o=rt;
int ret=d?INF:-INF;
while(o!=NULL)
if(d?v<o->v:v>o->v)ret=o->v,o=o->ch[d^1];
else o=o->ch[d];
return ret;
}

扩展操作

  • 启发式合并两棵 Treap\(\mathrm{O}( n \log ( m / n ) )\)

所谓启发式合并,就是把较小的 Treap 节点一个个暴力拆掉插入到较大的 Treap 中。如果较小的有 \(n\) 个节点,较大的有 \(m\) 个节点,则可以证明时间复杂度为 \(\mathrm{O}( n \log ( m / n ) )\)。如果是相对有序,则可以用非旋转式 Treap 维护。相对有序是指较小 Treap 的最大值小于较大 Treap 的最小值,这样只需要首尾相接。

非旋转式 Treap

非旋转 Treap 拥有 Treap 的中序遍历相对有序特性与堆特性,但是不能旋转。由于非旋转式 Treap 不需要旋转,因此能较好地维护节点的父子关系,也可以做到可持久化

基本操作

  • 建树 \(\mathrm{O}( n )\)
  • 合并 \(\mathrm{O}( \log n )\)
  • 分裂 \(\mathrm{O}( \log n )\)

虽然基本操作少,但是一起用却可以做很多事情。

构建

Treap 与笛卡尔树是一样的,因此我们可以以笛卡尔树的线性建树方法建 Treap。

考虑一个已经相对有序的序列(可以是一个递增 / 递减数列,也可以是一段固定的字符串,Treap 的中序遍历维护的正是这些),对于节点 $i (val , key) $,有:

  1. 倒序寻找第一个 \(j < i\) 使得 \(key_j < key_i\)
  2. \(i\) 的左子树设为 \(j\),将 \(j - 1\) 的右子树设为 \(i\)

这可以用栈维护。每个节点只会进出栈一次,因此时间复杂度是线性的。可以这么考虑:栈维护了当前的树的最右链,我们正打算插入节点 \(i\)。但是插入节点 \(i\) 之后可能会破坏堆性质,需要手动 \(key\) 值大于 \(i\) \(key\) 值的树挂在 \(i\) 的左子树来维持小根堆形态,同时又不破坏中序遍历。最后需要把摘下来的子树的父亲节点重设右子树为 \(i\)。实际中为了方便,会添加一个 \(val\) \(key\) 都为 \(\infty\) 的虚拟节点来方便建树。

注意每个节点在出栈之后都不会再被修改儿子,因此我们需要在出栈之后维护该节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void Build(int n){
stack<Node*> s;
rt=new Node(-INF,-INF);
s.push(rt);

for(int i=1;i<=n;i++){
Node *o=new Node(i);
while(s.top()->r > o->r){
o->ch[0]=s.top();
s.top()->Maintain();
s.pop();
}
s.top()->ch[1]=o;
s.push(o);
}
while(!s.empty()){
s.top()->Maintain();
s.pop();
}
rt=rt->ch[1];
}

合并

类似于斜堆,非旋转式 Treap 的合并是一个递归过程。合并的时候需要保证两个 Treap 已经相对有序,只需要中序遍历首尾相接。但是由于不可旋转,因此它不能像斜堆合并一样转来转去,需要特判。流程如下:

  1. 两棵树有一棵为空,则当前新根为另一棵非空子树
  2. 否则如果 \(val _a < val _b\),合并 \(a\) 的右子树和 \(b\),并作为 \(a\) 的新右子树,当前新根为 \(a\)
  3. 否则合并 \(a\) \(b\) 的左子树,并作为 \(b\) 的新左子树,当前新根为 \(b\)

注意合并的顺序。如步骤 2 不能把 \(b\) 的左子树与 \(a\) 合并,这样没有保证合并的两个 Treap 是有序的。合并的过程仍要保持相对有序。同时修改了儿子的节点需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
Node* Merge(Node *a,Node *b){
if(a==NULL)return b;
if(b==NULL)return a;
if(a->r < b->r){
a->ch[1]=Merge(a->ch[1],b);
a->Maintain();
return a;
}else{
b->ch[0]=Merge(a,b->ch[0]);
b->Maintain();
return b;
}
}

分裂

分裂过程仍然是递归,需要返回两个分裂的节点。具体的过程可以画图模拟加深理解。分裂过程让我们把一个区间分成两个,实现了对序列中特定区间的操作,则像是区间翻转之类的操作也可以实现了。算法步骤如下:

  1. 如果该节点为空节点则返回一对空节点
  2. 否则如果分裂的位置在左子树,递归分裂左子树,并将当前根节点左儿子设为分裂的第二个节点,返回这对节点
  3. 否则递归分裂右子树,并将当前根节点右儿子设为分裂的第一个节点,返回这对节点

修改了儿子节点的节点需要维护

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pNode Split(Node *o,int k){
pNode ret(NULL,NULL);
if(o==NULL)return ret;

int l=o->ch[0]->Size();
if(k<=l){
ret=Split(o->ch[0],k);
o->ch[0]=ret.second;
o->Maintain();
ret.second=o;
}else{
ret=Split(o->ch[1],k-l-1);
o->ch[1]=ret.first;
o->Maintain();
ret.first=o;
}
return ret;
}

扩展操作

  • 插入 (Split+Merge )
  • 删除 ( Split +Split +Merge)
  • 查询节点 / 区间 ( Split+Split+ Merge )
  • 查询第 k 大 ( Split +Split+Merge)
  • 区间标记 ( Split+Split+Lazy +Merge)
  • ……

所有的操作都可以把操作区间拆除来再做。不过我们重点考虑区间标记这个操作。

我们把 Treap 当成一个二叉搜索树,却忘记它也是一棵树,也可以进行树的操作。联想一下同为二叉树的线段树,我们也可以把 Treap 当线段树使用。不过更多的,Treap 用来维护与区间翻转有关的序列更多,因为线段树不支持这样的操作。另外,查询区间和、查询最大值这些信息只需要存在节点并一同维护即可,必要的时候可以用 Lazy 标记并 Pushdown

可持久化 Treap

来自非旋转 Treap。其 MergeSplit 都是自上而下的递归操作,并且父子关系不会中途改变,因此我们可以实现可持久化。每次 Split 与 Merge 的时候,都对该节点复制一个新节点,则对这些新节点的操作不影响历史节点。

这里有一个空间优化。对于一般的先 SplitMerge 操作,Merge 影响到的节点是 Split 创建的新节点与将要合并的新节点。这两个节点有个特征,就是它们不会影响其历史版本,因此我们可以直接在 Merge 过程覆盖掉它们,而不需要再为新节点创造新节点。

我认为是,Split 创造的新节点是分裂处的节点,而分裂处一定是中序遍历一段连续的位置,Merge 操作是对结尾一段连续的位置进行修改,因此 Merge 操作的节点是 Split 新开的节点。(但是这个说法我也不能说服我自己。)

虽然不算难构造,但还是放上代码参考。对于新建的节点的时机,我的做法是决定了要处理的节点后,立刻对其进行复制并处理新节点,而不是等到下一层递归。这样会容易考虑和维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Node* Merge(Node *a,Node *b){
if(a==NULL)return new Node(b);
if(b==NULL)return new Node(a);
Node *t=new Node();
if(a->r < b->r){
*t=*a;
t->ch[1]=Merge(t->ch[1],b);
}else{
*t=*b;
t->ch[0]=Merge(a,t->ch[0]);
}
t->Maintain();
return t;
}

pNode Split(Node *o,int k){
pNode ret(NULL,NULL);
if(o==NULL)return ret;

int l=o->ch[0]->Size();
Node *t=new Node();*t=*o;
if(k<=l){
ret=Split(t->ch[0],k);
t->ch[0]=ret.second;
t->Maintain();
ret.second=t;
}else{
ret=Split(t->ch[1],k-l-1);
t->ch[1]=ret.first;
t->Maintain();
ret.first=t;
}
return ret;
}

练习

基础

BZOJ3224 普通平衡树

基础平衡树操作。

NOI2004 郁闷的出纳员

基础平衡树操作。注意一开始没有加入的员工是不计算在内的。

HNOI2002 营业额统计

平衡树维护前驱后继。

HNOI2004 宠物收养所

同一时刻收养所只有人和宠物表示要么人等宠物,要么宠物等人。标记表示现在收养所里是宠物还是人,维护平衡树即可。

进阶

SDOI2008 郁闷的小 J

一个比较直观的在线做法是线段树套平衡树,每个节点放一棵平衡树,区间统计出现次数。当然也有树状数组的离线做法,按照查询书的种类排序后按类处理。如果数据小可以莫队。

ZJOI2007 报表统计

MIN_GAP 就是维护相邻元素差绝对值的最小值,用一个小根堆维护值即可。注意如果添加了新元素,则与前一个元素相关的差值要删除,并添加两对新差值。MIN_SORT_GAP 平衡树维护前驱后继。

HNOI2012 永无乡

Treap 的启发式合并,并查集维护。

BZOJ3223 文艺平衡树

Splay 当然可以,不过非旋转式 Treap 也可以。

BZOJ3196 二逼平衡树

线段树套平衡树。Kth 查询需要二分判定答案,再判定 Rank 是否满足。

NOI2003 文本编辑器

需要区间操作的 Splay 或非旋转式 Treap。注意一次插入的文本可能会很大。

AHOI2006 文本编辑器

NOI2003 文本编辑器,但增加了 Rotate 操作。

BSOJ4531 可持久化平衡树

就是可持久化平衡树。

较难

NOI2005 维护数列

需要很多线段树的操作。注意细节。(但我还没写 Orz)

总结

  • Treap 可以维护集合内部的排名,但是非相对有序时不能高效合并
  • 非旋转 Treap 能在相对有序时高效合并与分裂,可以提取区间单独操作。
  • 非旋转 Treap 每一个子树的中序遍历维护一个相对有序的序列,可以实现区间旋转,也可以像线段树一样维护一段序列的信息
  • 非旋转 Treap 父子形态稳定而不需要旋转,且操作自上而下,可以实现可持久化
  • 多区间查询 Kth 时,通常采用二分答案判定 Rank。

树是一类特殊的图,一棵树是拥有 \(n\) 个节点与 \(n-1\) 条边的无向联通图。树的性质很多,因此关于树的算法也不少。

## 树的直径

即树上最远的点对距离,可以用 DFS 或动态规划在 $\(\mathrm{O}(n)\) 的时间内处理。NOIP 内容,不细讲。

可以证明,每个点在树上的最远点一定是直径的两端。

###DFS

随意从树上一个点 DFS 找到距离最远的点 \(u\),再从 \(u\) 开始 DFS 找到距离最远的点 \(v\),则直径为 \(u\to v\)

### 动态规划

对每个节点记录它能到达最深和次深的距离 \(dep_{i,0},dep_{i,1}\)。考察节点 \(u\) 的每个子树 \(v\)

  • \(dep_{v,0}>dep_{u,0}\),则用 \(u\)最大值更新 \(u\)次大值,用 \(v\)最大值更新 \(u\)最大值
  • 否则若 \(dep_{v,0}>dep_{u,1}\),用 \(v\)最大值更新 \(u\)次大值
  • 否则考察下一个节点

则直径为 \(max_{u\in V}\{dep_{u,0}+dep_{u,1}\}\),初始状态为 \(dep_{u,0}=dep_{u,1}=0\)

## 最近公共祖先

求最近公共祖先(Lowest Common Ancestor)也是有点水。

###RMQ

处理出树的欧拉序,则从 \(u\) \(v\) 的欧拉序中必定经过 \(LCA(u,v)\),且是唯一一个深度最小的节点。用 RMQ 可以 \[\mathrm {O}(n\log {n})$ 预处理,\](1)$ 回答。

### 并查集

这是离线做法,没写过。但是时间复杂度是 $\(\mathrm{O}(n)\) 的。

### 倍增

通过 $\(\mathrm{O}(n\log{n})\) 预处理每个节点 \(u\) \(2^k\) 倍祖先 \(anc_{u,k}\)\(0\) 倍祖先为父节点)。对于每次询问 \((u,v)\) 且假设 \(dep_u\leq dep_v\)

  1. \(dep_u<dep_v\) 时,找到最大的 \(k\) 使得 \(anc_{u,k}\not= v\),并将 \(anc_{u,k}\) 赋给 \(u\),直到 \(dep_u=dep_v\)
  2. \(anc_{u,0}\not=anc_{v,0}\) 时,找到最大的 \(k\) 使得 \(anc_{u,k}\not=anc_{v,k}\),并将 \(anc_{u,k}\) 赋给 \(u\)\(anc_{v,k}\) 赋给 \(v\),直到 \(anc_{u,0}=anc_{v,0}\)
  3. \(LCA(u,v)=anc_{u,0}\)

单次询问时间复杂度 $\(\mathrm{O}(\log{n})\)。第二步前需要特判 \(u\) 是否已经等于 \(v\)

### 树链剖分

大概什么时候会写树剖的总结。树剖的预处理复杂度为 \[\mathrm {O}(n)$,单次询问时间复杂度 \]()$,常数比倍增小。虽然树剖可以求 LCA,但这算是树剖的附属品。

类似倍增,每次移动所在重链的 \(top\) 深度较大的节点到 \(top\) 的父节点,直到 \(u=v\)。需要特判 \(u\) \(v\) 在同一重链上的情况。

## 树的重心

即子树节点数最大值最小的节点。每次都从重心开始进行点分治保证了点分治的层数为 $\(\mathrm{O}(\log{n})\)

两次 DFS(可能爆栈)或三次 BFS 即可解决。

## 树上不相交最长链

在树上选择两条链,使链和距离最大且没有公共边。

如果考虑反悔思想,则变得很容易。对第一次选择的链(直径)上的边权取反,则第二次若取到了相同的边相当于取消选择这条边。两次都取直径保证了距离和最大,而画个图可以发现两次的公共边等价于没有选择,且仍然是两条链。

感觉可以扩展到 \(k\) 条链的情况,但是看起来不一定有 \(k\) 条链(可以拆链成几段充数)。时间复杂度 $\(\mathrm{O}(kn)\)

## 树链剖分

终于到重点了。树链剖分(Heavy-Light Decomposition)就是将树分成不超过 \(\log{n}\) 条不相交的重链的方法。将树剖分后,就可以愉快地在树上使用线段树维护路径了。

剖分可以维护点,也可以维护边。但是都需要维护六个信息:子树大小 \(siz\)、节点深度 \(dep\)、父节点 \(pa\)、重儿子编号 \(son\)、所在重链顶节点 \(top\)、节点在线段树对应编号 \(idx\)。前四个可以在第一次 DFS 内求出,后两个需要在第二次 DFS 内求出。如果采用 BFS 需要三次。

上面这些变量的含义和方法可以在这里找到,此处不做赘述。

以下代码是基于点的树剖,基于边的树剖只需要改动 \(idx\) 就可以实现。 ​

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct HLDcp{
SegTree t; //略去线段树定义
int dep[N],siz[N],pa[N],son[N],top[N],idx[N];
int nIdx;

void Build(){
nIdx=dep[0]=siz[0]=son[0]=0;
DFS1();DFS2();
t.Init(idx);t.Build();
}

void DFS1(int u=1,int pa=0){
dep[u]=dep[HLDcp::pa[u]=pa]+1;
siz[u]=1;son[u]=0;
for(int i=0;i<a[u].size();i++){
int v=a[u][i];
if(v==pa)continue;
DFS1(v,u);
if(siz[v]>siz[son[u]])son[u]=v;
siz[u]+=siz[v];
}
}

void DFS2(int u=1,int pa=0){
idx[u]=++nIdx;top[u]=u;
if(son[pa]==u)top[u]=top[pa];
if(son[u])DFS2(son[u],u);
for(int i=0;i<a[u].size();i++){
int v=a[u][i];
if(v==pa||v==son[u])continue;
DFS2(v,u);
}
}
};
树剖相当于将树映射到某个线性序列上,使得树能使用线性序列的数据结构(如线段树)。树剖的查询过程需要经过不超过 \(\log{n}\)条边,因此如果写线段树,则单次时间复杂度为\[\mathrm {O}(\log^2 {n})$;如果写树套树则为 \](^3{n})\(;如果写带修改区间第 \)k\(大则为 \)\(\mathrm{O}(\log^4{n})\)

## 树分治

树分治可以基于点,也可以基于边。基于点的点分治每次找重心保证了不超过 \(\log{n}\) 层,而边分治十分不稳定。

参考 09 年论文《分治算法在树的路径问题中的应用》漆子超

(因为只看了点分治,就只谈点分治吧。)

### 点分治

感觉没有题目都不懂怎么往下讲

一般用来解决树上点对或路径有关的题目,且结果具有高效可合并性。

#### 树上点对统计

题意:给定 n 个节点的带权无向树,统计树上距离不小于 \(k\) 的点对数。\((n\leq 10^5)\)

最入门的点分治题目。假设我们在某一次分治已经找到重心 \(u\),并对其子树完成了分治。现在考虑怎么合并。

求树上距离我们能想到之前提过的动态规划方法。假设我们已经得到了所有点的深度,那只需要排序后两个指针一前一后扫一次就可以在 \[\mathrm {O}(n)$ 统计了。要得到所有子树的深度可以一次 DFS 获得,时间复杂度也是 \](n)$。

然而我们发现这个方法会把子树中满足条件的点对也计算到经过 \(u\) 节点的满足条件节点对数,那么从结果里减去每个子树满足的点对数就可以了。

### 边分治

哈?

### 链分治

哈?

## 题目

怎么还没讲完就到题目了

### 树型动态规划

####APIO2010 巡逻

添加一条边能让新出现的环总体减少一次经过次数,添加两条边能让两个环不重复的部分减少一次经过次数(重复的部分仍然需要经过两次),并且这个部分恰好是两条链。因此找树上不相交最长链,从总数减去即可。

#### 概率充电器

是个好题。重点有两个:

  • 化正为反
  • 化并为交

具体来说是指对每个节点 \(u\),考察被父节点或子节点充电化为考察既不被父节点又不被子节点充电。因为概率中求交要比求并方便多了。虽然我想到了化正为反,但是我陷入了同时考虑节点自己点亮或被相邻节点点亮的复合情况,并且没有处理好节点间的关系。如果处理好了就不难想到树型 DP 的方程。当然规模太大,需要 BFS。

假设 \(f_u\) 为不被子树和自己充电的概率,\(g_u\) 为不被父亲充电的概率,\(P\) 为父亲不被 \(u\) 之外的点充电的概率,则:

\[ f_u=(1-p_u)\sum_{v\in son} [f_v+(1-f_v)(1-w_{u,v})] \\ P=g_{pa_{pa}}\cdot \frac {f_{pa}}{f_u+(1-f_u)(1-w_{u,pa})} \\ g_u=P+(1-P)(1-w_{u,pa}) \]

即两种情况:充电但连边不通,不充电。

### 树链剖分

####CTSC2008 网络管理

一开始的想法很 naive,就是树剖之后再线段树套 Treap 维护区间第 \(k\) 大。然后复杂度大得要命。估摸着二分答案 + 树剖 + 线段树查询 + 平衡树查询一共 $\(\mathrm{O}(n\log^4{n})\),竟然也能过。

不过正解是只需要主席树。毕竟区间第 \(k\) 大可以很轻松用可持久化线段树维护(需要离散化),然后我们发现这就是一个带修改的可持久化线段树,用树状数组的方式维护 \(n\) 个可持久化线段树并统计就好了。其他做法同上。猜测复杂度二分答案 + 树剖 + 可持久化线段树查询 + 树状数组统计一共 \[\mathrm {O}(n\log^4 {n})$。然而并不是。按照 DFS 序的主席树套树状数组就是 \](n^2{n})$。

####BZOJ2819 Nim

Nim 游戏先手获胜 \(\Leftrightarrow\) 异或不为 \(0\),然后就是树上异或查询了。异或满足交换率(三种位运算符单独考虑都满足结合率、交换率),然后可以线段树,然后可以树剖,然后.. 就被卡 $\(\mathrm{O}(q\log^2{n})\) 了。

可以不用树剖。树上的区间查询相当于欧拉序连续区间查询,每次更新 \(u\) 时只会对 \(u\) 的子树产生影响。因此每次查询 \((u,v)\) 时答案为 \(st_v\ \mathrm{xor}\ ed_u\),每次更新 \(u\) 时更新 \([st_u,ed_u+1]\) 即可。然后复杂度就降到 $\(\mathrm{O}(q\log{n})\) 了。

因此我们得到启发:如果修改只影响子树或祖先,则可以考虑在 DFS 序上用树状数组维护。下一题就是如此。

####BZOJ4765 普通计算姬

(其实不是树剖)

每一次更新节点只会影响祖先节点,而每一次询问却是树上分散点集的和。我们先考虑询问单点的情况,则每一次更新就可以用 DFS 序 + 树状数组在 \[\mathrm {O}(\log {n})​$ 维护前缀和(树剖 \](^2 {n})​$)。然而还有区间询问,不如考虑分块。

维护一个块,统计一个节点及其父节点在块 \(i\) 的总数,则每次更新可以在 \[\mathrm {O}(\sqrt {n})$ 内完成。而对于不是完整块的查询可以直接用上面的方法单点查询。总时间复杂度 \](q(+))$。

因此我们再次得到启发:当单点维护代价高时,区间查询可以考虑用分块维护。

### 树分治

####SPOJ1825 Free Tour II

论文题。

点分治,维护到各个子树中不超过 \(i\) 个黑点 \((i\leq siz_v)\) 的最长路径。我们发现 \(i>siz_v\) 的时候答案不会改变,因此我们可以认为这个状态数就是 \(siz_v\)

论文说合并可以用平衡树轻易实现,然而我没想到怎么维护。

合并的时候对子树大小排序,考察从当前子树 \(v\) 到之前子树经过 \(min(lim,siz_v)\) 个黑色节点的最长路径并合并。合并 \(k\) 个子树的时间复杂度为 \[\mathrm {O}(\sum_{i=1}^k min_{j=1}^i\{siz_j\})=$\mathrm {O}(\sum_{i=1}^k siz_i)=$\mathrm {O}(siz_u)$。因此每层分治时间复杂度为 \](n)\(,对不超过 \)n\(条边的排序时间为 \)\(\mathrm{O}(\log{n})\),因此总时间复杂度为 $\(\mathrm{O}(n\log{n})\)

## 总结

树具有许多性质,利用这些性质可以在树上进行动态规划、剖分和分治。

  • 若给出了一个序列,序列间的关系只与 \(a_{i}\) \(a_{\lfloor i/2 \rfloor} 相关 \),则这个序列的关系可以构成一棵二叉树。

  • 不相交最长链的反悔思想:反权边。这在网络流里也有体现。

  • 树的遍历通常采用 DFS,但数据过大时需要采用 BFS。树的特性保证了其在拓扑序下也可以合并结果。

  • 在一类合并子树的动态规划 / 树分治的过程中,如果时间复杂度和当前已合并的子树大小有关,可以按子树大小顺序合并。这在一些题目中可以降低、保证复杂度。

  • 如果修改只影响子树或祖先,则可以考虑在 DFS 序上用树状数组维护而不是树剖。当单点维护代价高时,区间查询可以考虑用分块维护。

  • 然后我就喵喵喵了 TAT