LinkedList深度解析

1. LinkedList 基础介绍

1.1 重要的私有属性和构造器

1.11私有变量

重要的私有属性有三个, LinkedList的节点个数, 链表的首节点和链表的末节点. 这样设置的原因显而易见, 首先节点个数是我门经常使用的甚至作为循环控制条件的, 首节点末节点对于没有继承随机访问类的LinkedList来说(继承的顺序访问类)是最重要的开端是其他算法的基础.
在这里插入图片描述
这里的私有变量 first和last只是声明了却没有实例化, 因此在没有实例化的情况下一定是Null.

1.12 无参构造器

在这里插入图片描述
这个无参构造器就啥都不干哈哈哈哈哈, 因为它干的事都被私有变量声明的时候给干完了. 我们要实现的无非是size为0, 首节点末节点为null, 看看上面的私有变量的初始化是不是就是这回事.

1.13 有参构造器

在这里插入图片描述
这个有参构造器比较牛逼哈, 只要传进来个collection对象直接把你的元素全部放到我这里. 具体操作看一会addAll的解析.

1.2 Node Helper类 LinkedList类的精华

1.21 Node Helper类的初体验

在这里插入图片描述
Node Helper类是LinkedList的基础类型就像ArrayList的底层数据结构是数组.
Node的构造器很简单就是把传进来的对象赋值给item, item是对象或值, first, last是前后节点.
注意这里没有写无参构造器意味着new的时候必须传入参数不会自动生成无参构造器.
同时< E > 限制了这个Node的中所有带这个泛型的参数的数据类型, 但是Node是一次声明一个不同于容器声明一次一只调用, 怎么才能限制所有Node的类型整齐? 秘密就在于Node是LinkedList的私有类是不对外开放的, LinkedList是第一步初始化的限制了它内部的数据的类型, 它把它初始化的类型传给了内部的Node泛型, 因此Node一定是一样的因为是在内部初始化.
Node 跟 LinkedList的关系在于, LinkedList是存放Node的容器, LinkedList的底层数据结构是Node, Node是实现顺序搜索和速插的关键.

1.22 Node里泛型的应用和内部静态类的应用

Node为什么要有E呢目的是为了限制Node的值是和LinkedList存储的值类型是一样的.
接下来我们详细解说一下这里泛型的运行原理和内部静态类的.
首先看这个泛型很明显是从LinkedList声明的一瞬间, LinkedList里面所有的就有了对应的类型限制也就是里面所有的E都会被更提成特定的类型像C的占位符一样的. 然后这时候比如LinkedList里面有一个函数需要传参数然后参数类型是E, 那么你在调用的时候必须传入的参数是刚刚声明LinkedList的时候给定的泛型:
在这里插入图片描述
上面4报错的原因就是在声明JavaApplication的过程中已经给E替换成了String用Integer不对.
再说下内部静态类是可以在外部类的里面被实例化的, 但是如果在外部类的外面被实例化必须要带着外部类的引用就是JavaApplication.new NewClass(); 然而非静态内部类需要两个new.

2. LinkedList的操作

2.1 添加元素

A. Public void addFirst(E e){}

public void addFirst(E e) {
        linkFirst(e);
    }

这个真是再简单不过了调用了链接到首部的函数.

    private void linkFirst(E e) {
        //将内部保存的首节点点赋值给f
        final Node<E> f = first;
        //创建新节点,新节点的next节点是当前的首节点
        final Node<E> newNode = new Node<>(null, e, f);
        //把新节点作为新的首节点
        first = newNode;
        //判断是否是第一个添加的元素
        //如果是将新节点赋值给last
        //如果不是把原首节点的prev设置为新节点
        if (f == null)
            last = newNode;
        else
            f.prev = newNode;
        //更新链表节点个数
        size++;
        //将集合修改次数加1
        modCount++;
    }

