当前位置: 首页 > news >正文

LeetCode hot 100 每日一题(8)——438. 找到字符串中所有字母异位词

这是滑动窗口的另一道题目,难度为中等。
让我们来看看题目描述:

给定两个字符串 sp,找到 s 中所有 p异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。


示例 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 * 1 0 4 10^4 104
  • sp 仅包含小写字母

说人话环节:

给定两个字符串 s 和 p,找到 s 中所有与 p 字符组成相同(顺序不限)的子串,并返回这些子串的起始位置。

题解

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        // 创建一个哈希表,用来存储 p 中每个字符及其需要的出现次数
        Map<Character, Integer> need = new HashMap<>();
        // 创建一个哈希表,用来存储当前滑动窗口中各字符的出现次数
        Map<Character, Integer> window = new HashMap<>();

        // 遍历 p 中的每个字符,将每个字符的出现次数记录到 need 中
        for(char c : p.toCharArray()){
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

        // 初始化左右指针,表示滑动窗口的左右边界
        int left = 0, right = 0;
        // valid 用来记录窗口中满足 need 条件的字符种数
        int valid = 0;
        // 用于存储所有找到的异位词子串的起始索引
        List<Integer> res = new ArrayList<>();

        // 开始滑动窗口遍历 s 字符串
        while(right < s.length()){
            // 取出当前右边界的字符,并将右指针右移一位
            char c = s.charAt(right);
            right++;

            // 如果当前字符是 p 中需要的字符
            if(need.containsKey(c)){
                // 更新窗口中该字符的数量
                window.put(c, window.getOrDefault(c, 0) + 1);
                // 如果当前窗口中该字符的数量与 need 中要求的数量相等,valid 加 1
                if(window.get(c).equals(need.get(c))){
                    valid++;
                }
            }

            // 当窗口大小达到 p 的长度时,开始判断和调整窗口
            while(right - left >= p.length()){
                // 如果 valid 等于 need 中键的数量,说明当前窗口中的字符完全满足 p 的要求,是 p 的一个异位词
                if(valid == need.size()){
                    res.add(left); // 将当前窗口的起始索引加入结果列表
                }
                // 准备移除窗口左边界的字符
                char d = s.charAt(left);
                left++;

                // 如果移除的字符是 p 中需要的字符,则更新窗口数据
                if(need.containsKey(d)){
                    // 如果窗口中该字符的数量正好符合要求,则 valid 需要减 1
                    if(window.get(d).equals(need.get(d))){
                        valid--;
                    }
                    // 窗口中该字符的数量减 1
                    window.put(d, window.get(d)-1);
                }
            }
        }
        // 返回所有找到的异位词子串的起始索引
        return res;
    }
}

我们来回忆一下滑动窗口基本模板:

int left = 0, right = 0;

while (right < nums.size()) {
    // 增大窗口
    window.addLast(nums[right]);
    right++;
    
    while (window needs shrink) {
        // 缩小窗口
        window.removeFirst(nums[left]);
        left++;
    }
}

实例

我们以以下实例来大致模拟一下整个过程:

输入:
s = “cbaebabacd”
p = “abc”
输出: [0,6]

整体思路是使用固定大小为 p.length() 的滑动窗口来检测窗口内的字符是否能构成 p 的异位词。当窗口大小达到 p 的长度后,我们需要把窗口最左边的字符移除,为下一次比较腾出位置。

下面是这段代码在窗口缩小时的具体过程:

假设前面已经完成了以下初始化工作:

  • need = {‘a’:1, ‘b’:1, ‘c’:1}(记录 p 中每个字符出现的次数)
  • window:当前窗口中各字符的统计(初始为空)
  • left = 0, right = 当前右指针位置,valid 表示窗口中满足 need 条件的字符种数
  • 当窗口的大小 right - left 等于 p 的长度(即3)时,我们检查 valid 是否等于 need 的大小(3),如果相等就说明窗口中字符完全匹配 p(构成异位词),记录当前 left;随后需要通过调整窗口来继续检测后续位置。

接下来重点看缩小窗口这一段代码(当窗口长度固定为 p 的长度时):

char d = s.charAt(left);
left++;

if(need.containsKey(d)){
    if(window.get(d).equals(need.get(d))){
        valid--;
    }
    window.put(d, window.get(d)-1);
}

我们结合具体的运行过程说明:

1. 初始阶段形成第一个窗口

  • 索引 0 到 2:
    读取字符:
    • s[0] = ‘c’ → 更新 window:{‘c’:1},由于 window[‘c’] 与 need[‘c’] 匹配,valid 从 0 增加到 1。
    • s[1] = ‘b’ → 更新 window:{‘c’:1, ‘b’:1},window[‘b’] 与 need[‘b’] 匹配,valid 增加到 2。
    • s[2] = ‘a’ → 更新 window:{‘c’:1, ‘b’:1, ‘a’:1},window[‘a’] 与 need[‘a’] 匹配,valid 增加到 3。
      此时窗口大小为 3(即 p 的长度),并且 valid 等于 3(need 中不同字符个数),说明从索引 0 开始的子串 “cba” 是一个异位词,因此记录索引 0到结果中。

2. 开始缩小窗口,准备移动窗口

当窗口大小固定为 3 时,需要调整窗口:

  • 移除索引 0 的字符 ‘c’:
    • 执行 char d = s.charAt(left); 取出 ‘c’,然后 left++ 将 left 从 0 变为 1。
    • 检查 if(need.containsKey(d)):‘c’ 在 need 中,所以继续检查。
    • 判断 if(window.get(d).equals(need.get(d))):此时 window[‘c’] 是 1,与 need[‘c’] 也为 1,说明 ‘c’ 正好满足条件,所以 valid 减 1,从 3 变为 2。
    • 然后执行 window.put(d, window.get(d)-1);,将 window[‘c’] 从 1 减为 0。
      此操作完成后,当前窗口变为索引 [1,2](对应字符 “ba”),valid = 2。

这段代码的意义就在于:

  • 将窗口左侧最旧的字符移出窗口
  • 如果移出的字符正好满足 need 的要求(即移除前窗口中该字符的数量刚好符合需要),那么 valid 计数也要减少,因为窗口现在少了一个完全匹配的字符
  • 同时更新 window 中该字符的数量

3. 窗口继续滑动

接下来程序继续将右指针右移,补充窗口字符,再次达到窗口大小为 3后再做判断和移除:

  • 举例说明后续部分(关键节点):
    • 当 right 指针移动并形成新的窗口 “bac” 时(对应索引 6 到 8),窗口中的统计变为 {‘b’:1, ‘a’:1, ‘c’:1},此时 valid 恢复到 3(因为相应字符数量与 need 完全匹配),因此记录索引 6。
    • 紧接着,为了继续滑动窗口,又会执行一次缩小操作:移除当前窗口最左边的字符(如索引 6 的字符 ‘b’),如果移除前 window[‘b’] 恰好等于 need[‘b’](1),那么 valid 就会减少,保证窗口统计更新正确。

这一段代码的作用就是在每次窗口达到固定长度 p.length() 后,通过移除窗口最左边的字符来“滑动”窗口,并同步更新窗口中每个字符的计数以及 valid 指标,从而为后续比较做准备。如果不移除最左边的字符,窗口就会一直增大,就无法保持与 p 长度一致的窗口去判断是否为异位词。

通过不断移动右指针加入新字符,并用这段代码移除旧字符,我们就能遍历整个字符串 s,找出所有符合条件的异位词子串。最终在本例中,我们得到的结果是 [0,6],分别对应 “cba”(索引 0)和 “bac”(索引 6)。

问题与解答

[NOTE] 问题1
window这个hashmap中其实也只会有a、b、c三个key吗?其他的字符只会存在于left到right这个双指针之中对吗?
解答:
是的,window 这个 HashMap 只会存储 p 中出现的字符(在本例中是 ‘a’、‘b’ 和 ‘c’),因为只有当当前字符在 need 中(即在 p 中)时才会将其加入或更新 window。对于 s 中其他不在 p 中的字符,虽然它们依然会被 right 指针经过,但不会加入到 window 中,只在滑动窗口内存在,而不记录在 window 这个映射中。

