一、散列函数
散列函数会将键转为数组的索引。我们的散列函数应该计算速度快且能够均匀分布所有的键,如对于大小为M的散列表,我们的散列函数应当能够让任意的key都能够转化为 0-到M-1 的整数,对于不同的键应该有不同的散列函数。Java中许多常用的类都重写了hashCode方法,以针对不同的数据类型使用不同的散列函数。
二、基于拉链法的散列表
散列算法理想的状态是将不同的key都转为不同的索引值,但这显然是不可能的,一定会产生冲突,因此我们就需要对冲突进行处理。
一种直接的方法是将大小为M的数组中的每个索引指向一条链表,链表中的每个节点都存储了散列值为该链表所在索引值的键值对,这种方法就是拉链法。
如下是基本数据结构:
public class SeparateChainingHashST<Key,Value> {
/**
* 键值对总数
*/
private int N;
/**
* 散列表大小
*/
private int M;
private SequentialSearchST<Key,Value>[] st;
public SeparateChainingHashST() {
this(997);
}
public SeparateChainingHashST(int M) {
this.M=M;
st=new SequentialSearchST[M];
for (int i = 0; i < M; i++) {
//数组中每个索引值都初始化一个链表
st[i]=new SequentialSearchST<>();
}
}
}
SequentialSearchST
是前面顺序查找中实现的无序链表:
ublic class SequentialSearchST<Key, Value> {
/**
* 首节点
*/
private Node first;
private int size;
private class Node {
private Key key;
private Value value;
private Node next;
public Node(Key key, Value value, Node next) {
this.key = key;
this.value = value;
this.next = next;
}
}
/**
* 根据key查询对应的值,一个个往下遍历直到找到相等的key,返回对应的值,否则返回null
* @param key
* @return
*/
public Value get(Key key) {
for (Node x = first; x != null; x = x.next) {
if (key.equals(x.key)) {
return x.value;
}
}
return null;
}
/**
* 加入一个元素
* @param key
* @param value
*/
public void put(Key key, Value value) {
for (Node x = first; x != null; x = x.next) {
//key已存在,更新对应的值
if (key.equals(x.key)) {
x.value = value;
return;
}
}
//key不存在,新添加一个节点
first = new Node(key, value, first);
size++;
}
public boolean isEmpty() {
return size == 0;
}
private int size() {
return size;
}
public boolean contains(Key key) {
if (key == null) throw new IllegalArgumentException("argument to contains() is null");
return get(key) != null;
}
/**
* 删除key对应的节点
* @param key
*/
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException("argument to delete() is null");
first = delete(first, key);
}
/**
* 递归查找,直到找到相等的key,正常删除链表节点
* @param x
* @param key
* @return
*/
private Node delete(Node x, Key key) {
if (x == null) return null;
if (key.equals(x.key)) {
size--;
return x.next;
}
x.next = delete(x.next, key);
return x;
}
public Iterable<Key> keys() {
Queue<Key> queue=new Queue<>();
while (first!=null){
queue.enqueue(first.key);
first=first.next;
}
return queue;
}
}
hash计算:
上面说了,java中为所有数据类型都重写了hashCode()方法,该方法返回一个32位比特的整数,但我们需要的是数组的索引,因此我们需要将默认的hashCode方法和除留余数法结合起来产生一个0到M-1的整数,又因为hashCode返回的值是带有符号位的,这样就算导致计算结果出现负数,因此我们需要通过0x7fffffff
变为一个31位的非负整数,再使用除留余数法让其%M
,M为一个较大的质数。
private int hash(Key key){
return (key.hashCode() & 0x7fffffff) % M;
}
这样我们就可以对数据进行插入、删除和获取了:
插入实现:
如下,先计算key的哈希值,得到一个数组的索引,然后将该键值对插入到该索引对应的链表中。
public void put(Key key,Value value){
if (key==null){
throw new NoSuchElementException("key为空");
}
if (value==null){
delete(key);
}
//保证链表的长度在2到8之间
if (N>=8*M){
resize(M*2);
}
st[hash(key)].put(key,value);
}
删除实现:
先计算key的哈希值,找到其所在的链表,如果该链表中有该key,其删除之。
/**
* 删除指定键值对
* @param key
*/
public void delete(Key key) {
if (key == null) throw new IllegalArgumentException("argument to delete() is null");
int i = hash(key);
if (st[i].contains(key)){
N--;
}
st[i].delete(key);
//保证链表平均长度在2到8间 此为下界2
if (N>0 && N<=M*2){
resize(M/2);
}
}
public boolean contains(Key key) {
if (key == null) throw new IllegalArgumentException("key为空");
return get(key) != null;
}
获取值:
计算key的哈希值, 返回对应链表中的value
public Value get(Key key){
if (key == null) return null;
return st[hash(key)].get(key);
}
我们使用M条链表存储N个键,无论键在表中如何分布,其平均长度一定是 N/M
。
使用拉链法的一个好处是,如果存入的键多与预期,查找的时间只会比选择更大的数组长;如果低于预期,虽然会造成一点空间浪费,但查找很快。
因此内存足够时,可选择足够大的M,让查找使用变为常数;当内存紧张时,选择尽量大的M仍能够将性能提高M倍。
动态调整数组:
在删除后,如果平均长度N/M低于2,则让数组缩小 一倍:M/2;
在加入一个数据后,如果 N/M 高于8,则让数组增加一倍: M*2
private void resize(int capacity){
SeparateChainingHashST<Key,Value>hashST=new SeparateChainingHashST<>(capacity);
for (int i = 0; i < M; i++) {
for (Key key:st[i].keys()){
if (key!=null){
hashST.put(key,st[i].get(key));
}
}
}
this.M=hashST.M;
this.N=hashST.N;
this.st=hashST.st;
}
三、基于线性探测法的散列表
实现散列表的另一种方式是用大小为M的数组保存N个键值对(M>N),借助空位解决碰撞冲突,基于这种策略的所有方法都成为开放地址散列表。
开放地址散列表最简单的方法是线性探测法,即如果产生冲突(一个键的散列值已经被另一个不同的键占用),则直接检查散列表的下一个位置(索引+1),如果还是冲突,则一直向后探测,直到找到一个空位置,将该键值对插入进去。
数据结构如下:
这里使用一个Key[]保存键,一个Values[]保存key对应的值
public class LinearProbingHashST<Key, Value> {
private Key[] keys;
private Value[] values;
/**
* 键值对数
*/
private int N;
/**
* 线性表大小
*/
private int M;
public LinearProbingHashST(int M) {
this.M = M;
keys = (Key[]) new Object[this.M];
values = (Value[]) new Object[this.M];
}
}
插入操作:
我们需要计算待插入的键的hash值,然后判断当前索引是否被其他键占用,如果被占用的键也是待插入的键,则修改对应的值,否则一直遍历下去直到找到一个空位置。
public void put(Key key, Value value) {
if (key == null) throw new IllegalArgumentException("first argument to put() is null");
if (value==null){
delete(key);
}
//保证使用率 N/M 小于等于 1/2 ,当使用率趋近于1时,探测的次数会变得很大
if (N>=M*2){
resize(M*2);
}
int i;
for (i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (keys[i].equals(key)) {
//待插入的key存在,修改对应的值并返回
values[i] = value;
return;
}
}
//找到空位置,插入键值对
keys[i] = key;
values[i] = value;
N++;
}
private int hash(Key key) {
return (key.hashCode() & 0x7fffffff) % M;
}
查询操作:
计算key的哈希值,如果当前位置冲突(被其他key占用),则继续向后遍历,遍历的结束条件是从hash(key)到下一个null位置前,如果存在,则返回对应的value,如果不存在则返回null
public Value get(Key key) {
for (int i = hash(key); keys[i] != null; i = (i + 1) % M) {
if (keys[i].equals(key)) {
return values[i];
}
}
return null;
}
对于如下一个散列表:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
P M A C S H L E R X
10 9 8 4 0 5 11 12 3 7
A的散列值是4,H的散列值也是4,但因为在4冲突,所以插入H的时候被线性移动到了7位置,因此我们在查找H对应的值的时候,先从索引4开始,不匹配,顺次向下查找,如果在空位置9之前还没找到,说明不存在H,但因为H在9之前的7位置,索引返回对应的值5.
删除操作:
对于删除操作,我们不可以直接将对应的key置为null。
同样是上面的散列表,假如删除C,而H的散列值为4,在线性探测的过程中,会因为索引5为空,而“错误”的认为散列表中不存在H而返回null,但事实是H存在,并且在索引7中。
因此在删除给定key后,需要将key+1到下一个空位置前的所有键重新插入散列表,以避免上述错误的发生。
public void delete(Key key) {
if (!contains(key)) {
return;
}
int i=hash(key);
//线性探测找到待删除的key的索引
while (!keys[i].equals(key)){
i=(i+1)%M;
}
//置空
keys[i]=null;
values[i]=null;
//将i+1的位置到下一个空位置前的所有key重新插入到散列表中
i=(i+1)%M;
while (keys[i]!=null){
Key oldKey=keys[i];
Value oldValue=values[i];
keys[i]=null;
values[i]=null;
N--;
put(oldKey,oldValue);
i=(i+1)%M;
}
N--;
if (N>0 && N <= M/8){
resize(M/2);
}
}
另α= N/M
,我们称α为散列表的使用率
在《算法4》中给出了如下结论:
在一张大小为M并含有N个键的基于线性探测的散列表中,如果我们的散列函数能够均匀并独立地将所有的键分布在0到M-1之间,则命中和未命中的查找所需的探测次数分别为:
1 / 2(1 + 1 / 1−α) and 1/2 ( 1 + 1 / (1−α) ^ 2)
即当散列表快满的时候,查找所需的探测次数是巨大的(α趋近于1),但当使用率α < 1/2
时,探测的预计次数只在1.5到2.5之间,因此我们要保证 α的值不大于 1/2。
基于此我们要在
插入前和删除后对数组进行动态调整:
插入前判断:
//保证使用率 N/M 不能超过1/2 ,当使用率趋近于1时,探测的次数会变得很大
if (N>=M*2){
resize(M*2);
}
删除后判断:
保证使用的内存量和表中的键值对数量的比例总在一定范围内。
//数组减小一半,如果N/M 为12.5% 或更少
if (N>0 && N <= M/8){
resize(M/2);
}
来源:CSDN
作者:一颗小陨石
链接:https://blog.csdn.net/weixin_43696529/article/details/104731252