10【单列集合】

一、List集合

1.1 List集合概述

List接口是单列集合的一个重要分支,下面主要有两个实现 ArrayListLinkedList,List类型接口的特点是存储的元素是有序的,即存放进去是什么顺序,取出来还是什么顺序,也就是基于线性存储;因此在List接口中提供有大量根据索引来操作元素的方法;

  • List集合的体系:

List接口特点:

  1. List接口存储的数据是有序排列的,原来存储的时候是什么顺序,取出来就什么顺序(Set接口存储的是无序的);
  2. List接口为存储的每一个元素都分配了一个索引,通过索引我们可以精确的来访问某一个指定的元素;
  3. List接口存储的数据允许存在重复,这与Set接口不同(Set接口不允许存储相同的元素);

1.2 List接口的方法

List是Collection的子接口,因此Collection中存在的方法List都存在;因为List的特点是有序,因此除Collection接口提供的方法之外List还添加了许多与顺序相关的方法,例如指定顺序插入,指定顺序删除,指定顺序替换等;

1.2.1 常用方法

  • public boolean add(int index, E element):将指定的元素,添加到该集合中的指定位置上。
  • public E get(int index):返回集合中指定位置的元素。
  • public boolean remove(int index): 移除列表中指定位置的元素, 返回的是被移除的元素。
  • public E set(int index, E element):用指定元素替换集合中指定位置的元素,返回值的更新前的元素。
  • List<E> subList(int fromIndex, int toIndex):从fromIndex下标截取到toIndex下标(不包含)。

使用示例:

package com.dfbz.demo01_ArrayList;

import java.util.ArrayList;
import java.util.List;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_List接口常用方法 {

    public static void main(String[] args) {
        // 创建List集合对象
        List<String> cities = new ArrayList();
        cities.add("南京");
        cities.add("南昌");
        cities.add("南宁");

        List newList = cities.subList(0, 2);            // [南京, 南昌]
        System.out.println(newList);
    }

    public static void test() {
        // 创建List集合对象
        List<String>  cities = new ArrayList();

        cities.add("济南");           	// 索引: 0
        cities.add("石家庄");          	// 索引: 1
        cities.add("昆明");           	// 索引: 2

        System.out.println(cities);     // [济南, 石家庄, 昆明]

        cities.add(1, "福州");      // 原来的元素往后推

        System.out.println(cities);         // [济南, 福州, 石家庄, 昆明]

        // 删除索引位置为2的元素,返回被删除元素
        System.out.println(cities.remove(2));           // 石家庄

        System.out.println(cities);            // [济南, 福州, 昆明]

        // 在指定位置 进行 元素替代(改)
        cities.set(0, "银川");
        System.out.println(cities);             // [银川, 福州, 昆明]

        // 获取指定位置元素
        System.out.println(cities.get(0));      // 银川

        System.out.println("---------");
        // 跟size() 方法一起用  来 遍历的
        for (int i = 0; i < cities.size(); i++) {
            System.out.println(cities.get(i));
        }

        System.out.println("---------");
        //还可以使用增强for
        for (Object city : cities) {
            System.out.println(city);
        }
    }
}

1.2.2 ListIterator

List接口还提供了一个针对于List集合迭代的迭代器ListIterator,该迭代器与我们之前学过的Iterator迭代器不同,ListIterator允许迭代器往上或者往下迭代,而Iterator迭代器只允许指针一直往下移动;

  • ListIterator<E> listIterator():获取List集合的迭代器

  • ListIterator方法:
    • public boolean hasNext():是否还存在下一个元素;
    • public boolean hasPrevious():是否还存在上一个元素
    • E next():获取下一个元素,如果指针目前是向上运行,第一次调用时会调转指向返回指针当前指向的元素;
    • E previous():获取下一个元素,如果指针目前是向下运行,第一次调用时会调转指向返回指针当前指向的元素;
    • void add(E e):添加一个元素到当前指针指向的后面一位
    • void remove():移除当前指针指向的元素;

示例代码:

package com.dfbz.demo01_ArrayList;

import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02_ListIterator {
    public static void test(String[] args) {
        List<String> cities = new ArrayList();
        cities.add("河南胡辣汤");
        cities.add("山西刀削面");
        cities.add("江西粉蒸肉");
        cities.add("山东把子肉");

        // ListIterator迭代集合时,可以新增,也可以删除
        ListIterator<String> iterator = cities.listIterator();

        while (iterator.hasNext()) {
            String ele = iterator.next();
            if ("江西粉蒸肉".equals(ele)) {
                // 使用迭代器新增
                iterator.add("江西瓦罐煨汤");
            }
            if ("山西刀削面".equals(ele)) {
                // 使用迭代器删除
                iterator.remove();
            }
        }
        System.out.println(cities);         // [河南胡辣汤, 江西粉蒸肉, 江西瓦罐煨汤, 山东把子肉]
    }

    public static void main(String[] args) {
        // 创建List集合对象
        List<String> cities = new ArrayList();
        cities.add("南京");
        cities.add("南昌");
        cities.add("南宁");

        // 获取一个List迭代器
        ListIterator<String> iterator = cities.listIterator();
        // 南京
        System.out.println(iterator.next());
        // 南昌
        System.out.println(iterator.next());
        // 南宁
        System.out.println(iterator.next());
        // 南宁
        System.out.println(iterator.previous());
        // 南昌(调转指针指向)
        System.out.println(iterator.previous());
        // 南昌(调转指针指向)
        System.out.println(iterator.previous());
    }
}

1.3 ArrayList 集合

1.3.1 ArrayList集合概述

ArrayList底层采用数组这种数据结构来实现的;因此ArrayList元素查询速度快、增删相对较慢;我们在开发过程中,数据一般都是"读多写少",因此ArrayList非常常用;

  • ArrayList集合继承体系:

Tips:ArrayList默认初始化的数组大小容量为10,当存储的元素超出数组大小时,按照1.5倍进行数组扩容;

ArrayList的大部分方法在之前学习List接口的时候我们都使用过了,因此我们这里只列举一下ArrayList的构造方法;

1.3.2 ArrayList集合常用方法

  • 构造方法:
    • public ArrayList():构造一个初始容量为10的空列表。
    • public ArrayList(int initialCapacity):构造具有指定初始容量的空列表。
    • public ArrayList(Collection<? extends E> c):构造一个包含指定集合的元素的列表,按照它们由集合的迭代器返回的顺序。
  • 示例代码:
package com.dfbz.demo01_ArrayList;

import java.util.ArrayList;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03_ArrayList的构造方法 {
    public static void main(String[] args) {
        // 创建一个默认容量大小的集合
        ArrayList<String> arr1 = new ArrayList<>();
        arr1.add("武汉");
        arr1.add("长沙");
        arr1.add("南昌");

        // 创建一个指定容量大小的集合
        ArrayList<String> arr2 = new ArrayList<>(20);

        // 基于一个旧的集合创建一个新的集合
        ArrayList<String> arr3 = new ArrayList<>(arr1);
        arr3.add("郑州");
        arr3.add("合肥");

        System.out.println(arr3);           // [武汉, 长沙, 南昌, 郑州, 合肥]
    }
}

1.3.2 ArrayList底层原理

ArrayList底层采用的是数组这种数据结构,我们知道数组一旦定义了,长度就不可以发生改变了,但ArrayList的长度却可以任意变更。这是由于ArrayList底层会自动扩容,当存储的元素到达数组上限时,ArrayList会重新创建一个新长度的数组,将元素复制到新数组中,并将新数组返回。

1) ArrayList成员变量

ArrayList的成员变量:

public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    private static final long serialVersionUID = 8683452581122892189L;

    // ArrayList默认初始化数组容量大小
    private static final int DEFAULT_CAPACITY = 10;

    // 在创建ArrayLis容器大小指定为0时的临时数组
    private static final Object[] EMPTY_ELEMENTDATA = {};

    // 使用空参构造方法创建ArrayList时的临时数组
    private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

    // ArrayList用于存储元素的数组
    transient Object[] elementData; // non-private to simplify nested class access

    // 标记ArrayList实际存储的元素个数
    private int size;

2) ArrayList初始化

构造方法:

当使用空参构造方法来创建ArrayList时,elementData赋值为了一个空的数组,此时数组并没有指定大小;

ArrayList真正的初始化在第一次添加元素时:

3) ArrayList扩容原理

我们知道ArrayList默认数组容量初始化大小为10,当我们添加到第11个元素时,那么ArrayList将会触发扩容;

可以看到ArrayList在超出数组容量扩容时,是首先拿到原数组的长度,然后在元素的长度上右移位1(相当于除以2^1)再加上原数组长度,得到新数组长度,因此ArrayList的扩容可以看做是1.5倍进行扩容;

1.4 LinkedList 集合

1.4.1 LinkedList概述

LinkedList即是List派系下的实现类,也是Queue集合派系下的实现类,因此LinkedList除了具备List体系的方法外,还提供有Queue集合体系的方法,LinkedList底层采用链表这种数据结构来实现的,因此增删速度较快,查询速度较慢;

  • LinkedList集合继承体系:

Tips:LinkedList底层是一个双向链表,对比与单向链表多了一个指针指向上一个元素;

1.4.2 LinkedList常用方法

1)List接口相关方法

LinkedList属于List接口下的一个实现类,因此List接口中的那些有关于索引的操作方法,LinkedList都具备;

但需要注意的是,虽然LinkedList提供索引操作的相关方法,但LinkedList底层并不是采用数组实现,而是采用链表来实现,链表本身并没有索引而言,换句话来说,LinkedList并不能通过索引去查询一次就返回所需要的元素,而是采用一种算法(二分查找法),根据索引去挨个遍历查询整个链表查询所需要的元素,这样下来,LinkedList的查询效率将远不如ArrayList;

  • 示例代码:
package com.dfbz.demo02_LinkedList;

import java.util.LinkedList;
import java.util.List;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_LinkedList_List体系 {

    public static void main(String[] args) {

        List<String> list=new LinkedList<>();

        list.add("厦门");
        list.add("福州");
        list.add("莆田");

        System.out.println(list);

        String city = list.get(1);
        System.out.println(city);
    }
}

2)Queue接口相关方法

LinkedList的强项并不在于元素的查询,而是元素的增删,而我们在增删过程中,最好操作链表的头部或者尾部,因为这样不需要去浪费额外的时间来查询需要操作的元素位置,在Queue接口中的Deque接口下定义有很多关于链表(队列)头和尾部的操作;

方法如下:

  • public void addFirst(E e):将指定元素插入此列表的开头。
  • public void addLast(E e):将指定元素添加到此列表的结尾。
  • public E getFirst():返回此列表的第一个元素。
  • public E getLast():返回此列表的最后一个元素。
  • public E removeFirst():移除并返回此列表的第一个元素。
  • public E removeLast():移除并返回此列表的最后一个元素。
  • public boolean isEmpty():如果列表不包含元素,则返回true。

  • 代码示例1-链表相关操作:
package com.dfbz.demo02_LinkedList;

