Map 和 Set

目录

一. 二叉搜索树

1.1 搜索树的概念

1.2 搜索树的实现

1.2.1 查找

 1.2.2 插入

 1.2.3 删除

1.3 性能分析

二. Map和Set

2.1 搜索模型

2.2 Map的使用

 2.2.1 Map的常用方法

2.3 Set的使用

 2.3.1 Set的常用方法

三. 哈希表

3.1 哈希表的概念

3.2 哈希冲突

 3.2.1 哈希冲突的预防

 3.2.2 哈希冲突的解决-----开散列和闭散列

 3.3 哈希表的实现

 四. Map和Set续

4.1 部分源码剖析

 4.2 Hash和Tree的对比


本节目标

1. 掌握二叉搜索树的原理和实现

2.实现类 HashMap/TreeMap/HashSet/TreeSet 的使用
3. 掌握 HashMap HashSet 背后的数据结构哈希表的原理和简单实现

一. 二叉搜索树

1.1 搜索树的概念

二叉搜索树又称二叉排序树,它或者是一棵空树,或者是具有以下性质的二叉树 :
  • 若它的左子树不为空,则左子树上所有节点的值都小于根节点的值
  • 若它的右子树不为空,则右子树上所有节点的值都大于根节点的值
  • 它的左右子树也分别为二叉搜索树

1.2 搜索树的实现

1.2.1 查找

二叉搜索树-- 顾名思义,它的产生就是为了便于查找.

基于二叉搜索树的性质,我们的查找策略如下:

  • 根节点的val比要查找的元素key小-->查找根节点的右子树
  • 根节点的val比要查找的元素key大-->查找根节点的左子树
  • 根节点的val==要查找的元素key-->返回当前的根节点

思路其实还是很简单滴 ,不管用递归还是迭代都可以解决

public class SearchTree {
    TreeNode root=null;
    class TreeNode{
        int val;
        TreeNode left;
        TreeNode right;
        public TreeNode(int val){
            this.val=val;
        }
    }

    TreeNode search(int key){//使用迭代法
        TreeNode cur=this.root;//不能直接使用root遍历,会让root丢失原本的只
        while(cur!=null){
            if(cur.val>key){
                cur=cur.left;
            }else if(cur.val<key){
                cur=cur.right;
            }else {
                return cur;
            }
        }
        return null;//没有找到,返回null
    }

    TreeNode non_search(TreeNode root,int key){//使用递归
        if(root==null) return null;
        if(root.val==key) return root;
        if(root.val<key) return non_search(root.right,key);
        return non_search(root.left,key);
    }
}

 1.2.2 插入

搜索树的插入比较简单--每个新生成的结点都可以插入到叶子结点上

和搜索树的查找策略是一样的

  • 根节点的val比要插入的元素key小-->插入到根节点的右子树
  • 根节点的val比要查找的元素key大-->插入到根节点的左子树
  • 根节点的val==要查找的元素key-->不能插入该元素,返回false

同样给出递归和迭代两种代码:

boolean insert(int key){//迭代法插入
        TreeNode parent=null;
        TreeNode cur=this.root;
        while(cur!=null){
            parent=cur;//记录当前结点的父节点,方便插入
            if(cur.val<key){
                cur=cur.right;
            }else if(cur.val>key){
                cur=cur.left;
            }else {
                return false;
            }
        }
        TreeNode newNode=new TreeNode(key);
        if(this.root==null) this.root=newNode;
        else if(parent.val<key){
            parent.right=newNode;
        }else {
            parent.left=newNode;
        }
        return true;
    }

    TreeNode non_insert(TreeNode root,int key){//递归插入
        if(root==null) {
            TreeNode newNode=new TreeNode(key);
            if(this.root==null){
                this.root=newNode;//如果当前搜索树的根节点是null,需要更新root的值
            }
            return newNode;
        }

        if(root.val<key){
            root.right=non_insert(root.right,key);
        }else if(root.val>key){
            root.left=non_insert(root.left,key);
        }else {
            return null;
        }
        return root;
    }

 1.2.3 删除

在删除某个元素前同样需要先找到该元素,同时要记录该元素的父节点,方便删除

boolean delete(int key){
        TreeNode cur=root;
        TreeNode parent=null;
        while(cur!=null){
            if(cur.val<key){
                parent=cur;
                cur=cur.right;//当前根节点比要删除的元素小,往当前结点的右子树找
            }else if(cur.val>key){
                parent=cur;
                cur=cur.left;//当前根节点比要删除的元素大,往当前结点的左子树找
            }else {
                break;//找到该节点了
            }
        }
        //......
    }

 下面我们来看删除的逻辑:

 1.要删除的结点没有左子树或者没有右子树(叶子结点也包括在内)

