力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 03滑动窗口

滑动窗口是双指针的亲兄弟,也是哈希的搭档。它专门对付一类题:「在一个序列里, 找一个满足某条件的连续子段(子数组 / 子串)」。 这类题的暴力解都是「枚举所有子段」O(n²);滑动窗口的魔法在于——让左右两个边界都只朝一个方向走, 于是 O(n²) 被压成 O(n)。 学完本章你会拿到一个「通用模板」,本章后三道题是它的三种变体(求最短、求最长、固定窗口)。

学习目标
掌握滑窗通用模板,独立写 4 道题
前置要求
专题 01 双指针、专题 02 哈希
本章产出
通用模板 + 4 题 Java 代码
建议学时
6–7 小时

3.1从暴力 O(n²) 到 O(n)

先看清暴力解为什么慢,才知道滑窗省掉了什么。以「找和 ≥ target 的最短子数组」为例,暴力思路是:

for (int i = 0; i < n; i++)          // 左边界
    for (int j = i; j < n; j++)      // 右边界
        检查 nums[i..j] 是否满足条件

i 和 j 各自能往前往后乱走,组合出 O(n²) 个子段。但注意一个事实: 子段 [i, j] 和 [i, j+1] 只差一个元素,我们却每次都从头重新算一遍和——大量重复劳动。

关键观察:两个边界都「只前进、不回头」

能不能让 right 一直往右走,left 也一直往右走,谁也不后退?

因为两个指针都不回头,每个元素最多被「进窗口」和「出窗口」各一次,总操作 O(n)。 这就是滑动窗口的核心:用「两个只前进的指针 + 维护窗口状态」,替代「枚举所有子段」。

暴力思路滑窗思路结果
i、j 自由组合,O(n²)right、left 都只前进,各 O(n)O(n²) → O(n)
每子段重新算和进窗口 +x,出窗口 -x,O(1) 维护去掉重复计算
一句话定性:滑动窗口 = 双指针 + 边走边维护窗口信息。窗口信息(和、频次、是否达标)用变量或哈希实时更新, 而不是每次重算。它成立的隐含前提是:窗口扩大/缩小时,状态的变化是单调、可递推的(比如正数数组,加数必增、减数必减)。

3.2滑动窗口通用模板

下面这个骨架,本章四道题都能往上套。务必理解到能默写。先给带注释的通用版,再讲两种变体。

// 通用骨架(以「在序列里找满足条件的连续子段」为例)
int left = 0;            // 窗口左边界
// windowState 记录当前窗口的状态(和 / 频次 / 是否达标等)
for (int right = 0; right < n; right++) {
    // ① 右扩:把 nums[right] 纳入窗口,更新窗口状态
    纳入 nums[right];

    // ② 判断:窗口是否满足条件
    while (窗口满足条件) {
        // ③ 收缩:尝试把 left 右移,缩小窗口
        更新答案(可能);     // 求最短/最长在这里记录
        把 nums[left] 移出窗口,更新窗口状态;
        left++;
    }
    // 若求最长,有时在 ② 之外(窗口非法时)也更新答案,见下文变体
}

变体一:求「最短」子段(满足条件里挑最短)

典型:和 ≥ target 的最小子数组、最小覆盖子串。套路是「满足条件就收缩」: while (满足条件) 里不断踢左边界,每踢一次都可能是更短的答案,所以在收缩循环内更新答案。 收缩到「刚好不满足」为止,再扩右边界。

变体二:求「最长」子段(不满足条件里挑最长)

典型:无重复字符的最长子串。套路是「不满足(出现重复)就收缩」: 窗口一旦非法(出现了重复字符),就踢左边界直到重新合法;每次窗口合法时更新答案(在收缩循环外, 因为合法窗口本身就是候选最长)。

目标收缩时机更新答案位置
最短子段窗口满足条件时收缩收缩循环内部
最长子段窗口不合法时收缩收缩循环外部(合法时)
固定窗口(找所有)窗口超长就缩窗口长度恰为 k 时检查
新手最易写反的地方:把「求最短」和「求最长」的收缩方向搞混。记住口诀: 「最短——满足了还嫌长,往里缩;最长——不合法了要修复,往里缩。」 两者都是 left 右移(往里缩),区别只在缩的触发条件和记录答案的时机。

3.3长度最小的子数组(中等,力扣 209)

中等 核心:求最短变体 · 正数数组

题意:给定只包含正整数的数组 nums 和正整数 target, 找出和 ≥ target 的连续子数组的最小长度;若不存在返回 0。 例:nums = [2,3,1,2,4,3], target = 7,子数组 [4,3] 和为 7、长度 2,是最小,返回 2。

