文章背景图

LeetCode热题100(简单/中等)—— 滑动窗口 + 子串

2026-02-03
0
-
- 分钟

1 无重复字符的最长子串

难度:中等

问题描述

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

 

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。注意 "bca" 和 "cab" 也是正确答案。

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

 

提示:

  • 0 <= s.length <= 5 * 104

  • s 由英文字母、数字、符号和空格组成

思路 - 初见

  • 要无重复,首先想到的是哈希

  • 但是题目只要求返回最长的子串长度,想到是否可以找到相同字符的位置,其中间夹着的就是字串的长度,比较最长就可以

  • 问题是怎么区分当前相同字母中间没有其他相同字母

  • 新思路:双指针, ab从0开始,b指针往前走,找到一个字符就记录,当记录到相同字母时,记录字串长度,然后a指针移动到没有重复字母为止。

  • 怎么表示字母有没有重复,a指针移动时怎么删除字母。

  • 十分钟思考无果,参考答案

🔍 深度解析你的困惑

1. 怎么表示字母有没有重复?

使用 HashSet<Character>contains() 方法。它的查找速度是 O(1),非常快。

2. a 指针移动时怎么删除字母?

这正是滑动窗口最巧妙的地方:你不需要一步到位找到重复字母的位置

  • 只要 set.contains(s.charAt(b)) 为真,你就通过 set.remove(s.charAt(a)) 删掉最左边的,然后 a++

  • 即使删掉的不是重复的那个,随着 a 的右移,窗口迟早会把那个重复的字符剔除出去。

3. 为什么这个算法很快?

虽然里面有一个 while 循环,但每个字符最多被 b 指针 添加一次,被 a 指针 删除一次

  • 时间复杂度:O(n)

  • 空间复杂度:O(min(m, n)),取决于字符集的大小。

代码 - 初见

class Solution {
    public int lengthOfLongestSubstring(String s) {
        // 哈希集合,用来记录当前窗口内的字符
        Set<Character> set = new HashSet<>();
        
        int n = s.length();
        int a = 0; // 左指针
        int b = 0; // 右指针
        int maxLength = 0;

        while (b < n) {
            char currentChar = s.charAt(b);
            
            // 如果记事本里已经有了这个字符,说明重复了!
            while (set.contains(currentChar)) {
                // a 指针开始移动,把左边的字符从记事本里删掉
                set.remove(s.charAt(a));
                // a 向右挪,直到把那个重复的字符“挤”出去
                a++;
            }
            
            // 现在窗口里肯定没有 currentChar 了,把它加进去
            set.add(currentChar);
            
            // 更新最大长度:窗口大小就是 b - a + 1
            maxLength = Math.max(maxLength, b - a + 1);
            
            // b 继续前进
            b++;
        }
        
        return maxLength;
    }
}

问题与改进

你之前想过“找到相同字符的位置”,其实这是更高级的写法:不使用 Set,而是使用 Map 记录字符的下标。

如果 s.charAt(b) 重复了,a 指针不需要一个一个挪,而是直接跳到上次出现该字符位置的下一个。

代码 - 改进

class Solution {
    public int lengthOfLongestSubstring(String s) {
        int n = s.length();
        int maxLength = 0;
        // Key: 字符, Value: 字符最后一次出现的下标
        Map<Character, Integer> map = new HashMap<>();

        // a 是左指针,b 是右指针
        for (int a = 0, b = 0; b < n; b++) {
            char currentChar = s.charAt(b);

            // 如果发现重复字符
            if (map.containsKey(currentChar)) {
                /* * 关键点:a 直接跳到重复字符上次出现位置的下一个。
                 * 为什么用 Math.max?
                 * 因为 map 里的记录可能在当前窗口 a 的左边(即没被 remove 掉的僵尸记录),
                 * 我们绝对不能让 a 往回跳!
                 */
                a = Math.max(a, map.get(currentChar) + 1);
            }

            // 更新或记录当前字符的最新的位置
            map.put(currentChar, b);
            
            // 计算长度并更新最大值
            maxLength = Math.max(maxLength, b - a + 1);
        }
        return maxLength;
    }
}

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

