哈希表是一个用途很广泛的数据结构,常用于需要进行大集合搜索的地方,比如腾讯QQ。对于上线的用户我们需要将其添加到一个集合中,以便对其进行各种处理。那么这个集合该采取哪种数据结构呢?最基本的数据结构就两种:链表和数组。在前面的文章中,我们曾经比较过链表和数组的优缺点。链表适用于插入和删除操作较多的集合,但是不适用于取值操作多的集合。而数组不适用于插入和删除操作较多的集合,但是适用于取值操作较多的集合。然而很不幸的是,对于QQ而言。它既有很多插入删除操作也有很多取值操作。每当用户上下线,我们都需要立即将这个用户从集合中添加删除。而当用户上线时,我们需要将它与所有已经上线的用户比较一遍,来确定这个账号是不是已经在线了,防止重新登陆。这样一来,无论是链表还是数组,都无法很好地适用于这个场景。因此,今天我们就来介绍一个介于数组和链表之间的数据结构——哈希表。
一、哈希表的结构
哈希表又被称为数组链表。当插入删除操作和取值操作都较频繁时,我们可以采用哈希表来作为集合的数据结构。
定义:哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。大致结构如下
特点:
1.第一列是一个数组。因此我们在查找每一个链表头结点位置所耗费的时间复杂度都是常数1;
2.每一行都是一个链表。理论上,这个链表可以无限扩大。实际上当然是不行的,我们可以设想两种极端情况。一种是链表的长度远远大于头结点数组的长度,那么这时这个哈希表其实就相当于一个链表,它取值操作的时间复杂度还是接近n。另一种情况就是链表的长度远远小于头结点数组的长度,那么这时这个哈希表其实就相当于一个数组,它插入和删除操作的时间复杂度还是接近n。为了避免这两种极端情况的出现,我们引入了一个控制变量peakValue(当前哈希表的数据个数/数组长度)。如果这个值超过了某一界限,我们就对当前的哈希表进行重构。
3.每一次存放和取出数据,都是先找到对应的行号(即头结点的位置),然后再去遍历该行链表中的各个数据。
二、哈希表的构建思路
基本思路:首先我们需要开辟一个连续的数组来储存每个头结点,这个数组的大小是固定的。每当我们取到一个待加入的键值对时,首先要将其封装成一个节点。然后根据key计算出相应的hashcode,这个hashcode会定位到唯一的一个链表头。最后再把数据放到这个链表里面。
需要实现的方法
1.添加数据put()
2.获取数据get()
3.返回当前哈希表的大小size()
4.展示当前的哈希表构成show()
5.哈希表的重构rehash()——私有方法
6.具体的添加数据的方法input()——私有方法
这个方法里面实现了具体的数据添加方法,其实就是把rehash()和put()两个方法的共同部分给提取了出来,实现代码的复用
三、源代码
//构建一个Hashtable类
public class Hashtable {
//定义一个节点类,里面定义了每一个节点所需要的数据
public class Node {
Node next;//指向下一节点
Object key;//键值
Object data;//数据域
//节点的构造函数
public Node(Object key,Object data) {
this.key=key;
this.data=data;
}
}
public Node[] Headlist=new Node[1];//申请一个定长数组
public int size=0;//记录当前hash表的元素个数
public float peakValue=1.7f;//定义一个峰值,如果当前hash存储的元素个数超过这个峰值就进行rehash
//主函数入口
public static void main(String[] args) {
//定义一定数量的键值对
String[] key= {"a","b","c","d","e","f","g","i"};
String[] data= {"1","2","3","4","5","6","7","8"};
//初始化哈希表
Hashtable table=new Hashtable();
for(int i=0;i<key.length;i++) {
//将每一个键值对一一加到构造好的哈希表中
table.put(key[i], data[i]);
System.out.println("展示当前的hash表");
//展示每一次添加数据之后的哈希表构成
table.show();
}
for(int i=0;i<key.length;i++) {
//根据键值从哈希表中获取相应的数据
String data1=(String)table.get(key[i]);
System.out.print(data1+" ");
}
}
//往哈希表中添加一个键值对
public void put(Object key,Object data) {
//判断当前的哈希表容量是否已经达到峰值,如果达到峰值,就hash表的重构
if((size*1.0)/Headlist.length>peakValue) rehash();
//调用hash函数获取键值对应的hashcode,从而定位到相应的头结点
int index=hash(key,Headlist.length);
//把当前的节点封装成Node节点类
Node node=new Node(key,data);
//加入哈希表
input(node,Headlist,index);
size++;
}
//设计一个添加函数,实现代码的复用
private void input(Node node,Node[] list,int index) {
//如果头结点位置为空,则把当前节点赋值给头结点
if(list[index]==null) {
list[index]=node;
}else {
//否则,遍历该链表,并判断该键值是否已经存在于哈希表中,如果没有就将其加到链表尾部
Node temp=list[index];
//判断表头元素的键值是否和我们即将输入的键值一样
if(temp.key==node.key) {
System.out.println(temp.key+"--该键值已存在!");
}else {
while(temp.next!=null) {
temp=temp.next;
if(temp.key==node.key) {
System.out.println(temp.key+"--该键值已存在!");
break;
}
}
temp.next=node;
}
}
}
//hash函数计算出键值对应的hashcode,也就是头结点的位置
private Integer hash(Object key,int lenth) {
Integer index=null;
if(key!=null) {
//进来的可能是一个字符串,而不是数字
//先转化为字符数组
char[] charlist=key.toString().toCharArray();
int number=0;
//依次计算出每个字符对应的ASCII码
for(int i=0;i<charlist.length;i++) {
number+=charlist[i];
}
//对哈希表的数组长度取余,得到头结点的位置
index=Math.abs(number%lenth);
}
return index;
}
//rehash函数对当前的hash表进行扩展,重新定位当前表中的所有
public void rehash() {
//每次扩展都把当前的哈希表增大一倍
Node[] newnode=new Node[Headlist.length*2];
//遍历原来的哈希表,依次把每个数据重新添加到新的哈希表中
for(int i=0;i<Headlist.length;i++) {
if(Headlist[i]!=null) {
//先把每个列表的头结点重新hash进去
int headposition=hash(Headlist[i].key,newnode.length);
//这个地方一定要用new重新构建一个新的节点来保存原来哈希表中节点的键值对。
Node rehashheadnode=new Node(Headlist[i].key,Headlist[i].data);
//设置它的下一个节点为空,这条代码不写也可以,这里为了强调它的重要性,特意将其写了出来
//这条代码的作用就是去除原来哈希表中各个节点的关联关系
rehashheadnode.next=null;
input(rehashheadnode,newnode,headposition);
Node temp=Headlist[i];
while(temp.next!=null) {
temp=temp.next;
//定义一个Node类型的数据来储存需要rehash的数据
Node rehashnextnode=new Node(temp.key,temp.data);
rehashnextnode.next=null;
int nextposition=hash(temp.key,newnode.length);
input(rehashnextnode,newnode,nextposition);
}
}
}
//重新设置节点数组的引用
Headlist=newnode;
}
//显示当前的hash表
public void show() {
for(int i=0;i<Headlist.length;i++) {
if(Headlist[i]!=null) {
System.out.print(Headlist[i].key+":"+Headlist[i].data+"-->");
Node temp=Headlist[i];
while(temp.next!=null) {
temp=temp.next;
System.out.print(temp.key+":"+temp.data+"-->");
}
System.out.println();
}
}
}
//获取键值相对应的数据
public Object get(Object key) {
//先获取key对应的hashcode
int index=hash(key,Headlist.length);
Node temp=Headlist[index];
//先判断相应的头结点是否为空
if(temp==null) return null;
else {
//判断节点中的key和待查找的key是否相同
if(temp.key==key) return temp.data;
else {
while(temp.next!=null) {
temp=temp.next;
if(temp.key==key) return temp.data;
}
}
}
return null;
}
//返回当前hash表的大小
public int length(){
return size;
}
}
/*
*巨坑,原来的节点的下一个节点需要重新设置为null
*/
四、运行结果
五、总结反思
1.把一个String字符串转化为int型整数有两种意思。A.字符串本身是0-9的数值,我们把这个数值由字符串类型变为int类型。B.每个字符都有相应的ASCII码,而字符串是由字符组成的,因此我们可以获取它对应的ASCII的值。在这里我们要做的自然是第二种,而这个值我们就把它作为hashcode,通过这个值我们可以唯一找到每个键值key对应的头结点。
问题:对应字符相同,但是序列不同的字符串会不会出错。比如,“abbb”和“baaa”。这两个键值,他们对应的hashcode肯定是一样的,那么他们就会被映射到同一个头结点中,但是由于我们在判断重复的时候是直接比较键值,显然"abbb"!=“bbba”。所以即使哈希表中已经有了"abbb"这个键值,"bbba"也还是可以正常被加入到hash表中。
2.巨坑:在重构hash表的时候,一定要把原来hash表中各个节点的关联关系去掉!!!并且只能通过新建节点的方法,不能通过简单赋值。因为JAVA的引用和C++的指针不同。简单说就是如果我们令a=b;在JAVA中的意思是,a和b指向同一个内存地址的引用,如果你改变了a,b同样会被改变。而C++中的意思则是,额外开辟一个内存来保存b地址中的数据,这时不管你如何改变a的值,b的值都不会受影响。因此这里如果我们仅仅是通过节点的赋值来添加数据,原来hash表的结构很可能会在过程中被破坏,导致重构失败。(关于这个问题的详细解释,可以看我的另外一篇博客《JAVA赋值和C++赋值的区别》)
3.当数据量持续增大时,该哈希表的性能下降得很厉害。和JAVA中封装好的哈希表性能比较如下:
MyHash
数据量 | 耗时/ms |
|
100 | 0 |
|
10000 | 47 |
|
100000 | 4382 |
|
1000000 | 还没跑出来。。。 |
|
HashMap
数据量 | 耗时/ms |
|
100 | 0 |
|
10000 | 0 |
|
100000 | 42 |
|
1000000 | 430 |
|
Hashtable
数据量 | 耗时/ms |
|
100 | 0 |
|
10000 | 16 |
|
100000 | 72 |
|
1000000 | 324 |
|
目前已知的一个比较重要的原因是JAVA中的哈希表使用了红黑树来保持平衡,所以当数据量增大的时候,它的性能保持得比较好。想要了解更具体的细节原因请移步至我的另一篇博客《Hashtable和HashMap》