Leetcode 57

1 题目

567. 字符串的排列

给你两个字符串 s1s2 ,写一个函数来判断 s2 是否包含 s1的 排列。如果是,返回 true ;否则,返回 false

换句话说,s1 的排列之一是 s2子串

示例 1:

复制代码
输入:s1 = "ab" s2 = "eidbaooo"
输出:true
解释:s2 包含 s1 的排列之一 ("ba").

示例 2:

复制代码
输入:s1= "ab" s2 = "eidboaoo"
输出:false

提示:

  • 1 <= s1.length, s2.length <= 104
  • s1s2 仅包含小写字母

2 代码实现

cpp 复制代码
class Solution {
public:
    // 判断 s 中是否存在 t 的排列
    bool checkInclusion(string t, string s) {
        unordered_map<char, int> need, window;
        for (char c : t) need[c]++;

        int left = 0, right = 0;
        int valid = 0;
        while (right < s.size()) {
            char c = s[right];
            right++;
            // 进行窗口内数据的一系列更新
            if (need.count(c)) {
                window[c]++;
                if (window[c] == need[c])
                    valid++;
            }

            // 判断左侧窗口是否要收缩
            while (right - left >= t.size()) {
                // 在这里判断是否找到了合法的子串
                if (valid == need.size())
                    return true;
                char d = s[left];
                left++;
                // 进行窗口内数据的一系列更新
                if (need.count(d)) {
                    if (window[d] == need[d])
                        valid--;
                    window[d]--;
                }
            }
        }
        // 未找到符合条件的子串
        return false;
    }
};

解题思路

  1. 排列的本质 :两个字符串互为排列,意味着它们的字符种类和数量完全相同(顺序无关)。因此,问题转化为:在s中寻找一个长度与t相同的子串,其字符计数与t完全一致。
  2. 滑动窗口 :用一个窗口在s中滑动,始终保持窗口长度等于t的长度,通过哈希表记录窗口内的字符计数,与t的字符计数对比。
  3. 哈希表作用need存储t中每个字符的需求次数,window存储当前窗口中每个字符的实际次数,valid记录已满足需求的字符种类数(用于快速判断是否匹配)。

代码逐行解析

1. 初始化哈希表
cpp 复制代码
unordered_map<char, int> need, window;
for (char c : t) need[c]++;  // 统计t中每个字符的需求次数
  • need:键为字符,值为该字符在t中出现的次数(如t="ab"时,need={'a':1, 'b':1})。
  • window:用于动态记录当前窗口内每个字符的出现次数(初始为空)。
2. 滑动窗口的扩张(右边界移动)
cpp 复制代码
int left = 0, right = 0;  // 窗口左右边界(左闭右开区间 [left, right))
int valid = 0;  // 记录窗口中满足"次数等于need"的字符种类数
while (right < s.size()) {
    char c = s[right];  // 即将加入窗口的字符
    right++;  // 右边界右移,扩大窗口

    // 更新窗口数据:如果字符c是t中需要的,才更新window和valid
    if (need.count(c)) {  // c是t中存在的字符
        window[c]++;  // 窗口中c的次数+1
        if (window[c] == need[c]) {  // 当c的次数达到需求
            valid++;  // 满足条件的字符种类+1
        }
    }
    // ... 后续收缩窗口逻辑
}
  • 作用 :不断将右侧字符加入窗口,更新计数。只有当字符是t中需要的(need中存在),才会影响匹配结果,因此只更新这类字符的计数。
3. 滑动窗口的收缩(左边界移动)
cpp 复制代码
// 当窗口长度 >= t的长度时,需要收缩左边界(保证窗口长度等于t的长度)
while (right - left >= t.size()) {
    // 关键判断:如果所有字符的次数都满足需求(valid等于need的大小),说明找到排列
    if (valid == need.size()) {
        return true;
    }

    char d = s[left];  // 即将移出窗口的字符
    left++;  // 左边界右移,缩小窗口

    // 更新窗口数据:如果字符d是t中需要的,需调整window和valid
    if (need.count(d)) {  // d是t中存在的字符
        if (window[d] == need[d]) {  // 若移出前d的次数恰好满足需求
            valid--;  // 满足条件的字符种类-1
        }
        window[d]--;  // 窗口中d的次数-1
    }
}
  • 收缩条件 :窗口长度超过t的长度时,必须收缩左边界,确保窗口长度与t一致(因为排列的长度必须相等)。
  • 匹配判断 :当valid等于need.size()时,说明窗口中所有字符的次数都与t完全匹配(即找到t的排列),直接返回true
