子串专题部分

子串专题

Hot100 子串专题共 3 题:和为 K 的子数组、滑动窗口最大值、最小覆盖子串。这三题难度跨度较大,从中等到困难,涉及前缀和、单调队列、滑动窗口三种不同的核心技巧。


一、和为 K 的子数组(#560)

题意

给一个整数数组 nums 和整数 k,返回数组中和为 k 的连续子数组的个数。

复制代码
输入:nums = [1, 1, 1], k = 2
输出:2

输入:nums = [1, 2, 3], k = 3
输出:2  (子数组 [1,2] 和 [3])

注意:数组元素可以是负数,所以滑动窗口不适用(窗口收缩的前提是元素非负时和单调)。

前置知识:前缀和

前缀和 prefixiprefixiprefixi 表示 nums0+nums1+⋯+numsi−1nums0 + nums1 + \cdots + numsi-1nums0+nums1+⋯+numsi−1,定义 prefix0=0prefix0 = 0prefix0=0。

子数组 i,ji, ji,j 的和等于 prefixj+1−prefixiprefixj+1 - prefixiprefixj+1−prefixi

复制代码
nums   =  [1,  2,  3]
prefix = [0,  1,  3,  6]

子数组[0,1]的和 = prefix[2] - prefix[0] = 3 - 0 = 3 ✓
子数组[1,2]的和 = prefix[3] - prefix[1] = 6 - 1 = 5 ✓

思路

问题转化:找有多少对 (i,j)(i, j)(i,j) 满足 prefixj−prefixi=kprefixj - prefixi = kprefixj−prefixi=k,即 prefixi=prefixj−kprefixi = prefixj - kprefixi=prefixj−k。

遍历到位置 jjj 时,查询之前有多少个前缀和等于 prefixj−kprefixj - kprefixj−k,累加到答案里。用 unordered_map 记录每个前缀和出现的次数,边遍历边查询,O(1)O(1)O(1) 完成每次查询。

复制代码
nums = [1, 1, 1], k = 2
初始:mp = {0: 1}(前缀和为0出现1次,对应空前缀)

遍历:
  prefix=1: 查 1-2=-1,mp里没有 → ans+=0,mp={0:1, 1:1}
  prefix=2: 查 2-2=0,mp里有1次 → ans+=1,mp={0:1, 1:1, 2:1}
  prefix=3: 查 3-2=1,mp里有1次 → ans+=1,mp={0:1, 1:1, 2:1, 3:1}

ans = 2 ✓

为什么初始化 mp[0] = 1?因为如果某个前缀和 prefixjprefixjprefixj 本身就等于 kkk,那么 prefixj−k=0prefixj - k = 0prefixj−k=0,对应的是从下标 0 开始的子数组,需要被统计进来,所以空前缀(前缀和为 0)要提前存入。

代码

cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        unordered_map<int, int> mp;
        mp[0] = 1; // 空前缀,前缀和为 0
        int prefix = 0, ans = 0;
        for (int x : nums) {
            prefix += x;
            // 查询之前有多少前缀和等于 prefix - k
            if (mp.count(prefix - k)) {
                ans += mp[prefix - k];
            }
            mp[prefix]++;
        }
        return ans;
    }
};

复杂度

  • 时间 :O(n)O(n)O(n),单次遍历,每次哈希操作 O(1)O(1)O(1)
  • 空间 :O(n)O(n)O(n),哈希表最多存 n+1n+1n+1 个前缀和

二、滑动窗口最大值(#239)

题意

给一个整数数组 nums 和窗口大小 k,窗口从左到右滑动,每次只能看到窗口内的 kkk 个元素,返回每个窗口位置的最大值。

复制代码
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]

窗口位置          最大值
[1  3  -1] -3  5  3  6  7   →  3
 1 [3  -1  -3] 5  3  6  7   →  3
 1  3 [-1  -3  5] 3  6  7   →  5
 1  3  -1 [-3  5  3] 6  7   →  5
 1  3  -1  -3 [5  3  6] 7   →  6
 1  3  -1  -3  5 [3  6  7]  →  7

