【笔面试算法学习专栏】哈希表基础:两数之和与字母异位词分组

前言

在算法面试中,哈希表(Hash Table)是最常用的数据结构之一。它能够在平均 O(1)O(1)O(1) 的时间复杂度内完成查找、插入和删除操作,这使得它成为解决许多算法问题的利器。本文将深入剖析哈希表的核心原理,并通过力扣hot100中的两道经典题目------两数之和字母异位词分组,带你掌握哈希表在实战中的应用技巧。


一、哈希表核心原理

1.1 什么是哈希表?

哈希表(Hash Table),也称为散列表,是一种基于键值对(Key-Value Pair)的数据结构。它的核心思想是通过一个哈希函数(Hash Function),将键(Key)映射到表中的一个位置,从而实现快速访问。

假设我们有一个长度为 nnn 的数组,哈希函数 h(k)h(k)h(k) 将键 kkk 映射到区间 [0,n−1][0, n-1][0,n−1] 的整数索引。理想情况下,不同的键应该映射到不同的位置,但在实际应用中,由于键的空间远大于数组大小,不可避免地会出现哈希冲突(Hash Collision)。

1.2 哈希函数的设计

一个好的哈希函数应该满足以下特性:

  • 确定性:相同的键总是映射到相同的索引
  • 均匀性:键应该均匀分布在哈希表中,减少冲突
  • 高效性:计算速度快

常见的哈希函数设计方法包括:

直接定址法
h(k)=kmod  mh(k) = k \mod mh(k)=kmodm

其中 mmm 是哈希表的大小,通常选择一个质数以减少冲突。

乘法哈希
h(k)=⌊m×(k×Amod  1)⌋h(k) = \lfloor m \times (k \times A \mod 1) \rfloorh(k)=⌊m×(k×Amod1)⌋

其中 AAA 是一个常数(通常取黄金分割比 0.618...),这种方法能够更好地打散键的分布。

1.3 哈希冲突的解决

当两个不同的键映射到同一个位置时,就发生了哈希冲突。主要有两种解决策略:

(1)链地址法(Separate Chaining)

将哈希表的每个位置维护一个链表,所有映射到同一位置的元素都存储在该链表中。

复制代码
索引 0 -> [k1, v1] -> [k2, v2] -> NULL
索引 1 -> [k3, v3] -> NULL
索引 2 -> NULL
...

这种方法实现简单,链表可以无限增长,但需要额外的指针空间。

(2)开放定址法(Open Addressing)

当发生冲突时,按照某种探测序列寻找下一个空位置。常见的探测方法有:

  • 线性探测 :hi(k)=(h(k)+i)mod  mh_i(k) = (h(k) + i) \mod mhi(k)=(h(k)+i)modm
  • 二次探测 :hi(k)=(h(k)+c1i+c2i2)mod  mh_i(k) = (h(k) + c_1 i + c_2 i^2) \mod mhi(k)=(h(k)+c1i+c2i2)modm
  • 双重哈希 :hi(k)=(h1(k)+i×h2(k))mod  mh_i(k) = (h_1(k) + i \times h_2(k)) \mod mhi(k)=(h1(k)+i×h2(k))modm

1.4 时间复杂度分析

在理想情况下(无冲突),哈希表的查找、插入、删除操作的时间复杂度都是 O(1)O(1)O(1)。

在最坏情况下(所有键都冲突),这些操作的时间复杂度退化为 O(n)O(n)O(n),其中 nnn 是元素个数。

负载因子 (Load Factor)α\alphaα 定义为:
α=nm\alpha = \frac{n}{m}α=mn

其中 nnn 是元素个数,mmm 是哈希表大小。当 α\alphaα 超过某个阈值(如0.75)时,通常需要进行再哈希(Rehashing),即扩大哈希表并重新插入所有元素。

1.5 哈希表在不同语言中的实现

  • C++unordered_mapunordered_set
  • JavaHashMapHashSet
  • Pythondictset
  • Gomap

理解底层原理对于正确使用这些数据结构至关重要。例如,Java的HashMap在JDK 1.8之后,当链表长度超过8时,会将链表转换为红黑树,将最坏情况下的时间复杂度从 O(n)O(n)O(n) 降低到 O(log⁡n)O(\log n)O(logn)。


二、两数之和:从暴力到哈希优化

2.1 题目描述

力扣第1题:两数之和(简单)

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。

示例:

复制代码
输入:nums = [2, 7, 11, 15], target = 9
输出:[0, 1]
解释:因为 nums[0] + nums[1] == 9,返回 [0, 1]

2.2 暴力解法:双重循环

最直观的解法是使用双重循环,枚举所有可能的数对:

