08-集合

集合

数组就是一个集合,集合实质上就是一个容器,可以容纳其他类型的数据;JDBC编程中通过select关键字查询出来的结果就是放在ResultSet集合当中,将集合传到前端然后遍历集合,将数据都展现出来。

集合不能直接存储基本数据类型,集合也不能直接存储java对象;集合中存储的是引用

注意:集合在Java中本身是一个容器;集合中任何时候存储的都是引用

image-20221015212327204

Java中不同的集合底层会对对应不同的数据结构,往不同的集合中存储元素,等于将数据放到了不同的数据结构中;例如:数组,二叉树、链表、哈希表等

new ArrayList() 创建一个集合对象,底层是数组

new LinkedList() 创建一个集合对象,底层是链表

new TreeSet()创建一个集合对象,底层是红黑树

所有的集合类和接口都在java.util.* 包下。

集合继承结构

在Java中集合分为两类:

  1. 单个方式存储元素,这类集合的超级父接口是java.util.Collection
  • Iterable(interface) 可迭代的,可遍历的
    • Collection(interface)
      • List(interface) 特点:有序(存储顺序和取出顺序是相同的)、可重复、下标存储(0 ~ n)
        • ArrayList(class) 底层采用数组实现、非线程安全
        • LinkedList(class) 底层采用双向链表实现
        • Vector(class) 底层采用数组实现、线程安全、效率较低
      • Set (interface) 特点:无序、不能重复、没有下标
        • HashSet(class) 底层实际上是HashMap集合
        • TreeSet(class) SortedSet接口继承自Set接口,TreeSet实现了SortedSet接口 ;底层实际上是TreeMap,TreeMap底层采用了红黑树数据结构
        • LinkedHashSet:有序、不可重复、无下标

Iterable 接口中有 Iterator<T> iterator() 方法,该方法会返回一个Iterator集合的迭代器对象,该对象是用来遍历集合中的元素;所有集合继承Iterable的意思就是所有的集合都是可迭代的

Iterator it = Collection_Name.iterator();

Iteratornext()hasNext()remove()方法来完成迭代。

SortedSet继承自Set集合,所以它的特点是无序不可重复,但是放在该集合中的元素可以自动排序,我们称为可排序集合。