import java.util.LinkedList;
import java.util.Queue;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02_LinkedList_Queue体系 {
    public static void main(String[] args) {
        Queue<String> list=new LinkedList<>();

        // 将元素添加到队列
        list.offer("江西");
        list.offer("广西");
        list.offer("陕西");
        list.offer("山西");

        // 队列的特点: 先进先出
        System.out.println(list);           // [江西, 广西, 陕西, 山西]
        System.out.println("--------");

        // 从队列头部取出一个元素(该元素会从队列中移除)
        String poll = list.poll();
        System.out.println(poll);           // 江西
        System.out.println(list);           // [广西, 陕西, 山西]
        System.out.println("--------");

        // 获取处于队列头部的元素(该元素不会从队列中移除)
        String peek = list.peek();          // 广西
        System.out.println(peek);
        System.out.println(list);           // [广西, 陕西, 山西]
    }
}

1.5 Vector集合

1.5.1 Vector集合简介

Vector也是List集合的一个分支,是JDK1.0就推出的集合,也我们也称其为一代集合(ArrayList等集合则称为二代集合),Vector集合底层也是采用数组来实现元素的存储,因此Vector集合的特点也是查询快增删慢;

Vector集合实现与List接口,因此Collection和List接口所具备的方法,Vector都具备,并且Vector集合底层也是采用数组这种数据结构来存储元素的排列;

  • Vector集合的继承体系:

Vector集合与的主要ArrayList的区别如下:

  • 1)Vector集合在扩容是默认是扩容至原来的2倍,ArrayList则是1.5倍,关于容量都是初始化为10
  • 2)Vector集合是线程安全集合,他所有的方法之间是线程同步的,这意味则每次调用Vector的方法时都需要先获取锁,方法结束后也要释放锁,造成不必要的性能开销;ArrayList是线程不安全集合,调用ArrayList集合中的方法不需要先获取锁,调用完毕后也不需要释放锁;因此ArrayList性能比Vector要高,但安全性比Vector要低(可能产生并发问题);
  • 3)Vector集合支持Enumeration,也支持ListIterator迭代器,ArrayList支持ListIterator但不支持Enumeration。在使用Enumeration迭代元素时允许集合对元素进行增删

Tips:关于线程安全问题我们以后学习线程章节时再讨论;


1.5.2 Vector集合的使用

示例代码:

package com.dfbz.demo03_其他集合;

import java.util.ListIterator;
import java.util.Vector;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_Vector {
    public static void main(String[] args) {
        Vector<String> vector = new Vector();
        vector.add("鄱阳湖");
        vector.add("洞庭湖");
        vector.add("太湖");

        // addElement是Vector集合特有的功能,和add方法功能一致
        vector.addElement("洪泽湖");
        vector.addElement("巢湖");

        // 使用foreach迭代
        for (String country : vector) {
            System.out.println(country);
        }
        System.out.println("-------------");

        // 使用迭代器迭代
        ListIterator<String> iterator = vector.listIterator();

        while (iterator.hasNext()){
            String element = iterator.next();
            System.out.println(element);
        }
    }
}

1.5.3 Enumeration迭代

Vector集合支持Enumeration迭代元素,而ArrayList集合不支持,但两者都支持ListIterator迭代,使用ListIterator迭代元素时,可以使用ListIterator对象对集合进行增删操作;

使用Enumeration迭代元素时,也可以直接操作集合的元素(包括新增、修改、删除元素等);

  • 示例代码:
package com.dfbz.demo03_其他集合;

import java.util.Enumeration;
import java.util.Vector;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02_Enumeration迭代 {
    public static void main(String[] args) {
        Vector<String> vector = new Vector<>();
        vector.add("长江");
        vector.add("黄河");
        vector.add("珠江");

        Enumeration<String> elements = vector.elements();

        while (elements.hasMoreElements()) {
            String str = elements.nextElement();

            // 使用Vector的Enumeration迭代时,可以对集合中的元素进行增删操作
            if (str.equals("长江")) {
//                vector.remove(0);
                vector.add("赣江");
            }
        }

        System.out.println(vector);
    }
}

1.6 Stack集合

1.6.1 Stack集合简介

Stack是栈结构的集合,因此具有数据结构中栈的一般特性(后进先出);

在栈中,元素的添加和删除操作只能在容器的一端进行,即栈顶。元素的添加和删除遵循“后进先出”(LIFO)的原则,最后添加的元素总是最先出栈,栈对元素的访问加以限制,仅仅提供对栈顶元素的访问操作

  • 栈的操作示意图:

栈做为一种线性表,其实现方式主要有两种:数组和链表;

  • Stack类的继承体系如下:

Stack继承与Vector集合,说明Stack也是线程安全类,并且Stack底层也是采用数组来实现栈这种数据结构;

1.6.2 Stack集合的使用

1)Stack有关于数组的操作

Stack继承了Vector,因此Vecotr中的方法Stack也同样具备,包括Collection,List等接口中的方法;并且Stack并没有对Vector的方法进行重写改造(原封不动的继承下来),而是在Vector的基础上添加了许多栈有关的操作,例如压栈(push),弹栈(pop)等;我们也可以完全把Stack当作一个Vector集合用;

  • Stack使用Vector相关方法:
package com.dfbz.demo03_其他集合;

import java.util.Stack;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo07_Stack {

    public static void main(String[] args) {
        Stack<String> mountains = new Stack();
        mountains.add("泰山");
        mountains.add("华山");
        mountains.add("衡山");
        mountains.add("恒山");
        mountains.add("嵩山");

        // 迭代元素
        for (String mountain : mountains) {
            System.out.println(mountain);
        }
        System.out.println("-------------------");
        System.out.println(mountains);              // [泰山, 华山, 衡山, 恒山, 嵩山]
        System.out.println("------------------");

        // 根据索引获取元素
        System.out.println(mountains.get(0));       // 泰山
        System.out.println(mountains.get(2));       // 衡山
        System.out.println(mountains.get(4));       // 嵩山
    }

}

