子串与子数组模块 — LeetCode 热门 100 题精讲(Java 向)

从和为 K 的子数组到滑动窗口最大值再到最小覆盖子串,前缀和哈希、单调队列、双指针滑动窗口三种范式一次贯通

子串与子数组问题概述

子串(字符串连续片段)与子数组(数组连续片段)在算法题中占据半壁江山。解决此类问题的优化思路通常围绕"消除重复计算"展开,常见工具包括:

  • 前缀和 + 哈希表:将区间和转化为两前缀和之差,O(1) 查询目标值,典型题如和为 K 的子数组。
  • 单调队列:维护窗口内的极值,在滑动过程中 O(1) 获取最大值/最小值,典型题如滑动窗口最大值。
  • 双指针滑动窗口:动态伸缩窗口,配合状态变量(频次计数)在 O(n) 内求解最短/最长覆盖子串,典型题如最小覆盖子串。

LeetCode 热门 100 题中,有三道题分别代表了上述三种范式的经典应用:560. 和为 K 的子数组(前缀和+哈希)、239. 滑动窗口最大值(单调队列)、76. 最小覆盖子串(滑动窗口+频次计数)。下面逐一拆解。


1. 和为 K 的子数组(LeetCode 560)

题目:给定整数数组 nums 和整数 k,统计连续子数组之和等于 k 的个数。

暴力法:枚举所有子数组 (i, j),累加求和并计数,时间复杂度 O(n²)。

优化思路(前缀和 + 哈希表): 设前缀和 pre[i] = nums[0] + ... + nums[i],则子数组 (j, i] 的和 = pre[i] - pre[j-1]。要使其等于 k,等价于 pre[j-1] = pre[i] - k。因此,在遍历 i 时,只需查找之前出现过多少个前缀和等于 pre[i] - k,累加计数。用哈希表存储前缀和出现次数,初始放入 {0: 1} 代表空前缀。

public int subarraySum(int[] nums, int k) {
    Map<Integer, Integer> prefixCount = new HashMap<>();
    prefixCount.put(0, 1);
    int sum = 0, count = 0;
    for (int num : nums) {
        sum += num;
        count += prefixCount.getOrDefault(sum - k, 0);
        prefixCount.put(sum, prefixCount.getOrDefault(sum, 0) + 1);
    }
    return count;
}

复杂度:时间 O(n),空间 O(n)。

关键点:先查 target 再更新当前前缀和,避免 k=0 时将自身计入;哈希表存储频次而非布尔值,因为可能存在多个相同前缀和。


2. 滑动窗口最大值(LeetCode 239)

题目:给定数组 nums 和窗口大小 k,从左向右滑动窗口,返回每个窗口中的最大值。

暴力法:每个窗口扫描 k 个元素求最大值,总时间复杂度 O(n·k)。

优化思路(单调队列): 维护一个双端队列(Deque),其中存储数组下标,且队列中下标对应的元素值 严格单调递减。这样队首始终是当前窗口最大值的下标。窗口滑动时:

  • 移除队首出界的下标(index < i-k+1)。
  • 从队尾移除所有小于当前元素值的下标(因为它们不可能成为后续窗口的最大值)。
  • 将当前下标加入队尾。
  • 若窗口已形成(i >= k-1),队首即为当前窗口最大值。
public int[] maxSlidingWindow(int[] nums, int k) {
    if (nums.length == 0 || k == 0) return new int[0];
    int n = nums.length;
    int[] result = new int[n - k + 1];
    Deque<Integer> deque = new ArrayDeque<>();
    for (int i = 0; i < n; i++) {
        while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
            deque.pollFirst();
        }
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
            deque.pollLast();
        }
        deque.offerLast(i);
        if (i >= k - 1) {
            result[i - k + 1] = nums[deque.peekFirst()];
        }
    }
    return result;
}

复杂度:每个元素入队出队各一次,时间 O(n),空间 O(k)。