[NOTE] 问题2
我是否可以这样理解:
window和need这两个hashmap的key其实是一样的,不一样的是value的值,作为要找出的子串hashmap,need中保存每个字符出现的次数,而window的作用只是拿来与need进行对比,在滑动窗口的移动过程中,window中每个key的字符出现次数会改变,所以设置一个valid。
valid++的条件是if (window.get(c).equals(need.get(c))),只有当window和need中的valid == need.size()条件满足时,才会记录左指针left的值存入res。这才是这个算法最重要的判断条件对吗?
解答:
你的理解是正确的。简要总结如下:

  • 相同的 Key 集合
    needwindow 的 key 集合是一样的,都是 p 中出现的字符。

  • value 的含义不同

    • need 保存的是 p 中每个字符需要的出现次数。
    • window 动态记录当前滑动窗口中这些字符的实际出现次数。
  • valid 的作用
    window 中某个字符的出现次数达到 need 中的要求时,就会对 valid 进行累加。只有当 valid 的值等于 need.size() 时,说明当前窗口中的字符种类和数量都满足 p 的要求,此时可以认为这个窗口内的子串是 p 的一个异位词,因此记录下 left 的位置。

这正是该算法的核心判断条件。

完整程序可调试

以下是可在编译器调试的完整代码,需要自取:

import java.util.*;

public class test_438 {
    public static void main(String[] args) {
        // 输入样例
        String s = "cbaebabacd";
        String p = "abc";

        // 创建 Solution 对象并调用 findAnagrams 方法
        Solution solution = new Solution();
        List<Integer> result = solution.findAnagrams(s, p);

        // 输出结果
        System.out.println("输出: " + result);
    }
}

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        // 创建 need 哈希表,用于存储 p 中每个字符及其需要的出现次数
        Map<Character, Integer> need = new HashMap<>();
        // 创建 window 哈希表,用于存储当前滑动窗口中各字符的出现次数
        Map<Character, Integer> window = new HashMap<>();

        // 统计 p 中每个字符出现的次数
        for (char c : p.toCharArray()) {
            need.put(c, need.getOrDefault(c, 0) + 1);
        }

        int left = 0, right = 0;
        // valid 用于记录窗口中满足 need 条件的字符种数
        int valid = 0;
        // 用于存储所有找到的异位词子串的起始索引
        List<Integer> res = new ArrayList<>();

        // 开始滑动窗口遍历 s 字符串
        while (right < s.length()) {
            // 当前加入窗口的字符
            char c = s.charAt(right);
            right++;

            // 如果当前字符是 p 中需要的字符,则更新窗口数据
            if (need.containsKey(c)) {
                window.put(c, window.getOrDefault(c, 0) + 1);
                if (window.get(c).equals(need.get(c))) {
                    valid++;
                }
            }

            // 当窗口大小达到 p 的长度时,检查并缩小窗口
            while (right - left >= p.length()) {
                // 如果 valid 等于 need 中字符的种数,则说明当前窗口为异位词
                if (valid == need.size()) {
                    res.add(left);
                }
                // 移除窗口最左边的字符,准备缩小窗口
                char d = s.charAt(left);
                left++;

                // 如果移除的字符是 p 中需要的字符,更新窗口数据
                if (need.containsKey(d)) {
                    if (window.get(d).equals(need.get(d))) {
                        valid--;
                    }
                    window.put(d, window.get(d) - 1);
                }
            }
        }
        return res;
    }
}

相关文章:

  • p5.js:绘制各种内置的几何体,还能旋转
  • 设计模式分类解析与JavaScript实现
  • Linux Redis安装部署、注册服务
  • 蓝桥杯专项复习——stl(stack、queue)
  • hadoop伪分布式搭建--启动过程中如果发现某个datanode出现问题,如何处理?
  • 24.策略模式实现日志
  • leetcode日记(101)填充每个节点的下一个右侧节点指针Ⅱ
  • Deepseek+QuickAPI:打造 MySQL AI 智能体入门篇(一)
  • CVE-2017-5645(使用 docker 搭建)
  • Java面试:集合框架体系
  • 【web逆向】优某愿 字体混淆
  • 提升fcp
  • 八、Prometheus 静态配置(Static Configuration)
  • 仿“东方甄选”直播商城小程序运营平台
  • Git的基本指令
  • 使用爬虫获取自定义API操作API接口
  • 通信协议传输过程中的序列化和反序列化机制
  • 【记】如何理解kotlin中的委托属性?
  • Python的基本知识
  • MySQL学习笔记
  • 建筑瞭望|从黄浦江畔趸船改造看航运设施的升级与利用
  • 六省会共建交通枢纽集群,中部六省离经济“第五极”有多远?
  • 特朗普公开“怼”库克:苹果不应在印度生产手机
  • 受关税政策影响,沃尔玛将上调部分商品在美售价
  • 德州国资欲退出三东筑工,后者大股东系当地房企东海集团
  • 商务部:长和集团出售港口交易各方不得规避审查