万用框架!一篇文章带你学会滑动窗口算法

框架

Map<Character, Integer> window = new HashMap<>();
int size = s.size();
int left = 0, right = 0;
while(right < size) {
	// 增大窗口
	char c = s.charAt(right);
	// 表示增加键为 c 的值,如果 map 中没有这个键,则返回默认值 0,然后加上 1
	window.put(c, window.getOrDefault(c, 0) + 1); // 等同于c++的map[key]++
	right++;
	// 进行窗口内的一系列数据更新
	// ...
	
	// 注意最终代码不要留下print,因为IO很费时间
	System.out.printf("window:[%d, %d]\n", left, right);


	while(window needs shrink) {
		// 缩小窗口
		char d = s.charAt(left);
		// 移出去的字符对应需要的次数减一
		window.put(d, window.get(d) - 1);
		left++;
		// 进行窗口内的一系列数据更新
		// ...
	}
}

虽然滑动窗口代码框架中有一个嵌套的 while 循环,但算法的时间复杂度依然是 O(N),其中 N 是输入字符串/数组的长度。

为什么呢?简单来说,指针 left, right 不会回退(它们的值只增不减),所以字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会说有某些元素多次进入和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比。

另外,Java 中的 Integer 和 String 这种包装类不能直接用 == 进行相等判断,而应该使用类的 equals 方法。

通用思路

万用框架!一篇文章带你学会滑动窗口算法_第1张图片
第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解,也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历。

框架的使用方法

  1. 初始化window和need两个哈希表,记录窗口中的字符和需要凑齐的字符。
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
  1. 然后,使用 leftright 变量初始化窗口的两端,不要忘了,区间 [left, right) 是左闭右开的,所以初始情况下窗口没有包含任何元素:
int left = 0, right = 0;
int valid = 0; 
while (right < s.size()) {
    // 开始滑动
}

其中 valid 变量表示窗口中满足 need 条件的字符个数,如果 validneed.size 的大小相同,则说明窗口已满足条件,已经完全覆盖了串 T

要思考的问题

1、什么时候应该移动 right 扩大窗口?窗口加入字符时,应该更新哪些数据?

2、什么时候窗口应该暂停扩大,开始移动 left 缩小窗口?从窗口移出字符时,应该更新哪些数据?

3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

例题

最小覆盖子串

76. 最小覆盖子串 - 力扣(LeetCode)

public String minWindow(String s, String t) {
    // 用于记录需要的字符和窗口中的字符及其出现的次数
    Map<Character, Integer> need = new HashMap<>();
    Map<Character, Integer> window = new HashMap<>();
    // 统计 t 中各字符出现次数
    for (char c : t.toCharArray()) 
        need.put(c, need.getOrDefault(c, 0) + 1);

    int left = 0, right = 0;
    int valid = 0; // 窗口中满足需要的字符个数
    // 记录最小覆盖子串的起始索引及长度
    int start = 0, len = Integer.MAX_VALUE;
    while (right < s.length()) {
        // c 是将移入窗口的字符
        char c = s.charAt(right);
        // 扩大窗口
        right++;
        // 进行窗口内数据的一系列更新
        if (need.containsKey(c)) {
            window.put(c, window.getOrDefault(c, 0) + 1);
            if (window.get(c).equals(need.get(c)))
                valid++; // 只有当 window[c] 和 need[c] 对应的出现次数一致时,才能满足条件,valid 才能 +1
        }

        // 判断左侧窗口是否要收缩
        while (valid == need.size()) {
            // 更新最小覆盖子串
            if (right - left < len) {
                start = left;
                len = right - left;
            }
            // d 是将移出窗口的字符
            char d = s.charAt(left);
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            if (need.containsKey(d)) {
                if (window.get(d).equals(need.get(d)))
                    valid--; // 只有当 window[d] 内的出现次数和 need[d] 相等时,才能 -1
                window.put(d, window.get(d) - 1);
            }
        }
    }

    // 返回最小覆盖子串
    return len == Integer.MAX_VALUE ?
        "" : s.substring(start, start + len);
}

再次强调:

使用 Java 的朋友要尤其警惕语言特性的陷阱。Java 的 Integer,String 等类型判定相等应该用 equals 方法而不能直接用等号 ==,这是 Java 包装类的一个隐晦细节。所以在缩小窗口更新数据的时候,不能直接改写为 window.get(d) == need.get(d),而要用 window.get(d).equals(need.get(d)),之后的题目代码同理。

字符串排列

