C++ 力扣 438.找到字符串中所有字母异位词 题解 优选算法 滑动窗口 每日一题

文章目录

这是封面原图,还有AI生成的动图,嘿嘿:

一、题目描述

题目链接:找到字符串中所有字母异位词

题目描述:

示例 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 仅包含小写字母

二、为什么这道题值得我们弄懂?

作为滑动窗口在字符串匹配场景的经典例题,LeetCode 第 438 题的价值体现在:

  • 帮你掌握"固定窗口长度"的滑动窗口用法------不同于"水果成篮"中动态调整窗口大小,本题窗口长度由 p 的长度固定,是滑动窗口的另一类典型应用;
  • 带你理解"字符频率匹配"的核心逻辑------字母异位词的本质是"字符种类和数量完全相同",如何通过统计频率高效判断匹配;
  • 让你学会"窗口滑动时的频率更新技巧"------如何避免重复计算,仅通过增减边界字符的频率实现高效维护。

学会这道题,能为解决"字符串子串匹配""字符频率相关"的问题打下基础,比如后续要讲的"串联所有单词的子串",核心思路也与此题相通。

三、题目拆解:抓住关键约束

结合题目要求和示例,核心要素如下:

  1. 输入是两个字符串 s(待搜索的主串)和 p(目标子串),长度均可达 3×10⁴(需考虑效率)。
  2. 核心约束是"子串需为 p 的字母异位词",即:
    • 子串长度必须与 p 相同(因字母数量相同);
    • 子串中每种字符的出现次数与 p 完全一致。
  3. 目标是找到 s 中所有满足条件的子串的起始索引,并返回这些索引的列表。

关键点提炼

  • 窗口长度固定 :子串长度必等于 p.size(),因此滑动窗口的大小是固定的(p.size());
  • 匹配核心是频率 :无需关注字符顺序,只需统计窗口内字符频率与 p 的字符频率是否完全一致;
  • 效率要求高 :需避免暴力枚举(枚举所有长度为 p.size() 的子串,再逐个统计频率),时间复杂度需控制在 O(n) 级别(n 为 s 的长度);
  • 边界处理重要:窗口滑动时,需准确更新进入窗口和离开窗口的字符频率,避免统计错误。

四、思路演进:从暴力到滑动窗口

1. 暴力解法

最直观的思路是:枚举 s 中所有长度为 p.size() 的子串,对每个子串统计字符频率,再与 p 的字符频率对比,若一致则记录起始索引。

  • 枚举方式

    起始索引 i 从 0 到 s.size()-p.size(),对每个 i,取子串 s[i:i+p.size()],用数组或哈希表统计其字符频率,再与 p 的频率数组对比。

  • 为什么不可行

    时间复杂度高。假设 p 的长度为 m,s 的长度为 n,则需枚举 n-m+1 个子串,每个子串统计频率需 O(m) 时间,对比频率也需 O(26) 时间(因只有小写字母),总时间复杂度为 O((n-m+1)×m),当 m 接近 n 时(如 m=1.5×10⁴,n=3×10⁴),会产生约 2×10⁸ 次操作,容易超时。

结论:必须用滑动窗口优化,通过"固定窗口+动态更新频率"减少重复计算,将时间复杂度降至 O(n)。

2. 滑动窗口的思路:固定窗口+频率匹配

滑动窗口的核心是:维护一个长度为 p.size() 的固定窗口,在 s 中从左到右滑动,通过动态更新窗口内的字符频率,实时判断窗口是否与 p 的频率匹配。

步骤拆解:

  • 预处理 p 的频率 :用一个数组 hash1 统计 p 中每个字符的出现次数(因只有 26 个小写字母,数组大小设为 26 即可)。
  • 初始化窗口 :用另一个数组 hash2 统计 s 中初始窗口(前 p.size() 个字符)的频率,同时用一个变量 count 记录"窗口中满足'频率≤p中对应字符频率'的字符数量"(用于快速判断匹配)。
  • 滑动窗口
    • 向右移动窗口时,右侧新进入窗口的字符(in):更新 hash2[in],若更新后 hash2[in] 仍≤hash1[in],则 count 加 1(说明该字符的频率仍在合理范围内);
    • 左侧离开窗口的字符(out):更新 hash2[out],若更新前 hash2[out]hash1[out],则 count 减 1(说明该字符的频率已超出合理范围);
  • 判断匹配 :若 count 等于 p.size(),说明窗口内所有字符的频率均与 p 一致(即窗口是 p 的字母异位词),记录当前窗口的起始索引。

