刷了十几道双指针的题,每次遇到新题还是懵?上一篇讲了「对撞指针」和「快慢指针」,这篇把三兄弟的最后一个——滑动窗口彻底讲透。


📖 目录


一、滑动窗口到底是什么?

先一句话总结:

滑动窗口 = 用两个同向指针维护一个「窗口」,通过右指针扩张、左指针收缩来高效解决问题。

和其他两种双指针对比一下:

┌────────────────────────────────────────────────────────┐
│                  双指针三兄弟对比                          │
├──────────┬──────────────┬───────────────────────────────┤
│   形态    │   指针方向     │           典型场景             │
├──────────┼──────────────┼───────────────────────────────┤
│ 对撞指针  │  ← → 相向而行 │ 有序数组、回文判断             │
│ 快慢指针  │  → → 同向而行  │ 链表环检测、去重              │
│ 滑动窗口  │  → → 同向夹逼  │ 最长子串/子数组、覆盖问题      │
└──────────┴──────────────┴───────────────────────────────┘

滑动窗口的精髓在于:

  • 右指针(right):不断向右扩大窗口,把新元素纳入考虑
  • 左指针(left):当窗口不满足条件时,向右收缩,剔除无用元素
  • 窗口内的内容:就是当前考虑的「子串/子数组」

二、核心原理:右扩左缩

用一个具体例子来感受。给定字符串 "abcabcbb",求无重复字符的最长子串

初始状态:窗口为空
right = -1, left = 0

第1步:right 右移,加入 'a'
  a b c a b c b b
  ^           ^
  L           R
  窗口 = [a]         无重复 ✅

第2步:right 右移,加入 'b'
  a b c a b c b b
  ^ ^
  L R
  窗口 = [a,b]       无重复 ✅

第3步:right 右移,加入 'c'
  a b c a b c b b
  ^   ^
  L   R
  窗口 = [a,b,c]     无重复 ✅

第4步:right 右移,加入 'a'—— 重复了!❌
  a b c a b c b b
  ^     ^
  L     R
  窗口 = [a,b,c,a]   'a' 重复!

第5步:left 右移,直到窗口无重复
  a b c a b c b b
      ^ ^
      L R
  窗口 = [b,c,a]     无重复 ✅
  (left 从 0 移到 1)

... 继续这个过程 ...

核心逻辑就一句话:

right 一直右移扩大窗口,发现不满足条件了,就移动 left 收缩窗口,直到重新满足条件。

因为每个元素最多被 left 和 right 各访问一次,所以时间复杂度是 O(n)


三、通用模板代码

所有滑动窗口的题目,代码结构几乎都长这样:

public int slidingWindow(String s) {
    // 1. 用什么数据结构维护窗口状态?
    //    常见选择:HashMap(字符计数)、Set(去重)、int[](字符频次)
    Map<Character, Integer> window = new HashMap<>();

    int left = 0;   // 窗口左边界
    int result = 0; // 记录最优结果

    for (int right = 0; right < s.length(); right++) {
        // 2. 右指针右移:将新元素加入窗口
        char c = s.charAt(right);
        window.put(c, window.getOrDefault(c, 0) + 1);

        // 3. 检查窗口是否满足条件?
        //    如果【不满足】,就移动 left 收缩窗口
        while (窗口不满足条件) {
            char d = s.charAt(left);
            window.put(d, window.get(d) - 1);
            if (window.get(d) == 0) {
                window.remove(d);
            }
            left++; // 左指针右移,窗口缩小
        }

        // 4. 窗口满足条件了,更新最优结果
        result = Math.max(result, right - left + 1);
    }

    return result;
}

模板四步走:

┌─────────────────────────────────────────────────┐
│  ① 初始化:left=0,窗口数据结构                    │
│  ② 右扩:right 每轮右移一步,新元素加入窗口          │
│  ③ 左缩:检查条件,不满足则 left 右移(while循环)   │
│  ④ 更新:窗口满足条件时,更新最优解                  │
└─────────────────────────────────────────────────┘

不同题目的区别只在于:

  • 窗口用什么数据结构(Map / Set / int[])
  • 什么条件触发 left 收缩(while 的判断条件)
  • 更新什么结果(最大值、最小值、计数等)

四、经典题1:无重复字符的最长子串

LeetCode 3 — 无重复字符的最长子串
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。

4.1 思路分析

  • Set 维护窗口内的字符
  • right 每加入一个新字符,检查 Set 里有没有
  • 如果重复了:left 不断右移,把左边的字符从 Set 中移除,直到窗口里没有重复

4.2 图解过程

"pwwkew" 为例:

Step 1: right=0, 加入 'p'
  p w w k e w
  ^
  L R
  Set = {p}          无重复 ✅  maxLen = 1

Step 2: right=1, 加入 'w'
  p w w k e w
  ^ ^
  L R
  Set = {p,w}        无重复 ✅  maxLen = 2

