Bootstrap

Java集合框架-Set和Map

1. Set集合的使用

1.1. Set集合类型

  • Set:无序,唯一
  • HashSet
    • 采用Hashtable哈希表存储结构
    • 优点:添加、查询、删除速度快
    • 缺点:无序
  • LinkedHashSet
    • 采用哈希表存储结构,同时使用链表维护次序
    • 有序(添加顺序)
      在这里插入图片描述在这里插入图片描述
  • TreeSet
    • 采用二叉树(红黑树)的存储结构
    • 优点:有序,查询速度比List快(按照内容查询)
    • 缺点:查询速度没有HashSet快
      在这里插入图片描述

1.2. 使用各种Set集合类存储课程名称

public class TestSet1 {
    public static void main(String[] args) {
        // 创建一个集合set对象
        // Set<String> set = new HashSet<String>();
        // Set<String> set = new LinkedHashSet<String>();
        Set<String> set = new TreeSet<String>();
        set.add(new String("语文"));
        set.add("数学");
        System.out.println(set.size()); // 2
        System.out.println(set); // [wyb, xz]
        // 不可以使用for循环遍历set
        for (int i = 0; i < set.size(); i++) {
            // set.get(i);
        }
        // 支持增强的for循环,支持iterator
        Iterator<String> it = set.iterator();
        while (it.hasNext()) {
            System.out.println(it.next()); // 语文 数学
        }
    }
}
  • 总结
    • HashSet哈希表,唯一、无序
    • LinkedHashSet:哈希表+链表,唯一、有序(添加顺序)
    • TreeSet:红黑树,一种二叉平衡树,唯一、有序(自然顺序)
    • List针对Collection增加了一些关于索引位置操作的方法:get(i)、add(i, elem)、remove(i)、set(i, elem)
    • Set是无序的,不可能提供关于索引位置操作的方法,set针对Collection没有增加任何方法
    • List的遍历有三种方式:for循环、for-each循环、Iterator迭代器
    • Set的遍历方式有两种:for-each循环、Iterator迭代器

1.3. 使用各种Set存储自定义学生信息

public class TestSet2 {
    public static void main(String[] args) {
        // 创建一个集合set对象
        // Set<Student> set = new TreeSet<Student>();
        // Set<Student> set = new HashSet<Student>();
        Set<Student> set = new LinkedHashSet<Student>();
        Student stu = new Student(1, "wyb", "男", 59);
        Student stu1 = new Student(1, "xz", "男", 98);
        Student stu2 = new Student(1, "wyb", "男", 59);
        set.add(stu);
        set.add(stu1);
        set.add(stu2);
        System.out.println(set.size()); // 3
        System.out.println(set); 
    }
}

Q1: 为什么HashSet、LinkedHashSet String重复,会保持唯一,而Student有重复,不会保持唯一
A1: HashSet、LinkedHashSet需要Student实现hashCode()和equals()
Q2:为什么TreeSet可以添加String,不能添加Student,会抛出异常java.lang.ClassCastException: class com.wyb.staticcontent.Student cannot be cast to class java.lang.Comparable
A2:TreeSet需要Student实现Comparable接口并指定比较的规则

1.4. 让各种Set可以存储自定义类型的对象Student

public class Student implements Comparable<Student> {
    private int sno;
    private String name;
    private int age;
    private double score;
    @Override
    public int compareTo(Student o) {
        return -(this.sno - o.sno);
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return sno == student.sno && age == student.age && Double.compare(score, student.score) == 0 && Objects.equals(name, student.name);
    }
    @Override
    public int hashCode() {
        return Objects.hash(sno, name, age, score);
    }
}

2. Set集合的原理

2.1. 外部比较器Comparator的作用和使用

内部比较器Comparable只有一个,如果希望指定多种比较的规则,可以定义多个外部比较器,定义额外的类实现Comparator接口

2.1.1. 定义外部比较器,按照分数升序排列

public class StudentScoreComparator implements Comparator<Student>{
    @Override
    public int compare(Student stu1, Student stu2) {
        if(stu1.getScore() > stu2.getScore()) return 1;
        else if(stu1.getScore() < stu2.getScore()) return -1;
        else return 0;
    }
}

2.1.2. 定义外部比较器:按照姓名逆序排序,如姓名相同,按学号逆序排列

public class StudentNameDescComparator implements Comparable<Student>{
    @Override
    public int compare(Student stu1, Student stu2) {
        int n = stu1.getName().compareTo(stu2.getName());
        if(n != 0) return n;
        else return -(stu1.getSno() - stu2.getSno());
    }
}