Syntax error in textmermaid version 11.4.1
  1. 键值对方式存储元素,这类集合的超级父接口是java.util.Map
  • Map(interface)
    1、与Collection没有关系
    2、以Key-Value键值对方式存储元素(Key和Value都是存储内存地址
    3、所有Map集合的Key都是无序不可重复的
    4、Map集合的key和Set集合存储元素的特点相同
    • HashMap(class) 底层是哈希表数据结构、非线程安全
    • HashTable(class) 底层是哈希表数据结构、线程安全、效率较低
      • Properties 线程安全(继承自HashTable)、存储元素的时候Key-Value只支持String类型、被称为属性类
    • SortedMap (interface) SortedMap存储元素的特点:首先是无序不可重复的,但是Key元素会自动按照大小排序,称为可排序的集合
      • TreeMap ( class ) 底层用红黑树实现

Syntax error in textmermaid version 11.4.1

线程安全的集合效率都比较低,现在控制线程安全的方法较多,所以线程安全的集合使用都比较少

总结:

  • ArrayList 底层是数组
  • LinkedList 底层是双向链表
  • Vector 线程安全的数组
  • HashSet 底层是HashMap,放在HashSet集合中的元素等同于放在HashMap集合中Key部分了
  • TreeSet 底层是TreeMap,放在TreeSet集合中的元素等同于放在TreeMap集合的key部分了
  • HashMap 底层是HashTable
  • HashTable 线程安全的HashTable
  • Properties 线程安全的,Key-Value只能存储字符串
  • TreeMap 底层是二叉树TreeMap集合的Key可以自动按照大小顺序排序

List集合存储元素的特点:

  • 有序:存入的顺序和取出的顺序相同,每一个元素都有下标
  • 可重复:可以存入相同元素

Set(Map)集合存储元素的特点:

  • 无序:存入的顺序和取出的顺序不一定相同,Set集合中元素没有下标
  • 不可重复:不能存入相同元素

SortedSet(SortedMap)集合存储元素的特点:

  • 无序:继承自Set接口
  • 不可重复:继承自Set接口
  • 可排序:可以按照大小顺序排列

Map集合的Key 就是一个 Set集合;向Set集合中存储数据就是存入了Map集合的Key部分:

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

Collection

Collection存放的元素类型:没有使用“泛型”之前,可以存储Object的所有子类;使用“泛型”之后(直接泛型T),只能存储某个具体的子类型(集合中不能直接存储基本数据类型,也不能存储Java对象,只能存储Java对象的内存地址)

常用方法

image.png

boolean add(E e)

  • public boolean add(E e) 向集合中添加元素
public static void main(String[] args) {
    //Collection c = new Collection(); 接口是抽象的,无法实例化
    
    Collection c = new ArrayList(); //多态
    
    c.add(1200); //自动装箱,实际上是放入了 Integer x = Integer.valueOf(1200) 的 x 引用
    c.add(3.14);
    c.add(new Object());
    c.add(new Student());
    c.add(true); //自动装箱
    c.add("hello"); // 存储的是字符串对象的内存地址
}

在添加基本数据类型的时候,以int类型为例,装入的其实是通过Integer.valueOf()方法获取的Integer类型对象,valueOf方法内部有IntegerCache,在IntegerCache中直接获取 -128 ~ 127范围内的Integer类型对象,如果超过了这个范围,valueOf就会使用new关键字进行创建Integer对象

image-20230425225044970

127在IntegerCache的范围当中,使用双等号判断的结果是true

image-20230425225137200

128在IntegerCache的范围之外,使用双等号判断的结果是false

注意:add()方法的返回值:

  1. 向List接口添加元素,返回值永远为true
  2. 向Set接口添加元素,范围值可能是false

int size()

  • int size() 获取集合中元素个数
System.out.println("元素个数" + c.size());

void clear()

  • void clear() 清空集合
c.clear(); //元素个数为0

image.png

boolean contains(Object o)

  • boolean contains(Object o) 判断集合中是否有某个元素
boolean flag = c.contains("java");
System.out.println(flag);
/*
* boolean contains(Object o); 判断集合中是否包含某个对象o 如果包含返回true
* */
public class CollectionContainsTest01 {
    public static void main(String[] args) {
        Collection c = new ArrayList();

        String s1 = new String("abc");
        String s2 = new String("def");
        c.add(s1);
        c.add(s2);
        System.out.println("the number of elem is : " + c.size());

        String x = new String("abc");
        System.out.println(c.contains(x)); //结果为true 说明不是通过引用的内存地址进行判断
    }
}

是否包含x就看contains是否比较的是引用的内存地址(也就是是否调用了equals方法):

public boolean contains(Object o) {  
    return indexOf(o) >= 0;  
}  
  
public int indexOf(Object o) {  
    return indexOfRange(o, 0, size);  
}  
  
int indexOfRange(Object o, int start, int end) {  
    Object[] es = elementData;  //记录堆内存地址提高效率
    if (o == null) {  //null用 == 比较,非null用equals比较
        for (int i = start; i < end; i++) {  
            if (es[i] == null) {  
                return i;  
            }  
        }  
    } else {  
        for (int i = start; i < end; i++) {  
            if (o.equals(es[i])) {  //equals
                return i;  
            }  
        }  
    }  
    return -1;  
}

使用contains方法需要注意:

  1. 使用contains进行比较的时候,注意该类的equals方法是否重写(没有重写默认调用父类Object中的equals方法)
  2. 假如比较的是内存地址,那么如果有两个实例变量完全相同的Student对象(姓名、年龄、学号等)进行判断,得到的结果也是false(在没有重写equals方法的前提下)

未重写equals时(比较的是内存地址):

Student student1 = new Student(1,"lisi");
Student student2 = new Student(1,"lisi");
c.add(student2);
System.out.println(c.contains(student1));/*结果为false*/

这样显然是不合理的,所以contains方法调用重写的equals方法才是正确的选择

最终结论:放在集合中的元素需要重写equals方法

boolean remove(Object o)

  • boolean remove(Object o) 删除集合中的某个元素
c.remove("hello");

判断remove方法是否重写了equals方法:

Collection cc = new ArrayList();
String s1 = new String("hello");
String s2 = new String("hello");

cc.add(s1); 

cc.remove(s2);  // 如果重写equals方法 就会导致s1被清空
System.out.println(cc.size());

JDK8:

image.png

如果要删除的元素是null,通过 == 找到null的index,如果不是null就调用equals方法找到index

JDK21:

image.png

进入fastRemove方法:

JDK8:

image.png

modCount++,numMoved就是要移动的元素的个数 size - (index + 1)

移动的方法是 System.arraycopy(elementData,删除索引 + 1,elementData,删除索引,要移动的元素的个数)

最后让elementData最后一个位置的数据 = null,使得GC回收多余对象

通过equals方法判断两个元素是否相等,在两个Integer = 128时也可以成功删除

JDK21:

image.png

计算出新数组长度,如果新数组长度 = i,说明删除的就是最后一个元素,不需要移动元素

boolean isEmpty()

  • boolean isEmpty() 判断集合中是否存在元素(抽象方法由子类实现)
boolean isEmpty = c.isEmpty(); //return size == 0;

Object[] toArray()

  • Object[] toArray() 将集合中所有元素转换成数组
Object[] objs = c.toArray();
System.out.println(Arrays.toString(objs));
/*[hello, world, java, c#, Student@58372a00]*/

for (int i = 0; i < objs.length; i++) {
     System.out.println(objs[i]);//自动调用toString方法 Student没有重写toString方法
}

image.png

注意结果是Object[]类型的

<T> T[] toArray(T[] a)

这个方法将集合中的元素存入参数数组,并将这个数组返回

创建指定类型的数组:

image-20230403145452617

image.png

首先判断数组的长度是否小于集合中元素的个数,如果小于,通过Arrays.copyOf创建一个length = size的数组,并拷贝进元素,如果数组长度大于集合中元素的个数,最后的判断有什么意义?

只有当调用者知道列表不包含任何null元素时,这才有助于确定列表的长度

重点是如何创建一个指定类型的数组:

@IntrinsicCandidate  
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {  
    @SuppressWarnings("unchecked")  
    T[] copy = ((Object)newType == (Object)Object[].class)  
        ? (T[]) new Object[newLength]  
        : (T[]) Array.newInstance(newType.getComponentType(), newLength);  
    System.arraycopy(original, 0, copy, 0,  
                     Math.min(original.length, newLength));  
    return copy;  
}

将T[].class传入copyOf方法,通过反射调用newInstance创建对象,所以说当指定的数组长度 < size的时候,在第一次判断中创建并返回。

简略写法就是:

public <T> T[] toArray(T[] a) {  
    if (a.length < size){  
        // Make a new array of a's runtime type, but my contents:  
        T[] newArr = (T[])Array.newInstance(a.getClass().getComponentType(), size);  
        System.arraycopy(elementData,0,newArr,0,size);  
        return newArr;  
    }  
    System.arraycopy(elementData, 0, a, 0, size);  
    if (a.length > size)  
        a[size] = null;  
    return a;  
}

注意:在使用这个方法时,最好将参数数组长度设置为集合中元素的个数,可以省去一次创建数组的操作

迭代器 Iterator

用来迭代集合中的元素,是一个接口:

image.png

ArrayList中的私有成员内部类实现了这个接口:

image.png

迭代器的游标cursor最开始指向了0索引

因为这个内部类是私有的,在外部不能创建对象,只能提供iterator方法返回,并且使用父类型Iterator接收

方法

boolean hasNext()

image.png
是否有下一个元素,每次迭代后cursor向后移动,在index = length - 1迭代后cursor = size

E next()

image.png

next方法的返回值是上一个位置的元素,但是在判断是否越界时是在cursor移动之前进行的

其中使用到了checkForComodification()方法,这个方法是判断集合是否被当前线程修改的:

image.png

modCount是ArrayList的成员变量,ArrayList的成员方法对集合进行了修改都会触发这个标志位改变:

image.png

image.png

而迭代器记录的expectedModCount是在获取迭代器时被初始化了,如果在迭代期间集合进行了更改,就会抛出并发修改异常。

void remove()

从迭代器指向的 collection 中移除迭代器返回的最后一个元素(可选操作)。每次调用 next 只能调用一次此方法。如果进行迭代时用调用此方法之外的其他方式修改了该迭代器所指向的 collection,则迭代器的行为是不确定的。

image.png

当前游标cursor = 0时lastRet = -1,此时进行删除会抛出IllegalStateException,remove方法删除的也是上一个位置的元素

调用集合的remove方法删除元素,并且将游标移动到刚刚删除的位置(数组已移除这个元素),将lastRet种植为 -1,说明不能进行连续两次的remove操作,并将期待的modCount置为当前的modCount

ArrayList<Integer> list = new ArrayList<>(){{  
    add(1);  
    add(2);  
    add(3);  
}};  
  
Iterator<Integer> iterator = list.iterator();  
  
while (iterator.hasNext()){  
    //将游标下一一位,然后返回上一个位置的元素  
    Integer element = iterator.next();  
    System.out.println(element);  
}

HashSet集合:

Collection hashSet = new HashSet();
hashSet.add(100);
hashSet.add(200);
hashSet.add(300);
hashSet.add(100);

Iterator iterator = hashSet.iterator();
while (iterator.hasNext()){
    System.out.println(iterator.next());
}
/*
100
200
300
*/

说明Set集合中不能重复,但是不会报错

void forEachRemaining(Consumer<? super E> action)

用来遍历当前迭代器指向及其之后的元素,遍历结束cursor = size

image.png

细节

  1. 如果hasNext() == false还要继续使用next()方法,报错:“NoSuchElementException”
  2. 迭代器遍历完毕,指针不会复位:如果想再次遍历集合只能获取新迭代器对象
  3. 循环中只能用一次next方法
  4. 迭代器遍历的时候,不能用集合的方法进行删除或修改,集合结构改变必须重新获取迭代器
  5. 在使用迭代器或增强for遍历集合的过程中,不要使用集合的方法修改结构

但如果是最后一次遍历,是可以删除集合中的元素的,不会发生并发修改异常

如果将迭代器的获取放在add操作前进行:

public static void main(String[] args) {
    Collection c = new ArrayList();

    Iterator iterator = c.iterator(); //此时的迭代器指向的是集合中没有元素状态下的迭代器
    //注意:集合结构只要发生改变 迭代器必须重新获取
    
    c.add(1);
    c.add(2);
    c.add(3);
    
    while (iterator.hasNext()){ 
        Object obj = iterator.next(); ////`ConcurrentModificationException`
        System.out.println(obj);
    }
}

当集合结构发生改变,没有重新获取迭代器就调用next方法时 会发生ConcurrentModificationException并发修改异常

  • 如果在while循环中改变了集合的结构:
public class CollectionRemoveTest01 {
    public static void main(String[] args) {
        Collection cc = new ArrayList();
        cc.add("abc");
        cc.add("def");
        cc.add("xyz");

        Iterator iterator = cc.iterator();
        while (iterator.hasNext()){
            Object o = iterator.next();
            cc.remove(o); //删除元素之后 集合的结构发生了变化,但是循环下一次的时候迭代器没有更新
            System.out.println(o);
        }
    }
}

说明:在迭代的过程中不能改变集合的结构,否则会出现ConcurrentModificationException异常

但是在遍历到“xyz”的时候删除“abc”是没有问题的,因为此时的next()方法不会继续调用,不会检查modCount

增强for

since jdk1.5

  • 增强for底层就是迭代器,这是为了简化代码书写的
  • 只有Collection和数组可以用增强for遍历
for(DataType dataName : Collection_name){
    System.out.println(dataName);
}

快速生成:Collection_name.for

        Collection<String> coll = new ArrayList<>();
        coll.add("aaa");
        coll.add("bbb");
        coll.add("ccc");
        coll.add("ddd");

        for (String s : coll) {
            s = "qqq";
        }

        for (String s : coll) {
            System.out.println(s);//aaa bbb ccc ddd
        }

发现:增强for中的变量,不会改变集合中原本的数据;只是让s指向了其他的数据,也就是说增强for中的变量只是接收了集合中变量的值,并不代表这个元素本身;但是可以通过set方法操作对象内部的细节。

Lambda表达式遍历

default void forEach(Consumer <? super T> action)
    public static void main(String[] args) {
        Collection<String> coll = new ArrayList<>();
        coll.add("aaa");
        coll.add("bbb");
        coll.add("ccc");
        coll.add("ddd");

        coll.forEach(new Consumer<String>() {
            @Override
            //s 表示集合中的每一个数据
            public void accept(String s) {
                System.out.println(s);
            }
        });
    }

image-20230326140508374

elementAt(es,i)方法将迭代集合中每一个元素传递给accept方法,由accept方法进行处理,forEach方法的参数Consumer:

image-20230326140815460

由调用者自行实现accept方法的方法体,决定该方法的行为。

简化形式:

        Collection<String> coll = new ArrayList<>();
        coll.add("aaa");
        coll.add("bbb");
        coll.add("ccc");
        coll.add("ddd");

        coll.forEach(s -> System.out.println(s));

toString()

@Override  
public String toString() {  
    Iterator<E> iterator = iterator();  
    if (!iterator.hasNext())  
        return "[]";  
  
    StringBuilder builder = new StringBuilder("[");  
    for ( ; ; ){  
        E e = iterator.next();  
        builder.append(e == this ? "(this collection)" : e);  
        if (!iterator().hasNext()){  
            return builder.append("]").toString();  
        }  
        builder.append(",");  
    }  
}

使用空for循环,这样可以避免报错:缺少返回语句

并且如果出现list.add(list) 打印的时候内部的list被替换为this collection

List

List集合存储元素特点:有序(有下标 LinkedList底层是双向链表 但是也有下标)、可重复

常用方法

List接口继承自Collection,List多了索引,新增了一些和索引有关的方法

想使用List接口的方法,不能使用Collection c = ...创建对象,因为这些方法是子类接口List中特有的方法

public static void main(String[] args) {
        List myList = new ArrayList();
}

void add(int index, E element)

向指定下标插入元素(数组向指定位置添加元素效率较低) 没有add使用频繁

public static void main(String[] args) {
    List myList = new ArrayList();

    myList.add("A");
    myList.add("B");
    myList.add("C");
    myList.add("D"); //默认向集合末尾添加元素

    myList.add(1,"king"); 
    Iterator iterator = myList.iterator();
    while (iterator.hasNext()){
        Object obj = iterator.next();
        System.out.print(obj + " ");
    }
}
/*A king B C D */

add(int,E)方法的源代码,以ArrayList为例:

public void add(int index, E element) {  
    rangeCheckForAdd(index);  
    /*
	if (index > size || index < 0)  
	    throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
    */
    modCount++;  
    final int s;  
    Object[] elementData;  
    if ((s = size) == (elementData = this.elementData).length) //集合元素个数等于elementData数组长度
        elementData = grow();  
    System.arraycopy(elementData, index,  
                     elementData, index + 1,  
                     s - index);  
    elementData[index] = element;  
    size = s + 1;  
}

扩容机制

grow()方法:

  
private Object[] grow() {  
    return grow(size + 1);  
}

private Object[] grow(int minCapacity) {  
    int oldCapacity = elementData.length;  
    if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {  
        int newCapacity = ArraysSupport.newLength(oldCapacity,  
                minCapacity - oldCapacity, /* minimum growth */  
                oldCapacity >> 1           /* preferred growth */);  
        return elementData = Arrays.copyOf(elementData, newCapacity);  
    } else {  
        return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];  
    }  
}

首先会判断是否时无参构造方法创建的DEFAULTCAPACITY_EMPTY_ELEMENTDATA(空的Object数组),或者是oldCapacity是否大于0,也就是是否第一次添加元素

  • 如果为true,进入else:

直接创建长度为Math.max(DEFAULT_CAPACITY, minCapacity)的Object数组赋值给elementData,DEFAULT_CAPACITY=10

  • 如果为false,进入if:

ArraysSupport.newLength计算出新数组长度,在[[006-String#append()|StringBuilder的扩容机制]]里介绍过,该方法需要三个参数:

  1. 老容量
  2. 最小新增容量
  3. 偏好新增:老容量/2
public static int newLength(int oldLength, int minGrowth, int prefGrowth) {  
    // preconditions not checked because of inlining  
    // assert oldLength >= 0    // assert minGrowth > 0  
    int prefLength = oldLength + Math.max(minGrowth, prefGrowth); // might overflow  
    if (0 < prefLength && prefLength <= SOFT_MAX_ARRAY_LENGTH) {  
        return prefLength;  
    } else {  
        // put code cold in a separate method  
        return hugeLength(oldLength, minGrowth);  
    }  
}

计算出新数组的长度prefLength = oldLength + Math.max(minGrowth, prefGrowth),也就是:

  • 如果需要新增的元素个数 > 老容量 / 2:增加到需要的容量
  • 如果需要新增的元素个数 < 老容量 / 2:增加到原先容量的1.5倍

总的来说就是,如果扩容到1.5倍装不下元素,就扩容到需要的容量。

之后将新数组长度返回给 elementData = Arrays.copyOf(elementData, newCapacity),根据newCapacity创建出数组并进行拷贝,但是在[[003-数组、排序、查找#数组的拷贝与扩容|数组的拷贝与扩容]]中讲过,数组扩容效率较低,尽可能少用数组扩容。

优化:尽可能少的扩容,数组扩容效率较低。使用ArrayList集合的时候预估计元素的个数。

测试:

long begin = System.currentTimeMillis();
for (int i = 0; i < 5000000; i++) {
    list1.add(new Object());
}
long end = System.currentTimeMillis();
System.out.println("数组扩容需要:" + (end - begin));

begin = System.currentTimeMillis();
for (int i = 0; i < 5000000; i++) {
    list2.add(new Object());
}
end = System.currentTimeMillis();
System.out.println("指定初始化容量需要:" + (end - begin));

数组扩容需要:161
指定初始化容量需要:120

E get(int index)

String firstStr = myList.get(0);
System.out.println(firstStr);

//第二种遍历方式 【List集合特有】
for (int i = 0; i < myList.size(); i++) {
    System.out.print(myList.get(i) + " ");
}

int indexOf(Object o)

获取指定对象第一次出现时的索引

System.out.println(myList.indexOf("king"));

ArrayList源代码:

public int indexOf(Object o) {  
    return indexOfRange(o, 0, size);  
}  
  
int indexOfRange(Object o, int start, int end) {  
    Object[] es = elementData;  
    if (o == null) {  
        for (int i = start; i < end; i++) {  
            if (es[i] == null) {  
                return i;  
            }  
        }  
    } else {  
        for (int i = start; i < end; i++) {  
            if (o.equals(es[i])) {  
                return i;  
            }  
        }  
    }  
    return -1;  
}

int lastIndexOf(Object o)

最后一次出现的索引

public int lastIndexOf(Object o) {  
    return lastIndexOfRange(o, 0, size);  
}

int lastIndexOfRange(Object o, int start, int end) {  
    Object[] es = elementData;  
    if (o == null) {  
        for (int i = end - 1; i >= start; i--) {  
            if (es[i] == null) {  
                return i;  
            }  
        }  
    } else {  
        for (int i = end - 1; i >= start; i--) {  
            if (o.equals(es[i])) {  
                return i;  
            }  
        }  
    }  
    return -1;  
}

就是进行倒序遍历

E remove(int index)

删除指定下标位置的元素并返回

System.out.println(myList.remove(0));

ArrayList源代码:

public E remove(int index) {  
    Objects.checkIndex(index, size);  
    final Object[] es = elementData;  
  
    @SuppressWarnings("unchecked") 
    E oldValue = (E) es[index];  
    fastRemove(es, index);  
    return oldValue;  
}

private void fastRemove(Object[] es, int i) {  
    modCount++;  
    final int newSize;  
    if ((newSize = size - 1) > i)  
        System.arraycopy(es, i + 1, es, i, newSize - i);  
    es[size = newSize] = null;  
}

和Collection接口中的boolean remove(Object o)方法是不同的:

image-20230326142048503

这里默认调用的是Collection接口的方法,原因在[[第8章 虚拟机字节码执行引擎#静态分派|静态分派]]中解释过,因为字面量没有显式的静态类型,会自动选择一个合适的版本,1 默认是当作int类型的,就会调用List接口中的remove(int)方法,根据索引删除元素。而不是调用Collection接口中的remove(Object)删除指定元素

如果想删除数据1,可以手动装箱:

image-20230326142402813

此时的Integer就是Object类型的。

E set(int index, E element)

修改指定位置的元素,并将之前的元素返回

String set = myList.set(2, "Soft");
public E set(int index, E element) {  
    Objects.checkIndex(index, size);  
    E oldValue = elementData(index);  
    elementData[index] = element;  
    return oldValue;  
}

没有修改modCount,不会触发并发修改异常

ListIterator

列表迭代器,继承自Iterator

image.png

add方法可以在游标指向的位置添加元素

public void add(E e) {  
    checkForComodification();  
  
    try {  
        int i = cursor;  
        ArrayList.this.add(i, e);  
        cursor = i + 1;  
        lastRet = -1;  
        expectedModCount = modCount;  
    } catch (IndexOutOfBoundsException ex) {  
        throw new ConcurrentModificationException();  
    }  
}

插入到游标cursor指向的位置,永远都是插入到后一个位置。

并且插入之后会让游标 + 1,也就是再用next获取不到刚刚插入的元素,并且插入后不能立刻进行删除操作

while (integerListIterator.hasNext()){  
    Integer next = integerListIterator.next();  
  
    if (next.equals(1)){  
        integerListIterator.add(2);  
    }else if (next.equals(3)){  
        integerListIterator.add(4);  
    }  
    //integerListIterator.remove();  IllegalStateException  
    System.out.println(next); // 1,3,5  
}  
System.out.println(integers); //1,2,3,4,5

ArrayList

  • 非线程安全,底层是数组
  • 初始化容量为0,无参构造创建空数组DEFAULTCAPACITY_EMPTY_ELEMENTDATA,第一次添加不超过10个元素时扩容到10

size的含义:

  1. 元素个数
  2. 下一次插入元素的位置
  • 优点:检索效率比较高(每个元素占用空间大小相同内存地址是连续的,知道首元素地址通过数学表达式就可以定位元素)
  • 缺点:随机增删效率很低(向数组末尾添加元素效率很高)

image.png

添加到末尾不需要移动元素,但是可能涉及到数组的扩容。

  • 底层是Object数组
transient Object[] elementData; 
  • 可以指定初始化容量
//默认初始化容量是0
List list1 = new ArrayList();
//指定初始化容量是20 数组的长度是20
List list2 = new ArrayList(10000);

//size()方法是获取当前集合中元素的个数,不是获取集合容量
System.out.println(list1.size()); /**0*/

构造方法

image.png

可以指定初始化容量,或者根据Collection集合创建一个ArrayList

public static void main(String[] args) {
    List myList1 = new ArrayList();
    List myList2 = new ArrayList(100);

    Collection c = new HashSet();
    c.add(100);
    c.add(200);
    c.add(900);
    c.add(50);
    List myList3 = new ArrayList(c); //通过这个构造方法就可以将HashSet转换为List集合
    for (int i = 0; i < myList3.size(); i++) {
        System.out.print(myList3.get(i) + " ");
    }
}

特点

  • 扩容机制

如果添加元素的个数 > ArrayList长度 - 原有个数,进行扩容

如果扩容到1.5倍还装不下,就扩容到需要的容量

  • ArrayList#set(index, element):只是替换,不会扩容和拷贝,不会修改modCount
  • ArrayList#add(e):尾部插入,只有当数组满了才扩容
  • ArrayList#add(index, element):指定位置插入,不一定扩容,但会触发数组拷贝,尽量避免使用

获取ArrayList

  • List.of(T ... t) 获取的是[[009-集合#不可变集合 | 不可变集合]]
  • Arrays.asList(T ... t) 获取的是 [[003-数组、排序、查找#Arrays.asList(T ... a)| 伪不可变集合]]

LinkedList

底层是双向链表数据结构。

对于链表数据结构来说,基本的单元是节点Node,对于单向链表来说,任何一个节点Node都有两个属性:

  1. 存储的数据
  2. 下一个节点的内存地址

优点:随机增删元素效率较高(没有后续元素的位移),但是找到该元素需要遍历

缺点:查询效率较低(每一次查找某个元素时都需要从头节点开始往下遍历)

但是操作首尾元素效率很高,在LinkedList中多了很多操作首位元素的API(这些API在ArrayList中也是有的)

image.png

模拟SingleList

节点Node:

class Node<E>{  
    E element;  
    Node next;  
  
    public Node() {  
    }  
  
    public Node(E element, Node next) {  
        this.element = element;  
        this.next = next;  
    }  
}

SingleLink的属性:

class SingleLink<E>{  
    Node<E> header;  
    int size;  
  
    public SingleLink() {  
        this.header = null;  
        this.size = 0;  
    }  
}

给定指定节点,找到最后一个节点:

public Node<E> findLast(Node node){  
    if (node.next == null){  
        return node;  
    }  
    return findLast(node.next);  
}

非递归实现:

public Node<E> findLast(Node node){  
    while (node.next != null){  
        node = node.next;  
    }  
    return node;  
}

添加元素的方法:

public void add(E e){  
    if (header == null){  
        header = new Node<>(e,null);  
    }else {  
        Node<E> last = findLast(header);  
        last.next = new Node(e,null);  
    }  
}

测试:

SingleLink<Integer> link = new SingleLink<>();  
System.out.println(link.size);  
link.add(100);  
link.add(200);  
link.add(300);  
System.out.println("第一个节点的地址是"+ link.header + " 数据域是:" + link.header.element + " 指针域是:" + link.header.next);  
System.out.println("第二个节点的地址是"+ link.header.next + " 数据域是:" + link.header.next.element + " 指针域是:" + link.header.next.next);  
System.out.println("第三个节点的地址是"+ link.header.next.next + " 数据域是:" + link.header.next.next.element + " 指针域是:" + link.header.next.next.next);

LinkedList

双向链表基本的单元还是节点Node,由三部分组成:

  1. 数据
  2. 下个节点内存地址
  3. 上个节点内存地址
class Node<E>{
    Node<E> pre;
    E element;
    Node<E> next;
}

image.png

LinkedList成员:

image.png

构造方法

image.png

关注有参构造

public LinkedList(Collection<? extends E> c) {  
    this();  
    addAll(c);  
}

public boolean addAll(Collection<? extends E> c) {  
    return addAll(size, c);  
}

public boolean addAll(int index, Collection<? extends E> c) {  //size作为下一次添加元素的首地址 
    checkPositionIndex(index);  
  
    Object[] a = c.toArray();  
    int numNew = a.length;  
    if (numNew == 0)  
        return false;  
  
    Node<E> pred, succ;  
    if (index == size) {  //如果在末尾追加元素
        succ = null;      
        pred = last;    //记录尾节点地址
    } else {  
        succ = node(index);  
        pred = succ.prev;  
    }  
  
    for (Object o : a) {  
        @SuppressWarnings("unchecked")
        E e = (E) o;  
        Node<E> newNode = new Node<>(pred, e, null);  
        if (pred == null)  
            first = newNode;  
        else  
            pred.next = newNode;  
        pred = newNode;  
    }  
  
    if (succ == null) {  
        last = pred;  
    } else {  
        pred.next = succ;  
        succ.prev = pred;  
    }  
  
    size += numNew;  
    modCount++;  
    return true;  
}

boolean add(E e)

public boolean add(E e) {  
    linkLast(e);  
    return true;  
}

void linkLast(E e) {  
    final Node<E> l = last;   //记录尾节点
    final Node<E> newNode = new Node<>(l, e, null);  //构建新节点,pre指针域指向尾节点
    last = newNode;  //尾节点 = 新节点
    if (l == null)  //如果尾节点为空,代表链表中没有元素
        first = newNode;  //头尾节点指向同一个节点
    else          //链表中有元素
        l.next = newNode;  //原先尾节点指向现在的尾节点
    size++;  
    modCount++;  
}

image-20230326173039169

特点

  • 查询

  • 尽量使用getFirst()/getLast(),很快,因为内部维护了头尾节点

  • 避免使用get(index),内部包含遍历,较慢

  • 头尾插入

  • 尽量使用addFirst(e)/addLast(e)/add(e),都是对头尾节点的操作,很快

  • 中间插入/替换

  • 避免使用set(i, e)和add(i, e),内部需要先遍历再插入/替换

  • 删除

  • 尽量使用removeFirst()/remove()/removeLast(),都是对头尾节点的操作,很快

  • 避免使用remove(i)/remove(e),内部包含遍历,较慢

Vector

  • 初始化容量是10
public Vector() {
        this(10);
    }
  • 扩容之后是原容量的2倍
public synchronized boolean add(E e) {
        modCount++;
        add(e, elementData, elementCount);
        return true;
    }
private void add(E e, Object[] elementData, int s) {
        if (s == elementData.length)
            elementData = grow();
        elementData[s] = e;
        elementCount = s + 1;
    }
private Object[] grow() {
        return grow(elementCount + 1);
    }
private Object[] grow(int minCapacity) {
        int oldCapacity = elementData.length;
        int newCapacity = ArraysSupport.newLength(oldCapacity,
                minCapacity - oldCapacity, /* minimum growth */
                capacityIncrement > 0 ? capacityIncrement : oldCapacity
                                           /* preferred growth */);
        return elementData = Arrays.copyOf(elementData, newCapacity);
    }

其中:

/**
 * The amount by which the capacity of the vector is automatically
 * incremented when its size becomes greater than its capacity.  If
 * the capacity increment is less than or equal to zero, the capacity
 * of the vector is doubled each time it needs to grow.
 *
 * @serial
 */
protected int capacityIncrement;
  • 其中所有方法都是线程安全的,使用较少

集合转换

把线程不安全的ArrayList转换为线程安全的:

  • 集合工具类 java.util.Collections
		List myList = new ArrayList();
        Collections.synchronizedList(myList);
        myList.add("a");
        myList.add("b");
        myList.add("c");   

synchronizedList可以直接把myList变为线程安全的

Set

  • 无序:存取顺序不一致
  • 不重复:可以进行去重
  • 无索引:没有带索引的方法,不能使用普通for循环遍历,也不能通过索引来获取元素

有继承自Collection接口的方法,注意add返回值可能是false

HashSet

Set<String> strs = new HashSet<>();

strs.add("hello3");
strs.add("hello4");
strs.add("hello1");
strs.add("hello2");
strs.add("hello1");
strs.add("hello1");
strs.add("hello1");
strs.add("hello1");
strs.add("hello1");

for(String str : strs){
    System.out.print(str + " ");
}
  1. 存储顺序和取出的顺序不同
  2. 不可重复
  3. 放到HashSet集合中的元素实际上是放在HashMap集合的Key部分了

底层采用哈希表存储数据

  • 哈希表是一种增删改查数据都很好的结构
  • JDK8之前:数组+链表;JDK8开始:数组+链表+红黑树
  1. 数组:占用空间连续。 寻址容易,查询速度快。但是,增加和删除效率非常低。
  2. 链表:占用空间不连续。 寻址困难,查询速度慢。但是,增加和删除效率非常高。

哈希表可以通过“数组+链表”的方式来实现,还可以通过“数组+链表+红黑树”来实现,是一种非常重要的数据结构。在哈希表中进行添加、删除、查找和修改等操作,性能高,不考虑哈希冲突的情况下,仅需一次定位即可完成。

哈希值:对象的整数表现形式

如图,如果要向哈希表中添加元素,首先要根据公式 $index= (数组长度 - 1)&哈希值$ 计算出数组的下标

image-20230328170019156

哈希值是根据hashCode方法计算处理的int类型整数,该方法定义在Object类型中,所有对象都可以调用,默认使用地址值进行计算;一般情况下都会重写hashCode方法,利用对象内部的属性值计算哈希值

  • 如果没有重写hashCode方法,不同对象即使相同的属性计算出的哈希值是不同的
  • 如果已经重写hashCode方法,不同对象只要属性值相同,计算出的哈希值就是相同的
  • 在小部分情况下,不同属性值或不同的地址值计算出来的哈希值有可能相同(哈希碰撞)
    • 例如:int类型只有42亿,如果有50亿对象就会有8亿哈希碰撞

在没有重写hashCode方法时:

        Student s1 = new Student("zhangsan", 23);
        Student s2 = new Student("zhangsan", 23);
        System.out.println(s1.hashCode()); //1452126962
        System.out.println(s2.hashCode()); //931919113    不同对象计算出来的哈希值不同

重写了hashCode方法:(使用IDEA生成)相同的属性值计算出的hashCode就是相同的

        Student s1 = new Student("zhangsan", 23);
        Student s2 = new Student("zhangsan", 23);
        System.out.println(s1.hashCode()); //-1461067292
        System.out.println(s2.hashCode()); //-1461067292

哈希碰撞:

        System.out.println("abc".hashCode());//96354
        System.out.println("acD".hashCode());//96354

存储原理

  • JDK8之前:数组+链表
  • JDK8开始:数组+链表+红黑树
HashSet<String> hs = new HashSet<>();
  1. 创建一个默认长度16,默认加载因子0.75的数组,数组名table[]

image-20230328172350146

加载因子:HashSet的扩容时机,当数组中存了 $16 * 0.75 = 12$ 个元素后,数组就会扩容到原先的两倍

  1. 根据当前元素的哈希值和数组长度 $index= (数组长度 - 1)&哈希值$ 计算出应存入的位置

  2. 判断当前位置是否null,如果时null直接存入

  3. 如果不是null,表示当前位置有元素(哈希冲突或者地址冲突),调用equals方法比较key值(放入集合中的元素要重写hashCode和equals方法

  4. 如果key值相同,舍弃当前元素(不可重复)

  5. 如果key值不同:

    1. JDK8之前:新元素存入数组,老元素挂在新元素下面
    2. JDK8之后:新元素直接挂在老元素下面

    image-20230328195350927

  6. 当链表的长度>8 并且 数组长度 ≥ 64,当前链表自动转换为红黑树

    image-20230328195725900

image-20230328195757212

问题1:HashSet存与取的顺序不同

  • 因为存入元素的时候根据hashCode和数组长度计算出index,index是元素的存入位置;而哈希表在遍历时是按照数组顺序遍历链表/红黑树

问题2:HashSet没有索引

  • 数组索引对应位置可能挂载很多元素

问题3:HashSet如何去重?

  • HashCode计算出哈希值,相同的属性值得到的哈希值一定是相同的,但是不同属性可能也会得到相同的哈希值(哈希冲突),再使用equals方法就可以去除重复元素

问题4:Hash集合效率很高

  • 增删在链表上完成,查询也不需要全部扫描,只需要部分扫描

LinkedHashSet

有序、不重复、无索引,存储与取出的顺序相同

原理:底层数据结构依然是哈希表,只是每个元素又额外多了一个双链表的机制记录存储的顺序

在遍历的时候就不是HashSet按照数组的遍历方式了,是按照双链表从first节点到last节点的顺序遍历

如果要进行数据去重,默认使用HashSet,如果还要求有序,才能使用LinkedHashSet

TreeSet

底层是红黑树,不需要重写hashCode和equals

无序不可重复,但是存储的元素可以自动按照大小顺序排序(称为:可排序集合)

  • 对于数值类型:Integer、Double,默认按照从小到大的顺序进行排序
  • 对于字符、字符串类型,按照字符在ASCII码表中的数字升序进行排列
public static void main(String[] args) {
    Set<String> strings = new TreeSet<>();

    strings.add("A");
    strings.add("B");
    strings.add("C");
    strings.add("G");
    strings.add("D");
    strings.add("E");

    for (String str : strings){
        System.out.print(str + " ");
    }
}
/* A B C D E G */

TreeSet的第一种排序方式

  • bean类实现Comparable接口实现排序规则

image-20230329183602901

此时程序要比较lisi节点与wangwu节点的大小,依据的比较规则就是Comparable接口中的compareTo方法指定的比较规则,

返回值:

在正常情况下,默认的比较规则是:$要添加的元素值-红黑树中存在的元素值$,如果向倒序排列颠倒顺序即可

  • 负数:要添加的元素是小的,放在左子节点
  • 正数:要添加的元素是大的,放在右子节点
  • 0:已经存在,舍弃

方法的调用者this是要添加的元素,参数o是红黑树中已存在的元素,此次比较的结果是-1,应该放在左子节点的位置:

image-20230329190220615

再添加元素:zhangsan,还会与根先进行比较,结果-2,再与左子树上的lisi进行比较,结果-1,应该放在lisi的左子树上:

image-20230329190333362

但是此时不满足红黑树规则:两个红色节点不能相连;父lisi红,叔Nig黑,当前左子节点,处理步骤:

  1. 父lisi黑
  2. 祖父wangwu红
  3. 以wangwu为支点右旋

image-20230329184925562

如果此时再添加:zhaoliu 26

image-20230329190440835

第二种排序方式

  • 比较器排序,创建TreeSet对象的时候,传递比较器Comparator指定规则
/**
要求:存入四个字符串,c ab df qwer
按照长度排序,如果一样长按照首字母排序
*/

String类不能对其编码,这时只能通过传入比较器来指定排序规则:

image-20230329192537993

//s1 当前要添加的元素
//s2 已经存在的元素
TreeSet<String> ts = new TreeSet<>((s1,s2) ->s1.length() != s2.length() ? s1.length() - s2.length() : s1.compareTo(s2));

如果要存入TreeSet的元素既没有实现Comparable接口,也没有传递Comparator,就会报ClassCastException

单列集合的选择

  1. 如果想要集合中的元素可重复

    使用ArrayList

  2. 如果想要集合中的元素可重复,而且当前的头尾增删操作明显多于查询

    使用LinkedList集合

  3. 如果相对集合中元素去重

    HashSet集合

  4. 如果相对集合中的元素去重,而且保证存取顺序

    LinkedHashSet

  5. 对集合元素排序并去重

    TreeSet

Map

  1. Map和Collection没有继承关系
  2. Map集合以key和value的形式存储数据:键值对
  3. Key-Value都是引用数据类型
  4. Key-Value都是存储对象的内存地址
  5. Key起主导作用

向HashSet集合添加元素实际上是放在HashMap集合的key部分了:

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

其中的k-v组合被称为:键值对对象、Entry

Entry是Map类的接口,Node实现了Entry接口

常用方法

V put(K key, V value)

向Map添加键值对,或者覆盖值

  • 在添加数据的时候,如果键存在就将原有的键值对覆盖,将被覆盖的值返回
  • 该方法的逻辑反面:public V putIfAbsent(K key, V value)
 		Map<String,String> m = new HashMap<>();
        String putVal1 = m.put("郭靖", "黄蓉");
        System.out.println("添加操作时的返回值 : " + putVal1);//添加操作时的返回值 : null

        m.put("韦小宝","沐剑屏");
        m.put("尹志平","小龙女");

        String putVal2 = m.put("韦小宝", "双儿");
        System.out.println("覆盖操作时的返回值 : " + putVal2);//覆盖操作时的返回值 : 沐剑屏

        System.out.println(m);//{韦小宝=双儿, 尹志平=小龙女, 郭靖=黄蓉}

V get(Object key)

通过Key获取Value

String value = map.get(1);
System.out.println(value);

void clear()

清空Map集合

map.clear();
System.out.println(map.size());

boolean containsKey(Object key)

判断Map中是否包含某个Key

boolean containsValue(Object value)

判断Map中是否包含某个Value

containsKey和containsValue底层都是equals来判断的

isEmpty()

判断Map集合中元素个数是否为0

V remove(Object key)

根据键 删除键值对,并将删除的值返回

String value = map.remove(2);

int size()

获取Map集合中键值对的个数

Collection<V> values()

获取Map集合中所有的value,返回一个Collection

Collection<String> values = map.values();

for(String s : values){
    System.out.print(s + " ");
}

Set<K> keySet()

获取Map集合中所有的key,并作为set集合返回

Set<Map.Entry<K, V>> entrySet()

将Map集合转换为EntrySet集合

KEY VALUE
1 zhangsan
2 lisi
3 wangwu
4 zhaoliu

Set set = map.entrySet(),转换之后的set集合对象:

entrySet
1 = zhangsan
2 = lisi
3 = wangwu
4 = zhaoliu

其中的类型是 <Map.Entry<K, V>> 是一个泛型;Map.Entry是一个类名-静态内部接口

也就是说 entrySet方法返回了一个Set集合,集合中的对象是Map.Entry类型

类似于:

class MyMap{
    public static class MyEntry<k,v>{
        
    }
}
public static void main(String[] args) {
        Set<MyMap.MyEntry<Integer,String>> myEntries = new HashSet<>();
    }

HashMap中的Entry实际上是一个Node对象:

image.png

将所有的Node封装在set集合中,Node对象有方法:

image.png

可以获取到键和值

遍历Map集合

  1. 通过Set<K> keySet()方法获取所有key的集合,再遍历set集合通过map.get(key)获取value

    不建议使用,keySet获取Set集合遍历了一次,再遍历set集合获取value就又是一次

  2. Set<Map.Entry<K, V>> entrySet() 将Map集合直接全部转换成Set集合,Set集合中的元素是Map.Entry,entry.getKey
  3. forEach(BiConsumer(K k,V v))

获取entrySet的效率较高,不需要再去map集合中查找元素

forEach遍历:

image-20230331152233598

image-20230331152701924

将键和值都传递给BiConsumer接口的accept方法,forEach其实也是使用entrySet来遍历的,只是将如何做交给调用者决定

HashMap

  1. HashMap集合底层是 哈希表 也称为散列表的数据结构

  2. 无序、不重复、无索引

  3. 哈希表是 数组 + 单向链表 + 红黑树的结合体

    数组:查询方面效率很高,随机增删方面效率很低

    单链表:在随机增删方面效率很高,查询方面效率很低

    哈希表将以上两种数据结构融合在一起,充分发挥各自的优点

原理

散列函数

散列函数(也就是hashCode()方法),通过散列函数获得key对象的哈希码,实际上就是建立起key值与int值映射关系的函数
$index=哈希值 & (length - 1)$

散列碰撞(哈希冲突)

  1. 不同的key获得到相同的hash值,例如abc和acD

通过equals方法比较key,如果key相同就覆盖,并将旧值返回;key不同就挂在单链表末尾

不同的key得到不同的hash值,转化为相同的数组下标,这并不是hash冲突

源码

HashMap底层实际上就是一个实现了Map.Entry接口的Node数组,Node:

image-20230401142513407

一个键值对就是一个Node对象

注意:Node只是链表当中的元素,如果是红黑树,其中的节点是TreeNode:

image-20230401142658894

Node的继承结构:

Node:HashMap中链表节点,Entry:LinkedHashMap中的节点,TreeNode:红黑树中的节点

阅读源码前需要了解的内容:

Node<K,V>[] table   哈希表结构中数组的名字

DEFAULT_INITIAL_CAPACITY:   数组默认长度16

DEFAULT_LOAD_FACTOR:        默认加载因子0.75



//HashMap里面每一个对象包含以下内容:
1.1 链表中的键值对对象
    包含:  
          	int hash;         //键的哈希值
            final K key;      //键
            V value;          //值
            Node<K,V> next;   //下一个节点的地址值
         
         
//1.2 红黑树中的键值对对象
   包含:
         	int hash;             //键的哈希值
        	final K key;          //键
            V value;              //值
            TreeNode<K,V> parent;      //父节点的地址值
         	TreeNode<K,V> left;       //左子节点的地址值
         	TreeNode<K,V> right;   //右子节点的地址值
         	boolean red;         //节点的颜色
               
//1.3 数组中的键值对对象可能是链表键值对,也可能是红黑树键值对,红黑树键值对继承自链表键值对

对于以下程序:

HashMap<String,Integer> hm = new HashMap<>();

hm.put("aaa" , 111);
hm.put("bbb" , 222);
hm.put("ccc" , 333);
hm.put("ddd" , 444);
hm.put("eee" , 555);

添加元素时至少考虑三种情况:

  1. 数组位置为null
  2. 数组位置不为null,键不重复,挂在下面形成链表或者红黑树
  3. 数组位置不为null,键重复,元素覆盖

首先执行第一行创建对象:

public HashMap() {  
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted  
}

只是指定了加载因子为0.75,底层的Node<K,V>[] table还是null

调用put("aaa" , 111)

  • 参数一:键
  • 参数二:值
  • 返回值:被覆盖的元素值
public V put(K key, V value) {  
    return putVal(hash(key), key, value, false, true);  
}

调用hash(key)方法计算key的哈希值:

static final int hash(Object key) {  
    int h;  
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);  
}

该方法利用键计算出对应的哈希值,再把哈希值进行一些特殊的处理。

注意:该方法允许HashMap的key为空,如果为空实际上就转化为0

返回到putVal()方法,该方法的参数:

  • 参数一:键的哈希值
  • 参数二:键
  • 参数三:值
  • 参数四:如果键重复是否保留
    • true:老元素的值保留,不会覆盖
    • false:老元素的值被新元素覆盖
  • 参数五:略
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,  
               boolean evict) {  
    Node<K,V>[] tab; //局部变量记录数组的地址值 
    Node<K,V> p; //临时第三方变量,记录键值对对象的地址值
    int n, //当前数组长度
    int i; //表示索引
    //把哈希表中数组的地址值,赋值给局部变量tab  
    tab = table;  
  
	if (tab == null || (n = tab.length) == 0){  
		//1.如果当前是第一次添加数据,底层会创建一个默认长度为16,加载因子为0.75的数组  
		//2.如果不是第一次添加数据,会看数组中的元素是否达到了扩容的条件  
		//如果没有达到扩容条件,底层不会做任何操作  
		//如果达到了扩容条件,底层会把数组扩容为原先的两倍,并把数据全部转移到新的哈希表中  
		tab = resize();  
		//表示把当前数组的长度赋值给n  
		n = tab.length;  
	}

	//拿着数组的长度跟键的哈希值进行计算,计算出当前键值对对象,在数组中应存入的位置  
	i = (n - 1) & hash;//index  
	//获取数组中对应元素的数据  
	p = tab[i];

	if (p == null){  
		//底层会创建一个键值对对象,直接放到数组当中  
         tab[i] = newNode(hash, key, value, null);  
    }else {  
         Node<K,V> e;  
         K k;  
	  
		//== 的左边:数组中键值对的哈希值  
		//== 的右边:当前要添加键值对的哈希值  
		//如果哈希值不一样,此时返回false  
		//如果哈希值一样,返回true  
		boolean b1 = p.hash == hash;  

		/*如果哈希值相同,并且key相同,新节点e就设置为数组中的元素p*/
		if (b1 && ((k = p.key) == key || (key != null && key.equals(k)))){  
			 e = p;  
		 } else if (p instanceof TreeNode){  
			/*哈希值不同,需要创建新节点*/
			//判断数组中获取出来的键值对是不是红黑树中的节点  
			//如果是,则调用方法putTreeVal,把当前的节点按照红黑树的规则添加到树当中。  
			 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);  
		 } else {  
			//如果从数组中获取出来的键值对不是红黑树中的节点  
			//表示此时下面挂的是链表  
			 for (int binCount = 0; ; ++binCount) {  
				 if ((e = p.next) == null) {  
					  //此时就会创建一个新的节点,挂在下面形成链表  
					  p.next = newNode(hash, key, value, null);  
					  //判断当前链表长度是否超过8,如果超过8,就会调用方法treeifyBin  
					  //treeifyBin方法的底层还会继续判断  
					  //判断数组的长度是否大于等于64  
					  //如果同时满足这两个条件,就会把这个链表转成红黑树  
					 if (binCount >= TREEIFY_THRESHOLD - 1)  
						 treeifyBin(tab, hash);  
					 break;  
				 }  //end of create node and treeifyBin
				 
				//e:           0x0044  ddd  444       
				//要添加的元素: 0x0055   ddd   555       
				//如果哈希值一样,就会调用equals方法比较内部的属性值是否相同  
				 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))){  
					   break;  //相同就跳出循环
																	}  
                 p = e;  //继续下一次查找
             } // end of for  
         }//end of p instanceOf Node

		//到达此处,可能:
		//1. 数组位置只有一个元素,并且该元素hash和key与要添加的元素相等
		// 局部变量e = 该元素
		//2. 红黑树,转化为红黑树节点并存入
		//局部变量e = treeNode
		//3. 链表找到末尾,新元素挂在链表末尾
		//局部变量e = null
		//4. 链表查找过程中发现某一元素的key和hash与要添加的元素相同
		//局部变量e = 该元素

		//如果e为null,表示当前不需要覆盖任何元素  
		//如果e不为null,表示当前的键是一样的,值会被覆盖  
		//e:0x0044  ddd  555  
		//要添加的元素: 0x0055   ddd   555         
		if (e != null) {  
             V oldValue = e.value;  
             if (!onlyIfAbsent || oldValue == null){  
				//等号的右边:当前要添加的值  
		        //等号的左边:0x0044的值  
		        e.value = value;   
				 }  
             afterNodeAccess(e);  
             return oldValue;  //覆盖成功,返回oldValue
         }//end of e != null
	}// end of p != null

需要注意的是,此处不是整个Node节点的覆盖,只是覆盖了oldValue

	++modCount;  
    //threshold:记录的就是数组的长度 * 0.75,哈希表的扩容时机  16 * 0.75 = 12      
    if (++size > threshold){  
     resize();  
	}  
        
	//表示当前没有覆盖任何元素,返回null  
	return null;  
}

在resize方法中扩容:

总结:

  • put方法:
    将k、v封装到Node对象当中;调用key的hashCode()方法,得出哈希值,将哈希值转换成数组的下标;下标位置上如果为null就把Node添加到该位置上;如果下标对应的位置上有元素就会用k和链表上每一个节点中的key进行equals()比较,如果所有equals方法返回值均为false,那么新节点将会添加到链表的末尾。如果其中有一个equals方法返回了true,那么这个节点的value就会被覆盖

JDK8之后,如果链表的长度超过8 而且 数组的长度 ≥ 64 链表会自动转化为红黑树

产生hash冲突的两种情况:

  1. key不同转化的hashCode相同
  2. key不同,hashCode不同,但是计算出的数组下标相同
  • get方法:

通过key计算出哈希值,将hash值转化为数组下标,如果下标位置为空返回false;如果不为空对链表上的Node节点的key进行equals比较,equals为true返回该Node对应的value,每一个equals都为false就返回null

  • 增删是在链表上完成,查询也不需要都扫描,只需要部分扫描

重点:

HashMap集合的key会先后调用两个方法:

  1. hashCode()
  2. equals() 默认比较的是两个对象的内存地址

这两个方法需要重写 散列分布均匀 要求重写hashCode方法时 需要技巧

  • hashCode方法重写时返回固定值可以吗?

变成单向链表了,无法发挥哈希表性能 这种情况称为 散列分布不均匀

  • 哈希值不同的元素 经过计算也可能处于同一个链表上

放在HashMap key部分的元素 和 放在HashSet 集合中的元素;需要同时重写hashCode() 和 equals()

初始化容量和默认加载因子

image-20230401150035355

初始化容量

空参构造创建出的HashMap是空数组,添加第一个元素时数组扩容到16

一维数组元素个数达到 16 * 0.75 之后 就开始进行扩容,扩容之后的容量是原容量的2倍

/**  
 * The default initial capacity - MUST be a power of two. */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

HashMap默认的初始化容量必须是2整数次幂 :为了达到散列均匀,这是提高哈希表性能所必须的,可以加快散列计算减少散列冲突

	public static void main(String[] args) {
		// 获得散列码值,值为:2998638
		int hashCode = "ande".hashCode();
		// 计算得到2^14的结果,结果为:16384
		int length = (int)Math.pow(2, 14);
		// &运算结果为:366
		System.out.println(hashCode & (length - 1));
		// %运算结果为:366
		System.out.println(hashCode % length);
	}

假设可以为偶数,也可以为奇数:

  • 当length为偶数时,则length-1为奇数,奇数的二进制最低位肯定为1,hash &(length-1)结果的二进制最低位可能为0,也可能为1(这取决于hash的值)。也就是运算的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性。
  • 当length为奇数时,length-1为偶数,偶数的二进制最低位肯定为0,hash & (length-1)结果的二进制最低位肯定为0。也就是运算的结果肯定为偶数,这样得到的结果对应的就是table数组索引为偶数的位置,那么就浪费了近一半的空间。

因此,table数组空间长度为2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

思考:当我们创建一个HashMap对象,设置哈希表的容量为15,请问HashMap对象创建成功后,哈希表的实际容量为多少?

默认加载因子

当哈希表中的元素越来越多的时候,散列碰撞的几率也就越来越高(因为数组的长度是固定的),从而导致单链表过长,降低了哈希表的性能,此时就需要对哈希表进行扩容操作。

当执行put()操作的时候,如果HashMap中存储元素的个数超过数组长度* loadFactor的结果(loadFactor指的是负载因子,loadFactor的默认值一般为0.75)那么就就需要执行数组扩容操作。

扩容操作,就是把数组的空间大小扩大一倍,然后遍历哈希表中元素,把这些元素重新均匀分散到扩容后的哈希表中,重新计算每个元素在数组中的位置,这是一个非常消耗性能的操作。

为了避免扩容带来的性能损坏,建议使用哈希表之前,先预测哈希表需要存储元素的个数,提前为哈希表中的数组设置合适的存储空间大小,避免去执行扩容的操作,进一步提升哈希表的性能。

例如:我们需要存储1000个元素,按照哈希表的容量设置为2的整数次幂的思想,我们设置哈希表的容量为1024更合适。但是0.75 * 1024 < 1024,需要执行消耗性能的扩容操作,因此我们设置哈希表的容量为2048更加合适,这样既考虑了&的问题,也避免了扩容的问题。

进化和退化

树的门限值 = 8;

如果单个单向链表中存储超过8个元素;并且数组长度>=64 会把单向链表变成二叉树 / 红黑树

当二叉树 / 红黑树上存储的元素小于6时,会重新变为单向链表

构造方法

image.png

练习

某个班级80名学生,对A、B、C、D四个景点投票,每次只能选择一个景点,请统计最终哪个景点想去的人最多

        Map<String,Integer> map = new HashMap<>();

        String[] viewer = {"A","B","C","D"};

        List<String> list = new ArrayList(8000000); //使用List集合一定要指定初始化容量
        long begin2 = System.currentTimeMillis();
        for (int i = 0; i < 8000000; i++) {
            list.add(viewer[r.nextInt(viewer.length)]);
        }
        long end2 = System.currentTimeMillis();
        System.out.println(end2 - begin2);

        for (String name : list) {
            if (map.containsKey(name)){
                map.put(name,map.get(name) + 1);
            }else {
                map.put(name,1);
            }
        }     
  • 基础做法:
HashMap<String, Integer> hashMap = new HashMap<>();  
String[] viewers = {"A","B","C","D"};  
  
Random random = new Random();  
long begin_1 = System.currentTimeMillis();  
  
for (int i = 0; i < 800000; i++) {  
    int index = random.nextInt(viewers.length);  
    String viewer = viewers[index];  
    if (hashMap.get(viewer) == null) {  
        hashMap.put(viewer,1);  
    }else {  
        hashMap.put(viewer,hashMap.get(viewer) + 1);  
    }  
}  
long end_1 = System.currentTimeMillis();
  • stream流:
long begin_2 = System.currentTimeMillis();  
ArrayList<String> strs = new ArrayList<>(800000);  
for (int i = 0; i < 800000; i++) {  
    int index = random.nextInt(viewers.length);  
    String viewer = viewers[index];  
    strs.add(viewer);  
}  
Map<String, Long> collect = strs.stream().collect(Collectors.groupingBy(Function.identity(), Collectors.mapping(Function.identity(), Collectors.counting())));  
long end_2 = System.currentTimeMillis();
  • hashMap.merge
HashMap<String, Integer> hashMap = new HashMap<>();  
String[] viewers = {"A","B","C","D"};  
  
Random random = new Random();  
long begin_1 = System.currentTimeMillis();  
  
for (int i = 0; i < 800000; i++) {  
    int index = random.nextInt(viewers.length);  
    String viewer = viewers[index];  
    hashMap.merge(viewer, 1, Integer::sum);  
}  
long end_1 = System.currentTimeMillis();

merge方法:

default V merge(K key, V value,
        BiFunction<? super V, ? super V, ? extends V> remappingFunction) {
    //判断value和remappingFunction都不为空        
    Objects.requireNonNull(remappingFunction);
    Objects.requireNonNull(value);
    //通过key去获取旧值 若无这个key则null
    V oldValue = get(key);
    //新值 = 旧值为null则新值=null,旧值不为null则新值=  remappingFunction.apply(旧值, 新值);
    V newValue = (oldValue == null) ? value :
               remappingFunction.apply(oldValue, value);
    //判断新值为null的话           
    if(newValue == null) {
        //移除这个key
        remove(key);
    } else {
        //不为null的话,重新put
        put(key, newValue);
    }
    return newValue;
}

特点

  • HashMap允许key-value为null,对key调用hashCode方法不会出现空指针异常的原因是put方法将null转化为0
  • 但是key为null只能存在一个,也就是只能存在一个0
  • HashTable不允许key-value为null

HashTable

  • Hashtable对线程安全的处理导致效率较低,使用较少,底层都是哈希表数据结构
  • 如果key为null,key调用hashCode方法导致空指针异常
  • 初始化容量11 默认加载因子0.75
  • 每次扩容到原容量的2倍 + 1

Properties

  • Properties是一个Map集合,继承自HashTable;Properties的key和value都是String类型;Properties被称为属性类对象
  • Properties是线程安全的

需要掌握的两个方法:

public static void main(String[] args) {
    Properties pro = new Properties();

    pro.setProperty("url","jdbc:mysql://localhost:3306/mybatis");
    pro.setProperty("driver","com.mysql.jdbc.driver");
    pro.setProperty("username","root");
    pro.setProperty("password","123");

    String url = pro.getProperty("url");
    String driver = pro.getProperty("driver");
    String username = pro.getProperty("username");
    String password = pro.getProperty("password");

    System.out.println(url);
    System.out.println(driver);
    System.out.println(username);
    System.out.println(password);
}

LinkedHashMap

继承自HashMap

  • 由键决定:有序、不重复、无索引
  • 底层依然是哈希表数据结构,只是每个键值对元素又额外多了一个双链表机制记录存储的顺序

image-20230401122745216

其中的节点是Entry,继承自Node,多了前后指针域,遍历时从头节点遍历到尾节点

TreeMap

  1. TreeSet集合底层实际上是一个TreeMap
  2. TreeMap集合底层是一个红黑树
  3. 放在TreeSet集合中的元素等同于放在TreeMap集合Key部分了
  4. TreeSet集合对键进行排序,也可以按照自定规则排序
public static void main(String[] args) {

    TreeSet<String> ts = new TreeSet<>();

    ts.add("zhangsan");
    ts.add("lisi");
    ts.add("wangwu");
    ts.add("zhangsi");
    ts.add("wangliu");

    for(String s : ts){
        System.out.print(s + " ");
    }
}

按照字典顺序自动排序

自定义类型的排序规则:

  1. 实现Comparable接口,指定比较规则
  2. 创建集合时传递Comparator比较器对象,指定比较规则

如果两种方式同时实现,以第二种为准

如果没有实现Comparable接口:ClassCastException:class TreeMap.Person cannot be cast to class java.lang.Comparable

源码

1.TreeMap中每一个节点的内部属性
K key;					//键
V value;				//值
Entry<K,V> left;		//左子节点
Entry<K,V> right;		//右子节点
Entry<K,V> parent;		//父节点
boolean color;			//节点的颜色

2.TreeMap类中中要知道的一些成员变量
public class TreeMap<K,V>{  
    //比较器对象
    private final Comparator<? super K> comparator;
	//根节点
    private transient Entry<K,V> root;
	//集合的长度
    private transient int size = 0;
    
3.空参构造
	//空参构造就是没有传递比较器对象
	 public TreeMap() {
        comparator = null;
    }
		
4.带参构造
	//带参构造就是传递了比较器对象。
	public TreeMap(Comparator<? super K> comparator) {
        this.comparator = comparator;
    }

练习

对字符串“aababcabcdabcde”统计每一个字符的出现次数

统计类题目一般都是以计数器思想实现的,但是如果要统计的元素比较多,或者实现不知道要统计多少个元素,计数器思想就是有弊端的;可以利用map集合进行统计:

  • HashMap
  • TreeMap

如果题目中没有要求对结果进行排序,默认使用HashMap,要求排序就使用TreeMap

        TreeMap<Character,Integer> tm = new TreeMap<>();
        Scanner scanner = new Scanner(System.in);
        String str = scanner.next();
        for (int i = 0; i < str.length(); i++) {
            char c = str.charAt(i);
            int val = tm.containsKey(c) ? tm.get(c) + 1 : 1;
            tm.put(c,val);
        }
        StringBuilder builder = new StringBuilder();
//      tm.forEach((key,val)-> System.out.print(key + " (" + val +") "));
        tm.forEach((key,val)-> builder.append(key).append(" (").append(val).append(") "));
        System.out.println(builder);

也可以使用merge

面试题

  • TreeMap添加元素的时候,键是否需要重写hashCode和equals方法?

此时是不需要重写的。

  • HashMap是哈希表结构的,JDK8开始由数组,链表,红黑树组成的。

既然有红黑树,HashMap的键是否需要实现Compareable接口或者传递比较器对象呢?

不需要的。
因为在HashMap的底层,默认是利用哈希值的大小关系来创建红黑树的

  • TreeMap和HashMap谁的效率更高?

如果是最坏情况,添加了8个元素,这8个元素形成了链表,此时TreeMap的效率要更高

但是这种情况出现的几率非常的少。

一般而言,还是HashMap的效率要更高。

  • 你觉得在Map集合中,java会提供一个如果键重复了,不会覆盖的put方法呢?

有的。

image-20230401191947830

此时putIfAbsent本身不重要,代码中的逻辑都有两面性,如果我们只知道了其中的A面,而且代码中还发现了有变量可以控制两面性的发生,那么该逻辑一定会有B面。

习惯: boolean类型的变量控制,一般只有AB两面,因为boolean只有两个值 int类型的变量控制,一般至少有三面,因为int可以取多个值。

多列集合的选择

HashMap LinkedHashMap TreeMap

默认:HashMap(效率最高)

如果要保证存取有序:LinkedHashMap

如果要进行排序:TreeMap

Collections 集合工具类

java.util.Collections,构造方法私有化,方法都有静态标记

image-20230401192823206

排序

image.png

image.png

不传递Comparator,要求实现Comparable,或者传递比较器,否则ClassCastException

注意源码:

public static <T extends Comparable<? super T>> void sort(List<T> list) {  
    list.sort(null);  
}

泛型要求List中的T必须是Comparable的子类,并且Compareable的泛型必须是T的父类型,这样Compareable的compare参数才能是T及其T的子类型

public static <T> void sort(List<T> list, Comparator<? super T> c) {  
    list.sort(c);  
}

这里的Comparator泛型也是和上文一致的。

交换

image.png

批量添加元素

image.png

第二个参数可以是数组,如果想在set中批量添加元素,可以使用这个方法,将另一个集合通过toArray转换为数组

混排

Collections.shuffle(list);

反转

使用reverse方法可以根据元素的自然顺序,对指定列表按降序进行排序。

替换所有的元素(fill)

使用指定元素替换指定列表中的所有元素。

综合练习

随机点名器

班级里N个学生,随机点名器

        ArrayList<String> list = new ArrayList<>();
        Collections.addAll(list,"范闲","范建","范统","杜子腾","杜琦燕","宋河范","候隆腾","朱逸群","珠穆朗玛峰","袁明媛");
        Random random = new Random();

//        System.out.println(list.get(random.nextInt(list.size())));
        
        Collections.shuffle(list);
        System.out.println(list.get(0));

概率随机点名器

Random类不可以直接使用,Random是随机点,不能表示面

  • 思路一:定义长度10的int数组,数组中存储七1三0,shuffle后再random一个元素,1从m中随机,0从f中随机

  • 思路二:设置nextInt=10,<=6从m随机,>从f随机

去重随机点名器

被点到的学生不能再被点到,所有学生点完开启第二轮

  • 思路一:随机一个删除一个,删除的元素添加到back中,全部删完再将back的元素addAll到list中

    • 注意:全部删完的结束条件是 i < count,数组的长度是随时在变化的,不能直接使用size()
  • 思路二:开辟长度 = list.size()的数组

省市

定义Map,键表示省份名称,值表示city,市会有多个

        Map<String, Set<String>> cop = new HashMap<>();

        HashSet<String> jiangSu = new HashSet<>();
        jiangSu.add("南京市");
        jiangSu.add("扬州市");
        jiangSu.add("苏州市");
        jiangSu.add("无锡市");
        jiangSu.add("常州市");
        HashSet<String> huBei = new HashSet<>();
        huBei.add("武汉市");
        huBei.add("孝感市");
        huBei.add("十堰市");
        huBei.add("宜昌市");
        huBei.add("鄂州市");
        HashSet<String> heBei = new HashSet<>();
        heBei.add("石家庄市");
        heBei.add("唐山市");
        heBei.add("邢台市");
        heBei.add("保定市");
        heBei.add("张家口市");

        cop.put("江苏省",jiangSu);
        cop.put("湖北省",huBei);
        cop.put("河北省",heBei);

        cop.forEach((key,value) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(key).append("=");
            for (String s : value) {
                sb.append(s).append(",");
            }
            System.out.println(sb);
        });
    }

不可变集合

不可变集合:不能被修改的集合,长度和内容都不能被改变;只能进行查询操作

  • 如果某个数据不能修改,将其防御性的拷贝到不可变集合是个很好的选择
  • 当集合对象被不可信的库调用时,不可变的形式是安全的

比如电脑里的硬件信息、斗地主的出牌规则

在List、Set、Map接口中,都存在静态的of方法,获取一个不可变的集合

方法名称 说明
static <E> List<E> of(E ... elements) 创建一个具有指定元素的List不可变集合
static <E> Set<E> of(E ... elements) 创建一个具有指定元素的Set不可变集合
static <K, V> Map<K, V> of(K k,V v1 ... ) 创建一个具有指定元素的Map不可变集合

Stream流的toList、toSet等获取的都是不可变集合。

注意:

  • 如果进行修改,抛出UnsupportedOperationException异常
  • Set集合中的元素是唯一的,如果在of获取时添加了两个相同的元素抛出IllegalArgumentException异常
  • Map集合如果key相同,抛出IllegalArgumentException异常
  • Map集合的of方法最多支持添加20个元素,也就是10个键值对,因为[[004-方法#可变长参数|可变长参数]]只能出现在最后一个位置
  • 如果创建超过10个键值对的Map集合,可以使用ofEntries方法,传递可变长的Entry对象

根据已有的Map创建不可变集合:

image-20230402112945578

或者使用Map的静态方法copyOf:

image-20230402113143201

实用算法

主要处理两个数据集合的匹配问题

class Couple{  
    private Integer familyId;  
    private String userName;
}

public class Demo {  
    public static void main(String[] args) {  
        // husband组  
        List<Couple> husbands = new ArrayList<>();  
        husbands.add(new Couple(1, "梁山伯"));  
        husbands.add(new Couple(2, "牛郎"));  
        husbands.add(new Couple(3, "干将"));  
        husbands.add(new Couple(4, "工藤新一"));  
        husbands.add(new Couple(5, "罗密欧"));  
  
        // wive组  
        List<Couple> wives = new ArrayList<>();  
        wives.add(new Couple(1, "祝英台"));  
        wives.add(new Couple(2, "织女"));  
        wives.add(new Couple(3, "莫邪"));  
        wives.add(new Couple(4, "毛利兰"));  
        wives.add(new Couple(5, "朱丽叶"));  
    }  
}

要求对数据进行处理,最终输出:

梁山伯爱祝英台
牛郎爱织女
干将爱莫邪
工藤新一爱毛利兰
罗密欧爱朱丽叶

第一版算法

//记录循环次数  
int count = 0;  
for (Couple husband : husbands) {  
    for (Couple wife : wives) {  
        count++;  
        if (husband.getFamilyId().equals(wife.getFamilyId())){  
            System.out.println(husband.getUserName() + " 爱 " + wife.getUserName());  
        }  
    }  
}  
System.out.println("循环了 " + count + " 次"); //循环了 25次

总结一下第一版算法的优缺点。

  • 优点:代码逻辑非常直观,外层for遍历husband,内层for根据husband的familyId匹配到wife
  • 缺点:循环次数过多

当前数据量较小,可能看不出明显差距。实际上这是非常糟糕的一种算法。

想象一下,如果现在男女cp各1000人,那么全部匹配需要1000 * 1000 = 100w次循环。

如何改进?

在当前这个需求中,每位男嘉宾只能选一位女嘉宾。比如当外层for刚好轮到牛郎时,内层for需要遍历wives找出织女。一旦牛郎和织女牵手成功,其实就没必要继续往下遍历wives了,遍历完了又如何呢,反正只能带走织女。所以明智的做法是,牛郎匹配到织女后,就赶紧下去,换干将上场。

image.png

后面的三次是没有必要的

第二版算法

//记录循环次数  
int count = 0;  
outer : for (Couple husband : husbands) {  
    inner : for (Couple wife : wives) {  
        count++;  
        if (husband.getFamilyId().equals(wife.getFamilyId())){  
            System.out.println(husband.getUserName() + " 爱 " + wife.getUserName());  
            break inner;  
        }  
    }  
}  
System.out.println("循环了 " + count + " 次"); //循环了 15 次

我们发现,循环次数从第一版的25次减少到了15次,区别仅仅是增加了一个break:一旦牵手成功,就换下一位男嘉宾。

总结一下第二版算法的优缺点。

  • 优点:执行效率比第一版高
  • 缺点:理解难度稍微提升了一些

还能优化吗?

一位男嘉宾和一位女嘉宾牵手成功后,这位女嘉宾就要离开舞台了

请你重新看看我们的第二版代码,你会发现即使牛郎和织女牵手成功了,下一位男嘉宾(干将)入场时还是会在循环中碰到织女。织女在上一轮循环中,已经确定和牛郎在一起了,本次干将再去遍历织女是没有意义的。

image.png

在前两轮中,梁山伯、牛郎已经确定牵手祝英台、织女,应该把她们两个从舞台请下去

第三版算法

//记录循环次数  
int count = 0;  
outer : for (Couple husband : husbands) {  
    inner : for (Couple wife : wives) {  
        count++;  
        if (husband.getFamilyId().equals(wife.getFamilyId())){  
            System.out.println(husband.getUserName() + " 爱 " + wife.getUserName());  
            wives.remove(wife);  
            break inner;  
        }  
    }  
}  
System.out.println("循环了 " + count + " 次"); //循环了 5 次

大家可能有疑问,增强for循环底层是迭代器,删除元素后再迭代应该报并发修改异常。
此处没有异常是因为在删除完元素后直接break了,下一轮for循环开启的就是新的迭代器

我们发现,循环次数从第二版的15次减少到了5次,因为牵手成功的女嘉宾都被请下舞台了:wives.remove(wife)。

如果说,第二版算法是打断wives的循环,那么第三版算法则是直接把wives请出场外。

总结一下第三版算法的优缺点。

  • 优点:执行效率比第二版高了不少
  • 缺点:理解难度稍微提升了一些,平均性能不高

什么是“平均性能不高”?

比如我现在把男嘉宾的出场顺序倒过来:

public static void main(String[] args) {

    // 用于计算循环次数
    int count = 0;

    // husbands,原先梁山伯第一个出场,现在换罗密欧第一个
    List<Couple> husbands = new ArrayList<>();
    husbands.add(new Couple(5, "罗密欧"));
    husbands.add(new Couple(4, "工藤新一"));
    husbands.add(new Couple(3, "干将"));
    husbands.add(new Couple(2, "牛郎"));
    husbands.add(new Couple(1, "梁山伯"));

    // wives
    List<Couple> wives = new ArrayList<>();
    wives.add(new Couple(1, "祝英台"));
    wives.add(new Couple(2, "织女"));
    wives.add(new Couple(3, "莫邪"));
    wives.add(new Couple(4, "毛利兰"));
    wives.add(new Couple(5, "朱丽叶"));

    for (Couple husband : husbands) {
        for (Couple wife : wives) {
            // 记录循环的次数
            count++;
            if (husband.getFamilyId().equals(wife.getFamilyId())) {
                System.out.println(husband.getUserName() + "爱" + wife.getUserName());
                // 牵手成功,把女嘉宾从舞台请下来,同时换下一位男嘉宾上场
                wives.remove(wife);
                break;
            }
        }
    }

    System.out.println("----------------------");
    System.out.println("循环了:" + count + "次"); //循环了 15 次
}

循环次数从5次变成15次,和第二版算法是一样的。

这是怎么回事呢?

第一次是顺序遍历的:

image.png

第一位男嘉宾梁山伯上场:遇到第一位女嘉宾祝英台,直接牵手成功。

第二位男嘉宾牛郎上来了,此时祝英台不在了,他遇到的第一位女嘉宾是织女,也直接牵手成功。

第三位男嘉宾干将上场后一看,这不是莫邪吗,也牵手成功走了。

但是颠倒顺序后:

image.png

之前顺着来的时候,梁山伯带走了祝英台,牛郎出场就直接跳过祝英台了,这就是上一次循环对下一次循环的影响。

而这次,罗密欧错了4次以后终于带走了朱丽叶,但是工藤新一上场后,还是要试错3次才能找到毛利兰。提前离场的朱丽叶在毛利兰后面,所以罗密欧试错积累的优势无法传递给下一次循环。

对于某些算法而言,元素的排列顺序会改变算法的复杂度。在数据结构与算法中,对一个算法往往有三个衡量维度:

  • 最好复杂度
  • 平均复杂度
  • 最坏复杂度

现实生活中,我们往往需要结合实际业务场景与算法复杂度挑选出合适的算法。

在本案例中,第三版算法在男嘉宾顺序时可以得到最好的结果(5次),如果倒序则得到最差的结果(15次)。

第四版算法

终于要向大家介绍第四种算法了。

第四种算法是一种复杂度一致的算法,无论男嘉宾的出场顺序如何改变,效率始终如一。

这是一种怎么样的算法呢?

不急,我们先思考一个问题:

我们为什么要用for遍历?

咋一听,好像有点莫名其妙。不用for循环,我怎么遍历啊?

其实无论何时,使用for都意味着我们潜意识里已经把数据泛化牺牲数据的特性转而谋求统一的操作方式。想象一下,假设一个数组存了国家男子田径队的队员们,比如110米栏的刘翔、100米项目的苏炳添和谢震业。你如果写一个for循环:

for(sportsMan : sportsMen){
    sportsMan.kualan();
}

在循环中,你只能调用运动员身上的一项技能执行。

  • 你选跨栏吧,苏炳添和谢震业不会啊...
  • 你选100米短跑吧,刘翔肯定比不过专业短跑运动员啊...

所以,绝大多数情况下,for循环意味着抽取共同特性,忽略个体差异。好处是代码通用,坏处是无法发挥个体优势,最终影响效率。

这也就是[[002-Java程序基础#for循环的双面性|for循环的双面性]],或者说for循环是一体两面的。

  • 好处:抽取共同特性,代码通用
  • 坏处:忽略了个体差异,无法发挥个体优势,进而影响效率

回到案例中来。

每次男嘉宾上场后,他都要循环遍历女嘉宾,挨个问过去:你爱我吗?

哦,不爱。我问问下一位女嘉宾。

他为什么要挨个问?因为“女人心海底针”,他根本不知道哪位女嘉宾是爱他的,所以场上女嘉宾对他来说就是无差异的“黑盒”。

如果我们给场上的女嘉宾每人发一个牌子,让他们在上面写上自己喜欢的男嘉宾号码,那么男嘉宾上场后就不用挨个问了,直接找到写有自己号码的女嘉宾即可牵手成功。

这个算法的思想其实就是让数据产生差异化,外部通过差异快速定位目标数据

//记录循环次数  
int count = 0;  
  
Map<Integer,Couple> wivesMap = new HashMap<>();  
  
for (Couple wife : wives) {  
    wivesMap.put(wife.getFamilyId(), wife);  
    count++;  
}  
  
for (Couple husband : husbands) {  
    System.out.println(husband.getUserName() + " 爱 " + wivesMap.get(husband.getFamilyId()).getUserName());  
    count++;  
}  
  
System.out.println("循环了 " + count + " 次"); //循环了 10 次

此时无论你如何调换男嘉宾出场顺序,都只会循环10次。

image.png

小结

第一版和第二版就不讨论了,我们只谈谈第三版和第四版代码。

假设两组数据长度分别是n和m:

第三版的循环次数是n ~ ,是波动的,最好效率是n,这是非常惊人的(最差效率同样惊人...)。

第四版始终是 n + m。

在数据量较小的情况下,其实两者差距不大,CPU执行时间差可以忽略不计。我们设想n, m=1000的情况。

此时第三版的循环次数是:1000 ~

最好的结果是1000,固然可喜。但是最差的结果是1000+999+...+1=500500。

而此时第四版的循环次数是 1000+1000=2000,与第三版最好的结果相比也只差了1000次而已,对于CPU而言可以忽略不计。

考虑到实际编程中,数据库的数据往往是非常杂乱的,使用第三版算法几乎不可能得到最大效率。

所以推荐使用第四版算法。

它的精髓就是利用HashMap给其中一列数据加了“索引”,每个数据的“索引”(Map的key)是不同的,让数据差异化。

了解原理后,如何掌握这简单有效的小算法呢?

记住两步:

  • 先把其中一列数据由线性结构的List转为Hash散列的Map,为数据创建“索引”
  • 遍历另一列数据,依据索引从Map中匹配数据

相比第三版在原有的两个List基础上操作数据,第四版需要额外引入一个Map,内存开销稍微多了一点点。算法中,有一句特别经典的话:空间换时间。第四版勉强算吧。但要清楚,实际上Couple对象并没有增多,Map只是持有原有的Couple对象的引用而已。新增的内存开销主要是Map的索引(Key)。

扩展思考

我们都知道,实际开发中我们从数据库查询得到的数据都是由Mapper封装到单个List中,也就是说不具备“两个数据集合匹配”这种前提呀。

此时转换一下思维即可,比如前端要全量获取城市,而且是二级联动:

|-浙江省
	|-杭州市
	|-宁波市
	|-温州市
	|-...
|-安徽省
	|-合肥市
	|-黄山市
	|-芜湖市
	|-...

而数据库查出来的是:

id    name     pid

1     浙江省    0

2    杭州市     1

3    宁波市     1

4    温州市     1

5    安徽省     0

6    合肥市     5

7    黄山市     5

8    芜湖市     5

此时,List需要“自匹配”。

我们可以把“自匹配”转为“两个数据集合匹配”(List转Map,然后List和Map匹配):

image.png

public class Demo {  
    public static void main(String[] args) {  
        List<City> cities = new ArrayList<>();  
        cities.add(new City(1 , "浙江省" , 0));  
        cities.add(new City(2 , "杭州市" , 1));  
        cities.add(new City(3 , "宁波市" , 1));  
        cities.add(new City(4 , "温州市" , 1));  
        cities.add(new City(5 , "安徽省" , 0));  
        cities.add(new City(6 , "合肥市" , 5));  
        cities.add(new City(7 , "黄山市" , 5));  
        cities.add(new City(8 , "芜湖市" , 5));  
    }  
}  
class City{  
    private Integer id;  
    private String name;  
    private Integer pid;  
}
Map<Integer, City> provinces = cities.stream().collect(Collectors.toMap(City::getId,Function.identity()));  
for (City city : cities) {  
    if (city.getPid().equals(0))  
        continue;  
    System.out.println(provinces.get(city.getPid()).getName() + " - " + city.getName());  
}

上面这种情况属于自关联匹配,强行把同一张表的数据当成两个数据通过id和pid匹配。而实际开发中,更为常见的是两张表的数据匹配:

image.png

因为有些公司不允许过多的JOIN查询,此时就只能根据主表先把分页的10条数据查出来,再根据主表数据的ids把从表的10条数据查出来,最后在内存中匹配。(其实对于10条数据,用for循环也没问题)

尝试封装工具类

posted @   EUNEIR  阅读(14)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
点击右上角即可分享
微信分享提示