算法 --哈希表

什么是哈希表?

一句话总结:哈希表是一种通过"键(Key)"直接访问"值(Value)"的数据结构,它的平均查找、插入和删除时间复杂度都是 O(1)。

它是怎么做到 O(1) 的?靠的是哈希函数。

  1. 当你存入一个键值对 (Key, Value) 时,哈希函数会把 Key 转换成一个整数(哈希值)。

  2. 这个整数对应数组中的一个位置(索引),数据就存在这个位置。

  3. 下次你拿 Key 来查找时,只需再用哈希函数算一次,就能立刻定位到数据的位置,不需要像数组那样从头遍历。

哈希表的核心价值在于**"用空间换时间"**,将原本需要 O(N) 遍历查找的操作,降维打击成 O(1) 的快速查找。

题目一:两数之和

cpp 复制代码
class Solution {
public:
    vector<int> twoSum(vector<int>& nums, int target) {
        // 1. 创建一个哈希表,key是数组中的元素值,value是该元素对应的下标
        unordered_map<int, int> hash;
        
        // 2. 遍历整个数组
        for(int i = 0; i < nums.size(); i++)
        {
            // 3. 计算当前元素需要的"另一半"是多少
            int x = target - nums[i];
            
            // 4. 在哈希表中查找这个"另一半"是否已经存在
            if(hash.count(x)) 
                // 如果存在,说明找到了,直接返回它们的下标
                // hash[x] 是之前那个元素的下标,i 是当前元素的下标
                return {hash[x], i};
                
            // 5. 如果没找到,就把当前元素和它的下标存入哈希表,供后面的元素匹配
            hash[nums[i]] = i;
        }
        
        // 6. 如果遍历完都没找到符合条件的两个数(题目保证一定有解,所以正常不会走到这里)
        return {-1, -1};
    }
};

核心思路:空间换时间 (哈希表)

我们需要在数组中找到两个数 a 和 b,使得 a + b = target。

在遍历数组时,对于当前的数字 nums[i],我们其实需要知道:在它之前是否出现过 target - nums[i] 这个数。如果出现过,那么这两个数就是我们要找的答案。

为了能够快速(O(1) 时间复杂度)地判断一个数是否出现过以及它的下标,我们可以使用哈希表 unordered_map。

为什么不用双重循环?

如果用双重 for 循环暴力求解(外层遍历 i,内层遍历 j > i),时间复杂度是 O(N²) 。当数组非常大时,程序会运行超时。哈希表解法通过记录已经遍历过的信息,将时间复杂度降到了 O(N),是非常典型的"以空间换时间"的策略。

题目二:判断是否互为字符重排

解法一:哈希表统计字符频率

核心思想是"消消乐 ":先统计 s1 的字符,然后遍历 s2,遇到一个就抵消一个。

cpp 复制代码
class Solution {
public:
    bool CheckPermutation(string s1, string s2) {
        // 1. 长度不同,直接返回 false
        if (s1.size() != s2.size()) return false;

        // 2. 用哈希表统计 s1 中每个字符出现的次数
        unordered_map<char, int> hash;
        for (char c : s1) {
            hash[c]++;
        }

        // 3. 遍历 s2,抵消哈希表中的计数
        for (char c : s2) {
            // 如果字符不存在,或者数量已经减到 0 了,说明 s2 多了该字符
            if (hash.count(c) == 0 || hash[c] == 0) {
                return false;
            }
            hash[c]--;
        }

        // 4. 遍历完毕且没有提前返回,说明完全匹配
        return true;
    }
};
优缺点
  • 优点: 时间复杂度 O(N),只需遍历两次字符串。通用性强,不限制字符集。

  • 缺点: 需要额外的哈希表空间。

解法二:数组计数(针对小写字母的优化)

如果题目明确说明字符串只包含小写英文字母(a-z),我们完全可以用一个长度为 26 的整型数组来代替哈希表,效率更高,代码也更简洁。

