接上一篇文章,在遇到关于数组,字符串这两类型的算法题时,我们对数组的解决思路一般是能否先将乱序数组转化为有序数组,再进行后续处理。对于那些无法变为有序数组的,就类似于字符串,我们知道遍历所有的子数组或者是遍历所有的子串的时间复杂度是非常高的。那我们有没有更好的方法去解决或者优化呢?
通常我们会想到双指针,这里要用的也是双指针,但不同于快慢指针和对撞指针,它是一个同向的双指针,用left和right指针维持一个区间,这个区间就叫窗口,这个窗口在主字符串或者是主数组中遍历,并且left指针和right指针不需要也不会回溯,所以叫做滑动窗口。因此他的时间复杂度是。
那他通常用在什么场景呢?显而易见,因为是滑动窗口,它维持的是主串中的一个连续区间,所以通常是用在连续子串或连续子数组上。
比如我们用滑动窗口遍历一个数组,如果我们要找出它的一个和为x的长度最小的连续子数组,例如:
x = 7, nums = [2,3,1,2,4,3] 输出:子数组 [4, 3]
如果我们用暴力枚举所有连续子串的话,时间复杂度太高,我们使用滑动窗口的话,最坏的时间复杂度为,即两个指针left,right都把数组遍历了一遍。
例如,我们可以看一下这一题
问题简述(1004. 最大连续1的个数 III - 力扣(LeetCode)):
(思路)可以不用翻转0,我们只需要记住我们找出来的连续子数组里最多只能包含k个0即可,我们用滑动窗口遍历数组的时候,我们遇到第k个0的时候依旧可以继续扩大我们的窗口,只有在遇到第K+1个0的时候才需要更新我们的窗口。
具体细节用代码的形式给出:
class Solution {
public:
int longestOnes(vector& nums, int k) {
int left = 0;
int right = 0;
int len = 0;
int count_zero = 0;
while(right < nums.size()){
if(nums[right] == 0){
++count_zero;
}
while(count_zero > k){ //更新左区间
if(nums[left] == 0){
--count_zero;
}
++left;
}
len = max(len,right-left+1);
++right;
}
return len;
}
};
问题简述(1658. 将 x 减到 0 的最小操作数 - 力扣(LeetCode)):
(思路)滑动窗口,我们可以反着来,先求出数组的和sum,如果sum-x的值已经小于0的话说明肯定没有附和条件的子数组,然后我们用滑动窗口遍历数组,用tmp记录窗口内数组的和。
具体细节用代码的形式给出:
class Solution {
public:
int minOperations(vector& nums, int x) {
int sum = 0;
for(int i = 0; i < nums.size(); ++i){
sum += nums[i];
}
int left = 0;
int right = 0;
int tmp = 0;
int len = -1;
while(right < nums.size()){
tmp += nums[right];
while(left < nums.size() && tmp > sum-x){ //极端情况下如[1, 1],left指针会越界
tmp -= nums[left];
++left;
}
if(tmp == sum-x){
len = max(len,right-left+1); //如果有更新值一定是>=0
}
++right;
}
if(len >= 0){
return nums.size()-len;
}else{
return -1;
}
}
};
问题简述(3. 无重复字符的最长子串 - 力扣(LeetCode)):
(思路)滑动窗口+哈希表,通常我们遇到字符串都需要用到哈希表,因为我们需要用哈希表来记住我们之前出现过的字符,如果是知道字符范围,我们可以用数组来模拟哈希表,但是如果我们不知道会出现什么样的字符,我们就可以直接用哈希表来帮完成记住这些字符和其出现的次数等等。在这里,我们使用数组模拟哈希表,用滑动窗口遍历字符串。
具体细节用代码的形式给出:
class Solution {
public:
int lengthOfLongestSubstring(string s) {
int hash[128] = {0};
int left = 0;
int right = 0;
int len = 0;
while(right < s.size()){
hash[s[right]]++;
while(hash[s[right]] > 1){ //字符s[right]重复
hash[s[left]]--;
left++;
}
len = max(len,right-left+1);
++right;
}
return len;
}
};
问题简述(904. 水果成篮 - 力扣(LeetCode)):
(思路)哈希表+滑动窗口,我们用哈希表记录下我们遇到的不同数字。用滑动窗口依次遍历数组找到最大连续子数组长度即可。
具体细节用注释的形式标出:
class Solution {
public:
int totalFruit(vector& fruits) {
unordered_map hash;
int left = 0;
int right = 0;
int sum = 0;
while(right < fruits.size()){
hash[fruits[right]]++;
while(hash.size() > 2){
hash[fruits[left]]--;
if(hash[fruits[left]] == 0){ //把hash表中为0的元素删去,即完成hash表的size()--操作
hash.erase(fruits[left]);
}
++left;
}
sum = max(sum, right-left+1);
++right;
}
return sum;
}
};
问题简述(438. 找到字符串中所有字母异位词 - 力扣(LeetCode)):
(思路)滑动窗口+哈希表,包含的字符是小写字母,我们直接用两个数组模拟哈希表,记录下模式串p和窗口子串(窗口子串指滑动窗口里的这一个字符串,是主串的一个子串)中出现的字母。由于返回子串的长度和模式串p的长度是一样的,所以我们可以固定住我们的滑动窗口的长度,依次遍历主串即可。
具体细节用注释的形式标出:
class Solution {
public:
vector findAnagrams(string s, string p) {
if(s.size() < p.size()) //主串s比模式串p长度小直接返回
return {};
vector map_s(26);
vector map_p(26);
for(auto& i : p){
map_p[i - 'a']++;
}
int left = 0;
int right = 0;
vector ret;
while(right < s.size()){
map_s[s[right] - 'a']++;
if(right-left+1 == p.size()){ //子串的长度必须和模式串一致
if(map_p == map_s){ //两张哈希表相同表明窗口子串的出现的字符和模式串中的字符一致(种类一致)
ret.push_back(left);
}
map_s[s[left] - 'a']--;
++left; //更新左区间和哈希表
}
++right;
}
return ret;
}
};
将这题做一些改变,得到下一题。
问题简述(30. 串联所有单词的子串 - 力扣(LeetCode)):
(思路)因为words中的所有字符串的长度都是相同的,我们可以把他看成是一个字符,比如示例1,他的单词长度是3,我们可以把主串s用希尔排序中分组的思想,组距是单词长度3,我们只需要遍历这三组字符串:
s = "bar foo the foo bar man"
s = "arf oot hef oob arm"
s = "rfo oth efo oba rma"
我们只需要依次把这三组串遍历,这个问题就变回上一题的找异位词(),这里要注意对哈希表的处理。具体细节用注释的形式标出:
class Solution {
public:
vector findSubstring(string s, vector& words) {
unordered_map map_words;
for(auto& i : words){
map_words[i]++;
}
vector ret;
int step = words[0].size(); //指针移动步长
int len = step*words.size(); //模式串长度
for(int i = 0; i < step; ++i){ //像希尔排序的划分组一样
int left = i;
int right = i;
unordered_map map_window;
while(right+step <= s.size()){ //实际右区间指针是一个单词的末尾
map_window[s.substr(right,step)]++;
if(right+step-left == len){ //返回子串长度与模式串长度一致
if(map_window == map_words){ //比较两个哈希表
ret.push_back(left);
}
map_window[s.substr(left,step)]--;
if(map_window[s.substr(left,step)] == 0){
map_window.erase(s.substr(left,step));
}
left += step; //指针需要移动的步长是一个单词
}
right += step;
}
}
return ret;
}
};
问题简述(76. 最小覆盖子串 - 力扣(LeetCode)):
(思路)滑动窗口+哈希表,由于主串s和模式串t全是大小写英文字符,我们可以直接用两个全0数组模拟模式串和窗口子串的哈希表。滑动窗口的具体细节用注释的形式标出:
class Solution {
public:
string minWindow(string s, string t) {
int map_t[128] = {0}; //记录模式串t中每个字符的个数
int count_t = 0; //记录模式串t中的有效字符个数
int count_win = 0; //记录窗口中的有效字符个数
for(auto i : t){
if(map_t[i] == 0){
++count_t;
}
map_t[i]++;
}
int map_s[128] = {0}; //记录窗口中每个字符的个数
int left = 0, right = 0;
int min_win = INT_MAX; //记录最小窗口长度
int begin = -1; //记录最小窗口的索引
while(right < s.size()){
map_s[s[right]]++;
if(map_s[s[right]] == map_t[s[right]]){ //一旦两张哈希表中记录的该字符个数达到一致,我们就将这个记为有效字符
++count_win;
}
while(count_win == count_t){ // 此时窗口覆盖了模式串中的所有字符
if(right-left+1 < min_win){
min_win = right-left+1;
begin = left;
}
if(map_s[s[left]] == map_t[s[left]]){ //此时两张哈希表中记录的该字符个数再次回到一致,因为窗口缩小,这个字符会出窗口,所以有效字符应该减1,减去之后已经不能覆盖模式串t了
--count_win;
}
map_s[s[left]]--;
++left;
}
++right;
}
if(begin == -1){ //如果begin没有被更新,表明没有符合的子串
return "";
}else{
return s.substr(begin,min_win);
}
}
};
这里的滑动窗口判断条件,也可以用更容易想到的形式,即窗口中出现字符的次数大于等于模式串中该字符的次数,但这样遍历的话时间复杂度比较大。