精选专栏链接 🔗
欢迎订阅,点赞+关注,每日精进1%,与百万开发者共攀技术珠峰
更多内容持续更新中~
【LeetCode 热题 100】找到字符串中所有字母异位词
- 📝题目描述
- 💡提示信息
- [📌 字母异位词是什么](#📌 字母异位词是什么)
- [🔍 解题思路一:暴力枚举](#🔍 解题思路一:暴力枚举)
- [🔍 解题思路二:滑动窗口 + 数组计数](#🔍 解题思路二:滑动窗口 + 数组计数)
📝题目描述
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
示例 1:
bash
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
示例 2:
bash
输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
💡提示信息
- 1 <= s.length, p.length <= 3 * 10 4 10^4 104;
- s 和 p 仅包含小写字母;
📌 字母异位词是什么
字母异位词指的是两个字符串包含的字符完全相同(每个字符出现次数一致),仅顺序不同。例如 "abc" 和 "bca" 是异位词, "aab" 和 "aba" 也是异位词。
🔍 解题思路一:暴力枚举
既然要找 s 中长度为 p.length() 的子串,最直观的做法是:
- 截取:从 s 的第 0 位开始,每次截取一段长度等于 p 的子串;
- 比较:拿着这个子串去跟 p 比,看是不是异位词;
- 移动:起始位置 +1,重复上述步骤。
暴力枚举法代码实现如下:
java
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> result = new ArrayList<>();
int sLen = s.length();
int pLen = p.length();
// 将 p 转为字符数组并排序,作为比较的标准
char[] pChars = p.toCharArray();
Arrays.sort(pChars);
String sortedP = new String(pChars);
// 遍历 s 中所有可能的子串起点
for(int i = 0 ; i <= sLen - pLen ; i++){
// 截取长度为 pLen 的子串,注意substring字母都是小写
String sub = s.substring(i, i + pLen);
// 将子串也排序后与 p 比较
char[] subChars = sub.toCharArray();
Arrays.sort(subChars);
String sortedSub = new String(subChars);
if(sortedP.equals(sortedSub)){
result.add(i);
}
}
return result;
}
}
提交代码,运行结果如下:

可以看出,暴力枚举法效率实际上并不高。
暴力枚举法的缺陷
假设 s 的长度是 N ,p 的长度是 M 。我们要移动约 N 次。每次移动后,都要重新排序。
- 暴力枚举法的时间复杂度为 𝑂(𝑁⋅𝑀log𝑀) 。每次循环都要对子串进行排序;
- 暴力枚举法的空间复杂度为 𝑂(𝑀) ,用于存储排序后的字符数组。
知识点补充
在 Java 中,Arrays.sort() 对基本数据类型(如 char[])使用的是双轴快速排序,其平均时间复杂度是 O ( n log n ) O(n \log n) O(nlogn)。其中 n 是字符串 p 的长度。
🔍 解题思路二:滑动窗口 + 数组计数
我们需要在 s 中寻找长度恰好等于 p.length() 的子串。如果使用暴力枚举,每次窗口向右移动一格,我们都要重新统计整个新窗口的字符频率,这会造成大量的重复计算 。而滑动窗口的核心思想是 "增量更新" 。当窗口向右滑动一格时,实际上只发生了两件事:
- 左边界有一个字符离开了窗口;
- 右边界有一个新字符进入了窗口。
因此,我们只需要在旧的统计结果上,把离开的字符计数减 1,把进来的字符计数加 1,就能在 O(1) 的时间内得到新窗口的状态。具体例子如下:
bash
① 初始窗口 [0, 2]:子串是 "cba"。我们需要遍历这3个字符,统计出频率 {c:1, b:1, a:1};
② 窗口右移一格 [1, 3]:子串变成了 "bae";
③ 注意观察!新窗口 "bae" 和旧窗口 "cba" 中间重叠了 "ba";
④ 此时我们只需把刚离开窗口的左边字符 'c' 的计数减 1。把刚进入窗口的新字符 'e' 的计数加 1;
⑤ 经过两步简单的加减法,sCount 数组变成了 {b:1, a:1, e:1},这正是新窗口 "bae" 的频率!
代码实现如下:
java
class Solution {
public List<Integer> findAnagrams(String s, String p) {
int sLen = s.length();
int pLen = p.length();
// 1. 边界处理:如果 s 比 p 还短,不可能存在异位词,直接返回空列表
if(sLen < pLen){
return new ArrayList<Integer>();
}
List<Integer> result = new ArrayList<Integer>();
// 2. 初始化两个频率统计数组,长度为 26 对应 26 个小写英文字母
int[] sCount = new int[26];
int[] pCount = new int[26];
// 3. 构建初始窗口:统计 p 的字母频率,以及 s 中前 pLen 个字母频率
for(int i =0; i < pLen; i++){
// s.charAt(i) - 'a',可将字符映射为数组索引。'a'的ASCII 值为 97
++sCount[s.charAt(i) - 'a'];
++pCount[p.charAt(i) - 'a'];
// 此时sCount 记录了 s 中第一个窗口内各字母出现的频率,pCount 记录了目标字符串的字母频率
}
// 4. 检查 初始窗口 是否匹配
if (Arrays.equals(sCount, pCount)) {
result.add(0);
}
// 5. 核心滑动过程:窗口从索引 1 开始,一直滑动到 s 的末尾
for (int i = 0; i < sLen - pLen; i++) {
// 【出】窗口左边界字符离开:将 s[i] 的计数减 1
--sCount[s.charAt(i) - 'a'];
// 【进】窗口右边界新字符进入:将 s[i + pLen] 的计数加 1
++sCount[s.charAt(i + pLen) - 'a'];
// 6. 比较当前窗口与目标字符串 p 的频率是否一致
if (Arrays.equals(sCount, pCount)) {
// 注意:此时窗口的起始索引是 i + 1
result.add(i + 1);
}
}
return result;
}
}
提交代码,运行结果如下:

滑动窗口法 复杂度分析
时间复杂度: O(N) ,其中 N 是字符串 s 的长度,M 为字符串 p 的长度:
- 初始化两个数组和初始窗口需要 O(M) ;
- 滑动窗口过程需遍历 O(N−M) 次,每次操作(加减计数 + 比较 26 个元素)都是 O(1) ;
- 总体来看,时间复杂度与 s 的长度成线性关系。
空间复杂度: O(1) :
- 仅使用了两个固定大小为 26 的整型数组 sCount 和 pCount,以及几个辅助变量,不随输入数据的规模增长。