难度:中等

问题描述

给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。

 

示例 1:

输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。

 示例 2:

输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。

 

提示:

  • 1 <= s.length, p.length <= 3 * 104

  • s 和 p 仅包含小写字母

思路 - 初见

  • 还是用哈希 map,先将字串存到哈希中,值为0,并且记录长度n

  • 然后同样双指针ab,b指针找到哈希中存在的字母时,记录到map,值为1,a指针同时移动到该字母开始定位

  • 然后b指针前移,当字母不在map中时,map中记录置为0

  • 不对,参考上题思路,只需要记录下标,看下标是否小于a指针即可,假如b找到的新字母下标在ab之间,说明有重复,那么a移动到上一个该字母坐标的下一个位置

  • 找到n个时,最后看下标是否都在ab之间

你的思路非常有灵性!你试图将“无重复子串”的下标跳跃逻辑迁移到“异位词”问题上。

不过,异位词问题(LeetCode 438. Find All Anagrams in a String)与无重复子串有一个本质区别:异位词允许重复字符(只要频率一致),且长度必须固定。

如果单纯用“记录下标”的跳跃逻辑,处理 s = "baa", p = "aa" 这种有重复字符的情况会变得非常复杂。

📄 核心策略:定长滑动窗口 + 频率数组

处理“字母异位词”最稳妥、最高效的思路是:维护一个大小固定为 p.length() 的窗口,并比较窗口内字符的“频率”是否与 p 完全一致。

因为题目只包含小写字母,我们可以用 int[26] 数组代替 HashMap,性能更强。

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> result = new ArrayList<>();
        int sLen = s.length(), pLen = p.length();
        if (sLen < pLen) return result;

        // 1. 记录目标字符串 p 的字符频率
        int[] pCount = new int[26];
        int[] sCount = new int[26];
        for (int i = 0; i < pLen; i++) {
            pCount[p.charAt(i) - 'a']++;
        }

        // 2. 双指针移动
        int left = 0;
        for (int right = 0; right < sLen; right++) {
            // 将右边界字符加入当前窗口频率
            sCount[s.charAt(right) - 'a']++;

            // 3. 当窗口长度大于 pLen 时,左边界右移,并从频率中减去
            if (right - left + 1 > pLen) {
                sCount[s.charAt(left) - 'a']--;
                left++;
            }

            // 4. 检查两个频率数组是否完全相同
            // 因为窗口长度固定为 pLen,只要频率一致,就一定是异位词
            if (Arrays.equals(sCount, pCount)) {
                result.add(left);
            }
        }
        return result;
    }
}

问题与改进

如果你追求极致性能,可以引入一个 differ 变量来记录窗口与目标之间“不匹配的字符总数”。这样每次移动指针只需要 $O(1)$ 更新 differ,而不需要 $O(26)$ 去比较数组。

代码 - 改进

import java.util.*;

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        List<Integer> result = new ArrayList<>();
        int sLen = s.length(), pLen = p.length();
        if (sLen < pLen) return result;

        // count 数组记录 s 窗口与 p 之间字符频次的“差值”
        // count[i] > 0 表示 s 窗口里比 p 多
        // count[i] < 0 表示 s 窗口里比 p 少(即还欠 p 多少个)
        int[] count = new int[26];
        for (int i = 0; i < pLen; i++) {
            count[p.charAt(i) - 'a']--; // 初始化:p 中的字符都算作“欠下的”
            count[s.charAt(i) - 'a']++; // 初始化:把 s 开头的窗口填入
        }

        // 统计初始状态下,有多少个字母的频次是不匹配的
        int differ = 0;
        for (int c : count) {
            if (c != 0) differ++;
        }

        // 检查第一个窗口是否匹配
        if (differ == 0) result.add(0);

        // 滑动窗口开始
        for (int i = 0; i < sLen - pLen; i++) {
            // 1. 窗口左边界移除 s[i]
            int leftChar = s.charAt(i) - 'a';
            if (count[leftChar] == 1) { // 原本多一个,现在减去变平衡了
                differ--;
            } else if (count[leftChar] == 0) { // 原本平衡,现在减去变不平衡了
                differ++;
            }
            count[leftChar]--;

            // 2. 窗口右边界加入 s[i + pLen]
            int rightChar = s.charAt(i + pLen) - 'a';
            if (count[rightChar] == -1) { // 原本缺一个,现在补上变平衡了
                differ--;
            } else if (count[rightChar] == 0) { // 原本平衡,现在加入变不平衡了
                differ++;
            }
            count[rightChar]++;

            // 3. 如果差异值为 0,说明找到了一个异位词
            if (differ == 0) {
                result.add(i + 1);
            }
        }

        return result;
    }
}

