字符串最短循环子串 | 豆包MarsCode AI刷题

108 阅读6分钟

题目

问题描述

小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子串的后缀abP子串的前缀ab是完全相同的,如果利用好这个性质,我们可以让j回溯到P[2]的位置重新开始匹配,减少了匹配次数。

1.png

对比以下例子可以看出:

在暴力算法匹配失败后,i4回溯到1j4回溯到0

在KMP算法匹配失败后,i不回溯,j4回溯到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

再通过观察,我们得知,当前冲突的子串的前面部分的子串在SP中是完全一样的,我们可以将找最长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的最小重复子串。

2.png

我们上面提到next[]表示截至当前的子串,该位置的最长相同前后缀长度。

S的长度为nP的长度为m,我们对Snext[],就有: m=nnext[n1]m = n - next[n-1]

同时对于这题,还应满足以下两个条件

  • 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 算法,用双指针 ij 遍历字符串,每个字符最多访问两次,因此时间复杂度为 O(n)

空间复杂度分析

next 数组占用 O(n) 的空间,用于存储每个字符的最长前后缀信息。

总结

  • 时间复杂度: O(n)
  • 空间复杂度: O(n)