1.散列表的基本原理与实现[Hash Table]:
对于基于散列表实现的符号表,若要查找一个键,需要进行以下步骤:
a.使用散列函数将给定键转化为一个"数组的索引",理想情况下,不同的key会被转为不同的索引,但在实际应用中会遇到不同的键转为相同的索引的情况,这种情况叫做碰撞。
b.得到索引后,就可以像访问数组一样,通过这个索引访问到相应的键值对。
散列表是时空权衡的经典例子。
2.散列函数
在散列内部,我们使用桶(bucket)来保存键值对,前面所说数组的索引即为桶号,决定了给定的键存在于散列表的哪个同种。
散列表所拥有的桶数被称为散列表容量(capacity)
均匀散列假设: 我们使用的散列函数能够均匀并独立地将所有的键散布于0到M-1之间
Java中许多常用的类都重写了hashCode方法(Object类的hashCode方法默认返回对象的内存地址),用于为该类型对象返回一个hashCode,通常我们用这个hashCode除以桶数M的余数就可以获取一个桶号。
code example:
String 类的hashCode方法:
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;
}
hashCode方法中的value是一个char[]数组,存储中字符串的的每字符。我们可以看到在方法的最开始我们会把hash赋给h,这个hash就表示之前计算的hashCode,这样以来若之前已经计算过这个字符串对象的hashCode,这次我们就无需再计算了,直接返回之前计算过得即可。这种把hashCode缓存的策略只对不可变对象有效,因为不可变对象的hashCode是不会变的。
数值类型的hashCode方法:
public int hashCode(){
return Integer.hashCode(value);
}
public static int hashCode(int value){
return value;
}
Double类的hashCode方法:
public int hashCode(){
return Double.hashCode(value);
}
public static int hashCode(double value){
long bits = doubleToLongBits(value);
return (int)(bits^(bits>>>32));
}
Date类的hashCode方法:
public int hashCode(){
long ht = this.getTime();
return (int) ht^(int)(ht>>32);
}
由hashCode取桶号
private int hash(K key){
return (x.hashCode() & 0x7ffffffff) % M;
}
一个直接的办法就是直接拿得到的hashCode除以capacity(桶的数量),然后用所得的余数作为桶号。不过在Java中,hashCode是int型的,而Java中的int型均为有符号,所以我们要是直接使用返回的hashCode的话可能会得到一个负数,显然桶号是不能为负的。所以我们先将返回的hashCode转变为一个非负整数,再用它除以capacity取余数,作为key的对应桶号。
解决碰撞:
1.使用拉链法处理碰撞
public class ChainingHashMap<K,V>{
private int num;
private int capacity;
private SeqSearchST<K,V> st;
public ChainingHashMap(int initalCapacity){
capacity = initalCapacity;
st = (SeqSearchST<K,V>) new Object[capacity];
for(int i=0;i<capacity;i++){
st[i] = new SeqSearchST<>();
}
}
private int hash(K key){
return (key.hashCode() & 0x7ffffffff) % capacity;
}
private V get(K key){
return st[hash(hash)].get(key);
}
public void put(K key,V value){
st[hash(key)].put(key,value);
}
}
public class SeqSearchST<K, V>{
private Node first;
private class Node{
K key;
V val;
Node next;
public Node(K key,V val,Node next){
this.key = key;
this.val = val;
this.next = next;
}
}
public V get(K key){
for(Node node = first; node !=null; node = node.next){
if(key.equals(node.key)){
return node.val;
}
}
return null;
}
public void put(K key,V val){
Node node;
for(node = first;node !=null; node = node.next){
if(key.equals(node.key)){
node.val = val;
return ;
}
}
first = new Node(key,val,first);
}
}
2.线性探测法处理碰撞
线性探测法是另一种散列表的实现策略的具体方法,这种策略叫做开放定址法。开放定址法的主要思想是:用大小为M的数组保存N个键值对,其中M > N,数组中的空位用于解决碰撞问题。
线性探测法的主要思想是:当发生碰撞时(一个键被散列到一个已经有键值对的数组位置),我们会检查数组的下一个位置,这个过程被称作线性探测。线性探测可能会产生三种结果:
-
命中:该位置的键与要查找的键相同;
-
未命中:该位置为空;
-
该位置的键和被查找的键不同。
动态调整数组大小:
数组的大小为桶数的2倍,不支持动态调整数组大小。而在实际应用中,当负载因子(键值对数与数组大小的比值)接近1时,查找操作的时间复杂度会接近O(n),而当负载因子为1时,根据我们上面的实现,while循环会变为一个无限循环。显然我们不想让查找操作的复杂度退化至O(n),更不想陷入无限循环。所以有必要实现动态增长数组来保持查找操作的常数时间复杂度。当键值对总数很小时,若空间比较紧张,可以动态缩小数组,这取决于实际情况。
要实现动态改变数组大小:
if(num == capacity/2)
resize(2*capacity);
private void resize(int newCapacity){
LinearProbingHashMap<K,V> hashmap = new LinearProbingHashMap<>(newCapacity);
for(int i=0;i<capacity;i++){
if(keys[i] !=null ){
hashmap.put(keys[i],values[i]);
}
}
keys = hashmap.keys;
values = hashmap.values;
capacity = hashmap.capacity;
}
关于负载因子与查找操作的性能的关系,这里贴出《算法》(Sedgewick等)中的一个结论:
在一张大小为M并含有N = a*M(a为负载因子)个键的基于线性探测的散列表中,若散列函数满足均匀散列假设,命中和未命中的查找所需的探测次数分别为:
- 1/2 * (1 + 1/(1-a))和~1/2*(1 + 1/(1-a)^2)
关于以上结论,我们只需要知道当a约为1/2时,查找命中和未命中所需的探测次数分别为1.5次和2.5次。还有一点就是当a趋近于1时,以上结论中的估计值的精度会下降,不过我们在实际应用中不会让负载因子接近1,为了保持良好的性能,在上面的实现中我们应保持a不超过1/2。