思路推导

第一步,暴力。枚举所有子段求和,O(n²)。

第二步,为什么滑窗能用——正数数组的单调性。因为元素都 > 0,右边界右移 → 窗口和必增;左边界右移 → 窗口和必减。 这个单调性让「窗口和」随指针移动方向确定,从而能放心地「够了就缩、不够就扩」。

第三步,套「求最短」模板。right 不断把元素加进窗口、累加和;一旦 sum >= target, 就记录当前长度,并在 while 里收缩左边界(先减去将移出的元素再 left++),看能不能更短还满足。 收缩到 sum < target 后,继续扩右边界。

为什么 left 右移不会漏掉更优解?对于当前已 ≥ target 的窗口,更短且仍 ≥ target 的窗口只可能在它内部 (左边界更靠右)。所以放心收缩;一旦缩到不满足,说明当前 left 下再短就不可能满足了,必须扩 right——此时再回头缩 left 没意义,因为那会丢元素、更不满足。两指针都只前进,O(n)。

参考代码

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int left = 0, sum = 0;
        int ans = Integer.MAX_VALUE;          // 记录最短长度,初始为「无穷大」
        for (int right = 0; right < nums.length; right++) {
            sum += nums[right];               // ① 右扩:新元素进窗口
            while (sum >= target) {           // ② 满足就收缩(求最短)
                ans = Math.min(ans, right - left + 1); // 更新最短长度
                sum -= nums[left];            // ③ 收缩前,先减去将移出的元素
                left++;
            }
        }
        return ans == Integer.MAX_VALUE ? 0 : ans; // 没找到任何满足条件的,返回 0
    }
}

复杂度

O(n) 时间(right、left 各最多走 n 步),O(1) 空间(只用几个变量)。注意:只适用于正数/非负数组,见 3.7 节坑一。

边界自查:① target 比整个数组和还大 → 循环结束 ans 仍是 MAX,返回 0,正确。 ② 空数组 → 循环不进,返回 0。 ③ 恰等于 target 的单段如 [7], target=7 → 长度 1,正确。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 无重复字符的最长子串(力扣 3,中等)——与本题共用「滑动窗口」技巧;差异在这是「求最长」变体,窗口出现重复才收缩(提示:用 int[128] 记字符上次出现位置,重复时令 left = last[c] + 1,每次窗口合法就更新答案)。
  • 找到字符串中所有字母异位词(力扣 438,简单)——与本题共用「滑动窗口」技巧;差异在窗口长度固定为 p.length(),比频次而非比和(提示:双 int[26] 计数,进一个出一个,长度恰为 p.length() 时判 Arrays.equals)。

3.4无重复字符的最长子串(中等,力扣 3)

中等 核心:求最长变体 · 数组记上次位置

题意:给定字符串 s,找出不含重复字符的最长子串的长度。 例:s = "abcabcbb" → 最长无重复子串是 "abc",返回 3;s = "bbbbb" → "b",返回 1。

思路推导

第一步,暴力。枚举所有子串、逐个查是否含重复,O(n³) 或 O(n²),太慢。

第二步,滑窗维护「无重复」。维护窗口 [left, right] 始终无重复。right 右扩时加入 s[right]: 若它已经在窗口里出现过(重复),就必须把 left 右移到「那个重复字符的下一个位置」,让窗口重新无重复。 每次窗口合法,就更新最长长度。

第三步,用数组 O(1) 找「上次出现位置」。怎么快速知道 s[right] 上次在哪、是否还在窗口内? 用 int[128] 记每个字符最近一次出现的下标(ASCII 足够)。 规则:若 last[c] >= left,说明 c 在当前窗口里出现过,令 left = last[c] + 1(跳到重复字符之后); 然后更新 last[c] = right。这比「逐个 left++ 直到不重复」更直接,一步定位。

rights[right]last[c]≥left?left 动作当前最长
0alast=-1<0 否left=01
1b否left=02
2c否left=03
3alast[a]=0≥0 是left=0+1=13
4blast[b]=1≥1 是left=1+1=23

参考代码

class Solution {
    public int lengthOfLongestSubstring(String s) {
        int[] last = new int[128];           // 记录每个字符最近出现下标
        Arrays.fill(last, -1);               // 初始 -1 表示还没出现过
        int left = 0, ans = 0;
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            // 若 c 在窗口里出现过(上次位置 >= left),left 跳到它后面
            if (last[c] >= left) {
                left = last[c] + 1;
            }
            last[c] = right;                 // 更新 c 的最新位置
            ans = Math.max(ans, right - left + 1); // 窗口合法,更新最长
        }
        return ans;
    }
}

