🔥个人主页: 中草药
🔥专栏:【算法工作坊】算法实战揭秘
🦌一.水果成篮
题目链接:904.水果成篮
算法原理
算法原理是使用“滑动窗口”(Sliding Window)策略,结合哈希表(Map)来高效地统计窗口内不同水果的种类数量。以下是详细分析:
-
初始化:创建一个空的哈希表
map
用来存储每种水果的数量,初始化左右指针left
和right
为 0,同时初始化结果变量ret
为 0,用于记录最大的水果段长度。 -
扩展右边界:右指针
right
逐渐向右移动,每移动一步,就在哈希表map
中增加对应水果的数量。这代表尝试将新的水果加入当前的采摘窗口。 -
处理超过两种水果的情况:使用
while
循环检查哈希表map
的大小是否大于 2,即窗口内是否含有超过两种水果。如果是,则需要收缩左边界,即从窗口中移除最左边的水果(即减少其在哈希表中的计数,并在计数为 0 时从哈希表中移除该水果),然后将左指针left
向右移动一位,缩小窗口范围。 -
更新结果:每次右指针移动后,都检查当前窗口(
right-left+1
)的长度是否大于之前记录的最大长度ret
,如果是,则更新ret
为当前窗口的长度。 -
遍历结束:当右指针遍历完整个数组后,
ret
中存储的就是能够收集到的、每个篮子中只含有两种类型水果的最长连续段的长度。
时间复杂度与空间复杂度
- 时间复杂度:O(n),其中 n 是数组
fruits
的长度。每个元素最多被遍历一次。 - 空间复杂度:O(1),虽然使用了哈希表,但由于最多只存储两种类型的水果,所以哈希表的大小是常数级别,整体空间复杂度为 O(1)。
代码
public int totalFruit(int[] fruits) {
Map<Integer,Integer> map=new HashMap<>();
int ret=0;
for(int left=0,right=0;right<fruits.length;right++){
map.put(fruits[right],map.getOrDefault(fruits[right],0)+1);
while(map.size()>2){
map.put(fruits[left],map.get(fruits[left])-1);
if (map.get(fruits[left])==0){
map.remove(fruits[left]);
}
left++;
}
ret=Math.max(ret,right-left+1);
}
return ret;
}
举例
测试用例 [1,2,3,2,2]
初始状态
- 初始化
left = 0
,right = 0
,ret = 0
,以及一个空的哈希表map
用于记录每种水果的数量。
执行过程
-
右指针移动与计数:
- 当
right = 0
,fruits[right]=1
,哈希表变为{1:1}
。 - 当
right = 1
,fruits[right]=2
,哈希表变为{1:1, 2:1}
。 - 当
right = 2
,fruits[right]=3
,哈希表变为{1:1, 2:1, 3:1}
。此时哈希表大小超过2,触发左指针移动。
- 当
-
左指针移动与计数调整:
- 移动
left
,从left = 0
开始,哈希表变为{1:0, 2:1, 3:1}
,然后移除键值对{1:0}
,哈希表变为{2:1, 3:1}
,此时left = 1
。
- 移动
-
继续右指针移动:
- 当
right = 3
,fruits[right]=2
,哈希表变为{2:2, 3:1}
。 - 当
right = 4
,fruits[right]=2
,哈希表变为{2:3, 3:1}
。此时窗口内还是两种水果,不触发左指针移动。
- 当
结果计算
- 在整个过程中,不断更新
ret
以记录最长子数组长度。当右指针遍历结束时,ret
记录的是满足条件的最长子数组长度。 - 对于测试用例
[1,2,3,2,2]
,最长的连续子数组内包含两种水果的最大长度为从索引1到4,即[2,3,2,2]
,长度为4。
返回结果
- 因此,这段代码在处理完测试用例后返回的结果是
4
。
🦣二.找到字符串中的所有字母异位词
题目链接:438.找到字符串中所有字母异位词
算法原理
-
初始化:首先将输入的字符串
ss
和pp
分别转换成字符数组s
和p
,便于后续操作。然后,创建两个大小为 26 的整型数组hash1
和hash2
作为哈希表,用于记录字符计数。hash2
用于预存储字符串pp
中各字符出现的频次。 -
预处理:遍历字符串
pp
,在hash2
中记录每个字符出现的次数。比如,如果pp
是 "abc",那么hash2['a' - 'a']
(即hash2[0]
)将增加 1,以此类推。 -
滑动窗口遍历:
- 使用两个指针
left
和right
初始化为 0,定义一个count
记录当前窗口内满足条件的字符数量。 - 当
right
指针遍历s
时,更新hash1
中对应字符的计数,并检查如果该字符的计数不大于hash2
中的计数,则增加count
。 - 当窗口大小(
right - left + 1
)超过pp
的长度时,说明需要收缩窗口左侧。此时,检查left
指针所指字符的计数,如果它之前满足条件(即计数不大于hash2
中的计数),则减少count
,并减少hash1
中该字符的计数,然后left
指针右移。 - 当窗口内满足条件的字符数
count
等于pp
的长度时,说明找到了一个异位词,将left
索引添加到结果列表ret
中。
- 使用两个指针
-
返回结果:遍历结束后,返回存储了所有异位词起始索引的列表
ret
。
时间复杂度与空间复杂度
- 时间复杂度:O(N),其中 N 为字符串
ss
的长度。每个字符最多被遍历一次。 - 空间复杂度:O(1),虽然使用了两个大小为 26 的数组作为哈希表,但它们的空间需求是固定的,不依赖于输入字符串的大小。
代码
public List<Integer> findAnagrams(String ss, String pp) {
List<Integer> ret= new ArrayList<>();
char[] s=ss.toCharArray();
char[] p=pp.toCharArray();
int[] hash1=new int[26];
int[] hash2=new int[26];
//将pp对照的范本储存在hash2
for (char a:p){
hash2[a-'a']++;
}
int len=p.length;
for(int left=0,right=0,count=0;right<s.length;right++){
hash1[s[right]-'a']++;
if (hash1[s[right]-'a']<=hash2[s[right]-'a']){
count++;
}
if (right-left+1>len){
if (hash1[s[left]-'a']<=hash2[s[left]-'a']){
count--;
}
hash1[s[left++]-'a']--;
}
if (count==len){
ret.add(left);
}
}
return ret;
}
举例
测试用例 s = "cbaebabacd" ; p ="abc"
-
初始化: 定义两个长度为 26 的列表
hash1
和hash2
用于存储字符计数。hash2
用于存储模式字符串p
中每个字符出现的次数。 -
构建模式哈希: 遍历模式字符串
p
,并使用 ASCII 值减去'a'
的 ASCII 值作为索引来记录每个字符的出现次数到hash2
中。 -
双指针遍历:
- 初始化两个指针
left
和right
以及一个计数器count
来追踪当前窗口是否为有效异位词。 - 使用
right
指针向右移动,增加右侧字符在hash1
中的计数,如果该字符的计数小于等于hash2
中的计数,则增加count
。 - 当窗口大小(即
right - left + 1
)超过模式字符串长度时,说明需要收缩窗口:- 减少左侧字符在
hash1
中的计数,如果之前该字符的计数也小于等于hash2
中的计数,则减少count
。 - 左指针
left
向右移动一位,缩小窗口。
- 减少左侧字符在
- 检查
count
是否与模式字符串长度相等,如果相等则说明当前窗口是一个有效异位词,记录下其起始索引left
。
- 初始化两个指针
-
收集结果: 所有满足条件的起始索引被收集到列表
ret
中,并最终返回。
现在,针对给定的测试用例:
-
字符串
s = "cbaebabacd"
-
模式
p = "abc"
-
遍历开始,首先构建
hash2
,hash2
对于"abc"
会是[1, 1, 1]
(因为每个字符出现一次)。 -
双指针开始滑动:
- 当
right = 0
时,"c"
计数增加,但不满足异位词条件(因为没有比较字符)。 - 移动到
"b"
(right = 1
),此时窗口"cb"
,仍然不构成异位词。 - 添加
"a"
(right = 2
),窗口变为"cba"
,这时hash1
与hash2
匹配(各字符计数都是 1),因此count = 3
(与模式长度相等),记录索引0
。 - 继续滑动,当
"e"
进入窗口时(right = 3
),由于它不在模式中,之前"c"
的计数减一并不影响count
(仍为 3),但窗口大小已超过模式长度,所以收缩左侧。 - 收缩窗口时,移除
"c"
(left = 1
),检查并可能更新count
,但在这个例子中,直到"b"
(left = 2
) 被移除时count
才会减少,因为它与模式中的字符匹配。 - 当窗口滑动到最后,到达
"bac"
(在原字符串中的位置为索引 6 开始),再次满足异位词条件,记录索引6
。
- 当
最终,函数正确返回了异位词子串的起始索引 [0, 6]
,这与我们的手动分析相符。
🐮三.串联所有单词的子串
题目链接:30.串联所有单词的子串
算法原理
-
初始化:
- 创建一个结果列表
ret
用来存放符合条件的子串起始索引。 - 创建一个哈希表
hash1
来记录单词数组words
中每个单词出现的次数。 - 计算单个单词的长度
len
和单词数组的大小size
。
- 创建一个结果列表
-
构建单词频率哈希表:
- 遍历
words
数组,使用哈希表hash1
记录每个单词及其出现次数。
- 遍历
-
滑动窗口遍历字符串:
- 以单词长度
len
为步长,从字符串s
的每个可能的起始位置开始滑动窗口。- 对于每个起始位置
i
,初始化一个新的哈希表hash2
来记录窗口内各个单词的出现次数。 - 使用两个指针
left
和right
表示窗口的左右边界,初始时left = right = i
。 - 移动
right
指针,每次向右移动len
个单位,将新进入窗口的单词in
计数加入hash2
。- 如果
hash2
中in
的计数不大于hash1
中的计数,说明当前单词匹配成功,计数器count
加一。
- 如果
- 当窗口大小(即
right - left + 1
)超过所有单词组合的总长度(即len * size
)时:- 移出窗口最左边的单词
out
,更新hash2
并相应减少count
(如果必要)。 - 同时,左边界
left
向右移动len
个单位,缩小窗口。
- 移出窗口最左边的单词
- 如果
count
等于size
,说明窗口内的单词完全匹配了words
中的所有单词(考虑顺序和数量),将left
索引添加到结果列表ret
中。
- 对于每个起始位置
- 以单词长度
-
返回结果: 遍历完成后,返回包含所有符合条件子串起始索引的列表
ret
。
时间复杂度分析
- 外层循环遍历字符串
s
,时间复杂度为 O(n),其中 n 是字符串s
的长度。 - 内层循环虽然也是遍历字符串,但由于每次滑动窗口实际上是在“跳跃”(每次跳过一个单词长度),其复杂度受到单词长度和单词数组大小的影响,大致可视为 O(m*k),其中 m 是单词数组的大小,k 是单个单词的平均长度。
- 因此,总体时间复杂度大约为 O(nmk)。
空间复杂度
- 主要由哈希表占用,空间复杂度为 O(m+k),其中 m 是单词数组的大小,k 是不同单词的数量(在极端情况下,所有单词都不重复)。
代码
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> ret = new ArrayList<>();
Map<String,Integer> hash1=new HashMap<>();
int len=words[0].length();
int size=words.length;
for(String str:words){
hash1.put(str,hash1.getOrDefault(str,0)+1);
}
for(int i=0;i<len;i++){
Map<String,Integer> hash2=new HashMap<>();
for(int left=i,right=i,count=0;right+len <=s.length();right+=len){
String in=s.substring(right,right+len);
hash2.put(in,hash2.getOrDefault(in,0)+1);
if(hash2.get(in)<=hash1.getOrDefault(in,0)){
count++;
}
if(right-left+1>len*size){
String out=s.substring(left,left+len);
if(hash2.get(out)<=hash1.getOrDefault(out,0)){
count--;
}
hash2.put(out,hash2.get(out)-1);
left+=len;
}
if(count==size){
ret.add(left);
}
}
}
return ret;
}
举例
测试用例 s ="barfoofoobarthefoobarman";words =["bar","foo","the"]
-
初始化:
- 初始化结果列表
ret
。 - 创建哈希表
hash1
存储单词及其计数,初始化为空。 - 单词长度
len = 3
(因为words[0].length()
是 "bar" 的长度)。 - 单词数组大小
size = 3
(因为有三个单词)。 - 遍历
words
,填充hash1
,使其内容为{bar=1, foo=1, the=1}
。
- 初始化结果列表
-
滑动窗口遍历:
-
关键在于理解滑动窗口如何准确地识别出所有匹配的子串起始位置。
-
第一个匹配:
- 窗口从索引 0 开始,但首个匹配实际上从索引 6 开始,因为
"foobarthefoobarman"
中从索引 6("f"
) 开始的子串包含了"foo"
,"bar"
,"the"
(按照"foobarthe"
的顺序)。此时,count
达到size
(3),因此6
被添加到结果列表中。
- 窗口从索引 0 开始,但首个匹配实际上从索引 6 开始,因为
-
第二个匹配:
- 窗口继续右移,当右边界达到索引 9 时,子串变为
"oobarthefoo"
,其中包含了另一个"foo"
,"bar"
,"the"
序列(注意,尽管"foo"
重叠,但因为我们是寻找无序的组合,所以依然有效)。此时,也会触发count == size
,因此索引9
被记录。
- 窗口继续右移,当右边界达到索引 9 时,子串变为
-
第三个匹配:
- 最后一个匹配发生在索引 12,从这里开始的子串为
"obarfoobarman"
,同样包含了所需的三个单词(忽略顺序)。因此,索引12
也被加入结果列表。
- 最后一个匹配发生在索引 12,从这里开始的子串为
-
-
结果收集:
- 正确的输出是
[6, 9, 12]
,这三个位置开始的子串分别包含了给定单词数组["bar", "foo", "the"]
中所有单词的一个排列。
- 正确的输出是
🐏 四.最小覆盖串
题目链接:76.最小覆盖子串
算法原理
-
初始化:
- 将输入字符串
ss
和tt
转换为字符数组s
和t
以便于操作。 - 初始化两个哈希表(在这里使用整型数组
hash1
和hash2
)来记录字符出现的次数。数组的索引对应字符的 ASCII 码值,值表示字符出现的次数。 - 初始化变量
kind
记录需要匹配的字符种类数(即tt
中不同字符的数量),初始化最小长度minlen
为最大整数值,记录子串起始索引begin
为-1
。 - 遍历字符串
tt
,统计每个字符的出现次数到hash1
,同时增加kind
的值,表示需要匹配的字符种类。
- 将输入字符串
-
滑动窗口遍历:
- 使用两个指针
left
和right
构成一个滑动窗口,初始时left = right = 0
。 - 当
right
指针遍历s
时,对进入窗口的字符in
增加hash2
的计数,若该字符在hash2
中的计数等于其在hash1
中的计数,则说明这个字符已经匹配完成,增加计数器count
。 - 当
count
等于kind
时,说明窗口内的字符已经包含了tt
中所有字符至少各一次,此时尝试缩小窗口以寻找最小覆盖子串:- 检查当前窗口长度是否小于已记录的最小长度
minlen
,若是,则更新minlen
和子串起始索引begin
。 - 然后,从窗口左侧移除字符
out
(即s[left]
),并更新hash2
和count
,随后移动left
指针向右,继续寻找可能更小的覆盖子串。
- 检查当前窗口长度是否小于已记录的最小长度
- 使用两个指针
-
结果处理:
- 循环结束后,检查是否有找到符合条件的子串,如果没有(即
begin == -1
),返回空字符串;否则,返回ss
中从begin
开始长度为minlen
的子串。
- 循环结束后,检查是否有找到符合条件的子串,如果没有(即
算法特性
- 时间复杂度: O(N),其中 N 是字符串
ss
的长度。每个字符最多被访问两次:一次作为窗口的右边界,一次作为窗口的左边界。 - 空间复杂度: O(1),尽管使用了额外的哈希表,但因为字符集固定(ASCII码范围),所以空间复杂度是常数级别的。
代码
public String minWindow(String ss, String tt) {
char[] s=ss.toCharArray();
char[] t=tt. toCharArray();
int[] hash1=new int[128];
int[] hash2=new int[128];
int kind=0;
int minlen=Integer.MAX_VALUE;
int begin=-1;
for(char ch:t){
if(hash1[ch]++==0)kind++;
}
for(int left=0,right=0,count=0;right<s.length;right++){
char in=s[right];
if(++hash2[in]==hash1[in])count++;
while(kind==count){
if(right-left+1<minlen){
begin=left;
minlen=right-left+1;
}
char out=s[left];
if(hash2[out]--==hash1[out])count--;
left++;
}
}
if(begin==-1){
return new String();
}
return ss.substring(begin,begin+minlen);
}
举例
测试用例 s ="ADOBECODEBANC" ; t ="ABC"
-
初始化:
- 将字符串
s
和t
转换为字符数组。 - 初始化两个长度为 128 的数组
hash1
和hash2
作为哈希表,记录字符出现次数。 - 初始化变量
kind
为 0,用于记录目标字符串t
中不同字符的种类数;minlen
设置为Integer.MAX_VALUE
,用于记录最小覆盖子串长度;begin
设置为-1
,用于记录最小覆盖子串的起始位置。 - 遍历目标字符串
t
,对hash1
进行填充并计算种类数kind
。在此例中,hash1['A'] = 1
,hash1['B'] = 1
,hash1['C'] = 1
,因此kind = 3
。
- 将字符串
-
滑动窗口遍历:
- 使用双指针
left
和right
构建滑动窗口,初始时left = 0
,right = 0
,count = 0
。 - 遍历字符串
s
:- 当右指针
right
移动时,遇到的字符in = s[right]
,在hash2
中计数递增,如果这个递增使得hash2[in]
等于hash1[in]
,说明字符in
已经匹配完成,于是count++
。 - 当
count
等于kind
时,说明窗口内的字符已经包含了t
中所有字符至少各一次。此时开始收缩窗口:- 检查当前窗口长度是否小于
minlen
,若是,则更新minlen
和begin
。 - 移除窗口左侧的字符
out = s[left]
,在hash2
中对应的计数递减,如果递减后hash2[out]
等于hash1[out]
,说明移除的字符不再满足匹配条件,于是count--
。 - 然后
left++
,继续检查下一个可能的窗口。
- 检查当前窗口长度是否小于
- 当右指针
- 在这个过程中,对于测试用例,窗口会遍历并调整位置,直到找到包含
"ABC"
所有字符的最小子串。具体来说,当窗口覆盖"BANC"
时,满足条件,此时count = 3
,并且窗口长度是最小的。
- 使用双指针
-
结果处理:
- 最终,当遍历结束,如果找到了有效的子串(即
begin != -1
),根据记录的begin
和minlen
截取并返回最小覆盖子串。对于给定测试用例,返回结果是"BANC"
。
- 最终,当遍历结束,如果找到了有效的子串(即
🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀🍀
以上,就是本期的全部内容啦,若有错误疏忽希望各位大佬及时指出💐
制作不易,希望能对各位提供微小的帮助,可否留下你免费的赞呢🌸