算法思维----滑动窗口

滑动窗口作为一种高级双指针技巧的框架,在日常算法中尤其是字符串匹配查询中经常用到。所以总结一下滑动窗口的框架思维。

首先需要熟悉一下滑动窗口常用的数据结构:哈希表,也称为字典散列表,是一种 k => v 结构,这里就不细讲什么是哈希和哈希表了,因为作为基础的数据结构,应该大家都理解。

滑动窗口的思想一般是这样的:

  1. 在字符串S中使用双指针的左右指针技巧,初始化left=right=0,我们把索引的闭合区间[left, right]称为一个「窗口」
  2. 不断增加right指针的值扩大窗口[left, right],直到窗口中的字符串符合要求(具体根据题意,稍后我们在题中理解)
  3. 符合要求后,我们停止增加right,转而增加left指针来缩小窗口[left, right],直到窗口中的字符串不再符合要求(也是根据题意哦)。注意,每次增加left,我们都要更新一轮结果。
  4. 重复第2步和第3步,直到right到达字符串S的尽头。

其实这个思路,在第2步相当在寻找一个「可行解」,在第3步优化这个「可行解」,最终执行完第4步,在这些可行解中寻找到一个「最优解」。左右指针轮流前进,窗口大小递增递减,窗口不断向右滑动直到尽头。

最小覆盖子串

算法思维----滑动窗口_第1张图片
题目的意思就是要在S中找到包含T中全部字母的一个子串,顺序无所谓,但是这个字串一定是所有可行解中最短的。

设,needs和window为计数器,分别记录T中字符出现次数和窗口中相应字符出现次数。那么初始状态如下:
算法思维----滑动窗口_第2张图片
递增right,直到窗口[left, right]包含T中所有字符:
算法思维----滑动窗口_第3张图片
现在开始增加left:缩小窗口[left, right],这一步也就是优化可行解
算法思维----滑动窗口_第4张图片
直到窗口中的字符串不符合要求,left不再继续移动。
算法思维----滑动窗口_第5张图片

重复上述过程,直到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的地方,我们可以用startminLen分别记录最小符合字串的起始位置和长度,当当前的符合窗口长度小于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的长度。

找到字符串中所有字母异位词

算法思维----滑动窗口_第6张图片
这道题和上道题的思路其实很一样,唯一需要修改的其实就是更新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

}

无重复字符的最长子串

算法思维----滑动窗口_第7张图片
相信这道题大家都已经有思路了,使用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条件,为了实现这个条件的实时更新,我们需要很多实现代码,但是实际的思想还是很简单的,只是在大多数代码都在处理这个问题。

你可能感兴趣的:(LeetCode,算法)