在解决LeetCode上的"找到字符串中所有字母异位词"问题时,我们往往从直观的解法入手,但很快会遇到性能瓶颈。本文将带你从最初的暴力解法出发,逐步优化到高效的滑动窗口解法,深入理解算法优化的思考过程。
问题描述
给定两个字符串 s
和 p
,我们需要在 s
中找到所有 p
的字母异位词的起始索引。字母异位词指字母相同但排列不同的字符串。
示例:
makefile
输入: s = "cbaebabacd", p = "abc"
输出: [0, 6]
解释:
起始索引 0 的子串是 "cba",是 "abc" 的异位词。
起始索引 6 的子串是 "bac",是 "abc" 的异位词。
解法一:暴力排序法(超时)
初始思路
刚开始正好看了split和join函数,用split转列表,再列表排序,用join改回来字符最后比较就行,我说这题不是秒杀吗?结果我是小丑了。
最直观的想法是将 p
排序,然后在 s
中截取所有长度为 p.length
的子串,排序后与排序后的 p
比较。
javascript
var findAnagrams = function(s, p) {
let res = [];
let i = 0;
//转列表排序,在转回来
p = p.split('').sort().join('');
while(i < s.length) {
//取与p相同长度字符相比较
let temp = s.slice(i, i + p.length).split('').sort().join('');
if(temp === p) {
res.push(i);
}
i++;
}
return res;
};
复杂度分析
一开始还在为只有一个循环沾沾自喜,我忘了函数也有mlog m的时间复杂度,再乘以for的n,那就有点high了,运行结果自然就是超时了。
- 时间复杂度 :O(n * m log m),其中n是s的长度,m是p的长度
- 对每个子串进行排序需要O(m log m)
- 共有n个子串需要检查
- 空间复杂度:O(m),用于存储排序后的子串
缺陷分析
当字符串较长时(如s长度10^5,p长度10^4),这种解法会超时,因为排序操作在循环内重复执行,效率太低。
解法二:滑动窗口+字符计数(优化解法)
优化思路
我们可以使用滑动窗口和字符计数的方法来优化:
- 统计
p
中每个字符的出现次数 - 维护一个与
p
长度相同的滑动窗口 - 统计窗口内字符的出现次数
- 比较窗口内字符计数与
p
的字符计数是否一致
优化后的代码
javascript
var findAnagrams = function(s, p) {
const res = [];
const pLen = p.length;
const sLen = s.length;
if (sLen < pLen) return res;
// 初始化p的字符计数
const pCount = new Array(26).fill(0);
for (let char of p) {
pCount[char.charCodeAt() - 'a'.charCodeAt()]++;
}
// 初始化滑动窗口的字符计数
const windowCount = new Array(26).fill(0);
for (let i = 0; i < pLen; i++) {
windowCount[s.charCodeAt(i) - 'a'.charCodeAt()]++;
}
// 比较初始窗口
if (arraysEqual(windowCount, pCount)) {
res.push(0);
}
// 滑动窗口
for (let i = pLen; i < sLen; i++) {
// 移除左边界的字符
windowCount[s.charCodeAt(i - pLen) - 'a'.charCodeAt()]--;
// 添加右边界的字符
windowCount[s.charCodeAt(i) - 'a'.charCodeAt()]++;
// 比较当前窗口
if (arraysEqual(windowCount, pCount)) {
res.push(i - pLen + 1);
}
}
return res;
};
// 辅助函数:比较两个数组是否相等
function arraysEqual(a, b) {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
复杂度分析
这里循环虽然多,但都是自己玩自己的,乘不到一块去,复杂度只有n。
- 时间复杂度 :O(n),其中n是s的长度
- 只需要遍历s一次
- 每次窗口滑动是常数时间操作
- 空间复杂度 :O(1)
- 只使用了固定大小的计数数组(26个字母)
优化点分析
- 避免重复排序:使用字符计数代替排序
- 滑动窗口技巧:每次只更新两个字符的计数
- 常数时间比较:通过预计算p的字符计数
两种解法的对比
特性 | 暴力排序法 | 滑动窗口法 |
---|---|---|
时间复杂度 | O(n * m log m) | O(n) |
空间复杂度 | O(m) | O(1) |
适用数据规模 | 小规模数据 | 大规模数据 |
核心操作 | 排序比较 | 字符计数比较 |
性能表现 | 容易超时 | 高效稳定 |

关键思路演进
- 从比较内容到比较特征:从直接比较字符串内容,转变为比较字符出现次数这一特征
- 从重新计算到增量更新:利用滑动窗口避免重复计算,只更新变化的字符计数
- 从O(m)比较到O(1)比较:通过预计算将每次比较的时间复杂度降低到常数
总结
通过这个问题的解决过程,我们可以学到:
- 直观解法往往不是最优解,需要考虑时间复杂度
- 字符串问题中,字符计数是常用的优化手段
- 滑动窗口技巧能有效减少重复计算
- 算法优化常常需要从问题特征入手,寻找更高效的比较方式
掌握这种从暴力解法到优化解法的思考过程,对提升算法能力大有裨益。在实际面试中,即使先提出暴力解法,也能展示出逐步优化的思维能力,这也是面试官看重的。