Java哈希表HashTable(又称散列表)的实现及练习题
作者:Y409001
一、概念
关键词及重点:【 哈希函数(散列函数)、哈希冲突、负载因子、哈希桶 】
1.1 哈希表概念
用顺序结构或平衡树存放数据时,需要查找的时候必须经过关键码的多次比较,顺序查找的时间复杂度为 O(N),平衡树时间复杂度为树的高度,即 O(log₂N)。
但理想的搜索方法是不经过任何比较,一次直接从表中得到想要搜索的元素。而哈希表(或者称散列表)结构就是通过哈希(散列)函数使元素的存储位置与它的关键码之间建立一一映射的关系,从而在查找时不用进行比较,时间复杂度为 O(1)。当向该结构中:
插入元素:根据待插入元素的关键码,以哈希函数计算出该元素的存储位置并按此位置进行存放;
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
1.2 哈希函数
哈希函数设置为 hash(key) = key % capacity,capacity是指存储元素底层空间总的大小。
1.3 冲突

不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。 把具有不同关键码而具有相同哈希地址的数据元素称为 “同义词”。
1.4 避免冲突
1、设计哈希函数;
2、调节负载因子。
散列表的负载因子(载荷因子)定义为:ɑ = 填入表中的元素个数 / 哈希表的长度。
ɑ 越大,表明填入表中的元素越多,产生冲突的可能性越大;而 ɑ 与哈希表长度成反比,因此当逼近或等于负载因子时需立即扩容降低负载因子。【Java的系统库限制负载因子为 0.75。】
1.5 解决冲突
1、 闭散列 / 开放定址法
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把key存放到冲突位置中的“下一个” 空位置中去。而寻找下一个空位置的方法如下:
① 线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。缺点:不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,14查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

② 二次探测:线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为

或

其中,i = 1,2...,H₀是通过散列函数对关键码 key 进行运算得到的位置,m 是表的大小。

研究表明:当表的长度为质数且表负载因子 ɑ 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。
因此,闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。(消耗空间节省时间)
2、开散列 / 哈希桶 / 链地址法 / 开链法
首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。简单来说就算 数组+链表 的形式。

1.6 冲突严重时
刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:
1、每个桶的背后是另一个哈希表;
2、每个桶的背后是一棵搜索树。
1.7 tips
哈希函数作用是:建立元素与其存储位置之前的对应关系的,在存储元素时,先通过哈希函数计算元素在哈希表格中的存储位置,然后存储元素。好的哈希函数可以减少冲突的概率,但是不能够绝对避免,万一发生哈希冲突,得需要借助哈希冲突处理方法来解决。
只要想哈希表格中存储的元素超过两个,就有可能存在哈希冲突;
常见的哈希函数有:直接定址法、除留余数法、平方取中法、随机数法、数字分析法、叠加法等;
常见哈希冲突处理:闭散列(线性探测、二次探测)、开散列(链地址法)、多次散列。
二、哈希表的实现
数组 + 链表(数组存放链表首节点地址,节点形成单链表)
准备工作:
定义内部类节点 Node 、哈希表的成员变量:
public class HashBucket {
static class Node{
public int key;
public int val;
public Node next;
public Node(int key, int val){
this.key = key;
this.val = val;
}
}
public Node[] array = new Node[10];
public int usedSize;
}2.1 插入、扩容
2.1.1 基本思想

