字符串
📚 字符串算法实战与进阶笔记
第一阶:基础回文子串 (Manacher 算法模板)
关联文件:P_3805_模板_Manacher.cpp / mancher.cpp
- 抽象模型:给定一个字符串,求其内部最长的回文子串长度。
- 核心痛点:回文串有奇数长度(中心是字符)和偶数长度(中心是间隙)两种情况,分类讨论非常繁琐。暴力向两边扩展的复杂度高达 $O(n^2)$。
- 破局思路(Manacher 算法):
- 预处理插桩:在字符之间以及字符串首尾插入无关字符(如
#),将所有回文串强行转化为奇数长度。 - 维护“最远保护伞”:维护当前所有已知的回文串中,右边界最远的那个回文串(中心为
j,右边界为j + r[j])。 - 利用对称性加速:当遍历到新的中心
i时,如果i在“保护伞”内部,可以直接找到i关于j的对称点2*j - i。此时r[i]至少等于min(r[2*j - i], 保护伞右边界 - i)。这样跳过了大量不必要的字符比较,将总时间复杂度严格降至 $O(n)$。 - 黄金结论:在插桩后的字符串中,某位置的回文半径减 1(
R - 1),正好等于原字符串中该回文串的实际长度。
点击查看代码
vector<int> manacher(const string &s) {
// 上面的预处理去除奇偶分讨的情况
string t;
t += '#';
for (auto tmp : s) {
t += tmp;
t += '#';
}
int n = t.size();
vector<int> r(n);//维护最远的伞
for (int i = 0, j = 0; i < n; i++) { // j 是最远的伞
// 如果最远的伞包含住了 i 可以更新 注意不能超界
if (j + r[j] > i && 2 * j - i >= 0) {
r[i] = min(r[2 * j - i], j + r[j] - i);
}
// 暴力
while (i - r[i] >= 0 && i + r[i] < n && t[i - r[i]] == t[i + r[i]])
r[i]++;
// 更新最远的伞
if (i + r[i] > j + r[j]) j = i;
}
return r;
}
void solve() {
string s;
cin >> s;
vi r = manacher(s);
int max_len = 0;
int best_center = 0;
for (int i = 0; i < r.size(); i++) {
int len = r[i] - 1;
if (len > max_len) {
max_len = len;
best_center = i;
}
}
cout << max_len;
}
第二阶:区间子串快速比对 (String Hash 模板)
关联文件:字符串哈希.txt
- 抽象模型:在 O(1) 的时间内,快速判断一个序列(或字符串)的任意两个子区间是否完全相同。
- 核心思路(多项式滚动哈希):
- 映射转码:将字符串看作是一个 Base 进制的数字,将其转化为一个巨大的整数,并对某个大质数 $Mod$ 取模。
前缀和思想:预处理出前缀哈希数组 h 和 $Base$ 的次幂数组 p 。查询区间 [l, r] 的哈希值时,利用公式 (h[r] - h[l - 1] * p[r - l + 1] % mod + mod) % mod 即可 。
双哈希防碰撞:为了极大地降低哈希冲突(Hash Collision)的概率,模板采用了双哈希机制。同时使用两套不同的质数模数(1000000007 和 1000000009)进行运算 。
防坑细节:如果对整型数组(如 vector<int>)进行哈希,且数据中存在 0,强烈建议对所有元素加上一个偏移量(offset,例如 +1),防止前导 0 导致不同序列哈希值相同 。
点击查看代码
template<int N>
struct StringHash {
static constexpr int base[2] = {13331, 131};
static constexpr int mod[2] = {1000000007, 1000000009};
std::vector<std::array<int, N> > p, h;
StringHash() = default;
StringHash(const std::string &s) { //如果要用vector<int> 得 改成vector<int> 记得如果 int数据中有0 得 + 1(offset)
int n = s.size() - 1;
p.resize(n + 1);
h.resize(n + 1);
std::fill(p[0].begin(), p[0].end(), 1);
for (int i = 1; i <= n; i++)
for (int j = 0; j < N; j++)
p[i][j] = 1ll * p[i - 1][j] * base[j] % mod[j];
for (int i = 1; i <= n; i++)
for (int j = 0; j < N; j++)
h[i][j] = (1ll * h[i - 1][j] * base[j] + s[i]) % mod[j];
}
std::array<int, N> query(int l, int r) {
assert(r >= l - 1);
std::array<int, N> ans;
if (l > r) return {0, 0};
for (int i = 0; i < N; i++) {
ans[i] = (h[r][i] - 1ll * h[l - 1][i] * p[r - l + 1][i] % mod[i] + mod[i]) % mod[i];
}
return ans;
}
};
第三阶:状态递推与回文结合 (最长双回文串)
关联文件:P_4555_国家集训队_最长双回文串.cpp
- 抽象模型:求一个字符串中最长的“双回文串”长度。双回文串定义为两个非空的回文子串拼接而成(即 S = A + B,且 A, B 均为回文)。
- 核心思路:
- Manacher 提取特征:先跑一遍 Manacher 算法,求出每个位置的最大回文半径
r[i]。 - 维护边界极值:核心在于求出“以某个位置为结尾的最长回文长度
L”和“以某个位置为开头的最长回文长度R”。在遍历 Manacher 的半径时,如果以i为中心,它的左边界就是i - len,右边界就是i + len。利用这个信息更新L[right]和R[left]。 - 状态内推(巧妙的递推):Manacher 求出的是极限半径,但长回文内部一定包含短回文。所以需要进行一次向内的递推覆盖:
L[i-2] = max(L[i-2], L[i]-2)和R[i+2] = max(R[i+2], R[i]-2)。 - 分割点枚举:最后遍历所有合法的分割点(由于插了
#,偶数索引正好代表字符之间的间隙),寻找 max(L[i] + R[i])。
点击查看代码
vector<int> manacher(const string &s) {
// 上面的预处理去除奇偶分讨的情况
string t;
t += '#';
for (auto tmp : s) {
t += tmp;
t += '#';
}
int n = t.size();
vector<int> r(n);
for (int i = 0, j = 0; i < n; i++) { // j 是最远的伞
// 如果最远的伞包含住了 i 可以更新 注意不能超界
if (j + r[j] > i && 2 * j - i >= 0) {
r[i] = min(r[2 * j - i], j + r[j] - i);
}
// 暴力
while (i - r[i] >= 0 && i + r[i] < n && t[i - r[i]] == t[i + r[i]])
r[i]++;
// 更新最远的伞
if (i + r[i] > j + r[j]) j = i;
}
return r;
}
void solve() {
string s;
cin >> s;
vi r = manacher(s);
int n = r.size();
vi L(n), R(n);
for (int i = 0; i < r.size(); i++) {
int len = r[i] - 1;
int left = i - len;
int right = i + len;
L[right] = max(L[right], len);
R[left] = max(R[left], len);
}
for (int i = n - 1; i >= 2; i -= 2) {
L[i - 2] = max(L[i - 2], L[i] - 2);
}
for (int i = 0; i < n - 2; i += 2) {
R[i + 2] = max(R[i + 2], R[i] - 2);
}
int ans = 0;
for (int i = 0; i < n; i += 2) {
if (L[i] > 0 && R[i] > 0) ans = max(ans, L[i] + R[i]);
}
cout << ans << endl;
}
🔴 第四阶:哈希二分与组合逻辑综合 (2227D 变形应用)
关联文件:2227D.cpp
- 抽象模型:判断数组的某个复杂区间组合能否构成回文,寻找最优的分割点,并最终求出该区间的 MEX(未出现的最小非负整数)值。
- 破局思路:
- 抛弃 Manacher,拥抱哈希二分:由于题目涉及对原数组
a和反转数组b进行跨区间的动态回文判定,Manacher 的连续性遭到破坏。此时,“正向 Hash == 反向 Hash” 就成了判断回文的最强武器。 - $O(log N)$ 确定回文半径:通过二分查找回文半径
mid,配合 $O(1)$ 的哈希查询(is_palindrome),可以快速找到以特定分割点为中心的最大回文覆盖范围(find_max_2逻辑)。 - MEX 统计:拿到合法的回文区间
[L, R]后,开一个布尔数组vis记录区间内出现的数字,从 $0$ 开始向上遍历找第一个未被标记的数字,即为 MEX 值。这是典型的将“字符串高级判定(哈希二分)”与“数组基础统计”结合的复合型题目。
点击查看代码
void solve() {
int n;
cin >> n;
vi a(2 * n + 1), b(2 * n + 1);
for (int i = 1; i <= 2 * n; i++) {
cin >> a[i];
}
for (int i = 1; i <= 2 * n; i++) {
b[i] = a[2 * n - i + 1];
}
StringHash<2> Z(a);
StringHash<2> F(b);
auto is_palindrome = [&](int L, int R) { return Z.query(L, R) == F.query(2 * n - R + 1, 2 * n - L + 1); };
int P1 = 0, P2 = 0;
for (int i = 1; i <= 2 * n; i++) {
if (a[i] == 0) {
if (P1 == 0)
P1 = i;
else
P2 = i;
}
}
auto find_max_2 = [&](int sum) -> pii {
int Center_L = sum / 2;
int Center_R = sum - Center_L;
int l = -1;
int r = min(Center_L - 1, 2 * n - Center_R) + 1; // 注意 R 不能越界 否则会RE
while (l + 1 != r) {
int mid = (l + r) >> 1;
if (is_palindrome(Center_L - mid, Center_R + mid))
l = mid;
else
r = mid;
}
return make_pair(Center_L - l, Center_R + l);
};
auto get_mex = [&](int L, int R) {
vector<bool> vis(n + 1, false);
for (int i = L; i <= R; i++) {
int real_val = a[i];
vis[real_val] = true;
}
for (int i = 0; i <= n; i++) {
if (!vis[i]) {
return i;
}
}
return n;
};
int ans = 0;
auto get = [&](int P1) {
auto [L, R] = find_max_2(P1);
if (L <= R) {
ans = max(get_mex(L, R), ans);
}
};
get(P1 + P1);
get(P2 + P2);
get(P1 + P2);
cout << ans << "\n";
}

浙公网安备 33010602011771号