1. 问题理解与解法概述
今天想和大家分享一道力扣经典字符串处理题目——438. 找到字符串中所有字母异位词。这道题看似简单,但蕴含着滑动窗口和字符统计的精妙技巧。我在实际解题过程中踩过不少坑,也总结出了一些优化心得,希望能帮助到正在刷题的你。
1.1 什么是字母异位词?
字母异位词(Anagram)是指由相同字母重新排列组合形成的不同单词或短语。比如"listen"和"silent"就是一组典型的字母异位词。在本题中,我们需要在字符串s中找到所有与p构成字母异位词的子串,并返回它们的起始索引。
注意:字母异位词不仅要求字符种类相同,每个字符的出现次数也必须完全一致。例如"aab"和"abb"就不是字母异位词。
1.2 暴力解法的问题
最直观的解法是枚举s中所有长度为len(p)的子串,然后逐个检查是否为p的字母异位词。这种方法虽然直接,但时间复杂度高达O(n×m)(n为s长度,m为p长度),当字符串较长时(比如n=10^5),这种解法显然无法接受。
我在最初尝试时就用这种方法,结果在力扣的大数据测试用例上直接超时。这促使我开始思考更高效的解决方案。
1.3 滑动窗口的优化思路
滑动窗口(Sliding Window)是处理子串/子数组问题的利器。对于本题,由于我们要找的是固定长度(len(p))的子串,因此可以使用固定大小的滑动窗口:
- 初始化窗口大小为len(p)
- 窗口从s的起始位置开始滑动
- 每次滑动时,只关注"离开窗口"和"进入窗口"的字符
- 维护一个实时更新的字符频次统计
这种方法将时间复杂度降到了O(n),因为每个字符最多被处理两次(进入和离开窗口各一次)。
2. 核心算法实现细节
2.1 数组计数的选择
在处理小写字母统计问题时,使用长度为26的数组通常比哈希表更高效。原因有三:
- 数组的访问时间是O(1),且常数因子更小
- 可以直接用==操作符比较两个数组是否相同
- 不需要处理哈希冲突等问题
python复制# 初始化频次数组
p_count = [0] * 26
for char in p:
p_count[ord(char) - ord('a')] += 1
提示:ord(char) - ord('a')是将小写字母映射到0-25的标准方法。例如'a'→0,'b'→1,依此类推。
2.2 滑动窗口的实现
完整的滑动窗口实现需要注意以下几个关键点:
- 边界条件处理:当p比s长时直接返回空列表
- 初始窗口的统计:需要先统计s的前len(p)个字符
- 窗口滑动时的更新:正确处理移出和移入的字符
python复制class Solution:
def findAnagrams(self, s: str, p: str) -> list[int]:
len_p, len_s = len(p), len(s)
if len_p > len_s:
return []
res = []
p_count = [0] * 26
s_count = [0] * 26
# 初始化p的频次统计
for char in p:
p_count[ord(char) - ord('a')] += 1
# 初始化滑动窗口的初始状态
for i in range(len_p):
s_count[ord(s[i]) - ord('a')] += 1
# 检查初始窗口
if s_count == p_count:
res.append(0)
# 滑动窗口
for right in range(len_p, len_s):
# 新进入窗口的字符
in_char = s[right]
s_count[ord(in_char) - ord('a')] += 1
# 移出窗口的字符
out_char = s[right - len_p]
s_count[ord(out_char) - ord('a')] -= 1
# 检查当前窗口
if s_count == p_count:
res.append(right - len_p + 1)
return res
2.3 索引计算的小技巧
在滑动窗口过程中,正确计算起始索引很重要。这里有个容易出错的地方:
当窗口右边界移动到right位置时,对应的子串起始位置应该是right - len_p + 1,而不是right - len_p。这是因为字符串索引是从0开始的。
例如,当len_p=3,right=3时:
- 子串范围是s[1:4](Python中为s[1:4])
- 起始索引是1,即3-3+1=1
3. 性能优化进阶
3.1 diff变量优化
虽然数组比较已经很快了,但每次比较两个长度为26的数组仍然需要26次比较。我们可以引入一个diff变量来记录当前窗口与目标p的差异度:
python复制# 优化版本的核心部分
diff = 0
for i in range(26):
if p_count[i] != s_count[i]:
diff += 1
if diff == 0:
res.append(left)
这样,我们只需要在字符频次变化时更新diff,而不需要每次都完整比较两个数组。
3.2 优化后的完整实现
python复制class Solution:
def findAnagrams(self, s: str, p: str) -> list[int]:
len_p, len_s = len(p), len(s)
if len_p > len_s:
return []
res = []
p_count = [0] * 26
s_count = [0] * 26
for char in p:
p_count[ord(char) - ord('a')] += 1
# 初始窗口
for i in range(len_p):
s_count[ord(s[i]) - ord('a')] += 1
# 计算初始差异
diff = 0
for i in range(26):
if p_count[i] != s_count[i]:
diff += 1
if diff == 0:
res.append(0)
# 滑动窗口
for right in range(len_p, len_s):
# 处理进入字符
in_char = s[right]
in_index = ord(in_char) - ord('a')
# 更新差异
if s_count[in_index] == p_count[in_index]:
diff += 1
s_count[in_index] += 1
if s_count[in_index] == p_count[in_index]:
diff -= 1
# 处理移出字符
out_char = s[right - len_p]
out_index = ord(out_char) - ord('a')
if s_count[out_index] == p_count[out_index]:
diff += 1
s_count[out_index] -= 1
if s_count[out_index] == p_count[out_index]:
diff -= 1
# 检查差异
if diff == 0:
res.append(right - len_p + 1)
return res
这种优化在理论时间复杂度上仍然是O(n),但实际运行时会快很多,特别是在处理长字符串时。
4. 常见错误与调试技巧
4.1 边界条件处理
在实际编码中,有几个边界条件容易忽略:
- 当p比s长时,应该直接返回空列表
- 初始窗口的检查不能遗漏
- 索引计算要准确,特别是窗口起始位置
4.2 字符统计的陷阱
在处理字符统计时,我遇到过以下典型错误:
- 忘记将字符转换为0-25的索引
- 在更新频次数组时混淆了加减操作
- 没有正确处理初始窗口的统计
调试时可以打印出窗口滑动过程中的频次数组,确保它们按预期变化。
4.3 性能调优建议
如果发现代码运行时间较长,可以考虑:
- 使用数组而非字典/哈希表进行统计
- 减少不必要的全量比较(如使用diff优化)
- 避免在循环中创建新的数据结构
5. 同类题目扩展
掌握了这道题的解法后,可以尝试解决以下类似题目:
- 567. 字符串的排列:判断s2是否包含s1的排列,实际上是本题的特殊情况
- 76. 最小覆盖子串:使用可变滑动窗口寻找最小覆盖子串
- 3. 无重复字符的最长子串:另一种滑动窗口的经典应用
这些题目都使用了滑动窗口的技巧,但各有特点:
| 题目 | 窗口类型 | 关键点 | 难度 |
|---|
- 字母异位词 | 固定窗口 | 频次统计、数组比较 | 中等
- 字符串排列 | 固定窗口 | 是438题的特殊情况 | 中等
- 最小覆盖子串 | 可变窗口 | 需要维护满足条件的最小窗口 | 困难
- 无重复最长子串 | 可变窗口 | 字符唯一性维护 | 中等
在实际面试中,面试官可能会先问这道题,然后逐步扩展为更复杂的变种。因此理解滑动窗口的核心思想非常重要。