1 题目
给你两个字符串 s1 和 s2 ,写一个函数来判断 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 <= 104s1和s2仅包含小写字母
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;
}
};
解题思路
- 排列的本质 :两个字符串互为排列,意味着它们的字符种类和数量完全相同(顺序无关)。因此,问题转化为:在
s中寻找一个长度与t相同的子串,其字符计数与t完全一致。 - 滑动窗口 :用一个窗口在
s中滑动,始终保持窗口长度等于t的长度,通过哈希表记录窗口内的字符计数,与t的字符计数对比。 - 哈希表作用 :
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"为例)
- 初始化 :
need={'a':1, 'b':1},need.size()=2。 - 窗口扩张 :右边界移动,依次加入
'e'(非需求字符,不更新)、'i'(非需求字符)、'd'(非需求字符)、'b'(需求字符,window['b']=1,valid=1)、'a'(需求字符,window['a']=1,valid=2)。 - 收缩判断 :此时窗口长度为 5(
right=5,left=0),超过t的长度 2,进入收缩逻辑。收缩左边界至left=3时,窗口为[3,5),字符为'b'、'a',valid=2等于need.size(),返回true。
复杂度分析
- 时间复杂度 :O (n + m),其中
n是s的长度,m是t的长度。每个字符最多被加入和移出窗口各一次,哈希表操作是 O (1)。 - 空间复杂度 :O (k),其中
k是t中不同字符的种类数(最多 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记录s1,count2记录窗口内字符),若两者相等则找到答案。
滑动窗口框架适配
先明确框架中每个部分的作用,再对应到本题:
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=0,right=0。
滑动过程详解
按照框架一步步执行,观察窗口变化:
-
第一次循环(right=0):
- 移入字符
s2[0] = 'e'(索引 4),window[4]++ → window[4]=1。 right=1,窗口范围[0,1)(字符 'e'),大小 1 < 2,不收缩。- 窗口大小不等于 2,不检查匹配。
- 移入字符
-
第二次循环(right=1):
- 移入字符
s2[1] = 'i'(索引 8),window[8]++ → window[8]=1。 right=2,窗口范围[0,2)(字符 'e','i'),大小 2 == 2。- 不收缩(因为大小等于目标)。
- 检查
window与target:window是[0,0,0,0,1,0,0,0,1,...],与target([1,1,...])不匹配。
- 移入字符
-
第三次循环(right=2):
- 移入字符
s2[2] = 'd'(索引 3),window[3]++ → window[3]=1。 right=3,窗口范围[0,3),大小 3 > 2,需要收缩左侧。- 收缩:移出
s2[0] = 'e',window[4]-- → window[4]=0,left=1。 - 此时窗口范围
[1,3)(字符 'i','d'),大小 2。 - 检查匹配:
window是[0,0,0,1,0,0,0,0,1,...],不匹配。
- 移入字符
-
第四次循环(right=3):
- 移入字符
s2[3] = 'b'(索引 1),window[1]++ → window[1]=1。 right=4,窗口范围[1,4),大小 3 > 2,收缩左侧。- 收缩:移出
s2[1] = 'i',window[8]-- → window[8]=0,left=2。 - 窗口范围
[2,4)(字符 'd','b'),大小 2。 - 检查匹配:
window是[0,1,0,1,...],不匹配(缺 'a',多 'd')。
- 移入字符
-
第五次循环(right=4):
- 移入字符
s2[4] = 'a'(索引 0),window[0]++ → window[0]=1。 right=5,窗口范围[2,5),大小 3 > 2,收缩左侧。- 收缩:移出
s2[2] = 'd',window[3]-- → window[3]=0,left=3。 - 窗口范围
[3,5)(字符 'b','a'),大小 2。 - 检查匹配:
window是[1,1,0,...],与target完全一致!返回true。
- 移入字符
关键细节
- 窗口大小控制 :通过
right - left > n判断是否需要收缩,确保窗口大小始终等于s1的长度。 - 字符计数更新 :每次移入 / 移出字符时,直接更新
window数组中对应索引的值(c - 'a'),效率极高。 - 匹配判断 :只有当窗口大小等于
s1长度时,才需要比较window和target数组(固定大小 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)(m是s2长度),空间复杂度为 O (1)(两个固定大小的数组),非常适合处理大字符串。
【但是这里次序是乱的没关系吗?】
完全没关系!因为题目要求的是 "s1 的排列之一是 s2 的子串",而 "排列" 的核心含义就是字符种类和数量完全相同,顺序可以任意。
比如 s1 是 "ab",它的排列包括 "ab" 和 "ba",这两种都是合法的。我们的算法正是利用了 "排列不关心顺序,只关心字符计数" 这一特点来设计的。
为什么顺序不影响?
我们用字符计数数组(target和window)来判断是否匹配,而计数数组只记录每个字符出现的次数,不记录字符的顺序。
例如:
- 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 或 - 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避免溢出)。例如:记录窗口的左右边界left和right,用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),内置了丰富的操作(如取子串、拼接、访问单个字符等)。例如:题目中的s1和s2,直接定义string s1, s2;即可。 -
访问字符串中的单个字符 :通过索引,如
s2[right](C++ 中string本质是字符数组的封装)。注意:字符是char类型(如'a'),可以通过'a' - 'a' = 0转为整数索引,这是字符计数的关键。 -
多个字符串 :用
vector<string>(动态存储多个字符串),或unordered_map<string, int>(存储字符串→次数的映射,如统计单词出现次数)。
三、回到 "字符串的排列" 问题:如何一步步引入数据结构?
-
明确问题需求 :需要判断
s2中是否有一个子串,其字符种类和次数 与s1完全相同(顺序无关)。 -
拆解需要存储的数据:
s1的字符次数(固定,作为目标)。s2中当前窗口的字符次数(动态变化,随窗口滑动更新)。- 窗口的左右边界(整数,控制窗口范围)。
-
选择对应的数据结构:
- 字符次数:用
vector<int>(26)(因为 26 个小写字母,索引计算简单,更新 / 比较高效)。 - 窗口边界:用
int left = 0, right = 0(单个整数即可)。 - 原始字符串:用
string s1, s2(直接存储输入的字符串)。
- 字符次数:用
-
结合滑动窗口框架使用:
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; }
四、总结:数据结构选择的核心逻辑
- 字符计数场景 (如滑动窗口、异位词问题):优先用固定大小数组(针对已知范围的字符,如 26 个字母),效率高于哈希表。
- 整数存储 :单个用
int,多个且动态用vector<int>,需要排序去重用set。 - 字符串存储 :单个用
string,访问字符用索引,多个用vector<string>。
核心是:根据数据的类型和操作需求,选择能让操作更高效、代码更简洁的数据结构。比如字符计数用数组,就是因为它的 "索引访问" 和 "快速比较" 特性完美适配问题需求。
【关于 c - 'a' 在「字符串的排列」问题中用法的疑惑】
一、最初的疑惑:c - 'a' 是什么意思?
- 表面看 :代码里用
c - 'a'作为数组索引(如target[c - 'a']++),但不理解为什么能这么写。 - 困惑点 :
c是字符(比如'a'、'b'),'a'也是字符,字符之间为什么能做减法?结果为什么能当数组索引?
二、逐步理解:字符在计算机中的存储本质
-
字符的底层存储 :计算机中没有 "字符",字符本质是用 ASCII 码(整数) 表示的。例如:
'a'的 ASCII 码是97'b'的 ASCII 码是98- ...
'z'的 ASCII 码是122(这些是国际标准,所有计算机都遵循)
-
char类型的特殊性 :在 C++ 中,char是一种整数类型(占 1 字节),但:- 输出时会自动转换为对应的字符(比如
cout << 'a'显示a)。 - 运算时会用其 ASCII 码(整数)参与计算(比如
'b' - 'a'实际是98 - 97 = 1)。
- 输出时会自动转换为对应的字符(比如
三、关键突破:c - 'a' 的作用是 "字符→索引" 映射
- 目的 :将 26 个小写字母(
a-z)映射到数组的 0-25 索引(因为数组target和window大小为 26)。 - 计算逻辑 :对于任意小写字母
c,c - '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']++ 的作用是:
- 用
c - 'a'找到字符c在数组中对应的位置(索引)。 - 对该位置的值加 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 次)。
五、特殊情况的验证
- 包含
'z'时 :'z' - 'a' = 25,对应数组索引 25(数组大小 26,合法),计数正常。 - 字符范围外的情况 :若字符串包含大写字母(如
'A')或符号(如'!'),c - 'a'可能得到负数或超过 25 的索引(导致数组越界)。但本题明确说明 "仅包含小写字母",因此无需考虑。
六、总结:c - 'a' 的核心意义
- 本质是利用字符的 ASCII 码特性 ,将
a-z这 26 个字符一一映射到 0-25 的整数索引。 - 目的是用数组高效统计字符出现次数,为后续比较 "两个字符串的字符分布是否相同" 提供基础。
- 适用场景:仅包含小写字母的字符计数问题(若字符范围扩大,需改用 256 位数组或哈希表)。
通过以上梳理,能清晰理解 c - 'a' 的原理和用途,后续遇到类似字符计数问题时可举一反三。