【每日算法】专题十_哈希表

1. 算法思想

在算法题中,哈希表(Hash Table)的核心作用是高效存储和快速查找键值对,其算法思想可总结为以下几点:

1. 快速判断元素是否存在

  • 适用场景:需要判断某个元素是否在集合中,或统计元素出现次数。
  • 核心思路:用哈希表记录元素的存在性或频率,将原本 \(O(n)\) 的遍历查找优化为 \(O(1)\) 的哈希表查询。
  • 典型例题:两数之和、存在重复元素、字母异位词判断。

2. 分组与映射

  • 适用场景:将具有相同特征的元素归为一组(如字母异位词、同构字符串)。
  • 核心思路:设计哈希表的键为元素的特征(如排序后的字符串、字符频率数组),值为符合该特征的元素集合。
  • 典型例题:字母异位词分组、有效的字母异位词。

3. 缓存中间结果

  • 适用场景:避免重复计算,保存子问题的解(如动态规划中的重叠子问题)。
  • 核心思路:用哈希表记录已计算的结果,下次需要时直接查询,减少时间复杂度。
  • 典型例题:斐波那契数列、爬楼梯问题的优化。

4. 处理冲突与滑动窗口

  • 适用场景:在限定范围(如长度为 k 的窗口)内统计元素频率或判断重复。
  • 核心思路:结合哈希表与滑动窗口,动态维护窗口内的元素状态,通过哈希表快速检查窗口内是否存在目标元素。
  • 典型例题:存在重复元素 II、无重复字符的最长子串。

5. 双向映射与关系记录

  • 适用场景:需要维护两个变量之间的双向关联(如字符替换、模式匹配)。
  • 核心思路:使用两个哈希表分别记录正向和反向映射关系,确保映射的唯一性。
  • 典型例题:同构字符串、单词规律。

6. 空间换时间

  • 本质:哈希表通过额外的存储空间,将时间复杂度从 \(O(n)\) 或 \(O(n^2)\) 优化到 \(O(1)\) 或 \(O(n)\)。
  • 注意事项:需权衡空间复杂度,避免过度使用导致内存溢出(如哈希表存储整个数组的元素)。

解题步骤总结

  1. 分析问题:判断是否需要快速查找、去重、分组或缓存结果。
  2. 设计键值映射:确定哈希表的键(如元素值、排序后的字符串、频率数组)和值(如元素索引、出现次数、元素集合)。
  3. 处理冲突逻辑:根据题意选择合适的冲突处理方式(如直接覆盖、链表存储)。
  4. 优化与边界检查:考虑动态扩容、滑动窗口维护、空值或重复元素的特殊处理。

2. 例题

2.1 两数之和

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

这是经典 "两数之和" 问题的解法,核心思路如下:

1. 利用哈希表优化查找

unordered_map(哈希表)存储数组元素值 和对应的下标 。遍历数组时,通过哈希表快速判断 target - 当前元素 是否存在,将原本暴力枚举两层循环的 \(O(n^2)\) 时间复杂度,优化为 \(O(n)\) (n 是数组长度 )。

2. 遍历 + 查找的流程

  • 遍历数组 nums ,对每个元素 nums[i]
    • 计算差值 comp = target - nums[i] ,查哈希表是否有 comp
    • 若有,说明找到两个数(comp 对应数和 nums[i] ),返回它们的下标 {hash[comp], i}
    • 若没有,把当前元素 nums[i] 和下标 i 存入哈希表,继续遍历。

这样一趟遍历就能完成查找,高效解决问题,本质是用空间换时间,靠哈希表快速查询特性简化流程。

cpp 复制代码
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        unordered_map<int, int> hash;

        for(int i = 0; i < nums.size(); ++i)
        {
            if(hash.count(target - nums[i])) return {hash[target - nums[i]], i};
            hash[nums[i]] = i;
        }
        return {};
    }
};

2.2 面试题 01.02. 判定是否为字符重排

面试题 01.02. 判定是否互为字符重排 - 力扣(LeetCode)

核心思路如下:

1. 预处理:检查长度是否相等

  • 如果 s1s2 的长度不同,直接返回 false。因为变位词必须由完全相同的字符组成,长度必然相等。

2. 统计字符频率

  • 使用一个长度为 26 的数组 hash 统计 小写字母 的出现次数(假设输入仅含小写字母)。
  • 遍历 s1,将每个字符 ch 映射到数组下标 ch - 'a',并增加对应计数。

3. 验证字符频率匹配

  • 遍历 s2,对每个字符 ch
    • 如果 hash[ch - 'a'] > 0,说明 s1 中存在该字符,将计数减一。
    • 如果 hash[ch - 'a'] == 0,说明 s2 中的该字符在 s1 中不存在或已被用完,直接返回 false

4. 返回结果

  • 如果遍历完 s2 所有字符都没有提前返回 false,说明 s1s2 的字符频率完全一致,返回 true

关键点

  • 时间复杂度:\(O(n)\),其中 n 是字符串长度。两次遍历数组即可完成判断。
  • 空间复杂度:\(O(1)\),因为数组长度固定为 26,不随输入规模变化。
  • 适用条件:输入字符串仅含小写字母。若包含其他字符(如大写字母、数字),需扩大数组范围或改用哈希表。
