【LeetCode Hot100 子串】和为 k 的子数组、滑动窗口最大值、最小覆盖子串
子串
- 1. 和为 k 的子数组
- 题目描述
- 解题思路
- 主要思路
- 步骤
- 时间复杂度与空间复杂度
- 代码实现
- 2. 滑动窗口最大值
- 题目描述
- 解题思路
- 双端队列的原理:
- 优化步骤:
- Java实现
- 3. 最小覆盖子串
- 题目描述
- 解题思路
- 滑动窗口的基本思路:
- 具体步骤:
- 算法的关键点:
- Java实现
1. 和为 k 的子数组
题目描述
给定一个整数数组 nums
和一个整数 k
,你需要在数组中找到连续子数组的个数,使得这些子数组的和等于 k
。
解题思路
我们可以通过 前缀和 的方法来高效解决这个问题,结合 哈希表 来记录每个前缀和出现的次数,从而迅速计算出满足条件的子数组。
主要思路
-
前缀和的定义:
- 对于数组
nums
,prefix[i]
表示从nums[0]
到nums[i-1]
的和。也就是说,prefix[i] = nums[0] + nums[1] + ... + nums[i-1]
。 - 子数组的和
nums[i..j]
可以表示为:prefix[j+1] - prefix[i]
。因此,如果我们希望找到nums[i..j]
的和为k
,那么只需要满足prefix[j+1] - prefix[i] = k
。
- 对于数组
-
如何利用哈希表:
- 在遍历数组时,我们可以计算当前的前缀和
pre
。 - 然后,我们通过
map.containsKey(pre - k)
来判断是否存在一个前缀和为pre - k
的位置,这样就找到了一个子数组和为k
。 - 我们还需要维护一个哈希表
map
,其中map.get(pre)
表示当前前缀和pre
出现的次数。这样做是为了确保我们能够计算出所有符合条件的子数组。
- 在遍历数组时,我们可以计算当前的前缀和
-
核心算法:
- 初始化哈希表
map
,将0
的计数初始化为 1,因为如果前缀和刚好为k
,就意味着从数组起始位置开始的子数组和为k
。 - 遍历数组并更新前缀和,并利用哈希表记录前缀和的出现次数。
- 初始化哈希表
步骤
-
初始化:
pre = 0
表示当前的前缀和。cnt = 0
表示符合条件的子数组数量。map
存储前缀和及其出现次数,初始时将map.put(0, 1)
,即前缀和为 0 出现 1 次。
-
遍历数组:
- 对于每个元素,更新前缀和
pre
。 - 检查哈希表中是否存在
pre - k
,如果存在,说明从某个位置到当前位置的子数组和为k
,则将其出现次数加到cnt
中。 - 更新哈希表,将当前前缀和
pre
出现的次数加 1。
- 对于每个元素,更新前缀和
-
返回结果:
- 遍历完所有元素后,
cnt
中存储的就是符合条件的子数组数量。
- 遍历完所有元素后,
时间复杂度与空间复杂度
- 时间复杂度:
O(n)
,其中n
是数组nums
的长度。我们只需要遍历一次数组,同时进行常数时间的哈希表操作。 - 空间复杂度:
O(n)
,我们需要使用哈希表存储前缀和及其出现次数,最坏情况下哈希表的大小为n
。
代码实现
class Solution {
public int subarraySum(int[] nums, int k) {
int len = nums.length;
int pre = 0, cnt = 0;
HashMap<Integer, Integer> map = new HashMap<>();
map.put(0, 1); // 初始化,前缀和为0的有1个,这样做不会忽略掉“从数组起始位置开始的和为 k 的子数组”。
for (int i = 0; i < len; i++) {
pre += nums[i]; // 计算当前前缀和
if (map.containsKey(pre - k)) { // 说明从某个位置到当前位置存在连续子数组和为 k
cnt = cnt + map.get(pre - k); // 增加符合条件的子数组的数量
}
// 更新哈希表,记录当前前缀和出现的次数
map.put(pre, map.getOrDefault(pre, 0) + 1);
}
return cnt; // 返回符合条件的子数组数量
}
}
2. 滑动窗口最大值
题目描述
给定一个整数数组 nums
和一个滑动窗口的大小 k
,请你在数组中找出每个滑动窗口的最大值,并返回一个数组。
解题思路
这道题目是一个典型的滑动窗口问题。直接暴力计算每个窗口中的最大值的时间复杂度是 O(n*k)
,这种做法在数据量较大的情况下效率较低。因此,我们可以使用 双端队列(Deque) 来优化这一过程。
双端队列的原理:
- 双端队列是一种支持从两端高效插入和删除的队列结构。我们可以利用它来存储数组元素的下标,并保持队列中的元素按照值的大小顺序排列。这样可以确保队列的第一个元素永远是当前窗口中的最大值。
优化步骤:
及时去掉无用数据,保证双端队列有序(当前数组>=队尾,弹出队尾;弹出队首不在窗口内的元素)
- 使用一个双端队列
q
来存储窗口中的元素的下标。 - 保证队列中的元素下标对应的值是递减的,队列的首部始终是窗口的最大值。
- 每次移动窗口时:
- 入队操作:将新元素的下标加入队列,并从队列的尾部移除所有小于当前元素的值,以保证队列保持递减顺序。
- 出队操作:如果队列头部的元素已经不再在当前窗口范围内(即超出窗口的左边界),则将其从队列中移除。
- 记录结果:当窗口大小达到
k
时,记录当前窗口的最大值,即队列头部的元素。
Java实现
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
int n = nums.length;
Deque<Integer> q = new ArrayDeque<>(); // 存的是nums的下标
int[] ans = new int[n - k + 1];
for (int i = 0; i < n; i++) {
// 1. 入队:保持队列中的元素递减
while (!q.isEmpty() && nums[i] >= nums[q.getLast()]) {
q.removeLast();
}
q.addLast(i); // 队列存的是下标
// 2. 出队:如果队列的第一个元素不在窗口内,移除它
if (i - q.getFirst() >= k) {
q.removeFirst();
}
// 3. 存结果:当窗口大小达到k时,记录最大值
if (i >= k - 1) {
ans[i - k + 1] = nums[q.getFirst()];
}
}
return ans;
}
}
3. 最小覆盖子串
题目描述
给定字符串 s
和字符串 t
,找到 s
中包含 t
中所有字符的最小子串。如果 s
中没有包含 t
中所有字符的子串,则返回空字符串。
解题思路
这是一个经典的滑动窗口问题。我们需要在字符串 s
中找到一个最小的子串,该子串包含了 t
中所有字符。最初我们可以考虑暴力解法,但暴力解法会超时,因此我们需要使用 滑动窗口 技巧来优化算法。
滑动窗口的基本思路:
- 滑动窗口的定义:我们维护一个窗口,窗口的大小是可变的,在窗口内包含了
t
中的所有字符。 - 扩展窗口:从字符串
s
的开始位置开始扩展窗口,逐步包含t
中的字符。 - 收缩窗口:当窗口已经包含了
t
中的所有字符时,尝试缩小窗口的大小,以找到更小的符合条件的子串。 - 窗口合法性:当窗口内包含所有
t
中的字符时,窗口是合法的。
具体步骤:
- 使用两个指针
left
和right
表示滑动窗口的左右边界,初始化时都指向字符串s
的开头。 - 使用两个哈希表
cntT
和cntS
来记录t
中字符的出现频率和当前窗口中字符的出现频率。 - 当窗口包含
t
中的所有字符时,尝试缩小窗口的左边界。 - 在每次扩展和收缩窗口时,更新当前的最小子串。
算法的关键点:
- 记录
t
中所有字符的频率。 - 使用两个指针维护滑动窗口。
- 记录窗口内字符的频率并与
t
中的字符频率进行比较。
Java实现
class Solution {
public String minWindow(String s, String t) {
int ansLeft = -1;
int m = s.length();
int ansRight = m;
// 记录t的字符出现的次数
Map<Character, Integer> cntT = new HashMap<>();
for (char c : t.toCharArray()) {
cntT.put(c, cntT.getOrDefault(c, 0) + 1);
}
// 记录s的字符出现的次数
Map<Character, Integer> cntS = new HashMap<>();
int left = 0;
int formed = 0; // 记录s和t覆盖的字符的个数
int required = cntT.size(); // 记录t中的不同字符的个数
for (int right = 0; right < m; right++) {
char sr = s.charAt(right);
cntS.put(sr, cntS.getOrDefault(sr, 0) + 1);
// 如果s中的字符完全匹配t中的字符
if (cntT.containsKey(sr) && cntS.get(sr).intValue() == cntT.get(sr).intValue()) {
formed++;
}
// 当s子串能覆盖t的时候收缩窗口
while (formed == required) {
if (right - left < ansRight - ansLeft) {
ansLeft = left;
ansRight = right;
}
// 收缩窗口
char leftChar = s.charAt(left);
cntS.put(leftChar, cntS.get(leftChar) - 1);
if (cntT.containsKey(leftChar) && cntS.get(leftChar).intValue() < cntT.get(leftChar).intValue()) {
formed--;
}
left++;
}
}
return ansLeft < 0 ? "" : s.substring(ansLeft, ansRight + 1); // 左闭右开
}
}