NO.438. 找到字符串中所有字母异位词 —— LeetCode热题100
一、题目回顾
题目:给定两个字符串 s 和 p,找到 s 中所有 p 的异位词子串,返回这些子串的起始索引。
输入输出示例:
- 输入:
s = "cbaebabacd",p = "abc"→ 输出:[0,6] - 输入:
s = "abab",p = "ab"→ 输出:[0,1,2]
约束:
1 ≤ s.length, p.length ≤ 3×10⁴- 仅包含小写字母
二、标准解题逻辑步骤
步骤总览
1. 识别题型 → 固定长度子串匹配 → 滑动窗口
2. 建立字符频率映射
3. 维护窗口内的频率计数
4. 比较窗口与目标字符串的频率
5. 滑动窗口并记录匹配的起始索引
详细步骤拆解
Step 1:数据结构准备
const pLen = p.length; // 窗口大小固定为p的长度
const sLen = s.length;
const result = [];
if (sLen < pLen) return result; // 边界处理
Step 2:建立两个频率数组
const pCount = new Array(26).fill(0);
const windowCount = new Array(26).fill(0);
- 为什么是26?因为只包含小写字母('a'到'z')
- 数组索引:
charCodeAt() - 97将字母映射到0-25
Step 3:统计目标字符串p的频率
for (let i = 0; i < pLen; i++) {
pCount[p.charCodeAt(i) - 97]++;
}
- 例如 p="abc":pCount = [1,1,1,0,0,...]
Step 4:初始化第一个窗口
for (let i = 0; i < pLen; i++) {
windowCount[s.charCodeAt(i) - 97]++;
}
- 统计s中前pLen个字符的频率
Step 5:检查第一个窗口
if (isSame(pCount, windowCount)) {
result.push(0);
}
Step 6:滑动窗口
for (let i = pLen; i < sLen; i++) {
// 移除左边界字符
windowCount[s.charCodeAt(i - pLen) - 97]--;
// 添加右边界字符
windowCount[s.charCodeAt(i) - 97]++;
// 检查当前窗口
if (isSame(pCount, windowCount)) {
result.push(i - pLen + 1);
}
}
三、代码中每个API的详细解析
1. new Array(26).fill(0)
语法:
new Array(length) // 创建指定长度的数组
Array.prototype.fill(value, start, end)
参数说明:
length:数组长度,这里为26value:填充的值,这里为0start(可选):起始索引,默认0end(可选):结束索引,默认length
作用:创建长度为26的数组,所有元素初始化为0
适用场景:需要快速创建并初始化一个固定大小的数组
⚠️ 注意:new Array(26) 创建的是空位数组(稀疏数组),必须用 fill() 填充才能正确使用
2. String.prototype.charCodeAt(index)
语法:
str.charCodeAt(index)
参数说明:
index:字符串中字符的位置,从0开始计数- 返回值:指定位置字符的Unicode编码(0-65535之间的整数)
作用:获取字符串中指定位置字符的Unicode值
适用场景:
- 字符到数字的映射(如本题中 'a'→97, 'b'→98)
- 需要快速比较或统计字符时
示例:
"abc".charCodeAt(0) // 97 (a)
"abc".charCodeAt(1) // 98 (b)
"abc".charCodeAt(2) // 99 (c)
💡 本题特殊用法:charCodeAt(i) - 97 将'a'映射到0,'b'映射到1,以此类推
3. Array.prototype.push(element)
语法:
arr.push(element1, element2, ...)
参数说明:
element:要添加到数组末尾的元素,可以是任意类型- 返回值:新数组的长度
作用:在数组末尾添加一个或多个元素
适用场景:动态构建结果数组时
示例:
const result = [];
result.push(0); // result = [0]
result.push(6); // result = [0, 6]
4. Array.prototype.length
语法:
arr.length // 获取数组长度
arr.length = newLength // 设置数组长度
作用:获取或设置数组中元素的个数
适用场景:
- 遍历数组
- 边界判断
- 截断或扩展数组
⚠️ 注意:length 是属性而非方法,不要加括号
四、得分重点与理解难点
🎯 得分重点
| 重点 | 说明 | 分值占比 |
|---|---|---|
| 滑动窗口思想 | 理解为什么用滑动窗口而非暴力遍历 | 30% |
| 频率计数方法 | 用数组而非Map存储字符频率 | 20% |
| 窗口更新逻辑 | 正确移除和添加字符 | 25% |
| 边界条件处理 | s长度小于p长度时直接返回 | 15% |
| 索引计算 | 正确计算子串起始索引 | 10% |
🤔 理解难点
难点1:为什么用滑动窗口?
暴力解法会遍历所有长度为pLen的子串,每次都需要统计频率,时间复杂度O(n²)。
滑动窗口利用"重叠"特性,每次只更新两个字符,将复杂度降到O(n)。
难点2:数组如何表示字符频率?
索引0 → 'a'
索引1 → 'b'
...
索引25 → 'z'
例如 "abc" 的频率数组:
[1, 1, 1, 0, 0, 0, ...]
↑ ↑ ↑
a b c
难点3:窗口滑动时为什么先移除后添加?
// 假设当前窗口是 s[0..2],要变成 s[1..3]
// 移除 s[0],添加 s[3]
windowCount[s[0]]--; // 移除左边界
windowCount[s[3]]++; // 添加右边界
// 顺序不影响最终结果
难点4:起始索引的计算
result.push(i - pLen + 1);
当 i = pLen 时,起始索引 = pLen - pLen + 1 = 1
s = "c b a e b a b a c d"
索引: 0 1 2 3 4 5 6 7 8 9
pLen = 3
窗口1: [0,1,2] → 起始0
窗口2: [1,2,3] → 起始1 = 3 - 3 + 1
窗口3: [2,3,4] → 起始2 = 4 - 3 + 1
五、极易踩错的细节
❌ 错误1:忘记边界处理
// ❌ 错误
var findAnagrams = function(s, p) {
// 直接开始处理,当 s 比 p 短时会出问题
}
// ✅ 正确
if (s.length < p.length) return [];
❌ 错误2:数组初始化错误
// ❌ 错误
const count = new Array(26); // 数组元素是undefined
count[0]++; // NaN
// ✅ 正确
const count = new Array(26).fill(0);
❌ 错误3:索引映射错误
// ❌ 错误
const idx = s.charCodeAt(i) - 'a'; // 'a'是字符串,不能相减
// ✅ 正确
const idx = s.charCodeAt(i) - 97; // 97是数字
// 或
const idx = s.charCodeAt(i) - 'a'.charCodeAt(0);
❌ 错误4:窗口更新顺序导致索引错误
// ❌ 错误
for (let i = 0; i < sLen - pLen + 1; i++) {
// 每次都重新统计窗口,失去了滑动窗口的意义
}
// ✅ 正确
for (let i = pLen; i < sLen; i++) {
// 每次只更新两个字符
}
❌ 错误5:比较数组时用 ===
// ❌ 错误 - 数组是引用类型,不能直接比较
if (pCount === windowCount) { ... }
// ✅ 正确 - 需要逐个比较元素
function isSame(arr1, arr2) {
for (let i = 0; i < 26; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
}
❌ 错误6:忘记检查第一个窗口
// ❌ 错误 - 只检查了滑动后的窗口
for (let i = pLen; i < sLen; i++) {
// 更新窗口
// 检查
}
// 漏掉了索引0的窗口
// ✅ 正确
// 先检查第一个窗口
if (isSame(pCount, windowCount)) result.push(0);
// 再滑动检查
for (let i = pLen; i < sLen; i++) { ... }
六、优化思路与代码
📊 方案对比
| 方案 | 时间复杂度 | 空间复杂度 | 特点 |
|---|---|---|---|
| 基础写法 | O(26×n) | O(26) | 简单易懂,每次比较整个数组 |
| 优化写法 | O(n) | O(26) | 用diff记录差异,避免全数组比较 |
| 双指针写法 | O(n) | O(26) | 适合理解,代码简洁 |
优化方案:差异计数法
核心思想:不每次比较整个数组,而是维护一个 diff 变量,记录有多少个字符的数量不匹配。
优化代码
/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function(s, p) {
const result = [];
const sLen = s.length;
const pLen = p.length;
if (sLen < pLen) return result;
const count = new Array(26).fill(0);
let diff = 0;
// 初始化:p的字符+1,s窗口字符-1
for (let i = 0; i < pLen; i++) {
count[p.charCodeAt(i) - 97]++;
count[s.charCodeAt(i) - 97]--;
}
// 统计初始差异数量
for (let i = 0; i < 26; i++) {
if (count[i] !== 0) diff++;
}
if (diff === 0) result.push(0);
for (let i = pLen; i < sLen; i++) {
const leftIdx = s.charCodeAt(i - pLen) - 97;
const rightIdx = s.charCodeAt(i) - 97;
// 处理左边界字符(离开窗口)
if (count[leftIdx] === 0) diff++;
count[leftIdx]++;
if (count[leftIdx] === 0) diff--;
// 处理右边界字符(进入窗口)
if (count[rightIdx] === 0) diff++;
count[rightIdx]--;
if (count[rightIdx] === 0) diff--;
if (diff === 0) {
result.push(i - pLen + 1);
}
}
return result;
};
优化原理解析
diff 变量的含义:
count[i]表示字符i在p中比在窗口中多几个- 当
count[i] === 0时,该字符在p和窗口中数量相等 diff统计count[i] !== 0的个数
更新逻辑:
// 当 count[leftIdx] === 0 时,该字符原本是平衡的
// 移除它后变为不平衡,所以 diff++
if (count[leftIdx] === 0) diff++;
count[leftIdx]++; // 移除字符:差值+1
// 如果操作后变为0,说明从不平衡变为平衡
if (count[leftIdx] === 0) diff--;
例子演示:
p = "abc", 窗口 = "cba"
初始 count = [0,0,0,...] → diff = 0 → 匹配
滑动:窗口 "cba" → "bae"
移除 'c':count[c] 从0变为1,diff从0变为1
添加 'e':count[e] 从0变为-1,diff从1变为2
→ diff ≠ 0,不匹配
进一步优化:使用Map(适合任意字符)
var findAnagrams = function(s, p) {
const result = [];
const sLen = s.length;
const pLen = p.length;
if (sLen < pLen) return result;
const pMap = new Map();
const windowMap = new Map();
// 统计p
for (const char of p) {
pMap.set(char, (pMap.get(char) || 0) + 1);
}
// 初始化窗口
for (let i = 0; i < pLen; i++) {
windowMap.set(s[i], (windowMap.get(s[i]) || 0) + 1);
}
// 比较两个Map
const isAnagram = () => {
if (pMap.size !== windowMap.size) return false;
for (const [key, value] of pMap) {
if (windowMap.get(key) !== value) return false;
}
return true;
};
if (isAnagram()) result.push(0);
for (let i = pLen; i < sLen; i++) {
const left = s[i - pLen];
const right = s[i];
// 移除左边
windowMap.set(left, windowMap.get(left) - 1);
if (windowMap.get(left) === 0) windowMap.delete(left);
// 添加右边
windowMap.set(right, (windowMap.get(right) || 0) + 1);
if (isAnagram()) result.push(i - pLen + 1);
}
return result;
};
优缺点:
- ✅ 适用于所有字符(不仅限于小写字母)
- ❌ Map操作比数组稍慢
- ❌ 每次比较Map需要遍历
七、完整代码总结
推荐写法(平衡可读性与性能)
/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function(s, p) {
const result = [];
const sLen = s.length;
const pLen = p.length;
// 边界条件
if (sLen < pLen) return result;
// 频率数组
const pCount = new Array(26).fill(0);
const windowCount = new Array(26).fill(0);
// 统计p
for (let i = 0; i < pLen; i++) {
pCount[p.charCodeAt(i) - 97]++;
}
// 初始化窗口
for (let i = 0; i < pLen; i++) {
windowCount[s.charCodeAt(i) - 97]++;
}
// 比较函数
const isSame = (arr1, arr2) => {
for (let i = 0; i < 26; i++) {
if (arr1[i] !== arr2[i]) return false;
}
return true;
};
// 检查第一个窗口
if (isSame(pCount, windowCount)) {
result.push(0);
}
// 滑动窗口
for (let i = pLen; i < sLen; i++) {
const left = s.charCodeAt(i - pLen) - 97;
const right = s.charCodeAt(i) - 97;
windowCount[left]--;
windowCount[right]++;
if (isSame(pCount, windowCount)) {
result.push(i - pLen + 1);
}
}
return result;
};
时间/空间复杂度
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | n = s.length,每个字符访问常数次 |
| 空间复杂度 | O(1) | 只使用固定大小(26)的数组 |
八、延伸思考
如果字符集不是小写字母怎么办?
- 使用
Map代替数组 - 或使用更大的数组(如256个ASCII字符)
如果p非常长(接近s的长度)?
- 滑动窗口只需要检查少数几个窗口
- 时间复杂度仍然是O(n)
相关题目
- 76. 最小覆盖子串 - 变长窗口
- 567. 字符串的排列 - 相同思路,但只需返回是否存在
- 3. 无重复字符的最长子串 - 不同窗口条件
九、面试技巧
如果面试官问:"为什么不用排序?"
答:排序每个子串需要O(k log k),而滑动窗口只需要O(1)更新,更高效。
如果面试官问:"能进一步优化吗?"
答:可以用diff变量避免每次都比较整个数组,将常数因子从26降到1。
如果面试官问:"空间复杂度还能优化吗?"
答:已经是O(1),无法再优化,因为需要存储频率信息。
总结:这道题是滑动窗口的经典应用,核心是维护一个固定大小的窗口,通过字符频率比较来判断是否为异位词。掌握本题对理解所有滑动窗口题目都有帮助。