目录
一、概念与模型
1.概念:Map
和
set
是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关
。以前常见的搜索方式有: ①直接遍历,时间复杂度为O(N)
,元素如果比较多效率会非常慢 ②二分查找,时间复杂度为O(logN),但搜索前必须要求序列是有序的 。上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如: ①根据姓名查询考试成绩 ②通讯录,即根据姓名查询联系方式 ③不重复集合,即需要先搜索关键字是否已经在集合中,可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本博客总结的Map
和
Set
是一种适合动态查找的集合容器。
2.模型:
一般把搜索的数据称为关键字(
Key
),和关键字对应的称为值(
Value
),将其称之为
Key-value
的键值对,所以模型会有两种:
(1)纯
key
模型
,如: 有一个英文词典,快速查找一个单词是否在词典中快速查找某个名字在不在通讯录中
(2)Key-Value
模型
,如: ①统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:<
单词,单词出现的次数
> ②梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号。
而Map
中存储的就是
key-value
的键值对,
Set
中只存储了
Key
。
二、Map的使用
1.Map
是一个接口类,该类没有继承自
Collection
,该类中存储的是
<K,V>
结构的键值对,并且
K
一定是唯一的,不
能重复
。
2.
关于
Map.Entry<K, V>
的说明
方法
|
解释
|
K getKey()
|
返回
entry
中的
key
|
V getValue()
|
返回
entry
中的
value
|
V setValue(V value)
|
将键值对中的
value
替换为指定
value
|
3.Map 的常用方法说明
方法
|
解释
|
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
|
(1)Map
是一个接口,不能直接实例化对象
,如果
要实例化对象只能实例化其实现类
TreeMap
或者
HashMap
(2)Map
中存放键值对的
Key
是唯一的,
value
是可以重复的
(3)在TreeMap中插入键值对时,key
不能为空,否则就会抛
NullPointerException
异常
,但是
value
可以为空,但是HashMap两个都可以为空
(4)Map
中的
Key
可以全部分离出来,存储到
Set
中
来进行访问
(
因为
Key
不能重复
)
。
(5)Map
中的
value
可以全部分离出来,存储在
Collection
的任何一个子集合中
(value
可能有重复
)
。
(6)Map
中键值对的
Key
不能直接修改,
value
可以修改,如果要修改
key
,只能先将该
key
删除掉,然后再来进行重新插入。
TreeMap
和
HashMap
的区别:
Map
底层结构
|
TreeMap
|
HashMap
|
底层结构
| 红黑树 |
哈希桶
|
插入
/
删除
/
查找时间复杂度
| O(logN) |
O(1)
|
是否有序
|
关于
Key
有序
|
无序
|
线程安全
|
不安全
|
不安全
|
插入
/
删除
/
查找区别
|
需要进行元素比较
|
通过哈希函数计算哈希地址
|
比较与覆写
|
key
必须能够比较,否则会抛出ClassCastException异常
|
自定义类型需要覆写
equals
和hashCode方法
|
应用场景
|
需要
Key
有序场景下
|
Key
是否有序不关心,需要更高的时间性能
|
三、Set的说明
Set
与
Map
主要的不同有两点:
Set
是继承自
Collection
的接口类,
Set
中只存储了
Key
1.一些常用的方法
方法
|
解释
|
boolean add(E e)
|
添加元素,但重复元素不会被添加成功
|
void clear()
|
清空集合
|
boolean contains(Object o)
|
判断
o
是否在集合中
|
Iterator<E> iterator()
|
返回迭代器
|
boolean remove(Object o)
|
删除集合中的
o
|
int size()
|
返回
set
中元素的个数
|
boolean isEmpty()
|
检测
set
是否为空,空返回
true
,否则返回
false
|
Object[] toArray()
|
将
set
中的元素转换为数组返回
|
boolean containsAll(Collection<?> c)
|
集合
c
中的元素是否在
set
中全部存在,是返回
true
,否则返回false
|
boolean addAll(Collection<? extends E> c)
|
将集合
c
中的元素添加到
set
中,可以达到去重的效果
|
(1)Set
是继承自
Collection
的一个接口类
(2)Set
中只存储了
key
,并且要求
key
一定要唯一
(3)Set
的底层是使用
Map
来实现的,其使用
key
与
Object
的一个默认对象作为键值对插入到
Map
中的
(4)Set
最大的功能就是对集合中的元素进行去重
(5)实现
Set
接口的常用类有
TreeSet
和
HashSet
,还有一个
LinkedHashSet
,
LinkedHashSet
是在
HashSet
的基础上维护了一个双向链表来记录元素的插入次序。
(6)Set
中的
Key
不能修改,如果要修改,先将原来的删除掉,然后再重新插入
(7)Set
中不能插入
null
的
key
。
(8)TreeSet
和
HashSet
的区别:
Set
底层结构
|
TreeSet
|
HashSet
|
底层结构
|
红黑树
|
哈希桶
|
插入
/
删除
/
查找时间复杂度
| O(logN) |
O(1)
|
是否有序
|
关于
Key
有序
|
不一定有序
|
线程安全
|
不安全
|
不安全
|
插入
/
删除
/
查找区别
|
按照红黑树的特性来进行插入和删除
|
①先计算
key
哈希地址②
然后进行插入和删除
|
比较与覆写
|
key
必须能够比较,否则会抛出ClassCastException异常
|
自定义类型需要覆写
equals
和hashCode方法
|
应用场景
|
需要
Key
有序场景下
|
Key
是否有序不关心,需要更高的时间性能
|
四、一些小练习
1.给定10W个数据,统计每个数据出现的次数
public class TestDemo {
public static Map<Integer,Integer> func(int[] arr){
Map<Integer,Integer> map = new HashMap<>();
for (int x:arr) {
if(map.get(x) == null){
map.put(x,1);
}else{
int val = map.get(x);
map.put(x,val+1);
}
}
return map;
}
public static void main(String[] args) {
int[] arr = new int[10_0000];
Random random = new Random();
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(1000);
}
Map<Integer,Integer> map = func(arr);
System.out.println(map);
}
}
2.将10W个数据中的数据去重
把数据放到Set即可
public static Set<Integer> func2(int[] arr){
HashSet<Integer> set = new HashSet<>();
for (int x:arr) {
set.add(x);
}
return set;
}
3.从10W个数据中找到第一个重复的数据
把数据放到Set中,放之前检查一下,Set中是不是已经有了
public static int func3(int[] arr){
HashSet<Integer> set = new HashSet<>();
for (int x:arr) {
if(set.contains(x)){
return x;
}
set.add(x);
}
return -1;
}
五、搜索树
1.概念
二叉搜索树又称二叉排序树,它或者是一棵空树
,或者是具有以下性质的二叉树
:
- 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
- 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
- 它的左右子树也分别为二叉搜索树
2.查找操作
class Node{
public int val;
public Node left;
public Node right;
public Node(int val){
this.val = val;
}
}
public class BinarySearchTree {
Node root = null;
public Node search(int key){
Node cur = root;
while(cur != null){
if(cur.val < key){
cur = cur.right;
}else if(cur.val > key){
cur = cur.left;
}else{
return cur;
}
}
return null;//没有这个数据
}
}
3.插入操作
class Node{
public int val;
public Node left;
public Node right;
public Node(int val){
this.val = val;
}
}
public class BinarySearchTree {
Node root = null;
public boolean insert(int val){
if(root == null){
root = new Node(val);
return true;
}
Node cur = root;
Node parent = null;
while(cur != null){
if(cur.val < val){
parent = cur;
cur = cur.right;
}else if(cur.val == val){
return false;//不能有相同数据进行插入
}else{
parent = cur;
cur = cur.left;
}
}
Node node = new Node(val);
if(parent.val < val){
parent.right = node;
}else{
parent.left = node;
}
return true;
}
}
4.删除操作
设待删除结点为
cur,
待删除结点的双亲结点为
parent
(1)情况一:
cur.left == null
①cur
是
root
,则
root = cur.right
②cur
不是
root
,
cur
是
parent.left
,则
parent.left = cur.right
③cur 不是
root
,
cur
是
parent.right
,则
parent.right = cur.right
(2)情况二:
cur.right == null
①cur
是
root
,则
root = cur.left
②cur
不是
root
,
cur
是
parent.left
,则
parent.left = cur.left
③cur
不是
root
,
cur
是
parent.right
,则
parent.right = cur.left
(3)情况三:cur.left != null && cur.right != null
class Node{
public int val;
public Node left;
public Node right;
public Node(int val){
this.val = val;
}
}
public class BinarySearchTree {
Node root = null;
public void remove(int key){
Node cur = root;
Node parent = null;
while(cur != null){
if(cur.val == key){
removeNode(cur,parent);
break;
}else if(cur.val < key){
parent = cur;
cur = cur.right;
}else{
parent = cur;
cur = cur.left;
}
}
}
public void removeNode(Node cur,Node parent){
if(cur.left == null){
if(cur == root){
root = cur.right;
}else if(cur == parent.left){
parent.left = cur.right;
}else{
parent.right = cur.right;
}
}else if(cur.right == null){
if(cur == root){
root = cur.left;
}else if(cur == parent.left){
parent.left = cur.left;
}else{
parent.right = cur.left;
}
}else{
Node targetParent = cur;
Node target = cur.right;
while(target.left != null){
targetParent = target;
target = target.left;
}
cur.val = target.val;
if(target == targetParent.left){
targetParent.left = target.right;
}else {
targetParent.right = target.right;
}
}
}
public void inOrder(Node root){
if(root == null){
return;
}
inOrder(root.left);
System.out.print(root.val + " ");
inOrder(root.right);
}
}
插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。 对有n
个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多
最优情况下,二叉搜索树为完全二叉树,其时间复杂度为O(logn)
最差情况下,二叉搜索树退化为单支树,其时间复杂度为O(n)
TreeMap
和
TreeSet
即
java
中利用搜索树实现的
Map
和
Set
;实际上用的是红黑树,而红黑树是一棵近似平衡的二叉搜索树,即在二叉搜索树的基础之上 +
颜色以及红黑树性质验证,关于红黑树的知识点我们后面再总结
六、哈希表
1.概念
顺序结构以及平衡树
中,元素关键码与其存储位置之间没有对应的关系,因此在
查找一个元素时,必须要经过关键
码的多次比较
。
顺序查找时间复杂度为
O(N)
,平衡树中为树的高度,即
O(logn
),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以
不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若
关键码相等,则搜索成功
该方式即为哈希
(
散列
)
方法,
哈希方法中使用的转换函数称为哈希
(
散列
)
函数,构造出来的结构称为哈希表
(Hash
Table)(
或者称散列表
)
2.冲突
(1)概念:对于两个数据元素的关键字 和
(i != j)
,有
!=
,但有:
Hash( ) == Hash( )
,即:
不同关键字通过相同哈
希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
。
把具有不同关键码而具有相同哈希地址的数据元素称为
“
同义词
”
。
注意:
由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一
个问题,
冲突的发生是必然的
,但我们能做的应该是尽量的
降低冲突率
(2)冲突-->避免
引起哈希冲突的其中一个原因可能是:
哈希函数设计不够合理
。
哈希函数设计原则
:
①哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
②哈希函数计算出来的地址能均匀分布在整个空间中
③哈希函数应该比较简单
常见的哈希函数:
①直接定制法(常用):取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况
我们来看一条题:
代码实现:
class Solution {
public int firstUniqChar(String s) {
if(s == null){
return -1;
}
int[] arr = new int[26];
for(int i = 0; i < s.length(); i++){
char ch = s.charAt(i);
arr[ch-97]++;
}
for(int i = 0; i < s.length(); i++){
char ch = s.charAt(i);
if(arr[ch-97] == 1){
return i;
}
}
return -1;
}
}
②
除留余数法
(常用)
:
设散列表中允许的
地址数为
m
,取一个不大于
m
,但最接近或者等于
m
的质数
p
作为除数,按照哈希函数:
Hash(key) = key% p(p<=m),
将关键码转换成哈希地址
③平方取中法:
假设关键字为
1234
,对它平方就是
1522756
,抽取中间的
3
位
227
作为哈希地址; 再比如关键字为
4321
,对它平方就是18671041
,抽取中间的
3
位
671(
或
710)
作为哈希地址
平方取中法比较适合:不知道关键字的分
布,而位数又不是很大的情况
④折叠法:
将关键字从左到右分割成位数相等的几部分
(
最后一部分位数可以短些
)
,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
⑤随机数法:选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),
其中
random
为随机数函数。
通常应用于关键字长度不等时采用此法
⑥数学分析法:
设有
n
个
d
位数,每一位可能有
r
种不同的符号,这
r种不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。如:
假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前
7
位都是 相同的,那么我们可以
选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转
(
如1234改成
4321)
、右环位移
(
如
1234
改成
4123)
、左环移位、前两数与后两数叠加
(
如
1234
改成
12+34=46)
等方法。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均
匀的情况
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突
降低冲突率的另一个方法:
负载因子调节
负载因子α=存储散列表的元素个数/散列表的长度
α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中元素的个数”成正比,所以α越大,表明填入表中的元素越多,产生冲突的可能性越大;反之,α越小,表明填入表中的元素越少,产生冲突的可能性越少。实际上,散列表的平均查找长度是α的函数,只是不同处理冲突的方法有不同的函数。对于开放定址法,α是特别重要的因素,应严格限制在0.7~0.8以下,超过0.8,查表时的CPU缓存不命中按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统限制了α为0.75,超过此值将resize散列表
α与冲突率的关系图:
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。
(3)冲突-->解决
解决哈希冲突
两种常见的方法是:
闭散列
和
开散列
(i)闭散列
也叫开放地址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以
把
key
存放到冲突位置中的
“
下一个
”
空位置中去。
如何寻找下一个空位置?
①
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。插入:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
-
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4; 如果直接删除掉, 44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
-
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找
②二次探测:
二次探测为了避免线性探测的问题,找下一个空位置的方法为:Hi= (H0+i^2)% m, 或者:Hi= (H0+i^2)% m。其中:i = 1,2,3…,H0是通过散列函数Hash(x)
对元素的关键码
key
进行计算得到的位置,m是表的大小。 对于上面
中如果要插入
44
,产生冲突,使用解决后的情况为
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。 因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷
(ii)开散列
开散列法又叫链地址法
(
开链法
)
,首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
代码实现:
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[] arr;
public int usedSize;
public static final double DEFAULT_LOADFactor = 0.75;
public HashBuck(){
this.arr = new Node[10];
}
public void put(int key, int val){
//找到key所在的位置
int index = key % this.arr.length;
//遍历下标的链表,看看是不是有相同的key,如果有,要更新val
Node cur = arr[index];
while(cur != null){
if(cur.key == key){
cur.val = val;
return;
}
cur = cur.next;
}
//没有key这个元素,头插法插入
Node node = new Node(key,val);
node.next = arr[index];
arr[index] = node;
this.usedSize++;
//插入元素成功后,检查当前散列表的负载因子
if(loadFactor() > DEFAULT_LOADFactor){
resize();//扩容----->不是简单的把数组扩大,数组里面的每个链表的每个元素必须重新进行哈希
//14%10=4 14%20=14
}
}
private void resize(){
Node[] newArr = new Node[arr.length*2];
for(int i = 0; i < this.arr.length; i++){
Node cur = arr[i];
while(cur != null){
int index = cur.key % newArr.length;//获取新的下标
//把cur这个节点,以头插或者尾插的方式插入到新的数组对应下标的链表当中
Node curNext = cur.next;
cur.next = newArr[index];//先绑定前面
newArr[index] = cur;//再绑定后面
cur = curNext;
}
}
arr = newArr;
}
private double loadFactor(){
return 1.0 * usedSize / this.arr.length;
}
public int get(int key){
//找到key所在的位置
int index = key % this.arr.length;
//遍历下标的链表,看看是不是有相同的key,如果有,要更新val
Node cur = arr[index];
while(cur != null){
if(cur.key == key){
return cur.key;
}
cur = cur.next;
}
return -1;
}
}
进阶代码实现:
import java.util.HashMap;
import java.util.Objects;
class Person{
public String ID;
public Person(String ID){
this.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);
}
@Override
public int hashCode() {
return Objects.hash(ID);
}
@Override
public String toString() {
return "Person{" +
"ID='" + ID + '\'' +
'}';
}
}
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>[] arr = (Node<K,V>[])new Node[10];
public int usedSize;
public void put(K key, V val){
int hash = key.hashCode();//hashcode()寻找位置
int index = hash % arr.length;
Node<K,V> cur = arr[index];
while(cur != null){
if(cur.key.equals(key)){//equals()负责查看有没有一样的元素
cur.val = val;
return;
}
cur = cur.next;
}
Node<K,V> node = new Node(key,val);
node.next = arr[index];
arr[index] = node;
this.usedSize++;
}
//hashcode()一样,equals()不一定一样;equals()一样,hashcode()一定一样
public V get(K key){
int hash = key.hashCode();//hashcode()寻找位置
int index = hash % arr.length;
Node<K,V> cur = arr[index];
while(cur != null){
if(cur.key.equals(key)){//equals()负责查看有没有一样的元素
return cur.val;
}
cur = cur.next;
}
return null;
}
public static void main(String[] args) {
Person person1 = new Person("123");
Person person2 = new Person("123");
HashBuck2<Person,String> hashBuck2 = new HashBuck2();
hashBuck2.put(person1,"abc");
System.out.println(hashBuck2.get(person2));
}
}
编译运行该代码,输出如下:
abc
(3)一些问题:
①如果new HashMap(19),bucket数组多大?
②HashMap什么时候开辟bucket数组占用内存的?
③HashMap何时扩容?
(4)一个小题
求下面的查找成功的平均长度以及查找不成功的平均长度: