LeetCode Hot 100 题解 · 子串篇

LeetCode Hot 100 题解 · 子串篇

  1. 本专题收录 LeetCode Hot 100 中所有 子串 相关题目。
  2. 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。

📑 目录

题号 题目 难度 核心思路
560 560.和为 K 的子数组 🟠中等 前缀和 + 哈希表 :子数组和 = 两个前缀和之差,"和为 k" ⇔ 前面出现过多少个前缀和等于 pre - k,用哈希表边扫边数。数组含负数,窗口和不单调,滑动窗口直接出局------这是本题最大的辨析点。
239 239.滑动窗口最大值 🔴困难 单调队列 :队列存下标、对应值从队头到队尾单调递减,队头永远是窗口最大值。新元素进窗前把队尾所有又老又小 的下标弹掉(出窗更晚还更小,永远翻不了身)。每个下标进出队各一次,O(n)
76 76.最小覆盖子串 🔴困难 变长滑动窗口 + 缺口计数 :右端无脑扩张直到覆盖 t,左端收缩到刚好不覆盖,收缩沿途结算最短窗口。用 less(还欠着的字母种数)把"是否覆盖"压成一次判零,和 438 的 diff 同源。

560.和为 K 的子数组

题目链接560.和为 K 的子数组

题目描述 :给你一个整数数组 nums 和整数 k,数一数有多少个连续子数组 的和恰好等于 k 。比如 nums = [1,1,1]k = 2,答案是 2[1,1] 出现在两个位置)。关键约束:元素可以是负数和 0------这一点直接堵死了滑动窗口。


思路一:前缀和 + 两数之和-哈希表(把"区间和"变成"差值配对")

核心思想

  1. 暴力枚举所有子数组求和是 O(n²) 起步。先想滑动窗口?不行 ------窗口和随扩张不再单调(负数会把和往回拉),"违反约束时缩左端能恢复"的推理不成立。滑动窗口的命门是单调性,含负数直接出局
  2. 换个角度:定义前缀和 preSum[i] = 前 i 个数之和,那么子数组 (i, j](下标 i+1..j)的和 = preSum[j] - preSum[i]。"和为 k" ⇔ preSum[i] = preSum[j] - k
  3. 于是扫到位置 i 时,只需要知道前面有多少个前缀和的值等于 pre - k ------这正是哈希表计数的标准场景:一边扫、一边查、一边存,单趟 O(n)
  4. 两个细节:哈希表先放 (0, 1)(空前缀 preSum[0] = 0,让"从下标 0 开始的子数组"也能被数到);先查再存k = 0 时先把自己存进去,查询会把自己这条"空子数组"数进去)。

时间复杂度 : O ( n ) O(n) O(n),其中 n 为数组长度,单趟扫描,每轮一次哈希查询。

空间复杂度 : O ( n ) O(n) O(n),哈希表最多存 n+1 个前缀和取值。

java 复制代码
class Solution {
    public int subarraySum(int[] nums, int k) {
        int ans = 0, pre = 0; // pre: 当前前缀和(前 i+1 个数的和)
        // cntMap: 前缀和的值 → 它在 preSum[0..i-1] 中出现过的次数
        Map<Integer, Integer> cntMap = new HashMap<>();
        cntMap.put(0, 1); // 空前缀 preSum[0]=0:让"从头开始的子数组"也能被配对到

        // 【不变量:每轮查询时,cntMap 里存的是 preSum[0..i-1] 的计数,不含本轮的 pre】
        for (int x : nums) {
            pre += x;                               // 更新成 preSum[i],此刻才轮到"以 i 结尾"的子数组
            ans += cntMap.getOrDefault(pre - k, 0); // 数前面有多少个 j 满足 preSum[j] = pre - k
            cntMap.merge(pre, 1, Integer::sum);     // 查完才把自己存进去,先存会把自己数进去
        }
        return ans;
    }
}

先查再存为什么致命,拿 nums = [0]k = 0 走一遍:正确答案 1。若先存后查,第一轮 pre = 0 先把自己存成 {0:2},再查 pre - k = 02------多出的那个 1 就是刚存进去的自己配了自己(子数组 (i, i] 是空的)。查和存的顺序错了不是少算,是多算

