数据结构(Java实现)-详谈哈希表(Hash Table)

1、哈希表介绍

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)直接进行访问数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

哈希表的底层是数组
实现哈希表的两种方法:
1、数组+链表
2、数组+红黑二叉树

2、哈希函数H(k)

哈希函数:建立起数据元素的存放位置与数据元素的关键字之间的对应关系的函数。即使用哈希函数可将被查找的键转换为数组的索引。理想情况下它应该运算简单并且保证任何两个不同的关键字映射到不同的单元(索引值)。但是,这是不可能的,很多时候我们都需要处理多个键被哈希到同一个索引值的情况,即哈希碰撞冲突

哈希函数的构造方法:
(1)直接定址法
取关键字或关键字的某个线性函数值为哈希地址。即H(key)=key 或 H(key)=a*key+b (a,b为常数)。
举例1:统计1-100岁的人口,其中年龄作为关键字,哈希函数取关键字自身。查找年龄25岁的人口有多少,则直接查表中第25项。
在这里插入图片描述
举例2:统计解放以后出生人口,其中年份作为关键字,哈希函数取关键字自身加一个常数H(key)=key+(-1948).查找1970年出生的人数,则直接查(1970-1948)=22项即可
在这里插入图片描述
(2)数字分析法
若关键字是以r为基的数(如:以10为基的十进制数),并且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址。
举例:有80个记录,其关键字为8位十进制数,假设哈希表长1000,则可取两位十进制数组成哈希地址,为了尽量避免冲突,可先分析关键字。
在这里插入图片描述
经分析,发现第一位、第二位都是8,1,第三位只可能取3或4,第八位只可能取2,5或7,所以这四位不可取,那么对于第四、五、六、七位可看成是随机的,因此,可取其中任意两位,或取其中两位与另外两位的叠加求和舍去进位作为哈希地址。
(3)平方取中法
取关键字平方后的中间几位为哈希地址。(较常用的一种)

举例:为BASIC源程序中的标识符键一个哈希表(假设BASIC语言允许的标识符为一个字母或者一个字母和一个数字两种情况,在计算机内可用两位八进制数表示字母和数字),假设表长为512=2^{9},则可取关键字平方后的中间9位二进制数为哈希地址。(每3个二进制位可表示1位八进制位,即3个八进制位为9个二进制位)
A :01 (A的ASCII码值为65,65的八进制为101,取后两位表示关键字)
B:02 (B的ASCII码值为66,66的八进制为102,取后两位表示关键字)

Z:32(Z的ASCII码值为90,90的八进制为132,取后两位表示关键字)

0:60(0的ASCII码值为48,48的八进制为60,取后两位表示关键字)

9:71(9的ASCII码值为57,57的八进制为71,取后两位表示关键字)
在这里插入图片描述
(4)折叠法
将关键字分割成位数相同的几部分(最后一部分的位数可不同),然后取这几部分的叠加和(舍去进位)作为哈希地址。适用于关键字位数比较多,且关键字中每一位上数字分布大致均匀时。
举例:根据国际标准图书编号(ISBN)建立一个哈希表。如一个国际标准图书编号 0-442-20586-4的哈希地址为:
在这里插入图片描述
(5)除留余数法
取关键字被某个不大于哈希表表长m的数p除后所得余数为哈希地址(p为素数)
H(key)=key MOD p,p<=m (最简单,最常用)p的选取很重要
一般情况,p可以选取为质数或者不包含小于20的质因数的合数(合数指自然数中除了能被1和本身整除外,还能被其他数(0除外)整除的数)。
(6)随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址。即H(key)=random(key),其中random为随机函数。适用于关键字长度不等时。
总结:实际工作中根据情况不同选用的哈希函数不同,通常,考虑因素如下:
(1)计算哈希函数所需时间(包括硬件指令的因素)
(2)关键字的长度
(3)哈希表的大小
(4)关键字的分布情况
(5)记录的查找频率

3、解决哈希碰撞

1、开放地址法

开放地址法:通过系统的方法找到系统的空位(三种:线性探测、二次探测、再哈希法),并将待插入的元素填入,而不再使用用hash函数得到数字作为数组的下标。

  • 线性探测:假若当前要插入的位置已经被占用了之后,沿数组下标递增方向查找,直到找到空位为止
  • 二次探测:二次探测和线性探测的区别在于二次探测的步长是,若计算的原始下标是x则二次探测的过程是x+12,x+22,x+32,x+42,x+52随着探测次数的增加,探测的步长是探测次数的二次方(因此名为二次探测)。二次探测会产生二次聚集:即当插入的几个数经过hash后的下标相同的话,那么这一串数字插入的探测步长会增加很快
  • 再hash法:为了消除原始聚集和二次聚集,把关键字用不同的hash函数再做一遍hash化,用过这个结果作为探测的步长,这样对于特定的关键字在整个探测中步长不变,但是不同的关键字会使用不同的步长。stepSize = constant - (key % constant) 这个hash函数求步长比较实用,constant是小于数组容量的质数。(注意:第二个hash函数必须和第一个hash函数不同,步长hash函数输出的结果值不能为0)

