1.哈希表
数据结构中的压轴戏,面试中出场频率极高的内容,同时也是工作中出场频率极高的内容,更是现代分布式系统的基础。
给定若干个整数[0,99],再给定一个数字N,判定N是否在刚才的集合中出现:
- 基于顺序表:可以用一个数组保存若干个整数,拿着N在数组中依次遍历,进行比较。时间复杂度为:O(N)
- 基于链表:可以用一个链表保存若干个整数,拿着N在数组中依次遍历,进行比较。时间复杂度为O(N)
- 基于二叉搜索树:可以用二叉搜索树来保存这些整数,按照二叉搜索树的方式进行查找比较。时间复杂度最坏O(N),比较理想的情况:O(logN)
哈希表:可以按照O(1)时间复杂度,完成插入,查找,删除操作,数组访问下标是很高效的,哈希表本质依赖了数组下标的随机访问能力。
2.hash冲突
如果发现两个key不同的元素,计算得到的hash值相同,此时就称为“hash冲突”。
3.解决hash冲突的办法
主要有两种方式:(hash就是散列,这是意译,哈希是音译,哈希函数,哈希表也可以称为散列函数,散列表)
3.1闭散列
核心思路是:在冲突位置开始往后找到一个合适位置来存放这个冲突的值。
3.2开散列
数组的每个元素不再是存key,而是一个存key的顺序表或者链表(常见)
3.3hash冲突与hash表
一旦涉及到hash冲突,此时hash表的基本操作的时间复杂度就不是严格的O(1)了,随着冲突越严重,效率就会越低。正因为如此,在hash表长度的选择的时候,就有一种说法,一般都要选一个比较大的值(如果集合中有100个元素,就最好搞一个1000个元素的数组),如果数组元素个数选的比较大了,确实能降低冲突概率,但是浪费的空间也会变多。另外,如果把数组元素个数选成一个素数,那么冲突概率就会低一些。
hash冲突是理论上客观存在的,避免不了的~~但是可以通过开散列或闭散列的方式来处理冲突,虽然出现冲突,但是只是影响到效率,而不会影响到增删查结果的正确性。
存放key更好一些,当前咱们的hash函数只是一个简单粗暴的%,但是实际上的hash函数可能是非常复杂的运算(要经历一些列 + - * / % << >> …),hash函数的目标就是为了把key映射成下标(希望映射过程能够尽量避免冲突)
对于key为整数的时候,算hash一般比较好算,直接进行一些数学变换就行了。
如果key为String的时候,算hash就会比较复杂一些。(md5,sha1两种常用的字符串hash算法)
3.4负载因子
通过这个指标可以衡量元素冲突的概率,当前hash表中的实际元素个数/数组的capacity=> 负载因子,可以根据负载因子的值来决定是否要对hash表进行扩容,所谓的扩容就是申请一个更大的数组作为新的hash表,把原来的元素拷贝过去。(非常耗时的)
如果是闭散列:负载因子一定是小于1的。
如果是开散列:负载因子可以大于1,因为元素挂的是链表。
4.hash表的代码讲解
//通过开散列的方式来处理hash冲突
public class MyHashMap {
static class Node {//用开散列的方式需要创建一个节点类
public int key;
public int value;
public Node next;
public Node(int key, int value) {
this.key = key;
this.value = value;
}
}
private static final double LOAD_FACTOR = 0.75;//设置一个负载因子
//array就是hash表的本体,数组的每个元素又是一个链表的头结点,所以array的类型用Node[]来表示
private Node[] array = new Node[101];
private int size = 0;//表示当前hash表中的元素个数
private int hashFunc(int key) {//构造一个hash函数
//实际用的hashFunc一般会比较复杂,在这里我们就用相对简单的方式。
return key % array.length;
}
//如果key已经已经存在,就修改当前的value值
//如果key不存在,就插入新的键值对。
public void put(int key,int value) {//插入hash表的操作
//1.需要通过hash函数把key映射成数组下标。
int index = hashFunc(key);
//2.根据下标找到对应的链表
Node list = array[index];
//3.当前key在链表中是否存在。
for (Node cur = list; cur != null; cur = cur.next) {
if (cur.key == key) {//key已经存在,直接修改value即可。
cur.value = value;
}
}
//4.如果刚才循环结束,没有找到相同的key节点,那么就将键值对插入到指定链表的头部。
//这里尾插也可以,只是在这头插方便所以用头插。
Node newNode = new Node(key,value);
newNode.next = list;
array[index] = newNode;
size++;
if (size / array.length > LOAD_FACTOR) {//hash表的扩容操作
resize();
}
}
private void resize() {//hash表的扩容方法
Node[] newArray = new Node[array.length * 2];
//把原来hash表中的所有元素搬运到新的数组上
for (int i = 0; i < array.length; i++) {
for (Node cur = array[i]; cur != null; cur = cur.next) {
int index = cur.key % newArray.length;//原来数组中的key在新的newArray数组中对应的下标
Node newNode = new Node(cur.key,cur.value);//注意,这里用的也是头插法。
newNode.next = newArray[index];
newArray[index] = newNode;
}
}
//让新的数组代替原来数组
array = newArray;
}
//根据key查找指定元素,如果找到那么就返回对应的value,如果没找到,那么就返回null
public Integer get(int key) {
//1.先计算出key对应的下标
int index = hashFunc(key);
//2.根据下标找到对应的链表
Node list = array[index];
//3.在链表中查找指定元素
for (Node cur = list;cur != null;cur = cur.next) {
if (cur.key == key) {
return cur.value;
}
}
return null;
}
}
5.哈希思想的应用
(实际工作中非常常见的场景)
例如:此处我有很多很多的数据(比如用户数据,用户数据中包含用户的账户,用户购物车内容,用户的注册时间,用户的浏览记录…),可能有几亿个用户数据,由于每个用户数据量都很大(10m),此时我们不能把所有的用户信息都放到一个内存中(要放入磁盘中),接下来如果想高效的查找到给定用户账户所对应的相关信息,该如何做呢?
例如:可以从磁盘文件中查找,但是计算机访问磁盘的操作是非常慢的【太低效】,要想高效,最好还是得在内存中查找,如果一台机器放不下,就使用多台机器来保存。