LinkedList
LinkedList 的简介
LinkedList是基于链表实现的,LinkedList是一种双向链表,双向链表我认为有两点含义:
1、链表中任意一个存储单元都可以通过向前或者向后寻址的方式获取到其前一个存储单元和其后一个存储单元
2、链表的尾节点的后一个节点是链表的头结点,链表的头结点的前一个节点是链表的尾节点
LinkedList的基本存储单元,它是LinkedList中的一个内部类:
private static class Entry<E> {
E element;
Entry<E> next;
Entry<E> previous;
...
}
看到LinkedList的Entry中的"E element",就是它真正存储的数据。"Entry next"和"Entry previous"表示的就是这个存储单元的前一个存储单元的引用地址和后一个存储单元的引用地址。用图表示就是:
添加元素
1 public static void main(String[] args)
2 {
3 List<String> list = new LinkedList<String>();
4 list.add("111");
5 list.add("222");
6 }
逐行分析main函数中的三行代码是如何执行的,首先是第3行,看一下LinkedList的源码:
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
private transient Entry<E> header = new Entry<E>(null, null, null);
private transient int size = 0;
/**
* Constructs an empty list.
*/
public LinkedList() {
header.next = header.previous = header;
}
...
}
看到,new了一个Entry出来名为header,Entry里面的previous、element、next都为null,执行构造函数的时候,将previous和next的值都设置为header的引用地址,还是用画图的方式表示。32位JDK的字长为4个字节,而目前64位的JDK一般采用的也是4字长,所以就以4个字长为单位。header引用地址的字长就是4个字节,假设是0x00000000,那么执行完"List list = new LinkedList()"之后可以这么表示:
接着看第4行add一个字符串"111"做了什么:
1 public boolean add(E e) {
2 addBefore(e, header);
3 return true;
4 }
private Entry<E> addBefore(E e, Entry<E> entry) {
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
size++;
modCount++;
return newEntry;
}
第2行new了一个Entry出来,可能不太好理解,根据Entry的构造函数,我把这句话"翻译"一下,可能就好理解了:
1、newEntry.element = e;
2、newEntry.next = header.next;
3、newEntry.previous = header.previous;
header.next和header.previous上图中已经看到了,都是0x00000000,那么假设new出来的这个Entry的地址是0x00000001,继续画图表示:
一共五步,每一步的操作步骤都用数字表示出来了:
1、新的entry的element赋值为111;
2、新的entry的next是header的next,header的next是0x00000000,所以新的entry的next即0x00000000;
3、新的entry的previous是header的previous,header的previous是0x00000000,所以新的entry的next即0x00000000;
4、“newEntry.previous.next = newEntry”,首先是newEntry的previous,由于newEntry的previous为0x00000000,所以newEntry.previous表示的是header,header的next为newEntry,即header的next为0x00000001;
5、“newEntry.next.previous = newEntry”,和4一样,把header的previous设置为0x00000001;
最后看一下add了一个字符串"222"做了什么,假设新new出来的Entry的地址是0x00000002,画图表示:
还是执行的那5步,图中每一步都标注出来了,只要想清楚previous、next各自表示的是哪个节点就不会出问题了。
至此,往一个LinkedList里面添加一个字符串"111"和一个字符串"222"就完成了。从这张图中应该理解双向链表比较容易:
1、中间的那个Entry,previous的值为0x00000000,即header;next的值为0x00000002,即tail,这就是任意一个Entry既可以向前查找Entry,也可以向后查找Entry
2、头Entry的previous的值为0x00000002,即tail,这就是双向链表中头Entry的previous指向的是尾Entry
3、尾Entry的next的值为0x00000000,即header,这就是双向链表中尾Entry的next指向的是头Entry
查找元素
public E get(int index) {
return entry(index).element;
}
private Entry<E> entry(int index) {
if (index < 0 || index >= size)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+size);
Entry<E> e = header;
if (index < (size >> 1)) {
for (int i = 0; i <= index; i++)
e = e.next;
} else {
for (int i = size; i > index; i--)
e = e.previous;
}
return e;
}
这段代码就体现出了双向链表的好处了。双向链表增加了一点点的空间消耗(每个Entry里面还要维护它的前置Entry的引用),同时也增加了一定的编程复杂度,却大大提升了效率。
由于LinkedList是双向链表,所以LinkedList既可以向前查找,也可以向后查找,第6行~第12行的作用就是:当index小于数组大小的一半的时候(size >> 1表示size / 2,使用移位运算提升代码运行效率),向后查找;否则,向前查找。
这样,在我的数据结构里面有10000个元素,刚巧查找的又是第10000个元素的时候,就不需要从头遍历10000次了,向后遍历即可,一次就能找到我要的元素。
删除元素
和ArrayList一样,LinkedList支持按元素删除和按下标删除,前者会删除从头开始匹配的第一个元素。用按下标删除举个例子好了,比方说有这么一段代码:
public static void main(String[] args)
{
List<String> list = new LinkedList<String>();
list.add("111");
list.add("222");
list.remove(0);
}
也就是我想删除"111"这个元素。看一下第6行是如何执行的:
1 public E remove(int index) {
2 return remove(entry(index));
3 }
private E remove(Entry<E> e) {
if (e == header)
throw new NoSuchElementException();
E result = e.element;
e.previous.next = e.next;
e.next.previous = e.previous;
e.next = e.previous = null;
e.element = null;
size--;
modCount++;
return result;
}
当然,首先是找到元素在哪里,这和get是一样的。接着,用画图的方式来说明比较简单:
这里我提一点,第3步、第4步、第5步将待删除的Entry的previous、element、next都设置为了null,这三步的作用是让虚拟机可以回收这个Entry。
但是,这个问题我稍微扩展深入一点:按照Java虚拟机HotSpot采用的垃圾回收检测算法----根节点搜索算法来说,即使previous、element、next不设置为null也是可以回收这个Entry的,因为此时这个Entry已经没有任何地方会指向它了,tail的previous与header的next都已经变掉了,所以这块Entry会被当做"垃圾"对待。之所以还要将previous、element、next设置为null,我认为可能是为了兼容另外一种垃圾回收检测算法----引用计数法,这种垃圾回收检测算法,只要对象之间存在相互引用,那么这块内存就不会被当作"垃圾"对待。
插入元素
public void add(int index, E element) {
addBefore(element, (index==size ? header : entry(index)));
}
private Entry<E> addBefore(E e, Entry<E> entry) {
Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
newEntry.previous.next = newEntry;
newEntry.next.previous = newEntry;
size++;
modCount++;
return newEntry;
}
LinkedList和ArrayList的对比
**1、顺序插入速度ArrayList会比较快,因为ArrayList是基于数组实现的,数组是事先new好的,只要往指定位置塞一个数据就好了;LinkedList则不同,每次顺序插入的时候LinkedList将new一个对象出来,如果对象比较大,那么new的时间势必会长一点,再加上一些引用赋值的操作,所以顺序插入LinkedList必然慢于ArrayList。
2、基于上一点,因为LinkedList里面不仅维护了待插入的元素,还维护了Entry的前置Entry和后继Entry,如果一个LinkedList中的Entry非常多,那么LinkedList将比ArrayList更耗费一些内存
3、数据遍历的速度,看最后一部分,这里就不细讲了,结论是:使用各自遍历效率最高的方式,ArrayList的遍历效率会比LinkedList的遍历效率高一些。
4、有些说法认为LinkedList做插入和删除更快,这种说法其实是不准确的:
(1)LinkedList做插入、删除的时候,慢在寻址,快在只需要改变前后Entry的引用地址
(2)ArrayList做插入、删除的时候,慢在数组元素的批量copy,快在寻址。
所以,如果待插入、删除的元素是在数据结构的前半段尤其是非常靠前的位置的时候,LinkedList的效率将大大快过ArrayList,因为ArrayList将批量copy大量的元素;越往后,对于LinkedList来说,因为它是双向链表,所以在第2个元素后面插入一个数据和在倒数第2个元素后面插入一个元素在效率上基本没有差别,但是ArrayList由于要批量copy的元素越来越少,操作速度必然追上乃至超过LinkedList。
从这个分析看出,如果你十分确定你插入、删除的元素是在前半段,那么就使用LinkedList;如果你十分确定你删除、删除的元素在比较靠后的位置,那么可以考虑使用ArrayList。如果你不能确定你要做的插入、删除是在哪儿呢?那还是建议你使用LinkedList吧,因为一来LinkedList整体插入、删除的执行效率比较稳定,没有ArrayList这种越往后越快的情况;二来插入元素的时候,弄得不好ArrayList就要进行一次扩容,记住,ArrayList底层数组扩容是一个既消耗时间又消耗空间的操作。
LinkedList以及ArrayList的迭代
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
注意到ArrayList是实现了RandomAccess接口而LinkedList则没有实现这个接口
Queue
Deque
DeQueue(Double-ended queue)为接口,继承了Queue接口,创建双向队列,灵活性更强,可以前向或后向迭代,在队头队尾均可心插入或删除元素。它的两个主要实现类是ArrayDeque和LinkedList。
ArrayDeque (底层使用循环数组实现双向队列)
PriorityQueue(底层用数组实现堆的结构)
优先队列跟普通的队列不一样,普通队列是一种遵循FIFO规则的队列,拿数据的时候按照加入队列的顺序拿取。 而优先队列每次拿数据的时候都会拿出优先级最高的数据。
优先队列内部维护着一个堆,每次取数据的时候都从堆顶拿数据(堆顶的优先级最高),这就是优先队列的原理。
####### 添加元素
public boolean add(E e) {
return offer(e); // add方法内部调用offer方法
}
public boolean offer(E e) {
if (e == null) // 元素为空的话,抛出NullPointerException异常
throw new NullPointerException();
modCount++;
int i = size;
if (i >= queue.length) // 如果当前用堆表示的数组已经满了,调用grow方法扩容
grow(i + 1); // 扩容
size = i + 1; // 元素个数+1
if (i == 0) // 堆还没有元素的情况
queue[0] = e; // 直接给堆顶赋值元素
else // 堆中已有元素的情况
siftUp(i, e); // 重新调整堆,从下往上调整,因为新增元素是加到最后一个叶子节点
return true;
}
private void siftUp(int k, E x) {
if (comparator != null) // 比较器存在的情况下
siftUpUsingComparator(k, x); // 使用比较器调整
else // 比较器不存在的情况下
siftUpComparable(k, x); // 使用元素自身的比较器调整
}
private void siftUpUsingComparator(int k, E x) {
while (k > 0) { // 一直循环直到父节点还存在
int parent = (k - 1) >>> 1; // 找到父节点索引,等同于(k - 1)/ 2
Object e = queue[parent]; // 获得父节点元素
// 新元素与父元素进行比较,如果满足比较器结果,直接跳出,否则进行调整
if (comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e; // 进行调整,新位置的元素变成了父元素
k = parent; // 新位置索引变成父元素索引,进行递归操作
}
queue[k] = x; // 新添加的元素添加到堆中
}
poll,出队方法
public E poll() {
if (size == 0)
return null;
int s = --size; // 元素个数-1
modCount++;
E result = (E) queue[0]; // 得到堆顶元素
E x = (E) queue[s]; // 最后一个叶子节点
queue[s] = null; // 最后1个叶子节点置空
if (s != 0)
siftDown(0, x); // 从上往下调整,因为删除元素是删除堆顶的元素
return result;
}
private void siftDown(int k, E x) {
if (comparator != null) // 比较器存在的情况下
siftDownUsingComparator(k, x); // 使用比较器调整
else // 比较器不存在的情况下
siftDownComparable(k, x); // 使用元素自身的比较器调整
}
private void siftDownUsingComparator(int k, E x) {
int half = size >>> 1; // 只需循环节点个数的一般即可
while (k < half) {
int child = (k << 1) + 1; // 得到父节点的左子节点索引,即(k * 2)+ 1
Object c = queue[child]; // 得到左子元素
int right = child + 1; // 得到父节点的右子节点索引
if (right < size &&
comparator.compare((E) c, (E) queue[right]) > 0) // 左子节点跟右子节点比较,取更大的值
c = queue[child = right];
if (comparator.compare(x, (E) c) <= 0) // 然后这个更大的值跟最后一个叶子节点比较
break;
queue[k] = c; // 新位置使用更大的值
k = child; // 新位置索引变成子元素索引,进行递归操作
}
queue[k] = x; // 最后一个叶子节点添加到合适的位置
remove,删除队列元素
再执行 siftUp() 上滤过程:
public boolean remove(Object o) {
int i = indexOf(o); // 找到数据对应的索引
if (i == -1) // 不存在的话返回false
return false;
else { // 存在的话调用removeAt方法,返回true
removeAt(i);
return true;
}
}
private E removeAt(int i) {
modCount++;
int s = --size; // 元素个数-1
if (s == i) // 如果是删除最后一个叶子节点
queue[i] = null; // 直接置空,删除即可,堆还是保持特质,不需要调整
else { // 如果是删除的不是最后一个叶子节点
E moved = (E) queue[s]; // 获得最后1个叶子节点元素
queue[s] = null; // 最后1个叶子节点置空
siftDown(i, moved); // 从上往下调整
if (queue[i] == moved) { // 如果从上往下调整完毕之后发现元素位置没变,从下往上调整
siftUp(i, moved); // 从下往上调整
if (queue[i] != moved)
return moved;
}
}
return null;
}
总结和同步的问题
1、jdk内置的优先队列PriorityQueue内部使用一个堆维护数据,每当有数据add进来或者poll出去的时候会对堆做从下往上的调整和从上往下的调整。
2、** PriorityQueue不是一个线程安全的类,如果要在多线程环境下使用,可以使用 PriorityBlockingQueue 这个优先阻塞队列。**其中add、poll、remove方法都使用 ReentrantLock 锁来保持同步,take() 方法中如果元素为空,则会一直保持阻塞。