Set体系

1.HashSet

        学习Collection接口时,记得Collection中可以存放重复元素,也可以不存放重复元素,那么我们知道List中是可以存放重复元素的。那么如果想存储不重复的元素,该如何存储呢?可以考虑使用Set体系,它里面的集合,所存储的元素就是不重复的,并且存取元素顺序不一定一致。

        在此之前,我们先来了解一下HashSet的简单使用:
        特点:
                1.不能存储重复元素
                2.存储元素的顺序和取元素的顺序不一定一致
                3.没有索引,无法通过索引操作集合中的元素

1.1.HashSet原理

        HashSet底层原理(哈希算法)
        哈希值(hashCode): 根据某种算法生成的值,在Java中一般使用对象地址值或对象属性值计算出哈希值。

        下面看代码,理解哈希值如何计算和转换:

public class Person {
    private String name;
    private int age;
    public Person(){

    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
}
public class HashSetDemo02 {
    public static void main(String[] args) {
        Person p1 = new Person("张三", 19);
        Person p2 = new Person("李四", 29);
        System.out.println(p1);
        System.out.println(p2);
        System.out.println("---------------------");
        System.out.println(p1.hashCode());//public native int hashCode();native修饰就是涉及到底层硬件的实现,相当于java调用c语言
        System.out.println(p2.hashCode());
        System.out.println("---------------------");
        System.out.println(Integer.toHexString(p1.hashCode()));
        System.out.println(Integer.toHexString(p2.hashCode()));
    }
}

        对于上述代码直接打印对象p1和p2,在没重写toString方法情况下肯定是包名.类名@十六进制值(如下图格式输出)。

        这时如果调用hashCode方法(如下图),其实会根据对象内存地址值生成十进制哈希值。这里的hashCode没有方法体,可以看到native,意思就是本地方法,非java语言写的(C语言、C++等),所以调用该方法会使用本地方法库。这里会使用即可,不做深入探究。

