1. 长度最小的子数组
题目描述:
解法一:暴力枚举出所有的子数组的和(会超时)
算法思路:(时间复杂度)
从前往后枚举数组中的任意一个元素,把它当成起始位置,然后从这个起始位置开始,寻找一段最短的区间,使得这段区间的和大于等于目标值
将所有元素作为起始位置所得到的结果中,找到最小值即可
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
//记录结果
int ret = Integer. MAX_VALUE;
int n = nums.length;
//枚举出所有满足和大于等于 target 的子数组[start, end]
//由于是取到最小,因此枚举的过程中要尽量让数组的长度最小
//枚举开始位置
for (int start = 0; start < n; start++) {
int sum = 0; //记录从这个位置开始的连续数组的和
//寻找结束位置
for (int end = start; end < n; end++) {
sum += nums[end]; //将当前位置加上
if (sum >= target) { //当这段区间内的和满足条件时
//更新结果,start 开头的最短区间已经找到,后续即使再有满足大于等于 target 的区间,也不是最短区间了,所以直接跳出本次循环
ret = Math.min(ret, end - start + 1);
break;
}
}
}
return ret == Integer.MAX_VALUE ? 0 : ret;
}
}
解法二:滑动窗口(利用单调性,使用"同向双指针"来优化)
图解:
tip:像这样 left 和 right 一直往前走,不回退的场景才可以使用 "滑动窗口"!
代码实现:
java
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int left = 0;
int right = 0;
int n = nums.length;
int sum = 0;
int len = Integer.MAX_VALUE;
for ( ; right < n; right++) {
sum += nums[right]; //进窗口
while (sum >= target) { //判断
len = Math.min(len, right - left + 1); //更新结果
sum -= nums[left]; //出窗口
left++;
}
}
//若循环结束后,len 没变,说明没有满足条件的子数组
return len == Integer.MAX_VALUE ? 0 : len;
}
}
时间复杂度:,虽然有两个循环,但是时间复杂度低,具体原因如下补充
空间复杂度:,只使用了有限的几个变量
补充:为何滑动窗口可以解决问题,并且时间复杂度更低?
2. 无重复字符的最长字串
题目描述:
解法一:暴力枚举 + 哈希表(判断字符是否重复出现)
时间复杂度:
算法思路:
枚举【从每一个位置】开始往后,无重复字符的子串可以到达什么位置,找出其中长度最大的即可
在往后寻找无重复字串能到达的位置时,可以利用【哈希表】统计出字符出现的频次来判断什么时候字串出现了重复元素
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int len = 0;
char[] arr = s.toCharArray();
int n = arr.length;
//枚举从不同位置开始的无重复子串
//枚举起始位置
for (int i = 0; i < n; i++) {
//创建一个哈希表,统计频次
int[] hash = new int[128];
//寻找结束位置
for (int j = i; j < n; j++) {
hash[arr[j]]++; //统计字符出现的频次
if (hash[arr[j]] > 1) { //如果出现重复的字符
break;
}
//如果没有重复,更新 len
len = Math.max(len, j - i + 1);
}
}
return len;
}
}
解法二:滑动窗口
研究对象是一段连续的区间,且能通过不回退的 left 和 right 双指针解决问题,因此使用【滑动窗口】思想来优化上述暴力枚举
滑动窗口需满足:窗口内所有元素都是不重复的
思路:
代码实现:
java
class Solution {
public int lengthOfLongestSubstring(String s) {
char[] arr = s.toCharArray(); //将字符串转为字符数组
//没必要真的创建一个 hash 表,所以用数组模拟哈希表
//例如:49 下标处为 2,则表示 ASCII 码为 49 的字符出现了 2 次
//数组大小为 128,足够表示常用的字符
int[] hash = new int[128];
int left = 0;
int right = 0;
int len = 0;
int n = arr.length;
while (right < n) {
hash[arr[right]]++; //进入窗口(让字符进入哈希表)
while (hash[arr[right]] > 1) { //判断(若窗口内出现重复字符)
hash[arr[left]]--; //出窗口(让字符离开哈希表)
left++;
}
len = Math.max(len, right - left + 1); //更新结果
right++; //让下一个字符进入窗口
}
return len;
}
}
时间复杂度:
空间复杂度:,(因为 char[] arr = s.toCharArray();)
3. 最大连续 1 的个数 III
题目描述:
解法一:暴力枚举 + zero 计数器(会超时)
代码实现:
java
class Solution {
public int longestOnes(int[] nums, int k) {
int n = nums.length;
int len = 0;
for (int left = 0; left < n; left++) { //从头开始遍历数组
int zero = 0; //记录子数组中 0 的个数
for (int right = left; right < n; right++) { //每次从 left 下标处开始向后遍历
if (nums[right] == 0) { //统计 0 的个数
zero++;
}
if (zero > k) { //若 0 的个数超过 k,则跳出本次循环,回到外层 for 循环
break;
}
len = Math.max(len, right - left + 1); //更新子数组的最长长度
}
}
return len;
}
}
解法二:滑动窗口
算法思路:
此题万万不能想着怎样去翻转,否则很复杂,此题可以简单的理解为一段连续的 1 中间塞了 k 个 0
因此,我们可以把问题转化为:求数组中一段最长的连续区间,要求这段区间内 0 的个数不超过 k 个
既然是连续区间,就可以考虑使用【滑动窗口】来解决问题
算法流程:
代码实现:
错误代码总结:
java
//错误版本总结:
class Solution {
public int longestOnes(int[] nums, int k) {
int n = nums.length;
int left = 0;
int right = 0;
int zero = 0;
int len = 0;
while (right < n) {
if (nums[right] == 0) {
zero++;
}
//错误一:zero 只要小于等于 k 就可以继续往下执行了,不用非得减到零!
if (zero > k) {
while (left < right - 1) {
left++;
if (nums[left] == 0) {
zero--;
}
}
}
right++; //错误二:要先计算 len 的值,之后再 right++ !
len = Math.max(len, right - left + 1);
}
return len;
}
}
正确代码:
java
class Solution {
public int longestOnes(int[] nums, int k) {
int left = 0;
int right = 0;
int n = nums.length;
int len = 0; //记录子数组的最大长度
int zero = 0; //记录子数组中 0 的个数
while (right < n) {
if (nums[right] == 0) { //进窗口,当 right 下标处值为 0 时,计数器 + 1
zero++;
}
//判断,当计数器 > k 时,left 左移
//若此时 left 下标处值为 1,不进行处理
//若此时 left 下标处值为 0,计数器 - 1
while (zero > k) {
if (nums[left] == 0) {
zero--;
}
left++;
}
//更新数组最大长度
len = Math.max(len, right - left + 1);
right++;
}
return len;
}
}
4. 将 x 减到 0 的最小操作数
题目描述:
解法一:暴力枚举(会超时)
java
class Solution {
public int minOperations(int[] nums, int x) {
int sum1 = 0; //记录数组中所有元素之和
for (int a : nums) {
sum1 += a;
}
int n = nums.length;
//找出最长的子数组的长度 len,使得其中所有元素的和等于 sum1 - x
int target = sum1 - x;
if (target == 0) { //该情况为数组所有元素之和 等于 x
return n;
}
int len = -1; //记录满足条件的数组长度
int sum2 = 0; //记录当前 [i, j] 范围内数组元素之和
//第一层 for 循环,遍历数组
for (int i = 0; i < n; i++) {
sum2 = 0;
//第二层 for 循环,遍历从 i 下标开始往后的数组元素
for (int j = i; j < n; j++) {
sum2 += nums[j];
if (sum2 == target) {
len = Math.max(len, j - i + 1);
break;
}
if (sum2 > target) {
break;
}
}
}
if (len == -1) {
return -1;
} else {
return n - len;
}
}
}
解法二:滑动窗口
(正难则反):题目要求的是数组【左端+右端】两端连续的、和为 x 的最短数组,信息量很多(既要考虑左右端移除元素的时机,又要求最短),不易解答;因此我们可以转换为求数组内一段连续的、和为 sum1 - x 的最长数组 arr(注:此处 sum1 为数组所有元素之和),这样令整个数组长度减去 arr 的长度,即可得到结果
算法流程:
-
转化问题:求 target = sum1 - x,如果 target < 0,就没有单调性可言,问题无解
-
初始化左右指针 left = 0,right = 0,记录当前滑动窗口内数组和的变量 sum2 = 0(tip:区分 sum1 和sum2),记录当前满足条件数组的最大区间长度 len = -1
-
当 right 小于数组长度时,一直循环:
a) 如果 sum2 < target,右移右指针,直至 sum2 大于等于 target,或右指针已经移到数组外
b) 如果 sum2 > target,左移左指针,直至 sum2 小于等于 target,或左指针已经移到数组外
c) 如果经过前面两步的左右移动使得 sum2 == target,则更新满足条件数组的最大长度,并 right++,让下一个元素进入窗口
- 循环结束后,若 len 的值有意义,则返回 【数组长度 - len】;否则返回 -1
代码实现:
java
class Solution {
public int minOperations(int[] nums, int x) {
int n = nums.length; //数组长度
int sum1 = 0; //记录整个数组的和
for (int i : nums) {
sum1 += i;
}
int left = 0;
int right = 0;
int len = 0; //统计最长子数组的长度(其中所有元素的和等于 sum1 - x)
int target = sum1 - x;
if (target < 0) { //如果数组中所有元素相加和都小于 x,说明没有满足条件的结果
return -1;
}
int sum2 = 0; //记录当前[left, right]数组中元素的和
while (right < n) {
sum2 += nums[right]; //进窗口
while (sum2 > target) { //判断
sum2 -= nums[left]; //出窗口
left++;
}
if (sum2 == target) { //更新结果
len = Math.max(len, right - left + 1);
}
right++;
}
if (len == 0) {
return -1;
}
return n - len;
}
}
时间复杂度:
空间复杂度:
5. 水果成篮
题目描述:
解法一:暴力枚举 + 哈希表(会超时)
代码实现:
java
class Solution {
public int totalFruit(int[] fruits) {
int n = fruits.length;
int kinds = 0; //记录 hash 数组中果树种类的个数
int len = 0; //记录最大采集果树数量
for (int i = 0; i < n; i++) {
int[] hash = new int[n + 1]; //存放果树种类,每次内层循环结束之后,需重置
kinds = 0; //内层循环结束之后,需要重置 hash 数组中果树种类的个数
for (int j = i; j < n; j++) {
if (hash[fruits[j]] == 0) { //若 hash 中 fruits[j] 处的值为 0 说明这是新种类的树
kinds++;
}
hash[fruits[j]]++; //标志当前种类的树已经出现几次
if (kinds > 2) {
break;
}
len = Math.max(len, j - i + 1);
}
}
return len;
}
}
解法二:滑动窗口
代码实现:
- 数组模拟哈希表
java
class Solution {
public int totalFruit(int[] fruits) {
int n = fruits.length;
//hash 的下标表示果树的种类,下标处的值表示该种类果树出现了几次
int[] hash = new int[n + 1]; //存放窗口内水果的种类(使用数组模拟哈希表)
int left = 0;
int right = 0;
int len = 0; //记录能采摘果树棵树的最大值
int kinds = 0; //记录窗口内水果种类个数
while (right < n) {
if (hash[fruits[right]] == 0) { //维护水果种类
kinds++; //若是 hash 中 fruits[right] 处的果树种类是第一次出现,那么当前窗口内的果树种类 +1
}
hash[fruits[right]]++; //进窗口(hash 中 fruits[right] 处的果树出现次数 +1)
while (kinds > 2) { //判断(当前窗口内果树种类超过 2 时)
hash[fruits[left]]--; //出窗口(将 hash 中 fruits[left] 下标种类的果树 出现次数 -1
if (hash[fruits[left]] == 0) { //当 hash 中 fruits[left] 下标种类的果树出现次数减为 0 后,当前窗口的果树种类 -1
kinds--;
}
left++; //left 右移,直到窗口内果树种类小于等于 2
}
len = Math.max(len, right -left + 1); //更新结果
right++;
}
return len;
}
}
- 使用容器(HashMap)
java
//时间复杂度:O(N)
//空间复杂度:O(N)
class Solution {
public int totalFruit(int[] fruits) {
Map<Integer, Integer> hash = new HashMap<Integer, Integer>(); //统计窗口内水果的种类
int len = 0;
int left = 0;
int right = 0;
for ( ; right < fruits.length; right++) {
int in = fruits[right];
hash.put(in, hash.getOrDefault(in, 0) + 1); //进窗口
while (hash.size() > 2) { //判断
int out = fruits[left];
hash.put(out, hash.get(out) - 1); //出窗口
if (hash.get(out) == 0) {
hash.remove(out);
}
left++;
}
len = Math.max(len, right - left + 1); //更新结果
}
return len;
}
}
6. 找到字符串中所有字母异位词
题目描述:
tip:如何快速判断两个字符串是否是"异位词"
利用哈希表,将两个字符串中字符分别放到两个哈希表中,判断其对应字符出现的次数是否相同
解法一:暴力枚举 + 哈希表
解法二:滑动窗口 + 哈希表
算法思路:
因为字符串 p 的异位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度与字符串 p 相同长度的滑动窗口,并在滑动中维护窗口中每种字母的数量
当窗口中每种字母的数量与字符串 p 中每种字母的数量相同时,则说明当前窗口为字符串1 p 的异位词
可以用两个大小为 26 的数组来模拟哈希表,一个用来保存 s 中的字串每个字符出现的个数,另一个来保存 p 中每一个字符出现的个数,对比两数组来判断两个串是否为异位词
代码实现:
java
//版本一:比较两个 hash 来确认是否是异位词
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> list = new ArrayList<>();
char[] arr1 = s.toCharArray();
char[] arr2 = p.toCharArray();
int n1 = arr1.length;
int n2 = arr2.length;
int[] hash1 = new int[26]; //使用数组模拟哈希表
int[] hash2 = new int[26];
int left = 0;
int right = 0;
//将 arr2 中元素通过减字符 a 的方式放到整型数组 hash2 中
for (int i = 0; i < n2; i++) {
hash2[arr2[i] - 'a']++;
}
//将 arr1 中 n2 长度的元素放到 hash1 中,判断两 hash 的异同
for ( ; right < n1; right++) {
int in = arr1[right] - 'a';
hash1[in]++; //进窗口
if (right - left + 1 > n2) { //判断
int out = arr1[left] - 'a'; //出窗口
hash1[out]--;
left++;
}
if (check(hash1, hash2)) { //判断两个 hash 的异同
list.add(left); //若相同,将该窗口中元素的起始下标添加到 List 中
}
}
return list;
}
private boolean check(int[] hash1, int[] hash2) {
//遍历两数组,若其中元素大小有差异,说明当前窗口内的子串不是字符串 p 的异位词
for (int i = 0; i < hash1.length; i++) {
if (hash1[i] != hash2[i]) {
return false;
}
}
return true;
}
}
方案二:利用变量 count 存储有效字符个数,看是否等于 p 的长度
java
class Solution {
public List<Integer> findAnagrams(String s, String p) {
List<Integer> list = new ArrayList<>();
char[] arr1 = s.toCharArray();
char[] arr2 = p.toCharArray();
int n1 = arr1.length;
int n2 = arr2.length;
int[] hash1 = new int[26]; //使用数组模拟哈希表
int[] hash2 = new int[26];
int left = 0;
int right = 0;
int count = 0; //记录有效字符个数
//将 arr2 中元素通过减字符 a 的方式放到整型数组 hash2 中
for (int i = 0; i < n2; i++) {
hash2[arr2[i] - 'a']++;
}
//将 arr1 中 n2 长度的元素放到 hash1 中,判断两 hash 的异同
for ( ; right < n1; right++) {
int in = arr1[right] - 'a';
hash1[in]++; //进窗口
//当进行完进窗口操作后,若 hash1 中当前位置的值小于等于 hash2 当前位置的值
//说明有效字符数量 +1
if (hash1[in] <= hash2[in]) {
count++;
}
if (right - left + 1 > n2) { //判断
int out = arr1[left] - 'a';
if (hash1[out] <= hash2[out]) {
count--;
}
//在出窗口之前若 hash1 当前下标处的值小于等于 hash2 当前下标处的值
//说明有效字符个数和 count 是对等的,若要执行下面出窗口,则 count 也应随之 -1
//反之若 hash1 当前下标处的值大于 hash2 当前下标处的值
//说明 hash1 此处下标的值为无效字符,不与 count 挂钩,因此 count 不做操作
hash1[out]--; //出窗口
left++;
}
if (count == n2) {
list.add(left);
}
}
return list;
}
}
7. 串联所有单词的字串
题目描述:
解法:滑动窗口 + 哈希表
算法思路:
因为 words 中所有字符串长度相同,我们可以将 words 字符数组中的字符串元素看成一个整体,也将字符串 s 按照 words 中一个字符串的长度拆分成若干份
这样就和上一题 "438.找到字符串中所有字母异位词" 相类似,解法也是差不多了,不同的地方有以下几点:
-
哈希表,上一题中使用的是一个数组模拟哈希表(因为其存储的是一个一个的字符),该题中存储的是一个一个字符串,因此需要借助一个容器(HashMap<String, int>,String 表示该字符串,int 表示该字符串出现的次数)实现
-
left 和 right 指针的移动
在该题中,两指针移动的步长是单词的长度,记为 len(如上图,从 b -> f,而不是从 b -> a),上图示例中的 len 为 3(就是 words 中一个字符串的长度)
- 滑动窗口执行的次数
滑动窗口循环执行的次数就是单词的长度,记为 len
代码实现:
java
class Solution {
public List<Integer> findSubstring(String s, String[] words) {
List<Integer> ret = new ArrayList<Integer>();
Map<String, Integer> hash1 = new HashMap<String, Integer>(); // 该 hash 存放 words 中所有字符串的频次
for (String str : words) {
// hash1.getOrDefault(str, 0) + 1 的意思是:
// 若 hash1 中已经存在 str,则用已经存在的次数 +1
// 否则 0 + 1
hash1.put(str, hash1.getOrDefault(str, 0) + 1);
}
int len = words[0].length();
int m = words.length;
for (int i = 0; i < len; i++) { // 执行次数
// 该 hash 保存窗口内所有单词的频次
Map<String, Integer> hash2 = new HashMap<String, Integer>();
//细节处理:每次 left 和 right 都要从 i 位置开始,否则每次都一样了
int left = i;
int right = i;
int count = 0;
//细节处理:right 的极限位置应该是末尾单词的起始位置,再往后就不是完整的词了,肯定不符合条件
//所以判断条件应设为 right + 单词长度小于等于 s 字符串的长度
for ( ; right + len <= s.length(); right += len) {
// 进窗口 + 维护 count
String in = s.substring(right, right + len); //用 in 表示要进窗口的单词
hash2.put(in, hash2.getOrDefault(in, 0) + 1); // 该操作意义和上边一样
//判断有效字符串个数
//if (hash2.get(in) <= hash1.get(in)) { //这样写是错误的
//上述写法若 in 在 hash1 中不存在就会报错
//下面正确写法,若存在则返回其出现次数,否则返回 0
//因为能进这个判断,所以 in 肯定不小于等于 0,所以该操作可行
if (hash2.get(in) <= hash1.getOrDefault(in, 0)) {
count++;
}
// 判断
if (right - left + 1 > len * m) { //若窗口大小 大于 (words 中一个单词长度乘以数组长度) 的话,出窗口
// 出窗口 + 维护 count
String out = s.substring(left, left + len); //用 out 表示要出窗口的单词
if (hash2.get(out) <= hash1.getOrDefault(out, 0)) { //若滑出窗口字符串为有效字符串,则 count--
count--;
}
hash2.put(out, hash2.get(out) - 1);
left += len;
}
//更新结果
if (count == m) { //有效单词个数 等于 words 中所有单词个数时
ret.add(left);
}
}
}
return ret;
}
}
tip:该题中所用方法
1) getOrDefault(主要是与 Map 接口相关的实现,如:HashMap、TreeMap等)
java
V getOrDefault(Object key, V defaultValue)
从 Java8 开始,该方法接受两个参数:第一个参数是你想要从 Map 中获取的键,第二个参数是如果该键不存在时应该返回的默认值
2) get(Map 接口中的方法)
java
V get(Object key)
用于根据给定的键(key)检索对应的值(value),如果 Map 中包含该键的映射,则返回对应的值;否则返回 nul
需注意:如果 Map 中的值本身就是 null,那么 get 方法也会返回 null
3) substring(String 类中方法)
用于从字符串中提取子字符串,有以下两种常见用法
- 使用起始索引:
java
String str = "Hello, World!";
String subStr = str.substring(7); // 从索引7开始提取,直到字符串结束
System.out.println(subStr); // 输出: World!
- 使用起始索引和结束索引(左闭右开区间)
java
String str = "Hello, World!";
String subStr = str.substring(7, 12); // 从索引7开始提取,到索引12结束(不包括索引12处的字符)
System.out.println(subStr); // 输出: World
8. 最小覆盖字串
题目描述:
解法一:暴力枚举 + 哈希表
在字符串 s 中枚举出所有符合条件(hash1 中出现所有有效字符且次数大于等于 hash2)的子串
取其中最短子串即为最小子串
解法二:滑动窗口 + 哈希表
算法思路:
上述情况中,当 left 左移一个位置后,right 会出现两种情况:
-
left 左移之后,依旧符合要求(比如:left 原来的位置是一个无效字符或重复的有效字符),此时 right 可以不动
-
left 左移之后,不符合要求(比如:left 原来的位置就是当前窗口中不重复的一个有效字符),此时 right 就需要右移继续寻找下一个满足条件的位置
像这种 left 和 right 只前进不回退,有单调性的题可以使用【滑动窗口】
算法流程:
代码优化:
上面 check 判断两 hash 是否相同会遍历一遍 hash1,还会遍历一遍 hash2,时间复杂度是非常高的,因此有以下优化:
使用变量 count 标记有效字符的种类(与上面 "6. 找到字符串中所有字母异位词" 中的 count 标记有效字符的个数不一样)
若是标记有效字符的个数的话,会出现:
因此需标记有效字符出现的种类,且需判断该种类出现的次数:
具体步骤:
代码实现:
java
class Solution {
public String minWindow(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
int[] hash2 = new int[128]; //存储 t 中字符
int kinds = 0; //统计 t 中字符种类
for (char a : tt) {
if (hash2[a] == 0) { //若该种类字符第一次出现,则种类++
kinds++;
}
hash2[a]++; //将 t 中各字符出现次数统计到 hash2 中
}
int left = 0;
int right = 0;
int[] hash1 = new int[128]; //存储 [left, right] 中的字符
int count = 0; //统计 hash1 中有效字符种类
int begin = -1; //记录符合条件子串的起始下标
int minLen = Integer.MAX_VALUE;
for ( ; right < ss.length; right++) {
char in = ss[right];
hash1[in]++; //进窗口
if (hash1[in] == hash2[in]) { //字符相同,且出现次数相同,则种类++
count++;
}
while (count == kinds) { //判断 hash1 中有效字符个数 count 是否等于 kinds
if (right - left + 1 < minLen) { //判断当前窗口中子串长度是否小于已记录的子串长度
minLen = right - left + 1; //若条件成立,则更新子串最短长度及起始位置
begin = left;
}
char out = ss[left];
//在出窗口之前,先判断当前 hash1 和 hash2 的 left 下标出值是否相同
//若相同,则count--(这是因为在出窗口之后,有效字符的种类 -1
if (hash1[out] == hash2[out]) {
count--;
}
hash1[out]--; //left 坐标处的字符种类出现次数 -1
left++; //出窗口
}
}
if (begin == -1) { //若此时起始位置并未改变过,证明字符串 s 中并没有符合要求的子串
return new String();
} else { //若有符合要求的子串,则输出[begin, begin + minLen) 的子串
return s.substring(begin, begin + minLen);
}
}
}