题目
问题描述
小M在研究字符串时发现了一个有趣的现象:某些字符串是由一个较短的子串反复拼接而成的。如果能够找到这个最短的子串,便可以很好地还原字符串的结构。你的任务是给定一个字符串,判断它是否是由某个子串反复拼接而成的。如果是,输出该最短的子串;否则,输出空字符串""。
例如:当输入字符串为 abababab 时,它可以由子串 ab 反复拼接而成,因此输出 ab;而如果输入 ab,则该字符串不能通过子串的重复拼接得到,因此输出空字符串。
测试样例
样例1:
输入:
inp = "abcabcabcabc"输出:'abc'
样例2:
输入:
inp = "aaa"输出:'a'
样例3:
输入:
inp = "abababab"输出:'ab'
样例4:
输入:
inp = "ab"输出:''
样例5:
输入:
inp = "abcdabcdabcdabcd"输出:'abcd'
样例6:
输入:
inp = "b"输出:''
解题思路
在解题前我们先了解一下KMP算法,它能够高效地解决字符串匹配问题。
字符串匹配问题
在字符串匹配问题中,通常我们会使用两个字符串来描述问题:
- 主串(Text 或 String) ,记作
S。 - 模式串(Pattern) ,记作
P。
在字符串匹配问题中,我们的目标是 查找模式字符串 P 在主字符串 S 中出现的位置。也就是说,我们需要判断 P 是否是 S 的一个子字符串,或者返回它在 S 中所有匹配的起始位置。
假设:主串 S = "ababcababcabc";模式串 P = "ababa"
暴力算法的过程如下:
// round 1.0
i
a b a b c a b a b c a b c
a b a b a
j
// ...
// round 1.4
i
a b a b c a b a b c a b c
a b a b a
j
// 不匹配,i j 指针回溯
// round 2.0
i
a b a b c a b a b c a b c
a b a b a
j
算法复杂度:O(nm)
KMP算法
算法核心:利用匹配失败后的信息尽量减少模式串与主串匹配的次数,以达到快速匹配的目的。
首先给出前缀和后缀的定义
- 前缀:是指不包含最后一个字符的所有以第一个字符开头的连续子串
- 后缀:是指不包含第一个字符的所有以最后一个字符结尾的连续子串
当匹配失败时如何利用已有信息呢?
可以看到,当匹配失败时,按照原来的暴力的思路,j指针会重新指向第一个位置,即P[0]。
事实上,通过观察可知,已经遍历过的两个子串部分,S子串的后缀ab与P子串的前缀ab是完全相同的,如果利用好这个性质,我们可以让j回溯到P[2]的位置重新开始匹配,减少了匹配次数。
对比以下例子可以看出:
在暴力算法匹配失败后,i从4回溯到1,j从4回溯到0。
在KMP算法匹配失败后,i不回溯,j从4回溯到2。
// kmp round 2.0
i
a b a b c a b a b c a b c
a b a b a
j
再通过观察,我们得知,当前冲突的子串的前面部分的子串在S和P中是完全一样的,我们可以将找最长S后缀和P前缀的操作转化为求P的最长相同前后缀,因此我们引入了模式串自匹配求next[]数组这一操作。在这里,next[]表示截至当前的子串,该位置的最长相同前后缀长度,在这里同时也是回溯的位置。举例如下:
a b a b a c b
0 0 1 2 3 0 0
a 0 一个字符,不存在前后缀
ab 0 没有相等的前后缀
aba 1 a // 注意:ab与ba是不相等的
abab 2 ab
ababa 3 aba
ababac 0
ababacn 0
我们这里j回溯的方法是,当出现冲突时,j回溯到发生冲突的前一位字符所指的next数组的位置,注意,回溯是个迭代的过程,应一直回溯直至字符匹配为止。
KMP代码如下:
// s[]是长文本,p[]是模式串,n是s的长度,m是p的长度
// 模式串自匹配,求模式串的Next数组
void getNext(int next[], string p){
int m = p.size();
int j = 0;
next[0] = 0;
for(int i = 1; i < m; i++){ // j表示前缀末尾,同时也表示最长相同前后缀的长度,i表示后缀末尾
while(j && p[i] != p[j]) j = next[j-1]; // 遇到不相等的情况,j回溯,回溯的位置即为前一个next数组的值
if(p[i] == p[j]) j++; // 匹配成功
next[i] = j;
}
}
// 文本串与模式串的匹配
int n = s.size();
for(int i = 0, j = 0; i < n; i++){
// 字符匹配的过程,与模式串自匹配相似
while(j && s[i] != p[j]) j = next[j-1];
if(s[i] == p[j]) j++;
if(j == m){ //成功匹配完一次模式串
// 匹配成功后的逻辑
}
}
回归题目
假设字符串S是由一个较短的子串P反复拼接而成,最长相等前后缀不包含的子串是字符串S的最小重复子串。
我们上面提到next[]表示截至当前的子串,该位置的最长相同前后缀长度。
设S的长度为n,P的长度为m,我们对S求next[],就有:
同时对于这题,还应满足以下两个条件
n % m == 0,显然,n必须是m的倍数,反例:
a b c a b c a b
0 0 0 1 2 3 4 5 // 8 - 5 = 3, 8 % 3 = 1, 该字符串不是由"abc"重复构成
next[n - 1] > 0,反例:
a b c
0 0 0 // 3 - 0 = 3, 3 % 3 = 0, 但是该字符串不是由"abc"重复构成
答案P就是S的前m个字符组成的字符串。
警告(针对我自己):不要数前面连续0的个数,反例
a b a c a b
0 0 1 0 1 2
(每次看到正确例子前面连续0的个数对应的就是答案,直接就这么认为了,其实不是的)
Code
#include <iostream>
#include <string>
#include <vector>
void getNext(std::vector<int>& next, const std::string& s) {
int n = s.size();
int j = 0;
next[0] = 0;
for (int i = 1; i < n; i++) {
while (j && s[i] != s[j]) j = next[j-1];
if (s[i] == s[j]) j++;
next[i] = j;
}
}
std::string solution(const std::string &inp) {
int n = inp.size();
std::vector<int> next(n, 0);
getNext(next, inp);
int m = n - next[n - 1];
if (next[n - 1] > 0 && n % m == 0)
return inp.substr(0, m);
else
return "";
}
int main() {
std::cout << (solution("abcabcabcabc") == "abc") << std::endl;
std::cout << (solution("aabccbcbbbcccaccca") == "") << std::endl;
std::cout << (solution("ab") == "") << std::endl;
return 0;
}
复杂度分析
时间复杂度分析
getNext 函数的时间复杂度为 O(n),其中 n 是字符串 inp 的长度。getNext 函数基于 KMP 算法,用双指针 i 和 j 遍历字符串,每个字符最多访问两次,因此时间复杂度为 O(n)。
空间复杂度分析
next 数组占用 O(n) 的空间,用于存储每个字符的最长前后缀信息。
总结
- 时间复杂度:
O(n) - 空间复杂度:
O(n)