提示:DDU,供自己复习使用。欢迎大家前来讨论~
字符串Part02
学习字符串的基础操作。
一、题目
题目一:151.反转字符串中的单词
正常思路:
采用split库函数,将单词分割出来,然后倒序输入到一个新的字符串中。
增加题目难度,不要使用辅助空间,空间复杂度要求为O(1)。(为了不使用split库函数,只能在原字符串上下功夫了)
解题思路:
-
移除字符串中的多余空格
-
反转字符串
-
反转字符串中的单词
举个例子,源字符串为:"the sky is blue "
移除多余空格 : “the sky is blue”
字符串反转:“eulb si yks eht”
单词反转:“blue is sky the”
细节:
删除元素,是可以在O(n)的时间复杂度实现,使用双指针。
erase()函数的本身的时间复杂度就是O(n),再嵌套一个for循环的情况下就是O(n^2)。
class Solution {
public:
void reverseString(vector<char>& s) {
for (int i = 0, j = s.size() - 1; i < s.size() / 2; i++, j--) {
swap(s[i], s[j]); //这里使用了函数,也可以使用中间变量进行两个数的交换
}
}
};
- 时间复杂度: O(n)
- 空间复杂度: O(1)
题目二: 右旋字符串
为了让本题更有意义,提升一下本题难度:不能申请额外空间,只能在本串上操作。 (Java不能在字符串上修改,所以使用java一定要开辟新空间)
整体反转+局部反转就可以实现反转单词顺序的目的。
解题思路:
-
字符串分割:将字符串视为两部分,右移n位意味着将后n个字符移到前部。例如,n=2时,后两个字符成为新的前缀。
-
初步倒序:实现右移,可以先将整个字符串倒序,这样原本的第二段变为开头,第一段移到末尾,而字符的相对位置保持不变。
-
局部倒序:经过初步倒序后,虽然各段的位置正确,但字符顺序颠倒了。接下来,分别对这两段进行倒序操作,以恢复字符的正确顺序。
通过这三步,我们能有效地将字符串向右循环移动n位,同时保持字符的内部顺序。
通过 整体倒叙,把两段子串顺序颠倒,两个段子串里的的字符在倒叙一把,负负得正,这样就不影响子串里面字符的顺序了。
代码细节:reverse
函数接受两个迭代器作为参数,表示要反转的序列的范围。这个范围是==左闭右开。==
1.第一种实现方法:
先整体反转,在局部反转。
// 版本一
#include<iostream>
#include<algorithm>
using namespace std;
int main() {
int n;
string s;
cin >> n;
cin >> s;
int len = s.size(); //获取长度
reverse(s.begin(), s.end()); // 整体反转
reverse(s.begin(), s.begin() + n); // 先反转前一段,长度n
reverse(s.begin() + n, s.end()); // 再反转后一段
cout << s << endl;
}
2.第二种实现方式(也是两种):
先局部反转,再整体反转。
细节注意:
区间一定要留意。
s.begin()
和 s.end()
是两个迭代器,它们分别指向字符串 s
的第一个字符的开始位置和字符串末尾的下一个位置(即字符串的结束之后的位置)。
// 版本2.1
#include<iostream>
#include<algorithm>
using namespace std;
int main() {
int n;
string s;
cin >> n;
cin >> s;
int len = s.size(); //获取长度
reverse(s.begin(), s.begin() + n); // 先反转前一段,长度n,注意这里是和版本一的区别
reverse(s.begin() + n, s.end()); // 再反转后一段
reverse(s.begin(), s.end()); // 整体反转
cout << s << endl;
}
// 版本2.2
#include<iostream>
#include<algorithm>
using namespace std;
int main() {
int n;
string s;
cin >> n;
cin >> s;
int len = s.size(); //获取长度
reverse(s.end() - n , s.end()); // 先反转后一段,n,注意这里是和版本一的区别
reverse(s.begin(), s.end() - n); // 再反转前一段
reverse(s.begin(), s.end()); // 整体反转
cout << s << endl;
}
最后:剑指offer的题目是左反转,那么左反转和右反转 有什么区别呢?
其实思路是一样一样的,就是反转的区间不同而已。
代码如下:
#include<iostream>
#include<algorithm>
using namespace std;
int main() {
int n;
string s;
cin >> n;
cin >> s;
int len = s.size(); //获取长度
reverse(s.begin(), s.begin() + n); // 反转第一段长度为n
reverse(s.begin() + n, s.end()); // 反转第二段长度为len-n
reverse(s.begin(), s.end()); // 整体反转
cout << s << endl;
}
为啥能移动到b的原因:你已经知道了f前面的字符都匹配上了
那么1.文本串不会从第二个a开始,而是可以直接从当前的b开始
2.模式串也不需要从第一个a开始,而是从b开始,因为知道文本串前面的一定是aa
二、 KMP算法的基础理论
思路:
KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。
将以如下顺序来讲解KMP,需要二刷,第一遍先大概知道。
-
什么是KMP:
说到KMP,先说一下KMP这个名字是怎么来的,为什么叫做KMP呢。
因为是由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP
-
KMP有什么用:
当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了
所以如何记录已经匹配的文本内容,是KMP的重点,也是next数组肩负的重任。
-
什么是前缀表:
前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀
-
为什么一定要用前缀表:
前缀表具有告诉我们当前位置匹配失败,跳到之前已经匹配过的地方的能力。
-
如何计算前缀表:
下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。
-
前缀表与next数组
next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
-
使用next数组来匹配
-
时间复杂度分析
整个KMP算法的时间复杂度是O(n+m)的。
暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。
-
构造next数组
-
使用next数组来做匹配
-
前缀表统一减一 C++代码实现
-
前缀表(不减一)C++实现
-
总结
精髓:
只是比较模式串,比较指针i永不回退。
前缀来到后缀相同的位置
代码实现
next数组:
a a b a a f
0 1 0 1 2 0 原始的最长相等的前后缀
-1 0 1 0 1 2 初始化第一个为-1,其他的右移操作
-1 0 -1 0 1 -1 在原始的基础上进行减一
求next[]数组的步骤
-
初始化
-
前后缀相同
-
前后缀不同
-
更新next
//i指向前缀的末尾,j指向后缀的末尾(最长相等前后缀的长度) void getNext(int* next, const string& s){ int j = -1; next[0] = j; for(int i = 1; i < s.size(); i++) { // 注意i从1开始 while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了 j = next[j]; // 向前回退 } if (s[i] == s[j + 1]) { // 找到相同的前后缀 j++; } next[i] = j; // 将j(前缀的长度)赋给next[i] } }
关于指针回溯求next的理解
每次求next【i】,可看作前缀与后缀的一次匹配,在该过程中就可以用上之前所求的next,若匹配失败,则像模式串与主串匹配一样,将指针移到next【j-1】上。
求next过程实际上是dp(动态规划),只与前一个状态有关:
若不匹配,一直往前退到0或匹配为止
若匹配,则将之前的结果传递: 因为之前的结果不为0时,前后缀有相等的部分,所以j所指的实际是与当前值相等的前缀,可视为将前缀从前面拖了过来,就不必将指针从前缀开始匹配了,所以之前的结果是可以传递的。
三、字符串总结
1.什么是字符串?
字符串是若干字符组成的有限序列,也可以理解为是一个字符数组,把一个字符串存入一个数组时,也把结束符 '\0’存入数组,并以此作为该字符串是否结束的标志。
2.要不要使用库函数?
不要太迷恋于库函数。习惯于调用substr,split,reverse之类的库函数,却不知道其实现原理,也不知道其时间复杂度,这样实现出来的代码,如果在面试现场,面试官问:“分析其时间复杂度”的话,一定会一脸懵逼!所以建议如果题目关键的部分直接用库函数就可以解决,建议不要使用库函数。如果库函数仅仅是 解题过程中的一小部分,并且你已经很清楚这个库函数的内部实现原理的话,可以考虑使用库函数。
3.双指针法
使用双指针法实现了反转字符串的操作,双指针法在数组,链表和字符串中很常用。
很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
会使用for循环里调用库函数erase来移除元素,这其实是O(n^2)的操作,因为erase就是O(n)的操作,所以这也是典型的不知道库函数的时间复杂度。
4.反转系列
当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章。
先整体反转再局部反转,达到了左旋的效果
- 字符串类类型的题目,往往想法比较简单,但是实现起来并不容易,复杂的字符串题目非常考验对代码的掌控能力。
- 双指针法是字符串处理的常客。
- KMP算法是字符串查找最重要的算法。
四、双指针回顾
数组篇
原地移除数组上的元素,我们说到了数组上的元素,不能真正的删除,只能覆盖。
字符串篇
注意这里强调要原地反转。使用双指针法,定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。,时间复杂度是O(n)。
链表篇
翻转链表是现场面试,白纸写代码的好题,考察了候选者对链表以及指针的熟悉程度,而且代码也不长,适合在白纸上写。
N数之和篇
总结
-
字符串章节过了一遍
-
KMP算法需要好好看第二遍,掌握一下代码。
-
双指针的使用,需要好好掌握。使用情况和使用方法。
1.学完字符串及之前的算法, 感觉自己学的太泛了, 只是听懂了,然后自己在看了解题思路之后,跟着打了一遍代码,提交上去。
2.感觉这样意义不大, 但要掌握住实在太难了,不要灰心,还是要从基础一点点积累, 先掌握“1+1=2”的题目,再进阶吧。
看到这里的未来的各位老板,可不可以留下一句鼓励我的话(随心留,简单一句话,可以是建议、“鸡汤”、学习方法等等)
如果可以请扶我起来,我还能学!!送花送花