滑动窗口算法可以用以解决数组/字符串的子元素问题,它可以将嵌套的循环问题,转换为单循环问题,降低时间复杂度。
简而言之,滑动窗口算法在一个特定大小的字符串或数组上进行操作,而不在整个字符串和数组上操作,这样就降低了问题的复杂度,从而也达到降低了循环的嵌套深度。
可以用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。往往类似于“ 请找到满足 xx 的最 x 的区间(子串、子数组)的 xx ”这类问题都可以使用该方法进行解决。
转载labuladong:
力扣
fucking-algorithm/滑动窗口技巧.md at master · labuladong/fucking-algorithm · GitHub
适用题型:
遇到子串问题,首先想到用滑动窗口技巧。(意味着一个串长度一定小与另一个串)
题型分为窗口大小不断变化和窗口大小固定两种题型。
思路:
滑动窗口算法的思路是这样:
1、我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。
2、我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。
3、此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
4、重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。
这个思路其实也不难,**第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。**左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。
上述过程可以简单地写出如下伪码框架:
string s, t;
// 在 s 中寻找 t 的「最小覆盖子串」
int left = 0, right = 0;
string res = s;
while(right < s.size()) {
window.add(s[right]);
right++;
// 如果符合要求,移动 left 缩小窗口
while (window 符合要求) {
// 如果这个窗口的子串更短,则更新 res
res = minLen(res, window);
window.remove(s[left]);
left++;
}
}
return res;
注:
1,输入两个字符串的,需要needs和window两个hash计数器。输入为一个字符串的只需要一个window计数器。
2,shrink条件,即更新结果条件两个地方要注意。
可以用两个哈希表当作计数器解决。用一个哈希表 needs 记录字符串 t 中包含的字符及出现次数,用另一个哈希表 window 记录当前「窗口」中包含的字符及出现的次数,如果 window 包含所有 needs 中的键,且这些键对应的值都大于等于 needs 中的值,那么就可以知道当前「窗口」符合要求了,可以开始移动 left 指针了。
思路:
(1) 往右滑,更新使window符合条件的变量(这里是match): 当right所处位置字符在needs中,且window中c1出现次数等于needs中c1出现次数时才增加mach。而window中可以记录[left, right]中所有字符,也可以只记录[left, right]范围内只出现在needs中的字符
(2) 窗口内字符满足条件:匹配到的数量等于needs.size()
(3) 结果更新:用start和minLen记录所要求的s的子串起始pos和长度。
(4) 往左滑,更新使window不符合条件的变量(这里是match)
window滑动的过程中只记录s在[left, right]段中在needs中出现过的字符:
string minWindow(string s, string t) {
// 记录最短子串的开始位置和长度
int start = 0, minLen = INT_MAX;
int left = 0, right = 0;
unordered_map window;
unordered_map needs;
for (char c : t) needs[c]++;
int match = 0;
while (right < s.size()) {
char c1 = s[right];
if (needs.count(c1)) {
window[c1]++;
if (window[c1] == needs[c1])
match++;
}
right++;
while (match == needs.size()) {
if (right - left < minLen) {
// 更新最小子串的位置和长度
start = left;
minLen = right - left;
}
char c2 = s[left];
if (needs.count(c2)) {
window[c2]--;
if (window[c2] < needs[c2])
match--;
}
left++;
}
}
return minLen == INT_MAX ?
"" : s.substr(start, minLen);
}
window滑动的过程中会记录s在[left, right]段的所有字符:
class Solution {
public:
string minWindow(string s, string t) {
int left = 0;
int right = 0;
unordered_map needs;
unordered_map window;
for (auto ch : t) {
needs[ch]++;
}
int match = 0;
int minLen = INT_MAX;
int start = 0;
while (right < s.size()) {
char c1 = s[right];
window[c1]++;
if (needs.count(c1) > 0 && (window[c1] == needs[c1])) {
match++;
}
right++; // 扩大窗口右边界
while (match == needs.size()) { // 扩大窗口右边界后找到一个解。
// 缩减窗口左边界优化这个解
//更新
if (right - left < minLen) {
start = left;
minLen = right - left;
}
char c2 = s[left];
if (needs.count(c2) > 0 && (window[c2] == needs[c2])) {
match--;
}
window[c2]--;
left++;
}
}
string res = minLen != INT_MAX ? s.substr(start, minLen) : "";
return res;
}
};
思路:
(1) 往右滑,更新使window符合条件的变量(这里是match): 当right所处位置字符在needs中,且window中c1出现次数等于needs中c1出现次数时才增加mach。window只记录[left, right]范围内只出现在needs中的字符。
(2) 窗口内字符满足条件:匹配到的数量等于needs.size()
(3) 结果更新:只有right - left大小等于p串长度,才是目标子串。
(4) 往左滑,更新使window不符合条件的变量(这里是match)
vector findAnagrams(string s, string t) {
// 用数组记录答案
vector res;
int left = 0, right = 0;
unordered_map needs;
unordered_map window;
for (char c : t) needs[c]++;
int match = 0;
while (right < s.size()) {
char c1 = s[right];
if (needs.count(c1)) {
window[c1]++;
if (window[c1] == needs[c1])
match++;
}
right++;
while (match == needs.size()) {
// 如果 window 的大小合适
// 就把起始索引 left 加入结果
if (right - left == t.size()) {
res.push_back(left);
}
char c2 = s[left];
if (needs.count(c2)) {
window[c2]--;
if (window[c2] < needs[c2])
match--;
}
left++;
}
}
return res;
}
类似之前的思路,使用 window 作为计数器记录窗口中的字符出现次数,然后先向右移动 right,当 window 中出现重复字符时,开始移动 left 缩小窗口,如此往复:
需要注意的是,因为我们要求的是最长子串,所以需要在每次移动 right 增大窗口时更新 res,而不是像之前的题目在移动 left 缩小窗口时更新 res。
// 滑动窗口, 窗口[left, right)和window_map的间接关系,
// window_map中存的就是[left, right)中的元素。
// 增加求子串本身
int lengthOfLongestSubstring(string s) {
int res = 0;
int left = 0;
int right = 0;
int start = 0;
// unordered_map need;
unordered_map window;
while (right < s.size()) {
char c1 = s[right];
window[c1]++;
right++;
// 满足条件时:窗口中有重复字符(但不知道此时重复字符ch1处在当前窗口的哪个位置),
// 缩小窗口, 直到窗口中与ch1重复的字符被从窗口里拿掉
while (window[c1] > 1) {
char c2 = s[left];
window[c2]--;
left++;
}
// 此时window已经滤掉了重复元素
// res = max(res, right - left);
if (right - left > res) {
res = right - left;
start = left;
}
}
string resStr = s.substr(start, res);
return res;
}
424. 替换后的最长重复字符
// 方法:滑动窗口
// 思路:
// 右边界先移动找到一个满足题意的可以替换 k 个字符以后,所有字符都变成一样的当前看来最长的子串,直到右边界纳入一个字符以后,不能满足的时候停下;然后考虑左边界向右移动,左边界只须要向右移动一格以后,右边界就又可以开始向右移动了,继续尝试找到更长的目标子串; 替换后的最长重复子串就产生在右边界、左边界交替向右移动的过程中。
class Solution1 {
public:
int characterReplacement(string s, int k) {
int res = 0;
int left = 0;
int right = 0;
vector window(26);
int maxCnt = 0;
// [left, right) 内最多替换 k 个字符可以得到只有一种字符的子串
while (right < s.size()) {
window[s[right] - 'A']++;
// 在这里维护 maxCount,因为每一次右边界读入一个字符,字符频数增加,才会使得 maxCount 增加
maxCnt = max(maxCnt, window[s[right] - 'A']);
right++;
if (right - left > maxCnt + k) { // if判断,仅缩小一次窗口左边界
// 说明此时 k 不够用(把其它不是最多出现的字符替换以后,都不能填满这个滑动的窗口),这个时候须要考虑左边界向右移动
// 移出滑动窗口的时候,频数数组须要相应地做减法
window[s[left] - 'A']--;
left++;
}
res = max(res, right - left); // 因为已经缩小了窗口左边界, 因此在这里更新结果
}
return res;
}
};
思路:固定长度窗口向前滑动,窗口维护一个total值,每次移动窗口,只需要对窗口加新头、去旧尾两步操作(不要每次滑动都重新把窗口内的值全部计算一遍,这样时间复杂度是O(n*k))保证O(n)时间复杂度。
1343. 大小为 K 且平均值大于等于阈值的子数组数目
class Solution {
public:
int numOfSubarrays(vector& arr, int k, int threshold) {
size_t itBound = arr.size() - k;
int sum = accumulate(arr.begin(), arr.begin() + k, 0);
int kThreshold = k * threshold;
int result = sum >= kThreshold ? 1 : 0;
int headKMinusOneSum = sum - arr[0];
for (size_t i = 1; i <= itBound; i++) {
sum = headKMinusOneSum + arr[i + k - 1];
result = sum >= kThreshold ? result + 1 : result;
headKMinusOneSum = sum - arr[i];
}
return result;
}
};
567. 字符串的排列
class Solution {
public:
// 穷举, 超时 O(m! * (n-m))
bool checkInclusion1(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
sort(s1.begin(), s1.end());
do {
if (s2.find(s1) != string::npos) {
return true;
}
} while (next_permutation(s1.begin(), s1.end()));
return false;
}
// 滑动窗口(固定大小子串),排序
// 思路:对s1排序,然后一遍遍历s2,每步截取s1.size()长度子串并对子串排序,然后再和
// 序后的s1比较相等。
//代码:略
// 滑动窗口(哈希数组), 通过7% + 6%, O(26*m*(n-m))
// 思路:一遍遍历s2,截取s1.size()长度子串后放入哈希数组2,然后再比较两个哈希数组。
bool checkInclusion2(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
vector hashVec1(26);
size_t s1Len = s1.size();
for (size_t i = 0; i < s1Len; i++) {
hashVec1[s1[i] - 'a']++;
}
size_t s2Bound = s2.size() - s1.size() + 1;
for (size_t i = 0; i < s2Bound; i++) {
string subS2 = s2.substr(i, s1Len);
vector hashVec2(26);
for (size_t j = 0; j < s1Len; j++) {
hashVec2[subS2[j] - 'a']++;
}
size_t k = 0;
for (; k < 26; k++) {
if (hashVec1[k] != hashVec2[k]) {
break;
}
}
if (k == 26) {
return true;
}
}
return false;
}
// 子串问题:滑动窗口(哈希窗口) 89.3%, 96.29% O(26*(n - m))
// 思路:只维护两个哈希数组,一遍遍历s2,比较两个哈希数组。
bool checkInclusion(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
vector needs(26);
vector window(26);
for (size_t i = 0; i < s1.size(); i++) {
needs[s1[i] - 'a']++;
window[s2[i] - 'a']++;
}
size_t s2Bound = s2.size() - s1.size() + 1;
for (size_t i = 0; i < s2Bound; i++) {
size_t k = 0;
for (; k < 26; k++) {
if (needs[k] != window[k]) {
break;
}
}
if (k == 26) {
return true;
}
if (i == s2Bound - 1) {
break;
}
// 窗口只需加头,去尾两步操作
window[s2[i + s1.size()] - 'a']++;
window[s2[i] - 'a']--;
}
return false;
}
// 同上文lc438找到字符串中所有字母异位词: 41ms, 41.5%。用这种解法
bool checkInclusion(string s, string t) {
if (s.size() > t.size()) {
return false;
}
int left = 0;
int right = 0;
unordered_map needs;
unordered_map window;
for (auto ch : s) {
needs[ch]++;
}
int match = 0;
while (right < t.size()) {
char c1 = t[right];
if (needs.count(c1) != 0) {
window[c1]++;
if (window[c1] == needs[c1]) {
match++;
}
}
right++;
while (match == needs.size()) {
if (right - left == s.size()) {
return true;
}
char c2 = t[left];
if (needs.count(c2) != 0) {
window[c2]--;
if (window[c2] < needs[c2]) {
match--;
}
}
left++;
}
}
return false;
}
};
783. 二叉搜索树节点最小距离
思路:// 贪心 + 滑动窗口 + 二叉搜索树中序遍历
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
// 贪心 + 滑动窗口 + 二叉树中序遍历
int minDiffInBST(TreeNode* root) {
vector inOrderPath;
InOrderTravel(root, inOrderPath);
int result = INT_MAX;
// 窗口大小为2, 相邻两元素
int windowLeft = inOrderPath[0];
for (size_t i = 1; i < inOrderPath.size(); i++) {
result = min(result, inOrderPath[i] - windowLeft);
windowLeft = inOrderPath[i];
}
return result;
}
void InOrderTravel(TreeNode* root, vector& inOrderPath) {
if (root != nullptr) {
InOrderTravel(root->left, inOrderPath);
inOrderPath.push_back(root->val);
InOrderTravel(root->right, inOrderPath);
}
}
};
滑动窗口题目:
3. 无重复字符的最长子串 done
30. 串联所有单词的子串
76. 最小覆盖子串 done
159. 至多包含两个不同字符的最长子串
209. 长度最小的子数组 done
class Solution {
public:
int minSubArrayLen(int s, vector& nums) {
int sum = 0;
int left = 0;
int right = 0;
int result = nums.size();
while (right < nums.size()) {
sum += nums[right];
right++;
while (sum >= s) {
result = min(result, right - left);
sum -= nums[left];
left++;
}
}
return result;
}
};
方法2:前缀和 + 二分
因为这道题保证了数组中每个元素都为正,所以前缀和一定是递增的,这一点保证了二分的正确性。如果题目没有说明数组中每个元素都为正,这里就不能使用二分来查找这个位置了。
class Solution {
public:
int minSubArrayLen(int s, vector& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
int ans = INT_MAX;
vector sums(n + 1, 0);
for (int i = 1; i <= n; i++) {
sums[i] = sums[i - 1] + nums[i - 1];
}
for (int i = 1; i <= n; i++) {
int target = s + sums[i - 1];
auto bound = lower_bound(sums.begin(), sums.end(), target);
if (bound != sums.end()) {
ans = min(ans, static_cast((bound - sums.begin()) - (i - 1)));
}
}
return ans == INT_MAX ? 0 : ans;
}
};
239. 滑动窗口最大值 done 滑动窗口 + 单调队列(单调递减队列)
// 滑动窗口 + 单调队列, 固定窗口大小
// 思路:
// 单调递减队列中存储数组下标, 下标是递增的, 下标对应的值是递减的
// 窗口滑动时,当前元素若大于(或大于等于)队尾下标对应的值则循环出队尾, 若当前队首下标在窗口外(队首下标小于left=i-k)则循环队首出队--保证队列中的值始终是滑动中窗口内的值
注:此类题不能用优先队列代替单调队列,因为无法实现优先队列中的值始终是窗口内的值,不能实现窗口外的值及时出优先队列,eg:[9,10,9,-7,-4,-8,2,-6],k=5。
class Solution {
public:
vector maxSlidingWindow(vector& nums, int k) {
if (nums.size() * k == 0) {
return {};
}
vector res;
deque q;
for (size_t i = 0; i < k; i++) {
while (!q.empty() && nums[q.back()] < nums[i]) {
q.pop_back();
}
q.push_back(i);
}
res.push_back(nums[q.front()]);
for (size_t i = k; i < nums.size(); i++) {
// 队列头是上一步窗口内最大值的索引值。若队列头不在当前窗口内,则队首出队,看第二大的数处于队首,再继续比较
while (!q.empty() && q.front() <= i - k) {
q.pop_front();
}
while (!q.empty() && nums[q.back()] < nums[i]) {
q.pop_back();
}
q.push_back(i);
res.push_back(nums[q.front()]);
}
return res;
}
};
567. 字符串的排列 done
class Solution {
public:
// 穷举, 超时 O(m! * (n-m))
bool checkInclusion1(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
sort(s1.begin(), s1.end());
do {
if (s2.find(s1) != string::npos) {
return true;
}
} while (next_permutation(s1.begin(), s1.end()));
return false;
}
// 滑动窗口(固定大小子串),排序
// 思路:对s1排序,然后一遍遍历s2,每步截取s1.size()长度子串并对子串排序,然后再和
// 序后的s1比较相等。
//代码:略
// 滑动窗口(哈希数组), 通过7% + 6%, O(26*m*(n-m))
// 思路:一遍遍历s2,截取s1.size()长度子串后放入哈希数组2,然后再比较两个哈希数组。
bool checkInclusion2(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
vector hashVec1(26);
size_t s1Len = s1.size();
for (size_t i = 0; i < s1Len; i++) {
hashVec1[s1[i] - 'a']++;
}
size_t s2Bound = s2.size() - s1.size() + 1;
for (size_t i = 0; i < s2Bound; i++) {
string subS2 = s2.substr(i, s1Len);
vector hashVec2(26);
for (size_t j = 0; j < s1Len; j++) {
hashVec2[subS2[j] - 'a']++;
}
size_t k = 0;
for (; k < 26; k++) {
if (hashVec1[k] != hashVec2[k]) {
break;
}
}
if (k == 26) {
return true;
}
}
return false;
}
// 子串问题:滑动窗口(哈希窗口) 89.3%, 96.29% O(26*(n - m))
// 思路:只维护两个哈希数组,一遍遍历s2,比较两个哈希数组。
bool checkInclusion(string s1, string s2) {
if (s1.size() > s2.size()) {
return false;
}
vector needs(26);
vector window(26);
for (size_t i = 0; i < s1.size(); i++) {
needs[s1[i] - 'a']++;
window[s2[i] - 'a']++;
}
size_t s2Bound = s2.size() - s1.size() + 1;
for (size_t i = 0; i < s2Bound; i++) {
size_t k = 0;
for (; k < 26; k++) {
if (needs[k] != window[k]) {
break;
}
}
if (k == 26) {
return true;
}
if (i == s2Bound - 1) {
break;
}
// 窗口只需加头,去尾两步操作
window[s2[i + s1.size()] - 'a']++;
window[s2[i] - 'a']--;
}
return false;
}
// 同上文lc438找到字符串中所有字母异位词: 41ms, 41.5%。用这种解法
bool checkInclusion3(string s, string t) {
if (s.size() > t.size()) {
return false;
}
int left = 0;
int right = 0;
unordered_map needs;
unordered_map window;
for (auto ch : s) {
needs[ch]++;
}
int match = 0;
while (right < t.size()) {
char c1 = t[right];
if (needs.count(c1) != 0) {
window[c1]++;
if (window[c1] == needs[c1]) {
match++;
}
}
right++;
while (match == needs.size()) {
if (right - left == s.size()) {
return true;
}
char c2 = t[left];
if (needs.count(c2) != 0) {
window[c2]--;
if (window[c2] < needs[c2]) {
match--;
}
}
left++;
}
}
return false;
}
};
632. 最小区间
727. 最小窗口子序列
其他子串问题:
回文子串不用滑动窗口,用中心扩展法。
注:回文串有两种:
(1) "aabaa" (2) "aabba"
class Solution {
public:
string longestPalindrome(string s)
{
int sLength = s.size();
if (sLength == 0) {
return "";
}
int start = 0;
int end = 0;
int maxLen = 0;
int len = 0;
string result;
for (int i = 0; i < sLength; i++) {
int len1 = CenterExpandValid(s, sLength, i, i); // 尝试计算aabaa类型回文串长度
int len2 = CenterExpandValid(s, sLength, i, i + 1); // 尝试计算aabbaa类型回文长度串
len = max(len1, len2);
if (len > maxLen) {
maxLen = len;
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
result = s.substr(start, maxLen);
return result;
}
int CenterExpandValid(string& s, int sLength, int left, int right)
{
while (left >= 0 && right < sLength && s[left] == s[right]) {
left--;
right++;
}
return right - left - 1;
}
};