散列表
什么是散列表
散列表也叫哈希表,是一种通过键值对直接访问数据的机制。
每一个key对应一个唯一索引。
散列表的实现原理正是映射的原理,通过设定的一个关键字和一个映射函数,就可以直接获得访问数据的地址,实现O(1)的数据访问效率。
在映射的过程中,事先设定的函数就是一个映射表,也可以称作散列函数或者哈希函数。
偷的一张图片:
将key进行hash变换,然后直接找到存储数据的数组的索引下标,时间复杂度O(1)。
散列表的特点
- 查找效率高。通过键可以直接计算出存储位置,无需遍历整个结构,平均情况下时间复杂度是 O(1)。
- 插入和删除的效率也较高(不冲突的话),时间复杂度也是O(1)。
- 占用空间大。为了换取高效率,内部需要使用链地址法解决冲突,占用空间更大。
- 哈希表是无序的,遍历输出的结果是根据索引的。
- 适用于大量数据的快速查找。适合存储大量的数据,快速进行查询。
散列表的实现
散列表的实现,散列表的实现是使用数组。因为数组可以通过下标直接访问,然后通过hash函数计算出key的索引。这样充分利用了数组的快速访问机制。
散列表冲突
我们在使用key计算hash值时,可能会发生冲突。
拿String举例子,String的hashcode()函数返回值仅仅是一个int,而int仅仅是四个字节,而String是无穷大的,所以数据多起来是一定会发生hash冲突的(hash冲突指不同key的hashcode值一样)。
发生hash冲突后,我们通过下标访问的数据就会冲突,比如:
key: hhh, value:ok;
key: ooo, value:ko;
如果hhh和ooo的hash函数值相等,那么它们就会被存到同一个索引下。 这样我们取数据时取的是哪个呢?
这时就叫发生了hash冲突,我们应该怎么办呢?
有以下几种常见的办法:
- 链地址法(拉链法): 在冲突的位置用一个链表将冲突的数据串起来,依次访问,直到找到我需要的数据
- 开放地址法: 当发生冲突时,从冲突的位置开始继续往后遍历,找到没有存储值的空间,将冲突键值对存储进去。
- 再hash法,当哈希发生冲突的时候,不仅修改哈希表索引,还修改哈希函数,使用另外的哈希函数重新计算哈希值。
还有解决办法,我这里只是写了常用的。
散列表在Java中的实现
Java中散列表的实现主要有HashMap和HashTable。
- HashMap:
- HashMap 是基于哈希表的 Map 接口的最常用实现。
- 它实现了 Map 接口,允许使用 null 键和值。
- Hashtable:
- 不允许使用 null 键和值。
- 它的效率比 HashMap 低,但是线程是安全的
它们都是使用拉链法来解决hash冲突。
手写一个简单的HashMap
我们实现一个简单的HashMap,拥有基本的get(), put()方法即hash冲突的解决。我们这里仅仅是对散列表的一个认识,没有完成HashMap的扩容,红黑树转换。
下面是完成后的类的结构:
从上到下依次是:
- 存储数据的节点数组
- 数组容量
- hash算法
- 构造函数
- 存放键值对的方法
- 取值方法
- 静态节点类
构造函数
FakeHashMap(){
elements = new Node[8];
}
这里给数组开一个容量为8的空间,如果太大,不便于测试
静态节点类
static class Node<K,V> {
final K key;
V value;
Node<K, V> next;
Node(K key, V value, Node<K, V> next) {
this.key = key;
this.value = value;
this.next = next;
}
}
这个类设为静态的,这样所有的FakeHashMap使用时都不用再加载一遍,它是存在于内存的,使用时更方便。
数组存储的基本单位,也是实现解决hash冲突的关键,结构为链表。
hash函数
int hash(K key){
return key.hashCode();
}
这里是返回键的hashCode值,所以键对象需要实现hashcode()方法。
put()方法
public void put(K key, V value ){
// 找出索引
int index = hash(key) & (capacity-1);
// 找出索引对应的值
Node<K, V> element = elements[index];
// 判断是否为空
if (element ==null){
elements[index] = new Node<K,V>(key,value,null);
return;
}
Node<K, V> prev = null;
// 当其不为空
while (element!=null){
// 判断是否与加进来的相同
if(key.equals(element.key)){
// 覆盖值
element.value = value;
return;
}
prev = element;
element = element.next;
}
prev.next = new Node<K,V>(key,value,null);
}
put方法:
- 先计算出数组下标,通过hashcode()值 & (capacity-1), 这样得到的下标永远不会超过capacity。
任何数 & 0111, 得到的范围都是0-7。 - 判断索引位置是否有值,无值就往里面添加新节点;有值就通过equals判断是否与我的key相同,相同就覆盖,不相同就继续遍历;如果里面没有相同的,则把新节点放在当前链表末尾。
你应该看到比较key需要使用equals()方法,所以key也必须重写equals()方法
get()方法
public V get(K key){
int hash = hash(key);
int index = hash & (capacity-1);
Node<K, V> element = elements[index];
while (element!=null){
if(element.key.equals(key)){
return element.value;
}
element = element.next;
}
return null;
}
get方法就稍微简单了,通过同样的方法获取数组索引,然后取出元素,看key是否相同,相同就取值,不相同就继续遍历;如果没有,返回null。
测试方法
public static void main(String[] args) {
FakeHashMap<String, String> map = new FakeHashMap<>();
map.put("hhh", "hhh");
map.put("jjj", "jjj");
map.put("www", "www");
map.put("aaa", "aaa");
map.put("bbb", "bbb");
map.put("ccc", "ccc");
map.put("ddd", "ddd");
map.put("mmm", "mmm");
map.put("eee", "eee");
System.out.println("测试冲突会不会被覆盖:");
for (Node<String, String> element : map.elements) {
while (element!=null){
System.out.print(element.value + " ");
element = element.next;
}
System.out.println();
}
System.out.println("=======================");
System.out.println("测试冲突的值会不会拿不到:");
System.out.println(map.get("eee"));
System.out.println(map.get("jjj"));
System.out.println("测试相同key会不会覆盖值:");
System.out.println("原来的eee对应值:"+map.get("eee"));
map.put("eee", "???");
System.out.println("现在的eee对应值:"+map.get("eee"));
}
我这里的key使用的是String,它帮我们重写了hashCode()和equals(),所以我们可以直接使用。
输出
测试冲突会不会被覆盖:
hhh
aaa
jjj bbb
ccc
ddd
mmm eee
www
=======================
测试冲突的值会不会拿不到:
eee
jjj
测试相同key会不会覆盖值:
原来的eee对应值:eee
现在的eee对应值:???
分析:
- 我们往数组放了九个数,而数组容量是8,所以一定是会冲突的,我们打印每一个索引对应的值;可以看到jjj bbb冲突, mmm 和 eee冲突,但是数据都没丢失,都被打印出来了
- 然后测试冲突值是否可取,我们通过key获得冲突的值,获取成功
- 然后测试key相同是否会覆盖,结果覆盖了,证明测试成功。
那证明我们手写的这个HashMap功能是满足hash表的特点的,而且还解决了hash冲突。
总结
- 散列表就是一个key对应一个索引下标,一种映射关系,虽然可能会有冲突,所以hash函数选取不容易发生冲突的
- 散列表查询速度很快,时间复杂度为O(1),冲突了就是O(1 + 冲突的个数)。
- 散列表要正确使用,你选为key的类必须正确编写hashCode()与equals()方法
- Java中主要实现了散列表的有HashMap和HashTable,但是HashTable是线程安全的
- hash冲突是指不同的key计算出了相同的hash值
- hash冲突可以减少,不可能避免
- HashMap和HashTable解决冲突使用链地址法
- 手写HashMap不难,难的是冲突长度超过8,数组长度超过64链表会变为红黑树(我写不来),红黑树查询时间复杂度降低(O(logn))