1. 滑动窗口算法基础与问题解析
滑动窗口算法是处理数组/字符串子区间问题的经典技巧,特别适合解决"最长/最短满足某条件的连续子序列"这类问题。它的核心思想是维护一个动态变化的窗口,通过左右指针的移动来高效地遍历所有可能的子区间。
在力扣2831题中,我们需要找到一个子数组,使得在最多删除k个元素后,剩下的所有元素都相同。换句话说,我们需要找到一个区间,其中某个数字出现的次数最多,其他数字的总数不超过k个。
举个例子,对于数组[1,2,2,3,2,1,1]和k=2:
- 子数组[2,2,3,2]中数字2出现3次,其他数字出现1次(3+1=4,窗口大小4 ≤ 3+2=5)
- 子数组[1,1]中数字1出现2次(但长度不如前面的子数组长)
2. 暴力解法与性能瓶颈分析
最直观的解法是使用滑动窗口配合哈希表统计频次,并在每次窗口变动时计算当前窗口中出现次数最多的数字:
cpp复制int longestEqualSubarray(vector<int>& nums, int k) {
int ret = 0, len = nums.size(), left = 0, right = 0;
unordered_map<int, int> mp;
auto getMaxn = [&]{
int maxn = 0;
for(auto [_, num] : mp) maxn = max(maxn, num);
return maxn;
};
for(; right < len; ++right) {
++mp[nums[right]];
while(right - left + 1 > getMaxn() + k) {
--mp[nums[left++]];
}
ret = max(ret, getMaxn());
}
return ret;
}
这个解法的问题在于每次计算getMaxn()都需要遍历整个哈希表,时间复杂度为O(n^2),在数据量大时会导致超时。
3. 优化思路:历史最大值维护
观察到一个关键性质:我们只需要知道窗口中出现次数的历史最大值,而不需要实时计算当前窗口的最大值。因为:
- 只有当某个数字的出现次数超过当前maxn时,才可能产生更优解
- 当窗口收缩时,maxn可能不再准确,但这不影响最终结果,因为:
- 如果收缩时移除了maxn对应的数字,maxn会偏大,但窗口会继续收缩直到满足条件
- 最终结果只由历史最大值决定
优化后的代码:
cpp复制int longestEqualSubarray(vector<int>& nums, int k) {
int len = nums.size(), left = 0, right = 0, maxn = 0;
unordered_map<int, int> mp;
for(; right < len; ++right) {
maxn = max(maxn, ++mp[nums[right]]);
while(right - left + 1 > maxn + k) {
--mp[nums[left++]];
}
}
return maxn;
}
这个版本的时间复杂度降为O(n),因为每个元素最多被加入和移除哈希表各一次。
4. 关键问题深度解析
4.1 为什么可以维护历史最大值?
关键在于问题的定义:我们需要找的是"删除k个元素后能得到的最大等值子数组"。这意味着:
- maxn记录的是某个数字在整个数组中出现次数的最大值
- 即使窗口收缩导致当前窗口的频次减少,历史最大值仍然有效
- 只有当发现更大的频次时,才需要更新maxn
4.2 while循环能否改为if?
理论上可以,但需要理解其影响:
cpp复制// 将while改为if
if(right - left + 1 > maxn + k) {
--mp[nums[left++]];
}
这样修改后:
- 每次右指针移动后最多只收缩一次窗口
- 可能导致窗口暂时不满足条件,但maxn仍然是历史最大值
- 最终结果不受影响,因为maxn只增不减
- 但窗口大小可能比最优解稍大,不影响最终结果
4.3 删除与替换操作的区别
题目要求的是"删除"k个元素,如果改为"替换"操作,解法会有何不同?
- 删除操作:结果就是maxn(保留的数字个数)
- 替换操作:结果应该是maxn + min(k, 其他数字总数)
- 因为可以用k次替换将其他数字改为maxn对应的数字
- 但受限于k的大小和剩余数字数量
修改后的代码:
cpp复制int longestReplacementSubarray(vector<int>& nums, int k) {
int len = nums.size(), left = 0, right = 0, maxn = 0;
unordered_map<int, int> mp;
for(; right < len; ++right) {
maxn = max(maxn, ++mp[nums[right]]);
if(right - left + 1 > maxn + k) {
--mp[nums[left++]];
}
}
return min(maxn + k, len); // 考虑k没用完的情况
}
5. 相似题目对比:力扣424题
力扣424题"替换后的最长重复字符"与本题非常相似,区别在于:
- 字符限定为大写字母(只有26种可能)
- 明确是替换操作而非删除
- 可以直接暴力计算maxn而不影响性能
424题的解法:
cpp复制int characterReplacement(string s, int k) {
int left = 0, right = 0, maxn = 0;
vector<int> count(26, 0);
for(; right < s.size(); ++right) {
maxn = max(maxn, ++count[s[right]-'A']);
if(right - left + 1 > maxn + k) {
--count[s[left++]-'A'];
}
}
return min(maxn + k, (int)s.size());
}
由于只有26个字母,即使每次计算maxn也只需26次操作,不会成为性能瓶颈。
6. 滑动窗口问题的通用模板
基于以上分析,可以总结出滑动窗口处理这类问题的通用模板:
- 初始化左右指针和统计数据结构(哈希表/数组)
- 移动右指针,更新统计信息
- 维护窗口内的关键指标(如maxn)
- 当窗口不满足条件时移动左指针
- 根据问题需求更新最终结果
通用代码结构:
cpp复制int slidingWindowTemplate(vector<int>& nums, int k) {
int left = 0, right = 0, maxn = 0;
// 选择合适的统计数据结构
unordered_map<int, int> freq;
for(; right < nums.size(); ++right) {
// 更新统计信息和关键指标
maxn = max(maxn, ++freq[nums[right]]);
// 调整窗口使其满足条件
while(窗口不满足条件) {
// 更新统计信息
--freq[nums[left++]];
}
// 更新最终结果(根据具体问题)
}
return 结果;
}
7. 实战技巧与注意事项
-
数据结构选择:
- 当元素范围已知且较小时(如26个字母),使用数组比哈希表更高效
- 元素范围大或未知时,使用哈希表
-
边界条件处理:
- 空输入情况
- k=0的情况
- 所有元素都相同的情况
-
性能优化点:
- 避免在窗口移动时重复计算(如暴力求maxn)
- 利用问题特性减少不必要的操作
-
调试技巧:
- 打印窗口变化过程
- 验证统计信息的正确性
- 检查最终结果是否符合预期
-
常见错误:
- 窗口调整条件写错
- 统计信息更新不及时
- 忽略了历史最大值的维护
在实际编码中,我习惯先用暴力解法确保逻辑正确,再逐步优化。对于滑动窗口问题,最重要的是清楚地定义:
- 窗口何时扩展(右指针移动)
- 窗口何时收缩(左指针移动)
- 如何判断当前窗口是否有效
- 如何更新最终结果
掌握这些核心要点后,就能灵活应对各种滑动窗口变种问题。