langShiftlangShift

子串(题单顺序)

热题 100 - 子串分组(按题单顺序):560 / 239 / 76

本页包含题单「子串」分组的全部题目,顺序与 list.json 保持一致。


560. 和为 K 的子数组

力扣原题:560. 和为 K 的子数组
Medium
数组
哈希表
前缀和

给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 

子数组是数组中元素的连续非空序列。

 

示例 1:

输入:nums = [1,1,1], k = 2
输出:2

示例 2:

输入:nums = [1,2,3], k = 3
输出:2

 

提示:

  • 1 <= nums.length <= 2 * 104
  • -1000 <= nums[i] <= 1000
  • -107 <= k <= 107
最优解(前缀和 + 哈希表:把“求区间和”改成“找差值”)
正在加载编辑器...
最优解讲解(通俗版 + 推理过程)
  • 先写出区间和的公式:设 prefix[i] 表示前 i 个数的和(从 0 开始),那么子数组 nums[l..r] 的和是:
    • sum(l..r) = prefix[r+1] - prefix[l]
  • 题目要的是 sum(l..r) = k,把上式改写:
    • prefix[r+1] - prefix[l] = k
    • prefix[l] = prefix[r+1] - k
  • 这一步很关键:把“找一段区间”变成“找一个历史前缀值”
    • 当我们遍历到 r(也就是当前前缀和 prefix),想知道有多少个 l 能让区间和为 k
    • 只需要问:以前出现过多少次 prefix - k
  • 为什么要存“次数”而不是“下标”?
    • 因为可能有多个 lprefix[l] 相同(例如有 0、正负抵消),每一个都对应一个合法子数组。
    • 所以 Map 里存的是 前缀和 -> 出现次数
  • 为什么初始要 countByPrefix.set(0,1)
    • 表示“在任何元素之前,前缀和为 0 出现过一次”。
    • 这样当某个前缀和本身就等于 k 时(即从 0 开始到当前位置的子数组),也能正确统计到。
  • 小例子走一遍nums=[1,1,1], k=2
    • 初始:count[0]=1
    • 扫到第 2 个 1(prefix=2)时,need=0,count[0]=1 → ans+=1(子数组 [1,1])
    • 扫到第 3 个 1(prefix=3)时,need=1,count[1]=1 → ans+=1(子数组 [1,1] 从第二个开始)
  • 为什么不是滑动窗口?:因为数组里可能有负数,窗口右移/左移时区间和不具备单调性,滑动窗口会失效;前缀和 + 哈希才是通用解。
  • 复杂度:时间 (O(n)),空间 (O(n))(最坏情况下前缀和都不同)。
类似题目(前缀和 + 哈希:统计满足差值的区间)
const freq = new Map()
freq.set(0, 1)
let prefix = 0
let ans = 0
for (const x of nums) {
prefix += x
// 关键:把“区间条件”改写成“前缀差值条件”
ans += freq.get(prefix - target) || 0
freq.set(prefix, (freq.get(prefix) || 0) + 1)
}

239. 滑动窗口最大值

力扣原题:239. 滑动窗口最大值
Hard
队列
数组
滑动窗口
单调队列
堆(优先队列)

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值

 

示例 1:

输入: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

示例 2:

输入:nums = [1], k = 1
输出:[1]

 

提示:

  • 1 <= nums.length <= 105
  • -104 <= nums[i] <= 104
  • 1 <= k <= nums.length
最优解(单调队列:队头永远是最大值)
正在加载编辑器...
最优解讲解(通俗版 + 推理过程)
  • 题意翻译:窗口长度固定为 k,每向右滑一步,就输出当前窗口里的最大值。
  • 朴素做法:每个窗口都扫一遍找最大值,单个窗口 (O(k)),总共 (O(nk)),k 大就超时。
  • 关键观察:窗口滑动时,只进一个元素、出一个元素。我们想维护一个数据结构,让“取最大值”是 (O(1))。
  • 为什么用“单调队列”?
    • 我们维护一个队列,里面存的是“可能成为当前/未来窗口最大值”的候选下标。
    • 队列对应的值必须从头到尾递减:这样队头永远是最大值。
  • 推理队列规则(最容易写对的部分)
    1. 新元素进来时:把队尾所有 <= 新元素 的都弹掉
      • 因为新元素更大(或相等且更新),而且更靠右、活得更久;那些更小的再也不可能成为最大值。
    2. 窗口右移时:如果队头下标已经滑出窗口(<= i-k),就把它弹掉。
    3. 窗口形成后:直接取 nums[dq[0]] 作为最大值。
  • 为什么是 (O(n)):每个元素最多进队一次、出队一次,所有 pop/shift 总次数是线性的。
  • 复杂度:时间 (O(n)),空间 (O(k))(队列最多保留一个窗口内的候选下标)。