流程讲一下哈, 首先创建个新节点f=最开始first也就是把最原始的first给存下来, 这是交换两个参数的值得最常用的方式(引入另一个节点). 接下来创建一个新节点并且把传进来的e也就是要放在LinkedList首位的元素传进来, 并且把f(此时是原来的第一个元素)传入e的next的位置. 这个时候已经完成了把新的元素放到第一位并且把原来的第一位放到第二位的操作了(对于e来说哈), 所以我们目前看到有两种将Node串联起来的方式. 接下来还差的就是对于原来的第一个元素来说把自己的前一个设置成新的第一个元素. 但是在这之前要进行判断addFirst(e)中的e是不是LinkedList里第一个添加的元素, 若是的话就没必要吧null.prev设成e也很有可能会报错.
进行了判断后如果不为调用f.prev把f的前一个设成e. 为空就进行最后一步处理, 在介绍LinkedList的私有变量的时候也讲了有三个东西很重要, size , first 和 last. 因此我们先更新first(这个其实可以再e一传进来就赋值给first不用等后续的判断也不用特殊处理因为泛型)接下来就是把改变last这里涉及两个选择, 如果e是第一个加进来的元素就把e赋值给last反之不管, 接下来就是改变size因为加了一位元素所以size++. 最后大家也看到了modCount, 这个顾名思义是修改次数(Modification Count) 所以这个记录了LinkedList更改的次数可以用与复杂度吧.

这里我们发现了两种讲Node连接起来的方法:

  1. 用构造器通过在特定的位置传入参数实现连个Node的相互连接
  2. 用Node调用自己的私有变量(是public的)来直接设置自己的前一个或后一个. Node类是静态私有类

B. Public void addLast(E element){}

//在链表尾部添加元素
public void addLast(E e) {
        linkLast(e);
    }
    void linkLast(E e) {
        //将内部保存的尾节点赋值给l
        final Node<E> l = last;
        //创建新节点,新节点的prev节点是当前的尾节点
        final Node<E> newNode = new Node<>(l, e, null);
        //把新节点作为新的尾节点
        last = newNode;
        //判断是否是第一个添加的元素
        //如果是将新节点赋值给first
        //如果不是把原首节点的next设置为新节点
        if (l == null)
            first = newNode;
        else
            l.next = newNode;
        //更新链表节点个数
        size++;
        //将集合修改次数加1
        modCount++;
    }

这个和上面的addFirst如出一辙就不介绍了

C. Public void add (E element){}

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

这个和上面的addLast简直就是一个模子里刻出来的不同的是返回true因为没用容量限制.

D. Public void add(int index, E element){}

这个其实是区别于前面三个或者说一个的这个是在任意位置加入一个元素. 这个地方也真正体现出来了为什么要有两个指针两个信息就是升维就能加快运行速度-算法的核心思想.

//该方法和上面add方法的区别是,该方法可以指定位置插入元素
    public void add(int index, E element) {
        //判断是否越界
        checkPositionIndex(index);
		//如果index等于链表节点个数,就将元素添加到俩表尾部,否则调用linkBefore方法
        if (index == size)
            linkLast(element);
        else
            linkBefore(element, node(index));
    }

这个地方第一步看给的index是不是超出了目前的索引数或者有没有道理
然后如果index刚好等于size也就是目前的索引+1的话很明显后端加入最快因此调用.
否则去调用node函数先去找到index的位置再去将前后.next和.prev改掉查找是线性复杂度.
注意为什么不判断是不是需要前方加入, 因为前方加入的话list一定为空前后加都一样.

    //获取指定位置的节点
    Node<E> node(int index) {
		//如果index小于size的一半,就从首节点开始遍历,一直获取x的下一个节点
        //如果index大于或等于size的一半,就从尾节点开始遍历,一直获取x的上一个节点
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++)
                x = x.next;
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--)
                x = x.prev;
            return x;
        }
    }

这个首先要找到index所在的位置对吧, 那么从前面开始还是从后面开始呢, 我们选择用二分法,如果index小于一半的现有大小就从前面不然就从后面, 比如从前面开始:

  1. 声明一个新的节点并获取firt的值 这是i=0哈所以结束循环时, i=index.
  2. 从i=0到i=index-1x一直等于后一个node
  3. 等到循环结束x就会等于LinkedList[index]就是我们要处理.prev的位置, 在它前面加入元素.
  4. 把找到的这个index前面的一个node返回出去

