字符串:总结篇
从字符串的定义到库函数的使用原则,从各种反转到KMP算法
什么是字符串
字符串是若干字符组成的有限序列,也可以理解为是一个字符数组。
反转系列
344-反转字符串
**编写一个函数,其作用是将输入的字符串反转过来。**输入字符串以字符数组 char[] 的形式给出。不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。
如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
class Solution {
public void reverseString(char[] s) {
int n = s.length;
for (int left = 0,right = n-1; left < right ; left++,right--) {
char tmp = s[left];
s[left] = s[right];
s[right] = tmp;
}
}
}
复杂度分析
时间复杂度:O(N),其中 N 为字符数组的长度。一共执行了 N/2 次的交换。
空间复杂度:O(1)。只使用了常数空间来存放若干变量。
541-反转字符串||
给定一个字符串 s 和一个整数 k,你需要对从字符串开头算起的每隔 2k 个字符的前 k 个字符进行反转。
如果剩余字符少于 k 个,则将剩余字符全部反转。
如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
**思路:**其实在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。因为要找的也就是每2 * k 区间的起点,这样写程序会高效很多。
/**
* 方法 1:暴力
* 直接翻转每个 2k 字符块。每个块开始于 2k 的倍数,也就是 0, 2k, 4k, 6k, ...。需要注意的一件是:如果没有足够的字符,我们并不需要翻转这个块。
*
*/
class Solution {
public String reverseStr(String s, int k) {
if (s == null || s.length() == 0 || k <= 1) return s;
char[] chars = s.toCharArray();
int length = chars.length;
int start = 0, end = start + k - 1;
while (start <= length - 1) {
end = end > length - 1 ? length - 1 : end;
int originStart = start;
while (start < end) {
char tmp = chars[start];
chars[start] = chars[end];
chars[end] = tmp;
start++;
end--;
}
start = originStart + 2 * k;
end = start + k - 1;
}
return new String(chars);
}
}
复杂度分析
时间复杂度:O(N),其中 N 是 s 的大小。我们建立一个辅助数组,用来翻转 s 的一半字符。
空间复杂度:O(N),a 的大小。
151-翻转字符串里的单词
给定一个字符串,逐个翻转字符串中的每个单词。
示例 1:
输入: “the sky is blue”
输出: “blue is sky the”
这道题目通过 先整体反转再局部反转,实现了反转字符串里的单词。
class Solution {
public String reverseWords(String s) {
StringBuilder sb = trimSpaces(s);
// 翻转字符串
reverse(sb, 0, sb.length() - 1);
// 翻转每个单词
reverseEachWord(sb);
return sb.toString();
}
public StringBuilder trimSpaces(String s) {
int left = 0, right = s.length() - 1;
// 去掉字符串开头的空白字符
while (left <= right && s.charAt(left) == ' ') {
++left;
}
// 去掉字符串末尾的空白字符
while (left <= right && s.charAt(right) == ' ') {
--right;
}
// 将字符串间多余的空白字符去除
StringBuilder sb = new StringBuilder();
while (left <= right) {
char c = s.charAt(left);
if (c != ' ') {
sb.append(c);
} else if (sb.charAt(sb.length() - 1) != ' ') {
sb.append(c);
}
++left;
}
return sb;
}
public void reverse(StringBuilder sb, int left, int right) {
while (left < right) {
char tmp = sb.charAt(left);
sb.setCharAt(left++, sb.charAt(right));
sb.setCharAt(right--, tmp);
}
}
public void reverseEachWord(StringBuilder sb) {
int n = sb.length();
int start = 0, end = 0;
while (start < n) {
// 循环至单词的末尾
while (end < n && sb.charAt(end) != ' ') {
++end;
}
// 翻转单词
reverse(sb, start, end - 1);
// 更新start,去找下一个单词
start = end + 1;
++end;
}
}
}
/**
* 方法一:使用语言特性
*使用 split 将字符串按空格分割成字符串数组;
*使用 reverse 将字符串数组进行反转;
*使用 join 方法将字符串数组拼成一个字符串。
*/
class Solution {
public String reverseWords(String s) {
// 除去开头和末尾的空白字符
s = s.trim();
// 正则匹配连续的空白字符作为分隔符分割
List<String> wordList = Arrays.asList(s.split("\\s+"));
Collections.reverse(wordList);
return String.join(" ", wordList);
}
}
复杂度分析
时间复杂度:O(N),其中 N 为输入字符串的长度。
空间复杂度:Java 和 Python 的方法需要 O(N)的空间来存储字符串,
剑指Offer58-II-左旋转字符串
字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。请定义一个函数实现字符串左旋转操作的功能。比如,输入字符串"abcdefg"和数字2,该函数将返回左旋转两位得到的结果"cdefgab"。
示例 1:
输入: s = “abcdefg”, k = 2
输出: “cdefgab”
class Solution {
public String reverseLeftWords(String s, int n) {
return s.substring(n, s.length()) + s.substring(0, n);
}
}
class Solution {
public String reverseLeftWords(String s, int n) {
StringBuilder res = new StringBuilder();
for(int i = n; i < s.length(); i++)
res.append(s.charAt(i));
for(int i = 0; i < n; i++)
res.append(s.charAt(i));
return res.toString();
}
}
时间复杂度 O(N): 线性遍历 s 并添加,使用线性时间;
空间复杂度 O(N) : 新建的辅助 res 使用O(N) 大小的额外空间。
双指针法
双指针法在数组,链表和字符串中很常用。其实很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
剑指Offer 05-替换空格
请实现一个函数,把字符串 s 中的每个空格替换成"%20"。
示例 1:
输入:s = “We are happy.”
输出:“We%20are%20happy.”
/**
* 方法一:字符数组
* 由于每次替换从 1 个字符变成 3 个字符,使用字符数组可方便地进行替换。
* 建立字符数组地长度为 s 的长度的 3 倍,这样可保证字符数组可以容纳所有替换后的字符。
*
*/
class Solution {
public String replaceSpace(String s) {
int length = s.length();
char[] array = new char[length * 3];
int size = 0;
for (int i = 0; i < length; i++) {
char c = s.charAt(i);
if (c == ' ') {
array[size++] = '%';
array[size++] = '2';
array[size++] = '0';
} else {
array[size++] = c;
}
}
String newStr = new String(array, 0, size);
return newStr;
}
}
复杂性分析
- 时间复杂度:O(n)。遍历字符串
s
一遍。 - 空间复杂度:O(n)。额外创建字符数组,长度为
s
的长度的 3 倍。
KMP
算法:最长相等前后缀
前缀:不包含最后一个字母的子串
后缀:不包含首字母的子串
前缀表:aabaaf ---->0 1 0 1 2 0 next数组,其中的2就是最长子串,到时候从下标为2的地方再开始匹配就可。
a 0
aa 1
aab 0
aaba 1
aabaa 2
aabaaf 0
代码实现:关于next数组的实现https://www.bilibili.com/video/BV1M5411j7Xx/?spm_id_from=333.788.recommend_more_video.-1
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。
KMP的精髓所在就是前缀表,在字符串:KMP是时候上场了(一文读懂系列)中提到了,什么是KMP,什么是前缀表,以及为什么要用前缀表。
前缀表:起始位置到下表i之前(包括i)的子串中,有多大长度的相同前缀后缀。
那么使用KMP可以解决两类经典问题:
- 匹配问题:28. 实现 strStr()
- 重复子串问题:459.重复的子字符串
在字符串:听说你对KMP有这些疑问? 强调了什么是前缀,什么是后缀,什么又是最长相等前后缀。
前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀:指不包含第一个字符的所有以最后一个字符结尾的连续子串。
然后针对前缀表到底要不要减一,这其实是不同KMP实现的方式,我们在字符串:前缀表不右移,难道就写不出KMP了?中针对之前两个问题,分别给出了两个不同版本的的KMP实现。
其中主要理解j=next[x]这一步最为关键!
28- 实现 strStr()
实现 strStr() 函数。
给定一个 haystack 字符串和一个 needle 字符串,在 haystack 字符串中找出 needle 字符串出现的第一个位置 (从0开始)。如果不存在,则返回 -1。
示例 1:
输入: haystack = “hello”, needle = “ll”
输出: 2
//**复杂度分析 KMP**
//时间复杂度:O((N+M),匹配过程的时间复杂度为O(n),计算next的O(m)时间,两个独立的环节串行,所以整体时间复杂度为O(m + n)。
//空间复杂度:O(n)。
class Solution {
public int strStr(String haystack, String needle) {
int tn = haystack.length();
int pn = needle.length();
if (pn == 0) return 0;
if (tn < pn) return -1;
char[] ts = haystack.toCharArray(),ps = needle.toCharArray();
return kmp(ts,ps,tn,pn);
}
private int kmp(char[] ts,char[] ps, int tn, int pn){
int[] next = getNext(ps,pn);
int j = -1; // 因为next数组里记录的起始位置为-1
for (int i = 0; i < tn; i++) { // 注意i就从0开始
while (j >= 0 && ts[i] != ps[j + 1]){ //发现不匹配的字符,然后根据 next 数组移动指针,移动到最大公共前后缀的,前缀的后一位
j = next[j]; // j 寻找之前匹配的位置
}
if(ts[i] == ps[j + 1]){ // 匹配,j和i同时向后移动
j++;
}
if(j == pn - 1) return i - pn + 1; // 文本串s里出现了模式串t
}
return -1;
}
private int[] getNext(char[] ps,int pn){
int[] next = new int[pn];
int j = -1;
next[0] = j;
for (int i = 1; i < pn; i++) { // 注意i从1开始
while (j >= 0 && ps[i] != ps[j + 1]){ // 前后缀不相同了
j = next[j]; // 前后缀不相同了
}
if (ps[i] == ps[j + 1]){
j++; // 找到相同的前后缀
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
return next;
}
}
/**
* 方法一:暴力解法
* 最直接的方法 - 沿着字符换逐步移动滑动窗口,将窗口内的子串与 needle 字符串比较。
*
* class Solution {
*
* public int strStr(String haystack, String needle) {
* int L = needle.length();
* int n = haystack.length();
* for (int start = 0; start < n - L + 1; start++) {
* if (haystack.substring(start, start + L).equals(needle)) {
* return start;
* }
* }
* return -1;
* }
* }
*
复杂度分析
时间复杂度:O((N - L)L),其中 N 为 haystack 字符串的长度,L 为 needle 字符串的长度。内循环中比较字符串的复杂度为 L,总共需要比较 (N - L) 次。
空间复杂度:O(1)。
*/
459-重复的子字符串
给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母,并且长度不超过10000。
示例 1:
输入: “abab”
输出: True
解释: 可由子字符串 “ab” 重复两次构成。
/**
* kmp算法衍生:
*
* 最长相等前后缀的长度为:next[len - 1] + 1。
*
* 数组长度为:len。
*
* 如果len % (len - (next[len - 1] + 1)) == 0 ,则说明 (数组长度-最长相等前后缀的长度) 正好可以被数组的长度整除,说明有该字符串有重复的子字符串。
*/
class Solution {
public boolean repeatedSubstringPattern(String s) {
if(s.length() == 0) return false;
char[] ps = s.toCharArray();
int len = s.length();
int[] next = getNext(ps,len);
if (next[len - 1] != -1 && len % (len - (next[len - 1 ] +1)) == 0){
return true;
}
return false;
}
private int[] getNext(char[] ps,int pn){
int[] next = new int[pn];
int j = -1;
next[0] = j;
for (int i = 1; i < pn; i++) { // 注意i从1开始
while (j >= 0 && ps[i] != ps[j + 1]){ // 前后缀不相同了
j = next[j]; // 前后缀不相同了
}
if (ps[i] == ps[j + 1]){
j++; // 找到相同的前后缀
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
return next;
}
}
复杂度分析
- 时间复杂度:O(n),其中 n是字符串 s 的长度。
- 空间复杂度:O(n)。