单调队列力扣题(leetcode)
单调队列力扣题(leetcode)
76. 最小覆盖子串
难度:中等
题目:
给定两个字符串 s 和 t,长度分别是 m 和 n,返回 s 中的 最短窗口 子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""。
测试用例保证答案唯一。
示例 1:
输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"
解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
示例 2:
输入:s = "a", t = "a"
输出:"a"
解释:整个字符串 s 是最小覆盖子串。
示例 3:
输入: s = "a", t = "aa"
输出: ""
解释: t 中两个字符 'a' 均应包含在 s 的子串中,
因此没有符合条件的子字符串,返回空字符串。
提示:
- \(m == s.length\)
- \(n == t.length\)
- \(1 <= m, n <= 10^5\)
- \(s 和 t由英文字母组成\)
进阶:你能设计一个在 O(m + n) 时间内解决此问题的算法吗?
分析
题目是这样的:给定两个字符串 S 和 T,我们要在 S 中找到最小的窗口,使得这个窗口包含 T 中的所有字符。
举个例子,如果 S 是 "ADOBECODEBANC" 这样一串共 13 个字符,T 是 "ABC",那么答案就是 "BANC",因为它是包含 A、B 和 C 的最小子串。如果找不到这样的窗口,就返回空字符串。

暴力解法会枚举 S 的所有子串,然后检查每个子串是否包含 T 的所有字符,这样的时间复杂度是 O (N³),无法通过面试。
最优的解法是使用滑动窗口加哈希表。核心思路是这样的:我们用两个哈希表,一个记录 T 中每个字符需要的数量,另一个记录当前窗口中每个字符的数量。然后用两个指针,左指针和右指针来维护这个窗口。右指针不断向右扩展窗口,直到窗口包含 T 的所有字符,这时窗口就是有效的。然后左指针开始收缩窗口,在保持窗口有效的前提下,尽可能缩小窗口的大小。每当找到一个有效窗口,我们就更新记录的最小窗口。

具体怎么操作。还是这个例子,S 是 "ADOBECODEBANC",T 是 "ABC"。开始之前先说明几个变量:need 表记录每个目标字符需要多少个,这里 A、B、C 都需要一个;window 表记录当前窗口里 A、B、C 各有多少个;valid 表示有多少种字符已经在窗口里数量达标了,当 valid 等于 3 时,窗口才有效。同时用 start 和 length 记录目前找到的最短答案。
初始时,left 和 right 都在最左边,window 为空,valid 等于 0,length 先设为无穷大。

第一轮扩展,right 读到 A,把 A 加入窗口,A 的数量变成 1,A 达标,所以 valid 变成 1。继续读到 D、O,他们不是目标字符,不影响。读到 B,把 B 加入窗口,B 的数量变成 1,B 达标,所以 valid 变成 2。读到 E,不影响。读到 C,把 C 加入窗口,C 的数量变成 1,C 达标,所以 valid 变成 3,窗口第一次有效。窗口一有效就尝试更新答案,当前窗口是 "ADOBEC",长度 6,比之前更短,所以 start 记在当前 left,length 更新为 6。

接下来开始第一轮收缩,left 指向 A,把它移出窗口,A 的数量从 1 变成 0,A 不达标,valid 从 3 变成 2,窗口失效,停止收缩。

继续第二轮扩展,right 依次读到 O、D、E,都不影响。再读到 B,把 B 加入窗口,B 的数量从 1 变成 2,超过需求不算额外达标,所以 valid 还是 2。再读到 A,把 A 加入窗口,A 的数量从 0 变成 1,A 达标,valid 回到 3,窗口再次有效。这次窗口更长,所以不更新最小长度。


开始第二轮收缩,left 依次移出 D、O,不影响。移出 B,B 的数量从 2 变成 1,仍达标,valid 还是 3。移出 E,不影响。此时窗口仍然有效,长度是 6,并不比之前记录的长度小,所以不更新最小长度。继续移除 C,C 的数量从 1 变成 0,不达标,valid 从 3 变成 2,窗口失效,停止收缩。


开始下一轮扩展,right 读到 N,不影响。读到 C,把 C 加入窗口,C 的数量从 0 变成 1,C 达标,valid 回到 3,窗口再次有效。开始收缩,left 依次移除 O、D、E,期间窗口仍有效,但长度更短了,所以相应更新 start 和最小长度,现在窗口正好是 "BANC",长度是 4。继续收缩,再移出 B,B 的数量从 1 变成 0,不达标,valid 从 3 变成 2,窗口失效。但此时 right 已经到头,无法再扩展,所以最终答案就是 "BANC",长度为 4。



