Java编程的逻辑2
2018-06-18
第9章 列表和队列
9.1 ArrayList
- Java中的容器类可以粗略的映射到数据结构这门课。
- ArrayList内部有一个数组elementData,一般会有一些预留的空间,有一个整数size记录实际的元素个数。
- foreach语法是一个编译阶段的,编译器会编译为类似下面代码:
Iterator<Integer> it = intList.iterator();
while(is.hasNext()){
//具体代码
}
所以foreach只支持Iterator类型的集合。
2018-06-19
- 迭代器接口
public interface Iterable<T>{
Iterator<T> iterator();
}
public interface Iterator<E>{
boolean hasNext();//判断是否还有元素没有访问
E next();//返回下一个元素
void remove();//删除最后返回的元素
}
Iterable和Iterator的区别:
- Iterable表示对象可以被迭代,它有一个方法iterator(),返回Iterator对象,实际通过Iterator接口的方法进行遍历;
- 如果对象实现了Iterable,就可以使用foreach语法
- 类可以不实现Iterable,也可以创建Iterator。
Java8对Iterable添加了默认方法foreEach和spliterator。
- ListIterator
除了iterator(),ArrayList还提供了两个返回Iterator接口的方法:
public ListIterator<E> listIterator();
public ListIterator<E> listIterator(int index);
ListIterator扩展了Iterator接口,增加了一些方法,向前遍历、添加元素、修改元素。
- 迭代器的陷阱
关于迭代器,有一种常见的误用,就是在迭代过程中调用容器的删除方法,这种情况会运行时抛出异常:java.util.ConcurrentModificationException。例如:
public void remove(ArrayList<Integer> list){
for(Integer a: list){
if(a<100){
list.remove;
}
}
}
但是我们可以使用迭代器的remove方法。例如:
public void remove(ArrayList<Integer> list){
Iterator<Integer> it = list.iterator();
while(it.hasNext()){
if(it.next<100){
it.remove();//调用这个方法之前,必须先调用next方法,不然会抛出java.lang.IllegalStateException。
}
}
}
这是为什么呢?因为迭代器内部会维护一些索引位置相关的数据,要求在迭代过程中,容器不能发生结构性变化,否则这些索引就失效了。调用容器的remove方法就改变了容器的结构,当然还有添加、插入操作,只修改元素的值不算结构变化。
那为什么迭代器的remove方法可以使用呢?因为ArrayList的iterator的实现方法中,remove不仅调用了list的remove方法,而且还更新了相关的变量的值。
- 迭代器的好处
1)foreach语法简洁
2)迭代器表示的是一种关注点分离的思想,讲数据的实际组织方式和数据的迭代遍历相分离,是一种常见的设计模式;
3)从封装的思路上讲,迭代器封装了各种数据组织方式的迭代操作,提供了简单和一直的接口。
4)在某些容器上,效率更高。例如LinkedList。 - ArrayList实现的接口
主要介绍RandomAccess接口。
public interface RandomAccess{
}
没有定义任何代码。没有任何代码的接口称为标记接口,用于声明类的一种属性。
实现了RandomAccess的类可以随机访问,就是具备类似数组那样的特性,数据在内存中连续存放,根据索引值就可以定位到具体的元素,访问效率很高。
那是否声明RandomAccess有什么关系了?主要使用在某些算法代码中,可以根据这个实现选择更高效的实现。例如Collectios类中有一个方法binarySearch,在List中进行二分查找,它的实现是根据是否实现了RandomAccess而采用不同的实现机制。
- ArrayList的其他方法
主要介绍静态asList方法。asList方法的结果是返回一个List,但是它的具体实现不是ArrayList,而是ArrayList里面的一个内部类。这个内部类用的数组就是外部传入的数组,没有拷贝,也不会动态改变大小,所以对数组的修改也会翻页到List中,对List调用add、remove方法会抛出异常。
Integer[] ints = new Integer[]{1, 2, 3};
List<Integer> list = Arrays.asList(ints);
System.out.println(list);
ints[2] = null;
System.out.println(list);
list.set(2, 4);
System.out.println(list);
System.out.println(ints[2]);
list.add(5);
System.out.println(list);
输出结果为:
[1, 2, 3]
[1, 2, null]
[1, 2, 4]
4
java.lang.UnsupportedOperationException
at java.util.AbstractList.add(AbstractList.java:148)
at java.util.AbstractList.add(AbstractList.java:108)
所以Arrays的静态方法asList,具体实现不是ArrayList的实现方法,而是Arrays的一个内部类。这个内部类使用的数组就是传入的数组,当然也就不能动态改变数组大小,无法进行增删操作,但是改查还是可以的。
10.ArrayList的特点
必须了解每种数据结构的特点,不同的场合选用不同的数据结构。
对于ArrayList,他的特点是内部采用动态数组实现,所以:
1)可以随机访问——动态数组,按照索引位置可以直接方法,效率为O(1)
2)除非数组已经排序,否则按照内容访问效率很低,具体是O(n),n为数组长度
3)添加元素的效率还可以,重新分配和复制数组的开销被平摊了,具体效率为O(n),n为添加n个元素
4)插入和删除元素的效率比较低,因为需要移动元素,具体为O(n)
- ArrayList容器是线程不安全的,且插入和删除元素效率低。
9.2 LinkedList
- LinkedList除了实现了List之外,还实现了Deque和Queue接口,可以按照队列、栈和双端队列的方式进行操作。
- Queue
Queue是队列接口,扩展了Collection,它的主要操作有三个:
- 在尾部添加元素(add、offer)
- 查看头部元素(element、peek),返回头部元素,但不改变队列
- 删除头部元素(remove、poll),返回头部元素,并且从队列中删除
没中操作都有两个,为啥呢?区别在于对特殊情况的处理不同。队列为空时,element和remove会抛出NoSuchElementException的异常;peek和poll会返回特殊书值null。当队列满了的时候,add会抛出IllegalStateException,而offer只返回false,当然LinkedList队列长度没有限制,但是其他的实现可能有。
- Deque
Java没有单独的栈接口,栈相关接口包括在了表示双端队列的接口Deque中。主要有三个方法:
- void push(E e)——表示入栈,栈的空间有限,如果空间满了会抛出IllegalStateExcepiton
- E pop()——表示出栈,返回头部元素并且从栈中删除,如果栈为空,则会抛出NoSuchElementException
- E peek()——查看栈头部元素,不修改栈,如果栈为空,返回null
实现原理
- ArrayList的内部实现是数组,元素在内存中是连续存放的,但LinkedList不是。它的实现是双向链表,每个元素在内存中是单独存放的,元素之间通过链接连在一起。为了表示链接关系,需要一个节点的概念。
- LinkedList特点分析
1)按需分配空间,不需要预先分配很多空间
2)不可用随机访问,按照索引访问效率比较低,必须从头或尾顺序访问,效率为O(N/2)
3)不管列表是否已排序,只要是按照内容查找元素,效率都比较低,必须逐个比较,效率为O(N)
4)在两端添加删除元素效率很高,为O(1)
5)在中间插入、删除元素,要先定位,效率比较低,为O(N),但修改本身效率很高为O(1)
9.3 剖析ArrayDeque
- ArrayDeque是一个双端队列,基于数组实现。一般而言,由于需要移动元素,数组的插入和删除效率比较低,但ArrayDeque的效率却非常高。构造函数如下:
public ArrayDeque();
//初始分配的空间至少容纳这么多元素,但空间不是正好这么大
public ArrayDeque(int numElements);
public ArrayDeque(Collection<? extends E> c);
ArrayDeque实现了Deque接口,它的长度也是没有限制的。
- 特点分析
1)在两端添加、删除元素效率很高
2)根据元素内容查找和删除的效率比较低,为O(N)
3)没有索引位置的概念,不能根据索引位置进行操作。
20180627
第10章 Map和Set
10.1 剖析HashMap
- keySet()、values()、entrySet()有一个共同点,他们返回的都是视图,不是复制的值,基于返回值的修改都会修改Map自身。
10.1.1 实现原理
1.内部组成
HashMap内部有如下几个主要的实例变量:
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TYBLE;
transient int size;
int threhold;
final float loadFactor;
- size表示实际键值对的个数,不是map的容器大小
- table是一个Enry类型的数组,称为哈希表或哈希桶,其中的每个元素指向一个单向链表,链表中的每个节点表示一个键值对。
- Entry是一个内部类,他的实例变量和构造方法如下:
static class Entry<K,V> implements Map.Entry<K,V>{
final K key;
V value;
//next表示,如果该位置已经有hash,则把相同hash的对象存到这里
Entry<K,V> next;
int hash;
Entry(int h,K k,V v,Entry<K,V> n){
value =v;
next = n;
key=k;
hash=h;
}
}

