第八单元 散列表
lesson 1 什么是散列表(哈希表)?
一、散列思想:假设你们班级100个同学每个人的学号是由院系-年级-班级和编号组成,例如学号为01100168表示是1系,10级1班的68号。为了快速查找到68号的成绩信息,可以建立一张表,但是不能用学号作为下标,学号的数值实在太大。因此将学号除以1100100取余,即得到编号作为该表的下标,那么,要查找学号为01100168的成绩的时候,只要直接访问表下标为68的数据即可。这就能够在O(1)时间复杂度内完成成绩查找。
实际上这里就用到了散列的思想。本文重在介绍散列的思想以及散列需要考虑的问题。
二、散列表(哈希表)
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存存储位置的数据结构。
它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
理想散列表(哈希表)是一个包含关键字的具有固定大小的数组,它能够以常数时间O(1)执行插入,删除和查找操作。
-
——每个关键字被映射到0到数组大小N-1范围,并且放到合适的位置,这个映射规则就叫散列函数
-
——理想情况下,两个不同的关键字映射到不同的单元,然而由于数组单元有限,关键字范围可能远超数组单元,因此就会出现两个关键字散列到同一个值得时候,这就是散列冲突
实例演示:
通过前面的描述,我们已经了解了一些基本概念,现在来看一个实例。
假设有一个大小为7的表,现在,要将13,18,19,50,20散列到表中。
-
选择散列函数,例如使用hash(x)=x%7作为散列函数
-
计算数据散列值,并放到合适的位置
计算13 % 7得到6,因此将13放到下标为6的位置:
计算18 % 7得到4,因此将18放到下标为4的位置:
计算19 % 7得到5,因此将19放到下标为5的位置:
计算50 % 7得到1,因此将50放到下标为1的位置:
计算20 % 7得到6,因此将20放到下标为6的位置,但是此时6的位置已经被占用了,因此就产生了散列冲突,关于散列冲突的解决,我们后面再介绍。
将数据散列之后,如何从表中查找呢?例如,查找数值为50的数据位置,只需要计算50 % 7,得到下标1,访问下标1的位置即可。但是如果考虑散列冲突,就没有那么简单了。
通过这个实例,了解了以下几个概念:
-
——散列函数,散列函数的选择非常重要
-
——散列冲突,涉及散列表时,因尽量避免散列冲突,对于冲突也要有好的解决方案
-
——快速从散列表中查找数据
三、冲突解决
解决散列冲突通常有以下几种方法:
-
***拉链法
-
***开放定址法
-
***再散列
-
***…
-
1. 拉链法
分离链接法的做法是将同一个值的关键字保存在同一个表中。例如,对于前面的例子,如果再要插入元素20,则在下标为6的位置存储表头,而表的内容是13和20。
这种方法的特点是需要另外分配新的单元来存储散列到同一个位置的数据。
查找的时候,除了根据计算出来的散列值找到对应位置外,还需要在链表上进行搜索。而在单链表上的查找速度是很慢的。另外散列函数如果设计得好,冲突的概率其实也会很小。
-
2. 开放定址法
而开放定址法的思想是,如果冲突发生,就选择另外一个可用的位置。
而开放定址法中也有常见的几种策略。
-
(1)线性探测法
还是以前面的例子来说,如果此时再要插入20,则20 % 7 = 6,但是6的位置已有元素,因此探测下一个位置(6+1)%7,在这里就是下标为0的位置。因此20的存储在下标为0的位置。但这种方式的一个问题是,可能造成一次聚集,因为一旦冲突发生,为了处理冲突就会占用下一个位置,而如果冲突较多时,就会出现数据都聚集在一块区域。这样就会导致任何关键字都需要多次尝试才可能解决冲突。
- (2)平方探测法
顾名思义,如果说前面的探测函数是F(i)= i % 7,那么平方探测法就是F(i)= (i^2 )% 7。但是这也同样会产生二次聚集问题。
-
(3)双散列
为了避免聚集,在探测时选择跳跃式的探测,即再使用一个散列函数,用来计算探测的位置。假设前面的散列函数为hash1(X),用于探测的散列函数为hash2(X),那么一种流行的选择是F(i) = i * hash2(X),即第一次冲突时探测hash1(X)+hash2(X)的位置,第二次探测hash1(X)+2hash2(X)的位置。
可以看到,无论是哪种开放定址法,它都要求表足够大。
-
3. 再散列
我们前面也说到,散列表可以认为是具有固定大小的数组,那么如果插入新的数据时散列表已满,或者散列表所剩容量不多该怎么办?这个时候就需要再散列,常见做法是,建立一个是原来两倍大小的散列表,将原来表中的关键字重新散列到新表中。
-
四、散列表的应用
-
散列表应用很广泛。例如做文件校验或数字签名。当然还有快速查询功能的实现。例如,redis中的字典结构就使用了散列表,使用MurmurHash算法来计算字符串的hash值,并采用拉链法处理冲突,当散列表的装载因子(关键字个数与散列表大小的比)接近某个大小时,进行再散列。
-
五、总结
-
一个设计良好的散列表能够几乎在O(1)时间复杂度内完成插入,删除和查找,但前提是散列函数设计得足够优雅,以及有着合适散列冲突解决方案。常见冲突解决方案有:拉链法、开放地址检测法。其中拉链法在实际中是很常见的一种解决方案。另外本文重点说明什么是散列表(哈希表),因此没有涉及具体的代码,后面将会通过实例来看散列表的实际应用。
-
lesson 2 散列表(哈希表)相关习题
-
1. 两数之和
题目描述
给定一个整数数组
nums
和一个目标值target
,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。示例:
给定 nums = [2, 7, 11, 15], target = 9 因为 nums[0] + nums[1] = 2 + 7 = 9 所以返回 [0, 1]
题目解析
使用散列表来解决该问题。
首先设置一个 map 容器 record 用来记录元素的值与索引,然后遍历数组 nums 。每次遍历时使用临时变量 complement 用来保存目标值与当前值的差值。在此次遍历中查找 record ,查看是否有与 complement 一致的值,如果查找成功则返回查找值的索引值与当前变量的值i。如果未找到,则在 record 保存该元素与索引值 i
两数之和代码实现
// 1. Two Sum // 时间复杂度:O(n) // 空间复杂度:O(n) class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int,int> record; for(int i = 0 ; i < nums.size() ; i ++){ int complement = target - nums[i]; if(record.find(complement) != record.end()){ int res[] = {i, record[complement]}; return vector<int>(res, res + 2); } record[nums[i]] = i; } } };
2. 无重复字符的最长子串
题目描述
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
题目解析
建立一个 HashMap ,建立每个字符和其最后出现位置之间的映射,然后再定义两个变量 res 和 left ,其中 res 用来记录最长无重复子串的长度,left 指向该无重复子串左边的起始位置的前一个,一开始由于是前一个,所以在初始化时就是 -1。接下来遍历整个字符串,对于每一个遍历到的字符,如果该字符已经在 HashMap 中存在了,并且如果其映射值大于 left 的话,那么更新 left 为当前映射值,然后映射值更新为当前坐标 i,这样保证了 left 始终为当前边界的前一个位置,然后计算窗口长度的时候,直接用 i-left 即可,用来更新结果 res 。
代码实现
class Solution { public: int lengthOfLongestSubstring(string s) { int res = 0, left = -1, n = s.size(); unordered_map<int, int> m; for (int i = 0; i < n; ++i) { if (m.count(s[i]) && m[s[i]] > left) { left = m[s[i]]; } m[s[i]] = i; res = max(res, i - left); } return res; } };
拓展
此题也可以使用滑动窗口的概念来处理。
建立一个 256 位大小的整型数组 freg ,用来建立字符和其出现位置之间的映射。
维护一个滑动窗口,窗口内的都是没有重复的字符,去尽可能的扩大窗口的大小,窗口不停的向右滑动。
-
(1)如果当前遍历到的字符从未出现过,那么直接扩大右边界;
-
(2)如果当前遍历到的字符出现过,则缩小窗口(左边索引向右移动),然后继续观察当前遍历到的字符;
-
(3)重复(1)(2),直到左边索引无法再移动;
-
(4)维护一个结果 res,每次用出现过的窗口大小来更新结果 res ,最后返回 res 获取结果。
代码实现
// 3. Longest Substring Without Repeating Characters // 滑动窗口 // 时间复杂度: O(len(s)) // 空间复杂度: O(len(charset)) class Solution { public: int lengthOfLongestSubstring(string s) { int freq[256] = {0}; int l = 0, r = -1; //滑动窗口为s[l...r] int res = 0; // 整个循环从 l == 0; r == -1 这个空窗口开始 // 到l == s.size(); r == s.size()-1 这个空窗口截止 // 在每次循环里逐渐改变窗口, 维护freq, 并记录当前窗口中是否找到了一个新的最优值 while(l < s.size()){ if(r + 1 < s.size() && freq[s[r+1]] == 0){ r++; freq[s[r]]++; }else { //r已经到头 || freq[s[r+1]] == 1 freq[s[l]]--; l++; } res = max(res, r-l+1); } return res; } };
3. 三数之和
-
-
题目描述
给定一个包含 n 个整数的数组
nums
,判断nums
中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。题目解析
题目需要我们找出三个数且和为 0 ,那么除了三个数全是 0 的情况之外,肯定会有负数和正数,所以一开始可以先选择一个数,然后再去找另外两个数,这样只要找到两个数且和为第一个选择的数的相反数就行了。也就是说需要枚举 a 和 b ,将 c 的存入 map 即可。
需要注意的是返回的结果中,不能有有重复的结果。这样的代码时间复杂度是 O(n^2)。在这里可以先将原数组进行排序,然后再遍历排序后的数组,这样就可以使用双指针以线性时间复杂度来遍历所有满足题意的两个数组合。
代码实现
class Solution { public: vector<vector<int>> threeSum(vector<int>& nums) { vector<vector<int>> res; sort(nums.begin(), nums.end()); if (nums.empty() || nums.back() < 0 || nums.front() > 0) return {}; for (int k = 0; k < nums.size(); ++k) { if (nums[k] > 0) break; if (k > 0 && nums[k] == nums[k - 1]) continue; int target = 0 - nums[k]; int i = k + 1, j = nums.size() - 1; while (i < j) { if (nums[i] + nums[j] == target) { res.push_back({nums[k], nums[i], nums[j]}); while (i < j && nums[i] == nums[i + 1]) ++i; while (i < j && nums[j] == nums[j - 1]) --j; ++i; --j; } else if (nums[i] + nums[j] < target) ++i; else --j; } } return res; } };
4. 重复的 DNA 序列
题目描述
所有 DNA 由一系列缩写为 A,C,G 和 T 的核苷酸组成,例如:“ACGAATTCCG”。在研究 DNA 时,识别 DNA 中的重复序列有时会对研究非常有帮助。
编写一个函数来查找 DNA 分子中所有出现超过一次的 10 个字母长的序列(子串)。
题目解析
首先,先将 A , C , G , T 的 ASCII 码用二进制来表示:
A: 0100 0001 C: 0100 0011 G: 0100 0111 T: 0101 0100
通过观察发现每个字符的后三位都不相同,因此可以用末尾的三位来区分这四个字符。
题目要求是查找 10 个字母长的序列,这里我们将每个字符用三位来区分的话,10 个字符就需要 30 位 ,在32位机上也 OK 。
为了提取出后 30 位,需要使用 mask ,取值为 0x7ffffff(二进制表示含有 27 个 1) ,先用此 mask 可取出整个序列的后 27 位,然后再向左平移三位可取出 10 个字母长的序列 ( 30 位)。
为了保存子串的频率,这里使用哈希表。
首先当取出第十个字符时,将其存在哈希表里,和该字符串出现频率映射,之后每向左移三位替换一个字符,查找新字符串在哈希表里出现次数,如果之前刚好出现过一次,则将当前字符串存入返回值的数组并将其出现次数加一,如果从未出现过,则将其映射到 1。
解题代码
class Solution { public: vector<string> findRepeatedDnaSequences(string s) { vector<string> res; if (s.size() <= 10) return res; int mask = 0x7ffffff, cur = 0; unordered_map<int, int> m; for (int i = 0; i < 9; ++i) { cur = (cur << 3) | (s[i] & 7); } for (int i = 9; i < s.size(); ++i) { cur = ((cur & mask) << 3) | (s[i] & 7); if (m.count(cur)) { if (m[cur] == 1) res.push_back(s.substr(i - 9, 10)); ++m[cur]; } else { m[cur] = 1; } } return res; } };
5. 两个数组的交集
-
题目描述
给定两个数组,编写一个函数来计算它们的交集。
题目解析
容器类 set 的使用。
-
遍历 num1,通过 set 容器 record 存储 num1 的元素
-
遍历 num2,在 record 中查找是否有相同的元素,如果有,用 set 容器 resultSet 进行存储
-
将 resultSet 转换为 vector 类型
两个数组的交集代码实现
// 时间复杂度: O(nlogn) // 空间复杂度: O(n) class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { set<int> record; for( int i = 0 ; i < nums1.size() ; i ++ ){ record.insert(nums1[i]); } set<int> resultSet; for( int i = 0 ; i < nums2.size() ; i ++ ){ if(record.find(nums2[i]) != record.end()){ resultSet.insert(nums2[i]); } } vector<int> resultVector; for(set<int>::iterator iter = resultSet.begin(); iter != resultSet.end(); iter ++ ){ resultVector.push_back(*iter); } return resultVector; } };
6. 两个数组的交集 II
题目来源于 LeetCode 上第 350 号问题: Intersection of Two Arrays II。
题目描述
给定两个数组,编写一个函数来计算它们的交集。
示例 1:
输入: nums1 = [1,2,2,1], nums2 = [2,2] 输出: [2,2]
示例 2:
输入: nums1 = [4,9,5], nums2 = [9,4,9,8,4] 输出: [4,9]
题目解析
与上题 两个数组的交集 类似。只不过这里使用的是 map 。
-
遍历 num1,通过 map 容器 record 存储 num1 的元素与频率;
-
遍历 num2 ,在 record 中查找是否有相同的元素(该元素的存储频率大于 0 ),如果有,用 map 容器resultVector 进行存储,同时该元素的频率减一。
动画描述
两个数组的交集 II代码实现
// 时间复杂度: O(nlogn) // 空间复杂度: O(n) class Solution { public: vector<int> intersect(vector<int>& nums1, vector<int>& nums2) { map<int, int> record; for(int i = 0 ; i < nums1.size() ; i ++){ record[nums1[i]] += 1; } vector<int> resultVector; for(int i = 0 ; i < nums2.size() ; i ++){ if(record[nums2[i]] > 0){ resultVector.push_back(nums2[i]); record[nums2[i]] --; } } return resultVector; } };
7. 回旋镖的数量(看不懂)
-
-
题目描述
给定平面上 n 对不同的点,“回旋镖” 是由点表示的元组
(i, j, k)
,其中i
和j
之间的距离和i
和k
之间的距离相等(需要考虑元组的顺序)。找到所有回旋镖的数量。你可以假设 n 最大为 500,所有点的坐标在闭区间 [-10000, 10000] 中。
题目解析n 最大为 500,可以使用时间复杂度为 O(n^2)的算法。
-
遍历所有的点,让每个点作为一个锚点
-
然后再遍历其他的点,统计和锚点距离相等的点有多少个
-
然后分别带入 n(n-1) 计算结果并累加到 res 中
注意点:
-
如果有一个点a,还有两个点 b 和 c ,如果 ab 和 ac 之间的距离相等,那么就有两种排列方法 abc 和 acb ;
-
如果有三个点b,c,d 都分别和 a 之间的距离相等,那么有六种排列方法,abc, acb, acd, adc, abd, adb;
-
如果有 n 个点和点 a 距离相等,那么排列方式为 n(n-1);
-
计算距离时不进行开根运算, 以保证精度;
-
只有当 n 大于等于 2 时,res 值才会真正增加,因为当n=1时,增加量为
1 * ( 1 - 1 ) = 0
。
8. 四数相加 II// 时间复杂度: O(n^2) // 空间复杂度: O(n) class Solution { public: int numberOfBoomerangs(vector<pair<int, int>>& points) { int res = 0; for( int i = 0 ; i < points.size() ; i ++ ){ // record中存储 点i 到所有其他点的距离出现的频次 unordered_map<int, int> record; for(int j = 0 ; j < points.size() ; j ++){ if(j != i){ // 计算距离时不进行开根运算, 以保证精度 record[dis(points[i], points[j])] += 1; } } for(unordered_map<int, int>::iterator iter = record.begin() ; iter != record.end() ; iter ++){ res += (iter->second) * (iter->second - 1); } } return res; } private: int dis(const pair<int,int> &pa, const pair<int,int> &pb){ return (pa.first - pb.first) * (pa.first - pb.first) + (pa.second - pb.second) * (pa.second - pb.second); } };
题目描述给定四个包含整数的数组列表 A , B , C , D ,计算有多少个元组
(i, j, k, l)
,使得A[i] + B[j] + C[k] + D[l] = 0
。为了使问题简单化,所有的 A, B, C, D 具有相同的长度 N,且 0 ≤ N ≤ 500 。所有整数的范围在 -2^28 到 2^28- 1 之间,最终结果不会超过 2^31 - 1 。
题目解析与 Two Sum 极其类似,使用哈希表来解决问题。
-
把 A 和 B 的两两之和都求出来,在哈希表中建立两数之和与其出现次数之间的映射;
-
遍历 C 和 D 中任意两个数之和,只要看哈希表存不存在这两数之和的相反数就行了。
// 时间复杂度: O(n^2) // 空间复杂度: O(n^2) class Solution { public: int fourSumCount(vector<int>& A, vector<int>& B, vector<int>& C, vector<int>& D) { unordered_map<int,int> hashtable; for(int i = 0 ; i < A.size() ; i ++){ for(int j = 0 ; j < B.size() ; j ++){ hashtable[A[i]+B[j]] += 1; } } int res = 0; for(int i = 0 ; i < C.size() ; i ++){ for(int j = 0 ; j < D.size() ; j ++){ if(hashtable.find(-C[i]-D[j]) != hashtable.end()){ res += hashtable[-C[i]-D[j]]; } } } return res; } };
-
-
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律