📖 第5课:找到字符串中所有字母异位词

2 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第5课:找到字符串中所有字母异位词

模块:哈希表 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/fi… 前置知识:第2课(字母异位词分组) 预计学习时间:25分钟


🎯 题目描述

给定两个字符串 sp,找出 s 中所有 p异位词的起始索引。

异位词是指由相同字母重新排列形成的字符串。

示例:

输入:s = "cbaebabacd", p = "abc"
输出:[0, 6]
解释:
  起始索引 0 的子串是 "cba",是 "abc" 的异位词
  起始索引 6 的子串是 "bac",是 "abc" 的异位词
输入:s = "abab", p = "ab"
输出:[0, 1, 2]
解释:
  起始索引 0 的子串是 "ab",是 "ab" 的异位词
  起始索引 1 的子串是 "ba",是 "ab" 的异位词
  起始索引 2 的子串是 "ab",是 "ab" 的异位词

约束条件:

  • 1 ≤ s.length, p.length ≤ 3 × 10⁴
  • s 和 p 仅包含小写字母

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入s="a", p="a"[0]基本功能
p比s长s="ab", p="abc"[]边界处理
无匹配s="abc", p="def"[]无结果情况
全部匹配s="aaa", p="a"[0,1,2]重叠窗口
重复字符s="aaaa", p="aa"[0,1,2]重复元素

💡 思路引导

生活化比喻

想象你是一位图书管理员,需要在一排书架(字符串s)上找出所有包含特定书籍组合(字符串p)的连续区域。

🐌 笨办法:每看到一个位置,你就从这个位置开始数出p长度的书,然后把这些书搬到桌上排序,再和p的排序结果对比。这样每次都要搬书、排序,太累了!

🚀 聪明办法:你拿一个固定大小的"窗口框"(长度等于p),在书架上滑动。窗口框右边进来一本新书,左边就移出一本旧书。你只需要维护一个"书籍计数表",每次滑动只更新进出的两本书的计数,就能立刻判断窗口内的书是否和p匹配!

关键洞察

固定窗口滑动 + 字符频率比对 = O(n)时间内找出所有异位词!


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:两个字符串 s 和 p,都是小写字母
  • 输出:整数数组,表示所有异位词的起始索引
  • 限制:需要找出所有满足条件的位置,不能遗漏

Step 2:先想笨办法(暴力法)

对于s中每个可能的起始位置i(从0到len(s)-len(p)),截取长度为len(p)的子串,判断是否是p的异位词。

判断异位词的方法:将两个字符串排序后比较,或者比较字符频率。

  • 时间复杂度:O(n × m × log m) 或 O(n × m × 26)
    • n = len(s), m = len(p)
    • 对每个位置排序需要 O(m log m),或者计数比较需要 O(m)
  • 瓶颈在哪:每次都要重新统计窗口内的字符频率,大量重复计算

Step 3:瓶颈分析 → 优化方向

观察相邻两个窗口:

  • 窗口 [i, i+m-1] 的字符频率已经统计好了
  • 窗口 [i+1, i+m] 只是右边多了一个字符,左边少了一个字符

核心问题:能不能利用前一个窗口的信息,O(1)时间更新到下一个窗口?

优化思路:滑动窗口!维护一个大小固定为 len(p) 的窗口,窗口每次右移时:

  • 加入右边新字符的频率
  • 减去左边移出字符的频率
  • O(1)时间完成频率表更新

Step 4:选择武器

  • 选用:固定大小滑动窗口 + 哈希表(Counter/数组)
  • 理由:
    • 滑动窗口避免重复统计 → O(n)遍历
    • 哈希表 O(1) 更新字符频率
    • 比较两个频率表只需 O(26) = O(1)

🔑 模式识别提示:当题目出现"连续子串"+"满足某种字符条件",优先考虑"滑动窗口"


🔑 解法一:暴力统计法(朴素)

思路

对s中每个可能的起始位置,截取长度为len(p)的子串,使用Counter统计字符频率并与p的频率比较。

