字符串 笔记
hash
hash 是一个非常简单的东西,原理也很简单,其实就是把一个字符串当作一个 \(p\) 进制的数,当然这个数很大,因为字符串通常很长,所以我们通常对这个数进行大质数的取模,然后没了。
KMP
科技树很浅,但真的不好理解的牛牛玩意儿。
首先,他解决的问题是关于两个字符串 \(s\) 和 \(t\),求出 \(t\) 在 \(s\) 中作为子段出现的位置。
我们设一个数组 \(nxt\),\(nxt_i\) 指的是最长的长度 \(j < i\),使得 \(t\) 的长度为 \(i\) 的前缀中,长度为 \(j\) 的前缀和长度为 \(j\) 的后缀相同。
我们再设一个数组 \(f\),\(f_i\) 指的是长度最长的长度 \(j <= i\),使得 \(t\) 的长度为 \(j\) 的前缀等于 \(s\) 的长度为 \(i\) 的前缀的长度为 \(j\) 的后缀。
事实上,\(nxt\) 和 \(f\) 的设定很像。
考虑如何求出数组 \(nxt\)。
引理及证明
如果 \(j\) 能作为 \(nxt_i\) 的侯选项(即满足条件,但不一定最大),那么小于 \(j\) 的最大的候选项是 \(nxt_j\)。
证明是简单的,直接假设有一个 \(j_0\),使得 \(nxt_j < j_0 < j\),并且 \(j_0\) 是 \(nxt_i\) 的候选项,画图后发现,显然此时 \(j_0\) 也是 \(nxt_j\) 的候选项并且比当前的 \(nxt_j\) 大,所以不存在这样一个 \(j_0\)。
操作
求 \(nxt_i\) 时我们枚举 \(nxt_{i - 1}\) 的所有侯选项(通过引理),记为 \(j\),初始时等于 \(nxt_{i - 1}\),如果 \(a_i\) 和 \(a_j + 1\) 相同,就代表 \(nxt_i\) 可以直接等于 \(j + 1\),否则就是 \(j = nxt_j\),这一步旨在枚举 \(nxt_{i - 1}\) 的侯选项。
求 \(f\) 时与求 \(nxt\) 时很像,可以参考下述代码。当 \(f_i = |t|\) 时,\(s\) 中就出现了一个 \(t\)。
nxt[1] = 0;
for(int i = 2, j = 0; i <= m; i ++){
while(j > 0 && t[i] != t[j + 1])
j = nxt[j];
if(t[i] == t[j + 1])
j ++;
nxt[i] = j;
}
for(int i = 1, j = 0; i <= n; i ++){
while(j > 0 && (j == n || s[i] != t[j + 1]))
j = nxt[j];
if(s[i] == t[j + 1])
j ++;
f[i] = j;
// if(f[i] == m) 此时匹配上了
}
复杂度
主要复杂度来源在于 \(j\),\(j\) 在 for 循环中每次最多增加 \(1\),而在 while 循环中每次最少减少 \(1\),所以关于 \(j\) 的变化是 \(O(n + m)\) 级别的,整体复杂度是 \(O(n + m)\)。
最小循环元
有一个结论,我们考虑一个字符串的每一个前缀 \(s_i\),前缀 \(s_i\) 的最小循环元的长度一定是 \(i - nxt_i\),如果 \(i - nxt_i\) 整除 \(i\) 的话。下文记为 \(len\)。
充分性很好证,直接画一个图,然后把 \(1 \sim len\) 在 \(1 \sim nxt_i\) 这一前缀和在 \(i - nxt_i + 1 \sim i\) 这一后缀上标出来,意味着这两段都等于子串 \(1 \sim len\),这时候我们发现 \(len + 1 \sim len + len\) 也被标为了 \(1 \sim len\),然后一直推下去就可以了。
必要性也很简单,因为当我们有一个长度为 \(len\) 的最小循环元时,发现 \(nxt[i]\) 的值显然是 \(i - len\)。
下述代码枚举了字符串 \(s\) 的所有前缀的最小循环元。
nxt[1] = 0;
for(int i = 2, j = 0; i <= n; i ++){
while(j && s[i] != s[j + 1])
j = nxt[j];
if(s[i] == s[j + 1])
j ++;
nxt[i] = j;
}
for(int i = 2; i <= n; i ++){
if(i % (i - nxt[i]) == 0 && i / (i - nxt[i]) > 1){
printf("%lld %lld\n", i, i / (i - nxt[i]));
}
}
其他循环元的性质
还有一个小结论,就是一个字符串的所有循环元长度必然是最小循环元长度的倍数。这一点在蓝书上没有更多解释和证明,我推出来的是因为假设存在两个循环元 \(l_1\) 和 \(l_2\),那么一定有一个更小的循环元存在并且长度等于 \(\gcd(l_1, l_2)\)。
首先证明当 \(l_1\) 和 \(l_2\) 互质的情况,不失一般性,我们假设 \(l_1 > l_2\),我们知道 \(i / l_1\) 等于 \(l_2\),并且 \(1, \ 1 + l_1, \ 1 + 2 l_1, \cdots , i - l_1 + 1\),这些位置模 \(l_2\) 后恰好等于 \(0,1,2,\cdots,l_2-1\),这时发现所有字母都相等,所以有最小循环元 \(1\)。
当 \(l_1\) 和 \(l_2\) 不互质时,可以把每 \(gcd(l_1, l_2)\) 个字母当成一个整体,这时根据上面的结论,这些整体全部相同,所以存在循环元 \(\gcd(l_1, l_2)\)。
后缀数组
一个很有用的东西,他的基础作用是将一个字符串的后缀们排序,记录两个数组 \(rk\) 和 \(sa\),分别是 \(rk_i\) 表示字符串长度为 \(i\) 的后缀在所有后缀中的排名位置,\(sa_i\) 表示所有后缀中排名为 \(i\) 的后缀的长度。
我们通常先求出来 \(rk\) 数组,然后用 \(rk\) 数组求出来 \(sa\)。
性质: \(rk_{sa[i]} = sa_{rk[i]} = i\)。
rk 数组的求法
利用倍增,假设知道了两个后缀的长度为 \(2^i\) 的前缀的排名,以及 \(2^i + 1 \sim 2^{i + 1}\) 的排名,直接把这两个东西当作第一关键字和第二关键字,利用基数排序即可求出。值得注意的是,基数排序带来了极大的空间压力,如果确保时间允许,可以直接使用 sort 排序,代价是复杂度退化一个 \(\log\)。
注意: 两个关键字都相同时,两个后缀的排名需要相同。
常数优化
- 每次记录最大排名,防止基数排序时过多冗余运算。
- 当最大排名是字符串长度时,所有排名就已经计算完了,接下来的排序就不需要了。
求后缀们的 LCP(最长公共前缀)
我们设数组 \(height\),\(height_i\) 表示后缀 \(sa_i\) 和后缀 \(sa_{i - 1}\) 的 LCP,也就是第 \(i\) 名和第 \(i - 1\) 名的后缀的LCP。
引理
\(height_{rk_i} \ge height_{rk[i - 1]} - 1\)。
我们假设后缀 \(i - 1\) 是 \(aAB\),小写字母表示一个字符,大写字母表示一个字符串。然后我们知道后缀 \(sa_{rk[i - 1] - 1}\) 就是 \(aAC\),其中 \(A\) 的长度加 \(1\) 就是 \(height_{rk[i - 1]}\),由定义得知 \(C < B\)。现在考虑后缀 \(i\),它可以被表示为 \(AB\),然后 \(sa_{rk[i] - 1}\) 是比 \(AB\) 小的最大的后缀,同时必然存在 \(AC < AB\),所以 \(AC \le sa_{rk[i] - 1} < AB\),那么 \(sa_{rk[i] - 1}\) 就存在前缀 \(A\) 了。
求 LCP
利用引理,可以直接求出 \(height\) 数组。
int k = 0;
for(int i = 1; i <= n; i ++){
if(rnk[i] == 1)
continue;
if(k)
k --;
while(a[i + k] == a[sa[rnk[i] - 1] + k])
k ++;
height[rnk[i]] = k;
}
任意两个后缀的 LCP
两个后缀的排名间的 \(height\) 的最小值就是这两个后缀的 LCP,即 \(lcp(i, j) = \min(height_{rk[i] + 1}, height_{rk[i] + 2}, \cdots, height_{rk[j]})\)。
后缀数组的很多应用
LCP 拥有无限潜力,因为后缀们的前缀们就是一个字符串的子串们,所以当涉及到一个字符串内的子串间、多个字符串内的子串间甚至更有扩展性的问题,都可以使用后缀数组 + LCP 通吃。
有一个小技巧,很多关于满足某个条件求最长的子串,都可以想想二分答案,因为这个条件很有可能是满足单调性的,越长的子串越不容易满足条件嘛。
经典应用
对于一些经典常用的后缀数组的应用,可以参考文献 2009 年集训队论文《后缀数组——处理字符串的有力工具》by 罗穗骞 of 华中师范大学附属中学,其中讲的已经很详尽了,我便不过多陈述。
结合其他
- 结合并查集:由于两个后缀的 LCA 是两者排名中间的 \(height\) 的最小值,所以当涉及多个后缀的 LCA 满足某个长度条件(不小于 \(k\))时,可以离线下来,从大往小枚举这个长度条件,此时会有越来越多的 \(height\) 满足这个条件,后缀们也会慢慢联系在一起,可以使用并查集。参考题目:[NOI2015] 品酒大会。
- 结合单调栈:同样因为两个后缀的 LCA 是两者排名中间的 \(height\) 的最小值,单调栈可以做到一些事情。参考题目:[AHOI2013] 差异。
后续再更,上面的题我都是口胡的,等我补一下(