cpp 复制代码
class Solution {
public:
    bool CheckPermutation(string s1, string s2) {
        if (s1.size() != s2.size()) return false;

        int count[26] = {0}; // 统计 a-z 的频率

        // s1 字符对应位置 +1,s2 字符对应位置 -1
        for (int i = 0; i < s1.size(); i++) {
            count[s1[i] - 'a']++;
            count[s2[i] - 'a']--;
        }

        // 检查所有计数是否归零
        for (int i = 0; i < 26; i++) {
            if (count[i] != 0) return false;
        }
        return true;
    }
};
代码逻辑解析
  1. 长度检查 :长度不同直接返回 false。

  2. 同时遍历 :用一个循环同时遍历 s1 和 s2。

  3. 加加减减 :s1 中出现的字符,在数组对应位置 +1;s2 中出现的字符,在数组对应位置 -1。

  4. 结果检查:如果两个字符串互为重排,那么每个字符的净变化量必须是 0。最后遍历一次数组,只要有任何一个位置不为 0,就说明不匹配。

优缺点
  • 优点: 极快。没有哈希表的哈希计算开销,纯数组操作,空间固定为 26 个 int。

  • 缺点: 仅适用于字符集有限的情况(如纯小写字母)。如果有大写、数字,就不适用了。

题目三:存在重复元素

哈希集合 (Hash Set)

这是最直观、也是最常被写出的解法。

cpp 复制代码
class Solution {
public:
    bool containsDuplicate(vector<int>& nums) {
        unordered_set<int> hash;
        for (int x : nums) {
            // 如果元素已经在集合中,说明找到了重复元素
            if (hash.count(x)) return true; 
            // 否则将元素加入集合
            hash.insert(x);
        }
        return false;
    }
};
代码逻辑解析
  1. 创建一个 unordered_set(哈希集合),用于存储已经遍历过的数字。

  2. 遍历数组中的每一个数字 x。

  3. 在集合中查找 x 是否已经存在(hash.count(x))。

  4. 如果存在,直接返回 true(因为题目只要求判断是否存在,找到第一个重复的就可以结束了)。

  5. 如果不存在,把 x 插入到集合中,继续遍历下一个数字。

  6. 如果遍历完整个数组都没有找到重复元素,返回 false。

优缺点分析
  • 优点:

    • 时间复杂度最优: 平均时间复杂度为 O(N)。只需遍历一次数组,哈希表的插入和查找操作平均都是 O(1)。

    • 提前终止: 只要在数组前部发现有重复元素,就会立刻返回,不需要遍历完整个数组。

    • 逻辑简单: 代码非常符合人类直觉,"见过就记下来,再见到就报错"。

  • 缺点 :

    • 空间开销大: 最坏情况下(数组无重复元素),需要把数组里的 N 个元素全部存入哈希表,空间复杂度为 O(N)。如果内存极其受限,这种方法不合适。
题目四:存在重复元素II
cpp 复制代码
class Solution {
public:
    bool containsNearbyDuplicate(vector<int>& nums, int k) {
        // 1. 哈希表:记录元素值 -> 该元素最近一次出现的下标
        unordered_map<int, int> hash;
        
        // 2. 遍历数组
        for(int i = 0; i < nums.size(); i++)
        {
            // 3. 如果该元素之前出现过
            if(hash.count(nums[i]))
            {
                // 4. 判断当前下标 i 与上次出现的下标之间的距离是否 <= k
                if(i - hash[nums[i]] <= k) 
                    return true; // 满足条件,直接返回
            }
            
            // 5. 关键步骤:更新哈希表。
            // 无论是否满足条件,都要把当前元素的下标更新为 i。
            // 因为对于后续的元素来说,当前下标 i 才是最近的。
            hash[nums[i]] = i; 
        }
        
        // 6. 遍历完都没有找到符合条件的重复元素
        return false;
    }
};

核心思路:记录最新下标

我们需要判断:对于当前遍历到的元素 nums[i],在它前面是否出现过相同的元素,并且它们之间的下标距离不超过 k。

为了高效查找"是否出现过",以及"上一次出现的位置",我们依然可以使用哈希表 unordered_map。

  • Key (键): 数组中的元素值 nums[i]。

  • Value (值): 该元素最近一次 出现的下标 i。