代码:
题目大意
给定字符串s和t,在s中找到包含t所有字符的最小长度子串,保证答案唯一(无解返回空串)。
解题思路(滑动窗口 + 哈希表)
核心:枚举终点i,找到最近的起点j,使[j, i]包含t所有字符,维护最小长度。
- 准备工作:
- 用
ht哈希表统计t中各字符的出现次数; - 用
hs哈希表统计窗口[j, i]中各字符的出现次数; - 用
count记录窗口中 “有效字符数”(即不超过t中计数的字符数)。
- 用
- 滑动窗口步骤:
- 扩展窗口:
i右移,更新hs[nums[i]],若hs[c] ,count++`(有效字符增加); - 收缩窗口:当
count == t.size()(窗口有效),尝试右移j:若hs[s[j]] > ht[s[j]](字符多余),则hs[s[j]]--,j++; - 更新答案:每次收缩后,用
i-j+1更新最小子串长度。
- 扩展窗口:
class Solution
{
public:
// 函数功能:在s中找到包含t所有字符的最短子串
string minWindow(string s, string t)
{
// hs:记录当前滑动窗口内 每个字符的出现次数
// ht:记录目标字符串t 每个字符需要的次数(固定不变)
unordered_map<char, int> hs, ht;
// 先把t里所有字符统计好,存入ht
for (auto c: t) ht[c] ++ ;
// res:存储最终答案(最短子串)
string res;
// cnt:记录当前窗口内【有效字符】的总个数(满足t需求的字符)
int cnt = 0;
// 滑动窗口双指针:i右指针,j左指针
for (int i = 0, j = 0; i < s.size(); i ++ )
{
// 1. 右指针i字符进入窗口,计数+1
hs[s[i]] ++ ;
// 2. 如果这个字符是t需要的,且数量没超过需求 → 有效字符cnt+1
if (hs[s[i]] <= ht[s[i]]) cnt ++ ;
// 3. 尝试收缩左指针j:
// 如果左指针字符数量 > t需要的数量 → 可以删掉,j右移
while (hs[s[j]] > ht[s[j]]) hs[s[j ++ ]] -- ;
// 4. 如果当前窗口有效(包含了t所有字符)
if (cnt == t.size())
{
// 5. 如果答案为空 或 当前窗口更短 → 更新最短子串
if (res.empty() || i - j + 1 < res.size())
res = s.substr(j, i - j + 1);
}
}
// 返回最终最短子串
return res;
}
};
相似题目
长度最小的子数组中等
滑动窗口最大值困难
字符串的排列中等
最小区间困难
最小窗口子序列困难
239. 滑动窗口最大值
难度:困难
题目:
给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入:nums = [1], k = 1
输出:[1]
提示:
- \(1 <= nums.length <= 10^5\)
- \(-10^4 <= nums[i] <= 10^4\)
- \(1 <= k <= nums.length\)
代码:
class Solution
{
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k)
{
vector<int> res; // 存储最终结果
deque<int> q; // 双端队列,存储nums的下标(核心!)
int n = nums.size();
for (int i = 0; i < n; i++)
{
// 步骤1:移除队头——超出窗口范围的下标(窗口左边界是 i-k+1)
if (!q.empty() && q.front() < i - k + 1) q.pop_front();
// 步骤2:维护队列单调性——保证队头是当前窗口最大值
// 移除队尾所有 <= 当前元素的下标(这些元素不可能成为后续窗口的最大值)
while (!q.empty() && nums[q.back()] <= nums[i]) q.pop_back();
// 步骤3:当前下标入队
q.push_back(i);
// 步骤4:窗口形成(i >= k-1),记录当前窗口最大值(队头)
if (i >= k - 1) res.push_back(nums[q.front()]);
}
return res;
}
};
相似题目
最小覆盖子串困难
最小栈中等
粉刷房子 II困难
跳跃游戏 VI中等
礼盒的最大甜蜜度中等
438. 找到字符串中所有字母异位词
难度:中等
题目:
给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
示例 1:
输入: s = "cbaebabacd", p = "abc"
输出: [0,6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
示例 2:
输入:l1 = [], l2 = []
输出:[]
示例 3:
输入: s = "abab", p = "ab"
输出: [0,1,2]
解释:
起始索引等于 0 的子串是 "ab", 它是 "ab" 的异位词。
起始索引等于 1 的子串是 "ba", 它是 "ab" 的异位词。
起始索引等于 2 的子串是 "ab", 它是 "ab" 的异位词。
提示:
- \(1 <= s.length, p.length <= 3 * 10^4\)
- \(s 和 p 仅包含小写字母\)
代码:
class Solution
{
public:
vector<int> findAnagrams(string s, string p)
{
int m = s.size(), n = p.size();
if(m < n)
// 哈希表:统计目标字符串p中每个字符的需要次数
unordered_map<char, int> cnt;
// 初始化:把p的所有字符存入哈希表,记录每个字符的出现次数
for (auto c: p) cnt[c] ++ ;
vector<int> res; // 存储最终结果(所有异位词的起始索引)
int tot = cnt.size(); // tot:p中「不同字符的总数」(比如p=abc则tot=3)
// 滑动窗口双指针:
// i:右指针(窗口右边界)
// j:左指针(窗口左边界)
// satisfy:当前窗口中「满足p字符数量要求的不同字符数」(比如a的数量刚好等于p中a的数量,就算1个)
for (int i = 0, j = 0, satisfy = 0; i < s.size(); i ++ )
{
// 步骤1:右指针i的字符进入窗口,对应计数-1
// 关键逻辑:如果减完后等于0 → 说明这个字符的数量刚好满足p的要求 → satisfy+1
if ( -- cnt[s[i]] == 0) satisfy ++ ;
// 步骤2:窗口长度超过p的长度 → 收缩左指针j(保证窗口长度等于p.size())
while (i - j + 1 > p.size())
{
// 收缩前检查:如果当前j位置的字符计数是0 → 说明移出后该字符不再满足要求 → satisfy-1
if (cnt[s[j]] == 0) satisfy -- ;
// j位置字符移出窗口,对应计数+1,j右移
cnt[s[j ++ ]] ++ ;
}
// 步骤3:如果满足要求的字符数等于p的不同字符总数 → 说明当前窗口是异位词
// 记录窗口起始索引j
if (satisfy == tot) res.push_back(j);
}
return res;
}
};
class Solution
{
public:
vector<int> findAnagrams(string s, string p)
{
int m = s.size(), n = p.size();
// 边界条件:s长度小于p,不可能有异位词,直接返回空结果
if(m < n) return {};
// cnt:哈希表存储「字符的供需差值」= p中字符需要量 - 窗口中字符实际量
// 初始时窗口为空,所以cnt[c] = p中c的需要量(实际量为0)
unordered_map<char, int> cnt;
// 初始化cnt:统计p中每个字符的需要量,作为初始供需差值
for (auto c: p) cnt[c] ++ ;
vector<int> res; // 存储最终结果(所有异位词的起始索引)
int tot = cnt.size(); // tot:p中「不同字符的总数」(比如p=abc则tot=3)
// 滑动窗口双指针:
// i:右指针(窗口右边界),负责将字符加入窗口
// j:左指针(窗口左边界),负责将字符移出窗口(控制窗口长度)
// satisfy:当前窗口中「供需差值=0」的字符种类数(即刚好满足p需求的字符数)
for (int i = 0, j = 0, satisfy = 0; i < s.size(); i ++ )
{
// 步骤1:字符s[i]进入窗口 → 消耗需求,供需差值-1
// 关键逻辑:减完后供需差值=0 → 该字符从「缺/多余」变为「刚好满足」→ satisfy+1
if ( -- cnt[s[i]] == 0) satisfy ++ ;
// 步骤2:窗口长度超过p的长度 → 收缩左指针j(保证窗口长度严格等于p.size())
while (i - j + 1 > p.size())
{
// 收缩前检查:如果当前j位置字符的供需差值=0 → 移出后该字符从「刚好满足」变「缺」→ satisfy-1
if (cnt[s[j]] == 0) satisfy -- ;
// 步骤:字符s[j]移出窗口 → 归还需求,供需差值+1;同时j右移(窗口左边界收缩)
// 注:j++是后置自增,先取j的当前值操作,再将j+1
cnt[s[j ++ ]] ++ ;
}
// 步骤3:如果所有p的不同字符都达到「供需平衡」→ 当前窗口是p的异位词
// 记录窗口起始索引j(此时窗口长度=n,j是窗口第一个字符的下标)
if (satisfy == tot) res.push_back(j);
}
return res;
}
};
代码分析
一、cnt 的准确定义(最核心,先记死)
cnt 是哈希表 / 数组(仅小写字母时用数组更高效),存储的是:
目标字符串 p 中「字符 c 的需要量」 - 当前滑动窗口中「字符 c 的实际量」
(简称:字符 c 的「供需差值」)
三种核心状态(用 p=abc 举例,初始 cnt [a]=1、cnt [b]=1、cnt [c]=1)
| cnt [c] 的值 | 状态含义 | 通俗解释 |
|---|---|---|
| > 0(如 1) | 供 < 需 | 窗口中 c 的数量 < p 需要的数量,缺 cnt [c] 个,不满足 |
| = 0 | 供 = 需 | 窗口中 c 的数量 = p 需要的数量,刚好满足 |
| < 0(如 - 1) | 供 > 需 | 窗口中 c 的数量 > p 需要的数量,多 -cnt [c] 个,满足但有多余 |
补充:不在 p 中的字符
如果字符 c 不在 p 中,p 对 c 的需要量为 0 → cnt [c] 初始值为 0(哈希表默认 / 数组初始化),此时:
- cnt [c] = 0 - 窗口中 c 的实际量 → 窗口加入 c 则 cnt [c]=-1,移出则 cnt [c] 回 0。
二、cnt 的核心处理逻辑(「进窗口」和「出窗口」的逆操作)
滑动窗口的本质是「动态维护窗口内字符的供需差值」,所有操作都是围绕 cnt 展开的,核心是 「进则减,出则加」。
1. 字符进入窗口(右指针 i 移动,s [i] 加入窗口)
// 步骤1:消耗需求 → 供需差值-1
--cnt[s[i]];
// 步骤2:如果减完后供需平衡 → 该字符从「不满足/多余」变为「刚好满足」→ 满足的字符种类数+1
if (cnt[s[i]] == 0) satisfy++;
举例(p=abc,cnt [a] 初始 = 1):
- 窗口加入第一个 a → cnt [a] = 1-1 = 0 → satisfy+1(a 刚好满足);
- 窗口加入第二个 a → cnt [a] = 0-1 = -1 → cnt≠0 → satisfy 不变(a 满足但多余)。
2. 字符移出窗口(左指针 j 移动,s [j] 移出窗口)
// 步骤1:先判断——移出前该字符是否「刚好满足」
if (cnt[s[j]] == 0) satisfy--; // 移出后从「满足」变「不满足」→ 满足种类数-1
// 步骤2:归还需求 → 供需差值+1,同时j右移
cnt[s[j++]]++;
举例(p=abc,窗口中有 2 个 a,cnt [a]=-1):
- 移出第一个 a → cnt [a]=-1≠0 → satisfy 不变;cnt [a]++→0(a 从多余变刚好满足);
- 再移出第二个 a → cnt [a]=0 → satisfy-1;cnt [a]++→1(a 从满足变缺 1 个)。
三、完整处理流程(结合题目逻辑)
以「找到字符串中所有字母异位词」为例,完整流程如下:
步骤 0:初始化
unordered_map<char, int> cnt;
for (auto c : p) cnt[c]++; // 初始化cnt:存储p中每个字符的需要量(供需差值=需要量-0)
int tot = cnt.size(); // p中「不同字符的总数」(比如p=abc则tot=3)
int satisfy = 0; // 当前窗口中「刚好满足」的字符种类数
vector<int> res; // 存储异位词起始索引
步骤 1:滑动窗口遍历 s
for (int i = 0, j = 0; i < s.size(); i++)
{
// 1. 右指针:字符进窗口,更新cnt和satisfy
--cnt[s[i]];
if (cnt[s[i]] == 0) satisfy++;
// 2. 左指针:窗口长度超p的长度时,收缩窗口(保证窗口长度=len(p))
while (i - j + 1 > p.size())
{
// 2.1 移出前判断:是否刚好满足
if (cnt[s[j]] == 0) satisfy--;
// 2.2 字符出窗口,更新cnt,j右移
cnt[s[j++]]++;
}
// 3. 判断:所有字符都刚好满足 → 当前窗口是异位词
if (satisfy == tot) res.push_back(j);
}
四、关键逻辑验证(两种典型场景)
场景 1:s [j] 在 p 中且 cnt [s [j]]=0(刚好满足)
- p=abc,s [j]=a,cnt [a]=0(窗口中 a 刚好 1 个);
- 移出前:cnt [a]=0 → satisfy--(从 3→2);
- 移出后:cnt [a]++→1(窗口中 a=0,缺 1 个),j 右移;
- 逻辑:a 从「满足」变「不满足」,satisfy 同步减少,符合预期。
场景 2:s [j] 不在 p 中(如 s [j]=d)
- p=abc,cnt [d] 初始 = 0;窗口加入 d 后 cnt [d]=-1;
- 移出时:cnt [d]=-1≠0 → satisfy 不变;
- 移出后:cnt [d]++→0(恢复初始状态),j 右移;
- 逻辑:d 从未被计入 satisfy,移出时不影响,符合预期。
总结(核心记忆点)
- cnt 的本质:供需差值 = p 的需要量 - 窗口的实际量,能表达「缺、够、多」三种状态;
- 核心操作:字符进窗口→cnt-1(消耗需求),字符出窗口→cnt+1(归还需求);
- satisfy 的作用:仅统计「供需差值 = 0」的字符种类数,等于 tot 时窗口是异位词;
- 边界兼容:不在 p 中的字符全程不影响 satisfy,无需额外判断。
记住这四句话,整个滑动窗口处理逻辑就完全通了,无论字符是否在 p 中、cnt 是正 / 负 / 零,都能精准判断!
相似题目
有效的字母异位词简单
字符串的排列中等

浙公网安备 33010602011771号