2.1.3. 使用外部比较器实现TreeSet对学生排序

public class Test3 {
    public static void main(String[] args) {
        // 创建一个set集合对象
        // Comparator comp = new StudentScoreComparator();
        // Comparator comp = new StudentNameDescComparator();
        Comparator comp = new Comparator<Student>() {
            @Override
            public int compare(Student stu1, Student stu2) {
                return -(stu1.getSno() - stu2.getSno());
            }
        };
        // 没有指定比较器,使用内部比较器
        // Set<Student> set = new TreeSet<Student>();
        // 指定了外部比较器,就使用外部比较器
        Set<Student> set = new TreeSet<Student>(comp);
        Student stu1 = new Student(1, "wyb", 27, 56);
        Student stu2 = new Student(2, "xz", 33, 98);
        Student stu3 = new Student(1, "wyb", 23, 56);
        set.add(stu1);
        set.add(stu2);
        set.add(stu3);
        System.out.println(set.size()); // 2
        for (Student student : set) {
            System.out.println(student);
        }
    }
}

内部比较器只能定义一个,一般将使用频率最高的比较规则定义为内部比较器的规则,外部比较器可以定义多个

  • 注意
    • 对于外部比较器,如果使用次数较少,可以通过匿名内部类来实现
    • 需要比较的场合才需要实现内部比较器或外部比较器,比如排序、TreeSet中数据的存储和查询,在HashSet、LinkedHashSet、ArrayList中存储元素,不需要实现内部比较器或外部比较器

2.2. 哈希表的原理

2.2.1. 引入哈希表

在无序数组中按照内容查找,效率低下,时间复杂度是O(n)
在这里插入图片描述
在有序数组中按照内容查找,使用折半查找,时间复杂度是O(log2n)
在这里插入图片描述
在二叉平衡树中按照内容查找,时间复杂度是O(log2n)
在这里插入图片描述
在数组中按照索引查找,不进行比较和计数,直接计算得到,效率最高,时间复杂度O(1)
在这里插入图片描述
哈希表:按照内容查找,不进行比较,通过计算得到地址,实现类似数组按照索引查询的高效率O(1)
理想方法是:不需要比较,根据给定值直接定位记录的存储位置,这样,需要在记录的存储位置与该记录的关键字之间建立一种确定的对应关系,使每个记录的关键字与一个存储位置相对应

2.2.2. 哈希表的结构和特点

哈希表也叫散列表
特点:快
结构:顺序表+链表
主结构:顺序表
每个顺序表的节点在单独引出一个链表
在这里插入图片描述

2.2.3. 哈希表是如何添加数据的

  1. 计算哈希码(调用hashCode()),结果是一个int值,整数的哈希码取自身即可
  2. 计算在哈希表中的存储位置:y = k(x) = x % 11,x:哈希码;k(x):在哈希表中的存储位置
  3. 存入哈希表
    • 情况1:一次添加成功
    • 情况2:多次添加成功(出现了冲突,调用equals()和对应链表的元素进行比较,比到最后,结果都为false,创建新节点,存储数据,并加入链表末尾)
    • 情况3:不添加(出现了冲突,调用equals()和对应链表的元素进行比较,经过一次或多次比较后,结果是true,表明重复,不添加)
      结论1: 哈希表添加数据快(3步即可,不考虑冲突)
      结论2: 唯一、无序
      在这里插入图片描述

2.2.4. 哈希表是如何查询数据的

和添加数据的过程是相同的

  • 情况1: 一次找到 2、86、76
  • 情况2: 多次找到 67、56、78
  • 情况3: 找不到 100、200

结论1: 哈希表查询速度快

2.2.5. hashCode和equals到底有什么神奇的作用

  • hashCode():计算哈希码,是一个整数,根据哈希码可以计算出数据在哈希表中的存储位置
  • equals():添加时出现了冲突,需要通过equals进行比较,判断是否相同;查询时也需要使用equals进行比较,判断是否相同

2.2.6. 各种类型数据的哈希码如何获取hashCode()

  1. int:取自身,看Integer的源码
  2. double:3.14、3.15、3.145、6.567、9.89,取整不可以,看Double源码
  3. String:将各个字符的编码值相加不可以
  4. Student:先各个属性的哈希码,进行某些相加相乘的运算

