447_滑动窗口面试题¶
一句话说明¶
滑动窗口是用双指针维护一个"窗口区间",在 O(n) 时间内解决"连续子数组/子字符串"的极值/满足条件问题,避免暴力 O(n²)。
核心知识点¶
白话理解¶
滑动窗口就像用一个移动的"取景框"在数组上滑动,左边界和右边界都只向右移,每次扩张右边界纳入新元素,当窗口不满足条件时收缩左边界。整体每个元素最多进出窗口各一次,所以是 O(n)。
两种窗口类型¶
- 固定大小窗口:窗口大小固定为 k,左右边界同步移动
- 可变大小窗口:右边界不断扩展,满足条件后收缩左边界
代码模板¶
# 可变窗口模板
left = 0
for right in range(len(s)):
# 1. 将 right 纳入窗口,更新窗口状态
window.update(s[right])
# 2. 当窗口不满足条件时,收缩左边界
while window_is_invalid():
window.remove(s[left])
left += 1
# 3. 更新结果(此时窗口合法)
ans = max(ans, right - left + 1)
经典题目与解法¶
题目1:无重复字符的最长子串(LeetCode 3)¶
题意:找出字符串中不含重复字符的最长子串长度。
def length_of_longest_substring(s: str) -> int:
"""
可变窗口:右边界不断扩展,遇到重复字符收缩左边界
用集合记录窗口内的字符
"""
char_set = set() # 窗口内的字符集合
left = 0 # 窗口左边界
max_len = 0 # 最长子串长度
for right in range(len(s)):
# 如果 right 指向的字符已在窗口中,收缩左边界直到无重复
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_len
# 优化版:用字典记录字符最后出现的位置,直接跳跃左边界
def length_of_longest_substring_v2(s: str) -> int:
char_pos = {} # {字符: 最后出现的位置}
left = 0
max_len = 0
for right, c in enumerate(s):
# 如果字符已见且在窗口内,直接跳跃左边界
if c in char_pos and char_pos[c] >= left:
left = char_pos[c] + 1 # 跳到重复字符的下一位
char_pos[c] = right # 更新字符位置
max_len = max(max_len, right - left + 1)
return max_len
# 测试
print(length_of_longest_substring("abcabcbb")) # 3("abc")
print(length_of_longest_substring("bbbbb")) # 1("b")
print(length_of_longest_substring("pwwkew")) # 3("wke")
时间复杂度:O(n)
空间复杂度:O(min(m, n)),m是字符集大小
题目2:最小覆盖子串(LeetCode 76,难)¶
题意:在字符串 s 中找包含 t 的所有字符的最小子串。
from collections import Counter
def min_window(s: str, t: str) -> str:
"""
可变窗口:右边界扩展,窗口覆盖t后收缩左边界找最小
用 Counter 记录窗口状态和目标状态的差距
"""
if not t or not s:
return ""
need = Counter(t) # 需要的字符及数量
window = Counter() # 当前窗口的字符及数量
have = 0 # 窗口中已满足的字符种数
required = len(need) # 需要满足的字符种数
left = 0
min_len = float('inf')
result = ""
for right in range(len(s)):
c = s[right]
window[c] += 1 # 右边界字符纳入窗口
# 如果当前字符满足了其在 t 中的需求
if c in need and window[c] == need[c]:
have += 1 # 满足的种数+1
# 当窗口已覆盖 t 的所有字符时,尝试收缩
while have == required:
if right - left + 1 < min_len: # 更新最小窗口
min_len = right - left + 1
result = s[left:right + 1]
# 收缩左边界
left_c = s[left]
window[left_c] -= 1
if left_c in need and window[left_c] < need[left_c]:
have -= 1 # 满足的种数-1
left += 1
return result
# 测试
print(min_window("ADOBECODEBANC", "ABC")) # "BANC"
print(min_window("a", "a")) # "a"
print(min_window("a", "aa")) # ""(s中没有两个a)
时间复杂度:O(|s| + |t|)
空间复杂度:O(|t|)
题目3:长度最小的子数组(LeetCode 209,固定和)¶
题意:找和 >= target 的最短连续子数组长度。
def min_sub_array_len(target: int, nums) -> int:
"""
可变窗口:右边界扩展,当窗口和>=target时收缩左边界找最小
"""
left = 0
window_sum = 0 # 窗口内元素的和
min_len = float('inf')
for right in range(len(nums)):
window_sum += nums[right] # 纳入右边界
# 满足条件时收缩左边界
while window_sum >= target:
min_len = min(min_len, right - left + 1) # 更新最小长度
window_sum -= nums[left] # 移除左边界
left += 1 # 左边界右移
return min_len if min_len != float('inf') else 0
# 测试
print(min_sub_array_len(7, [2,3,1,2,4,3])) # 2(子数组[4,3])
print(min_sub_array_len(4, [1,4,4])) # 1(子数组[4])
print(min_sub_array_len(11, [1,1,1,1,1])) # 0(不存在)
时间复杂度:O(n) 每个元素最多进出各一次
空间复杂度:O(1)
面试技巧¶
- 识别滑动窗口题型:题目里有"连续子数组/子字符串""最长/最短"等关键词
- 先确定窗口合法条件:什么时候窗口合法,什么时候需要收缩——这是核心
- 固定窗口更简单:大小固定时左右同步,不用 while
- 窗口状态用什么记录:sum(求和)、Counter(字符统计)、set(去重)
- 说清楚 O(n) 的原因:每个元素最多进窗口一次,出窗口一次,所以是 O(2n) = O(n)
速查表¶
# 固定大小窗口(大小为k)
window_sum = sum(nums[:k])
max_sum = window_sum
for i in range(k, len(nums)):
window_sum += nums[i] - nums[i-k] # 右边进,左边出
max_sum = max(max_sum, window_sum)
# 可变窗口模板
left = 0
state = {} # 窗口状态
for right in range(len(s)):
state.update(s[right]) # 纳入右边界
while not valid(state): # 不合法时收缩
state.remove(s[left])
left += 1
ans = update(ans, right - left + 1) # 更新结果
| 题型关键词 | 窗口类型 | 窗口状态 |
|---|---|---|
| 最长不含重复 | 可变(最大化) | set 或 dict |
| 最小覆盖 | 可变(最小化) | Counter |
| 和>=目标 | 可变(最小化) | sum 变量 |
| K大小的子数组 | 固定大小 | 滑进滑出 |
| 至多K种字符 | 可变(最大化) | Counter |