在之前数据结构的学习中,对于数据的查找都是基于给定一个值,通过和序列中的关键字比较而实现的。因此这样的查找效率一般都是更依赖于比较的次数,像直接遍历或二分查找都是如此。而如果我们可以不经过任何比较,只是通过记录的关键字直接得到关键字在序列中的位置,必然会提高我们的查找效率。本文的Map和Set就是实现了这样的查找方式的一种集合。
文章目录
- 搜索
- 静态查找
- 动态查找
- Map和Set的结构模型
- 纯key模型
- key-value模型
- Map
- Map的常用方法
- HashMap和TreeMap
- Set
- Set的常用方法
- HashSet和TreeSet
- 哈希表
- 哈希表查找的基本思想
- 冲突
- 避免哈希冲突的方法
- 哈希函数的设计
- 负载因子的调节
- 解决哈希冲突的方法
- 闭散列
- 线性探测
- 二次探测
- 开散列(哈希桶)
- 代码实现
搜索
静态查找
像直接遍历和二分查找都属于静态查找,即对一个不会再进行插入或删除操作的序列(或集合)进行查找;
动态查找
本文的Map和Set就是可以进行动态查找的集合,即在查找的过程中仍可以对序列(或集合)进行插入或删除的操作;
Map和Set的结构模型
一般我们将进行查找或搜索的数据称为关键字(key),与关键字对应的元素称为值(value),整体可以称作是key-value的键值对;一般有2种模型:
纯key模型
单纯只有关键字;
像一个花名册,查找某个名字是否在该花名册上;Set的结构属于纯key模型;
key-value模型
包括关键字-关键字对应的值两部分;
像一个成绩单,每个名字对应一个分数;Map中存储的数据就是key-value结构的;
Map
Map属于一个接口类,类中存储<k,v>类型的键值对;该类下包括了HashMap和TreeMap2个类,由于TreeMap实现了SortedMap接口,因此TreeMap中的存储是有序的;
Map的常用方法
以代码演示:
public static void main(String[] args) {
Map<String,Integer> map=new HashMap<>();
//向HashMap中放入元素
map.put("hello",12);
map.put("today",2);
map.put("happy",5);
map.put("i",5);
map.put("log",3);
//1.返回key对应的value
Integer t1=map.get("happy");
System.out.println(t1); // 5
//2.返回key对应的value,key不存在时返回默认值
Integer t2=map.getOrDefault("hehe",100);
System.out.println(t2); // 100
//3.设置key对应的value,不存在的key直接存入,以及存在的key会覆盖之前的value
map.put("i",10);
Integer t3=map.get("i");
System.out.println(t3); // 10
//4.删除key对应的映射关系
map.remove("today");
//5.返回所有key的不重复的集合
Set<String> set=map.keySet();
System.out.println(set); // [log, happy, i, hello]
//6.返回所有value的集合
Collection<Integer> con=map.values();
System.out.println(con); // [3, 5, 10, 12]
//7.返回所有key-value的映射关系
Set<Map.Entry<String,Integer>> set2=map.entrySet();
System.out.println(set2); // [log=3, happy=5, i=10, hello=12]
//8.判断是否包含key
boolean t=map.containsKey("happy");
System.out.println(t); // true
//9.判断是否包含value
boolean f=map.containsValue(100);
System.out.println(f); // false
}
Map是一个接口,不可以直接进行实例化,需要借助HashMap或TreeMap才可以实例化对象;
不可以直接修改Map中的key值;可以先删除再重新插入;
HashMap和TreeMap
- TreeMap的底层实现是红黑树结构,HashMap的底层实现是哈希桶结构;
- TreeMap插入/删除/查找的时间复杂度O(logN)(以2为底),HashMap为O(1);
- TreeMap关于key有序,HashMap无序;
- TreeMap线程不安全,HashMap线程安全;
Set
Set同样属于一个接口类,继承自Collection类,类中存储纯key类型的元素;
Set的常用方法
同样使用代码进行演示:
public static void main(String[] args) {
Set<Character> set=new TreeSet<>();
//添加元素
set.add('a');
set.add('m');
set.add('q');
// set.add('a');// 添加不成功,不可以添加重复的元素
//1.判断元素是否在集合中
set.contains('m');
//2.删除集合中的某个元素
set.remove('a');
//3.返回集合中的元素个数
int size=set.size(); //2
System.out.println(size);
//4.判断集合是否为空
System.out.println(set.isEmpty()); //false
//再创建一个集合,加入一些元素到集合里
Collection<Character> c=new ArrayList<>();
c.add('a');
c.add('b');
c.add('c');
//5.集合c中的元素在set中是否全部存在
System.out.println(set.containsAll(c)); //false
//6.将集合c中的元素添加到set中
System.out.println(set.addAll(c)); // true
//7.将集合中的元素转换为数组
Object [] arr=set.toArray();
for ( Object i:arr) {
System.out.println(i); // a b c m q
}
//清空集合
set.clear();
}
Set是一个接口类,实例化对象需要借助HashMap或TreeMap;
Set不支持插入重复的元素;
Set不可以插入null;
可以使用Set对集合中的元素进行去重;
HashSet和TreeSet
- HashSet的底层结构:哈希桶,TreeSet的底层结构:红黑树;
- TreeSet插入/删除/查找的时间复杂度O(logN)(以2为底),HashSet为O(1);
- HashSet和TreeSet线程均不安全;;
- TreeSet关于key有序;
哈希表
前面说到,如果可以在查找元素的过程中,使用记录的关键字直接得到关键字在查找的序列中的位置,就可以加快查找的效率,哈希表就是这样一种存储结构;
哈希表查找的基本思想
在元素的关键字k和元素的存储位置P之间建立一个对应关系,使用P=H(k)表示,则H就是哈希函数,使用这种关系构造出来的结构就称为哈希表;
使用哈希表查找的核心就是哈希函数,即将关键字映射到查找表中的存储位置。
冲突
设置哈希函数为H(key)=key%capacity;
通过上面的哈希函数对序列中的元素进行存储,发现存在key1!=key2,但H(key1)==H(key2)的情况,即不同关键字通过相同的哈希函数得出了相同的哈希地址,我们称这种现象为哈希冲突(哈希碰撞);称key1与key2为同义词;
尽管冲突现象是难以避免的,但我们还是希望可以找到一个合适的哈希函数的设置方法来尽可能地降低冲突率,即哈希函数的设计;
避免哈希冲突的方法
哈希函数的设计
哈希函数的设计一般都需要遵循简单且易于计算和计算得到的地址要尽量均匀分布2个原则,下面是一些常见的哈希函数:
- 直接定址法
即直接使用关键字求得哈希地址: H(key)=a*key+b;
直接定址法得到的哈希函数简单且分布相对均匀,但使用这种方法时需要事先知道关键字的分布情况;
更适合查找小且连续的元素;
-
除留余数法;
设哈希表的长度为m,取一个小于等于m的最大素数p,得到的哈希函数为: H(key)=key%p (p<=m); -
平方取中法
即首先求出关键字的平方值,再根据需要取平方值的中间几位作为哈希地址;例如关键字2345,对它平方得到5499025,就可以取其中间的990作为哈希地址;
平方取中法更加适合当关键字的分布未知同时位数又不太大的情况;
- 分段叠加法(折叠法)
即将关键字从左到右分割成位数相等的几部分,然后将这几部分相加,最后通过哈希表的长度,取结果的后几位为哈希地址;
折叠法同样使用于关键字的分布未知的情况,但更适合位数较多的情况;
- 随机数法
即采用一个随机函数得到哈希地址,其哈希函数为:H(key)=random(key);
随机数法更适合于关键字的位数不一致的情况;
- 数学分析法
数学分析法一般是实现知道关键字的分布,同时关键字的位数要大于哈希表的大小的位数;就从关键字中选取分布比较均匀的几位作为哈希地址;
一般情况下,设计哈希函数需要考虑到下面几个方面的问题:
- 计算哈希函数的时间;
- 关键字的长度;
- 哈希表的大小;
- 关键字的分布情况;
- 等
负载因子的调节
哈希表的负载因子即 r=填入表中的元素的个数/哈希表的长度;
负载因子的大小影响着哈希表中是否有剩余空间,即哈希表是否被装满,也就意味着可能发生冲突的概率大小;一般负载因子的值越大,发生冲突的概率就会越大,因此一个合适的负载因子的取值是重要的。
java中,负载因子的值为0.75;
解决哈希冲突的方法
解决哈希冲突有2种常见的方法,即闭散列和开散列;
闭散列
闭散列,也称为开放定址法;当一个关键字的哈希地址出现冲突时,就以该哈希地址为基础产生另一个哈希地址,若产生的哈希地址又冲突,再以此地址产生下一个新的哈希地址,如此直到元素顺利插入哈希表中。实际就是按照一定规则在哈希表中寻找空闲地址的方式,寻找新的空闲地址的方法主要有下面几种:
线性探测
线性探测,从发生冲突的位置开始,依次向后探测,直到找到下一个空闲的哈希地址;
由于这种线性探测方法的特殊性,采用了该方法处理哈希冲突的散列表,在进行元素的删除时是使用标记法进行伪删除,以上面的例子为例,也就是避免因为删除3,而找不到13或不容易找到13;
二次探测
线性探测的方式方便但也带来了一个问题,即产生冲突的元素都是堆积在一起的,为了避免这个问题,就有了二次探测的方法。即采用H(i)=(H(0)+i^2)%n来查找下一个空闲的哈希地址;
开散列(哈希桶)
开散列法又称为链地址法,即首先对关键字根据哈希函数计算哈希地址,若是遇到地址相同的关键字,就将所有地址相同的关键字使用一个链表连接,存储在计算出的哈希地址的位置上;也就是哈希表类似于一个数组,数组的元素可以为链表,每个链表上的元素都是之前计算出的哈希地址相同的元素;
因此,哈希桶的每个桶中存放的元素都是产生冲突的元素;
代码实现
下面使用代码来实现一个哈希表;
public class HashBack {
//定义结点结构
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;
//设定一个负载因子
private static final double DEFAULT_LOAD_FACTOR=0.75;
/*
* 实例化一个哈希表(数组)
* */
public HashBack(){
this.array=new Node[10];
}
/*
* 通过key,得到val
* */
public int get(int key){
//通过哈希函数得到哈希地址【即数组的下标】
int index=key%array.length;
//通过该下标得到链表的头结点
Node cur=array[index];
//遍历链表,查找与key相等的结点
while (cur!=null){
if (cur.key==key){
return cur.val;
}
cur=cur.next;
}
return -1;
}
public void put(int key,int val){
//创建一个新的结点,待插入到哈希表
Node node=new Node(key,val);
//确定存放的位置
int index=key%array.length;
/*
* 遍历当前位置的链表;
* 遇到一样的key,则替换当前的val;
* 遍历完没有遇到一样的key,则使用头插法插入该结点;
* */
Node cur=array[index];
while (cur!=null){
if (cur.key==key){
cur.val=val;
return;
}
cur=cur.next;
}
//插入
node.next=array[index];
array[index]=node;
usedSize++;
//检查当前哈希表中的元素个数与哈希表大小的占比是否超过了最初设定的负载因子
if (loadFactor()>=DEFAULT_LOAD_FACTOR){
//超出负载因子,进行扩容;
resize();
}
}
/*
* 进行扩容
* 扩容时,要为原来哈希表中的元素重新计算在新的哈希表中新的哈希地址
* */
private void resize(){
Node [] tmp=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 index=cur.key%tmp.length;
//插入
cur.next=tmp[index];
tmp[index]=cur;
cur=curNext;
}
}
array=tmp;
}
private double loadFactor(){
return usedSize*1.0/array.length;
}
}
插入几个元素进行测试:
public class Test {
public static void main(String[] args) {
HashBack hashBack=new HashBack();
hashBack.put(1,23);
hashBack.put(3,33);
hashBack.put(13,56);
hashBack.put(23,3);
hashBack.put(7,9);
hashBack.put(17,23);
}
}
调试代码,可以看到当前存储的结构:
上面代码实现的哈希表只是存储基本数据类型的情况,那若是存储引用类型呢?使用下面代码演示;
public class HashBack2 <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[4];
public int usedSize;
private static final double DEFAULT_LOAD_FACTOR=0.75;
public void put(K key,V val) {
Node<K,V> node = new Node<>(key,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;
}
cur = cur.next;
}
node.next = array[index];
array[index] = node;
usedSize++;
}
private double loadFactor(){
return usedSize*1.0/array.length;
}
private void resize(){
Node<K,V>[] tmp= (Node<K, V>[]) new Node [2*array.length];
for (int i=0;i<array.length;i++){
Node<K,V> cur=array[i];
while (cur!=null){
//记录当前结点的下一个指向
Node<K,V> curNext=cur.next;
//计算新的哈希地址
int index=cur.key.hashCode() % tmp.length;
//插入
cur.next=tmp[index];
tmp[index]=cur;
cur=curNext;
}
}
array=tmp;
}
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)) {
return cur.val ;
}
cur = cur.next;
}
return null;
}
}
over!