2.2.7. 如何减少冲突

  1. 哈希表的长度和表中的记录数的比例–装填因子
    如果Hash表的空间远远大于最后实际存储的记录个数,就会造成很大的空间浪费,如果选小了,容易造成冲突。
    在实际情况下,需要根据最终记录存储个数和关键字的分布特点,确定Hash表的大小。如果事先不知道最终需要存储的记录个数,则需要动态维护Hash表的容量,此时可能需要重新计算Hash地址
    装填因子 = 表中的记录数 / 哈希表的长度
  • 装填因子越小,表明表中还有很多的空单元,则添加发生的冲突的可能型越小
  • 装填因子越大,表明发生冲突的可能性越大,在查找时耗费的时间越多。
  • 一般装填因子在0.5左右时,Hash性能达到最优
  1. 哈希函数的选择:直接定址法、平方取中法、折叠法、除数取余法(y = x % 11)
  2. 处理冲突的方法:链地址法、开放地址法、再散列法、建立一个公共溢出区

3. Map集合的使用

3.1. Map集合类型

  • Map:特点:存储的键值对映射关系,根据key可以找到value
  • HashMap:采用Hashtable哈希表存储结构
    • 优点:添加、查询、删除速度快
    • 缺点:key无序
  • LinkedHashMap
    • 采用哈希表存储结构,同时使用链表维护次序
    • key有序(添加顺序)
      在这里插入图片描述
  • TreeMap:采用二叉树(红黑树)的存储结构
    • 优点:key有序,查询速度比List快(按照内容查询)
    • 缺点:查询速度没有HashSet快
      在这里插入图片描述

3.2. 使用Map存储国家名称-国家名称映射

public class TestMap1 {
    public static void main(String[] args) {
        // 创建一个Map集合对象
        // Map<String, String> map = new HashMap<String, String>();
        // Map<String, String> map = new LinkedHashMap<String, String>();
        Map<String, String> map = new TreeMap<String, String>();
        map.put("cn", "china");
        map.put("us", "America");
        System.out.println(map.size()); // 2
        System.out.println(map); // {cn=china, us=America}
        // Set得到所有的key
        System.out.println(map.keySet()); // [cn, us]
        // Collection得到所有的value
        System.out.println(map.values()); // [china, America]
        System.out.println(map.get("cn")); // china
        // Map遍历
        // 1. 先得到所有的key(Set),然后根据key找到value
        Set<String> keyset = map.keySet();
        for (String key : keyset) {
            System.out.println(key + "--->" + map.get(key)); // cn--->china  us--->America
        }
        // 2. 先得到所有的key-value组成的Set,然后输出每个key-value
        Set<Map.Entry<String, String>> entrySet = map.entrySet();
        Iterator<Map.Entry<String, String>> it = entrySet.iterator();
        while (it.hasNext()) {
            // 取出一个Entry
            Map.Entry<String,String> entry = it.next();
            System.out.print(entry + "\t");
            System.out.println(entry.getKey() + "--->" + entry.getValue()); // cn=china	cn--->china  us=America	us--->America
        }
    }
}

3.3. 使用各种Map存储学号-学生映射

public class TestMap2 {
    public static void main(String[] args) {
        // 创建一个Map对象用户存储key-value
        Map<Integer, Student> map = new HashMap<Integer, Student>();
        // Map<Integer, Student> map = new TreeMap<Integer, Student>();
        Student stu = new Student(1, "wyb", 23, 56);
        Student stu1 = new Student(1, "wyb", 23, 56);
        Student stu2 = new Student(3, "xz", 23, 56);
        map.put(stu.getSno(), stu);
        map.put(stu1.getSno(), stu1);
        map.put(stu2.getSno(), stu2);
        // map.remove(2);
        // map.clear();
        // map.replace(1, new Student(1, "bjyx", 5, 100));
        System.out.println(map.containsKey(1));; // true
        System.out.println(map.containsValue(stu1)); // true
        System.out.println(map.isEmpty());; // false
        Student s = map.get(1);
        if(s == null) System.out.println("该学生不存在");
        else System.out.println(s);
        System.out.println(map.size()); // 2
        System.out.println(map.toString()); // {1=com.wyb.setmap.Student@471fec69, 3=com.wyb.setmap.Student@4093cde9}
        Set<Entry<Integer, Student>> entrySet = map.entrySet();
        for (Entry<Integer, Student> entry : entrySet) {
            Student student = entry.getValue();
            System.out.println(student);
        }
    }
}
  • 方法
    • clear():从此映射中移除所有映射关系(可选操作),返回boolean
    • containsKey(Object key):如果此映射包含指定键的映射关系,则返回true
    • containsValue(Object value):如果此映射将一个或多个键映射到指定值,则返回true
    • entrySet():返回此映射中包含的映射关系的Set视图
    • equals(Object obj):比较指定的对象与此映射是否相等
    • get(Object key):返回指定键所映射的值;如果此映射不包含该键的映射关系,则返回null
    • hashCode():返回此映射的哈希码值
    • isEmpty():如果此映射没有键-值映射关系,则返回true
    • keySet(): 返回此映射中包含的键的Set视图
    • put(K key, V value):将指定键与此映射中的指定键关联(可选操作)
    • putAll(Map<? extends K, ? extends V> m):从指定映射中将所有映射关系复制到此映射中。(可选操作)
    • remove(Object key):如果存在一个键的映射,则将其从此映射中移除。(可选操作)
    • size():返回此映射中的键-值映射关系数。
    • values(): 返回此映射中包含的值的Collection视图。

