图 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)。
图 2:四格推演快照,看窗口扩张与收缩的每一步。
面试现场的答题结构。
先把题意翻译成「区间内某种字符数 ≤ m」——这一步是整个答案的枢纽,面试官最想听的就是它;再给出 O(n²) 暴力(枚举所有区间统计字符数)作为基线,指出瓶颈在重复统计;然后引出窗口不变量与单调指针,写出代码;最后主动跑边界:空串、m = 0(退化为最长连续相同字符)、m 不小于串长。这套「翻译 → 基线 → 优化 → 验证」的顺序,比直接背出窗口代码更能体现推导能力。
跳出笔试本身,这个模型在工程里也有影子:流式监控里「允许少量异常点的最长稳定区间」——比如一分钟内至多容忍 3 个错误样本,求最长的健康时间段——和本题是同一个窗口结构,把「待翻转字符」换成「异常样本」即可。所以这道题练的不只是一次笔试,而是在线算法里复用率极高的一块基本功。
留个延伸问题:如果字符集从 a、b 两个扩展到任意小写字母,这套窗口模板还成立吗?对每个目标字符各扫一遍的代价是多少,有没有办法只扫一遍?欢迎在评论区聊聊你的想法。