目录
前言
在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应关系,因此在查找一个元素时,必须经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索效率取决于搜索过程中元素的比较次数。
有没有一种搜索方法?可以不经过任何比较,一次直接从表中得到要搜索的元素。
一.哈希表
1.1概念
哈希表(Hash Table)又称散列表,是根据关键码值(Key)而直接进行访问的数据结构。
即通过关键码值(Key)映射到表中一个位置来访问记录,加快查找速度。这个映射函数叫作映射函数(Hash函数),存放记录的数组叫做散列表(Hash表)。
哈希表核心在于哈希函数,将键(key)通过哈希函数转化成一个整型数字,这个数字叫哈希值(Hash Value)。再将哈希值对数组长度取余,将结果作为下标,把value存储到以该数字为下标的空间里。
二.哈希函数
1.2.1定义
哈希函数,也称散列函数,是一种从任何一种数据中创建小的数字“指纹”的方法。 无论原始数据的大小或形式如何,哈希函数都能生成一个固定长度的数字串(哈希值)。
规则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见的哈希函数
1.直接定址法
取关键字的某个线性函数为散列地址:Hash(Key)= A * Key + B 或Hash(Key)=Key
优点:简单,均匀。
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
2. 除留余数法
- 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数作为除数,按照哈希函数:Hash(Key) = key % p,(p<=m),将关键字转换成哈希地址
3. 平方取中法 (了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对 它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
三.哈希冲突
若在设置哈希函数的时候,哈希函数不够合理,可能会造成哈希冲突。
示例:假设有一个数组为{1,2,4,6,8,9};
哈希函数设置为:Hash(key)=key%capacity;(capacity为存储元素底层空间总的大小)
如果在数组假如12,让12根据哈希函数映射到哈希表中,会发现此时12的哈希值(12%10=2)与2的哈希值发生冲突。
哈希冲突的概念
对于两个数据元素的关键字Keyi!=Keyj (i!=j),有Keyi!=Keyj,但有:Hash( Keyi ) == Hash( Keyj ),即:不同关键字通过相同哈 希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞.
注意:哈希冲突是必然的,哈希表底层数组的容量旺旺是小于实际要存储的关键字的数量 ,所以我们能做的就是尽量的降低冲突率。
降低冲突率的方法
- 设置合理的哈希函数
- 降低负载因子
什么是负载因子?
散列表的载荷因子定义为:a=填入表中的元素个数 / 散列表的长度
a是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,a越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。
注意:对于开放地址法,载荷因子应该严格控制在0.7-0.8以下。
解决哈希冲突的方法*
设计再好的哈希函数也无法避免河西冲突,所以就需要通过一定的方法来解决哈希冲突,常见解决哈希冲突主要是:【开放地址法(Open Addrressing)】和【链地址法(Chaining)】
开放地址法(闭散列)
1.线性探测
定义:从发生冲突的位置开始,依次往后探测,直到寻找到下一个空位置为止。
缺点:一旦发生冲突,连续插入的元素可能会在哈希表的相邻位置上寻找空闲槽位,从而导致数据在表中的分布不均匀,形成聚集。这会降低后续查找、插入和删除操作的效率。
示例:以上图为例,插入12时,由于发生冲突,所以往2之后的位置探测,寻找空位,下标为3的位置时空位,把12放到以下标为3的位置。
2.二次探测
线性探测会导致数据出现“聚集”现象,因此可以使用二次探测
定义:发生冲突时,找下一个空位置的方法:H(i)=(H0+i^2)%m 或 H(i)=(H0-i^2)%m。(其中i=1,2,3,...)。H0是通过哈希函数Hash(x)对关键码key进行计算得到的位置,m是表长。
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不 会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情 况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容 。
链地址法(开散列)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
示例:
对于开散列,其实就是把一个在大集合内的搜索化为一个在小集合内的搜索,降低搜索时长。
四.哈希桶 🪣
哈希桶(Hash Bucket 或 Bucket)是哈希表(Hash Table)中用于存储哈希值对应数据的数据结构。在哈希表中,哈希函数将键(Key)转换为哈希值,这个哈希值通常被用来确定数据在哈希桶中的位置。哈希桶可以是一个数组、链表或其他数据结构,用于存放具有相同哈希值的元素。
工作原理
哈希桶的工作原理如下:
- 哈希函数:哈希函数是哈希表的核心,它接受一个键作为输入,然后计算出一个整数,这个整数通常在0到哈希表大小(即桶的数量)之间。一个好的哈希函数应该尽量使得不同的键产生不同的哈希值,以减少冲突。
- 冲突处理:由于哈希函数不是完美的,不同的键可能会产生相同的哈希值,这就产生了冲突。哈希桶通常使用一些策略来处理冲突,如链地址法、开放寻址法等。在链地址法中,每个哈希桶实际上是一个链表,所有哈希到同一桶的键值对都会附加到这个链表上。
- 存储和检索:当插入一个键值对时,先计算键的哈希值,然后将这对数据放入对应哈希值的桶中。在检索数据时,同样计算键的哈希值,然后直接查找对应的哈希桶,如果使用链地址法,就在链表中搜索匹配的键。
- 负载因子:哈希表的性能与它的负载因子有关,这是已存储元素数量与总桶数的比率。如果负载因子过高,冲突的可能性会增加,影响查找效率。因此,当哈希表变得过于拥挤时,通常会选择动态扩容,增加哈希表的大小,重新哈希所有元素以分散数据。
- 扩展性:为了保持高效性能,哈希表通常会选择基数较大的桶数,通常是2的幂,这样可以更容易地进行容量调整,并且哈希函数通常会设计成对桶数取模,以确保哈希值落在有效的桶范围内。
哈希桶的目的是通过快速定位数据来提供高效的查找、插入和删除操作,其性能主要取决于哈希函数的质量和处理冲突的策略。
哈希桶的模拟实现
/**
* HashBucket 类实现了简单的哈希表数据结构。
*/
class HashBuck {
/**
* Node 内部类表示哈希表中的节点。
*/
static class Node {
public int key;
public int val;
public Node next;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
public Node[] table;
public int useSize;
private double loadFactor = 0.75;//设置负载因子为0.75
public HashBuck() {
table = new Node[10];
useSize = 0;
}
/**
* 向哈希表中插入键值对。
* 如果键已存在,则更新对应的值。
*
* @param key 插入的键。
* @param val 插入的值。
*/
//插入键值
public void put(int key, int val) {
// 根据键计算哈希索引
//找键值位
int index = key % table.length;
Node cur = table[index];
// 遍历哈希表中的节点,查找是否存在相同的键
//判断键值是否已经存在
while (cur != null) {
if (cur.key == key) {
// 如果键已存在,更新该节点的值并返回
cur.val = val;//存在则更新值
return;
}
cur = cur.next;
}
// 如果键不存在,创建新节点并插入到哈希表中
// 键值不存在则插入,采用头插
Node node = new Node(key, val);
node.next = table[index];
table[index] = node;
useSize++;
//判断负载因子
if (loadFactorcount() >= loadFactor) {
//进行扩容
resize();
}
}
/**
* 当哈希表的负载因子达到阈值时,扩容哈希表。
* 新表的大小是旧表的两倍。
*/
private void resize() {
// 创建一个新的哈希表,大小为当前表大小的两倍
Node[] newTable = new Node[table.length * 2];
// 遍历旧表中的每个元素
for (int i = 0; i < table.length; i++) {
// 遍历链表中的每个节点
//遍历table
Node cur = table[i];
while (cur != null) {
// 计算节点在新表中的位置
//遍历cur
int newindex = cur.key % newTable.length;
// 保存当前节点的下一个节点
Node curN = cur.next;
// 将当前节点插入到新表的相应位置
cur.next = newTable[newindex];
newTable[newindex] = cur;
// 移动到下一个节点
cur = curN;
}
}
// 更新表指针,指向新的、更大的表
table = newTable;
}
/**
* 计算并返回哈希表的负载因子。
*
* @return 哈希表的负载因子。
*/
public double loadFactorcount() {
return useSize * 1.0 / table.length;
}
/**
* 根据键获取哈希表中对应的值。
* 如果键不存在,返回 -1。
*
* @param key 要查找的键。
* @return 键对应的值,如果键不存在则返回 -1。
*/
public int get(int key) {
// 计算键在表中的索引
int index = key % table.length;
// 遍历链表,查找匹配的键
Node cur = table[index];
while (cur != null) {
if (cur.key == key) {
// 如果找到匹配的键,返回对应的值
return cur.val;
}
// 如果当前节点的键不匹配,移动到下一个节点
cur = cur.next;
}
// 如果遍历完毕没有找到匹配的键,返回-1
return -1;
}
public static void main(String[] args) {
HashBuck hashBuck = new HashBuck();
hashBuck.put(3, 1024);
hashBuck.put(7, 104);
hashBuck.put(6, 10);
hashBuck.put(3, 124);
hashBuck.put(14, 124);
hashBuck.put(25, 124);
hashBuck.put(42, 124);
hashBuck.put(33, 124);
hashBuck.put(31, 124);
System.out.println(hashBuck.get(3));
}
}
在java中,为我们提供了HashMap和HashSet这两种,对于下面这部分,建议对照着上一篇来学。
数据结构之二叉搜索树(TreeSet&&TreeMap)http://t.csdnimg.cn/rVs0G
HahsMap
定义
HashMap是javabian编程语言中的一种数据结构,实现了Map接口,提供了键(KEY)到值(VALUE)的映射关系。
操作
方法 | 解释 |
V get(Object key) | 返回key对应的value |
V getOrDefault(Object key, V defaultValue) | 返回 key 对应的 value,key 不存在,返回默认值 |
V put(K key, V value) | 设置key对应的value |
V remove(Object key) | 删除key对应的映射关系 |
Set<K> keySet() | 返回所有key不重复的集合 |
Collection<V> values() | 返回所有value的可重复集合 |
Set<Map.Entry<K, V>> entrySet() | 返回所有的key-value的映射关系 |
boolean containsKey(Object key) | 判断是否存在key |
boolean containsValue(Object value) | 判断是否存在value |
测试:
public static void main(String[] args) {
HashMap<String,String> hashMap= new HashMap<>();
//添加元素
hashMap.put("1","2");
hashMap.put("2","3");
hashMap.put("3","4");
hashMap.put("4","5");
//返回key为1对应的value值 1--2
System.out.println(hashMap.get("1"));
hashMap.remove("1");//删除key为1的元素
//判断key是否存在
System.out.println(hashMap.containsKey("1"));
//判断value是否存在
System.out.println(hashMap.containsValue("3"));
Set<String> set1=hashMap.keySet();//获取key集合
for(String s:set1){
System.out.println(s);
}
//获取key-value集合
Set<Map.Entry<String,String>> set2=hashMap.entrySet();//获取key-value集合
for(Map.Entry<String,String> entry:set2){
System.out.println(entry.getKey()+"--"+entry.getValue());
}
//获取value集合
Collection<String> collection=hashMap.values();//获取value集合
for(String s:collection){
System.out.println(s);
}
}
特点
HashMap 的核心作用在于提供了一种灵活、高效且易于使用的数据存储方案,特别适合需要快速存取数据的场景,是构建现代应用程序不可或缺的一部分。
- 高效存储与检索:通过哈希算法,HashMap 提供了快速的插入、删除和查找操作(平均时间复杂度为 O(1))。这对于需要频繁存取数据的应用场景至关重要。
- 键值关联:允许以键(Key)到值(Value)的方式存储数据,使得数据间的关系更加明确,方便根据特定的键直接获取对应的值。
- 灵活性:HashMap 支持 null 键和多个 null 值,增加了使用的灵活性,能够适应更多样化的数据存储需求。
- 动态大小调整:根据需要自动调整内部容量,确保了高效的性能同时避免了空间的浪费。
- 简单易用:HashMap 提供了一套简单直观的API,如 put, get, remove, containsKey 等,使得开发者能轻松地操作映射关系。
- 作为其他数据结构的基础:HashMap 经常被用作构建更复杂数据结构的基础,如 LinkedHashMap 保持插入顺序,WeakHashMap 实现弱引用等。
- 支持并行计算的变体:虽然 HashMap 本身不是线程安全的,但通过使用 ConcurrentHashMap,可以在多线程环境中安全地进行并行读写操作,适用于高性能并发场景。
应用场景
- 缓存实现:由于其快速的查找性能,HashMap 常被用来实现本地缓存,存储那些计算代价高或频繁查询的数据。例如,数据库查询结果缓存、配置信息缓存等。
- 会话管理:在 Web 开发中,可以利用 HashMap 存储用户的会话信息(Session),实现会话管理。每个用户会话对应 HashMap 中的一个条目,键通常是会话ID,值是会话数据。
- 统计计数:HashMap 可以用来统计各类数据出现的频次,如网站访问统计、用户行为分析等。键可以是统计的类别,值则是该类别的计数。
- 去重处理:利用 HashMap 的键唯一性,可以快速判断元素是否已经存在,从而实现数据去重功能。
- 映射关系:在需要建立两个实体之间一对一或多对一映射关系时,HashMap 是理想的选择。例如,数据库查询结果转换为对象时,可以用 ID 作为键,对象作为值。
- 配置映射:将配置文件中的键值对加载到 HashMap 中,便于程序动态读取配置项。
- 图形算法辅助:在某些图算法中,HashMap 可以用来存储顶点与邻接节点的映射关系,加速遍历或搜索过程。
HashSet
定义
HashSet是集合框架中一个实现类,它继承自 AbstractSet 类并实现了 Set 接口。HashSet 主要用于存储无序的、不可重复的元素集合。
操作
测试:
public static void main(String[] args) {
HashSet<String> hashSet=new HashSet<>();
//添加元素
hashSet.add("1");
hashSet.add("2");
hashSet.add("3");
hashSet.add("4");
System.out.println(hashSet.contains("1"));//判断元素是否存在
hashSet.remove("1");
System.out.println(hashSet.size());//判断元素个数
for(String s:hashSet){//遍历集合
System.out.print(s+" ");
}
System.out.println();
//利用迭代器遍历集合
Iterator<String> iterator=hashSet.iterator();
while(iterator.hasNext()){
System.out.print(iterator.next()+" ");
}
System.out.println();
//集合转数组
String[] s=hashSet.toArray(new String[0]);
for(String s1:s){
System.out.print(s1+" ");
}
System.out.println();
//判断集合是否为空
System.out.println(hashSet.isEmpty());
}
特点
HashSet 是一个用于存储唯一元素的集合类,适用于不需要维护元素插入顺序的场景,并且在需要高效查找、插入和删除操作时非常有用。
- 无序性:HashSet 不保证元素的迭代顺序与插入顺序一致,也不保证任何特定的顺序。实际上,元素的顺序取决于它们的哈希值。
- 不允许重复:HashSet 中不能包含重复的元素。当尝试添加重复元素时,该操作不会改变集合的状态,也不会抛出异常。
- 内部实现:HashSet 的内部实际上是由 HashMap 实现的。每个添加到 HashSet 中的元素都会变成 HashMap 的一个键,而所有键对应的值都是一个固定的对象(通常是 present,这是一个私有的静态 final 对象)。
- 哈希码和 equals 方法:由于 HashSet 依赖于 HashMap,所以向 HashSet 添加的元素必须正确覆写 hashCode() 和 equals() 方法,以确保元素的唯一性和正确存储。这两个方法共同决定了元素在集合中的存储位置以及如何识别重复元素。
- 线程不安全:HashSet 类同 HashMap 一样,没有内置的线程同步机制,因此在多线程环境下不保证线程安全。若需在并发环境下使用,应考虑使用 ConcurrentSkipListSet 或对 HashSet 进行适当的同步控制。
- 性能:由于其基于哈希表的实现,HashSet 提供了快速的插入、删除和查找操作,平均时间复杂度为 O(1)。
应用场景
数据去重:当需要确保集合中元素的唯一性时,HashSet 是理想选择。例如,从数据库查询的结果集中去除重复记录,或者在处理用户输入数据时避免添加重复项。
集合运算:HashSet 支持集合间的交集、并集、差集等操作,适用于需要对多个数据集进行比较和合并的场景。
唯一性校验:在表单提交、数据验证过程中,可以使用 HashSet 存储已验证过的数据,快速判断新数据是否已存在,从而避免重复提交或存储。
缓存实现:尽管 HashMap 更常用于缓存,但在只需要存储对象而不关心键值对应关系的场景下,HashSet 也可以作为一种简单的缓存机制,用于存储最近访问或计算的结果。
权限控制:在系统权限管理中,可以使用 HashSet 存储用户拥有的权限列表,快速判断用户是否有执行某项操作的权限。
图的邻接节点表示:在图算法中,可以使用 HashSet 存储每个节点的邻接节点列表,便于进行遍历或搜索操作。
Tree和Hash的区别
SET
MAP
总结
通过对哈希表的学习,掌握了哈希表的基本实现,掌握了在哈希表中如何避免哈希冲突的两种方法:1.设置合理的哈希函数,2.降低负载因子。了解并掌握了解决哈希冲突的两类方法:1.开放地址法(线性探测、二次探测),2.链地址法。以及在java中如何使用HashMap和HashSet。
若有不足之处,欢迎指正~😊