4. Map和Set集合源码

4.1. 理解HashMap的源码

  • JSK1.7及其之前,HashMap底层就是一个table数组 + 链表实现的哈希表结构
    在这里插入图片描述
  • 链表的每个节点就是一个Entry,其中包括:键Key、值Value、键的哈希码hash、执行下一个节点的引用next四部分
static class Entry<K, V> implements Map.Entry<K, V> {
    final K key; 
    V value;
    Entry<K, V> next;
    int hash;
}
  • JDK1.7中HashMap的主要成员变量及其含义
public class HashMap<K, V> implements Map<K, V> {
    // 哈希表主数组的默认长度
    static final int DEFAULT_INITIAL_CAPACITY = 16;
    // 默认负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 主数组的引用
    transient Entry<K, V>[] table;
    // 界限值 阈值
    int threshold;
    // 装填因子
    float float loadFactor;
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }
    public HashMap(int initialCapacity, float loadFactor) {
        this.loadFactor = loadFactor;
        threshold = (int)Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); // 16 * 0.75 = 12
        table = new Entry[capacity];
        ...
    }
}
  • 调用put方法添加键值对。哈希表三步添加数据原理的具体实现;是计算key的哈希码,和value无关。特别注意:
    1. 第一步计算哈希码时,不仅调用了key的hashcode()方法,还进行了更复杂的处理,目的是尽量保证不同的key得到不同的哈希码
    2. 第二部根据哈希码计算存储位置时,使用了位运算提高效率
    3. 第三步添加Entry时添加到链表的第一个位置,而不是链表末尾
    4. 第三步添加Entry时,发现了相同的key已经存在,就使用新的value替代旧的value,并且返回旧的value
public class HashMap {
    public V put(K key, V value) {
        if(key == null) return putForNullKey(value);
        // 1. 计算key的hash值
        int hash = hash(key);
        // 2. 将哈希码带入函数,计算出存储位置,y = x % 16
        int i = indexFor(hash, table.length);
        // 如果已经存在链表,判断是否存在该key,需要用到equals()
        for(Entry<K, V> e = table[i]; e != null; e = e.next) {
            Object k;
            // 如果找到了,使用心得value替换旧的value,返回旧value
            if(e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        // 添加一个节点
        addEntry(hash, key, value, i);
        return null;
    }
    final int hash(Object key) {
        int h = 0;
        h ^= k.hashCode();
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }
    static int indexFor(int h, int length) {
        // 作用就相当于y = x % 16,采用了位运算,效率更高
        return h & (length - 1);
    }
}
  • 调用get方法,根据key获取value
    • 哈希表三步查询数据原理的具体实现
    • 其实是根据key找Entry,再从Entry中找value
public V get(Object key) {
    // 根据key找到Entry(Entry中有key和value)
    Entry<K,V> entry = getEntry(key);
    // 如果entry==null,返回null,否则返回value
    return (entry == null) ? null : entry.getValue();
}
  • 添加元素时,如达到了阈值需扩容,每次扩容为原来主数组容量的2倍
void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果达到了门槛值就扩容,容量为原来容量的2倍   16-32
    if((size >= threshold) && (null != table[bucketIndex]))
        resize(2 * size);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    // 添加节点
    createEntry(hash, key, value, bucketIndex);
}
  • 在JDK1.8中有了一些变化,当链表的存储数据个数>=8时,不再采用链表存储,而采用红黑树存储,
  • 这么做主要是查询的时间复杂度上,链表为O(n),而红黑树一直是O(logn)。如果冲突多,并且超过8,采用红黑树来提高效率
    在这里插入图片描述

4.2. 理解TreeMap的源码

