算法之哈希表
算法之哈希表
介绍
散列表思想
假设你们班级100个同学每个人的学号是由院系-年级-班级和编号组成,例如学号为01100168表示是1系,10级1班的68号。为了快速查找到68号的成绩信息,可以建立一张表,但是不能用学号作为下标,学号的数值实在太大。因此将学号除以1100100取余,即得到编号作为该表的下标,那么,要查找学号为01100168的成绩的时候,只要直接访问表下标为68的数据即可。这就能够在O(1)时间复杂度内完成成绩查找。
理想散列表(哈希表)是一个包含关键字的具有固定大小的数组,它能够以常数时间执行插入,删除和查找操作。
- 每个关键字被映射到0到数组大小N-1范围,并且放到合适的位置,这个映射规则就叫散列函数
- 理想情况下,两个不同的关键字映射到不同的单元,然而由于数组单元有限,关键字范围可能远超数组单元,因此就会出现两个关键字散列到同一个值得时候,这就是散列冲突
实例演示
通过前面的描述,我们已经了解了一些基本概念,现在来看一个实例。
假设有一个大小为7的表,现在,要将13,18,19,50,20散列到表中。
- 选择散列函数,例如使用hash(x)=x%7作为散列函数
- 计算数据散列值,并放到合适的位置
计算13 % 7得到6,因此将13放到下标为6的位置:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
13 |
计算18 % 7得到4,因此将18放到下标为4的位置:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
18 | 13 |
计算19 % 7得到5,因此将19放到下标为5的位置:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
18 | 19 | 13 |
计算50 % 7得到1,因此将50放到下标为1的位置:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
50 | 18 | 19 | 13 |
计算20 % 7得到6,因此将20放到下标为6的位置,但是此时6的位置已经被占用了,因此就产生了散列冲突,关于散列冲突的解决,我们后面再介绍。
将数据散列之后,如何从表中查找呢?例如,查找数值为50的数据位置,只需要计算50 % 7,得到下标1,访问下标1的位置即可。但是如果考虑散列冲突,就没有那么简单了。
通过这个实例,了解了以下几个概念:
- 散列函数,散列函数的选择非常重要
- 散列冲突,涉及散列表时,因尽量避免散列冲突,对于冲突也要有好的解决方案
- 快速从散列表中查找数据
冲突解决
解决散列冲突通常有以下几种方法:
- 拉链法
- 开放定址法
- 再散列
拉链法
分离链接法的做法是将同一个值的关键字保存在同一个表中。例如,对于前面:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
50 | 18 | 19 | 13 |
如果再要插入元素20,则在下标为6的位置存储表头,而表的内容是13和20。
这种方法的特点是需要另外分配新的单元来存储散列到同一个位置的数据。
查找的时候,除了根据计算出来的散列值找到对应位置外,还需要在链表上进行搜索。而在单链表上的查找速度是很慢的。另外散列函数如果设计得好,冲突的概率其实也会很小。
拉链法也叫链地址法,java中hashMap就是使用这个办法解决冲突的
开放定址法
而开放定址法的思想是,如果冲突发生,就选择另外一个可用的位置。
而开放定址法中也有常见的几种策略。
- 线性探测法
还是以前面的为例:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
50 | 18 | 19 | 13 |
如果此时再要插入20,则20 % 7 = 6,但是6的位置已有元素,因此探测下一个位置(6+1)%7,在这里就是下标为0的位置。因此20的存储位置如下:
0 | 1 | 2 | 3 | 4 | 5 | 6 |
---|---|---|---|---|---|---|
20 | 50 | 18 | 19 | 13 |
但这种方式的一个问题是,可能造成一次聚集,因为一旦冲突发生,为了处理冲突就会占用下一个位置,而如果冲突较多时,就会出现数据都聚集在一块区域。这样就会导致任何关键字都需要多次尝试才可能解决冲突。
- 平方探测法
顾名思义,如果说前面的探测函数是F(i)= i % 7,那么平方探测法就是F(i)= (i^2 )% 7。
但是这也同样会产生二次聚集问题。
- 双散列
为了避免聚集,在探测时选择跳跃式的探测,即再使用一个散列函数,用来计算探测的位置。假设前面的散列函数为hash1(X),用于探测的散列函数为hash2(X),那么一种流行的选择是F(i) = i * hash2(X),即第一次冲突时探测hash1(X)+hash2(X)的位置,第二次探测
hash1(X)+2hash2(X)的位置。
可以看到,无论是哪种开放定址法,它都要求表足够大。
再散列法
我们前面也说到,散列表可以认为是具有固定大小的数组,那么如果插入新的数据时散列表已满,或者散列表所剩容量不多该怎么办?这个时候就需要再散列,常见做法是,建立一个是原来两倍大小的散列表,将原来表中的关键字重新散列到新表中。
散列表的应用
散列表应用很广泛。例如做文件校验或数字签名。当然还有快速查询功能的实现。例如,redis中的字典结构就使用了散列表,使用MurmurHash算法来计算字符串的hash值,并采用拉链法处理冲突,,当散列表的装载因子(关键字个数与散列表大小的比)接近某个大小时,进行再散列。
面试题
两数之和
给定一个整数数组
nums
和一个目标值target
,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。
示例
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
解题思路
-
思路一:暴力解法,两次for循环,遍历所有可能,这也是容易想到的方法,时间复杂度O(n^2),空间复杂度O(1);
-
思路二:使用散列表来解决该问题。
首先设置一个 map 容器 record 用来记录元素的值与索引,然后遍历数组 nums 。
- 每次遍历时使用临时变量 complement 用来保存目标值与当前值的差值
- 在此次遍历中查找 record ,查看是否有与 complement 一致的值,如果查找成功则返回查找值的索引值与当前变量的值i
- 如果未找到,则在 record 保存该元素与索引值 i
代码实现
这里只提供思路2的实现
// 1. Two Sum//
public static int[] twoSum(int[] numbers, int target) {
int[] a = new int[2];
HashMap<Integer, Integer> map = new HashMap<>(16);
for (int i = 0; i < numbers.length; i++) {
//如果包含差值,即为所求
if (map.containsKey(numbers[i])) {
a[0] = map.get(numbers[i]);
a[1] = i + 1;
return a;
}
//存储差值为key,下标为value
map.put(target - numbers[i], i + 1);
}
return a;
}
时间复杂度:O(n);空间复杂度:O(n)
三数之和
给定一个包含 n 个整数的数组
nums
,判断nums
中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。注意事项:
在三元组(a, b, c),要求a <= b <= c。结果不能包含重复的三元组。
您在真实的面试中是否遇到过这个题?
样例
如S = {-1 0 1 2 -1 -4}, 你需要返回的三元组集合的是:(-1, 0, 1)
(-1, -1, 2)
解题思路
题目需要我们找出三个数且和为 0 ,那么除了三个数全是 0 的情况之外,肯定会有负数和正数,所以一开始可以先选择一个数,然后再去找另外两个数,这样只要找到两个数且和为第一个选择的数的相反数就行了。也就是说需要枚举 a 和 b ,将 c 的存入 map 即可。
需要注意的是返回的结果中,不能有有重复的结果。这样的代码时间复杂度是 O(n^2)。在这里可以先将原数组进行排序,然后再遍历排序后的数组,这样就可以使用双指针以线性时间复杂度来遍历所有满足题意的两个数组合。
即使:排序预处理后,设置一个指针i 用来遍历,剩下两个元素,设置两个指针j 指向i+ 1, 和 k 指向 size() -1, 这两个指针从两侧向中间移动,寻找符合条件的元素。
代码实现
public static List<List<Integer>> threeSum(int[] nums) {
if (nums == null) {
return null;
}
//排序预处理
Arrays.sort(nums);
List<List<Integer>> ans = new ArrayList<>();
for (int i = 0; i < nums.length - 2; i++) {
//相同元素,以前者计算,后者跳过
if (i >= 1 && nums[i] == nums[i - 1]) {
continue;
}
//循环到正数,结束循环
if (nums[i] > 0) {
break;
}
//第二指针为下一元素,第三指针为最后元素
int j = i + 1, k = nums.length - 1;
while (j < k) {
if (nums[i] + nums[j] + nums[k] == 0) {
ans.add(Arrays.asList(nums[i], nums[j], nums[k]));
j++;
k--;
//相同元素,以前者计算,后者跳过
while (j < k && nums[j] == nums[j - 1]) {
j++;
}
//相同元素,以后者计算,前者跳过
while (j < k && nums[k] == nums[k + 1]) {
k--;
}
} else if (nums[i] + nums[j] + nums[k] > 0) {
k--;
} else {
j++;
}
}
}
return ans;
}
有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的一个字母异位词。
示例 1:输入: s = "anagram", t = "nagaram"输出: true
示例 2:
输入: s = "rat", t = "car"输出: false
示例 3:
输入: s = "", t = ""输出: true
说明:
你可以假设字符串只包含小写字母。
进阶:
如果输入字符串包含 unicode 字符怎么办?你能否调整你的解法来应对这种情况?
解题思路
我们首先要验证两个字符串是否长度相同,长度都不同,就没有验证的必要了(),不要用equals方法判断两者(此方法也是由字符串转为byte数组,再一一验证数值是否相同,得不偿失),长度相同的情况下,再找出两者有区别的地方,如果以上验证大多都通过了说明两个字符组成相同。
新手有可能遇到的解题思路陷阱:
忽略了先觉条件长度必须一致,不一致就没有可比性了。
代码实现
暴力破解法
两层循环遍历两个字符串所化的数组,然后匹配,当匹配成功就OK,并赋值为字符‘A’,防止重复匹配。当有一个匹配不成功,就返回错误。此方法太过缓慢,只做参考。
经过我的测试使用String的charAt()方法要比toCharArray()方法更加快捷一些,这里为什么使用了toCharArray()方法呢???因为String类型变量对比长度使用了length()方法,此方法也是字符串转为byte数组,返回数组长度,还不如使用toCharArray()方法,直接使用数组的长度。
public static boolean isAnagram(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
if (ss.length != tt.length) {
return false;
}
boolean sign = false;
for (int i = 0; i < s.length(); i++) {
sign = false;
for (int j = 0; j < t.length(); j++) {
if (ss[i] == tt[j]) {
sign = true;
//防止重复对比
tt[j] = 'A';
break;
}
}
if (!sign) {
return false;
}
}
return sign;
}
复杂度分析
时间复杂度:O(n^ 2)。
空间复杂度:O(n)。
集合法
如使用Map集合的结构,首先必须满足两个字符串长度相等的条件,接着使用将字符串化为char类型的数组,将字符数组遍历加入Map集合,key是字符,value值是该字符出现的次数,接着将另一个字符数组的字符遍历,检查Map集合中是否有此字符,有就将该字符出现的次数减一,次数为零则删除此字符,无此字符就直接返回false。
或者使用List集合,首先必须满足长度相等的先决条件,然后将第一个字符串所有字符加入List集合,List集合减去第二个字符串中所有字符,再判断字符是否为空,不为空返回false,为空则true。
public boolean isAnagram2(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
if (ss.length != tt.length) {
return false;
}
Map<Character, Integer> map = new HashMap<>();
for (char temp : ss) {
if (map.containsKey(temp)) {
map.put(temp, map.get(temp) + 1);
} else {
map.put(temp, 1);
}
}
for (char temp : tt) {
if (!(map.containsKey(temp))) {
return false;
} else {
map.put(temp, map.get(temp) - 1);
if (map.get(temp) == 0) {
map.remove(temp);
}
}
}
return true;
}
复杂度分析
时间复杂度:O(n)。
空间复杂度:O(n)。
public boolean isAnagram3(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
if (ss.length != tt.length) {
return false;
}
List<Character> list = new ArrayList<>();
for (char c : ss) {
list.add(c);
}
for (char c : tt) {
list.remove((Character) c);
}
return list.isEmpty();
}
复杂度分析
时间复杂度:O(n)。
空间复杂度:O(n)。
这块List中删除元素复杂度其实是O(n),我们在本例中暂时把这个忽略,这个只是思路发散
数组排序法
首先必须满足长度相等的先决条件,然后按字母顺序排序,在一一比对,不相同就返回错误。
Arrays.sort()方法也是有循环,但是里面是有多种排序方法,也可自行排序。
public boolean isAnagram4(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
if (ss.length != tt.length) {
return false;
}
Arrays.sort(ss);
Arrays.sort(tt);
for (int i = 0; i < ss.length; i++) {
if (ss[i] != tt[i]) {
return false;
}
}
return true;
}
复杂度分析
时间复杂度:O(n)。
空间复杂度:O(n)。
数组法
首先字符拆串转化为字符数组,必须满足长度相等的先决条件,将字符与字符‘a’相减转int值,数值范围在[0,25]之间,一个加,一个减,如果能达到平衡,结果都为零,则代表互为异位词,反之不是。
public boolean isAnagram5(String s, String t) {
char[] ss = s.toCharArray();
char[] tt = t.toCharArray();
if (ss.length != tt.length) {
return false;
}
int[] letter = new int[26];
for (int i = 0; i < ss.length; i++) {
letter[ss[i] - 'a']++;
letter[tt[i] - 'a']--;
}
for (int i = 0; i < 26; i++) {
if (letter[i] != 0) {
return false;
}
}
return true;
}
复杂度分析
时间复杂度:O(n)。
空间复杂度:O(n)。