Step 3: right=2, 加入 'w'—— 重复!
  p w w k e w
  ^   ^
  L   R
  Set = {p,w,w}      'w' 重复 ❌
  → left 右移,移除 'p'
  Set = {w,w}        还重复 ❌
  → left 右移,移除 'w'
  Set = {w}          无重复 ✅

  p w w k e w
      ^
      L R
  maxLen = 2(不变)

Step 4: right=3, 加入 'k'
  p w w k e w
      ^ ^
      L   R
  Set = {w,k}        无重复 ✅  maxLen = 2

Step 5: right=4, 加入 'e'
  p w w k e w
      ^     ^
      L     R
  Set = {w,k,e}      无重复 ✅  maxLen = 3

Step 6: right=5, 加入 'w'—— 重复!
  p w w k e w
      ^       ^
      L       R
  Set = {w,k,e,w}    'w' 重复 ❌
  → left 右移,移除 'w'
  Set = {k,e,w}      无重复 ✅

  p w w k e w
        ^     ^
        L     R
  maxLen = 3

4.3 完整代码

class Solution {
    public int lengthOfLongestSubstring(String s) {
        // 用 Set 维护窗口内的字符(保证无重复)
        Set<Character> window = new HashSet<>();

        int left = 0;    // 窗口左边界
        int maxLen = 0;  // 记录最长长度

        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);

            // 如果当前字符在窗口中已存在 → 收缩左边界
            while (window.contains(c)) {
                window.remove(s.charAt(left));
                left++;
            }

            // 加入当前字符
            window.add(c);

            // 更新最长长度
            maxLen = Math.max(maxLen, right - left + 1);
        }

        return maxLen;
    }
}

4.4 复杂度分析

复杂度说明
时间O(n)每个字符最多被 left 和 right 各访问一次
空间O(min(m,n))m 为字符集大小,n 为字符串长度,取较小值

五、经典题2:最小覆盖子串

LeetCode 76 — 最小覆盖子串
给你一个字符串 s、一个字符串 t。返回 s 中涵盖 t 所有字符的最小子串

5.1 思路分析

这道题比上道难一点,因为:

  • 需要统计字符频率(t 中可能有重复字符)
  • 窗口需要包含 t 的所有字符(不仅仅是"无重复")
  • 最小窗口(不是最大)

核心思路:

  1. Map 记录 t 中每个字符的「需要次数」
  2. right 右移,加入字符并减少「需要次数」
  3. 当所有字符的「需要次数」都 ≤ 0 时(窗口覆盖了 t),尝试收缩 left
  4. 记录过程中的最小窗口

5.2 图解过程

s = "ADOBECODEBANC", t = "ABC" 为例:

need = {A:1, B:1, C:1}   ← 还需要覆盖的字符

Step 1-5: right 不断右移
  A D O B E C O D E B A N C
  ^         ^
  L         R
  窗口 = [A,D,O,B,E,C]    覆盖了 A,B,C ✅
  need = {A:0, B:0, C:0}
  → 记录当前窗口长度 = 6

Step 6: 尝试收缩 left
  移除 'A' → need[A]=1,不再覆盖 ❌,停止收缩

  A D O B E C O D E B A N C
    ^       ^
    L       R
  窗口 = [D,O,B,E,C]      未覆盖 ❌

... right 继续右移 ...

最终找到最小窗口 = "BANC"(长度4)
  A D O B E C O D E B A N C
                  ^   ^
                  L   R

5.3 完整代码

class Solution {
    public String minWindow(String s, String t) {
        if (s == null || s.length() == 0 || t == null || t.length() == 0) {
            return "";
        }

        // need: 记录 t 中每个字符还需要出现几次
        Map<Character, Integer> need = new HashMap<>();
        for (char c : t.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

        // needCnt: 还有多少个字符的「需要次数」> 0
        int needCnt = need.size();

        int left = 0;         // 窗口左边界
        int minLen = Integer.MAX_VALUE;
        int start = 0;        // 最小窗口的起始位置

        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);

            // 右扩:将字符加入窗口
            if (need.containsKey(c)) {
                need.put(c, need.get(c) - 1);
                // 如果该字符的「需要次数」恰好变为0,说明这个字符已满足
                if (need.get(c) == 0) {
                    needCnt--;
                }
            }

            // 当所有字符都满足时,尝试收缩左边界
            while (needCnt == 0) {
                // 更新最小窗口
                if (right - left + 1 < minLen) {
                    minLen = right - left + 1;
                    start = left;
                }

                // 左缩:移除左边界的字符
                char d = s.charAt(left);
                if (need.containsKey(d)) {
                    need.put(d, need.get(d) + 1);
                    // 如果该字符的「需要次数」变为1,说明不再满足
                    if (need.get(d) > 0) {
                        needCnt++;
                    }
                }
                left++;
            }
        }

        return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
    }
}

5.4 复杂度分析

复杂度说明
时间O(m+n)m=
空间O(k)k 为 t 中不同字符的数量

六、经典题3:找到字符串中所有字母异位词

LeetCode 438 — 找到字符串中所有字母异位词
给定两个字符串 s 和 p,找到 s 中所有 p 的「异位词」的起始索引。

6.1 思路分析

异位词 = 字符种类和数量完全相同,顺序可以不同。

关键观察:如果 p 长度为 m,那异位词就是一个长度固定为 m 的滑动窗口

和前面的"可变窗口"不同,这题是定长窗口

  • 窗口大小始终等于 p 的长度
  • 每次右移一步:去掉最左边的字符,加入右边的新字符

6.2 图解过程

s = "cbaebabacd", p = "abc" 为例:

p 的字符频率: {a:1, b:1, c:1}
窗口大小 = 3

Step 1: 窗口 [0,2] = "cba"
  c b a e b a b a c d
  ^ ^ ^
  L ? R
  窗口频率 = {a:1, b:1, c:1}  == p 的频率 ✅
  → ans = [0]

Step 2: 窗口右移 [1,3] = "bae"
  c b a e b a b a c d
    ^ ^ ^
    L ? R
  窗口频率 = {a:1, b:1, e:1}  != p 的频率 ❌

Step 3: 窗口右移 [2,4] = "aeb"
  c b a e b a b a c d
      ^ ^ ^
      L ? R
  窗口频率 = {a:1, e:1, b:1}  != p 的频率 ❌

Step 4: 窗口右移 [3,5] = "eba"
  c b a e b a b a c d
        ^ ^ ^
        L ? R
  窗口频率 = {e:1, b:1, a:1}  != p 的频率 ❌

Step 5: 窗口右移 [4,6] = "bab"
  c b a e b a b a c d
          ^ ^ ^
          L ? R
  窗口频率 = {b:2, a:1}        != p 的频率 ❌

Step 6: 窗口右移 [5,7] = "aba"
  c b a e b a b a c d
            ^ ^ ^
            L ? R
  窗口频率 = {a:2, b:1}        != p 的频率 ❌

Step 7: 窗口右移 [6,8] = "bac"
  c b a e b a b a c d
              ^ ^ ^
              L ? R
  窗口频率 = {b:1, a:1, c:1}  == p 的频率 ✅
  → ans = [0, 6]

最终结果: [0, 6]

6.3 完整代码

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> ans = new ArrayList<>();

        if (s.length() < p.length()) {
            return ans;
        }

        int pLen = p.length();

        // pCount: p 的字符频率
        int[] pCount = new int[26];
        for (char c : p.toCharArray()) {
            pCount[c - 'a']++;
        }

        // sCount: 当前窗口的字符频率
        int[] sCount = new int[26];

        // 初始化第一个窗口
        for (int i = 0; i < pLen; i++) {
            sCount[s.charAt(i) - 'a']++;
        }

        // 比较数组是否相等
        if (Arrays.equals(pCount, sCount)) {
            ans.add(0);
        }

        // 滑动窗口:每次右移一步
        for (int i = pLen; i < s.length(); i++) {
            // 去掉最左边的字符
            sCount[s.charAt(i - pLen) - 'a']--;
            // 加入右边的新字符
            sCount[s.charAt(i) - 'a']++;

            // 比较频率数组
            if (Arrays.equals(pCount, sCount)) {
                ans.add(i - pLen + 1);
            }
        }

        return ans;
    }
}

6.4 复杂度分析

复杂度说明
时间O(n × 26)n 为 s 长度,每次比较26个字符的数组
空间O(1)固定26长度的数组

七、三道题对比总结

把三道题放在一起看,发现套路完全一样:

对比项无重复最长子串最小覆盖子串字母异位词
题目LeetCode 3LeetCode 76LeetCode 438
窗口类型可变(求最大)可变(求最小)定长
数据结构SetHashMapint[26]
left 触发条件字符重复时收缩全部覆盖后尝试收缩定长,每步都移
优化方向最大长度最小长度计数
时间O(n)O(m+n)O(n)
空间O(min(m,n))O(k)O(1)

一句话总结选择策略:

┌─────────────────────────────────────────────────────────┐
│  看到"子串/子数组" + "最xx" → 想滑动窗口                   │
│                                                         │
│  "最长" → 窗口尽可能大 → 满足条件时才更新结果               │
│  "最短" → 窗口尽可能小 → 不满足条件时更新左边界              │
│  "所有" → 定长窗口 → 每步右移,比较窗口内容                 │
└─────────────────────────────────────────────────────────┘

记住这个模板,80%的滑动窗口题直接套就行。剩下20%需要微调 while 的判断条件,但框架不变。


💡 刷题建议:先把这三道吃透(每道至少自己手写3遍),再去做 LeetCode 209(长度最小的子数组)、904(水果成篮)、763(划分字母区间),你会发现全是同一套路。

如果觉得有帮助,点赞 + 收藏支持一下!有问题欢迎评论区讨论 💬

更多推荐