代码随想录刷题day9丨151.翻转字符串里的单词,卡码网:55.右旋转字符串,28. 实现 strStr(), 459.重复的子字符串,字符串总结,双指针回顾
1.题目
1.1翻转字符串里的单词
-
题目链接:151. 反转字符串中的单词 - 力扣(LeetCode)
-
文档讲解:https://programmercarl.com/0151.%E7%BF%BB%E8%BD%AC%E5%AD%97%E7%AC%A6%E4%B8%B2%E9%87%8C%E7%9A%84%E5%8D%95%E8%AF%8D.html
-
解题思路:双指针法
-
我们将整个字符串都反转过来,那么单词的顺序指定是倒序了,只不过单词本身也倒序了,那么再把单词反转一下,单词不就正过来了
-
整体思路:
-
将整个字符串反转
-
移除多余空格
-
将每个单词反转
-
-
-
代码:
//时间复杂度O(n) //空间复杂度 O(1) class Solution { public String reverseWords(String s) { char[] ch = s.toCharArray(); //首先反转整个字符串 reverse(ch,0,s.length() -1); //慢指针 int slow = 0; //记录中间的slow,方便进行单词反转 int index = 0; for (int fast = 0; fast < ch.length; fast++) { if (ch[fast] != ' ') { if (slow != 0) { ch[slow] = ' '; slow++; } index = slow; while (fast < ch.length && ch[fast] != ' ') { ch[slow] = ch[fast]; fast++; slow++; } reverse(ch,index,slow-1);//反转每个单词,因为前面进行了slow++,所以这里需要减1 } } return new String(ch,0,slow);//返回修剪后的字符串 } // 定义反转函数 private void reverse(char[] s, int start, int end) { while (start < end) { char temp = s[start]; s[start] = s[end]; s[end] = temp; start++; end--; } } }
-
总结:
- 注意反转顺序,先进行整体反转,再在遍历过程中移除多余空格并进行单词反转
- 注意slow不在初始位置时,单词之间需要留有一个空格
1.2右旋转字符串
-
题目链接:55. 右旋字符串(第八期模拟笔试) (kamacoder.com)
-
文档讲解:https://programmercarl.com/kama55.%E5%8F%B3%E6%97%8B%E5%AD%97%E7%AC%A6%E4%B8%B2.html
-
解题思路:
-
先进行整体反转
-
再进行局部反转
-
-
代码:
import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner in = new Scanner(System.in); int n = in.nextInt(); String s = in.next(); int len = s.length(); //获取字符串长度 char[] ch = s.toCharArray(); reverse(ch, 0, len - 1); //反转整个字符串 reverse(ch, 0, n - 1); //反转前一段字符串 reverse(ch, n, len - 1); //反转后一段字符串 System.out.println(ch); } public static void reverse(char[] ch, int start, int end) { //异或法反转字符串 while (start < end) { ch[start] ^= ch[end]; ch[end] ^= ch[start]; ch[start] ^= ch[end]; start++; end--; } } }
-
总结:
- 先进行局部反转再进行整体反转也是可以的
1.3实现 strStr()
-
题目链接:28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
-
视频讲解:
- KMP算法(理论篇):帮你把KMP算法学个通透!(理论篇)_哔哩哔哩_bilibili
- KMP算法(代码篇):帮你把KMP算法学个通透!(求next数组代码篇)_哔哩哔哩_bilibili
-
文档讲解:https://programmercarl.com/0028.%E5%AE%9E%E7%8E%B0strStr.html
- n为文本串长度,m为模式串长度
-
解题思路:暴力解法或者KMP前缀表法(不减一)
-
KMP主要应用在字符串匹配上。
-
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
-
next数组就是一个前缀表(prefix table),前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
-
如果暴力匹配,发现不匹配,此时就要从头匹配了。但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配。
-
前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
-
什么是前缀表?
- 记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
- 前缀表要求的就是相同前后缀的长度
- 举例:字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2
-
为什么一定要用前缀表?
-
回顾一下,刚刚匹配的过程在下标5的地方遇到不匹配,模式串是指向f,如图:
-
然后就找到了下标2,指向b,继续匹配:如图:
-
以下这句话,对于理解为什么使用前缀表可以告诉我们匹配失败之后跳到哪里重新匹配 非常重要!
-
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
-
所以前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
-
为什么要看前一个字符的前缀表的数值?
- 因为要找前面字符串的最长相同的前缀和后缀。
-
-
-
时间复杂度分析
- 其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。
- 暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率
-
构造next数组其实就是计算模式串s,前缀表的过程。 主要有如下三步:
-
初始化
-
处理前后缀不相同的情况
-
处理前后缀相同的情况
-
填充
next
数组:- 循环遍历字符串
needle
,对每个字符needle[i]
,尝试扩展当前的匹配。 - 如果遇到不匹配,则使用
next
数组跳转到前一个最长匹配前缀的位置。 - 如果匹配成功,增加
j
的值,并将其存储在next[i]
中。
- 循环遍历字符串
-
-
如何使用next数组来做匹配?
-
在文本串里找是否出现过模式串
-
匹配逻辑:
- 如果
needle[j]
和haystack[i]
不匹配,算法利用next
数组跳过一些不必要的比较。 - 如果
needle[j]
和haystack[i]
匹配,则同时递增i
和j
。 - 当
j
等于needle
的长度时,表示needle
已经完全匹配在haystack
中,此时返回匹配的起始索引。
- 如果
-
-
-
代码:
-
暴力解法
//时间复杂度 O(n * m) //空间 O(n + m) class Solution { /** 牺牲空间,换取最直白的暴力法 */ public int strStr(String haystack, String needle) { // 获取 haystack 和 needle 的长度 int n = haystack.length(), m = needle.length(); // 将字符串转换为字符数组,方便索引操作 char[] s = haystack.toCharArray(), p = needle.toCharArray(); // 遍历 haystack 字符串 for (int i = 0; i < n - m + 1; i++) { // 初始化匹配的指针 int a = i, b = 0; // 循环检查 needle 是否在当前位置开始匹配 while (b < m && s[a] == p[b]) { // 如果当前字符匹配,则移动指针 a++; b++; } // 如果 b 等于 m,说明 needle 已经完全匹配,返回当前位置 i if (b == m) return i; } // 如果遍历完毕仍未找到匹配的子串,则返回 -1 return -1; } }
-
KMP前缀表法(不减一)
//时间复杂度: O(n + m) //空间复杂度: O(m) class Solution { public int strStr(String haystack, String needle) { if(needle.length() == 0){ return 0; } int[] next = new int[needle.length()]; getNext(next,needle); int j = 0; for(int i = 0;i < haystack.length();i++){ while(j > 0 && needle.charAt(j) != haystack.charAt(i)){ j = next[j -1]; } if(needle.charAt(j) == haystack.charAt(i)){ j++; } if(j == needle.length()){ return i - needle.length() + 1; } } return -1; } private void getNext(int[] next, String s){ int j = 0; next[0] = 0; for(int i = 1;i < s.length();i++){ while(j > 0 && s.charAt(j) != s.charAt(i)){ j = next[j -1]; } if(s.charAt(j) == s.charAt(i)){ j++; next[i] = j; } } } }
-
-
总结:
- 很多KMP算法的实现都是使用next数组来做回退操作,那么next数组与前缀表有什么关系呢?
- next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
- 为什么这么做呢?
- 其实这并不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表;也可以是前缀表统一减一;也可以(右移一位,初始位置设为-1)。
- 区别只是遇见冲突之后找的方式不一样而已,我们习惯直接把前缀表当做我们的next数组
- 很多KMP算法的实现都是使用next数组来做回退操作,那么next数组与前缀表有什么关系呢?
1.4重复的子字符串
-
题目链接:459. 重复的子字符串 - 力扣(LeetCode)
-
文档讲解:https://programmercarl.com/0459.%E9%87%8D%E5%A4%8D%E7%9A%84%E5%AD%90%E5%AD%97%E7%AC%A6%E4%B8%B2.html
-
解题思路:暴力解法 或者移动匹配法 或者KMP解法
-
暴力解法: 就是一个for循环获取 子串的终止位置, 然后判断子串是否能重复构成字符串,又嵌套一个for循环,所以是O(n^2)的时间复杂度
- 怎么一个for循环就可以获取子串吗?至少得一个for获取子串起始位置,一个for获取子串结束位置吧?
- 其实我们只需要判断,以第一个字母为开始的子串就可以,所以一个for循环获取子串的终止位置就行了。 而且遍历的时候 都不用遍历结束,只需要遍历到中间位置,因为子串结束位置大于中间位置的话,一定不能重复组成字符串。
- 怎么一个for循环就可以获取子串吗?至少得一个for获取子串起始位置,一个for获取子串结束位置吧?
-
移动匹配法
-
KMP解法
-
在一个串中查找是否出现过另一个串,这是KMP的看家本领。
-
寻找重复子串怎么也涉及到KMP算法了呢?
-
KMP算法中next数组为什么遇到字符不匹配的时候可以找到上一个匹配过的位置继续匹配,靠的是有计算好的前缀表。 前缀表里,统计了各个位置为终点字符串的最长相同前后缀的长度。
-
在由重复子串组成的字符串中,最长相等前后缀不包含的子串就是最小重复子串
-
数组长度减去最长相同前后缀的长度相当于是第一个周期的长度,也就是一个周期的长度,如果这个周期可以被整除,就说明整个数组就是这个周期的循环。
-
-
-
-
代码:
//KMP解法 //时间复杂度: O(n) //空间复杂度: O(n) class Solution { public boolean repeatedSubstringPattern(String s) { if(s.equals("")){ return false; } int len = s.length(); int[] next = new int[len]; // 构造 next 数组,i从1开始遍历字符串,j初始化为0 int j = 0; for (int i = 1; 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[len - 1] 即 next 数组末尾的值 if (next[len - 1] > 0 && len % (len - next[len - 1]) == 0) { return true; } return false; } }
-
总结:
-
判断逻辑详解:
if (next[len - 1] > 0 && len % (len - next[len - 1]) == 0) { return true; }
- 这里分为两部分:
next[len - 1] > 0
:- 这意味着字符串
s
存在一个非空的前缀,这个前缀同时也是字符串的后缀。如果next[len - 1] == 0
,表示字符串没有这样的前缀和后缀,它不可能由重复子串构成。
- 这意味着字符串
len % (len - next[len - 1]) == 0
:- 这个条件的作用是检查字符串
s
的长度len
是否能被len - next[len - 1]
整除。 len - next[len - 1]
是什么?- 这个值表示“
s
除去其最长相同前后缀后的剩余部分的长度”,也就是可能的最短重复子串的长度。 - 如果
s
的长度len
可以被这个值整除,那么s
就可以被看作是由一个长度为len - next[len - 1]
的子串重复多次构成的。
- 这个值表示“
- 为什么要整除?
- 如果
len
能整除len - next[len - 1]
,这意味着s
可以被均匀地分割成几个相同的子串。换句话说,整个字符串是由某个子串多次重复构成的。
- 如果
- 这个条件的作用是检查字符串
- 这里分为两部分:
-
1.5字符串总结
- 字符串是若干字符组成的有限序列,也可以理解为是一个字符数组
- 很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作
- 针对数组删除操作的问题,使用双指针法进行移除操作
- 当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章
- 先整体反转再局部反转,实现了反转字符串里的单词
- 先局部反转再整体反转可以达到左旋的效果
- "左旋"通常指的是将数组中的元素依次向左移动一个或多个位置,将移出的元素重新放到数组的末尾。
- KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
- KMP的精髓所在就是前缀表
- 前缀表:起始位置到下标i之前(包括i)的子串中,有多大长度的相同前缀后缀。习惯用作next表
- 前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串。
- 后缀:指不包含第一个字符的所有以最后一个字符结尾的连续子串。
- 主要理解j=next[x]这一步最为关键!
- KMP可以解决两类经典问题:
- 匹配问题
- 重复子串问题
- KMP的精髓所在就是前缀表
- 双指针法是字符串处理的常客。
1.6双指针回顾
- 双指针法在数组,链表和字符串中很常用。
- 使用双指针法才展现出效率的优势:通过两个指针在一个for循环下完成两个for循环的工作。
- 在删除冗余空格的过程中,如果不注意代码效率,很容易写成了O(n^2)的时间复杂度。其实使用双指针法O(n)就可以搞定。
- 使用双指针法来翻转链表,只需要改变链表的next指针的指向,直接将链表反转 ,而不用重新定义一个新的链表。
- 链表中求环,通过双指针判断是否有环,而且还要找到环的入口。
- 使用快慢指针(双指针法),分别定义 fast 和 slow指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。
- 除了链表一些题目一定要使用双指针,其他题目都是使用双指针来提高效率,一般是将O(n^2)的时间复杂度,降为 O(n)