数据结构从青铜到王者第二十话---Map和Set(3)
🔥个人主页:寻星探路
🎬作者简介:Java研发方向学习者
📖个人专栏: 《从青铜到王者,就差这讲数据结构!!!》、 《JAVA(SE)----如此简单!!!》、《数据库那些事!!!》
⭐️人生格言:没有人生来就会编程,但我生来倔强!!!
续接上一话
目录
一、哈希表(续)
1、冲突-解决
1.1冲突-解决-闭散列
1.1.1线性探测
1.1.2二次探测
1.2冲突-解决-开散列/哈希桶(重点掌握)
2、冲突严重时的解决办法
3、实现
4、性能分析
5、和 java 类集的关系
二、OJ练习
1、 只出现一次的数字
2、 随机链表的复制
3、 宝石与石头
4、 前K个高频单词
一、哈希表(续)
1、冲突-解决
解决哈希冲突两种常见的方法是:闭散列和开散列
1.1冲突-解决-闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1.1.1线性探测
比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入
通过哈希函数获取待插入元素在哈希表中的位置
如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
1.1.2二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: Hi= (H0 +i^2 )% m, 或者: Hi= (H0 - i^2)% m。其中:i = 1,2,3…,H0 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置, m是表的大小。 对于如果要插入44,产生冲突,使用解决后的情况为:
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
1.2冲突-解决-开散列/哈希桶(重点掌握)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
2、冲突严重时的解决办法
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
(1)每个桶的背后是另一个哈希表
(2)每个桶的背后是一棵搜索树
3、实现
// key-value 模型public class HashBucket {private static class Node {private int key;private int value;Node next;public Node(int key, int value) {this.key = key;this.value = value;}}private Node[] array;private int size; // 当前的数据个数private static final double LOAD_FACTOR = 0.75;public int put(int key, int value) {int index = key % array.length;// 在链表中查找 key 所在的结点// 如果找到了,更新// 所有结点都不是 key,插入一个新的结点for (Node cur = array[index]; cur != null; cur = cur.next) {if (key == cur.key) {int oldValue = cur.value;cur.value = value;return oldValue;}}Node node = new Node(key, value);node.next = array[index];array[index] = node;size++;if (loadFactor() >= LOAD_FACTOR) {resize();}return -1;}private void resize() {Node[] newArray = new Node[array.length * 2];for (int i = 0; i < array.length; i++) {Node next;for (Node cur = array[i]; cur != null; cur = next) {next = cur.next;int index = cur.key % newArray.length;cur.next = newArray[index];newArray[index] = cur;}}array = newArray;}private double loadFactor() {return size * 1.0 / array.length;}public HashBucket() {array = new Node[8];size = 0;}public int get(int key) {int index = key % array.length;Node head = array[index];for (Node cur = head; cur != null; cur = cur.next) {if (key == cur.key) {return cur.value;}}return -1;}
}
4、性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的, 也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是 O(1) 。
5、和 java 类集的关系
(1)HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
(2)java 中使用的是哈希桶方式解决冲突的
(3)java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
(4)java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。
二、OJ练习
1、 只出现一次的数字
使用哈希表(HashMap)来统计每个数字出现的次数。遍历数组,将每个数字作为键,出现次数作为值存入哈希表。然后遍历哈希表,找到值为1(即只出现一次)的键并返回。如果没找到,则返回-1(但题目保证有且只有一个这样的数字,所以实际上不会返回-1)
class Solution {public int singleNumber(int[] nums) {Map<Integer, Integer> map = new HashMap<>();for (Integer i : nums) {Integer count = map.get(i);count = count == null ? 1 : ++count;map.put(i, count);}for (Integer i : map.keySet()) {Integer count = map.get(i);if (count == 1) {return i;}}return -1; // can't find it.}
}
2、 随机链表的复制
我们用哈希表记录每一个节点对应新节点的创建情况。遍历该链表的过程中,我们检查「当前节点的后继节点」和「当前节点的随机指针指向的节点」的创建情况,如果这两个节点中的任何一个节点的新节点没有被创建,我们都立刻递归地进行创建。
当我们拷贝完成,回溯到当前层时,我们即可完成当前节点的指针赋值。注意一个节点可能被多个其他节点指向,因此我们可能递归地多次尝试拷贝某个节点,为了防止重复拷贝,我们需要首先检查当前节点是否被拷贝过,如果已经拷贝过,我们可以直接从哈希表中取出拷贝后的节点的指针并返回即可。
在实际代码中,我们需要特别判断给定节点为空节点的情况。
class Solution {Map<Node, Node> cachedNode = new HashMap<Node, Node>();public Node copyRandomList(Node head) {if (head == null) {return null;}if (!cachedNode.containsKey(head)) {Node headNew = new Node(head.val);cachedNode.put(head, headNew);headNew.next = copyRandomList(head.next);headNew.random = copyRandomList(head.random);}return cachedNode.get(head);}
}
3、 宝石与石头
遍历字符串 jewels,使用哈希集合存储其中的字符,然后遍历字符串 stones,对于其中的每个字符,如果其在哈希集合中,则是宝石。
class Solution {public int numJewelsInStones(String jewels, String stones) {int jewelsCount = 0;Set<Character> jewelsSet = new HashSet<Character>();int jewelsLength = jewels.length(), stonesLength = stones.length();for (int i = 0; i < jewelsLength; i++) {char jewel = jewels.charAt(i);jewelsSet.add(jewel);}for (int i = 0; i < stonesLength; i++) {char stone = stones.charAt(i);if (jewelsSet.contains(stone)) {jewelsCount++;}}return jewelsCount;}
}
4、 前K个高频单词
我们可以预处理出每一个单词出现的频率,然后依据每个单词出现的频率降序排序,最后返回前 k 个字符串即可。
具体地,我们利用哈希表记录每一个字符串出现的频率,然后将哈希表中所有字符串进行排序,排序时,如果两个字符串出现频率相同,那么我们让两字符串中字典序较小的排在前面,否则我们让出现频率较高的排在前面。最后我们只需要保留序列中的前 k 个字符串即可。
class Solution {public List<String> topKFrequent(String[] words, int k) {Map<String, Integer> cnt = new HashMap<String, Integer>();for (String word : words) {cnt.put(word, cnt.getOrDefault(word, 0) + 1);}List<String> rec = new ArrayList<String>();for (Map.Entry<String, Integer> entry : cnt.entrySet()) {rec.add(entry.getKey());}Collections.sort(rec, new Comparator<String>() {public int compare(String word1, String word2) {return cnt.get(word1) == cnt.get(word2) ? word1.compareTo(word2) : cnt.get(word2) - cnt.get(word1);}});return rec.subList(0, k);}
}