POJ 3693 题解报告 后缀数组+ST表
又是一道神奇的后缀数组题,刚刚A掉这个题,发现后缀数组跟ST表是好朋友。。。又双叒叕一起出现了,嘤嘤嘤,跟上一道一样,自己的想法WA了,然后找到一组卡死自己的样例,于是乖乖地去找网上大佬们的博客来拜读。哇,大佬们都tql,我在这里记个笔记。
题目大意
给我们n个字符串,我们需要找出字符串中存在循环节最多的然后如果循环节一样多则选择字典序最小的子串并输出这个子串。题目在此
思路
我们采取的策略是,枚举循环节长度L,然后在每个位置上查看是否能够满足要求,如果能够同时记录下循环次数和循环节大小就更好了。于是后缀数组又派上了大用处,这次的用法非常魔幻。
我们假设有一个字符串ccabababc,它产生的答案是ababab,接下来我们就此字符串进行讨论:
| height | suffix |
|---|---|
| NULL | ababc |
| 4 | abababc |
| 2 | abc |
| 0 | bababc |
| 3 | babc |
| 1 | bc |
| 0 | c |
| 1 | cabababc |
| 1 | ccabababc |
观察上表,我们能够得出结论,一个前缀上存在循环节且循环次数多于 2 的后缀,它的所有后缀中必定存在一个前缀上有 相同 循环节且循环次数少 1 的后缀。比如abababc这个后缀,它的前缀上的循环节是ab,它的所有后缀是:
bababc,ababc,babc,abc,bc,c,然后我们看到有ababc和abc与abababc在前缀上的循环节都是ab。然后参照上表,abababc与ababc的最长公共长度等于他们间的height的最小值(在代码中,用ST表预处理,可达到O(1)的查询复杂度),也就是4。
然后我们需要证明一个理论:
假设存在一个字符串由n个字符组成,我们假设这三个未知字符为x, y, z,组成了xyz。
再假设从它首字母位置+1位置的后缀字符串yz与xyz的最长公共前缀为2,那么易得知y=x,两个字符串被化为了xz和xxz,也意味着z=y=x,于是两个字符变为了xxx和xx,原字符串的前缀变为了一个循环节长度为1且3次循环的循环字符串。
再回头看abababc和ababc,同理,它们也有一个长度为2的循环节,并且循环次数为3。反过来,我们可以认为只要两个后缀的首字母距离小于最长公共前缀,它们间必定存在循环节。
那么我们只需要对于每个位置i,枚举可能的长度L,看看后缀i+L与后缀i之间的最长公共前缀是否大于等于L,即可判断后缀中是否存在循环的前缀。
但是题目不仅要求判断循环,还要求我们找到这个循环的字符串。
对于一个循环的字符串,我们只需要知道循环节长什么样,以及循环了多少次,我们即可重现这个字符串。因此我们需要利用后缀数组以及上面的操作来找到 循环的 开始的位置,循环节长度,以及 循环次数。
- 循环节长度:即等于判断成功后的L的值;
- 循环次数:应为最长公共前缀的长度除L后向上取整的值。
比如abababc和ababc,最长公共前缀长度为 4,而我们此时枚举的L应为 2,4 / 2 + 1 = 3,3 即循环节的循环次数(不信的同学可以自己手推几组,都是这个规律); - 循环开始的位置:与之前的不同,这个恰恰是我们的思路中最暴力的部分。有人可能会有疑问,循环的位置不就应该是我们遍历到的位置吗?感觉一点都不暴力。答案是——否。虽然有了上面的规律之后,条条大路通罗马,但是鉴于题目要求的字典序最小,我们采取更简洁的方案。遍历后缀数组,找到的第一个满足最大循环要求的便是我们的循环开始位置(因为后缀数组的特性~)。
知晓了上面的步骤后,我们粗略地计算一下复杂度,发现遍历一遍所有的后缀需要n,在每个位置上枚举可能的L又是一个n,那总复杂度为O(n2),emmm,TLE预定?然而并不是~
我们应该先枚举可能的长度L:
for (int l = 1; l < len; l++)
然后再遍历数组
for (int l = 1; l < len; l++)
for (int i = 0; i < len - l; i += l)
大家可以看到这里for循环的步长是L,也就是说 i 所在的循环的步长在不断增加。先不考虑是否可行,用时≈n/1+n/2+n/3+…+n/n=n * (1/1+1/2+1/3+…+1/n),根据欧拉公式,总复杂度 = O(n * ln(n) + Cn)。
现在我们用一个例子来说明这个是正确的:
假设一个字符串为 bababab,在L为2的循环中,第一个a的位置是不会被遍历到的,因为它在奇数位上。
但是当我们遍历到后缀babab时,会发现该后缀与相隔2的后缀bab的最长公共前缀为bab,我们能够得到循环次数 = 3 / 2 + 1 = 2,但是如此操作便会漏掉ababab的情况。于是我们需要利用起前缀的长度3。我们尝试在前面扩展,找到一个开始位置能够一直循环到上述最长公共前缀的最后一位。先找到最长公共前缀多余的部分是最后一个b,长度为1,如果要匹配这个字符,那么至少要再补上1位字符才能够跟 原来的循环节 长度相同,我们只需要向前找1位,然后判断一下即可,如果能够满足要求,则循环次数 +1 (不明白的同学可以自己手推几组样例试一下)。
当我们步长为L地去遍历时,只要循环节有长度L,总会有一环被遍历到,再加上上面的回头扩展,只要我们枚举的L不缺,就不会有任何循环子串被漏掉。
AC代码
#include <cstdio>
#include <algorithm>
#include <iostream>
#include <cstring>
using namespace std;
typedef unsigned long long ull;
typedef long long ll;
const int CHAR_NUM = 128;
const int MAXN = 1000005;
const ll NUM = 2e5 + 10;
int SA[MAXN], myRank[MAXN], height[MAXN], sum[MAXN], tp[MAXN];
int elens[MAXN];//elens记录单位长度的序列
char str[MAXN];
//rank[i] 第i个后缀的排名, SA[i] 排名为i的后缀的位置, Height[i] 排名为i的后缀与排名为(i-1)的后缀的LCP
//sum[i] 基数排序辅助数组, 存储小于i的元素有多少个, tp[i] rank的辅助数组(按第二关键字排序的结果),与SA意义一样
bool cmp(const int *f, int x, int y, int w) {
return f[x] == f[y] && f[x + w] == f[y + w];
}
void get_SA(const char *s, int n, int m) {
//先预处理长度为1的情况
for (int i = 0; i < m; i++) sum[i] = 0;//清0
for (int i = 0; i < n; i++) sum[myRank[i] = s[i]]++;//统计每个字符出现的次数
for (int i = 1; i < m; i++) sum[i] += sum[i - 1];//sum[i]为小于等于i的元素的数目
for (int i = n - 1; i >= 0; i--) SA[--sum[myRank[i]]] = i;//下标从0开始,所以先自减
//SA[i]存储排名第i的后缀下标,SA[--sum[rank[i]]] = i 即下标为i的后缀排名为--sum[rank[i]],这很显然
for (int len = 1; len <= n; len *= 2) {
int p = 0;
//直接用SA数组对第二关键字排序
for (int i = n - len; i < n; i++) tp[p++] = i;//后面i个数没有第二关键字,即第二关键字为空,所以最小
for (int i = 0; i < n; i++) {
if (SA[i] >= len) tp[p++] = SA[i] - len;
}
//tp[i]存储按第二关键字排序第i的下标
//对第二关键字排序的结果再按第一关键字排序,和长度为1的情况类似
for (int i = 0; i < m; i++) sum[i] = 0;
for (int i = 0; i < n; i++) sum[myRank[tp[i]]]++;
for (int i = 1; i < m; i++) sum[i] += sum[i - 1];
for (int i = n - 1; i >= 0; i--) SA[--sum[myRank[tp[i]]]] = tp[i];
//根据SA和rank数组重新计算rank数组
swap(myRank, tp);//交换后tp指向旧的rank数组
p = 1;
myRank[SA[0]] = 0;
for (int i = 1; i < n; i++) {
myRank[SA[i]] = cmp(tp, SA[i - 1], SA[i], len) ? p - 1 : p++;//注意判定rank[i]和rank[i-1]是否相等
}
if (p >= n) break;
m = p;//下次基数排序的最大值
}
//求height
int k = 0;
n--;
for (int i = 0; i <= n; i++) myRank[SA[i]] = i;
for (int i = 0; i < n; i++) {
if (k) k--;
int j = SA[myRank[i] - 1];
while (s[i + k] == s[j + k]) k++;
height[myRank[i]] = k;
}
}
void reset(int n) {
for (int i = 0; i < n + 2; i++)
SA[i] = myRank[i] = height[i] = sum[i] = tp[i] = elens[i] = str[i] = 0;
}
void debug(char *str1) {
ull len = strlen(str1) + 1;
for (ull i = 1; i < len; i++) {
cout << endl << height[i] << " " << SA[i] << " ";
for (ull j = SA[i]; j < len; j++)
cout << str[j];
}
cout << endl << "answer: " << endl;
}
struct ST {
ll STMin[NUM][20], mn[NUM];
void initST(int n, const int *a) {
memset(STMin, 0, sizeof(STMin));
memset(mn, 0, sizeof(mn));
mn[0] = -1;
for (int i = 1; i <= n; ++i) {
mn[i] = ((i & (i - 1)) == 0) ? mn[i - 1] + 1 : mn[i - 1];
STMin[i][0] = a[i];
}
for (int j = 1; j <= mn[n]; ++j) {
for (int i = 1; i + (1 << j) - 1 <= n; ++i) {
STMin[i][j] = min(STMin[i][j - 1], STMin[i + (1 << (j - 1))][j - 1]);
}
}
}
ll rmqMin(int l, int r) {
int k = mn[r - l + 1];
return min(STMin[l][k], STMin[r - (1 << k) + 1][k]);
}
} st;
int getLcp(int a, int b) {
int pos1 = myRank[a];
int pos2 = myRank[b];
if (pos1 < pos2)
return st.rmqMin(pos1 + 1, pos2);
else
return st.rmqMin(pos2 + 1, pos1);
}
int main() {
#ifdef ACM_LOCAL
freopen("in.txt", "r", stdin);
freopen("out.txt", "w", stdout);
#endif
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int c = 0;
while (cin >> str, str[0] != '#') {
cout << "Case " << ++c << ": ";
int len = strlen(str);
str[len] = 0;
str[len + 1] = 0;
len++;
get_SA(str, len, CHAR_NUM);
len--;
st.initST(len, height);
int maxCnt = 0;
int index = 0;
//枚举长度l
for (int l = 1; l < len; l++) {
for (int i = 0; i < len - l; i += l) {
int cnt = 0;
int elen = 0;
//任意两后缀的最大公共前缀等于height之间的最小值
elen = getLcp(i, i + l);
//cnt表示匹配的重复个数,至少为1
cnt = elen / l + 1;
//t表示该位置之后能够有机会再添加一位的位置
int t = l - elen % l;
t = i - t;
//若t位置合法,则进行判断
if (t >= 0 && elen % l) {
if (getLcp(t, t + l) >= elen)
cnt++;
}
if (cnt > maxCnt) {
index = 0;
elens[index] = l;
maxCnt = cnt;
} else if (cnt == maxCnt)
elens[++index] = l;
}
}
int start = 0, ansLen = 0;
bool hasFound = false;
for (int i = 1; i <= len; i++) {
if (hasFound)
break;
int pos = SA[i];
for (int j = 0; j <= index; j++) {
int elen = elens[j];
if(pos + elen >= len)
continue;
if (getLcp(pos, pos + elen) >= ((maxCnt - 1) * elen)) {
start = pos, ansLen = elen * maxCnt;
hasFound = true;
break;
}
}
}
for (int i = start; i < start + ansLen; i++)
cout << str[i];
cout << endl;
reset(len + 10);
}
return 0;
}
emmm,感觉后缀数组太灵活了,很多性质不做题根本想不到。前几天HDU多校遇到的6661还没补,看大佬的博客里说跟这个题的思路差不多,回头补一下~
如果上文中有什么不对的或者是表述不清的地方,欢迎在评论区反映
本文详细解析了POJ3693题的解题思路,通过后缀数组与ST表的巧妙结合,解决寻找字符串中循环节最多且字典序最小的子串问题。文章深入探讨了后缀数组的运用,如何通过高度数组与ST表预处理实现高效查询,以及如何确定循环节的长度、次数和开始位置。

1527

被折叠的 条评论
为什么被折叠?