2)Stack有关于栈的操作

Stack底层本质上还是一个数组,其特性和Vector一模一样,只不过Stack集合新增了一些关于栈的操作,例压栈,弹栈等;因此对外界来说,Stack可以看作是一个栈;

  • public E push(E item):添加一个元素到栈顶(压栈);
  • public synchronized E pop():从栈顶移除一个元素并返回(弹栈);
  • public synchronized E peek():获取栈顶的一个元素返回,该元素不会从栈顶移除;

示例代码:

package com.dfbz.demo03_其他集合;

import java.util.Stack;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04_Stack_关于栈的操作 {
    public static void main(String[] args) {
        Stack<String> mountains = new Stack();
        mountains.push("庐山");
        mountains.push("黄山");
        mountains.push("雁荡山");

        // 从栈顶获取一个元素,并不移除
        System.out.println(mountains.peek());
    }

    public static void t1(){
        Stack<String> mountains = new Stack();

        // 将庐山添加到栈顶        ["庐山"]
        mountains.push("庐山");

        // 将黄山添加到栈顶        ["黄山","庐山"]
        mountains.push("黄山");

        // 将雁荡山添加到栈顶       ["雁荡山","黄山","庐山"]
        mountains.push("雁荡山");

        // 如果直接打印,还是打印数组中元素的排列顺序,而不是栈的排列顺序
        System.out.println(mountains);          // [庐山, 黄山, 雁荡山]

        // 从栈顶移除一个元素
        System.out.println(mountains.pop());        // 雁荡山
        System.out.println(mountains.pop());        // 黄山
        System.out.println(mountains.pop());        // 庐山

        System.out.println(mountains);              // 元素已经被全部移除,因此集合为空: []
    }
}

二、Set集合

2.1 Set集合概述

Set接口和List接口一样,继承与Collection接口,也是一个单列集合;Set集合中的方法和Collection基本一致;并没有对Collection接口进行功能上的扩充,只是底层实现的方式不同了(采用的数据结构不一样);

  • Set集合体系结构:

2.2 HashSet 集合

2.2.1 HashSet特点

HashSet是Set接口的一个实现类,它所存储的元素是不可重复的,并且元素都是无序的(即存取顺序不一致)。

Tips:HashSet底层采用的是散列表这种数据结构来存储元素

【代码示例-无序】

package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_HashSet的特点_无序 {

    public static void main(String[] args) {
        HashSet<String> cities = new HashSet<>();
        cities.add("合肥");
        cities.add("南昌");
        cities.add("武汉");
        cities.add("郑州");

        System.out.println(cities);
    }
}

运行效果:

Tips:HashSet的存取是无序的;

【代码示例-去重】

package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02_HashSet的特点_去重 {
    public static void main(String[] args) {
        HashSet<String> cities = new HashSet<>();
        cities.add("南昌");
        cities.add("南昌");

        System.out.println(cities);
    }
}

运行效果:

Tips:HashSet会自动去除重复数据;

2.2.2 HashSet的哈希冲突

【例1-观察下列代码】

HashSet判断两个元素是否重复的依据是什么呢?下面案例代码HashSet将会存储几个元素呢?

package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03_HashSet去重的特点{
    public static void main(String[] args) {
        //创建 Set集合
        HashSet<String> set = new HashSet();

        //添加元素
        set.add(new String("河北"));
        set.add("湖北");
        set.add("河北");
        set.add("湖北");

        System.out.println(set.size());                // ?

        System.out.println("--------------------");

        HashSet<A> set2 =new HashSet<>();
        set2.add(new A());
        set2.add(new A());

        System.out.println(set2.size());                 // ?
    }
}

class A{}

要探究HashSet的去重原理我们需要继续往下看!

1)哈希冲突1

我们知道hash表数据结构的特点是:根据元素的hash值来对数组的长度取余,最终计算出元素所要存储的位置;

元素的hash值正是通过元素的hashCode方法来得出的,在默认情况下,对象的hash值是根据对象的内存地址值来计算,如果不希望对象的hash值的计算依据是内存地址值则可以重写hashCode方法;

HashSet的去重很大程度上就是依赖于元素的hash值和equals方法;

我们之前学习hashCode方法时就了解过关于hash冲突的特点:

  • 1) hashCode一致的两个对象不能说明两个对象一定相同,因为可能会造成hash冲突(例如上面的Aa和BB);
  • 2) 但是如果hashCode不同,则一定能说明这两个对象肯定不同,因为同一个对象计算的hashCode永远一样;

示例代码:演示hash冲突

package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo04_HashSet的哈希冲突_01 {
    public static void main(String[] args) {

        HashSet<String> hashSet=new HashSet<>();

        String s1 = "Aa";
        String s2 = "BB";

        System.out.println(s1.hashCode());          // 2112
        System.out.println(s2.hashCode());          // 2112

        hashSet.add(s1);
        hashSet.add(s2);

        System.out.println(hashSet);        //      [Aa, BB]
    }
}

HashSet在存储元素时,首先调用这个对象的hashCode方法获取到该元素的hash值,然后对散列表中的数组长度取余,获得到一个位置,将元素存储到这个位置,如果如果位置上已经存在有其他元素,那么会将这个元素的hash值与新存储进来的元素的hash值进行对比,如果hash值一致,则会使用新元素来调用它的equals方法将老的元素传递进来对比,只有对比的结果为false时,那么新元素才会存储到HashSet中;

当hash冲突时,HashSet将会调用当前对象的equlas方法来比较两个对象是否一致,如果一致则不存储该元素,如果不一致则存储该元素;

【Hash冲突-01小案例】

  • 定义一个Person类:
package com.dfbz.demo01_HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Person {

    private String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                '}';
    }

    @Override
    public int hashCode() {
        // hashCode固定为1,不管存储什么元素,一定会产生hash冲突
        return 1;
    }

    @Override
    public boolean equals(Object obj) {
        System.out.println("this: " + this);
        System.out.println("传递进来的: " + obj);
        
        // 比较的结果固定为false,那就是一定会存储传递进来的元素
        return false;
    }
}
  • 测试hash冲突:
package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo05_HashSet的哈希冲突_01_小案例 {
    public static void main(String[] args) {

        HashSet<Person> hashSet = new HashSet<>();
        Person p1 = new Person("小黄");
        Person p2 = new Person("小灰");

        hashSet.add(p1);

        /*
        存储小灰的时候,发现小灰的hash值和小黄的冲突了
        此时小灰就会调用自己的equals把与它冲突的那个元素(小黄)传递进来对比
        对比的结果为false,因此小灰会被存进HashSet中
         */
        hashSet.add(p2);

        System.out.println("-----------");
        System.out.println(hashSet);
    }
}

输出结果:

图解分析:

2)哈希冲突2

前面主要讨论的是两个元素的hashCode一致的情况,那如果两个元素的hashCode不一致呢?是否会出现hash冲突呢?

假设数组的长度为9,当元素1的hash值为6,元素2的hash值为15,计算的数组槽位都是6,同样的,那么这个时候也会触发hash冲突问题;

  • Integer对hashCode的实现:
@Override
public int hashCode() {
    return Integer.hashCode(value);
}
  • Integer.hashCode():
public static int hashCode(int value) {
    // 就是返回元素本身
    return value;
}

Tips:Integer的hashCode就是本身数值;

  • 示例代码:
package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo06_HashSet的哈希冲突_02 {
    public static void main(String[] args) {

        HashSet<Integer> hashSet = new HashSet<>();

        Integer a1 = 1;
        Integer a2 = 17;

        System.out.println(a1.hashCode());          // 1
        System.out.println(a2.hashCode());          // 17

        hashSet.add(a1);
        hashSet.add(a2);

        System.out.println(hashSet);        //      [1, 17]
    }
}

HashSet存储时的Hash冲突情况:

发生上面这种Hash冲突时HashSet采用拉链法,将新增的元素添加到上一个元素之后形成链表;

根据hash冲突的特点我能够知道,在hash冲突的第二种情况下,并不需要调用equals来去重;

【Hash冲突-01小案例】

  • 定义一个Student类:
package com.dfbz.demo01_HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Student {
    private String name;
    
    // hash值让外部传递进来
    private Integer hash;

    public Student(String name,Integer hash) {
        this.name = name;
        this.hash = hash;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", hash=" + hash +
                '}';
    }

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

    @Override
    public boolean equals(Object obj) {
        System.out.println("this: " + this);
        System.out.println("传递进来的: " + obj);
        
        /*
            比较的结果为true,那么就是如果调用了equals来比较的话,则一定不会存储这个元素
            但如果连equals都没有调用的话,则说明两个对象的hash值不同,不需要借助equals方法来判断两个元素是否相同
         */
        return true;
    }
}
  • 测试代码:
package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo07_HashSet的哈希冲突_02_小案例 {
    public static void main(String[] args) {

        HashSet<Student> hashSet = new HashSet<>();
        Student s1 = new Student("小黄",1);
        Student s2 = new Student("小灰",17);

        hashSet.add(s1);

        /*
        小灰的hash值为17,并没有与其他元素的hash值冲突
        因此hashSet直接存储小灰,并不会调用equals方法来对比
         */
        hashSet.add(s2);

        System.out.println("-----------");
        System.out.println(hashSet);
    }
}

2.2.3 HashSet 去重原理

HashSet在存储元素时,都会调用对象的hashCode()方法计算该对象的hash值来判断是否存储该元素:

  • 1)如果hash值与集合中的其他元素一样:则调用equals方法对冲突的元素进行对比,如果equals方法返回true,说明两个对象是一致的,HashSet并不会存储该对象,反之则存储该元素;
  • 2)如果hash值和其他元素的hashCode不一样:那么将直接存储这个元素;

【代码示例】

package com.dfbz.demo01_HashSet;

import java.util.HashSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo08_HashSet去重原理 {
    public static void main(String[] args) {
        //创建 Set集合
        HashSet<String> set = new HashSet();

        //添加元素
        set.add(new String("河北"));
        set.add("河北");

        System.out.println(set.size());                 // ?
    }
}

一般情况下,不同对象的hash值计算出来的结果是不一样的,但还是有某些情况下,不同一个对象的hash值计算成了同一个,这种情况我们称为hash冲突;当hash冲突时,HashSet会调用equals方法进行对比,默认情况下equals方法对比的是对象内存地址值,因此如果对象不是同一个,equals返回的都是false;

另外,Java中的HashSet还进行了优化,如果两个字符串都是存储在常量池,那么直接在常量池中进行判断,不需要调用equals来判断是否重复

  • 示例代码:
public static void main(String[] args) {
    //创建 Set集合
    HashSet<String> set = new HashSet();

    set.add("河北");

    /*
         存储第二个"河北"时,hashSet发现两个hashCode一致
         并且都是存储在常量池,因此都不需要调用equals来判断这个元素是否存在hashSet
         */
    set.add("河北");

    System.out.println(set.size());                 // 1
}

2.2.4 HashSet的底层原理

HashSet在JDK8做了一次重大升级,JDK8之前采用的是Hash表,也就是数组+链表来实现;到了JDK8之后采用数组+链表+红黑树来实现;

Tips:关于红黑树我们暂时理解为红黑树就是一颗平衡的二叉树(即使他不是绝对平衡);