注意整个过程是创建了一个新节点x并且吧first的值付过去往后找的不影响原来node.

//将元素插入到指定节点前
    void linkBefore(E e, Node<E> succ) {
        // assert succ != null;
        //拿到succ的上一节点
        final Node<E> pred = succ.prev;
        //创建新节点
        final Node<E> newNode = new Node<>(pred, e, succ);
        //将新节点作为succ的上一节点
        succ.prev = newNode;
        //判断succ是否是首节点
        //如果是将新节点作为新的首节点
        //如果不是将新节点作为pred的下一节点
        if (pred == null)
            first = newNode;
        else
            pred.next = newNode;
        //更新链表节点个数
        size++;
        //将集合修改次数加1
        modCount++;
    }

这里已经找到了要插入位置的元素那自然而然的先拿到前一个, 然后初始化一个新的节点, 利用构造器将刚拿到的前一个插入元素和刚刚找到的插入位置的元素连起来(对于插入元素来说)
接着要调整前后元素的.next和.prev. 刚刚找到的那个元素直接把自己的前节点设成newNode但是这个时候要判断之前找到的那个节点是不是首节点, 因为我们之前只判断了插入的index是不是刚好是末尾, index是否合理, 但是这两种情况下有个例外就是LinkedList里只有这一个元素, 然后我们插入到它前面去没调用addFirst. 因此要判断刚刚找到的那个元素是不是原来的首节点是的话就不能用pred.next不是的话再把pred和node绑起来, 不然的话直接走到最后一步, 就是调整First指针以及size. 那这里为什么不调整last指针呢, 因为刚才考虑过了是不是加入的位置刚好是LinkedList的结尾, 也只有那一种情况是要更改Last的.

E. Public boolean addAll(int index, Collection<? extends E>){}

这个可以看到是需要传入两个参数的, 那么为什么有参构造器直接传入了c, 这是因为那个addALL对这个进行了进一步的包装然后进一步传入了饿index=size刚开始就是0.

    //将集合内的元素依次插入index位置后
    public boolean addAll(int index, Collection<? extends E> c) {
        //判断是否越界
        checkPositionIndex(index);
		//将集合转换为数组
        Object[] a = c.toArray();
        int numNew = a.length;
        //判断数组长度是否为0,为0直接返回false
        if (numNew == 0)
            return false;
		//pred上一个节点,succ当前节点
        Node<E> pred, succ;
        //判断index位置是否等于链表元素个数
        //如果等于succ赋值为null,pred赋值为当前链表尾节点last
        //如果不等于succ赋值为index位置的节点,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);
            //如果上一个节点为null,把新节点作为新的首节点,否则pred的下一个节点为新节点
            if (pred == null)
                first = newNode;
            else
                pred.next = newNode;
            //把新节点赋值给上一个节点
            pred = newNode;
        }
		//如果index位置的节点为null,把pred作业尾节点
        //如果不为null,pred的下一节点为index位置的节点,succ的上一节点为pred
        if (succ == null) {
            last = pred;
        } else {
            pred.next = succ;
            succ.prev = pred;
        }
		//更新链表节点个数
        size += numNew;
        //将集合修改次数加1
        modCount++;
        //因为是无界的,所以添加元素总是会成功
        return true;
    }
  1. 检查index是否满足上下界
  2. 将collection转化为array
  3. 获得数组的长度判断数组是否为空为空直接返回false
  4. 声明两个节点index-插入位置所在的节点和它的前一个节点, 这时候只是声明没初始化
  5. 然后需要判断index是不是刚好是在末尾插入是的话succ=null, pred=last, 这一步的目的是尽可能找到一些特例用来优化时间
  6. 如果不是的话那么用node函数传入index返回当前链表中index对应的元素, pred=succ.prev
  7. 之后循环将数组中的值加入链表里,
  8. 对于每一个元素先声明一个新的节点然后判断pred是否为0与上一个一样可能succ是唯一的
  9. 然后把prev.next设置成新节点 再把新节点赋值给prev像迭代一样
    10.最后处理succ和last, 如果succ为null说明末尾加入那么不用说了pred就last然后size++
    不然就只有pred=succ的前一个(这里pred是数组中最后一个加入的元素)