其中key和value分别表示建和值,next指向下一个Entry节点,hash是key的hash值。直接存储hash是为了比较时加快计算。
- threshold表示阈值,超过该值就需要扩容。一般threshold=table.length*loadFactor;
- loadFactor是负载因子,默认为0.75
- 保存键值对
1)如果是第一次保存,需要先给方法分配实际空间,默认空间大小为16,threshold为12;
2)检查key是否为null,如果为nul进行putForNullKey的单独处理
3)计算key的hash值,先利用key类型自带的Hash值,然后进行位运算,目的是随机和均匀性;
4)通过indexFor方法计算应该将键值对放到table的哪个位置,代码为
static int indexFor(int h, int length){
//HashMap中,length为2的幂次方,h&(length-1)等同于h%length
return h&(length-1);
}
5)遍历代码确认key是否已存在。先比较hash,因为整数比较速度快;如果可以找到,直接修改value值即可;
6)如果没找到,调用addEntry添加一条记录。如果空间是够的,不需要resize。如果空间不够,并且对应的位置已经插入过对象了,检查代码,调用resize对table进行扩展,扩展策略是乘2。
- 查找方法
查找流程可以汇总如下:
1)计算键的hash值,代码为:
int hash = (key==null)?0:hash(key);
2)根据hash找到table中对应链表,代码为:
table[indexFor(hash,table.length)]
3)在链表中遍历查找,遍历代码;
4)逐个比较,先通过hash快速比较,hash相同再通过equals比较;
indexFor计算出的是数组中的位置,数组中位置一致不代表hash值一致
HashMap可以方便高效的按照键进行操作,但是如果按照值进行操作,就需要遍历。
- 根据键删除键值对
1)计算hash,根据hash找到对应的table索引;
2)遍历table[i],查找待删除节点,使用变脸prev指向前一个节点,next指向后一个节点,e指向当前节点;
3)判断是否找到,依然是比较hash值,hash值相同时再使用equals方法比较
4)删除的逻辑就是让长度减小,然后让待删除节点的前后节点链接麒麟,如果待删除节点是第一个节点,则让table[i]直接指向后一个节点; - 小结
HashMap里面有个hash表,即数组table,每个元素table[i]指向一个单向链表,根据键存取值,用键算出hash值,取模得到数组中的索引位置,然后操作table[i]指向的单向链表。
存取的时候根据键的hash值,只在对应的链表中操作,不会访问别的链表,在对应链表的访问中,先使用hash判断,如果相同在使用equals方法比较。这就要求,相同的对象hash必须相同,如果键是自定义的类,就特别需要注意这一点。Java 8对HashMap做了优化,如果冲突较多(具体是至少8个元素,且总的键值对个数至少是64)的时候就会转换一个平衡二叉树,提高查询效率。
10.1.2 小结
HashMap可以方便的按照键存取值,内部使用数组链表和哈希的方式进行实现,所以:
1)根据键保存和获取的效率都很高,为O(1) ,每个单向链表往往只有一个或少数个节点,根据hash值就可以快速定位
2)HashMap中的键值对没有顺序,因为hash是随机的。
HashMap不是现成安全的,还有一个HashTable,是Java中最早的容器之一,实现了Map接口,没有特别的有点,就是线程安全的。HashMap中键值都可以为null,而Hashtable不可用。在高并发的情况下,推荐使用ConcurrentHashMap。
20180628
10.2 剖析HashSet
本节介绍Set的一个重要实现,HashSet。其中Set表示接口,利用Hash方式实现了接口。
10.2.1 用法
- Set表示没有重复元素,且不保证顺序的容器接口,扩展了Collection,但没有定义任何新的方法。但是对于其中的一些方法,它有着自己的规范。其中add和addAll方法着重说明:
- add方法:添加元素时,如果已经存在相同的元素,则不会改变集合,返回false;只有不存在时,才会添加返回true。
- allAll 重复的元素不添加,不重复的添加,如果集合有变化,返回true,否则返回false。
同HashMap一样,对象相同,hash必须相同。如果使用自定义对象,一定要注意。注意重写hash和equals方法。否则可能会把同一份存储两遍。
- HashSet的应用场景
1)排重。如果对排重后的元素没有顺序要求,则HashSet可以方便的用于排重。
2)保存特殊值,比如黑名单等。
3)集合运算。使用Set可以很方便的进行集合运算,例如交集、并集等。
10.2.2 实现原理
HashSet内部是使用HashMap实现的,有一个HashMap实例变量:
private transient HashMap<E,Object> map;
HashSet相当于只有键,值都是相同的固定值,这个值定义为:
private static final Object PRESENT = new Object();
10.2.3 小结
充分利用了HashMap,所以有以下特点:
1)没有重复元素;
2)可以高效的添加、删除、判断元素是否存在,效率都为O(1);
3)没有顺序
如果需要保持添加的顺序,可以使用LinkedHashSet,如果需要排序,使用TreeSet;
20180629
10.3 排序二叉树
10.3.1 基本概念
- 二叉树是一棵树,每个节点最多有两个子节点,一左一右,左边的称为左孩子,右边的称作右孩子。
- 数有深度或者高度的概念,从根节点到叶子节点经过的节点个数的最大值称为树的高度。如果只有一个根节点,则高度为1。
- 排序二叉树也是树,顺序是这样的:
1)如果左子树的树不为空,则左树上的所有节点都小于该节点;
2)如果右子树不为空,则右树上的所有节点都大于该树。
10.3.2 基本算法
- 查找
排序二叉树有一个很好的优点,按值在其中查找一个元素时很方便、也很高效,基本步骤为:
1)首先与根节点比较,如果相同,就找到了;
2)如果小于根节点,则到左子树中递归查找;
3)如果大于根节点,则到右子树中查找
此外,二叉树很方便查找最大值和最小值。 - 遍历
排序二叉树可以方便的按序遍历。使用递归的方式:
1)访问左子树——一直到最左边的节点
2)访问当前节点
3)访问右子树
也可以不用递归的方式按序遍历。 - 插入
在排序二叉树中,插入元素首先需要找插入位置,即新节点的父节点,步骤为:
1)与当前节点比较,如果相同,表示已经存在,不能再插入了
2)如果小于当前节点,则到左子树中寻找,如果左子树为空,则当前节点为要寻找的父节点;
3)如果大于当前节点,泽道右子树中寻找,如果右子树为空,则当前节点就为父节点。
找到父节点之后即可插入,如果元素小于父节点,则作为左孩子插入,否则作为右孩子插入。 - 删除
在排序二叉树中删除一个节点要复杂一些,有三种情况:
1)节点为叶子节点——直接删掉,把父节点对应的子节点为空
2)节点只有一个孩子节点——把孩子节点和父节点直接关联起来
3)节点有两个孩子几点——找到后继节点
10.3.3 平衡二叉树
排序二叉树的形状与插入的数值密切相关,极端情况下可能变为一个链表。退化为链表之后,排序二叉树的优点就没有了,如果排序二叉树高度不平衡,效率也会变的很低。
10.4 剖析TreeMap
TreeMap是按键有序,而非按值有序。
10.4.1 基本用法
- 构造方法
public TreeMap()
public TreeMap(Comparator<? super K> comparator)
如果使用默认构造方法,则要求Map中的键实现Comparable 接口,TreeMap内部进行比较时会调用Comparable接口中的compareTo方法。
第二个构造方法在内部比较时会调用这个方法,不再要求键实现Comparable接口。
- 字符串有一个常量会忽略大小写。所以在创建忽略大小写的TreeMap时可以使用new TreeMap(String.CASE_INSENSITIVE_ORDER)
public static final Comparator<String> CASE_INSENSITIVE_ORDER = new CaseInsensitiveComparator();
- Collections.有一个反转排序的方法:reverseOrder()。这个方法返回一个逆序的比较器。如果是一个逆序的忽略大小写的方法可以用:
TreeMap<Object, Object> map = new TreeMap<>(Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
- TreeMap会根据比较结果进行排重。及时键实际上不同,但是比较结果相同就会被认为相同,键只会保存一份。
@org.junit.Test
public void test() {
TreeMap<String,String> map = new TreeMap<>(Collections.reverseOrder(String.CASE_INSENSITIVE_ORDER));
map.put("t", "t");
map.put("T", "T");
System.out.println(map.toString());
}
输出结果为:{t=T}
注意:后面put的时候,put生效了。而不是因为键一样就放弃更新。
- 扩展功能
和HashMap一样,实现了Map接口。但是有序性又扩展了功能。例如SortedMap和NavigableMap接口,可以按键的顺序进行查找,如第一个、最后一个、某一范围的键、邻近键等。
20180705
10.4.2 实现原理
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;//指向父节点。如果是根节点,为null
boolean color=BLACK;
ENTRY<K key,V value, Entry<K,V> parent){
this.key=key;
this.value=value;
this.parent=parent;
}
}
- 保存键值对
循环找到父节点,并插入作为其左孩子或右孩子,然后调整树的大致平衡。 - 根据键获取值
public V get(Object key){
Entry<K,V> p = getEntry(key);
return (p==null? null:p.value);
}
getEnrty的过程:如果比较器不为空,调用但都的方法getEntryUsingComparator;否则,假定key实现了Comparable接口,使用接口的compareTo方法进行比较,找的逻辑也很简单。从根开始找,小于往左边找,大于往右边找,知道找到为止。
- 查看是否包含某个值
采用遍历的方法获取值,不是从根节点开始遍历,而是从第一个节点开始遍历。 - 根据键删除键值对
根据key找到节点,然后删除节点。主要考虑是链路的维护,即叶子节点、只有一个孩子和有两个孩子。
10.4.3 总结
和HashMap相比,TreeMap内部使用了红黑树实现,所以有以下特点:
1)按键有序。
2)为了按键有序,TreeMap的键需要实现Comparable接口或者通过构造方法提供一个比较器对象。
3)根据键保存、查找、删除的效率比较高,为O(h),h为树的高度,在树平衡的情况下,h为log2(N),N为节点数。
不要求键有序,使用HashMap,如果 考虑键有序,用Treemap。
20180711
10.5 剖析TreeSet
同HashSet基于HashMap一样,TreeSet基于TreeMap。
10.5.1 用法
- TreeSet有两个构造方法,带比较器的和默认。
TreeSet实现了两点:排重和有序。排重基于比较结果,如果结果为0即视为相同,只会保留第一个元素。
10.5.2 实现原理
- TreeSet有以下成员:
private transient NavigableMap<E,Object> m;
private static final Object PRESENT = new Object();
10.5.3 小结
1)没有重复元素,保留第一个元素。
2)添加、删除元素、判断元素是否存在,效率比较高,为O(log2(N)),N为元素个数。
3)有序;
4)为了有序,TreeSet要求元素实现Comparable接口或通过构造方法提供一个Comparator对象。
10.6 剖析LinkedHashMap
它是HashMap的子类,保持元素按插入或访问有序。
20180717
10.6.1 基本用法
LinkedHashMap是HashMap的子类,但内部还有一个双向链表。即每个键值对即位于hash表中,也位于这个双向链表中。
LinkedHashMap的插入顺序好理解,那么什么是访问顺序呢?
访问顺序是指对一个键进行get/put操作后,其对应的键值会移到链表末尾,所以最末尾的是最近访问的,最开始的是最久没被访问的。这种顺序就是顺序访问。
在LinkedHashMap的五个构造方法里面,其中4个都是按插入顺序的,只有一个构造方法可以按访问顺序。
public LinkedHashMap(int initialCapacity,float loadFactor,boolean accessOrder)
其中accessOrder就是用来指定是否按访问顺序。
那么什么时候使用按访问顺序呢?可以利用LinkedHashMap的按访问顺序特点快速实现LRU缓存。
LRU缓存的全称是Least Recently Used,即最近最少使用。它的思路是最近刚被使用的很快再次被使用的可能性最高,而最久没被访问的很快再次被使用的可能性最低,所以被优先清理。
默认情况下,LinkedHashMap对容量没有做限制,但他可以轻易的做到,有一个protected方法:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest){
return false;
}
在添加元素到LinkedHashMap后,LinkedHashMap会调用这个方法,传递的参数是最久没被访问的键值对,如果这个方法返回true,则这个最久的键值对就会被删除。LinkedHashMap的这个值永远为false,但是可以通过继承的方式来实现。例如:
public class LRUCache<K,V> extends LinkedHashMap<K,V>{
private int maxEntries;
public LRUCache(int maxEntries){
super(16,0.75,true);
this.maxEntires=maxEntries;
}
@Override
protected boolean removeEldestEntry(Entry<K,V> eldest){
return size>maxEntries;
}
}
这样当超过最大值的时候就会自动删除了。
10.7 剖析EnumMap
如果需要一个Map,且键是枚举类的时候,应该使用EnumMap,简洁、方便、安全;实现原理上内部有两个数组,长度相同,一个表示所有可能的键,一个表示对应的值,值为null表示没有该键值对,键都有一个索引,根据所有可直接访问和操作其键值,效率很高。
构造方法比较特殊,需要指定枚举类型。
10.8剖析EnumSet
这是一个非常高效的实现Set接口的类。前面介绍的HashSet/TreeSet都是用HashMap/TreeMap实现的,但EnumSet不是。
EnumSet的实现与EnumMap没有任何关系,而是用极为精简和高效的位向量实现的。位向量是计算机程序中解决问题的一种常用方法,有必要了解和掌握。
除了实现机制,使用方法也有一些不同。
10.8.1 基本用法
与TreeSet/HashSet不同,EnumSet是一个抽象类。虽然不能直接new对象,但是它提供了若干个静态工厂方法,可以创建EnumSet类型的对象,比如:
public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType)
noneOf会创建一个指定枚举类型的EnumSet,不包含任何元素。创建的EnumSet对象的实际类型是EnumSet的子类。
10.8.2 应用场景
使用目的和TreeSet、HashSet一致。
10.8.3 实现原理
位向量:就是用一个位表示一个元素的状态,用一组位表示一个集合的状态,每个位对于一个元素,而状态只可能有两种。
例如有一个表示一周7天的枚举DayEnum。一个DayEnum的集合就可以用一个字节byte表示,最高位不用,设为0,最右边的位对于最小的枚举,从右到左,每位对于一个枚举值,1表示包含该元素,0表示不包含该元素。
例如表示星期一,星期二,星期三,星期五的位向量结构图如下:
|0|0|0|1|0|1|1|1|
对应的整数就是23.。
位向量能表示的元素个数与向量长度有关,一个byte类型可以表示8个元素,一个long类型可以表示64个元素。EnumSet有两个子类:当长度小于64的时候,使用RegularEnumSet的long类型作为位向量,如果超过64为使用long类型数组的JumboEnumSet。
EnumSet自身没有记录元素个数的变量,也没有位向量,他们是子类维护的。
- Regular用一个long类型表示位向量,没有表示元素个数的变量,是实时计算出来的。
- 对于JumboEnumSet,它用一个long数组表示,有单独的size变量。
10.8.4 小结
对于只有两种状态,且需要进行集合运算的数据,使用位向量进行表示、位运算进行处理,是计算机程序中常用的一种思维方式。
Java中有一个更为通用的可动态扩展长度的位向量容器里BitSet,可以方便的对指定位置的位进行操作,与其他位向量进行位运算。