哈希表
概述
哈希表
是一种使用哈希函数
组织数据,以支持快速插入和搜索的数据结构。
有两种不同类型的哈希表:哈希集合和哈希映射。
- 哈希集合是集合数据结构的实现之一,用于存储非重复值。
- 哈希映射是映射 数据结构的实现之一,用于存储(key, value)键值对。
在标准模板库的帮助下,哈希表是易于使用的。大多数常见语言(如Java,C ++ 和 Python)都支持哈希集合和哈希映射。
通过选择合适的哈希函数,哈希表可以在插入和搜索方面实现出色的性能。
在本 LeetBook 中,我们将回答以下问题:
哈希表的 原理
是什么?
如何 设计
哈希表?
如何使用 哈希集
来解决与重复相关的问题?
如何使用 哈希映射
按键聚合信息?
如何在使用哈希表时 设计正确的键
?
哈希表的原理
正如我们在介绍中提到的,哈希表
是一种数据结构,它使用哈希函数组织数据,以支持快速插入和搜索
。在本文中,我们将简要说明哈希表的原理。
哈希表的原理
哈希表的关键思想是使用哈希函数将键映射到存储桶
。更确切地说,
- 当我们插入一个新的键时,哈希函数将决定该键应该分配到哪个桶中,并将该键存储在相应的桶中;
- 当我们想要搜索一个键时,哈希表将使用相同的哈希函数来查找对应的桶,并只在特定的桶中进行搜索。
示例
在示例中,我们使用 y = x % 5
作为哈希函数。让我们使用这个例子来完成插入和搜索策略:
插入:我们通过哈希函数解析键,将它们映射到相应的桶中。
- 例如,1987 分配给桶 2,而 24 分配给桶 4。
搜索:我们通过相同的哈希函数解析键,并仅在特定存储桶中搜索。
如果我们搜索 1987,我们将使用相同的哈希函数将1987 映射到 2。因此我们在桶 2 中搜索,我们在那个桶中成功找到了 1987。
例如,如果我们搜索 23,将映射 23 到 3,并在桶 3 中搜索。我们发现 23 不在桶 3 中,这意味着 23 不在哈希表中。
设计哈希表的关键
在设计哈希表时,你应该注意两个基本因素。
哈希函数
哈希函数是哈希表中最重要的组件,该哈希表用于将键映射到特定的桶。在上一篇文章中的示例中,我们使用 y = x % 5 作为散列函数,其中 x 是键值,y 是分配的桶的索引。
散列函数将取决于键值的范围
和桶的数量。
下面是一些哈希函数的示例:
哈希函数的设计是一个开放的问题。其思想是尽可能将键分配到桶中,理想情况下,完美的哈希函数将是键和桶之间的一对一映射。然而,在大多数情况下,哈希函数并不完美,它需要在桶的数量和桶的容量之间进行权衡。
冲突解决
理想情况下,如果我们的哈希函数是完美的一对一映射,我们将不需要处理冲突。不幸的是,在大多数情况下,冲突几乎是不可避免的。例如,在我们之前的哈希函数(y = x % 5)中,1987 和 2 都分配给了桶 2,这是一个冲突。
冲突解决算法应该解决以下几个问题:
- 如何组织在同一个桶中的值?
- 如果为同一个桶分配了太多的值,该怎么办?
- 如何在特定的桶中搜索目标值?
根据我们的哈希函数,这些问题与桶的容量
和可能映射到同一个桶
的键的数目
有关。
让我们假设存储最大键数的桶有 N 个键。
通常,如果 N 是常数且很小,我们可以简单地使用一个数组将键存储在同一个桶中。如果 N 是可变的或很大,我们可能需要使用高度平衡的二叉树来代替.。
训练
到目前为止,您应该能够实现基本的哈希表。我们为您提供了实现哈希集和哈希映射的练习。阅读需求,确定哈希函数并在需要时解决冲突。
如果你不熟悉哈希集或是哈希映射的概念,可以返回介绍部分找出答案。
插入和搜索是哈希表中的两个基本操作。
此外,还有基于这两个操作的操作。例如,当我们删除元素时,我们将首先搜索元素,然后在元素存在的情况下从相应位置移除元素。
设计哈希集合
不使用任何内建的哈希表库设计一个哈希集合(HashSet)。
实现 MyHashSet
类:
void add(key)
向哈希集合中插入值 key 。bool contains(key)
返回哈希集合中是否存在这个值 key 。void remove(key)
将给定值 key 从哈希集合中删除。如果哈希集合中没有这个值,什么也不做。
示例:
输入: |
模板:
class MyHashSet { |
题解:
链地址法:
设哈希表的大小为base,则可以设计一个简单的哈希函数:hash(x)=x mod base。
我们开辟一个大小为 base 的数组,数组的每个位置是一个链表。当计算出哈希值之后,就插入到对应位置的链表当中。
由于我们使用整数除法作为哈希函数,为了尽可能避免冲突,应当将base 取为一个质数。在这里,我们取 base=769。
T705.设计哈希集合 |
复杂度分析
时间复杂度:O(n/b)。其中 n 为哈希表中的元素数量,b 为链表的数量。假设哈希值是均匀分布的,则每个链表大概长度为n/b 。
空间复杂度:O(n+b)。
如果总共有 M 个键,那么在使用哈希表时,可以很容易地达到 O(M) 的空间复杂度。
但是,你可能已经注意到哈希表的时间复杂度与设计有很强的关系。
我们中的大多数人可能已经在每个桶中使用数组来将值存储在同一个桶中,理想情况下,桶的大小足够小时,可以看作是一个常数。插入和搜索的时间复杂度都是 O(1)。
但在最坏的情况下,桶大小的最大值将为 N。插入时时间复杂度为 O(1),搜索时为 O(N)。
内置哈希表的原理
内置哈希表的典型设计是:
- 键值可以是任何可哈希化的类型。并且属于可哈希类型的值将具有哈希码。此哈希码将用于映射函数以获取存储区索引。
- 每个桶包含一个数组,用于在初始时将所有值存储在同一个桶中。
- 如果在同一个桶中有太多的值,这些值将被保留在一个高度平衡的二叉树搜索树中。
插入和搜索的平均时间复杂度仍为 O(1)。最坏情况下插入和搜索的时间复杂度是 O(logN),使用高度平衡的 BST。这是在插入和搜索之间的一种权衡。
设计哈希映射
不使用任何内建的哈希表库设计一个哈希映射(HashMap)。
实现 MyHashMap 类:
- MyHashMap() 用空映射初始化对象
- void put(int key, int value) 向 HashMap 插入一个键值对 (key, value) 。如果 key 已经存在于映射中,则更新其对应的值 value 。
- int get(int key) 返回特定的 key 所映射的 value ;如果映射中不包含 key 的映射,返回 -1 。
- void remove(key) 如果映射中存在 key 的映射,则移除 key 和它所对应的 value 。
T706.设计哈希映射 |
复杂度分析
时间复杂度:O(n/b)。其中 n 为哈希表中的元素数量,b 为链表的数量。假设哈希值是均匀分布的,则每个链表大概长度为n/b 。
空间复杂度:O(n+b)。
实际应用 - 哈希集 - 用法
哈希集
是集合的实现之一,它是一种存储不重复值
的数据结构。
|
使用哈希集查重
我们知道,插入新值并检查值是否在哈希集中是简单有效的。
因此,通常,使用哈希集来检查该值是否已经出现过。
让我们来看一个例子:
给定一个整数数组,查找数组是否包含任何重复项。
这是一个典型的问题,可以通过哈希集来解决。
你可以简单地迭代每个值并将值插入集合中。 如果值已经在哈希集中,则存在重复。
/* |
实践 - 存在重复元素
给定一个整数数组,判断是否存在重复元素。
如果存在一值在数组中出现至少两次,函数返回 true
。如果数组中每个元素都不相同,则返回 false
。
方法1:排序
T217.存在重复元素 |
复杂度分析
时间复杂度:O(NlogN),其中 N 为数组的长度。需要对数组进行排序。
空间复杂度:O(logN),其中 N 为数组的长度。注意我们在这里应当考虑递归调用栈的深度。
方法2:哈希表
T217.存在重复元素 |
复杂度分析
- 时间复杂度:O(N),其中 N 为数组的长度。
- 空间复杂度:O(N),其中 N 为数组的长度。
实践 - 只存在一次的数字
如果不考虑时间复杂度和空间复杂度的限制,这道题有很多种解法,可能的解法有如下几种。
使用集合存储数字。遍历数组中的每个数字,如果集合中没有该数字,则将该数字加入集合,如果集合中已经有该数字,则将该数字从集合中删除,最后剩下的数字就是只出现一次的数字。
使用哈希表存储每个数字和该数字出现的次数。遍历数组即可得到每个数字出现的次数,并更新哈希表,最后遍历哈希表,得到只出现一次的数字。
使用集合存储数组中出现的所有数字,并计算数组中的元素之和。由于集合保证元素无重复,因此计算集合中的所有元素之和的两倍,即为每个元素出现两次的情况下的元素之和。由于数组中只有一个元素出现一次,其余元素都出现两次,因此用集合中的元素之和的两倍减去数组中的元素之和,剩下的数就是数组中只出现一次的数字。
时间空间复杂度都为O(n)。
其他方法:可使用异或运算⊕。异或运算有以下三个性质。
任何数和 0 做异或运算,结果仍然是原来的数,即 a⊕0=a。
任何数和其自身做异或运算,结果是 0,即 a⊕a=0。
异或运算满足交换律和结合律,即 a⊕b⊕a=b⊕a⊕a=b⊕(a⊕a)=b⊕0=b。
T136.只出现一次的数字 |
实践 - 两个数组的交集
给定两个数组,编写一个函数来计算它们的交集。
计算两个数组的交集,直观的方法是遍历数组 nums1,对于其中的每个元素,遍历数组 nums2 判断该元素是否在数组 nums2 中,如果存在,则将该元素添加到返回值。假设数组 nums1 和 nums2 的长度分别是 m 和 n,则遍历数组 nums1 需要 O(m) 的时间,判断 nums1 中的每个元素是否在数组 nums2 中需要 O(n) 的时间,因此总时间复杂度是 O(mn)。
如果使用哈希集合
存储元素,则可以在 O(1) 的时间内判断一个元素是否在集合中,从而降低时间复杂度。
首先使用两个集合分别存储两个数组中的元素,然后遍历较小的集合,判断其中的每个元素是否在另一个集合中,如果元素也在另一个集合中,则将该元素添加到返回值。该方法的时间复杂度可以降低到 O(m+n)。
T349.两个数组的交集 |
复杂度分析
时间复杂度:O(m+n),其中 m 和 n 分别是两个数组的长度。使用两个集合分别存储两个数组中的元素需要 O(m+n)的时间,遍历较小的集合并判断元素是否在另一个集合中需要 O(\min(m,n))O(min(m,n)) 的时间,因此总时间复杂度是 O(m+n)。
空间复杂度:O(m+n),其中 m 和 n 分别是两个数组的长度。空间复杂度主要取决于两个集合。
实践 - 快乐数
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果 可以变为 1,那么这个数就是快乐数。
如果 n 是快乐数就返回 true ;不是,则返回 false 。
方法一:用哈希集合检测循环
我们可以先举几个例子。我们从 77 开始。则下一个数字是 49,然后下一个数字是 97。我们可以不断重复该的过程,直到我们得到 11。因为我们得到了 1,我们知道 7 是一个快乐数,函数应该返回 true。
根据我们的探索,我们猜测会有以下三种可能。
- 最终会得到 11。
- 最终会进入循环。
- 值会越来越大,最后接近无穷大。
第三个情况比较难以检测和处理。我们怎么知道它会继续变大,而不是最终得到 11 呢?我们可以仔细想一想,每一位数的最大数字的下一位数是多少。
Digits | Largest | Next |
---|---|---|
1 | 9 | 81 |
2 | 99 | 162 |
3 | 999 | 243 |
4 | 9999 | 324 |
13 | 9999999999999 | 1053 |
对于 33 位数的数字,它不可能大于 243243。这意味着它要么被困在 243243 以下的循环内,要么跌到 11。44 位或 44 位以上的数字在每一步都会丢失一位,直到降到 33 位为止。所以我们知道,最坏的情况下,算法可能会在 243243 以下的所有数字上循环,然后回到它已经到过的一个循环或者回到 11。但它不会无限期地进行下去,所以我们排除第三种选择。
即使在代码中你不需要处理第三种情况,你仍然需要理解为什么它永远不会发生,这样你就可以证明为什么你不处理它。
算法
算法分为两部分,我们需要设计和编写代码。
- 给一个数字 nn,它的下一个数字是什么?
- 按照一系列的数字来判断我们是否进入了一个循环。
第 1 部分我们按照题目的要求做数位分离,求平方和。
第 2 部分可以使用哈希集合完成。每次生成链中的下一个数字时,我们都会检查它是否已经在哈希集合中。
- 如果它不在哈希集合中,我们应该添加它。
- 如果它在哈希集合中,这意味着我们处于一个循环中,因此应该返回 false。
我们使用哈希集合而不是向量、列表或数组的原因是因为我们反复检查其中是否存在某数字。检查数字是否在哈希集合中需要 O(1)的时间,而对于其他数据结构,则需要 O(n) 的时间。选择正确的数据结构是解决这些问题的关键部分。
复杂度分析
确定这个问题的时间复杂度对于一个「简单」级别的问题来说是一个挑战。如果您对这些问题还不熟悉,可以尝试只计算 getNext(n) 函数的时间复杂度。
时间复杂度
:.O(243⋅3+logn+loglogn+logloglogn)… = O(logn)。
- 查找给定数字的下一个值的成本为 O(logn),因为我们正在处理数字中的每位数字,而数字中的位数由 logn 给定。
- 要计算出总的时间复杂度,我们需要仔细考虑循环中有多少个数字,它们有多大。
- 我们在上面确定,一旦一个数字低于 243243,它就不可能回到 243 以上。因此,我们就可以用 243 以下最长循环的长度来代替 243,不过,因为常数无论如何都无关紧要,所以我们不会担心它。
- 对于高于 243 的 n,我们需要考虑循环中每个数高于 243 的成本。通过数学运算,我们可以证明在最坏的情况下,这些成本将是 O(logn)+O(loglogn)+O(logloglogn)…。幸运的是,O(logn) 是占主导地位的部分,而其他部分相比之下都很小(总的来说,它们的总和小于logn),所以我们可以忽略它们。
空间复杂度
:O(logn)。与时间复杂度密切相关的是衡量我们放入哈希集合中的数字以及它们有多大的指标。对于足够大的 n,大部分空间将由 n 本身占用。我们可以很容易地优化到 O(243⋅3)=O(1),方法是只保存集合中小于 243 的数字,因为对于较高的数字,无论如何都不可能返回到它们。
T202.快乐数 |
哈希映射 - 用法
哈希映射
是用于存储 (key, value)
键值对的一种实现。
|
实践 - 两数之和
T1.两数之和 |
复杂度分析
时间复杂度:O(N),其中 N 是数组中的元素数量。对于每一个元素 x,我们可以 O(1) 地寻找 target - x。
空间复杂度:O(N),其中 N 是数组中的元素数量。主要为哈希表的开销。
实践 - 同构字符串
给定两个字符串 s 和 t,判断它们是否是同构的。
如果 s 中的字符可以按某种映射关系替换得到 t ,那么这两个字符串是同构的。
每个出现的字符都应当映射到另一个字符,同时不改变字符的顺序。不同字符不能映射到同一个字符上,相同字符只能映射到同一个字符上,字符可以映射到自己本身。
T205.同构字符串 |
复杂度分析
时间复杂度:O(n),其中 n 为字符串的长度。我们只需同时遍历一遍字符串 s 和 t 即可。
空间复杂度:O(∣Σ∣),其中 Σ 是字符串的字符集。哈希表存储字符的空间取决于字符串的字符集大小,最坏情况下每个字符均不相同,需要 O(∣Σ∣) 的空间。
实践 - 两个列表的最小索引总和
假设Andy和Doris想在晚餐时选择一家餐厅,并且他们都有一个表示最喜爱餐厅的列表,每个餐厅的名字用字符串表示。
你需要帮助他们用最少的索引和找出他们共同喜爱的餐厅。 如果答案不止一个,则输出所有答案并且不考虑顺序。 你可以假设总是存在一个答案。
示例 1:
输入:
[“Shogun”, “Tapioca Express”, “Burger King”, “KFC”]
[“Piatti”, “The Grill at Torrey Pines”, “Hungry Hunter Steakhouse”, “Shogun”]
输出: [“Shogun”]
解释: 他们唯一共同喜爱的餐厅是“Shogun”。
T599.两个列表的最小索引总和 |
复杂度分析
时间复杂度
:O(l1 ∗l2 ∗x)。list1 中的每个字符串都与 list2 中的字符串进行了比较。l_1 和 l_2 是 list1 和 list2 列表的长度,x 是字符串的平均长度。
空间复杂度
:O(l1 ∗l2 ∗x) 。最坏情况下,list1 和 list2 中所有字符串都相同,那么哈希表最大会变成 l1 ∗l2 ∗x,其中 x 是字符串的平均长度。
哈希表与滑动窗口
什么是滑动窗口
顾名思义,滑动窗口就是将数组或字符串中的一个分段,形象地看作一个“窗口”,通过更改“窗口”的左右边界,实现窗口的移动、缩放等操作。
如图所示,窗口的左右边界使用 i, j 实现,当两个指针同时向右移动,则为窗口的“滑动”操作;而当其中一个指针不动,另一个指针向右移动时,窗口会扩大或缩小。
需要注意的是,一般情况下,在实际题目中指针只会沿 一个方向 移动。
对于基本情况,滑动窗口使用双指针即可实现,但是有时会出现一些问题,假如如窗口中的元素为 “(aaaaaabc)”,左右边界指针分别为 i 和 j,如果我们想要将窗口变为 “(abc)”,可以选择将左边界指针 i 向右移动 5 步。
以上情况会造成时间的浪费,假如建立元素 a 为键,下标为值的哈希表 {“a”:5},那么元素 a 只需 1 步即可“跳跃”到下标 5 的位置。
优化运行时间,是滑动窗口问题中使用哈希表的一个目的。除此之外,哈希表还被用来统计窗口中的元素个数,以判断当前窗口的状态是否满足条件。以下是滑动窗口类题目的总结:
例题1:存在重复元素 II(T219)
例题2:替换后的最长重复字符(T424)
例题3:最大连续 1 的个数 III(T1004)
例题4:至多包含两个不同字符的最长子串(T159)
设计键
在前面的问题中,我们主要考虑了根据值的不同形式构造哈希表,而键的设计相对简单。然而在某些题目中,我们可能已经想到了哈希表的解法,但受限于 无法设计出合适的键,本章将对键的设计做出总结。
示例
我们来看一个例子:
给定一组字符串,将字母异位词组合在一起。
字母异位词指字母相同,但排列不同的字符串,比如 “ate” 与 “eat” 是一组字母异位词。如果单纯地把每个字符串作为键,显然没有起到任何作用。
解决方案
经过分析发现,同一组字母异位词中,如果按照字典序排列,得到的字符串相同,且长度相等。这时我们自然想到以 按照字典序排列后的字符串 作为键,这样就能合理区分出字母异位词了。例如对于 “ate” 和 “eat” 构造的哈希表为 {“aet”:[“ate”, “eat”]}。
设计键的策略可能是非常 棘手的,接下来我们将提供一些习题,并对键的设计技巧做出总结。
例题1:字母异位词分组(T49)
例题2:移位字符串分组(T249)
例题3:有效的数独(T36)
例题4:寻找重复的子树(T652)
例题5:稀疏矩阵的乘法(T311)
总结
以下是一些关于设计键的方法总结,我们鼓励你根据以下思路及做题经验,做出自己的总结。
当字符串 / 数组中每个元素的顺序不重要时,可以使用 排序后的字符串 / 数组 作为键。
如果只关心每个值的偏移量,通常是第一个值的偏移量,则可以使用偏移量作为键。
在树的题目中,可以直接使用 TreeNode 作为键。但在大多数情况下,可以将 子树的序列化结果 作为键。
在矩阵中,你可能会使用 行索引 或 列索引 作为键。
如果需要将矩阵分块,可以将行索引和列索引进行组合以标识该元素属于哪个 块。
有时,在矩阵中,你可能会希望将对角线的元素组合在一起。