3 和为 K 的子数组

难度:中等

题目描述

给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 

子数组是数组中元素的连续非空序列。

 

示例 1:

输入:nums = [1,1,1], k = 2
输出:2

示例 2:

输入:nums = [1,2,3], k = 3
输出:2

 

提示:

  • 1 <= nums.length <= 2 * 104

  • -1000 <= nums[i] <= 1000

  • -107 <= k <= 107

思路 - 初见

  • 子数组问题,那么可以考虑窗口

  • 窗口内和大于k时,缩减窗口,小于k时,扩大窗口,直至找出满足的子数组

  • 模拟:从左侧开始,窗口内和小于k时,b指针右扩,若满足,子数组个数+1,若大于k,a指针右移,缩减窗口内和,小于k时,b指针继续右扩

  • 目前思路貌似没有问题,写出代码尝试

代码 - 初见

class Solution {
    public int subarraySum(int[] nums, int k) {
        int count = 0;
        int sum = 0;
        int a = 0; // 左指针

        // b 为右指针,不断扩大窗口
        for (int b = 0; b < nums.length; b++) {
            sum += nums[b]; // 扩大窗口,累加和

            // 当窗口内和大于 k 时,尝试缩减窗口(左指针右移)
            while (sum > k && a < b) {
                sum -= nums[a];
                a++;
            }

            // 检查当前窗口和是否满足条件
            if (sum == k) {
                count++;
            }
        }

        return count;
    }
}

问题与改进

  • 没有考虑到负数问题,在窗口扩大过程中,如果遇到负数,会导致总和变小

    • 改进思路:增加对于负数的解决方案,在之前思路基础上貌似很难进行简单的改进,重新考虑该题解法

📝 极简笔记:前缀和 + HashMap

1. 核心公式

  • 原理:任意子数组和 = 当前前缀和 - 历史前缀和

  • 推导:我们要找 Sum = k,即 当前 - 历史 = k

  • 查找目标历史 = 当前 - k

2. 关键动作

走到当前位置 i,累加得到 preSum,然后问自己两个问题:

  1. “回头看”:哈希表里有过 preSum - k 吗?(如果有,答案就 + 次数)

  2. “记下来”:把当前的 preSum 存进哈希表,供未来查询。

3. 数据结构

  • HashMap: <前缀和, 出现次数>

  • 初始化: map.put(0, 1) —— 必背点(处理从下标 0 开始的子数组)。

代码 - 改进

class Solution {
    public int subarraySum(int[] nums, int k) {
        // Key: 前缀和, Value: 该前缀和出现的次数
        Map<Integer, Integer> map = new HashMap<>();
        
        // 【关键点】初始化:前缀和为 0 出现了一次。
        // 这一步是为了处理那些“从数组起始位置开始”就满足条件的子数组
        // 比如 nums=[3], k=3。当前 pre=3, target=0。如果 map 里没记 0,就会漏掉。
        map.put(0, 1);
        
        int count = 0;
        int preSum = 0;
        
        for (int num : nums) {
            preSum += num; // 1. 计算当前位置的前缀和
            
            // 2. 核心公式:寻找是否存在历史前缀和 = preSum - k
            if (map.containsKey(preSum - k)) {
                count += map.get(preSum - k);
            }
            
            // 3. 将当前的前缀和记录到 map 中,或者更新出现次数
            map.put(preSum, map.getOrDefault(preSum, 0) + 1);
        }
        
        return count;
    }
}

评论交流

文章目录