其实就是前缀和+两数之和C++写法如下:

cpp 复制代码
class Solution {
public:
    /*
     * 时间:2025年1月14日15:00:02
     * 位置:前缀和
     * 算法思路:区分 子数组和子序列。子数组是连续的
     *       法一,暴力
     *       法二:使用前缀和 求curSum
     *       法三:前缀和+哈希"两数之和的思想"
     *           <i,j>对,满足preSum[j+1]-preSum[i]=k
     *        枚举j+1,然后 i的位置是 preSum[j+1]-k
     */
    int subarraySum(vector<int>& nums, int k) {
        // 使用前缀和
        int n = nums.size();
        unordered_map<int, int> map;
        map[0] = 1; // [1,1,0] k=1.
        int cnt = 0;
        int preSum = 0;
        for (int num : nums) {
            preSum += num;
            if (map.count(preSum - k)) // 或者contains
                cnt += map[preSum - k];
            map[preSum] = map[preSum] + 1;
        }
        return cnt;
    }
};

优化空间C++版本:

cpp 复制代码
class Solution {
public:
    int subarraySum(vector<int>& nums, int k) {
        int ans = 0, s = 0;
        unordered_map<int, int> cnt{{0, 1}}; // s[0]=0 单独统计
        for (int x : nums) {
            s += x;
            // 注意不要直接 += cnt[s-k],如果 s-k 不存在,会插入 s-k
            ans += cnt.contains(s - k) ? cnt[s - k] : 0;
            cnt[s]++;
        }
        return ans;
    }
};

思路二:暴力枚举右端点(以 i 结尾向左延伸)

区分 子数组和子序列。子数组是连续的

核心思想 :枚举每个右端点 i,从 i 开始向左累加 sum,每加一个数判一次 sum == k。省掉了前缀和数组(累加方向反过来就是前缀和本身),但 O(n²)n10^5 时会超时,笔试当保底对拍用,面试直接讲思路一

时间复杂度 : O ( n 2 ) O(n^2) O(n2),其中 n 为数组长度,每个右端点向左扫一遍。

空间复杂度 : O ( 1 ) O(1) O(1)。

设置i为左端点,枚举数对(i,j)

C++写法:

cpp 复制代码
class Solution {
public:
    /*
     * 时间:2025年1月14日15:00:02
     * 位置:前缀和
     * 算法思路:区分 子数组和子序列。子数组是连续的
     *       法一,暴力
     */
    int subarraySum(vector<int>& nums, int k) {
        // 纯正的暴力
        int n = nums.size();
        int curSum = 0;
        int cnt = 0;
        for (int i = 0; i < n; ++i) {
            for (int j = i; j < n; ++j) {
                curSum += nums[j];
                if (curSum == k) {
                    ++cnt;
                    // 不能break,后面可能还可以抵消
                }
            }
            curSum = 0; // 新下标开始,重新计数
        }
        return cnt;
    }
};

设置i为右端点,枚举[j,i]

java写法:

java 复制代码
class Solution {
    public int subarraySum(int[] nums, int k) {
        int n = nums.length, ans = 0;
        for (int i = 0; i < n; ++j) {   // 枚举子数组的右端点
            int curSum = 0;
            for (int j = i; j >= 0; --j) { // 从右端向左延伸,curSum 始终是 [j, i] 的和
                curSum += nums[j];
                if (curSum == k) ans++;    // 每延伸一格判一次,不漏任何 [j, i]
            }
        }
        return ans;
    }
}

239.滑动窗口最大值

题目链接239.滑动窗口最大值

题目描述 :给你数组 nums 和窗口大小 k,窗口从最左滑到最右,每次平移一格,返回每个窗口位置的最大值 组成的数组。比如 nums = [1,3,-1,-3,5,3,6,7]k = 3,答案是 [3,3,5,5,6,7]n 可达 10^5,暴力每窗重扫 k 个的 O(nk) 会超时。


思路一:单调队列(队尾比新元素小=的全弹光,队头就是答案)