关键逻辑 :当我们遍历到 nums[i] 时:

  1. 看哈希表里有没有记录过这个元素。

  2. 如果记录过,取出它上一次出现的下标 hash[nums[i]],计算与当前下标 i 的距离。

  3. 如果 i - hash[nums[i]] <= k,说明满足条件,直接返回 true。

  4. 如果不满足,或者哈希表里没有这个元素,我们需要更新 哈希表:hash[nums[i]] = i。因为当前下标 i 是距离未来元素最近的位置,保留旧的下标没有意义。

进阶优化(滑动窗口 + 哈希集合)

还有另一种思路:维护一个大小为 k 的滑动窗口 。我们使用一个 unordered_set,保证集合里最多只有 k 个元素。遍历数组时,先检查集合里有没有当前元素,如果有则返回 true;然后把当前元素加入集合,如果集合大小超过了 k,就把最旧的元素(nums[i - k])从集合中移除。

cpp 复制代码
class Solution {
public:
    bool containsNearbyDuplicate(vector<int>& nums, int k) {
        unordered_set<int> window;
        for (int i = 0; i < nums.size(); ++i) {
            if (window.count(nums[i])) return true; // 窗口内有重复
            window.insert(nums[i]);
            if (window.size() > k) { // 保持窗口大小不超过 k
                window.erase(nums[i - k]);
            }
        }
        return false;
    }
};

这种方法的空间复杂度被严格控制在 O(k) ,在 k 远小于 N 的时候,比第一种方法更省内存。但是 map 解法(只保留最近下标)在逻辑上更直观,且不需要额外处理窗口大小的判断。

题目五:字母异位词分组

cpp 复制代码
class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        // 1. 定义哈希表:Key是排序后的字符串,Value是原始字符串的数组
        unordered_map<string, vector<string>> hash;
        
        // 2. 遍历输入的每一个字符串
        for(auto& s : strs) // 使用引用遍历,提高效率
        {
            // 3. 复制当前字符串,用于排序
            string tmp = s;
            
            // 4. 对复制的字符串进行排序(例如 "eat" -> "aet")
            sort(tmp.begin(), tmp.end());
            
            // 5. 将原始字符串存入以排序后字符串为键的哈希表中
            // 如果是第一次遇到这个键,会自动创建一个新的 vector
            hash[tmp].push_back(s); 
        }
        
        // 6. 准备返回结果
        vector<vector<string>> ret;
        
        // 7. 遍历哈希表,将所有的 Value(即分组好的异位词数组)提取出来
        for(auto& [x, y] : hash) // C++17 结构化绑定,x是键,y是值
        {
            ret.push_back(y);
        }
        
        // 8. 返回最终结果
        return ret;
    }
};

核心思路:排序 + 哈希表

如何判断两个单词是字母异位词?它们包含的字母种类和数量完全一致。因此,如果我们将这两个单词内部的字母分别进行排序,得到的字符串一定是相同的。

例如:

  • "eat" 排序后变成 "aet"

  • "tea" 排序后变成 "aet"

  • "ate" 排序后变成 "aet"

我们可以利用这个特性:

  1. 创建一个哈希表,Key (键) 存储排序后的字符串 ,Value (值) 存储原始字符串的集合(即一组字母异位词)。

  2. 遍历输入的字符串数组,对于每一个字符串,将其排序,得到 tmp。

  3. 以 tmp 为键,将原始字符串 s 存入哈希表中对应的数组里。

  4. 最后,把哈希表中所有的 Value 提取出来,就是分组后的结果。

复杂度分析

假设数组中有 N 个字符串,每个字符串的最大长度为 K。

  • 时间复杂度:O(N * K * log(K))

    • 我们需要遍历 N 个字符串(外层循环)。

    • 对于每个字符串,我们需要将其复制并排序。对长度为 K 的字符串进行排序的时间复杂度是 O(K * log K)。

    • 哈希表的插入操作平均是 O(1)。因此总的时间复杂度为 O(N * K * log(K))。

  • 空间复杂度:O(N * K)

    • 哈希表需要存储所有的字符串,总共消耗 O(N * K) 的空间。

    • 排序时产生的临时字符串 tmp 消耗 O(K) 的额外空间。

    • 因此,总的空间复杂度为 O(N * K)。

