数据结构:Map 和 Set (二)
1. 概念及方法
1.1 Map
1.1.1 Map的概念
Map 是一个接口类,该类不继承 Collection,它存储的是<K,V>结构的键值对,并且K一定是唯一的,不能重复。<K,V>结构的键值对是 Map 内部实现的用来存放<key, value>键值对映射关系的内部类。说白了就是在 Map 中可以存储一个唯一的 key ,每个 key 都会有一个 value 值与它对应(value可以不唯一)。其实键值对就很像中国的谚语的关系,例如:竹篮打水 ——— 一场空。
1.1.2 Map 的常用方法
这里面其实也不需要把全部的记下来,只需要记住增删查改和包含就行了。
1.1.3 Map 总结
1. Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap。
2. Map中存放键值对的Key是唯一的,value是可以重复的。
3. 在TreeMap中插入键值对时,key不能为空,否则就会抛NullPointerException异常,因为key 必须是可用于比较的,而如果你拿 null 去比较,就会引起空指针异常。但是value可以为空。
4. HashMap的key和value都可以为空。因为这里面的 key 不用于比较。
5. Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。
由于 HashMap 的增删查改操作时间复杂度都是O(1),所以这就是许多算法题都会用到哈希表的原因。关于 HashMap ,我们后面会展开比较详细的讲解。
1.2 Set
1.2.1 Set 的概念
Set 与 Map 主要的不同有两点:Set 是继承自 Collection 的接口类,Set中只存储了 Key,它没有 value 的存在。
1.2.2 Set 的常用方法
同样的,我们只需要记住增删查改和包含的操作就可以了,剩下的忘记了再回来查就行。
注意,如果你输入两个相同的 key ,Set 只会在类中放前一个的 key ,而不是后一个。
1.2.3 Set 总结
1. Set是继承自Collection的一个接口类。
2. Set中只存储了key,并且key是一定要唯一的。
3. 由于 key 只能是唯一的,所以Set最大的功能就是对集合中的元素进行去重。
4. 实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
5. Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入。
6. TreeSet中不能插入null的key,HashSet可以。
2. 哈希表
2.1 基本概念
我们在之前所学的顺序结构以及平衡树中,在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数。
那么是否有一种理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素呢? 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数就可以很快地找到该元素。
确实有这种方式,该方式即为我们具体要讲的哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称散列表)。
它的具体实现方法的流程是:对元素的关键码进行哈希函数(hash(key) = key % capacity; capacity为存储元素底层空间总的大小)的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。我们以图的形式来表示就是这样:
假设容量是 10 ,那我们要找 1 所应该存储的位置就能通过哈希函数求得 hash(1) = 1 % 10 = 1,然后将 1 放在数组的 1 下标即可。
但是,这样会存在问题。如果插入的元素变得也越来越多,就有可能会出现插入的位置重复的问题(即地址相同),这样的问题我们将它称为——哈希冲突。
2.2 哈希冲突
2.2.1 概念
不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
既然出现了冲突,那我们作为程序员就需要去解决冲突,解决出现的问题。
2.2.2 哈希冲突的处理
首先,我们需要明确,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,虽然不能根除冲突,但是我们能做的应该是尽量去降低冲突率。防止冲突频繁发生。
如何去处理哈希冲突,那我们就要想想该冲突引发有可能是因为什么因素引起的。我们认为,最明显的一个原因就是:哈希函数设置或编译不够合理。
这里引出了负载因子的概念,有下图可知,负载因子越多,冲突率就会越高。通常我们认定,当冲突率达到75%及以上的时候,该哈希表必定会发生哈希冲突。
1. 闭散列法(线性探测)
闭散列法:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。存放到下一个空位置的流程我们称为线性探测,即从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
线性探测虽然能在一定程度上解决哈希冲突,但是它也有缺陷:就是如果连续产生冲突,那探测后存储的数据将会堆积在一块。这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找。所以我们对它做出了优化,采用二次探测的方法去做。
2. 闭散列法(二次探测)
因此二次探测为了避免该问题,找下一个空位置的方法为:
其中的是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。 其实就是引入一个全新的公式去计算下一个空位置,然后再插入就行了。
3. 开散列法(哈希桶法 || 链地址法)
你可以把它看成一个数组,然后数组中每一个元素都是一个链表。这样在通过哈希函数计算要插入的地址时,由于链表可以认为是无界的,所以就不是很需要担心哈希冲突的问题,直接插入即可。重复地址插入时只需要维护XXX.next 即可。该方法的插入/删除/查找时间复杂度达到了O(1) 。
下面呢,我们来完成一下哈希桶的代码实现,感受一下。
3.1 哈希桶思路及代码实现
3.1.1 插入操作
首先呢,我们把哈希桶方法(HashBucket)创建出来,然后给上 key值、value值 和 Node next,该类型是我们自定义的类型 Node,然后再给 key 和 value 提供构造方法。下面也给出数组的定义(array)、数据的个数统计(size)、哈希冲突阈值(LOAD_FACTOR)和 默认桶的大小(DEFAULT_SIZE)。然后接着定义一个方法叫 loadfactor,它在每次插入的时候回不断进行负载因子的计算,如果超过了75%,则我们需要扩容哈希桶。
注意:这些定义都是参照哈希桶的源码然后进行再定义的。
我们先来写插入的代码,这里定义的是 put 方法,传参为 key值 和 value值:
既然我们是要插入,那就会有插入时数组的第一次插入和重复插入的情况。首先我们通过哈希函数求出要插入的下标 index ,再定义一个 cur 代表该 array[index],因为如果你直接去判断 array[index] 是不行的,因为数组中的元素是链表是引用类型,因此我们要定义一个 cur 节点来代替。
然后就开始插入:这里给一个while循环{ cur != null } 而不是 if ,是因为有可能会连续插入同样的 key ,但是 value 值不一样,这时我们就要更新 value 值。当走出循环时,证明该 key 不是重复的值,我们需要使用头插法(尾插法)来进行插入,这里以头插法为例。当你使用头插法时,有可能在这个下标中已经插入过了节点,所以我们在写的是要以这个为前提去写。由于当你在数组遍历时,array[index] 的下标节点的地址就是第一个被插入的节点的地址,那我们只需要让要插入的节点的 node.next = array[index],然后再把 array[index] = node 地址调换一下即可。
最后,由于定义了数据个数 size,所以我们要让 size++,并且还要在每插入一个元素时去判断,如果超过了哈希冲突阈值,则我们调用扩容方法(这里设置成 resize)。
public int put(int key, int value) {// write code hereint index = key % DEFAULT_SIZE;Node cur = array[index];while(cur != null){if(cur.key == key){cur.value = value;return 1;}}Node node = new Node(key, value);//cur.next = node;//尾插法//头插法node.next = array[index];array[index] = node;size++;if(loadFactor() >= LOAD_FACTOR){resize();}return 1;}
3.1.2 扩容操作
这里的扩容大家需要注意,并不是将原来的数组扩大2倍,而是要重新创建一个新的并且相较于原来是2倍的数组,然后再重新遍历原数组的所有节点,再重新通过哈希函数计算后插入到新数组中(因为数组变大了,所以容量会变大,哈希函数求得的值有可能会不相同,所以我们需要重新求)。
然后就是要遍历原数组的每个节点再插入了,这一步相信大家都能够写出来:
for (int i = 0; i < DEFAULT_SIZE; i++) {
我们在这里也定义一个 cur 来记录每次拿到的下标节点值。需要注意的是,每个下标元素下的链表节点可能不唯一,可能会有多个节点,所以我们这里要写成一个循环,一直插入下去,直到该下标元素中的 cur == null ,即当 cur != null 时进行循环:
while(cur != null){
然后在每次插入的时候我们都要重新通过哈希函数去计算要插入的下标,然后进行头插法即可。然后再令 cur = cur.next 去继续找原来数组下标中的其他元素。如果有则继续头插,没有就转至下一个下标。这里需要注意的是,不能直接让 cur = cur. next ,应该定义一个 curN = cur.next ,让 cur = curN 。为什么呢?因为在你将原数组的节点头插到新数组时,由于是头插法,那么你的下标可能还有其他元素,这时你如果直接让 cur = cur. next ,会一直在原节点或跑到空节点。最好的办法是先记录原数组中 cur 的next 是哪一个,最后直接令 cur = cur. next 即可。
最后我们让 原 array = arrayNew 即可。
private void resize() {// write code hereNode[] arrayNew = new Node[DEFAULT_SIZE * 2];for (int i = 0; i < DEFAULT_SIZE; i++) {Node cur = arrayNew[i];while(cur != null){int index = cur.key % DEFAULT_SIZE * 2;Node curN = cur.next;cur.next = arrayNew[index];//头插法arrayNew[index] = cur;cur = curN;}}array = arrayNew;}
3.1.3 获取元素操作
这部分大家可以自己尝试一下,我这里给出代码参考:
总结:
java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。
3.习题
答案:B;A选项错误,哈希冲突是不能杜绝的,这个与存储的元素以及哈希函数相关。
C选项错误,哈希冲突是不同的元素,通过相同的哈希函数而产生相同的哈希地址而引起的,注意仔细看选项。
D选项错误,不同元素在计算出相同的哈希值时就会冲突。
答案:C。
答案:D;
答案:C;
下面就是编程题了,都比较简单。
771. 宝石与石头 - 力扣(LeetCode)
参考答案:
class Solution {public int numJewelsInStones(String jewels, String stones) {HashSet<Character> set = new HashSet<>();int count = 0;for(int i = 0;i < jewels.length();i++){char treasure = jewels.charAt(i);//因为是字符,所以要用charAt来查看,并用一个字符类型来接收set.add(treasure); }for(int j = 0;j < stones.length();j++){char ch = stones.charAt(j);if(set.contains(ch)){count++;}}return count;}
}
136. 只出现一次的数字 - 力扣(LeetCode)
参考答案:
class Solution {public int singleNumber(int[] nums) {HashSet<Integer> set = new HashSet<>();for(int i = 0;i < nums.length;i++){if(!set.contains(nums[i])){set.add(nums[i]);}else{set.remove(nums[i]);}}int tmp = 0;for(int j = 0;j < nums.length;j++){if(set.contains(nums[j])){tmp = nums[j];break;}}return tmp;}
}
217. 存在重复元素 - 力扣(LeetCode)
参考答案:
class Solution {public boolean containsDuplicate(int[] nums) {HashSet<Integer> set = new HashSet<>();boolean tmp = false;for(int i = 0;i < nums.length;i++){if(!set.contains(nums[i])){set.add(nums[i]);}else{tmp = true;}} return tmp;}
}
219. 存在重复元素 II - 力扣(LeetCode)
参考答案:
class Solution {public boolean containsNearbyDuplicate(int[] nums, int k) {HashMap<Integer,Integer> map = new HashMap<>();boolean tmp = false;for(int i = 0;i < nums.length;i++){if(!map.containsKey(nums[i])){map.put(nums[i],i);}else{if(i - map.get(nums[i]) <= k){tmp = true;break;}else{map.put(nums[i],i);}}}return tmp;}
}
那么,本篇文章到此结束!希望能对你有帮助。