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

11 KiB
Raw Blame History

tags, create time
tags create time
LeetCode
滑动窗口
哈希表
中等
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((n-m+1) · m · log m) — 每个子串排序需要 O(m log m),共有 n-m+1 个子串(n = len(s))
  • 空间复杂度:O(m) — 排序需要的额外空间

当 m 较大时,这种做法明显超时,需要利用"滑动窗口增量更新"来避免重复排序。

方法二:定长滑动窗口 + 计数数组 ⭐(最优)

[!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 中减去。每次移动后再次比较两个数组。

flowchart TD
    Start["初始化 pCount 统计 p 的字符频次"] --> InitWin["初始化 windowCount 统计 s 前 m 个字符频次"]
    InitWin --> CheckStart{"pCount == windowCount"}
    CheckStart -->|"是"| Add0["将索引 0 加入结果"]
    CheckStart -->|"否"| SkipStart["跳过"]
    Add0 --> EnterLoop["进入循环 i 从 m 到 n-1"]
    SkipStart --> EnterLoop
    EnterLoop --> RightIn["右进新字符加入窗口"]
    RightIn --> LeftOut["左出旧字符离开窗口"]
    LeftOut --> CheckWin{"pCount == windowCount"}
    CheckWin -->|"是"| AddIdx["将索引 i-m+1 加入结果"]
    CheckWin -->|"否"| SkipWin["跳过"]
    AddIdx --> LoopCheck{"i < n-1"}
    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(n·m) 优化到 O(n) 的关键。

[!tip] 🔑 数组比较的小优化 如果只想判断窗口状态有无变化,可以维护一个变量 matches(或 diff),记录当前有多少个位置的频次已经对齐。但这会增加代码复杂度,对于本题常数级比较(26次)而言优化意义不大,保持简洁即可。

时间复杂度:O(n) — 先遍历 p 一次 O(m),再遍历 s 一次 O(n)。窗口内的数组比较虽然做了 26 次元素对比,但 26 是常量,均摊后仍为 O(n)。

空间复杂度:O(1) — 只用到了固定大小的数组(26 个整型元素)。


代码提示

// 伪代码模板
m = len(p)
n = len(s)

if m > n: return []

pCount     = 数组[int](26) // 记录 p 的字符频次
windowCount = 数组[int](26) // 记录当前窗口的字符频次

// 初始化: 统计 p 和 s 前 m 个字符的频次
for i = 0 到 m-1:
    pCount[p[i]-'a']++
    windowCount[s[i]-'a']++

result = []

// 检查初始窗口
if arraysEqual(pCount, windowCount):
    result.append(0)

// 滑动窗口: 逐个右移
for i = m 到 n-1:
    windowCount[s[i]-'a']++      // 新字符入
    windowCount[s[i-m]-'a']--    // 旧字符出
    
    if arraysEqual(pCount, windowCount):
        result.append(i - m + 1)

return result

Go 语言中利用数组类型天然支持 == 运算符的特性,可直接比较两个 [26]int:

// 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 优化:预先分配容量,避免运行时多次扩容(上限为 n-m+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 的子数组平均值
可变长度 左右指针独立运动,按条件伸缩 无重复字符最长子串、最小覆盖子串

本题是"固定窗口"范式——只要知道窗口大小 m,左右指针步调就锁定了:left = right - m + 1。识别这一点可以避免过度设计。

[!info] 📊 字符集大小决定数据结构

字符集限制 推荐方案 理由
仅小写字母(如本题) [26]int 数组 常数快、内存紧、Go 支持 == 直接比较
Unicode/任意字符 map[rune]int 灵活但慢,Go 中不支持 == 比较 map

面试时可以主动确认:"题目中的字符范围是否可以用定长数组表示?"这体现了你对边界条件的敏感度。

[!danger] ⚠️ 常见陷阱

① p 的长度大于 s:此时不可能存在异位词,应提前返回空数组。否则窗口初始化时会越界访问 s[m-1]。

② 混淆异位词与最长公共子串:异位词只看字符集合和频次,不要求连续或有序。例如 "abc" 的异位词是 "acb", "bca", "cab", "cba" 等全排列。

③ Go 中 string 索引返回的是 byte 而非 rune:由于题目限定仅含小写字母,每个字符恰好占 1 字节,直接用 s[i]-'a' 是正确的。但如果题目涉及多字节 Unicode 字符,则必须改用 []rune(s) 转换后再索引。


代码

func findAnagrams(s string, p string) []int {
	n, m := len(s), len(p)
	if m > n {
		return nil
	}

	var pCount, windowCount [26]int // 统计字符频次,[26]int 天然支持 == 比较

	// 初始化: 分别统计 p 和 s 的前 m 个字符的频次
	for _, ch := range p {
		pCount[ch-'a']++
	}
	for i := 0; i < m; i++ {
		windowCount[s[i]-'a']++
	}

	// 💡 预分配容量,避免运行时多次扩容(上限为 n-m+1)
	result := make([]int, 0, n-m+1)

	// 检查第一个窗口
	if pCount == windowCount {
		result = append(result, 0)
	}

	// 滑动窗口: 逐个右移
	for i := m; i < n; i++ {
		windowCount[s[i]-'a']++       // 新字符从右侧进入窗口
		windowCount[s[i-m]-'a']--     // 左侧字符离开窗口

		if pCount == windowCount {
			result = append(result, i-m+1)
		}
	}

	return result
}

[!success] ✅ 运行验证

  • LeetCode 第 438 题,通过率约 56%,中等难度中滑动窗口的经典应用。
  • 这道题的核心洞察是:异位词的本质是"字符频次分布相同"。只要窗口内各字符的出现次数与 p 一模一样,无论顺序如何,就是异位词。固定长度的窗口设计让左右指针的运动被完全绑定,简化了逻辑。
  • 与 08-无重复字符的最长子串 对比:后者是"最大化窗口"(窗口可缩可扩),本题是"固定窗口"(窗口大小锁定)。两者的共同点都是利用增量更新避免重复计算,是理解滑动窗口的两块基石。
  • 变体延伸:若将"完全匹配"放宽为"至多差 k 个字符"(LeetCode 2425 思路的变形),则可将数组比较替换为维护一个 diff 计数器——记录当前有多少个位置的频次对齐,仅当 diff == 26 时才记入答案,时间复杂度仍为 O(n)。