// 默认负载因子的限制值
public static final double DEFAULT_LOAD_FACTOR = 0.75;
public void push(int key, int val){
int index = key % array.length;
Node cur = array[index];
// 找到有无该节点
while(cur != null){
if (cur.key == key){
cur.val = val; // 更新 value值
return;
}
cur = cur.next;
}
// 没有找到该节点,那么插入
Node node = new Node(key,val);
node.next = array[index];
array[index] = node;
usedSize++;
if (doLoadFactor() >= DEFAULT_LOAD_FACTOR){
resize();
}
}
private double doLoadFactor(){ // 计算当前的负载因子
return usedSize*1.0 / array.length; // 乘1.0的目的是将整型隐式转换为浮点型
}
private void resize(){
// 错误写法,扩容不仅仅是将容器放大容量,还应该重新哈希
//array = Arrays.copyOf(array,2*array.length);
Node[] newArray = new Node[2*array.length]; // 存放到新的数组
// 遍历原来的数组,重新哈希
for (int i = 0; i < array.length; i++) {
Node cur = array[i];
while (cur != null) {
int newIndex = cur.key % newArray.length;
Node curNext = cur.next; // 记录原先数组的下一个节点
cur.next = newArray[newIndex];
newArray[newIndex] = cur;
cur = curNext; // 回到原先数组的下一个节点,而不是新数组的下一个节点
}
}
array = newArray;
}2.1.2 测试示例:
1、push()
public class Test {
public static void main(String[] args) {
HashBucket h = new HashBucket();
h.push(1,111);
h.push(2,222);
h.push(4,444);
h.push(14,141414);
h.push(21,212121);
h.push(12,121212);
h.push(24,242424);
}当前存放元素个数为 7,负载因子为 0.7,未超过 0.75,不扩容。测试结果:

2、扩容之后

2.2 查找,获取value值
// 查找有无key,与push方法中的查找类似
public int getVal(int key){
int index = key % array.length;
Node cur = array[index];
while (cur != null){
if (cur.key == key){
return cur.val;
}
cur = cur.next;
}
return -1;
}上述的代码中 key 和 value 的类型都是整型,如果想要其他类型,就定义泛型结构:
package hash;
public class HashBucket2<K, V> {
static class Node<K,V>{
public K key;
public V val;
public Node<K, V> next;
public Node(K key, V val){
this.key = key;
this.val = val;
}
}
public Node<K, V>[] array = (Node<K, V>[])new Node[10]; // 强转
public int usedSize;
public static final double DEFAULT_LOAD_FACTOR = 0.75;
public void push(K key, V val){
int hashcode = key.hashCode(); // 调用 hashCode,使引用类型转换为整数类型
int index = hashcode % array.length;
Node<K,V> cur = array[index];
while(cur != null){
if (cur.key.equals(key)){ // 引用类型用 .equals()
cur.val = val;
return;
}
cur = cur.next;
}
Node<K, V> node = new Node<>(key,val);
node.next = array[index];
array[index] = node;
usedSize++;
if (doLoadFactor() >= DEFAULT_LOAD_FACTOR){
resize();
}
}
private double doLoadFactor() { // 计算当前的负载因子
return usedSize * 1.0 / array.length;
}
private void resize(){
Node<K,V>[] newArray = (Node<K, V>[])new Node[2*array.length]; // 存放到新的数组
// 遍历原来的数组,重新哈希
for (int i = 0; i < array.length; i++) {
Node<K,V> cur = array[i];
while (cur != null) {
int hashcode = cur.key.hashCode();
int newIndex = hashcode % newArray.length;
Node<K,V> curNext = cur.next; // 记录原先数组的下一个节点
cur.next = newArray[newIndex];
newArray[newIndex] = cur;
cur = curNext; // 回到原先数组的下一个节点,而不是新数组的下一个节点
}
}
array = newArray;
}
public V getVal(K key){
int hashcode = key.hashCode();
int index = hashcode % array.length;
Node<K,V> cur = array[index];
while(cur != null){
if (cur.key.equals(key)){
return cur.val;
}
cur = cur.next;
}
return null;
}
}测试:
package hash;
import java.util.Objects;
class Student{
public String id;
public Student(String id){
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return Objects.equals(id, student.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
public class Test {
public static void main(String[] args) {
Student student1 = new Student("409");
Student student2 = new Student("409");
System.out.println(student1.hashCode());
System.out.println(student2.hashCode());
// 当自定义类没有重写 equals() 和 hashCode() 方法时,下面输出false
System.out.println(student1.equals(student2));
HashBucket2<Student, String> h = new HashBucket2<>();
h.push(student1,"Li");
h.push(student2,"NAZA");
System.out.println(h.getVal(student1));
}
}输出结果:
51548
51548
true
NAZA
(此处插入一个面试题:对于HashMap,如果一个对象为 key 时,hashCode 和 equals 方法的用法要注意什么?)
2.3 性能分析
虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是 O(1) 。
2.4 和Java集合类的关系
1、HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
2、java 中使用的是哈希桶方式解决冲突的
3、java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
4、java 中计算哈希值实际上是调用类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。
所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须重写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 产生的整数结果一定是一致的。但是 hashCode 一样时,equals 得到的结果可能一样也可能不一样,因为 hashCode 一样只能说明存放在表中的位置是一样的,key 不一定一样。
简而言之:如果两个对象根据 equals() 方法比较是相等的,那么调用这两个对象的 hashCode() 方法必须产生相同的整数结果。反之,两个对象的 hashCode() 相同,它们并不一定 equals()。(面试题解答的核心原则)
【用哈希时使用的自定义类型不需要实现可比较,但是用 TreeMap 或 TreeSet 时需要可比较的类型】
三、Map 和 Set 练习题
3.1 统计英文阅读中的单词词频
使用 Map 的核心逻辑:如果 map 中存在该单词,则计数+1;否则放入 map 并设置计数为1。
package hash;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
public class Test {
public static Map<String, Integer> countWords(String[] str) {
Map<String, Integer> map = new HashMap<>();
for (String s : str) {
if (map.get(s) != null){
int val = map.get(s); // 取得当前的value值
map.put(s,val+1);
}else{
map.put(s,1);
}
}
return map;
}
public static void main(String[] args) {
String[] str = {"Long","Long","Long","time","age","there","is","a","pig","live","in","a","house"};
Map<String, Integer> map = countWords(str);
Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
for (Map.Entry<String, Integer> s : entrySet) {
System.out.println("word: "+s.getKey() + " nums: "+s.getValue());
}
}
}3.2 找出单身狗/只出现一次的数字
解法有多种,除了使用异或的方法,还能使用集合来解决。
使用集合的基本思想:遍历数组,第一次出现的放到 Set 中,第二次出现的就将其移出,最后 Set 里面得到的就是只出现一次的数字。
public int singleNumber(int[] nums) {
HashSet<Integer> set = new HashSet<>();
for (int n : nums) {
if (set.contains(n)){
set.remove(n);
}else {
set.add(n);
}
}
for (int n : nums) {
if (set.contains(n)){
return n;
}
}
return -1;
}3.3复制带随机指针的链表
采用 Map 会非常方便,但是如果不使用 Map 该如何复制?

public Node copyRandomList(Node head) {
HashMap<Node, Node> map = new HashMap<>();
// 1、根据val值新建节点
Node cur = head;
while (cur != null){
Node node = new Node(cur.val);
map.put(cur,node); // 将旧节点地址和新节点地址一起放到map中
cur = cur.next;
}
// 2、重新遍历,设置next和random
cur = head;
while (cur != null){
// get()返回的是val值,即返回的是存放在map中的 新地址
map.get(cur).next = map.get(cur.next);
map.get(cur).random = map.get(cur.random);
cur = cur.next;
}
return map.get(head); // 返回的是头节点的新地址
}3.4宝石与石头
解法可以用数组也可以采用集合的方法。使用数组的方法执行用时会比使用集合的方法用时短。
采用集合:将一颗颗“宝石”存放到 Set 中,遍历“石头”,查看“石头”里面是否包含了“宝石”。
public int numJewelsInStones(String jewels, String stones) {
HashSet<Character> jewelsSet = new HashSet<>();
for(int i = 0; i < jewels.length(); i++){
char s = jewels.charAt(i);
jewelsSet.add(s);
}
int count = 0;
for(int i = 0; i < stones.length(); i++){
char s = stones.charAt(i);
if(jewelsSet.contains(s)){
count++;
}
}
return count;
}3.5坏键盘打字
基本思想:将实际输出的字符存放到 Set 中,遍历应该输入的字符串,注意有重复输出的情况。

public static void findBrokenKey(String str1, String str2){
// 1、先全部转化为大写
str1 = str1.toUpperCase();
str2 = str2.toUpperCase();
// 2、将实际输出的按键存储到 set 中
HashSet<Character> setAct = new HashSet<>();
for (int i = 0; i < str2.length(); i++) {
char c2 = str2.charAt(i);
setAct.add(c2);
}
// 3、遍历应该输出的字符串
/*for (int i = 0; i < str1.length(); i++) {
char c1 = str1.charAt(i);
if (!setAct.contains(c1)) {
System.out.print(c1);
}
}*/ // 存在问题:重复输出
// 4、修正,避免重复输出的情况
HashSet<Character> setBroken = new HashSet<>();
for (int i = 0; i < str1.length(); i++) {
char c1 = str1.charAt(i);
// 如果已经存放到 setBroken ,即已经被包含,就跳过if语句块
if (!setAct.contains(c1) && !setBroken.contains(c1)) {
setBroken.add(c1);
System.out.print(c1);
}
}
}
public static void main(String[] args) {
String s1 = "7_This_is_a_test";
String s2 = "_hs_s_a_es";
findBrokenKey(s1,s2);
}3.6前K个高频单词
基本思想:类似 Top-K:统计所有单词的词频,前K个建小根堆,比较交换重建小根堆……
不过题目要求说“如果不同的单词有相同出现频率, 按字典顺序 排序”,因此除了关注词频,还得关注单词的大小比较,因此使用 Map 存放两个值。
重点是建立 PriorityDeque 时传入的比较器的方法重写。
public List<String> topKFrequent(String[] words, int k) {
// 1、统计单词词频
HashMap<String, Integer> wordFrequency = new HashMap<>();
for (String word: words) {
if (wordFrequency.get(word) == null){
wordFrequency.put(word,1);
}else{
int val = wordFrequency.get(word);
wordFrequency.put(word,val+1);
}
}
// 2、建堆
PriorityQueue<Map.Entry<String, Integer>> minHeap = new PriorityQueue<>(new Comparator<Map.Entry<String, Integer>>() {
@Override
public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
if (o1.getValue().compareTo(o2.getValue()) == 0){ // 如果词频一样,就按照字典顺序比较
return o2.getKey().compareTo(o1.getKey()); // 此处应该建大根堆,因为如果建立小根堆,后面reverse之后与题目要求不符
}
return o1.getValue().compareTo(o2.getValue()); // 根据词频建堆
}
});
// 3、遍历HashMap
for (Map.Entry<String,Integer> entry: wordFrequency.entrySet()) {
if (minHeap.size() < k) { // 前k个单词建立小根堆
minHeap.offer(entry);
}else{
Map.Entry<String, Integer> top = minHeap.peek(); // 得到目前堆顶的元素
// 进行比较
if (top.getValue().compareTo(entry.getValue()) < 0){ // 如果后面的元素词频大于前面最小的单词的词频,就“交换”
minHeap.poll();
minHeap.add(entry);
}else if(top.getValue().compareTo(entry.getValue()) == 0){ // 如果字母词频相同
if (top.getKey().compareTo(entry.getKey()) > 0){ // 字母较大的,存放到堆里面
minHeap.poll();
minHeap.add(entry);
}
}
}
}
ArrayList<String> list = new ArrayList<>();
for (int i = 0; i < k; i++) {
Map.Entry<String, Integer> tmp = minHeap.poll();
list.add(tmp.getKey());
}
Collections.reverse(list); // ArrayList 继承的Collections接口可以翻转
return list;
}通过上面的练习,我发现似乎涉及同一个元素出现次数 / 统计频率 / 有无某元素 / 复制等类型都能使用到 Map 和 Set ,因此在处理超过2个以上的数据时,就得应用集合的知识,发散性思考。
总结
到此这篇关于Java哈希表HashTable(又称散列表)实现及练习题的文章就介绍到这了,更多相关Java哈希表HashTable内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