核心思想

  1. 窗口每滑一格只进一个、出一个字符 ,最大值大概率没变,重扫整个窗口全浪费在没变的比较上------和 438.找到字符串中所有字母异位词 增量维护计数是同一个动机:窗口只变两个位置,凭什么重新统计全部
  2. 关键观察:哪些下标永远当不了窗口最大值?i 左边且值 ≤ nums[i] ------i 比它们年轻(出窗更晚)还比它们大,它们这辈子翻不了身。新元素进窗前,把这些下标从队尾全部弹掉。
  3. 弹完之后队列里剩下的下标天然从队头到队尾单调递减 ,队头就是窗口最大值------这就是单调队列
  4. 完全套用定长滑窗模板:进窗(弹队尾、入队)→ 窗口满后结算(取队头)→ 出窗(队头下标过期就弹出)。每个下标最多进出队各一次,O(n)

时间复杂度 : O ( n ) O(n) O(n),其中 n 为数组长度,每个下标入队一次、出队至多一次。

空间复杂度 : O ( k ) O(k) O(k),队列里最多同时存 k 个下标。

java写法:

java 复制代码
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int[] ans = new int[n - k + 1]; // 共 n-k+1 个窗口,答案下标恰好是窗口左端点
        // 队列存下标;对应值从队头到队尾单调递减,队头永远是当前窗口最大值
        Deque<Integer> q = new ArrayDeque<>();

        // 【不变量:每轮结算时,q 里全是 [left, right] 内的下标且值单调递减,队头是最大】
        for (int right = 0; right < n; right++) {
            // 1. 进窗:队尾值 <= nums[right] 的全弹掉------比新来的还小还更老,永远当不了最大值【右边入】
            while (!q.isEmpty() && nums[q.peekLast()] <= nums[right]) { 
            // 大于等于就就加入
                q.pollLast(); // q.removeLast();维护q的单调性
            }
            q.offerLast(right); // 新下标入队(相等也弹旧的,留下标更年轻的)
            // q.addLast(right)
            
            int left = right - k + 1;
            if (left < 0) { // 窗口长度不足 k
                continue;
            }
            // 2. 结算:队头就是窗口 [left, right] 的最大值
            ans[left] = nums[q.peekFirst()]; // getFirst()
            // 3. 出窗:最老的下标 left 离开,若它正好是队头就一起出队[左边出]
            if (q.peekFirst() == left) { // getFirst()
            // 由于队首到队尾单调递减,所以窗口最大值就在队首
                q.pollFirst(); // removeFirst()
            }
        }
        return ans;
    }
}

这就是滑动窗口篇模板的第三块拼图:模板骨架(进窗 → left < 0continue → 结算 → 出窗)完全不变,变的只是"窗口状态"------438 里是计数数组,这里是单调队列。学模板学的是节拍,学队列学的是"哪些元素可以提前扔" :单调队列的本质是,弹掉队尾时就已经断定它对任何未来窗口都不是答案,扔得心服口服。

java换种写法:

java 复制代码
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int[] ans = new int[n - k + 1]; // 窗口个数
        Deque<Integer> q = new ArrayDeque<>(); // 更快的写法见【Java 数组】

        for (int i = 0; i < n; i++) {
            // 1. 右边入
            while (!q.isEmpty() && nums[q.getLast()] <= nums[i]) { // 大于等于就就加入
                q.removeLast(); // 维护 q 的单调性
            }
            q.addLast(i); // 注意保存的是下标,这样下面可以判断队首是否离开窗口

            // 2. 左边出
            int left = i - k + 1; // 窗口左端点
            if (q.getFirst() < left) { // 队首离开窗口
                q.removeFirst();
            }

            // 3. 在窗口左端点处记录答案
            if (left >= 0) {
                // 由于队首到队尾单调递减,所以窗口最大值就在队首
                ans[left] = nums[q.getFirst()];
            }
        }

        return ans;
    }
}

C++写法:

cpp 复制代码
class Solution {
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        int n = nums.size();
        vector<int> ans(n - k + 1); // [0,n-k]
        int l = 0, r = 0;
        deque<int> q; // 单调队列
        while (r < n) {

            // 1. 入
            while (!q.empty() && nums[q.back()] <= nums[r]) {
                q.pop_back(); // 单调递减队列
            }
            q.push_back(r); // 存下标

            // 2.出
            l = r - k + 1;
            if (q.front() < l) { // 超出长度了
                q.pop_front();
            }
            if (l >= 0) {
                ans[l] = nums[q.front()];
            }

            ++r;
        }
        return ans;
    }
};

思路二:大顶堆 + 懒删除(O(n log n),好想但慢一档)

核心思想 :大顶堆存 (值, 下标),右端点无脑入堆;结算时看堆顶,堆顶下标已出窗就弹掉再取 ------出窗的元素不主动删(堆删指定元素做不到),过期了才处理,叫懒删除 。比单调队列好想(没有任何"提前处刑"的推理),代价是 O(n log n),面试先讲思路一,这个当备选。

时间复杂度 : O ( n log ⁡ n ) O(n \log n) O(nlogn),其中 n 为数组长度,每个元素入堆一次、弹堆至多一次。

空间复杂度 : O ( n ) O(n) O(n),堆里会残留已出窗但没被弹掉的过期元素。

java 复制代码
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int[] ans = new int[n - k + 1];
        // 大顶堆存 (值, 下标),按值从大到小排
        PriorityQueue<int[]> heap = new PriorityQueue<>((a, b) -> b[0] - a[0]);
        for (int right = 0; right < n; right++) {
            heap.offer(new int[]{nums[right], right}); // 右端点入堆,不管它在不在窗口内
            int left = right - k + 1;
            if (left < 0) { // 窗口长度不足 k
                continue;
            }
            while (heap.peek()[1] < left) { // 堆顶已出窗:弹掉过期的,剩下的堆顶必然在窗内
                heap.poll();                // 懒删除:过期元素不主动删,堆顶过期才弹
            }
            ans[left] = heap.peek()[0]; // 堆顶就是窗口 [left, right] 的最大值
        }
        return ans;
    }
}

76.最小覆盖子串

题目链接76.最小覆盖子串

题目描述 :给你字符串 st,找 s涵盖 t 所有字母(含重复次数)的最短子串 ,不存在返回空串。比如 s = "ADOBECODEBANC"t = "ABC",答案是 "BANC"。注意是"覆盖"不是"恰好":窗口里字母可以多、不能少------这个"可以多"正是它和 438 的分水岭。


思路一:变长滑动窗口 + 双计数数组(右端无脑扩,左端缩到极限)

核心思想

  1. 先把判定条件结构化:窗口覆盖 t每种字母的窗口量 ≥ t 的需要量 。和 438.找到字符串中所有字母异位词 一样维护两个计数数组:cntT 是判定的"标准答案",建好就不动;cntS 随窗口滑动增量维护。差别在判定方向:438 要求"恰好"(逐一相等),76 只要求"够"(cntS[i] >= cntT[i] 即可,多出来无所谓)------这个"可以多"让 76 成了变长窗口。
  2. 主循环 right 无脑右扩;每进一个字符就问一句 isCovered------一旦覆盖,进入内层 while:先结算当前窗口,再左端收缩一格 ,直到刚好不覆盖退出。以 right 结尾的最短覆盖窗口必然出现在这条收缩路径上,所以结算放在收缩循环里不会漏。
  3. isCovered 每次全量比较 52 个字母,单次 O(|\Sigma|)。好想、不易错,是这题的标准写法;想省掉这 52 次比较见思路二。
  4. leftright 都只右移不回退,每个字符进窗一次、出窗至多一次------变长窗口也是 O(n) 级,复杂度来源和 3.无重复字符的最长子串 一样:left 被约束逼着走,不是被 right 推着走

时间复杂度 : O ( ∣ Σ ∣ ⋅ ( n + m ) ) O(|\Sigma| \cdot (n + m)) O(∣Σ∣⋅(n+m)),其中 n、m 为 st 的长度------进出窗合计 O(n) 步,每步至多触发一次 isCovered 的 52 个字母比较(内层 while 的每轮收缩前也判一次,但收缩总量 ≤ n)。