哈希表的扩容
关于开放寻址也有个疑问,那就是如果一直找不到空的位置怎么办?
这种情况不存在,为啥嘞?你这样想,是因为你考虑了一个前提,那就是位置已经被占光了,没有空位置了,但是实际情况是位置不会被占光的,因为有一定量的位置被占了的时候就会发生扩容。当哈希表被占的位置比较多的时候,出现哈希冲突的概率也就变高了,所以很有必要进行扩容。
那么这个扩容是怎么扩的呢?
这里一般会有一个增长因子的概念,也叫作负载因子,简单点说就是已经被占的位置与总位置的一个百分比,比如一共十个位置,现在已经占了七个位置,就触发了扩容机制,因为它的增长因子是0.7,也就是达到了总位置的百分之七十就需要扩容。拿HashMap来说,当它当前的容量占总容量的百分之七十五的时候就需要扩容了。而且这个扩容也不是简单的把数组扩大,而是新创建一个数组是原来的2倍,然后把原数组的所有元素都重新Hash一遍放到新的数组。

2、链地址法

链地址法 :创建一个存放单词链表的数组,数组内不直接存放元素,而是存储元素的链表。发生冲突的时候,数据项直接接到这个数组下标所指的链表中即可。
  优势:填入过程允许重复,所有关键值相同的项放在同一链表中,找到所有项就需要查找整个是链表,稍微有点影响性能。删除只需要找到正确的链表,从链表中删除对应的数据即可。表容量是质数的要求不像在二次探测和再hash法中那么重要,由于没有探测的操作,所以无需担心容量被步长整除,从而陷入无限循环中。

如果冲突的很多,那这个增加的链表岂不是很长?
如果冲突过多的话,这个key对应的链表会变得比较长,怎么处理呢?这里举个例子吧,拿java集合类中的HashMap来说吧,如果这里的链表长度大于等于8的话,链表就会转换成红黑树结构,当然如果长度小于等于6的话,就会还原链表。以此来解决链表过长导致的性能问题。这样设计是因为中间有个7作为一个差值,来避免频繁的进行树和链表的转换,因为转换频繁也是影响性能的啊。

5、使用哈希表管理雇员信息

题目:有一个公司,当有新的员工来报道时,要求将该员工的信息加入(id、姓名,性别,电话),当输入该员工的id时,要求查找到该员工的所有信息.
要求:

  • 不使用数据库,速度越快越好
  • 添加时,保证按照id从低到高插入
  • 使用链表来实现哈希表, 该链表不带表头

思路分析:
添加雇员信息

  1. 创建一个节点类存储雇员的信息(id,name,sex,phone)
  2. 创建一个定长数组为哈希表,哈希表的每个数组元素储存一条链表头节点
  3. 根据散列函数将要添加的雇员id进行散列(比如:散列函数构造采用简单的取模法:H(k)=id % size 假如id=1001 数组长度为7则取余后的key为0 对应的数组下标为0)
  4. 根据id散列后的key值将对应id的雇员节点链接到对应的数组下标下的链表后面

查找对应id的雇员信息:

  1. 根据id散列得到key值
  2. 到key值对应的数组下标的链表中进行查询
    在这里插入图片描述
import java.util.Scanner;
public class HashTabDemo {
	public static void main(String[] args) {		
		//创建哈希表
		HashTab hashTab = new HashTab(7);	
		//写一个简单的菜单
		int n;
		Scanner scanner = new Scanner(System.in);
		while(true) {
			System.out.println("1:  添加雇员");
			System.out.println("2: 显示雇员");
			System.out.println("3: 查找雇员");
			System.out.println("4: 退出系统");			
			n = scanner.nextInt();
			switch (n) {
			case 1:
				System.out.println("输入id");
				int id = scanner.nextInt();
				System.out.println("输入名字");
				String name = scanner.next();
				System.out.println("输入性别");
				String sex = scanner.next();
				System.out.println("输入电话");
				String phone = scanner.next();
				//创建 雇员
				Emp emp = new Emp(id, name,sex,phone);
				hashTab.add(emp);
				break;
			case 2:
				hashTab.list();
				break;
			case 3:
				System.out.println("请输入要查找的id");
				id = scanner.nextInt();
				hashTab.findEmpById(id);
				break;
			case 4:
				scanner.close();
				System.exit(0);
			default:
				break;
			}
		}	
	}
}
//创建HashTab 管理多条链表
class HashTab {
	private EmpLinkedList[] empLinkedListArray;
	private int size; //表示有多少条链表	
	//构造器
	public HashTab(int size) {
		this.size = size;
		//初始化empLinkedListArray
		empLinkedListArray = new EmpLinkedList[size];
		for(int i = 0; i < size; i++) {
			empLinkedListArray[i] = new EmpLinkedList();
		}
	}
	