关键问题:为什么用 count 而不是直接对比两个频率数组?

直接对比 hash1hash2 数组(每次滑动后遍历 26 个元素)也能判断匹配,但时间复杂度会增加 O(26)×(n-m+1)。而 count 的作用是"实时记录有效字符数":当 count 等于 p.size() 时,意味着窗口内恰好有 p.size() 个字符,且每个字符的频率都未超过 p 中的频率(因只有满足 hash2[ch]≤hash1[ch] 时才会累计 count),结合窗口长度与 p 相同,此时必然是字母异位词。用 count 可将判断环节的时间复杂度从 O(26) 降至 O(1)。

五、算法实现:固定窗口的滑动与频率更新

实现代码

cpp 复制代码
class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        vector<int> ret;  // 存储结果:所有满足条件的子串起始索引
        int m = p.size(), n = s.size();
        // 若s长度小于p,直接返回空(不可能有符合条件的子串)
        if (n < m) return ret;
        
        // hash1统计p中每个字符的频率(下标0-25对应a-z)
        int hash1[26] = {0};
        for (auto ch : p) hash1[ch - 'a']++;
        
        // hash2统计当前窗口中每个字符的频率
        int hash2[26] = {0};
        // left:窗口左边界;right:窗口右边界;count:窗口中"频率≤hash1对应频率"的字符总数
        for (int left = 0, right = 0, count = 0; right < n; right++) {
            // 右侧字符进入窗口
            char in = s[right];
            hash2[in - 'a']++;
            // 若进入后该字符频率仍≤p中的频率,说明是有效字符,count+1
            if (hash2[in - 'a'] <= hash1[in - 'a']) count++;
            
            // 当窗口长度超过p的长度时,左侧字符离开窗口
            if (right - left + 1 > m) {
                char out = s[left++];  // left右移前记录离开的字符
                // 若离开前该字符频率≤p中的频率,说明减少后有效字符数减少,count-1
                if (hash2[out - 'a'] <= hash1[out - 'a']) count--;
                hash2[out - 'a']--;  // 更新频率(注意先判断再减,否则判断条件错误)
            }
            
            // 当count等于p的长度时,窗口是p的字母异位词,记录起始索引left
            if (count == m) ret.push_back(left);
        }
        return ret;
    }
};

代码细节拆解:

  1. 频率数组初始化
    hash1hash2 均为大小 26 的数组(对应 26 个小写字母),初始化为 0。hash1 先遍历 p 填充,记录 p 中每个字符的出现次数。

  2. 窗口初始化与扩展

    右指针 right 从 0 开始移动,每次将 s[right] 加入窗口(hash2[in-'a']++)。若加入后该字符的频率未超过 p 中的频率(hash2[in-'a']≤hash1[in-'a']),则 count 加 1------count 累计的是"窗口中符合频率要求的字符总数"。

  3. 窗口收缩(固定长度)

    当窗口长度(right-left+1)超过 p.size() 时,左指针 left 右移,同时将 s[left] 移出窗口。移出前需判断:若移出前该字符的频率≤p 中的频率,则 count 减 1(因该字符不再属于窗口,有效字符数减少),之后再更新 hash2[out-'a']--

  4. 匹配判断与结果记录

    count 等于 p.size() 时,说明窗口内所有字符的频率均与 p 一致(且窗口长度恰好为 p.size()),因此当前窗口是 p 的字母异位词,记录起始索引 left

时间复杂度与空间复杂度

时间复杂度

该算法的时间复杂度为 O(m + n),其中 m 是字符串 p 的长度,n 是字符串 s 的长度。

具体来说:

  • 预处理阶段,遍历 p 以统计字符频率,这一步的时间开销为 O(m);
  • 滑动窗口过程中,right 指针从 s 的起始位置移动到末尾,每个字符仅被访问一次,left 指针也仅单向移动,不会重复遍历,这部分的时间开销为 O(n);
  • 其余辅助操作(如更新频率数组、维护 count 变量等)均为常数级别的操作,时间开销可忽略。

当 m ≤ n 时,时间复杂度可简化为 O(n)。