cpp 复制代码
vector<int> twoSum(vector<int>& nums, int target) {
    int n = nums.size();
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (nums[i] + nums[j] == target) {
                return {i, j};
            }
        }
    }
    return {};
}

复杂度分析:

  • 时间复杂度:O(n2)O(n^2)O(n2),需要枚举 Cn2=n(n−1)2C_n^2 = \frac{n(n-1)}{2}Cn2=2n(n−1) 个数对
  • 空间复杂度:O(1)O(1)O(1),仅使用常数空间

2.3 哈希表优化:空间换时间

暴力解法的瓶颈在于:对于每个元素,我们需要 O(n)O(n)O(n) 时间查找是否存在匹配的另一个元素。如果能够将查找时间降低到 O(1)O(1)O(1),整体时间复杂度将优化为 O(n)O(n)O(n)。

这正是哈希表的用武之地!我们可以维护一个哈希表,记录已经遍历过的元素及其索引。对于当前元素 nums[i],只需要检查 target - nums[i] 是否在哈希表中即可。

优化思路:

  1. 遍历数组,对于每个元素 nums[i]
  2. 计算 complement = target - nums[i]
  3. 检查 complement 是否在哈希表中
    • 如果在,返回 {hash[complement], i}
    • 如果不在,将 {nums[i]: i} 存入哈希表

代码实现:

cpp 复制代码
vector<int> twoSum(vector<int>& nums, int target) {
    unordered_map<int, int> hash;
    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];
        if (hash.count(complement)) {
            return {hash[complement], i};
        }
        hash[nums[i]] = i;
    }
    return {};
}

复杂度分析:

  • 时间复杂度:O(n)O(n)O(n),只需遍历一次数组,每次查找和插入操作都是 O(1)O(1)O(1)
  • 空间复杂度:O(n)O(n)O(n),哈希表最多存储 nnn 个元素

2.4 为什么这样做是对的?

这个解法的正确性基于以下观察:

定理 :假设存在唯一解 (i, j) 满足 nums[i] + nums[j] = targeti < j,则当遍历到索引 j 时,索引 i 必然已经在哈希表中。

证明

  • 我们按照索引递增的顺序遍历数组
  • 当遍历到索引 j 时,所有满足 k < j 的元素 nums[k] 都已经存入哈希表
  • 因为 i < j,所以 nums[i] 已经在哈希表中
  • 此时 complement = target - nums[j] = nums[i],查找成功

这个证明揭示了一个重要的技巧:利用遍历顺序避免重复使用同一元素。因为我们在检查之后再插入,所以当前元素不会被自己匹配到。

2.5 边界情况与注意事项

在实际编码时,需要注意以下边界情况:

  1. 负数:哈希表可以正常处理负数作为键
  2. 重复元素:由于题目保证每种输入只对应一个答案,且不能使用同一元素两次,我们的解法天然避免了自己匹配自己的情况
  3. 无解情况:虽然题目保证有解,但在实际应用中应该处理无解的情况

2.6 扩展思考

问题1:如果有多个解怎么办?

可以修改解法,找出所有满足条件的数对:

cpp 复制代码
vector<vector<int>> twoSumAll(vector<int>& nums, int target) {
    vector<vector<int>> result;
    unordered_map<int, int> hash;
    for (int i = 0; i < nums.size(); i++) {
        int complement = target - nums[i];
        if (hash.count(complement)) {
            result.push_back({hash[complement], i});
        }
        hash[nums[i]] = i;
    }
    return result;
}

问题2:如果数组是有序的,是否有更优解法?

可以使用双指针法,时间复杂度 O(n)O(n)O(n),空间复杂度 O(1)O(1)O(1):

cpp 复制代码
vector<int> twoSumSorted(vector<int>& nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left < right) {
        int sum = nums[left] + nums[right];
        if (sum == target) return {left, right};
        else if (sum < target) left++;
        else right--;
    }
    return {};
}

但需要注意的是,这要求返回的是排序后数组的索引,如果需要原始索引,还需要额外处理。


三、字母异位词分组:哈希+字符串处理

3.1 题目描述

力扣第49题:字母异位词分组(中等)

给你一个字符串数组,请你将字母异位词分组在一起。可以按任意顺序返回结果列表。

字母异位词(Anagram)是由重新排列源单词的所有字母得到的一个新单词。

示例:

复制代码
输入:strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出:[["bat"], ["nat", "tan"], ["ate", "eat", "tea"]]

3.2 问题分析

这道题的核心在于:如何判断两个字符串是字母异位词?

两个字符串是字母异位词,当且仅当它们包含相同的字符,且每个字符的出现次数相同。换句话说,它们互为排列。

有以下几种方法可以判断:

方法1:排序

  • 将字符串按字符排序,字母异位词排序后会得到相同的字符串
  • 例如:"eat" 排序后为 "aet""tea" 排序后也是 "aet"