        对于输出的十六进制,是由Integer的方法toHexString()(看该方法名就是理解去转化为十六进制字符串)来转化的。Integer.toHexString(p1.hashCode()就可以返回p1在上面转化二来的十进制。所以下面看最后的运行结果图:

HashSet 在JDK 7及之前版本的原理

HashSet是基于哈希表的实现,那么先来看看哈希表(不懂的可以理解为数组):
        哈希表: 哈希表是一种表结构,我们可以直接根据给定的值计算出目标位置。一般使用数组构建哈希表,根据元素的哈希值计算出存储位置(目标位置)哈希值(hashCode): 根据某种算法生成的值,在Java中一般使用对象地址值或对象属性值计算出哈希值。

        下面看看基于上图HashSet是如何存储的(假设依次往HashSet里存入abc、def、abc、ghk、mvp等元素):

---->TreeSet底层用的红黑树的结构

JDK 7及之前版本:
HashSet第一次存储 abc 过程


        首先底层会创建一个长度为16的数组(如上图API),该数组作为哈希表2.根据当前元素(abc)的哈希值(利用元素的hashcode(0方法),底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引(假设为3)3.此时会判断3索引位置是否为nul (判断该位置是否存储元素),为null,直接存入abc
第二次存储def过程
        根据当前元素(def)利用hashcode(方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引(假设为1)2.此时会判断1索引位置是否为null (判断该位置是否存储元素),为null,直接存入def
第三次存储abc过程
        根据当前元素(abc),利用hashcode0方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为32.此时会判断3索引位置是否为nu,不为null,发现此位置已存储abc元素会将即将存储的abc 与 集合中已有 abc 的 哈希值 进行比较:
                abc和abc 的哈希值相同,此时再利用equals方法比较,结果也相同
                底层认为 即将存储的abc 和 集合中的 abc 是相同元素,不再存入HashSet 
第四次存储ghk过程:
        根据当前元素(ghk),利用hashcode0方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为 3
        此时会判断3索引位置是否为nu(是否已存储元素),不为null,说明该位置已有元素(abc)
                会将即将存储的 ghk 与 集合中已有 abc 的 哈希值 进行比较:
                                假设 ghk 和 abc的哈希值不同,此时不再比较 equals
                                底层认为ghk和abc是不同元素,将ghk以链表的形式存入HashSet(哈希表)
第五次存储mvp过程:

        1.根据当前元素(mvp),利用hashcode(方法获取哈希值,底层会根据哈希值 再结合 某种算法 计算出存储到数组中的索引为3
        2.此时会判断3索引位置是否为nul(是否已存储元素),不为nul,说明该位置已有元素(abc)
由于3位置已经有元素,而且形成了链表结构,那么myp 会与链表上元素逐个比较(假设满足b情况)
                a.如果myp与链表上的abc比较发现哈希值不同
                        此时再去比较链表上的ghk发现哈希值也不同=>认为是不同元素=>存入
                b.如果mvp 与链表上的abc比较发现哈希值相同,equals不同
                        此时再去比较链表上的ghk发现哈希值相同,equals不同 =>认为是不同元素 =>存入
                c.如果mvp 与链表上的abc比较发现哈希值相同,equals相同
                        此时说明mvp和abc是相同元素,不再比较其它元素>不存
        总结: 1.新添加元素 与 集合中已有元素进行比较:
                a.如果新添加的元素与集合中已有的元素的哈希值 均不相同 =>存
                b.如果新添加的元素与集合中已有的元素的哈希值 相同
                                此时再去比较equals =>equals一旦相同=>说明该元素重复=>不存
                                                                     equals均不相同 =>存

HashSet 在JDK 8及以后版本的原理

JDK 8及以后版本 底层存储原理 和JDK 7及之前版本 相同,但是底层数据结构上做了一些优化:底层存储原理:
        如果新添加的元素 与 集合中已有元素 哈希值不同 =>存
        如果新添加的元素 与 集合中已有元素 哈希值相同
                此时再去比较equals=>equals一日有相同=>不存
                                      =>equals均不相同 =>存

JDK 8及以后版本 不同点:

         1.如果链表上元素个数>=8时候,将链表转换成 一种 树形结构 (底层使用的是红黑树)为了提高查找效率(减少比较次数)
         2.如果链表上的元素<8 依然保持链表结构

HashSet扩容思想

        在理解HashSet的扩容思想之前,先看一下加载因子

        加载因子:  加载因子 = 填入表中的元素个数 / 哈希表的长度

        加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
        加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数 

        HashSet底层的 加载因子是 0.75 是 冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷
        也就是说当哈希表中存储了 16* 0.75=12个元素的时候, 哈希表就会首次扩容为原先的两倍,即32  ,然后将老表中的元素重新存入新表中。

        对于HashSet的存储以及达到扩容的过程,可以理解下面的核心源码:

JDK21中HashSet核心源码分析:

//添加元素的源码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;

        //1.如果哈希表为空,则造表(初始容量为16) 
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
    
        //2.存取的元素利用哈希值计算的索引位置 没有元素直接存入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else { //3.else说明该位置有元素
            Node<K,V> e; K k;

            //4.如果存取的元素的哈希值与该位置第一个元素的哈希值相同并且equals也相同,不存
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode) //5.存取的元素的哈希值与该位置第一个元素
                                            //哈希值不同 或者 哈希值相同,equals不同
                                            //该条件为真,说明原来的链表已经树化,将该元素加入树中
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                //6.走到这里,说明没有树化,依然是链表结构
                //存储的元素与该索引位置的第一个元素已经比较过(哈希值不同 或者 哈希值相同,equals不同)
                //下面的for循环,就是将存储的元素与链表上剩余的元素逐个比较
                // 比较过程中,一旦出现哈希值相同,equals也相同,不存
                // 与所有的元素 哈希值全都不同 存
                // 与所有的元素 哈希值相同,equals不同 也存
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) 
                            treeifyBin(tab, hash);
                        break;
                    }
                    
                    //比较过程中,一旦出现哈希值相同,equals也相同,就会终止比较
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
        }
   
    }


1.2.HashSet存储自定义对象

        使用默认hashCode()与equals()方法
        1.自定义引用类型使用默认的hashCode和equals方法
                hashCode(): 默认是根据对象的内存地址值生成哈希值, 不同对象生成的哈希值不同
                equals(): 默认比较两个对象的地址值,如果两个对象地址值相同返回true,否则返回false

下面来验证一下,上面的两个方法是如何被调用的:

public class Person{
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

    /**
     * 为了查看hashCode方法被调用
     * @return
     */
    @Override
    public int hashCode() {
        System.out.println("hashCode方法被调用..."+this.name+" "+super.hashCode());
        return super.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        System.out.println("equals方法被调用..."+this.name+" "+((Person)obj).name);
        return super.equals(obj);
    }
}

public class HashSetDemo01 {
    public static void main(String[] args) {
        HashSet<Person> people = new HashSet<>();
        Person p1 = new Person("张三", 19);
        Person p2 = new Person("张三", 19);
        Person p3 = new Person("王五", 22);

        people.add(p1);
        people.add(p2);
        people.add(p3);

        System.out.println(people);
    }
}

     HashSet保证元素唯一的原理:
         如果新添加的元素与集合中已有的元素哈希值均不相同 =>存
         如果新添加的元素与集合中已有的元素哈希值相同
                此时再去比较equals => 一旦有equals相同 =>不存
                             => 与equals比较均不相同=>存

