Files
leetcode-go/滑动窗口/09-找到字符串中所有字母异位词.md
T

271 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)。