方法2:字符计数

  • 统计每个字符的出现次数
  • 字母异位词的字符计数完全相同

这两种方法都可以作为哈希表的键,将字母异位词映射到同一分组。

3.3 解法一:排序作为键

思路:

  • 遍历每个字符串
  • 将字符串排序,得到一个"标准化"的形式
  • 使用排序后的字符串作为哈希表的键
  • 将原始字符串添加到对应的分组中

代码实现:

cpp 复制代码
vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> groups;
    for (string& s : strs) {
        string key = s;
        sort(key.begin(), key.end());
        groups[key].push_back(s);
    }
    
    vector<vector<string>> result;
    for (auto& p : groups) {
        result.push_back(p.second);
    }
    return result;
}

复杂度分析:

  • 时间复杂度:O(n×klog⁡k)O(n \times k \log k)O(n×klogk),其中 nnn 是字符串数组的长度,kkk 是字符串的最大长度
    • 需要遍历 nnn 个字符串
    • 每个字符串排序的时间复杂度是 O(klog⁡k)O(k \log k)O(klogk)
  • 空间复杂度:O(n×k)O(n \times k)O(n×k),哈希表存储所有字符串

3.4 解法二:字符计数作为键

思路:

  • 统计每个字符串中每个字符的出现次数
  • 将字符计数编码为一个唯一的键
  • 字母异位词具有相同的字符计数,因此映射到同一键

编码方式:

可以用一个长度为26的数组表示字符计数,然后将其编码为字符串。例如:

  • "aabbcc" -> [2, 2, 2, 0, 0, ..., 0] -> "2a2b2c"

更简单的方式是直接拼接:

cpp 复制代码
string getKey(string& s) {
    vector<int> count(26, 0);
    for (char c : s) count[c - 'a']++;
    string key;
    for (int i = 0; i < 26; i++) {
        if (count[i] != 0) {
            key += string(1, 'a' + i) + to_string(count[i]);
        }
    }
    return key;
}

代码实现:

cpp 复制代码
vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> groups;
    for (string& s : strs) {
        vector<int> count(26, 0);
        for (char c : s) count[c - 'a']++;
        
        string key;
        for (int i = 0; i < 26; i++) {
            key += '#' + to_string(count[i]);
        }
        groups[key].push_back(s);
    }
    
    vector<vector<string>> result;
    for (auto& p : groups) {
        result.push_back(p.second);
    }
    return result;
}

复杂度分析:

  • 时间复杂度:O(n×k)O(n \times k)O(n×k),遍历 nnn 个字符串,每个字符串统计字符计数需要 O(k)O(k)O(k) 时间
  • 空间复杂度:O(n×k)O(n \times k)O(n×k),哈希表存储所有字符串

3.5 两种解法的比较

方法 时间复杂度 空间复杂度 优缺点
排序作为键 O(n×klog⁡k)O(n \times k \log k)O(n×klogk) O(n×k)O(n \times k)O(n×k) 实现简单,但排序开销较大
字符计数作为键 O(n×k)O(n \times k)O(n×k) O(n×k)O(n \times k)O(n×k) 时间更优,但需要额外的编码空间

当字符串较长时,字符计数方法更优。当字符串较短时,排序方法的常数因子更小,实际性能可能更好。

3.6 更优的编码方式

为了进一步优化,我们可以使用一种巧妙的编码方式:质数乘积

思路:

  • 为每个字母分配一个唯一的质数
  • 字符串的键为所有字符对应质数的乘积
  • 根据算术基本定理,不同的质数乘积对应不同的因数分解

示例:

复制代码
a = 2, b = 3, c = 5, d = 7, ...
"ab" = 2 * 3 = 6
"ba" = 3 * 2 = 6 (相同)
"abc" = 2 * 3 * 5 = 30

代码实现:

cpp 复制代码
vector<vector<string>> groupAnagrams(vector<string>& strs) {
    vector<int> primes = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 
                          31, 37, 41, 43, 47, 53, 59, 61, 67, 
                          71, 73, 79, 83, 89, 97, 101};
    
    unordered_map<long long, vector<string>> groups;
    for (string& s : strs) {
        long long key = 1;
        for (char c : s) {
            key *= primes[c - 'a'];
        }
        groups[key].push_back(s);
    }
    
    vector<vector<string>> result;
    for (auto& p : groups) {
        result.push_back(p.second);
    }
    return result;
}

注意事项:

  • 当字符串很长时,质数乘积可能溢出,需要使用 long long 或高精度运算
  • 这种方法的理论复杂度是 O(n×k)O(n \times k)O(n×k),但乘法运算比加法慢

3.7 实际应用中的选择