4. 未找到匹配的情况
cpp 复制代码
return false;  // 遍历完所有窗口都未匹配,返回false

关键逻辑梳理

  • valid的作用 :避免每次都遍历整个哈希表来判断是否匹配。当一个字符的次数达到需求时,valid加 1;当次数从需求值减少时,valid减 1。当valid等于t中不同字符的种类数(need.size()),则匹配成功。
  • 窗口长度控制 :通过right - left >= t.size()确保窗口长度不超过t的长度,收缩时始终保持窗口长度等于t的长度(因为一旦超过就收缩左边界)。

示例演示(以t="ab",s="eidbaooo"为例)

  1. 初始化need={'a':1, 'b':1}need.size()=2
  2. 窗口扩张 :右边界移动,依次加入'e'(非需求字符,不更新)、'i'(非需求字符)、'd'(非需求字符)、'b'(需求字符,window['b']=1valid=1)、'a'(需求字符,window['a']=1valid=2)。
  3. 收缩判断 :此时窗口长度为 5(right=5,left=0),超过t的长度 2,进入收缩逻辑。收缩左边界至left=3时,窗口为[3,5),字符为'b'、'a'valid=2等于need.size(),返回true

复杂度分析

  • 时间复杂度 :O (n + m),其中ns的长度,mt的长度。每个字符最多被加入和移出窗口各一次,哈希表操作是 O (1)。
  • 空间复杂度 :O (k),其中kt中不同字符的种类数(最多 26 种小写字母,因此可视为 O (1))。

滑动窗口代码框架(cpp)

