哈希表理论基础
哈希表
了解哈希表的内部实现原理,哈希函数,哈希碰撞,以及常见哈希表的区别,数组,set 和map。
什么时候想到用哈希法?
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
哈希表->hash table 散列表
哈希表是根据关键码的值而直接进行访问的数据结构。
数组其实就是一张哈希表,以数组为例:
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如图:
那么哈希表能解决什么问题呢?——一般哈希表都是用来快速判断一个元素是否出现集合里。
例如:现需要查询一个学生是否在某所学校里(通过学生姓名查询),如果要枚举的话,时间复杂度是O(n),但如果使用哈希表的话,只需要O(1)就可以做到。
只需要初始化把这所学校里学生的名字都存在哈希表里,在查询时通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到 哈希函数(hash function)
哈希函数
哈希函数,将学生姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了,然后就可以通过查询索引下标快速知道这位同学是否在这所学校了。
哈希函数如下图所示
通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办?
就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
此时引入哈希碰撞。
哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
(数据规模是dataSize, 哈希表的大小为tableSize)
线性探测法
使用线性探测法,一定要保证tableSize>dataSize。需要依靠哈希表中的空位来解决碰撞问题。
常见的三种哈希结构
使用哈希法来解决问题的时候,一般会选择如下三种数据结构:
- 数组
- set 集合
- map 映射
总结: 当遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法
数据小用数组,数据大用set,数据比较散用map k-v
242.有效的字母异位词
给定两个字符串s和t,编写一个函数来判断 t 是否是 s 的 字母异位词
示例 1: 输入: s = “anagram”, t = “nagaram” 输出: true
示例 2: 输入: s = “rat”, t = “car” 输出: false
说明: 你可以假设字符串只包含小写字母。a-z
(确保两个字符串是由相同字母组成)
定义数组hash[26],大小为26 就可以了,初始化为0,统计第一个字符串 里每个字母出现的频率
需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
再遍历 字符串s的时候,只需要将 s[i] - ‘a’ 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
那看一下如何检查字符串t中是否出现了这些字符,同样在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。
最后如果数组所有元素都为零0,说明字符串s和t是字母异位词,return true。
时间复杂度为O(n),空间上因为定义是的一个常量大小的辅助数组,所以空间复杂度为O(1)。
class Solution {
public boolean isAnagram(String s, String t) {
if(s.length() != t.length()){
return false;
}
int table[] = new int[26];
for(int i=0;i<s.length();i++){
table[s.charAt(i) - 'a']++ ;
}
for(int i=0;i<t.length();i++){
table[t.charAt(i) - 'a'] -- ;
}
for(int i=0;i<26;i++){
if (table[i] != 0){
return false;
}
}
return true;
}
}
349. 两个数组的交集
首先是考虑用数组来做哈希表,但是,使用数组来做哈希的题目,是因为题目都限制了数值的大小,而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。而且如果哈希值比较少、特别分散、跨度非常大,使用数组就会造成空间的极大浪费。
这时考虑使用另一种结构体—>set
与其他集合类型如List 和 Map不同,set强调的是集合中元素的唯一性,这使得它在需要去重、快速查找等场景下显得尤为重要。
set的常见实现类
Hashset
HashSet 是最常用的set实现类之一,它基于哈希表实现,具有快速的插入、删除、查找操作,它不维护元素的顺序,遍历顺序可能和插入顺序不同。
LinkedHashSet
LinkedHashSet是HashSet的子类,它维护着一个双向链表,以保证元素的插入顺序。
因此,遍历LinkedHashSet时,元素将按照其插入顺序返回。
TreeSet
TreeSet实现了SortedSet接口,并基于红黑树实现。它保证元素按自然顺序或指定的比较器顺序排序。
1.为什么需要判断 null 和 length == 0?
if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {
return new int[0];
}
**判断 null:**是为了防止出现空指针异常。当数组为 null 时,访问其任何属性或方法都会引发异常。
**判断 length == 0:**即判断数组是否为空。空数组(长度为 0)意味着它没有任何元素,不可能和另一个数组有交集,直接返回空数组是合理的。
直接判断数组长度是不够的,因为在数组为 null 时无法访问其属性,会抛出异常。
2. 使用 HashSet 来存储数据
Set<Integer> set1 = new HashSet<>();
Set<Integer> resSet = new HashSet<>();
set1 用于存储 nums1 中的所有元素,利用 HashSet 的特性,去重并且支持快速查找。
resSet 用于存储 nums2 和 nums1 交集中的元素,最终返回的就是这个集合。
3.遍历 nums1 数组,填充 set1
for (int i : nums1) {
set1.add(i);
}
遍历数组 nums1 中的每个元素,将它们添加到 set1 中。HashSet 会自动处理去重,保证每个元素在 set1 中只出现一次。
4.遍历 nums2 数组,查找交集
for (int i : nums2) {
if (set1.contains(i)) {
resSet.add(i);
}
}
遍历数组 nums2 中的每个元素,对于每个元素 i,检查它是否存在于 set1 中:如果存在,说明 i 是 nums1 和 nums2 的交集元素,将 i 加入到 resSet 中。
5.将结果集合转换为数组并返回
(1)
return resSet.stream().mapToInt(x -> x).toArray();
这行代码使用 Java 8 的流式 API,将 resSet(一个 HashSet)转换为一个普通的整数数组:
- stream():将 resSet 转换为流。
- mapToInt(x -> x):将流中的元素(Integer)转换为原始类型 int。
- toArray():将流中的元素收集成一个 int[] 数组。
(2)手动转换集合为数组
int[] arr = new int[resSet.size()];
int j = 0;
for(int i : resSet){
arr[j++] = i;
}
return arr;
这是另一个转换 resSet 为数组的方法。具体步骤是:
首先,创建一个大小为 resSet.size() 的新数组 arr,用于存放交集元素。
然后,遍历 resSet 中的每个元素,并逐个将元素放入数组 arr 中。
这种方法手动实现了集合到数组的转换,最终返回数组 arr。
202. 快乐数
class Solution {
public boolean isHappy(int n) {
Set<Integer> record = new HashSet<>();
while (n != 1 && !record.contains(n)) {
record.add(n);
n = getNextNumber(n);
}
return n == 1;
}
private int getNextNumber(int n) {
int res = 0;
while (n > 0) {
int temp = n % 10;
res += temp * temp;
n = n / 10;
}
return res;
}
}
- isHappy(int n) 方法
Set<Integer> record = new HashSet<>();
创建一个hashset集合,用来记录遇到的数字,set是一种不允许重复元素的集合,如果一个数字已经出现过,则说明我们进入了一个循环,最终无法得到1.
while (n != 1 && !record.contains(n))
这个 while 循环判断两个条件:
- n != 1:如果当前数字 n 不是 1,就继续进行计算。
- !record.contains(n):如果数字 n 之前没有出现过,就继续计算;如果它已经出现过,说明进入了无限循环,直接返回 false。
record.add(n);
把当前数字 n 加入到 record 集合中,以便后续检查是否进入了循环。
n = getNextNumber(n);
调用 getNextNumber(n) 方法来计算当前数字的下一个数字。快乐数的下一个数字是当前数字各位数字的平方和。
return n == 1;
如果退出 while 循环时 n == 1,说明数字是快乐数,返回 true。否则,返回 false。
- getNextNumber(int n) 方法
这个方法计算给定数字 n 的下一个数字,也就是将 n 的每一位数字的平方相加。
private int getNextNumber(int n) {
int res = 0;//创建一个变量 res 来保存每个数字平方和的结果。
while (n > 0) {//循环通过 n 的每一位,直到 n 变为 0。
int temp = n % 10; // 获取 n 的最后一位数字
res += temp * temp; // 将该数字的平方加到 res 上
n = n / 10; // 去掉 n 的最后一位
}
return res;//返回计算得到的平方和。
}
1. 两数之和
很明显暴力的解法是两层for循环查找,时间复杂度是O(n^2)。
242. 有效的字母异位词 这道题目是用数组作为哈希表来解决哈希问题,349. 两个数组的交集这道题目是通过set作为哈希表来解决哈希问题。
什么时候使用哈希法?
当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
本题需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
那么我们就应该想到使用哈希法了。
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
使用数组和set来做哈希法的局限:
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map 。
map是一种key-value的存储结构,可以用key保存数值,用value再保存数值所在的下标。
明确两点:
- 1.map用来做什么?
map目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下标,这样才能找到与当前元素相匹配的(相加等于target) - 2.map中key和value分别表示什么?
我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。
所以 map中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
在遍历数组的时候,只需向map查询是否有和目前遍历元素匹配的数值,如果有,就找到匹配对;如果没有,就把目前遍历的元素放进map中,因为map存放的就是我们访问过的元素。
//使用哈希表
public int[] twoSum(int[] nums, int target) {
int[] res = new int[2];//创建一个长度为 2 的数组 res,用来存放两个元素的下标。返回结果时,会将这两个下标存储在 res[0] 和 res[1] 中。
if(nums == null || nums.length == 0){
return res;
}//如果输入的数组 nums 为 null 或者数组长度为 0,直接返回一个空的数组 res。这是一个边界检查,避免后续操作中出现空指针异常。
Map<Integer, Integer> map = new HashMap<>();//创建一个哈希表 map,它用来存储数组中已经遍历过的数字及其对应的索引。这里使用 Map<Integer, Integer>,第一个 Integer 是数组中的值,第二个 Integer 是该值的索引。
for(int i = 0; i < nums.length; i++){//遍历数组 nums 中的每个元素 nums[i],并且记录当前的索引 i。
int temp = target - nums[i]; // 遍历当前元素,并在map中寻找是否有匹配的key。例如,假设 target = 9,当前元素 nums[i] = 2,那么 temp = 9 - 2 = 7。接下来我们要查找 temp(即 7)是否在哈希表中已经存在。
if(map.containsKey(temp)){//检查 temp 是否已经存在于哈希表 map 中
res[1] = i;//如果在哈希表中找到了 temp,那么当前元素 nums[i] 就是和目标值相加的第二个数字。因此,将当前数字的索引 i 存储在 res[1] 中。
res[0] = map.get(temp);//获取 temp 对应的值(即差值 temp 在哈希表中保存的数字的索引),并将其存储在 res[0] 中。这个值表示第一个数字的位置。
break;//找到答案后,我们可以退出循环,因为题目中已经说明了每个输入只会有一个答案,因此不需要继续遍历数组。
}
map.put(nums[i], i); // 如果没找到匹配对,就把访问过的元素和下标加入到map中
}
return res;//循环结束后,返回结果数组 res,它包含了和为目标值的两个数字的下标。
}
时间复杂度与空间复杂度:
- 时间复杂度: O(n)
我们只需要遍历一次数组,查找和插入哈希表的操作都是 O(1),因此总时间复杂度是 O(n),其中 n 是数组的长度。 - 空间复杂度: O(n)
哈希表存储了数组中的元素及其索引,最多需要存储 n 个元素,因此空间复杂度是 O(n)。
总结:
这段代码利用哈希表的快速查找特性,减少了暴力解法中的重复计算,从而将时间复杂度从 O(n^2) 降到 O(n)。通过计算差值 temp = target - nums[i],快速查找是否存在一个以前的数字,使得它们的和等于目标值,从而提高了解题效率。