滑动窗口(Sliding Window)是一种常用的算法技术,通常用于处理序列(如数组或字符串)的问题,尤其是在寻找连续子序列、子数组或优化某些条件时非常有效。其基本思想是使用两个指针来表示当前窗口的边界,并通过移动这些指针来动态调整窗口的大小。
一、基本原理
-
定义窗口:通过两个指针(通常是左指针和右指针)来定义一个“窗口”,这个窗口可以是当前考虑的子数组或子字符串。
-
移动窗口:
- 扩展窗口:通过移动右指针来扩大窗口的范围,包含更多元素。
- 收缩窗口:通过移动左指针来缩小窗口,排除某些元素。
-
条件判断:在每次移动指针时,检查当前窗口是否满足特定条件,比如和、长度、包含的元素种类等。
-
记录结果:根据窗口状态更新所需的结果,比如最小长度、最大和等。
二、应用场景
滑动窗口常用于以下几类问题:
- 寻找最大或最小子数组:如在给定和的情况下寻找子数组的最小长度。
- 字符串处理:如找到最小覆盖子串、无重复字符的最长子串等。
- 动态数据流处理:处理连续的数据流,以维护某种统计特征。
三、示例:
分享丨【题单】滑动窗口与双指针(定长/不定长/至多/至少/恰好/单序列/双序列/三指针) - 力扣(LeetCode)
https://leetcode.cn/circle/discuss/0viNMK/
一、基础区:
eg 1、1456. 定长子串中元音的最大数目 - 力扣(LeetCode)(典型)
class Solution {
public:
int maxVowels(string s, int k) {
int ans = 0, tmp = 0;
for (int i = 0; i < s.length(); i++) {
// 进入窗口
if (s[i] == 'a' || s[i] == 'e' || s[i] == 'i' || s[i] == 'o' ||
s[i] == 'u')
tmp++;
if (i < k - 1)
continue;
// 更新答案(找出最大的元音字母数)
ans = max(ans, tmp);
// 出窗口
char out = s[i - k + 1];
if (out == 'a' || out == 'e' || out == 'i' || out == 'o' ||
out == 'u')
tmp--;
}
return ans;
}
};
eg 2、643. 子数组最大平均数 I - 力扣(LeetCode)
class Solution {
public:
double findMaxAverage(vector<int>& nums, int k) {
double ans = INT_MIN, sum = 0;
int n = nums.size();
for(int i = 0; i < n; i++){
// 进入窗口
sum += nums[i];
if(i < k - 1)
continue;
// 更新答案
ans = max(ans, sum);
// 出窗口
int out = nums[i - k + 1];
sum -= out;
}
return ans / k;
}
};
eg 3、1343. 大小为 K 且平均值大于等于阈值的子数组数目 - 力扣(LeetCode)
class Solution {
public:
int numOfSubarrays(vector<int>& arr, int k, int threshold) {
double ans = 0, sum = 0;
int n = arr.size();
for(int i = 0; i < n; i++){
// 进入窗口
sum += arr[i];
if(i < k - 1)
continue;
// 更新答案
if(sum / k >= threshold)
ans++;
// 出窗口
sum -= arr[i - k + 1];
}
return ans;
}
};
eg 4、2090. 半径为 k 的子数组平均值 - 力扣(LeetCode)
class Solution {
public:
vector<int> getAverages(vector<int>& nums, int k) {
int n = nums.size();
vector<int> ans(n, -1);
if(k == 0) return nums; // k == 0 不选--就是nums数组
if(k > n) return ans; // k > n 数组不够长选不了--答案数组全都是-1
// 第一次进入窗口
long long sum = 0;
for(int i = 0; i < 2 * k + 1 && i < n; i++)
sum += nums[i];
for(int i = k; i < n - k; i++){
// 更新答案
ans[i] = (sum / (2 * k + 1));
// 第二次进窗口
if(i + k + 1 < n)
sum += nums[i + k + 1];
// 出窗口
sum -= nums[i - k];
}
return ans;
}
};
eg 5、2379. 得到 K 个黑块的最少涂色次数 - 力扣(LeetCode)
class Solution {
public:
int minimumRecolors(string blocks, int k) {
int n = blocks.size();
int ans = INT_MAX;
int sum = 0; // 操作次数--白块的个数
int len = 0; // 操作后的子串长度
for (int i = 0; i < n; i++) {
// 进入窗口
len++;
if (blocks[i] == 'W')
sum++;
// 更新答案
if (len == k)
ans = min(ans, sum);
// 出窗口
if (len == k) {
if (blocks[i - len + 1] == 'W')
sum--;
len--;
}
}
return ans;
}
};
eg 6、1052. 爱生气的书店老板 - 力扣(LeetCode)
class Solution {
public:
int maxSatisfied(vector<int>& customers, vector<int>& grumpy, int minutes) {
int n = customers.size();
int sum = 0;
int ans = 0;
int windows = 0;
// 初步处理 sum
for (int i = 0; i < n; i++)
if (!grumpy[i]) // 先将不生气时的店长的顾客加入到sum
sum += customers[i];
// 初始化窗口顾客人数
for (int i = 0; i < minutes; i++)
if (grumpy[i])
windows += customers[i];
// 更新答案
ans = sum + windows;
// 开始滑动秘诀 minutes
for (int i = minutes; i < n; i++) {
// 出窗口
if (grumpy[i - minutes])
windows -= customers[i - minutes];
// 进入窗口
if (grumpy[i])
windows += customers[i];
// 更新答案
ans = max(ans, sum + windows);
}
return ans;
}
};
eg 7、1461. 检查一个字符串是否包含所有长度为 K 的二进制子串 - 力扣(LeetCode)
class Solution {
public:
bool hasAllCodes(string s, int k) {
int n = s.size();
if (n < k) return false; // 字符串长度小于k,肯定不能包含所有子串
unordered_set<int> seen;
int totalSubstrings = 1 << k; // 2^k,表示所有可能的子串个数
int currentHash = 0;
int maxMask = (1 << k) - 1; // 用于保持k位的掩码,去掉超出部分
// 初始化--计算前k位子串的哈希值
for (int i = 0; i < k; ++i) {
currentHash = (currentHash << 1) | (s[i] - '0');
}
// 进入窗口--插入第一个k位子串的哈希
seen.insert(currentHash);
// 滑动窗口,逐步计算后续子串的哈希
for (int i = k; i < n; ++i) {
// 出窗口--左移一位,丢弃左侧的高位,添加当前字符的值(0或1)
currentHash = ((currentHash << 1) & maxMask) | (s[i] - '0');
// 进入窗口
seen.insert(currentHash);
// 更新答案--如果已经找到所有可能的子串,返回true
if (seen.size() == totalSubstrings) {
return true;
}
}
return false; // 没有找到所有子串
}
};
// 初始化--计算前k位子串的哈希值
for (int i = 0; i < k; ++i) {
currentHash = (currentHash << 1) | (s[i] - '0');
}
-
currentHash << 1
:这是一个位移操作,将currentHash
的值左移一位。也就是说,将currentHash
的二进制表示向左移动一个位置,并且右侧补零。例如,原来currentHash
的二进制是101
,左移一位后变成1010
。 -
(s[i] - '0')
:这部分是将字符s[i]
转换为对应的数字。假设s[i]
是一个字符'0'
或'1'
,s[i] - '0'
的作用是将字符'0'
转换为整数0
,将字符'1'
转换为整数1
。 -
(currentHash << 1) | (s[i] - '0')
:将currentHash
左移一位后,再将s[i] - '0'
这个值通过按位或(|
)的操作,加入到currentHash
的最低位。如果s[i]
是'1'
,那么就把 1 放在currentHash
的最低位;如果s[i]
是'0'
,则把 0 放在最低位。
总结:将当前 currentHash
左移一位,并且把字符 s[i]
('0' 或 '1')的数值添加到 currentHash
的最低位。
同理:
currentHash = ((currentHash << 1) & maxMask) | (s[i] - '0');
-
currentHash << 1
:同第一行代码,将currentHash
左移一位。 -
(currentHash << 1) & maxMask
:这里有一个按位与(&
)操作。maxMask
是一个掩码,用来限制currentHash
的某些位。例如,如果maxMask
是一个全是 1 的二进制数,那么currentHash << 1
仍然会被保留;如果maxMask
只在某些位上是 1,那么通过按位与操作后,只保留这些位,其他位会被清零。这样做的目的是确保currentHash
的高位没有超出某个范围。 -
(s[i] - '0')
:将字符s[i]
转换为数字,操作同上。 -
((currentHash << 1) & maxMask) | (s[i] - '0')
:最终,左移并掩码后的currentHash
与s[i] - '0'
通过按位或操作结合。这样做的目的是将s[i]
的值添加到currentHash
的最低位,并且确保currentHash
的高位不超出maxMask
指定的范围。
总结:将 currentHash
左移一位,应用掩码 maxMask
限制高位,再将字符 s[i]
的数值添加到 currentHash
的最低位。
eg 8、2841. 几乎唯一子数组的最大和 - 力扣(LeetCode)
class Solution {
public:
long long maxSum(vector<int>& nums, int m, int k) {
long long ret = 0, sum = 0;
int n = nums.size();
unordered_map<long long, int> hash;
// 先进 k - 1 个 -- 便于我们书写后面 更新答案 和 出窗口
for(int i = 0; i < k - 1; i++){
sum += nums[i];
hash[nums[i]]++;
}
for (int i = k - 1; i < n; i++) {
// 进入窗口
sum += nums[i];
hash[nums[i]]++;
// 更新答案
if (hash.size() >= m) {
ret = max(ret, sum);
}
// 出窗口
int out = nums[i + 1 - k];
// 如果没有出现了, 就移除
if (--hash[out] == 0)
hash.erase(out);
sum -= out;
}
return ret;
}
};
eg 9、2461. 长度为 K 子数组中的最大和 - 力扣(LeetCode)
// ------------------数组(vector会增加时间、空间复杂度) + 滑动-----------------
class Solution {
public:
long long maximumSubarraySum(vector<int>& nums, int k) {
int n = nums.size();
long long ret = 0, sum = 0;
int hash[100010] = {0};
for (int i = 0, j = 0; i < n; i++) {
// 进入窗口
sum += nums[i];
hash[nums[i]]++;
// 出窗口
while (hash[nums[i]] == 2) {
sum -= nums[j];
hash[nums[j++]]--;
}
// 更新答案
if (i - j + 1 == k) {
ret = max(ret, sum);
sum -= nums[j];
hash[nums[j++]]--;
}
}
return ret;
}
};
/*
--------------------hash表 + 滑动----------------------
class Solution {
public:
long long maximumSubarraySum(vector<int>& nums, int k) {
int n = nums.size();
long long ret = 0, sum = 0;
unordered_map<long long , int> hash;
// 先进 k - 1 个
for(int i = 0; i < k - 1; i++){
sum += nums[i];
hash[nums[i]]++;
}
for(int i = k - 1; i < n; i++){
// 进入窗口
sum += nums[i];
hash[nums[i]]++;
// 更新答案
if(hash.size() == k)
ret = max(ret, sum);
// 出窗口
int out = nums[i + 1 - k];
sum -= out;
if(--hash[out] == 0)
hash.erase(out);
}
return ret;
}
};
*/
eg 10、1423. 可获得的最大点数 - 力扣(LeetCode)
// --------------滑动窗口----------------
class Solution {
public:
int maxScore(vector<int>& cardPoints, int k) {
int n = cardPoints.size();
int _k = n - k;
int nums_sum = 0;
int sum = 0;
// 计算数组所有元素的总和 -- accumulate(cardPoints.begin(), cardPoints.end(), 0);
for (auto i : cardPoints)
nums_sum += i;
// 先初始化sum为前_k个元素的和 -- accumulate(cardPoints.begin(), cardPoints.begin() + m, 0)
for (int i = 0; i < _k; i++)
sum += cardPoints[i];
int min_s = sum;
for (int i = _k; i < n; i++) {
// 进入窗口 + 出窗口
sum += cardPoints[i] - cardPoints[i - _k];
// 更新答案
min_s = min(min_s, sum);
}
return nums_sum - min_s;
}
};
eg 11、1652. 拆炸弹 - 力扣(LeetCode)
// 时间复杂度O(n)
class Solution {
public:
std::vector<int> decrypt(const std::vector<int>& code, int k) {
int n = code.size();
std::vector<int> ans(n);
// 确定滑动窗口的起始和结束索引
int start = k > 0 ? 1 : (n + k);
int end = k > 0 ? (k + 1) : n;
k = abs(k);
// 初始化窗口内元素和
int sum = reduce(code.begin() + start, code.begin() + end);
for (int i = 0; i < n; ++i) {
// 更新答案
ans[i] = sum;
// 进出窗口--最后的元素出窗口
sum += code[end % n] - code[(end - k) % n];
end++;
}
return ans;
}
};
eg 12、1297. 子串的最大出现次数 - 力扣(LeetCode)
class Solution {
public:
int maxFreq(string s, int maxLetters, int minSize, int maxSize) {
unordered_map<int, int> hash1;
unordered_map<string, int> hash2;
string str = "";
int ans = 0;
// 先进minSize - 1个
for (int i = 0; i < minSize - 1; i++) {
str += s[i];
hash1[s[i] - 'a']++;
}
for (int i = minSize - 1; i < s.size(); i++) {
// 进窗口
str += s[i];
hash1[s[i] - 'a']++;
hash2[str]++;
// 出窗口
if (str.size() > minSize) {
hash1[s[i - minSize] - 'a']--;
if (hash1[s[i - minSize] - 'a'] == 0)
hash1.erase(s[i - minSize] - 'a');
str.erase(0, 1);
hash2[str]++; // 注意这里的hash2[str]++
}
// 更新答案
if (hash1.size() <= maxLetters) {
ans = max(ans, hash2[str]);
}
}
return ans;
}
};
eg 13、2653. 滑动子数组的美丽值 - 力扣(LeetCode)
class Solution {
public:
vector<int> getSubarrayBeauty(vector<int>& nums, int k, int x) {
const int N = 50; // 偏移量
int cnt[2 * N + 1] = {0};
// 先初始化窗口--进入(k - 1)个元素
for(int i = 0; i < k - 1; i++)
cnt[nums[i] + N]++;
vector<int> ans(nums.size() - k + 1);
for(int i = k - 1; i < nums.size(); i++){
// 进入窗口
cnt[nums[i] + N]++;
int target = x; // <= num 的整数个数 >= x
// 枚举负数范围[-50, -1]
for(int j = 0; j < N; j++){
target -= cnt[j];
// 更新答案
if(target <= 0){
ans[i - k + 1] = j - N;
break;
}
}
// 滑出窗口
cnt[nums[i - k + 1] + N]--;
}
return ans;
}
};
二、进阶区:
eg 1、2134. 最少交换次数来组合所有的 1 II - 力扣(LeetCode)
// 统计窗口0的个数
class Solution {
public:
int minSwaps(vector<int>& nums) {
int n = nums.size();
// 窗口大小
int k = accumulate(nums.begin(), nums.end(), 0);
/*
for(auto i : nums)
if(i == 1)
k++;
*/
// 初始化窗口--进入k个元素
int num_0 = 0;
for(int i = 0; i < k; i++){
if(nums[i] == 0)
num_0++;
}
int ans = num_0;
// 滑动窗口 j--起始点 i--终止点
for(int i = k, j = 0; j < n; i++, j++){
// 滑出窗口
if(nums[j] == 0)
num_0--;
// 进入窗口
if(nums[i % n] == 0)
num_0++;
// 更新答案
ans = min(ans, num_0);
}
return ans;
}
};
eg 2、1888. 使二进制字符串字符交替的最少反转次数 - 力扣(LeetCode)
// 前后缀和
// class Solution {
// public:
// // 计算最小翻转次数的函数
// int minFlips(string s) {
// int n = s.length();
// int ans = n;
// // 枚举开头是 '0' 还是 '1'
// for (unsigned char head = '0'; head <= '1'; head++) {
// // 左边每个位置的不同字母个数
// vector<int> leftDiff(n);
// int diff = 0;
// for (size_t i = 0; i < n; i++) {
// if (s[i] != (head ^ (i & 1))) {
// diff++;
// }
// leftDiff[i] = diff;
// }
// // 右边每个位置的不同字母个数
// unsigned char tail = head ^ 1;
// diff = 0;
// for (int i = n - 1; i >= 0; i--) {
// // 左边 + 右边即为整个字符串的不同字母个数,取最小值
// ans = min(ans, leftDiff[i] + diff);
// if (s[i] != (tail ^ ((n - 1 - i) & 1))) {
// diff++;
// }
// }
// }
// return ans;
// }
// };
// 滑动窗口
class Solution {
public:
int minFlips(string s) {
int n = s.length();
vector<char> s01 = {'0', '1'};
int cnt = 0;
for (size_t i = 0; i < n; ++i) {
if (s[i] == s01[i % 2]) {
cnt++;
}
}
int ans = min(cnt, n - cnt);
for (int i = n; i < n * 2; ++i) {
if (s[(i - n) % n] == s01[(i - n) % 2]) cnt--;
if (s[i % n] == s01[i % 2]) cnt++;
ans = min(ans, min(cnt, n - cnt));
}
return ans;
}
};
eg 3、567. 字符串的排列 - 力扣(LeetCode)
class Solution {
public:
bool checkInclusion(string s1, string s2) {
int len_s1 = s1.length(); // 窗口长度
int len_s2 = s2.length(); // 遍历长度
// 处理特殊情况
if(len_s1 > len_s2) return false;
vector<int> hash1(26, 0), hash2(26, 0);
// 先比较前len_s1个
for (int i = 0; i < len_s1; i++) {
hash1[s1[i] - 'a']++;
hash2[s2[i] - 'a']++;
}
// 更新答案
if(hash1 == hash2) return true;
// 开始滑动窗口
for (int i = len_s1; i < len_s2; i++) {
// 进入窗口
hash2[s2[i] - 'a']++;
// 滑出窗口
hash2[s2[i - len_s1] - 'a']--;
// 更新答案
if(hash1 == hash2) return true;
}
return false;
}
};
eg 4、3439. 重新安排会议得到最多空余时间 I - 力扣(LeetCode)
class Solution {
public:
int maxFreeTime(int eventTime, int k, vector<int>& startTime, vector<int>& endTime) {
int n = startTime.size();
// 这里可以写成一个 int get(int i) 的函数
auto get = [&](int i) -> int {
if (i == 0) // 第 1 个事件的空闲时间 = 在事件发生之前的时间
return startTime[0];
if (i == n) // 第 n 个事件的空闲时间 = 活动的总时间 - 事件最后结束的时间
return eventTime - endTime[n - 1];
// 第 i 个事件的空闲时间 = 当前事件的开始时间 - 上一个时间的结束时间
return startTime[i] - endTime[i - 1];
};
int s = 0, ans = 0;
for(int i = 0; i <= n; i++){
// 进窗口
s += get(i); // 获取到第 i 个事件的空闲时间
if (i < k) // 空闲时间段数不够
continue;
// 更新答案
ans = max(ans, s);
// 出窗口
s -= get(i - k); // 减去最左侧的空闲时间
}
return ans;
}
};
重难点:
1、为什么要用定长滑动窗口 ?
想象有一场持续一段时间的活动,期间安排了多场会议。每两场会议之间会有空闲时间,活动开始前和结束后也可能有空闲时间。现在有个规则,就是会议之间的先后顺序不能改变,也就是说每场会议必须按照原来安排的时间依次进行。
已知活动中一共有
n
场会议,这样就会产生n + 1
个空闲时间段(活动开始前、每两场会议之间、活动结束后)。现在你有一个 “合并机会”,最多可以把k + 1
个相邻的空闲时间段合并成一个大的空闲时间段。你的任务是找出,通过这种合并方式,能得到的最长空闲时间段是多长。也就是在 n + 1 个空闲时间段中 把相邻的 k + 1 个空闲时间段 合并 求最长的空闲时间段
2、为什么 for 循环中的 i <= n 而不是 i < n ?
i
的含义
i
在这里代表的是空闲时间段的索引。i
的取值范围从0
到n
,对应着n + 1
个空闲时间段:
- 当
i = 0
时,代表的是第一个事件开始之前的空闲时间,通过get(0)
函数计算得到startTime[0]
。- 当
0 < i < n
时,代表的是第i
个事件和第i - 1
个事件之间的空闲时间,通过get(i)
函数计算得到startTime[i] - endTime[i - 1]
。- 当
i = n
时,代表的是最后一个事件结束之后的空闲时间,通过get(n)
函数计算得到eventTime - endTime[n - 1]
。