目录
一、双指针法相关
344.反转字符串
541. 反转字符串II
151.翻转字符串里的单词
剑指Offer58-II.左旋转字符串
43. 字符串相乘
二、字符串填充替换
剑指Offer 05.替换空格
三、KMP算法
28. 实现 strStr()[*]
459.重复的子字符串
四、滑动窗口延伸
187. 重复的DNA序列
1234. 替换子串得到平衡字符串[*]
五、二分查找判断子序列
792. 匹配子序列的单词数
void reverseString(vector& s) {
int front = 0;
int back = s.size() - 1;
while (front < back) {
char temp = s[back];
s[back] = s[front];
s[front] = temp;
front++;
back--;
}
}
这里提供一个方便查看vector结果的函数:
template
void MyPrint(vector v) {
for (auto it = v.begin(); it != v.end(); it++) {
cout << *it << " ";
}
cout << endl;
}
void reverseString(string& s, int front, int back) {
while (front < back) {
char temp = s[back];
s[back] = s[front];
s[front] = temp;
front++;
back--;
}
}
string reverseStr(string s, int k) {
int start = 0;
int count = s.size();
while (start < s.size())
{
int left = start;
if (count < k) {
int right = start + count - 1;
reverseString(s, left, right);
return s;
}
else if (count >= k && count < 2 * k) {
int right = start + k - 1;
reverseString(s, left, right);
return s;
}
else {
int right = start + k - 1;
reverseString(s, left, right);
start += 2 * k;
count -= 2 * k;
}
}
return s;
}
与上一题类似,直接进行行为模拟即可。当考察内容不是直接调用库函数就能完成时,可以采用库函数。例如本题中的反转可以利用reverse实现。
另外,当需要固定规律一段一段去处理字符串的时候,要想想在for循环的表达式上做做文章。本题可以利用下式进行循环。
for (int i = 0; i < s.size(); i += (2 * k))
string reverseWords(string s) {
int fast = s.size() - 1;
int slow = s.size() - 1;
string res;
while (fast >= 0) {
if (s[fast] == ' ') {
slow--;
fast--;
}
else {
while (fast >= 0 && s[fast] != ' ') {
fast--;
}
//除第一次外,每次添加单词前,加一个空格
if (res.size() > 0) {
res.append(1, ' ');
}
res.append(s.substr(fast + 1, slow - fast));
slow = fast;
}
}
return res;
}
本算法的时间复杂度为O(n),但采用了额外的空间。本题可以先利用双指针法删除字符串中额外的空格,再翻转整个字符串,最后再翻转每个单词,这样就可以不借助额外空间。此处不再写了。
string reverseStr(string& s, int left, int right) {
while (left < right) {
char temp = s[left];
s[left] = s[right];
s[right] = temp;
left++;
right--;
}
return s;
}
string reverseLeftWords(string s, int n) {
reverseStr(s, 0, n - 1);
reverseStr(s, n, s.size() - 1);
reverseStr(s, 0, s.size() - 1);
return s;
}
本题和上一题类似,高端解法既要在O(n)时间内完成,又不能使用额外空间。仍然可以采用先局部翻转,再整体翻转的思路。 即先翻转前半段,再翻转后半段,最后翻转整个字符串。
至此,可以总结出翻转字符串相关的问题,都可以采用双指针法完成。
string multiply(string num1, string num2) {
int m = num1.size(), n = num2.size();
// 结果最多为 m + n 位数
vector res(m + n, 0);
// 从个位数开始逐位相乘
// num1[i] 和 num2[j] 的乘积对应的是 res[i+j] 和 res[i+j+1] 这两个位置
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 0; j--) {
int mul = (num1[i] - '0') * (num2[j] - '0');
int sum = mul + res[i + j];
res[i + j] = sum / 10;
res[i + j + 1] = sum % 10;
}
}
// 结果前缀可能存的 0(未使用的位)
int i = 0;
while (i < res.size() && res[i] == 0)
i++;
string str;
for (; i < res.size(); i++)
str.push_back('0' + res[i]);
return str.size() == 0 ? "0" : str;
}
如图所示(来源:labuladong),num1[i]
和 num2[j]
的乘积对应的是 res[i+j]
和 res[i+j+1]
这两个位置。
string replaceSpace(string s) {
//首先统计空格字符个数,对字符串进行预扩容
int count = 0;
for (auto i : s) {
if (i == ' ') {
count++;
}
}
if (count == 0) return s;
s.resize(s.size() + 2 * count);
int right = s.size() - 1;
int left = right - 2 * count;
//从后向前修改字符串
while (left >= 0) {
if (s[left] == ' ') {
s.replace(right - 2, 3, "%20");
left--;
right -= 3;
}
else {
s[right--] = s[left--];
}
}
return s;
}
数组填充类的问题,可以预先给数组扩容到填充后的大小,再从后向前进行操作。
本题难度较高,需要深入学习KMP算法。
本题的关键思想在于最长相等前后缀表的构造,利用最长相等前后缀的性质,查找haystack字符串时,若发生失配,只回退needle字符串。算法时间复杂度为O(m+n)。该过程如下图所示(转自公众号:labuladong):
首先理解什么是前后缀:例如子串aabaaf,其前缀串有:a,aa,aab,aaba,aabaa,其后缀串有:f,af,aaf,baaf,abaaf。
其次理解什么是最长相等前后缀:上面的子串aabaaf,前缀串和后缀串没有重复的,其最长相等前后缀长度为0。换一个字符串aabaa,其前缀串有:a,aa,aab,aaba,其后缀串有:a,aa,baa,abaa,可以看出前两个相同,从而其最长相等前后缀长度为2。
最后理解什么是前缀表:对于待查找字符串aabaaf,第一个位置a前缀和后缀都不存在,自然地最长相等前后缀为0;第二个位置,从头开始的子串为aa,最长相等前后缀长度为1(前缀后缀串分别为a和a);第三个位置,从头开始的子串为aab,最长相等前后缀长度为0。依此类推,可以构造出对应于待查找字符串aabaaf的前缀表为[0,1,0,1,2,0]。
前缀表记录了下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀,可以指示发生失配时,待匹配的needle字符串回退到哪个位置。
例如上图(来源:代码随想录),匹配失败的位置是后缀子串的后面(5),那么我们回退到与其相同的前缀的后面(2)处开始匹配就可以了,而不需要全部重新匹配。在位置(4)处,记录了aabaa的最长相等前后缀长度2(即应当回退到needled目标串的下标位置),因此在某一位置失配时,要寻找该位置上一个位置记录的最长相等前后缀长度(即应当回退到的位置)。
构造next数组的过程包括:
初始时front指向前缀末尾位置(初始为0),rear指向后缀末尾位置(初始为1)。在一个循环中只遍历一次目标字符串,即移动后缀末尾位置rear。
前后缀不相同时,next[j]记录着j(包括j)之前的子串的相同前后缀的长度。利用front = next[front - 1]进行状态回退。
前后缀相同时,同时移动rear和front,同时记录front的位置更新前缀表。
//使用KMP算法,首先构造前缀表;
//实现方法为整体右移,查找时跳转至冲突元素指向的的位置即可
void getNext(int* next, const string& s) {
//初始化第一个元素为0
int front = 0;
next[front] = 0;
//rear指向后缀最后一个元素,front起始时指向上个rear元素已匹配好的最长前缀的最后一个元素
//next数组每个元素存放上一个元素对应最长相等前后缀的长度
for (int rear = 1; rear < s.size(); rear++) {
//前后缀没有匹配,则进行回退
while (front > 0 && s[front] != s[rear]) {
front = next[front - 1];
}
//前后缀匹配的情况
if (s[front] == s[rear]) {
front++;
}
next[rear] = front;
}
}
利用前缀表可以方便实现目标子串的查找:
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
int *next = new int[needle.size()];
getNext(next, 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 - j + 1);
}
}
return -1;
}
和前缀表的构建很类似,若失配,则利用next数组一直回退到具有最大相同前后缀的位置 。回退后继续进行比较,若比较成功,在目标子串中更新指针状态,进入下一次循环,直到查找完毕。
采用动态规划思想理解KMP算法:算法过程参考连接
int strStr(string haystack, string needle) {
// dp[j][c] = next:当前是状态 j,遇到了字符 c,应该转移到状态 next
// next作为下标的含义是:已匹配了next个字符
int length = needle.size();
vector> dp(length, vector(26, 0));
// 初始化dp数组:匹配到第一个字符时,状态才能推进
dp[0][needle[0] - 'a'] = 1;
// 递推公式:
// 1.字符 c 和 pat[j] 匹配的话,状态就应该向前推进一个,也就是说 next = j + 1【即状态推进】
// 2.1 如果字符 c 和 pat[j] 不匹配的话,状态就要回退(或原地不动)【即状态重启】
// 2.2 确定回退的位置:当前状态具有相同的前后缀【即影子状态X】
// 定义影子位置
int X = 0;
for (int j = 1; j < length; ++j) {
for (int c = 0; c < 26; ++c) {
if (needle[j] == (char)('a' + c)) {
dp[j][c] = j + 1;
}
else {
dp[j][c] = dp[X][c];
}
}
// 更新影子位置:状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前缀
X = dp[X][needle[j] - 'a'];
}
// 利用dp数组进行查找
int j = 0;
for (int i = 0; i < haystack.size(); ++i) {
j = dp[j][haystack[i] - 'a'];
// 抵达终止态
if (j == length) {
return i - j + 1;
}
}
// 没到达终止态,匹配失败
return -1;
}
dp[j][c] = next:当前是状态 j,遇到了字符 c,应该转移到状态 next。在深入理解该dp数组含义的基础上,可以较清楚地构造状态转移表和搜索算法。
其中较难理解的点在于“影子状态”的构造,基于动态规划的思想,从迭代起始位置和迭代过程中的操作方式也可以看出,状态 X 总是落后状态 j 一个状态,与 j 具有最长的相同前后缀,因此更新X的方式为:X = dp[X][needle[j] - 'a']。(j已经遇到过该模式,就会更新;j没遇到过该模式,回退或不动)。
void getNext(int* next, const string& s) {
int front = 0;
next[front] = 0;
for (int rear = 1; rear < s.size(); rear++) {
while (front > 0 && s[front] != s[rear]) {
front = next[front - 1];
}
if (s[front] == s[rear]) {
front++;
}
next[rear] = front;
}
}
bool repeatedSubstringPattern(string s) {
int* next = new int[s.size()];
getNext(next, s);
if (s.size() == 0) {
return false;
}
int len = s.size();
if (next[len - 1] != 0 && len % (len - next[len - 1]) == 0) {
return true;
}
return false;
}
本题仍采用KMP算法构建了前缀表,利用了前缀表的性质。若字符串可以被重复子串构成,那么最后一个周期的字符串与第一个周期的字符串相同,可以想象出来:前缀表的最后一个元素,记录了第一个周期最后一个元素的位置(即最大重复子串长度,亦即最长相同前后缀长度)。
因此利用if (next[len - 1] != 0 && len % (len - next[len - 1]) == 0),可以判断字符串是否可以被重复子串构建。
vector findRepeatedDnaSequences(string s) {
// 滑动哈希技巧:在滑动窗口中快速计算元素的哈希值
unordered_set dna_vals;
set res_set;
// 暂存当前窗口内字符串对应数值[A:1 G:2 C:3 T:4]
int dna_val = 0;
int left = 0, right = 0;
int add_val = 0, delete_val = 0;
while (right < s.size()) {
// 右边界移动逻辑
add_val = CharToInt(s[right]);
right++;
// 转化为四进制数
dna_val = dna_val * 4 + add_val;
// 左边界移动逻辑
if (right - left == 10) {
if (dna_vals.find(dna_val) != dna_vals.end()) {
res_set.insert(s.substr(left, 10));
}
else dna_vals.insert(dna_val);
delete_val = CharToInt(s[left]);
left++;
dna_val -= delete_val * pow(4, 9);
}
}
vector res(res_set.begin(), res_set.end());
return res;
}
int CharToInt(char c) {
if (c == 'A') return 1;
else if (c == 'G') return 2;
else if (c == 'C') return 3;
else return 4;
}
本题优化的关键在于,不需要取出滑动窗口内的字符串,而是用一些其他形式的唯一标识来表示滑动窗口中的子字符串,并且还能在窗口滑动的过程中快速更新。这种在滑动窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧。
由于只有AGCT四个数,因此可以采用四进制的数储存10位长度的子串对应的值。本方法适用于哈希表规模较小的情况。若哈希表规模较大,可采用取模的方式,出现哈希冲突时再采用暴力搜索进行验证。
int balancedString(string s) {
unordered_map record;
int target = s.size() / 4;
for (auto c : s) {
record[c]++;
}
// 检查函数
auto check = [&]() {
if (record['Q'] > target || record['W'] > target
|| record['E'] > target || record['R'] > target) {
return false;
}
return true;
};
if (check()) return 0;
int left = 0, right = 0, res = s.size();
// 移动左窗口
for (; left < s.size(); left++) {
while (right < s.size() && !check()) {
// 其中一种字符的出现次数大于 target 时, 移动右窗口
record[s[right]]--;
right++;
}
// 以当前left开头无解
// [注意]:此处没有使用continue,因为一旦无解,继续移动左指针也不会有解
if (!check()) {
break;
}
// 区间定义:[left, right)
res = min(res, right - left);
record[s[left]]++;
}
return res;
}
class Solution {
public:
int numMatchingSubseq(string s, vector& words) {
// 记录目标串中各字符的下标
unordered_map> records;
for (int i = 0; i < s.size(); ++i) {
records[s[i]].push_back(i);
}
int res = 0;
for (string word : words) {
// 启动算法,子序列第一个元素从s[0]开始查找
int s_idx = 0;
bool is_match = true;
for (int i = 0; i < word.size(); ++i) {
// 若s中不存在子序列当前元素,直接跳出
if (!records.count(word[i])) {
is_match = false;
break;
}
// 若上次找到了一个目标元素,相当于移动i指针,word[i]对应s的下标应从s_idx + 1开始
int record_idx = left_bound(records[word[i]], s_idx);
// 有一个字符匹配不上,就结束本单词的匹配
if (record_idx == -1) {
is_match = false;
break;
}
// 继续匹配下一个字符,从s的下一个位置开始
s_idx = records[word[i]][record_idx] + 1;
}
if (is_match) res++;
}
return res;
}
// 二分查找左边界:当 val 不存在时,得到的索引恰好是比 val 大的最小元素索引
// 例如子序列"abc",查找到a在s中的下标为1,查找b时,在b对应s的下标索引中,找比1大的第一个元素即可
int left_bound(vector& record, int target) {
int left = 0;
int right = record.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (record[mid] < target) {
left = mid + 1;
}
else {
right = mid - 1;
}
// 若不存在比子序列上一个元素索引更大的当前子序列元素,标记返回结果为-1
if (left == record.size()) return -1;
}
return left;
}
};
算法执行过程如下图所示(来源:labuladong):
本题需要注意的是利用到了左边界二分查找的性质:当 val
不存在时,得到的索引恰好是比 val
大的最小元素索引。