2024年数据结构学习笔记(七):哈希表(Hash Table,从外包公司到今日头条offer

img
img

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化资料的朋友,可以戳这里获取

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

定义一个 字典(哈希表), 键-值 为上述列表的 索引-元素

dict_1 = {0:‘赵’, 1:‘钱’, 2:‘孙’, 3:‘李’}

再定义一个 字典(哈希表),键-值 为 拼音首字母-汉字

dict_2 = {‘z’:‘赵’, ‘q’:‘钱’, ‘s’:‘孙’, ‘l’:‘李’}



## 2 哈希函数(Hash Function)与哈希冲突(Hash Collision)


### 2.1 哈希函数及其设计方法


数组是顺序存储,可以顺序访问(即遍历),也可以随机访问;而哈希表则实现了**随机存取**,无论是存储还是取出数据都与数据所在的位置无关。实现随机存取依靠的是**哈希函数(Hash Function)**。


哈希函数的格式是:**addr = f(key)**。其中addr指的是数据的内存地址,key指的是数据值有关的关键字。存储时,通过关键字分配得一个内存空间;取出时,通过关键字定位到内存地址,从而得到数据值。


键值对为“索引-元素”的哈希表,哈希函数应写为:**addr = f(index) = index**。其实就是一个一元一次函数:y=x,这是一个线性函数,因此数组既能顺序访问,也能随机访问。但是数组的随机访问是有局限性的,访问元素只有基于顺序的索引这一个选择,不能根据数值本身包含的信息查找数据。我们来看下面的两个存储姓氏的哈希表,代码语言为Python:



family_name_1 = {1:‘赵’, 2:‘钱’, 3:‘孙’, 4:‘李’}

family_name_2 = {‘z’:‘赵’, ‘q’:‘钱’, ‘s’:‘孙’, ‘l’:‘李’}


第一个哈希表(字典)的关键字是姓氏在《百家姓》中的次序,第二个哈希表(字典)的关键字是姓氏的拼音首字母,如何定义关键字是根据需求来的。


哈希函数的设计是为了让“键值对”中的“键”具有**唯一性**,且符合数据检索的需求。设计哈希函数的常用方法包括:直接定制法、数字分析法、平方取中法、折叠法和除留取余法。


1. 直接定制:比如上面姓氏哈希表的“键”就是根据某种规则直接定制的;
2. 数字分析:比如存储个人信息的表中,取身份证后六位,因为这几位不容易重复;
3. 平方取中:即取平方再取中间的若干位,适用于重复位较多的数据,平方为了扩大差异;
4. 分段叠加:比如将18位身份证号三等分,再将三个六位数值叠加(舍去进位);
5. 除留取余:将关键字与长度短于关键字的数字做除法,取余数为地址;
6. 随机数法:比如快递柜的六位取件码就是随机生成的。


从以上方法中不难看不出,对原数据值哈希函数可以起到**化长为短**的作用。比如18位的身份证号经过数字分析法后就变成了6位。 如果作为关键字的数据占空间很大,直接拿来用是很浪费内存的。


### 2.2 哈希冲突及其解决方案(含Java模拟)


任何一种哈希函数都很难保证得到的哈希值不会重复,一旦重复就会产生冲突。解决哈希冲突有两种常用的方法:开放地址法(openning addressing)和链表法(chaining)。


#### 2.2.1 开放地址法


开放地址法的思路很好理解,如果出现哈希冲突,就采用某种探测方法从发生冲突的位置依照某种探测顺序依次查找,直到找到哈希表中的空位,将关键字存入。探测方法包括**线性探测(Linear Probing)、二次探测(Quadratic Probing)、双重哈希(Double hashing)**等。最常用的是**线性探测**。


比如我们要存入一组关键字{10,12,13,14,22,21},哈希函数采用除留取余法,除数为7,余数分别是{3,5,6,0,1,0},我们发现有两个0,存在冲突,先进入的14占据了0位置,21不能再用这个位置。线性探测的过程如下图所示:


![](https://img-blog.csdnimg.cn/20210626020916450.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NTM3MDQyMg==,size_16,color_FFFFFF,t_70)


可以用**Java**简单地模拟一下这个过程,数组模拟哈希表,数组的索引模拟地址。



package com.notes.data_structure7;

public class LinearProbing {

public static void main(String[] args) {
	// 模拟 哈希表 的数组
	int[] table = new int[7];
	// 要存入的关键字
	int[] keys = {10,12,13,14,22,21};
	for(int i=0;i<keys.length;i++) {
		int key = keys[i];
		int addr = get_addr(key);
		if(table[addr]==0) { // 无哈希冲突
			table[addr] = key;
		}else { // 有哈希冲突
			int current_addr = addr;
			a:for(int j=0;j<table.length;j++) {
				current_addr++;
				if(current_addr==table.length) {
					current_addr=0;
				}
				if(table[current_addr]==0) { // 无哈希冲突
					table[current_addr] = key;
					break a;
			    }
		    }
	    }
    }
	// 打印表
	for(int i=0;i<table.length;i++) {
		System.out.println("addr:"+i+",key:"+table[i]);
	}
}

// 哈希函数
static public int get_addr(int key) {
	return key%7;
}

}


打印结果下,key值为0表示位置为空。



addr:0,key:14
addr:1,key:22
addr:2,key:21
addr:3,key:10
addr:4,key:0
addr:5,key:12
addr:6,key:13


线性探测也是有局限性的,加入的值越多,发生哈希冲突的可能性就越大,哈希表的性能就越差。比如上面的关键字21因为哈希冲突被调到了地址2的位置,如果再加一个关键字为23,经哈希函数计算地址为2,又与21发生哈希冲突,不得不再次线性探测。


为了弥补上述缺陷,又出现了**二次探测**、**双重哈希**等改进方法。**二次探测**在线性探测基础上增加了地址跳转的跨度,那上面的代码来说,线性探测的次序是current\_addr+1,+2,+3,二次探测次序是current\_addr+1,+4,+9。**双重哈希**是预设多个的哈希函数,一个发生冲突,就换一个,直到不冲突为止。


#### 2.2.2 链表法


链表法的思路是,哈希表的每一个槽(slot)都是一个链表,哈希函数计算的是槽位,将值根据相应的槽位存入对应的链表,这种解决方法比开放地址法要更加方便。


同样用**Java**模拟这个方法,该方法相比于开放地址法,省去判断是否有哈希冲突和遍历寻址的操作。链表的代码复用“数据结构学习笔记”系列的第一篇文章“链表”的代码,链接贴在下面:


<https://blog.csdn.net/weixin_45370422/article/details/116573863>


![](https://img-blog.csdnimg.cn/20210626031808960.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NTM3MDQyMg==,size_16,color_FFFFFF,t_70)


Java代码模拟的结果呈现方式是:由于链表里定义的打印结点的方法是不换行打印,因此不同的槽位用空行隔开,空位直接打印“空槽”两个字。



package com.notes.data_structure7;

public class Chaining {

public static void main(String[] args) {
	// 模拟 哈希表 的数组,表槽(slot)的数据类型是链表
	MyLink[] table = new MyLink[7];
	// 要存入的关键字
	int[] keys = {10,12,13,14,22,21};
	for(int i=0;i<keys.length;i++) {
		int key = keys[i];
		int addr = get_addr(key);
		if(table[addr]==null) {
			table[addr] = new MyLink();
		}
		table[addr].addNode(key);
	}
	// 打印表(链表的结点值打印不换行,不同的表槽用空行隔开,空位直接打印空槽)
	for(int i=0;i<table.length;i++) {
		MyLink link = table[i];
		try {

img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上大数据知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

需要这份系统化资料的朋友,可以戳这里获取

础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上大数据知识点,真正体系化!**

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

需要这份系统化资料的朋友,可以戳这里获取

  • 5
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值