cpp 复制代码
// 滑动窗口算法伪码框架
void slidingWindow(string s) {
    // 用合适的数据结构记录窗口中的数据,根据具体场景变通
    // 比如说,我想记录窗口中元素出现的次数,就用 map
    // 如果我想记录窗口中的元素和,就可以只用一个 int
    auto window = ...

    int left = 0, right = 0;
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        window.add(c);
        // 增大窗口
        right++;

        // 进行窗口内数据的一系列更新
        ...

        // *** debug 输出的位置 ***
        printf("window: [%d, %d)\n", left, right);
        // 注意在最终的解法代码中不要 print
        // 因为 IO 操作很耗时,可能导致超时

        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s[left];
            window.remove(d);
            // 缩小窗口
            left++;

            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

详解

核心思路回顾

问题是判断s2中是否包含s1的排列。由于 "排列" 意味着长度相同且字符计数完全一致,因此:

  • 窗口大小必须固定为s1的长度(示例 1 中s1长度为 2,窗口大小为 2)。
  • 用两个数组记录字符出现次数(count1记录s1count2记录窗口内字符),若两者相等则找到答案。

滑动窗口框架适配

先明确框架中每个部分的作用,再对应到本题:

cpp 复制代码
void slidingWindow(string s) {
    // 数据结构:用数组记录字符出现次数(因为只有小写字母)
    vector<int> window(26, 0);  // 对应本题的count2
    vector<int> target(26, 0);  // 对应本题的count1(s1的字符计数)

    int left = 0, right = 0;
    while (right < s.size()) {
        // 移入右侧字符,更新窗口
        char c = s[right];
        window[c - 'a']++;
        right++;

        // 窗口大小超过目标长度时,收缩左侧
        while (right - left > target_len) {  // target_len是s1的长度
            char d = s[left];
            window[d - 'a']--;
            left++;
        }

        // 窗口大小等于目标长度时,检查是否匹配
        if (right - left == target_len && window == target) {
            return true;  // 找到符合条件的子串
        }
    }
    return false;
}

测试用例拆解(s1="ab",s2="eidbaooo")

初始化
  • s1长度n=2(目标窗口大小),s2长度m=8
  • target数组(记录s1的字符计数):a出现 1 次,b出现 1 次 → target = [1,1,0,...0](索引 0 对应 'a',1 对应 'b')。
  • window数组(记录窗口内字符计数):初始全为 0。
  • left=0right=0
滑动过程详解

按照框架一步步执行,观察窗口变化:

  1. 第一次循环(right=0)

    • 移入字符s2[0] = 'e'(索引 4),window[4]++ → window[4]=1
    • right=1,窗口范围[0,1)(字符 'e'),大小 1 < 2,不收缩。
    • 窗口大小不等于 2,不检查匹配。
  2. 第二次循环(right=1)

    • 移入字符s2[1] = 'i'(索引 8),window[8]++ → window[8]=1
    • right=2,窗口范围[0,2)(字符 'e','i'),大小 2 == 2。
    • 不收缩(因为大小等于目标)。
    • 检查windowtargetwindow[0,0,0,0,1,0,0,0,1,...],与target[1,1,...])不匹配。
  3. 第三次循环(right=2)

    • 移入字符s2[2] = 'd'(索引 3),window[3]++ → window[3]=1
    • right=3,窗口范围[0,3),大小 3 > 2,需要收缩左侧。
    • 收缩:移出s2[0] = 'e'window[4]-- → window[4]=0left=1
    • 此时窗口范围[1,3)(字符 'i','d'),大小 2。
    • 检查匹配:window[0,0,0,1,0,0,0,0,1,...],不匹配。
  4. 第四次循环(right=3)

    • 移入字符s2[3] = 'b'(索引 1),window[1]++ → window[1]=1
    • right=4,窗口范围[1,4),大小 3 > 2,收缩左侧。
    • 收缩:移出s2[1] = 'i'window[8]-- → window[8]=0left=2
    • 窗口范围[2,4)(字符 'd','b'),大小 2。
    • 检查匹配:window[0,1,0,1,...],不匹配(缺 'a',多 'd')。
  5. 第五次循环(right=4)

    • 移入字符s2[4] = 'a'(索引 0),window[0]++ → window[0]=1
    • right=5,窗口范围[2,5),大小 3 > 2,收缩左侧。
    • 收缩:移出s2[2] = 'd'window[3]-- → window[3]=0left=3
    • 窗口范围[3,5)(字符 'b','a'),大小 2。
    • 检查匹配:window[1,1,0,...],与target完全一致!返回true

关键细节

  1. 窗口大小控制 :通过right - left > n判断是否需要收缩,确保窗口大小始终等于s1的长度。
  2. 字符计数更新 :每次移入 / 移出字符时,直接更新window数组中对应索引的值(c - 'a'),效率极高。
  3. 匹配判断 :只有当窗口大小等于s1长度时,才需要比较windowtarget数组(固定大小 26,比较成本低)。

完整代码(基于框架实现)

cpp 复制代码
#include <vector>
#include <string>
using namespace std;

class Solution {
public:
    bool checkInclusion(string s1, string s2) {
        int n = s1.size();
        int m = s2.size();
        if (n > m) return false;  // s1更长,不可能包含
        
        // 初始化目标计数(s1的字符分布)
        vector<int> target(26, 0);
        for (char c : s1) {
            target[c - 'a']++;
        }
        
        // 滑动窗口相关
        vector<int> window(26, 0);  // 记录当前窗口的字符分布
        int left = 0, right = 0;
        
        while (right < m) {
            // 移入右侧字符,更新窗口
            char c = s2[right];
            window[c - 'a']++;
            right++;
            
            // 窗口大小超过s1长度时,收缩左侧
            while (right - left > n) {
                char d = s2[left];
                window[d - 'a']--;
                left++;
            }
            
            // 窗口大小等于s1长度时,检查是否匹配
            if (right - left == n) {
                if (window == target) {
                    return true;
                }
            }
        }
        
        // 所有窗口都不匹配
        return false;
    }
};

