一、题目描述
给定两个字符串
s和p,找到s中所有p的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。示例 1:
输入: s = "cbaebabacd", p = "abc" 输出: [0,6] 解释: 起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。 起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。示例 2:
输入: s = "abab", p = "ab" 输出: [0,1,2] 解释: 起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。 起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。 起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。提示:
1 <= s.length, p.length <= 3 * 104
s和p仅包含小写字母
二、思路分析
第一步:理解"异位词"的本质
思考1:怎么判断两个字符串是异位词?
-
长度必须相同
-
每个字符出现的次数必须完全一样
举例:
-
p = "abc"→ 字符频率:a:1, b:1, c:1 -
s的子串"bca"→ 字符频率:a:1, b:1, c:1 → 是异位词 -
s的子串"aab"→ a:2, b:1 → 不是
Java知识点1:字符与ASCII码
在Java中,
char类型本质是整数(ASCII码),我们可以用:
char c = 'a'; int index = c - 'a'; // 'a'→0, 'b'→1, ..., 'z'→25→ 这样可以用 长度为26的数组 表示26个小写字母的频率!
第二步:暴力法 → 优化方向
暴力思路:
-
遍历
s中每个长度为p.length()的子串 -
对每个子串,统计字符频率,和
p比较 -
时间复杂度:O(n × m),其中 n 是 s 长度,m 是 p 长度 → 太慢!
优化方向:滑动窗口 + 频率数组复用
我们发现:
相邻的两个子串,只有第一个字符被移除,最后一个字符被加入
我们不需要每次都重新统计整个子串的频率!只需更新两个字符的计数!
这就是滑动窗口的核心思想!
第三步:设计滑动窗口结构
步骤分解:
-
创建两个数组
pFreq[26]和windowFreq[26],分别记录p和当前窗口的字符频率。 -
初始化:先把
p的字符频率统计好。 -
用双指针
left和right控制窗口:-
right向右扩展,加入新字符 -
当窗口大小 ==
p.length()时:-
比较
pFreq和windowFreq -
如果相等 → 记录
left -
然后
left++,移除左端字符,继续滑动
-
-
第四步:动手写代码 ------ 一步一步来!
Step 1:准备结果容器和边界判断
java
import java.util.*;
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> result = new ArrayList<>(); // 存放结果索引
// 边界情况:s为空、p为空、s比p短 → 不可能有异位词
if (s == null || p == null || s.length() < p.length()) {
return result;
}
// 后续代码写在这里...
}
}
Java知识点2:ArrayList
List<Integer> result = new ArrayList<>();→ 动态数组,可自动扩容
.add(index)→ 添加元素
Step 2:创建频率数组,初始化 pFreq
java
int[] pFreq = new int[26]; // p的字符频率
int[] windowFreq = new int[26]; // 当前窗口的字符频率
// 填充 pFreq
for (char c : p.toCharArray()) {
pFreq[c - 'a']++;
}
Java知识点3:for-each 循环 & toCharArray()
p.toCharArray()→ 把字符串转成字符数组
for (char c : array)→ 遍历每个字符,简洁安全算法思想:计数排序/桶排序思想
- 用数组下标表示字符,值表示出现次数 → O(1) 查找和更新
Step 3:滑动窗口主循环
我们用 right 控制右边界,left 控制左边界。
java
int left = 0;
for (int right = 0; right < s.length(); right++) {
// 1. 把 s[right] 加入窗口
char rightChar = s.charAt(right);
windowFreq[rightChar - 'a']++;
// 2. 如果窗口大小等于 p 的长度,开始检查
if (right - left + 1 == p.length()) {
// 检查是否是异位词
if (Arrays.equals(pFreq, windowFreq)) {
result.add(left);
}
// 3. 移动左边界:移除 s[left]
char leftChar = s.charAt(left);
windowFreq[leftChar - 'a']--;
left++;
}
}
Java知识点4:Arrays.equals()
- 比较两个数组内容是否完全相等 → 非常适合比较频率数组!
算法思想:滑动窗口
窗口大小固定(= p.length())
每次右移一步,左移一步 → 保持窗口大小不变
频率数组只需更新"进"和"出"的字符 → 高效!
Step 4:完整代码整合
java
import java.util.*;
import java.util.Arrays;
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> result = new ArrayList<>();
if (s == null || p == null || s.length() < p.length()) {
return result;
}
int[] pFreq = new int[26];
int[] windowFreq = new int[26];
// 初始化 p 的频率
for (char c : p.toCharArray()) {
pFreq[c - 'a']++;
}
int left = 0;
for (int right = 0; right < s.length(); right++) {
// 加入右端字符
windowFreq[s.charAt(right) - 'a']++;
// 窗口大小达到 p 的长度
if (right - left + 1 == p.length()) {
// 检查是否是异位词
if (Arrays.equals(pFreq, windowFreq)) {
result.add(left);
}
// 移除左端字符,窗口右移
windowFreq[s.charAt(left) - 'a']--;
left++;
}
}
return result;
}
}
第五步:算法复杂度分析
-
时间复杂度:O(n)
-
s只遍历一次,每个字符最多进窗口一次、出窗口一次 -
每次比较频率数组是 O(26) = O(1)
-
-
空间复杂度:O(1)
- 只用了两个固定长度26的数组 + 一些变量
第六步:举个例子走一遍流程(手动模拟)
假设:
-
s = "cbaebabacd" -
p = "abc"
pFreq = [1,1,1,0,0,...] (a,b,c 各1次)
滑动过程:
| right | 窗口内容 | windowFreq | 是否等于 pFreq | left 移动后 |
|---|---|---|---|---|
| 0 | "c" | [0,0,1,...] | 否 | - |
| 1 | "cb" | [0,1,1,...] | 否 | - |
| 2 | "cba" | [1,1,1,...] | 是 → 记录 left=0 | left=1 |
| 3 | "bae" | [1,1,0,0,1,...] | 否 | left=2 |
| ... | ... | ... | ... | ... |
| 6 | "bac" | [1,1,1,...] | 是 → 记录 left=5 | left=6 |
最终结果:[0, 6]
总结
| 项目 | 内容 |
|---|---|
| 算法思想 | 滑动窗口、字符频率统计、异位词判定 |
| 数据结构 | 固定数组(桶)、ArrayList |
| Java技巧 | toCharArray(), charAt(), Arrays.equals(), for-each循环 |
| 优化关键 | 避免重复计算,窗口滑动时只更新"进出"字符 |
| 复杂度 | 时间O(n),空间O(1) |
暴力算法解决
Step 1:边界判断
如果
s比p短,直接返回空列表。
javaif (s.length() < p.length()) return new ArrayList<>();Step 2:外层循环 ------ 枚举所有起始位置
从
i = 0到i = s.length() - p.length(),每个i作为子串起始点:
javafor (int i = 0; i <= s.length() - p.length(); i++) { String substring = s.substring(i, i + p.length()); // 判断 substring 和 p 是否是异位词 }Java知识点:substring(beginIndex, endIndex)
substring(i, i + len)→ 截取[i, i+len)区间的子串(左闭右开)时间复杂度 O(len),每次都会创建新字符串对象 → 开销大!
Step 3:判断两个字符串是否是异位词 ------ 写一个 helper 函数
我们可以写一个函数
isAnagram(String a, String b):方法一:排序法(最直观)
javaprivate boolean isAnagram(String a, String b) { char[] aChars = a.toCharArray(); char[] bChars = b.toCharArray(); Arrays.sort(aChars); Arrays.sort(bChars); return Arrays.equals(aChars, bChars); }Java知识点:Arrays.sort() & Arrays.equals()
Arrays.sort(char[])→ 对字符数组排序,O(n log n)
Arrays.equals()→ 逐个比较数组元素是否相等举例:
"abc"→ 排序 →"abc"
"bca"→ 排序 →"abc"→ 相等 → 是异位词!Step 4:如果判断为异位词,就把起始索引
i加入结果
javaif (isAnagram(substring, p)) { result.add(i); }完整暴力算法代码
javaimport java.util.*; import java.util.Arrays; class Solution { public List<Integer> findAnagrams(String s, String p) { List<Integer> result = new ArrayList<>(); // 边界情况 if (s == null || p == null || s.length() < p.length()) { return result; } int len = p.length(); // 遍历所有起始位置 for (int i = 0; i <= s.length() - len; i++) { String substring = s.substring(i, i + len); if (isAnagram(substring, p)) { result.add(i); } } return result; } private boolean isAnagram(String a, String b) { char[] aChars = a.toCharArray(); char[] bChars = b.toCharArray(); Arrays.sort(aChars); Arrays.sort(bChars); return Arrays.equals(aChars, bChars); } } 时间复杂度分析
外层循环次数:
n - m + 1≈ O(n),其中n = s.length(),m = p.length()每次循环:
substring()→ O(m)
toCharArray()→ O(m)
Arrays.sort()→ O(m log m)总时间复杂度:O(n × m log m)
举例:如果
s长度是 10000,p长度是 100 → 每次排序 100 log100 ≈ 700 操作,总共 9900 次 → 700×9900 ≈ 700万次 → 在 Java 中勉强能过(但很慢)暴力法 vs 滑动窗口法 对比
项目 暴力法 滑动窗口法 思路 简单直接,容易想到 需要理解"窗口滑动"和"频率复用" 时间复杂度 O(n × m log m) 或 O(n × m) O(n) 空间复杂度 O(m) ------ 每次生成子串和数组 O(1) ------ 固定两个数组 是否创建新字符串 每次都 substring只用 charAt,不创建子串适合场景 数据量小、快速验证逻辑 数据量大、追求效率