一、二叉搜索树搜索树
1.二叉搜索树的概念
二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树:
若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
它的左右子树也分别为二叉搜索树
2.二叉搜索树的模拟实现
import sun.reflect.generics.tree.Tree;
public class BinarySearchTree {
static class TreeNode{
public int val;
public TreeNode left;
public TreeNode right;
public TreeNode(int val){
this.val = val;
}
}
public TreeNode root = null;
public TreeNode find(int val,TreeNode root){
TreeNode cur = root;
while(cur != null){
if(cur.val<val){
cur = cur.right;
}
else if(cur.val>val){
cur = cur.left;
}
else{
return cur;
}
}
return null;
}
public void insert(int val){
TreeNode newNode = new TreeNode(val);
if(root == null){
root = newNode;
return ;
}
TreeNode cur = root;
TreeNode pre= cur;
while(cur != null){
if(cur.val<val){
pre = cur;
cur = cur.right;
}
else if(cur.val>val){
pre = cur;
cur = cur.left;
}
else{
return;
}
}
if(val> pre.val){
pre.right = newNode;
}
if(val<pre.val){
pre.left = newNode;
}
}
public void delete (int val){
if(root == null){
return;
}
TreeNode cur = root;
TreeNode parent= cur;
while(cur != null){
if(cur.val<val){
parent = cur;
cur = cur.right;
}
else if(cur.val>val){
parent = cur;
cur = cur.left;
}
else{
remove(parent,cur,val);
}
}
}
private void remove(TreeNode parent, TreeNode cur, int val) {
if(cur.left == null){
if(cur == root){
root = root.right;
}
if(parent.left == cur){
parent.left = cur.right;
}
if(cur == parent.right){
parent.right = cur.right;
}
}else if(cur.right == null){
if(cur == root){
root = root.left;
}
if(parent.left == cur){
parent.left = cur.left;
}
if(cur == parent.right){
parent.right = cur.left;
}
}
else{
TreeNode target = cur.right;
TreeNode targetParent = cur;
while(target.left!= null){
targetParent = target;
target = target.left;
}
cur.val = target.val;
if(target==targetParent.left){//证明此时taeget没有左子树
targetParent.left = target.right;
}else
targetParent.right = target.right;
}
}
}
实际上在JDK中TreeSet 和TreeMap底层实现用的就是一颗二叉搜索树(红黑树)。
二、Map & Set
Map和set是一种专门用来进行搜索的容器或者数据结构,其搜索的效率与其具体的实例化子类有关。以前常见的 搜索方式有:
- 直接遍历,时间复杂度为O(N),元素如果比较多效率会非常慢。
- 二分查找,时间复杂度为O(logN),但搜索前必须要求序列是有序的。
上述排序比较适合静态类型的查找,即一般不会对区间进行插入和删除操作了,而现实中的查找比如:
- 根据姓名查询考试成绩
- 通讯录,即根据姓名查询联系方式
不重复集合,即需要先搜索关键字是否已经在集合中可能在查找时进行一些插入和删除的操作,即动态查找,那上述两种方式就不太适合了,本节介绍的Map和Set是 一种适合动态查找的集合容器。
一般把搜索的数据称为关键字(Key),和关键字对应的称为值(Value),将其称之为Key-value的键值对,所以 模型会有两种:
- 纯 key 模型,比如:
有一个英文词典,快速查找一个单词是否在词典中
快速查找某个名字在不在通讯录中 - Key-Value 模型,比如:
统计文件中每个单词出现的次数,统计结果是每个单词都有与其对应的次数:<单词,单词出现的次数>
梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号
而Map中存储的就是key-value的键值对,Set中只存储了Key。
2.1 Map
2.1.1关于Map的说明
Map是一个接口类,该类没有继承自Collection,该类中存储的是<K,V>结构的键值对,并且K一定是唯一的,不 能重复。
有一点需要注意,我们知道Map的底层是一个具有其他限制的二叉搜索树(红黑树),而这个二叉搜索树所比较的对象并不是value,而是key.举个例子来说,现在有键值对<“a”,2>、<“b”,1>,那么这两个键值对如果构建一个map的话根节点是<“a”,2>,而<“b”,1>是左子树(注意不是右子树),因为“a”比“b”大。
2.2.2 关于Map.Entry<K, V>的说明
Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了
<key, value>的获取,value的设置以及Key的比较方式。
2.2.3 Map 的常用方法说明
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
public class Test {
public static void main(String[] args) {
Map<String,Integer> treeMap = new TreeMap<>();
treeMap.put("a",0);//添加键值对
treeMap.put("a",1);//treeMao中key类似于唯一标识符,只能有一个对象的key是a,所以这里实际是将原来a=0改成a=1
treeMap.put("b",2);
treeMap.put("c",3);
treeMap.put("d",4);
System.out.println(treeMap);//treeMao重写了TosString方法,map输出使用{}
System.out.println(treeMap.get("a"));//根据key返回value
//将treeMap中的所有键值对的key都放入Set中
Set<String> keyset = treeMap.keySet();//set输出使用【】
System.out.println(keyset);
//将treeMap中所有的键值对都放入一个set中
Set<Map.Entry<String,Integer>> set = treeMap.entrySet();
System.out.println(set);
for (Map.Entry<String,Integer> entry:set) {
System.out.println("key="+entry.getKey()+" value="+entry.getValue());
}
System.out.println(treeMap.getOrDefault("e",1));
//如果treeMap中有"e“就返回value,如果没有就返回1
}
}
注意:
- Map是一个接口,不能直接实例化对象,如果要实例化对象只能实例化其实现类TreeMap或者HashMap
- Map中存放键值对的Key是唯一的,value是可以重复的
- 在TreeMap中插入键值对时,key不能为空,否则就会NullPointerException异常,value可以为空。但是HashMap的key和value都可以为空。
- Map中的Key可以全部分离出来,存储到Set中来进行访问(因为Key不能重复)。
- Map中的value可以全部分离出来,存储在Collection的任何一个子集合中(value可能有重复)。
- Map中键值对的Key不能直接修改,value可以修改,如果要修改key,只能先将该key删除掉,然后再来进行重新插入。
2.2 Set 的说明
Set与Map主要的不同有两点:Set是继承自Collection的接口类,Set中只存储了Key。
注意:
-
Set是继承自Collection的一个接口类
-
Set中只存储了key,并且要求key一定要唯一
-
TreeSet的底层是使用Map来实现的,其使用key与Object的一个默认对象作为键值对插入到Map中的
-
Set最大的功能就是对集合中的元素进行去重
-
实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
-
Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入
-
TreeSet中不能插入null的key,HashSet可以。
三、哈希表
3.1 概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数.
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函 数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放 - 搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若 关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快 。
那么如果此时我们的key是14,那么14%10==4,发现也是要存放到下标为4的位置的,但是此时下标为4的位置已经有元素了呀?这种现象就叫做冲突。
3.2 冲突-概念
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
3.2 冲突-避免
首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一 个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率。
3.2.1哈希函数设计
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
- 哈希函数计算出来的地址能均匀分布在整个空间中
- 哈希函数应该比较简单
常见哈希函数
- 直接定址法–(常用)
取关键字的某个线性函数为散列地址:
Hash(Key)= A*Key + B
优点:简单、均匀 缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
面试题:字符串中第一个只出现一次字符
https://editor.csdn.net/md/?articleId=130651358 - 除留余数法–(常用)
设散列表中允许的地址数为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)等方 法。数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均 匀的情况
3.3冲突-避免-负载因子调节(重点)
散列表的载荷因子定义为: α = 填入表中的元素个数/散列表的长度
α 是散列表装满程度的标志因子。由于表长是定值,α 与“填入表中的元素个数”成正比,所以,α 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,α 越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α 的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0. 7-0. 8以下。超过0. 8,查表时的CPU缓存不命中( cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的 系统库限制了荷载因子为0.75,超过此值将resize散列表。
所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。
已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。
3.4 冲突-解决
3.4.1闭散列(开放定址法)
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
- 线性探测
比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该 位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止,插入
通过哈希函数获取待插入元素在哈希表中的位置,如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 一个空位置,插入新元素。
注意:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他 元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标 记的伪删除法来删除一个元素。
- 二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为
或者:
其中:i = 1,2,3…, H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置, m是表的大小。 对于3.3.1中如果要插入44,产生冲突,使用解决后的情况为:
闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
3.4.2冲突-解决-开散列/哈希桶(重点掌握)
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。如下图
实际上,开散列的方式是链表+数组+红黑树的配置。
红黑树体现在:当链表长度>=8,并且数组长度>=64时,底层会直接重新组织数据,形成红黑树。java的hashMap就是这种结构
开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了,时间复杂度可以认为是O(1)(因为数组是随机访问,我们认为链表的长度是一个常数)
3.5 HashCode
我们之前所讲的哈希表的值都是整型的,但是实际上当用到HashMap或者HashSet的时候,他里面存的可能是String类型,可能是一个我们定义的对象,那么我们怎么将其映射到我们的哈希表里呢?这就用到了HashCode.
在java中,我们每生成一个对象,他的地址是唯一的,所以我们可以通过地址来映射出每一个对象的唯一标识HashCode,HashCode,我们就可以根据这个对象的HashCode值结合散列函数和一系列解决、避免冲突的方法,将其映射到哈希表中从而达到我们的目的。
关于hashcode,可以去看这篇博客
https://blog.csdn.net/qq_46940224/article/details/124384187
```java
import java.util.Set;
import java.util.TreeSet;
public class Test2 {
public static void main(String[] args) {
Person person1 = new Person("张三",4);
Person person2 = new Person("李四",5);
System.out.println(person1.hashCode());
System.out.println(person2.hashCode());
}
static class Person{
String name;
int age;
public Person(String name, int age){
this.name = name;
this.age = age;
}
}
}
面试题:两个对象,hashCode一样,equals也一样吗:equals一样,hashCode一样吗?
首先要要注意的是equals如果是引用类型调用的话,比较的是这两个引用类型的地址;hashcode相同只能说明地址经过映射之后的值相同,不能说明地址相同,也就不能说equals相同了。如果equals相同(没有重写)了,那么证明地址相同,本身就是同一对象,那么当然hashcode当然相同了。