比如:

要删除6(没有右子树),就让6的左节点代替

要删除25(没有左子树),就让25的右节点代替

 2.要删除的结点既有右子树又有左子树

比如:

要删除结点9-->肯定是不能直接删除的,因为会株连九族~~

这时候我们就需要找一个替罪羊-->让左子树最右边的结点/右子树最左边的结点来代替9,然后删除该节点

来分析一下这个替代法删除的可行性:

用左子树最右边的结点--6覆盖9,然后在左子树中删除6这个结点,因为是左子树的最右节点,没有左子树,所以可以按照第一个情况删除

同时我们可以发现6的左子树都比6小,6的右子树都比6大

使用右子树的最左边结点10来代替9也可以,自己论证吧~

 下面我们继续写删除部分的代码

boolean delete(int key){
        TreeNode cur=root;
        TreeNode parent=null;
        while(cur!=null){
            if(cur.val<key){
                parent=cur;
                cur=cur.right;//当前根节点比要删除的元素小,往当前结点的右子树找
            }else if(cur.val>key){
                parent=cur;
                cur=cur.left;//当前根节点比要删除的元素大,往当前结点的左子树找
            }else {
                break;//找到该节点了
            }
        }
        if(cur==null) return false;//没有找到要删除的结点,返回false
        if(cur.left==null){//要删除的结点没有左子树
            if(parent!=null){//要删除的结点如果是this.root,parent仍等于null
                if(cur==parent.right){
                    parent.right=cur.right;
                }else {
                    parent.left=cur.right;
                }
            }

        }else if(cur.right==null){//要删除的结点没有右子树
            if(parent!=null){
                if(cur==parent.left){
                    parent.left=cur.left;
                }else {
                    parent.right=cur.left;
                }
            }
        }else {
            TreeNode sub=cur.right;//找右子树最左边的结点
            TreeNode subP=cur;//记录要代替的结点的父节点,方便删除该节点
            while(sub.left!=null){
                subP=sub;
                sub=sub.left;
            }
            cur.val=sub.val;
            if(sub==subP.left){
                subP.left=null;
            }else {
                subP.right=null;
            }
        }
        return true;
    }

1.3 性能分析

插入和删除操作都必须先查找,查找效率代表了二叉搜索树中各个操作的性能。
对有 n 个结点的二叉搜索树,若每个元素查找的概率相等,则二叉搜索树平均查找长度是结点在二叉搜索树的深度的函数,即结点越深,则比较次数越多。
但对于同一个关键码集合,如果各关键码插入的次序不同,可能得到不同结构的二叉搜索树:

 最优情况下,二叉搜索树为完全二叉树,平均比较次数为log2N

最坏情况下,二叉搜索树退化为单支树,平均比较次数为N

可以看到,当二叉搜索树是单支树时效率最低,能否避免这种情况?

可以使用AVL树和红黑树,有关这两种树的内容以后会更新~~

二. Map和Set

2.1 搜索模型

一般把搜索的数据称为关键字( Key ),和关键字对应的称为值(Value),将其称之为 Key-value 的键值对,搜索模型会有两种:
1. key 模型
  • 有一个英文词典,快速查找一个单词是否在词典中
  • 快速查找某个名字在不在通讯录中
2. Key-Value 模型
  • 查找某个英文单词的中文释义
  • 梁山好汉的江湖绰号:每个好汉都有自己的江湖绰号
Map 中存储的就是 key-value 的键值对, Set 中只存储了 Key

2.2 Map的使用

Map是一个接口,要用TreeMap和HashMap来实例化,TreeMap的底层是用红黑树实现的,HashMap底层是用哈希表实现的

Map内存储的是<k,v>键值对,并且要求k是唯一的

 2.2.1 Map的常用方法

Map中设置了一个内部类Entry用来存储K-V键值对

 这个内部类提供了方法,用来得到key和value及更改value的值

方法
说明
K getKey ()
返回 entry 中的 key
V getValue ()
返回 entry 中的 value
V setValue(V value)
将键值对中的value替换为指定value

下面是Map的常见方法
方法说明
V get (Object key)
返回 key 对应的 value
V getOrDefault (Object key, V defaultValue)
返回 key 对应的 value key 不存在,返回默认值
V put (K key, V value)
设置 key 对应的 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

注意:

  • TreeMap底层是红黑树,使用TreeMap实现Map接口时,Key类对象必须可比较(实现Comparable接口或者有比较类对象),并且Key不能为null
