深蓝黑背景封面:右侧大标题「最多翻转 m 个字符,最长连续相同子串」,副标题「滑动窗口一次扫描,O(n) 拿下」;左侧示意图为字符串 aababaab 的 8 个字符格子,第 3、5 格的字母 b 用紫色描边标出表示待翻转字符,前 7 格下方用青色括线框出当前窗口[0, 6],窗口上方标出 left 指第 1 格、right 指第 7 格,括线下标注「翻转数 2 ≤ m = 2,全部翻成 a,长度 7」;左下角注明对「全 a」和「全 b」两个方向各扫一遍取最大值;左上标注「笔试复盘 · 算法」,右上圆角胶囊标注「Day 04 · 11-11」 图 1:本文封面,窗口 [0, 6] 里 2 个 b 翻成 a,长度 7。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面:给定一个只含字符 a 和 b 的字符串,允许任选至多 m 个字符翻转(a 变 b、b 变 a),求操作后能得到的最长连续相同字符子串的长度。

先想清楚:翻转后的「连续全 a」到底长什么样。

最终的目标子串只有两种可能:全 a 或全 b。以全 a 为例,它一定来自原串中某段连续区间,把区间里的 b 全部翻转而来。于是题目翻译成了一句话:找一个最长区间,使区间内 b 的个数不超过 m。全 b 方向对称,区间内 a 的个数不超过 m。「翻转」消失了,剩下的是标准的区间约束——这正是滑动窗口的长相。

窗口怎么维护:不变量是「待翻转字符数 cnt ≤ m」。

用 [left, right] 圈住当前考察的区间,cnt 记录窗口内待翻转字符(比如全 a 方向就是 b)的数量。右指针每读入一个字符,是待翻转字符就 cnt 加一;一旦 cnt 超过 m,说明窗口不合法,左指针右移、移出字符同步减 cnt,直到恢复合法。窗口合法时,用长度 right − left + 1 刷新最大值。以题面样例 aababaab、m = 2、全 a 方向为例:right 扫到下标 6 时窗口是 aababaa,里面 2 个 b,cnt 恰好等于 m,长度 7 成为最优;right 再到下标 7 读入第 3 个 b,cnt 变 3 超限,左指针收缩到下标 3,cnt 回到 2。

function maxSameAfterFlip(s, m, flipChar) {
  let left = 0, cnt = 0, best = 0;
  for (let right = 0; right < s.length; right++) {
    if (s[right] === flipChar) cnt++;        // 目标字符需要翻转
    while (cnt > m) {                        // 翻转数超限,收缩左边界
      if (s[left] === flipChar) cnt--;
      left++;
    }
    best = Math.max(best, right - left + 1); // 窗口合法,刷新答案
  }
  return best;
}

function maxSame(s, m) {
  // 全 a 方向翻 b,全 b 方向翻 a,取两者最大
  return Math.max(maxSameAfterFlip(s, m, 'b'), maxSameAfterFlip(s, m, 'a'));
}

这段代码用 Node.js 22 实际运行:样例 aababaab、m = 2 输出 7;边界用例空串输出 0、m = 0 时 aaaa 输出 4、m 大于串长时整串可归一;再生成两万组随机字符串与暴力枚举逐一对拍,结果完全一致。

为什么要扫两遍,能不能只扫一遍。

全 a 和全 b 是两类不同的目标,窗口统计的量不一样:前者数 b、后者数 a,同一次扫描里无法共用一份 cnt。扫两遍是最干净的写法,代价只是常数乘 2,不改变复杂度。这也是这道题的常见失分点——只想起来扫一个方向,把答案算小了一半。

为什么是对的,为什么是线性的。

正确性靠一句话:对每个 right,循环结束时 left 停在「不破坏不变量的最左位置」,所以当前窗口恰好是以 right 结尾的最长合法区间——所有可能的答案区间都被逐一当过候选,最大值不会漏。复杂度靠另一句话:right 走 n 步,left 全程也只向右、最多走 n 步,while 循环的总执行次数被 left 的移动次数封顶,单方向 O(n),两个方向各一遍仍是 O(n)。

深蓝黑背景概念图,标题「一次扫描:窗口怎么扩、怎么缩」。左上方规则卡片:不变量是窗口内待翻转字符数 cnt 不超过 m;右扩时读入右端字符,若是目标字符则 cnt 加一;当 cnt 超过 m 时左指针右移、移出字符同步减 cnt。右上方答案卡片:每当 cnt 不超过 m,当前窗口合法,用长度 right − left + 1 刷新最大值,目标字符为 a 和 b 各跑一遍取最大。中部是字符串 aababaab、m 等于 2、目标字符 b 的四格推演快照:快照一 right 到 2,窗口 aab,cnt 为 1,best 为 3;快照二 right 到 5,窗口 aababa,cnt 为 2,best 为 6;快照三 right 到 6,cnt 为 2,best 为 7;快照四 right 到 7 时 cnt 变 3 超过 m,左指针收缩到 3 后 cnt 回到 2,窗口长度 5,best 保持 7。每个快照用青色描边标窗口、紫色标待翻转的 b。底部总结条:左右指针只进不退,全程 O(n),换目标字符再跑一遍即可 图 2:四格推演快照,看窗口扩张与收缩的每一步。

面试现场的答题结构。

先把题意翻译成「区间内某种字符数 ≤ m」——这一步是整个答案的枢纽,面试官最想听的就是它;再给出 O(n²) 暴力(枚举所有区间统计字符数)作为基线,指出瓶颈在重复统计;然后引出窗口不变量与单调指针,写出代码;最后主动跑边界:空串、m = 0(退化为最长连续相同字符)、m 不小于串长。这套「翻译 → 基线 → 优化 → 验证」的顺序,比直接背出窗口代码更能体现推导能力。

跳出笔试本身,这个模型在工程里也有影子:流式监控里「允许少量异常点的最长稳定区间」——比如一分钟内至多容忍 3 个错误样本,求最长的健康时间段——和本题是同一个窗口结构,把「待翻转字符」换成「异常样本」即可。所以这道题练的不只是一次笔试,而是在线算法里复用率极高的一块基本功。

留个延伸问题:如果字符集从 a、b 两个扩展到任意小写字母,这套窗口模板还成立吗?对每个目标字符各扫一遍的代价是多少,有没有办法只扫一遍?欢迎在评论区聊聊你的想法。