文章目录
151.翻转字符串里的单词
单词 是由非空格字符组成的字符串。s
中使用至少一个空格将字符串中的 单词 分隔开。
返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。
**注意:**输入字符串 s
中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。
示例 1:
输入:s = "the sky is blue"
输出:"blue is sky the"
示例 2:
输入:s = " hello world "
输出:"world hello"
解释:反转后的字符串中不能存在前导空格和尾随空格。
示例 3:
输入:s = "a good example"
输出:"example good a"
解释:如果两个单词间有多余的空格,反转后的字符串需要将单词间的空格减少到仅有一个。
提示:
1 <= s.length <= 104
s
包含英文大小写字母、数字和空格' '
s
中 至少存在一个 单词
**进阶:**如果字符串在你使用的编程语言中是一种可变数据类型,请尝试使用 O(1)
额外空间复杂度的 原地 解法。
Related Topics
- 双指针
- 字符串
思路
这道题目可以说是综合考察了字符串的多种操作。
一些同学会使用split库函数,分隔单词,然后定义一个新的string字符串,最后再把单词倒序相加,那么这道题题目就是一道水题了,失去了它的意义。
所以这里我还是提高一下本题的难度:不要使用辅助空间,空间复杂度要求为O(1)。
不能使用辅助空间之后,那么只能在原字符串上下功夫了。
想一下,我们将整个字符串都反转过来,那么单词的顺序指定是倒序了,只不过单词本身也倒序了,那么再把单词反转一下,单词不就正过来了。
所以解题思路如下:
- 移除多余空格
- 将整个字符串反转
- 将每个单词反转
举个例子,源字符串为:"the sky is blue "
- 移除多余空格 : “the sky is blue”
- 字符串反转:“eulb si yks eht”
- 单词反转:“blue is sky the”
这样我们就完成了翻转字符串里的单词。
public String reverseWords(String s) {
char[] chars = s.toCharArray();
int i = 0;
int j = s.length()-1;
//去除前边多余空格
while(true) {
if(chars[i]!=' ') {
break;
}else{
i++;
}
}
//按顺序获取所有单词
List<String> strings = new ArrayList<>();
while (i<=j) {
StringBuilder temp = new StringBuilder();
//StringBuilder right = new StringBuilder();
while(i<=j&&chars[i]!=' ') {
temp.append(chars[i]);
i++;
}
while(i<=j&&chars[i]==' ') {
i++;
}
strings.add(temp.toString());
}
int len = strings.size();
//翻转字符串List
String[] strings1 = new String[len];
for (int k = 0; k < len; k++) {
strings1[k] = strings.get(len-1-k);
}
//定义新StringBuilder用来存放新的字符串
StringBuilder res = new StringBuilder();
for (int k = 0; k < len-1; k++) {
res.append(strings1[k]);
res.append(' ');
}
res.append(strings1[len-1]);
return res.toString();
}
时间复杂度:O(N)
空间复杂度:O(N)
public String reverseWords(String s) {
char[] chars = s.toCharArray();
int i = 0;
int j = s.length()-1;
//按顺序获取所有单词
List<String> strings = new ArrayList<>();
while (i<=j) {
StringBuilder temp = new StringBuilder();
//将去除多余空格的代码统一在这里
while(i<=j&&chars[i]==' ') {
i++;
}
while(i<=j&&chars[i]!=' ') {
temp.append(chars[i]);
i++;
}
String x = temp.toString();
if(!x.isEmpty())
{
strings.add(x);
}
}
int len = strings.size();
//翻转字符串List
String[] strings1 = new String[len];
for (int k = 0; k < len; k++) {
strings1[k] = strings.get(len-1-k);
}
//定义新StringBuilder用来存放新的字符串
StringBuilder res = new StringBuilder();
for (int k = 0; k < len-1; k++) {
res.append(strings1[k]);
res.append(' ');
}
res.append(strings1[len-1]);
return res.toString();
}
时间复杂度:O(N)
空间复杂度:O(N)
卡码网:55.右旋转字符串
题目描述
字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s 和一个正整数 k,请编写一个函数,将字符串中的后面 k 个字符移到字符串的前面,实现字符串的右旋转操作。
例如,对于输入字符串 “abcdefg” 和整数 2,函数应该将其转换为 “fgabcde”。
输入描述
输入共包含两行,第一行为一个正整数 k,代表右旋转的位数。第二行为字符串 s,代表需要旋转的字符串。
输出描述
输出共一行,为进行了右旋转操作后的字符串。
输入示例
2
abcdefg
输出示例
fgabcde
提示信息
数据范围:
1 <= k < 10000,
1 <= s.length < 10000;
Java不能在字符串上修改,所以使用java一定要开辟新空间
import java.util.Scanner;
public class Main {
public static void main(String[] args){
Scanner in = new Scanner(System.in);
int k = Integer.parseInt(in.nextLine());
String s = in.nextLine();
StringBuilder stringBuilder1 = new StringBuilder();
for (int i = s.length()-k; i < s.length(); i++) {
stringBuilder1.append(s.charAt(i));
}
for (int i = 0; i < s.length()-k; i++) {
stringBuilder1.append(s.charAt(i));
}
//StringBuilder stringBuilder2 = new StringBuilder();
System.out.println(stringBuilder1.toString());
}
}
时间复杂度O(n)
空间复杂度O(n)
/**
* @description: 不开辟新空间的做法,用原始数组进行翻转操作
* @author: 君君
* @date: 2023/8/3 15:25
* @param: [s, n]
* @return: java.lang.String
**/
public String reverseLeftWords2(String s, int n){
char[] chars = s.toCharArray();
reverse(chars, 0, chars.length - 1);
reverse(chars, 0, chars.length - 1 - n);
reverse(chars, chars.length - n, chars.length - 1);
return new String(chars);
}
/**
* @description: 翻转char数组,等于翻转字符串
* @author: 李宋君
* @date: 2023/8/3 15:25
* @param: [chars, left, right]
* @return: void
**/
public void reverse(char[] chars, int left, int right) {
while (left < right) {
chars[left] ^= chars[right];
chars[right] ^= chars[left];
chars[left] ^= chars[right];
left++;
right--;
}
}
时间复杂度O(n)
空间复杂度O(1)
28. 实现 strStr()
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
示例 1:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。
示例 2:
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。
提示:
1 <= haystack.length, needle.length <= 104
haystack
和needle
仅由小写英文字符组成
Related Topics
-
双指针
-
字符串
-
字符串匹配
思路
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。
本篇将以如下顺序来讲解KMP,
- 什么是KMP
- KMP有什么用
- 什么是前缀表
- 为什么一定要用前缀表
- 如何计算前缀表
- 前缀表与next数组
- 使用next数组来匹配
- 时间复杂度分析
- 构造next数组
- 使用next数组来做匹配
- 前缀表统一减一 C++代码实现
- 前缀表(不减一)C++实现
- 总结
什么是KMP
说到KMP,先说一下KMP这个名字是怎么来的,为什么叫做KMP呢。
因为是由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP
KMP有什么用
KMP主要应用在字符串匹配上。
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
其实KMP的代码不好理解,一些同学甚至直接把KMP代码的模板背下来。
没有彻底搞懂,懵懵懂懂就把代码背下来太容易忘了。
不仅面试的时候可能写不出来,如果面试官问:next数组里的数字表示的是什么,为什么这么表示?
估计大多数候选人都是懵逼的。
什么是前缀表
写过KMP的同学,一定都写过next数组,那么这个next数组究竟是个啥呢?
next数组就是一个前缀表(prefix table)。
前缀表有什么作用呢?
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
为了清楚地了解前缀表的来历,我们来举一个例子:
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。
请记住文本串和模式串的作用,对于理解下文很重要,要不然容易看懵。所以说三遍:
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。
如动画所示:
动画里,我特意把 子串aa
标记上了,这是有原因的,大家先注意一下,后面还会说到。
可以看出,文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。
但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配。
此时就要问了
前缀表是如何记录的呢?
首先要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,
此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
那么什么是前缀表:
记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
最长公共前后缀
文章中字符串的
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
正确理解什么是前缀什么是后缀很重要!
那么网上清一色都说 “kmp 最长公共前后缀” 又是什么回事呢?
我查了一遍 算法导论 和 算法4里KMP的章节,都没有提到 “最长公共前后缀”这个词,也不知道从哪里来了,我理解是用“最长相等前后缀” 更准确一些。
因为前缀表要求的就是相同前后缀的长度。
而最长公共前后缀里面的“公共”,更像是说前缀和后缀公共的长度。这其实并不是前缀表所需要的。
所以字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2。 等等…。
为什么一定要用前缀表
这就是前缀表,那为啥就能告诉我们 上次匹配的位置,并跳过去呢?
回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:
然后就找到了下标2,指向b,继续匹配:如图:
以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
很多介绍KMP的文章或者视频并没有把为什么要用前缀表?这个问题说清楚,而是直接默认使用前缀表。
计算前缀表
接下来就要说一说怎么计算前缀表。
如图:
长度为前1个字符的子串a
,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)
长度为前2个字符的子串aa
,最长相同前后缀的长度为1。
长度为前3个字符的子串aab
,最长相同前后缀的长度为0。
以此类推: 长度为前4个字符的子串aaba
,最长相同前后缀的长度为1。 长度为前5个字符的子串aabaa
,最长相同前后缀的长度为2。 长度为前6个字符的子串aabaaf
,最长相同前后缀的长度为0。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
再来看一下如何利用 前缀表找到 当字符不匹配的时候应该指针应该移动的位置。如动画所示:
找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。
这个数值也就是可以让模式串回退到前后缀相同的上一个节点(中间节点,也就是上一个前后缀为0的点)
为什么要前一个字符的前缀表的数值呢,
因为要找前面字符串的最长相同的前缀和后缀。
所以要看前一位的 前缀表的数值。
前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。 可以再反复看一下上面的动画。
最后就在文本串中找到了和模式串匹配的子串了。
前缀表与next数组
很多KMP算法的时间都是使用next数组来做回退操作,那么next数组与前缀表有什么关系呢?
next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
为什么这么做呢,其实也是很多文章视频没有解释清楚的地方。
其实这并不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。
时间复杂度分析
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。
为了和力扣题目28.实现strStr保持一致,方便大家理解,以下文章统称haystack为文本串, needle为模式串。
都知道使用KMP算法,一定要构造next数组。
构造next数组
我们定义一个函数getNext来构建next数组,函数参数为指向next数组的指针,和一个字符串.
代码如下:
public void getNext(int[] next,String s)
构造next数组其实就是计算模式串s,前缀表的过程。 主要有如下三步:
- 初始化
- 处理前后缀不相同的情况
- 处理前后缀相同的情况
接下来我们详解一下。
- 初始化:
定义两个指针i和j,j指向前缀末尾位置,i指向后缀末尾位置。
然后还要对next数组进行初始化赋值,如下:
int j = -1;
next[0] = j;
j 为什么要初始化为 -1呢,因为之前说过 前缀表要统一减一的操作仅仅是其中的一种实现,我们这里选择j初始化为 -1,下文我还会给出j不初始化为-1的实现代码。
next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)
所以初始化next[0] = j 。
- 处理前后缀不相同的情况
因为j初始化为-1,那么i就从1开始,进行s[i] 与 s[j+1]的比较。
所以遍历模式串s的循环下标i 要从 1开始,代码如下:
for (int i = 1; i < s.length(); i++) {
如果 s[i] 与 s[j+1]不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。
怎么回退呢?
next[j]就是记录着j(包括j)之前的子串的相同前后缀的长度。
那么 s[i] 与 s[j+1] 不相同,就要找 j+1前一个元素在next数组里的值(就是next[j])。
所以,处理前后缀不相同的情况代码如下:
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
3.处理前后缀相同的情况
如果 s[i] 与 s[j + 1] 相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将j(前缀的长度)赋给next[i], 因为next[i]要记录相同前后缀的长度。****
代码如下:
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j;
最后整体构建next数组的函数如下:
/**
* @description: 构建next数组,用来记录前后缀
* @author: 君君
* @date: 2023/8/21 14:23
* @param: [next, s]
* @return: void
**/
public void getNext(int[] next,String s)
{
int j = -1;//j是前缀长度
next[0] = j;
//next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)
//相当于i是判断后缀,j+1是判断前缀,j之所以+1是为了更好的后退到前一位
for (int i = 1; i < s.length(); i++) {
//i是从1开始的,j+1是从0开始的
while(j>=0&&s.charAt(i)!=s.charAt(j+1))
{
j = next[j];//向前回退
}
//如果找到相同的前后缀
if(s.charAt(i)==s.charAt(j+1)){
j++;
}
next[i] = j;//将j的前缀长度赠与next[];
}
}
代码构造next数组的逻辑流程动画如下:
得到了next数组之后,就要用这个来做匹配了。
使用next数组来进行匹配
在文本串s里 找是否出现过模式串t。
定义两个下标j 指向模式串起始位置,i指向文本串起始位置。
那么j初始值依然为-1,为什么呢? 依然因为next数组里记录的起始位置为-1。
i就从0开始,遍历文本串,代码如下:
for (int i = 0; i < s.size(); i++)
接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的) 进行比较。
如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置。
代码如下:
while(j >= 0 && s[i] != t[j + 1]) {
j = next[j]; //回退
}
此处的t是模式串
如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动, 代码如下:
if (s[i] == t[j + 1]) {
j++; // i的增加在for循环里
}
如何判断在文本串s里出现了模式串t呢,如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了。
本题要在文本串字符串中找出模式串出现的第一个位置 (从0开始),所以返回当前在文本串匹配模式串的位置i 减去 模式串的长度,就是文本串字符串中出现模式串的第一个位置。
代码如下:
if (j == (t.size() - 1) ) {
return (i - t.size() + 1);
}
那么使用next数组,用模式串匹配文本串的整体代码如下:
int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < s.size(); i++) { // 注意i就从0开始
while(j >= 0 && s[i] != t[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (s[i] == t[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (t.size() - 1) ) { // 文本串s里出现了模式串t
return (i - t.size() + 1);
}
}
前缀表统一减一 java代码实现
public int strStr(String haystack, String needle) {
if (needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
//获得next数组用来记录前后缀
next = getNext(next,needle);
//next数组中记录的起始位置是-1 此处的j也就是目标表的下标 ,然后去进行匹配,关于前后缀
//但是在我的理解中,可以理解为当前可以匹配的字符串的长度
//当长度为(needle.length() - 1)时,就相当于已经匹配到了字符串
int j = -1;
for (int i = 0; i < haystack.length(); i++) {
while(j>=0&& haystack.charAt(i) != needle.charAt(j + 1)){
j = next[j]; //会退到之前的位置
}
//匹配成功,则i和j同时向后移动
if (haystack.charAt(i) == needle.charAt(j + 1)) {
j++;
}
//此时已经出现了字符串t 则返回其坐标
if (j == (needle.length() - 1)) {
return (i - needle.length() + 1);
}
}
//没有出现
return -1;
}
/**
* @Description 构建next数组,用来记录前后缀
* @Param next
* @Param s
* @Return {@link null}
* @Author 君君
* @Date 2024/6/29 23:50
*/
public int[] getNext(int[] next,String s){
//j是当前前缀长度
int j = -1;
next[0] = j;
//next[i]表示i(包括i),之前最长相等的前后缀长度(其实就是j)
//相当于i是判断后缀,j+1是判断后缀,j之所以+1是为了更好的后退到前一位.
for (int i = 1; i < s.length(); i++) {
//i是从1开始的,j+1是从0开始的.
while(j>=0&&s.charAt(i)!=s.charAt(j+1)){
//向前回退(回退就相当于j--),当减到j=-1时,退出循环,就是没有相同前后缀,相同前后缀长度为0;
j=next[j];
}
//如果找到了相同的前后缀
if(s.charAt(i)==s.charAt(j+1)){
j++;
}
//将j的前缀长度赋予next[i]
next[i]=j;
}
return next;
}
时间复杂度O(n+m) O(n)
空间复杂度O(m),只需要保存字符串needle的前缀表 O(n)
前缀表(不减一)java实现
那么前缀表就不减一了,也不右移的,到底行不行呢?
行!
这仅仅是KMP算法实现上的问题,如果就直接使用前缀表可以换一种回退方式,找j=next[j-1] 来进行回退。
主要就是j=next[x]这一步最为关键!
getNext的实现为:
getNext的实现为:
public int[] getNext(int[] next, String s) {
int j = 0;//j是前缀长度
next[0] = 0;
//next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)
//相当于i是判断后缀,j是判断前缀
for (int i = 1; i < s.length(); i++) {
//i是从1开始的,j是从0开始的
while (j >= 0 && s.charAt(i) != s.charAt(j)) {
j = next[j-1];//向前回退
}
//如果找到相同的前后缀
if (s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;//将j的前缀长度赠与next[];
}
return next;
}
此时如果输入的模式串为aabaaf,对应的next为 [0,1,0,1,2,0],(其实这就是前缀表的数值了)。
那么用这样的next数组也可以用来做匹配,代码要有所改动。
/**
* @Description 前缀表(不减一)
* @Param next
* @Param s
* @Return {@link int[]}
* @Author 君君
* @Date 2024/6/30 0:31
*/
public int[] getNext(int[] next, String s) {
//当前前后缀的长度
int j = 0;
//next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)
next[0] = j;
//i是从1开始的,j是从0开始的
for (int i = 1; i < s.length(); i++) {
//判断相同前后缀
while (j > 0 && s.charAt(i) != s.charAt(j)) {
//向前回退
j = next[j - 1];
}
if (s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
/**
* @Description
* @Param haystack
* @Param needle
* @Return {@link int}
* @Author 君君
* @Date 2024/6/30 0:38
*/
public int strStr(String haystack, String needle) {
if (needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
//获得next数组用来记录前后缀
next = getNext(next, needle);
//next数组中记录的起始位置是0 此处的j也就是目标表的下标 ,然后去进行匹配,关于前后缀
//但是在我的理解中,可以理解为当前可以匹配的字符串的长度
//当长度为(needle.length())时,就相当于已经匹配到了字符串
int j = 0;
for (int i = 0; i < haystack.length(); i++) {
while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
//向前回退
j = next[j - 1];
}
if (haystack.charAt(i) == needle.charAt(j)) {
j++;
}
//此时已经出现了字符串t 则返回其坐标
if (j == (needle.length())) {
return (i - needle.length() + 1);
}
}
return -1;
}
总结
我们介绍了什么是KMP,KMP可以解决什么问题,然后分析KMP算法里的next数组,知道了next数组就是前缀表,再分析为什么要是前缀表而不是什么其他表。
接着从给出的模式串中,我们一步一步的推导出了前缀表,得出前缀表无论是统一减一还是不减一得到的next数组仅仅是kmp的实现方式的不同。
其中还分析了KMP算法的时间复杂度,并且和暴力方法做了对比。
然后先用前缀表统一减一得到的next数组,求得文本串s里是否出现过模式串t,并给出了具体分析代码。
又给出了直接用前缀表作为next数组,来做匹配的实现代码。
可以说把KMP的每一个细微的细节都扣了出来,毫无遮掩的展示给大家了!
459.重复的子字符串
给定一个非空的字符串 s
,检查是否可以通过由它的一个子串重复多次构成。
示例 1:
输入: s = "abab"
输出: true
解释: 可由子串 "ab" 重复两次构成。
示例 2:
输入: s = "aba"
输出: false
示例 3:
输入: s = "abcabcabcabc"
输出: true
解释: 可由子串 "abc" 重复四次构成。 (或子串 "abcabc" 重复两次构成。)
提示:
1 <= s.length <= 104
s
由小写英文字母组成
Related Topics
- 字符串
- 字符串匹配
思路
暴力的解法,就是一个for循环获取子串的终止位置,然后判断子串是否能重复构成字符串,又嵌套一个for循环,所以是O(n^2)的时间复杂度。
有的同学可以想,怎么一个for循环就可以获取子串吗?至少得一个for获取子串起始位置,一个for获取子串结束位置吧。
其实我们只需要判断,以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就行了。而且遍历的时候 都不用遍历结束,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串。
暴力的解法,这里就不详细讲解了。
主要讲一讲移动匹配 和 KMP两种方法。
移动匹配
当一个字符串s:abcabc,内部由重复的子串组成,那么这个字符串的结构一定是这样的:
也就是由前后相同的子串组成。
那么既然前面有相同的子串,后面有相同的子串,用 s + s,这样组成的字符串中,后面的子串做前串,前面的子串做后串,就一定还能组成一个s,如图:
所以判断字符串s是否由重复子串组成,只要两个s拼接在一起,里面还出现一个s的话,就说明是由重复子串组成。
当然,我们在判断 s + s 拼接的字符串里是否出现一个s的的时候,要刨除 s + s 的首字符和尾字符,这样避免在s+s中搜索出原来的s,我们要搜索的是中间拼接出来的s。
代码如下:
class Solution {
public:
bool repeatedSubstringPattern(string s) {
string t = s + s;
t.erase(t.begin());
t.erase(t.end() - 1); // 掐头去尾
if (t.find(s) != std::string::npos) return true; // r
return false;
}
};
时间复杂度: O(n)
空间复杂度: O(1)
不过这种解法还有一个问题,就是 我们最终还是要判断 一个字符串(s + s)是否出现过 s 的过程,大家可能直接用contains,find 之类的库函数。 却忽略了实现这些函数的时间复杂度(暴力解法是m * n,一般库函数实现为 O(m + n))。
如果我们做过 28.实现strStr (opens new window)题目的话,其实就知道,实现一个 高效的算法来判断 一个字符串中是否出现另一个字符串是很复杂的,这里就涉及到了KMP算法。
KMP
为什么会使用KMP
以下使用KMP方式讲解,强烈建议大家先把以下两个视频看了,理解KMP算法,再来看下面讲解,否则会很懵。
- 视频讲解版:帮你把KMP算法学个通透!(理论篇)(opens new window)
- 视频讲解版:帮你把KMP算法学个通透!(求next数组代码篇)(opens new window)
- 文字讲解版:KMP算法(opens new window)
在一个串中查找是否出现过另一个串,这是KMP的看家本领。那么寻找重复子串怎么也涉及到KMP算法了呢?
KMP算法中next数组为什么遇到字符不匹配的时候可以找到上一个匹配过的位置继续匹配,靠的是有计算好的前缀表。前缀表里,统计了各个位置为终点字符串的最长相同前后缀的长度。
那么 最长相同前后缀和重复子串的关系又有什么关系呢。
可能很多录友又忘了前缀和后缀的定义,再回顾一下:
- 前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;
- 后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串
在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串,这里拿字符串s:abababab 来举例,ab就是最小重复单位,如图所示:
如何找到最小重复子串
这里有同学就问了,为啥一定是开头的ab呢。 其实最关键还是要理解 最长相等前后缀,如图:
步骤一:因为 这是相等的前缀和后缀,t[0] 与 k[0]相同, t[1] 与 k[1]相同,所以 s[0] 一定和 s[2]相同,s[1] 一定和 s[3]相同,即:,s[0]s[1]与s[2]s[3]相同 。
步骤二: 因为在同一个字符串位置,所以 t[2] 与 k[0]相同,t[3] 与 k[1]相同。
步骤三: 因为 这是相等的前缀和后缀,t[2] 与 k[2]相同 ,t[3]与k[3] 相同,所以,s[2]一定和s[4]相同,s[3]一定和s[5]相同,即:s[2]s[3] 与 s[4]s[5]相同。
步骤四:循环往复。
所以字符串s,s[0]s[1]与s[2]s[3]相同, s[2]s[3] 与 s[4]s[5]相同,s[4]s[5] 与 s[6]s[7] 相同。
正是因为 最长相等前后缀的规则,当一个字符串由重复子串组成的,最长相等前后缀不包含的子串
就是最小重复子串。
简单推理
这里再给出一个数学推导,就容易理解很多。
假设字符串s使用多个重复子串构成(这个子串是最小重复单位),重复出现的子字符串长度是x,所以s是由n * x组成。
因为字符串s的最长相同前后缀的长度一定是不包含s本身,所以 最长相同前后缀长度必然是m * x,而且 n - m = 1,(这里如果不懂,看上面的推理)
所以如果 nx % (n - m)x = 0,就可以判定有重复出现的子字符串。
next 数组记录的就是最长相同前后缀 字符串:KMP算法精讲 (opens new window)这里介绍了什么是前缀,什么是后缀,什么又是最长相同前后缀), 如果 next[len - 1] != -1,则说明字符串有最长相同的前后缀(就是字符串里的前缀子串和后缀子串相同的最长长度)。
最长相等前后缀的长度为:next[len - 1] + 1。(这里的next数组是以统一减一的方式计算的,因此需要+1,两种计算next数组的具体区别看这里:字符串:KMP算法精讲 (opens new window))
数组长度为:len。
如果len % (len - (next[len - 1] + 1)) == 0 ,则说明数组的长度正好可以被 (数组长度-最长相等前后缀的长度) 整除 ,说明该字符串有重复的子字符串。
数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环。
强烈建议大家把next数组打印出来,看看next数组里的规律,有助于理解KMP算法
如图:
next[len - 1] = 7,next[len - 1] + 1 = 8,8就是此时字符串asdfasdfasdf的最长相同前后缀的长度。
(len - (next[len - 1] + 1)) 也就是: 12(字符串的长度) - 8(最长公共前后缀的长度) = 4, 4正好可以被 12(字符串的长度) 整除,所以说明有重复的子字符串(asdf)。
public boolean repeatedSubstringPattern(String s) {
if (s.equals("")) return false;
int[] next = new int[s.length()];
//获得当前字符串的前缀值
next = getNext(next,s);
int len = s.length();
//判断是否出现了重复的子字符串,这里 next[len] 即代表next数组末尾的值
if (next[len-1] > 0 && len % (len - next[len-1]) == 0) {
return true;
}
return false;
}
/**
* @Description 前缀表(不减一)
* @Param next
* @Param s
* @Return {@link int[]}
* @Author 君君
* @Date 2024/6/30 0:31
*/
public int[] getNext(int[] next, String s) {
//当前前后缀的长度
int j = 0;
//next[i] 表示 i(包括i)之前最长相等的前后缀长度(其实就是j)
next[0] = j;
//i是从1开始的,j是从0开始的
for (int i = 1; i < s.length(); i++) {
//判断相同前后缀
while (j > 0 && s.charAt(i) != s.charAt(j)) {
//向前回退
j = next[j - 1];
}
if (s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
总结
什么是字符串
字符串是若干字符组成的有限序列,也可以理解为是一个字符数组,但是很多语言对字符串做了特殊的规定,接下来我来说一说C/C++中的字符串。
在C语言中,把一个字符串存入一个数组时,也把结束符 '\0’存入数组,并以此作为该字符串是否结束的标志。
例如这段代码:
char a[5] = "asd";
for (int i = 0; a[i] != '\0'; i++) {
}
在C++中,提供一个string类,string类会提供 size接口,可以用来判断string类字符串是否结束,就不用’\0’来判断是否结束。
例如这段代码:
string a = "asd";
for (int i = 0; i < a.size(); i++) {
}
那么vector< char > 和 string 又有什么区别呢?
其实在基本操作上没有区别,但是 string提供更多的字符串处理的相关接口,例如string 重载了+,而vector却没有。
所以想处理字符串,我们还是会定义一个string类型。
要不要使用库函数
在文章344.反转字符串 (opens new window)中强调了打基础的时候,不要太迷恋于库函数。
甚至一些同学习惯于调用substr,split,reverse之类的库函数,却不知道其实现原理,也不知道其时间复杂度,这样实现出来的代码,如果在面试现场,面试官问:“分析其时间复杂度”的话,一定会一脸懵逼!
所以建议如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。
如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
双指针法
在344.反转字符串 (opens new window),我们使用双指针法实现了反转字符串的操作,双指针法在数组,链表和字符串中很常用。
接着在字符串:替换空格 (opens new window),同样还是使用双指针法在时间复杂度O(n)的情况下完成替换空格。
其实很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
那么针对数组删除操作的问题,其实在27. 移除元素 (opens new window)中就已经提到了使用双指针法进行移除操作。
同样的道理在151.翻转字符串里的单词 (opens new window)中我们使用O(n)的时间复杂度,完成了删除冗余空格。
一些同学会使用for循环里调用库函数erase来移除元素,这其实是O(n^2)的操作,因为erase就是O(n)的操作,所以这也是典型的不知道库函数的时间复杂度,上来就用的案例了。
反转系列
在反转上还可以在加一些玩法,其实考察的是对代码的掌控能力。
541. 反转字符串II (opens new window)中,一些同学可能为了处理逻辑:每隔2k个字符的前k的字符,写了一堆逻辑代码或者再搞一个计数器,来统计2k,再统计前k个字符。
其实当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章。
只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。
因为要找的也就是每2 * k 区间的起点,这样写程序会高效很多。
在151.翻转字符串里的单词 (opens new window)中要求翻转字符串里的单词,这道题目可以说是综合考察了字符串的多种操作。是考察字符串的好题。
这道题目通过 先整体反转再局部反转,实现了反转字符串里的单词。
后来发现反转字符串还有一个牛逼的用处,就是达到左旋的效果。
在字符串:反转个字符串还有这个用处? (opens new window)中,我们通过先局部反转再整体反转达到了左旋的效果。
KMP
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
KMP的精髓所在就是前缀表,在KMP精讲 (opens new window)中提到了,什么是KMP,什么是前缀表,以及为什么要用前缀表。
前缀表:起始位置到下标i之前(包括i)的子串中,有多大长度的相同前缀后缀。
那么使用KMP可以解决两类经典问题:
再一次强调了什么是前缀,什么是后缀,什么又是最长相等前后缀。
前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀:指不包含第一个字符的所有以最后一个字符结尾的连续子串。
然后针对前缀表到底要不要减一,这其实是不同KMP实现的方式,我们在KMP精讲 (opens new window)中针对之前两个问题,分别给出了两个不同版本的的KMP实现。
其中主要理解j=next[x]这一步最为关键!
总结
字符串类类型的题目,往往想法比较简单,但是实现起来并不容易,复杂的字符串题目非常考验对代码的掌控能力。
双指针法是字符串处理的常客。
KMP算法是字符串查找最重要的算法.
好了字符串相关的算法知识就介绍到了这里了,明天开始新的征程,大家加油!