LeetCode100天Day9-无重复字符的最长子串与赎金信:滑动窗口与计数数组
摘要:本文详细解析了LeetCode中两道经典题目------"无重复字符的最长子串"和"赎金信"。通过滑动窗口算法查找最长子串,以及使用计数数组统计字符频率,帮助读者掌握字符串处理的高效技巧。
目录
文章目录
- LeetCode100天Day9-无重复字符的最长子串与赎金信:滑动窗口与计数数组
-
- 目录
- [1. 无重复字符的最长子串(Longest Substring Without Repeating Characters)](#1. 无重复字符的最长子串(Longest Substring Without Repeating Characters))
-
- [1.1 题目描述](#1.1 题目描述)
- [1.2 解题思路](#1.2 解题思路)
- [1.3 代码实现](#1.3 代码实现)
- [1.4 代码逐行解释](#1.4 代码逐行解释)
- [1.5 执行流程详解](#1.5 执行流程详解)
- [1.6 滑动窗口图解](#1.6 滑动窗口图解)
- [1.7 复杂度分析](#1.7 复杂度分析)
- [1.8 边界情况](#1.8 边界情况)
- [2. 赎金信(Ransom Note)](#2. 赎金信(Ransom Note))
-
- [2.1 题目描述](#2.1 题目描述)
- [2.2 解题思路](#2.2 解题思路)
- [2.3 代码实现](#2.3 代码实现)
- [2.4 代码逐行解释](#2.4 代码逐行解释)
- [2.5 执行流程详解](#2.5 执行流程详解)
- [2.6 算法图解](#2.6 算法图解)
- [2.7 复杂度分析](#2.7 复杂度分析)
- [2.8 边界情况](#2.8 边界情况)
- [3. 两题对比与总结](#3. 两题对比与总结)
-
- [3.1 算法对比](#3.1 算法对比)
- [3.2 滑动窗口模板](#3.2 滑动窗口模板)
- [3.3 计数数组应用](#3.3 计数数组应用)
- [3.4 HashMap vs 数组](#3.4 HashMap vs 数组)
- [4. 总结](#4. 总结)
- 参考资源
- 文章标签
1. 无重复字符的最长子串(Longest Substring Without Repeating Characters)
1.1 题目描述
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
注意 "bca" 和 "cab" 也是正确答案。
示例 2:
输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
1.2 解题思路
这道题使用滑动窗口算法,核心思想是:
- 维护一个窗口,窗口内没有重复字符
- 使用HashMap记录每个字符最后出现的位置
- 遇到重复字符时,移动窗口左边界
- 不断更新最大长度
解题步骤:
- 创建HashMap记录字符位置
- 使用左右指针维护滑动窗口
- 遍历字符串,检查字符是否重复
- 如果重复,移动左指针到重复字符的下一个位置
- 更新最大长度
1.3 代码实现
java
class Solution {
public int lengthOfLongestSubstring(String s) {
int len = s.length();
int ans = 0;
int left = 0;
Map<Character, Integer> map = new HashMap<>();
for(int i = 0; i < len; i++){
char temp = s.charAt(i);
// 检查字符是否在窗口内重复
if(map.containsKey(temp) && map.get(temp) >= left){
left = map.get(temp) + 1;
}
// 更新字符位置
map.put(temp, i);
// 更新最大长度
ans = Math.max(ans, i - left + 1);
}
return ans;
}
}
1.4 代码逐行解释
第一部分:变量初始化
java
int len = s.length(); // 字符串长度
int ans = 0; // 最长子串长度
int left = 0; // 滑动窗口左边界
Map<Character, Integer> map = new HashMap<>(); // 记录字符位置
变量作用:
| 变量 | 类型 | 作用 |
|---|---|---|
len |
int | 字符串长度 |
ans |
int | 记录最大长度 |
left |
int | 滑动窗口左边界 |
map |
Map | 记录每个字符最后出现的位置 |
第二部分:遍历字符串
java
for(int i = 0; i < len; i++){
char temp = s.charAt(i);
// 检查重复并更新窗口
if(map.containsKey(temp) && map.get(temp) >= left){
left = map.get(temp) + 1;
}
// 更新字符位置
map.put(temp, i);
// 更新最大长度
ans = Math.max(ans, i - left + 1);
}
第三部分:重复检查逻辑
java
if(map.containsKey(temp) && map.get(temp) >= left){
left = map.get(temp) + 1;
}
为什么要两个条件:
| 条件 | 作用 |
|---|---|
map.containsKey(temp) |
检查字符是否出现过 |
map.get(temp) >= left |
检查字符是否在当前窗口内 |
示例:
字符串: "abcabcbb"
第1次遍历:
i=0, temp='a'
map不包含'a'
map = {'a': 0}
ans = max(0, 0-0+1) = 1
第2次遍历:
i=1, temp='b'
map不包含'b'
map = {'a': 0, 'b': 1}
ans = max(1, 1-0+1) = 2
第3次遍历:
i=2, temp='c'
map不包含'c'
map = {'a': 0, 'b': 1, 'c': 2}
ans = max(2, 2-0+1) = 3
第4次遍历:
i=3, temp='a'
map包含'a',位置0
0 >= left(0)? 是
left = 0 + 1 = 1 ← 移动左边界
map = {'a': 3, 'b': 1, 'c': 2}
ans = max(3, 3-1+1) = 3
...继续遍历
1.5 执行流程详解
示例1 :s = "abcabcbb"
i=0, temp='a':
map不包含'a'
map = {'a': 0}, left=0
ans = max(0, 0-0+1) = 1
窗口: [a]
i=1, temp='b':
map不包含'b'
map = {'a': 0, 'b': 1}, left=0
ans = max(1, 1-0+1) = 2
窗口: [a, b]
i=2, temp='c':
map不包含'c'
map = {'a': 0, 'b': 1, 'c': 2}, left=0
ans = max(2, 2-0+1) = 3
窗口: [a, b, c]
i=3, temp='a':
map包含'a',位置0 >= left(0)
left = 0 + 1 = 1
map = {'a': 3, 'b': 1, 'c': 2}
ans = max(3, 3-1+1) = 3
窗口: [b, c, a]
i=4, temp='b':
map包含'b',位置1 >= left(1)
left = 1 + 1 = 2
map = {'a': 3, 'b': 4, 'c': 2}
ans = max(3, 4-2+1) = 3
窗口: [c, a, b]
i=5, temp='c':
map包含'c',位置2 >= left(2)
left = 2 + 1 = 3
map = {'a': 3, 'b': 4, 'c': 5}
ans = max(3, 5-3+1) = 3
窗口: [a, b, c]
i=6, temp='b':
map包含'b',位置4 >= left(3)
left = 4 + 1 = 5
map = {'a': 3, 'b': 6, 'c': 5}
ans = max(3, 6-5+1) = 3
窗口: [c, b]
i=7, temp='b':
map包含'b',位置6 >= left(5)
left = 6 + 1 = 7
map = {'a': 3, 'b': 7, 'c': 5}
ans = max(3, 7-7+1) = 3
窗口: [b]
最终输出: 3
1.6 滑动窗口图解
字符串: a b c a b c b b
索引: 0 1 2 3 4 5 6 7
步骤1: i=0
窗口: [a]
↑
left=0
ans=1
步骤2: i=1
窗口: [a b]
↑ ↑
left=0
ans=2
步骤3: i=2
窗口: [a b c]
↑ ↑
left=0
ans=3
步骤4: i=3, 遇到重复的'a'
窗口: [b c a]
↑ ↑
left=1
ans=3
步骤5: i=4, 遇到重复的'b'
窗口: [c a b]
↑ ↑
left=2
ans=3
1.7 复杂度分析
| 分析维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(n) | 每个字符最多访问两次 |
| 空间复杂度 | O(min(m,n)) | m是字符集大小,n是字符串长度 |
1.8 边界情况
| 输入 | 说明 | 输出 |
|---|---|---|
"" |
空字符串 | 0 |
"a" |
单个字符 | 1 |
"aa" |
全部重复 | 1 |
"ab" |
无重复 | 2 |
2. 赎金信(Ransom Note)
2.1 题目描述
给你两个字符串:ransomNote 和 magazine,判断 ransomNote 能不能由 magazine 里面的字符构成。
如果可以,返回 true;否则返回 false。
magazine 中的每个字符只能在 ransomNote 中使用一次。
示例 1:
输入:ransomNote = "a", magazine = "b"
输出:false
示例 2:
输入:ransomNote = "aa", magazine = "ab"
输出:false
示例 3:
输入:ransomNote = "aa", magazine = "aab"
输出:true
2.2 解题思路
这道题的核心是统计字符频率:
- 统计magazine中每个字符出现的次数
- 遍历ransomNote,对每个字符减1
- 如果某个字符的计数变为负数,说明magazine中字符不够
解题步骤:
- 创建长度为26的计数数组(假设只有小写字母)
- 遍历magazine,统计每个字符的出现次数
- 遍历ransomNote,对每个字符减1
- 如果计数变为负数,返回false
- 遍历结束,返回true
2.3 代码实现
java
class Solution {
public boolean canConstruct(String ransomNote, String magazine) {
int[] cnt = new int[26];
// 统计magazine中的字符
for(int i = 0; i < magazine.length(); i++){
cnt[magazine.charAt(i) - 'a']++;
}
// 检查ransomNote
for(int i = 0; i < ransomNote.length(); i++){
cnt[ransomNote.charAt(i) - 'a']--;
if(cnt[ransomNote.charAt(i) - 'a'] < 0){
return false;
}
}
return true;
}
}
2.4 代码逐行解释
第一部分:创建计数数组
java
int[] cnt = new int[26];
数组结构:
索引: 0 1 2 3 ... 25
字符: a b c d ... z
cnt[0] 记录 'a' 的数量
cnt[1] 记录 'b' 的数量
cnt[2] 记录 'c' 的数量
...
cnt[25] 记录 'z' 的数量
第二部分:统计magazine字符
java
for(int i = 0; i < magazine.length(); i++){
cnt[magazine.charAt(i) - 'a']++;
}
字符到索引的转换:
java
char c = 'a';
int index = c - 'a'; // 97 - 97 = 0
char c = 'b';
int index = c - 'a'; // 98 - 97 = 1
char c = 'z';
int index = c - 'a'; // 122 - 97 = 25
示例:
magazine = "aab"
i=0: magazine.charAt(0) = 'a'
cnt['a' - 'a'] = cnt[0]++
cnt = [1, 0, 0, ..., 0]
i=1: magazine.charAt(1) = 'a'
cnt[0]++
cnt = [2, 0, 0, ..., 0]
i=2: magazine.charAt(2) = 'b'
cnt['b' - 'a'] = cnt[1]++
cnt = [2, 1, 0, ..., 0]
第三部分:检查ransomNote
java
for(int i = 0; i < ransomNote.length(); i++){
cnt[ransomNote.charAt(i) - 'a']--;
if(cnt[ransomNote.charAt(i) - 'a'] < 0){
return false;
}
}
检查逻辑:
magazine统计后: cnt = [2, 1, 0, ...]
a b
ransomNote = "aa"
i=0: ransomNote.charAt(0) = 'a'
cnt[0]-- → cnt[0] = 1
cnt[0] < 0? 否,继续
i=1: ransomNote.charAt(1) = 'a'
cnt[0]-- → cnt[0] = 0
cnt[0] < 0? 否,继续
循环结束,返回 true
2.5 执行流程详解
示例1 :ransomNote = "a", magazine = "b"
步骤1:统计magazine
magazine = "b"
cnt['b' - 'a']++ → cnt[1]++
cnt = [0, 1, 0, 0, ..., 0]
步骤2:检查ransomNote
ransomNote = "a"
cnt['a' - 'a']-- → cnt[0]--
cnt[0] = -1
-1 < 0? 是,返回 false
输出: false
示例2 :ransomNote = "aa", magazine = "ab"
步骤1:统计magazine
magazine = "ab"
cnt[0]++ → cnt = [1, 0, 0, ...]
cnt[1]++ → cnt = [1, 1, 0, ...]
步骤2:检查ransomNote
ransomNote = "aa"
i=0: cnt[0]-- → cnt[0] = 0
i=1: cnt[0]-- → cnt[0] = -1
-1 < 0? 是,返回 false
输出: false
示例3 :ransomNote = "aa", magazine = "aab"
步骤1:统计magazine
magazine = "aab"
cnt[0]++ → cnt = [1, 0, 0, ...]
cnt[0]++ → cnt = [2, 0, 0, ...]
cnt[1]++ → cnt = [2, 1, 0, ...]
步骤2:检查ransomNote
ransomNote = "aa"
i=0: cnt[0]-- → cnt[0] = 1
i=1: cnt[0]-- → cnt[0] = 0
循环结束,返回 true
输出: true
2.6 算法图解
magazine = "aab"
ransomNote = "aa"
步骤1:统计magazine
a a b
↓ ↓ ↓
cnt: [2, 1, 0, 0, ..., 0]
a b
步骤2:消耗ransomNote
a a
↓ ↓
cnt: [0, 1, 0, 0, ..., 0]
a b
结果:所有cnt都 >= 0,返回 true
2.7 复杂度分析
| 分析维度 | 复杂度 | 说明 |
|---|---|---|
| 时间复杂度 | O(m + n) | m是magazine长度,n是ransomNote长度 |
| 空间复杂度 | O(1) | 固定大小的数组(26) |
2.8 边界情况
| ransomNote | magazine | 说明 | 输出 |
|---|---|---|---|
"a" |
"a" |
刚好够 | true |
"a" |
"b" |
字符不匹配 | false |
"aa" |
"a" |
数量不够 | false |
"" |
"a" |
空字符串 | true |
3. 两题对比与总结
3.1 算法对比
| 对比项 | 无重复字符的最长子串 | 赎金信 |
|---|---|---|
| 核心算法 | 滑动窗口 | 计数数组 |
| 数据结构 | HashMap | 数组 |
| 时间复杂度 | O(n) | O(m + n) |
| 空间复杂度 | O(min(m,n)) | O(1) |
| 应用场景 | 查找最长子串 | 字符匹配 |
3.2 滑动窗口模板
java
// 滑动窗口标准模板
int left = 0;
int max = 0;
for(int right = 0; right < length; right++){
// 1. 将right指向的元素加入窗口
// 2. 如果窗口不满足条件,移动left
while(窗口不满足条件){
// 移除left指向的元素
left++;
}
// 3. 更新结果
max = Math.max(max, right - left + 1);
}
return max;
3.3 计数数组应用
何时使用计数数组:
- 字符范围有限(如26个小写字母)
- 需要统计字符频率
- 需要比较字符数量
计数数组的优点:
- 空间复杂度O(1)
- 访问速度快O(1)
- 实现简单
3.4 HashMap vs 数组
| 对比项 | HashMap | 数组 |
|---|---|---|
| 空间复杂度 | O(n) | O(1)固定大小 |
| 访问速度 | O(1)平均 | O(1) |
| 适用范围 | 任意字符 | 有限字符集 |
| 灵活性 | 高 | 低 |
4. 总结
今天我们学习了两道字符串处理题目:
- 无重复字符的最长子串:掌握滑动窗口算法,理解HashMap在记录字符位置中的作用
- 赎金信:掌握计数数组的使用,理解字符频率统计的方法
核心收获:
- 滑动窗口是解决子串问题的利器
- HashMap可以记录字符位置,辅助窗口移动
- 计数数组适合有限字符集的频率统计
- 字符与数字的转换是常用技巧
练习建议:
- 尝试用数组代替HashMap解决最长子串问题(假设字符集是ASCII)
- 思考如何找到最长无重复子串本身,而不是长度
- 尝试用HashMap解决赎金信问题(不限制字符集)
参考资源
文章标签
#LeetCode #算法 #Java #字符串 #滑动窗口
喜欢这篇文章吗?别忘了点赞、收藏和分享!你的支持是我创作的最大动力!