  • 基本特征:二叉树、二叉查找树、二叉平衡树、红黑树
    在这里插入图片描述
  • 每个节点的结构
    在这里插入图片描述
static final class Entry<K,V> implements Map.Entry<K,V> {
    K key;
    V value;
    Entry<K,V> left;
    Entry<K,V> right;
    Entry<K,V> parent;
    boolean color = BLACK;
}
  • TreeMap主要的成员变量及其含义
public class TreeMap<K, V> implemments NavigableMap<K, V> {
    // 外部比较器
    private final Comparator<? super K> comparator;
    // 红黑树根节点的引用
    public transient Entry<K, V> root = null;
    // 红黑树中节点的个数
    public transient int size = 0;
    public TreeMap() {
        // 没有指定外部比较器
        comparator = null;
    }
    public TreeMap(Comparator<? super K> comparator) {
        // 指定了外部比较器
        this.comparator = comparator;
    }
}
  • 添加原理
    • 从根节点开始比较
    • 添加过程就是构造二叉平衡树的过程,会自动平衡
    • 平衡离不开比较:外部比较器优先,然后是内部比较器。如果两个比较器都没有,就抛出异常
public V pub(K key, V value) {
    Entry<K, V> t = root;
    // 如果添加第一个节点
    if(t == null) {
        // 即使是添加第一个节点,也要使用比较器
        compare(key, key);
        // 创建根节点
        root = new Entry<>(key, value, null);
        // 此时只有一个节点
        size = 1;
        return null;
    }
    // 如果添加非第一个节点
    int cmp;
    Entry<K, V> parent;
    Comparator<? super K> cpr = comparator;
    // 如果外部比较器存在,就使用外部比较器
    if(cpr != null) {
        do {
            parent = t;
            cmp = cpr.compare(key, t.key);
            if(cmp < 0) t = t.left; // 在左子树中查找
            else if(cmp > 0) t = t.right; // 在右子树中查找
            else return t.setValue(value); // 找到了对应的key,使用新的value覆盖旧的value
        }while (t != null);
    }else {
        // 如果外部比较器没有,就使用内部比较器
        ...
    }
    // 找到了要添加的位置,创建一个新的节点,加入到树中
    Entry<K, V> e = new Entry<>(key, value, parent);
    if(cmp < 0) parent.left = e;
    else parent.right = e;
    size++;
    return null;
}
  • 查询基本原理同添加
public V get(Object key) {
    // 根据key找Entry
    Entry<K, V> p = getEntry(key);
    // 如果Entry存在,返回value
    return (p == null ? null : p.value); 
}
final Entry<K, V> getEntry(Object key) {
    // 如果外部比较器存在,就使用外部比较器
    if(comparator != null) return getEntryUsingComparator(key);
    if(key == null) throw new NullPointerException();
    @SuppressWarnings("unchecked")
    // 如果外部比较器不存在,就使用内部比较器
    Comparable<? super K> k = (Comparable<? super K>) key;
    Entry<K, V> p = root;
    while (p != null) {
        int cmp = k.compareTo(p.key);
        if(cmp < 0) p = p.left;
        else if(cmp > 0) p = p.right;
        else return p; // 如果找到了,就返回Entry
    }
    // 如果没有找到,就返回null
    return null;
}

4.3. 理解HashSet的源码

  • HashSet的底层使用的是HashMap,所以底层结构也是哈希表
  • HashSet的元素到HashMap中做key,value统一的是new Object()
public class HashSet<E> implements Set<E> {
    private transient HashMap<E, Object> map;
    private static final Object PRESENT = new Object();
    public HashSet() {
        map = new HashMap<>();
    }
    public boolean add(E e) {
        return map.put(e, PRESENT) == null;
    }
    public int size() {
        return map.size();
    }
    public Iterator<E> iterator() {
        return map.keySet().iterator();
    }
}

4.4. 理解TreeSet的源码

  • TreeSet的底层使用的是TreeMap,所以底层结构也是红黑树
  • TreeSet的元素e是作为TreeMap的key存在,value统一为new Object()
public class TreeSet<E> implements NavigableSet<E> {
    // 底层的TreeMap引用
    private transient NavigableMap<E, Object> m;
    private static final Object PRESENT = new Object();
    public TreeSet() {
        // 创建TreeSet对象就是创建一个TreeMap对象
        this(new TreeMap<E, Object>());
    }
    TreeSet(NavigableMap<E, Object> m) {
        this.m = m;
    }
    public boolean add(E e) {
        return m.put(e, PRESENT) == null;
    }
    public int size() {
        return m.size();
    }
}
;