cpp 复制代码
class Solution {
public:
    bool CheckPermutation(string s1, string s2) {
        if(s1.size() != s2.size()) return false;
        int hash[26];

        for(auto ch : s1) ++hash[ch - 'a'];

        for(auto ch : s2) 
            if(hash[ch - 'a'] > 0) --hash[ch - 'a'];
            else return false;

        return true;
    }
};

2.3 存在重复元素

217. 存在重复元素 - 力扣(LeetCode)

核心思路如下:

1. 利用哈希表记录元素出现次数

  • 使用 unordered_map<int, int>(哈希表)统计每个元素的出现次数。
  • :数组中的元素值;:该元素出现的次数。

2. 遍历数组,检查重复

  • 遍历数组 nums,对每个元素 n
    • 检查哈希表 :如果 n 已存在(即 hash.count(n) > 0),说明该元素是重复的,直接返回 true
    • 记录元素 :若 n 不存在,将其加入哈希表并计数为 1(即 ++hash[n])。

3. 返回结果

  • 如果遍历完整个数组都没有发现重复元素,返回 false

关键点

  • 时间复杂度:\(O(n)\),其中 n 是数组长度。遍历一次数组,哈希表的插入和查找操作平均时间为 \(O(1)\)。
  • 空间复杂度:\(O(n)\),最坏情况下数组所有元素都不同,需存储整个数组的元素。
  • 哈希表的选择 :使用 unordered_map 而非 map 是因为前者基于哈希表实现,查找效率更高(平均 \(O(1)\) vs. 红黑树的 \(O(\log n)\))。
cpp 复制代码
class Solution {
public:
    bool containsDuplicate(vector<int>& nums) {
        unordered_map<int, int> hash;

        for(auto n : nums)
        {
            if(hash.count(n)) return true;
            else ++hash[n];
        }

        return false;
    }
};

2.4 存在重复元素 2

219. 存在重复元素 II - 力扣(LeetCode)

核心思路如下:

1. 问题理解

判断数组中是否存在重复元素 ,且它们的下标之差不超过给定值 k 。 例如:nums = [1,2,3,1]k = 3,下标 0 和 3 的元素均为 1,差值 3 ≤ 3,返回 true

2. 哈希表维护元素的最新下标

  • 使用 unordered_map<int, int> 记录每个元素最近一次出现的下标
  • :元素值;:该元素在数组中的最新下标。

3. 遍历数组,检查条件

  • 遍历数组 nums,对每个元素 nums[i]
    1. 检查重复 :若哈希表中已存在 nums[i],计算当前下标 i 与上次下标 hash[nums[i]] 的差值。
      • 若差值 ≤ k,说明存在满足条件的重复元素,返回 true
    2. 更新哈希表 :将当前元素的下标 i 存入哈希表(覆盖旧值),确保记录的是最新位置。

4. 返回结果

  • 若遍历结束仍未找到符合条件的重复元素,返回 false

关键点

  • 时间复杂度:\(O(n)\),一次遍历数组,哈希表操作平均 \(O(1)\)。
  • 空间复杂度:\(O(n)\),最坏情况下需存储所有元素的下标。
  • 滑动窗口思想 :通过哈希表动态维护元素的最新位置,等价于在长度为 k 的滑动窗口内检查重复。
cpp 复制代码
class Solution {
public:
    bool containsNearbyDuplicate(vector<int>& nums, int k) {
        unordered_map<int, int> hash;
        for(int i = 0; i < nums.size(); ++i)
        {
            if(hash.count(nums[i]) && abs(hash[nums[i]] - i) <= k)
                return true;

            hash[nums[i]] = i;
        }
        return false;
    }
};

2.5 字母移位次分组

49. 字母异位词分组 - 力扣(LeetCode)

核心思路如下:

1. 问题理解

将字符串数组中的字母异位词 (由相同字母重排列形成的字符串)分为一组。 例如:strs = ["eat", "tea", "tan", "ate"][["eat","tea","ate"],["tan"]]

2. 哈希表映射:排序后的字符串 → 原字符串列表

  • 关键观察 :字母异位词排序后得到的字符串相同(如 "eat""tea" 排序后均为 "aet")。
  • 使用 unordered_map<string, vector<string>> 记录:
    • :排序后的字符串(如 "aet")。
    • :所有排序后等于该键的原字符串列表(如 ["eat", "tea", "ate"])。

3. 遍历字符串数组,构建哈希表

  • 对每个字符串 s
    1. 生成键 :复制 stmp 并排序,得到 tmp(如 "eat""aet")。
    2. 分组 :将 s 加入哈希表中 tmp 对应的列表(如 hash["aet"].push_back("eat"))。

4. 提取结果

  • 遍历哈希表,将每个键对应的字符串列表(vector<string>)收集到结果 ret 中。

关键点

  • 时间复杂度:\(O(n \cdot k \log k)\),其中 n 是字符串数量,k 是字符串的最大长度(每个字符串排序时间为 \(O(k \log k)\))。
  • 空间复杂度:\(O(n \cdot k)\),存储所有字符串及其排序后的键。
  • 扩展性:若输入含非小写字母(如大写字母或数字),排序逻辑需调整(如使用计数排序生成键)。
cpp 复制代码
class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        vector<vector<string>> ret;

        unordered_map<string, vector<string>> hash;

        for(auto s : strs)
        {
            string tmp = s;
            sort(tmp.begin(), tmp.end());
            hash[tmp].push_back(s);
        }

        for(auto& [x, y] : hash)
            ret.push_back(y);

        return ret;
    }
};