//使用Comparable接口
class Person implements Comparable<Person>{
    int age;
    public Person(int age){
        this.age=age;
    }

    @Override
    public int compareTo(Person o) {
        return this.age-o.age;
    }
}

TreeMap<Person,Integer> map=new TreeMap<>();


//使用Comparator接口
class Person {
    int age;
    public Person(int age){
        this.age=age;
    }
}
//创建Person的比较类
class AgeComp implements Comparator<Person>{
    @Override
    public int compare(Person o1, Person o2) {
        return o1.age-o2.age;
    }
}

TreeMap<Person,Integer> map=new TreeMap<>(new AgeComp());//使用Comparator对象初始化TreeMap对象
  • Map中的Key是唯一的,Value是可以重复的
  • Map中的Key不可以修改,只能先删除然后重新插入修改Value使用Entry提供的setValue方法

2.3 Set的使用

 Set和Map的不同点是Set是纯Key搜索模型

Set和Map一样,都是接口,需要TreeSet或HashSet实例化

 2.3.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
Object[] toArray()
set 中的元素转换为数组返回
boolean containsAll(Collection<?> c)
集合 c 中的元素是否在 set 中全部存在,是返回 true ,否则返回 false
boolean addAll(Collection<? extends E> c)
将集合c中的元素添加到set中,可以达到去重的效果

注意:

  • Set 中只存储了 key ,并且要求 key 一定要唯一
  • Set 的底层是使用 Map 来实现的,其使用 key Object 的一个默认对象作为键值对插入到 Map 中的
TreeSet同

  

  • Set最大的功能就是对集合中的元素进行去重
  • 实现 Set 接口的常用类有 TreeSet HashSet ,还有一个 LinkedHashSet LinkedHashSet 是在 HashSet的基础上维护了一个双向链表来记录元素的插入次序
  • Set 中的 Key 不能修改,如果要修改,先将原来的删除掉,然后再重新插入
  • TreeSet 中不能插入 null key HashSet 可以

三. 哈希表

TreeMap和TreeSet的底层是红黑树--比搜索树效率更高的树型结构 

HashMap和HashSet的底层是哈希表,下面一起来了解一下吧

3.1 哈希表的概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中为树的高度,即 O( logN) ,搜索的效率取决于搜索过程中 元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素
当向该结构中:
插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
举例:
有一组数据集合{1,2,4,7,10}, 哈希函数设置为: hash(key) = key % capacity ; capacity 为存储元素底层空间总的大小
该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称为哈希表 (Hash  Table)( 或者称散列表 )

3.2 哈希冲突

不同的key通过哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
把具有不同关键码而具有相同哈希地址的数据元素称为 同义词
比如,
向刚才的哈希表插入元素11,就会发生哈希冲突

 3.2.1 哈希冲突的预防

哈希冲突是无法避免的,我们要做的就是降低冲突率

引起哈希冲突的一个原因是哈希函数,我们要尽可能设计出一个合理的哈希函数,使数据均匀分布

常见的哈希函数有以下几种

1. 直接定制法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况
下面给出一道练习题: 第一个只出现一次的字符
//解题思路:先遍历字符串s,让每个字符和它的次数建立映射关系;再遍历一遍字符串s,如果当前遍历的字符出现次数为1,返回该字符
class Solution {
    public int firstUniqChar(String s) {
        int[] arr=new int[26];
        for(int i=0;i<s.length();i++){
           int index=s.charAt(i)-'a';
           arr[index]++;
        }
        for(int i=0;i<s.length();i++){
            int index=s.charAt(i)-'a';
            if(arr[index]==1) {
                return i;
            }
        }
        return -1;
    }
}

