1. 滑动窗口算法入门:从暴力解法到高效优化
第一次接触滑动窗口是在LeetCode第209题"长度最小的子数组",当时我用了最直接的暴力解法——双重循环枚举所有可能的子数组。虽然通过了测试用例,但面对大数据量时直接超时。这种O(n²)的时间复杂度让我开始思考更优解,直到发现了滑动窗口这个神奇的思想。
滑动窗口本质上是一种双指针技巧的变体,它通过维护一个动态变化的窗口来减少不必要的计算。想象你在公交车上观察窗外风景:窗口大小固定,随着车辆前进,旧风景离开视野,新风景进入视野。算法中的滑动窗口也是如此,只不过我们观察的是数组或字符串的子区间。
提示:滑动窗口特别适合解决"连续子数组/子串"类问题,尤其是涉及最大值、最小值、平均值或特定条件的题目。
2. 滑动窗口的两种基本类型
2.1 固定大小的滑动窗口
这类问题的窗口大小在解题过程中保持不变,典型例题是LeetCode 239"滑动窗口最大值"。我第一次尝试时直接用了暴力法:
def maxSlidingWindow(nums, k): return [max(nums[i:i+k]) for i in range(len(nums)-k+1)]虽然简洁,但时间复杂度是O(nk),当n和k都很大时性能堪忧。后来学习到可以用单调队列优化到O(n):
from collections import deque def maxSlidingWindow(nums, k): q = deque() res = [] for i, num in enumerate(nums): while q and nums[q[-1]] <= num: q.pop() q.append(i) if q[0] == i - k: q.popleft() if i >= k - 1: res.append(nums[q[0]]) return res2.2 可变大小的滑动窗口
更常见的是窗口大小可变的场景,如LeetCode 3"无重复字符的最长子串"。这类问题通常需要维护某些条件(如字符唯一性),通过左右指针的移动来寻找最优解。我的解题模板如下:
def lengthOfLongestSubstring(s): char_set = set() left = 0 max_len = 0 for right in range(len(s)): while s[right] in char_set: char_set.remove(s[left]) left += 1 char_set.add(s[right]) max_len = max(max_len, right - left + 1) return max_len3. 滑动窗口解题的通用模板
经过上百道题的实践,我总结出了滑动窗口的通用解题框架:
初始化阶段:
- 定义左右指针(通常left=right=0)
- 创建哈希表或变量记录窗口状态
- 初始化结果变量
窗口扩张阶段:
- 移动右指针,扩展窗口
- 更新窗口状态(如字符计数、和值等)
条件判断阶段:
- 检查当前窗口是否满足条件
- 如果满足,更新结果
- 如果不满足,进入收缩阶段
窗口收缩阶段:
- 移动左指针,收缩窗口
- 更新窗口状态
- 返回条件判断阶段
以LeetCode 76"最小覆盖子串"为例的代码实现:
def minWindow(s, t): from collections import defaultdict target = defaultdict(int) for c in t: target[c] += 1 left = 0 count = len(t) min_len = float('inf') result = "" for right in range(len(s)): if target[s[right]] > 0: count -= 1 target[s[right]] -= 1 while count == 0: if right - left + 1 < min_len: min_len = right - left + 1 result = s[left:right+1] target[s[left]] += 1 if target[s[left]] > 0: count += 1 left += 1 return result4. 滑动窗口的常见变种与解题技巧
4.1 多指针滑动窗口
有些问题需要维护多个指针,如LeetCode 930"和相同的二元子数组"。这类题目通常需要记录前缀和或使用哈希表辅助:
def numSubarraysWithSum(nums, goal): from collections import defaultdict prefix = defaultdict(int) prefix[0] = 1 res = 0 curr_sum = 0 for num in nums: curr_sum += num res += prefix[curr_sum - goal] prefix[curr_sum] += 1 return res4.2 带计数的滑动窗口
如LeetCode 904"水果成篮",需要维护窗口内元素的种类数:
def totalFruit(fruits): from collections import defaultdict basket = defaultdict(int) left = 0 max_fruits = 0 for right, fruit in enumerate(fruits): basket[fruit] += 1 while len(basket) > 2: basket[fruits[left]] -= 1 if basket[fruits[left]] == 0: del basket[fruits[left]] left += 1 max_fruits = max(max_fruits, right - left + 1) return max_fruits4.3 滑动窗口与单调栈的结合
某些问题需要结合单调性来优化,如LeetCode 1438"绝对差不超过限制的最长连续子数组":
def longestSubarray(nums, limit): from collections import deque max_q = deque() min_q = deque() left = 0 res = 0 for right, num in enumerate(nums): while max_q and num > max_q[-1]: max_q.pop() max_q.append(num) while min_q and num < min_q[-1]: min_q.pop() min_q.append(num) while max_q[0] - min_q[0] > limit: if nums[left] == max_q[0]: max_q.popleft() if nums[left] == min_q[0]: min_q.popleft() left += 1 res = max(res, right - left + 1) return res5. 滑动窗口常见错误与调试技巧
5.1 边界条件处理
新手常犯的错误包括:
- 窗口初始化不正确(特别是right从0还是1开始)
- 结果更新时机错误(应该在收缩前还是收缩后)
- 忘记处理空输入或特殊输入
注意:在每次写滑动窗口代码时,务必手动跑这几个测试用例:
- 空输入(如空字符串或空数组)
- 单元素输入
- 所有元素都相同的输入
- 刚好满足条件的边界情况
5.2 窗口收缩条件
收缩条件过于宽松或严格都会导致错误。我的调试方法是:
- 在循环中加入打印语句,输出窗口状态
- 画图模拟窗口移动过程
- 对简单测试用例手动计算预期结果
5.3 性能优化
当遇到超时问题时,检查:
- 是否有不必要的重复计算(可以用哈希表缓存)
- 窗口收缩是否可以更积极(尽早移动左指针)
- 数据结构选择是否最优(如用数组代替哈希表)
6. 滑动窗口在面试中的实战应用
在技术面试中,滑动窗口问题出现的频率极高。根据我的面试经验,面试官通常期待:
- 快速识别问题类型:能否在1-2分钟内判断出可以使用滑动窗口
- 代码实现能力:在15-20分钟内写出无bug的代码
- 复杂度分析:准确分析时间空间复杂度
- 边界处理:考虑各种极端情况
- 优化思路:能否提出进一步优化的方向
我建议按照这个流程应对滑动窗口面试题:
- 明确问题要求(连续子数组/子串、求最大/最小值等)
- 举例说明暴力解法及其复杂度
- 提出滑动窗口优化思路
- 讨论窗口移动条件和状态维护方式
- 编写代码并测试边界条件
- 分析复杂度并讨论可能的优化
7. 滑动窗口进阶:处理更复杂的问题
7.1 多维滑动窗口
有些问题需要在二维矩阵上应用滑动窗口,如LeetCode 1074"元素和为目标值的子矩阵数量"。这类问题通常需要:
- 固定行或列的维度
- 在另一个维度上应用滑动窗口
- 结合前缀和优化计算
def numSubmatrixSumTarget(matrix, target): rows, cols = len(matrix), len(matrix[0]) count = 0 for i in range(rows): col_sum = [0] * cols for j in range(i, rows): prefix = {0: 1} curr_sum = 0 for k in range(cols): col_sum[k] += matrix[j][k] curr_sum += col_sum[k] count += prefix.get(curr_sum - target, 0) prefix[curr_sum] = prefix.get(curr_sum, 0) + 1 return count7.2 滑动窗口与动态规划结合
某些问题需要结合DP思想,如LeetCode 1151"最少交换次数来组合所有的1"。解题思路:
- 使用滑动窗口确定目标窗口
- 用DP计算达到目标所需的最小交换次数
def minSwaps(data): ones = sum(data) if ones == 0: return 0 window = sum(data[:ones]) max_ones = window for i in range(ones, len(data)): window += data[i] - data[i - ones] max_ones = max(max_ones, window) return ones - max_ones7.3 时间序列上的滑动窗口
处理时间序列数据时,滑动窗口也非常有用。例如LeetCode 1838"最高频元素的频数",需要考虑元素的增量操作:
def maxFrequency(nums, k): nums.sort() left = 0 total = 0 max_freq = 1 for right in range(1, len(nums)): total += (nums[right] - nums[right-1]) * (right - left) while total > k: total -= nums[right] - nums[left] left += 1 max_freq = max(max_freq, right - left + 1) return max_freq8. 滑动窗口算法的时间复杂度分析
正确分析滑动窗口算法的时间复杂度是面试中的关键点。一般来说:
基础滑动窗口:O(n)
- 每个元素最多被左右指针各访问一次
- 如LeetCode 3"无重复字符的最长子串"
带哈希表的滑动窗口:O(n)
- 哈希操作平均O(1)
- 如LeetCode 76"最小覆盖子串"
带单调队列的滑动窗口:O(n)
- 每个元素入队出队各一次
- 如LeetCode 239"滑动窗口最大值"
嵌套循环的滑动窗口:O(nk)
- 内层循环可能执行k次
- 需要特殊优化才能降为O(n)
空间复杂度通常为O(k)或O(n),取决于需要维护的辅助数据结构。在实际分析时,我会特别注意:
- 最坏情况下哈希表可能达到O(n)空间
- 单调队列的空间通常是O(k)
- 如果只使用有限变量,空间可以是O(1)
9. 滑动窗口与其他算法的对比
9.1 滑动窗口 vs 双指针
虽然滑动窗口是双指针的一种应用,但两者有区别:
- 双指针通常用于有序数组,指针移动有特定规律
- 滑动窗口关注子区间性质,指针移动由窗口条件决定
9.2 滑动窗口 vs 动态规划
选择依据:
- 滑动窗口:连续子数组/子串的最值问题
- 动态规划:非连续子序列或需要记忆化的问题
有时可以结合使用,如先用滑动窗口预处理,再用DP求解。
9.3 滑动窗口 vs 前缀和
前缀和适合:
- 需要频繁计算任意区间和
- 问题可以转化为寻找特定和值
滑动窗口适合:
- 需要维护窗口内的特定性质
- 窗口大小固定或可变但连续
10. 滑动窗口实战训练建议
根据我的刷题经验,建议按这个顺序练习:
基础阶段(掌握模板):
- 长度最小的子数组
- 无重复字符的最长子串
- 最小覆盖子串
进阶阶段(理解变种):
- 水果成篮
- 和相同的二元子数组
- 最大连续1的个数 III
高手阶段(综合应用):
- 滑动窗口最大值
- 绝对差不超过限制的最长连续子数组
- 最高频元素的频数
我的训练方法是:
- 第一遍:自己思考并实现
- 第二遍:学习最优解,比较差异
- 第三遍:一周后重做,检验掌握程度
- 建立错题本,记录典型错误和优化思路
对于想系统掌握滑动窗口的同学,我建议至少完成30道相关题目,覆盖各种变种和难度级别。在实际编码时,养成先写注释再填充代码的习惯,明确每个步骤的意图,这样可以大大减少调试时间。