Java 容器类(三)Set

161 阅读7分钟

Set集合

Set 接口继承自 Collection 接口,作用是实现一个不重复元素的容器。所谓不重复,就是说,当我们add操作时,如果容器内存在某个元素与之相同,那么则Add方法返回false,且元素不会加入。

HashSet

参考来源:java中的Set集合

HashSet直接继承自AbstractSet,实现了Set接口:

//HashSet.java
public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
   

在该类中,定义了这样的几个类变量:

private transient HashMap<E,Object> map;

// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();

显然,在HashSet中,采用HashMap来存储集合中的元素,我们知道HashMap通过Hash函数映射,以键值对的方式存储元素,并以一些方法来处理冲突,包括拉链法、开放定址法等等。如果HashSet采用HashMap底层来实现,那么HashSet自然而然会具有HashMap的一些性质:

1. 不保证元素的排列位置,顺序可能与添加顺序不同,可能发生变化。
2. 集合内的元素值可以为NULL
3. 可以相同的值,但是不会有相同的键。

如果要保证一个Set中元素不重复,那么最重要的就是Set.add()方法,在代码中,我们可以找到add方法:

public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

e即是我们加入的元素,以该元素为Key,PRESENT作Value加入,如果map中存在该元素为Key的键值对,则返回已经存在的元素并替换;如果不存在则插入成功,返回NULL。而 PRESENT 则是在开头定义的一个静态类变量,对HashSet添加任何元素,其内部的HashMpa中的Value一定是这个PRESENT

// Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

HashMap中在一个元素首次插入的时候,map.put返回的是null,对于add方法返回的是true,作如下测试,产生输出:

    //模拟HashSet操作的add操作
    HashMap<Integer,Object> maps = new HashMap<>();
    Object PRESENT = new Object();
    System.out.println(maps.put(1,PRESENT));
    System.out.println(maps.put(1,PRESENT));
    System.out.println(maps.put(1,PRESENT));

null
java.lang.Object@2077d4de
java.lang.Object@2077d4de

所以在add方法中,我们作插入操作,插入成功时,返回的实际上是上一个值(在HashSet中永远是PRESENT),因此,add方法可以确保插入重复值时返回NULL。由于HashSet内部采用的是HashMap作为存储结构,自然而然也是通过Object.equals()方法来判断是否重复的,对于自定义类,我们可以重写equals规则来判断是否重复元素。

//以id作为重复判断的依据
class PeopleWithOverrideEqualsFunction{
	private int id;

