找到字符串中所有字母异位词:定长窗口 + 计数表
异位词长度被 p 锁死。窗口只负责进一个、出一个,计数一相等就记下左端。
p 的异位词长度只能是 m=len(p)。在 s 上维护长度为 m 的窗口:右端字符计数 +1,左端滑出时 −1。窗口满且 26 维计数与 p 完全相同,就记下起点 i−m+1。每次移动 O(1),比对 26 个格子视为常数,总时间 O(n)。
这是 LeetCode 438. Find All Anagrams in a String。给你字符串 s 和 p,找出 s 中所有是 p 的字母异位词的子串,返回它们的起始下标,顺序任意。异位词:字母相同、顺序可以不同。
主例 s = "cbaebabacd",p = "abc"。下标 0 起的 "cba" 三个字母正好是 a、b、c;下标 6 起的 "bac" 也是。中间 "bae"、"aeb"、"eba"、"bab"、"aba" 都对不上。答案 [0, 6]。
每个起点截一段长度 m 再排序比较,是 O(n m log m)。缺的不是「什么叫异位词」,而是相邻两个窗口只差一个字符,计数不该重算。本文把窗口钉死在长度 3 上,用手走主例每一次右移。
异位词先收成等长加计数相等
两个串互为异位词,当且仅当长度相同,并且每个字母出现次数相同。p 的长度 m 一旦给定,s 里只可能在长度为 m 的窗口里找到答案。长度不对的子串,排序再巧也不是异位词。
于是题目变成:在 s 上找所有 [i, i+m) ,使得这段的 26 维计数等于 p 的计数。LC242 只判断一对比对;这里要对每个起点都比对一次。
缺口是相邻窗口的重复劳动。窗口从 [0, m) 移到 [1, m+1),中间 m−1 个字符没变,只是丢掉 s[0]、纳入 s[m]。整段重新计数是浪费。定长滑窗要维护的就是这两次 ±1。
长度先锁死可变长滑窗回答「最短覆盖」「至多 K 种」这类问题。异位词要求每种字母不多不少,长度被 p 钉死,窗口只会平移,不会伸缩。
进一个、出一个,满窗再比对
need 存 p 的计数,cur 存当前窗口。从左到右读 s[i]:cur 里这个字母 +1;若 i >= m,说明窗口已经超长,把 s[i−m] 的计数 −1。i >= m−1 时窗口恰好满,cur == need 就把起点 i−m+1 记进答案。
用手走主例,m=3。i=0..2 纳入 c、b、a,窗口 "cba",计数与 abc 相同,记下 0。i=3 纳入 e,同时丢掉 c,窗口 "bae",多了 e、少了 c,不中。之后 "aeb"、"eba"、"bab"、"aba" 都对不上。
i=8 纳入 c,丢掉下标 5 的 a,窗口变成下标 6..8 的 "bac",计数回到 a、b、c 各一,记下 6。最后 i=9 纳入 d,丢掉 b,窗口 "acd",不中。演示三帧分别是命中 0、滑到 "bae"、再命中 6。答案 [0, 6]。
Go:定长滑窗 + 计数比对
func findAnagrams(s, p string) []int {var need, cur [26]intfor _, c := range p { need[c-'a']++ }var res []intn, m := len(s), len(p)for i := 0; i < n; i++ {cur[s[i]-'a']++if i >= m { cur[s[i-m]-'a']-- }if i >= m-1 && cur == need { res = append(res, i-m+1) }}return res}
1need 是 p 的目标计数。数组可直接 ==,不必手写 26 次比较。
2每个 s[i] 先进入窗口。主例 i=2 纳入 a 之后,第一次满窗。
3i >= m 才丢左边。i=3 丢掉 c,窗口从 "cba" 变成 "bae"。
4满窗且 cur == need 才记起点。主例两次命中分别是 i=2 和 i=8,起点 0 与 6。
总结
窗口长度等于 p。主例 "cba" 记 0,"bac" 记 6,其余窗口计数对不上。
- 异位词 = 等长 + 计数相同。长度先锁死,再谈滑动。
- 相邻窗口只差一个字符:右边 +1、左边 −1,不要整段重数。
- 窗口未满不比对。s 比 p 短则答案为空,循环自然给不出命中。