复杂度

O(n) 时间(right 走一遍,left 只增),O(1) 空间(128 长度固定数组)。这是「求最长」变体的标准写法。

易错点:last 数组必须初始化为 -1 而不是 0!因为下标 0 是合法的窗口位置, 若初始为 0,第一个字符会被误判成「已经在窗口里」(last[c] >= left 成立)导致 left 错移。用 -1 区分「没出现过」和「在下标 0 出现过」。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 最小覆盖子串(力扣 76,困难)——与本题共用「求最长」滑窗框架;差异在窗口需「覆盖 t 的全部字符及频次」,用 valid 计数达标字符种类(提示:need/win 双计数数组,right 扩时某字符频次刚达标就 valid++,left 缩时先判 win[d]==need[d] 再 valid--)。
  • 至多包含 K 个不同字符的最长子串(力扣 340,中等)——与本题共用「求最长」滑窗;差异在合法条件是「窗口内不同字符数 ≤ K」而非「无重复」(提示:用 HashMap 记字符频次,超过 K 种就移动 left 直到恢复)。

3.5找到字符串中所有字母异位词(简单,力扣 438)

简单 核心:固定窗口 · 双计数数组

题意:给定字符串 s 和 p,找出 p 的所有字母异位词在 s 中的起始下标。 例:s = "abab", p = "ab" → 异位词出现在下标 0("ab")、1("ba")、2("ab"),返回 [0, 1, 2]。

思路推导

第一步,暴力。枚举 s 所有长度 = p.length() 的子串,逐个判断是否异位词,O(n·k²) 量级。

第二步,固定窗口 + 频次比较。p 的异位词长度一定等于 p.length(),所以窗口大小固定为 plen。 用 int[26] 数组 need 记 p 的频次;用 win 记当前窗口频次。当窗口长度达到 plen 时, 若 win 与 need 完全一致,则该窗口是 p 的异位词,记下左边界。

第三步,滑起来:进一个、出一个。right 右移把 s[right] 加进 win;一旦窗口长度超过 plen, 就把「刚滑出左边界的元素」s[right - plen] 从 win 减掉,保持窗口恒为 plen。这样每步只做 O(1) 的频次增减, 到长度恰为 plen 时用 Arrays.equals(win, need) 判等(O(26) 常数)。

为什么只看长度 = plen 的窗口?因为异位词必须长度和字符频次都一致;长度不一直接排除,长度一致再比频次,恰好完整覆盖「是异位词」的充要条件。

参考代码

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> res = new ArrayList<>();
        if (s.length() < p.length()) return res;     // s 比 p 还短,不可能有
        int[] need = new int[26];
        int[] win = new int[26];
        for (char c : p.toCharArray()) need[c - 'a']++; // p 的频次
        int plen = p.length();
        for (int right = 0; right < s.length(); right++) {
            win[s.charAt(right) - 'a']++;             // 右端字符进窗口
            if (right >= plen) {                       // 窗口超长,左端字符移出
                win[s.charAt(right - plen) - 'a']--;
            }
            if (right >= plen - 1) {                   // 窗口刚好 plen 长,检查
                if (Arrays.equals(win, need)) {
                    res.add(right - plen + 1);        // 记录起始下标
                }
            }
        }
        return res;
    }
}

复杂度

O(n) 时间(right 走一遍,每步 O(26) 常数频次操作),O(1) 空间(两个 26 长数组)。这是「固定窗口」变体的范例。

对比 2.5 节分组题:那里是把整串排序当 key;这里是在长串里滑一个固定窗口比频次。 两者都用「频次相同 ⇔ 异位词」,但场景不同:一个是整串归类,一个是长串里定位子串。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 字符串的排列(力扣 567,中等)——与本题共用「固定窗口 + 双计数数组」技巧;差异在只判断「是否存在」一个异位词而非「所有起始位置」(提示:窗口长度恒为 s1.length(),win.equals(need) 立即返回 true)。
  • 串联所有单词的子串(力扣 30,困难)——与本题共用「固定窗口滑窗」思想;差异在窗口按「单词」而非字符滑动,比较的是词频(提示:建立词→下标映射,每次移动一个 wordLen,用 HashMap 维护词频)。

3.6最小覆盖子串(困难,力扣 76)

困难 核心:求最短变体完整形态 · need/window 计数

