数据结构算法学习:LeetCode热题100-子串篇(和为 K 的子数组、滑动窗口最大值、最小覆盖子串)
文章目录
- 简介
- 560. 和为 K 的子数组
- 问题描述
- 解题方法
- 暴力求解
- 前缀和加哈希表
- 239. 滑动窗口最大值
- 问题描述
- 解题方法
- 单调队列加滑动窗口求解
- 76. 最小覆盖子串
- 问题描述
- 解题方法
- 哈希表+滑动窗口
- 个人学习总结
简介
本博客聚焦于子串问题中的经典算法应用,重点探讨了滑动窗口技术和**队列(栈)**的使用。通过三个典型题目——560.和为K的子数组、239.滑动窗口最大值、76.最小覆盖子串,详细展示了从暴力解法到优化解法的演进过程。每个题目均包含问题描述、多种解题思路(如前缀和+哈希表、单调队列、滑动窗口+哈希表)、代码实现及复杂度分析。特别地,在76题中深入剖析了Java中Integer对象比较的陷阱,为读者提供了宝贵的实战经验。本博客旨在帮助读者深入理解子串问题的核心算法思想,掌握高效解决此类问题的技巧。
560. 和为 K 的子数组
问题描述
给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 。
子数组是数组中元素的连续非空序列。
示例
示例 1:
输入:nums = [1,1,1], k = 2
输出:2示例 2:
输入:nums = [1,2,3], k = 3
输出:2
标签提示: 数组、哈希表、前缀和
解题方法
暴力求解
解题思路
暴力求解的思路非常直接:枚举所有可能的连续子数组,计算每个子数组的和,统计其中和等于 k 的子数组数量。
解题步骤
- 初始化计数器 count = 0
- 使用双重循环遍历所有可能的子数组:
- 外层循环 i 从 0 到 n-1,表示子数组的起始位置
- 内层循环 j 从 i 到 n-1,表示子数组的结束位置
- 在内层循环中:
- 维护一个变量 sum,表示从 i 到 j 的子数组和
- 每次内层循环迭代时,sum 加上 nums[j]
- 如果 sum == k,则计数器 count 加 1
- 循环结束后返回 count
代码实现
class Solution {public int subarraySum(int[] nums, int k) {int count = 0, n = nums.length;for(int i = 0; i < n; i ++){int sum = 0;for(int j = i; j < n; j ++){sum += nums[j];if(sum == k){count ++;}}}return count;}
}
复杂度分析
时间复杂度:O(n²),因为有两层嵌套循环
空间复杂度:O(1),只使用了常数个额外变量
前缀和加哈希表
解题思路
前缀和是一种高效计算子数组和的技术。定义前缀和 pre_num 为数组从开头到当前位置的和。核心思想是:
- 子数组 nums[i…j] 的和 = pre_num[j] - pre_num[i-1]
- 要使子数组和等于 k,需要满足:pre_num[j] - pre_num[i-1] = k
- 转化为:pre_num[i-1] = pre_num[j] - k
使用哈希表存储前缀和出现的次数,可以在遍历时快速查找满足条件的前缀和。
解题步骤
- 初始化:
- 计数器 count = 0
- 当前前缀和 pre_num = 0
- 哈希表 map,初始存入 {0: 1}(表示前缀和为0出现1次)
- 遍历数组:
- 更新当前前缀和:pre_num += nums[i]
- 检查哈希表中是否存在 pre_num - k:
- 如果存在,说明有子数组和为 k,将对应的出现次数加到 count
- 更新哈希表:
- 将当前前缀和 pre_num 的出现次数加1
- 遍历结束后返回 count
代码实现:
class Solution {public int subarraySum(int[] nums, int k) {int count = 0, n = nums.length;int pre_num = 0;Map<Integer, Integer> map = new HashMap<>();map.put(0,1); // 初始化:前缀和为0出现1次for(int i = 0; i < n; i ++){pre_num += nums[i]; // 更新当前前缀和// 检查是否存在 pre_num - k 的前缀和if(map.containsKey(pre_num - k)){count += map.get(pre_num - k);} // 更新哈希表:当前前缀和出现次数+1map.put(pre_num, map.getOrDefault(pre_num, 0) + 1);}return count;}
}
复杂度分析
时间复杂度:O(n),只遍历数组一次,哈希表操作为O(1)
空间复杂度:O(n),哈希表最多存储n个不同的前缀和
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 31 [3 -1 -3] 5 3 6 7 31 3 [-1 -3 5] 3 6 7 51 3 -1 [-3 5 3] 6 7 51 3 -1 -3 [5 3 6] 7 61 3 -1 -3 5 [3 6 7] 7示例 2:
输入:nums = [1], k = 1
输出:[1]
标签提示: 队列、数组、滑动窗口
解题方法
单调队列加滑动窗口求解
解题思想
使用单调递减双端队列来维护窗口内的元素,队列中存储的是数组元素的索引(而非值),并保证队列中索引对应的值始终单调递减。这样队首元素始终是当前窗口的最大值。核心思想:
- 维护单调性:队列中存储的索引对应的值从队首到队尾单调递减
- 动态更新:当窗口滑动时,移除不在窗口内的元素,并加入新元素时保持单调性
- 高效获取最大值:队首元素始终是当前窗口的最大值
解题步骤
- 初始化:
- 创建双端队列 deque 存储索引
- 创建结果数组 result,长度为 n - k + 1
- 遍历数组(索引 i 从 0 到 n-1):
- a. 维护单调性(从队尾移除):
- 当队列非空且队尾元素对应的值 ≤ 当前元素 nums[i] 时,移除队尾元素
- 重复此过程直到队列为空或队尾元素 > 当前元素
- b. 加入新元素:
- 将当前索引 i 加入队尾
- c. 移除过期元素(从队首移除):
- 当队首元素索引 ≤ i - k(即不在当前窗口内)时,移除队首元素
- d. 记录最大值:
- 当 i ≥ k - 1(窗口已形成)时,将队首元素对应的值存入结果数组
- a. 维护单调性(从队尾移除):
- 返回结果:
- 遍历完成后返回结果数组
代码实现:
import java.util.Deque;
import java.util.LinkedList;class Solution {public int[] maxSlidingWindow(int[] nums, int k) {if (nums == null || nums.length == 0 || k <= 0) {return new int[0];}int n = nums.length;int[] result = new int[n - k + 1];Deque<Integer> deque = new LinkedList<>(); // 存储索引for (int i = 0; i < n; i++) {// 1. 维护单调性:移除队尾所有小于等于当前元素的索引while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {deque.pollLast();}// 2. 加入新元素索引deque.offerLast(i);// 3. 移除过期元素(不在窗口内的队首元素)if (deque.peekFirst() <= i - k) {deque.pollFirst();}// 4. 记录当前窗口最大值if (i >= k - 1) {result[i - k + 1] = nums[deque.peekFirst()];}}return result;}
}
复杂度分析
时间复杂度:O(n)
- 元素遍历:每个元素被处理一次(入队操作)
- 元素出队:每个元素最多出队一次(从队尾或队首)
- 队尾出队:保持单调性(每个元素最多被移除一次)
- 队首出队:移除过期元素(每个元素最多被移除一次)
- 总操作次数:每个元素最多执行2次操作(入队+出队)
- 结果记录:每次窗口滑动记录一次结果(O(1)时间)
关键点:虽然代码中有嵌套循环(while循环),但每个元素最多被处理两次,因此总时间复杂度为线性O(n),而非O(nk)。
空间复杂度:O(k)
- 双端队列:
- 队列中最多存储k个元素(窗口大小)
- 最坏情况:数组严格递减时,队列存储整个窗口元素
- 结果数组:
- 大小为n-k+1(题目要求输出)
- 通常不计入额外空间复杂度(视为必要输出)
- 额外空间:
- 仅双端队列使用O(k)额外空间
- 其他变量(索引、临时变量)使用O(1)空间
76. 最小覆盖子串
问题描述
给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 “” 。
注意:
对于 t 中重复字符,我们寻找的子字符串中该字符数量必须不少于 t 中该字符数量。
如果 s 中存在这样的子串,我们保证它是唯一的答案。
示例
示例 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 的子串中,
因此没有符合条件的子字符串,返回空字符串。
标签提示: 哈希表、字符串、滑动窗口
解题方法
哈希表+滑动窗口
解题思想
使用滑动窗口(双指针)技术,在字符串s中寻找包含t中所有字符的最小子串。具体思路:
- 统计t中每个字符的频率(使用哈希表mapt)。
- 使用两个指针(begin和i)表示窗口的左右边界,初始化为0。
- 移动右指针i扩展窗口,直到窗口包含t中所有字符(通过计数count判断)。
- 当窗口满足条件时,尝试移动左指针begin缩小窗口,以找到更小的满足条件的子串。
- 在缩小窗口过程中,更新最小子串的起始位置和长度。
- 最终返回最小子串,若不存在则返回空字符串。
在这里插入代码片
解题步骤:
-
初始化:
- 如果s的长度小于t,直接返回空字符串。
- 创建两个哈希表:mapt记录t中每个字符的频率,mapwin记录当前窗口中每个字符的频率。
- 遍历t,初始化mapt。
-
滑动窗口遍历:
- 使用右指针i遍历s,对于每个字符c:
- 如果c在t中,则更新mapwin中c的计数。
- 如果mapwin中c的计数等于mapt中c的计数,则将count加1(表示一个字符已满足要求)。
- 当count等于mapt的大小(即t中所有字符都满足要求)时,进入内层循环:
- 更新最小子串:如果当前窗口长度(i-begin+1)小于之前记录的最小长度,则更新最小长度和起始、结束位置。
- 移动左指针begin缩小窗口:
- 如果左指针指向的字符a在t中,则更新mapwin中a的计数。
- 如果mapwin中a的计数在减少前等于mapt中a的计数,则将count减1(表示该字符不再满足要求)。
- 将a的计数减1,并移动左指针begin。
- 使用右指针i遍历s,对于每个字符c:
-
返回结果:
- 如果没有找到满足条件的子串(ansb仍为-1),返回空字符串。
- 否则,返回从ansb到anse的子串。
解题代码:
class Solution {public String minWindow(String s, String t) {int ls = s.length(), lt = t.length();if(ls < lt){return "";}Map<Character, Integer> mapt = new HashMap<>();Map<Character, Integer> mapwin = new HashMap<>();for(char strt : t.toCharArray()){mapt.put(strt, mapt.getOrDefault(strt, 0) + 1);}int count = 0, begin = 0, minlen = Integer.MAX_VALUE, ansb = -1, anse = -1;for(int i = 0; i < ls; i ++){// 添加字符char c = s.charAt(i);if(mapt.containsKey(c)){mapwin.put(c, mapwin.getOrDefault(c, 0) + 1);if(mapwin.get(c).equals(mapt.get(c))){count ++;}}while(count == mapt.size()){// 更新最短结果if(i - begin + 1 < minlen){minlen = i - begin + 1;ansb = begin;anse = i;}// 缩小窗口char a = s.charAt(begin);if(mapt.containsKey(a)){if(mapwin.get(a).equals(mapt.get(a))){count --;}mapwin.put(a, mapwin.get(a) - 1);}begin ++;}}if(ansb == -1){return "";}return s.substring(ansb, anse + 1);}
}
注意:Integer比较陷阱
在Java中,Integer对象在-128到127之间会被缓存,超出这个范围会创建新对象。因此,使用==比较两个Integer对象时,如果值在缓存范围内,比较的是值(因为指向同一个对象);如果超出缓存范围,比较的是对象引用(即使值相同,也可能返回false)。我在这里卡了很久,没想到是这个基础问题导致的。
复杂度分析
时间复杂度:O(|s| + |t|),其中|s|和|t|分别是字符串s和t的长度。
- 初始化mapt需要遍历t,时间复杂度O(|t|)。
- 遍历s时,每个元素最多被访问两次(一次被右指针i,一次被左指针begin),因此时间复杂度为O(|s|)。
空间复杂度:O(|Σ|),其中Σ是字符集的大小(例如ASCII字符集为128或256)。
- 使用了两个哈希表,存储字符频率,最坏情况下需要存储所有不同的字符。
总结
本题通过滑动窗口和哈希表高效解决了最小覆盖子串问题。在实现时,需特别注意Java中Integer对象的比较陷阱,避免因引用比较导致的逻辑错误。修复后,代码能够正确处理所有测试用例,包括极端情况(如长字符串导致字符计数超过127)。
个人学习总结
通过本次对子串问题的系统学习,我深刻体会到滑动窗口技术在处理连续子数组/子串问题中的强大威力。以下是我的主要收获与感悟:
- 滑动窗口技术的灵活应用:在76题最小覆盖子串中,通过双指针动态调整窗口边界,结合哈希表统计字符频率,实现了高效的最小子串查找。这让我认识到,滑动窗口不仅适用于固定大小窗口(如239题),也能动态调整窗口大小以满足特定条件。
- 前缀和与哈希表的结合:560题展示了前缀和与哈希表的巧妙结合,将子数组和问题转化为前缀和差值问题,将时间复杂度从O(n²)优化到O(n)。这启示我,在遇到求和类问题时,应优先考虑前缀和优化。
- 单调队列的威力:239题中,单调队列的引入使得滑动窗口最大值问题的时间复杂度从O(nk)优化到O(n)。通过维护一个单调递减队列,确保队首始终是当前窗口的最大值,这种思想在处理极值问题时非常高效。
- 细节决定成败:在76题的实现中,我因忽略了Java中Integer对象的缓存机制(-128到127),导致使用
==
比较时出现逻辑错误。这提醒我,在编程时必须注意基础数据类型的特性,尤其是对象比较时,应使用equals方法而非==
,以避免因缓存机制导致的陷阱。 - 复杂度分析的重要性:通过对每个解法进行时间复杂度和空间复杂度的分析,我学会了如何评估算法的效率,并理解了优化解法(如前缀和、单调队列)为何能大幅提升性能。这为我后续设计高效算法提供了理论指导。
总之,子串问题看似简单,但蕴含着丰富的算法思想。通过深入理解滑动窗口、前缀和、单调队列等核心技术,并注重编程细节,我们能够高效解决此类问题。这次学习不仅提升了我的算法能力,也让我更加注重代码的健壮性和细节处理。