1) HashSet的负载因子

我们前面了解过HashSet底层采用的是散列表这种数据结构(数组+链表),并且在JDK1.8对传统的散列表进行了优化,增加了红黑树来优化链表查询慢的情况;并且散列表中的数据会根据一定的条件进行扩容。

在默认情况下,HashSet中散列表的数组长度为16,并通过负载因子来控制数组的长度;HashSet中负载因子默认为0.75;

HashSet中实际存储的元素/数组的长度=负载因子

第一次扩容: 16*0.75 = 12
数组长度为: 32

第二次扩容: 32*0.75 = 24
数组长度为: 64

第三次扩容: 64*0.75 = 48
数组长度为: 128

由此可以得出当HashSet中的元素个数为12个时(12÷16=0.75),将进行数组的扩容,默认情况下将会扩容到原来的2倍;数组长度将会变为32,由公式可以推算出,下一次HashSet数组扩容时元素个数将为32*0.75=24,以此类推...

Tips:HashSet在每次存储元素之前都要更加负载因子来判断一下是否需要进行数组扩容

  • 当负载因子过高:

当负载因子是1.0的时候,也就意味着,只有当数组的16个值全部填充了,填充第17个元素的时候,才会发生扩容。这就带来了很大的问题,因为Hash冲突是避免不了的。当负载因子是1.0的时候意味着会出现大量的Hash的冲突,因为我们很难保证元素平均分摊到数组的每一个槽位,大多数情况下是数组的一个槽位上就存储了N多个元素,底层的红黑树变得异常复杂。对于查询效率极其不利。

这种情况就是牺牲了时间来保证空间的利用率,因此一句话总结就是负载因子过大,虽然空间利用率上去了,但是时间效率降低了

  • 当负载因子过低:

负载因子是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。

但是这时候空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。

总结下来就是负载因子太小,虽然时间效率提升了,但是空间利用率降低了

Tips:载因子是0.75的时候,空间利用率比较高,而且避免了相当多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率

负载因子是控制数组扩容的一个重要参数;并且HashSet允许我们在创建时指定负载因子和数值的默认容量大小;

2) HashSet的扩容原理

HashSet底层存储如图所示:

上述图中是一个hash表数据结构(数组+链表),也是JDK8之前的HashSet底层存储结构或者说还未达到阈值转换为红黑树的时候

当存储的元素越来越多,势必造成链表长度非常长,查找元素时性能会变得很低;在JDK8中当链表长度到达指定阈值TREEIFY_THRESHOLD(默认是8),并且数组容量达到了MIN_TREEIFY_CAPACITY(默认是64)时,将链表转换为红黑树,这样大大降低了查询的时间;红黑树的节点数量少于UNTREEIFY_THRESHOLD(默认是6)将会重新转换为链表,此时增删性能会得到提升;

如图所示:

  • HashSet特点总结:
    • 1)存取无序,元素唯一,先比较hashCode,
      • 1)Hash冲突情况1:hash值直接冲突了,当hash冲突时再比较equals,equals返回true则不存;
      • 2)Hash冲突情况2:hash值没有冲突,但是%数组的长度得到槽位冲突了,使用拉链法形成链表
    • 2)底层采用Hash表数据结构,当数组长度大于等于64并且链表长度大于等于8时,链表将会转换为红黑树;
    • 3)HashSet默认数组长度为16,默认的负载因子为0.75;
    • 4)每次数组扩容时,默认扩容到原来的2倍;

2.3 TreeSet 集合

2.3.1 TreeSet 简介

与HashSet一样,TreeSet存储的元素也是无序;存储的元素虽然是无序的,但TreeSet可以根据排序规则对存储的元素进行排序,可以对集合中的元素提供排序功能;需要注意的是TreeSet存储的元素必须实现了Comparable接口,否则将抛出ClassCastException

TreeSet的特点:

  1. TreeSet每存储一个元素都会将该元素提升为Comparable类型,如果元素未实现Comparable接口,则抛出ClassCastException异常;
  2. 存储的数据是无序的,即存取顺序不一致,但TreeSet提供排序功能;
  3. 存储的元素不再是唯一,具体结果根据compareTo方法来决定;

2.3.2 Comparable接口

Comparable接口是一个比较器,通过其compareTo方法进行两个对象的比较,具体比较的内容、规则等可以由开发人员来决定,compare方法的返回值有3类;

TreeSet底层依赖红黑树,TreeSet得到compareTo方法三类不同的值的含义如下:

  • 1)正数:返回正数代表存储在树的右边
  • 2)负数:存储在树的左边
  • 3)0:不存储这个元素
public interface Comparable<T> {
    public int compareTo(T o);
}

2.3.3 TreeSet使用

TreeSet中存储的元素必须实现Comparable接口;

  • 定义一个Person对象:
package com.dfbz.demo02_TreeSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Person {
    private String name;
    private Integer age;

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }

    public Person() {
    }

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }
}

测试类:

package com.dfbz.demo02_TreeSet;

import java.util.Set;
import java.util.TreeSet;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_TreeSet存储测试 {
    public static void main(String[] args) {
        Set<Person> set=new TreeSet();

        set.add(new Person("小灰",20));
        set.add(new Person("小蓝",18));
        set.add(new Person("小刘",16));
        set.add(new Person("小静",22));

        System.out.println(set);
    }
}

运行结果如下:

TreeSet在存储元素时会将元素提升为Comparable类型,然后调用compareTo方法进行比较;如果元素没有实现Comparable接口,则提升为Comparable类型时肯定会出现类型转换异常;

实现Compareable接口:

public class Person implements Comparable {

    private String name;
    private Integer age;

