基础知识
字符串是若干字符组成的有限序列,也可以理解为是一个字符数组,但是很多语言对字符串做了特殊的规定。
在C语言中,把一个字符串存入一个数组时,也把结束符 '\0’存入数组,并以此作为该字符串是否结束的标志。
char a[5] = "asd";
for (int i = 0; a[i] != '\0'; i++) {
}
在C++中,提供一个string
类,string类会提供 size
接口,可以用来判断string类字符串是否结束,就不用’\0’来判断是否结束
string a = "asd";
for (int i = 0; i < a.size(); i++) {
}
题目
1.反转字符串
题目链接
编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
C++有该功能函数,直接用C++里的一个库函数std::reverse
其原理是使用双指针方法,从左端,右端出发,然后使用std::swap
函数 可以交换列表vector数据中指定两个索引的数值
for(int i=0, j=s.size()-1; i<s.size()/2; i++, j--){std::swap()}
时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( 1 ) O(1) O(1)
2.反转字符串2进阶
题目链接
给定一个字符串 s 和一个整数 k,从字符串开头算起, 每计数至 2k 个字符,就反转这 2k 个字符中的前 k 个字符。如果剩余字符少于 k 个,则将剩余字符全部反转。如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。
使用for循环,每次索引增加2k索引;判断i+k与s.size()的大小关系,是否到达字符串末尾。
class Solution {
public:
string reverseStr(string s, int k) {
for(int i=0; i<s.size()-1; i += 2*k){
if(i+k<=s.size()) reverse(s.begin()+i, s.begin()+i+k);
else reverse(s.begin()+i, s.end()); // 末尾标记
}
return s;
}
};
时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( 1 ) O(1) O(1)
3.替换数字
题目链接
给定一个字符串 s,它包含小写字母和数字字符,请编写一个函数,将字符串中的字母字符保持不变,而将每个数字字符替换为number。例如,对于输入字符串 “a1b2c3”,函数应该将其转换为 “anumberbnumbercnumber”。
其实很多数组填充类的问题,其做法都是先预先给数组扩容带填充后的大小,然后在从后向前进行操作。
这么做有两个好处:
1.不用申请新数组。即无需额外的内存空间,我们只需对原字符串的内存空间进行扩容resize()
2.从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题(拷贝时的内存覆盖问题)。
这题我们首先遍历words,统计出字符串中数字字符的数量,然后相应地扩容words的内存空间;最后再从后往前拷贝&替换words中的字符。
4.翻转字符串里的单词
题目链接
给定一个字符串,逐个翻转字符串中的每个单词
直观的解法是:一些同学会使用split
库函数,分隔单词,然后定义一个新的string字符串,最后再把单词倒序相加,那么这道题题目就是一道水题了,失去了它的意义。
所以解题思路如下:
1.移除多余空格(单词往往以一个空格隔开):一次遍历可完成
2.将整个字符串反转:(令我想起了,反转的过程有点类似数组中,先全翻,再局部翻(或局部翻,再全局翻),这取决于向左滚动,还是向右滚动)
3.将每个单词反转
class Solution {
public:
string reverseWords(string s) {
int left = 0;
for(int right=0; right<s.size(); right++){
if(right==0 && s[right]==' ') continue;
if(right>0 && s[right]==' ' && s[right-1]==' ') continue;
if(right==s.size()-1 && s[right]==' ') continue;
s[left++] = s[right];
}
if(s[left-1]==' ') left--; // 检查末尾为0的情况
s.resize(left);
reverse(s.begin(), s.end());
left = 0;
for(int right=0; right<s.size(); right++){
if(s[right]==' '){
reverse(s.begin()+left, s.begin()+right);
left = right+1;
}
else if(right==s.size()-1) reverse(s.begin()+left, s.end());
}
return s;
}
};
时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( 1 ) 或 O ( n ) O(1) 或 O(n) O(1)或O(n),取决于语言中字符串是否可变
5.右旋转字符串
题目链接
这题就十分类似数组中反转数字的问题了,全局反转+局部反转,即可解决类似的问题;
#include<iostream>
#include<algorithm>
using namespace std;
int main(){
int n;
string words;
cin>> n;
cin>> words;
int len = words.size();
reverse(words.begin(), words.end());
reverse(words.begin(), words.begin()+n);
reverse(words.begin()+n, words.end());
cout<< words<< endl;
}
时间复杂度: O ( 1 ) O(1) O(1),空间复杂度: O ( 1 ) O(1) O(1)
6.实现strStr()
题目链接
给定一个 haystack 字符串和一个 needle 字符串,在 haystack 字符串中找出 needle 字符串出现的第一个位置 (从0开始)。如果不存在,则返回 -1。
这个问题本质上是字符串匹配问题,通常是使用到KMP算法。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个==next()函数(前缀表)==实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)。
扩展:KMP算法
- KMP:主要应用在字符串匹配上。KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了
- 那么什么是前缀表:next数组就是一个前缀表(prefix table)。记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配
- 理解前缀&后缀:字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
例如 字符串a的最长相等前后缀为0。 字符串aa的最长相等前后缀为1。 字符串aaa的最长相等前后缀为2。
next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1),也就是以上的前缀表中所有所有数值-1
1.如何计算前缀表?
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串,后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。前缀表要求的就是相同前后缀的长度。
例如长度为前1个字符的子串a,最长相同前后缀的长度为0;长度为前2个字符的子串aa,最长相同前后缀的长度为1;长度为前3个字符的子串aab,最长相同前后缀的长度为0;长度为前4个字符的子串aaba,最长相同前后缀的长度为1;
下标5之前这部分的字符串(也就是字符串aabaa)的最长相等的前缀 和 后缀字符串是 子字符串aa ,因为找到了最长相等的前缀和后缀,匹配失败的位置是后缀子串的后面,那么我们找到与其相同的前缀的后面重新匹配就可以了。
2.构造next数组
/*
构造next数组其实就是计算模式串s,前缀表的过程
1.初始化
2.处理前后缀不相同的情况
3.处理前后缀相同的情况
*/
// 前缀有-1版本
void getNext(int* next, const string& s){
// next是前缀表使用j遍历,s是模式串使用i遍历
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]
}
}
// 前缀无-1版本
// 待定
3.在得到next数组后,来做匹配给定字符串
定义两个下标j 指向模式串t起始位置,i指向文本串s起始位置;j初始值依然为-1,因为next数组里记录的起始位置为-1。比较的是
s
[
i
]
与
t
[
j
+
1
]
s[i] 与 t[j + 1]
s[i]与t[j+1],j意义是已经匹配的
- 接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的) 进行比较;
- 如果 s[i] 与 t[j + 1] 不相同,j就要从next数组里寻找下一个匹配的位置;
- 如果 s[i] 与 t[j + 1] 相同,那么i 和 j 同时向后移动;
- 如果j指向了模式串t的末尾,那么就说明模式串t完全匹配文本串s里的某个子串了;
// 前缀有-1版本
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
vector<int> next(needle.size());
getNext(&next[0], needle);
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1) ) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
// 前缀无-1版本
也存在前缀表(不减一)C++实现,即因为next数组里记录的起始位置为0;其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是
O
(
n
)
O(n)
O(n),之前还要单独生成next数组,时间复杂度是
O
(
m
)
O(m)
O(m)。所以整个KMP算法的时间复杂度是
O
(
n
+
m
)
O(n+m)
O(n+m)的。
暴力的解法显而易见是
O
(
n
×
m
)
O(n × m)
O(n×m),所以KMP在字符串匹配中极大地提高了搜索的效率
7.重复字符串
题目链接
给定一个非空的字符串,判断它是否可以由它的一个子串重复多次构成。给定的字符串只含有小写英文字母,并且长度不超过10000。
该题可以使用KMP算法进行解决,根据重复字符串的next数组的特点,若next[size-1]满足一定的数值关系,即可认为这个字符串是重复的。
bool repeatedSubstringPattern (string s) {
if (s.size() == 0) {
return false;
}
int next[s.size()];
getNext(next, s);
int len = s.size();
if (next[len - 1] != -1 && len % (len - (next[len - 1] + 1)) == 0) {
return true;
}
return false;
}
时间复杂度: O ( n ) O(n) O(n);空间复杂度: O ( n ) O(n) O(n);
总结
在使用字符串的库函数时,习惯于调用substr,split,reverse, erase之类的库函数,更要了解其实现的原理以及其时间复杂度分析。
但面对题目关键的部分直接用库函数就可以解决,建议不要使用库函数,这类题目考察的是库函数的原理。
双指针法
- 例如反转字符串;双指针法在数组,链表和字符串中很常用
- 删除冗余空格,替换空格;很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作
反转字符
- 按照k个字符进行分组翻转
- 翻转字符串里的单词(包含冗余空格的单词组)-通过先整体反转再局部反转,实现了反转字符串里的单词
KMP
KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。KMP算法是字符串查找最重要的算法,但彻底理解KMP并不容易。
前缀表(next数组):起始位置到下标i之前(包括i)的子串中,有多大长度的相同前缀后缀。
可以用于解决两类问题
- 匹配问题
- 重复子串问题
前缀:指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀:指不包含第一个字符的所有以最后一个字符结尾的连续子串。
然后针对前缀表到底要不要减一,这其实是不同KMP实现的方式,有两种不同的实现方法。