为什么不循环调用add(int, collection) 因为这样最外面一层循环是线性的然后每一次找到位置又是线性的这样是O(n2)的复杂度, 但是如果我只找到一次index然后循环加入其实就相当于一直在末尾加入了只不过最后还要处理下后半部分但是复杂度是2n也就是O(n).

2.2 删除操作.

2.21 Public E remove() {}

    //删除首节点
    public E removeFirst() {
        final Node<E> f = first;
        //如果首节点为null,说明是空链表,抛出异常
        if (f == null)
            throw new NoSuchElementException();
        return unlinkFirst(f);
    }

这里先把第一个节点拿到通过声明一个新节点的方式, 这里之所以不直接把first=first.next 是因为这样的话原来的first那个节点对象还是在占着内存只是我们看不到了而已. 注意判断null

    //删除首节点
    private E unlinkFirst(Node<E> f) {
        //首节点的元素值
        final E element = f.item;
        //首节点的下一节点
        final Node<E> next = f.next;
        //将首节点的元素值和下一节点设为null,等待垃圾收集器收集
        f.item = null;
        f.next = null; // help GC
        //将next设置为新的首节点
        first = next;
        //如果next为null,说明说明链表中只有一个节点,把last也设为null
        //否则把next的上一节点设为null
        if (next == null)
            last = null;
        else
            next.prev = null;
        //链表节点个数减1
        size--;
        //将集合修改次数加1
        modCount++;
        //返回删除节点的元素值
        return element;
    }

这里因为把上面的第一个元素传了进来, 首先new一个节点并且等于传进来也就是第一个元素.next, 之后再让first等于新创建的节点. 然后将f也就是原第一个元素的值和指针赋成空回收.
别忘了判断first是不是为空如果是我们删掉了唯一一个元素那么last也为空, 最后该大小次数.
注意这个函数返还了删除掉的元素, 也就是类型为E的那个元素值.

为什么不直接让first=传进来的元素.next? 这里有丶问题 本来以为是对象相等=的指向问题:
在这里插入图片描述
在这里插入图片描述
可以看到在First=a.next后, First 和 c 其实就是指向一个对象了, 之后a.next=null和First没关系.
所以这里是有丶迷惑的, 但是注意如果是First=a, 然后再把a.next=null, 那毋庸置疑First=null.

2.22 Public E removeLast(){}

和之前那个大同小异不过这时候是判断是不是删除掉了最后一个元素要不要改First.

    public E removeLast() {
        final Node<E> l = last;
        //如果首节点为null,说明是空链表,抛出异常
        if (l == null)
            throw new NoSuchElementException();
        return unlinkLast(l);
    }

一模一样的先获得最后一个节点.

    private E unlinkLast(Node<E> l) {
       	//尾节点的元素值
        final E element = l.item;
        //尾节点的上一节点
        final Node<E> prev = l.prev;
        //将尾节点的元素值和上一节点设为null,等待垃圾收集器收集
        l.item = null;
        l.prev = null; // help GC
        //将prev设置新的尾节点
        last = prev;
        //如果prev为null,说明说明链表中只有一个节点,把first也设为null
        //否则把prev的下一节点设为null
        if (prev == null)
            first = null;
        else
            prev.next = null;
        //链表节点个数减1
        size--;
        //将集合修改次数加1
        modCount++;
        //返回删除节点的元素值
        return element;
    }

这里流程也是一模一样不过.next改成.prev然后判断First受不受影响, 再返还删掉的元素.

2.23 Public boolean remove(Object o){}

这个和上面的add(index, Element) 或者 addAll是一个强度的.

public boolean remove(Object o) {
        //因为LinkedList允许存在null,所以需要进行null判断
        if (o == null) {
            //从首节点开始遍历
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null) {
                   	//调用unlink方法删除指定节点
                    unlink(x);
                    return true;
                }
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item)) {
                    unlink(x);
                    return true;
                }
            }
        }
        return false;
    }