进阶优化(计数法避免排序)

虽然排序法已经足够优秀,但如果字符串非常长,O(K * log K) 的排序时间可能会成为瓶颈。我们可以将每个字符串的字符出现次数统计出来(例如 a 出现 1 次,e 出现 2 次...),把这个统计信息转化为一个唯一的字符串作为 Key。

因为题目提示字符串只包含小写字母,我们可以用一个长度为 26 的数组或字符串来作为 Key:

cpp 复制代码
class Solution {
public:
    vector<vector<string>> groupAnagrams(vector<string>& strs) {
        unordered_map<string, vector<string>> hash;
        
        for (auto& s : strs) {
            string key(26, '0'); // 创建一个长度为26的字符串,初始化为 '0'
            for (char c : s) {
                key[c - 'a']++; // 统计字符出现的次数
            }
            // 例如 "eat" -> "10001000000000000000000000"
            hash[key].push_back(s);
        }
        
        vector<vector<string>> ret;
        for (auto& [key, group] : hash) {
            ret.push_back(group);
        }
        return ret;
    }
};
  • 时间复杂度: 降为 O(N * K)。因为统计字符只需要遍历一次字符串。

  • 空间复杂度: 依然是 O(N * K),因为 Key 的长度变成了固定的 26。

直接寻址表和哈希表

"用数组模拟哈希表" (通常称为直接寻址表 )和**"标准库中的哈希表"** (如 C++ 的 unordered_map / unordered_set)虽然目的相似(都是为了实现 O(1) 的快速查找),但在底层原理、性能、内存消耗和适用场景上有着巨大的区别。

1. 核心原理的区别

  • 数组模拟哈希表(直接寻址):

    • 原理: 极其简单粗暴。它直接把元素的值(Key)当作数组的下标(Index)。

    • 映射关系: 数组[Key] = Value 或者 数组[Key] = true。

    • 例如:要统计字符串中小写字母的出现次数,字符 'a' 的 ASCII 码是 97,我们就把它映射到 arr[0],'b' 映射到 arr[1]。

  • 标准库哈希表 (unordered_map / unordered_set):

    • 原理: 复杂精妙。它内部维护了一个哈希函数 和一个桶数组(Bucket Array)。

    • 映射关系: 下标 = 哈希函数(Key) % 桶的数量。

    • 例如:存入数字 100000,哈希函数可能算出 100000 % 100 = 0,它就会被放进第 0 个桶里。如果发生哈希冲突,还会用到链表或红黑树来解决。

2. 时间复杂度的区别

  • 数组模拟哈希表:

    • 绝对 O(1)。 因为数组支持随机访问,通过下标取值是计算机硬件层面最基础的操作。没有任何冲突处理,没有哈希计算,速度极快。
  • 标准库哈希表:

    • 平均 O(1),最坏 O(N)。 虽然有哈希函数,但计算哈希值需要时间。如果发生严重的哈希冲突(所有元素都映射到同一个桶),查找会退化成遍历链表,时间复杂度变成 O(N)。

    • 此外,当哈希表中的元素越来越多时,它需要扩容(Rehash),这会瞬间导致一次 O(N) 的操作。

3. 空间复杂度与内存占用的区别

  • 数组模拟哈希表:

    • 空间固定且连续。 必须开辟一块连续的内存。

    • 致命缺点:空间浪费或越界。 如果数据的范围是 1 ~ 10^9,你不可能开一个大小为 10亿 的数组,内存会直接爆炸。但如果数据只有 1, 5, 1000 这三个数,你又开了一个大小 1001 的数组,中间的 997 个空间全是浪费的。

  • 标准库哈希表:

    • 动态分配,按需增长。 它只会为实际存在的元素分配内存(加上桶数组的一些额外开销)。

    • 空间利用率高。 无论数据的范围跨度有多大(比如只有 3 个元素,但值是 1, 5000, 1000000),它都能很好地处理,不会浪费大量连续内存。

4. 适用场景的区别

