散列的基本思想
假设有N对键值对需要存储,且所有的键都是互不相同的小整数,则可以使用一个大小为M的数组存储这些键值对,其中数组的长度M要大于所有键中的最大值。这样,在存储的时候,可以直接用键key作为数组的索引,该索引对应的位置存放的就是key相对应的值value。
由于所有键相互之间可能并不是连续的,所以数组中有些位置可能是空的,没有存储值,相当于有一些内存被浪费了。但是,采用此种方式存储数据,读写数据时就可以通过key作为索引直接定位到数组中相应的位置,使耗费的时间为常数级别。
下面是一个简单的例子,有5个键值对需要存储,键为小整数,值为字符串,如图所示。存储数据的数组大小为10。通过相应的键作为索引将值存储到数组中的示意图如下。
如果存储的一系列键都是不同的小整数(确保可以直接将键key作为数组索引),而且从0开始具有较大的连续性(使数组中空闲位置较少),则可以采取上面的方法存储数据,以占用稍大的内存为代价换取更快的常数级别的数据读写时间。
上面所述就相当于是一个简单的散列表,散列表的大小M就是数组的长度。然而,上面的假设是一种理想的情况。通常我们需要存储的键值对的键key并不是小整数,甚至并不是一个整数,而更可能是一个字符串,是某个类的某个对象,等等。为了能够采用以上的存储方法,首先就需要将可能是各种各样类型的key转换为一个较小的整数。这个转换的过程就叫做散列,将非整数类型的key转换为整数类型的一个函数称为散列函数。
下面假设仍然有N对键值对需要存储,键key的类型为字符串,为了能够采用以上的存储方法,首先就要将每个键key通过散列函数散列为一个较小的整数。而且,若散列表的大小即数组的长度为M,为了使散列得到的整数能够直接作为数组的索引,则要求散列得到的整数的范围为[0,M)。
当N<M时,比如N为5,M为10,即散列表的大小为10,需要存储的键值对数量为5时,通过某一个散列函数,分别将5个key散列为了5个不同的[0, 10)范围内的整数,例如5个key的散列值分别为2,0,5,3,7,则可以顺利的将对应的值value存入数组中相应的位置,这就和前面第一个例子一样。
但是,如果散列函数选取不合适,在散列的过程中有可能会发生一件不幸的事情,即由于散列不均匀而造成冲突碰撞。依然以上面的例子为例,通过一个散列函数对5个不同的key进行散列,然而却只得到了4个不同的散列值。换句话说,有两个key通过散列函数得到的散列值是相同的。如下图所示。
那么,在这种情况下,产生冲突的第一对键值对可以顺利的存放到数组中,第二个键值对准备存储时却发现相应位置已经被第一对键值对占用了,没有办法继续在那个位置进行存储。
这就是产生了碰撞冲突现象。由于几乎没有散列函数可以对所有的键key进行均匀的散列,因此在散列过程中碰撞冲突现象是不可避免的。目前,有两种常见的处理碰撞冲突的办法,一种是拉链法(分离链接法),一种是开放定址法(如线性探测法)。
前面简单叙述了散列表的基本思想,下面将分别重点介绍散列表中的两个关键之处:散列函数以及碰撞冲突的处理。
散列函数
散列函数的作用是对键key进行散列,得到一个对应的散列值,并以该值作为索引确定值value在散列表数组中存放的位置。
散列函数的性能将会直接影响到整个散列表的性能优劣,理想的散列函数能够将所有的键均匀的散列。前面所说的5个key散列只得到了4个不同的散列值就是一个简单的散列不均匀的例子。更极端的一个例子是,比如散列表的大小M为10,需要存储的键值对数量N也为10,极端的散列不均匀的情况就是10个key都被散列到同一个散列值,这是非常糟糕的情况,将会造成严重的碰撞冲突,在实际中应该尽可能的避免使用这种不均匀的散列函数。而理想的均匀的散列函数能够将这10个key恰好散列为[0,10)范围内的不同的10个散列值。
然而,在实际中,几乎没有散列函数能够将所有的键进行完全均匀的散列,只能尽可能地采用近似均匀的散列函数。
由于Java中所有类的对象都可以作为键key,那么所有的类都需要有一个函数能够对其实例对象进行散列。所以,Object类中定义了一个hashCode()方法,该方法的返回值是一个整数类型。Object类中默认的实现是返回对象的内存地址,但是许多常见的类(如String)都重写了此方法。由于java中int类型的长度为32位,所以较为理想的hashCode方法需要将所有的键key尽可能均匀的散布为所有可能的32位表示范围内的整数。
下面是String类的hashCode源码。因为String类是不可变,所以其散列值也是不会变的。所以,hashCode方法中首先就会判断hash值(散列值)是否已经计算过,如果已经计算过了,就无需再次计算,可以直接将其返回。从for循环中可以看出,为了使hash值散列更均匀,需要充分考虑到字符串中的每一个字符,使每个字符对最终的结果都产生一定的影响。
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
需要注意的是,Java中的hashCode()方法返回的整数并不一定是一个小整数,也可能是一个很大的整数(在32位表示范围内是等可能出现的),所以并不能直接使用该函数的返回值作为散列表中数据存储的索引,需要进行二次处理才能得到真正的索引。通过Hashtable的源码可以看出这点,下面是Hashtable中put方法的源码。从源码中可以看出通过hashCode()方法得到的hash值经过了一定的处理再与散列表的长度求余才得到最终的索引。
public synchronized V put(K key, V value) {
// Make sure the value is not null
if (value == null) {
throw new NullPointerException();
}
// Makes sure the key is not already in the hashtable.
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> entry = (Entry<K,V>)tab[index];
for(; entry != null ; entry = entry.next) {
if ((entry.hash == hash) && entry.key.equals(key)) {
V old = entry.value;
entry.value = value;
return old;
}
}
addEntry(hash, key, value, index);
return null;
}
另外,为了判断两个对象是否相等即equals()方法返回的值是否为true,需要用到hashCode()方法。如果equals方法返回值为true,那么参与对比的两个对象的hash值必须相同。反之,就算两个对象的hash值相同,也不能够保证两个对象相等(equals方法返回为true)。但是,如果两个对象的hash值不同,那么它们肯定是不相等的。(这里说的相等于不相等都是指的equals()方法返回值是否为true)。所以,在重写equals()方法的同时,也必须重写hashCode()方法。
碰撞冲突的处理
前面讲述了散列表中的散列函数。由于散列函数不能对所有的键进行完全均匀的散列,所以,就可能产生碰撞冲突的现象。下面分别简单介绍一下常见的两种处理碰撞冲突的方法。
拉链法
碰撞冲突是指由于多个键被散列得到了同一个散列值,从而得到了同一个索引。而数组中原本一个位置只能存放一个键值对,所以就导致在数组中存储时出现了位置冲突的现象。处理碰撞冲突的一种方法就是,散列表数组中每个元素的位置不再是存储一个键值对,而是存储一条链表(或者是其他的符号表),链表中的每个结点都存储了一个键值对。这样,每当一个键被散列到数组中某一个位置时,即使在之前已经有其他的键被散列到该索引位置也没有关系,只需要新建一个结点存储当前的键值对,并将其链接到该位置的链表即可(出于数据访问的特点,一般是将新建结点作为链表的头结点,但是并不一定),如此一来就不会再出现没有位置存储碰撞数据的情况了。这种方法叫拉链法。使用此方法的一个例子如下。需要存储的键值对以及通过某一散列函数得到的散列值如下图所示,右半部分显示了所有数据插入完成之后散列表的结构(数据插入时是作为链表的尾节点链接到原链表的)。
采用拉链法,大小为M的散列表可以存储N对(N>M)键值对。散列表中每条链表的长度约为lamda = N/M,lamda称作装填因子,是影响散列表性能的主要因素。
使用拉链法实现的散列表可以灵活地在时间与空间之间摇摆,一方面可以通过增加散列表的大小(减小链表的平均长度)用空间换时间,另一方面也可以减小散列表的大小节约空间,但是会使数据读写的时间耗费相对增加。
对于一定数量的N:
- 当M<N时,lamda大于1,链表的平均长度大于1,散列表读写数据的时间相对较长
- 当M=N时,lamda等于1,链表的平均长度等于1,散列表读写数据的时间相对较短
- 当M>N时,lamda小于1,链表的平均长度小于1,散列表读写数据的时间相对较短
当需要存储数据时,可以首先预估一下数据的规模,使散列表采用合适的大小M。当存储的数据量不断增加时,链表的长度不断加大,数据读写的时间不断增加。为了降低数据读写时间,可以新建一个更大的散列表,将之前存放的所有键值对进行再次散列并存放到新的散列表中。但是再散列的耗费是相当大的,会将之前已经存放的所有键值对都重新处理一遍。所以,合理预估数据规模并设定合适的散列表大小是非常重要。
从Hashtable的源码中可以看出其采用的方法就是拉链法。Hashtable会通过装填因子lamda的变化而调整散列表的大小。默认的装填因子的阈值大小为0.75。当装填因子大于此值时,就会进行扩容,将M增大为原来的2倍并进行再散列。
开放地址法
解决碰撞冲突的另一个方法是采用开放地址法。
线性探测法
开放地址法中最简单是采用线性探测法。当碰撞发生时,直接检查数组中下一个元素的位置,如果当前位置已经是数组的最后一个位置,则下一个位置是数组的第一个位置。
以插入操作为例,首先根据键的散列值确定索引。然后判断散列表数组中该索引位置是否为空,如果是,就直接将键值对存放到该位置处,如果不是,就判断下一个位置是否为空。一直进行此过程,直到找到一个空的位置进行存储。下面是一个简单的例子。
如果当前操作是在散列表中查找数据,首先根据散列值确定索引。如果该索引位置处元素为null,没有存储键值对,则查找结束,当前散列表中不存在查找的键。如果该索引位置处的元素不为null,即该位置存储了键值对,则判断存储的键值对是否为需要查找的键值对。若是的,则查找结束,返回该键值对;若不是,则依次检查该索引位置的下一个位置。直到查找成功,或者遇到一个元素为空的位置表明查找失败。
以上例子表明,在向基于开放地址法实现的散列表中读取、插入或者删除数据时,当碰撞产生时可能会多次检查多个索引位置处的元素。假定h(i)为第i+1次检查的索引位置,且h(i) = ( hash + f(i) ) % M。在以上的例子中,每次检查的索引位置较上次检查的位置都是增加了1。当第1次检查时,也就是h(0)=hash % M,所以f(0) = 0。 所以f(i)=i。f(i)为线性函数,所以此方法称为线性探测法。
平方探测法
平方探测法与线性探测法的原理类似,只是每次产生冲突后下一个检查的索引位置不再是单纯的线性的增加,而是以2次的速度增加,也就是说就是f(i)=i^2。下面是一个简单的向使用平方探测法的散列表中插入数据的过程。插入59时发生了碰撞冲突,第二次检查的位置为h(1)=(9 + 1^2) % 10 = 0,而位置0处为空,所以将59插入位置0处。插入78时也发生了碰撞,第二次检查的位置为h(1)=(8 + 1^2) % 10 = 9,位置9处已经被占用,接着开始第三次的位置检查,h(2)=(8 + 2^2) % 10 = 2,位置2处为空,所以将78保存在此处,此次插入操作完成。