好啦由于LinkedList也支持存入null这下和ArrayList一样要写两个循环去对应不同的表达式了.
不过这个的核心逻辑很简单, 循环从第一个节点开始往后找直到节点为空遍历结束, 这个过程中, 去判断当前节点和传入的元素是否相等相等就unlink it 并且返回true, 循环结束返回false.

 //删除指定节点
    E unlink(Node<E> x) {
        //获取x节点的元素,以及它上一个节点,和下一个节点
        final E element = x.item;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;
		//如果x的上一个节点为null,说明是首节点,将x的下一个节点设置为新的首节点
        //否则将x的上一节点设置为next,将x的上一节点设为null
        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }
		//如果x的下一节点为null,说明是尾节点,将x的上一节点设置新的尾节点
        //否则将x的上一节点设置x的上一节点,将x的下一节点设为null
        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }
		//将x节点的元素值设为null,等待垃圾收集器收集
        x.item = null;
        //链表节点个数减1
        size--;
        //将集合修改次数加1
        modCount++;
        //返回删除节点的元素值
        return element;
    }

这个看着长也不难, 就是先把element拿出来用于最后返回.
然后分别把.prev, .next拿出来用于判断first, last改不改
并且把.prev, .next连起来
最后把item赋成0.
大小更改次数++.

2.24 Public E remove(int index){}

/删除指定位置的节点,其实和上面的方法差不多
	//通过node方法获得指定位置的节点,再通过unlink方法删除
    public E remove(int index) {
        checkElementIndex(index);
        return unlink(node(index));
    }

这个和之前的太像了知道为什么unlink要有返回值了吧, 没错就是为了这个准备的. 但是还是有些不一样的那个以为不涉及到指针没法调用node(int)这个函数找到相对应的元素, 因此只能自己找, 但是这个可不一样有指针了就调用node来返回对应的元素接下来和上面就一样了.

2.3 获取元素

2.31 Public E get (int index){}

E还是之前在声明LinkedList的时候就初始化的起规范包含元素作用的类, 这里给了index, 因此很自然的调用之前的Node函数获得index所对应的元素注意在封装的上一层中返还元素. item, 而不是item本身, 因为节点这个类是内部静态类出了这个外类, Node是没有用的.

    //获取指定位置的元素值
    public E get(int index) {
        //判断是否越界
        checkElementIndex(index);
        //直接调用node方法获取指定位置的节点,并反回其元素值
        return node(index).item;
    }

同时注意要检查index的上下界是否合理, 以及与之前不同返回的index对应值不是用去unlink.

2.32 Public E getFirst(){}

这个和之前一样返回规定好的E类型, 然后不同的是不需要传入指针也不需node函数因为指针.

    //获取链表首节点的元素值
    public E getFirst() {
        final Node<E> f = first;
        //判断是否是空链表,如果是抛出异常,否则直接返回首节点的元素值
        if (f == null)
            throw new NoSuchElementException();
        return f.item;
    }

注意判断first是否是null否则调用f.item没有意义, 这里为了保证first的规范性统一新声明节点而不用原来的. 注意这个地方f与first两个引用变量指向同一个地址因此改f的属性其实改了指向对象的属性自然而然也改了first的属性, 但是f和first的值都没变为所指向的地址. 但是这样做的目的可能是为了f可以继续更改所指向的对象但不会影响first在此之前又能改变第一个节点.

2.33 Public E getLast(){}

    //获取链表尾节点的元素值
    public E getLast() {
        final Node<E> l = last;
        //判断是否是空链表,如果是抛出异常,否则直接返回尾节点的元素值
        if (l == null)
            throw new NoSuchElementException();
        return l.item;
    }

这个和上面除了用的指针不一样其他完全一样, 总之在LinkedList里声明新节点再操作就对了.

2.4 设置节点元素值

2.41 Public E set (int index, E element){}

这个传入的参数让我们想到了什么:

  1. 肯定要调用node函数用于返回当前index对应的对象在它和它前面节点之间插入新节点
  2. 肯定要声明新节点用构造函数把它和刚才获得的节点以及它前一个节点连起来
  3. 然后分别处理前一个节点吼后一个节点肯定涉及到判断前一个和后一个是否为0的情况
  4. 前一个为null则把first指向新插入的节点, 反之获得节点为null把末尾指向element所对应.
  5. 同时注意在第一步应该判断index是否等于size如果是不需要用node直接末尾添加.
    public E set(int index, E element) {
        //判断是否越界
        checkElementIndex(index);
        //指定位置的节点
        Node<E> x = node(index);
        E oldVal = x.item;
        //设置新值
        x.item = element;
        //返回老值
        return oldVal;
    }