空间复杂度 : O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣),即 O(128) 的两个计数数组,常数空间。

java 复制代码
class Solution {
    public String minWindow(String S, String t) {
        int[] cntS = new int[128]; // s 子串字母的出现次数
        int[] cntT = new int[128]; // t 中字母的出现次数
        for (char c : t.toCharArray()) {
            cntT[c]++;
        }

        char[] s = S.toCharArray();
        int m = s.length;
        int ansLeft = -1;
        int ansRight = m;

        int left = 0;
        for (int right = 0; right < m; right++) { // 移动子串右端点
            cntS[s[right]]++; // 右端点字母移入子串
            while (isCovered(cntS, cntT)) { // 涵盖
                if (right - left < ansRight - ansLeft) { // 找到更短的子串
                    ansLeft = left; // 记录此时的左右端点
                    ansRight = right;
                }
                cntS[s[left]]--; // 左端点字母移出子串
                left++;
            }
        }

        return ansLeft < 0 ? "" : S.substring(ansLeft, ansRight + 1);
    }

    private boolean isCovered(int[] cntS, int[] cntT) {
        for (int i = 'A'; i <= 'Z'; i++) {
            if (cntS[i] < cntT[i]) {
                return false;
            }
        }
        for (int i = 'a'; i <= 'z'; i++) {
            if (cntS[i] < cntT[i]) {
                return false;
            }
        }
        return true;
    }
}

高频出错点:ansLeft 初始化成 -1 而不是 0------它同时兼任"找没找到"的标记(最后 ansLeft < 0 返回空串),且 ansRight - ansLeft 初始是 m+1,比任何合法子串都长,第一组答案一定能刷进去。

注意点:1.初始化;2.while更新;3.return

思路二:缺口计数(把 isCovered 的 52 次比较压成一次判零)

核心思想

  1. 思路一每动一下窗口都要 isCovered 全量比 52 个字母,但真正需要知道的只有一个:覆盖了没有 。把两个数组合成一个差值数组 cnt[c] = t需要量 - 窗口量,配平语义和 438 思路二完全同源,差别只有一处:438 要求"恰好"(cnt ≠ 0 都算失衡),76 只要求"够"(cnt > 0 才算欠着,负数是白送的多余,不算失衡)
  2. less 维护"还欠着的字母种数"(cnt[c] > 0 的种类数),less == 0 ⇔ 窗口已覆盖 t,把整次判定压成一次判零------正因 76 只数正数,跨越 0 的判断是单边的:进窗只判"减完是否为 0",出窗只判"加之前是否为 0",不像 438 那样前后各判一次。
  3. 循环骨架和思路一完全一样:r 无脑右扩,less == 0 就进入"先结算再收缩"的内层 while。变的只是"是否覆盖"的判定方式,从全量比较换成 O(1) 维护。

时间复杂度 : O ( n + m + ∣ Σ ∣ ) O(n + m + |\Sigma|) O(n+m+∣Σ∣),其中 n、m 为 st 的长度,进出窗各一次 O(1) 维护,|\Sigma| 是建计数数组的 128。

空间复杂度 : O ( ∣ Σ ∣ ) O(|\Sigma|) O(∣Σ∣),一个差值计数数组,常数空间。

