- 搜索方式介绍
- TreeMap
- Map使用
- TreeSet
- Set使用
- Set和Map常用方法练习(后面补充)
- 练习之Set/Map
- oj练习(后面补充)
- 哈希表
- 哈希冲突
- 避免冲突-哈希函数设计
- 避免冲突-负载因子调节
- 避免冲突-闭散列
- 避免冲突-开散列
- 模拟实现哈希表
- 哈希Map源码分析
搜索方式介绍
哈希集合(Hash Set)是一种数据结构,集合(Set)的一种实现方式。哈希集合使用哈希表(Hash Table)来实现这一特性。Set和Map底下有四种实现:Map和Set适合动态查找的集合容器;TreeSet和TreeMap背后都是一棵搜索树(红黑树)
原始的搜索方式:效率低;不适对区间经常进行插入和删除操作的对象查找
1.直接遍历,时间复杂度为O(N),元素如果比较多效率会非常慢
2.二分查找,时间复杂度为,但搜索前必须要求序列是有序的
为什么Set和Map适合动态查找呢?这就要取决于它们的模型(Key-value的键值对)
1:纯key模型;Set只存key
比如:我要查通讯录的某个名字在不在
2:key-Value模型;Map存key-Value键值对
比如:统计单词出现的个数;key:value
TreeMap
TreeMap底层是一个搜索树(红黑树);存放的键一定是按照有序的顺序存储的,因为二叉搜索树也是有序的。
TreeMap和TreeSet的key都是要可比较的;堆的也是需要可比较的;但是堆的第一次offer是不进行比较的;所以它第一次放进去不报错;但是这里的TreeMap和TreeSet如果是不能比较的就必报错。
Map使用
Map是一个接口类,该类没有继承自Collection,该类中存储的是<K,V>结构的键值:。Map没有实现Iterable所以Map不能使用迭代器去遍历;我们后面遍历得使用特殊手段
1:Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap
2:Map中存放键值对的Key是唯一的,value是可以重复的
3:Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
4:Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
5:Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。
Map.Entry<K, V>是什么?
Map.Entry<K, V>是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了<key, value>的获取,value的设置以及Key的比较方式。(注意:这里并没有提供设置Key的方法)
TreeSet
底层也是一棵搜索树,存储数据的特点为有序的,不可以重复的,所有存放的元素都是可以比较,不可重复的。
TreeSet底层使用TreeMap的实现(HashSet底层使用HashMap)。TreeSet和TreeMap存元素时的key一定得可比较;不然会出现ClassCastException的异常
Map的遍历
1:
把Key放入set里;然后遍历获取全部value
public static void main(String[] args) {
Map<String,Integer> map = new HashMap<>();
map.put("aaa",1);
map.put("bbb",2);
map.put("ccc",3);
Set<String> set = map.keySet();
for (String s : set) {
System.out.println(s+" = "+map.get(s));
}
}
2:
使用Enrty然后获取里面的Key
public static void main(String[] args) {
Map<String,Integer> map = new HashMap<>();
map.put("aaa",1);
map.put("bbb",2);
map.put("ccc",3);
Set<Map.Entry<String, Integer>> entries = map.entrySet();
for (Map.Entry<String, Integer> entry : entries) {
System.out.println(entry.getKey()+" = "+entry.getValue());
}
}
Set使用
Set与Map主要的不同有两点:Set是继承自Collection的接口类,Set中只存储了Key;重复的key后面会覆盖前面
1.Set最大的功能就是对集合中的元素进行去重
2.实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
3.Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入
4.Set中不能插入null的key。队列是可以插入null
Set的三种遍历方法:
1:迭代器
public static void main(String[] args) {
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(3);
Iterator<Integer> it = set.iterator();
while(it.hasNext()) {
System.out.print(it.next()+" ");
}
}
2:for each循环
public static void main(String[] args) {
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(3);
for (Integer integer : set) {
System.out.print(integer+" ");
}
}
Set和Map常用方法练习(后面补充)
总结:
练习之Set/Map
10万个为什么:如何生成10W个随机数
1:统计10W个数据中;不重复的数据 [去重]
Treeset和HashSet都能做到
//十万个为什么之Set、Map运用。生成范围1-50000
public static void main(String[] args) {
int []array=new int[10_0000];
Random random=new Random();
for (int i = 0; i <10_0000 ; i++) {
array[i]=random.nextInt(5_0000);
}
func1(array);
}
//先生成10w个为什么
public static void func1(int []array){
HashSet<Integer> set=new HashSet<>();
// Set<Integer> set=new TreeSet<>();
set.add(9);
set.add(1);
for (int i = 0; i < array.length; i++) {
set.add(array[i]);
}
System.out.println(set);
}
注意:Integer类型;没超过这int类型表示的最大数字范围;它的哈希值计算还是它本身。所以我们打印的结果就看似有序的。其实并非真正意义上的有序。
2:统计10W个数据中,第一个重复的数据库
//统计10W个数据中,第一个重复的数据库
public static void first(int []array){
HashSet<Integer> set=new HashSet<>();
// Set<Integer> set=new TreeSet<>();
for (int i = 0; i < array.length; i++) {
if(!set.contains(array[i])) {
set.add(array[i]);
}else {
System.out.println(array[i]);
return;
}
}
}
3:统计10W个数据中每个数据出现的次数
最终结果是一个key value。key是值不重复;value是出现的次数。直接存Map里
//统计10W个数据中每个数据出现的次数
public static void count(int[] array){
HashMap<Integer,Integer> map=new HashMap<>();
for (int i = 0; i <array.length ; i++) {
int key=array[i];
//我获取一下看看有没有这个元素;如果没有;我得放进去(key,1)
if(map.get(key)==null){
map.put(key,1);
}else {
//里面已经有了;我们直接给value加1。那我们得先获取到value
int val=map.get(key);
map.put(key,val+1);
}
for (Map.Entry<Integer,Integer> entry:map.entrySet() ) {
System.out.println(entry.getKey()+"出现"+entry.getValue()+"次");
}
}
}
oj练习(后面补充)
哈希表
理想的搜索方法;不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数hashFunc。使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。(数组遍历是O(N);二分查找是O(log N);搜索树是O(log N);而哈希表能达到O(1))
插入元素:根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素:对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
哈希表是一种数据结构:上述方式为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity;capacity为存储元素底层空间总的大小。
哈希冲突
哈希冲突:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
这些具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
例如:上面的例子2和12的位置相同(2%10=2;12%10=2)
避免冲突-哈希函数设计
当我们试图将大量的数据映射到有限数量的哈希桶时;冲突是必然的;冲突没法解决;只能尽量降低冲突率
降低冲突:哈希函数设计要合理设计;需要以下设计原则
1:哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
2:哈希函数计算出来的地址能均匀分布在整个空间中
3:哈希函数应该比较简单
常见的哈希函数:
1.直接定制法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况面试题:字符串中第一个只出现一次字符
2.除留余数法–(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
避免冲突-负载因子调节
现在知道负载因子的重要性:如何调节负载因子;存的数越来越多,前面的填入表的个数是无法改变的(你不能说不给人家存)只能改变后面的表的长度
避免冲突-闭散列
闭散列也叫开放定址法的基本思想是:当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。那如何寻找下一个空位置呢?
例如:上面举的例子;如果现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。我们怎么找下一个空位置?
1.线性探测法;
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。找到8的位置为空;插入进去。
当我们使用闭散列处理哈希冲突时;不能随便删掉哈希表的已有元素;哪怕不用了也不能随便删除;因为直接删除掉,44查找起来可能会受影响。所以线性探测采用标记的伪删除法来删除一个元素。
2.二次探测法;
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:Hi=(H0+i^2)%m或者Hi=(H0- i ^2)%m;i为1,2,3……(i是第一次冲突,或者第二次冲突等等)
H0是通过散列函数Hash(x)对元素的关键码key进行计算得到的位置;m是表的大小。对于刚才要插入44,产生冲突,使用解决后的情况为:(4+1^2)%10
表的长度为质数(质数能比较好降低冲突;比如公倍数)且表负载因子a不超过0.5时,新的表项才一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的负载因子a不超过0.5,如果超出必须考虑增容。
闭散列缺点:为了高效;空间利用率比较低,这也是哈希的缺陷。
避免冲突-开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
总结:采用开放地址法处理哈希冲突的时候,其平均查找长度应当大于链地址法
冲突的地方;这里是一个链表;全部堆积在这里;jdk1.7之前采用头插法;jdk1.8使用尾插法。在大集合中的搜索问题转化为在小集合中做搜索。
这里使用单链表:优点是它相对简单且占用的额外内存较少。每个节点只需存储键、值和下一个节点的引用即可。然而,单链表的缺点是在查找特定键值对时需要遍历整个链表,这可能会导致查找操作的时间复杂度为 O(n),其中 n 是链表的长度。在极端情况下,如果哈希函数选择不当,链表可能会变得非常长,影响性能。
哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是O(1)
模拟实现哈希表
针对int类型
import java.util.Arrays;
//int类型
public class HashBuck {
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[] array;
public int usedSize;
public HashBuck() {
array = new Node[8];
}
//我们使用头插比较简单
public void put(int key,int val) {
int index = key % array.length;
//遍历Index下标的数组,如果有相同的key那么替换
Node cur = array[index];
while (cur != null) {
if(cur.key == key) {
cur.val = val;
return;
}
cur = cur.next;
}
//进行头插法
Node node = new Node(key, val);
node.next = array[index];
array[index] = node;
usedSize++;
if( loadFactor() >= 0.75f) {
//扩容
//array = Arrays.copyOf(array,2*array.length);
resize();
}
}
private void resize() {//哈希函数值变了;所以我们不能简简单单的扩容;需要根据哈希函数重新把原来的元素放到相应的位置;然后才能找得到
Node[] newArray = new Node[2*array.length];
for (int i = 0; i < array.length; i++) {
Node cur = array[i];
while (cur != null) {
Node curNext = cur.next;
int newIndex = cur.key % newArray.length;
//拿着cur节点 进行插入到新的位置
cur.next = newArray[newIndex];
newArray[newIndex] = cur;
cur = curNext;
}
}
array = newArray;
}
private float loadFactor() {
return usedSize*1.0f / array.length;
}
public int get(int key) {
int index = key % array.length;
Node head = array[index];
while (head != null) {
if(head.key == key) {
return head.val;
}
head = head.next;
}
return -1;
}
}
针对泛型:
//泛型
//泛型需要注意;使用的是哈希code; int index = hash % array.length;。不是我们上一个代码的int类型直接key%array.length;
//
public class HashBuck2<K,V> {
static class Node<K,V> {
public K key;
public V val;
public Node<K,V> next;
public Node(K key, V val) {
this.key = key;
this.val = val;
}
}
public Node<K,V>[] array = (Node<K,V>[])new Node[10];
public int usedSize;
public void put(K key,V val) {
int hash = key.hashCode();
int index = hash % array.length;
Node<K,V> cur = array[index];
while (cur != null) {
if(cur.key.equals(key)) {
cur.val = val;
return;
}
cur = cur.next;
}
Node<K,V> node = new Node<>(key, val);
node.next = array[index];
array[index] = node;
usedSize++;
}
public V get(K key) {//先找桶再找值
int hash = key.hashCode();
int index = hash % array.length;
Node<K,V> cur = array[index];
while (cur != null) {
if(cur.key.equals(key)) {//如果两个id相同的对象;存的key是不同;但是他们存在同一个桶里;通过equals比较取哪个
return cur.val;
}
cur = cur.next;
}
return null;
}
}
假设我要储存person这个对象:我得重写哈希code;这样子id值相同的对象才能存到一个桶里;因为是根据哈希值来计算的。最后通过key比较我们就可以取出想要的。
import java.util.*;
class Person {
public String id;
public Person(String id) {
this.id = id;
}
@Override
public String toString() {
return "Person{" +
"id='" + id + '\'' +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Person person = (Person) o;
return Objects.equals(id, person.id);
}
//重写了HashCode方法,根据对象的id属性来计算哈希值
//如果两个对象的 id 属性值相同,那么它们的哈希码也会相同,这符合哈希表中的要求。
//这两个对象被视为在哈希表中的相同位置,即它们会被放置在哈希表的同一个桶中。
@Override
public int hashCode() {
return Objects.hash(id);
}
}
hashcode和equals区别:
hashCode 是一个方法,用于计算对象的哈希码,通常是一个整数。
hashCode 方法的主要作用是提供一种快速定位对象的机制,以加速数据结构的存储和检索操作。
哈希码的计算通常基于对象的内容(属性值),但不是唯一标识对象的内容,因此可能存在哈希冲突(不同对象具有相同哈希码)。
equals 是一个方法,用于比较两个对象的内容(属性值),以确定它们是否在语义上相等。
equals 方法的主要作用是定义对象之间的相等性比较规则,通常用于业务逻辑中的对象比较。
HashCode一定要再哈希表当中才会发挥出它的意义的;一般来说我们重写equals都要重写一下HashCode;但是我们通常在其它情况都是使用不到HashCode;所以前面的学习也就一直没有重写。
在哈希表设计里:
hashcode相同equals一定相同吗
不一定;但哈希码并不完全唯一。不同的对象可以具有相同的哈希码,这种情况称为哈希冲突。而我们使用equals区分这个相同的哈希码是不是同一个对象。
equals相同hashcode一定相同吗
一定;equals一样,代表是两个对象一样,那么它的对应的HashCode的值也一定是相同的
哈希Map源码分析
哈希映射(HashMap)的底层通常使用数组和链表(或红黑树)来实现。在 Java 8 及以后的版本中,如果同一个桶中的键值对数量达到一定阈值(通常是8个),则链表会被转换为红黑树,以提高检索性能。
构造方法:
无参构造:
默认容量是0;但是我们去put却能成功。在putVal(树化的代码也在在里面)完成的(大串代码,调用无参构造方法的时候,第一次pu’t才会开辟内存)
分配的内存是按2的次幂分配:例如你new的是19;实际分配的是32;往大的分;往小16放不下19个
哈希Map如何解决哈希冲突:链地址法通常用于大多数哈希映射实现;可能还会使用其它的方法应对不同的情况
java中的:哈希map和哈希table有点区别;HashMap的key和value可以为null;为null时给你赋值为0。hashtable的key、value不能为null