NO.438. 找到字符串中所有字母异位词 —— LeetCode热题100

5 阅读9分钟

NO.438. 找到字符串中所有字母异位词 —— LeetCode热题100


一、题目回顾

题目:给定两个字符串 sp,找到 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:数组长度,这里为26
  • value:填充的值,这里为0
  • start(可选):起始索引,默认0
  • end(可选):结束索引,默认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)

相关题目

  1. 76. 最小覆盖子串 - 变长窗口
  2. 567. 字符串的排列 - 相同思路,但只需返回是否存在
  3. 3. 无重复字符的最长子串 - 不同窗口条件

九、面试技巧

如果面试官问:"为什么不用排序?"

:排序每个子串需要O(k log k),而滑动窗口只需要O(1)更新,更高效。

如果面试官问:"能进一步优化吗?"

:可以用diff变量避免每次都比较整个数组,将常数因子从26降到1。

如果面试官问:"空间复杂度还能优化吗?"

:已经是O(1),无法再优化,因为需要存储频率信息。


总结:这道题是滑动窗口的经典应用,核心是维护一个固定大小的窗口,通过字符频率比较来判断是否为异位词。掌握本题对理解所有滑动窗口题目都有帮助。