滑动窗口题目的一般格式:
一个序列中, 满足某些条件的,子串的, 最长/最短/个数。
时间复杂度分析: 暴力解法的时间复杂度一般为O(N ^ 3):O(N ^ 2)枚举所有的子串,O(N)判断是否满足条件。
滑动窗口在两个方面都降低时间复杂度:首先并不枚举所有的子串,只枚举可能包含答案的那些。其次通过记录窗口内子串的一些信息,使得判断是否满足条件的时间复杂度下降。
难点: 什么时候更新答案,窗口内保存哪些信息来降低判断的时间。
正确性证明: 分解为子问题,或者反证法证明窗口内一定包含过正确答案。
最长:
这一类题目的特点是:一开始子串就满足条件,然后不断扩大窗口直至不满足条件,再缩小窗口直至满足条件,每次扩大窗口并判断满足条件后都更新答案。
3. 无重复字符的最长子串:给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
解法: 窗口的范围为[left,right)
,用一个字典记录窗口中的字符及其出现次数,当窗口内的子串满足条件时扩大窗口(right
右移)并更新答案,不满足条件时缩小窗口(left
右移)。
正确性证明: 实际上我们把问题分解为了多个子问题:求以s[i]
为起点的满足条件的子串中最长的,再将子问题答案合并即可。
但还有一个问题:left
每次并不是只向前走一步,如何保证枚举每一个s[i]
呢?假设[left,right)
满足条件,[left,right+1)
不满足条件,此时答案至少是right-left
。left
向前走k
步直至满足条件的过程中,以s[left]
为起点的满足条件的子串,长度都不可能超过right-left
,也就是说这些子问题我们虽然没有求,但它们的答案没有意义,本身也就不需要求。
时间复杂度分析: left
和right
均没有后退:O(N)。判断right-left==len(window)
:O(1)。故总时间复杂度为O(N)。
代码:
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
ls = len(s)
window = dict()
left = right = 0
ans = 0
while right<ls:
c = s[right]
right += 1
window[c] = window.get(c,0)+1
while len(window)<right-left:
d = s[left]
left+=1
window[d] -= 1
if window[d]==0:
window.pop(d)
ans = max(ans,right-left)
return ans
最短:
76. 最小覆盖子串:给你一个字符串s
、一个字符串t
。返回s
中涵盖t
所有字符的最小子串。如果s
中不存在涵盖t
所有字符的子串,则返回空字符串 ""
。
解法: 窗口的范围为[left,right)
,用一个字典记录窗口中的字符及其出现次数,当窗口内的子串不满足条件时扩大窗口(right
右移),满足条件时缩小窗口(left
右移)并更新答案。
正确性证明: 类似上一题,把问题分解为子问题:求以s[i]
为终点的满足条件的子串中最短的。
假设[left,right)
是第一个满足条件的区间,则所有终点小于right
的子问题都不必再考虑。之后依次枚举每一个right
。但每次枚举时,left
并没有从最左边开始找,是否可能漏掉一些答案呢?其实和上一题类似,如果[left,right)
是当前子问题的最优解,则下一个子问题从[left+1,right+1)
开始找,即使在left+1
左边存在该子问题的解,对于最终的最优解也是没有意义的,故不需要考虑。
时间复杂度分析: left
和right
均没有后退:O(N)。判断是否满足条件:O(1)。故总时间复杂度为O(N)。
代码:
class Solution:
def minWindow(self, s: str, t: str) -> str:
ls,lt = len(s),len(t)
need = {} # t中的元素
for i in t:
need[i] = need.get(i,0)+1
window = {} # 窗口中的元素
left = right = 0 # 窗口范围
start = length = float('inf') # 答案
valid = 0 # window是否包含t
while right<ls:
# 更新窗口内信息
c = s[right]
window[c] = window.get(c,0)+1
if c in need and window[c] == need[c]:
valid+=1
right += 1
# 更新答案
while valid == len(need):
if right - left < length:
start, length = left, right-left
c = s[left]
window[c] -= 1
if c in need and window[c] < need[c]:
valid -= 1
left += 1
return s[start:start+length] if length<float('inf') else ''
总结:
滑动窗口为什么快?实际上是因为它把原问题分解为了子问题,而其中很大一部分子问题是不需要求解的(最优解一定不出现在这些子问题之中)。故要求问题一定要有二段性质:
求最长时,对于任意子问题s[i]
,一定存在j>i
,使得s[i...k],i<k<j
都满足条件,s[i...k]
,j<=k<n
都不满足条件,才可以使当s[i+1...j]
不满足条件时,无需求解子问题s[i+1]
。
求最短时,对于任意子问题s[j]
,一定存在i<j
,使得s[k...j],0<k<i
都满足条件,s[k...j]
,i<=k<j
都不满足条件,才可以使当s[i+1...j+1]
不满足条件时,无需求解子问题s[j+1]
。
总而言之,求解最长问题的子问题s[left]
的过程中,right
向右移时要先满足再不满足;求解最短问题的子问题s[right]
的过程中,left
右移时也要先满足再不满足。这样才可以用滑动窗口算法。
395. 至少有K个重复字符的最长子串,由于不满足上述条件,就无法直接使用滑动窗口算法。