图解过程

示例:s = "cbaebabacd", p = "abc"
目标频率:Counter({'a':1, 'b':1, 'c':1})

Step 1:检查位置 0,子串 "cba"
  Counter("cba") = {'c':1, 'b':1, 'a':1} ✅ 匹配!

Step 2:检查位置 1,子串 "bae"
  Counter("bae") = {'b':1, 'a':1, 'e':1} ❌ 不匹配

Step 3:检查位置 2,子串 "aeb"
  Counter("aeb") = {'a':1, 'e':1, 'b':1} ❌ 不匹配

...继续到位置 6,子串 "bac"
  Counter("bac") = {'b':1, 'a':1, 'c':1} ✅ 匹配!

结果:[0, 6]

Python代码

from typing import List
from collections import Counter


def findAnagrams(s: str, p: str) -> List[int]:
    """
    解法一:暴力统计法
    思路:对每个位置截取子串并用Counter比较
    """
    result = []
    len_s, len_p = len(s), len(p)

    if len_s < len_p:  # 边界:s比p短,直接返回空
        return result

    # 统计p的字符频率
    p_count = Counter(p)

    # 遍历每个可能的起始位置
    for i in range(len_s - len_p + 1):
        # 截取长度为len_p的子串
        substring = s[i:i + len_p]
        # 统计子串的字符频率并比较
        if Counter(substring) == p_count:
            result.append(i)

    return result


# ✅ 测试
print(findAnagrams("cbaebabacd", "abc"))  # 期望输出:[0, 6]
print(findAnagrams("abab", "ab"))         # 期望输出:[0, 1, 2]
print(findAnagrams("a", "a"))             # 期望输出:[0]

复杂度分析

  • 时间复杂度:O(n × m) — n = len(s), m = len(p)
    • 具体地说:遍历 n-m+1 个位置,每个位置创建Counter需要 O(m)
    • 如果 s = 10000, p = 100,大约需要 10000 × 100 = 100万次操作
  • 空间复杂度:O(1) — Counter最多存储26个小写字母

优缺点

  • ✅ 代码简洁,易于理解
  • ❌ 每次都重新创建Counter,存在大量重复计算 → 引出滑动窗口优化

⚡ 解法二:滑动窗口 + Counter(优化)

优化思路

使用固定大小的滑动窗口,维护窗口内字符频率。窗口每次右移时:

  • 加入右边新字符
  • 移出左边旧字符
  • O(1)更新频率,O(1)比较(因为只有26个字母)

💡 关键想法:窗口滑动时只需增量更新,而不是重新统计整个窗口!

图解过程

示例:s = "cbaebabacd", p = "abc"
p_count = {'a':1, 'b':1, 'c':1}

初始化窗口 [0, 2]:"cba"
  window = {'c':1, 'b':1, 'a':1} ✅ 匹配! → 记录索引 0

滑动窗口:右边+1,左边+1
[1, 3]:"bae"
  window 移出 'c':{'b':1, 'a':1}
  window 加入 'e':{'b':1, 'a':1, 'e':1} ❌

[2, 4]:"aeb"
  window 移出 'b':{'a':1, 'e':1}
  window 加入 'b':{'a':1, 'e':1, 'b':1} ❌

[3, 5]:"eba"
  window 移出 'a':{'e':1, 'b':1}
  window 加入 'a':{'e':1, 'b':1, 'a':1} ❌

[4, 6]:"bab"
  window 移出 'e':{'b':1, 'a':1}
  window 加入 'b':{'b':2, 'a':1} ❌

[5, 7]:"aba"
  window 移出 'b':{'b':1, 'a':1}
  window 加入 'a':{'b':1, 'a':2} ❌

[6, 8]:"bac"
  window 移出 'a':{'b':1, 'a':1}
  window 加入 'c':{'b':1, 'a':1, 'c':1} ✅ 匹配! → 记录索引 6

结果:[0, 6]

Python代码

from typing import List
from collections import Counter


