LeetCode 热题 100 : 子串

57 阅读4分钟

滑动窗口是处理连续子数组/子串问题的利器,但并非所有“子数组”问题都适合用双指针滑动。有些问题(如固定和、最值、覆盖)需要结合前缀和单调队列哈希频次匹配等技巧。


1. 和为 K 的子数组

题目描述

给定一个整数数组 nums 和一个整数 k,请你找出该数组中和为 k 的连续子数组的个数

解题思路

❗注意:本题不能用滑动窗口!因为数组中可能包含负数,窗口扩大/缩小无法保证单调性(即 sum 不一定随 right 增大而增大)。

核心技巧:前缀和 + 哈希表

  • 定义前缀和 sum = nums[0] + ... + nums[i]
  • 若存在某个 j < i,使得 sum_i - sum_j = k,则子数组 [j+1, i] 的和为 k
  • 等价于:查找是否存在前缀和为 sum - k
  • 用哈希表记录每个前缀和出现的次数,边遍历边统计

💡 关键洞察:将“子数组和”问题转化为“两个前缀和之差”,用哈希表实现 O(1) 查找。

代码实现

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var subarraySum = function(nums, k) {
    const map = new Map();
    map.set(0, 1); // 初始化:前缀和为0出现1次(空数组)
    
    let sum = 0;
    let count = 0;
    
    for (let num of nums) {
        sum += num;
        // 查找是否存在前缀和为 (sum - k)
        if (map.has(sum - k)) {
            count += map.get(sum - k);
        }
        // 更新当前前缀和的出现次数
        map.set(sum, (map.get(sum) || 0) + 1);
    }
    
    return count;
};

复杂度分析

  • 时间复杂度:O(n),一次遍历
  • 空间复杂度:O(n),哈希表最多存 n 个前缀和

2. 滑动窗口最大值

题目描述

给定一个整数数组 nums 和一个整数 k,表示滑动窗口的大小。请你返回每个窗口中的最大值组成的数组。

解题思路

❗注意:本题窗口固定长度,但要求动态维护窗口内的最大值,普通滑动窗口无法高效更新最值。

核心技巧:单调双端队列(Monotonic Deque)

  • 使用一个双端队列 queue 存储数组下标
  • 队列维护单调递减:队首始终是当前窗口最大值的下标
  • 每次 right 右移:
    • 弹出队尾所有小于等于 nums[right] 的元素
    • right 入队
    • 若队首下标已滑出窗口(< left),则弹出队首
    • 当窗口形成(left >= 0),记录 nums[queue[0]]

💡 单调队列在 O(1) 均摊时间内维护滑动窗口最值,每个元素最多入队出队一次。

代码实现

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number[]}
 */
var maxSlidingWindow = function (nums, k) {
    const ans = [];
    const queue = []; // 存储下标,维护单调递减

    for (let right = 0; right < nums.length; right++) {
        // 维护单调性:弹出队尾所有 <= nums[right] 的元素
        while (queue.length > 0 && nums[queue[queue.length - 1]] <= nums[right]) {
            queue.pop();
        }
        queue.push(right);

        const left = right - k + 1;
        // 移除已滑出窗口的队首
        if (queue[0] < left) {
            queue.shift();
        }
        // 窗口形成后记录最大值
        if (left >= 0) {
            ans.push(nums[queue[0]]);
        }
    }
    return ans;
};

复杂度分析

  • 时间复杂度:O(n),每个元素最多入队出队一次
  • 空间复杂度:O(k),队列最多存 k 个元素

3. 最小覆盖子串

题目描述

给定字符串 st,返回 s包含 t 所有字符的最短子串。若不存在,返回空字符串。

解题思路

核心技巧:可变滑动窗口 + 哈希频次匹配 + 有效字符计数

  • need 哈希表记录 t 中每个字符的需求频次
  • window 哈希表记录当前窗口中各字符的实际频次
  • valid 记录已满足需求的字符种类数(不是总个数!)
  • right 扩张窗口,加入字符;当 valid === need.size 时,尝试收缩 left
  • 收缩过程中不断更新最小覆盖子串

💡valid 的设计避免了每次遍历整个哈希表判断是否覆盖,实现 O(1) 条件检查。

代码实现

/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
var minWindow = function(s, t) {
    if (t.length > s.length) return "";

    const need = new Map();
    for (let c of t) {
        need.set(c, (need.get(c) || 0) + 1);
    }

    let left = 0;
    let valid = 0; // 满足 need 要求的字符种类数
    let start = 0;
    let minLen = Infinity;

    const window = new Map();

    for (let right = 0; right < s.length; right++) {
        const c = s[right];
        // 扩大窗口
        if (need.has(c)) {
            window.set(c, (window.get(c) || 0) + 1);
            if (window.get(c) === need.get(c)) {
                valid++; // 该字符刚好满足需求
            }
        }

        // 尝试收缩窗口:当所有字符都满足时
        while (valid === need.size) {
            // 更新答案
            if (right - left + 1 < minLen) {
                start = left;
                minLen = right - left + 1;
            }

            const d = s[left];
            left++;
            if (need.has(d)) {
                if (window.get(d) === need.get(d)) {
                    valid--; // 破坏满足状态
                }
                window.set(d, window.get(d) - 1);
            }
        }
    }

    return minLen === Infinity ? "" : s.slice(start, start + minLen);
};

复杂度分析

  • 时间复杂度:O(n + m),n = s.length, m = t.length,每个字符最多进出窗口一次
  • 空间复杂度:O(k),k 为字符集大小(如 ASCII 为 128)

总结对比

题目窗口类型核心数据结构关键技巧
和为 K 的子数组无窗口哈希表(前缀和)前缀和差值 → 转化为查找问题
滑动窗口最大值固定窗口单调双端队列维护窗口内最值,O(1) 查询
最小覆盖子串可变窗口哈希表(频次+valid)频次匹配 + 有效字符计数

希望这篇解析对你有帮助!如果你喜欢这类结构清晰、对比鲜明的算法总结,欢迎关注我的 LeetCode 热题 100 系列 👋