根据这个存储原理,很容易推断出people中的元素:

       由于equals和hashCode我们并没有重写,而是只是为了方便查看加了一条被调用的输出语句。所以显然后面打印的十进制哈希值不一样,那就直接存入。

        如果把上面的HashCode重写:

    @Override
    public int hashCode() {
        System.out.println("hashCode方法被调用..."+this.name);
        return 0;
    }

        那么这样无论谁调用HashCode方法返回的哈希值都为0,即哈希值都一样,再来看看运行结果:

        第一次存入p1,HashSet为空,直接存入
        第二次存入p2, p1.hashCode()和 p2.hashCode()比较相同
                再去比较equals =>p2.equals(p1)=>比较的是对象地址值=>equals返回false=>存入
         第三次
             1.存入的p3先和p1比较
                p3.hashCode()和 p1.hashCode()比较相同
                再去比较equals =>p3.equals(p1)=>比较的是对象地址值=>equals返回false
             2.存入的p3再和p2比较
                p3.hashCode()和 p2.hashCode()比较相同
                再去比较equals =>p3.equals(p2) =>比较的是对象地址值=>equals返回false
                p3和p1,p2比较,哈希值相同,但equals均不相同,认为是不同元素 存入

        既然哈希值都一样,那么在存入第二个对象时,就会进一步通过equals()方法挨个比较是否存在完全相同的元素,由于equals方法我们也没重写,所以此时利用equals比较的时候,就是比较地址值,p1和p2虽然内容相同,但是地址值不一样,还是存入了。因此,要是把equals重写掉(比较内容而不是地址值),那就能确定HashSet存入不重复的元素了,这也就是上面说的:HashSet保证元素唯一的原理:
        使用hashCode()与equals()方法来确保存入的元素不重复!!!根据实际存入的数据类型情况来修改方法来确保元素的唯一性!

下面来重写hashCode()与equals()方法

自定义引用类型使用默认的hashCode和equals方法
        hashCode(): 默认是根据对象的内存地址值生成哈希值, 不同对象生成的哈希值不同
        equals(): 默认比较两个对象的地址值,如果两个对象地址值相同返回true,否则返回false

还是依据上面的Person类,来重写:

    @Override
    public final boolean equals(Object object) {
        if (this == object) return true;
        if (!(object instanceof Person person)) return false;

        return age == person.age && name.equals(person.name);
    }

    /**
     * 通过属性值来计算哈希值
     * @return
     */
    @Override
    public int hashCode() {
        int result = name.hashCode();
        result = 31 * result + age;
        return result;
    }

这样一来,就可以达到去重元素了,运行结果:

      上图的运行过程可以理解为:  

        第一次存入p1,HashSet为空,直接存入
        第二次存入p2, p1.hashCode()和 p2.hashCode()比较相同
                再去比较equals =>p2.equals(p1)=>比较的是各个属性值=>equals返回true=>不存
        第三次
                1.存入的p3先和p1比较
                p3.hashCode()和 p1.hashCode()不同=>直接存入

        也就是说此时p1和p2比较的就是内容了,不再比较地址值,既然内容一样,p2就在对比发现hashCode值一样,equals比较内容也一样,认为是相同元素,不再存入。


2.TreeSet

2.1.TreeSet使用

TreeSet也是Set体系的一员,具有以下特点:

        1.不包含重复元素
        2.无带索引的方法
        3.可以将元素按照一定规则排序(与HashSet最大区别)

public class TreeSetDemo01 {
    public static void main(String[] args) {
        TreeSet<Integer> ts = new TreeSet<>();
        ts.add(12);//new Integer(12)
        ts.add(2);
        ts.add(5);
        ts.add(7);
        ts.add(7);
        System.out.println(ts);
    }
}

运行结果:

        显然,ts中的元素从小到大被存入,而且重复的存不进去。这就要说---->存入TreeSet的元素需要指定比较规则:自然排序比较器排序.

2.2.TreeSet自然排序规则

自然排序规则:
        1.存入集合的元素需要实现Comparable接口
        2.重写上面接口中的public int compareTo(T o)方法

可以看一下Integer中的compareTo方法的API:

        compareTo并没有直接排序,而是又调用了compare方法,如下图:


自然排序规则及简单原理:
        例如:Integer实现了Comparable接口,重写public int compareTo(T o)方法,代表Integer可比较
        3.compareTo方法决定了TreeSet中存入元素的顺序,简单原理如下:
               a.如果compareTo方法返回负数,说明新添加的元素比集合中的元素小, 存左边
               b.如果compareTo方法返回0, 说明新添加的元素与集合中的元素相同, 不存
               c.如果compareTo方法返回正数,说明新添加的元素比集合中的元素大, 存右边