对不起看了源码发现想错了, 我写的是add(int index, E element) 哈哈哈哈哈哈哈
没事这个还是有些地方分析对了的比如说用node返回index所对应的节点. 这个其实很简单
就是获得当前所对应节点, 进而获取当前所对应元素值并返回, 然后用.item更新节点元素值.

2.5 其他的重要操作

2.51 Public int indexOf(Object o){}

    public int indexOf(Object o) {
        int index = 0;
        if (o == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.item == null)
                    return index;
                index++;
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (o.equals(x.item))
                    return index;
                index++;
            }
        }
        return -1;
    }

这个和之前的那个 node的实现是不同的和remove前面找相应节点是很像的.

  1. Node传入的是index通过index去找相应的节点自然而然可以二分并且用index循环
  2. 而remove和indexOf传入的是Object因此不能通过指针找只能从前面第一个节点开始往后循环, 同时由于判断条件的不同还必须写两个循环.

2.52 Public int lastIndexOf(){}

    public int lastIndexOf(Object o) {
        int index = size;
        if (o == null) {
            for (Node<E> x = last; x != null; x = x.prev) {
                index--;
                if (x.item == null)
                    return index;
            }
        } else {
            for (Node<E> x = last; x != null; x = x.prev) {
                index--;
                if (o.equals(x.item))
                    return index;
            }
        }
        return -1;
    }

这个和上面一模一样不同的是从后面开始循环, 找不到返回-1.

2.53 Public boolean contains(Object o){}

这个其实调用了从前往后找的indexOf 封装了一层函数如果不为-1则返回true完全因为返回值.

	public boolean contains(Object o) {
        return indexOf(o) != -1;
    }
	

2.54 Public Object[] toArray(){}

Object[] 这不就是Object类型的数组嘛.

    public Object[] toArray() {
        Object[] result = new Object[size];
        int i = 0;
        //将链表中所有节点的元素值添加到object数组中
        for (Node<E> x = first; x != null; x = x.next)
            result[i++] = x.item;
        return result;
    }

第一步声明一个Object类型的数组, 注意Object类型就是为了迎合节点中存储的元素类型.
第二步声明一个数组指针i用于给数组赋值(基本类型数组的唯一赋值方式).
第三部建立循环从first第一个节点开始直到当前节点不等于null, 每次循环节点=节点.next哈
第四部在循环内部把每次得到的节点赋值给当前指针所指的数组位置.
第五步是把数组的指针+1, 其实这一步是和第四部一起完成的result[i++]=x.item.
在这里插入图片描述
注意这个toArray每次返回的都是一个新数组, 但是数组包含了原引用变量而不是克隆了.
可以看到尽管返回的两个数组并不相同但是他们包含了同一组引用变量, 因此可以说当其中一个引用变量对它所指向的堆中地址进行了改变后, 所有指向该对象的引用变量都会相应变化.

//People类和上一个例子中的一样,这里不再列出了。
public static void main(String[] args) {
    List<People> list = new ArrayList<>();
    list.add(new People("小明"));
    list.add(new People("小王"));
    Object[] objects1 = list.toArray();
    Object[] objects2 = list.toArray();
    System.out.println("objects1 == objects2 : "+(objects1 == objects2));
    ((People)objects1[1]).name = "小花";
    System.out.println("show objects1: "+ Arrays.toString(objects1));
    System.out.println("show objects2: "+ Arrays.toString(objects2));
    System.out.println("show list: "+list);
}

objects1 == objects2 : false
show objects1: [People{name=‘小明’}, People{name=‘小花’}]
show objects2: [People{name=‘小明’}, People{name=‘小花’}]
show list: [People{name=‘小明’}, People{name=‘小花’}]

原创文章 28 获赞 44 访问量 3806

猜你喜欢

转载自blog.csdn.net/qq_42141943/article/details/105738253