适合收藏的面试高频题解析。本文带你从“识题→思路→代码→优化→类题拓展”一步到位,两种可直接过OJ的实现,并讲清楚:为什么遇到这种题,第一反应就是滑动窗口。


题目速览

给定字符串 st,返回 s包含 t 所有字符(含出现次数)的最短子串;若不存在返回空串。
例如:s="ADOBECODEBANC", t="ABC",答案是 "BANC"


为什么一看就知道是滑动窗口?

把这几个“关键词”画重点:

  • 子串(连续)

  • 包含/覆盖(要求窗口内某些元素的计数达到目标)

  • 最短/最长(一边扩、一边收)

当一个题同时出现这三类信号时,经验法则:80% 是滑动窗口 + 计数
其中,“包含/覆盖”意味着要维护一个需求表(HashMap/数组),“最短/最长”意味着右指针扩张满足条件,左指针收缩尽量短


解法一:HashMap 标准滑动窗口(通用、好记)

思路

  1. need 统计 t 中每个字符需要的次数;required = need 的键数

  2. 右指针 r 向右扩,记录 window[ch]++。当某个字符 ch 的当前计数恰好等于 need[ch] 时,formed++

  3. formed == required,说明窗口已覆盖 t;尝试用左指针 l 收缩,能收就收,同时维护最优答案。

  4. 左指针每左移一步,减少计数;如果某个 need 被破坏(window[ch] < need[ch]),formed--,再去扩张。

复杂度

  • 时间:O(|s| + |t|)

  • 空间:O(字母表)(Map 或数组)

代码(js)

// O(n + m) 时间,O(字符种类) 空间
function minWindow(s, t) {
  if (!s || !t || s.length < t.length) return "";

  // 1) 统计需求
  const need = new Map();
  for (const ch of t) need.set(ch, (need.get(ch) || 0) + 1);
  const required = need.size;

  // 2) 窗口计数
  const window = new Map();
  let formed = 0;

  let l = 0;
  let bestLen = Infinity, bestL = 0;

  // 3) 右指针扩张
  for (let r = 0; r < s.length; r++) {
    const ch = s[r];
    window.set(ch, (window.get(ch) || 0) + 1);
    if (need.has(ch) && window.get(ch) === need.get(ch)) formed++;

    // 4) 满足覆盖后尝试收缩
    while (l <= r && formed === required) {
      if (r - l + 1 < bestLen) {
        bestLen = r - l + 1;
        bestL = l;
      }
      const leftCh = s[l];
      window.set(leftCh, (window.get(leftCh) || 0) - 1);
      if (need.has(leftCh) && window.get(leftCh) < need.get(leftCh)) {
        formed--;
      }
      l++;
    }
  }

  return bestLen === Infinity ? "" : s.slice(bestL, bestL + bestLen);
}

常见坑位

  • “恰好等于需要次数时才 formed++”,不能写成大于等于。

  • 收缩左边时,先更新答案再减计数,顺序不能颠倒。

  • t 有重复字符(例如 "aa")时,Map 的正确性尤为重要。


解法二:计数数组 + 剩余需求(更简洁、速度通常更快)

解法一用的是“种类满足”的思路;解法二换个角度:维护“还差多少个字符”
具体做法:

  1. need[128] 统计 t 中每个 ASCII 字符需要的次数,remain = len(t)

  2. 右指针每读到一个字符 c,把 need[c]--如果 need[c] 仍然 ≥ 0,说明这个 c 正好能“抵债”,remain--

  3. remain == 0 说明已经覆盖,开始用左指针收缩:

    • need[s[l]] < 0,说明窗口里该字符多了,need[s[l]]++ 并左移;

    • 否则当前窗口已经是该右端下的最短,尝试更新答案,然后把 s[l] 退出窗口:need[s[l]]++remain++,左移一格,回到扩张阶段。

代码(Js)

// O(n + m) 时间,O(常数) 空间(ASCII 128)
function minWindow2(s, t) {
  if (!s || !t || s.length < t.length) return "";

  const need = new Array(128).fill(0);
  for (let i = 0; i < t.length; i++) need[t.charCodeAt(i)]++;

  let remain = t.length;     // 还差多少字符
  let l = 0, bestL = 0, bestLen = Infinity;

  for (let r = 0; r < s.length; r++) {
    const cr = s.charCodeAt(r);
    if (need[cr] > 0) remain--;  // 抵债成功
    need[cr]--;

    // 覆盖后尽量收缩
    while (remain === 0) {
      if (r - l + 1 < bestLen) {
        bestLen = r - l + 1;
        bestL = l;
      }
      const cl = s.charCodeAt(l);
      need[cl]++;                // 退还左端字符
      if (need[cl] > 0) remain++; // 退还后又欠债
      l++;
    }
  }

  return bestLen === Infinity ? "" : s.slice(bestL, bestL + bestLen);
}

优点:纯数组,常数因子小;“抵债/欠债”语义非常直观。
适用:字符集不大(ASCII、扩展 ASCII、甚至 Unicode 也可用 HashMap 变形实现)。


两种写法如何选?

  • 喜欢通用模板、字符集未知:用解法一(HashMap 模板)

  • 追求更快、字符集较小(ASCII 等):用解法二(计数数组 + remain)

  • s 很长、t 很短:在二者之上叠加索引过滤优化。


测试用例(覆盖边界)

const cases = [
  ["ADOBECODEBANC", "ABC", "BANC"],
  ["a", "a", "a"],
  ["a", "aa", ""],
  ["ab", "b", "b"],
  ["aa", "aa", "aa"],
  ["bba", "ab", "ba"]
];

for (const [s, t, expect] of cases) {
  console.log("Map  :", minWindow(s, t), " <=> expect:", expect);
  console.log("Array:", minWindow2(s, t), " <=> expect:", expect);
  console.log("---");
}

这类题的“识题清单”(拿走就能用)

  • 出现 “子串/窗口 + 覆盖/包含/至少 + 最短/最长” → 优先想 滑动窗口

  • 需要统计个数 → 准备 Counter/Map/计数数组

  • “最短”/“最长” → 右扩满足条件,左收尽量逼近

  • 代码骨架:for r in range(n): 扩张while 满足条件: 更新答案 + 收缩


延伸练手(同一模板)

  • 567. 字符串的排列(判断是否存在长度为 len(t) 的“覆盖窗口”)

  • 438. 找到字符串中所有字母异位词

  • 3. 无重复字符的最长子串(窗口内维护去重)


结语

如果这篇对你有帮助,一键三连就是对我最大的支持!也欢迎在评论区分享你的“滑动窗口心得/易错点”,一起把这道“面试经典题”玩明白。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