【LeetCode 面试经典150题】3-滑动窗口
1 滑动窗口理论基础
1.1 算法思想
一个固定大小的窗口在数据序列上滑动,并在窗口内执行特定操作的问题。
通俗的讲就是,对于数组,维持一个特定窗口/动态可变窗口,通过该窗口来在数组或者字符串上进行操作。
字面意思上理解:
滑动 : 窗口时移动的,就是按照一定方向来进行移动
窗口:窗口大小并不是固定的,而是不断扩容直到满足一定条件;也可以不断缩小,找到满足条件的最小窗口;也可以固定大小。
1.2 使用场景
- 满足xx条件(计算结果,出现次数,同时包含)
- 最长/最短
- 子串/子数组/子序列
感觉是你认为需要在一个数组/字符串中维持一个滑动的窗口来解题的题,都可以用滑动窗口来解决。
1.3 使用思路
寻找最长
核心:左右双指针(l.r),$[l,r]$即为窗口内的元素。r为窗口的结束位置,l为窗口的起始位置。
每次滑动过程中:窗内元素满足条件,r向右移动扩大窗口,更新最优结果;窗内元素不满足条件,L向右移动缩小窗口。
模板如下:
1 | int l = 0; // 窗内的起始位置 |
寻找最短
核心:左右双指针(l.r),$[l,r]$即为窗口内的元素。r为窗口的结束位置,l为窗口的起始位置。
每次滑动过程中:窗内元素不满足条件,r向右移动扩大窗口;窗内元素满足条件,L向右移动缩小窗口,更新最优结果。
模板如下
1 | int l = 0; // 窗内的起始位置 |
实现滑动窗口时,需要确定如下几点
- 窗口内是什么
- 如何移动窗口的起始位置
- 如何移动窗口的结束位置
以209长度最小的子数组为例
窗口内:满足和>=s的长度最小的连续子数组
起始位置如何移动:如果当前窗口值>=s,窗口需要缩小
1 | for (int r = 0; r < n; r++) { // 枚举右端点 |
结束位置如何移动:结束位置就是遍历数组的指针,也就是for循环的索引
2 209.长度最小的子数组
中等
给定一个含有 n 个正整数的数组和一个正整数 target 。
找出该数组中满足其总和大于等于 target 的长度最小的
子数组
[numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
示例 1:
1 | 输入:target = 7, nums = [2,3,1,2,4,3] |
示例 2:
1 | 输入:target = 4, nums = [1,4,4] |
示例 3:
1 | 输入:target = 11, nums = [1,1,1,1,1,1,1,1] |
提示:
1 <= target <= 1091 <= nums.length <= 1051 <= nums[i] <= 104
进阶:
- 如果你已经实现
O(n)时间复杂度的解法, 请尝试设计一个O(n log(n))时间复杂度的解法。
2.1 题目分析
可以暴力求解,但是时间复杂度市$O(n^2)$。这里可以使用滑动窗口来求解。
滑动窗口是一种处理数组子区间问题的有效方法,它的核心思想是通过两个指针 l 和 r 来表示一个窗口,这个窗口内的数值总和是我们关心的属性。
2.2 算法步骤
- 初始化:定义两个指针
l和r,分别代表窗口的左右边界,初始时都指向数组的开始位置。定义ans来存储当前窗口的和,res用来存储满足条件的最短子数组的长度,初始值设为Integer.MAX_VALUE,表示一个很大的数。 - 窗口扩张:遍历数组,每次将
r指针向右移动一位,并将nums[r]加到ans中。这样,ans就代表了从l到r的窗口内所有元素的和。 - 窗口缩小:如果
ans大于等于target,说明当前窗口内的和已经满足或超过了目标值,此时尝试缩小窗口以找到更短的子数组。将l指针向右移动一位,并将nums[l]从ans中减去,这样ans就代表了从l+1到r的窗口内所有元素的和。 - 更新结果:每次窗口缩小后,如果
ans仍然大于等于target,则更新res的值,取r-l+1和res中的较小值作为新的res。 - 循环结束:重复步骤 2 和 3,直到
r指针遍历完整个数组。 - 返回结果:如果
res仍然是Integer.MAX_VALUE,说明没有找到满足条件的子数组,返回 0;否则返回res。
2.3 代码实现
1 | class Solution { |
2.4 时间复杂度
- 时间复杂度:O(n),其中 n 是数组
nums的长度。这是因为每个元素最多被访问两次(一次是窗口扩张,一次是窗口缩小)。 - 空间复杂度:O(1),除了输入数组外,我们只需要常数级别的额外空间。
3 3.无重复字符的最长字串
中等
给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串的长度。
示例 1:
1 | 输入: s = "abcabcbb" |
示例 2:
1 | 输入: s = "bbbbb" |
示例 3:
1 | 输入: s = "pwwkew" |
提示:
0 <= s.length <= 5 * 104s由英文字母、数字、符号和空格组成
3.1 题目分析
滑动窗口的方法来解决。滑动窗口在这里指的是一个动态的子串区间,这个区间内的字符都是唯一的。
窗口内需要满足的条件:窗口内的字符必须是唯一的。所以可以用一个hashset来记录窗口内出现的字符,确保唯一性。
滑动窗口的移动:
- 窗口扩张:当当前字符不在窗口中时,将字符添加到窗口的哈希表中,并向右移动右指针
r,窗口扩张。 - 窗口缩小:如果当前字符已经在窗口中,说明出现了重复,需要移动左指针
l来缩小窗口,直到当前字符不再出现在窗口中。
3.2 算法步骤
初始化:定义两个指针
l和r,分别代表窗口的左右边界,初始时都指向字符串的开始位置。定义一个哈希表set来存储窗口内的字符,以及两个变量res和curres分别用来存储最长不重复子串的长度和当前窗口的长度。遍历字符串:通过右指针
r遍历字符串。- 检查重复:在每次迭代中,首先检查当前字符
s.charAt(r)是否已经在哈希表set中。如果在,说明出现了重复,需要缩小窗口。- 窗口缩小:如果出现重复,通过
while循环,不断移除左指针l指向的字符,并右移l,直到当前字符不再出现在窗口中。
- 窗口缩小:如果出现重复,通过
- 窗口扩张:将当前字符
s.charAt(r)添加到哈希表set中,并更新当前窗口长度curres。 - 更新结果:每次窗口扩张后,比较并更新
res的值,取res和curres中的较大值作为新的res。
- 检查重复:在每次迭代中,首先检查当前字符
返回结果:遍历结束后,返回
res作为最长不重复子串的长度。
3.3 代码实现
1 | class Solution { |
3.4 复杂度分析
- 时间复杂度:O(n),其中 n 是字符串
s的长度。这是因为每个字符最多被访问两次(一次是窗口扩张,一次是窗口缩小)。 - 空间复杂度:O(min(n, m)),其中 n 是字符串
s的长度,m 是字符集的大小。在最坏的情况下,哈希表需要存储所有不同的字符。
4 30.串联所有单词的子串
困难
给定一个字符串 s 和一个字符串数组 words。 words 中所有字符串 长度相同。
s 中的 串联子串 是指一个包含 words 中所有字符串以任意顺序排列连接起来的子串。
- 例如,如果
words = ["ab","cd","ef"], 那么"abcdef","abefcd","cdabef","cdefab","efabcd", 和"efcdab"都是串联子串。"acdbef"不是串联子串,因为他不是任何words排列的连接。
返回所有串联子串在 s 中的开始索引。你可以以 任意顺序 返回答案。
示例 1:
1 | 输入:s = "barfoothefoobarman", words = ["foo","bar"] |
示例 2:
1 | 输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"] |
示例 3:
1 | 输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"] |
提示:
1 <= s.length <= 1041 <= words.length <= 50001 <= words[i].length <= 30words[i]和s由小写英文字母组成
4.1 题目分析
这个问题要求找出所有包含特定单词集合 words 的子串的起始索引。这里的关键是使用滑动窗口的方法来高效地检查每个可能的子串,同时使用哈希表来跟踪单词的出现频次。
- 单词频次映射:首先,我们需要记录
words数组中每个单词的出现频次,这可以通过一个哈希表(Map)来实现。 - 滑动窗口:使用滑动窗口来遍历字符串
s,窗口内包含words数组中所有单词的一个或多个实例。 - 窗口初始化:对于每种可能的单词划分方式,初始化窗口,并检查第一个窗口是否包含
words数组中的所有单词。 - 窗口滑动:在确认第一个窗口后,继续滑动窗口,每次移动时更新窗口内的单词频次,并检查是否满足条件。
- 条件检查:如果窗口内的单词频次与
words数组中的频次完全匹配,则记录窗口的起始索引。
4.2 算法步骤
- 初始化变量:
n:words数组中每个单词的长度。m:words数组的长度,即单词的数量。ls:字符串s的长度。res:用来存储所有满足条件的子串起始索引的列表。
- 遍历划分方式:
- 对于字符串
s中的每种可能的单词划分方式,检查是否能够形成words数组中的单词。
- 对于字符串
- 初始化滑动窗口:
- 对于每种划分方式,从
s中取出m个单词,并记录它们的出现频次。
- 对于每种划分方式,从
- 检查
words数组中的单词:- 遍历
words数组,更新窗口内单词的频次,如果频次减至0,则从哈希表中移除该单词。
- 遍历
- **滑动窗口遍历字符串
s**:- 继续遍历字符串
s,每次移动窗口时,添加新进入窗口的单词,并移除离开窗口的单词。 - 如果在任何时候哈希表为空,说明当前窗口包含了
words数组中的所有单词,记录下当前窗口的起始索引。
- 继续遍历字符串
- 返回结果:
- 返回所有满足条件的子串起始索引的列表。
4.3 代码实现
1 | class Solution { |
4.4 复杂度分析
- 时间复杂度:O(m * n * (ls - m * n + 1)),在最坏的情况下,我们需要遍历整个字符串
s多次,每次遍历都需要检查m个单词。 - 空间复杂度:O(m),用于存储单词的哈希表。
5 76.最小覆盖子串
困难
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
注意:
- 对于
t中重复字符,我们寻找的子字符串中该字符数量必须不少于t中该字符数量。 - 如果
s中存在这样的子串,我们保证它是唯一的答案。
示例 1:
1 | 输入:s = "ADOBECODEBANC", t = "ABC" |
示例 2:
1 | 输入:s = "a", t = "a" |
示例 3:
1 | 输入: s = "a", t = "aa" |
提示:
m == s.lengthn == t.length1 <= m, n <= 105s和t由英文字母组成
进阶:你能设计一个在 o(m+n) 时间内解决此问题的算法吗?
5.1 题目分析
使用滑动窗口技术,通过动态调整窗口大小来寻找包含字符串 t 所有字符的 s 的最小子串,并利用哈希表高效地进行字符频次比较和更新。
用两个Map存储
一个Map ori 存储t中所有字符出现的次数
一个Map cnt 存储滑动遍历s时滑动窗口内,所有字符出现的次数
使用一个Check函数比较ori和cnt两个map,比较对于ori中的所有字符,cnt中是不是都出现并并且出现次数>= ori中的
滑动规则则为:
check函数满足:当前s的子串能涵盖t的所有字符,记录当前子串,左指针左移
不满足则右指针右移
5.2 算法步骤
- 字符频次统计:首先,使用两个哈希表
ori和cnt分别存储目标字符串t中所有字符的出现次数和当前滑动窗口内字符的出现次数。 - 滑动窗口初始化:初始化两个指针
l和r,分别代表滑动窗口的左右边界。 - 窗口扩张:通过移动右指针
r来扩张窗口,直到窗口内包含了t中所有字符的所需数量。 - 窗口缩小:当窗口满足条件后(即包含了
t中所有字符的所需数量),开始移动左指针l来缩小窗口,寻找更小的满足条件的子串。 - 检查函数:使用一个
check函数来比较ori和cnt两个哈希表,检查当前窗口是否包含了t中所有字符的所需数量。 - 结果更新:每当找到一个满足条件的窗口时,比较并更新记录的最小子串。
5.3 代码实现
1 | class Solution { |
5.4 复杂度分析
- 时间复杂度:O(ls + lt),在最坏的情况下,我们需要遍历整个字符串
s和t。 - 空间复杂度:O(lt),用于存储字符频次的哈希表。