LeetCode Hot 100 题解 · 子串篇
- 本专题收录 LeetCode Hot 100 中所有 子串 相关题目。
- 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。
📑 目录
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 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------这一点直接堵死了滑动窗口。
思路一:前缀和 + 两数之和-哈希表(把"区间和"变成"差值配对")
核心思想:
- 暴力枚举所有子数组求和是
O(n²)起步。先想滑动窗口?不行 ------窗口和随扩张不再单调(负数会把和往回拉),"违反约束时缩左端能恢复"的推理不成立。滑动窗口的命门是单调性,含负数直接出局。 - 换个角度:定义前缀和
preSum[i]= 前i个数之和,那么子数组(i, j](下标i+1..j)的和 =preSum[j] - preSum[i]。"和为k" ⇔preSum[i] = preSum[j] - k。 - 于是扫到位置
i时,只需要知道前面有多少个前缀和的值等于pre - k------这正是哈希表计数的标准场景:一边扫、一边查、一边存,单趟O(n)。 - 两个细节:哈希表先放
(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 = 0得2------多出的那个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²) 在 n 到 10^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) 会超时。
思路一:单调队列(队尾比新元素小=的全弹光,队头就是答案)
核心思想:
- 窗口每滑一格只进一个、出一个字符 ,最大值大概率没变,重扫整个窗口全浪费在没变的比较上------和 438.找到字符串中所有字母异位词 增量维护计数是同一个动机:窗口只变两个位置,凭什么重新统计全部。
- 关键观察:哪些下标永远当不了窗口最大值?在
i左边且值≤ nums[i]的 ------i比它们年轻(出窗更晚)还比它们大,它们这辈子翻不了身。新元素进窗前,把这些下标从队尾全部弹掉。 - 弹完之后队列里剩下的下标天然从队头到队尾单调递减 ,队头就是窗口最大值------这就是单调队列。
- 完全套用定长滑窗模板:进窗(弹队尾、入队)→ 窗口满后结算(取队头)→ 出窗(队头下标过期就弹出)。每个下标最多进出队各一次,
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 < 0就continue→ 结算 → 出窗)完全不变,变的只是"窗口状态"------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.最小覆盖子串
题目描述 :给你字符串 s 和 t,找 s 中涵盖 t 所有字母(含重复次数)的最短子串 ,不存在返回空串。比如 s = "ADOBECODEBANC"、t = "ABC",答案是 "BANC"。注意是"覆盖"不是"恰好":窗口里字母可以多、不能少------这个"可以多"正是它和 438 的分水岭。
思路一:变长滑动窗口 + 双计数数组(右端无脑扩,左端缩到极限)
核心思想:
- 先把判定条件结构化:窗口覆盖
t⇔ 每种字母的窗口量 ≥t的需要量 。和 438.找到字符串中所有字母异位词 一样维护两个计数数组:cntT是判定的"标准答案",建好就不动;cntS随窗口滑动增量维护。差别在判定方向:438 要求"恰好"(逐一相等),76 只要求"够"(cntS[i] >= cntT[i]即可,多出来无所谓)------这个"可以多"让 76 成了变长窗口。 - 主循环
right无脑右扩;每进一个字符就问一句isCovered------一旦覆盖,进入内层while:先结算当前窗口,再左端收缩一格 ,直到刚好不覆盖退出。以right结尾的最短覆盖窗口必然出现在这条收缩路径上,所以结算放在收缩循环里不会漏。 isCovered每次全量比较 52 个字母,单次O(|\Sigma|)。好想、不易错,是这题的标准写法;想省掉这 52 次比较见思路二。left、right都只右移不回退,每个字符进窗一次、出窗至多一次------变长窗口也是O(n)级,复杂度来源和 3.无重复字符的最长子串 一样:left被约束逼着走,不是被right推着走。
时间复杂度 : O ( ∣ Σ ∣ ⋅ ( n + m ) ) O(|\Sigma| \cdot (n + m)) O(∣Σ∣⋅(n+m)),其中 n、m 为 s、t 的长度------进出窗合计 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 次比较压成一次判零)
核心思想:
- 思路一每动一下窗口都要
isCovered全量比 52 个字母,但真正需要知道的只有一个:覆盖了没有 。把两个数组合成一个差值数组cnt[c] = t需要量 - 窗口量,配平语义和 438 思路二完全同源,差别只有一处:438 要求"恰好"(cnt ≠ 0都算失衡),76 只要求"够"(cnt > 0才算欠着,负数是白送的多余,不算失衡)。 - 用
less维护"还欠着的字母种数"(cnt[c] > 0的种类数),less == 0⇔ 窗口已覆盖t,把整次判定压成一次判零------正因 76 只数正数,跨越 0 的判断是单边的:进窗只判"减完是否为 0",出窗只判"加之前是否为 0",不像 438 那样前后各判一次。 - 循环骨架和思路一完全一样:
r无脑右扩,less == 0就进入"先结算再收缩"的内层while。变的只是"是否覆盖"的判定方式,从全量比较换成O(1)维护。
时间复杂度 : O ( n + m + ∣ Σ ∣ ) O(n + m + |\Sigma|) O(n+m+∣Σ∣),其中 n、m 为 s、t 的长度,进出窗各一次 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); // 从没覆盖过就是空串
}
}
三组对照帮记忆:
- 变长 vs 定长 :定长窗口(438.找到字符串中所有字母异位词、239.滑动窗口最大值)的
left被right推着走(left = right - k + 1);变长窗口(76.最小覆盖子串、3.无重复字符的最长子串 题)的left被约束逼着走------覆盖了才收缩,收缩是为了找最短。节拍从"平移"变成"扩张 → 收缩交替"。- 单边判 vs 双边判 :438.找到字符串中所有字母异位词 的
diff数cnt ≠ 0的种数(多了少了都失衡),所以进/出窗前后各判一次 0;(76.最小覆盖子串 的less只数cnt > 0(多余白送),所以进窗只判减完、出窗只判加之前,各判一次单边的。- 结算放哪:定长窗口结算夹在进窗后出窗前(那一刻窗口完整);变长覆盖窗口结算放在收缩循环里------收缩完再结算就晚了,窗口已经不覆盖了。
总结:子串三题本质是"子数组问题选工具 "------先看约束有没有单调性:和含负数(560.和为K的子数组)单调性没了,换前缀和 + 哈希 把区间和变成差值配对;约束单调且求窗口最值(239.滑动窗口最大值),用单调队列 把队尾更小的提前弹掉;约束是覆盖/计数型且单调(76.最小覆盖子串、438.找到字符串中所有字母异位词、3.无重复字符的最长子串),用滑动窗口 右扩左缩、增量维护。560.和为K的子数组 靠
pre - k配对、239.滑动窗口最大值 靠队尾弹小值、76.最小覆盖子串 靠less判零(或isCovered全量比,思路一好想、思路二更快),核心都是把每次O(k)或O(n)的重复统计,变成每步O(1)的增量维护。