总结

通过固定窗口大小(等于s1长度),用数组记录字符计数,每次滑动时仅更新边缘字符的计数,最终高效判断是否存在匹配的子串。

整个过程时间复杂度为 O (m)(ms2长度),空间复杂度为 O (1)(两个固定大小的数组),非常适合处理大字符串。

【但是这里次序是乱的没关系吗?】

完全没关系!因为题目要求的是 "s1 的排列之一是 s2 的子串",而 "排列" 的核心含义就是字符种类和数量完全相同,顺序可以任意

比如 s1 是 "ab",它的排列包括 "ab" 和 "ba",这两种都是合法的。我们的算法正是利用了 "排列不关心顺序,只关心字符计数" 这一特点来设计的。

为什么顺序不影响?

我们用字符计数数组(targetwindow)来判断是否匹配,而计数数组只记录每个字符出现的次数,不记录字符的顺序。

例如:

  • s1="ab" 的计数是a:1, b:1(顺序无关)。
  • s2 中的子串 "ba" 的计数也是a:1, b:1(顺序不同但计数相同)。
  • 因此算法会认为它们匹配,这正是我们需要的结果。

结合测试用例再看

在示例 1 中,s2 的子串 "ba"(位置 3-4):

  • 顺序是 "b 在前,a 在后",和 s1 的 "a 在前,b 在后" 完全相反。
  • 但两者的计数数组都是[1,1,0,...](a 出现 1 次,b 出现 1 次),所以算法判定匹配,返回 true。

这恰恰体现了算法的正确性 ------ 它不关心字符的顺序,只关心 "有没有足够多的字符",完美契合 "排列" 的定义。

本质原因

排列的数学定义是:从 n 个元素中取出 m 个元素,按照任意顺序排列所得到的序列。因此,判断两个字符串是否为排列关系,只需比较它们的字符种类和数量是否完全一致,与顺序无关。

我们的滑动窗口算法正是通过比较字符计数来绕过 "顺序" 的干扰,高效判断是否存在符合条件的子串。

【我不知道怎么在开始引入合适的数据结构,整数就是动态数组int,字符串就是string/char吗?】

在编程中选择合适的数据结构,核心是看你需要存储什么数据 以及需要对数据进行什么操作。针对不同场景(比如整数、字符串、字符计数等),选择的数据结构会不同。结合你之前的问题(字符串的排列),我们来详细拆解 "如何引入合适的数据结构",以及整数、字符串在实际问题中常用的数据结构。

一、先明确:数据结构的选择依据

选择数据结构时,主要考虑两个问题:

  1. 存储的数据类型:是单个整数?字符串?还是字符出现的次数?
  2. 需要的操作:是快速查询某个值是否存在?还是统计次数?或者是维护一个动态变化的区间(如滑动窗口)?

比如在 "字符串的排列" 问题中:

  • 我们需要存储 "字符出现的次数"(不是单个字符,也不是整个字符串)。
  • 需要的操作是:快速更新某个字符的次数(+1 或 - 1),以及快速比较两组次数是否完全相同。

基于这两点,才能确定用什么数据结构。

二、针对不同场景的数据结构选择

1. 当需要存储 "字符出现的次数"(如滑动窗口问题)

这是你问题中最核心的场景。由于字符串仅包含小写字母 (共 26 个),最合适的是固定大小的数组 (如int[26]vector<int>(26))。

  • **为什么不用哈希表(如 map)?**哈希表虽然也能存键值对(字符→次数),但对于已知范围的固定集合(如 26 个字母),数组的效率更高:

    • 数组通过索引(c - 'a')直接访问,时间复杂度 O (1),比哈希表的哈希计算更快。
    • 比较两个数组是否相等(判断次数是否一致)时,只需遍历 26 个元素,操作简单且高效。
  • 示例 :对于字符'a',索引是0'b'1......'z'25。存储s1 = "ab"的次数时,数组为[1,1,0,0,...,0]a出现 1 次,b出现 1 次,其余 0 次)。

