多校第一场的字符串题,对于我这样入门没多久的字符串选手稍稍有点困难,不过靠着官方题解和标程,终于算是完全补掉了这个题,AC的时候发现只有270个人补了这个题。用到了诸多后缀自动姬机的重要性质。做了这个题之后,加深了我对后缀自动机的认识,因此在此写一篇题解加深一下印象,也算记个笔记。
没有学习过后缀自动机的同学请先学习后缀自动机,再食用以下内容~
给出一个字符串,问将它打印出来最少需要多少花费,有两种收费机制:
题目在此
比赛的时候都没有看过这个题(因为自己实在太菜了,一直在搞其他的题,嘤嘤嘤~)。赛后补题时负责的是字符串这块,因此拿到了这个题。刚开始以为是简单的贪心:
一直维护一个可匹配的串,然后遇到一个不能匹配的字符时,直接结算一下之前的字符串的钱,然后再从这个不能匹配的字符重新开始匹配。
但是事实是简单贪心的思路是有问题的。比如aaaaaa
这个串,在p=9,q=10的情况下如果贪心,就会花费38元:
a | a | a | a | a | a |
---|---|---|---|---|---|
9 | 18 | 27 | 28 | 37 | 38 |
因此我们需要用DP思想来解决这个最优问题:
设置ans[i]数组来维护打印 i 位置及之前的字符串所需的最小花费;
假设 str[j - i](表示str从 j 位置开始到 i 位置的子串)是str[0 - j)(表示str直到 j 位置前一位的前缀)的一个子串,那么对于任意 x ∈ [j, i],ans[x] = min(ans[x - 1] + p, ans[j - 1] + q)。如果用这种方法计算上个字符串的最小花费:
a | a | a | a | a | a |
---|---|---|---|---|---|
9 | 18 | 27 | 28 | 37 | 37 |
全部打印完便宜了1元!
确定了DP思路之后,我们需要寻找一个合适的数据结构来进行子串匹配以及失配转移,此时后缀自动姬就派上用场了。她能够为我们存储字符串前缀,同时能够在O(m)的复杂度内确定一个串是否为匹配串,最最重要的是,她内置的 失配指针 会在砍时间复杂度上做出巨大贡献。
为什么需要在匹配时转移匹配节点到父节点的位置呢?我们主要是想要知道当前的匹配串是否能够在原串找到匹配,如果直接把这个匹配串放进自动机跑一遍,那么复杂度为 O(m),如果每个位置都需要做一遍这个操作,那么复杂度高达 O(nm),m最差情况接近于n,因此复杂度近似为 O(n2),题目所给的字符串最长有 100000 个字符,直接TLE。而进行失配转移的话,所有匹配串在原串匹配的总复杂度为 O(n),那么对于单个匹配串来说,失配转移近似达到了 O(1) 的匹配效率,总体复杂度也降为了 O(n)。后缀自动姬非常给力!
#include
using namespace std;
typedef long long ll;
const int CHAR_NUM = 26;
#ifdef ACM_LOCAL
const int NUM = 410000;
#else
const int NUM = 410000;
#endif
struct SAM {
int index = 1;
int len[NUM * 2]; //最长子串的长度(该节点子串数量=len[x]-len[link[x]])
int fa[NUM * 2]; //后缀链接(最短串前部减少一个字符所到达的状态)
int ch[NUM * 2][CHAR_NUM]; //状态转移(尾部加一个字符的下一个状态)(图)
int tot; //结点编号
int last; //最后结点
/**
* 初始化
*/
void init() {
index = 1;
for (int i = 1; i <= tot; i++)
fa[i] = len[i] = 0, memset(ch[i], 0, sizeof(ch[i]));
last = tot = 1; //1表示root起始点 空集
}
/**
* 将字符c添加进自动机
* @param c 目标字符
*/
void extend(int c) { //插入字符,为字符ascll码值
c -= 'a';
int x = ++tot; //创建一个新结点x;
len[x] = len[last] + 1; // 长度等于最后一个结点+1
//num[x] = 1; //接受结点子串除后缀连接还需加一
int p; //第一个有C转移的结点;
for (p = last; p && !ch[p][c]; p = fa[p])
ch[p][c] = x;//沿着后缀连接 将所有没有字符c转移的节点直接指向新结点
if (!p)fa[x] = 1;
else {
int q = ch[p][c]; //p通过c转移到的结点
if (len[p] + 1 == len[q]) //pq是连续的
fa[x] = q;
else {
int nq = ++tot; //不连续 需要复制一份q结点
len[nq] = len[p] + 1; //令nq与p连续
fa[nq] = fa[q]; //因后面link[q]改变此处不加cnt
memcpy(ch[nq], ch[q], sizeof(ch[q])); //复制q的信息给nq
for (; p && ch[p][c] == q; p = fa[p])
ch[p][c] = nq; //沿着后缀连接 将所有通过c转移为q的改为nq
fa[q] = fa[x] = nq; //将x和q后缀连接改为nq
}
}
last = x; //更新最后处理的结点
}
/**
* 获取字符在自动机某节点下的匹配节点
* @param c 指定字符
* @param index 匹配节点编号
* @return 下一个匹配位置
*/
bool match(char c) {
c -= 'a';
return ch[index][c] != 0;
}
/**
* 修改当前匹配节点为可包含x长度的匹配节点
* @param x 目标串长度
*/
void change(int x) {
// 直到父节点不再能够包含x长度的字符串,否则一直转移
while (index != 0 && len[fa[index]] >= x)
index = fa[index];
if (index == 0)
index = 1;
}
/**
* 更新index为可匹配c字符的位置
* @param c 指定字符
*/
void updateIndex(int c, int length) {
c -= 'a';
index = ch[index][c];
if (index == 0)
index = 1;
change(length);
}
} sam;
char str[NUM];
ll ans[NUM];
int main() {
#ifdef ACM_LOCAL
// freopen("in.txt", "r", stdin);
freopen("02", "r", stdin);
freopen("out.txt", "w", stdout);
auto start_____ = clock();
#endif
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
while (cin >> str) {
ll p, q;
cin >> p >> q;
sam.init();
memset(ans, 0, sizeof(ans));
int n = strlen(str);
sam.extend(str[0]);
ans[0] = p;
int l = 1, r = 0;// 表示匹配串的左边界右边界[l, r]
//遍历
for (int i = 1; i < n; i++) {
r = i;
ans[i] = ans[i - 1] + p;
// 当一直无法匹配到下一点或匹配串长度过长并且匹配串内仍有字符
while ((!sam.match(str[i]) || r - l + 1 > l) && l <= r) {
sam.extend(str[l++]);// 推入匹配串的第一个字符
sam.change(r - l);// 寻找后缀中的符合当前后缀(不包含此次字符的)长度要求的节点
}
// 更新index
sam.updateIndex(str[i], r - l + 1);
//当前字符未被保留时,说明该字符尚未出现,则不能够更新答案
if (l <= r)
ans[i] = min(ans[i], ans[l - 1] + q);
}
cout << ans[n - 1] << endl;
}
#ifdef ACM_LOCAL
auto end_clock_for_debug = clock();
cerr << "Run Time: " << double(end_clock_for_debug - start_____) / CLOCKS_PER_SEC << "s" << endl;
#endif
return 0;
}
除去后缀自动姬的部分直接套板子,代码量其实很小,重点在于要想到DP的操作和失配转移的操作,如果对最优问题没什么经验并且对后缀自动机的失配指针了解不够深刻的话,想A出来大概只能依靠题解了(比如本人,(╥╯^╰╥))。
有任何问题或是疑问,欢迎在评论区留言~