3.7
字符串
反转字符串
题目:
-
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
-
不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
双指针法
因为字符串也是一种数组,所以元素在内存中是连续分布,这就决定了反转链表和反转字符串方式上还是有所差异的。
对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。
-
双指针移动
-
交换
class Solution {
public void reverseString(char[] s) {
int left = 0;
int right = s.length - 1;
while(left<right){
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
}
}
注意细节
-
char temp
被定义为char
类型是因为它用来暂存char[] s
数组中的单个字符。在这个上下文中,s
是一个字符数组(char[]
),而不是一个字符串对象,因此对数组元素进行操作时,需要使用与数组元素相同的数据类型,即char
-
char[] s
在Java中声明了一个字符数组-
对于数组(不仅仅是字符数组,还包括其他类型的数组),使用
.length
来获取数组的长度。这是一个数组的属性,不是一个方法,因此不使用括号()
。 -
对于
String
对象,使用.length()
方法来获取字符串的长度。这是一个方法,因此需要使用括号()
。
在您的代码示例中,
s
是一个字符数组,所以使用s.length
来获取数组的长度是正确的,不需要加括号。如果s
是一个字符串对象,那么我们需要使用s.length()
来获取字符串的长度。 -
-
另一种字符交换方法:
s[l] ^= s[r]
实现了不使用额外变量的情况下,交换序列s
中索引为l
和r
处的两个元素的值。将
s[l]
和s[r]
的值进行异或操作,并将结果赋值给s[l]
,同时将s[l]
的原始值与s[r]
进行异或操作,并将结果赋值给s[r]
。这样就完成了两个元素值的交换操作。
反转字符串II
题目:
-
给定一个字符串 s 和一个整数 k,从字符串开头算起, 每计数至 2k 个字符,就反转这 2k 个字符中的前 k 个字符。
-
如果剩余字符少于 k 个,则将剩余字符全部反转。
如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
题目的意思其实概括为:每隔2k个反转前k个,尾数不够k个时候全部反转
-
将字符串转换为字符数组:
char[] ch = s.toCharArray();
。这是因为字符串在Java中是不可变的,而通过操作字符数组可以轻松地在原地修改字符。 -
遍历字符数组:
for(int i = 0; i < ch.length; i += 2 * k)
。这个循环每次增加2k
,按照题目要求分段处理每个部分。每次跳2k;i < ch.length,所以,start+k-1是前k个的位置,判断剩余距离就是当前i与start+k-1之前的距离
-
计算反转的开始和结束索引:
-
int start = i;
表示每个部分的开始位置。 -
int end = Math.min(ch.length - 1, start + k - 1);
表示反转的结束位置。 -
如果剩余字符少于k个,
end
将是数组的最后一个元素的索引,将会全部反转;否则,剩余字符不少于k个,
end
是从start
开始的k个字符的最后一个字符的索引,翻转前k个。
-
-
反转字符:
while(start < end)
循环用于实际反转start
和end
之间的字符。通过交换start
和end
索引处的字符并逐步将start
向前移动和end
向后移动来实现反转。 -
返回修改后的字符串:
return new String(ch);
。在完成所有反转操作后,使用修改后的字符数组创建一个新的字符串并返回。
class Solution {
public String reverseStr(String s, int k) {
char[] ch = s.toCharArray();
for(int i = 0; i < ch.length; i += 2 * k){
int start = i;
int end = Math.min(ch.length - 1, start + k -1);
while(start<end){
char temp = ch[start];
ch[start] = ch[end];
ch[end] = temp;
start++;
end--;
}
}
return new String(ch);
}
}
注意细节
-
每隔2k个反转前k个,尾数不够k个时候全部反转:这里说的
每隔2k个反转前k个
是当前2k个
的前k个
,所以for(int i = 0; i < ch.length; i += 2 * k)
int end = Math.min(ch.length - 1, start + k - 1);
-
start + k - 1 而不是 start + k
:当我们说“反转从索引start
开始的k
个字符”时,我们需要考虑到从0开始的索引计数。如果start
是开始索引,那么start + k
实际上会指向第k+1
个字符(因为包括start
在内,我们已经计算了一个字符)。因此,为了确保我们只反转k
个字符,我们需要结束在start + k - 1
。 -
toCharArray
:s.toCharArray()
是String
类的一个方法,它将字符串转换为一个新的字符数组(char[]
),每个字符在数组中的位置与它在原字符串中的位置相同。 -
return new String(ch);
这行代码创建了一个新的字符串对象,这个对象的内容是修改后的字符数组ch
。这是因为,尽管您已经修改了字符数组ch
的内容,但最终您需要的输出是一个字符串。在Java中,可以通过传递一个字符数组给String
类的构造函数来创建一个新的字符串对象,这个新的字符串将包含数组中的字符,顺序与数组中的顺序相同。
替换数字
题目:
给定一个字符串 s,它包含小写字母和数字字符,请编写一个函数,将字符串中的字母字符保持不变,而将每个数字字符替换为number。
-
定义一个数字1-10集合
-
直接暴力遍历
-
如果是数字,替换为number,这个需要扩充大小每个元素移动
从前向后填充就是O(n^2)的算法了,因为每次添加元素都要将添加元素之后的所有元素整体向后移动。
其实很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
这么做有两个好处:
-
不用申请新数组。
-
从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。
-
创建数组逐个替换
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
String s = in.nextLine();
// 估算替换后的字符串可能的最大长度
char[] result = new char[s.length() * 6]; // "number"占6位,每个字符都可能被替换
int resultIndex = 0;
for (int i = 0; i < s.length(); i++) {
char currentChar = s.charAt(i);
if (Character.isDigit(currentChar)) {
// 替换为"number"
String replacement = "number";
for (int j = 0; j < replacement.length(); j++) {
result[resultIndex++] = replacement.charAt(j);
}
} else {
// 直接复制字符
result[resultIndex++] = currentChar;
}
}
// 创建最终字符串
String finalResult = new String(result, 0, resultIndex);
System.out.println(finalResult);
}
}
StringBuilder
代码随想录答案:
import java.util.Scanner;
class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
String s = in.nextLine();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
if (Character.isDigit(s.charAt(i))) {
sb.append("number");
}else sb.append(s.charAt(i));
}
System.out.println(sb);
}
}
String s = in.nextLine();
通过调用
in.nextLine()
方法,读取用户输入的整行文本。nextLine()
方法读取输入直到遇到换行符(回车键被按下),然后返回这一行输入的内容(不包括换行符)。
Character.isDigit(s.charAt(i))
检查它是否是数字
StringBuilder sb = new StringBuilder();
创建一个
StringBuilder
对象sb
。StringBuilder
是用来构建和修改字符串的一个类。与字符串(String
)不同,StringBuilder
允许在现有内容上添加或修改内容,而不需要每次都创建一个新的字符串对象。
System.out.println(sb);
最后,将
StringBuilder
对象sb
转换成字符串(这是在println
方法内部自动完成的,因为StringBuilder
的toString()
方法会被调用)并打印到标准输出。这将显示已经替换了所有数字字符为"number"
的新字符串。
注意细节
-
Character.isDigit(currentChar)
Character.isDigit(s.charAt(i))
判断字符是否为数字
-
StringBuilder sb = new StringBuilder();
创建一个
StringBuilder
对象sb
。StringBuilder
是用来构建和修改字符串的一个类。与字符串(String
)不同,StringBuilder
允许在现有内容上添加或修改内容,而不需要每次都创建一个新的字符串对象。 -
sb.append("number");
向StringBuilder
对象sb
的当前内容末尾追加字符串"number"
-
Scanner in = new Scanner(System.in);
String s = in.nextLine();
翻转字符串里的单词
题目:给定一个字符串,逐个翻转字符串中的每个单词。不要使用辅助空间,空间复杂度要求为O(1)。
将整个字符串都反转过来,那么单词的顺序指定是倒序了,只不过单词本身也倒序了,那么再把单词反转一下,单词不就正过来了。
所以解题思路如下:
-
移除多余空格
-
将整个字符串反转
-
将每个单词反转
StringBuilder操作
StringBuilder
是 Java 中一个非常有用的类,用于创建和操作动态字符串。相比于String
类,StringBuilder
提供了更加灵活和高效的方式来处理字符串,因为它允许在同一个对象上进行修改而不需要每次修改时都创建新的对象。下面是一些StringBuilder
类的常用方法:初始化
StringBuilder():创建一个不包含任何字符且初始容量为 16 个字符的
StringBuilder
实例。StringBuilder(int capacity):创建一个不包含任何字符但具有指定初始容量的
StringBuilder
实例。StringBuilder(String str):创建一个初始包含字符串
str
内容的StringBuilder
实例。添加和插入
append(Various Types):将参数的字符串表示形式追加到序列。
append
方法有多个重载版本,可以接受不同类型的参数,包括String
、char
、boolean
、int
、long
、float
、double
等。insert(int offset, Various Types):将参数的字符串表示形式插入到序列中的指定位置。
insert
方法也有多个重载版本,可以处理不同类型的数据。删除和替换
delete(int start, int end):移除序列中指定的子字符串。
deleteCharAt(int index):移除序列中指定位置的字符。
replace(int start, int end, String str):使用给定的字符串替换序列中指定的子字符串。
反转
reverse():将序列中的字符反转。
容量和长度
length():返回当前序列中字符的数量。
setLength(int newLength):设置序列的长度。如果新长度大于当前长度,则添加零字符;如果新长度小于当前长度,则移除超出新长度部分的字符。
capacity():返回当前容量(即能够容纳的最大字符数,不需要重新分配)。
ensureCapacity(int minimumCapacity):确保容量至少等于指定的最小值。如果当前容量小于
minimumCapacity
,则重新分配内存以增加容量。字符和子串
charAt(int index):返回序列中指定位置的字符。
substring(int start, int end):返回序列中指定子序列的新
String
对象。其他
toString():将序列转换为
String
。这是从StringBuilder
获取最终字符串结果的常用方法。
删除空格
使用 star<=end
而不是 start<end
使用
<=
保证了即使在start
和end
指向同一个字符时,这个字符也会被处理。这对于确保算法的正确性和完整性是必要的,特别是在字符串处理中经常需要考虑边界条件。实际例子
考虑字符串
" a "
:
移除首尾空格后,
start
指向索引1(字符'a'
),end
也指向索引1。如果条件是
while (start < end)
,循环体不会执行,因为start
等于end
,这会导致中间的'a'
被错误地忽略。如果条件是
while (start <= end)
,循环会执行,'a'
会被检查并正确地添加到StringBuilder
对象中。
class Solution {
/**
* 1.去除首尾以及中间多余空格
* 2.反转整个字符串
* 3.反转各个单词
*/
public String reverseWords(String s) {
// 去除首尾以及中间多余空格
StringBuilder sb = removeSpace(s);
// 反转整个字符串
reverseString(sb,0,sb.length()-1);
// 反转各个单词
reverseEachWord(sb);
return sb.toString();
}
public StringBuilder removeSpace(String s){
int start = 0;
int end = s.length()-1;
// 保证开头和结尾没有空格
while(s.charAt(start) == ' ') start++;
while(s.charAt(end) == ' ') end--;
StringBuilder sb = new StringBuilder();
while(start<=end){
char c = s.charAt(start);
// 遇到原 s 有不为空的,添加到 sb 中;或者 sb 的‘当前’字符 不为空,
if(c != ' ' || sb.charAt(sb.length() - 1) != ' '){
sb.append(c);
}
start++;
}
return sb;
}
public void reverseString(StringBuilder sb, int start ,int end){
while(start<end){
char temp = sb.charAt(start);
// sb.charAt(start) = sb.charAt(end); charAt(int index) 方法用于返回在指定索引处的字符。但是,这个方法仅用于获取字符,并不能直接用来设置或修改指定索引处的字符值
sb.setCharAt(start, sb.charAt(end));
sb.setCharAt(end, temp);
start++;
end--;
}
}
public void reverseEachWord(StringBuilder sb){
int start = 0;
int n = sb.length();
for(int end = 0; end <= n; end++){// 当 end < n 作为循环条件时,循环在到达字符串的最后一个字符之后停止,不会进入循环体。如果字符串最后一个单词后没有空格,这意味着这个单词将不会被反转,因为通常反转单词的触发条件是遇到空格或字符串末尾。使用 end <= n 作为条件,可以确保循环在字符串末尾之后的"虚拟位置"停止,使得循环体在 end == n 时能够执行。这个"额外的循环迭代"为我们提供了一个机会来检查并处理最后一个单词,即使它后面没有空格。这样,就能保证所有单词,包括位于字符串末尾的单词,都被反转。
// 当到达字符串末尾或遇到空格时,反转前面的单词
if(end == n || sb.charAt(end) == ' '){
reverseString(sb, start, end - 1);
start = end + 1;
}
}
}
// private void reverseEachWord(StringBuilder sb) {
// int start = 0;
// int end = 1;
// int n = sb.length();
// while (start < n) {
// while (end < n && sb.charAt(end) != ' ') {
// end++;
// }
// reverseString(sb, start, end - 1);
// start = end + 1;
// end = start + 1;
// }
// }
}
注意细节
-
移除多余空格
-
移除收尾空格
-
遍历:新建StringBuilder sb,(原s中不为空的添加到sb || sb的当前不为空的可以添加到sb(也就是在sb当前不为空时都可以填充,包括空格,这样保证一个空格后面只能填充字符))
-
-
setCharAt(int index, char ch)
charAt(int index)
方法用于返回在指定索引处的字符。但是,这个方法仅用于获取字符,并不能直接用来设置或修改指定索引处的字符值。因此,sb.charAt(start) = sb.charAt(end);
这种写法在Java中是不合法的,因为charAt
方法的返回值不是一个可以被赋值的左值。要修改
StringBuilder
或StringBuffer
中特定索引处的字符,应该使用setCharAt(int index, char ch)
方法。这个方法将指定索引处的字符替换为给定的字符。 -
记得返回
return sb.toString();
右旋字符串
题目:
字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s 和一个正整数 k,请编写一个函数,将字符串中的后面 k 个字符移到字符串的前面,实现字符串的右旋转操作。
例如,对于输入字符串 "abcdefg" 和整数 2,函数应该将其转换为 "fgabcde"。
输入:输入共包含两行,第一行为一个正整数 k,代表右旋转的位数。第二行为字符串 s,代表需要旋转的字符串。
输出:输出共一行,为进行了右旋转操作后的字符串。
思路:
将字符串分为【前】和【后n】,那么就把整体全部反转,然后将翻转后的【前n】和【后length-n】内部反转
异或交换
-
任何数与自身异或的结果是 0(
x ^ x = 0
)。 -
任何数与 0 异或的结果是其自身(
x ^ 0 = x
)。 -
异或运算满足交换律和结合律(
a ^ b ^ c = a ^ (b ^ c) = (a ^ b) ^ c
)。 -
如果我们有
a ^ b = c
,那么c ^ b = a
和c ^ a = b
。
while (start < end) {
ch[start] ^= ch[end];
ch[end] ^= ch[start];
ch[start] ^= ch[end];
start++;
end--;
}
假设
ch[start]
的值为A
,ch[end]
的值为B
。
ch[start] ^= ch[end];
执行后,ch[start]
变成了A ^ B
。
ch[end] ^= ch[start];
执行后,因为ch[end]
现在与A ^ B
异或,根据上述性质,它变成了B ^ (A ^ B) = A
(因为B ^ B = 0
,然后0 ^ A = A
)。
ch[start] ^= ch[end];
执行后,因为ch[start]
现在是A ^ B
,且ch[end]
是A
,所以ch[start]
变成了(A ^ B) ^ A = B
。
import java.util.Scanner;
public class Main{
public static void main(String[] args){
// 输入
Scanner in = new Scanner(System.in);
int n = Integer.parseInt(in.nextLine());
String s = in.nextLine();
// 分为前len-n与后n,整体反转,再分别反转
int len = s.length();
char[] chars = s.toCharArray();
reverseString(chars,0,len-1);
reverseString(chars,0,n-1);
reverseString(chars,n,len-1);
// 输出
System.out.println(chars);
}
public static void reverseString(char[] ch,int start, int end){
while(start<end){
ch[start] ^= ch[end];
ch[end] ^= ch[start];
ch[start] ^= ch[end];
start++;
end--;
}
}
}
// 如果使用StringBuilder
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
// 输入
Scanner in = new Scanner(System.in);
int n = Integer.parseInt(in.nextLine());
String s = in.nextLine();
// 使用StringBuilder来优化代码
StringBuilder sb = new StringBuilder(s);
// 整体反转
sb.reverse();
// 分别反转前n和后len-n部分
String firstPart = new StringBuilder(sb.substring(0, n)).reverse().toString();
String secondPart = new StringBuilder(sb.substring(n)).reverse().toString();
// 拼接
sb = new StringBuilder(firstPart + secondPart);
// 输出
System.out.println(sb.toString());
}
}
注意细节
-
异或交换方法
while (start < end) { ch[start] ^= ch[end]; ch[end] ^= ch[start]; ch[start] ^= ch[end]; start++; end--; }
-
反转要注意反转后结果的整体顺序与局部顺序
实现 strStr()
题目:实现 strStr() 函数。给定一个 haystack 字符串和一个 needle 字符串,在 haystack 字符串中找出 needle 字符串出现的第一个位置 (从0开始)。如果不存在,则返回 -1。
KMP
由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP
主要应用在字符串匹配上。,KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
前缀表
前缀表有什么作用呢?
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
为了清楚地了解前缀表的来历,我们来举一个例子:
要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。
可以看出,文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。
但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配。
要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
最长公共前后缀
字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
正确理解什么是前缀什么是后缀很重要!
因为前缀表要求的就是相同前后缀的长度。
而最长公共前后缀里面的“公共”,更像是说前缀和后缀公共的长度。这其实并不是前缀表所需要的。
所以字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2。 等等.....
为什么一定要用前缀表
这就是前缀表,那为啥就能告诉我们 上次匹配的位置,并跳过去呢?
回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:
然后就找到了下标2,指向b,继续匹配:如图:
以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
如何计算前缀表
长度为前1个字符的子串a
,最长相同前后缀的长度为0。(注意字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。)
长度为前2个字符的子串aa
,最长相同前后缀的长度为1。
长度为前3个字符的子串aab
,最长相同前后缀的长度为0。
以此类推: 长度为前4个字符的子串aaba
,最长相同前后缀的长度为1。 长度为前5个字符的子串aabaa
,最长相同前后缀的长度为2。 长度为前6个字符的子串aabaaf
,最长相同前后缀的长度为0。
对于字符串
aaba
,最长相同前缀和后缀的长度确实为 1,因为最长的相同前缀和后缀是a
。对于字符串
aabaa
,最长相同前缀和后缀的长度为 2,因为最长的相同前缀和后缀是aa
。对于字符串
aabaaf
,您询问为什么最长相同前缀和后缀的长度不是 2 而是 0。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
可以看出模式串与前缀表对应位置的数字表示的就是:下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少。
为什么要前一个字符的前缀表的数值呢,因为要找前面字符串的最长相同的前缀和后缀。
所以要看前一位的 前缀表的数值。
前一个字符的前缀表的数值是2, 所以把下标移动到下标2的位置继续比配。 可以再反复看一下上面的动画。
最后就在文本串中找到了和模式串匹配的子串了
使用next数组来匹配
(不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。)
以下我们以前缀表统一减一之后的next数组来做演示。
有了next数组,就可以根据next数组来 匹配文本串s,和模式串t了。
注意next数组是新前缀表(旧前缀表统一减一了)。
生动地解释next数组应用
构建Next数组:学习跳舞步骤
假设你在学习一段复杂的舞蹈,这段舞蹈有一个重复的核心动作序列,即模式串
pattern
。你希望能够在音乐中准确无误地找到这个动作序列的每一次出现。
学习动作(初始化):
你开始时没有任何动作记忆,所以
j=-1
,意味着如果第一步就错了,你需要从头开始。
next[0]=-1
表示没有更早的动作序列可以参考。记忆动作(构建Next数组):
想象每个动作都是一系列的步骤
i
。你开始执行每个步骤并试图记住它们如何连贯起来。当你发现下一个步骤
i
与当前记忆中的下一步j+1
不匹配时,你意识到可能跳错了。这时,你需要回退到之前的某个点j=next[j]
,即之前正确的连续动作序列的结束点,看看是否可以从那里恢复。如果下一步骤
i
与记忆中的下一步j+1
相符,说明你找到了一个更长的连贯动作序列。于是你的记忆点j
前进一步,j++
,表示这个连贯序列现在更长了。对于每个步骤
i
,next[i]=j
记录了在这一点你能回溯到的最长连贯动作序列的长度。通过这种方式,
next
数组帮助你记住每一步如果出错应该回退到哪里,以最快地恢复正确的舞步。使用Next数组:在舞会上找到舞蹈
现在,想象你在一个舞会上,音乐开始了,你想找到恰当的时机加入舞池,完美地融入那段复杂舞蹈的核心动作序列。
找到起始点:
你开始时站在舞池边缘,
j=-1
,意味着你还没开始跳舞。音乐里每个节拍对应你要检查的每个动作
i
。开始跳舞:
当音乐的下一个节拍
i
与你记忆中的下一个舞步j+1
匹配时,你就向前迈出一步,j++
,意味着你与音乐同步了。如果某一刻你发现节拍与你的舞步不匹配,你需要快速找到一个点重新开始,而不是完全停下来。这时,
j
跳转到next[j]
,即之前学过的最长连贯舞步序列,你尝试从那里重新开始匹配。完美融入:
一旦
j
达到模式串(舞蹈序列)的末尾,意味着你已经完美地执行了整段核心舞蹈。此时j == pattern.length() - 1
。你找到了加入舞池的完美时机,在音乐中完美地展现了那段复杂的舞蹈。
通过KMP算法,即使在舞会上音乐快速变化,你也能快速找到并融入你所学的那段舞蹈,而不会因为一两次的失误而完全失去节奏。
Java构造next数组
public class KMP {
/**
* 构造next数组。next数组用于记录模式串中每个位置之前的子串的最长相同前后缀的长度。
* 例如,对于模式串"ababa",next数组为[-1, 0, 0, 1, 2]。
*
* @param pattern 模式串
* @return next数组
*/
public static int[] getNext(String pattern) {
int len = pattern.length();
int[] next = new int[len];
next[0] = -1; // 初始化next[0]为-1,表示如果第一个字符就不匹配,没有相同的前后缀,匹配过程应该重新开始。
int j = -1; // j指针追踪已匹配前缀的最后一位,初始化为-1,因为开始时还没有匹配。
for (int i = 1; i < len; i++) { // 遍历模式串,从第二个字符开始,因为next[0]已定义。
// 循环回退直到找到一个位置j,使得pattern[j + 1]等于pattern[i],或j回退到-1
while (j >= 0 && pattern.charAt(i) != pattern.charAt(j + 1)) {
j = next[j]; // 回退过程中,j逐步跳转到之前已匹配的子串的前缀末端。
}
// 如果找到了相同的字符,即pattern[i] == pattern[j + 1],则最长相同前后缀长度加1。
if (pattern.charAt(i) == pattern.charAt(j + 1)) {
j++; // 匹配成功,j向后移动。
}
// 将当前计算得到的最长相同前后缀长度(即j的值)赋给next[i]。
next[i] = j; // 这里j代表的是前缀的长度,即相同前后缀的最大长度。
}
return next; // 返回构建好的next数组。
}
public static void main(String[] args) {
String pattern = "aabaaf";
int[] next = getNext(pattern); // 调用getNext方法,获取next数组。
System.out.println(Arrays.toString(next)); // 输出next数组,以便验证其正确性。
}
}
使用next数组来做匹配
public class KMP {
// 已有的getNext方法...
/**
* 使用next数组来匹配字符串
* @param s 文本串
* @param t 模式串
* @return 模式串在文本串中的起始位置,如果不存在则返回-1
*/
public static int kmpSearch(String s, String t) {
int[] next = getNext(t);
int j = -1; // 初始化j为-1,表示模式串的起始位置之前
for (int i = 0; i < s.length(); i++) { // 遍历文本串
// 不匹配时,使用next数组回退。直到找到匹配的位置,或j回退到-1
while (j >= 0 && s.charAt(i) != t.charAt(j + 1)) {
j = next[j]; // 回退操作
}
if (s.charAt(i) == t.charAt(j + 1)) { // 当前字符匹配,j和i同时向后移动
j++;
}
if (j == t.length() - 1) { // 检查是否找到了完整的模式串
return i - t.length() + 1; // 返回模式串在文本串中的起始位置
}
}
return -1; // 未找到匹配,返回-1
}
public static void main(String[] args) {
String s = "abcxabcdabcdabcy"; // 文本串
String t = "abcdabcy"; // 模式串
System.out.println("模式串在文本串中的起始位置: " + kmpSearch(s, t));
}
}
KMP法
class Solution {
/**
* 构造next数组。
* @param next 存放结果的数组
* @param s 模式串
*/
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j; // 初始化next数组的第一个元素为-1
for (int i = 1; i < s.length(); i++) { // 遍历模式串
while (j >= 0 && s.charAt(i) != s.charAt(j + 1)) { // 当前后缀末尾字符不相同
j = next[j]; // 回退到前一位置的最长相同前后缀的长度
}
if (s.charAt(i) == s.charAt(j + 1)) { // 当前后缀末尾字符相同
j++; // 前缀末尾位置增加1
}
next[i] = j; // 更新next数组
}
}
/**
* KMP算法实现字符串查找。
* @param haystack 文本串
* @param needle 模式串
* @return needle在haystack中的起始位置,未找到返回-1。
*/
public int strStr(String haystack, String needle) {
if (needle.length() == 0) return 0; // 空字符串特判
int[] next = new int[needle.length()];
getNext(next, needle); // 构造next数组
int j = -1; // j表示needle中已匹配的最后位置,初始化为-1
for (int i = 0; i < haystack.length(); i++) { // 遍历haystack
while (j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) { // 当前字符不匹配
j = next[j]; // 根据next数组回退
}
if (haystack.charAt(i) == needle.charAt(j + 1)) { // 当前字符匹配
j++; // 匹配位置增加
}
if (j == needle.length() - 1) { // 完全匹配needle
return (i - needle.length() + 1); // 返回匹配起始位置
}
}
return -1; // 未找到匹配
}
}
朴素解法1
class Solution { public int strStr(String ss, String pp) { int n = ss.length(), m = pp.length(); char[] s = ss.toCharArray(), p = pp.toCharArray(); // 枚举原串的「发起点」 for (int i = 0; i <= n - m; i++) { // 从原串的「发起点」和匹配串的「首位」开始,尝试匹配 int a = i, b = 0; while (b < m && s[a] == p[b]) { a++; b++; } // 如果能够完全匹配,返回原串的「发起点」下标 if (b == m) return i; } return -1; } }
朴素解法2
// 使用了equal、substring class Solution { public int strStr(String haystack, String needle) { // 获取haystack和needle的长度 int hlen = haystack.length(); int nlen = needle.length(); // 初始化结果为-1,表示默认未找到needle int result = -1; // 如果haystack的长度小于needle的长度,则needle不可能被包含在haystack中 if(hlen < nlen) { return -1; } // 从haystack的末尾开始向前遍历,直到达到剩余长度与needle长度相同的位置 // 这里使用hlen-nlen计算遍历的起始位置 for(int i = hlen - nlen; i >= 0; i--) { // 提取haystack中从i开始长度为nlen的子字符串 String temp = haystack.substring(i, i + nlen); // 比较提取的子字符串是否与needle相等 if(temp.equals(needle)) { // 如果相等,更新result为当前子字符串的起始位置i result = i; // 注意这里没有break语句,所以循环会继续执行 // 直到遍历完所有可能的起始位置 // 这意味着如果needle在haystack中多次出现 // result将被更新为最后一次匹配的起始位置 } } // 返回最终的结果,如果needle未在haystack中出现,则result保持为-1 return result; } }
注意细节
-
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。找到的不匹配的位置, 那么此时我们要看它的前一个字符的前缀表的数值是多少,从而找前面字符串的最长相同的前缀和后缀。
-
对于字符串
aaba
,最长相同前缀和后缀的长度确实为 1,因为最长的相同前缀和后缀是a
。 -
对于字符串
aabaa
,最长相同前缀和后缀的长度为 2,因为最长的相同前缀和后缀是aa
。 -
对于字符串
aabaaf
,您询问为什么最长相同前缀和后缀的长度不是 2 而是 0。
-
-
KMP法:前缀表,next数组,回退,更新
重复的子字符串
题目:
给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母,并且长度不超过10000。
KMP
class Solution {
public boolean repeatedSubstringPattern(String s) {
// 如果输入是空字符串,则直接返回false
if (s.equals("")) return false;
int len = s.length();
// 在原字符串前加一个空格作为哨兵,这样字符数组的下标就从1开始了
// 这个技巧使得算法的实现更加直观,同时也避免了一些边界条件的判断
s = " " + s;
char[] chars = s.toCharArray(); // 将字符串转换为字符数组
int[] next = new int[len + 1]; // next数组的长度比原字符串长度多1,以适应从1开始的下标
// 构造next数组的过程,其中j代表当前已匹配前缀的最后一个字符的下标,i从2开始迭代
for (int i = 2, j = 0; i <= len; i++) {
// 如果当前字符不匹配,根据next数组回退j的值
// 直到找到一个位置使得chars[i]和chars[j+1]匹配,或者j回退到0
while (j > 0 && chars[i] != chars[j + 1]) j = next[j];
// 如果找到匹配的字符,则将j往后移一位
if (chars[i] == chars[j + 1]) j++;
// 更新next数组在i位置的值为j
next[i] = j;
}
// 判断s是否由重复的子字符串构成
// next[len]存储的是整个字符串中最长的相等前后缀的长度
// 如果next[len]不为0,且s的长度能被(len-next[len])整除
// 则说明s可以完全由一个长度为(len-next[len])的子字符串重复构成
if (next[len] > 0 && len % (len - next[len]) == 0) {
return true;
}
return false;
}
}
KMP
class Solution { public boolean repeatedSubstringPattern(String s) { // 为空或单字符字符串,不可能由重复子串构成 if (s.length() <= 1) return false; int len = s.length(); int[] next = new int[len]; // 构造next数组 for (int i = 1, j = 0; i < len; i++) { // 当前后缀末尾字符不相同,j回到前一位置next数组所对应的值 while (j > 0 && s.charAt(i) != s.charAt(j)) { j = next[j - 1]; } // 匹配成功,j往后移 if (s.charAt(i) == s.charAt(j)) { j++; } // 更新next数组的值 next[i] = j; } // next数组的最后一个元素值表示最长相同前后缀的长度 int longestPrefixSuffix = next[len - 1]; // 如果最长相同前后缀的长度不为0,且s的长度能被其长度减去这个最长相等前后缀的长度整除 // 则说明s可以完全由一个长度为(len - next[len - 1])的子字符串重复构成 return longestPrefixSuffix > 0 && len % (len - longestPrefixSuffix) == 0; } }
枚举
class Solution { public boolean repeatedSubstringPattern(String s) { int n = s.length(); // 获取字符串的长度 // 遍历所有可能的子串长度i // 如果一个子串重复构成整个字符串,那么子串的长度肯定是字符串长度的因子 // 因此,只需要检查长度i,使得i是n的因子,且i不大于n的一半(i*2 <= n) for (int i = 1; i * 2 <= n; ++i) { // 检查n是否能被i整除,即i是否是n的因子 if (n % i == 0) { boolean match = true; // 假设当前子串可以构成整个字符串 // 遍历字符串,从第i个字符开始,每个字符都应该与它之前第i个字符相同 for (int j = i; j < n; ++j) { // 如果发现不匹配的字符,说明当前子串不能构成整个字符串 if (s.charAt(j) != s.charAt(j - i)) { match = false; // 标记为不匹配 break; // 退出当前循环 } } // 如果整个字符串都匹配,说明找到了可以重复构成整个字符串的子串 if (match) { return true; // 返回true } } } return false; // 如果没有找到任何可以重复构成整个字符串的子串,返回false } }
数组常用方法:
-
双指针
-
反转系列
-
移动窗口
-
KMP