前言
串实际上是我觉得比线性表还要boring的一章。
因为相信很多人在学C的指针和char时已经非常精通了(exclude me)。
依稀记得被strlen,strcpy,strcmp这些个函数支配的恐惧。
那一部分人通过C++类的学习已经能写出一个很完整的string封装类了。
但!但!
我们不用从char起步用char*来进行实现,我们还是只要做到会使用,知道常用的操作即可!
# 一、学习目标:
掌握串的基本操作,深入学习KMP模式串匹配算法
串的基本操作
由于c++帮我们封装好了一个string类,我们只要引入头文件进行使用即可。
#include<string>
using namespace std;
int main(){
string s; // 直接进行声明即可
return 0;
}
空串自然要为其赋值,我这儿给出了自己常见到的三种方式:
第一种是用string值来给一个空string类型变量赋值,第二种是用const char* 类型赋值,第三种就是直接赋值。可以从结果看出都是成立的。
这里注意:如果直接用char * 对string进行赋值会报异常,会提示“ISO C++ forbids converting a string constant to 'char’ [-Wwrite-strings]”,但结果也没问题。 这个问题学C时应该讲过,想了解的话自行baidu一下吧。
然后来讲述一些自认为题目中经常会用到的操作。
英语好的同学不妨直接看这儿哈—字符串c++官方文档。
首先是字符串的插入功能,可以直接利用insert实现,效果如下:
第一种方式就是指定插入位置和插入的字符串。
自己进行尝试:
根据索引,索引为3的地方刚好在原字符串s的‘J’后面一个位置,因此从这个位置开始插入我们指定的字符串就能得到正确答案。
第二种方式这么多数字是啥意思嘞?
实际上就是从str3的第三个字符开始取出连续的4个字符作为一个字符串,然后再进行插入。实际上并没有啥不同。
那么我们来进行自行尝试:
可以按照刚刚的解释自己数一哈,是没问题的哈。
第三第四种insert方式实际上都是大同小异,无非第三种用最后一个数字指定了想要插入的字符串的长度,而第四种是全部插入。
第五种方式稍微有些不同。第一个参数仍然是插入的位置,第三个参数就是一个字符,第二个数字实际上就是那个字符的个数。自己做个例子:
我们在首位置插入5个‘a’,不难看出答案与我们预想的是一致的。
后面有涉及到begin,end迭代器稍微推后一点。
刚刚在insert的第二种方式实际上已经产生了子串的概念。回顾一下刚刚我们就是指定了从哪儿开始取多少长度得到原字符串的子串!
那么实际上我们有单独的substr来实现这一功能。
两个参数就是指定了开始位置pos,以及截取长度len。这样一来会返回一个新的string类型的数据。
这儿就不再进行尝试了,因为与insert的第二种方式截取是一个意思。
find函数也是一个很实用的函数。它会寻找我们传入参数字符串str第一次在原串中出现的位置。假如我们另外指定了一个pos,那么原串只会在此位置之后进行寻找从而忽略前面的情况。
对于返回值,如果能成功找到第一次出现的位置则返回首字母在原串中的位置。
否则会返回string::npos。
最后讲一下迭代器。
迭代器这个概念在vector中应该是接触到过的,同样是有begin和end两个属性。
迭代器给人的感觉和指针很像,迭代器的遍历会比通过索引遍历来得更加快。我们通过下面的方式来进行遍历:
根据这个可以自己对照回insert最后几种情况看一哈就行。
API这个东西没必要全部记住,记住常用的会用就行,然后学会查和理解同样重要。
KMP算法
接下来介绍一下KMP模式匹配算法。
上学期的CG计图让我现在还对 “模视变换” 心有余悸,mo shi有毒
最基础的匹配算法实际上也就是暴力法是非常简单的。我们用一个indexOf函数来实现即可:
int indexOf(string s, string mo){
int index = INT_MAX;
for (int i = 0; i < int(s.length()); i ++){
if(s[i] == mo[0]){
int temp = i;
bool flag = true;
for (int j = 0; j < int(mo.length()); j ++){
if(s[temp++] != mo[j]){
flag = false;
break;
}
}
if(flag){
index = i;
break;
}
}
}
if(index == INT_MAX){
cout << "无法找到匹配的情况" << endl;
return index;
}
else {
cout << "找到匹配情况,且首次出现的位置在" << index << "处" << endl;
return index;
}
}
很容易看出这是一个O(n²)的算法。
不难看出每次失配后主串指针都会重新到上次遍历位置的后一位,而模式串指针也都会从头开始比对。
KMP算法就是减少了大量的回溯,优化为O(m+n)时间复杂度的算法。
其核心在于当主串i位置和模式串j位置失配后,需要尽可能得将模式串往右滑动!
注意是移模式串!
而在算法中体现出来很重要的一步就是求出next数组。
那么如何理解这一个数组?
比如当我们遍历到数组中第j个字符与主串相应某个字符失配时,根据next[j] = k,我们就直接把模式串指针移动到k。
这就需要我们确保 第j个字符前的长度为k - 1的串,与从头开始的长度为k - 1的串是完全相同的!!理解一下这个就行。
比如看下面这张图:
比如j = 4时,next[4] = 2,这就是表明第一个字符和和第三个字符时相匹配的!
套刚刚说明的那句加粗的话!
又如j = 6时,next[6] = 3,表明前两个字符和第4,5个字符是匹配的!!
算法中我们时刻保存当前的j指针和next[j]对应的指针位置。
有了这个想法后,我们可以直接初始化前两个字符的next值。将第一个字符的next值设为-1。(教材中是0,这是因为教材中SString会在第0个字符保存字符串的长度值,但我们String是直接从0开始计数的!)其次可以将第二个字符的next值设为0。
(当然也可以不做!)
然后如何求取接下来的字符的next值呢?
分几种情况:
如果现在j指针指向的字符和next指针指向的字符是匹配的,那么下一个j的next值就是原来的next值加1。
举个例子,当j为5时,next指针指向2,经判断,两个位置的字符是相同的,于是j为6时的next值就是2 + 1为3。
如果失配,就让j再次成为其指向的next值。比如,当j指针指向2时,我们的next指向的1。不难看出此时,位置1和2的字符是不匹配的!于是我们修改next指针指向其对应的next值,即next[1]。这时会发现,next指向了0(用我们的算法也就是指向了-1),那么此时实际上就是说明即使是第一个字符也无法与当前字符相匹配,那么两个指针同样都往前移动。同时将next[3]进行赋值为0即可。
利用KMP就相对容易很多。我们就利用一开始就讲的思想,一旦失配,就令模式串的指针返回到next值。一旦当模式串第一个字符都无法与主串当前位置匹配,或者说两者匹配上了就为两者指针分别加1。
最后退出循环后就对模式串指针进行和长度的比较,判断是否匹配成功。
代码如下:
void Get_Next(vector<int> &p, string s){
int i = 0;
p[i] = -1;
int j = -1;
while(i < int(s.length())){
if(j == -1 || s[i] == s[j]){
++j, ++i, p[i] = j;
}
else j = p[j];
}
}
void KMP(vector<int> &p, string s, string ss){
int i = 0, j = 0;
while(i < int(s.length()) && j < int(ss.length())){
if(j == -1 || s[i] == ss[j]){
++i, ++j;
}
else j = p[j];
}
if(j >= int(ss.length())){
cout << "匹配成功, 位置是: " << i - ss.length() << endl;
}
else{
cout << "匹配失败" << endl;
}
}
先来道绿题练练手。
题目即要求反转元音字母,其无非是包括“a e i o u A E I O U”。
大写字母很关键,因为我就跌倒在这里一次。
一开始想反转嘛不正好利用到我们刚复习完的栈嘛!于是有了如下代码:
class Solution {
public:
string reverseVowels(string s) {
// 元音字母为 a e i o u
string result = "";
stack<char> st;
for(string::iterator it = s.begin() ; it != s.end() ; it ++){
if(*it == 'a' || *it == 'e' || *it == 'i' || *it == 'o' || *it == 'u' ||
*it == 'A' || *it == 'E' || *it == 'I' || *it == 'O' || *it == 'U'){
st.push(*it);
}
}
for(string::iterator it = s.begin() ; it != s.end() ; it ++){
if(*it == 'a' || *it == 'e' || *it == 'i' || *it == 'o' || *it == 'u' ||
*it == 'A' || *it == 'E' || *it == 'I' || *it == 'O' || *it == 'U'){
result += st.top();
st.pop();
}
else{
result += *it;
}
}
return result;
}
};
首先我们通过迭代器遍历稍稍会快一些。
然后第一遍遍历的目的就是找出元音字母入栈。
第二次遍历就是将非元音字母直接加入结果字符串,否则的话就从栈不断去取。
最后返回答案。
但空间效率属实惊人!
那么如何优化空间效率呢?
最显然的就是不另外设结果字符串result,我们可以直接对原串进行修改即可。
那么可不可以不用栈呢?是可以的!
这里我用到了双指针的思想。
双指针一个从最前面开始往后遍历,一个从最后面开始往前遍历。两者分别找到第一个元音字母后进行交换,在原串上进行修改。修改完毕后令两个指针同时再往自己的行进方向前进1。
那么写成代码是这样的:
class Solution {
public:
string reverseVowels(string s) {
// 元音字母为 a e i o u
int front = 0, back = s.length() - 1;
while(back > front){
while(back > front && s[front] != 'a' && s[front] != 'e' && s[front] != 'i' &&
s[front] != 'o' && s[front] != 'u' && s[front] != 'A' &&
s[front] != 'E' && s[front] != 'I' && s[front] != 'O' &&
s[front] != 'U'){
front ++;
}
while(back > front && s[back] != 'a' && s[back] != 'e' && s[back] != 'i' &&
s[back] != 'o' && s[back] != 'u' && s[back] != 'A' &&
s[back] != 'E' && s[back] != 'I' && s[back] != 'O' &&
s[back] != 'U'){
back --;
}
char temp = s[front];
s[front] = s[back];
s[back] = temp;
back --;
front ++;
}
return s;
}
};
这样一来最后的效果就比较好了!
注意while循环的条件必须是back严格大于front。不明白的自己Debug一下实例数组就能看出错在哪啦,因为我也是这样过来的······
接下来是一道中等难度的题目:
这题的预备步骤比较容易想。
无非就是我们需要利用两个容器分别装取两个关键词出现的所有位置。
最后我们对两个容器进行最近点的计算。
统计步骤较为简单,只需一次遍历即可。
那么目前实际上我们并无法判断用什么容器进行装取会比较方便。
这就需要依赖于我们怎么计算最近点的方式了。
最简单的方式当然就是两重循环,就写一下伪代码。
initialize(result) // 赋一个大值
for i in v1
for j in v2 // v1 v2是两个容器
result = min(result, abs(i - j))
显然地,这是一个O(n²)的算法。我也并没有对其进行提交测试。
那么现要对其进行优化。
不难发现我们两个容器的数组都是有序的
比如我们拥有这么两组word1和word2出现的位置数组。
如何避免暴力法求解最小距离呢?
我的思路是这样的:
我们先拿1和3比较,求解出一个距离值为2。
然后就废弃掉1!
试想,对于这么一个递增数组,1的右边必然比1要大,且只有在其右边才能存在离3更近的可能性。
为什么我们不能直接废弃掉3呢?
因为1后面可能有2存在啊!可能有4存在啊!如果舍掉3的话,类似于这样的数的存在就都只能与7去比较了!那么一个很近的值就被我们错误地忽略了!
于是我们总结出一个规律就是,不断拿两个数组最前端的数进行比较并且计算和更新最短距离。然后把那个偏小的数直接进行舍弃。
这是啥?这是典型的先进先出并且存在一个头部的数据结构啊,于是我们就总结得到了我们要利用的是队列!
代码如下:
class Solution {
public:
int findClosest(vector<string>& words, string word1, string word2) {
int result = INT_MAX;
queue<int> q1;
queue<int> q2;
for(int i = 0 ; i < words.size() ; i ++){
if(words[i] == word1)
q1.push(i);
if(words[i] == word2)
q2.push(i);
}
while(!q1.empty() && !q2.empty()){
result = min(result, abs(q1.front() - q2.front()));
if(q1.front() < q2.front())
q1.pop();
else
q2.pop();
}
return result;
}
};
最后的结果如下:
我记得线性表的困难题还没有完成过,准备到时候和串的困难题一起刷。
因此数组,串都是没有特别多技巧的数据结构。其往往连接的是高级的算法,分治,动规等等。
因此到时候一起搞吧。