(v4 更新)0x20 字符串
0x21 字符串 I
哈希
字符串哈希
字符串哈希:字符串哈希要求数据有序。将字符串 \(s\) 视为一个 \(\mathrm{base}\) 进制数,即
其中模数 \(M\) 尽量为大质数,并且尽量对两个大质数模数 \(M_1, M_2\) 各求一次哈希(双哈希)
静态字符串区间哈希:
动态字符串区间哈希:区间哈希值可以合并。
设左右两个区间的长度与哈希值分别为 \((l_1, h_1), (l_2, h_2)\),将它们拼接形成大区间的长度与哈希值为
可以使用线段树动态维护。
卡自然溢出哈希(Hash Killer I):
\(\mathrm{base}\) 为偶数:此时 \(\mathrm{base}^{64} \equiv 0 \pmod{2^{64}}\)。所以两个长度 \(> 64\),且后 \(64\) 位相同的不完全相同字符串就会哈希冲突。
\(\mathrm{base}\) 为奇数:使用 Thue Morse 序列攻击。对于二进制串 \(s\),设 \(\overline{s}\) 表示 \(s\) 按位取反后得到的串。
Thue Morse 序列:\(t_0 = \mathtt{0}\),\(t_i = t_{i - 1} + \overline{t_{i - 1}}\)。此时
记 \(h_i = f(t_i) - f(\overline{t_i})\),有 \(h_i = \left(\mathrm{base}^{2^{i - 1}} - 1\right)h_{i - 1}\)。由于
其中每一项都是偶数,所以 \(2^i \mid \mathrm{base}^{2^{i - 1}} - 1\)。于是 \(2^{\frac{i(i + 1)}{2}} \mid h_i\),因此 \(t_{11}\) 与 \(\overline{t_{11}}\) 就会哈希冲突。
Thue Morse 序列的规律:第 \(x\) 位上的值等于 \(\mathrm{popcount}(x) \bmod 2\)。
卡大质数哈希(Hash Killer II):使用生日悖论攻击。
集合哈希
集合哈希:集合哈希要求数据无序。将集合的元素通过某种映射规则转化后,再通过某种具有交换律的运算结合。
可用的映射规则:以打乱原来可能有的某些性质。
- 查表映射:将原数值 \(x\) 随机赋一个新数值 \(c_x\) 并存入表中。
- 位运算映射:将原数值 \(x\) 经过 \(\boldsymbol{\mathrm{xorshift}}\) 得到一个新数值。
可用的结合方式:
- 使用加法结合:
- 使用异或结合(异或哈希):此时出现偶数次元素无贡献,出现奇数次元素只贡献一次。
异或哈希判完全平方数:定义完全异或性函数 \(f\),对于任意正整数 \(x, y\) 都有 \(f(xy) = f(x) \oplus f(y)\)。
先对 \(1 \sim n\) 进行一次线性筛。给其中的所有质数 \(p\) 对应的 \(f(p)\) 赋一个范围较大的随机数,其余合数的 \(f\) 值通过线性筛得出。
此时 \(x\) 为完全平方数,当且仅当 \(f(x) = 0\)。
当待判断的数,包含的质因子在 \(1 \sim n\) 时,就可以根据所有质因子的 \(f\) 函数的异或和,求出该数的 \(f\) 函数。
kmp
0x21 kmp.cpp:
// net[i] : s[1:i] 真前缀与真后缀匹配的最大长度(s[1:i] 的 border)
auto kmp(const auto &s) {
int n = s.size() - 1; // 下标从 1 开始
std::vector<int> net(n + 1);
net[1] = 0;
for (int i = 2, j = 0; i <= n; i ++) {
while (j > 0 && s[j + 1] != s[i]) j = net[j];
if (s[j + 1] == s[i]) j ++;
net[i] = j;
}
return net;
}
// f[i] : t[1:i] 后缀与 s 前缀匹配的最大长度
auto kmp_match(const auto &s, const auto &t, const auto &net) {
int n = s.size() - 1, m = t.size() - 1; // 下标从 1 开始
std::vector<int> f(m + 1);
for (int i = 1, j = 0; i <= m; i ++) {
while (j > 0 && (j == n || s[j + 1] != t[i])) j = net[j];
if (s[j + 1] == t[i]) j ++;
f[i] = j;
}
return f;
}
Z Algorithm
0x21 Z-Algorithm.cpp:
// Z[i] : lcp(s, s[i:n])
auto Zalgo(const auto &s) {
int n = s.size() - 1; // 下标从 1 开始
std::vector<int> Z(n + 1);
Z[1] = n;
for (int i = 2, l = 0, r = 0; i <= n; i ++) {
Z[i] = i <= r ? std::min(Z[i - l + 1], r - i + 1) : 0;
while (i + Z[i] <= n && s[1 + Z[i]] == s[i + Z[i]]) {
Z[i] ++;
}
if (i + Z[i] - 1 > r) {
l = i, r = i + Z[i] - 1;
}
}
return Z;
}
// p[i] : lcp(s, t[i:m])
auto Zalgo_match(const auto &s, const auto &t, const auto &Z) {
int m = t.size() - 1; // 下标从 1 开始
std::vector<int> p(m + 1);
for (int i = 1, l = 0, r = 0; i <= m; i ++) {
p[i] = i <= r ? std::min(Z[i - l + 1], r - i + 1) : 0;
while (i + p[i] <= m && s[1 + p[i]] == t[i + p[i]]) {
p[i] ++;
}
if (i + p[i] - 1 > r) {
l = i, r = i + p[i] - 1;
}
}
return p;
}
manacher
0x21 manacher.cpp:
// 初始化为形如 #a#b#c# 的字符串
auto manacher_init(const std::string &s) {
std::string t = " #";
for (char ch : s) {
t += ch, t += "#";
}
return t;
}
// p[i] : 以 i 为中心的回文半径
auto manacher(const auto &s) {
int n = s.size() - 1; // 下标从 1 开始
std::vector<int> p(n + 1);
p[1] = 1;
for (int i = 2, k = 1; i <= n; i ++) {
if (k + p[k] - 1 < i) {
p[i] = 1;
} else {
p[i] = std::min(k + p[k] - i, p[k * 2 - i]);
}
while (i - p[i] > 0 && i + p[i] <= n && s[i - p[i]] == s[i + p[i]]) {
p[i] ++;
}
if (i + p[i] > k + p[k]) {
k = i;
}
}
return p;
}
// 判断区间 [l, r] 是否回文(转换成 #a#b#c# 形式后):p[l + r] - 1 >= r - l + 1
最小表示法
0x21 最小表示法.cpp:
// std::rotate(s.begin(), s.begin() + minrep(s), s.end())
int minrep(const auto &s) {
int n = s.size(), k = 0, i = 0, j = 1;
while (k < n && i < n && j < n) {
if (s[(i + k) % n] == s[(j + k) % n]) {
k ++;
} else {
s[(i + k) % n] > s[(j + k) % n] ? i += k + 1 : j += k + 1;
if (i == j) i ++;
k = 0;
}
}
return std::min(i, j);
}
0x22 字符串 II
Trie
神功早已大成!日后补全功法。
AC 自动机
神功早已大成!日后补全功法。
SA
0x22 SA.cpp:
// 后缀数组
namespace SA {
int m;
int sa[N], rk[N], height[N];
int cnt[N], id[N], px[N];
int tmp_rk[N];
bool same(int x, int y, int k) {
int p = x + k <= n ? tmp_rk[x + k] : -1,
q = y + k <= n ? tmp_rk[y + k] : -1;
return tmp_rk[x] == tmp_rk[y] && p == q;
}
void build() { // 注意特判 n = 1
m = 256;
for (int i = 1; i <= n; i ++) rk[i] = s[i];
for (int i = 0; i <= m; i ++) cnt[i] = 0;
for (int i = 1; i <= n; i ++) cnt[rk[i]] ++;
for (int i = 1; i <= m; i ++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i --) sa[cnt[rk[i]] --] = i;
for (int k = 1, p = 0; k < n; k <<= 1, m = p) {
p = 0;
for (int i = n - k + 1; i <= n; i ++) id[++ p] = i;
for (int i = 1; i <= n; i ++)
if (sa[i] > k) id[++ p] = sa[i] - k;
for (int i = 0; i <= m; i ++) cnt[i] = 0;
for (int i = 1; i <= n; i ++) cnt[px[i] = rk[id[i]]] ++;
for (int i = 1; i <= m; i ++) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; i --) sa[cnt[px[i]] --] = id[i];
for (int i = 1; i <= n; i ++) tmp_rk[i] = rk[i];
p = 0;
for (int i = 1; i <= n; i ++) rk[sa[i]] = same(sa[i - 1], sa[i], k) ? p : ++ p;
}
for (int i = 1, h = 0; i <= n; i ++) {
if (h) h --;
while (s[i + h] == s[sa[rk[i] - 1] + h]) h ++;
height[rk[i]] = h;
}
}
}
height 数组:
height 数组求后缀 LCP:后缀 \(\mathrm{sa}_i, \mathrm{sa}_j(i < j)\) 的最长公共前缀为
SAM
SAM:SAM 是一个状态自动机,形态为 DAG。有一个起点,若干个终点。
原串的所有子串,与 SAM 上从起点开始,以任意点结束的所有路径一一对应,不重不漏。
SAM 边的类型:SAM 主要考虑两种边。
- 普通转移边:表示在当前状态所表示的串后面添加一个字符。
- 后缀链接 \(\mathrm{link}_i\):表示将当前状态所表示的最短串的首字母删除。这类边构成一棵树,称作 parent 树(不一定满足 \(\mathrm{link}_i < i\))。
SAM 点数:点数 \(\leq 2n - 1\)。
SAM 边数:边数 \(\leq 3m - 4\)。
endpos 集合:子串 \(s\) 的所有终止位置的集合,记做 \(\mathrm{endpos}(s)\)。
- SAM 中的每一个状态,都对应一个 endpos 等价类。
- SAM 中的每一个状态(endpos 等价类),包含的子串长度构成一个连续段,且短串是长串的后缀。
- 若 \(s_1\) 为 \(s_2\) 的后缀,则 \(\mathrm{endpos}(s_1) \supseteq \mathrm{endpos}(s_2)\);否则 \(\mathrm{endpos}(s_1) \bigcap \mathrm{endpos}(s_2) = \varnothing\)。
endpos 集合的维护:在 parent 树上使用可持久化线段树合并维护。
由于在 parent 树上,节点 \(p\) 的 endpos 集合为 \(\mathrm{subtree}(p)\) 中所有节点 endpos 集合的并集。为此,我们在 \(s[1 : i]\) 对应状态的 endpos 集合里插入位置 \(i\),然后使用数据结构合并子树的 endpos 集合。
0x22 SAM.cpp:
// 后缀自动机
namespace SAM {
const int SIZE = N * 2;
int nodeCount, Last;
struct node {
int trans[26];
int link, maxl;
} t[SIZE];
int create() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = 0;
t[p].link = t[p].maxl = 0;
return p;
}
void init() {
nodeCount = 0, Last = create(); // 根节点为 1
}
void extend(int c) {
int p = Last, np = Last = create();
t[np].maxl = t[p].maxl + 1;
for (; p && t[p].trans[c] == 0; p = t[p].link) {
t[p].trans[c] = np;
}
if (!p) {
t[np].link = 1;
} else {
int q = t[p].trans[c];
if (t[q].maxl == t[p].maxl + 1) {
t[np].link = q;
} else {
int nq = create();
t[nq] = t[q], t[nq].maxl = t[p].maxl + 1;
t[np].link = t[q].link = nq;
for (; p && t[p].trans[c] == q; p = t[p].link) {
t[p].trans[c] = nq;
}
}
}
}
}
SAM 子串定位(求 \(s[l : r]\) 在 SAM 中的对应状态):
先定位 \(s[1 : r]\) 在 SAM 中的对应状态(在构造 SAM 的过程中记录一下),然后在 parent 树上倍增,找到深度最浅的满足 \(\mathrm{maxl} \geq r - l + 1\) 的状态。
SAM 子串匹配(给定模式串 \(S\) 与文本串 \(T\),对每个 \(r\) 求最小的 \(l\) 使得 \(T[l : r]\) 为 \(S\) 的子串):
先对 \(S\) 建出 SAM。采用增量法,令 \(r\) 从左往右扫。维护当前匹配到的子串长度 \(\mathrm{len}\),以及在 SAM 上的对应状态 \(p\)。
当 \(r\) 扩展时,不断地跳后缀链接(这里令 \(\mathrm{len}\) 变为对应状态的 \(\mathrm{maxl}\)),直到跳到根节点或存在相应的转移边为止。若存在相应的转移边,则走该转移边(这里令 \(\mathrm{len} \gets \mathrm{len} + 1\))。
int p = 1, len = 0; // 当前匹配到的状态,以及长度
for (int i = 1; i <= m; i ++) {
int c = T[i] - 'a';
while (p > 1 && !SAM::t[p].trans[c]) {
p = SAM::t[p].link, len = SAM::t[p].maxl;
}
if (SAM::t[p].trans[c]) {
p = SAM::t[p].trans[c], len ++;
}
// 此时的 p 与 len,就是以 i 为结尾时匹配到的最长状态,以及对应的长度
}
子 SAM(给定模式串 \(S\) 与文本串 \(T\),让 \(T\) 在 \(S[l : r]\) 中进行匹配):
判断由 \(S[l : r]\) 组成的子 SAM 是否存在相应的转移边,首先在原 SAM 就要存在相应的转移边,并且原 SAM 中新状态的 endpos 集合在区间 \([l + \mathrm{len}' - 1, r]\) 中至少要有一个元素。
失配时,应先尝试 \(\mathrm{len} \gets \mathrm{len} - 1\) 然后继续匹配,而非直接跳失配链接。
SAM 前端删除(在一个状态的前面删除字符):
若长度等于当前状态的 \(\mathrm{minl}\),则跳后缀链接。
SAM 前端插入(在一个状态的前面添加字符):
维护每个状态 \(u\) 对应 endpos 集合的任意一个元素 \(\mathrm{pos}_u\),就可以做到在原串中定位状态 \(u\)。
- 若长度等于当前状态的 \(\mathrm{maxl}\):则相当于在 parent 树上向某一个儿子走。在构建 parent 树的时候,预处理每个状态的极长串前加一个字符 \(c\),能走到哪个儿子即可(枚举 \(u\) 以及其儿子 \(v\),根据 \(\mathrm{pos}_v\) 在原串中定位,即可得知从 \(u\) 到 \(v\) 需要添加什么字符)。
- 若长度小于当前状态的 \(\mathrm{maxl}\):相当于要考虑当前状态是否能够容纳新串,根据 \(\mathrm{pos}_u\) 在原串中定位,判断新加的字符是否与原串对应位置上的字符匹配。
0x22 SAM 前端操作.cpp:
// 后缀自动机(前端操作)
namespace SAM {
const int SIZE = N * 2;
int strL, str[N];
int nodeCount, Last;
struct node {
int trans[26];
int link, maxl, pos;
} t[SIZE];
std::vector<int> son[SIZE];
int net[SIZE][26];
int create() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = net[p][c] = 0;
t[p].link = t[p].maxl = t[p].pos = 0;
son[p].clear();
return p;
}
void init() {
nodeCount = 0, Last = create(); // 根节点为 1
strL = 0;
}
void extend(int c) {
int p = Last, np = Last = create();
str[++ strL] = c, t[np].pos = strL;
t[np].maxl = t[p].maxl + 1;
for (; p && t[p].trans[c] == 0; p = t[p].link) {
t[p].trans[c] = np;
}
if (!p) {
t[np].link = 1;
} else {
int q = t[p].trans[c];
if (t[q].maxl == t[p].maxl + 1) {
t[np].link = q;
} else {
int nq = create();
t[nq] = t[q], t[nq].maxl = t[p].maxl + 1;
t[np].link = t[q].link = nq;
for (; p && t[p].trans[c] == q; p = t[p].link) {
t[p].trans[c] = nq;
}
}
}
}
void dfs_init(int u) {
for (int v : son[u]) {
net[u][str[t[v].pos - t[u].maxl]] = v;
dfs_init(v);
}
}
void build_tree() {
for (int i = 2; i <= nodeCount; i ++) {
son[t[i].link].push_back(i);
}
dfs_init(1);
}
void walk_front(int &p, int &len, int c) {
if (len == t[p].maxl) {
p = net[p][c];
} else if (str[t[p].pos - len] != c) {
p = 0;
}
len = p ? len + 1 : 0;
}
}
SAM 补节点(在 parent 树中,补充询问对应的状态):
parent 树是反串的后缀树,而后缀树又是后缀 trie 虚树化(仅保留所有后缀在后缀 trie 上的终止节点,以及两两 LCA)。
由于询问串 \(s[l : r]\) 在 SAM 上的状态可能不完全覆盖 \(\mathrm{minl} \sim \mathrm{maxl}\)(为该区间的一个前缀),于是我们离线将所有询问串在 SAM 上的状态找出来,然后将状态划分,建立有关询问串的虚拟节点(补充有关询问串的虚树结构)。此时所有询问串一定完整地覆盖了从当前状态到根的路径,且总节点数不超过 \(2n + m\)。
简化了 parent 树的结构,降低了讨论难度。
广义 SAM
0x22 离线广义 SAM.cpp:
// (离线)广义 SAM
namespace GSAM {
const int SIZE = N * 2;
int trieCount, trie[N][26], site[N];
int nodeCount;
struct node {
int trans[26];
int link, maxl;
} t[SIZE];
int newtrie() {
int p = ++ trieCount;
for (int c = 0; c < 26; c ++) trie[p][c] = 0;
site[p] = 0;
return p;
}
int newnode() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = 0;
t[p].link = t[p].maxl = 0;
return p;
}
void init() {
trieCount = 0, newtrie(); // trie 根节点为 1
nodeCount = 0, newnode(); // GSAM 根节点为 1
}
void insert(const std::string &s) {
int p = 1;
for (char ch : s) {
int v = ch - 'a';
if (!trie[p][v]) trie[p][v] = newtrie();
p = trie[p][v];
}
}
int extend(int Last, int c) {
int p = Last, np = newnode();
t[np].maxl = t[p].maxl + 1;
for (; p && t[p].trans[c] == 0; p = t[p].link) {
t[p].trans[c] = np;
}
if (!p) {
t[np].link = 1;
} else {
int q = t[p].trans[c];
if (t[q].maxl == t[p].maxl + 1) {
t[np].link = q;
} else {
int nq = newnode();
t[nq] = t[q], t[nq].maxl = t[p].maxl + 1;
t[np].link = t[q].link = nq;
for (; p && t[p].trans[c] == q; p = t[p].link) {
t[p].trans[c] = nq;
}
}
}
return np;
}
void build() {
std::queue<int> q;
q.push(1), site[1] = 1;
while (q.size()) {
int u = q.front(); q.pop();
for (int i = 0; i < 26; i ++) {
if (trie[u][i]) {
site[trie[u][i]] = extend(site[u], i);
q.push(trie[u][i]);
}
}
}
}
}
0x22 在线广义 SAM.cpp:
// (在线)广义 SAM
namespace GSAM {
const int SIZE = N * 2;
int nodeCount, Last;
struct node {
int trans[26];
int link, maxl;
} t[SIZE];
int create() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = 0;
t[p].link = t[p].maxl = 0;
return p;
}
void init() {
nodeCount = 0, Last = create(); // GSAM 根节点为 1
}
void extend(int c) {
int p = Last;
if (t[p].trans[c]) {
int q = t[p].trans[c];
if (t[q].maxl == t[p].maxl + 1) {
Last = q;
} else {
int nq = create();
t[nq] = t[q], t[nq].maxl = t[p].maxl + 1;
t[q].link = nq;
for (; p && t[p].trans[c] == q; p = t[p].link) {
t[p].trans[c] = nq;
}
Last = nq;
}
} else {
int np = Last = create();
t[np].maxl = t[p].maxl + 1;
for(; p && t[p].trans[c] == 0; p = t[p].link) {
t[p].trans[c] = np;
}
if (!p) {
t[np].link = 1;
} else {
int q = t[p].trans[c];
if (t[q].maxl == t[p].maxl + 1) {
t[np].link = q;
} else {
int nq = create();
t[nq] = t[q], t[nq].maxl = t[p].maxl + 1;
t[np].link = t[q].link = nq;
for (; p && t[p].trans[c] == q; p = t[p].link) {
t[p].trans[c] = nq;
}
}
}
}
}
}
// 在插入一个串之前,需要令 GSAM::Last = 1
PAM
PAM:PAM 是一个状态自动机,形态为由两棵树构成的森林。有两个起点(偶根为 \(0\),奇根为 \(1\)),若干个终点。
原串的所有回文子串,与 PAM 上的所有状态一一对应,不重不漏。
PAM 边的类型:
- 普通转移边:表示在当前状态所表示的串前后各加一个字符。
- 后缀链接 \(\mathrm{link}_i\):表示当前状态所表示的串的最长回文后缀。这类边构成一棵树,称作 parent 树。
偶根的 link 指向奇根,而我们不关心奇根的 link(因为奇根不可能失配,奇根转移出的下一个状态是单个字符,必定为回文串)。
PAM 点数(本质不同回文子串个数):点数 \(\leq n\)。
因为在任何串后面加一个字符,运用反证法可以得知新增的本质不同回文子串个数至多增加 \(1\)。
0x22 PAM.cpp:
// 回文自动机
namespace PAM {
int strL, str[N];
int nodeCount, Last;
struct node {
int trans[26];
int link, len;
} t[N];
int create() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = 0;
t[p].link = t[p].len = 0;
return p;
}
void init() {
str[strL = 0] = -1;
nodeCount = -1, Last = create(), Last = create(); // 偶根为 0,奇根为 1
t[0].len = 0, t[0].link = 1;
t[1].len = -1;
}
int find(int p) {
while (str[strL - t[p].len - 1] != str[strL]) {
p = t[p].link;
}
return p;
}
void extend(int c) {
str[++ strL] = c;
int p = find(Last);
if (!t[p].trans[c]) {
int np = create();
t[np].len = t[p].len + 2;
t[np].link = t[find(t[p].link)].trans[c];
t[p].trans[c] = np;
}
Last = t[p].trans[c];
}
}
PAM 的 half 指针:表示当前状态所表示的串,长度小于等于该串一半的最长回文后缀。
0x22 PAM half.cpp:
// 回文自动机(half 指针)
namespace PAM {
int strL, str[N];
int nodeCount, Last;
struct node {
int trans[26];
int link, half, len;
} t[N];
int create() {
int p = ++ nodeCount;
for (int c = 0; c < 26; c ++) t[p].trans[c] = 0;
t[p].link = t[p].half = t[p].len = 0;
return p;
}
void init() {
str[strL = 0] = -1;
nodeCount = -1, Last = create(), Last = create(); // 偶根为 0,奇根为 1
t[0].len = 0, t[0].link = 1;
t[1].len = -1;
}
int find(int p) {
while (str[strL - t[p].len - 1] != str[strL]) {
p = t[p].link;
}
return p;
}
int find_half(int p, int L) {
while (t[p].len + 2 > L || str[strL - t[p].len - 1] != str[strL]) {
p = t[p].link;
}
return p;
}
void extend(int c) {
str[++ strL] = c;
int p = find(Last);
if (!t[p].trans[c]) {
int np = create();
t[np].len = t[p].len + 2;
t[np].link = t[find(t[p].link)].trans[c];
t[np].half = t[np].len > 2 ? t[find_half(t[p].half, t[np].len / 2)].trans[c] : t[np].link;
t[p].trans[c] = np;
}
Last = t[p].trans[c];
}
}

浙公网安备 33010602011771号