题意:给定字符串 s 和 t,在 s 中找出能覆盖 t 所有字符(含频次)的最短子串; 若没有返回空串。 例:s = "ADOBECODEBANC", t = "ABC" → 最短覆盖子串是 "BANC",返回它。

本节定位:这是本章模板的完整形态——求最短变体 + 双计数数组 + 达标种类计数。 第一次学可先跳过,学完 3.3、3.4、3.5 再来,会发现它只是「模板 + 更多细节」。

思路推导

第一步,套「求最短」模板。维护窗口 [left, right],目标是让窗口「覆盖 t」。right 扩、left 在满足时缩,找最短。

第二步,怎么算「覆盖了 t」。用 need[128] 记 t 的频次,win[128] 记窗口频次。 但「每步比对整个数组是否包含 t」太慢。改进:用 valid 记录「频次已达标」的字符种类数。 只有某字符在窗口里的频次刚好达到它在 t 里的频次时,valid 才 +1。 当 valid == t 中不同字符的种类数,说明窗口已覆盖 t,可以收缩求最短。

第三步,收缩时别忘了回退 valid。left 右移踢出字符 d 前,若 win[d] 当时恰好等于 need[d], 说明这一踢会让 d 不再达标,valid 要 -1;再执行 win[d]--。顺序不能反(先判再减)。

状态含义
win[c] < need[c]c 还不够,valid 不动
win[c] == need[c]c 刚达标,valid++(right 扩时)
win[d] == need[d] 时准备踢出踢出后 d 不再达标,valid--(left 缩时)

参考代码

class Solution {
    public String minWindow(String s, String t) {
        int[] need = new int[128];
        int[] win = new int[128];
        int needCnt = 0;                        // t 中不同字符的种类数
        for (char c : t.toCharArray()) {
            if (need[c]++ == 0) needCnt++;      // 首次出现某字符,种类数 +1
        }
        int left = 0, valid = 0;                // valid:窗口中「频次已达标」的字符种类数
        int start = 0, minLen = Integer.MAX_VALUE;
        for (int right = 0; right < s.length(); right++) {
            char c = s.charAt(right);
            if (need[c] > 0) {                  // 只关心 t 里出现的字符
                win[c]++;
                if (win[c] == need[c]) valid++; // 该字符频次刚达标
            }
            while (valid == needCnt) {          // 已覆盖 t,收缩求最短
                if (right - left + 1 < minLen) {
                    minLen = right - left + 1;  // 记录当前最短
                    start = left;
                }
                char d = s.charAt(left);
                if (need[d] > 0) {
                    if (win[d] == need[d]) valid--; // 即将移出导致不达标,先减 valid
                    win[d]--;                    // 再真正移出
                }
                left++;
            }
        }
        return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
    }
}

复杂度

O(n) 时间(两指针各走一遍,每步 O(1) 频次操作),O(1) 空间(128 定长数组)。困难题,但骨架和 3.3 完全一致,只是状态维护更细。

为什么用 128 数组而不是 26:本题字符串可能含大写字母、符号(s = "ADOBECODEBANC" 有大写), 26 个小写桶装不下,用 int[128] 覆盖 ASCII 即可。 若含 Unicode 再换 Map<Character, Integer>。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 串联所有单词的子串(力扣 30,困难)——与本题共用「滑动窗口 + 计数」技巧;差异在窗口按词而非字符滑动,每个槽位长度是词长(提示:把单词整体当下标单位,用 HashMap 存词频,窗口每次移一个 wordLen)。
  • 滑动窗口最大值(力扣 239,困难)——与本题同属「滑动窗口」家族;差异在维护的是「窗口内最大值」而非字符频次,需用单调队列(提示:双端队列存下标且保持值递减,超出窗口就踢队首,队首即最大值)。

3.7本专题常见坑

  • 坑一:有负数还用普通滑窗。滑窗成立靠「右扩必增、左缩必减」的单调性,这只对正数/非负数组成立。 若数组含负数,右扩可能使和变小、左缩可能使和变大,单调性破坏,while (sum >= target) 的收缩逻辑会漏解。 含负数要改为前缀和 + 有序结构或滑动窗口的变体,别无脑套。
  • 坑二:收缩条件写反(求最长/最短混淆)。最短是「满足了就缩」(在 while 内更新答案),最长是「不合法才缩」(在 while 外更新答案)。 写反会得到错误答案或死循环。背熟 3.2 节那张变体表。
  • 坑三:计数数组忘了回退。左边界移出窗口时,必须 win[d]--(或 last 更新)。漏写会导致窗口状态「虚高」, 误判为满足/不重复,典型症状是一部分用例 WA。每次写收缩逻辑都核对:进窗口 +1、出窗口 -1,成对出现。
  • 坑四:求最长时答案更新放错位置。无重复子串应在「窗口每次合法时」更新(即 for 循环体内、收缩之后), 不是在 while 里。若放进 while,会漏掉「从未触发收缩」的那些最长窗口。
  • 坑五:left 越界 / 窗口长度算错。子段长度是 right - left + 1,别漏 +1;固定窗口里「移出左端」的下标是 right - plen,不是 left(left 此时还没动)。
  • 坑六:初始化值踩到下标 0。记录「上次出现位置」的数组初始必须 -1(见 3.4),否则下标 0 被误判为已出现。