    public PeopleWithOverrideEqualsFunction(int id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        PeopleWithOverrideEqualsFunction people = (PeopleWithOverrideEqualsFunction) o;
        return id == people.id;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}  

LinkedHashSet

在HashMap中,有一个特殊的构造方法:

//HashSet.java Line 162
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

该方法只被LinkedHashSet所调用,并且他创建的是LinkedHashMap

LinkedHashSet的构造函数有三个参数,分别是:初始容量、装载因子、标志位。装载因子表示如果该LinkedHashSet的程度已经到达容量的散列因子数值的占用即开始扩容;而标志位是特殊标识,告诉其父类HashSet:我是LinkedHashSet。不过在HashSet中,也只有一个构造函数能给LinkedHashSet使用,函数定义如下:

HashSet(int initialCapacity, float loadFactor, boolean dummy) {
    map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

LinkedHashSet是HashSet的一个子类,LinkedHashSet采用链表来维护元素的插入次序。这样使得元素看起来是符合插入顺序保存的,所以我们遍历LinkedHashSet时,不是按照HashCode的顺序,而是按照其插入顺序进行遍历的。输出集合里的元素时,元素顺序总是与添加顺序一致。但是LinkedHashSet依然是HashSet,因此它不允许集合重复,LinkedHashSet也是按照hashCode进行安排Key的位置的。

做如下测试,自定义Number类,重写hashCode、equals函数,并采用取余法定址,查看输出:

//Number类中
class Number{
    int number;

    @Override
    public int hashCode() {
        return number % 7;
    }
    public boolean equals(Object o) {
        //default override
    }
		
	//constructor()......
}

public static void main(String[] args) {
        HashSet<Number> hashSet = new HashSet<>();
        LinkedHashSet<Number> linkedHashSet = new LinkedHashSet<>();
        for (int i = 0; i < 100; i++) {
            hashSet.add(new Number(i));
            linkedHashSet.add(new Number(i));
        }

    //按Hash值数排列
    for (Number number : hashSet) {
        System.out.println(number);
    }

    //按插入顺序排列
    for (Number number : linkedHashSet) {
        System.out.println(number);
    }
}

可以从输出中发现,HashSet的输出是按照Number % 7 == 0、1、 2、 3向后排序的。而LinkedHashSet则是按照插入的顺序输出的,并且HashMap和LinkedHashSet都是不同步的,在多线程并发下会出现问题。

TreeSet类

红黑树

参考来自:红黑树(一)之 原理和算法详细介绍 红黑树是每个节点都带有颜色属性的自平衡二叉查找树,颜色或红或黑。

红黑树的性质如下:

  1. 每个节点或黑或红
  2. 根节点一定是黑色的
  3. 每个叶子结点是黑色的(NULL节点)
  4. 如果一个节点是红色,它的子节点必须是黑色的
  5. 从一个节点到该节点的子孙节点的所有路径上,包含相同数目的黑节点

红黑树的图示如下: 显然,红黑树的各个层上,红黑色交替,所有的节点,如果其左右子树为NULL,则指向一个黑色节点。

而一般的二叉查找树中,我们并不要求其平衡,只要求(左子树<根<右子树)如果对数据:[5,4,3,2,1]建立一棵二叉搜索树,则会建立成一棵单链表形式的单边树:

为了防止这种情况的出现,自平衡的红黑树应运而生。红黑树从根到叶子的最长路径不会超过最短路径的两倍,当插入节点、删除节点时,这些既有的规则可能会被打破,于是这是就要我们在插入、删除时判定维护红黑树的形态。红黑树的调整包括旋转、变色,详细可见:漫画:什么是红黑树?

此处我们只需要知道红黑树是一种优化的二叉查找树我们只需要知道红黑树是一种优化的二叉查找树,通常情况下,在查找时具有更高的查找效率。和二叉平衡树不同,平衡二叉树的左右子树的高度差绝对值不超过1,追求整体的绝对平衡;而红黑树则追求一种大致上的平衡,其平衡算法的实现相对来说较为简单。

TreeSet

TreeSet是SortedSet接口的实现类。TreeSet可以保障集合元素处于排序状态。其内部的实现方式正是红黑树

Comparator和自然排序

在TreeSet的构造函数中,有三个重载构造函数:

//某个Collection
public TreeSet(Collection<? extends E> c) {
    this();
    addAll(c);
}
// 某个排序Set
public TreeSet(SortedSet<E> s) {
    this(s.comparator());
    addAll(s);
}
//比较器
public TreeSet(Comparator<? super E> comparator) {
    this(new TreeMap<>(comparator));
}
public TreeSet() {
    this(new TreeMap<>());
}
/* 默认不带访问修饰符的权限是:只能被所在的包中所访问 */
TreeSet(NavigableMap<E,Object> m) {
    this.m = m;
}

前两个构造函数允许通过既有集合来构造一个TreeSet,而第三个构造函数则传入了一个Comparator,这是一个比较器,用来构建自定义类的或者基础类型有特殊需求的比较关系,如果采用Integer的类型,我们作如下声明是没有问题的:

    TreeSet<Integer> treeSet = new TreeSet<>();
    for (int i = 0; i < 10; i++) {
        treeSet.add(i);
    }

    treeSet.forEach(i-> System.out.println(i));
   

因为默认构造函数调用的是:

public TreeSet(){
	this(new TreeMap<>());
}

public TreeMap() {
    comparator = null;
}

这里将Comparator置为空了,如果我们在调用add方法添加时,最终会走到这一步:

//TreeMap.java Line:783
//······
Comparator<? super K> cpr = comparator;
  if (cpr != null) {
        do {
             //执行cpr的比较的逻辑
        } while (t != null);
    } else {
        Comparable<? super K> k = (Comparable<? super K>) key;
        do {
            //执行自带的比较逻辑(0、1、2、3,即小到大)
        } while (t != null);
    }
//······

那如果我们自定义一个类,会发生什么呢?

 //Main方法
 TreeSet<Number> treeSet = new TreeSet<>();
 Number[] numbers = new Number[10];
 for (int i = 0; i < 10; i++) {
     numbers[i] = new Number(i);
     treeSet.add(numbers[i]);
 }

 treeSet.forEach(number -> System.out.println(number.toString()));

class Number{
    int number;

    public Number(int number) {
        this.number = number;
    }

    @Override
    public String toString() {
        return String.valueOf(number);
    }
}

抛出了一个异常:

class com.example.demo.Number cannot be cast to class java.lang.Comparable

我们无法将自定义的类作为一个Comparable来使用,并且在TreeSet->TreeMap中的Comparator为空。因此,我们在使用TreeSet的时候,必须保障类实现了Comparable接口,或者手动地传入了Comparator对象,两个都被定义了,取构造函数中的Comparator。

//方法一:
class Number implements Comparable<Number>{
	    int number;
    public Number(int number) {
        this.number = number;
    }
    @Override
    public String toString() {
        return String.valueOf(number);
    }
    @Override
    public int compareTo(Number o) {
        return number - o.number;
        /**
         * 如果结果为 +:则按照结果递增
         * 如果结果为 -:则按照结果递减
         */
    }
}
//方法二:
TreeSet<Number> treeSet = new TreeSet<>((o1, o2) -> o1.number - o2.number);

总的来说,通过默认构造函数只支持基本的数据类型 + 基本数据类型的封装类型。例如int、Integer、double、Double等等。如果我们传入了一个Comparator,那么该Comparator会被记录在TreeSet中的TreeMap的comparator中:

//TreeMap.java 167
public TreeMap(Comparator<? super K> comparator) {
    this.comparator = comparator;
}

如果是自定义的类实现了Comparable接口,那么会在此处获取到他的compareTo方法:

//TreeMap.java 803
 Comparable<? super K> k = (Comparable<? super K>) key;

这个key实际上就是我们需要比较的类对象(比如上文自定义的Number类)。由于这个类实现了Comparable接口,那么我们可以通过接口调用到它的compareTo方法,而TreeSet中的dowhile循环,则将数据按照指定的规则或者是自然规则(小到大)进行排序。

EnumSet类

  1. EnumSet是一个专门为枚举类设计的集合类,EnumSet中的所有元素都必须是指定枚举类型的枚举值,该枚举类型在创建EnumSet时显式或隐式地指定。EnumSet的集合元素也是有序的,
  2. EnumSet以枚举值在Enum类内的定义顺序来决定集合元素的顺序。
  3. EnumSet在内部以位向量的形式存储,这种存储形式非常紧凑、高效,因此EnumSet对象占用内存很小,而且运行效率很好。
  4. EnumSet集合不允许加入null元素。

详细可见:EnumSet详细讲解

总结

HashSet区分为普通HashSet,LinkedHashSet,前者通过HashMap来实现Set,而后者通过LinkedHashMap来实现Set,前者的迭代器输出并不按照插入顺序输出,而后者维护了一条双向链表,会记录输入的顺序。

TreeSet内部使用红黑树实现,红黑树是一种更高效率的二叉排序树,TreeSet的内部数据的顺序是有序的,除了基本的数据类型(int、double等等)和一些封装的数据类型(Integer、String等等)以外,如果要使用TreeSet,我们需要实现Comparable接口或者是传入Comparator对象,完成对比较逻辑的补充。实现的接口会在比较时按照Comparable类型进行调用;而传入的Comparator对象则会被记录在TreeSet->TreeMap下的comparator变量上。