一、刷题部分
1.1 151.翻转字符串里的单词
- 原文链接:代码随想录
- 题目链接:151. 反转字符串中的单词 - 力扣(LeetCode)
1.1.1 题目描述
给你一个字符串 s
,请你反转字符串中 单词 的顺序。
单词 是由非空格字符组成的字符串。s
中使用至少一个空格将字符串中的 单词 分隔开。
返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。
注意:输入字符串 s
中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。
示例 1:
输入:s = "the sky is blue"
输出:"blue is sky the"
示例 2:
输入:s = " hello world "
输出:"world hello"
解释:反转后的字符串中不能存在前导空格和尾随空格。
示例 3:
输入:s = "a good example"
输出:"example good a"
解释:如果两个单词间有多余的空格,反转后的字符串需要将单词间的空格减少到仅有一个。
提示:
1 <= s.length <= 104
s
包含英文大小写字母、数字和空格' '
s
中 至少存在一个 单词
进阶:如果字符串在你使用的编程语言中是一种可变数据类型,请尝试使用 O(1)
额外空间复杂度的 原地 解法。
1.1.2 初见想法
首先我想到了直接把字符串整体反转,然后再找里面所有单词的左右下标,再逐个反转单词,就行了。但是这个想法缺点在于对空格的特殊处理需要另做。不过另做就另做,双指针一搞就出来了。
class Solution {
public:string reverseWords(string s) {//先整体反转reverse(s.begin(), s.end());//cout<<"整体反转:"<<s<<endl;//双指针处理特殊的空格int fast = 0;int slow = 0;int spaceCount = 0;//处理前导空格if (s[0] == ' ') {while (fast < s.size() && s[fast] == ' ') {fast++;spaceCount++;}//全是空格的字符串直接返回空字符串if (fast == s.size()) {s.clear();return s;}while(fast < s.size()) {s[slow] = s[fast];slow++;fast++;}s.resize(s.size() - spaceCount);//cout << "处理前导空格:" << s << endl;}//处理中间多余空格,复杂度可以弄到nfast = 0;slow = 0;while (s[slow] != ' ') {slow++;fast++;//处理序列就是一个单词的情况if (slow == s.size()) {reverse(s.begin(), s.end());return s;}}slow++;fast++;//slow指向下一个承接的位置,fast指向下一个要移动的元素while (fast < s.size()) {if (s[slow - 1] == ' ' && s[fast] == ' ') {fast++;continue;}s[slow] = s[fast];slow++;fast++;}while (slow < fast) {s[slow] = ' ';slow++;}//cout << "处理中间空格:" << s << endl;//处理尾随空格fast = s.size() - 1;spaceCount = 0;while(fast >= 0 && s[fast] == ' ') {fast--;spaceCount++;}s.resize(s.size() - spaceCount);//cout << "处理尾随空格:" << s << endl;//反转单词slow = 0;fast = 0;while (fast < s.size()) {//定位单词并翻转while(fast < s.size() && s[fast] != ' '){fast++;}fast--;reverse(s.begin() + slow, s.begin() + fast + 1);//cout << "slow = " << slow << ", fast = " << fast << endl;//cout << "反转1次单词:" << s << endl;//移动到下一单词开头fast += 2;slow = fast;}//cout << "结果:" << s << endl;return s;}
};
写了好久啊,感觉没有想象中那么简单。还好最后做出来了,看看题解怎么想的。
1.1.3 看录后想法
思路差不多,不过明显录里的写法要简洁很多,可以详细看看到底差在哪里。
确实我做了太多操作了,双指针法移除空格可以更简单的,录里的第一种方法我来写一下:
void removeExtraSpace(string& s) {int slow = 0;int fast = 0;//移除多余空格//移动快指针到第一个非空格元素while (fast < s.size() && s[fast] == ' ') {fast++;if (fast == s.size()) {s.clear();return;}}//开始移动元素,同时移除多余空格while (fast < s.size()) {if(fast > 0 && s[fast - 1] == ' ' && s[fast] == ' ') {fast++;continue;}s[slow++] = s[fast++];}//resize来删除末尾空格if(slow > 0 && s[slow - 1] == ' ') {s.resize(slow - 1);}else {s.resize(slow);}
}
接下来是录里第二种方法去除空格,逻辑更加的紧凑:
void removeExtraSpace(string& s) {int slow = 0;for (int fast = 0; fast < s.size(); fast++) {//手动添加空格if(slow != 0 && s[slow - 1] != ' ') {s[slow++] = ' ';}//只有fast遇到非空格才行动,并且一次行动保证过完一个单词while (fast < s.size() && s[fast] != ' ') {s[slow++] = s[fast++];}}//移除末尾空格if(slow > 0 && s[slow - 1] == ' ') {s.resize(slow - 1);}else {s.resize(slow);}}
带上完整的逻辑,代码如下:
class Solution {public:string reverseWords(string s) {removeExtraSpace(s);reverse(s.begin(), s.end());int slow = 0;for (int fast = 0; fast < s.size();) {if (fast == s.size() - 1 || s[fast + 1] == ' ') {reverse(s.begin() + slow, s.begin() + fast + 1);fast += 2;slow = fast;}else {fast++;}}return s;}void removeExtraSpace(string& s) {int slow = 0;for (int fast = 0; fast < s.size(); fast++) {//手动添加空格if(slow != 0 && s[slow - 1] != ' ') {s[slow++] = ' ';}//只有fast遇到非空格才行动,并且一次行动保证过完一个单词while (fast < s.size() && s[fast] != ' ') {s[slow++] = s[fast++];}}//移除末尾空格if(slow > 0 && s[slow - 1] == ' ') {s.resize(slow - 1);}else {s.resize(slow);}}};
1.1.4 遇到的困难
感觉写的时候逻辑绕的很,不过其实还是自己没见过这种题。后面再练的时候能想起来就可以了。
1.2 右旋字符串
- 原文链接:右旋字符串 | 代码随想录
- 题目链接:55. 右旋字符串(第八期模拟笔试)
1.2.1 题目描述
题目描述
字符串的右旋转操作是把字符串尾部的若干个字符转移到字符串的前面。给定一个字符串 s 和一个正整数 k,请编写一个函数,将字符串中的后面 k 个字符移到字符串的前面,实现字符串的右旋转操作。
例如,对于输入字符串 "abcdefg" 和整数 2,函数应该将其转换为 "fgabcde"。
输入描述
输入共包含两行,第一行为一个正整数 k,代表右旋转的位数。第二行为字符串 s,代表需要旋转的字符串。
输出描述
输出共一行,为进行了右旋转操作后的字符串。
输入示例
2
abcdefg
输出示例
fgabcde
提示信息
数据范围:
1 <= k < 10000,
1 <= s.length < 10000;
1.2.2 初见想法
这就是一个 On 的题目,如果不申请额外空间想象怎么做。可以弄双指针,right指向待移动字串的末尾,left指向待移动字串前面的一个位置,然后交换两个元素,整体左移一位,这样的操作做k次。如此就可以把字串往前移动k位,多来几次就可以把字串移动到开头了。
然后就需要考虑最后一一次移动的时候,如果前面的元素个数小于k,那就需要再搞一遍类似的问题了,稍微有点麻烦但是思路是清楚的。写一下:
#include<iostream>
#include<string>
using namespace std;//定义一个基本操作,将字串与其前面相邻的数个元素构成的字串交换
//left right就是字串的左右界
//不检查合法性,需要确保前面元素个数够多
void nearExchange(string &s, int &left, int &right);
//定义一个基本操作,将字串与最前面相同个数的字串交换之
void farExchange(string &s, int left, int right);
// //定义右旋字符串:字符串,后面k个元素,字符串的右界
void rightRotate(string &s, int k, int right);int main() {int k;cin >> k;string s;cin >> s;rightRotate(s, k, s.size() - 1);cout << s;
}void nearExchange(string &s, int &left, int &right) {//先将left初始位置保存一下int o_left = left;left--;while(right >= o_left) {swap(s[left--], s[right--]);}left++;//函数退出的时候,left right 依然是字串的左右界
}
void farExchange(string &s, int left, int right){int o_right = right;right = left;left = 0;//此时左指针指向开头。右指针指向待交换字串开头while (right <= o_right) {swap(s[left++], s[right++]);}
}
void rightRotate(string &s, int k, int right) {int left = right - k + 1;//只要前面元素数量够多,那就一直交换//前面元素个数就是leftwhile (left >= k) {nearExchange(s, left, right);}//如果前面还有没换完的元素,就进行更精细的操作if(left != 0) {//先把最前面的left个元素全换到后面去,让它们到最终位置farExchange(s, right - left + 1, right);right = right - left;//此时只有末尾字串是位置不对的//问题转化成了将前面一部分的字串进行右旋,右旋的长度为 k - leftrightRotate(s, k - left, right);}else {return;}
}
果然和我最初的预感一样,可能就用到递归了。我觉得我的方法应该算是挺不错了,看看录是怎么解的这道题。
1.2.3 看录后想法
还是我的方法太麻烦了,可以先整体反转然后再局部反转,两步就做完了。我的天啊我为什么想不到!
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;int main() {int k;cin >> k;string s;cin >> s;reverse(s.begin(), s.end());reverse(s.begin(), s.begin() + k);reverse(s.begin() + k, s.end());cout << s << endl;
}
1.2.4 遇到的困难
自己用了一个很麻烦的方法解出来了,复杂度应该会高一点,但是数量级差不多。想的时候花了好长时间。
1.3 28. 实现 strStr()
- 原文链接:代码随想录
- 题目链接:28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)
1.3.1 题目描述
给你两个字符串 haystack
和 needle
,请你在 haystack
字符串中找出 needle
字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle
不是 haystack
的一部分,则返回 -1
。
示例 1:
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。
示例 2:
输入:haystack = "leetcode", needle = "leeto"
输出:-1
解释:"leeto" 没有在 "leetcode" 中出现,所以返回 -1 。
提示:
1 <= haystack.length, needle.length <= 104
haystack
和needle
仅由小写英文字符组成
1.3.2 初见思路
这题显然是 KMP 算法的使用,之前学习过这个算法的思想,但是没有用代码实现过。需要先看一下录来学习一番:
KMP 的核心思想就是在字符串匹配问题中,遇到不匹配的情况可以不必从头匹配,而是根据模式串的结构来推导出 next 数组,再根据 next 数组 求出模式串最少向前移动几位就可以继续匹配下去了。
如何求 前缀表?
next 数组是根据前缀表来推导出的。前缀表里存放的是模式串里的某字符前面的字串的 最长公共前后缀(包括某字符本身,即闭区间)。由于自己已经大致留有印象这里就不多说了,只给出一个例子用来复习一下前缀表具体是怎么来的。
对于串 aabaaf
, 其前缀表应为:
a
—— 0aa
—— 1aab
—— 0aaba
—— 1aabaa
—— 2aabaaf
—— 0
合在一起就是 010120
。可见前缀表长度和模式串一样,一个字符对应一个数字。如果直接把前缀表当做 next 数组,那么遇到不匹配的情况时,应当看前一位的数字,然后将模式串的指针移动到这个数字对应的下标位置,例如f
发现了不匹配,那么应当将指针移动到 2
的位置,也就是指向字母 b
。
前缀表与 next 数组
已知前缀表那么如何求出 next 数组呢?其实有很多不同的方案,下面列举其中几个:
- 前缀表直接充当 next 数组。
- 前缀表右移一位,左边空位补
-1
得到 next 数组。 - 前缀表整体 -1 得到 next 数组。
时间复杂度分析
生成 next 数组:$O(m)$
匹配过程:$O(n)$
总计:$O(m+n)$
生成 next 数组的代码实现
后面均以前缀表整体 -1 得到的 next 数组为例。
先定义函数:
void getNext(int *next, const string &s) {...
}
首先看动画,有一个大致的感觉:
- 初始化
定义指针 j 指向后缀末尾位置, i 指向前缀末尾位置。
int j = -1;
next[0] = j;
我们的处理过程其实是拿 s[j + 1]
与 s[i]
做比较,因此应有此循环:
for (int i = 1; i < s.size(); i++) {...
}
- 前后缀末尾相同的情况
前后缀末尾相同则说明找到了这样的最长公共前后缀,此时将 j 赋给 next[i] 即可。
if (s[i] == s[j + 1]) {j++;
}
next[i] = j;
- 前后缀末尾不同的情况
若不同说明此时的前后缀并不是公共的,应当让 j+1
向前回溯,直到找到相同的前后缀末尾,如果到最后也没有找到,那么 j 最后就是 -1。回溯也不是一个一个的回溯,而是根据前面已求得的 next 数组来回溯。
while (j >= 0 && s[i] != s[j + 1]) {j = next[j];
}
为何这样回溯是正确的呢,因为 next[j] 记录着 j 之前的字串相同前后缀的长度, s[i] 与 s[j + 1] 不相同那么就找 j+1 前一个元素在 next 数组里的值。
将上面的部分汇总起来就是完整的代码:
void getNext(int *next, const string &s) {//初始化int j = -1;next[0] = j;for (int i = 1; i < s.size(); i++) {//比较 s[i] 与 s[j + 1]//处理不同前后缀while (j >= 0 && s[i] != s[j + 1]) {j = next[j];}//处理相同前后缀if (s[i] == s[j + 1]) {j++;}next[i] = j;}
}
使用 next 数组匹配的代码实现
定义两个下标 j 指向模式串起始位置, i 指向文本串起始位置。
int j = -1;
for (int i = 0; i < s.size(); i++) {...
}
接下来比较 s[i]
与 t[j + 1]
。
s[i]
与t[j + 1]
相同,则 i j 直接向后移动
if(s[i] == t[j + 1]) {j++;//i++ 在 for 循环里
}
s[i]
与t[j + 1]
不相同,说明匹配失败,应当回溯 j 指针
while(j >= 0 && s[i] != t[j + 1]) {j = next[j];
}
然后是匹配成功的条件判断以及返回语句:
if(j == (t.size() - 1)) {return (i - t.size() + 1);
}
汇总一下代码:
int j = -1;
for (int i = 0; i < s.size(); i++) {while (j >= 0 && s[i] != t[j + 1]) {j = next[j];}if (s[i] == t[j + 1]) {j++;}if (j == (t.size() - 1)) {return (i - t.size() + 1);}
}
直接用前缀表作为next数组的实现:
class Solution {
public:void getNext(int* next, const string& s) {int j = 0;next[0] = 0;for(int i = 1; i < s.size(); i++) {while (j > 0 && s[i] != s[j]) {j = next[j - 1];}if (s[i] == s[j]) {j++;}next[i] = j;}}int strStr(string haystack, string needle) {if (needle.size() == 0) {return 0;}vector<int> next(needle.size());getNext(&next[0], needle);int j = 0;for (int i = 0; i < haystack.size(); i++) {while(j > 0 && haystack[i] != needle[j]) {j = next[j - 1];}if (haystack[i] == needle[j]) {j++;}if (j == needle.size() ) {return (i - needle.size() + 1);}}return -1;}
};
1.3.3 实现过程
现在学习了前缀表整体 -1 得到的 next 数组的代码。可以立即开始写这一题来巩固一下。
力扣模板:
class Solution {
public:int strStr(string haystack, string needle) {}
};
先写一下求 next 数组的函数:
void getNext(int *next, const string &s) {int j = -1;next[0] = j;for(int i = 1; i < s.size(); i++) {//不匹配则 j 回溯while (j >= 0 && s[i] != s[j + 1]) {j = next[j];}//匹配则继续if (s[i] == s[j + 1]) {j++;}next[i] = j;}
}
再写一下KMP的部分:
int KMP(int *next, const string &s, const string &t) {int j = -1;for (int i = 0; i < s.size(); i++) {while (j >=0 && s[i] != t[j + 1]) {j = next[j];}if (s[i] == t[j + 1]) {j++;}if (j == (t.size() - 1)) {return (i - t.size() + 1);}}
}
综合一下:
class Solution {
public:void getNext(int *next, const string &s) {int j = -1;next[0] = j;for(int i = 1; i < s.size(); i++) {//不匹配则 j 回溯while (j >= 0 && s[i] != s[j + 1]) {j = next[j];}//匹配则继续if (s[i] == s[j + 1]) {j++;}next[i] = j;}}int KMP(int *next, const string &s, const string &t) {int j = -1;for (int i = 0; i < s.size(); i++) {while (j >=0 && s[i] != t[j + 1]) {j = next[j];}if (s[i] == t[j + 1]) {j++;}if (j == (t.size() - 1)) {return (i - t.size() + 1);}}return -1;}int strStr(string haystack, string needle) {vector<int> next(needle.size());getNext(next.data(), needle);return KMP(next.data(), haystack, needle);}
};
1.3.4 遇到的困难
KMP 的具体实现并没有练习过,发现只有当真正写的时候才会遇到很多想不到的问题。之前大概知道 KMP 能干什么,但是就是不会写。
1.3.5 本题总结
花了挺大功夫重新学习了一遍 KMP 算法。感觉收获不少,本题学习了 KMP 算法的理论基础,知道了 前缀表 可以让模式串不必回溯到最开始就继续匹配,在实现过程中使用 next 数组可以使代码逻辑更加方便。时间复杂度为 $O(m+n)$,暴力的复杂度为 $O(m * n)$。
1.4 重复的子字符串
- 原文链接:代码随想录
- 题目链接:459. 重复的子字符串 - 力扣(LeetCode)
1.4.1 题目描述
给定一个非空的字符串 s
,检查是否可以通过由它的一个子串重复多次构成。
示例 1:
输入: s = "abab"
输出: true
解释: 可由子串 "ab" 重复两次构成。
示例 2:
输入: s = "aba"
输出: false
示例 3:
输入: s = "abcabcabcabc"
输出: true
解释: 可由子串 "abc" 重复四次构成。 (或子串 "abcabc" 重复两次构成。)
提示:
1 <= s.length <= 104
s
由小写英文字母组成
1.4.2 初见思路
实在想不到这个如何和 KMP 算法结合,就写一个相对暴力的方法吧。
力扣模板:
class Solution {
public:bool repeatedSubstringPattern(string s) {}
};
用 i 来记录字串长度,写一个大循环:
for (int i = 1; i <= s.size() / 2; i++) {}
然后根据周期函数的定义,去写里层的判断条件
if (s.size() % i != 0) {continue;
}
for (j = 0; j <= s.size() - i) {if (s[j] != s[j + i]) {break;}
}
基本框架已经有了,之后就是补充一些细节就可以了:
class Solution {
public:bool repeatedSubstringPattern(string s) {for (int i = 1; i <= s.size() / 2; i++) {if (s.size() % i != 0) {continue;}int j;for (j = 0; j < s.size() - i; j++) {if (s[j] != s[j + i]) {break;}}if(j == s.size() - i) {return true;}}return false;}
};
本题在力扣上以 $O(n^2)$ 的复杂度通过了。
1.4.3 看录后的思路
接下来看看录的思路再实现一下 KMP 的方法。
移动匹配方法
设原来的串是 S ,那么构造 S + S ,并去掉头尾字符,记为 S2。若 S2 中可以匹配出 S,那么说明 S 是由重复子串构成,反之说明不是由重复子串构成。详细证明见录:移动匹配
代码如下:
class Solution {
public:bool repeatedSubstringPattern(string s) {string t = s + s;t.erase(t.begin()); t.erase(t.end() - 1); // 掐头去尾if (t.find(s) != std::string::npos) return true; // rreturn false;}
};
那么这个 find 函数其实可以用 KMP 算法来实现。不过本题的 KMP 方法并不体现在这里。下面是 KMP 算法求解本题。
KMP 方法
若一个串是由重复字串所构成的,那么该字符串的最长相等前后缀,所不包含的串就是最小重复字串:
那么拿到一个一般的串,如何判断是不是由重复子串构成,只需要判断那个“不包含的子串”的长度是不是能够整除整个串。
那么我们可以来做这道题目了:
先把求 next 数组的函数写出:
void getNext(int *next, const string &s) {int j = -1;next[0] = -1;for (int i = 1; i < s.size(); i++) {while (j >= 0 && next[i] != next[j + 1]) {j = next[j];}if (next[i] == next[j + 1]) {j++;}next[i] = j;}
}
然后找next[s.size() - 1] + 1
的值就是那个最长公共前后缀的长度了。
写出整体代码:
class Solution {
public:bool repeatedSubstringPattern(string s) {vector<int> next(s.size());getNext(next.data(), s);if (next[s.size() - 1] == -1) {return false;}if (s.size() % (s.size() - (next[s.size() - 1] + 1)) == 0) {return true;}return false;}void getNext(int *next, const string &s) {int j = -1;next[0] = -1;for (int i = 1; i < s.size(); i++) {while (j >= 0 && s[i] != s[j + 1]) {j = next[j];}if (s[i] == s[j + 1]) {j++;}next[i] = j;}for(int i = 0; i < s.size(); i++) {cout << next[i] << " ";}cout << endl;}
};
1.4.4 遇到的困难
感觉自己对 KMP 算法还是有些绕不太清,留给后面二刷的时候再解决吧。一直拖下去也是在浪费时间。
1.4.5 本题总结
本题重点在于对字符串的灵活运用,需要一些巧思。不过说到底见得多了也就会了,因此通过练习也是可以获得这些写题的思想。
1.5 字符串:总结篇
- 原文链接:代码随想录
- 题目链接:🈚️
在 C++ 中,对字符串专门有一个 string 类,区别于 vector<char>
,它额外有一些功能。比如 string 重载了 +。
C++ 对字符串有一些方便的库函数,还是按照之前的原则,如果库函数就是解题的一小步那可以用,如果是题目的主要部分那就要自己来实现一下了。
在字符串中,双指针法也是常用的算法,可以用来做反转字符串、替换空格、移除元素、反转单词。很多时候双指针法可以将 $O(n^2)$ 复杂度优化成 $O(n)$ ,也就是比暴力讲了一个复杂度。
字符串的练习少不了 KMP 算法,其实本质是找到模式串的前缀和数组,求 next 数组以及匹配过程有一些固定的写法,可以后面熟悉了多练练。
1.6 双指针回顾
写到现在,已经练习了很多道使用双指针来解的题目了。可以现在复习一下。
在 数组篇 -- 移除元素 中,使用双指针法可以完成移除数组中的元素,即在数组中删去所有值为 target 的数。
在 字符串篇 -- 反转字符串 中,使用双指针法实现了reverse,这里就是左右各用一个指针,然后使用三步值交换的思路,一直交换直到 left >= right。
在 字符串篇 -- 替换空格 中,先扫描一遍预先判断需要扩充的字串长度,然后用双指针从后往前填充。
在 字符串篇 -- 翻转字符串里的单词 中,可以用双指针法,类似于数组篇移除元素的思路,可以方便地移除多余的空格。实际上还是手动实现 erase 方法。
在 链表篇 -- 反转链表 中,使用双指针(实际上还有一个辅助指针,总共3个指针)可以条理清晰地完成链表的反转操作。
在 链表篇 -- 环形链表2 中,使用双指针可以探测链表中的环,只需要让快指针一次走2步,慢指针一次走1步,这样下去若有环,则快慢指针一定会相遇。相遇可以继续推导得到找到入口的方法。
在 哈希表篇 -- 三数之和 中,依然可以使用双指针来让题目变得简单,在四数之和也是可以用同样的思路来做。
2. 总结与回顾
今天把字符串的后几道题写了一下, 还详细学习了 KMP 算法。之后又把双指针法进行了总结,可以说是工作量拉满的一天。