java 复制代码
class Solution {
    public String minWindow(String s, String t) {
        int m = s.length();
        // cnt[c] = t需要的c数量 - 窗口内的c数量;> 0 欠着,< 0 多余(不算失衡),0 刚好
        int[] cnt = new int[128];
        int less = 0; // 还欠着的字母种数(cnt[c] > 0 的种类数),0 ⇔ 窗口已覆盖 t
        for (char c : t.toCharArray()) {
            if (cnt[c] == 0) less++; // 又多了一种欠着的字母(同种字母只数一次)
            cnt[c]++;
        }
        int ansL = -1, minLen = Integer.MAX_VALUE;

        // 【不变量:内层 while 退出时,[l, r] 恰好不覆盖 t,而 [l-1, r] 是右端 r 处的最短覆盖窗口】
        for (int r = 0, l = 0; r < m; r++) {
            char in = s.charAt(r);   // 右端点字母进窗
            cnt[in]--;               // 真正动 cnt:进窗后窗口量加、差值减
            if (cnt[in] == 0) less--; // 减完恰好为 0:这种字母从"欠"变"够",只此瞬间动 less

            while (less == 0) { // 窗口已覆盖 t:结算 + 左端收缩,缩到刚好不覆盖为止
                if (r - l + 1 < minLen) { // 先结算:当前 [l, r] 是覆盖窗口,沿途每个都可能是最短
                    minLen = r - l + 1;
                    ansL = l;
                }
                char out = s.charAt(l++); // 左端点字母出窗,l 只进不退
                if (cnt[out] == 0) less++; // 加之前恰好为 0:出去一个就欠一个,覆盖被破坏
                cnt[out]++;                // 真正动 cnt:出窗后窗口量减、差值加
            }
        }
        return ansL == -1 ? "" : s.substring(ansL, ansL + minLen); // 从没覆盖过就是空串
    }
}

三组对照帮记忆:

  1. 变长 vs 定长 :定长窗口(438.找到字符串中所有字母异位词239.滑动窗口最大值)的 leftright 推着走(left = right - k + 1);变长窗口(76.最小覆盖子串3.无重复字符的最长子串 题)的 left约束逼着走------覆盖了才收缩,收缩是为了找最短。节拍从"平移"变成"扩张 → 收缩交替"。
  2. 单边判 vs 双边判438.找到字符串中所有字母异位词diffcnt ≠ 0 的种数(多了少了都失衡),所以进/出窗前后各判一次 0;(76.最小覆盖子串less 只数 cnt > 0(多余白送),所以进窗只判减完、出窗只判加之前,各判一次单边的。
  3. 结算放哪:定长窗口结算夹在进窗后出窗前(那一刻窗口完整);变长覆盖窗口结算放在收缩循环里------收缩完再结算就晚了,窗口已经不覆盖了。

总结:子串三题本质是"子数组问题选工具 "------先看约束有没有单调性:和含负数(560.和为K的子数组)单调性没了,换前缀和 + 哈希 把区间和变成差值配对;约束单调且求窗口最值(239.滑动窗口最大值),用单调队列 把队尾更小的提前弹掉;约束是覆盖/计数型且单调(76.最小覆盖子串438.找到字符串中所有字母异位词3.无重复字符的最长子串),用滑动窗口 右扩左缩、增量维护。560.和为K的子数组pre - k 配对、239.滑动窗口最大值 靠队尾弹小值、76.最小覆盖子串less 判零(或 isCovered 全量比,思路一好想、思路二更快),核心都是把每次 O(k)O(n) 的重复统计,变成每步 O(1) 的增量维护

相关推荐
辰烨chenye1 小时前
LeetCode Hot 100 题解 · 堆篇
java·算法·leetcode
手写码匠1 小时前
华为云Flexus+DeepSeek征文|DeepSeek 应用监控告警体系实战:从“用户投诉才知道“到“故障前就发现“
人工智能·深度学习·算法·aigc
shehuiyuelaiyuehao2 小时前
算法29,前缀和,除自身以外的数组的乘积
java·数据结构·算法
智购科技自动售货机厂家2 小时前
2026自动售货机设备清洁效果自动验证:从图像比对到评分算法的工程实践~YH
人工智能·算法·计算机视觉
姜穆澜2 小时前
机器学习实战指南:从算法原理到工程落地
人工智能·算法·机器学习
Q一件事3 小时前
RWEQ——保留与消去P的soil_loss联合推导
算法
HugoStudio_SWAN4 小时前
洛谷 P10719 \[GESP202406 五级] 黑白格——暴力美学与图像处理的最小外接矩形
c++·图像处理·人工智能·学习·程序人生·算法·目标跟踪
那年窗外下的雪.4 小时前
AIDC 学习日志|第 20 天|多归属业务验收与哈希不均定位
学习·算法·哈希算法