第05篇-滑动窗口算法-一套模板解决子串与子数组问题

概述:为什么滑动窗口是子串与子数组问题的通用武器

学完双指针之后,下一类非常值得尽快掌握的技巧,就是滑动窗口

很多初学者第一次接触这类题时,会觉得它和双指针有点像,但又总觉得"写起来很乱"。

原因很简单:滑动窗口并不是单纯维护两个指针,而是要维护一个动态区间

这类题通常长这样:

  • 求最长不重复子串
  • 求最短满足条件的连续子数组
  • 判断某个排列是否出现在另一个字符串中
  • 统计某个区间内是否满足某种字符频率要求

它们的共同点非常明显:

  • 处理对象通常是连续区间
  • 区间会不断向右移动
  • 每次移动时,都要维护窗口内的状态

所以这篇文章的目标,不是让你死记某一道题的代码,而是帮你建立滑动窗口最重要的 3 个意识:

  1. 滑动窗口解决的是连续区间问题
  2. 核心不只是左右指针,而是窗口状态维护
  3. 很多题都能归结为"右指针扩张,左指针收缩"

学完这篇,你应该能看出一道题能不能用滑动窗口,以及知道窗口里到底该维护什么。

核心概念:滑动窗口到底是什么

所谓滑动窗口,你可以把它理解成:

在数组或字符串上维护一个连续区间 [left, right],随着指针移动,不断更新区间信息,从而避免每次都重新统计整段内容。

它之所以叫"窗口",是因为你可以把这个区间想象成一块不断向右滑动的透明玻璃:

  • 窗口右边扩张:把新元素纳入考虑
  • 窗口左边收缩:把旧元素移出考虑

只要窗口移动时,你能高效维护窗口内的信息,就能避免大量重复计算。

滑动窗口和双指针有什么关系

滑动窗口本质上也是双指针的一种应用,但它更强调:

  • 区间必须连续
  • 需要维护窗口内状态
  • 指针移动通常有明确的触发条件

比如:

  • 双指针更像"两个位置怎么配合"
  • 滑动窗口更像"一个连续区间怎么动态更新"

滑动窗口是双指针的进阶用法,重点不在两个指针本身,而在窗口内信息怎么维护。

原理:为什么滑动窗口能避免重复遍历

很多连续区间问题,最容易想到的暴力解法就是:

  1. 枚举每一个起点
  2. 枚举每一个终点
  3. 对每个区间重新统计信息

这种思路通常会导致:

text 复制代码
O(n^2)

甚至更高。

滑动窗口之所以高效,是因为它抓住了一个核心事实:

相邻两个窗口之间,绝大部分内容是重叠的。

例如:

text 复制代码
[1, 2, 3]
[2, 3, 4]

这两个窗口只有一个元素进来、一个元素出去。

如果你每次都重新计算整段内容,就浪费了大量重复工作。

滑动窗口的优化思路就是:

  • 右指针右移时,只处理"新进入窗口"的元素
  • 左指针右移时,只处理"离开窗口"的元素

这样每个元素通常只会被加入窗口一次、移出窗口一次,所以很多题的复杂度都能降到:

text 复制代码
O(n)

先建立框架:滑动窗口最常见的两种模型

并不是所有滑动窗口题都长一样。

初学阶段,你可以先把它分成两类。

1. 固定长度窗口

窗口大小是固定的,比如:

  • 长度为 k 的子数组最大和
  • 所有长度为 k 的子串统计

这类题最典型的特征是:

窗口大小始终不变,只是在向右平移。

2. 可变长度窗口

窗口大小不是固定的,而是根据条件动态扩张和收缩,比如:

  • 最长不重复子串
  • 最短满足和至少为 target 的子数组
  • 包含某些字符条件的最短子串

这类题最典型的动作是:

  • 先用 right 扩张窗口
  • 当窗口满足或不满足某个条件时,再移动 left

固定窗口重在"平移更新",可变窗口重在"满足条件时收缩,不满足时扩张"。

模板一:固定长度滑动窗口

来看一个最基础的问题:

给定数组 nums 和整数 k,求长度为 k 的连续子数组的最大和。

暴力写法

如果每次都重新计算长度为 k 的区间和,写法通常是:

java 复制代码
public static int maxSumBruteForce(int[] nums, int k) {
    int n = nums.length;
    int ans = Integer.MIN_VALUE;

    for (int i = 0; i <= n - k; i++) {
        int sum = 0;
        for (int j = i; j < i + k; j++) {
            sum += nums[j];
        }
        ans = Math.max(ans, sum);
    }

    return ans;
}

时间复杂度通常是:

text 复制代码
O(n * k)

滑动窗口写法

更高效的方法是:

  1. 先算出第一个窗口的和
  2. 每次窗口右移一格时,减去左边出去的数,加上右边进来的数
java 复制代码
public static int maxSumFixedWindow(int[] nums, int k) {
    int n = nums.length;
    int windowSum = 0;

    for (int i = 0; i < k; i++) {
        windowSum += nums[i];
    }

    int ans = windowSum;

    for (int right = k; right < n; right++) {
        windowSum += nums[right];
        windowSum -= nums[right - k];
        ans = Math.max(ans, windowSum);
    }

    return ans;
}

为什么这段代码高效

因为窗口从:

text 复制代码
[i, i + k - 1]

移动到:

text 复制代码
[i + 1, i + k]

时,中间绝大部分元素没有变化。

你只需要:

  • 减去离开窗口的元素
  • 加上新进入窗口的元素

整个过程可以理解为:
#mermaid-svg-7zFAwLXBOZiNeZXW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7zFAwLXBOZiNeZXW .error-icon{fill:#552222;}#mermaid-svg-7zFAwLXBOZiNeZXW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7zFAwLXBOZiNeZXW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7zFAwLXBOZiNeZXW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .marker.cross{stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7zFAwLXBOZiNeZXW p{margin:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label text{fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label span{color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster-label span p{background-color:transparent;}#mermaid-svg-7zFAwLXBOZiNeZXW .label text,#mermaid-svg-7zFAwLXBOZiNeZXW span{fill:#333;color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .node rect,#mermaid-svg-7zFAwLXBOZiNeZXW .node circle,#mermaid-svg-7zFAwLXBOZiNeZXW .node ellipse,#mermaid-svg-7zFAwLXBOZiNeZXW .node polygon,#mermaid-svg-7zFAwLXBOZiNeZXW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .rough-node .label text,#mermaid-svg-7zFAwLXBOZiNeZXW .node .label text,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label,#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label{text-anchor:middle;}#mermaid-svg-7zFAwLXBOZiNeZXW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .rough-node .label,#mermaid-svg-7zFAwLXBOZiNeZXW .node .label,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label,#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label{text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .node.clickable{cursor:pointer;}#mermaid-svg-7zFAwLXBOZiNeZXW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .arrowheadPath{fill:#333333;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-7zFAwLXBOZiNeZXW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster text{fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW .cluster span{color:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-7zFAwLXBOZiNeZXW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7zFAwLXBOZiNeZXW rect.text{fill:none;stroke-width:0;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape p,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-7zFAwLXBOZiNeZXW .icon-shape .label rect,#mermaid-svg-7zFAwLXBOZiNeZXW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-7zFAwLXBOZiNeZXW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7zFAwLXBOZiNeZXW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7zFAwLXBOZiNeZXW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 先统计第一个长度为 k 的窗口
窗口右移一格
减去左边移出的元素
加上右边进入的元素
更新答案

时间复杂度:

text 复制代码
O(n)

空间复杂度:

text 复制代码
O(1)

模板二:可变长度滑动窗口

滑动窗口真正高频的,其实是可变长度模型。

这类题的关键不在于窗口有多大,而在于:

当前窗口是否满足某个条件。

最常见的套路是:

  1. 右指针不断向右扩张
  2. 把新元素加入窗口状态
  3. 当窗口满足或破坏某个条件时,移动左指针调整
  4. 在合适时机更新答案

一个通用模板可以先记成下面这样:

java 复制代码
public static void slidingWindowTemplate(String s) {
    int left = 0;

    for (int right = 0; right < s.length(); right++) {
        char in = s.charAt(right);
        // 1. 处理新进入窗口的元素

        while (窗口需要收缩) {
            char out = s.charAt(left);
            // 2. 处理离开窗口的元素
            left++;
        }

        // 3. 在这里更新答案
    }
}

你可以把它理解成一句话:

right 负责扩张,left 负责纠偏,答案在过程中更新。

经典例题一:最长不重复子串

这是滑动窗口最经典、也最值得反复练熟的一道题。

