• Java 集合


    集合的由来及集合继承体系图

    集合的由来

    • 数组长度是固定,当添加的元素超过了数组的长度时需要对数组重新定义
    • java内部给我们提供了集合类,能存储任意对象,长度是可以改变的,随着元素的增加而增加,随着元素的减少而减少

    数组和集合的区别

      区别1 :

    • 数组既可以存储基本数据类型,又可以存储引用数据类型,基本数据类型存储的是值,引用数据类型存储的是地址值
    • 集合只能存储引用数据类型(对象),集合中也可以存储基本数据类型,但是在存储的时候会自动装箱变成对象,int - Integer

      区别2:

    • 数组长度是固定的,不能自动增长
    • 集合的长度的是可变的,可以根据元素的增加而增长

    数组和集合什么时候用

    • 如果元素个数是固定的推荐用数组
    • 如果元素个数不是固定的推荐用集合

    集合的由来及集合继承体系图

    • List:有序集合,有索引,存与取的顺序一样,可以重复
    • Set:无序集合,无索引,存与取的顺序不一样,不可以重复

    集合(Collection)继承体系图

    集合中部分数组的实现原理

      有个10容量的初始化数组,不够时,再搞个1.5倍的新数组,把以前的数组垃圾回收

    Collection集合的基本功能

    基本功能方法

    • boolean add(E e)
    • boolean remove(Object o)
    • void clear()
    • boolean contains(Object o)
    • boolean isEmpty()
    • int size()

    集合的遍历之集合转数组遍历

    集合的遍历

    其实就是依次获取集合中的每一个元素。

    案例演示

    把集合转成数组,可以实现集合的遍历 toArray()

    import java.util.ArrayList;
    import java.util.List;
    
    public class Demo01 {
        public static void main(String[] args) {
            List<String> list = new ArrayList<>();
            list.add("a");
            list.add("b");
            list.add("c");
            
            String[] arr = new String[3];
            list.toArray(arr);
        }
    }

    集合的遍历之迭代器遍历

    迭代器概述

      迭代器是用来遍历集合的每一个元素的

    迭代器的使用

    使用迭代器遍历ArrayList集合

    import java.util.ArrayList;
    import java.util.Iterator;
    import java.util.List;
     
    public class Ch05 {
     
        public static void main(String[] args) {
     
            List list = new ArrayList<>();
            
            //集合
            list.add(1);
            list.add(2);
            list.add(3);
            
            //Iterator迭代器
            //1、获取迭代器
            Iterator iter = list.iterator();
            //2、通过循环迭代
            //hasNext():判断是否存在下一个元素
            while(iter.hasNext()){
                //如果存在,则调用next实现迭代
                //Object-->Integer-->int
                int j=(int)iter.next();  //把Object型强转成int型
                System.out.println(j);
            }
        }
     
    }

    ArrayList 的实现原理

    ArrayList 概述

    ArrayList是List接口的可变数组的实现。实现了所有可选列表操作,并允许包括 null 在内的所有元素。除了实现 List 接口外,此类还提供一些方法来操作内部用来存储列表的数组的大小。
    每个 ArrayList 实例都有一个容量,该容量是指用来存储列表元素的数组的大小。它总是至少等于列表的大小。随着向 ArrayList 中不断添加元素,其容量也自动增长。自动增长会带来数据向新数组的重新拷贝,因此,如果可预知数据量的多少,可在构造 ArrayList 时指定其容量。在添加大量元素前,应用程序也可以使用 ensureCapacity 操作来增加 ArrayList实例的容量,这可以减少递增式再分配的数量。
    注意,此实现不是同步的。如果多个线程同时访问一个 ArrayList 实例,而其中至少一个线程从结构上修改了列表,那么它必须保持外部同步。

    ArrayList 的实现

    对于 ArrayList 而言,它实现 List 接口、底层使用数组保存所有元素。其操作基本上是对数组的操作。

    1)  底层使用数组实现

    private transient Object[] elementData;

    2) 构造方法

    ArrayList 提供了三种方式的构造器,可以构造一个默认初始容量为 10 的空列表、构造一个指定初始容量的空列表以及构造一个包含指定 collection 的元素的列表,这些元素按照该 collection 的迭代器返回它们的顺序排列的。

    public ArrayList() {
        this(10);
    }
    
    public ArrayList(int initialCapacity) {
        super();
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
        this.elementData = new Object[initialCapacity];
     }
    
    public ArrayList(Collection<? extends E> c) {
        elementData = c.toArray();
        size = elementData.length;
        // c.toArray might (incorrectly) not return Object[] (see 6260652)
        if (elementData.getClass() != Object[].class)
            elementData = Arrays.copyOf(elementData, size, Object[].class);
     }

    3) 存储:
    ArrayList 提供了 set(int index, E element)、add(E e)、add(int index, E element)、addAll(Collection<? extends E> c)、addAll(int index, Collection<? extends E> c)这些添加元素的方法。

     // 用指定的元素替代此列表中指定位置上的元素,并返回以前位于该位置上的元素。
     public E set(int index, E element) {
       RangeCheck(index);
       E oldValue = (E) elementData[index];
       elementData[index] = element;
       return oldValue;
     }
     // 将指定的元素添加到此列表的尾部。
     public boolean add(E e) {
       ensureCapacity(size + 1);
       elementData[size++] = e;
       return true;
     }
     // 将指定的元素插入此列表中的指定位置。
     // 如果当前位置有元素,则向右移动当前位于该位置的元素以及所有后续元素(将其索引加 1)。
     public void add(int index, E element) {
       if (index > size || index < 0)
         throw new IndexOutOfBoundsException("Index: "+index+", Size: "+size);
       // 如果数组长度不足,将进行扩容。
       ensureCapacity(size+1); // Increments modCount!!
       // 将 elementData 中从 Index 位置开始、长度为 size-index 的元素,
       // 拷贝到从下标为 index+1 位置开始的新的 elementData 数组中。
       // 即将当前位于该位置的元素以及所有后续元素右移一个位置。
       System.arraycopy(elementData, index, elementData, index + 1, size - index);
       elementData[index] = element;
       size++;
     }
     // 按照指定 collection 的迭代器所返回的元素顺序,将该 collection 中的所有元素添加到此列表的尾部。
     public boolean addAll(Collection<? extends E> c) {
       Object[] a = c.toArray();
       int numNew = a.length;
       ensureCapacity(size + numNew); // Increments modCount
       System.arraycopy(a, 0, elementData, size, numNew);
       size += numNew;
       return numNew != 0;
     }
     // 从指定的位置开始,将指定 collection 中的所有元素插入到此列表中。
     public boolean addAll(int index, Collection<? extends E> c) {
       if (index > size || index < 0)
         throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size);
       Object[] a = c.toArray();
       int numNew = a.length;
       ensureCapacity(size + numNew); // Increments modCount
       int numMoved = size - index;
       if (numMoved > 0)
         System.arraycopy(elementData, index, elementData, index + numNew, numMoved);
       System.arraycopy(a, 0, elementData, index, numNew);
       size += numNew;
       return numNew != 0;
     }

    4)  读取

     // 返回此列表中指定位置上的元素。
     public E get(int index) {
       RangeCheck(index);
       return (E) elementData[index];
     }

    5) 删除

    ArrayList 提供了根据下标或者指定对象两种方式的删除功能。

     // 移除此列表中指定位置上的元素。
     public E remove(int index) {
       RangeCheck(index);
       modCount++;
       E oldValue = (E) elementData[index];
       int numMoved = size - index - 1;
       if (numMoved > 0)
         System.arraycopy(elementData, index+1, elementData, index, numMoved);
       elementData[--size] = null; // Let gc do its work
       return oldValue;
     }
     // 移除此列表中首次出现的指定元素(如果存在)。这是应为ArrayList中允许存放重复的元素。
     public boolean remove(Object o) {
       // 由于 ArrayList 中允许存放 null,因此下面通过两种情况来分别处理。
       if (o == null) {
         for (int index = 0; index < size; index++){
           if (elementData[index] == null) {
             // 类似 remove(int index),移除列表中指定位置上的元素。
             fastRemove(index);
             return true;
           }
         } 
      } else {     for (int index = 0; index < size; index++){       if (o.equals(elementData[index])) {         fastRemove(index);         return true;       }
         }
      }   return false; }

    注意:从数组中移除元素的操作,也会导致被移除的元素以后的所有元素的向左移动一个位置。

    6) 调整数组容量

    从上面向 ArrayList 中存储元素的代码中,我们看到每当向数组中添加元素时,都要去检查添加后元素的个数是否会超出当前数组的长度,如果超出,数组将会进行扩容,以满足添加数据的需求。数组扩容通过一个公开的方法 ensureCapacity(int minCapacity)来实现。在实际添加大量元素前,我也可以使用 ensureCapacity 来手动增加 ArrayList 实例的容量,以减少递增式再分配的数量。

     public void ensureCapacity(int minCapacity) {
       modCount++;
       int oldCapacity = elementData.length;
       if (minCapacity > oldCapacity) {
         Object oldData[] = elementData;
         int newCapacity = (oldCapacity * 3)/2 + 1;
         if (newCapacity < minCapacity){
           newCapacity = minCapacity;
         }
        // minCapacity is usually close to size, so this is a win:     elementData = Arrays.copyOf(elementData, newCapacity);   } }

    从上述代码中可以看出,数组进行扩容时,会将老数组中的元素重新拷贝一份到新的数组中,每次数组容量的增长大约是其原容量的 1.5 倍。这种操作的代价是很高的,因此在实际使用时,我们应该尽量避免数组容量的扩张。当我们可预知要保存的元素的多少时,要在构造 ArrayList 实例时,就指定其容量,以避免数组扩容的发生。或者根据实际需求,通过调用 ensureCapacity 方法来手动增加 ArrayList 实例的容量。

    ArrayList 还给我们提供了将底层数组的容量调整为当前列表保存的实际元素的大小的功能。它可以通过 trimToSize 方法来实现。代码如下:

     public void trimToSize() {
       modCount++;
       int oldCapacity = elementData.length;
       if (size < oldCapacity) {
         elementData = Arrays.copyOf(elementData, size);
       }
     }

    7) Fail-Fast 机制:
    ArrayList 也采用了快速失败的机制,通过记录 modCount 参数来实现。在面对并发的修改时,迭代器很快就会完全失败,而不是冒着在将来某个不确定时间发生任意不确定行为的风险。

    Vector的使用

      Vector在JDK1.0 版本就有了,从 Java 2 平台 v1.2 开始,此类改进为可以实现 List 接口,使它成为 Java Collections Framework 的成员,Vector 是同步的。

    Vector类特有功能

    • public void addElement(E obj)
    • public E elementAt(int index)
    • public Enumeration elements() 

    数据结构之数组和链表特点

    数组

    • 查询快、修改也快
    • 增删慢

    链表

    • 查询慢,修改也慢
    • 增删快

    List的三个子类的特点

    ArrayList

    • 底层数据结构是数组,查询快,增删慢。
    • 线程不安全,效率高。

    Vector

    • 底层数据结构是数组,查询快,增删慢。
    • 线程安全,效率低。
    • Vector相对ArrayList查询慢(线程安全的)
    • Vector相对LinkedList增删慢(数组结构)

    LinkedList

    • 底层数据结构是链表,查询慢,增删快。
    • 线程不安全,效率高。

    list总结

    Vector和ArrayList的区别

    • Vector是线程安全的,效率低 ArrayList是线程不安全的,效率高
    • 共同点:都是数组实现的

    ArrayList和LinkedList的区别

    • ArrayList底层是数组实现,查询和修改快
    • LinkedList底层是链表结构实现,增和删比较快,查询和修改比较慢
    • 共同点:都是线程不安全的

    List有三个子类到底使用谁呢?

    • 查询多用ArrayList
    • 增删多用LinkedList
    • 如果都多ArrayList

    泛型概述和基本使用

    泛型概述(Generic)

    • 泛型的作用:把类型明确的工作推前到创建对象或者调用方法的时候。
    • 泛型是一种参数化类型,把类型当作参数一样传递来明确集合的元素类型

    泛型好处

    • 提高安全性(将运行期的错误转换到编译期)
    • 省去强转的麻烦

    泛型基本使用

    • 声明集合泛型的格式List<Student> list = new ArrayList<Student>();
    • <>中放的必须是引用数据类型

    泛型使用注意事项

    1.默认声明一个泛型集合,前后类型要一至

      List<Student> list = new ArrayList<Student>();

    2.这样声明前后类型不一至是不可以的

      List<Object> list = new ArrayList<Student>();

    3.集合泛型的声明,可以只声明前面的泛型,jdk1.7的新特性:菱形泛型,开发时建议还是写成前后一至

      List<Student> list1 = new ArrayList();

    4.集合声明的泛型,代表此类或者子类都可以成为集合的元素,eg: Person -> Student

    5.声明的泛型类型一定是引用数据类型

     

    泛型类和泛型方法

    泛型类概述<T>

    • 把泛型定义在类上

    定义格式

    • public class 类名<泛型类型1,…>

    泛型类型注意事项

    • 泛型类型必须是引用类型
    • T的值是什么时候有的?是在创建对象时

    泛型方法

    • 泛型方法,把泛型定义在方法上
    • 定义格式: public 返回类型 方法名(泛型类型 变量名)
    • 对象方法的泛型参数要与类中的泛型一致,不可以使用其它名

    泛型高级之通配符

    泛型通配符<?>

      任意类型,如果没有明确,那么就是Object以及任意的Java类了

    ? extends E

      向下限定,E及其子类

    ? super E

      向上限定,E及其父类

    三种迭代的能否删除

    • 普通for循环,可以删除,但是索引要(--减减 )
    • 迭代器,可以删除,但是必须使用迭代器自身的remove方法,否则会出现并发修改异常
    • 增强for循环不能删除

    HashSet 的实现原理

    HashSet 概述

    HashSet 实现 Set 接口,由哈希表(实际上是一个 HashMap 实例)支持。它不保证 set 的迭代顺序;特别是它不保证该顺序恒久不变。此类允许使用 null 元素。

    HashSet 的实现

    对于 HashSet 而言,它是基于 HashMap 实现的,HashSet 底层使用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成, HashSet 的源代码如下

    public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
            static final long serialVersionUID = -5024744406713321676L;
    
            // 底层使用 HashMap 来保存 HashSet 中所有元素。
            private transient HashMap<E, Object> map;
    
            // 定义一个虚拟的 Object 对象作为 HashMap 的 value,将此对象定义为 static final。
            private static final Object PRESENT = new Object();
    
            /**
             * 默认的无参构造器,构造一个空的 HashSet。
             *
             * 实际底层会初始化一个空的 HashMap,并使用默认初始容量为 16 和加载因子 0.75。
             */
    
            public HashSet() {
                map = new HashMap<E, Object>();
            }
    
            /**
             * 构造一个包含指定 collection 中的元素的新 set。
             *
             * 实际底层使用默认的加载因子 0.75 和足以包含指定 collection 中所有元素的初始容量来创建一个 HashMap。
             * 
             * @param c
             *            其中的元素将存放在此 set 中的 collection。
             */
            public HashSet(Collection<? extends E> c) {
                map = new HashMap<E, Object>(Math.max((int) (c.size() / .75f) + 1, 16));
                addAll(c);
            }
    
            /**
             * 以指定的 initialCapacity 和 loadFactor 构造一个空的 HashSet。
             *
             * 实际底层以相应的参数构造一个空的 HashMap。
             * 
             * @param initialCapacity
             *            初始容量。
             * @param loadFactor
             *            加载因子。
             */
            public HashSet(int initialCapacity, float loadFactor) {
                map = new HashMap<E, Object>(initialCapacity, loadFactor);
            }
    
            /**
             * 以指定的 initialCapacity 构造一个空的 HashSet。
             *
             * 实际底层以相应的参数及加载因子 loadFactor 为 0.75 构造一个空的 HashMap。
             * 
             * @param initialCapacity
             *            初始容量。
             */
            public HashSet(int initialCapacity) {
                map = new HashMap<E, Object>(initialCapacity);
            }
    
            /**
             * 以指定的 initialCapacity 和 loadFactor 构造一个新的空链接哈希集合。 此构造函数为包访问权限,不对外公开,实际只是是对
             * LinkedHashSet 的支持。
             *
             * 实际底层会以指定的参数构造一个空 LinkedHashMap 实例来实现。
             * 
             * @param initialCapacity
             *            初始容量。
             * @param loadFactor
             *            加载因子。
             * @param dummy
             *            标记。
             */
            HashSet(int initialCapacity, float loadFactor, boolean dummy) {
                map = new LinkedHashMap<E, Object>(initialCapacity, loadFactor);
            }
    
            /**
             * 返回对此 set 中元素进行迭代的迭代器。返回元素的顺序并不是特定的。
             *
             * 底层实际调用底层 HashMap 的 keySet 来返回所有的 key。 可见 HashSet 中的元素,只是存放在了底层 HashMap 的 key
             * 上, value 使用一个 static final 的 Object 对象标识。
             * 
             * @return 对此 set 中元素进行迭代的 Iterator。
             */
            public Iterator<E> iterator() {
                return map.keySet().iterator();
            }
    
            /**
             * 返回此 set 中的元素的数量(set 的容量)。
             *
             * 底层实际调用 HashMap 的 size()方法返回 Entry 的数量,就得到该 Set 中元素的个数。
             * 
             * @return 此 set 中的元素的数量(set 的容量)。
             */
            public int size() {
                return map.size();
            }
    
            /**
             * 如果此 set 不包含任何元素,则返回 true。
             *
             * 底层实际调用 HashMap 的 isEmpty()判断该 HashSet 是否为空。
             * 
             * @return 如果此 set 不包含任何元素,则返回 true。
             */
            public boolean isEmpty() {
                return map.isEmpty();
            }
    
            /**
             * 如果此 set 包含指定元素,则返回 true。 更确切地讲,当且仅当此 set 包含一个满足(o==null ? e==null :
             * o.equals(e)) 的 e 元素时,返回 true。
             *
             * 底层实际调用 HashMap 的 containsKey 判断是否包含指定 key。
             * 
             * @param o
             *            在此 set 中的存在已得到测试的元素。
             * @return 如果此 set 包含指定元素,则返回 true。
             */
            public boolean contains(Object o) {
                return map.containsKey(o);
            }
    
            /**
             * 如果此 set 中尚未包含指定元素,则添加指定元素。 更确切地讲,如果此 set 没有包含满足(e==null ? e2==null :
             * e.equals(e2)) 的元素 e2,则向此 set 添加指定的元素 e。 如果此 set 已包含该元素,则该调用不更改 set 并返回 false。
             *
             * 底层实际将将该元素作为 key 放入 HashMap。 由于 HashMap 的 put()方法添加 key-value 对时,当新放入 HashMap
             * 的 Entry 中 key 与集合中原有 Entry 的 key 相同(hashCode()返回值相等,通过 equals 比较也返回 true),
             * 新添加的 Entry 的 value 会将覆盖原来 Entry 的 value,但 key 不会有任何改变, 因此如果向 HashSet
             * 中添加一个已经存在的元素时,新添加的集合元素将不会被放入 HashMap 中, 原来的元素也不会有任何改变,这也就满足了 Set 中元素不重复的特性。
             * 
             * @param e
             *            将添加到此 set 中的元素。
             * @return 如果此 set 尚未包含指定元素,则返回 true。
             */
            public boolean add(E e) {
                return map.put(e, PRESENT) == null;
            }
    
            /**
             * 如果指定元素存在于此 set 中,则将其移除。 更确切地讲,如果此 set 包含一个满足(o==null ? e==null :
             * o.equals(e))的元素e, 则将其移除。如果此 set 已包含该元素,则返回 true (或者:如果此 set 因调用而发生更改,则返回
             * true)。(一旦调用返回,则此 set 不再包含该元素)。
             *
             * 底层实际调用 HashMap 的 remove 方法删除指定 Entry。
             * 
             * @param o
             *            如果存在于此 set 中则需要将其移除的对象。
             * @return 如果 set 包含指定元素,则返回 true。
             */
            public boolean remove(Object o) {
                return map.remove(o) == PRESENT;
            }
    
            /**
             * 从此 set 中移除所有元素。此调用返回后,该 set 将为空。
             *
             * 底层实际调用 HashMap 的 clear 方法清空 Entry 中所有元素。
             */
            public void clear() {
                map.clear();
            }
    
            /**
             * 返回此 HashSet 实例的浅表副本:并没有复制这些元素本身。
             *
             * 底层实际调用 HashMap 的 clone()方法,获取 HashMap 的浅表副本,并设置到 HashSe
             * 
             */
            public Object clone() {
                try {
                    HashSet<E> newSet = (HashSet<E>) super.clone();
                    newSet.map = (HashMap<E, Object>) map.clone();
                    return newSet;
                } catch (CloneNotSupportedException e) {
                    throw new InternalError();
                }
            }
        }
    View Code

    HashSet存储字符串并遍历

    Set集合概述及特点

    • 通过API查看Set
    • Set 是一个不包含重复元素的 collection
    • Set只是一个接口,一般使用它的子类HashSet, LinkedHashSet, TreeSet

    HaseSet

    • 此类实现 Set 接口,由哈希表支持
    • 它不保证 set 的迭代顺序;特别是它不保证该顺序恒久不变。
    • 此类允许使用 null 元素

    HashSet存储字符串并遍历

    • 使用增强for循环
    • 迭代器

    HashSet如何保证元素唯一性的原理

    HashSet原理

    1. 使用Set集合都是需要去掉重复元素的, 如果在存储的时候逐个equals()比较, 效率较低,哈希算法提高了去重复的效率, 降低了使用equals()方法的次数
    2. 当HashSet调用add()方法存储对象的时候, 先调用对象的hashCode()方法得到一个哈希值, 然后在集合中查找是否有哈希值相同的对象
    3. 如果没有哈希值相同的对象就直接存入集合
    4. 如果有哈希值相同的对象, 就和哈希值相同的对象逐个进行equals()比较,比较结果为false就存入, true则不存

    将自定义类的对象存入HashSet去重复的关键

    1. 类中必须重写hashCode()和equals()方法
    2. hashCode(): 属性相同的对象返回值必须相同, 属性不同的返回值尽量不同(提高效率)
    3. equals(): 属性相同返回true, 属性不同返回false,返回false的时候存储

    为什么自动生成hashcode的时有个31的数

    • 31是一个质数,质数是能被1和自己本身整除的数,没有公约数
    • 31这个数既不大也不小
      • 大的话可能超过int的取值范围
      • 小的话,相同率机出现比较多
    • 31这个数好算,2的五次方-1,1向左移动5位 - 1, [1 << 5] -1

    将List集合中的重复元素去掉(set实现)

    LinkedHashSet的概述和使用

    • LinkedHashSet是一个具有可预知迭代顺序的 Set 接口。
    • 内部实现是使用哈希表和链接列表
    • LinkedHashSet的特点是可以保证怎么存就怎么取
    • LinkedHashSet是set集合中唯一一个能保证怎么存就怎么取的集合对象
    • LinkedHashSet是HashSet的子类,所以也是保证元素唯一的,与HashSet的原理一样

    TreeSet存储Integer类型的元素并遍历

    • TreeSet是一个可以用于排序的集合
    • TreeSet:基于 TreeMap 的 NavigableSet 实现。
    • TreeSet的排序方法有两种
      • 使用元素的自然顺序Comparable对元素进行排序
      • 使用构造方法的 Comparator 进行排序

    TreeSet存储自定义对象

    使用TreeSet存储自定义对象时会出现异常:Person cannot be cast to java.lang.Comparable

    1. 如果想用TreeSet存储自定义对象,这个对象必须要实现Comparable接口
    2. 此接口强行对实现它的每个类的对象进行整体排序。
    3. 这种排序被称为自然排序,类的 compareTo 方法被称为它的自然比较方法。
    4. 当compareTo方法返回0的时候集合中只有一个元素
    5. 当compareTo方法返回正数的时候集合会怎么存就怎么取
    6. 当compareTo方法返回负数的时候集合会倒序存储

    例子:TreeSet存储自定义对象并遍历

    TreeSet存储自定义对象并遍历,按照姓名长度、字母、年龄排序

    • 通过比较字符串的compareTo方法可以比较大小
    • 排序是按照unicode码的大小进行排序的
    • 防止名字相同,但年龄不同的bug

    TreeSet的构造方法(比较器)

    • TreeSet(Comparator<? super E> comparator)
    • TreeSet有个带Comparator参数的构造方法
    • 构造一个新的空 TreeSet,它根据指定的 “比较器”进行排序

    TreeSet排序原理总结

    TreeSet的特点

    TreeSet是用来排序的, 可以指定一个顺序, 对象存入之后会按照指定的顺序排列

    TreeSet排序方式有两种自然顺序和比较器顺序

    • 自然顺序(Comparable)
      1. TreeSet类的add()方法中会把存入的对象提升为Comparable类型
      2. 调用对象的compareTo()方法和集合中的对象比较
      3. 根据compareTo()方法返回的结果进行存储
    • 比较器顺序(Comparator)
      1. 创建TreeSet的时候可以制定 一个Comparator
      2. 如果传入了Comparator的子类对象, 那么TreeSet就会按照比较器中的规则比较
      3. add()方法内部会自动调用Comparator接口中compare()方法排序
      4. 调用的对象是compare方法的第一个参数,集合中的对象是compare方法的第二个参数

    Map集合概述和特点

    Map是属于java.util的一个接口Map<K,V>

    类型参数:

    • K - 映射所维护的键的类型
    • V - 映射值的类型

    Map是将键映射到值的对象。一个映射不能包含重复的键;每个键最多只能映射到一个值。

    Map接口和Collection接口的不同

    1. Map是双列的,Collection是单列的
    2. Map的键唯一,Collection的Set是唯一的
    3. Map集合的数据结构值针对键有效,跟值无关
    4. Collection集合的数据结构是针对元素有效

    Map是一个接口,一般使用它的子类HashMap

    HashMap 概述

    HashMap 是基于哈希表的 Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

    HashMap 的数据结构

    在 java 编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap 也不例外。HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。

    从上图中可以看出,HashMap 底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个 HashMap 的时候,就会初始化一个数组。

    /**                                                                               
    * The table, resized as necessary. Length MUST Always be a power of two.          
    */                                                                                
    transient Entry[] table;                                                          
                                                                                      
    static class Entry<K,V> implements Map.Entry<K,V> {                               
      final K key;                                                                      
      V value;                                                                          
      Entry<K,V> next;                                                                  
      final int hash;                                                                  
      ……                                                                               
     }                                                                                

    可以看出,Entry 就是数组中的元素,每个 Map.Entry 其实就是一个 key-value 对,它持有一个指向下一个元素的引用,这就构成了链表。

    HashMap  的存取实现

    存储

    public V put(K key, V value) {
            // HashMap 允许存放 null 键和 null 值。
            // 当 key 为 null 时,调用 putForNullKey 方法,将 value 放置在数组第一个位置。
            if (key == null)
                return putForNullKey(value);
            // 根据 key 的 hashCode 重新计算 hash 值。
            int hash = hash(key.hashCode());
            // 搜索指定 hash 值在对应 table 中的索引。
            int i = indexFor(hash, table.length);
            // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。
            for (Entry<K, V> e = table[i]; e != null; e = e.next) {
                Object k;
                if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                    V oldValue = e.value;
                    e.value = value;
                    e.recordAccess(this);
                    return oldValue;
                }
            }
            // 如果 i 索引处的 Entry 为 null,表明此处还没有 Entry。
            modCount++;
            // 将 key、value 添加到 i 索引处。
            addEntry(hash, key, value, i);
            return null;
        }

    从上面的源代码中可以看出:当往 HashMap 中 put 元素的时候,先根据 key 的hashCode 重新计算 hash 值,根据 hash 值得到这个元素在数组中的位置(即下标),如果数组该位置上已经存放有其他元素了,那么在这个位置上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。如果数组该位置上没有元素,就直接将该元素放到此数组中的该位置上。

    addEntry(hash, key, value, i)方法根据计算出的 hash 值,将 key-value 对放在数组 table的 i 索引处。addEntry 是 HashMap 提供的一个包访问权限的方法,代码如下:

        void addEntry(int hash, K key, V value, int bucketIndex) {
            // 获取指定 bucketIndex 索引处的 Entry
            Entry<K, V> e = table[bucketIndex];
            // 将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entr
    
            table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
            // 如果 Map 中的 key-value 对的数量超过了极限
            if (size++ >= threshold)
                // 把 table 对象的长度扩充到原来的 2 倍。
                resize(2 * table.length);
        }

    当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据key来计算并决定每个Entry的存储位置。我们完全可以把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置之后,value 随之保存在那里即可。
    hash(int h)方法根据 key 的 hashCode 重新计算一次散列。此算法加入了高位计算,防止低位不变,高位变化时,造成的 hash 冲突。

     static int hash(int h) {
       h ^= (h >>> 20) ^ (h >>> 12);
       return h ^ (h >>> 7) ^ (h >>> 4);
     }

    可以看到在 HashMap 中要找到某个元素,需要根据 key 的 hash 值来求得对应数组中的位置。如何计算这个位置就是 hash 算法。前面说过 HashMap 的数据结构是数组和链表的结合,所以我们当然希望这个 HashMap 里面的 元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表,这样就大大优化了查询的效率。


    对于任意给定的对象,只要它的 hashCode() 返回值相同,那么程序调用 hash(int h) 方法所计算得到的 hash 码值总是相同的。首先想到的就是把 hash 值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,“模”运算的消耗还是比较大的,在 HashMap 中是这样做的:调用indexFor(int h, int length) 方法来计算该对象应该保存在 table 数组的哪个索引处。indexFor(int h, int length) 方法的代码如下:

    static int indexFor(int h, int length) {
         return h & (length-1)
    }

    这个方法非常巧妙,它通过 h & (table.length -1) 来得到该对象的保存位,而 HashMap底层数组的长度总是 2 的 n 次方,这是 HashMap 在速度上的优化。在 HashMap 构造器中有如下代码:

     int capacity = 1;
       while (capacity < initialCapacity)
         capacity <<= 1;

    这段代码保证初始化时 HashMap 的容量总是 2 的 n 次方,即底层数组的长度总是为 2的 n 次方。当 length 总是 2 的 n 次方时,h& (length-1)运算等价于对 length 取模,也就是
    h%length,但是&比%具有更高的效率。
    这看上去很简单,其实比较有玄机的,我们举个例子来说明:
    假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下:

    从上面的例子中可以看出:当它们和 15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8 和 9 会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到 8 或者 9,这样就降低了查询的效率。同时,我们也可以发现,当数组长度为 15 的时候,hash 值会与 15-1(1110)进行“与”,那么 最后一位永远是 0,而 0001,0011,0101,1001,1011,0111,
    1101 这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!而当数组长度为 16 时,即为 2 的 n 次方时,2 n -1 得到的二进制数的每个位上的值都为 1,这使得在低位上&时,得到的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的 hash 值的两个值才会被放到数组中的同一个位置上形成链表。所以说,当数组长度为 2 的 n 次幂的时候,不同的 key 算得得 index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。


    根据上面 put 方法的源代码可以看出,当程序试图将一个 key-value 对放入 HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。

     读取

    public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
    }

    有了上面存储时的 hash 算法作为基础,理解起来这段代码就很容易了。从上面的源代码中可以看出:从 HashMap 中 get 元素时,首先计算 key 的 hashCode,找到数组中对应位置的某一元素,然后通过 key 的 equals 方法在对应位置的链表中找到需要的元素。归纳起来简单地说,HashMap 在底层将 key-value 当成一个整体进行处理,这个整体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value 对,当需要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当需要取出一个 Entry 时,也会根据 hash算法找到其在数组中的存储位置,再根据 equals 方法从该位置上的链表中取出该 Entry。

    HashMap 的 resize (rehash )

    当 HashMap 中的元素越来越多的时候,hash 冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对 HashMap 的数组进行扩容,数组扩容这个操作也会出现在 ArrayList 中,这是一个常用的操作,而在 HashMap 数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是 resize。

    那么 HashMap 什么时候进行扩容呢?当 HashMap 中的元素个数超过数组大小*loadFactor 时,就会进行数组扩容,loadFactor 的默认值为 0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为 16,那么当 HashMap 中元素个数超过 16*0.75=12 的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。

    HashMap  的性能参数

    HashMap 包含如下几个构造器:

    • HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。
    • HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。
    • HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

    HashMap 的基础构造器 HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量 initialCapacity 和加载因子 loadFactor。
    initialCapacity:HashMap 的最大容量,即为底层数组的长度。
    loadFactor:负载因子 loadFactor 定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。
    负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是 O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。HashMap 的实现中,通过 threshold 字段来判断 HashMap 的最大容量:

    threshold = (int)(capacity * loadFactor);

    结合负载因子的定义公式可知,threshold 就是在此 loadFactor 和 capacity 对应下允许的最大元素数目,超过这个数目就重新 resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍:

    if (size++ >= threshold)
         resize(2 * table.length);

    Fail-Fast  机制

    我们知道 java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
    这一策略在源码中的实现是通过 modCount 域,modCount 顾名思义就是修改次数,对HashMap 内容的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。

     HashIterator() {
       expectedModCount = modCount;
       if (size > 0) { // advance to first entry
       Entry[] t = table;
       while (index < t.length && (next = t[index++]) == null)
         ;
       }
     }

    在迭代过程中,判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了 Map:
    注意到 modCount 声明为 volatile,保证线程之间修改的可见性。

    final Entry<K,V> nextEntry() {
        if (modCount != expectedModCount)
        throw new ConcurrentModificationException();

    在 HashMap 的 API 中指出:
    由所有 HashMap 类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的 remove 方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。
    注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。

    LinkedHashMap 的实现原理

    LinkedHashMap 概述

    LinkedHashMap 是 Map 接口的哈希表和链接列表实现,具有可预知的迭代顺序。此实现提供所有可选的映射操作,并允许使用 null 值和 null 键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
    LinkedHashMap 实现与 HashMap 的不同之处在于,后者维护着一个运行于所有条目的双重链接列表。此链接列表定义了迭代顺序,该迭代顺序可以是插入顺序或者是访问顺序。
    注意,此实现不是同步的。如果多个线程同时访问链接的哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。

    LinkedHashMap  的实现

    对于 LinkedHashMap 而言,它继承与 HashMap、底层使用哈希表与双向链表来保存所有元素。其基本操作与父类 HashMap 相似,它通过重写父类相关的方法,来实现自己的链接列表特性。下面我们来分析 LinkedHashMap 的源代码:

    1) Entry  元素

    LinkedHashMap 采用的 hash 算法和 HashMap 相同,但是它重新定义了数组中保存的元素 Entry,该 Entry 除了保存当前对象的引用外,还保存了其上一个元素 before 和下一个元素 after 的引用,从而在哈希表的基础上又构成了双向链接列表。

      /**
         * 双向链表的表头元素。
         */
        private transient Entry<K, V> header;
    
        /**
        * LinkedHashMap 的 Entry 元素。
        * 继承 HashMap 的 Entry 元素,又保存了其上一个元素 before 和下一个元素 after 的引用。
        */
         private static class Entry<K,V> extends HashMap.Entry<K,V> {
             Entry<K,V> before, after;
             ……
         }

    2)  初始化

    通过源代码可以看出,在 LinkedHashMap 的构造方法中,实际调用了父类 HashMap的相关构造方法来构造一个底层存放的 table 数组。如:

     public LinkedHashMap(int initialCapacity, float loadFactor) {
       super(initialCapacity, loadFactor);
       accessOrder = false;
     }

    HashMap 中的相关构造方法

        public HashMap(int initialCapacity, float loadFactor) {
            if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
            initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
            loadFactor);
            
             // Find a power of 2 >= initialCapacity
             int capacity = 1;
             while (capacity < initialCapacity)
             capacity <<= 1;
            
             this.loadFactor = loadFactor;
             threshold = (int)(capacity * loadFactor);
             table = new Entry[capacity];
             init();
        }

    我们已经知道 LinkedHashMap 的 Entry 元素继承 HashMap 的 Entry,提供了双向链表的功能。在上述 HashMap 的构造器中,最后会调用 init()方法,进行相关的初始化,这个方法在 HashMap 的实现中并无意义,只是提供给子类实现相关的初始化调用。

    LinkedHashMap 重写了 init()方法,在调用父类的构造方法完成构造后,进一步实现了对其元素 Entry 的初始化操作。

     void init() {
       header = new Entry<K,V>(-1, null, null, null);
       header.before = header.after = header;
     }

    3) 存储

    LinkedHashMap 并未重写父类 HashMap 的 put 方法,而是重写了父类 HashMap 的put 方法调用的子方法 void addEntry(int hash, K key, V value, int bucketIndex) 和 void createEntry(int hash, K key, V value, int bucketIndex),提供了自己特有的双向链接列表的实现。

    void addEntry(int hash, K key, V value, int bucketIndex) {
            // 调用 create 方法,将新元素以双向链表的的形式加入到映射中。
            createEntry(hash, key, value, bucketIndex);
    
            // 删除最近最少使用元素的策略定义
            Entry<K, V> eldest = header.after;
            if (removeEldestEntry(eldest)) {
                removeEntryForKey(eldest.key);
            } else {
                if (size >= threshold)
                    resize(2 * table.length);
            }
        }
     void createEntry(int hash, K key, V value, int bucketIndex) {
       HashMap.Entry<K,V> old = table[bucketIndex];
       Entry<K,V> e = new Entry<K,V>(hash, key, value, old);
       table[bucketIndex] = e;
       // 调用元素的 addBrefore 方法,将元素加入到哈希、双向链接列表。
       e.addBefore(header);
       size++;
     }
     private void addBefore(Entry<K,V> existingEntry) {
       after = existingEntry;
       before = existingEntry.before;
       before.after = this;
       after.before = this;
     }

    4) 读取

    LinkedHashMap 重写了父类 HashMap 的 get 方法,实际在调用父类 getEntry()方法取得查找的元素后,再判断当排序模式 accessOrder 为 true 时,记录访问顺序,将最新访问的元素添加到双向链表的表头,并从原来的位置删除。由于的链表的增加、删除操作是常量级的,故并不会带来性能的损失。

     public V get(Object key) {
       // 调用父类 HashMap 的 getEntry()方法,取得要查找的元素。
       Entry<K,V> e = (Entry<K,V>)getEntry(key);
       if (e == null)
         return null;
       // 记录访问顺序。
       e.recordAccess(this);
       return e.value;
     }
     void recordAccess(HashMap<K,V> m) {
       LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
       // 如果定义了 LinkedHashMap 的迭代顺序为访问顺序,
       // 则删除以前位置上的元素,并将最新访问的元素添加到链表表头。
       if (lm.accessOrder) {
         lm.modCount++;
         remove();
         addBefore(lm.header);
       }
     }

    5) 排序模式

    LinkedHashMap 定义了排序模式 accessOrder,该属性为 boolean 型变量,对于访问顺序,为 true;对于插入顺序,则为 false。

    private final boolean accessOrder;

    一般情况下,不必指定排序模式,其迭代顺序即为默认为插入顺序。看 LinkedHashMap的构造方法,如:

     public LinkedHashMap(int initialCapacity, float loadFactor) {
         super(initialCapacity, loadFactor);
         accessOrder = false;
     }

    这些构造方法都会默认指定排序模式为插入顺序。如果你想构造一个LinkedHashMap,并打算按从近期访问最少到近期访问最多的顺序(即访问顺序)来保存元素,那么使用下面的构造方法构造 LinkedHashMap:

     public LinkedHashMap(int initialCapacity, float loadFactor, boolean accessOrder) {
       super(initialCapacity, loadFactor);
       this.accessOrder = accessOrder;
     }

    该哈希映射的迭代顺序就是最后访问其条目的顺序,这种映射很适合构建 LRU 缓存。LinkedHashMap 提供了 removeEldestEntry(Map.Entry<K,V> eldest)方法,在将新条目插入到映射后,put 和 putAll 将调用此方法。该方法可以提供在每次添加新条目时移除最旧条
    目的实现程序,默认返回 false,这样,此映射的行为将类似于正常映射,即永远不能移除最旧的元素。

     protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
       return false;
     }

    此方法通常不以任何方式修改映射,相反允许映射在其返回值的指引下进行自我修改。如果用此映射构建 LRU 缓存,则非常方便,它允许映射通过删除旧条目来减少内存损耗。例如:重写此方法,维持此映射只保存 100 个条目的稳定状态,在每次添加新条目时删除最旧的条目。

     private static final int MAX_ENTRIES = 100;
     protected boolean removeEldestEntry(Map.Entry eldest) {
       return size() > MAX_ENTRIES;
     }

    HashMap使用注意事项

    1. 声明HashMap时的键值可以是任意对象
    2. 如果有重复的键,会把以前的替换
    3. 值能为空
    4. 键能为空,但这样写没什么意义
    5. put方法的返回值
      • 如果键是第一次存储,就直接存储元素,返回null
      • 如果键不是第一次存在,就用值把以前的值替换掉,返回以前的值

    Map集合的遍历一(键找值)

    Map集合的遍历二(键值对对象找键和值)

    1. 获取所有键值对对象(Entry)的集合
    2. 遍历键值对对象的集合,获取到每一个键值对对象
    3. Entry这个对象相当于内部有个key和value属性
    4. 根据键值对对象找键和值

    键值对对象找键和值源码分析

    1.Map.Entry理解成"键值对对象"

    2.Map.Entry是一个接口,它的实现类对象是HashMap$Node

    3.Map.Entry是有个key和value属性,通过get方法可以取值

    4.遍历Entry的两种方法,通过迭代器和for增强

    LinkedHashMap和HashMap的区别

    • LinkedHashMap的特点:底层是链表实现的可以保证怎么存就怎么取
    • HashMap是存的和取的顺序是不一样的

    TreeMap集合

    • TreeMap是Map接口的实现类
    • TreeMap与TreeSet一样,是可以排序的

    实现排序的两种方法

    1.pojo内部实现comparable接口

    2.在new TreeMap中传入匿名类comparator

     

    HashMap和Hashtable的区别

    • Hashtable是JDK1.0版本出现的,是线程安全的,效率低,有加锁,HashMap是JDK1.2版本出现的,是线程不安全的,效率高
    • Hashtable不可以存储null键和null值,HashMap可以存储null键和null值

    Collections工具类常见方法

    • public static <T> void sort(List<T> list)
    • public static <T> int binarySearch(List<?> list,T key)
    • public static <T> T max(Collection<?> coll)
    • public static void reverse(List<?> list)
    • public static void shuffle(List<?> list)
  • 相关阅读:
    Beta 冲刺(1/7)
    福大软工 · 第十次作业
    福大软工 · BETA 版冲刺前准备(团队)
    福大软工 · 第十一次作业
    Alpha 冲刺 (10/10)
    Alpha 冲刺 (9/10)
    Alpha 冲刺 (8/10)
    vue 写一个瀑布流插件
    微信小程序页面滚动到指定位置
    写一个vue的滚动条插件
  • 原文地址:https://www.cnblogs.com/aaron911/p/9861356.html
Copyright © 2020-2023  润新知