【LeetCode Hot100 刷题日记(9/100)】438. 找到字符串中所有字母异位词 —— 滑动窗口 、字符串、哈希表、双指针📌

51 阅读7分钟

📌 题目链接:(leetcode.cn/problems/fi…)

🔍 难度:中等 | 🏷️ 标签:字符串、哈希表、滑动窗口、双指针

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(1)(常数空间,因为字符集固定为26个小写字母)


🧠 题目分析

✅ 给定两个字符串 sp,要求找出 s 中所有 p异位词(anagram)的起始索引。

🔤 什么是异位词?
指两个字符串由相同的字母组成,但顺序可以不同。例如 "abc""bca" 是异位词。

🎯 关键点:

  • 异位词长度必须相等。
  • 字符频次相同即可,顺序无关。
  • 要求返回的是所有满足条件的子串的起始位置

❗ 注意:

  • s 可能比 p 短,此时直接返回空。
  • 不考虑输出顺序。
  • 全部是小写英文字母 → 可用数组代替哈希表优化空间和速度。

🔍 核心算法及代码讲解

✅ 核心思想:滑动窗口 + 字符频次统计

我们使用一个长度为 len(p) 的滑动窗口在 s 上移动,每次判断当前窗口内的字符频次是否与 p 完全一致。

💡 为什么用滑动窗口? 因为我们要找的是“长度固定”的子串(等于 p 的长度),且只关心字符出现次数,不关心顺序。这正是滑动窗口的经典应用场景!

🛠️ 数据结构选择:

  • 使用 vector<int> count(26) 来记录每个字母的频次差(或绝对值)。
  • 由于只有小写字母,所以可以用下标 c - 'a' 映射到数组索引。

🧪 方法一:基础滑动窗口(推荐初学者掌握)

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size(), pLen = p.size();

        if (sLen < pLen) {
            return vector<int>(); // s太短,不可能有异位词
        }

        vector<int> ans;
        vector<int> sCount(26); // 当前窗口中各字符的数量
        vector<int> pCount(26); // p中各字符的数量

        // 初始化:先处理第一个窗口 [0, pLen-1]
        for (int i = 0; i < pLen; ++i) {
            ++sCount[s[i] - 'a']; // 加入窗口中的字符
            ++pCount[p[i] - 'a']; // 记录p中字符数量
        }

        // 第一个窗口是否匹配?
        if (sCount == pCount) {
            ans.emplace_back(0);
        }

        // 滑动窗口:从 i=0 开始,每次右移一位
        for (int i = 0; i < sLen - pLen; ++i) {
            // 移除左边界的字符(即将离开窗口)
            --sCount[s[i] - 'a'];
            // 添加右边界的字符(进入窗口)
            ++sCount[s[i + pLen] - 'a'];

            // 判断当前窗口是否与p的字符分布一致
            if (sCount == pCount) {
                ans.emplace_back(i + 1); // 新窗口起点
            }
        }

        return ans;
    }
};

逐行解析:

vector<int> sCount(26), pCount(26);
  • 两个大小为26的数组,分别存储 s 的当前窗口和 p 的字符频次。
  • 下标 0~25 对应 'a' ~ 'z'
for (int i = 0; i < pLen; ++i) {
    ++sCount[s[i] - 'a'];
    ++pCount[p[i] - 'a'];
}
  • 初始化第一个窗口 [0, pLen-1],同时统计 p 的字符频次。
if (sCount == pCount)
  • C++ 中 vector<int> 支持 == 操作符,逐元素比较,非常高效。
--sCount[s[i] - 'a'];
++sCount[s[i + pLen] - 'a'];
  • 左边界 s[i] 出窗,右边界 s[i+pLen] 入窗,更新频次。
if (sCount == pCount)
  • 每次滑动后检查是否匹配。

🚀 方法二:优化版滑动窗口(面试加分项!)

💡 优化思路:不再每次都做 sCount == pCount 的完整比较(O(26)),而是维护一个变量 differ 表示有多少个字符的频次不一致。

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size(), pLen = p.size();

        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;
        vector<int> count(26); // count[i] = s中字符 - p中字符 的频次差
        int differ = 0; // 当前有多少个字符的频次不一致

        // 初始化:计算初始窗口的频次差
        for (int i = 0; i < pLen; ++i) {
            ++count[s[i] - 'a'];
            --count[p[i] - 'a'];
        }

        // 统计有多少个字符频次不一致
        for (int j = 0; j < 26; ++j) {
            if (count[j] != 0) {
                ++differ;
            }
        }

        // 初始窗口是否匹配?
        if (differ == 0) {
            ans.emplace_back(0);
        }

        // 滑动窗口
        for (int i = 0; i < sLen - pLen; ++i) {
            // 处理左边界字符 s[i] 离开窗口
            if (count[s[i] - 'a'] == 1) { // 从"不同"变为"相同"
                --differ;
            } else if (count[s[i] - 'a'] == 0) { // 从"相同"变为"不同"
                ++differ;
            }
            --count[s[i] - 'a'];

            // 处理右边界字符 s[i + pLen] 进入窗口
            if (count[s[i + pLen] - 'a'] == -1) { // 从"不同"变为"相同"
                --differ;
            } else if (count[s[i + pLen] - 'a'] == 0) { // 从"相同"变为"不同"
                ++differ;
            }
            ++count[s[i + pLen] - 'a'];

            // 如果没有字符频次不一致,则是异位词
            if (differ == 0) {
                ans.emplace_back(i + 1);
            }
        }

        return ans;
    }
};