    @Override
    public int compareTo(Object o) {

        Person person = (Person) o;

        return this.getAge() - person.getAge();
    }
    
    // get/set/toString...
}

2.3.4 TreeSet存储原理

TreeSet底层采用红黑树算法来存储元素的,根据compareTo方法的返回值来确定存储在左子树还是右子树;

0)小灰存入到TreeSet集合;

1)小灰传递小灰调用compareTo方法;结果为0,不存储此次的小灰(因为已经存储过一次了)

this:小灰;传递进来的对象:小灰

2)存储小蓝时,使用小蓝调用CompareTo方法,并将根元素(小灰)当做参数传递给compareTo方法,结果为-2,将小蓝存储在左子树;

this:小蓝;传递进来的对象:小灰;

3)存储小刘时,使用小刘调用CompareTo方法,并将根元素(小灰)当做参数传递给compareTo方法,结果为-4,将小刘存储在左子树;

this:小刘;传递进来的对象:小灰;

4)又跟小蓝比较,使用小刘调用compareTo方法,把小蓝当做参数传递给compareTo方法,结果为-2,存储在小蓝的左子树节点;此时树已经不再平衡,需要调整为平衡二叉树;

this:小刘;传递进来的对象:小蓝;

5)存储小静时,使用小静调用compareTo方法,将根元素(此时根元素是小蓝)当做参数传递给compareTo方法,结果为4,存储在小蓝的右子数;

this:小静;传递进来的对象:小蓝;

6)又跟小灰比较,使用小静调用compareTo方法,把小灰当做参数传递给compareTo,结果为2,存储在小灰的右子树;

this:小静;传递进来的对象:小灰;

在compareTo方法打印比较内容:

@Override
public int compareTo(Object o) {

    System.out.println("this: " + this + "-----------" + "传递进来的对象: " + o);
    Person person = (Person) o;

    return this.getAge() - person.getAge();
}

口诀:

  • 正序(从小到大):this-传递的对象
  • 倒序(从大到小):传递的对象-this

运行测试类,结果如下:

树的读取结果是从左往右读取,因此排序顺序是:(小刘,16)、(小蓝,18)、(小灰,20)、(小静,22);

2.4 LinkedHashSet 集合

2.4.1 LinkedHashSet 概述

LinkedHashSet继承与HashSet,和HashSet一样,同样是根据元素的hashCode值来决定元素的存储位置,其底层的数据结构以及去重原理等和HashSet完全一致。

与HashSet不同的是,LinkedHsahSet在HashSet之上底层新增了一个双向链表来保存节点的访问顺序,因此LinkedHashSet存储的元素是有序的;当遍历该集合时候,LinkedHashSet将会以元素的添加顺序访问集合的元素。

2.4.2 LinkedHashSet使用

示例代码:

package com.dfbz.demo03_LinkedHashSet;

import java.util.LinkedHashSet;
import java.util.Set;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_LinkedHashSet的使用 {
    public static void main(String[] args) {
        Set<String> cities = new LinkedHashSet<>();
        cities.add("云南");
        cities.add("湖南");
        cities.add("河南");
        cities.add("云南");
        cities.add("湖南");
        System.out.println(cities);         // [云南, 湖南, 河南]
    }
}

LinkedHashSet的特点:

  • 1)底层新增了一个双向链表以保证元素存取的顺序
  • 2)由于底层新增了一个双向链表,所以在遍历LinkedHashSet时只需要从头或尾开始沿着链表方向遍历即可,因此LinkedHashSet的迭代访问性能要优于HashSet
  • 3)LinkedHashSet在新增或删除元素时,不仅要调整内部的散列表,还要调整链表,因此LinkedHashSet插入/删除性能稍微弱于HashSet。

三、Queue集合

3.1 Queue集合概述

Queue集合一般用于模拟队列这种数据结构。队列的特点是先进先出,队列的头部保存放着队列中存放时间最长的元素,尾部保存存放时间最短的元素。新元素插入到队列的尾部,取出元素会返回队列头部的元素。通常,队列不允许随机访问队列中的元素;

  • Queue接口的体系结构:

在Queue本身是一个队列,队列一般都是单向操作,先进先出,只能操作头部的元素;但Queue下面的Deque接口则是双向队列,该接口下的实现类允许对队列的头部和尾部进行操作,其中LinkedList就是Deque接口下比较常见的一个实现类了;

另外Queue下面还有另一个集合派系——BlockingQueue,该接口规范的是阻塞队列,其中有ArrayBlockingQueueDelayQueueLinkedBlockingQueue等类的实现,这些类底层都是采用不同的数据结构来实现阻塞队列的功能;

Tips:关于阻塞队列我们以后的章节再详细介绍,本章重点介绍Deque派系的集合;

3.2 Queue集合的使用

由于Queue也是属于单列集合,因此Queue同样具备Collection中的相关方法,这里就不再演示;

Queue中的方法如下:

  • boolean add(E e):将元素添加到队列的尾部
  • **boolean offer(E e)**:将元素添加到队列的尾部,功能和add()方法一致
  • E remove():移除队列头部的元素并将该元素返回
  • **E poll()**:移除队列头部的元素并将该元素返回,功能和remove()方法一致
  • **E element()**:获取队列头部的元素,并不会删除该元素
  • **E peek()**:获取队列头部的元素,并不会删除该元素,功能和element()方法一致

示例代码:

package com.dfbz.demo01;

