字符串操作其实和数组的操作是差不多的,本篇文章也是以做笔记为主,主要记录一下遇到字符串题型的几种做法。
1、双指针
2、反转系列
3、kmp
目录
一、双指针法
字符串本质也是序列,可以用数组的常规做法去做,或者将字符串转换成数组来处理。
比如经典的字符串翻转。344. 反转字符串 - 力扣(LeetCode)
用到的就是双指针法,通过首尾两个指针指向的字符相互交换,使首尾指针不断靠近,最终翻转整个字符串。
需要注意的点在进入while()的判断条件,是选择左闭右开还是左闭右闭。
左闭右开,则为while(left <= right),因为left和right可以相等;
左闭右闭,则为while(left < right),因为left和right不可以相等。
代码如下
class Solution {
public void reverseString(char[] s) {
// 双指针 左闭右闭
int left = 0;
int right = s.length - 1;
while (left <= right) {
char t = s[left];
s[left] = s[right];
s[right] = t;
left++;
right--;
}
}
}
二、翻转系列
翻转系列可以说是字符串的常考类型,比较常见的类型是隔几个翻转一次,以及整体反转 + 局部反转。
2.1、间隔反转
题目参考LC_541.
这道题读懂题目很重要,题目描述是隔2k个字符就反转一次前k个字符,同时每次要判断剩余字符的个数,并作出不同的处理。
隔2k个字符就反转一次前k个字符,其实就是每隔k个字符,反转k个字符。
剩余的不足k个则全部反转 大于k个则继续反转前k个
那么这道题可以分为几部去解决:
1、既然每隔2k个反转一次,那么可以考虑在循环中将步长调整为2k,这样i就是反转区间的左边界,这样左边界是一定能确定下来的。
2、反转右边界会根据字符串剩余长度发生改变,因此每次循环时都要判断右边界是否超过了字符串长度,如果超过了,说明剩余字符已经不足k个了,则全部反转,然后下次直接退出循环;如果没超过,说明剩余字符大于等于k个,那么就反转前k个。
3、用双指针法反转指定区间的字符串。
这样可以保证长度不为0的字符串最少可以反转一次。
具体代码如下
class Solution {
public String reverseStr(String s, int k) {
char[] c = s.toCharArray();
int right = 0;
// 每隔k个反转k个 剩余的不足k个则全部反转 大于k个则反转前k个
for (int i = 0; i < s.length(); i += 2 * k) { // 步长为2k,也是左边界
right = i + k - 1; // 反转区间的右边界
if (i + k <= s.length()) { // 至少有k个 直接反转前k个
reverse(c, i, right);
}else reverse(c, i, s.length() - 1); // 否则反转剩余全部
}
return new String(c);
}
}
也可以换一种思路,直接看right与字符串长度的大小关系,选小的那个作为新的右边界。
class Solution {
public String reverseStr(String s, int k) {
char[] c = s.toCharArray();
int right = 0;
// 每隔k个反转k个 剩余的不足k个则全部反转 大于k个则反转前k个
for (int i = 0; i < s.length(); i += 2 * k) { // 步长为2k,也是左边界
right = i + k - 1;
// 换种思路 剩余字符如果小于2k 则下次循环会直接退出
// 此次循环 当前右边界如果在长度范围内,说明至少有k个,右边界为right,反转前k个
// 当前右边界如果不在长度范围内 则剩余字符全部反转 也就是右边界为s.length() - 1;
// 因此 可以比较right和s.length() - 1 选小的那个为新的右边界
int newRight = Math.min(right, s.length() - 1);
reverse(c, i, newRight);
}
return new String(c);
}
}
2.2、全局反转 + 局部反转
这里以LC_151以及剑指offer_58为例。
剑指 Offer 58 - II. 左旋转字符串 - 力扣(LeetCode)
2.2.1、反转字符串的单词
这道题最直接的做法就是,去除首位空格,再用spilt + 反向写的方式,需要使用库函数。
class Solution {
public String reverseWords(String s) {
int start = 0;
int end = s.length() - 1;
// 记录首尾第一次不为空格的字符的下标
while (start < s.length() && s.charAt(start) == ' ') start++;
while (end >= 0 && s.charAt(end) == ' ') end--;
String s1 = s.substring(start, end + 1); // 切割
if (s1.length() == 0) return ""; // 空串 直接返回
String[] spl = s1.split(" +"); // split分割 参数为正则 匹配多个空格
StringBuilder sb = new StringBuilder();
for (int i = spl.length - 1; i > 0; i--) { // 反着加
sb.append(spl[i]); // 每加一个单词就加一个空格
sb.append(" ");
}
sb.append(spl[0]); // 第一个单词加进去
return sb.toString();
}
}
但如果要在原字符串上进行操作,并且不使用库函数,难度会稍微大一些。
去除空格的思路还是一样,先去除首尾冗余空格,再去除中间一些多余的空格,保留每个单词之间只有一个空格间隙。
public void deleteSpace(StringBuilder str, String s) {
int end = s.length() - 1;
int start = 0;
String temp = "";
while (start < s.length() && s.charAt(start) == ' ') {
start++;
}
while (end >= 0 && s.charAt(end) == ' ') {
end--;
}
// 处理中间空格
while (start <= end) { // <= 取到所有字符
char ch = s.charAt(start);
// 若获取的字符不为空或者新的字符串最后一个不为空格 则插入字符
if (ch != ' ' || str.charAt(str.length() - 1) != ' ') {
str.append(ch);
}
start++;
}
}
此时就只剩反转问题了,如何将单词的顺序反转?
这里就可以用全局反转 + 局部反转的思路
比如字符串" the sky is blue "
- 移除多余空格 : "the sky is blue"
- 字符串反转:"eulb si yks eht"
- 单词反转:"blue is sky the"
全局反转可以用首尾双指针,轻车熟路了。
局部反转,即反转每一个单词,可以使用快慢指针,当发现空格时,就确定下来一个单词,然后再整体反转这个单词就行。
public void reverseEachWord(StringBuilder s) {
// 快慢指针反转
int low = 0;
int fast = 1;
while (fast < s.length()) {
// fast指向空格就停止
while (fast < s.length() && s.charAt(fast) != ' ') {
fast++;
}
reverse(s, low, fast - 1); // 反转该单词
low = fast + 1; // 更新指针指向 保持与循环前的位置一致
fast = low + 1;
}
}
整体代码如下
class Solution {
public String reverseWords(String s) {
// 副本字符串
StringBuilder str = new StringBuilder();
// 去除多余空格 即首尾多余空格
deleteSpace(str,s);
// 字符串反转 按照指定区间
reverse(str,0,str.length() - 1);
// 字符串局部翻转 每个单词反转
reverseEachWord(str);
return str.toString();
}
public void deleteSpace(StringBuilder str, String s) {
int end = s.length() - 1;
int start = 0;
String temp = "";
boolean flag = true;
while (start < s.length() && s.charAt(start) == ' ') {
start++;
}
while (end >= 0 && s.charAt(end) == ' ') {
end--;
}
// 处理中间空格
while (start <= end) { // <= 取到所有字符
char ch = s.charAt(start);
// 若获取的字符不为空或者新的字符串最后一个不为空格 则插入字符
if (ch != ' ' || str.charAt(str.length() - 1) != ' ') {
str.append(ch);
}
start++;
}
}
public void reverse(StringBuilder s, int start, int end) {
// 双指针反转
int len = s.length();
while (start < end) {
char temp = s.charAt(start);
s.setCharAt(start, s.charAt(end));
s.setCharAt(end, temp);
end--;
start++;
}
}
public void reverseEachWord(StringBuilder s) {
// 快慢指针反转
int low = 0;
int fast = 1;
while (fast < s.length()) {
while (fast < s.length() && s.charAt(fast) != ' ') {
fast++;
}
reverse(s, low, fast - 1);
low = fast + 1;
fast = low + 1;
}
}
}
2.2.2、左旋字符串
观察发现,也可以用全局反转 + 局部反转
代码如下
class Solution {
public String reverseLeftWords(String s, int n) {
// 整体反转 + 局部反转
char[] c = s.toCharArray();
reverseString(c, 0, c.length - 1); // 全局反转
reverseString(c, 0, c.length - n - 1); // 反转前length - n个
reverseString(c, c.length - n, c.length - 1); // 反转倒数n个
return new String(c);
}
public void reverseString(char[] s, int left, int right) {
// 双指针 左闭右闭
while (left <= right) {
char t = s[left];
s[left] = s[right];
s[right] = t;
left++;
right--;
}
}
}
三、KMP算法
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。
以LC_28为例
28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
这道题可以用两层循环直接去匹配,每次匹配上模式串的首字符时,就继续往下匹配,如果能匹配到尾,则就算匹配完成,否则就退出继续迭代文本串进行匹配。
代码如下
class Solution {
public int strStr(String haystack, String needle) {
int len1 = haystack.length();
int len2 = needle.length();
if (len1 < len2) return -1;
for (int i = 0; i < len1; i++) {
if (haystack.charAt(i) == needle.charAt(0)) { // 第一个匹配上了
int index = 1;
while (index < len2 && index + i < len1) { // 看看后边还能不能匹配上
if (haystack.charAt(index + i) != needle.charAt(index)) break;
index++;
}
if (index == len2) return i; // 说明遍历完了needle 找到了第一次出现的位置
}
}
return -1;
}
}
这种做法比较直接,一旦匹配不上,之后再想匹配就要从头开始遍历模式串needle。
而kmp的核心,就是避免从头再去做匹配。
用卡哥的动画来描述kmp算法非常清晰。
那么现在要解决的问题就是,当匹配不上的时候,如何知道该回退到哪个地方,才不至于从头开始?
kmp的做法是:使用前缀表记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
3.1、最长公共前后缀
最长公共前后缀的概念:
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
比如aaba,前缀包括a、aa、aab;后缀包括aba、ba、a。
那么最长公共前后缀为a,长度为1。
那么当匹配失败时,怎么进行回退?前缀表记录的是最长公共前后缀,匹配失败的位置是后缀子串的后面,那么只要找到与其相同的前缀的后面重新匹配就可以了。
通俗点说,最长相等后缀在前缀中已经匹配过了,所以直接在最长前缀的后面再往后匹配就行,因此避免了从头开始匹配。
3.2、计算前缀表
前缀表如果不减去1,那么就next数组与前缀表就是相同的。
以下就是求模式串的next数组,之后通过next数组去进行匹配。
next[i]表示:下标i之前(包括i)的字符串中,最长的公共前后缀长度。
直接贴出代码实现
public void getNext(String s, int[] next) {
int j = 0;
next[0] = j;
for (int i = 1; i < next.length; i++) {
// 注意是while 匹配不上就一直回退
while (j > 0 && s.charAt(i) != s.charAt(j)) {
// 回退
j = next[j - 1];
}
if (s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;
}
}
得到了next数组后,就用next数组来进行匹配。
3.3、用next数组进行匹配
匹配的逻辑大致分为三步:
1、若文本串与字符串的字符匹配不上,那么j要一直回退。
2、若文本串与字符串的字符匹配上了,则 i 和 j 一起往后走。
3、如果 j == needle.length(),则说明模式串已经匹配完成了,直接返回模式串首次出现位置。
完整代码如下
class Solution {
public int strStr(String haystack, String needle) {
if (needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
getNext(needle, next);
// 用获取到的next进行匹配
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++;
}
if (j == needle.length()) {
return i - needle.length() + 1; // 注意这里要 +1
}
}
return -1;
}
public void getNext(String needle, int[] next) {
// 定义两个下标 i指向后缀末尾 j指向前缀末尾 且next数组整体要减一
int j = 0;
next[0] = j;
for (int i = 1; i < next.length; i++) { // 需要j的位置与i比较 则i要从1位置开始 指向后缀末尾
// 处理前后缀末尾不相同的情况
// 要求最长公共缀,所以遇到相同的就停下
while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
// 让j回退
j = next[j - 1];
}
// 处理前后缀相同的情况
if (needle.charAt(i) == needle.charAt(j)) {
j++;
}
// next数组记录前后缀的公共长度
next[i] = j;
}
}
}