场景特征 推荐方案 原因
数据范围小且集中(如:小写字母 a-z,数字 0-1000) 数组模拟 极快,代码简单,无哈希开销。
数据范围极大或未知(如:任意整数、字符串、浮点数) 标准库哈希表 数组开不下,必须用哈希函数压缩空间。
需要极高的时间性能(如:算法竞赛卡常数) 数组模拟 数组访问比哈希表访问快 5-10 倍以上。
数据是复杂类型(如:结构体、自定义对象) 标准库哈希表 数组无法直接用复杂对象做下标,除非自己写映射。

识别信号

信号一:需要快速判断"某元素是否存在"或"是否重复"

这是最直接的信号。如果题目要求你频繁地检查一个元素之前有没有出现过,用数组遍历是 O(N),用哈希表是 O(1)。

  • 关键词: 是否存在、是否重复、唯一、去重。

  • 经典例题:

    • 存在重复元素: 判断数组里有没有重复数字。-> 用 unordered_set 边遍历边检查。

    • 两个数组的交集: 找两个数组共有的元素。-> 把一个数组丢进 set,遍历另一个数组去查。

    • 1. 两数之和: 找 target - x 是否在之前遍历过的数字里。-> 用 map 记录"值 -> 下标"。

信号二:需要统计"元素出现的次数/频率"

如果题目要求你统计字符、数字或字符串出现的次数,哈希表(或数组模拟)是最标准的工具。

  • 关键词: 频率、次数、计数、出现多少次。

  • 经典例题:

    • 判定是否互为字符重排: 判断两个字符串字符数量是否一致。-> 用 map 统计 s1,遍历 s2 抵消。

    • 有效的字母异位词: 同上。

    • 前 K 个高频元素: 统计频率后排序或使用堆。-> 第一步必须用 map 统计频率。

信号三:需要建立"映射关系"(Key -> Value)

当题目中存在某种一一对应的关系,或者你需要通过一个值快速找到另一个值时。

  • 关键词: 映射、对应、查找、转换。

  • 经典例题:

    • 两数之和: 数字 -> 下标。

信号四:需要将"具有相同特征"的元素归类(分组)

如果题目要求你把一堆东西按照某种规则分成几组,哈希表的 Key 可以存储"特征",Value 存储"属于该特征的所有元素"。

  • 关键词: 分组、归类、具有相同属性。

  • 经典例题:

    • 字母异位词分组: 把字母组成相同的单词放在一起。-> 排序后的字符串作为 Key,原始字符串数组作为 Value。

信号五:暴力解法是双重循环 O(N²),且内层循环是为了"查找"

这是算法优化的终极信号。当你写出两层 for 循环,发现内层循环的作用仅仅是为了"在数组里找一个满足条件的数"时,就可以用哈希表把内层循环优化掉,将整体时间复杂度降为 O(N)。

  • 优化逻辑:

    • 暴力:外层遍历 i,内层遍历 j 找 target - nums[i]。

    • 优化:外层遍历 i,哈希表 O(1) 查找 target - nums[i]。

  • 经典例题: 绝大多数两数之和、三数之和、四数之和的变种,都可以用哈希表优化。

相关推荐
灯澜忆梦1 小时前
【面向对象编程c++】| 数组与函数
java·数据结构·c++
Interview Aid1122 小时前
Stripe VO 面经汇总:5 轮面试题目与考察重点
算法
EEET智选2 小时前
科技晨间速报 | AI 安全警钟、开源新模型与苹果智能家居
人工智能·科技·算法·机器学习·ai
挖掘狂人2 小时前
哈希算法:一根看不见的 "指纹",如何撑起整个互联网
算法·程序员·掘金技术征文
鶴哥只手遮天2 小时前
ecRad 1.5.0 算法原理详解
算法·开源
不会就选b3 小时前
算法日常・每日刷题--<动态规划>22
算法·动态规划
释厄6233 小时前
字画同体论——字即画·几何即字·漫画与小说=文本层
人工智能·算法
沫璃染墨3 小时前
从零入门数据结构与算法(一):复杂度分析——从算法效率到时间与空间复杂度
c语言·开发语言·数据结构·笔记·算法·飞书
熊猫钓鱼>_>3 小时前
面试出现 606 次的第 K 大,被拆成三个问题问
算法·面试·职场和发展·校招·面经·oj·ai生成