前置知识:单调队列

单调队列是一个两端都能操作的队列(双端队列 deque),队列内的元素保持单调性。

这道题需要的是单调递减队列:队头是当前窗口的最大值,从队头到队尾单调递减。

维护规则:

  • 新元素从队尾入队前,把队尾所有比它小的元素弹出(它们永远不可能成为后续窗口的最大值)

  • 如果队头元素的下标已经滑出窗口,从队头弹出

    队列里存的是下标,方便判断是否滑出窗口

思路

遍历 nums,对每个位置 right

  1. 清理队尾 :把队尾所有满足 nums[队尾] <= nums[right] 的元素弹出,再把 right 入队

  2. 清理队头 :如果队头下标 <= right - k,说明已滑出窗口,弹出队头

  3. 记录答案 :当 right >= k-1 时(窗口已满),队头就是当前窗口最大值

    nums = [1,3,-1,-3,5,3,6,7], k=3

    right=0(1): 队列[0]
    right=1(3): 3>1,弹出0,队列[1]
    right=2(-1): -1<3,直接入队,队列[1,2],窗口满,最大值=nums[1]=3
    right=3(-3): -3<-1,直接入队,队列[1,2,3]
    队头1未滑出(1 <= 3-3=0? 否),最大值=nums[1]=3
    right=4(5): 5>-3弹3,5>-1弹2,5>3弹1,队列[4]
    最大值=nums[4]=5
    right=5(3): 3<5,直接入队,队列[4,5]
    最大值=nums[4]=5
    right=6(6): 6>3弹5,6<5? 不,6>5弹4,队列[6]
    最大值=nums[6]=6
    right=7(7): 7>6弹6,队列[7]
    最大值=nums[7]=7

代码

cpp 复制代码
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        deque<int> dq; // 存下标,单调递减(队头最大)
        vector<int> res;
        for (int right = 0; right < nums.size(); right++) {
            // 清理队尾:保持单调递减
            while (!dq.empty() && nums[dq.back()] <= nums[right]) {
                dq.pop_back();
            }
            dq.push_back(right);
            // 清理队头:滑出窗口的元素
            if (dq.front() <= right - k) {
                dq.pop_front();
            }
            // 窗口已满,记录答案
            if (right >= k - 1) {
                res.push_back(nums[dq.front()]);
            }
        }
        return res;
    }
};

复杂度

  • 时间 :O(n)O(n)O(n),每个元素最多入队一次、出队一次
  • 空间 :O(k)O(k)O(k),单调队列最多存 kkk 个元素

