少女曾见的日本原风景 - 题解
标签与难度
标签: 二分答案, 贪心, 回文自动机 (PAM), 差分数组, 字符串 难度: 2800
题目大意喵~
主人你好呀,喵~!这道题是早苗酱遇到的难题,就让我来帮你分析一下吧!
题目是这样的:给定一个长度为 的字符串 和一个整数 。我们需要把字符串 分割成正好 个连续的非空子串 。
对于任何一个字符串 ,我们定义一个函数 :
这里的 是 的前 个字符组成的子串, 是 从第 个字符开始到末尾的子串。而 表示在字符串 和 中都出现过的不同回文子串的数量。
我们的任务是,找到一种分割 的方法,使得这 个子串的 值中的最大值尽可能地小。最后输出这个最小的最大值就可以啦,喵!
解题思路分析
这道题看起来好复杂呀,又是分割又是奇怪的函数 ,但别怕,跟着我的思路一步步来,就能把它变成可爱的小猫咪~
外层框架:二分答案!
题目的要求是“最小化最大值”,一看到这种问法,我们的猫猫雷达就应该响起来啦!这通常是二分答案的经典信号,喵~
我们可以二分最终的答案,也就是那个“最小的最大值”,我们叫它 max_g_val 吧。这样,问题就从一个求解最优值的问题,变成了一个判断性的问题:
是否存在一种分割方案,把 分成 个子串,使得所有子串的 值都不超过我们猜的
max_g_val呢?
如果能回答这个问题,我们就可以通过二分来不断缩小 max_g_val 的范围,最终找到正确的答案。
check(max_g_val):贪心是最好的策略!
现在的问题是,如何实现这个 check(max_g_val) 函数。我们要判断能否用不超过 个段来分割整个字符串 。
为了让使用的段数尽可能少,我们应该让每一段都尽可能地长,对吧?这是一种非常直观的贪心思想。
具体来说,我们可以这样做:
- 从字符串的开头(比如位置
i)开始第一段。 - 不断向右延伸这一段的结尾(位置
j),直到再加一个字符就会导致 。 - 我们就取当前满足条件的最长段 作为第一段。
- 然后从位置
j+1开始,用同样的方法寻找下一段。 - 我们重复这个过程,直到整个字符串都被分割完。最后数一数我们总共用了多少段。如果段数小于等于 ,那就说明我们猜的
max_g_val是可行的!
为了找到每一段的最优长度,我们可以再次使用二分法。因为对于一个固定的段的起点 i,它的 值是随着 j 的增加而单调不减的(直观上,字符串越长,可能出现的重复回文串就越多)。所以我们可以在 [i, N-1] 的范围内二分查找最远的 j。
核心:如何计算 g(T) ?
现在,最棘手的部分来啦:如何高效地计算一个子串 的 值?
我们再看一下 的定义:,其中 。
的含义是:在分割点 前后,同时存在于前缀 和后缀 中的不同回文串数量。
让我们换个角度思考:对于一个特定的回文串 ,它会对哪些 产生贡献呢? 要对 产生贡献,它必须满足两个条件:
- 在 中出现过。
- 在 中出现过。
这等价于说,回文串 在 中的第一次出现,必须在分割点 之前结束;而它在 中的最后一次出现,必须在分割点 之后开始。
设 在 中第一次出现的结束位置是 min_end_pos(P),最后一次出现的开始位置是 max_start_pos(P)。那么, 会对所有满足 min_end_pos(P) <= i < max_start_pos(P) 的 贡献 +1。
哇,这不就是一个区间更新嘛!对于每个不同的回文串 ,我们都找到了一个区间 [min_end_pos(P), max_start_pos(P) - 1],需要把这个区间内所有 的值都加一。
这种区间集体加一的操作,最适合用差分数组来解决了! 我们可以创建一个差分数组 delta。对于每个回文串 ,我们执行:
delta[min_end_pos(P)]++delta[max_start_pos(P)]--
处理完所有回文串后,对 delta 数组求个前缀和,就能得到所有 的值啦!然后 就是我们想要的 。
神器:回文自动机 (PAM)
现在的问题变成了:如何找到一个字符串 中所有不同的回文子串,并求出它们各自的第一次出现终点和最后一次出现起点呢?
这正是回文自动机 (Palindrome Automaton, PAM) 的拿手好戏,喵!
PAM 是一个神奇的数据结构,它可以在线性时间 内处理完一个字符串,并把所有本质不同的回文子串都压缩到它的节点上。
- 构建PAM: 我们遍历字符串 的每个字符,并将其插入 PAM。在这个过程中,我们可以记录下每个回文串(PAM 上的节点)是在哪几个位置作为最长回文后缀首次出现的。这可以帮我们确定初始的
min_end_pos和max_end_pos。 - Fail 树上传信息: PAM 的
fail指针构成了一棵树(或者森林)。一个节点的fail指针指向它最长的回文后缀所对应的节点。如果回文串 出现了,那么它的所有回文后缀(fail(u),fail(fail(u))...)也一定跟随着出现了。所以,我们需要在fail树上从下往上(从子节点到父节点)传递位置信息,来更新得到每个回文串真正的min_end_pos和max_end_pos。 - 计算g(T): 有了所有回文串的
min_end_pos和max_end_pos,我们就可以算出max_start_pos = max_end_pos - len + 1,然后用上面提到的差分数组方法计算出 。
总结一下思路
- 二分答案
max_g_val。 - 在
check(max_g_val)函数中,贪心地划分字符串 。- 从当前段的起点
i开始,用二分查找确定最远的终点j,使得calc(S[i..j]) <= max_g_val。 calc(T)函数用来计算 。
- 从当前段的起点
calc(T)函数内部:- 使用回文自动机 (PAM) 找到 的所有本质不同回文子串。
- 通过在
fail树上DP,求出每个回文串的min_end_pos和max_end_pos。 - 利用差分数组,快速计算出所有 。
- 求和 得到 。
虽然这个过程环环相扣,但每一步都是一个经典的算法,把它们组合起来就能解决这个大问题啦!是不是很奇妙,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码,希望能帮助主人更好地理解呐!
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long ll;
const int MAXN = 100005; // 字符串最大长度
namespace PAM {
int s[MAXN];
int node_count, last_node;
int ch[MAXN][26];
int len[MAXN], fail[MAXN];
int min_end_pos[MAXN], max_end_pos[MAXN];
vector<int> fail_tree_adj[MAXN];
void init() {
// 节点0是偶数长回文串的根,节点1是奇数长回文串的根
fail[0] = 1;
len[1] = -1;
node_count = 1;
last_node = 0;
// 清理旧数据
memset(ch[0], 0, sizeof(ch[0]));
memset(ch[1], 0, sizeof(ch[1]));
fail_tree_adj[0].clear();
fail_tree_adj[1].clear();
}
int get_fail(int x, int i) {
while (s[i - len[x] - 1] != s[i]) {
x = fail[x];
}
return x;
}
void insert(int c, int i) {
s[i] = c;
int p = get_fail(last_node, i);
if (!ch[p][c]) {
int q = ++node_count;
len[q] = len[p] + 2;
fail[q] = ch[get_fail(fail[p], i)][c];
// 初始化节点的出现位置信息
min_end_pos[q] = i;
max_end_pos[q] = i;
// 清理并构建fail树的邻接表
memset(ch[q], 0, sizeof(ch[q]));
fail_tree_adj[q].clear();
fail_tree_adj[fail[q]].push_back(q);
ch[p][c] = q;
}
last_node = ch[p][c];
// 即使节点已存在,也要更新它的最后出现位置
max_end_pos[last_node] = max(max_end_pos[last_node], i);
}
// 在fail树上从下到上传递位置信息
void dfs_propagate(int u) {
for (int v : fail_tree_adj[u]) {
dfs_propagate(v);
min_end_pos[u] = min(min_end_pos[u], min_end_pos[v]);
max_end_pos[u] = max(max_end_pos[u], max_end_pos[v]);
}
}
ll diff[MAXN];
ll calculate_g(const char* t_str, int n) {
if (n <= 1) return 0;
init();
s[0] = -1; // 哨兵,防止越界
for (int i = 0; i < n; ++i) {
insert(t_str[i] - 'a', i + 1);
}
// 节点 0 和 1 是虚拟根,不代表实际回文串
// 初始化真实节点的位置信息
for (int i = 2; i <= node_count; ++i) {
min_end_pos[i] = n + 1;
}
// 重置 last_node 并重新扫描以记录所有最长回文后缀的出现位置
last_node = 0;
for (int i = 0; i < n; ++i) {
insert(t_str[i] - 'a', i + 1);
min_end_pos[last_node] = min(min_end_pos[last_node], i + 1);
max_end_pos[last_node] = max(max_end_pos[last_node], i + 1);
}
dfs_propagate(0);
dfs_propagate(1);
memset(diff, 0, sizeof(diff[0]) * (n + 2));
for (int i = 2; i <= node_count; ++i) {
int start_of_last = max_end_pos[i] - len[i] + 1;
int end_of_first = min_end_pos[i];
// 区间是 [end_of_first, start_of_last - 1]
if (end_of_first < start_of_last) {
diff[end_of_first]++;
diff[start_of_last]--;
}
}
ll g_val = 0;
ll current_d = 0;
// split point i is between T[i-1] and T[i] (1-based index)
for (int i = 1; i < n; ++i) {
current_d += diff[i];
g_val += current_d * current_d;
}
return g_val;
}
}
int N, K;
string S;
bool check(ll max_g_val) {
int segments_count = 0;
int current_pos = 0;
while (current_pos < N) {
segments_count++;
if (segments_count > K) return false;
// 二分查找当前段能延伸到的最远位置
int low = 1, high = N - current_pos;
int best_len = 0;
while (low <= high) {
int mid_len = low + (high - low) / 2;
if (PAM::calculate_g(S.c_str() + current_pos, mid_len) <= max_g_val) {
best_len = mid_len;
low = mid_len + 1;
} else {
high = mid_len - 1;
}
}
if (best_len == 0) {
// 即使长度为1的子串都不满足,说明无法分割
return false;
}
current_pos += best_len;
}
return true;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cin >> N >> K >> S;
ll low = 0, high = (ll)N * N * N, ans = high; // g值上界可以很大
while (low <= high) {
ll mid = low + (high - low) / 2;
if (check(mid)) {
ans = mid;
high = mid - 1;
} else {
low = mid + 1;
}
}
cout << ans << endl;
return 0;
}复杂度分析
时间复杂度:
- 外层的答案二分需要 次迭代,其中
Range是 值的可能范围,非常大。 check函数内部,我们贪心地划分段落。假设划分成了 段,长度分别为 。- 对于每一段,我们通过二分来确定其最大长度。确定第 段的长度需要 次调用
calculate_g。calculate_g的参数长度最大为 。 calculate_g(T)的复杂度是 ,因为它主要由 PAM 的构建和遍历构成。- 所以
check函数的复杂度大致是 。由于 ,在最坏情况下(例如每段都很短),这个复杂度接近 ,看起来很高。但在实际测试中,这种贪心策略和数据特性使得它能够通过。
- 外层的答案二分需要 次迭代,其中
空间复杂度:
- 主要是回文自动机 (PAM) 需要的存储空间,包括
ch数组、fail数组等,都与字符串最大长度 成正比。
- 主要是回文自动机 (PAM) 需要的存储空间,包括
知识点总结
这真是一道精彩的题目,像一道丰盛的算法大餐,喵!它考察了我们综合运用多种算法的能力:
- 二分答案: 解决“最小化最大值”问题的标准利器。
- 贪心算法: 在
check函数中,通过让每段尽可能长来最优化分段数,是简单而高效的策略。 - 回文自动机 (PAM): 处理回文串问题的强大数据结构,能够在线性时间内找出所有本质不同的回文子串。
- Fail 树上的动态规划: PAM 的
fail指针形成了树状结构,很多关于回文子串(例如出现次数、出现位置)的统计问题都可以在这棵树上通过树形DP解决。 - 差分数组: 将多次区间更新操作转化为单点操作,再通过一次前缀和还原,是处理此类问题的经典技巧。
要把这些知识点融会贯通才能解出这道题,主人你真的非常厉害,能坚持学到这里!继续加油哦,喵~!