不知道你有没有这种感觉:刷字符串子串题的时候,脑子第一反应永远是暴力截取 + 对比。我当初刷这道题的时候就是这德行,心想不就是找字母异位词吗?截出来排个序对比不就完了?结果写完跑示例秒过,一提交直接超时,给我整不会了。
题目说穿了很简单
给两个字符串 s 和 p,找出 s 里所有是 p 的异位词的子串,返回它们的起始下标。异位词就是字母种类和数量完全一样,只是顺序不同,比如 "abc" 和 "bac" 就是一对。
最开始的笨办法:暴力对比
最开始我思路特别直,完全没多想:
- 遍历 s,每次截一段长度和 p 一样的子串
- 把子串和 p 都排个序,转成字符串比一比
- 一样就把起始下标塞进结果里
写起来是真快,三五行就搞定了,跑示例也没问题。可一提交碰到长字符串直接超时 —— 毕竟每次排序都有开销,再套一层循环,数据量大了根本顶不住。
后来开窍了:固定窗口 + 字母计数
踩了超时的坑我才反应过来:我们对比的本质是「字母出现的次数」,排序完全是绕了个大弯。而且窗口每次只往右挪一位,中间的字符计数是可以复用的,根本不用每次从头算。
这不就是典型的固定大小滑动窗口嘛!
思路其实特别顺:
- 因为都是小写字母,用两个长度 26 的数组,分别存「目标 p 的字母频率」和「当前窗口的字母频率」
- 窗口长度固定是 p 的长度,右指针一步步往右走,把新字符加进窗口计数
- 窗口长度凑够 p 的长度时,对比两个计数数组:完全一致就是异位词
- 对比完把窗口最左边的字符减掉,窗口整体右移一位
拿示例 1 举个例子就懂了:s = "cbaebabacd",p = "abc"(长度 3)
- 右指针走到索引 2,窗口是 "cba",计数和 p 一致,把起始索引 0 加入结果
- 窗口右移,去掉最左的 c,加上索引 3 的 e,窗口变成 "bae",计数不一致
- 继续右移,重复 “加右边、减左边、对比” 的操作,直到遍历完 s
比每次重新排序高效太多了。
代码实现(JavaScript)
这里纠正一个我之前的认知误区:我以前一直以为 LeetCode 没引入 lodash,后来才发现人家 JS 环境默认就挂了全局 _,_.isEqual 是能直接跑的。不过我还是更推荐自己封装对比函数,一来逻辑更轻量,二来面试手写也不会慌,总不能面试的时候跟面试官说我调个 lodash 吧。
var findAnagrams = function (s, p) {
const sLen = s.length;
const pLen = p.length;
const res = [];
// 用数组存a-z的频率,比Map省事儿,毕竟只有26个小写字母
const windowFreq = new Array(26).fill(0);
const targetFreq = new Array(26).fill(0);
const aCode = 'a'.charCodeAt(0);
// 封装频率数组对比函数,主逻辑更清爽
const isFreqEqual = (arr1, arr2) => {
for (let i = 0; i < 26; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
// 先统计目标串p的字母出现次数
for (let i = 0; i < pLen; i++) {
targetFreq[p[i].charCodeAt(0) - aCode]++;
}
// 右指针遍历s,一步步滑动窗口
for (let right = 0; right < sLen; right++) {
// 当前字符进入窗口,计数+1
const curIndex = s[right].charCodeAt(0) - aCode;
windowFreq[curIndex]++;
// 计算当前窗口的左边界
const left = right - pLen + 1;
// 窗口还没凑够p的长度,先不对比
if (left < 0) continue;
// 调用封装好的对比函数,判断当前窗口是不是异位词
if (isFreqEqual(windowFreq, targetFreq)) {
res.push(left);
}
// 窗口右移前,把左边界的字符移出计数
// 别问我为什么写在对比后面,我写反过,卡了十分钟
const leftIndex = s[left].charCodeAt(0) - aCode;
windowFreq[leftIndex]--;
}
return res;
};
顺手补一个偷懒写法,LeetCode 上能直接运行:把对比那一行换成 _.isEqual,代码会更短。
// 偷懒写法:LeetCode 环境默认支持 lodash,可直接使用
if (_.isEqual(windowFreq, targetFreq)) {
res.push(left);
}
不过刷题还是建议手写对比逻辑,毕竟只有 26 次循环,性能完全够用,还能练一下底层实现思路。
对了,左边界 right - pLen + 1 这个计算我一开始也写错过,少写了 +1,窗口长度永远差一位,找 bug 找了半天,属实是经典的边界问题了。
进阶优化:更优雅的单数组滑动窗口
上面的双数组写法好理解,但每次都要遍历 26 个字母对比,还是有点啰嗦。我后来刷多了才发现,其实只用一个计数数组就能搞定,连对比步骤都省了,写起来更清爽。
核心思路换个角度想:我们先给 p 里的每个字母预存 “出现次数额度”。窗口右移加入新字符时,就消耗一个对应字母的额度;如果额度减成负数了,说明当前窗口里这个字母太多了,超过了 p 里的数量。这时候我们就不断右移左边界,把移出窗口的字母额度还回去,直到当前字母的额度不再是负数。神奇的地方来了:当窗口长度刚好等于 p 的长度时,就说明窗口里所有字母的额度都刚好匹配,就是我们要找的异位词。
为啥不用再逐个对比了?因为我们保证了窗口里每个字母的数量都不会超过 p 里的数量,而总长度又和 p 一样,那自然每个字母的数量都恰好相等。这个思路我第一次看懂的时候拍了下大腿,太巧妙了。
代码写出来是这样的:
var findAnagrams = function(s, p) {
// 只用一个计数数组,先统计 p 的字母额度
const cnt = new Array(26).fill(0);
for (const c of p) {
cnt[c.charCodeAt() - 'a'.charCodeAt()]++;
}
const ans = [];
let left = 0;
for (let right = 0; right < s.length; right++) {
const c = s[right].charCodeAt() - 'a'.charCodeAt();
cnt[c]--; // 右端点字母进入窗口,消耗一个额度
// 当前字母额度为负,说明窗口里这个字母太多了,收缩左边界
while (cnt[c] < 0) {
cnt[s[left].charCodeAt() - 'a'.charCodeAt()]++; // 左端点字母离开窗口,归还额度
left++;
}
// 窗口长度恰好等于 p 的长度,说明完美匹配
if (right - left + 1 === p.length) {
ans.push(left);
}
}
return ans;
};
这个写法不仅变量更少,还省掉了每次循环里对比 26 个字母的开销,逻辑也很顺,我现在写这题优先用这个版本。
复杂度到底怎么算?
这里分开说两种写法的复杂度,也方便大家对比差异。
第一种双数组固定窗口的写法,时间复杂度严格来讲是 O(m + n × |Σ|) 。其中 m 是 p 的长度,n 是 s 的长度,|Σ| 是字符集大小,这题里就是 26。主要开销是统计 p 的 O (m),加上遍历 s 的 O (n),再乘上每次对比 26 个字母的 O (|Σ|)。因为 26 是固定常数,日常说它是线性时间也没问题,但面试的时候说完整会更显功底。
第二种单数组优化的写法,时间复杂度就是标准的 O(m + n) 。每个字符最多进入窗口一次、移出窗口一次,没有额外的遍历对比开销,是真正的纯线性时间,效率更高。
两种写法的空间复杂度都是 O(|Σ|) ,都只开了常数大小的计数数组,不会随输入规模增长。
最后唠两句
这题算是滑动窗口的经典入门题,核心就是抓住「固定窗口大小」的特点,复用中间的计数结果,避免重复计算。
给大家提几个我踩过的坑:
- 别上来就写暴力排序,长字符串必超时
- 窗口左右边界的索引要算仔细,差 1 是常事
- 刷题可以用工具函数,但最好能手写核心逻辑,面试才不慌
其实滑动窗口练熟了,这类子串问题套路性很强,手到擒来。
你刷这道题的时候,第一想法是啥?有没有在边界计算上栽过跟头?评论区聊聊呗,我每条都会看~如果觉得这篇题解对你有帮助,点个赞让更多小伙伴看到呀