标签: Java编程思想
还记得当初学数据结构时学到哈希表,当时比较懵啊。现在要好好理解一下什么是哈希,哈希值,哈希函数,哈希表。
哈希是Hash的音译,意为“散列”,原意为:混杂、一团糟。是电脑科学中一种对资料的处理方法,通过某种特定的函数、算法将要检索的项与用来检索的索引关联起来,生成一种便于搜索的数据结构。
实际上通俗的说法就是把某种状态或者资料给映射到某个值上的操作。
从某种程度上说,散列和排序是相反的操作:排序是将集合中的元素按照某种规则排列在一起;而散列通过计算哈希值,打破元素之间原有的关系,使集合中的元素按照散列函数的分类进行排列。
在介绍Map时,我们总强调需要重写 equlas() 方法和 hashCode() 方法,确保唯一性。这里的 hashCode() 表示的是对当前对象的唯一标示。计算 hashCode 的过程就称作哈希。
我们对于哈希的最初的认识可能来源于数据结构中的散列表,散列表是用于查找的数据结构。查找按照顺序存储的方法存储的大量元素时,需要进行遍历查找,而通过散列表存储,则可以通过hash来计算地址,从而大大地减少比较次数。
哈希可以非常好地将一个非常复杂的状态转化成一个可以检索的状态。
现在有10个数{7,4,1,14,100,30,5,9,20,134},现在将其存到数组中,然后查找 5 是否存在。
int[] numbers = new int[]{7,4,1,14,100,30,5,9,20,134};
for (int i = 0; i < numbers.length; i++) {
if (numbers[i] == 134){
System.out.println("find it!");
return;
}
}
时间复杂度是O(n)。
如果使用哈希函数进行计算,步骤如下:
H(7) = 7 mod 13 = 7
H(4) = 4 mod 13 = 4
H(1) = 1 mod 13 = 1
H(14) = 14 mod 13 = 1
H(100) = 100 mod 13 = 9
H(30) = 30 mod 13 = 4
H(5) = 5 mod 13 = 5
H(9) = 9 mod 13 = 9
H(20) = 20 mod 13 = 7
H(134) = 134 mod 13 = 4
我们将计算得到的结果构成一个表:
地址 | 1 | 4 | 5 | 7 | 9 |
---|---|---|---|---|---|
key | 1,14 | 4,30,134 | 5 | 7,20 | 9,100 |
3. 以上是准备工作,接下来查找 5 是否存在,只需要将5带入Hash函数,由H(5) = 5 mod 13 = 5
可知key在Hash表中的地址为5,然后去位置5查看是否存在就好了,本例中只需查找一次,时间复杂度为 O(1)。
因此可以发现,Hash其实是随机存储的一种优化,先进行分类,然后查找时按照这个对象的分类去找。
哈希通过一次计算大幅度缩小查找范围,自然比从全部数据里查找速度要快。
对于上例,还有两个问题:
哈希函数是一种映射关系,根据数据的关键词 key ,通过一定的函数关系,计算出该元素存储位置的函数。
表示为:address = H(Key)
下面介绍常用的Hash函数构造方法
取关键字或关键字的某个线性函数值为散列地址。
即 H(key) = key
或 H(key) = a*key + b
,其中a和b为常数。
取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。
即 H(key) = key % p, p < m。
在本方法中,p的选择很重要,一般p选择小于或等于表长的最大素数,这样可以减少冲突。
当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。
先计算出关键字值的平方,然后取平方值中间几位作为散列地址。通常在选定Hash函数的时候不一定能知道关键字的全部情况,仅去其中的几位为地址不一定合适,而一个数平方之后的中间几位数和数的每一位都相关,由此得到的Hash地址随机性更大。
选择一个随机函数,把关键字的随机函数值作为它的哈希值。
通常当关键字的长度不等时用这种方法。
构造哈希函数的方法很多,实际工作中要根据不同的情况选择合适的方法,总的原则是尽可能少的产生冲突。
通常考虑的因素有关键字的长度和分布情况、哈希值的范围等。
如:当关键字是整数类型时就可以用除留余数法;如果关键字是小数类型,选择随机数法会比较好。
选用哈希函数计算哈希值时,可能不同的key会得到相同的结果,一个地址怎么存放多个数据呢?这就是冲突。
要注意的是,只可能尽量减少冲突的发生,而不能完全避免重推。因此,在建立Hash表的时候必须要进行冲突处理。
假设Hash表是一个数序表,那么冲突就是指一个关键字得到的Hash地址处已经存在其他的记录,那么冲突的处理就是为该关键字的记录找到另一个空的Hash地址。在处理冲同的过程中,所得到的新的Hash地址也可能发生冲突,那智能继续进行处理,以此类推,知道不发生冲突位置,将记录放在表中的Hash地址。
常用的主要有两种方法解决冲突:
用开放定址法解决冲突的做法是:
当冲突发生时,一发生冲突的Hash地址为自变量,通过某种冲突解决函数得到一个新的空闲的Hash地址。只要散列表足够大,空的散列地址总能找到。
按照形成探查序列的方法不同,可将开放定址法区分为线性探查法、二次探查法、双重散列法等。
线性探查法是从发生冲突的地址(设为d)开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T1,…,直到找到一个空位置为止。
迭代公式为:H(k) = ( H(k) + i ) Mod m
线性探查法容易产生堆积问题。因为当连续出现若干同义词后,后续的若干同义词一次占用其后面的单元,因此后任何后面的单元上的Hash映射都会与前面的同义词堆积产生冲突。
探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1^2],T[d+2^2],T[d+3^2],…,等,直到探查到 有空余地址 或者到 T[d-1]为止。
平方探查发是一种较好的处理冲突的方法,可以避免出现堆积问题,缺点是无法探查到整个散列空间。
迭代公式为:H(k) = ( H(k) + i*i ) Mod m
该方法使用了两个散列函数 h(key) 和 h1(key)。探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+h1(d)], T[d + 2*h1(d)],…,等。
定义 h1(key) 的方法较多,但无论采用什么方法定义,都必须使 h1(key) 的值和 m 互素,才能使发生冲突的同义词地址均匀地分布在整个表中,否则可能造成同义词地址的循环计算。
迭代公式为:H(k) = ( H(k) + i*H1(k) ) Mod m
拉链法解决冲突的做法是:
将所有关键字为同义词的结点链接在同一个单链表中。Hash表每个单元中存放的不再是记录本身,而是相应同义词单链表的表头指针。
若选定的散列表长度为 m,则可将散列表定义为一个由 m 个头指针组成的指针数组 T[0..m-1] 。
凡是散列地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。
T 中各分量的初值均应为空指针。
在拉链法中,装填因子 α 可以大于 1,但一般均取 α ≤ 1。
在之前的学习中我们多次的接触过hashCode()方法,但是我们没有真正的去了解它。
以下我们来看一下hashCode()的定义:
hashcode方法返回该对象的哈希码值。对象的散列码是为了更好的支持基于哈希机制的Java集合类,例如 Hashtable, HashMap, HashSet 等。
我们对于hashCode()方法,可以总结出关键的几点:
hashCode的存在主要是用于查找的快捷性,如Hashtable,HashMap等,hashCode是用来在散列存储结构中确定对象的存储地址的;
如果两个对象相同,就是适用于equals(Java.lang.Object) 方法,那么这两个对象的hashCode一定要相同;
如果对象的equals方法被重写,那么对象的hashCode也要重写,并且产生hashCode使用的对象,一定要和equals方法中使用的一致,否则就会违反上面提到的第2点;
两个对象的hashCode相同,并不一定表示两个对象就相同,也就是不一定适用于equals(java.lang.Object)方法,只能够说明这两个对象在散列存储结构中,如Hashtable,存放在统一地址中。
对于hashCode()和equals()的关系,归纳一下就是:hashCode是用于查找使用的,而equals是用于比较两个对象的是否相等的。
这里主要解释一下第三点,“如果对象的equals方法被重写,那么对象的hashCode也要重写”。
以HashTable为例,当发生冲突,即一个地址中存有多个关键字时,我们无法通过地址来确定是哪个元素,这是就需要equals()方法,在该地址中找到元素,但是在此之前,我们需要重写hashCode()来找到这一地址。
下面通过代码来解释一下:
public class HashTest {
private int i;
public int getI() {
return i;
}
public void setI(int i) {
this.i = i;
}
public int hashCode() { //重写hashCode()方法
return i % 10;
}
public final static void main(String[] args) {
HashTest a = new HashTest();
HashTest b = new HashTest();
a.setI(1);
b.setI(1);
//创建一个HashSet容器,里面存放a,b两个对象
Set set = new HashSet();
set.add(a);
set.add(b);
System.out.println("a,b的哈希值是否相等:" + a.hashCode() == b.hashCode());
System.out.println("a,b两个对象是否相等:" + a.equals(b));
System.out.println(set);
}
}
输出:
a,b的哈希值是否相等:true
a,b两个对象是否相等:false
[com.ubs.sae.test.HashTest@1, com.ubs.sae.test.HashTest@1]
以上这个示例,我们只是重写了hashCode方法,从上面的结果可以看出,虽然两个对象的hashCode相等,但是实际上两个对象并不是相等,因为我们没有重写equals方法,那么就会调用object默认的equals方法(比较两个对象的引用是否相同)。由于是两个不同的对象,因此引用一定不同。
这里我们将生成的对象放到了HashSet中,而HashSet中只能够存放唯一的对象,也就是相同的(适用于equals方法)的对象只会存放一个,但是这里实际上是两个内容相同的对象a,b都被放到了HashSet中,这样HashSet就失去了他本身的意义了。
此时我们重写equals()方法,基于对象的内容来判断:
public boolean equals(Object object) {
if (object == null) {
return false;
}
if (object == this) {
return true;
}
if (!(object instanceof HashTest)) {
return false;
}
HashTest other = (HashTest) object;
if (other.getI() == this.getI()) {
return true;
}
return false;
}
此时得到的结果是:
a,b的哈希值是否相等:true
a,b两个对象是否相等:true
[com.ubs.sae.test.HashTest@1]
从结果我们可以看出,现在两个对象就完全相等了,HashSet中也只存放了一份对象。
ps:用心学习,喜欢的话请点赞 (在左侧哦)