跳到主要内容 滑动窗口算法深度解析:LeetCode 经典例题实战 | 极客日志
C++ 算法
滑动窗口算法深度解析:LeetCode 经典例题实战 本文通过五个 LeetCode 经典例题(最小操作数、水果成篮、字母异位词、串联所有单词的子串、最小覆盖子串),详细讲解了滑动窗口算法的核心思想与实现技巧。内容涵盖暴力解法优化、双指针移动策略、哈希表计数维护以及边界条件处理,提供完整的 C++ 代码示例与复杂度分析,帮助读者掌握滑动窗口在字符串和数组问题中的应用。
12. 将 x 减到 0 的最小操作数
题目链接
题目描述:
给你一个整数数组 nums 和一个整数 x。每一次操作时,你应当移除数组 nums 最左边或最右边的元素,然后从 x 中减去该元素的值。请注意,需要修改数组以供接下来的操作使用。
如果可以将 x 恰好减到 0,返回最小操作数;否则,返回 -1。
示例 1:
输入 nums = [1,1,4,2,3], x = 5
输出:2
解释:最佳解决方案是移除后两个元素,将 x 减到 0。
示例 2:
输入 nums = [5,6,7,8,9], x = 4
输出:-1
示例 3:
输入 nums = [3,2,20,1,1,3], x = 10
输出:5
解释:最佳解决方案是移除后三个元素和前两个元素(总共 5 次操作),将 x 减到 0。
对于示例 1,最优的操作次数是 2 次。对于示例 2,不论是从左边还是右边进行删除操作,x 永远不可能到 0,所以返回 -1。
我们将问题进行转换:原来要求的是从最左边或者最右边进行删除元素,直到 x 变成 0。我们可以将问题区域划分,左边的区域是 a,右边的区域是 b,a 区域和 b 区域里面的数值加起来等于 x。那么中间剩下的就是我们整个数组的值减去 x。题目让我们求出最小的操作次数,也就是说 a+b 最短的情况,换种思路,就是中间区域最长的情况,即 sum-x。我们只要求出整个数组中和为 sum-x 的区域的最长值就行了。
找出最长的子数组的长度,子数组的所有元素的和正好等于 sum-x。
做这种题我们可以想对立面。我们可以先想想暴力解法:设置两个指针,左和右,一开始指向起点,让右指针进行移动操作,直到两个指针的区域间的值大于等于 sum-x,我们就更新下我们的操作次数,然后移动左指针继续进行更新的操作。
当左指针往右边移动了,右指针就没有必要往左边进行移动了,因为左指针向右移动过后,区间中的元素大小更小了,右指针只能往右移动。
在这个暴力解法的基础上进行优化:
class Solution {
public :
int minOperations (vector<int >& nums, int x) {
int sum = 0 ;
for (int a : nums) {
sum += a;
}
int target = sum - x;
if (target < 0 ) return -1 ;
int ret = -1 ;
for (int left = , right = , tmp = ; right < nums. (); right++) {
tmp += nums[right];
(tmp > target) {
tmp -= nums[left];
left++;
}
(tmp == target) {
ret = (ret, right - left + );
}
}
(ret == ) ret;
nums. () - ret;
}
};
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown 转 HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML 转 Markdown 互为补充。 在线工具,Markdown 转 HTML在线工具,online
HTML 转 Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML 转 Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
0
0
0
size
while
if
max
1
if
-1
return
else
return
size
13. 水果成篮 你正在探访一家农场,农场从左到右种植了一排果树。这些树用一个整数数组 fruits 表示,其中 fruits[i] 是第 i 棵树上的水果种类。
你想要尽可能多地收集水果。然而,农场的主人设定了一些严格的规矩,你必须按照要求采摘水果:
你只有两个篮子,并且每个篮子只能装单一类型的水果。每个篮子能够装的水果总量没有限制。
你可以选择任意一棵树开始采摘,你必须从每棵树(包括开始采摘的树)上恰好摘一个水果。采摘的水果应当符合篮子中的水果类型。每采摘一次,你将会向右移动到下一棵树,并继续采摘。
一旦你走到某棵树前,但水果不符合篮子的水果类型,那么就必须停止采摘。
给你一个整数数组 fruits,返回你可以收集的水果的最大数目。
输入:fruits = [1,2,1]
输出:3
解释:可以采摘全部 3 棵树。
输入:fruits = [0,1,2,2]
输出:3
解释:可以采摘 [1,2,2] 这三棵树。如果从第一棵树开始采摘,则只能采摘 [0,1] 这两棵树。
输入:fruits = [1,2,3,2,2]
输出:4
解释:可以采摘 [2,3,2,2] 这四棵树。如果从第一棵树开始采摘,则只能采摘 [1,2] 这两棵树。
输入:fruits = [3,3,3,1,2,1,1,2,3,3,4]
输出:5
解释:可以采摘 [1,2,1,1,2] 这五棵树。
我们现在有两个篮子进行摘水果,然后每个篮子只能装一个水果,不能装多种水果。按实例 2 来说,示例二我们先采摘 0 号水果,然后再采摘 1 号水果,然后我们就不能采摘了,因为再后面的 2 号水果和 1 号水果种类不同,所以我们是只能采摘 2 棵树,但是我们如果从 1 号开始采摘的话,我们然后可以摘 2 号水果,我们再还能接着采摘 2 号水果,所以我们这种方案是可以采摘 3 棵树的。
就是我们的两个篮子只能装两种类型的水果,就是一个区间只能存在两种数字,不能存在第三种数字。
转化: 找出一个最长的子数组,子数组里面不超过两种类型的水果。
暴力解法:直接将所有的子数组都给找出来,找出其中最长的一个。我们可以借助一个哈希表来记录当前出现水果种类的次数。
我们现在定义两个指针,左和右,两个指针都从最开始的位置行动,右指针保持运行,直到我们水果的种类大于 2 了,我们就停下来,那么此时左和右中间的区域就是我们要求的。然后我们的左指针往右运行,那么我们就得想想我们的右指针回不回来呢?是不是和左指针重新开始还是说直接从上次的位置开始。
我们的左指针往右运行,那么我们水果种类只能出现两种:不变或者是变小,绝不可能变大,因为我们左往右走了一步,水果少了一个。
如果这个在左指针运行后,我们水果的种类不变的话,我们右指针就没必要回退了。
如果水果种类减少的话,那么我们区间里面还是只存在一种类型的结果,我们的右指针也是没有必要回去的,右指针直接右移。
进窗口的操作就是将我们右指针指向的元素丢到哈希表中。如果这个哈希表的长度大于 2 的话,我们就进行出窗口的操作。在哈希表中,我们不仅仅存入这个水果的种类,还要存入水果的数量。
在出窗口的时候我们还要对我们哈希表中的元素进行判断,如果某种种类的水果数量变成了 0 的话,那么我们要将这个水果从哈希表中删除的。
class Solution {
public :
int totalFruit (vector<int >& f) {
unordered_map<int , int > hash;
int ret = 0 ;
for (int left = 0 , right = 0 ; right < f.size (); right++) {
hash[f[right]]++;
while (hash.size () > 2 ) {
hash[f[left]]--;
if (hash[f[left]] == 0 ) {
hash.erase (f[left]);
}
left++;
}
ret = max (ret, right - left + 1 );
}
return ret;
}
};
但是我们发现我们的这个时间复杂度不行,因为我们哈希表中插入删除元素。所以我们这里是可以进行优化的操作的,对当前的哈希表进行优化操作。
我们可以直接利用一个数组来创建哈希表,并且增加一个变量 kinds 来表示我们水果的种类。下面我们就是直接利用数组进行操作。
class Solution {
public :
int totalFruit (vector<int >& f) {
int hash[100001 ] = {0 };
int ret = 0 ;
for (int left = 0 , right = 0 , kinds = 0 ; right < f.size (); right++) {
if (hash[f[right]] == 0 ) kinds++;
hash[f[right]]++;
while (kinds > 2 ) {
hash[f[left]]--;
if (hash[f[left]] == 0 ) {
kinds--;
}
left++;
}
ret = max (ret, right - left + 1 );
}
return ret;
}
};
14. 找到字符串中所有字母异位词 给定两个字符串 s 和 p,找到 s 中所有 p 的异位词的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
输入:s = "cbaebabacd", p = "abc"
输出:[0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
输入:s = "abab", p = "ab"
输出:[0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
我们需要快速判断两个字符串是异位词,我们可以通过比较两个字符串来比较判断是否是异位词。
下方就是暴力解法:不断的比较。一次移动 len 个子串然后和另一个子串进行比较,那么我们这里是可以用滑动窗口进行解答的。
利用 count 来统计窗口中有效字符的个数。进窗口后我们考虑下我们进窗口的这个数据是否是有效的数据,这个字母的出现个数是否跟另一个字符串里面对应字母的出现次数相等。
出窗口之前我们判断下我们出去这个字符是否是有效字符,是有效字符的话那么我们就进行 count-- 操作。
class Solution {
public :
vector<int > findAnagrams (string s, string p) {
vector<int > ret;
int hash1[26 ] = {0 };
for (auto ch : p) {
hash1[ch - 'a' ]++;
}
int hash2[26 ] = {0 };
int m = p.size ();
for (int left = 0 , right = 0 , count = 0 ; right < s.size (); right++) {
char in = s[right];
hash2[in - 'a' ]++;
if (hash2[in - 'a' ] <= hash1[in - 'a' ]) count++;
if (right - left + 1 > m) {
char out = s[left++];
if (hash2[out - 'a' ] <= hash1[out - 'a' ]) count--;
hash2[out - 'a' ]--;
}
if (count == m) ret.push_back (left);
}
return ret;
}
};
说白了就是一直维持这个 count 的大小,然后知道 count 的大小等于这个 p 字符串的长度了,我们就将起始的左指针进行返回的操作。
15. 串联所有单词的子串 给定一个字符串 s 和一个字符串数组 words。words 中所有字符串长度相同。
s 中的串联子串是指一个包含 words 中所有字符串以任意顺序排列连接起来的子串。
例如,如果 words = ["ab","cd","ef"],那么 "abcdef","abefcd","cdabef","cdefab","efabcd",和 "efcdab" 都是串联子串。"acdbef" 不是串联子串,因为他不是任何 words 排列的连接。
返回所有串联子串在 s 中的开始索引。你可以以任意顺序返回答案。
输入:s = "barfoothefoobarman", words = ["foo","bar"]
输出:[0,9]
解释:因为 words.length == 2 同时 words[i].length == 3,连接的子字符串的长度必须为 6。
子串 "barfoo" 开始位置是 0。它是 words 中以 ["bar","foo"] 顺序排列的连接。
子串 "foobar" 开始位置是 9。它是 words 中以 ["foo","bar"] 顺序排列的连接。
输出顺序无关紧要。返回 [9,0] 也是可以的。
输入:s = "wordgoodgoodgoodbestword", words = ["word","good","best","word"]
输出:[]
解释:因为 words.length == 4 并且 words[i].length == 4,所以串联子串的长度必须为 16。
s 中没有子串长度为 16 并且等于 words 的任何顺序排列的连接。
所以我们返回一个空数组。
输入:s = "barfoofoobarthefoobarman", words = ["bar","foo","the"]
输出:[6,9,12]
解释:因为 words.length == 3 并且 words[i].length == 3,所以串联子串的长度必须为 9。
子串 "foobarthe" 开始位置是 6。它是 words 中以 ["foo","bar","the"] 顺序排列的连接。
子串 "barthefoo" 开始位置是 9。它是 words 中以 ["bar","the","foo"] 顺序排列的连接。
子串 "thefoobar" 开始位置是 12。它是 words 中以 ["the","foo","bar"] 顺序排列的连接。
我们可以将题目进行转换,在左边的字符串中找到我们右边的 a b 的异位词就行了,那么这个题就很像之前的那个找到字符串中所有字母异位词这个题了。
相较于找到字符串中所有字母异位词这个题的话,我们有三点不同:
哈希表:之前的那题我们里面的哈希表存储的是一个个的字符,这里的话我们是一个又一个的字符串。
left 和 right 指针的移动:因为我们字符串数组里面的字符串的长度都是相等的,那么我们在移动的时候为了让新的字符串进入到窗口的话,那么我们每次移动的话是需要前进 len 个距离,len 就是我们单个字符串的长度。移动的步长是每个单词的长度。
滑动窗口的执行次数:我们滑动窗口执行的次数是 len 次。因为我们的指针不仅仅是从第一个位置开始的,也可能从第二个位置,甚至第 len 个位置,如果从 len+1 的位置开始的话,那么就是重复了,和从第一个位置开始的情况重叠了,所以我们进行滑动窗口的次数是 len 次。
class Solution {
public :
vector<int > findSubstring (string s, vector<string>& words) {
vector<int > ret;
unordered_map<string, int > hash1;
for (auto & w : words) hash1[w]++;
int len = words[0 ].size (), m = words.size ();
for (int i = 0 ; i < len; i++) {
unordered_map<string, int > hash2;
for (int left = i, right = i, count = 0 ; right + len <= s.size (); right += len) {
string in = s.substr (right, len);
hash2[in]++;
if (hash1. count (in) && hash2[in] <= hash1[in]) count++;
if (right - left + 1 > len * m) {
string out = s.substr (left, len);
if (hash1. count (out) && hash2[out] <= hash1[out]) count--;
hash2[out]--;
left += len;
}
if (count == m) ret.push_back (left);
}
}
return ret;
}
};
我们先创建一个哈希 1,用来存储我们 words 中的单词出现的次数,利用范围 for 获得每个单词出现的频次。
然后我们再计算 len 就是每个单词的长度,因为这个题中每个单词的长度都是一样的。
然后我们再来一个 for 循环,次数就是 len 次,因为我们的左指针有效循环的次数就是 len 次,如果是 len+1 次的话,那么结果是和第一次的结果是相同的。
在这个 for 循环中我们再来一个 for 循环进行进窗口的操作,在进窗口之前我们需要创建一个哈希 2 来记录我们滑动窗口内单词的频次。
这个内循环的我们需要维护好 count,就是窗口内有效单词的数量,如果没有在哈希 1 中出现过的,统统都是无效单词,出现过的才算是有效单词。
然后我们进行进窗口的操作,利用 substr 获取我们 right 后面 len 个范围的单词,将这个字符串放到 in 中,然后给 hash2 记录这个单词出现的次数,我们在进窗口后我们维护好我们的 count,如果当前的单词在 hash2 中出现的次数小于 hash1 中出现的次数的话,那么就证明这个单词是有效的,为什么呢?因为如果 hash2 中这个单词出现的次数大于 hash1 中的话,那么这个单词就是无效的,在 hash1 中不存在的,所以 hash2 中出现次数小于等于 hash1 中单词的次数的话,那么在这个单词就是有效的单词,那么我们 count++。
然后我们进行判断,如果我们当前的 right 和 left 的范围大于我们 words 中字符串组合成的字符串的长度的话,那么我们就进行出窗口的操作,我们就得将我们的 left 往右边进行移动了,还是利用 substr 获取我们 left 后面 len 个字符的字符串,将这个字符串存在 out 中,然后将 hash2 中的 out 这个字符串给减一,在减一之前我们还需要维护下我们的 count,对我们这个单词进行判断,是否是有效的单词。
出完窗口后,我们的 left 需要往右边进行移动,移动的距离是 len。
然后我们进行结果的更新,如果我们 count=m 的话,那么我们将此时的 left,就是这个对应字符串的开始位置存储在 vector 容器中去。
但是吧,我们当前的这个效率比较慢,因为我们在判断当前的字符串是否是有效的字符串的时候,如果我们这个字符串 in 在 hash1 中不存在的话,那么我们就得创建 hash1[in] 了,所以这里是存在效率变低的,所以我们可以在判断条件中加上 hash1.count(in) 来判断我们的这个单词的数量,如果是 0 的话,我们直接退出了这个条件判断了。
16. 最小覆盖子串 给你一个字符串 s、一个字符串 t。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 ""。
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
输入:s = "a", t = "aa"
输出:""
解释:t 中两个字符 'a' 均应包含在 s 的子串中,因此没有符合条件的子字符串,返回空字符串。
只要我们的 s 中可以将 t 全部涵盖了的话,那么就是符合要求的。
使用暴力解决,如果 hash2 中的 ABC 的次数大于 hash1 中的次数的话,那么 hash2 里面存的这一段字符串就是有效的字符串了。
然后接下来从 D 这个位置接着枚举,就这种暴力解决的方法。
我们在找到符合要求的一组之后,我们的 left 往右走,那么此时我们的 right 是没有必要回去的。
如果我们原先 left 指向的元素他的出现次数太多了或者是一个无效的,那么我们的 left 往右边移动一位,和此时 right 组成的区间依旧是有效的。
但是如果我们原先 left 指向的元素是一个有效字符并且出现次数的小于我们 hash1 中的次数,那么我们此时的区间就是不符合要求的,我们的 right 依旧不用回来,继续向后面走,直到找到了符合要求的那个元素为止。
我们使用 count 标记有效字符的种类,我们这里需要 hash2 中出现 hash1 中所有种类的字符,而不是个数,我们一定要按照有效字符种类来进行操作。
我们进窗口的时候维护一下 count,出窗口的时候维护一下 count,判断的时候判断一下 count。
class Solution {
public :
string minWindow (string s, string t) {
int hash1[128 ] = {0 };
int kinds = 0 ;
for (auto ch : t) {
if (hash1[ch] == 0 ) kinds++;
hash1[ch]++;
}
int hash2[128 ] = {0 };
int minlen = INT_MAX, begin = -1 ;
for (int left = 0 , right = 0 , count = 0 ; right < s.size (); right++) {
char in = s[right];
hash2[in]++;
if (hash2[in] == hash1[in]) count++;
while (count == kinds) {
if (right - left + 1 < minlen) {
minlen = right - left + 1 ;
begin = left;
}
char out = s[left];
left++;
if (hash2[out] == hash1[out]) count--;
hash2[out]--;
}
}
if (begin == -1 ) return "" ;
else return s.substr (begin, minlen);
}
};
我们先创建 hash1 来记录我们 t 中字符出现的次数,创建变量 kinds 记录种类,
然后我们遍历 t,获取我们的种类的数量,并且记录字符出现的频次。
然后我们创建 hash2 来记录我们窗口中的每个字符出现的频次。
创建变量 minlen 记录我们当前最小的子串长度,以及开始的指针 begin。
然后我们进入到滑动窗口的操作,我们先获取我们 right 指针的位置上的元素,并且在 hash2 中记录出现的次数。
然后我们在进窗口后判断下当前进的这个字符是否是有效的字符,并且进行维护我们的 count(字符种类)。
是否是有效的字符,我们依靠这段代码 hash2[in]==hash1[in],如果我们 hash2 中进的这个字符的次数等于我们 hash1 中字符的次数的话,那么我们就判定当前字符是有效的。
如果有效的话,那么我们当前的 count 就++。
进行判断,如果我们的 count 的大小等于我们的 kinds 的话,就是说明我们当前的窗口就是一个有效的窗口了。
记录我们当前区间的长度:right-left+1 并且记录我们当前的 begin。
然后我们再进行出窗口的操作,我们记录我们当前 left 位置的元素,然后从 hash2 中减少次数。
并且我们在减少次数之前我们需要判断下我们这个出窗口的元素是否是有效的字符,