关键逻辑详解:

  • count[c] > 0:表示 s 中该字符多于 p

  • count[c] < 0:表示 s 中该字符少于 p

  • count[c] == 0:刚好相等

  • 当某个字符的频次变化导致其从非零变为零时,differ--

  • 从零变为非零时,differ++

🧠 例子: 假设 count['a'] = 1(s中多一个'a'),现在把一个'a'移出窗口:

  • count['a'] 变成 0 → 从“不同”变“相同” → differ--

同理,加入一个'b',原来 count['b'] = -1,现在变成 0 → differ--

优点:

  • 时间复杂度更优:O(n + m),避免了每次 O(Σ) 的比较。
  • 面试中展示你对“状态维护”的理解,体现工程思维!

🧩 解题思路(分步拆解)

  1. 预处理边界情况

    • s.length() < p.length(),直接返回空。
  2. 初始化频次数组

    • 创建两个长度为26的数组:sCount, pCount 或单个 count 数组(带差值)。
  3. 初始化第一个窗口

    • s[0:pLen]p 的字符频次统计出来。
  4. 判断第一个窗口是否匹配

    • 若频次完全一致,记录索引 0
  5. 滑动窗口

    • 循环遍历 i = 0sLen - pLen - 1
      • 移除 s[i](左边界)
      • 添加 s[i + pLen](右边界)
      • 更新频次并判断是否匹配
  6. 收集结果

    • 匹配则将 i+1 加入答案列表。

📊 算法分析

项目方法一(基础)方法二(优化)
时间复杂度O(m + (n-m) × Σ)O(n + m + Σ)
空间复杂度O(Σ)O(Σ)
Σ(字符集大小)26(小写字母)26
是否适合面试✅ 适合入门✅✅ 推荐进阶

📌 解释:

  • m = p.length(), n = s.length()
  • 方法一每次比较 sCount == pCount 花费 O(26)
  • 方法二通过 differ 变量将判断降到 O(1)

💡 面试提示:

  • 主考官可能先让你写方法一,再问:“能否优化?”
  • 此时你可以提出方法二,并解释 differ 的设计思想。
  • 展现你对“减少重复比较”的敏感度。

🧪 代码(完整可运行)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 方法一:基础滑动窗口(清晰易懂)
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size(), pLen = p.size();

        if (sLen < pLen) {
            return vector<int>();
        }

        vector<int> ans;
        vector<int> sCount(26);
        vector<int> pCount(26);

        // 初始化窗口和p的频次
        for (int i = 0; i < pLen; ++i) {
            ++sCount[s[i] - 'a'];
            ++pCount[p[i] - 'a'];
        }

        if (sCount == pCount) {
            ans.emplace_back(0);
        }

        // 滑动窗口
        for (int i = 0; i < sLen - pLen; ++i) {
            --sCount[s[i] - 'a'];
            ++sCount[s[i + pLen] - 'a'];

            if (sCount == pCount) {
                ans.emplace_back(i + 1);
            }
        }

        return ans;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    
    // 测试用例1
    string s1 = "cbaebabacd", p1 = "abc";
    auto result1 = sol.findAnagrams(s1, p1);
    cout << "Test 1: ";
    for (auto x : result1) cout << x << " "; // 输出: 0 6
    cout << endl;

    // 测试用例2
    string s2 = "abab", p2 = "ab";
    auto result2 = sol.findAnagrams(s2, p2);
    cout << "Test 2: ";
    for (auto x : result2) cout << x << " "; // 输出: 0 1 2
    cout << endl;

    return 0;
}

测试结果:

Test 1: 0 6 
Test 2: 0 1 2 

验证正确性:

  • "cba""abc" 的异位词 ✔️
  • "bac""abc" 的异位词 ✔️
  • "ab", "ba", "ab" 都是 "ab" 的异位词 ✔️

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第44题 —— 和为 K 的子数组(中等)

🔹 题目:给定一个整数数组 nums 和整数 k,返回数组中和为 k 的连续子数组的个数。

🔹 核心思路:使用前缀和 + 哈希表,记录每个前缀和出现的次数,从而快速判断是否存在和为 k 的子数组。

🔹 考点:前缀和、哈希表、动态规划思想、面试高频率题。

🔹 难度:中等,但属于“经典模板题”,建议背熟!

💡 提示:不要暴力枚举所有子数组(O(n²)),要用哈希表优化到 O(n)!

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!


本篇总结

  • 学会了如何用滑动窗口解决“固定长度子串匹配”问题。
  • 掌握了字符频次统计的两种实现方式。
  • 理解了如何通过状态维护(如 differ)来优化比较操作。
  • 面试中可灵活选择方法一(清晰)或方法二(高效)。

📌 建议练习


🚀 坚持每天一题,算法不再难!
下期见!👋