2. 当需要存储 "整数" 时

根据需求不同,常用的数据结构有:

  • 单个整数 :直接用int(或long long避免溢出)。例如:记录窗口的左右边界leftright,用int left = 0;即可。

  • 多个整数(动态变化)

    • 若长度固定:用数组(int arr[10])。
    • 若长度不确定:用动态数组(vector<int>),支持动态扩容。例如:存储一组测试数据的结果,用vector<int> results;,需要时用results.push_back(ans)添加。
  • 需要快速查询 / 修改 :数组或vector(通过索引访问,O (1))。

  • 需要排序 / 去重vector配合排序函数(sort),或set(自动排序 + 去重,但插入删除 O (log n))。

3. 当需要存储 "字符串" 时
  • 单个字符串 :用string(C++)或str(Python),内置了丰富的操作(如取子串、拼接、访问单个字符等)。例如:题目中的s1s2,直接定义string s1, s2;即可。

  • 访问字符串中的单个字符 :通过索引,如s2[right](C++ 中string本质是字符数组的封装)。注意:字符是char类型(如'a'),可以通过'a' - 'a' = 0转为整数索引,这是字符计数的关键。

  • 多个字符串 :用vector<string>(动态存储多个字符串),或unordered_map<string, int>(存储字符串→次数的映射,如统计单词出现次数)。

三、回到 "字符串的排列" 问题:如何一步步引入数据结构?

  1. 明确问题需求 :需要判断s2中是否有一个子串,其字符种类和次数s1完全相同(顺序无关)。

  2. 拆解需要存储的数据

    • s1的字符次数(固定,作为目标)。
    • s2中当前窗口的字符次数(动态变化,随窗口滑动更新)。
    • 窗口的左右边界(整数,控制窗口范围)。
  3. 选择对应的数据结构

    • 字符次数:用vector<int>(26)(因为 26 个小写字母,索引计算简单,更新 / 比较高效)。
    • 窗口边界:用int left = 0, right = 0(单个整数即可)。
    • 原始字符串:用string s1, s2(直接存储输入的字符串)。
  4. 结合滑动窗口框架使用

    cpp 复制代码
    // 存储目标次数(s1的字符分布)
    vector<int> target(26, 0);  
    for (char c : s1) target[c - 'a']++;  // 遍历s1,初始化目标次数
    
    // 存储当前窗口的次数(s2的子串)
    vector<int> window(26, 0);  
    
    // 窗口边界(整数)
    int left = 0, right = 0;  
    
    // 滑动窗口逻辑
    while (right < s2.size()) {
        char c = s2[right];  // 取当前字符(char类型)
        window[c - 'a']++;  // 更新窗口次数(数组操作)
        right++;  // 移动右边界(整数更新)
    
        // 收缩窗口(当窗口过大时)
        while (right - left > s1.size()) {
            char d = s2[left];
            window[d - 'a']--;  // 减少左边界字符的次数
            left++;  // 移动左边界
        }
    
        // 判断是否匹配(比较两个数组)
        if (window == target) return true;
    }

四、总结:数据结构选择的核心逻辑

  1. 字符计数场景 (如滑动窗口、异位词问题):优先用固定大小数组(针对已知范围的字符,如 26 个字母),效率高于哈希表。
  2. 整数存储 :单个用int,多个且动态用vector<int>,需要排序去重用set
  3. 字符串存储 :单个用string,访问字符用索引,多个用vector<string>

核心是:根据数据的类型和操作需求,选择能让操作更高效、代码更简洁的数据结构。比如字符计数用数组,就是因为它的 "索引访问" 和 "快速比较" 特性完美适配问题需求。

【关于 c - 'a' 在「字符串的排列」问题中用法的疑惑】

一、最初的疑惑:c - 'a' 是什么意思?

  • 表面看 :代码里用 c - 'a' 作为数组索引(如 target[c - 'a']++),但不理解为什么能这么写。
  • 困惑点c 是字符(比如 'a''b'),'a' 也是字符,字符之间为什么能做减法?结果为什么能当数组索引?