类似题目(单调队列模板:维护“最值”)
const dq = [] // 存下标,保证单调
for (let i = 0; i < n; i++) {
while (dq.length && worse(dq[dq.length - 1], i)) dq.pop()
dq.push(i)
if (dq[0] <= i - k) dq.shift()
if (i >= k - 1) ans.push(value(dq[0]))
}

76. 最小覆盖子串

力扣原题:76. 最小覆盖子串
Hard
哈希表
字符串
滑动窗口

给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的 最短窗口 子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""

测试用例保证答案唯一。

 

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。

示例 2:

输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。

示例 3:

输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。

 

提示:

  • m == s.length
  • n == t.length
  • 1 <= m, n <= 105
  • st 由英文字母组成

 

进阶:你能设计一个在 O(m + n) 时间内解决此问题的算法吗?
最优解(滑动窗口:先扩张满足,再收缩到最短)
正在加载编辑器...
最优解讲解(通俗版 + 推理过程)
  • 题意翻译:在 s 中找一个最短子串,要求它包含 t 的所有字符(包含次数也要满足,比如 t="AABC" 就需要 2 个 A)。
  • 为什么不能暴力?:枚举所有子串是 (O(n^2)),每个子串再检查是否覆盖 t,又会更慢。
  • 滑动窗口的适用前提:我们要找的是“满足条件的最短窗口”,这类题通常都能用:
    • 右指针负责“让窗口满足条件”
    • 一旦满足条件,左指针就尽量“压缩”窗口
  • 怎么判定“窗口是否已经覆盖 t”?
    • need 记录每个字符还差多少个(可以为负,表示窗口里多出来了)。
    • missing 记录还缺多少个字符(总缺口):当 missing == 0,说明覆盖成功。
  • 为什么 need 可以变成负数?
    • 负数代表“这个字符窗口里多了”,多了并不影响覆盖,只影响我们能否把左边界往右缩。
    • 正数才代表“还缺”,一旦某个字符从 0 变回 1,说明窗口不再覆盖。
  • 推理窗口过程
    1. right 右移:每看到一个 t 里需要的字符,就把 need[ch]--;如果它原本还缺(need[ch] > 0),则 missing--
    2. missing == 0:窗口已经“够用了”,此时再扩大只会更长,所以开始移动 left 尝试缩短。
    3. left 右移时如果移走了一个关键字符(导致 need[out] > 0),说明窗口不够了,停止收缩,回到步骤 1 继续扩张。
  • 小例子抓住直觉s="ADOBECODEBANC", t="ABC"
    • 先扩到第一次覆盖(包含 A/B/C),然后开始收缩左边,缩到刚好不能再缩为止;
    • 再继续往右扩,找到新的覆盖,再收缩……不断更新最短答案,最终得到 "BANC"。
  • 易错点
    • missing 只在“确实补上了缺口”时才 --;移出字符后只有在 need[out] > 0(变回缺口)时才 ++
    • need 允许为负数是正常的,它表示“窗口里多出来的字符”,不应当触发 missing 变化。
  • 复杂度:左右指针都只单调前进,整体时间 (O(n)),空间 (O(|\Sigma|))(字符表大小,常见场景可视为常数)。
类似题目(可变长窗口:先满足再极限收缩)
let left = 0
for (let right = 0; right < n; right++) {
add(arr[right])
while (windowIsValid()) {
ans = update(ans, left, right) // 在“合法”状态下更新最短/最优
remove(arr[left])
left++
}
}