567. 字符串的排列 - 力扣(LeetCode)

相当于给你一个 S1 和一个 S2,请问你 S2 中是否存在一个子串,包含 S1 中所有字符且不包含其他字符

class Solution {
    public boolean checkInclusion(String s1, String s2) {
        Map<Character, Integer> need = new HashMap<>();
        Map<Character, Integer> window = new HashMap<>();

        // 获取所有需要的字符个数
        for (int i = 0; i < s1.length(); i++) {
            char c = s1.charAt(i);
            need.put(c, need.getOrDefault(c, 0) + 1);
        }
        
        int left = 0, right = 0;
        int valid = 0;
        
        while (right < s2.length()) {
            char c = s2.charAt(right);
            right++;

            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c))) {
                    valid++;
                }
            }

            // 左边缩小
            while (right - left >= s1.length()) {
                if (valid == need.size()) {return true;}
                char d = s2.charAt(left);
                left++;

                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                    valid--;
                    }
                    window.put(d, window.getOrDefault(d, 0) - 1);
                }
            }
        }
        return false;
    }
}

对于这道题的解法代码,基本上和最小覆盖子串一模一样,只需要改变几个地方:

1、本题移动 left 缩小窗口的时机是窗口大小大于 t.size() 时,因为排列嘛,显然长度应该是一样的。

2、当发现 valid == need.size() 时,就说明窗口中就是一个合法的排列,所以立即返回 true

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

438. 找到字符串中所有字母异位词 - 力扣(LeetCode)

相当于,输入一个串 S,一个串 P,找到 S 中所有 P 的排列,返回它们的起始索引

// 注意:java 代码由 chatGPT 根据我的 cpp 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码不保证正确性,仅供参考。如有疑惑,可以参照我写的 cpp 代码对比查看。

public List<Integer> findAnagrams(String s, String p) {
    Map<Character, Integer> need = new HashMap<>();
    Map<Character, Integer> window = new HashMap<>();
    for (int i = 0; i < p.length(); i++) {
        char c = p.charAt(i);
        need.put(c, need.getOrDefault(c, 0) + 1);
    }

    int left = 0, right = 0;
    int valid = 0;
    List<Integer> res = new ArrayList<>(); // 记录结果
    while (right < s.length()) {
        char c = s.charAt(right);
        right++;
        // 进行窗口内数据的一系列更新
        if (need.containsKey(c)) {
            window.put(c, window.getOrDefault(c, 0) + 1);
            if (window.get(c).equals(need.get(c))) {
                valid++;
            }
        }
        // 判断左侧窗口是否要收缩
        while (right - left >= p.length()) {
            // 当窗口符合条件时,把起始索引加入 res
            if (valid == need.size()) {
                res.add(left);
            }
            char d = s.charAt(left);
            left++;
            // 进行窗口内数据的一系列更新
            if (need.containsKey(d)) {
                if (window.get(d).equals(need.get(d))) {
                    valid--;
                }
                window.put(d, window.get(d) - 1);
            }
        }
    }
    return res;
}

无重复字符的最长子串

3. 无重复字符的最长子串 - 力扣(LeetCode)

// 注意:java 代码由 chatGPT 根据我的 cpp 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码不保证正确性,仅供参考。如有疑惑,可以参照我写的 cpp 代码对比查看。

int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> window = new HashMap<>();

    int left = 0, right = 0;
    int res = 0; // 记录结果
    while (right < s.length()) {
        char c = s.charAt(right);
        right++;
        // 进行窗口内数据的一系列更新
        window.put(c, window.getOrDefault(c, 0) + 1);
        // 判断左侧窗口是否要收缩
        while (window.get(c) > 1) {
            char d = s.charAt(left);
            left++;
            // 进行窗口内数据的一系列更新
            window.put(d, window.get(d) - 1);
        }
        // 在这里更新答案
        res = Math.max(res, right - left);
    }
    return res;
}

这就是变简单了,连 needvalid 都不需要,而且更新窗口内数据也只需要简单的更新计数器 window 即可。

window[c] 值大于 1 时,说明窗口中存在重复字符,不符合条件,就该移动 left 缩小窗口了嘛。

唯一需要注意的是,在哪里更新结果 res 呢?我们要的是最长无重复子串,哪一个阶段可以保证窗口中的字符串是没有重复的呢?

这里和之前不一样,要在收缩窗口完成后更新 res,因为窗口收缩的 while 条件是存在重复元素,换句话说就是收缩完成后一定保证窗口中没有重复。

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