在实际编程竞赛或面试中:

  1. 首选排序法:实现简单,代码量少,不容易出错
  2. 字符计数法:当字符串较长或对时间复杂度有严格要求时使用
  3. 质数乘积法:理论上有趣,但实际使用较少

四、总结与拓展

4.1 核心知识点回顾

通过这两道题目,我们深入学习了哈希表的核心原理与应用技巧:

哈希表的本质

  • 通过哈希函数将键映射到索引
  • 以空间换时间,实现 O(1)O(1)O(1) 的查找效率
  • 需要处理哈希冲突(链地址法、开放定址法)

两数之和的关键技巧

  • 利用哈希表存储已遍历元素
  • 将查找时间从 O(n)O(n)O(n) 降低到 O(1)O(1)O(1)
  • 通过遍历顺序避免元素重复使用

字母异位词分组的关键技巧

  • 寻找字符串的"标准化"表示作为哈希键
  • 排序法 vs 字符计数法,根据场景选择
  • 理解编码方式对性能的影响

4.2 哈希表的常见应用场景

哈希表在算法中有广泛的应用,包括但不限于:

1. 查找与去重

  • 判断元素是否存在
  • 数组去重
  • 两个数组的交集/并集

2. 频率统计

  • 字符/元素出现次数统计
  • 滑动窗口中的频率维护

3. 缓存与记忆化

  • LRU缓存
  • 递归中的记忆化搜索

4. 索引构建

  • 构建倒排索引
  • 快速定位元素位置

4.3 相关题目推荐

为了巩固哈希表的使用技巧,推荐以下力扣题目:

入门级:

    1. 两数之和(简单)
    1. 存在重复元素(简单)
    1. 赎金信(简单)

进阶级:

    1. 字母异位词分组(中等)
    1. 最长连续序列(中等)
    1. 两个数组的交集(简单)
    1. 四数相加 II(中等)

挑战级:

    1. LRU缓存机制(中等)
    1. 和为K的子数组(中等)
    1. 最小覆盖子串(困难)

4.4 哈希表的局限性

虽然哈希表功能强大,但也有一些局限性:

1. 无序性

  • 哈希表不维护元素的插入顺序(unordered_map
  • 如需有序遍历,应使用 map(红黑树实现)或 LinkedHashMap

2. 空间开销

  • 哈希表需要额外的空间存储哈希桶和指针
  • 负载因子影响空间利用率

3. 哈希冲突

  • 最坏情况下性能退化
  • 哈希函数的选择影响性能

4. 不支持范围查询

  • 哈希表不支持高效的区间查找
  • 需要范围查询时,应考虑平衡二叉搜索树

4.5 深入学习建议

要真正掌握哈希表,建议深入学习以下内容:

理论基础:

  • 哈希函数的设计与安全性(密码学角度)
  • 完美哈希(Perfect Hashing)
  • 一致性哈希(Consistent Hashing)

工程实践:

  • 不同语言哈希表的实现细节
  • 哈希表在数据库索引中的应用
  • 分布式哈希表(DHT)

相关数据结构:

  • 布隆过滤器(Bloom Filter)
  • 跳表(Skip List)
  • 红黑树与平衡二叉搜索树

结语

哈希表是算法学习中的一块基石。从简单的两数之和,到复杂的字母异位词分组,哈希表的应用无处不在。掌握哈希表的核心原理,理解其在不同场景下的应用技巧,将极大地提升你的算法解题能力。

记住核心思想 :哈希表通过"空间换时间"的策略,将查找效率从 O(n)O(n)O(n) 提升到 O(1)O(1)O(1)。当你发现题目中存在大量的查找操作时,不妨考虑使用哈希表进行优化。

相关推荐
"菠萝"2 小时前
C#知识学习-021(文字关键字)
开发语言·学习·c#
chase。2 小时前
【学习笔记】让机器人“边想边动”——实时动作分块流策略的执行方法
笔记·学习·机器人
abant22 小时前
leetcode 239 单调队列 需要一些记忆
算法·leetcode·职场和发展
漫霂2 小时前
二叉树的统一迭代遍历
java·算法
炽烈小老头2 小时前
【每天学习一点算法 2026/04/08】阶乘后的零
学习·算法
Mr_Xuhhh3 小时前
算法刷题笔记:从滑动窗口到哈夫曼编码,我的算法进阶之路
开发语言·算法
MicroTech20253 小时前
突破虚时演化非酉限制:MLGO微算法科技发布可在现有量子计算机运行的变分量子模拟技术
科技·算法·量子计算
唐樽3 小时前
C++ 竞赛学习路线笔记
c++·笔记·学习
ShineWinsu3 小时前
对于Linux:文件操作以及文件IO的解析
linux·c++·面试·笔试·io·shell·文件操作