尚学堂视频笔记三:容器
Java容器学习笔记
- 容器(Collection)
- 1.泛型Generics
- 1.2 自定义泛型
- 1.3 容器中使用泛型
- 2.Collection接口
- 2.1 List特点和常用方法
- 2.2 ArrayList的底层实现
- 2.3 LinkedList特点和底层实现
- 2.4 Vector向量
- 3.Map接口
- 3.1 HashMap底层实现
- 3.1.1 HashMap基本结构讲解 ( 这里以JDK1.6中的HashMap为例 )
- 3.2.1 存储数据过程put(key, value>)
- 3.2.2 取数据过程get(key)
- 3.2.3 扩容问题
- 3.2.4 JDK8 将链表在大于 8 情况下变为黑红二叉树
- 3.2.5 通过编写自定义的HashMap来理解HashMap底层实现
- 3.2 TreeMap的使用和底层实现
- 3.3 HashMap和HashTable
- 4.Set接口
- 4.1 HashSet基本使用
- 4.2 HashSet的底层实现
- 4.3 TreeSet的使用和底层实现
- 5.迭代器介绍
- 5.1 遍历集合的方法总结
- 6.Collections工具类
- 7.使用容器来存储表格
容器(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("===========");// 返回传入对象的第一个索引位置,没有返回-1System.out.println(list.indexOf("钱前"));System.out.println(list.indexOf("倩倩"));System.out.println("===========");// 返回传入对象的最后一个索引位置,没有返回-1list.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;// 默认构造函数,如果不指定长度,那么数组设置为默认长度10public 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类@Overridepublic 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对象。*/@Overridepublic String toString() {// [a,b,c] first = a, last = c// a,b,cStringBuilder 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 相当于除以2temp = 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;}@Overridepublic 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 arrayNode<K, V>[] table;// 存放键值对的个数int size;// 负载因子static final float DEFAULT_LOAD_FACTOR = 0.75F;public MyhashMap() {// 默认长度,2的整数次幂table = new Node[16];}@Overridepublic 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;}@Overridepublic 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();}// 使用迭代器遍历Listpublic 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);}}// 遍历Setpublic 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);}}// 遍历mappublic 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;}@Overridepublic String toString() {return "id: " + id + " " + "name: " + name + " " + "salary: "+ salary + " " + "hiredate: " + hireData;}
}
尚学堂视频笔记三:容器相关推荐
- 正则表达式基础知识---文本操作(尚学堂视频笔记)
正则表达式(Regular Expression)基础知识 一.开发中使用流程: -分析要匹配的数据 写出测试用的典型数据 -在工具软件中进行匹配测试 -在程序中调用通过测试的正则表达式 (有些高级语 ...
- 尚学堂学习笔记。。。
1.02_尚学堂马士兵_Struts2_Struts2_HelloWorld_2.avi 指定Tomcat的目录,指定JDK搭建开发环境(拷贝jar包,复制struts.xml文件 此文件不要放在WE ...
- 看尚学堂视频Java学习笔记
//这是我刚接触java时做的笔记, 后面也没去整理, 可能其中有些理解有误, 仅供参考!!! 1.java中一个英文字母或一个中文汉字都是2个字节来存储.如:(char是16bit) 2." ...
- [React] 尚硅谷 -- 学习笔记(三)
第三章 react应用(基于react脚手架) 使用create-react-app创建react应用 react脚手架 xxx 脚手架:用来帮助程序员快速创建一个基于 xxx 库的模板项目 包含了所 ...
- 尚学堂oracle笔记1
1.解锁scott用户: alter user scott account unlock; I.在其他用户切换到sysdba用户 conn sys/root as sysdba II.授权 g ...
- 尚学堂Oracle笔记整理
1.解锁scott用户: alter user scott account unlock; I.在其他用户切换到sysdba用户 conn sys/root as sysdba II.授权 ...
- 尚学堂oracle笔记2
第一课:客户端 1. Sql Plus(客户端),命令行直接输入:sqlplus,然后按提示输入用户名,密码. 2. 从开始程序运行:sqlplus,是图形版的 ...
- 音视频笔记-----三种数字调制的形式之FSK
数字调制技术 数字调制是指用数字数据调制模拟信号,主要有三种形式:移频键控法FSK(Frequency-shift keying).移幅键控法ASK().移相键控法PSK().计算机在处理信息时,只能 ...
- 尚学堂Java学习笔记
尚学堂Java学习笔记 ============================ J2SDK&JRE J2SDK:JAVA2 SOFTWARE DEVELOPMENT KIT JRE:JAVA ...
- 尚学堂Java培训:JAVA优秀书籍推荐
转自:[http://www.bjsxt.com/books/goodbooks.html] 如果你曾经尝试过自学某些知识点,比如JavaSE.JDBC等等,相信有很多情况会觉得按照书上的操作非常难进 ...
最新文章
- android常见错误-Installation error: INSTALL_FAILED_INSUFFICIENT_STORAGE
- Python3 django2.0 字段加密 解密 AES
- boost::spirit模块实现一个类似于 XML 的小型解析器的测试程序
- 再见 Typora,这款 Markdown 编辑器开源又免费!
- 从0到1详解数据挖掘过程
- 哈工大女孩学计算机毕业转行,2020考生切记,上985读这些专业,据说都是“坑”!...
- 数独动态解题演示小网站 - 基于Vue/pixi.js/Flask
- 查看一个进程的线程情况
- LeetCode 412 Fizz Buzz
- 手把手教你上架HarmonyOS(鸿蒙)应用
- 正则表达式美元符号$
- windows平台Emacs单实例原理、设置及右键菜单的添加
- 【毕业设计】 大数据二手房数据爬取与分析可视化 -python 数据分析 可视化
- html怎么读取lrc文件,lrc文件怎么打开?lrc是什么文件?
- django+vue+nginx+frp搭建漫画网站之接入谷歌统计和百度统计(三)
- ZCMU-1635:超大型 LED 显示屏(细节题)
- 大物下学期期末复习笔记
- DNS-over-HTTPS 的下一代是 DNS ON BLOCKCHAIN
- 计算机入会大会新生发言稿,新生大会发言稿(精选7篇)
- Vue3集成富文本编辑器TinyMce6