Java中的散列表——HashSet
散列表,是一种数据结构,通过存储位置与key的映射关系存储数据,实现平均时间复杂度为
O(1)
的查找功能。在Java中,每个类因为继承关系,都含有一个public int hashCode()
方法,当我们要将自己实现的类作为散列表中的key时,我们需要自己重写这个函数......
一、查找
我们知道在查找一个线性表中是否contains
某个值时,我们需要遍历整个线性表,判断是否存在某个值,时间复杂度为O(n)
,如Java中的ArrayList和LinkedList:
1.ArrayList
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
//如果o不是null,遍历数组判断是否有相同的值
for (int i = 0; i < size; i++)
if (o.equals(elementData[i]))
return i;
}
return -1;
}
2.LinkedList
public boolean contains(Object o) {
return indexOf(o) != -1;
}
public int indexOf(Object o) {
int index = 0;
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null)
return index;
index++;
}
} else {
//如果o不是null,遍历链表判断是否有相同的值
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
}
return -1;
}
3.HashSet
这时候散列表就显示出它的作用来了,它就是为快速查找value诞生的。在Java中
HashSet
就是散列表的实现,HashSet
使用数组和链表来实现散列表,不同hashcode
的value存放在不同数组下标的位置,相同hashcode
的value存放在相同数组下标的链表上的不同位置上。在判断是否contains
某个value时,我们需要传入要查找的value的key,然后:
- 先查找key是否存在
public boolean contains(Object o) {
return map.containsKey(o);
}
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
- 再计算key的hashcode
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 接着根据key的
hashcode
,找到该hashcode
对应的数组下标
first = tab[(n - 1) & hash];
- 假如恰好该数组下标的位置存放的链表的第一个节点的value就是所要找的value,那么所有查找的时间复杂度都是
O(1)
。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
- 如果很不幸,散列表出现了
冲突
,也就是说该数组下标所在的位置存放了很多相同hashcode
的不同value,那么还需要遍历链表来判断是否存在需要查找的value,此时查找的时间复杂度就取决于有具有相同hashcode
的value的数量。
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果出现散列冲突,遍历链表查找
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
二、hashcode、冲突
上面我们对
HashSet
的介绍中,我们提到了几个名词hashcode
、冲突
,这里我们来解释一下
1.hashcode
我们知道每个类都继承于Object,每个类中都有一个继承来的方法public int hashCode()
,我们查看Java文档:
This method is supported for the benefit of hash tables.
也就是说,这个hashcode在散列表的构建中是至关重要的。
我们来看一个例子,在下面这个例子中,我们定义了一个Person类,将Person作为HashSet的泛型参数,并往集合中存了一个ID为12345678的Person。接着,我们调用contains方法查询是否有一个ID为12345678的Person,执行程序后,输出为false。
public class Person {
private int ID;
public Person(int ID) {
this.ID = ID;
}
}
public class Main {
public static void main(String[] args) {
HashSet<Person> people = new HashSet<>();
people.add(new Person(12345678));
System.out.println(people.contains(new Person(12345678)));
}
}
//output: false
显然,这不是我们要的结果。这里出现的错误就是hashCode()方法引起的错误(当然还有equals())。
为什么会出现这种错误呢?
因为在我们的Person类中,Person类自动继承了Object的hashCode()方法生成了hashcode,而基类是使用对象的地址计算hashcode的。所以我们存储在HashSet中的Person实例和我们查询时的Person实例地址是不一样的,自然它们的hashcode也是不一样的。
解决办法
我们重写了Person类的hashCode()方法,并重写了equals()方法,因为我们查看HashSet源码的时候,我们会发现都有一句key.equals(k)
,HashSet使用equals()方法,判断当前的key是否和散列表中的key一样。
public class Person {
private int ID;
public Person(int ID) {
this.ID = ID;
}
@Override
public int hashCode() {
return ID;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (!(obj instanceof Person))
return false;
return ((Person) obj).ID == this.ID;
}
}
public class Main {
public static void main(String[] args) {
HashSet<Person> people = new HashSet<>();
people.add(new Person(12345678));
System.out.println(people.contains(new Person(12345678)));
}
}
//output:true
当我们完善了hashCode()方法和equals()方法后,我们得到了输出:true。
一些约定
接下来我们就可以了解一些覆写hashCode()方法的一些约定了:
- 在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序地多次执行中,每次执行所返回地整数可以不一致。
- 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
- 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提供散列表的性能。
约定的第三点中提到,即使两个对象根据equals()方法比较是不相等,那么它们的hashcode也有可能是相等的。这就是接下来要讲的冲突。
2.冲突
理解冲突的时候,我们需要了解散列表是如何构建的。在构建散列表的时候,是根据key计算出value存放的地址。在HashSet中是根据hash函数和key计算value存放的数组下标。
//HashSet中使用的hash函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//如果该数组下标所在的地址为null,就实例化一个新的节点存放对象
//i = (n - 1) & hash计算value存放的数组下标
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
而我们根据hash函数计算的hashcode,是有可能一样的。当一个散列表中,如果存在相同的hashcode,这种情况就称为冲突
。
解决冲突
- 链接表法,把相同hashcode的value都放在一个链表中,HashSet就是采用这种方法解决的。
- 开放寻址法,不使用链表。如果计算出来的地址已经有了一个value,那么开放寻址法就在当前的hashcode基础上计算出一个为null的地址来存放新的value,该过程称为
探查
。
在上面第一部分关于HashSet的分析中,我们知道散列表查询的性能很大程度上取决于冲突的发生。减少冲突的发生,就需要构建好的hash函数。
三、hash函数
在《算法导论中》,提到了一个好的hash函数的特点:
一个好的散列函数应(近似地)满足简单均匀散列假设:每个关键字都会被等可能地散列到m个槽位中的任何一个,并与其他关键字已散列到哪个槽位无关。
上面的槽位可以理解为数组下标。我们在上面举的Person的例子,使用每个人的ID来构建hashcode,这个就保证了每个key的hashcode都是不一样的,这种使用每个对象独一无二的属性来构建hashcode,也是一种hash方法。接下来我们来简单介绍一些hash函数:
- 除法散列法。通过取k除以m的余数,将关键字k映射到m个槽中的一个。
假设有以下这些key和value,除法散列法的hash函数为:f(key) = key % 7
那么经过散列之后的存储为:
6 % 7 = 6 ,15 % 7 = 1 ,23 % 7 = 2 ,67 % 7 = 4 ,56 % 7 = 0 ,45 % 7 = 3
如果我们的key和value,是下面这个表,除法散列法的hash函数还是:f(key) = key % 7
那么我们通过除法散列法得到的数组下标分别是:
6 % 7 = 6 ,16 % 7 = 2 ,24 % 7 = 3 ,66 % 7 = 3 ,55 % 7 = 6 ,45 % 7 = 3
我们发现发生了散列冲突了。这里我们试着用开放寻址法来解决,我们构造的再散列函数是:
F(key) = (f(key) + 1) % 7
解决冲突后的存储为:
- 乘法散列方法。
- 用关键字k乘以常数A(0 < A < 1),并提取kA的小数部分。
- 用m乘以这个值,再向下取整。
- 全域散列方法。随机选择散列函数,使之独立于要存储的关键字。
四、应用
因为HashSet每次在添加数据的时候都会先看集合中原来有没有存储相同的数据,如果有就将新的数据覆盖老的数据,所以可以保证HashSet中的数据是没有重复的。那么我们可以利用这个特性来进行数据去重。
如有错误,望指正