不知道你有没有这种感觉:刷字符串子串题的时候,脑子第一反应永远是暴力截取 + 对比。我当初刷这道题的时候就是这德行,心想不就是找字母异位词吗?截出来排个序对比不就完了?结果写完跑示例秒过,一提交直接超时,给我整不会了。
题目说穿了很简单
给两个字符串 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 吧。
javascript
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,代码会更短。
javascript
// 偷懒写法:LeetCode 环境默认支持 lodash,可直接使用
if (_.isEqual(windowFreq, targetFreq)) {
res.push(left);
}
不过刷题还是建议手写对比逻辑,毕竟只有 26 次循环,性能完全够用,还能练一下底层实现思路。
对了,左边界 right - pLen + 1 这个计算我一开始也写错过,少写了 +1,窗口长度永远差一位,找 bug 找了半天,属实是经典的边界问题了。
复杂度到底怎么算?
时间复杂度严格来讲是 O(n + m × |Σ|) 。其中 n 是 p 的长度,m 是 s 的长度,|Σ| 代表字符集的大小 ------ 这题限定了小写字母,所以 |Σ| = 26。拆解一下:
- 提前统计 p 的字母频率,遍历一遍 p,开销是 O (n)
- 滑动窗口遍历 s 的过程中,每个字符进出窗口都是 O (1) 操作,这部分是 O (m)
- 每次窗口长度达标后,要对比两个频率数组是否一致,单次对比要遍历 26 个位置,也就是 O (|Σ|);全程最多对比 n 次,所以这部分是 O (m × |Σ|)
当然因为 26 是个固定常数,不会随输入规模增长,工程上笼统说它是线性时间也没问题,但面试的时候说完整会更显功底。
空间复杂度是 O(|Σ|) 。我们只开了两个长度固定为 26 的计数数组,额外空间不会跟着输入字符串变长而变大,属于常数级别的空间开销。
最后唠两句
这题算是滑动窗口的经典入门题,核心就是抓住「固定窗口大小」的特点,复用中间的计数结果,避免重复计算。
给大家提几个我踩过的坑:
- 别上来就写暴力排序,长字符串必超时
- 窗口左右边界的索引要算仔细,差 1 是常事
- 刷题可以用工具函数,但最好能手写核心逻辑,面试才不慌
其实滑动窗口练熟了,这类子串问题套路性很强,手到擒来。
你刷这道题的时候,第一想法是啥?有没有在边界计算上栽过跟头?评论区聊聊呗,我每条都会看~如果觉得这篇题解对你有帮助,点个赞让更多小伙伴看到呀