滑动窗口作为一种高级双指针技巧的框架,在日常算法中尤其是字符串匹配查询中经常用到。所以总结一下滑动窗口的框架思维。
首先需要熟悉一下滑动窗口常用的数据结构:哈希表
,也称为字典
、散列表
,是一种 k => v
结构,这里就不细讲什么是哈希和哈希表了,因为作为基础的数据结构,应该大家都理解。
滑动窗口的思想一般是这样的:
left=right=0
,我们把索引的闭合区间[left, right]称为一个「窗口」其实这个思路,在第2步相当在寻找一个「可行解」,在第3步优化这个「可行解」,最终执行完第4步,在这些可行解中寻找到一个「最优解」。左右指针轮流前进,窗口大小递增递减,窗口不断向右滑动直到尽头。
题目的意思就是要在S中找到包含T中全部字母的一个子串,顺序无所谓,但是这个字串一定是所有可行解中最短的。
设,needs和window为计数器,分别记录T中字符出现次数和窗口中相应字符出现次数。那么初始状态如下:
递增right,直到窗口[left, right]包含T中所有字符:
现在开始增加left:缩小窗口[left, right],这一步也就是优化可行解:
直到窗口中的字符串不符合要求,left不再继续移动。
重复上述过程,直到right指针到达字符串S的末端,算法结束。
上述过程可以写成伪代码:
func minWindow(s, t string) {
left, right := 0, 0
var res string = s //更新结果
window := make(map[byte]int)
for right < len(s) {
window[byte(s[right])]++
right++
//如果符合要求,要移动left缩小窗口
for window 符合要求 {
//如果这个窗口的字串更短,那么要更新res
res = minLen(res, window)
window.remove(byte(s[left]))
left++
}
}
return res
}
那么怎么来判断window即字串[left, right]是否符合要求呢?即是否包含T的所有字符呢?
我们可以用两个计数器来实现,用一个哈希表needs来记录字符串T中包含的字符和出现的次数,用另一个哈希表window来记录当前「窗口」中包含的字符和出现的次数,如果window包含所有needs中的所有键,且这些键对应的值(即出现的次数)都大于等于needs中的值,那么就可以知道当前窗口符合要求了。
func minWindow(s, t string) {
left, right := 0, 0
var res string = s //更新结果
window, needs := make(map[byte]int), make(map[byte]int)
for _, v := range t {
needs[byte(v)]++
}
//记录window中已经有多少字符符合要求了
match := 0
for right < len(s) {
c1 := byte(s[right])
if _, ok := needs[c1]; ok {
window[c1]++ //加入window
if window[c1] == needs[c1] {
match++ //字符c1出现的次数符合要求了
}
}
right++
//window中的字符已经符合needs要求了
for match == len(needs) {
//更新res
res = minLen(res, window)
c2 := byte(s[left])
if _, ok := needs[c2]; ok {
window[c2]-- //移出window
if window[c2] < needs[c2] {
match-- //字符c2出现的次数不再符合要求了
}
}
left++
}
}
return res
}
上面的代码已经基本符合逻辑了,还有一处伪代码,那就是更新res的地方,我们可以用start
和minLen
分别记录最小符合字串的起始位置和长度,当当前的符合窗口长度小于minLen时,我们更新start和minLen。
所以完整的代码如下:
func minWindow(s string, t string) string {
const INT_MAX = int(^uint(0) >> 1)
start, minLen := 0, INT_MAX
left, right := 0, 0
window := make(map[byte]int)
needs := make(map[byte]int)
match := 0
for _, v := range t {
needs[byte(v)]++
}
for right < len(s) {
var c1 byte = byte(s[right])
if _, ok := needs[c1]; ok {
window[c1]++
if window[c1] == needs[c1] {
match++
}
}
right++
for match == len(needs) {
if right - left < minLen {
start = left
minLen = (right - left)
}
var c2 byte = byte(s[left])
if _, ok := needs[c2]; ok {
window[c2]--
if window[c2] < needs[c2] {
match--
}
}
left++
}
}
if minLen == INT_MAX {
return ""
} else {
return s[start:(start+minLen)]
}
}
该算法的时间复杂度是O(M+N),M和N分别是字符串S和T的长度。
这道题和上道题的思路其实很一样,唯一需要修改的其实就是更新res部分的代码。我们可以用一个数组来记录符合要求的起始位置。
当match == len(needs)
时,我们需要移动left,每次移动时判断right - left == len(p)
,如果符合,那就将当前的left添加进数组中做记录。
代码如下:
func findAnagrams(s string, p string) []int {
left, right := 0, 0
window, needs := make(map[byte]int), make(map[byte]int)
match := 0
var ves []int
for _, v := range p {
needs[byte(v)]++
}
for right < len(s) {
c1 := byte(s[right])
if _, ok := needs[c1]; ok {
window[c1]++
if window[c1] == needs[c1] {
match++
}
}
right++
for match == len(needs) {
if right - left == len(p) {
ves = append(ves, left)
}
c2 := byte(s[left])
if _, ok := needs[c2]; ok {
window[c2]--
if window[c2] < needs[c2] {
match--
}
}
left++
}
}
return ves
}
相信这道题大家都已经有思路了,使用window作为计数器记录窗口中字符出现的次数,然后先向右移动right,当window中出现重复字符时,开始移动left移动窗口,如此往复。
需要注意的是:要求的是「最长字串」,所以需要在每次移动right增大窗口时更新res,而不是像之前一样在移动left缩小窗口时更新。
func lengthOfLongestSubstring(s string) int {
left, right := 0, 0
windows := make(map[byte]int)
res := 0
for right < len(s) {
c1 := byte(s[right])
windows[c1]++
right++
for windows[c1] > 1 {
c2 := byte(s[left])
windows[c2]--
left++
}
if right - left > res {
res = right - left
}
}
return res
}
滑动窗口的抽象思想是:
int left = 0;
int right = 0;
while (right < s.size()) {
window.add(s[right]);
right++;
while(valid) {
window.remove(s[left])
left++
}
}
麻烦的是valid条件,为了实现这个条件的实时更新,我们需要很多实现代码,但是实际的思想还是很简单的,只是在大多数代码都在处理这个问题。