1. 前置概念:
Map和Set是用来解决搜索问题的
我们先来了解一下什么是二叉搜索树
二叉搜索树: 它是一种根节点的值大于所有左子树的值 而小于所有右子树的值的一种树.
当我们中序遍历它的时候,就可以得到一个有序的从小到大的一组数.
AVL树: 也就是二叉平衡树,这个树是基于二叉搜索树 的,它主要是来解决二叉搜索树出现单分支情况,而导致树的高度太高了.**解决方式是左旋或者右旋(**左右高度差大于等于2就进行).这样就把树的高度给平衡了.
红黑树: 它是基于AVL树进行改良的,给每个节点加上了颜色,然后可以减少左旋右旋的次数来平衡高度.
这个是底层接口之间的关系:
HashMap和HashSet,TreeMap和TreeSet之间的关系
2. 二叉搜索树的自我实现
老样子,我们先自己实现一个二叉搜索树,再介绍java内部的TreeSet和HashSet.
2.1 前置变量准备
我们先申请个节点静态内部类,里面有val,并且能存放左右子树的变量. 然后我们设置一个构造方法,当我们每次创建节点的时候,把val值传进去.并且,我们创建一个根节点.
2.2 查找元素search()
因为二叉搜索树的特点(右子树>根>左子树),因此我们把根节点作为和我们查找的val值的比较起点,如果比根小就去根的左子树继续找,如果比根大就去根的右边继续去找.
2.3 插入元素insert()
整体思路: parent用来记录上一个节点,cur用来记录当前节点. 1.我们先遍历树,先找到合适的位置. 2. 利用parent(记录的是合适位置的位置),判断是放在左树还是放在右树.
2.3 删除节点remove()
设待删除结点为 cur, 待删除结点的双亲结点为 parent
- cur.left == null
1> cur 是 root,则 root = cur.right
2> cur 不是 root,cur 是 parent.left,则 parent.left = cur.right
3> cur 不是 root,cur 是 parent.right,则 parent.right = cur.right
- cur.right == null
1> cur 是 root,则 root = cur.left
2> cur 不是 root,cur 是 parent.left,则 parent.left = cur.left
3> cur 不是 root,cur 是 parent.right,则 parent.right = cur.left
-
cur.left != null && cur.right != null
-
需要使用替换法进行删除,即在它的右子树中寻找中序下的第一个结点(关键码最小),用它的值填补到被删除节点中,再来处理该结点的删除问题.
2.4 整体代码
package Map_Set.binarysearchtree;
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;
//时间复杂度
// 最好情况: 完全二叉树 O(log2^n)
// 最坏情况: 单分支的树 O(n)
//TODO 查找元素
public boolean search(int key) {
TreeNode cur = root;
while (cur != null) {
//大于根去右边,小于根去左边
if(cur.val < key){
cur = cur.right;
}else if(cur.val > key) {
cur = cur.left;
}else {
return true;
}
}
//找不到
return false;
}
//TODO 插入元素
//找到要插入的位子
//用parent记录cur的位置
public boolean insert(int val) {
//如果root为空,我们直接创建
if(root == null) {
root = new TreeNode(val);
return true;
}
TreeNode cur = root;
TreeNode parent = null;
//如果root不为空,我们先找到要插入的位置
while (cur != null) {
if(cur.val > val) {
parent = cur;
//去左边找
cur = cur.left;
}else if(cur.val < val) {
parent = cur;
//去右边找
cur = cur.right;
}else {
//二叉搜索树不需要重复的值
return false;
}
}
//parent记录了合适的下标
TreeNode node = new TreeNode(val);
//判断插入左边还是右边
if(parent.val > val) {
parent.left = node;
}else {
parent.right = node;
}
return true;
}
//TODO 删除节点
public void remove(int key) {
//找到val,用cur来记录它的位置,parent来记录它的父节点的位置
TreeNode parent = null;
TreeNode cur = root;
while (cur != null) {
//大于根去右边,小于根去左边
if(cur.val < key){
parent = cur;
cur = cur.right;
}else if(cur.val > key) {
parent = cur;
cur = cur.left;
}else {
//进行删除
removeNode(cur,parent);
}
}
}
//cur表示当前的根结点
//parent表示它的父亲节点
//替换法进行删除
private void removeNode(TreeNode cur, TreeNode parent) {
//三种情况
if(cur.left == null) {
//当cur是root的时候
if(cur == root) {
root = cur.right;
//cur是parent的左边
}else if(cur == parent.left) {
parent.left = cur.right;
//cur是parent的右边
}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 {//左右都不为空
//cur里面的元素是左树的最大值,右树的最小值
//那么我们先找到左边的最大值[左树最右边的数)或者右边的最小值(右树左边的数)
//在上述步骤找到合适的数据之后,直接替换cur的值,然后删除那个合适的数据节点即可
//找替罪羊
TreeNode targetParent = cur;
//去cur的右边
TreeNode target = cur.right;
//去cur的右边找右边的最小值
while (target.left != null) {
targetParent = target;
target = target.left;
}
//替换掉cur的值
cur.val = target.val;
//如果taget在targetParent的右边
if(targetParent.right == target) {
targetParent.right = target.right;
//如果target在targetParent的左边(target是右边的最小值,因此是右树的最左边的位置,这个位置的左边为空
}else {
targetParent.left = target.right;
}
}
}
}
3. Java内部的Map和Set
map里面存储的是key-val键值对.比如:单词-该单词搜索的次数
set里面只存储了key.比如:快速查找某个名字在不在通讯录中
3.1 关于java中Map的各种方法的介绍
Map是一个接口类,该类没有继承自Collection ,该类中存储的是<K,V>结构的键值对,并且K一定是唯一的,不能重复.
Map.Entry<K, V> 是Map内部实现的用来存放<key, value>键值对映射关系的内部类,该内部类中主要提供了<key, value>的获取,value的设置以及Key的比较方式
3.3.1 Map的常用方法介绍
|-------------------------------------------|---------------------------------------|
| 方法 | 解释 |
| V get(Object 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的不重复集合(放到set里面) |
| Collection<V> values() | 返回value的可重复集合 |
| Set<Map.Entry<K,V>> entrySet() | 返回所有的key-value映射关系,把键值对作为value放到set里面 |
| boolean containsKey(Object key) | 判断是否包含key |
| boolean containsValue(Object value) | 判断是否包含value |
几个注意的点:
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删除掉,然后再来进行重新插入。
具体使用:
//TODO map的使用
public static void main(String[] args) {
Map<String, String> m = new TreeMap<>();
// TODO put(key, value):插入key-value的键值对
// 如果key不存在,会将key-value的键值对插入到map中,返回null
m.put("林冲", "豹子头");
m.put("鲁智深", "花和尚");
m.put("武松", "行者");
m.put("宋江", "及时雨");
String str = m.put("李逵", "黑旋风");
System.out.println(m.size());
System.out.println(m);
// put(key,value): 注意key不能为空,但是value可以为空
// key如果为空,会抛出空指针异常
//m.put(null, "花名");
str = m.put("无名", null);
System.out.println(m.size());
// put(key, value):
// 如果key存在,会使用value替换原来key所对应的value,返回旧value
str = m.put("李逵", "铁牛");
// TODO get(key): 返回key所对应的value
// 如果key存在,返回key所对应的value
// 如果key不存在,返回null
System.out.println(m.get("鲁智深"));
System.out.println(m.get("史进"));
//TODO GetOrDefault(): 如果key存在,返回与key所对应的value,如果key不存在,返回一个默认值
System.out.println(m.getOrDefault("李逵", "铁牛"));
System.out.println(m.getOrDefault("史进", "九纹龙"));
//TODO 获取map大小
System.out.println(m.size());
//TODO containKey(key):检测key是否包含在Map中,时间复杂度:O(logN)
// 按照红黑树的性质来进行查找
// 找到返回true,否则返回false
System.out.println(m.containsKey("林冲"));
System.out.println(m.containsKey("史进"));
// TODO containValue(value): 检测value是否包含在Map中,时间复杂度: O(N)
// 找到返回true,否则返回false
System.out.println(m.containsValue("豹子头"));
System.out.println(m.containsValue("九纹龙"));
// 打印所有的key
// TODO keySet()返回所有key不重复集合
for(String s : m.keySet()){
System.out.print(s + " ");
}
//或者直接这么输出
System.out.println();
System.out.println(m);
System.out.println();
// 打印所有的value
// TODO values()是将map中的value放在collect的一个集合中返回的
for(String s : m.values()){
System.out.print(s + " ");
}
System.out.println();
// 打印所有的键值对
// TODO entrySet(): 将Map中的键值对放在Set中返回了
for(Map.Entry<String, String> entry : m.entrySet()){
System.out.println(entry.getKey() + "--->" + entry.getValue());
}
System.out.println();
}
public static void main1(String[] args) {
Binarysearchtree binarysearchtree = new Binarysearchtree();
int[] array = {5,12,3,2,11,15};
for (int i = 0; i < array.length; i++) {
binarysearchtree.insert(array[i]);
}
binarysearchtree.remove(12);
System.out.println("==");
}
3.3.2 TreeMap和HashMap的区别
|---------------|---------------------------------------|-------------------------------|
| Map底层结构 | TreeMap | HashMap |
| 底层结构 | 红黑树 | 哈希桶 |
| 插入/删除/查找时间复杂度 | O(logn) | O(1) |
| 是否有序 | 关于Key有序 | 无序 |
| 线程安全 | 不安全 | 不安全 |
| 插入/删除/查找区别 | 需要对元素进行比较 | 需要通过哈希函数计算哈希地址 |
| 比较和覆盖 | key必须能够进行比较,否则会抛出ClassCastException异常 | 自定义类型需要覆盖和重写equals和hashCode方法 |
| 应用场景 | 需要Key有序的情况下 | Key是否有序无所谓,主要是搜索的更快 |
3.2 关于java中Set的各种方法介绍
set继承自Collection,set只存了key
3.2.1 Set的常用方法介绍
|----------------------------------------------------|---------------------------------------|
| 方法 | 解释 |
| 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 |
| boolean containsAll(Collection<? extends E> c>) | 集合c中的元素是否在set中全部存在,是返回true,不然就返回false |
| boolean addAll(Collection<? extends E> c) | 将集合c中的元素添加到set中,可以达到去重效果 |
几个注意的点:
Set是继承自Collection的一个接口类
Set中只存储了key,并且要求key一定要唯一
TreeSet的底层是使用Map来实现的,其使用key与Object的一个默认对象作为键值对插入到Map中的
Set最大的功能就是对集合中的元素进行去重
实现Set接口的常用类有TreeSet和HashSet,还有一个LinkedHashSet,LinkedHashSet是在HashSet的基础上维护了一个双向链表来记录元素的插入次序。
Set中的Key不能修改,如果要修改,先将原来的删除掉,然后再重新插入
TreeSet中不能插入null的key,HashSet可以
具体使用:
public static void main(String[] args) {
Set<String> s = new TreeSet<>();
// TODO add(key): 如果key不存在,则插入,返回ture
// 如果key存在,返回false
boolean isIn = s.add("apple");
s.add("orange");
s.add("peach");
s.add("banana");
//TODO 计算set的大小size()
System.out.println(s.size());
System.out.println(s);
isIn = s.add("apple");
// add(key): key如果是空,抛出空指针异常
//s.add(null);
// TODO contains(key): 如果key存在,返回true,否则返回false
System.out.println(s.contains("apple"));
System.out.println(s.contains("watermelen"));
// TODO remove(key): key存在,删除成功返回true
// key不存在,删除失败返回false
// key为空,抛出空指针异常
s.remove("apple");
System.out.println(s);
s.remove("watermelen");
System.out.println(s);
//TODO 使用迭代器去遍历
Iterator<String> it = s.iterator();
while(it.hasNext()){
System.out.print(it.next() + " ");
}
System.out.println();
//TODO toArray() 把set里面的值转换为数组进行返回,并且进行去重效果
Object[] s1 = s.toArray();
System.out.println(s1);
ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("1");
arrayList.add("1");
arrayList.add("1");
s.addAll(arrayList);
System.out.println("我是加入了arryList的set: " + s);
}
3.2.2 TreeSet和HashSet的区别
|---------------|-------------------------------------|--------------------------|
| Set底层结构 | TreeSet | HashSet |
| 底层结构 | 红黑树 | 哈希桶 |
| 插入/删除/查找时间复杂度 | O(logn) | O(1) |
| 是否有序 | 关于key有序 | 不一定有序 |
| 线程安全 | 不安全 | 不安全 |
| 插入/删除/查找区别 | 按照红黑树的特性来进行插入和删除 | 1. 先计算key哈希地址 2. 进行插入和删除 |
| 比较和覆写 | Key必须能够进行比较,否则会抛出ClassCastException | 自定义类型需要覆盖hashCode方法 |
| 应用场景 | 需要Key有序场景下 | Key是否有序并不关系,搜索速度够快 |
4. 哈希表
4.1 哈希表的引入
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( ) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立 一一映射的关系,那么在查找时通过该函数可以很快找到该元素。我们因此设计了哈希表.哈希表是一种由数组+链表+红黑树组成的数据结构.
插入元素
根据插入元素的关键码,以此函数计算出该元素的存储位置并把元素存放到这个位置上.
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,如果关键码相等,则搜索成功.
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
4.2 哈希冲突以及解决方式
当不同关键码通过同一个哈希函数计算出来同一个hash地址,这个就是哈希冲突
4.2.1 解决方式
我们无法抹除哈希冲突,冲突是比如会发生的,我们只能降低冲突率.
1. 调节负载因子
负载因子: a = 填入表中的元素个数 / 散列表的长度
根据这个公式,如果我们需要降低负载因子的大小,我们只能增加散列表的长度,不能够减小元素个数.
负载因子越大说明产生冲突的概率就越大, java内部负载因子长度为0.75,超过了这个值,就会resize散列表("扩容",但是并不是单纯就扩大数组长度,后面实现hash表的时候会具体解释)
2. 闭散列
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的"下一个" 空位置中去。下面介绍找到空位置的方法.
1> 线性探测:
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
缺点: 产生冲突的元素都聚在一起了
2> 二次探测:
(i表示冲突次数)Hi = (H0 + i^2) % m,产生冲突的元素就分散开来了
3. 开散列/哈希桶
开散列: 可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。
哈希桶: 由数组+链表+红黑树所组成的数据结构
冲突严重的解决方法
- 每个桶的背后是另一个哈希表
- 每个桶的背后是一棵搜索树
4.3 哈希桶的底层实现
1. 前置变量介绍
我们老样子,创建一个节点内部类,并且提供相应的构造方法,然后我们建立一个数组,定义负载因子,并且提供数组的默认大小.
2. 把节点放入数组
3. 进行扩容操作
array = Arrays.copyOf(array,2 * array.length);仅仅进行这样的扩容操作是否有问题呢?(面试题)
答案是肯定的,因为我们扩容之后用的是扩容后的长度进行%,因此放的位置肯定不同,因此需要冲洗hash.
步骤:
-
先产生一个新的数组(大小是原来的二倍)
-
遍历原来的数组元素(找每个下标是否连接链表)
-
根据链表元素和新的哈希函数,我们计算出要插入的新数组的位置
-
然后我们进行头插法
4. 获取元素
先确定数组在哪个位置,我们使用哈希函数来计算index的值,在数组里面找到Index位置,然后遍历在这个位置的链表,寻找我们要的元素.
总体代码:
package Map_Set.HashConfict;
import java.util.AbstractList;
import java.util.Arrays;
//java中哈希冲突的解决
//一个数组,每个数组里面每个位置放置一个链表
//哈希桶
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 static final float DEFAULT_LOADFACTOR = 0.75f;
//初始化数组
public HashBuck() {
array = new Node[10];
}
//把节点放进数组,然后一个一个串起来
public void put(int key,int val) {
//用hash函数计算对应的hash地址
int index = key % array.length;
//遍历Index下标的链表 是否存在key 存在就更新value 不存在 就进行头插法插入数据
//找到元素可能在的链表位置
Node cur = array[index];
//遍历链表
while (cur != null) {
if(cur.key == key) {
//链表里面有这个元素,我们就更新value
cur.val = val;
return;
}
cur = cur.next;
}
//如果没有找到这个元素就进行头插法
Node node = new Node(key,val);
node.next = array[index];
array[index] = node;
usedSize++;//加了之后计算负载因子
//超过0.75进行扩容
if(doLoadFactor() > DEFAULT_LOADFACTOR) {
//进行扩容
//TODO 这样扩容有没有什么问题? 扩容之后用的是扩容后的长度进行%,因此放的位置肯定不同,因此需要冲洗hash
// 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) {
//记录cur的下一个
Node tmp = cur.next;
//记录下新的数组下标
int newIndex = cur.key % newArray.length;
//采用头插法,插入到新数组的newIndex下标
cur.next = newArray[newIndex];
newArray[newIndex] = cur;
cur = tmp;
}
}
array = newArray;
}
//计算我们的负载因子
private float doLoadFactor() {
return usedSize*1.0f / array.length;
}
//获取元素
public int get(int key) {
//先确立在数组的哪个位置
int index = key % array.length;
//遍历链表寻找元素
Node cur = array[index];
while (cur != null) {
if(cur.key == key) {
return cur.val;
}
cur = cur.next;
}
//没找到
return -1;
}
}