def findAnagrams_v2(s: str, p: str) -> List[int]:
    """
    解法二:滑动窗口 + Counter
    思路:维护固定大小窗口,增量更新字符频率
    """
    result = []
    len_s, len_p = len(s), len(p)

    if len_s < len_p:
        return result

    # 统计p的字符频率
    p_count = Counter(p)
    # 初始化窗口:前len_p个字符
    window = Counter(s[:len_p])

    # 检查第一个窗口
    if window == p_count:
        result.append(0)

    # 滑动窗口:从位置1开始
    for i in range(1, len_s - len_p + 1):
        # 移出左边字符 s[i-1]
        left_char = s[i - 1]
        window[left_char] -= 1
        if window[left_char] == 0:
            del window[left_char]  # 频率为0时删除键,保持字典简洁

        # 加入右边字符 s[i+len_p-1]
        right_char = s[i + len_p - 1]
        window[right_char] = window.get(right_char, 0) + 1

        # 比较当前窗口和p_count
        if window == p_count:
            result.append(i)

    return result


# ✅ 测试
print(findAnagrams_v2("cbaebabacd", "abc"))  # 期望输出:[0, 6]
print(findAnagrams_v2("abab", "ab"))         # 期望输出:[0, 1, 2]
print(findAnagrams_v2("a", "a"))             # 期望输出:[0]

复杂度分析

  • 时间复杂度:O(n) — n = len(s)
    • 窗口滑动 n-m 次,每次更新 O(1),比较字典 O(26) = O(1)
    • 如果 s = 10000,只需约 10000 次操作
  • 空间复杂度:O(1) — 两个Counter最多各存26个字母

🚀 解法三:滑动窗口 + 数组计数(最优)

优化思路

由于只有26个小写字母,可以用长度为26的数组代替Counter,进一步提升性能:

  • 数组索引直接映射字符(ord(c) - ord('a'))
  • 数组比较可以用 Python 的列表比较,或者维护一个"匹配字符数"变量

💡 关键想法:字符集固定时,数组比字典更快!

图解过程

使用数组表示频率:
p = "abc" → p_count = [1,1,1,0,0,...,0]
             索引:     a b c d e ... z

窗口 "cba" → window = [1,1,1,0,0,...,0] ✅ 数组相等!

Python代码

from typing import List


def findAnagrams_v3(s: str, p: str) -> List[int]:
    """
    解法三:滑动窗口 + 数组计数
    思路:用长度26的数组代替Counter,性能更优
    """
    result = []
    len_s, len_p = len(s), len(p)

    if len_s < len_p:
        return result

    # 初始化频率数组(26个小写字母)
    p_count = [0] * 26
    window = [0] * 26

    # 统计p的字符频率
    for char in p:
        p_count[ord(char) - ord('a')] += 1

    # 初始化窗口:前len_p个字符
    for i in range(len_p):
        window[ord(s[i]) - ord('a')] += 1

    # 检查第一个窗口
    if window == p_count:
        result.append(0)

    # 滑动窗口
    for i in range(1, len_s - len_p + 1):
        # 移出左边字符
        window[ord(s[i - 1]) - ord('a')] -= 1
        # 加入右边字符
        window[ord(s[i + len_p - 1]) - ord('a')] += 1

        # 比较数组
        if window == p_count:
            result.append(i)

    return result


# ✅ 测试
print(findAnagrams_v3("cbaebabacd", "abc"))  # 期望输出:[0, 6]
print(findAnagrams_v3("abab", "ab"))         # 期望输出:[0, 1, 2]
print(findAnagrams_v3("a", "a"))             # 期望输出:[0]

复杂度分析

  • 时间复杂度:O(n) — 与解法二相同,但常数因子更小
  • 空间复杂度:O(1) — 两个固定长度26的数组

🐍 Pythonic 写法

利用 Python 的 collections.Counter 和列表推导式的简洁写法:

from collections import Counter