	//添加雇员
	public void add(Emp emp) {
		//根据员工的id ,得到该员工应当添加到哪条链表
		int empLinkedListNO = hashFun(emp.id);
		//将emp 添加到对应的链表中
		empLinkedListArray[empLinkedListNO].add(emp);
		
	}
	//遍历所有的链表,遍历hashtab
	public void list() {
		for(int i = 0; i < size; i++) {
			empLinkedListArray[i].list(i);
		}
	}
	
	//根据输入的id,查找雇员
	public void findEmpById(int id) {
		//使用散列函数确定到哪条链表查找
		int empLinkedListNO = hashFun(id);
		Emp emp = empLinkedListArray[empLinkedListNO].findEmpById(id);
		if(emp != null) {//找到
			System.out.printf("在第%d条链表中找到 雇员 id = %d name = %s sex = %s phone = %s", (empLinkedListNO + 1), id,emp.name,emp.sex,emp.phone);
		}else{
			System.out.println("在哈希表中,没有找到该雇员~");
		}
	}
	
	//编写散列函数, 使用一个简单取模法
	public int hashFun(int id) {
		return id % size;
	}
	
	
}

//表示一个雇员
class Emp {
	public int id;
	public String name;
	public String sex;
	public String phone;
	public Emp next; //next 默认为 null
	public Emp(int id, String name,String sex,String phone) {
		super();
		this.id = id;
		this.name = name;
		this.sex = sex;
		this.phone = phone;
	}
}

//创建EmpLinkedList ,表示链表
class EmpLinkedList {
	
	//头指针,指向第一个Emp,因此我们这个链表的head 是直接指向第一个Emp
	private Emp head; //默认null	
	
	//添加雇员到链表
	//说明
	//1. 假定,当添加雇员时,id 是自增长,即id的分配总是从小到大
	//   因此我们将该雇员直接加入到本链表的最后即可
	public void add(Emp emp) {
		//如果是添加第一个雇员
		if(head == null) {
			head = emp;
			return;
		}
		//如果不是第一个雇员,则使用一个辅助的指针,帮助定位到最后
		Emp curEmp = head;
		while(true) 
		{
			if(curEmp.next == null) {//说明到链表最后
				break;
			}
			curEmp = curEmp.next; //后移
		}
		//退出时直接将emp 加入链表
		curEmp.next = emp;
	}
	
	//遍历链表的雇员信息
	public void list(int no) {
		if(head == null) { //说明链表为空
			System.out.println("第 "+(no+1)+" 链表为空");
			return;
		}
		System.out.print("第 "+(no+1)+" 链表的信息为");
		Emp curEmp = head; //辅助指针
		while(true) {
			System.out.printf(" => id=%d name=%s sex=%s phone=%s\t", curEmp.id, curEmp.name,curEmp.sex,curEmp.phone);
			if(curEmp.next == null) {//说明curEmp已经是最后结点
				break;
			}
			curEmp = curEmp.next; //后移,遍历
		}
		System.out.println();
	}
	
	//根据id查找雇员
	//如果查找到,就返回Emp, 如果没有找到,就返回null
	public Emp findEmpById(int id) {
		//判断链表是否为空
		if(head == null) {
			System.out.println("链表为空");
			return null;
		}
		//辅助指针
		Emp curEmp = head;
		while(true) {
			if(curEmp.id == id) {//找到
				break;//这时curEmp就指向要查找的雇员
			}
			//退出
			if(curEmp.next == null) {//说明遍历当前链表没有找到该雇员
				curEmp = null;
				break;
			}
			curEmp = curEmp.next;//以后
		}
		
		return curEmp;
	}
	
}

测试:

1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统
1
输入id
1001
输入名字
张三
输入性别
男
输入电话
12345674512
1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统
1
输入id
1002
输入名字
李强
输入性别
男
输入电话
14578214573
1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统
1
输入id
1009
输入名字
王文
输入性别
女
输入电话
14789654233
1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统
21 链表的信息为 => id=1001 name=张三 sex=男 phone=123456745122 链表的信息为 => id=1002 name=李强 sex=男 phone=14578214573	 => id=1009 name=王文 sex=女 phone=147896542333 链表为空
第 4 链表为空
第 5 链表为空
第 6 链表为空
第 7 链表为空
1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统
3
请输入要查找的id
1009
在第2条链表中找到 雇员 id = 1009 name = 王文 sex = 女 phone = 14789654233
1:  添加雇员
2: 显示雇员
3: 查找雇员
4: 退出系统

欢迎持续关注!
个人博客站:jQueryZK Blog

  • 6
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值