Java容器学习笔记
容器(Collection)
1.泛型Generics
容器,是用来装其他对象的一个对象。
比如数组,本身就是一种容器,可以在其中放置对象或基本数据类型。
数据的优势:是简单的现行序列,可以快速地访问数组元素,效率高。如果从效率和类型检查的角度讲,数组是最好的。
数组的劣势:不灵活。容器需要事先定义好,不能随着需求的变化而扩容。比如:我们在一个用户管理系统中,要把今天注册的所有用户取出来,那么这样的用户有多少个?我们在写程序时是无法确定的。因此,在这里就不能使用数组。
接下来是容器的类图结构:
!
其中,Set接口没有顺序,不能重复;而List接口有顺序可以重复。Map是用来存放键值对的。
泛型是JDK1.5以后增加的,它可以帮助我们建立类型安全的集合。在使用了泛型的集合中,遍历时不必进行强制类型转换。JDK提供了支持泛型的编译器,将运行时的类型检查提前到编译时执行,提高了代码可读性和安全性。
泛型的本质就是"数据类型的参数化",我们可以把"泛型"理解为数据类型的一个占位符(形式参数),即告诉编译器,在调用泛型时,必须传入实际类型。
一个简单的容器的例子:
package collection;
/**
* 测试容器
*/
public class TestGeneric {
public static void main(String[] args) {
MyCollection mc = new MyCollection();
mc.set("String", 0);
mc.set(1253, 1);
Object obj = mc.get(0);
System.out.println(obj.toString());
}
}
class MyCollection {
Object[] objects = new Object[5];
public void set(Object obj, int index) {
if(index < 5 && index >= 0) {
objects[index] = obj;
}elsrongqie{
System.out.println("Rang is wrong!");
}
}
public Object get(int index) {
if(index < 5 && index >= 0) {
return objects[index];
}else{
return null;
}
}
}
1.2 自定义泛型
我们可以在类的声明处增加泛型列表,如 <T, E, V>。
此处,字符可以是任何标志符,一般采用这3个字母。
下面是一个简单的泛型的使用例子:
package collection;
/**
* 测试泛型
*/
public class TestGeneric {
public static void main(String[] args) {
MyCollection<String> mc = new MyCollection<>();
mc.set("String", 0);
mc.set("1253", 1);
String obj = mc.get(0);
System.out.println(obj); // 默认调用了 .toString()方法
}
}
class MyCollection<E> {
Object[] objects = new Object[5];
public void set(E e, int index) {
if(index < 5 && index >= 0) {
objects[index] = e;
}else{
System.out.println("Rang is wrong!");
}
}
public E get(int index) {
if(index < 5 && index >= 0) {
return (E)objects[index];
}else{
return null;
}
}
leixing
}
/**
* 这里的E相当于提前传入了一个定义的类型。
*/
1.3 容器中使用泛型
容器相关类都定义了泛型,我们在开发和工作中,使用容器类时都要使用泛型。这样,在容器的存储数据、读取数据时都避免了大量的判断类型,非常便捷。
示例:泛型类的在集合中的使用
public class Test {
public static void main(String[] args) {
//以下代码中List、Set、Map、Iterator都是与容器相关的接口
List<String> list = new ArraryList<>();
Set<Man> mans = new HashSet<>();
Map<Integer, Man> maps = new HashMap<Integer, Man>();
Iterator<Man> iterator = mans.iterator();
}
}
2.Collection接口
Collection表示一组对象,它是集中、收集的意思。Collection接口的两个子接口是List、Set接口。
下表是 Collection接口中定义的方法:
方法 | 说明 |
---|---|
boolean add(Object element) | 增加元素到容器中 |
boolean remove(Object element) | 从容器中移除元素 |
boolean contains(Object element) | 容器中是否包含该元素 |
boolean isEmpty() | 容器是否为空 |
int size() | 容器中元素的数量 |
void clear() | 清空容器中所有元素 |
Iterator iterator() | 获得迭代器,用于遍历所有元素 |
boolean containsAll(Collection c) | 本容器是否包含c容器中的所有元素 |
boolean addAll(Collection c) | 将容器c中的所有元素增加到本容器中 |
boolean removeAll(Collection c) | 移除本容器和容器c中都包含的元素 |
boolean retainAll(Collection c) | 取本容器和容器c中都包含的元素,移除非交换元素 |
Object[] toArray() | 转化成Object数组 |
由于List、Set是Collection的子接口,意味着所有的List、Set的实现类都有上面的方法。
下面是一些例子:
package collection;
import java.util.ArrayList;
import java.util.Collection;
/**
* 测试List方法
*/
public class TestList {
public static void main(String[] args) {
Collection<String> c = new ArrayList<>();
// 增加元素
c.add("钱前");
c.add("倩倩");
System.out.println(c);
System.out.println(c.size());// 元素的大小
System.out.println(c.isEmpty());// 元素是否为空
System.out.println("===========");
// 转化出一个Object[]数组
Object[] objs = c.toArray();
System.out.println(objs);
System.out.println("==========");
// 返回是否包含该元素
System.out.println(c.contains("倩倩"));
System.out.println(c.contains("高老五"));
System.out.println("==========");
c.remove("倩倩"); // 删除指定对象
System.out.println(c);
System.out.println("==========");
c.clear(); // 删除所有的对象
System.out.println(c);
System.out.println(c.size());
}
}
/*
* [钱前, 倩倩]
* 2
* false
* ===========
* [Ljava.lang.Object;@5e2de80c
* ==========
* true
* false
* ==========
* [钱前]
* ==========
* []
* 0
*/
我们在使用Collection这个容器时,增加或者删除容器中的对象时,只是将对象的地址值存入或者从容器中删除,而实际上对象依然存在。
接下来是剩下来的一些方法,这些方法是涉及到两个容器之间的操作。
public static void restFunction() {
List<String> list01 = new ArrayList<>();
list01.add("aa");
list01.add("bb");
list01.add("cc");
List<String> list02 = new ArrayList<>();
list02.add("aa");
list02.add("dd");
list02.add("ee");
System.out.println("list01: " + list01);
System.out.println("===========");
// 传入对象的所有的元素加入原数组
list01.addAll(list02);
// 将两个容器中相同的对象删除
list01.removeAll(list02);
// 输出两个容器中相同对象
list01.retainAll(list02);
System.out.println("list01: " + list01);
System.out.println("===========");
System.out.println(list01.containsAll(list02));
}
2.1 List特点和常用方法
List是有序、可重复的容器。
有序:List中每一个元素都有索引标记。可以根据元素的索引标记(在List中的位置)访问元素,从而精确控制这些元素。
可重复:List允许加入重复的元素。更加确切地讲,List通常允许满足 e1.equals(e2) 的元素重复加入容器。
List接口常用的实现类有3个:ArrayList、LinkedList和Vector。目前我们主要使用ArrayList。其底层实现是数组。LinkedList底层实现是链表。Vector底层实现也是数组,只不过,它是线程安全的。
由于List继承了Collenction这个接口,所以List中的许多方法,用法和Collection一样。
下面是一些LIst接口中,主要是其实现类 ArrayList 的常用方法。
package collection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* 测试List方法
*/
public class TestList {
public static void main(String[] args) {
test03();
}
public static void preFunction() {
Collection<String> c = new ArrayList<>();
// 增加元素
c.add("钱前");
c.add("倩倩");
System.out.println(c);
System.out.println(c.size());// 元素的大小
System.out.println(c.isEmpty());// 元素是否为空
System.out.println("===========");
// 转化出一个Object[]数组
Object[] objs = c.toArray();
System.out.println(objs);
System.out.println("==========");
// 返回是否包含该元素
System.out.println(c.contains("倩倩"));
System.out.println(c.contains("高老五"));
System.out.println("==========");
c.remove("倩倩"); // 删除指定对象
System.out.println(c);
System.out.println("==========");
c.clear(); // 删除所有的对象
System.out.println(c);
System.out.println(c.size());
}
public static void restFunction() {
List<String> list01 = new ArrayList<>();
list01.add("aa");
list01.add("bb");
list01.add("cc");
List<String> list02 = new ArrayList<>();
list02.add("aa");
list02.add("dd");
list02.add("ee");
System.out.println("list01: " + list01);
System.out.println("===========");
// 传入对象的所有的元素加入原数组
list01.addAll(list02);
// 将两个容器中相同的对象删除
list01.removeAll(list02);
// 输出两个容器中相同对象
list01.retainAll(list02);
System.out.println("list01: " + list01);
System.out.println("===========");
System.out.println(list01.containsAll(list02));
}
public static void test03() {
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.add("D");
System.out.println(list);
System.out.println("===========");
// 在索引位置插入一个对象
list.add(2, "钱前");
System.out.println(list);
System.out.println("===========");
// 删除对应索引的对象
list.remove(2);
System.out.println(list);
System.out.println("===========");
// 将对应索引值的对象更改为传入的对象
list.set(2, "钱前");
System.out.println(list);
System.out.println("===========");
// 获得对应索引对象
System.out.println(list.get(2));
System.out.println("===========");
// 返回传入对象的第一个索引位置,没有返回-1
System.out.println(list.indexOf("钱前"));
System.out.println(list.indexOf("倩倩"));
System.out.println("===========");
// 返回传入对象的最后一个索引位置,没有返回-1
list.add("A");
list.add("B");
list.add("C");
list.add("D");
System.out.println(list);
System.out.println(list.lastIndexOf("B"));
System.out.println("===========");
}
}
// 这是结果:
/*
* [A, B, C, D]
* ===========
* [A, B, 钱前, C, D]
* ===========
* [A, B, C, D]
* ===========
* [A, B, 钱前, D]
* ===========
* 钱前
* ===========
* 2
* -1
* ===========
* [A, B, 钱前, D, A, B, C, D]
* 5
* ===========
*/
2.2 ArrayList的底层实现
ArrayList底层使用数组实现的存储。特点:查询效率高,增删效率低,线程不安全。我们一般使用它。
如果你的程序涉及到大量的增删处理,可以使用LInkedList。如果需要考虑线程安全性,那么使用Vector。
数组长度是有限的,而ArrayList是可以存放任意数量的对象,长度不限,那么他是怎么实现的呢?
它使用数组扩容来实现的。
底层默认的数组长度是10,如果存入的值存满默认长度数组还不够,就将创建一个新的数组来进行扩容,将旧数组中的所有对象存入新数组,并接着依次存入剩下的对象。一般而言一次扩容的长度是底层默认数组长度的一半。如果,扩容之后的数组大小依然不够,那么,继续扩容,直到将传入的对象全部装入为止。
其他的,如删除插入都是数组的拷贝。clear方法一次清空,就是依次将数组中的值置为null等。其实ArrayList的底层实现并不难。
下面自己写的一段ArrayList的简单底层代码实现:
package com.test.mycollection;
/**
* 自定义试验一个ArrayList,体验底层原理
*/
public class QQArrayList<E> {
// 底层是一个Object对象数组
private Object[] elementData;
// 这个是一个对象数组的长度
private int size = 0;
// 默认数组长度
private static final int DEFAULT_CAPACITY = 10;
// 默认构造函数,如果不指定长度,那么数组设置为默认长度10
public QQArrayList(){
elementData = new Object[DEFAULT_CAPACITY];
}
// 如果输入长度,进行判断,然后根据相应的情况赋值或者抛出异常
public QQArrayList(int capacity){
if(capacity < 10) {
throw new RuntimeException("容器不能为负");
}else if(capacity == 0) {
elementData = new Object[DEFAULT_CAPACITY];
}else{
elementData = new Object[capacity];
}
}
// 由于object的默认toString方法是不会一个一个输出数据的。
// 所以我们需要重写toString方法以便我们输出。
// 使用StringBuilder类
@Override
public String toString(){
StringBuilder sb = new StringBuilder();
sb.append("[");
for(int i = 0; i < size; i++) {
sb.append(elementData[i] +",");
}
sb.setCharAt(sb.length() - 1, ']');
return sb.toString();
}
// 这里就是整个ArrayList底层最难的地方,这里需要对长度不足的数据进行扩容。
public void add(E element) {
// 什么时候扩容?当size等于数组长度时,就需要扩容
if(size == elementData.length){
Object[] newArray = new Object[elementData.length + (elementData.length >> 1)]; // 右移1位,相当于 / 2
// 怎么扩容?将原来数组中的数据拷贝到新数组中,并将新数组的地址值赋值给旧数组
System.arraycopy(elementData, 0, newArray, 0, elementData.length);
elementData = newArray;
}
elementData[size++] = element;
}
public E get(int index) {
// 返回索引的值
checkRange(index);
return (E) elementData[index];
}
public void set(E element, int index) {
// 根据索引修改static值
checkRange(index);
elementData[index] = element;
}
public void checkRange(int index) {
// 索引合法判断
if(index < 0 || index > size -1) {
// 不合法
throw new RuntimeException("索引不合法: " + index );
}
}
public void remove(E element) {
// 将传入的元素和容器内的元素一一比较,获得第一个比较为true的,返回
for(int i = 0; i < size; i++) {
if(element.equals(get(i))) { // 容器中所有的比较操作都是equals,而不是 ==
// equals方法比较内容是否相同,而 == 一般用来比较地址值是否相同
//将该元素从此处移除
remove(i);
}
}
}
public void remove(int index) {
checkRange(index);
int numberMove = elementData.length-index-1;
System.arraycopy(elementData, index + 1, elementData, index, numberMove);
elementData[--size] = null; // --size,先减1,在执行语句。size--,先执行语句再减1
}
public int size() {
return size;
}
public boolean isEnpty() {
if(size == 0) {
return true;
}else if(size > 0) {
return false;
}
}
public static void main(String[] args) {
QQArrayList<Integer> Q1 = new QQArrayList<>();
for(int i =0; i < 16; i++){
Q1.add(i);
}
System.out.println(Q1.toString());
System.out.println(Q1.get(1));
Q1.remove(13);
System.out.println(Q1.toString());
Integer obj = 5;
Q1.remove(obj);
System.out.println(Q1.toString());
System.out.println(Q1.size());
System.out.println(Q1.isEnpty());
QQArrayList<String> Q2 = new QQArrayList<>();
System.out.println(Q2.isEnpty());
}
}
//输出结果
/**
* [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]
* 1
* [0,1,2,3,4,5,6,7,8,9,10,11,12,14,15]
* [0,1,2,3,4,6,7,8,9,10,11,12,14,15]
* 14
* false
* true
*/
2.3 LinkedList特点和底层实现
LinkedList底层用双向链表实现的存储。特点:查询效率低,增删效率高,线程不安全。
双线链表也叫双链表,是链表的一种,它的每个数据节点中都有两个指针,分别指向前一个节点和后一个节点。多疑,双向链表中的人一个节点开始,都可以很方便的找到所有的节点。
[外链图片转存失败(img-y2FLVTuZ-1565413944843)(E:\Document\java\JAVE EE\尚学堂笔记\深度截图_选择区域_20190712160653.png)]
每一个节点包含三个部分:上一个节点,下一个节点,存储的数据。
通过书写简单的LinkedList的底层代码来深刻的了解LinkedList的运行原理:
package com.test.mylinklist;
/**
* 通过手写来理解LinkedList
*/
// 首先链表需要自己去定义一个节点
// 这个节点有三个部分,存放前一个节点,存放后一个节点,和存放自己的数据
class Node {
Node previous; // 上一个节点
Node next; // 下一个节点
Object element; // 元素数据
public Node(Node previous, Node next, Object element) {
this.previous = previous;
this.next = next;
this.element = element;
}
public Node(Object element) {
this.element = element;
}
}
public class MyLinkedList<E> {
private Node first; // 第一个节点,便于向后进行查找
private Node last; // 最后一个节点
private int size; // 定义数组的长度
// []
// ["a", "b", "c"]
public void add(E obj) {
Node node = new Node(obj);
// 如果一开始,头节点是空,说明尾节点也是空,即该链表没有对象。就将对象放入,同事头尾节点都是该对象
if(first == null) {
first = node;
last = node;
}else { // 如果头结点不为空,说明已经有对象在该链表中,那么将新的节点对象的前一个对象指针指向原容器中最后一个对象,并且将新节点重新定义为尾节点。
node.previous = last;
node.next = null; // 可以不用谢,因为原来即为空
last.next = node;
last = node;
}
size ++; // 每次调用完使数组的长度加1
}
// 根据索引值,插入给定对象
public void add(int index, E obj) {
checkRange(index); // 首先判断索引值正确与否
Node newNode = new Node(obj); // 创建一个新的节点,用来插入
Node temp = getNode(index); // 创建一个新的临时节点,用来放入容器中索引值对应的原对象
// 这里需要进行判断,根据索引值找到的原元素是否为头或者尾节点
if(temp != null) {
if(temp.previous == null) {
// 如果temp节点为头结点,将temp节点的下一节点置为新节点;新节点的下一个节点置为temp节点,这样就连来了。
temp.previous = newNode;
newNode.next = temp;
first = newNode;
}
else if(temp.next == null) {
// 如果temp节点的下一节点为空,说明temp节点已经是尾节点。这时,将temp节点的下一节点置为新节点;新节点的下一节点置为temp节点,就在temp节点后添加了新的节点。
temp.next = newNode;
newNode.previous = temp;
}
else {
// 如果temp节点上一节点和下一节点均不为空,说明temp节点在中间。这就需要断开temp节点与前一个节点的联系,并将新的节点与temp的前一节点和temp节点连接起来。其具体操作是上两个方法的综合。
Node up = temp.previous;
up.next = newNode;
newNode.previous = up;
newNode.next = temp;
temp.previous = newNode;
}
}
}
/**
* 重写toString方法。因为Object的toString方法只能显示数据的类型和该容器的第一个元素所在的内存空间。
* 所以重写toString方法。这里的toString方法,不断遍历数组(使用 temp =temp.next ),并将其不断添加到
* 创建的StringBuilder对象,最后返回StringBuilder对象。
*/
@Override
public String toString() {
// [a,b,c] first = a, last = c
// a,b,c
StringBuilder sb = new StringBuilder();
sb.append("[");
Node temp = first;
while(temp!=null) {
sb.append(temp.element);
sb.append(",");
// 那么怎么进行遍历?只需要将temp的下一个节点赋值给temp。
temp = temp.next;
}
sb.setCharAt(sb.length()-1, ']');
return sb.toString();
}
private void checkRange(int index) {
if(index < 0 || index > size -1) {
throw new RuntimeException("索引数字不合法 : " + index);
}
}
public E get(int index) {
checkRange(index);
Node temp = getNode(index);
return temp != null ? (E) temp.element : null;
}
// 这个方法会多次使用,所以单独封装
private Node getNode(int index) {
checkRange(index);
Node temp =null;
/**
* 这里选择优化了一int index下算法,如果数组很长,从头开始,效率很低。这里选择使用了
* 一次二分法进行算法的优化。如果大于中间的数,从屁股开始遍历。
* 如果小于中间的数,从头开始遍历。
* 但是我觉得如果数据比较大的时候,加入二分法是一个不错的考虑
*/
if(index < (size >> 1)){ // size >>1 相当于除以2
temp = first;
for(int i=0; i < index; i++) {
temp = temp.next;
}
}else {
temp = last;
for (int i = size -1; i > index ; i--) {
temp = temp.previous;
}
}
return temp;
}
// 删除指定索引的值,如果是中间元素,只需要将断开其上一个节点和下一节点的联系,并将上下节点重新连接即可。如果是头结点,将下一个节点赋值给头结点。如果是最后一个元素,将前一个元素赋值给尾节点。别忘了,每删除一个元素,就需要将size-1;
public void remove(int index) {
checkRange(index);
Node temp = getNode(index);
if(temp != null) {
Node up = temp.previous;
Node down = temp.next;
// 删除中间元素
if(up != null) {
up.next = down;
}
if(down != null){
down.previous = up;
}
// 删除第一个元素
if(index == 0) {
first = down;
}
// 删除最后一个个元素
if(index == size -1) {
last = up;
}
size--;
}
}
// 删除出现的第一个obj元素
public void remove(E obj) {
Node temp = first;
int count = 0;
while(temp != null) {
// 通过遍历查找相同的对象,记下索引值,并调用remove(int index)方法进行删除操作。
if(temp.element.equals(obj)) {
remove(count);
break;
}
count ++;
temp = temp.next;
}
}
public static void main(String[] args) {
MyLinkedList<String> list = new MyLinkedList<>();
list.add("c");
list.add("b");
list.add("d");
list.add("c");
list.add("c");
list.add("e");
list.add("c");
list.add("c");
list.add("f");
System.out.println(list.get(5));
}
}
2.4 Vector向量
Vector底层使用数据实现的List,相关方法都加了同步检测,因此"线程安全"。比如,IndexOf方法就添加了synchronize同步标记。
public synchronized int indexOf(Object o, int index) {
// 代码
}
建议:
如何选用ArrayList、Link、Vector?
- 需要线程安全,用Vector。 —— 安全往往意味着效率低。
- 不存在线程安全问题时,并且查找较多用ArrayList(一般使用它)。为什么要一般使用ArrayList呢?因为一般时候,一个ArrayList容器是一个程序片断所私有,一般不会涉及多线程操作,相比起Vector效率又高,所以一般使用ArrayList。
- 不存在线程安全问题时,增加或删除元素较多用LinkedList。
3.Map接口
现实中,我们经常需要成对存储某些信息。比如,我们使用的微信,一个手机号只能对应一个微信账户。这就是一种成对存储的关系
Map就是用来存储"键(key)-值(value)" 对象的。Map类中存储的"键值对"通过键来标识,所以"键值对"不能重复。
Map接口的实现类有HashMap、TreeMap、HashTable、Properties等。
Map接口中常用的方法:
方法 | 说明 |
---|---|
Object put(Object key, Object value) | 存放键值对 |
Object get(Object key) | 通过键对象查找得到值对象 |
Object remove(Object key) | 删除键对象对应的键值对 |
boolean containsKey(Object key) | Map容器中是否包含键对象对应的键值对 |
boolean containsVaule(Object value) | Map容器中是否包含值对象对应的键值对 |
int size() | 包含键值对的数量 |
boolean isEmpty() | Map是否为空 |
void putAll(Map t) | 将t的所有键值对存放到本map对象中 |
void clear() | 清空本map对象所有键值对 |
下面是一段Map用法的代码:
package collection;
import java.util.HashMap;
import java.util.Map;
/**
* 测试HashMap
*/
public class TestMap {
public static void main(String[] args) {
Map<Integer,String> m1 = new HashMap<Integer, String>();
m1.put(1, "钱前");
m1.put(2, "张石");
m1.put(3, "李师傅");
System.out.println(m1);
System.out.println(m1.get(1));
System.out.println(m1.size());
System.out.println(m1.containsKey(2));
System.out.println(m1.containsValue("王司徒"));
Map<Integer,String > m2 = new HashMap<>();
m2.put(4,"Four");
m2.put(5, "Five");
m2.putAll(m1);
System.out.println(m2);
// map中键不能重复,如果重复(根据equals方法),新的覆盖旧的
m1.put(3, "渔船风");
System.out.println(m1);
}
}
第二个Map例子:
package collection.hashmap;
import java.util.HashMap;
import java.util.Map;
public class TestMap2 {
public static void main(String[] args) {
Employee e1 = new Employee(1001, "高淇", 50000);
Employee e2 = new Employee(1002, "高淇2", 60000);
Employee e3 = new Employee(1003, "高淇3", 70000);
Map<Integer, Employee> map = new HashMap<>();
map.put(1001, e1);
map.put(1002, e2);
map.put(1003, e3);
Employee emp = map.get(1001);
System.out.println(emp.toString());
}
}
class Employee {
private int id;
private String name;
private double salary;
public Employee(int id, String name, double salary) {
this.id = id;
this.name = name;
this.salary = salary;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getSalary() {
return salary;
}
public void setSalary(double salary) {
this.salary = salary;
}
@Override
public String toString() {
return "id: " + id +" " + "name: " + name + " " + "salary: " + salary;
}
}
3.1 HashMap底层实现
HashMap底层实现用了哈希表,哈希表是一种非常重要的数据结构。对于以后理解很多技术都非常有帮助(比如:redis数据库的核心技术和HashMap一样),因此,非常有必要让大家理解。
数据结构中有数组和链表来实现堆数据的存储,他们各有优点。
(1) 数据: 占用空间连续。寻址容易,查询速度快。但是增加和删除效率非常低。
(2) 链表: 占用空间不连续。寻址困难,查询速度慢。但是增加和删除效率非常高。
那么,我们能不能结合数据和链表的优点(即查询速度快,增删效率也高)呢?答案就是"哈希表"。哈希表的本质就是"链表加数组"。
重点:
对于本章中频繁出现的"底层实现"讲解,要尽量搞通,以便应对一些大型企业的笔试面试。
3.1.1 HashMap基本结构讲解 ( 这里以JDK1.6中的HashMap为例 )
哈希表的节本结构就是"数据+链表"。这是其中一段代码:
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
// 核心数组默认初始化的大小为16(数组大小必须为2的整数幂)
static final int DEFAULT_INITIAL_CAPACITY = 16;
// 负载因子(核心数据被占用超过0.75,则开始启动扩容)
static final float DEFAULT_LOAD_FACTOR = 0.75F;
// 核心数组(根据需要可以扩容)。数组的长度必须始终为2的整数幂。
transient Entry[] table; // Entry在父类Map.class中
// 这里是JDK1.6的代码,JDK1.8.212有所不同。在jDK1.8.212中这一段代码为:
// transient HashMap.Node<K, V>[] table;
}
其中的Entry[] table 就是HashMap的核心数组结构,我们也称之为"位桶数组"。我们继续看Entry是什么。源码如下:
// JDK1.6中的源码:
static class Entry<K, V> implements Map.Entry<K, V> {
final K key;
V value;
Entry<K, V> next;
final int hash;
/**
* Creates new entry
*/
Entry(int h, K k, V v, Entry<K, V> n) {
value = v;
next = n;
key = k;
hash = h;
}
// 以下代码省略
}
// 一下代码省略,同时这里是JDK1.8.212的源码。
static class Node<K, V> implements Entry<K, V> {
final int hash;
final K key;
V value;
HashMap.Node<K, V> next;
Node(int var1, K var2, V var3, HashMap.Node<K, V> var4) {
this.hash = var1;
this.key = var2;
this.value = var3;
this.next = var4;
}
}
一个Entry对象存储了:
- key:键对象 value:值对象
- next:下一个节点
- hash:键对象的hash值
显然每一个Entry对象就是一个单向链表结构,我们使用图像表示一个Entry对象的典型示意:
然后,我们画出Entry[](JDK1.8.212中为Node<K, V>[])的结构,也就是HashMap的结构:
3.2.1 存储数据过程put(key, value>)
明白了HashMap的基本结构后,我们继续深入学习HashMap如何存储数据。此处的核心是如何产生hash值,该值用来对应数组的存储位置。
我们的目的是将"key-value两个对象"成对存放到HashMap的Entry[]数组中。参见以下步骤 ( 这里以JDK1.6的源码为例,在JDK1.8中,Entry对象更改为 Node ) :
(1) 获得key对象的hashcode
首先调用key对象的hashcode()方法,获得hashcode。
(2) 根据hashcode计算出hash值 ( 要求在[ 0, 数据长度-1 ] 区间 )
hashcode是一个整数,我们需要将它转化成 [ 0, 数组长度-1 ] 的范围。我们要求转化后的hash值尽量均匀地分布在 [ 0, 数组长度-1 ] 这个区间,减少 “hash冲突”:
i. 一种极端简单和底下的算法是:
hash值 = hashcode/hashcode;
也就是说,hash值总是1。意味着,键值对对象都会存储到数据索引1位置,这样就形成了一个非常长的链表。相当于每存储一个对象都会发生"hash冲突",HashMap也退化成了一个"链表"。
ii. 一种简单和常用的算法是 ( 相除取余算法 ):
hash值 = hashcode % 数据长度
这种算法可以让hash值均匀地分布在 [ 0, 数组长度-1 ] 这个区间。早期的 HashTable 就是采用这种算法。但是,这种算法由于使用了 " 除法 ",效率底下。JDK后来改进了算法。首先约定数据长度必须为2的整数幂,这样采用位运算即可实现取余的效果:hash值 = hashcode & ( 数组长度-1 )。
iii. 如下为我们自己测试简单的hash算法:
package collection.hashmap;
/**
* 测试一下hash算法:相除取余数算法
*/
public class TestHash {
public static void main(String[] args) {
int h = 254854603;
int length = 16; // length 为 2 的整数次幂,则 h & (length - 1) 相当于堆length取模
myHash(h, length);
}
public static int myHash(int h, int length) {
System.out.println(h & (length - 1));
// length为2的整数次幂情况下,和取余的值一样
System.out.println(h % length); // 取余数
return h & (length - 1);
}
}
/**
* 结果为11
*/
运行如上程序,我们就能发现直接取余 ( h % length ) 和 ( h & ( length -1 ) ) 结果是一致的。事实上,为了获得更好的散列效果,JDK 对 hashcode 进行了两次散列处理( 核心目标就是为了分布更散更均匀 ),源码如下 ( 此源码为JDK1.6中的源码,而在JDK1.8.212 中,源码又进行了优化 ):
static int hash(int h) {
// This function ensures that hashCode that differ only by
// onstant multiples at each bit position hava a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
static int indexFor(int h, int length) {
return h & (length -1);
}
而在JDK1.8.212中,其算法如下:
static final int hash(Object var0) {
int var1;
return var0 == null ? 0 : (var1 = var0.hashCode()) ^ var1 >>> 16;
}
上述代码会直接获取传入对象的hashcode。而hashCode() 这个函数定义在Object中,使用Integer将地址值转化成Integer对象。
(3) 生成Entry对象
如上所述,一个Entry对象包含四个部分:key对象、value对象、hash值、指向下一个Entry对象的引用。我们现在算出了hash值。下一个Entry对象的引用为null。
(4) 将Entry对象放到table数组中。
如果Entry对象对应的数组索引位置还没有放Entry对象,则直接将Entry对象存储进数组;如果对应索引位置已经有Entry对象,则将已有Entry对象的next指针指向本Entry对象,形成链表。
总结如上过程:
当添加一个元素 (key-value) 时,首先计算key的hash值,一次确定插入数组中的位置,但是可能存在同一个hash值的元素已经被放在数组同一位置了,这时就添加到同一hash值的元素的后面,它们在数组的同一位置,这样就形成了链表,同一个链表上的hash值是相同的,所以说数组存放的是链表。
JDK8中,当链表长度大于8时,链表就转化为了红黑树,这样又大大提高了查找效率。
3.2.2 取数据过程get(key)
我们需要通过key对象获得 “键值对” 对象,进而返回value对象。明白了存储数据过程,取数据就比较简单了,参见以下步骤:
(1) 获得key的hashcode,通过hash()散列算法得到hash值,进而定位到数据的位置。
(2) 在链表上挨个比较key对象。调用equals()方法,将key对象和链表上所有节点的key对象进行比较,直到碰到返回true的节点对象为止。
(3) 返回equals() 为true的节点对象的value对象。
明白了存取数据的过程,我们再来看一 || size > elementData.length下 hashcode() 和 equals() 方法的关系:
Java中规定,两个内容相同 (equals()为true) 的对象必须具有相等的hashcode。因为如果equals() 为true而两个对象的hashcode不同,那么整个存储过程就发生了悖论。
什么意思呢?意思就是,只有hashcode相同,我们在第一步取hashcode,第二步根据hashcode寻找数组中对应的索引位置时,才能找到。而如果hashcode不同,就意味着两个内容相同的对象却不存放在统一索引位置,那么存取过程就会发生混乱。
3.2.3 扩容问题
HashMap的位桶数组,初始大小为16。实际使用时,显然大小是可变的。如果位桶数组中的元素达到 (0.75 * 数组长度),就重新调整数组大小,使其变为原来的两倍大小。
扩容很耗时。扩容的本质是定义新的更大的数组,并将就数组内容挨个拷贝到新数组中。
3.2.4 JDK8 将链表在大于 8 情况下变为黑红二叉树
JDK8中,HashMap在存储一个元素时,当对应链表长度大于8时,链表就转换为黑红树,这样又大大提高了查找的效率。
3.2.5 通过编写自定义的HashMap来理解HashMap底层实现
package collection.hashmap.myhashmap;
/**
* 通过自定义一个HashMap来理解HashMap的底层原理
* 第一步:实现put方法增加键值对,并解决了键重复的时候覆盖相应节点
* 第二步:重写toString() 方法,方便查看map中的键值对
* 第三步:增加get()方法,根据键对象,获取键值对
* 第四步:增加泛型
*/
/**
* 用于MyHashMap
*/
class Node<K, V> {
int hash;
K key;
V value;
Node next;
}
public class MyhashMap<K, V> {
// 位桶数组。 bucket array
Node<K, V>[] table;
// 存放键值对的个数
int size;
// 负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75F;
public MyhashMap() {
// 默认长度,2的整数次幂
table = new Node[16];
}
@Override
public String toString() {
// ( 10:aa, 20:bb )
StringBuilder sb = new StringBuilder("{ ");
for (int i = 0; i < table.length; i++) {
Node temp = table[i];
while (temp != null) {
sb.append(temp.key + ": " + temp.value + "," + " ");
temp = temp.next;
}
}
sb.deleteCharAt(sb.length() - 2);
sb.append("}");
return sb.toString();
}
public void put(K key, V value) {
// 定义了新的节点对象
Node<K, V> newNode = new Node<>();
newNode.hash = myHash(key.hashCode(), table.length);
newNode.key = key;
newNode.value = value;
newNode.next = null;
Node<K, V> temp = table[newNode.hash];
Node<K, V> iterLast = null; //正在遍历的最后一个元素
boolean keyReapt = false;
if (temp == null) {
// 此处数组元素为空,则直接将新节点放进去
table[newNode.hash] = newNode;
size++;
} else {
// 此处数组不为空,则遍历对应链表。
while (temp != null) {
// 判断key,如果重复,则覆盖
if (temp.key.equals(key)) {
int arrayLength = 0;
keyReapt = true;
// 只是覆盖value即可,其他值保持不变。其他值(hash, next, previous)
temp.value = newNode.value;
arrayLength++;
break;
} else {
int arrayLength = 0;
// key不重复,则遍历下一个
iterLast = temp;
temp = temp.next;
arrayLength++;
// 什么时候扩容
if (arrayLength == table.length * DEFAULT_LOAD_FACTOR) {
Node[] newArray = new Node[table.length + (table.length >> 1)];
// 怎么扩容
System.arraycopy(table, 0, newArray, 0, table.length);
table = newArray;
}
}
}
if (keyReapt == false) { // 如果没有发生key重复的情况,则添加到最后。
iterLast.next = newNode;
size++;
}
}
}
public V get(K key) {
int hash = myHash(key.hashCode(), table.length);
V value = null;
if (table[hash] != null) {
Node<K, V> temp = table[hash];
while (temp != null) {
if (temp.key.equals(key)) { // 如果相等,说明找到了相应的键值对
value = temp.value;
break;
} else {
temp = temp.next;
}
}
}
return value;
}
public int myHash(int v, int length) {
// 直接按位运算,效率高
// System.out.println("hash in myHash:" + (v&(length -1)));
// 取模运算,效率较低
// System.out.println("hash in myHash:" + (v%(length -1)));
return v & (length - 1);
}
public static void main(String[] args) {
MyhashMap<Integer, String> map1 = new MyhashMap<>();
map1.put(10, "aa");
map1.put(20, "bb");
map1.put(30, "cc");
map1.put(40, "dd");
map1.put(50, "ee");
System.out.println(map1.toString());
System.out.println(map1.get(40));
}
}
3.2 TreeMap的使用和底层实现
TreeMap是红黑二叉树的典型实现。我们打开TreeMap的源码,返现里面有一行核心代码:
private transient Entry<K, V> root = null;
root用来存储整个树的根节点。我们继续跟踪Entry ( 是TreeMap的内部类 ) 的代码:
static final class Entry<K, V> implements Map.Entry<k, V>{
K key;
V value;
Entry<K, V> left = null;
Entry<K, V> right = null;
Entry<K, V> parent;
boolean color = BLACK;
}
我们可以看到里面存储了本身数据、做节点、右节点、父节点、以及节点颜色。TreeMap的put()/remove() 方法大量使用了红黑树的理论。
TreeMap和HashMap实现了同样的接口,因此,用法对于调用者来讲没有什么不同。HashMap效率高于TreeMap。在需要排序的Map时才使用TreeMap。
使用 Comparable() 这个类来实现自定义排序
TreeMap在使用时,会自动根据按照key递增的方式排序。具体如何排序,是按照comparable()这个类来实现的。
具体的用法
class Employee implements Comparable<Employee> {
int id;
String name;
double salary;
public Employee(int id, String name, double salary) {
this.id = id;
this.name = name;
this.salary = salary;
}
@Override
public int compareTo(Employee employee) {
if (this.salary > employee.salary) {
return 1;
} else if (this.salary < employee.salary) {
return -1;
} else {
if (this.id > employee.id) {
return 1;
} else if (this.id < employee.id) {
return -1;
} else {
return 0;
}
}
}
}
3.3 HashMap和HashTable
HashMap采用哈希算法实现,是Map接口最常用的实现类。由于底层才用了哈希表存储数据,我们要求键不能重复,如果发生重复,新的键值对会替换就得键值对。HashMap在查找、删除、修改方面都有着非常高的效率。
HashTable类和HashMap用法几乎一样,底层实现几乎一样,只不过HashTable的方法添加了synchronized关键字确保线程同步检查,效率较低。
HashMap与HashTable的区别:
- HashMap:线程不安全,效率高。允许key或value为null。
- HashTable:线程安全,效率低。不允许key或value为null。
4.Set接口
Set接口继承字Collection,Set接口中没有新增方法,方法和Collection保持完全一致。我们在前面通过List学习的方法,在Set中仍然适用。因此,学习Set的适用没有难度。
Set容器特点:无序、不可重复。无序指Set中的元素没有索引,我们只能遍历查找;不可重复指不允许加入重复的元素。更确切地讲,新元素如果和Set中,某个元素通过equals() 方法对比为true,则不能加入;甚至,Set中也只能放入一个null元素,不能多个。
Set常用的实现类有:HashSet、TreeSet等,我们一般使用HashSet。
4.1 HashSet基本使用
重点体会"Set是无序、不可重复"的核心要点。
HashSet的使用
import java.util.HashSet;
import java.util.Set;
public class TestHashSet {
public static void main(String[] args) {
Set<String> set = new HashSet<>();
// 特性:不可重复
set.add("aa");
set.add("bb");
set.add("aa");
System.out.println(set);
set.remove("bb");
System.out.println(set);
Set<String> set2 = new HashSet<>();
set2.add("高淇");
set.addAll(set2);
System.out.println(set);
}
}
4.2 HashSet的底层实现
HashSet是采用哈希算法实现,底层实际是使用HashMap实现的 ( HashSet本质就是一个简化版的HashMap ),因此,查询效率和增删效率都比较高。这里是HashSet的源码:
public class HashSet<E> implements Set<E>, Cloneable, java.io.Serializable {
private transient HashMap<E, Object> map; // 如果想往HashSet对象存放元素,都是存放在map,这个HashMap对象中。Set元素中的所有元素都在这个map对象中作为key键的。而value是下面定义的PRESENT对象。
private static final Object PRESENT = new Object();
public HashSet() {
map = new HashMap<E, Object>();
}
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
// 一下代码省略
}
我们发现里面有一个map属性,这就是HashSet的核心秘密。我们再看 add() 方法,发现增加一个元素说白了就是在map中增加一个键值对,键对象就是这个元素,值对象是名为PRESENT的Object对象。
所以,我们在使用HashSet,使用add() 方法向其中添加元素时。由于我们添加的元素是作为HashSet底层中的HashMap的key,而key不可以重复。这就是Set不能重复的原因。
4.3 TreeSet的使用和底层实现
TreeSet底层实现实际使用TreeSet实现的,内部维持了一个简化版的TreeMap,通过key来存储Set的元素。TreeSet内部需要对存储的元素进行排序,因此,我们对应的类需要实现Comparable接口。这样,才能根据compareTo()方法比较对象之间的大小,才能进行内部排序。
TreeSet的使用方法和Set方法一致。
5.迭代器介绍
关于迭代器的使用:
import java.util.*;
public class TestIterator {
public static void main(String[] args) {
testIteratorList();
testIteratorSet();
testIteratorMap();
testIteratorMap2();
}
// 使用迭代器遍历List
public static void testIteratorList() {
List<String> list = new ArrayList<>();
list.add("aa");
list.add("bb");
list.add("cc");
for (Iterator<String> iter = list.iterator(); iter.hasNext(); ) {
String temp = iter.next();
System.out.println(temp);
}
}
// 遍历Set
public static void testIteratorSet() {
Set<String> set = new HashSet<>();
set.add("aa");
set.add("bb");
set.add("cc");
for (Iterator<String> iter = set.iterator(); iter.hasNext(); ) {
String temp = iter.next();
System.out.println(temp);
}
}
// 遍历map
public static void testIteratorMap() {
Map<Integer, String> map = new HashMap<>();
map.put(100, "a0");
map.put(110, "a1");
map.put(150, "a2");
Set<Map.Entry<Integer, String>> ss = map.entrySet();
for(Iterator<Map.Entry<Integer, String>> iter = ss.iterator(); iter.hasNext(); ) {
Map.Entry<Integer, String> temp = iter.next();
System.out.println(temp.getKey() + "--" + temp.getValue());
}
}
public static void testIteratorMap2() {
Map<Integer, String> map = new HashMap<>();
map.put(100, "a0");
map.put(110, "a1");
map.put(150, "a2");
Set<Integer> keySet = map.keySet();
for (Iterator<Integer> iterator = keySet.iterator(); iterator.hasNext(); ) {
Integer key = iterator.next();
System.out.println(key + "--" + map.get(key));
}
}
}s
5.1 遍历集合的方法总结
遍历List方法一:普通for循环
遍历List方法二:增强for循环 ( 使用泛型! )
遍历List方法三:使用迭代器(1)
for(Integer iter = list.iterator();iter.hasNext();){
String temp = (String) iter.next();
System.out.println(temp);
}
遍历List方法四:使用迭代器(2)
Iterator iter = list.iterator();
while(iter.hasNext()) {
Object obj = iter.next();
iter.remove(); // 如果要遍历时,删除集合中的元素,建议使用这种方式。
System.out.println(obj);
}
遍历Set方法一:增强for 循环
for(string temp:set) {
System.out.println(temp);
}
遍历Set方法二:使用Iterator迭代器 (1)
for(Iterator iter = set.iterator();iter.hasNext();) {
String temp = (String) iter.next();
System.out.println(temp);
}
遍历Map方法一:根据key获取value
Map<Iterator, Map> maps = new HashMap<Integer, Man>();
Set<Integer> keySet = maps.keySet();
for(Integer id : keySet) {
System.out.println(maps.get(id).name);
}
遍历Map方法二:使用entrySet
Set<Map.Entry<Integer, Man>> ss = maps.entrySet();
for(Iterator iterator = ss.iterator(); iterator.next();) {
Map.Entry e = (Entry) iterator.next();
System.out.println(e.getKey() + " " + e.getValue());
}
6.Collections工具类
类java.util.Collections提供了堆Set、List、Map进行排序、填充、查找元素的辅助方法。
- void sort(List):对List容器内的元素排序,排序的规则是按照升序进行排序。
- void shuffle(List) :对List容器内的元素进行随机排列。
- void reverse(List):对List容器内的元素进行逆序排列。
- void fill(List, Object) :用一个特定的对象重写整个List容器。
- int binarySearch(List, Object):对于顺序的List容器,采用折半查找的方法查找特定对象。
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* 辅助类的使用
*/
public class TestCollections {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add("钱" + i);
}
System.out.println(list);
Collections.shuffle(list); // 随机排列
System.out.println(list);
Collections.reverse(list); // 逆序排列
System.out.println(list);
Collections.sort(list); // 按照递增的方式排序 自定义类使用Comparable接口
System.out.println(list);
System.out.println(Collections.binarySearch(list, "钱7")); // 二分法查找
}
}
7.使用容器来存储表格
ID | 姓名 | 薪水 | 入职日期 |
---|---|---|---|
1001 | 张三 | 20000 | 2018.5.5 |
1002 | 李四 | 200000 | 2005.4.4 |
1003 | 王五 | 3000 | 2019.7.2 |
想法1:
每一行数据使用Map
整个表格使用list
这个其实就是ORM思想(对象关系映射)
import java.util.*;
/**
* 测试表格的存储
* ORM思想的简单实现:map表示一行数据,多行数据就是多个map,再将map放入List中
*/
public class TestStoreData {
public static void main(String[] args) {
Map<String, String> row1 = new HashMap<>();
row1.put("id", "1001");
row1.put("name", "张三");
row1.put("薪水", "20000");
row1.put("入职日期", "2018.5.5");
Map<String, String> row2 = new HashMap<>();
row2.put("id", "1002");
row2.put("name", "李四");
row2.put("薪水", "200000");
row2.put("入职日期", "2005.4.4");
Map<String, String> row3 = new HashMap<>();
row3.put("id", "1002");
row3.put("name", "王五");
row3.put("薪水", "3000");
rowjavajava3.put("入职日期", "2019.7.2");
List<Map<String, String>> table1 = new ArrayList<>();
table1.add(row1);
table1.add(row2);
table1.add(row3);
for(Map<String, String> row: table1) {
Set<String> keySet = row.keySet();
for(String key: keySet) {
System.out.print(key + ": " + row.get(key) + "\t");
}
System.out.println();
}
}
}
想法二:
每一行使用一个:javabean对象。
整个表格使用一个Map/List
import java.util.List;
import java.util.ArrayList;
/**
* 测试表格数据的存储
* 体会ORM思想
* 每一行数据使用Javabean对乡村粗,多行使用放入map或者list中
*/
public class UseJavaBean {
public static void main(String[] args) {
User user1 = new User(1001, "张三", 20000, "2018.5.5");
User user2 = new User(1002, "李四", 200000, "2005.4.4");
User user3 = new User(1003, "王五", 3000, "2019.7.2");
List<User> list = new ArrayList<>();
list.add(user1);
list.add(user2);
list.add(user3);
for (User u : list
) {
System.out.println(u);
}
}
}
class User {
private int id;
private String name;
private double salary;
private String hireData;
// 一个完整的javabean,要有Set和Get方法,以及无参构造方法
public User() {
}
public User(int id, String name, double salary, String hireData) {
this.id = id;
this.name = name;
this.salary = salary;
this.hireData = hireData;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getSalary() {
return salary;
}
public void setSalary(double salary) {
this.salary = salary;
}
public String getHireData() {
return hireData;
}
public void setHireData(String hireData) {
this.hireData = hireData;
}
@Override
public String toString() {
return "id: " + id + " " + "name: " + name + " " + "salary: "
+ salary + " " + "hiredate: " + hireData;
}
}