二、逐步理解:字符在计算机中的存储本质

  1. 字符的底层存储 :计算机中没有 "字符",字符本质是用 ASCII 码(整数) 表示的。例如:

    • 'a' 的 ASCII 码是 97
    • 'b' 的 ASCII 码是 98
    • ...
    • 'z' 的 ASCII 码是 122(这些是国际标准,所有计算机都遵循)
  2. char 类型的特殊性 :在 C++ 中,char 是一种整数类型(占 1 字节),但:

    • 输出时会自动转换为对应的字符(比如 cout << 'a' 显示 a)。
    • 运算时会用其 ASCII 码(整数)参与计算(比如 'b' - 'a' 实际是 98 - 97 = 1)。

三、关键突破:c - 'a' 的作用是 "字符→索引" 映射

  • 目的 :将 26 个小写字母(a-z)映射到数组的 0-25 索引(因为数组 targetwindow 大小为 26)。
  • 计算逻辑 :对于任意小写字母 cc - 'a' 的结果就是它对应的索引:
    • 'a' - 'a' = 97 - 97 = 0 → 索引 0
    • 'b' - 'a' = 98 - 97 = 1 → 索引 1
    • ...
    • 'z' - 'a' = 122 - 97 = 25 → 索引 25
  • 举例 :当 c = 'h' 时,'h' 的 ASCII 码是 104,104 - 97 = 7 → 对应数组索引 7,用于记录 'h' 的出现次数。

四、实际用途:统计字符出现次数

在代码中,target[c - 'a']++window[c - 'a']++ 的作用是:

  1. c - 'a' 找到字符 c 在数组中对应的位置(索引)。
  2. 对该位置的值加 1,记录这个字符又出现了一次。

例如,处理 s1 = "ajhfh" 时:

  • 'a' 对应索引 0 → target[0] 从 0→1(记录 'a' 出现 1 次)。
  • 'j' 对应索引 9 → target[9] 从 0→1(记录 'j' 出现 1 次)。
  • 'h' 对应索引 7 → 两次出现后 target[7] 从 0→2(记录 'h' 出现 2 次)。

五、特殊情况的验证

  1. 包含 'z''z' - 'a' = 25,对应数组索引 25(数组大小 26,合法),计数正常。
  2. 字符范围外的情况 :若字符串包含大写字母(如 'A')或符号(如 '!'),c - 'a' 可能得到负数或超过 25 的索引(导致数组越界)。但本题明确说明 "仅包含小写字母",因此无需考虑。

六、总结:c - 'a' 的核心意义

  • 本质是利用字符的 ASCII 码特性 ,将 a-z 这 26 个字符一一映射到 0-25 的整数索引。
  • 目的是用数组高效统计字符出现次数,为后续比较 "两个字符串的字符分布是否相同" 提供基础。
  • 适用场景:仅包含小写字母的字符计数问题(若字符范围扩大,需改用 256 位数组或哈希表)。

通过以上梳理,能清晰理解 c - 'a' 的原理和用途,后续遇到类似字符计数问题时可举一反三。

相关推荐
im_AMBER2 小时前
Leetcode 58 | 附:滑动窗口题单
笔记·学习·算法·leetcode
sin_hielo2 小时前
leetcode 2154
算法·leetcode
Sunhen_Qiletian2 小时前
YOLO的再进步---YOLOv3算法详解(上)
算法·yolo·计算机视觉
伯明翰java2 小时前
Redis学习笔记-List列表(2)
redis·笔记·学习
云帆小二2 小时前
从开发语言出发如何选择学习考试系统
开发语言·学习
Elias不吃糖3 小时前
总结我的小项目里现在用到的Redis
c++·redis·学习
BullSmall3 小时前
《道德经》第六十三章
学习
ANYOLY3 小时前
Sentinel 限流算法详解
算法·sentinel
AA陈超3 小时前
使用UnrealEngine引擎,实现鼠标点击移动
c++·笔记·学习·ue5·虚幻引擎