def findAnagrams_pythonic(s: str, p: str) -> list[int]:
    """一行流写法:Counter + 列表推导"""
    p_count = Counter(p)
    len_p = len(p)
    return [
        i for i in range(len(s) - len_p + 1)
        if Counter(s[i:i + len_p]) == p_count
    ]

# 测试
print(findAnagrams_pythonic("cbaebabacd", "abc"))  # [0, 6]

这个写法本质上是解法一,虽然代码简洁,但性能不如滑动窗口。

⚠️ 面试建议:先写清晰版本展示滑动窗口思路(解法二/三),再提 Pythonic 写法展示语言功底。 面试官更看重你的优化思维,而非代码行数。


📊 解法对比

维度解法一:暴力统计解法二:滑窗+Counter解法三:滑窗+数组
时间复杂度O(n×m)O(n)O(n)
空间复杂度O(1)O(1)O(1)
代码难度简单中等中等
面试推荐⭐⭐⭐⭐⭐⭐
适用场景小数据、快速验证通用推荐性能要求高

面试建议:先讲解法一建立理解,立刻指出瓶颈,然后优化到解法二展示滑动窗口思维。如果面试官追问性能,可以提解法三的数组优化。


🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题30秒)好的,这道题要求找出字符串s中所有p的异位词的起始索引。异位词就是字符组成相同但顺序不同的字符串。

让我先想一下...我的第一个想法是对每个可能的起始位置截取子串,用Counter比较字符频率,时间复杂度是 O(n×m)。

不过我们可以用滑动窗口来优化。维护一个固定大小的窗口,每次滑动只需 O(1) 更新字符频率,整体优化到 O(n)。核心思路是增量更新窗口的字符频率表。

面试官:很好,请写一下代码。

:(边写边说)我先用Counter统计p的字符频率作为目标。然后初始化一个窗口包含s的前len(p)个字符。接下来滑动窗口,每次移出左边字符、加入右边字符,比较当前窗口和目标频率是否相等。(写下解法二的代码)

面试官:测试一下?

:用示例 "cbaebabacd" 和 "abc" 走一遍。初始窗口是 "cba",Counter是 {c:1,b:1,a:1},和p匹配,记录索引0。然后窗口右移到 "bae",不匹配...最后到 "bac" 再次匹配,记录索引6。结果是 [0,6],正确。

再测一个边界情况:s="a", p="a",只有一个窗口,匹配,返回 [0],也正确。

面试官:如果s非常长,p也很长,还能优化吗?

:时间上已经是 O(n) 最优了,因为至少要遍历一遍s。空间上可以进一步优化:因为只有26个小写字母,可以用长度26的数组代替Counter,访问速度更快。或者可以维护一个"匹配字符数"变量,避免每次都比较整个频率表。

高频追问

追问应答策略
"还有更优解吗?"时间已经是O(n)最优,可以讨论用数组代替Counter的常数优化
"如果字符集很大怎么办?"Counter更合适,因为数组会浪费空间;或者用哈希表
"能不能只用一个哈希表?"可以!维护一个差值表,记录window和p_count的差异,当差值全为0时匹配
"如果要找最长异位词?"改为可变窗口,维护最大窗口长度,类似"最长无重复子串"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:Counter 直接比较 — 两个Counter相等当且仅当所有键值对相同
from collections import Counter
Counter("abc") == Counter("bca")  # True

# 技巧2:字符转数组索引 — ord()获取ASCII码
index = ord('c') - ord('a')  # index = 2 (对应数组位置)

# 技巧3:删除频率为0的键 — 保持Counter简洁
counter = Counter({'a': 1, 'b': 0})
if counter['b'] == 0:
    del counter['b']  # counter = Counter({'a': 1})

# 技巧4:列表推导式 + 条件过滤
result = [i for i in range(10) if i % 2 == 0]  # [0, 2, 4, 6, 8]

💡 底层原理(选读)

为什么滑动窗口这么快?

暴力法对每个位置都要重新统计频率,存在大量重复计算。滑动窗口利用了增量计算的思想:

  • 相邻窗口 [i, i+m-1] 和 [i+1, i+m] 只差两个字符
  • 只需更新这两个字符的频率,O(1) 完成
  • 避免了 O(m) 的重复统计