三、最小覆盖子串(#76)

题意

给字符串 st,找 s 中包含 t 所有字符(含重复)的最短子串,不存在则返回空字符串。

复制代码
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"

输入:s = "a", t = "aa"
输出:""  (s 里只有一个 a,无法覆盖 t 里的两个 a)

思路

这是滑动窗口的经典困难题,窗口可变长度,目标是找满足条件的最短窗口。

窗口条件 :窗口内包含 t 的所有字符,且每个字符的出现次数都不少于 t 中的次数。

用两个哈希表 needwindow

  • needt 中每个字符需要的数量
  • window:当前窗口内每个字符的数量

用变量 valid 记录窗口内已经满足数量要求的字符种数,当 valid == need.size() 时,窗口覆盖了 t

流程

  1. right 向右扩张,把字符纳入窗口,如果某字符的窗口数量恰好达到需要量,valid++

  2. valid == need.size() 时,窗口已覆盖 t,记录当前窗口长度,然后收缩 left

  3. 收缩时,如果移出的字符导致某字符数量低于需要量,valid--,退出收缩循环

  4. 重复直到 right 到达末尾

    s = "ADOBECODEBANC", t = "ABC"
    need = {A:1, B:1, C:1}

    right扩张到覆盖ABC:
    [ADOBEC] → valid=3,覆盖了,记录长度6,开始收缩left
    移出A → A的窗口数量0 < need[A]=1 → valid=2,停止收缩

    right继续扩张:
    [DOBECODEBA] → 又找到A,valid=3
    收缩left直到窗口最短:[CODEBA] → 长度6
    移出C → valid=2

    right继续:
    [CODEBANC] → 找到C,valid=3
    收缩:移出C → valid=2,停止 → 此时 [ODEBANC] 不对
    实际上收缩到 [BANC],长度4,更新最短

    最终答案:[BANC]

代码

cpp 复制代码
class Solution {
public:
    string minWindow(string s, string t) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;

        int left = 0, valid = 0;
        int start = 0, minLen = INT_MAX; // 记录最短窗口的起点和长度

        for (int right = 0; right < s.size(); right++) {
            char c = s[right];
            // 纳入右边字符
            if (need.count(c)) {
                window[c]++;
                if (window[c] == need[c]) valid++; // 该字符恰好满足
            }
            // 窗口已覆盖 t,收缩左边
            while (valid == need.size()) {
                // 更新最短窗口
                if (right - left + 1 < minLen) {
                    minLen = right - left + 1;
                    start = left;
                }
                char d = s[left];
                left++;
                if (need.count(d)) {
                    if (window[d] == need[d]) valid--; // 移出前恰好满足,移出后不满足
                    window[d]--;
                }
            }
        }
        return minLen == INT_MAX ? "" : s.substr(start, minLen);
    }
};

注意收缩时先判断 valid--window[d]-- 的顺序:因为判断条件是 window[d] == need[d](移出这个字符会导致不满足),所以要在减之前判断。

复杂度

  • 时间 :O(n+m)O(n + m)O(n+m),nnn 为 s 的长度,mmm 为 t 的长度。leftright 各最多移动 nnn 次,初始化 need 需要 O(m)O(m)O(m)
  • 空间 :O(∣Σ∣)O(|\Sigma|)O(∣Σ∣),两个哈希表的 key 都是字符,最多 O(1)O(1)O(1)

四、专题小结

题目 核心技巧 关键数据结构 时间 空间
和为 K 的子数组 前缀和 + 哈希查询 unordered_map O(n)O(n)O(n) O(n)O(n)O(n)
滑动窗口最大值 单调队列维护区间最大值 deque O(n)O(n)O(n) O(k)O(k)O(k)
最小覆盖子串 可变滑动窗口 + 双哈希表 unordered_map O(n+m)O(n+m)O(n+m) O(1)O(1)O(1)

三题用了三种完全不同的技巧,但有一个共同点:都在避免重复计算 。前缀和把区间求和变成两个前缀相减,单调队列把区间最大值查询变成 O(1)O(1)O(1),滑动窗口通过移动左右指针复用了窗口状态。

相关推荐
吃好睡好便好1 天前
提取矩阵某一行或某一列元素
开发语言·人工智能·线性代数·算法·matlab·矩阵
云泽8081 天前
笔试算法 -位运算篇(二):从唯一字符到消失数字
c++·算法·位运算
ʚ希希ɞ ྀ1 天前
不同路径|| -- dp
算法
IT 行者1 天前
SimHash 与 MinHash:相似性计算的双子星算法
算法·hash·比对
智者知已应修善业1 天前
【51单片机8位数码管动态显示日期小数点风格】2023-11-13
c++·经验分享·笔记·算法·51单片机
智者知已应修善业1 天前
【51单片机有三个LED 分别第一个灯闪三下 再到第二个灯又闪三下 再到第三个灯又闪三下 就这样循环程序】2023-11-16
c++·经验分享·笔记·算法·51单片机
小娄~~1 天前
C语言卷子错题集
c语言·开发语言·数据结构
小L~~~1 天前
基于贪心策略的混合遗传算法求解01背包问题
python·算法
洛水水1 天前
【力扣100题】53.最长回文子串
算法·leetcode·职场和发展