Java 滑动窗口 - 附 LeetCode 经典题解
一、什么是滑动窗口?
1.1 形象理解
想象你站在一扇窗户前看风景,窗户可以左右移动,也可以变大变小。你通过这扇窗户观察外面的景色,这就是"滑动窗口"。
在算法中,滑动窗口就是:
- 窗口:数组或字符串中的一段连续子序列
- 滑动:窗口的左右边界可以移动
- 目的:在移动过程中找到满足条件的最优解
Java 滑动窗口算法利用双指针动态维护连续子序列区间,分为定长与不定长两类。文章解析了核心思想与通用模板,重点演示 LeetCode 第 3 题无重复字符最长子串的四种 Java 实现方案,涵盖 HashSet、HashMap 及数组优化策略。同时归纳了空指针、数组越界等常见错误及性能优化技巧,适合算法学习与面试准备。
想象你站在一扇窗户前看风景,窗户可以左右移动,也可以变大变小。你通过这扇窗户观察外面的景色,这就是"滑动窗口"。
在算法中,滑动窗口就是:
滑动窗口其实就是双指针的一种应用:
[left, right) 之间的元素(左闭右开区间)字符串:a b c a b c b b ↑ ↑ left right 窗口内容:[a, b, c](不包含 right 指向的元素)
Java 特别注意: 在 Java 中,字符串索引从 0 开始,s.charAt(i) 获取索引 i 的字符。
核心: 通过移动左右指针,动态调整窗口大小,在 O(n) 时间内找到最优解。
关键点:
滑动窗口分为两大类:
特点: 窗口大小固定为 k,只需要滑动,不需要收缩。
适用场景:
模板:
public int fixedWindow(int[] nums, int k) {
int result = 0;
int sum = 0;
for (int right = 0; right < nums.length; right++) {
// 1. 扩大窗口:加入右边界元素
sum += nums[right];
// 2. 当窗口大小达到 k 时
if (right >= k - 1) {
// 更新结果
result = Math.max(result, sum);
// 缩小窗口:移除左边界元素
sum -= nums[right - k + 1];
}
}
return result;
}
图解:
数组:[1, 3, -1, -3, 5, 3, 6, 7],k = 3
步骤 1:窗口 [1, 3, -1],sum = 3
步骤 2:窗口 [3, -1, -3],sum = -1
步骤 3:窗口 [-1, -3, 5],sum = 1
步骤 4:窗口 [-3, 5, 3],sum = 5
... 窗口大小始终为 3,只需要滑动
特点: 窗口大小动态变化,需要根据条件收缩窗口。
适用场景:
模板:
public int variableWindow(String s) {
int left = 0, right = 0;
int result = 0;
Map<Character, Integer> window = new HashMap<>();
while (right < s.length()) {
// 1. 扩大窗口:加入右边界元素
char c = s.charAt(right);
right++;
window.put(c, window.getOrDefault(c, 0) + 1);
// 2. 收缩窗口:当窗口不满足条件时
while (/* 窗口不满足条件 */) {
char d = s.charAt(left);
left++;
window.put(d, window.get(d) - 1);
}
// 3. 更新结果
result = Math.max(result, right - left);
}
return result;
}
图解:
字符串:a b c a b c b b
步骤 1:窗口 [a],无重复,长度 = 1
步骤 2:窗口 [a, b],无重复,长度 = 2
步骤 3:窗口 [a, b, c],无重复,长度 = 3
步骤 4:窗口 [a, b, c, a],有重复!收缩窗口
步骤 5:窗口 [b, c, a],无重复,长度 = 3
... 窗口大小动态变化,需要收缩
| 特性 | 定长窗口 | 不定长窗口 |
|---|---|---|
| 窗口大小 | 固定为 k | 动态变化 |
| 是否收缩 | 不需要(只滑动) | 需要(根据条件) |
| 右指针 | 不断右移 | 不断右移 |
| 左指针 | 固定步长移动 | 根据条件移动 |
| 典型题目 | 大小为 k 的子数组最大和 | 最长无重复字符子串 |
| 难度 | 简单 | 中等/困难 |
记忆技巧:
给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。
示例 1:
输入:s = "abcabcbb"
输出:3
解释:因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入:s = "bbbbb"
输出:1
解释:因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入:s = "pwwkew"
输出:3
解释:因为无重复字符的最长子串是 "wke",所以其长度为 3。
Java 特殊输入场景:
输入:s = "\t\n\r" // 包含制表符、换行符、回车符
输出:3
输入:s = "你好世界" // 包含中文(Unicode 字符)
输出:4
这是一道典型的不定长滑动窗口问题。
核心思想:
为什么是不定长窗口?
import java.util.*;
public class Solution {
public int lengthOfLongestSubstring(String s) {
// 1. Java 中必须先判断 null(LeetCode 可能传入 null)
if (s == null || s.length() == 0) {
return 0;
}
// 2. 初始化窗口边界
int left = 0, right = 0;
// 3. 初始化结果和窗口状态
int maxLen = 0;
// Java 中 HashSet 用于快速判断字符是否存在(O(1) 时间复杂度)
Set<Character> window = new HashSet<>();
// 4. 右指针不断向右扩大窗口(不定长窗口的特点)
while (right < s.length()) {
// 4.1 获取右边界的字符
char c = s.charAt(right);
// 4.2 如果窗口内有重复字符,收缩窗口(不定长窗口的核心)
while (window.contains(c)) {
// 移除左边界的字符
window.remove(s.charAt(left));
left++; // 左指针右移
}
// 4.3 将右边界字符加入窗口
window.add(c);
right++; // 右指针右移
// 4.4 更新最大长度
maxLen = Math.max(maxLen, right - left);
}
return maxLen;
}
}
时间复杂度: O(n),每个字符最多被访问两次(一次加入,一次移除)
空间复杂度: O(min(n, m)),m 是字符集大小
import java.util.*;
public class Solution {
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) {
return 0;
}
int left = 0, right = 0;
int maxLen = 0;
// 用 HashMap 存储字符和它的索引(优化:可以直接跳到重复字符的下一个位置)
Map<Character, Integer> window = new HashMap<>();
while (right < s.length()) {
char c = s.charAt(right);
// 如果字符已存在,且在窗口内
if (window.containsKey(c) && window.get(c) >= left) {
// 直接跳到重复字符的下一个位置(优化点!)
left = window.get(c) + 1;
}
// 更新字符的索引
window.put(c, right);
// 更新最大长度
maxLen = Math.max(maxLen, right - left + 1);
right++;
}
return maxLen;
}
}
优化点: 用 HashMap 存储字符的索引,当遇到重复字符时,可以直接跳到重复字符的下一个位置,不需要一个一个移动左指针。
import java.util.Arrays;
public class Solution {
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) {
return 0;
}
// 用数组替代 HashMap(Java 中 ASCII 字符数组比 HashMap 更高效,空间复杂度 O(1))
int[] loc = new int[128]; // ASCII 字符集大小为 128
// Java 中数组默认值为 0,必须初始化为 -1 避免和索引 0 混淆
Arrays.fill(loc, -1);
int maxLen = 0, start = 0;
for (int end = 0; end < s.length(); end++) {
char c = s.charAt(end);
// 如果字符已存在,且在窗口内
if (loc[c] >= start) {
start = loc[c] + 1;
}
loc[c] = end;
maxLen = Math.max(maxLen, end - start + 1);
}
return maxLen;
}
}
适用场景: 只包含 ASCII 字符(如英文字母、数字、标点符号)
不适用场景: 包含中文、Emoji 等 Unicode 字符
import java.util.*;
public class Solution {
// Java 处理 Unicode 字符的版本(兼容中文/特殊字符)
public int lengthOfLongestSubstringUnicode(String s) {
if (s == null || s.length() == 0) {
return 0;
}
int maxLen = 0, start = 0;
// 必须使用 HashMap,因为 Unicode 字符集太大(65536+)
Map<Character, Integer> loc = new HashMap<>();
for (int end = 0; end < s.length(); end++) {
char c = s.charAt(end);
if (loc.containsKey(c) && loc.get(c) >= start) {
start = loc.get(c) + 1;
}
loc.put(c, end);
maxLen = Math.max(maxLen, end - start + 1);
}
return maxLen;
}
}
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | Java 优化方案 |
|---|---|---|---|---|
| HashSet | O(n) | O(min(n, m)) | 所有字符 | ✅ 通用方案 |
| HashMap | O(n) | O(min(n, m)) | 所有字符 | ✅ Java 优化方案 |
| 数组 | O(n) | O(1) | 仅 ASCII | ✅ 仅适用于 ASCII 字符 |
| HashMap (Unicode) | O(n) | O(min(n, m)) | Unicode 字符 | ✅ 适用于中文/Emoji |
// 错误写法
char[] str = s.toCharArray();
for (int i = 0; i <= str.length; i++) {
// 注意:应该是 < 而不是 <=
char c = str[i]; // 当 i = str.length 时,数组越界!
}
// 正确写法
char[] str = s.toCharArray();
for (int i = 0; i < str.length; i++) {
// Java 数组下标从 0 开始,到 length-1 结束
char c = str[i];
}
// 编译错误:找不到 Arrays 类
Arrays.fill(loc, -1);
// 编译错误:找不到 HashMap 类
Map<Character, Integer> map = new HashMap<>();
// 正确写法:在文件开头导入
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
// 错误写法:未判断 null
public int lengthOfLongestSubstring(String s) {
int len = s.length(); // 如果 s 是 null,抛出 NullPointerException
}
// 正确写法:先判断 null
public int lengthOfLongestSubstring(String s) {
if (s == null || s.length() == 0) {
return 0;
}
}
// 方式一:charAt()(推荐)
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i); // 时间复杂度 O(1)
}
// 方式二:toCharArray()
char[] chars = s.toCharArray(); // 创建新数组,时间复杂度 O(n)
for (int i = 0; i < chars.length; i++) {
char c = chars[i]; // 直接访问数组
}
// 错误:频繁调用 toCharArray()
for (int i = 0; i < s.length(); i++) {
char[] arr = s.toCharArray(); // 每次循环都创建新数组,浪费内存!
char c = arr[i];
}
Map<Character, Integer> map = new HashMap<>();
// 错误:未判断 null
int count = map.get('a'); // 如果 'a' 不存在,返回 null,自动拆箱时抛 NullPointerException
// 正确:使用 getOrDefault()
int count = map.getOrDefault('a', 0); // 如果不存在,返回默认值 0
import java.util.*;
public class VariableWindowTemplate {
// Java 不定长滑动窗口通用模板
public int variableWindow(String s) {
// 1. 边界判断
if (s == null || s.length() == 0) return 0;
// 2. 初始化
int left = 0, right = 0, result = 0;
Map<Character, Integer> window = new HashMap<>();
// 3. 右指针不断向右扩大窗口
while (right < s.length()) {
// 扩大窗口
char c = s.charAt(right++);
window.put(c, window.getOrDefault(c, 0) + 1);
// 收缩窗口(根据题目条件调整)
while (/* 窗口不满足条件 */) {
char d = s.charAt(left++);
window.put(d, window.get(d) - 1);
if (window.get(d) == 0) {
window.remove(d);
}
}
// 更新结果
result = Math.max(result, right - left);
}
return result;
}
}
public class FixedWindowTemplate {
// Java 定长滑动窗口通用模板
public int fixedWindow(int[] nums, int k) {
if (nums == null || nums.length == 0 || k <= 0) {
return 0;
}
int result = 0;
int sum = 0;
for (int right = 0; right < nums.length; right++) {
// 扩大窗口
sum += nums[right];
// 当窗口大小达到 k 时
if (right >= k - 1) {
// 更新结果
result = Math.max(result, sum);
// 缩小窗口
sum -= nums[right - k + 1];
}
}
return result;
}
}
// 方式一:charAt()(简单但稍慢)
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i); // 每次调用方法
}
// 方式二:toCharArray()(更快)
char[] chars = s.toCharArray(); // 只创建一次数组
for (int i = 0; i < chars.length; i++) {
char c = chars[i]; // 直接访问数组
}
// 方式一:HashMap(通用但稍慢)
Map<Character, Integer> map = new HashMap<>();
map.put('a', 1);
// 方式二:int[] 数组(更快)
int[] count = new int[128]; // ASCII 字符集
count['a'] = 1;
// 错误:在循环内创建对象
for (int i = 0; i < n; i++) {
Map<Character, Integer> map = new HashMap<>();
}
// 正确:在循环外创建对象
Map<Character, Integer> map = new HashMap<>();
for (int i = 0; i < n; i++) {
map.clear();
}
定长窗口,固定大小,只需滑动
不定长窗口,动态调整,需要收缩
右指针走,扩大窗口
左指针走,收缩窗口
窗口内,用哈希表
满足条件,更新结果
Java 中,先判 null
| 题号 | 题目 | 难度 | 窗口类型 | 核心技巧 |
|---|---|---|---|---|
| 3 | 无重复字符的最长子串 | 中等 | 不定长 | HashSet/HashMap |
| 76 | 最小覆盖子串 | 困难 | 不定长 | HashMap + 计数 |
| 209 | 长度最小的子数组 | 中等 | 不定长 | 固定条件收缩 |
| 438 | 找到字符串中所有字母异位词 | 中等 | 定长 | HashMap + 固定窗口 |
| 567 | 字符串的排列 | 中等 | 定长 | HashMap + 固定窗口 |
| 239 | 滑动窗口最大值 | 困难 | 定长 | 单调队列 |

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online