--- tags: ["LeetCode", "滑动窗口", "哈希表", "中等"] create time: 2026-05-14 10:30 --- # 09-找到字符串中所有字母异位词 ## 题面 给定两个字符串 `s` 和 `p`,找到 `s` 中所有 `p` 的 **异位词** 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。 > [!question] 💡 什么是"异位词"? > 两个字符串包含完全相同的字符,且每个字符出现的次数也相同——仅仅是排列顺序不同。例如 `"abc"` 和 `"cba"`、`"bca"` 互为异位词。 **示例 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 * 10^4` - `s` 和 `p` 仅包含小写英文字母 --- ## 思路 > [!info] 🎯 核心观察 > 异位词的长度是固定的(等于 `len(p)`),所以本质上是:在字符串 `s` 上维护一个**长度固定为 len(p)** 的滑动窗口,逐一检查窗口内的字符组成是否与 `p` 一致。这比一般的"可变长窗口"更简单——左指针的移动节奏由右指针决定:每前进一格,左右各前进一步。 ### 方法一:暴力枚举 ❌ 枚举 `s` 中所有长度为 `len(p)` 的子串,对每个子串排序后与排序后的 `p` 比较。 - **时间复杂度:O((m-n+1) · n · log n)** — 每个子串排序需要 O(n log n),共有 m-n+1 个子串(m = len(s)) - **空间复杂度:O(n)** — 排序需要的额外空间 当 `n` 较大时,这种做法明显超时,需要利用"滑动窗口增量更新"来避免重复排序。 ### 方法二:定长滑动窗口 + 计数数组 ⭐(最优) > [!abstract] 🔍 为什么用数组而非哈希表? > 题目限定只包含小写英文字母,字符集大小固定为 26。此时用 `int[26]` 数组比 `map[rune]int` 哈希表更快——数组不存在 hash 计算开销,内存也更紧凑。 #### 算法步骤 **第一步:统计 `p` 中各字符出现频次。** 用一个长度为 26 的数组 `pCount` 记录 `p` 的字符频率分布。 **第二步:在 `s` 上维护长度为 `len(p)` 的滑动窗口。** 初始时,让窗口覆盖 `s[0..len(p)-1]`。这里需要**分别统计**——`pCount` 记录 `p` 的字符频率,`windowCount` 记录窗口内(即 `s[0..len(p)-1]`)的字符频率,然后比较两者是否相等。之后每次窗口向右移动一格:新字符从右侧进入窗口并计入 `windowCount`,左侧离开窗口的字符从 `windowCount` 中减去。每次移动后再次比较两个数组。 ```mermaid flowchart TD Start["初始化 pCount 统计 p 的字符频次"] --> InitWin["初始化 windowCount 统计 s 前 n 个字符频次"] InitWin --> CheckStart{"pCount == windowCount"} CheckStart -->|"是"| Add0["将索引 0 加入结果"] CheckStart -->|"否"| SkipStart["跳过"] Add0 --> EnterLoop["进入循环 i 从 n 到 m-1"] SkipStart --> EnterLoop EnterLoop --> RightIn["右进新字符加入窗口"] RightIn --> LeftOut["左出旧字符离开窗口"] LeftOut --> CheckWin{"pCount == windowCount"} CheckWin -->|"是"| AddIdx["将索引 i-n+1 加入结果"] CheckWin -->|"否"| SkipWin["跳过"] AddIdx --> LoopCheck{"i < m"} SkipWin --> LoopCheck LoopCheck -->|"是"| EnterLoop LoopCheck -->|"否"| ReturnRes["返回结果数组"] ``` #### 逐步推演 以 `s = "abab", p = "ab"` 为例(匹配场景): | 阶段 | 操作 | pCount | windowCount | 匹配? | 结果 | |------|------|--------|-------------|-------|------| | 初始化窗口 | 统计 `s[0..1]="ab"` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0]` | | 窗口右移 (i=2) | 进 `'a'`,出 `s[0]='a'` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0, 1]` | | 窗口右移 (i=3) | 进 `'b'`,出 `s[1]='b'` | `{a:1, b:1}` | `{a:1, b:1}` | ✅ | `[0, 1, 2]` | 再看一个有匹配也有不匹配的例子 `s = "cbaebabacd", p = "abc"`(以下为完整推演,超出原题示例范围): | i | 窗口内容 | 窗口频次分布 | 匹配? | 结果追加 | |----|---------|------------|-------|---------| | 初始 | `"cba"` | `{a:1, b:1, c:1}` | ✅ | `[0]` | | 3 | `"bae"` | `{a:1, b:1, e:1}` | ❌ | — | | 4 | `"aeb"` | `{a:1, b:1, e:1}` | ❌ | — | | 5 | `"eba"` | `{a:1, b:1, e:1}` | ❌ | — | | 6 | `"bac"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6]` | | 7 | `"acb"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6, 7]` | | 8 | `"cba"` | `{a:1, b:1, c:1}` | ✅ | `[0, 6, 7, 8]` | 最终结果为 `[0, 6, 7, 8]`。(注意题目示例只列出前两个答案 `[0, 6]`,实际完整输出为全部四个索引。) > [!note] 🧠 "右进左出"的增量更新技巧 > 窗口每次右移一格时,只需做两次 O(1) 的加减操作:`windowCount[newChar]++` 和 `windowCount[oldChar]--`,无需重新统计整个窗口。这是滑动窗口把时间复杂度从 O(m·n) 优化到 O(m) 的关键。 > [!tip] 🔑 数组比较的小优化 > 如果只想判断窗口状态有无变化,可以维护一个变量 `matches`(或 `diff`),记录当前有多少个位置的频次已经对齐。但这会增加代码复杂度,对于本题常数级比较(26次)而言优化意义不大,保持简洁即可。 **时间复杂度:O(m)** — 先遍历 `p` 一次 O(n),再遍历 `s` 一次 O(m)。窗口内的数组比较虽然做了 26 次元素对比,但 26 是常量,均摊后仍为 O(m)。 **空间复杂度:O(1)** — 只用到了固定大小的数组(26 个整型元素)。 --- ## 代码提示 ``` // 伪代码模板 m = len(s) n = len(p) if n > m: return [] pCount = 数组[int](26) // 记录 p 的字符频次 windowCount = 数组[int](26) // 记录当前窗口的字符频次 // 初始化: 统计 p 和 s 前 n 个字符的频次 for i = 0 到 n-1: pCount[p[i]-'a']++ windowCount[s[i]-'a']++ result = [] // 检查初始窗口 if arraysEqual(pCount, windowCount): result.append(0) // 滑动窗口: 逐个右移 for i = n 到 m-1: windowCount[s[i]-'a']++ // 新字符入 windowCount[s[i-n]-'a']-- // 旧字符出 if arraysEqual(pCount, windowCount): result.append(i - n + 1) return result ``` Go 语言中利用数组类型天然支持 `==` 运算符的特性,可直接比较两个 `[26]int`: ```go // Go 风格精简版骨架 var pCount, windowCount [26]int // [26]int 天然支持 == 比较 for _, ch := range p { pCount[ch-'a']++ } for i := 0; i < len(p); i++ { windowCount[s[i]-'a']++ } // 💡 Go 优化:预先分配容量,避免运行时多次扩容(上限为 m-n+1) result := make([]int, 0, len(s)-len(p)+1) if pCount == windowCount { result = append(result, 0) } for i := len(p); i < len(s); i++ { windowCount[s[i]-'a']++ windowCount[s[i-len(p)]-'a']-- if pCount == windowCount { result = append(result, i-len(p)+1) } } ``` > [!note] 🐹 Go 中 `[26]int ==` 的特性 > Go 语言的数组是值类型,两个同构的 `[N]int` 数组可以直接用 `==` 逐元素比较,这在其他语言中并不常见。这省去了手写循环比对的时间,让代码异常简洁。 --- ## 技巧 > [!tip] 🔑 固定窗口 vs 可变窗口 > > | 场景 | 窗口行为 | 典型问题 | > |------|---------|---------| > | **固定长度** | 右指针每进一格,左指针也相应前进一格 | 字母异位词、大小为 K 的子数组平均值 | > | **可变长度** | 左右指针独立运动,按条件伸缩 | 无重复字符最长子串、最小覆盖子串 | > > 本题是"固定窗口"范式——只要知道窗口大小 `n`,左右指针步调就锁定了:`left = right - n + 1`。识别这一点可以避免过度设计。 > [!info] 📊 字符集大小决定数据结构 > > | 字符集限制 | 推荐方案 | 理由 | > |-----------|---------|------| > | 仅小写字母(如本题) | `[26]int` 数组 | 常数快、内存紧、Go 支持 `==` 直接比较 | > | Unicode/任意字符 | `map[rune]int` | 灵活但慢,Go 中不支持 `==` 比较 map | > > 面试时可以主动确认:"题目中的字符范围是否可以用定长数组表示?"这体现了你对边界条件的敏感度。 > [!danger] ⚠️ 常见陷阱 > > **① p 的长度大于 s**:此时不可能存在异位词,应提前返回空数组。否则窗口初始化时会越界访问 `s[n-1]`。 > > **② 混淆异位词与最长公共子串**:异位词只看字符集合和频次,不要求连续或有序。例如 `"abc"` 的异位词是 `"acb"`, `"bca"`, `"cab"`, `"cba"` 等全排列。 > > **③ Go 中 string 索引返回的是 byte 而非 rune**:由于题目限定仅含小写字母,每个字符恰好占 1 字节,直接用 `s[i]-'a'` 是正确的。但如果题目涉及多字节 Unicode 字符,则必须改用 `[]rune(s)` 转换后再索引。 --- ## 代码 ```go func findAnagrams(s string, p string) []int { m, n := len(s), len(p) if n > m { return nil } var pCount, windowCount [26]int // 统计字符频次,[26]int 天然支持 == 比较 // 初始化: 分别统计 p 和 s 的前 n 个字符的频次 for _, ch := range p { pCount[ch-'a']++ } for i := 0; i < n; i++ { windowCount[s[i]-'a']++ } // 💡 预分配容量,避免运行时多次扩容(上限为 m-n+1) result := make([]int, 0, m-n+1) // 检查第一个窗口 if pCount == windowCount { result = append(result, 0) } // 滑动窗口: 逐个右移 for i := n; i < m; i++ { windowCount[s[i]-'a']++ // 新字符从右侧进入窗口 windowCount[s[i-n]-'a']-- // 左侧字符离开窗口 if pCount == windowCount { result = append(result, i-n+1) } } return result } ``` > [!success] ✅ 运行验证 > - **LeetCode 第 438 题**,通过率约 56%,中等难度中滑动窗口的经典应用。 > - 这道题的核心洞察是:**异位词的本质是"字符频次分布相同"**。只要窗口内各字符的出现次数与 `p` 一模一样,无论顺序如何,就是异位词。固定长度的窗口设计让左右指针的运动被完全绑定,简化了逻辑。 > - 与 [[08-无重复字符的最长子串]] 对比:后者是"最大化窗口"(窗口可缩可扩),本题是"固定窗口"(窗口大小锁定)。两者的共同点都是利用增量更新避免重复计算,是理解滑动窗口的两块基石。 > - **变体延伸**:若将"完全匹配"放宽为"至多差 k 个字符"(LeetCode 2425 思路的变形),则可将数组比较替换为维护一个 `diff` 计数器——记录当前有多少个位置的频次对齐,仅当 `diff == 26` 时才记入答案,时间复杂度仍为 O(m)。