Counter vs 数组?

  • Counter 本质是字典,哈希查找 O(1),但有哈希函数和冲突处理的开销
  • 数组直接索引访问,常数因子更小,但需要预知字符集大小
  • 小写字母(26个)用数组,Unicode字符用Counter

算法模式卡片 📐

  • 模式名称:固定窗口滑动 + 字符频率匹配
  • 适用条件:
    • 需要在字符串中找满足某种字符频率条件的连续子串
    • 子串长度固定
  • 识别关键词:"异位词"、"排列"、"包含所有字符"、"固定长度子串"
  • 模板代码:
def sliding_window_fixed(s: str, p: str) -> list[int]:
    """固定窗口模板"""
    result = []
    window_size = len(p)
    target = Counter(p)
    window = Counter(s[:window_size])

    if window == target:
        result.append(0)

    for i in range(1, len(s) - window_size + 1):
        # 移出左边
        window[s[i - 1]] -= 1
        if window[s[i - 1]] == 0:
            del window[s[i - 1]]
        # 加入右边
        window[s[i + window_size - 1]] += 1
        # 检查
        if window == target:
            result.append(i)

    return result

易错点 ⚠️

  1. 忘记删除频率为0的键

    • 错误:Counter({'a': 1, 'b': 0}) != Counter({'a': 1})
    • 正确:当字符频率减为0时,del window[char]
  2. 窗口右边界越界

    • 错误:for i in range(len(s) - len_p) 会少检查最后一个窗口
    • 正确:for i in range(len(s) - len_p + 1)
  3. 初始窗口遗漏检查

    • 错误:只在循环中检查,遗漏了第一个窗口[0, len_p-1]
    • 正确:在循环前先检查初始窗口

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:日志异常检测 — 在系统日志流中检测固定时间窗口内的异常模式。例如:某个错误码在5分钟内出现的频率超过阈值,用滑动窗口统计每个时间窗口的错误码分布。

  • 场景2:DNA序列分析 — 生物信息学中查找DNA序列的重复片段或特定基因模式。DNA只有ACGT四种碱基,可以用数组优化的滑动窗口快速匹配。

  • 场景3:文本相似度检测 — 检测文章中是否包含某段文字的同义改写(字符组成相同但顺序不同),用于查重或抄袭检测。


🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 567. 字符串的排列Medium固定窗口+频率匹配和本题几乎相同,只需返回bool
LeetCode 76. 最小覆盖子串Hard可变窗口+频率匹配窗口大小可变,找最小的满足条件的窗口
LeetCode 3. 无重复字符的最长子串Medium可变窗口+去重改为判断窗口内字符无重复,窗口大小可变
LeetCode 239. 滑动窗口最大值Hard固定窗口+单调队列用单调队列维护窗口最大值

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定字符串 s 和 p,判断 s 是否包含 p 的排列(即任意一个异位词)。返回 True 或 False。

例如:s = "eidbaooo", p = "ab",返回 True(因为 s 包含 "ba")

💡 提示(实在想不出来再点开)

这就是找一个异位词,和本题的区别是只需返回布尔值,找到第一个匹配就可以立即返回 True。

✅ 参考答案
from collections import Counter

def checkInclusion(s: str, p: str) -> bool:
    """判断s是否包含p的排列"""
    if len(s) < len(p):
        return False

    p_count = Counter(p)
    window = Counter(s[:len(p)])

    if window == p_count:
        return True

    for i in range(1, len(s) - len(p) + 1):
        # 滑动窗口
        window[s[i - 1]] -= 1
        if window[s[i - 1]] == 0:
            del window[s[i - 1]]
        window[s[i + len(p) - 1]] += 1

        # 找到匹配立即返回
        if window == p_count:
            return True

    return False

核心思路和本题完全相同,只是找到第一个匹配就返回 True,最后返回 False。这就是 LeetCode 567 题!


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。