2.3.TreeSet比较器排序规则
 

比较器排序规则及简单原理:
        需求: 如果想对TreeSet中存入的Integer实现从大到小排序?
        由于Integer类是JDK提供的,别人写好的,我们无法修改其中的compareTo方法,也就是无法修改自然排序规则
        这时候我们就需要利用比较器Comparator(接口)进行排序


        查API可以看到,TreeSet可以传入一个比较器,即比较器的使用:一般通过TreeSet的构造方法来使用比较器,一旦传入比较器,就不再按照自然排序规则比较


 compare方法决定了TreeSet中存入元素的顺序,简单原理如下:
        a.如果compare方法返回负数,说明新添加的元素比集合中的元素小, 存左边
        b.如果compare方法返回0, 说明新添加的元素与集合中的元素相同, 不存
        c.如果compare方法返回正数,说明新添加的元素比集合中的元素大, 存右边

        比较器排序也就是去继承Comparator接口,方法内定义重写规则,o1-o2是默认的从小到大,那换成o2-o1就可以实现从大到小了。

class Sort implements Comparator<Integer>{
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2-o1;
    }
}

public class TreeSetDemo01  {
    public static void main(String[] args) {
        TreeSet<Integer> ts = new TreeSet<>(new Sort());
        ts.add(12);//new Integer(12)
        ts.add(2);
        ts.add(5);
        ts.add(7);
        ts.add(7);
        System.out.println(ts);//[12, 7, 5, 2]
    }
}

可以这样写(上面),但是我们一般不这样写,而是这样写(下面):

public class TreeSetDemo02{
    public static void main(String[] args) {
        TreeSet<Integer> ts = new TreeSet<>(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2-o1;
            }
        });
        ts.add(5);//new Integer(5)
        ts.add(7);
        ts.add(5);
        ts.add(1);
        ts.add(3);
        System.out.println(ts);
    }
}

运行结果图:

2.4.自定义类使用自然排序规则
 

        还是用Person类举例:

public class Person implements Comparable<Person> {
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    /**
     * 如果只使用年龄比较,可能出现
     *   两个人姓名不同,但年龄相同 compareTo方法返回0
     *   导致另外一人无法存入TreeSet,与现实业务违背
     * @return
     */
    @Override
    public int compareTo(Person o) {
        return this.age - o.age;
    }
}

        显然如果仅仅是像上面的利用compareTo比较年龄来确定是不是重复元素,那现实中名字不同,年龄相同的大有人在,不能说年龄只要一样就直接判定为相同元素而放弃存入集合,显然和实际情况不符合(这里不再代码演示),所以重写compareTo方法:

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

        先按照年龄比较,如果年龄相同,再按照姓名比较
                年龄:主要比较条件
                姓名:次要比较条件 姓名对于英文字符来说默认按照字典顺序排序,如果是中文字符按照unicode码表比较,在码表前面的字符认为小字符,在码表后面的就认为大字符,进行排序。

        那根据重写后的compareTo方法,再来验证一下,相同年龄的也能存入:

public class TreeSetDemo01 {
    public static void main(String[] args) {
        TreeSet<Person> people = new TreeSet<>();
        Person p1 = new Person("张三", 18);
        Person p2 = new Person("李四", 18);
        Person p3 = new Person("王二", 20);
        people.add(p1);
        people.add(p2);
        people.add(p3);
        System.out.println(people);
    }
}

运行结果:

2.5.自定义类使用比较器规则

        把用到的Person类再写一下:

public class Person{
    private String name;
    private int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public int getAge() {
        return age;
    }
    public void setAge(int age) {
        this.age = age;
    }
}

        其实利用比较器来实现,原理和自然排序规则类似,不难理解,直接上代码:
 

public class TreeSetDemo02 {
    public static void main(String[] args) {
        TreeSet<Person> people = new TreeSet<>(new Comparator<Person>() {
            @Override
            public int compare(Person o1, Person o2) {
                return o1.getAge()== o2.getAge()?(o1.getName().compareTo(o2.getName())):o1.getAge()- o2.getAge();
            }
        });

        Person p1 = new Person("zhangsan", 29);
        Person p2 = new Person("lisi", 24);
        Person p3 = new Person("wanger", 24);
        people.add(p1);
        people.add(p2);
        people.add(p3);
        System.out.println(people);
    }
}

运行结果:

        结果图也和我们意料中的一样,就是实现不重复元素的存入,重要的就是判断何为重复元素,在这里就是属性名和属性年龄都一样的情况。单一的名或年龄一样并不能判断出属于同一个元素。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值