面试高频-哈希表

哈希表是非常重要的一种数据结构,例如HashMap集合底层也是基于哈希表来实现的;关于哈希表的知识点也是经常在面试中被问到,通过这几天对于哈希表的学习,包括看了哈希表的源码,以及手动编写了一个简单的哈希表,加深了对哈希表的理解,并在此进行总结!

1. 什么是哈希表

Hash表也称散列表,也有直接译作哈希表,Hash表是一种根据关键字值(key - value)而直接进行访问的数据结构。它基于数组,通过把关键字映射到数组的某个下标来加快查找速度,但是又和链表、树等数据结构不同,在这些数据结构中查找某个关键字,通常要遍历整个数据结构,也就是O(N)的时间级,但是对于哈希表来说,只是O(1)的时间级。

哈希表也可以当做一种缓存产品来使用,我们知道,频繁的访问数据库会造成非常大的系统开销,因而出现了很多的缓存产品,例如redis;我们也可以将频繁访问的数据存放在哈希表中,这样每次获取哈希表的值就不用从数据库获取,减少系统开销。

2. 哈希函数

为什么需要哈希函数?哈希函数就是关键字转换为数组的下标,这个转换的函数称为哈希函数(也称散列函数),转换的过程称为哈希化。

哈希函数就是把一个大范围的数字哈希(转化)成一个小范围的数字,这个小范围的数对应着数组的下标。使用哈希函数向数组插入数据后,这个数组就是哈希表。

下面使用简单取模法作为哈希函数:

public int hashFun(int id){
    return id%size; //size表示哈希表的容量,也就是数组的大小
}

3. 哈希冲突

多个key映射到相同的数组下标,即发生了哈希冲突;常见解决冲突的方法有:开放地址法、链地址法、桶

4. 开放地址法

若数据项不能直接存放在由哈希函数所计算出来的数组下标时,就要寻找其他的位置。分别有三种方法:线性探测、二次探测以及再哈希法。

4.1 线性探测

线性探测,指的就是线性的查找空白单元,例如我们要插入的key对应哈希表数组的下标是3,并且这个位置3已经被其它数据占用了,那么会查看下一个位置4是否被占用,若被占用,继续往下递增查找,直到找到一个空白的位置。

4.2 二次探测

二次探测的思想是探测相距较远的单元,而不是和原始位置相邻的单元,二次探测可以防止聚集的产生;但是二次探测法也会导致二次聚集的产生。

线性探测中,如果哈希函数计算的原始下标是x, 线性探测就是x+1, x+2, x+3, 以此类推;而在二次探测中,探测的过程是x+1, x+4, x+9, x+16,以此类推,到原始位置的距离是步数的平方。

4.3 再哈希法

再哈希法是为了消除聚集和二次聚集提出来的;因为线性探测和二次探测产生的探测序列步长总是固定的,容易产生聚集,而再哈希法是指出现冲突后,把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长。对于指定的关键字,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。

5. 链地址法

链地址法的实现原理就是使用数组加链表,在哈希表每个单元中设置链表,当出现冲突后,不需要在原始的数组中寻找空位,而是将其他同样映射到这个位置的数据项加到链表中。

6. 桶

类似于链地址法,它是在每个数据项中使用子数组,而不是链表。这样的数组称为桶。

这个方法显然不如链表有效,因为桶的容量不好选择,如果容量太小,可能会溢出,如果太大,又造成性能浪费,而链表是动态分配的,不存在此问题。所以一般不使用桶。

7. 聚集和装填因子

7.1 聚集

当哈希表变得比较满时,我们每插入一个新的数据,都要频繁的探测插入位置,因为可能很多位置都被前面插入的数据所占用了,这称为聚集。数组填的越满,聚集越可能发生。

7.2 装填因子

已填入哈希表的数据项和哈希表容量的比率叫做装填因子

通过查看HashMap的源码可以看到,HashMap默认的装填因子就是0.75。

// 默认的加载因子 (扩容因子)
static final float DEFAULT_LOAD_FACTOR = 0.75f;

装填因子也叫加载因子、扩容因子或负载因子,用来判断什么时候进行扩容的,假如加载因子是0.5,HashMap的初始化容量是16,那么当HashMap中有16*0.5=8个元素时,HashMap就会进行扩容。

那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?

这其实是出于容量和性能之间平衡的结果:

  • 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生Hash冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
  • 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。

所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。

8. 关于哈希表的扩容

回顾数组的基本知识,我们知道,数组的大小是固定的,无法进行扩展,所以哈希表的扩容只能另外创建一个更大的数组,然后把旧数组中的数据插到新的数组中。

但是需要注意的是:哈希表是根据数组大小计算给定数据的位置的,所以这些数据项不能再放在新数组中和老数组相同的位置上。因此不能直接拷贝,需要按顺序遍历老数组,并使用insert方法向新数组中插入每个数据项。

示例:哈希表扩容的源码

public void insert(DataItem item){
        if(isFull()){
            //扩展哈希表
            System.out.println("哈希表已满,重新哈希化...");
            extendHashTable();
        }
        int key = item.getKey();
        int hashVal = hashFunction(key);
        while(hashArray[hashVal] != null && hashArray[hashVal].getKey() != -1){
            ++hashVal;
            hashVal %= arraySize;
        }
        hashArray[hashVal] = item;
        itemNum++;
    }