关键点:队列存储下标便于判断是否出界;单调性确保队首始终最大;新元素从队尾踢掉所有比自己小的元素。


3. 最小覆盖子串(LeetCode 76)

题目:给定字符串 st,在 s 中找到包含 t 中所有字符(含重复次数)的最短子串,若不存在返回空串。

优化思路(滑动窗口 + 频次计数器)

  • need 哈希表记录 t 中每个字符的需求量,用 window 记录当前窗口内各字符出现次数。
  • formed 记录当前窗口内满足需求(频次 >= need)的 字符种类数,当 formed == need.size() 时窗口已经覆盖所有字符。
  • 右指针 right 不断右移,加入新字符并更新 windowformed
  • formed == required 时,尝试收缩左指针 left,在收缩过程中不断更新最小长度和起始位置,同时更新 windowformed,直到不再满足条件。
public String minWindow(String s, String t) {
    Map<Character, Integer> need = new HashMap<>();
    Map<Character, Integer> window = new HashMap<>();
    for (char c : t.toCharArray()) {
        need.put(c, need.getOrDefault(c, 0) + 1);
    }
    int left = 0, right = 0;
    int formed = 0, required = need.size();
    int minLen = Integer.MAX_VALUE, start = 0;

    while (right < s.length()) {
        char c = s.charAt(right);
        window.put(c, window.getOrDefault(c, 0) + 1);
        if (need.containsKey(c) && window.get(c).intValue() == need.get(c).intValue()) {
            formed++;
        }
        while (left <= right && formed == required) {
            c = s.charAt(left);
            if (right - left + 1 < minLen) {
                minLen = right - left + 1;
                start = left;
            }
            window.put(c, window.get(c) - 1);
            if (need.containsKey(c) && window.get(c).intValue() < need.get(c).intValue()) {
                formed--;
            }
            left++;
        }
        right++;
    }
    return minLen == Integer.MAX_VALUE ? "" : s.substring(start, start + minLen);
}

复杂度:时间 O(n + m),空间 O(k)(k 为字符集大小)。

关键点formed 记录的是种类数(去重),只有当某个字符的窗口频次 恰好等于 需求频次时才加一,减一同样在恰好小于时发生;收缩时要先更新答案再移出左字符。


三题对比与启发

题目 核心技巧 数据结构 时间复杂度 空间复杂度 典型特征
和为 K 的子数组 前缀和 + 哈希表 HashMap O(n) O(n) 统计区间和等于目标值的数量
滑动窗口最大值 单调队列 Deque O(n) O(k) 维护固定窗口内最值
最小覆盖子串 双指针滑动窗口 + 频次计数 HashMap + 双指针 O(n) O(k) 求最短覆盖子串

共同本质:三者都利用了某种 “增量维护” 的思想,在遍历过程中实时更新状态,避免重复计算。前缀和哈希将区间和查询变为差值查询;单调队列在 O(1) 内获取窗口最值;滑动窗口通过双指针和频次计数器动态伸缩。

不同侧重

  • 560:依赖累积和的线性关系,配合哈希表快速查找目标值。
  • 239:借助单调性剔除不可能成为答案的元素,保持队列有序。
  • 76:利用双指针伸缩窗口,并在满足条件时收缩以寻找最优解。

总结:子串与子数组模块的核心心法

子串/子数组问题的优化核心在于"复用历史信息"。无论是前缀和的累加结果、单调队列维护的极值,还是滑动窗口内的频次状态,都避免了每次重新扫描整个区间。

解题时的思考路径:

  1. 若问题涉及 区间求和计数,优先考虑前缀和 + 哈希表。
  2. 若问题涉及 固定窗口的最值,使用单调队列。
  3. 若问题涉及 动态窗口的最优覆盖或约束,使用双指针滑动窗口,配合状态变量。
  4. 若上述均不适用,再考虑其他复杂方法(如 DP、分治等)。

掌握这三道题,你就掌握了数组/字符串子区间问题中最常用的三种优化利器。



🤖