2.26-哈希、滑窗

1. 两数之和 - 力扣(LeetCode)

lc1-new-c.png

image-20250226104741041

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 加入窗口之后,窗口内有重复元素。

具体思路:

  1. 创建一个空的哈希集合。

  2. 遍历 nums。

  3. 先判断 x=nums[i] 是否在哈希集合中,如果在,返回 true。

  4. 如果不在,把 x 加到哈希集合中。

  5. 如果 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.secondfalse,说明nums[i]已经存在于集合st中,因此当前元素nums[i]是重复的,并且距离前一个重复元素不超过k(由后续逻辑维护)。
  • 如果pair.secondtrue,说明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 互质的数,把对应的出现次数加到答案中

具体算法如下:

  1. 初始化答案 ans=0,初始化长为 10 的 cnt 数组,初始值均为 0。

  2. 遍历 nums,设 x=nums[j]。

  3. 枚举 [1,9] 内的数字 y,如果与 xmod10 互质,则 ans 增加 cnt[y]。

  4. 计算 x 的最高位,将其出现次数加一。

  5. 返回 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 加到一个列表中返回。

  1. 定义unordered_map<string,vector> map;
  2. 遍历strs数组,将每个str作为key,进行排序,相同的key后面存储str(如“eat”“tea”)全部存到map[aet]里。
  3. 最后用迭代器遍历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_setunordered_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. 定长子串中元音的最大数目

滑动窗口:

定长滑窗套路总结成三步:入-更新-出。

  1. 入:下标为 right的元素进入窗口,更新相关统计量。如果 i<k−1 则重复第一步。

  2. 更新:更新答案。一般是更新最大值/最小值。

  3. 出:下标为 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;
    }
};
posted @ 2025-02-26 18:47  七龙猪  阅读(1)  评论(0)    收藏  举报
-->