public void extendHashTable(){
        int num = arraySize;
        itemNum = 0;//重新计数,因为下面要把原来的数据转移到新的扩张的数组中
        arraySize *= 2;//数组大小翻倍
        DataItem[] oldHashArray = hashArray;
        hashArray = new DataItem[arraySize];
        for(int i = 0 ; i < num ; i++){
            insert(oldHashArray[i]);
        }
    }

9. 编写一个简单的哈希表

引言:曾遇到过Google的一道上机题,就是需要编写哈希表来实现

实际需求:
有一个公司,当有新的员工来报道时,要求将该员工的信息加入(id,性别,年龄,住址…),当输入该员工的id时,要求查
找到该员工的所有信息.
要求: 不使用数据库,尽量节省内存,速度越快越好=>哈希表(散列)

图解
在这里插入图片描述
代码实现

public class HashTabDemo {

	public static void main(String[] args) {
		//创建一个HashTab
		HashTab hashTab = new HashTab(7);
		//菜单
		String key="";
		Scanner sc=new Scanner(System.in);
		while(true){
			System.out.println("add: 添加雇员");
			System.out.println("list:显示雇员");
			System.out.println("find:查找雇员");
			System.out.println("exit:退出系统");
			key=sc.next();
			switch (key) {
			case "add":
				System.out.println("输入id");
				int id=sc.nextInt();
				System.out.println("输入name");
				String name=sc.next();
				//创建一个雇员
				Emp emp=new Emp(id, name);
				hashTab.add(emp);
				break;
			case "list":
				hashTab.list();
				break;
			case "find":
				System.out.println("输入id");
				int x=sc.nextInt();
				Emp e=hashTab.find(x);
				if(e==null){
					System.out.println("查找不到雇员");
				}else{
					System.out.println(e);
				}
				break;
			case "exit":
				sc.close();
				System.exit(0);
				break;
			default:
				break;
			}
		}
	}

}

//创建HashTab,管理多条链表,HashTab是暴露给用户的,用户都是基于HashTab操作的
class HashTab{
	private EmpLinkedList[] empLinkedLists;
	private Integer size;//表示HashTab中链表的条数
	public HashTab(int size) {
		this.size = size;
		empLinkedLists=new EmpLinkedList[size];
		//注意一个坑,上面的语句仅仅是初始化了HashTab,但是数组中的每条链表还还未初始化,都为null
		//需要使用for循环进行初始化,否则会报空指针异常
		for(int i=0;i<size;i++){
			empLinkedLists[i]=new EmpLinkedList();
		}
	}
	
	//添加节点
	public void add(Emp emp){
		int empLinkedListNo=hashFun(emp.id);//根据hash函数求出节点要插入到哪一条链表
		empLinkedLists[empLinkedListNo].add(emp);
	}
	
	//遍历链表
	public void list(){
		for(int i=0;i<size;i++){
			empLinkedLists[i].list();
		}
	}
	
	//根据id查找
	public Emp find(int id){
		int empLinkedListNo=hashFun(id);//根据hash函数求出要查找哪一条链表
		return empLinkedLists[empLinkedListNo].find(id);
	}
	
	//编写一个散列函数,使用简单取模法
	public int hashFun(int id){
		return id%size;
	}
}

//创建一个EmpLinkedList,表示每一条链表
class EmpLinkedList{
	
	private Emp head;//头节点,存放第一个节点,初始为null
	
	//在链表尾部添加节点
	public void add(Emp emp){
		//如果初始时链表为空,直接把新节点赋给头节点
		if(head==null){
			head=emp;
			return;//必须得return啊,不然就无限添加了!!!!
		}
		//否则,先遍历到链表的尾部,再插入
		Emp temp=head;
		while(temp.next!=null){
			temp=temp.next;
		}
		temp.next=emp;
	}
	
	//遍历链表
	public void list(){
		//判断链表是否为空
		if(head==null){
			System.out.println("链表为空~");
			return;
		}
		Emp temp=head;
		while(temp!=null){
			System.out.printf("=> id=%d name=%s\t",temp.id,temp.name);
			temp=temp.next;
		}
		System.out.println();
	}
	
	//根据id查找
	public Emp find(int id){
		if(head==null){
			System.out.println("链表为空~");
			return null;
		}
		Emp temp=head;
		while(temp!=null){
			if(temp.id==id){
				return temp;
			}
			temp=temp.next;
		}
		return null;
	}
}


//雇员类,即每一个雇员节点
class Emp{
	public Integer id;
	public String name;
	public Emp next;//指向下一个节点
	public Emp(Integer id, String name) {
		super();
		this.id = id;
		this.name = name;
	}
	@Override
	public String toString() {
		return "Emp [id=" + id + ", name=" + name + "]";
	}	
}

注意:上述代码中,头节点是有存放信息的,即存放第一个节点,以往更多的是头节点不存放任何数据,只是用来指向链表的第一个节点;因此,相关的方法都应该有适当的变更。

猜你喜欢

转载自blog.csdn.net/can_chen/article/details/105460800
今日推荐