import java.util.LinkedList;
import java.util.Queue;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo01_Queue集合 {
    public static void main(String[] args) {
        // 使用实现类-->LinkedList
        Queue<String> queue = new LinkedList<>();

        // 添加元素到队列的尾部
        queue.add("滕王阁");
        queue.add("黄鹤楼");
        queue.add("岳阳楼");

        System.out.println(queue);              // [滕王阁, 黄鹤楼, 岳阳楼]

        // 移除队列的头部元素并返回
        System.out.println(queue.remove());     // 滕王阁
        System.out.println(queue);              // [黄鹤楼, 岳阳楼]

        // 获取队列的头部元素(并不会移除)
        System.out.println(queue.element());    // 黄鹤楼
        System.out.println(queue);              // [黄鹤楼, 岳阳楼]
    }

    public static void test1(String[] args){
        // 使用实现类-->LinkedList
        Queue<String> queue = new LinkedList<>();

        // 添加元素到队列的尾部
        queue.offer("滕王阁");
        queue.offer("黄鹤楼");
        queue.offer("岳阳楼");

        System.out.println(queue);          // [滕王阁, 黄鹤楼, 岳阳楼]

        // 移除队列头部元素并返回
        System.out.println(queue.poll());   // 滕王阁
        System.out.println(queue);          // [黄鹤楼, 岳阳楼]

        // 获取队列头部的元素(不会移除)
        System.out.println(queue.peek());   // 黄鹤楼
        System.out.println(queue);          // [黄鹤楼, 岳阳楼]
    }
}

3.2.1 Deque集合

Deque是Queue接口的子类,Deque规范的是一个双向的队列,因此Deque接口中定义了很多有关于队列头部和尾部操作的方法;

在开发时,Deque集合也可以作为链表、栈、队列等结构的使用。因此Deque除了定义队列相关的头部和尾部操作的方法外,还提供了很多栈和链表的操作方法;这些方法在Deque的子类中都得到了实现;其中LinkedList底层则是采用链表来实现链表、栈、队列等数据结构;

Tips:由于Deque是双向队列,因此Deque可以获取反向迭代的迭代器;

Deque集合常用方法如下:

  • 链表相关方法:
    • public void addFirst(E e):将指定元素插入此列表的开头。
    • public void addLast(E e):将指定元素添加到此列表的结尾。
    • public E getFirst():返回此列表的第一个元素。
    • public E getLast():返回此列表的最后一个元素。
    • public E removeFirst():移除并返回此列表的第一个元素。
    • public E removeLast():移除并返回此列表的最后一个元素。
  • 栈相关方法:
    • public void push(E e):将元素推入此列表所表示的堆栈(栈顶),类似于addFirst()。
    • public E pop():从此列表所表示的堆栈(栈顶)处弹出一个元素,类似于removeFirst()。
  • 队列相关方法:
    • public boolean offer(E e):将元素添加到队列尾部,类似于addLast()。
    • public boolean offerFirst(E e):将元素添加到队列头部,类似于addFirst()。
    • public boolean offerLast(E e):将元素添加到队列尾部,类似于addLast()。
    • public E pollFirst():移除并返回此列表的第一个元素。类似于removeFirst()。
    • public E pollLast():移除并返回此列表的最后一个元素。类似于removeLast()。
  • 迭代器相关方法:
    • Iterator<E> iterator():获取集合的迭代器
    • Iterator<E> descendingIterator():获取集合的反向迭代器

1)LinkedList

Deque接口下有两个常用的实现类,分别为:LinkedListArrayDeque;这两个类基本上没有对Deque接口进行功能上的扩充,只是底层的实现方式不同了,达到的功能都是一致的;LinkedList采用的是链表来实现双向队列,而ArrayDeque采用的是数组来实现双向队列;

示例代码1-常用方法:

package com.dfbz.demo01;

import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo02_Deque_LinkedList {

    public static void main(String[] args) {
        // 创建一个Deque集合
        Deque<String> deque = new LinkedList<>();

        // 将元素添加到链表头部
        deque.addFirst("江西");
        deque.addFirst("陕西");
        deque.addFirst("山西");
        deque.addFirst("广西");

        System.out.println(deque);          // [岳阳楼, 黄鹤楼, 滕王阁]

        deque.addLast("西藏");            // [广西, 山西, 陕西, 江西]

        System.out.println(deque);          // [广西, 山西, 陕西, 江西, 西藏]

        System.out.println(deque.removeFirst());            // 广西
        System.out.println(deque);          // [山西, 陕西, 江西, 西藏]
        System.out.println(deque.removeLast());            // 西藏
        System.out.println(deque);          // [山西, 陕西, 江西]
    }
}

2)ArrayDeque

ArrayDeque也是Deque接口下的一个子类,其底层实现是数组;

ArrayDeque的特点如下:

  • 1)底层采用数组来实现双向队列
  • 2)默认初始化大小为16,每次2倍扩容
  • 3)线程不安全,多线程访问可能会引发线程安全问题
  • 4)不能存储null值,支持双向迭代器遍历

ArrayDeque的继承体系:

  • ArrayDeque使用示例:
package com.dfbz.demo01;

import java.util.ArrayDeque;

/**
 * @author lscl
 * @version 1.0
 * @intro:
 */
public class Demo03_Deque_ArrayQueue {
    public static void main(String[] args) {
        ArrayDeque<String> arr = new ArrayDeque<>();

        arr.addFirst("江西");
        arr.addFirst("陕西");
        arr.addFirst("山西");
        arr.addFirst("广西");

        System.out.println(arr);        // [广西, 山西, 陕西, 江西]

        System.out.println(arr.removeFirst());      // 广西
        System.out.println(arr);                    // [山西, 陕西, 江西]
        System.out.println(arr.removeLast());       // 江西
        System.out.println(arr);                    // [山西, 陕西]

    }
}

Tips:ArrayQueue同样具备关于链表、双向队列、栈等相关的方法;

posted @ 2023-02-09 13:43  绿水长流*z  阅读(117)  评论(0)    收藏  举报