2. 除留余数法--(常用)
设散列表中允许的 地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数: Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3. 平方取中法--(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对 它平方就是18671041,抽取中间的3位671(或710)作为哈希地址
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况


引起哈希冲突的另一个重要原因就是负载因子:

 

 HashMap/Set中默认设置的负载因子是0.75

HashSet实例在初始化时也可以传入负载因子

 3.2.2 哈希冲突的解决-----开散列和闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以 key 存放到冲突位置中的 下一个 空位置中去。 那如何寻找下一个空位置呢?

 1.线性探测

步骤:

  • 先用哈希函数计算出要插入的位置
  • 2.如果当前位置为空,直接插入;如果不为空,从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止
比如刚才要插入的元素11

 但是这个方法有个缺点,如果还想插入21,31,......,就要占据后面的空位置,这时候如果想插入元素3,5,..就不得不往后面一直找空位置,当插入的元素发生冲突时,容易"扎堆"出现,并且这只是一种缓兵之计--后面再插入元素时拥堵

2.二次探测

 步骤:

  • 先用哈希函数计算出要插入的位置index0
  • 如果当前位置为空,直接插入;如果不为空,找下一个空位置的方法为:indexi=(index0+i^2 )% capacity(i=1,2...)
     

再拿11来举例

先计算出index0=1,发现位置有冲突,此时i为初始值1

index1=1+i^2=2,看下标为2的位置有没有冲突,发现有元素

i++,index2=1+i^2=5,下标为5的位置没有冲突,放入11

  可以发现,闭散列法的空间利用率比较低

于是又引入了开散列~

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子 集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

 那么元素11就可以这样插入~

 3.3 哈希表的实现

现在我们来实现一个Key-Value类型的哈希表

public class HashBuck<K,V> {
    //定义内部类,相当于Map里的Entry类
    class Node{
        public K key;
        public V value;
        Node next;

        public Node(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }
    //哈希表的底层是一个数组
    private Object[] elems=new Object[10];
    private int usedSize=0;
    private final float factor=0.75f;
    //插入元素
    public V put(K key,V value){
        Node newNode=new Node(key,value);
        int hash=key.hashCode();//key类要重写hashCode方法或者默认使用Object父类方法
        int index=hash% elems.length;
        Node cur=(Node)elems[index];
        if(cur==null) {
            elems[index]=newNode;//key要重写equal方法或者默认使用Object父类方法
            usedSize++;
            if(calFac()>factor) {
                resize();
            }
            return value;
        }
        while(cur.next!=null){
            if(cur.key.equals(key)){//这里的开散列使用尾插法
                cur.value=value;
                return value;
            }
            cur=cur.next;
        }
        cur.next=newNode;
        usedSize++;
        if(calFac()>factor) {
            resize();
        }
        return value;
    }

    private void resize(){
        Object[] newElems=new Object[elems.length*2];
        for (int i = 0; i < elems.length; i++) {
            Node cur=(Node)elems[i];
            while(cur!=null){
                Node next=cur.next;
                cur.next=(Node)newElems[cur.key.hashCode()% newElems.length];
                newElems[cur.key.hashCode()% newElems.length]=cur;
                cur=next;
            }
        }
        elems=newElems;
    }
    private float calFac(){
        return (float) (usedSize*1.0/ elems.length);

    }
}

从上面哈希表的简单实现中我们可以看到,自定义类型最好重写HashCode方法和equal方法

 四. Map和Set续

4.1 部分源码剖析

下面来分析一下有关HashMap和HashSet内部的细节

先来看插入,因为在Java中,Set复用了Map的很多方法,下面我们主要研究Map

先来看看Map主要的的成员变量:

再来看看Map的初始化:

 可以发现,构造方法并没有对table变量初始化,这时候还没有给哈希表分配内存

一起来看看插入第一个元素时Map是怎么做的吧:

 可以看到,Map是在第一次插入元素时进行了哈希表的初始化,默认容量是16,之后每次都按二倍扩容

 仔细看put方法,发现新元素插入的位置是(n-1)&hash,这是采用了除留余数法,相当于hash%capacity,但前提是n必须是2的k次幂

这里肯定有同学会问:Map的构造方法中不是可以传入容量吗,如果我传入的容量不是2的k次幂咋整?

其实源码悄悄来了个狸猫换太子~

 上面的英文解释大意就是把容量变为2的k次幂

HashMap中除了有Node类,还有TreeNode类...

 这是因为当哈希表的一个元素上挂的结点太多时,也不便于查找,这个结点就会自动变成红黑树,这个临界点就是8

 4.2 Hash和Tree的对比

我们以Map为例

TreeMapHashMap
底层结构红黑树哈希表
线程安全不安全不安全
性能O(logN)O(1)
是否有序关于Key有序

无序

插入/查找/删除逻辑按照红黑树进行增删查改

1.根据哈希函数计算插入地址

2.进行增删查改

Key能否为null不能为null,会抛NullPointerException可以为null
对Key的要求必须实现Comparable接口,或者传入Comparator对象需要重写hashCode和equals方法
引用场景适用于key有序的场景下适用于对空间要求不高,对性能要求高的场景

还有一个遗留的问题,既然HashMap的某个链表在元素超过8 时会变成红黑树,红黑树的key必须可以比较,那么是根据什么来比较的呢?

不卖关子了~是根据key的哈希值比较的

 end~~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不 会敲代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值