2.26-哈希、滑窗
1. 两数之和 - 力扣(LeetCode)
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> idx; // 创建一个空哈希表
for (int j = 0; ; j++) { // 枚举 j
// 在左边找 nums[i],满足 nums[i]+nums[j]=target
auto it = idx.find(target - nums[j]);
if (it != idx.end()) { // 找到了
return {it->second, j}; // 返回两个数的下标
}
idx[nums[j]] = j; // 保存 nums[j] 和 j
}
}
};
复杂度分析
- 时间复杂度:O(n),其中 n 为 nums 的长度。
- 空间复杂度:O(n)。哈希表需要 O(n) 的空间。
很多涉及到「两个变量」的题目,都可以枚举其中一个变量,把它当成常量看待,从而转换成「一个变量」的问题。代码实现时,通常来说「枚举右,寻找左」是更加好写的。
相关题:
1512. 好数对的数目
哈希表idx中key为值nums[j] ,value为出现的个数,答案res每次加上j左边与nums[j]相同的元素个数。
class Solution { public: int numIdenticalPairs(vector<int>& nums) { unordered_map<int , int> idx; int res = 0; for(int j = 0 ; j < nums.size(); j ++){ auto it = idx.find(nums[j]); if(it != idx.end()) res += it->second; idx[nums[j]]++; } return res; } };灵神版的简洁代码:
class Solution { public: int numIdenticalPairs(vector<int>& nums) { int ans = 0; unordered_map<int, int> cnt; for (int x : nums) { ans += cnt[x]++; } return ans; } };
219.存在重复元素 II
法一:哈希表
在遍历 nums 的同时,用一个哈希表 idx 记录每个数 x 上一次(最后一次)出现的位置(下标)idx[x]。
class Solution { public: bool containsNearbyDuplicate(vector<int>& nums, int k) { unordered_map<int , int> idx; for (int j = 0 ; j < nums.size() ; j ++){ auto it = idx.find(nums[j]); if(it != idx.end() && (j - it->second) <= k) return true; idx[nums[j]] = j; } return false; } };法二:定长滑动窗口
问题等价于:判断 nums 是否存在一个长为 min(k+1,n) 的连续子数组,包含相同元素。
思路:维护一个长为
min(k+1,n)的滑动窗口,用哈希集合维护窗口内的元素。在元素 x 进入窗口之前,判断 x 是否在哈希集合中,如果在,则说明把 x 加入窗口之后,窗口内有重复元素。具体思路:
创建一个空的哈希集合。
遍历 nums。
先判断 x=nums[i] 是否在哈希集合中,如果在,返回 true。
如果不在,把 x 加到哈希集合中。
如果 i≥k,那么下一轮循环 nums[i−k] 不在窗口中,将其移出哈希集合。
代码:
class Solution { public: bool containsNearbyDuplicate(vector<int>& nums, int k) { unordered_set<int> st; for (int i = 0; i < nums.size(); i++) { if (!st.insert(nums[i]).second) { // st 中有 nums[i],返回false return true; } if (i >= k) { st.erase(nums[i - k]); } } return false; } };代码
if (!st.insert(nums[i]).second)的解释:
st.insert(nums[i])的返回值
st是一个unordered_set<int>,unordered_set是C++标准库中的一个容器,用于存储唯一的元素。
insert方法尝试将元素nums[i]插入集合st。
insert方法返回一个pair,格式为:pair<unordered_set<int>::iterator, bool> pair
pair.first:指向插入的元素(或已存在的元素)的迭代器。pair.second:一个布尔值,表示插入是否成功。如果元素已存在,则返回false,否则返回true。
if (!st.insert(nums[i]).second)的逻辑
- 这行代码检查
insert操作的.second是否为false。- 如果
pair.second为false,说明nums[i]已经存在于集合st中,因此当前元素nums[i]是重复的,并且距离前一个重复元素不超过k(由后续逻辑维护)。- 如果
pair.second为true,说明nums[i]是首次插入集合,没有重复。
1010.总持续时间可被 60 整除的歌曲
借鉴 1. 两数之和 的思路,遍历数组的同时用一个哈希表(或者数组)记录元素的出现次数。
遍历 time:
举例,如果 time[i]=1,那么需要知道左边有多少个模 60 是 59 的数。
举例,如果 time[i]=62,那么需要知道左边有多少个模 60 是 58 的数。
一般地,对于 time[i],需要知道
左边有多少个模 60 是 60−time[i]mod60的数。特别地,如果 time[i] 模 60 是 0,那么需要知道
左边有多少个模 60 也是 0 的数。这两种情况可以合并为:累加左边
(60−time[i]mod60)mod60的出现次数。代码实现时,用一个长为 60 的数组 cnt 维护
time[i]mod60的出现次数(用哈希表也是一样的)。class Solution { public: int numPairsDivisibleBy60(vector<int>& time) { int ans = 0 , cnt[60]{}; for (int t : time){ ans += cnt[(60 - t % 60) % 60]; cnt[t % 60]++; } return ans; } };
2748.美丽下标对的数目
枚举
x=nums[j],我们需要知道有多少个 nums[i],满足 i<j 且 nums[i] 的最高位与 xmod10 互质。需要直接枚举 nums[i] 吗?有没有更快的做法?
由于 nums[i] 的最高位在 [1,9] 中,我们可以在遍历数组的同时,统计最高位的出现次数,这样就只需枚举 [1,9] 中的与 xmod10 互质的数,把对应的出现次数加到答案中。
具体算法如下:
初始化答案 ans=0,初始化长为 10 的 cnt 数组,初始值均为 0。
遍历 nums,设 x=nums[j]。
枚举 [1,9] 内的数字 y,如果与 xmod10 互质,则 ans 增加 cnt[y]。
计算 x 的最高位,将其出现次数加一。
返回 ans。
class Solution { public: int countBeautifulPairs(vector<int>& nums) { int ans = 0 , cnt[10]{}; for (int x : nums){ for(int y = 1 ; y < 10 ; y ++){ if(cnt[y] && gcd(y , x % 10) == 1) ans += cnt[y]; } while (x >= 10) x /= 10; cnt[x] ++;// 统计最高位的出现次数 } return ans; } };第一次循环计算nums[0]时,由于cnt[]全为0,因此第一轮直接跳过,将x的最高位提取出来,加到cnt数组中。
49. 字母异位词分组 - 力扣(LeetCode)
思路:
注意到,如果把aab,aba,baa按照字母从小到大排序,我们可以得到同一个字符串aab。而对于每种字母出现次数不同于
aab的字符串,例如abb和bab,排序后为abb,不等于aab。所以当且仅当两个字符串排序后一样,这两个字符串才能分到同一组。
根据这一点,我们可以用哈希表来分组,把排序后的字符串当作 key,原字符串组成的列表(即答案)当作 value。
最后把所有 value 加到一个列表中返回。
- 定义unordered_map<string,vector
> map; - 遍历strs数组,将每个str作为key,进行排序,相同的key后面存储str(如“eat”“tea”)全部存到map[aet]里。
- 最后用迭代器遍历map,输出答案即可。
class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string , vector<string>> map; for(string str:strs){ string key = str; sort(key.begin() , key.end()); map[key].emplace_back(str); } vector<vector<string>> ans; for(auto it = map.begin() ; it != map.end(); it ++) ans.emplace_back(it->second); return ans; } };复杂度分析
- 时间复杂度:
O(nmlogm),其中 n 为 strs 的长度,m 为 strs[i] 的长度。每个字符串排序需要O(mlogm)的时间。我们有 n 个字符串,所以总的时间复杂度为O(nmlogm)。- 空间复杂度:
O(nm)。
128. 最长连续序列 - 力扣(LeetCode)
首先,本题是不能排序的,因为排序的时间复杂度是
O(nlogn),不符合题目O(n)的要求。核心思路:对于 nums 中的元素 x,以 x 为起点,不断查找下一个数 x+1,x+2,⋯ 是否在 nums 中,并统计序列的长度。
为了做到 O(n) 的时间复杂度,需要两个关键优化:
- 把 nums 中的数都放入一个哈希集合中,这样可以 O(1) 判断数字是否在 nums 中。
- 如果 x−1 在哈希集合中,则不以 x 为起点。为什么?因为以 x−1 为起点计算出的序列长度,一定比以 x 为起点计算出的序列长度要长!这样可以避免大量重复计算。比如 nums=[3,2,4,5],从 3 开始,我们可以找到 3,4,5 这个连续序列;而从 2 开始,我们可以找到 2,3,4,5 这个连续序列,一定比从 3 开始的序列更长。
⚠注意:遍历元素的时候,要遍历哈希集合,而不是 nums!如果 nums=[1,1,1,…,1,2,3,4,5,…](前一半都是 1),遍历 nums 的做法会导致每个 1 都跑一个 O(n) 的循环,总的循环次数是 \(O(n ^2 )\),会超时。
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
int ans = 0;
unordered_set<int> st(nums.begin(), nums.end()); // 把 nums 转成哈希集合,去重
for (int x : st) { // 遍历哈希集合
if (st.contains(x - 1)) {
continue;
}//不含x-1,从x开始
// x 是序列的起点
int y = x + 1;
while (st.contains(y)) { // 不断查找下一个数是否在哈希集合中
y++;
}
// 循环跳出条件是不含y,则循环结束后,y-1 是最后一个在哈希集合中的数
ans = max(ans, y - x); // 从 x 到 y-1 一共 y-x 个数
}
return ans;
}
};
复杂度分析
- 时间复杂度:O(n),其中 n 是 nums 的长度。在二重循环中,每个元素至多遍历两次:在外层循环中遍历一次,在内层循环中遍历一次。所以二重循环的时间复杂度是 O(n) 的。比如 nums=[1,2,3,4],其中 2,3,4 不会进入内层循环,只有 1 会进入内层循环。
- 空间复杂度:O(n)。
unordered_set和unordered_map是 C++ 标准库中基于哈希表实现的关联容器,它们有一些相似之处,但也存在一些区别:1. 存储的数据类型
unordered_set: 存储唯一的、不可重复的元素。每个元素都是一个单独的值。unordered_map: 存储键值对(key-value),键是唯一的,但值可以重复。2. 适用场景
unordered_set: 当需要快速查找、插入、删除元素,但不需要保存额外信息时使用。例如,筛选重复元素、维护一个不重复的集合(本题)等。unordered_map: 当需要通过键快速访问对应的值时使用(作映射)。例如,缓存(缓存键到值的映射)、字典(单词到定义的映射)等。3. 内部存储结构
unordered_set: 内部使用哈希表(hash table)存储元素,每个哈希桶中存储一个元素。unordered_map: 内部同样使用哈希表,但每个哈希桶中存储的是键值对(key-value pair)。4. 操作与方法
unordered_set:
- 插入操作:
insert(value)。- 查找操作:
find(value),返回一个迭代器或指向该元素的指针。- 删除操作:
erase(value),删除指定的元素。unordered_map:
- 插入操作:
insert({key, value})。- 查找操作:
find(key),返回一个指向键值对的迭代器。- 删除操作:
erase(key),删除对应的键值对。- 可通过
operator[]访问值:map[key]。5. 性能与问题
- 哈希冲突:两者都可能遇到哈希冲突问题。冲突时,性能会下降,因为在哈希桶中可能存储多个元素(通过链表或开放寻址法处理冲突),导致查找、插入或删除的时间复杂度增加到接近
O(n)的最坏情况。- 时间复杂度:
- 平均情况下:插入、查找、删除操作的时间复杂度为
O(1)。- 最坏情况下:当哈希冲突严重时,操作时间复杂度可能退化为
O(n)。总结
unordered_set用于存储唯一的值,适用于需要快速判断元素是否存在的情况。unordered_map用于存储键值对,适用于需要根据键快速查找对应值的情况。
3. 无重复字符的最长子串 - 力扣(LeetCode)
法一:哈希(整形数组)
class Solution { public: int lengthOfLongestSubstring(string s) { int n = s.length(), ans = 0, left = 0; unordered_map<char, int> cnt; // 维护从下标 left 到下标 right 的字符 for (int right = 0; right < n; right++) { char c = s[right]; cnt[c]++; while (cnt[c] > 1) { // 窗口内有重复字母 cnt[s[left]]--; // 移除窗口左端点字母 left++; // 缩小窗口 } ans = max(ans, right - left + 1); // 更新窗口长度最大值 } return ans; } };写法二:哈希集合(布尔数组)
class Solution { public: int lengthOfLongestSubstring(string s) { int n = s.length(), ans = 0, left = 0; unordered_set<char> window; // 维护从下标 left 到下标 right 的字符 for (int right = 0; right < n; right++) { char c = s[right]; // 如果窗口内已经包含 c,那么再加入一个 c 会导致窗口内有重复元素 // 所以要在加入 c 之前,先移出窗口内的 c while (window.contains(c)) { // 窗口内有 c window.erase(s[left]); left++; // 缩小窗口 } window.insert(c); // 加入 c ans = max(ans, right - left + 1); // 更新窗口长度最大值 } return ans; } };复杂度分析
- 时间复杂度:O(n),其中 n 为 s 的长度。注意 left 至多增加 n 次,所以整个二重循环至多循环 O(n) 次。
- 空间复杂度:O(∣Σ∣),其中 ∣Σ∣ 为字符集合的大小,本题中字符均为 ASCII 字符,所以 ∣Σ∣≤128。
438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
核心思路
设 n 是 p 的长度。本题有两种做法:
- 定长滑窗。枚举 s 的所有长为 n 的子串 s ′ ,如果 s ′ 的每种字母的出现次数,和 p 的每种字母的出现次数都相同,那么 s ′ 是 p 的异位词。
- 不定长滑窗。枚举子串 s ′ 的右端点,如果发现 s ′ 其中一种字母的出现次数大于 p 的这种字母的出现次数,则右移 s ′ 的左端点。如果发现 s ′ 的长度等于 p 的长度,则说明 s ′ 的每种字母的出现次数,和 p 的每种字母的出现次数都相同(如果出现次数 s ′ 的小于 p 的,不可能长度一样),那么 s ′ 是 p 的异位词。
方法一:定长滑窗
本题维护长为 n 的子串 s ′ 的每种字母的出现次数。如果 s ′ 的每种字母的出现次数,和 p 的每种字母的出现次数都相同,那么 s ′ 是 p 的异位词,把 s ′ 左端点下标加入答案。
class Solution { public: vector<int> findAnagrams(string s, string p) { vector<int> ans; array<int, 26> cnt_p{}; // 统计 p 的每种字母的出现次数 array<int, 26> cnt_s{}; // 统计 s 的长为 p.length() 的子串 s' 的每种字母的出现次数 for (char c : p) { cnt_p[c - 'a']++; } for (int right = 0; right < s.length(); right++) { cnt_s[s[right] - 'a']++; // 右端点字母进入窗口 int left = right - p.length() + 1; if (left < 0) { // 窗口长度不足 p.length() continue; } if (cnt_s == cnt_p) { // s' 和 p 的每种字母的出现次数都相同 ans.push_back(left); // s' 左端点下标加入答案 } cnt_s[s[left] - 'a']--; // 左端点字母离开窗口 } return ans; } };方法二:不定长滑窗
枚举子串 s ′ 的右端点,如果发现 s ′ 其中一种字母的出现次数大于 p 的这种字母的出现次数,则右移 s ′ 的左端点。如果发现 s ′ 的长度等于 p 的长度,则说明 s ′ 的每种字母的出现次数,和 p 的每种字母的出现次数都相同,那么 s ′ 是 p 的异位词。
证明:内层循环结束后,s ′ 的每种字母的出现次数,都小于等于 p 的每种字母的出现次数。如果 s ′ 的其中一种字母的出现次数比 p 的小,那么 s ′ 的长度必然小于 p 的长度。所以只要 s ′ 的长度等于 p 的长度,就说明 s ′ 的每种字母的出现次数,和 p 的每种字母的出现次数都相同,s ′ 是 p 的异位词,把 s ′ 左端点下标加入答案。
代码实现时,可以把 cntS 和 cntP 合并成一个 cnt:对于 p 的字母 c,把 cnt[p] 加一。
对于 s ′ 的字母 c,把 cnt[c] 减一。如果 cnt[c]<0,说明窗口中的字母 c 的个数比 p 的多,右移左端点。问:为什么只需判断字母 c 的出现次数?
答:字母 c 进入窗口后,如果导致 cnt[c]<0,由于其余字母的出现次数没有变化,所以有且仅有字母 c 的个数比 p 的多。
class Solution { public: vector<int> findAnagrams(string s, string p) { vector<int> ans; int cnt[26]{}; // 统计 p 的每种字母的出现次数 for (char c : p) { cnt[c - 'a']++; } int left = 0; for (int right = 0; right < s.size(); right++) { char c = s[right] - 'a'; cnt[c]--; // 右端点字母进入窗口 while (cnt[c] < 0) { // 字母 c 太多了 //如果s[right] 是p中没有的,则left也会平移到right位置,一起跳过该字符 cnt[s[left] - 'a']++; // 左端点字母离开窗口 left++; } if (right - left + 1 == p.length()) { // s' 和 p 的每种字母的出现次数都相同 ans.push_back(left); // s' 左端点下标加入答案 } } return ans; } };复杂度分析
- 时间复杂度:O(m+n),其中 m 是 s 的长度,n 是 p 的长度。虽然写了个二重循环,但是内层循环中对 left 加一的总执行次数不会超过 m 次,所以滑窗的时间复杂度为 O(m)。
- 空间复杂度:O(∣Σ∣),其中 ∣Σ∣=26 是字符集合的大小。返回值不计入。
注:如果特判 m<n 的情况(直接返回空列表),则时间复杂度为 O(m)。
1456. 定长子串中元音的最大数目
滑动窗口:
定长滑窗套路总结成三步:入-更新-出。
入:下标为 right的元素进入窗口,更新相关统计量。如果 i<k−1 则重复第一步。
更新:更新答案。一般是更新最大值/最小值。
出:下标为 i−k+1 的元素离开窗口,更新相关统计量。
以上三步适用于所有定长滑窗题目。
class Solution {
public:
int maxVowels(string s, int k) {
int a[26]{} , ans = 0 , cnt = 0 ,left = 0;
for(int right = 0 ; right < s.size() ; right ++){
a[s[right] - 'a']++;
if(s[right] == 'a' || s[right] == 'e' || s[right] == 'i' || s[right] == 'o' || s[right] == 'u') cnt ++;
if(right - left < k - 1) continue;//入的过程
ans = max(ans , cnt);//更新答案
a[s[left] - 'a']--;//出
if(s[left] == 'a' || s[left] == 'e' || s[left] == 'i' || s[left] == 'o' || s[left] == 'u') cnt --;
left ++;
}
return ans;
}
};
灵神版简洁写法:
本题没有窗口左端点连续移出的情况,每次窗口大小等于k之后,每次都进行right右移,left右移,因此可以简化left。
class Solution {
public:
int maxVowels(string s, int k) {
int ans = 0, vowel = 0;
for (int i = 0; i < s.length(); i++) {
// 1. 进入窗口
if (s[i] == 'a' || s[i] == 'e' || s[i] == 'i' || s[i] == 'o' || s[i] == 'u') {
vowel++;
}
if (i < k - 1) { // 窗口大小不足 k
continue;
}
// 2. 更新答案
ans = max(ans, vowel);
// 3. 离开窗口
char out = s[i - k + 1];
if (out == 'a' || out == 'e' || out == 'i' || out == 'o' || out == 'u') {
vowel--;
}
}
return ans;
}
};



浙公网安备 33010602011771号