3.8自测题

Q1. 为什么滑动窗口能把「枚举所有子段」的 O(n²) 降到 O(n)?关键假设是什么?

因为滑窗让 left、right都只朝一个方向前进,每个元素最多进窗口、出窗口各一次,总步数 O(n); 而子段信息(和、频次)靠「进 +x / 出 -x」实时维护,不再每次重算。

关键假设:窗口扩大/缩小时状态的变化可递推且单调(如正数数组,加必增、减必减)。没有这个单调性(如含负数),普通滑窗会失效。

Q2. 求「最短子数组」和求「最长无重复子串」,收缩的触发条件和更新答案的位置分别是什么?

最短(如 209):while (窗口满足条件) 内收缩,答案在 while 内部更新(每缩一次都可能是更短解)。

最长(如 3):while (窗口不合法) 内收缩,答案在 while 外部、窗口合法时更新(合法窗口本身就是候选最长)。

两者都是 left 右移,区别在「缩的触发条件」和「记答案的时机」,这是 3.2 节变体表的全部内容。

Q3. 无重复字符最长子串里,last 数组为什么初始化成 -1 而不是 0?

下标 0 是合法的窗口位置。若初始为 0,处理第一个字符时 last[c] >= left(=0) 会成立,被误判成「c 已经在窗口里」, 于是 left 错误地跳到 1,把本该算进去的长度弄丢。

用 -1 表示「从未出现」,和「在下标 0 出现过」严格区分,判断 last[c] >= left 才正确。

Q4. 最小覆盖子串里,valid 在 left 收缩时为什么要「先判断 win[d]==need[d] 再 win[d]--」,顺序反过来会怎样?

顺序反了(先 win[d]-- 再判断)会出错:减完之后 win[d] 已经比 need[d] 小,永远触发不了 valid--, 导致 valid 虚高——明明窗口已不再覆盖 t,程序却以为还覆盖,从而把不合法的短窗口当成答案、或漏掉该继续收缩的时机。

正确顺序:先确认「当前这脚踢出去会让 d 不达标」→ valid-- → 再真正 win[d]--。

Q5.【动手题】不参考代码,写出「长度最小的子数组」完整 Java 方法,并手推 nums=[2,3,1,2,4,3], target=7: 给出每步 right/left/sum 和记录的 ans。

方法见 3.3 节。手推要点:

right=0 加2 sum=2;right=1 加3 sum=5;right=2 加1 sum=6;right=3 加2 sum=8≥7 → ans=min(∞,4)=4,sum-=nums[0]=2→6,left=1; sum=6<7 停。right=4 加4 sum=10≥7 → ans=min(4,4)=4,sum-=nums[1]=3→7,left=2;仍≥7 → ans=min(4,3)=3,sum-=nums[2]=1→6,left=3 停。 right=5 加3 sum=9≥7 → ans=min(3,3)=3,sum-=nums[3]=2→7,left=4;≥7 → ans=min(3,2)=2,sum-=nums[4]=4→3,left=5 停。结束 ans=2。 验证最优子段 [4,3] 和=7 长度 2 ✓。

卡住自查:收缩循环里是「先减 left 元素再 left++」成对;ans 初始 MAX、最后判等返回 0。

学完这章,你应该能回答:
  • 滑窗为什么是 O(n)?它依赖的单调性前提是什么(为什么含负数会失效)?
  • 通用模板里「求最短」和「求最长」两种变体,收缩触发条件和记答案位置有何不同?
  • 209 / 3 / 438 / 76 四题分别对应哪种变体(最短 / 最长 / 固定 / 最短完整形态)?
  • 计数类滑窗,「进窗口 +1、出窗口 -1」为什么必须成对、不能漏?
  • 能脱稿写出本章 4 道题中任意一道的 Java 代码,并通过边界用例。