空间复杂度

该算法的空间复杂度为 O(1)(常数级)。

具体来说:

  • 用于统计字符频率的两个数组 hash1hash2,大小均固定为 26(对应 26 个小写字母),与输入字符串 sp 的长度无关;
  • 其他变量(如 leftrightcount 等)均为单个整数,占用的空间为常数级;
  • 结果数组 ret 用于存储输出结果,属于必要的输出空间,通常不计入算法的额外空间复杂度。

因此,算法额外使用的空间为固定常数,空间复杂度为 O(1)。

六、实现过程中的坑点总结

  1. 窗口收缩时的顺序错误

    • 错误 :先执行 hash2[out-'a']--,再判断是否需要减少 count
      例如:out 字符在 hash2 中的频率为 2,hash1 中为 2。若先减为 1,再判断"1≤2",会错误地认为需要减 count,但实际移出前频率是 2(符合条件),应该减 count------但顺序颠倒后逻辑就错了。
    • 解决 :先判断"移出前的频率是否≤hash1",再更新 hash2 的频率。即先执行 if (hash2[out-'a'] <= hash1[out-'a']) count--;,再执行 hash2[out-'a']--;
  2. 忽略"s长度小于p"的边界情况

    • 错误 :未判断 n < m 直接开始滑动,可能导致窗口长度始终无法达到 m,或数组访问越界。
    • 解决 :开头加判断 if (n < m) return ret;,直接返回空结果。
  3. count的含义理解偏差

    • 错误 :认为 count 是"窗口中与 p 共有的字符种类数",而非"符合频率的字符总数"。
      例如:p 是 "aab"(hash1[a]=2, hash1[b]=1),窗口是 "aab"(hash2[a]=2, hash2[b]=1),此时 count 应等于 3(2个a+1个b,共3个字符均符合频率),而非 2(字符种类数)。
    • 解决 :明确 count 统计的是"单个字符的数量",而非"种类数",只有当 count 等于 p.size() 时,才说明所有字符都匹配。
  4. 窗口长度判断错误

    • 错误 :用 right - left > m 判断窗口是否超长(正确应为 right - left + 1 > m)。
      窗口长度是 right - left + 1(闭区间 [left, right] 的元素个数),例如 left=0,right=2 时,长度是 3,而非 2。
    • 解决 :严格用 right - left + 1 > m 作为窗口超长的判断条件。

七、下题预告

明天将讲解 30. 串联所有单词的子串,这是滑动窗口在"多单词匹配"场景的进阶应用。

提前思考方向:

  • 题目中"串联所有单词的子串"的本质是什么?如何将其转化为可通过滑动窗口解决的问题?
  • 若单词长度固定,子串的长度是否固定?窗口大小如何设定?
  • 如何统计窗口内的单词频率,与目标单词列表的频率进行匹配?(与本题的字符频率匹配有何异同?)

如果觉得这篇解析有帮助,不妨:

🌟 点个赞,让更多人看到这份清晰思路

⭐ 收个藏,下次复习时能快速找回灵感

👀 加个关注,明天见!

相关推荐
敲上瘾7 分钟前
Linux I/O 多路复用实战:Select/Poll 编程指南
linux·服务器·c语言·c++·select·tcp·poll
已读不回14312 分钟前
设计模式-策略模式
前端·算法·设计模式
huangyuchi.14 分钟前
【Linux系统】匿名管道以及进程池的简单实现
linux·运维·服务器·c++·管道·匿名管道·进程池简单实现
MrZhangBaby33 分钟前
SQL-leetcode—3374. 首字母大写 II
linux·sql·leetcode
only-lucky43 分钟前
C++中的 Eigen库使用
开发语言·c++
CoovallyAIHub1 小时前
标注成本骤降,DINOv3炸裂发布!冻结 backbone 即拿即用,性能对标SOTA
深度学习·算法·计算机视觉
BB学长1 小时前
流固耦合|01流固耦合分类
人工智能·算法
汤永红1 小时前
week3-[分支嵌套]方阵
c++·算法·信睡奥赛
广州智造1 小时前
EPLAN教程:流体工程
开发语言·人工智能·python·算法·软件工程·软件构建
自信的小螺丝钉1 小时前
Leetcode 343. 整数拆分 动态规划
算法·leetcode·动态规划