题目大意:

给定一个字符串 s,求不含重复字符的最长子串长度。

为什么暴力法不理想

如果你枚举所有子串,再逐个检查有没有重复字符,复杂度会比较差。

真正高效的做法是维护一个"当前不重复"的窗口。

窗口思路

当右指针加入一个字符时:

  • 如果这个字符没重复,窗口可以继续扩张
  • 如果重复了,就移动左指针,直到窗口重新变成"不重复"

这里最常见的维护方式是用哈希集合。

java 复制代码
import java.util.HashSet;
import java.util.Set;

public static int lengthOfLongestSubstring(String s) {
    Set<Character> window = new HashSet<>();
    int left = 0;
    int ans = 0;

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

        while (window.contains(ch)) {
            window.remove(s.charAt(left));
            left++;
        }

        window.add(ch);
        ans = Math.max(ans, right - left + 1);
    }

    return ans;
}

这段代码的核心逻辑

比如字符串:

text 复制代码
abcabcbb

前面窗口扩张到 abc 时没有问题;

当新的 a 进来时,窗口里已经有 a 了,这时就必须不断移动左边,把旧的 a 移出去,直到窗口重新合法。

这个过程说明一件事:

可变窗口的关键不是"每次窗口扩大多少",而是"什么时候必须收缩"。

时间复杂度:

text 复制代码
O(n)

空间复杂度:

text 复制代码
O(k)

这里的 k 表示窗口中不同字符的数量,最坏可看作 O(n)

经典例题二:长度最小的连续子数组

再来看另一道非常典型的题:

给定一个正整数数组 nums 和正整数 target,找出和大于等于 target 的最短连续子数组长度。

这题为什么适合滑动窗口

因为题目要找的是:

  • 连续子数组
  • 而且要求的是"满足条件的最短长度"

这种题的标准动作通常就是:

  • 右指针不断扩张,让窗口和变大
  • 一旦窗口和已经满足要求,就尽量移动左指针,把窗口收缩到最短
java 复制代码
public static int minSubArrayLen(int target, int[] nums) {
    int left = 0;
    int 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;
}

为什么这里要用 while

很多初学者这里容易写成 if,这是不对的。

原因是:

  • 当窗口已经满足 sum >= target
  • 左边可能还可以继续缩
  • 你必须一直缩到"不再满足"为止

只有这样,才能保证当前右端点下的最短合法窗口被找到。

求"最短满足条件的连续区间",通常就是"满足条件后疯狂收缩左边"。

经典例题三:字符串排列是否出现

滑动窗口在字符串题里还有一类非常经典的用法:

判断某个短字符串的排列,是否出现在另一个长字符串中。

例如:

  • s1 = "ab"
  • s2 = "eidbaooo"

因为 "ba""ab" 的排列,所以答案为 true

这题的关键观察

如果 s1 长度是 m,那么只需要看 s2 中所有长度为 m 的子串。

这本质上就是一个固定长度窗口

只不过窗口里维护的不再是"和",而是"字符频率"。

java 复制代码
public static boolean checkInclusion(String s1, String s2) {
    if (s1.length() > s2.length()) {
        return false;
    }

    int[] need = new int[26];
    int[] window = new int[26];

    for (int i = 0; i < s1.length(); i++) {
        need[s1.charAt(i) - 'a']++;
        window[s2.charAt(i) - 'a']++;
    }

    if (matches(need, window)) {
        return true;
    }

    for (int right = s1.length(); right < s2.length(); right++) {
        window[s2.charAt(right) - 'a']++;
        window[s2.charAt(right - s1.length()) - 'a']--;

        if (matches(need, window)) {
            return true;
        }
    }

    return false;
}

private static boolean matches(int[] need, int[] window) {
    for (int i = 0; i < 26; i++) {
        if (need[i] != window[i]) {
            return false;
        }
    }
    return true;
}

这道题很重要,因为它会让你意识到:

滑动窗口维护的状态,不一定是和,也可能是计数、频率、去重状态或某种合法性条件。

滑动窗口到底在维护什么

很多人写滑动窗口时觉得乱,根源通常不是不会移动指针,而是没想清楚窗口里应该维护什么信息。

常见的窗口状态包括:

题型 窗口里维护的信息
定长子数组求和 当前窗口和
最长不重复子串 当前窗口字符集合或频次
最短满足和的子数组 当前窗口和
排列匹配问题 当前窗口字符频率
最小覆盖子串 当前窗口是否满足目标计数

所以做题时一定先问自己:

  1. 这题的窗口是否合法,靠什么判断?
  2. 当元素进入窗口时,哪些信息要更新?
  3. 当元素离开窗口时,哪些信息要回退?
  4. 状态是在扩张后更新,还是在收缩时更新?

滑动窗口真正难的地方,不是指针,而是状态设计。

易错点:新手写滑动窗口最容易踩的坑

1. 题目不是连续区间,却硬套滑动窗口

滑动窗口最适合的是:

  • 子串
  • 子数组
  • 连续区间

如果题目不要求连续,通常就不是这套模型。

2. 没想清楚窗口合法条件

有的题是:

  • 窗口内不能重复

有的题是:

  • 窗口和必须大于等于某个值

有的题是:

  • 窗口字符频率必须匹配目标

如果合法条件没先想明白,后面代码一定会乱。

3. 该用 while 收缩时写成了 if

这是最常见的错误之一。

只收缩一次,往往不能把窗口调回正确状态。

4. 加入窗口和移出窗口的更新不对称

例如:

  • 加入时做了 sum += nums[right]
  • 但移出时忘了做 sum -= nums[left]

或者字符频率加了却没减,都会直接导致结果错误。

5. 更新状态的位置放错了

有些题应该在窗口合法后更新状态;

有些题应该在收缩过程中更新状态。

这个位置放错,即使整体框架对了,结果也可能不对。

6. 忘记处理空串、短串或 k 越界

比如固定窗口题里,如果:

text 复制代码
k > nums.length

那就必须先特殊处理。

7. 把滑动窗口当成死模板硬背

模板当然重要,但模板只是骨架。

真正决定题目能不能做对的,是你是否知道窗口里该维护哪些状态。

复杂度总结:为什么滑动窗口经常是线性的

滑动窗口之所以常见,是因为它在很多题里都能把暴力枚举压到线性复杂度。

核心原因通常是:

  • right 只会从左到右走一遍
  • left 也只会从左到右走一遍

也就是说,两个指针虽然都在动,但总移动次数通常不会超过 2n 级别,所以整体仍然是:

text 复制代码
O(n)

下面这张表可以帮助你快速建立直觉:

题型 暴力复杂度 滑动窗口复杂度
长度为 k 的最大子数组和 O(n * k) O(n)
最长不重复子串 O(n^2) 甚至更高 O(n)
最短满足和的连续子数组 O(n^2) O(n)

当然,前提是你维护窗口状态的更新本身不能太慢。

如果每次更新都重新扫描整个窗口,那就失去了滑动窗口的意义。

总结

滑动窗口的本质,是维护一个会移动的连续区间。

滑动窗口不是在机械移动两个指针,而是在动态维护一个连续区间的有效信息。

当你真正掌握这一点后,很多子串和子数组题都会开始出现明显的"模板感"。

相关推荐
码云骑士2 小时前
【3.1Java基础】Java运算符常见错误排查:10个高频编译运行错误一网打尽
java·开发语言
小程故事多_802 小时前
RAGFlow 分块策略全景与 Book 策略深度解析
java·开发语言·rag
吴声子夜歌2 小时前
JVM——线程池实现原理
java·jvm·线程池
雾沉川2 小时前
IntelliJ IDEA 2025.2 安装与基础配置技术教程
java·ide·intellij-idea
辰海Coding2 小时前
MiniSpring框架学习笔记-JDBC 访问框架:如何抽取 JDBC 模板并隔离数据库?
java·数据库·笔记·学习·spring
叫我:松哥2 小时前
基于LSTM与ARIMA的城市空气质量分析与预测系统
人工智能·python·rnn·算法·机器学习·flask·lstm
j7~2 小时前
【C++】模板初阶--函数模板,类模板详解
数据结构·c++·算法·函数模板·类模板·函数模板实例化
CodeStats2 小时前
从 CPU 指令执行到权限管控:对比三大操作系统,梳理编程语言演进,解读 HTML/CSS/JS 浏览器解析的共通底层逻辑
java·linux·windows
闪电悠米2 小时前
黑马点评-Redis 消息队列-01_why_redis_mq
java·数据库·spring boot·redis·缓存·junit·消息队列