上一篇博客中,使用Java实现了单链的LinkedList,博客链接如下:
但我们知道,在Java源码里LinkedList是通过循环双链实现的,
并非上篇博客中介绍的单链.
为了能更深入的LinkedList
本篇博客将采用循环双链实现LinkedList的基本功能.
本篇博客所涉及到的代码,均已上传到github:
项目github链接
本篇博客涉及代码github链接
本篇博客要点如下:
一. 双链表简介
LinkedList是和ArrayList一样重要的一种数据结构
我们常说:
LinkedList的底层数据结构是链表,查询慢,增删快,线程不安全,效率高
如果我们想要知道,它为什么具有这样的特性,就需要对链表这种数据结构有一个进一步的了解
双链表概念
双向链表也叫双链表,是链表的一种.
它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱.
所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。
双链表存储结构
请看下面的图片:
从图片中可以看到:
对于双向链表的每一个元素,包含了两个指针域和一个数据域.
前指针指向该元素的直接前驱,后指针指向该节点的直接后继.
对于普通双向链表来说:头结点的前指针为空,尾节点的后指针为空
对于循环双向链表来说:头结点的前指针指向尾结点,尾结点的后指针指向前节点
我们分析下普通双向链表这种存储结构具有什么优点和缺点呢?
优点:
1.不存在闲置的情况
每一个节点,都包含了一个数据域,指向后继节点的指针域,指向前驱节点的指针域.
没有闲置.
从这个角度来说,节约内存资源
缺点:
1.操作难度大,理解成本高
和数组相比,链表确实更加抽象一些
对于我这种不够聪明的人来说,学习链表的成本更高!
2.查询效率低
普通双向链表的查询,只能从头结点开始遍历,根据指针域一个一个获取元素,
查询操作的时间复杂度为T(n)=O(n)
3.除了数值域,还要为指针域分配额外的空间
看到这里,读者应该会有疑问, 为什么我列举优点的时候没有将增删性能高包含进来呢?
仔细思考下,如果我们操作普通的双向链表,进行增删操作(默认是添加到末尾元素之后,删除末尾元素)
进行添加操作时,理论上 : 只需要将原末尾元素的后指针改为新添加的元素, 将新增加元素的前指针指向原末位尾元素即可
进行删除操作时, 理论上: 只需要将原末尾元素前驱节点的后指针置为空
但是我们有没有想过: 该如何找到该链表的末尾元素呢?
对于非循环的双向链表来说, 只能通过首节点遍历,
真正进行增删操作的时间复杂度为O(1), 但是遍历的时间复杂度却为O(n),
并不会比用数组实现的ArrayList增删操作更快哦!
双向循环链表的优势
1.基于头结点,和尾结点的删除和插入操作,时间复杂度为O(1)
2.根据索引进行查询性能优于普通双向链表
首先,普通双向链表和双向循环链表进行查询操作的时间复杂度T(n)=O(n)
但循环双向链表可以根据索引i的位置,来决定是从前向后遍历(离头结点近)
还是从后向前遍历(离尾结点近),平均查询每个元素执行的运算次数为:(1 + 1/2 n) /2
而普通的双向链表只能从头结点开始遍历,平均查询每个元素执行的运算次数为:(1 + n) / 2
平均查询性能为普通双向链表的2倍
二.使用Java代码实现双向循环链表LinkedList
基于上述的了解: 我们可以开始试着手写双向循环链表LinkedList的基本功能
为了更贴近源码的实现,代码里添加了泛型
因此,List接口,和代码的实现和之前两篇博客介绍的有所区别,但总体思路,基本功能一致
集合大小,非空判断,增加元素,删除元素,修改元素,获取元素
我们把上面列举出来的每一种操作封装为一个方法,可以得到一个接口,如下:
List接口
public interface MyList<E> {
// 返回线性表的大小,即数据元素的个数
int size();
// 返回线性表序号为i的数据元素
E get(int i);
// 判断线性表是否包含数据元素e
boolean contains(Object e);
// 线性表为空返回true,否则返回false
boolean isEmpty();
// 返回数据元素e在线性表中的序号
int indexOf(Object e);
// 将元素e插入到线性表末尾
boolean add(E e);
// 删除线性表中序号为i的元素,并返回
E remove(int i);
// 删除线性表中第一个与a相同的元素
boolean remove(Object e);
// 替换线性表中序号为i的数据元素为e,返回原数据元素
E replace(int i, E e);
}
Node类的引入
链表中的每个元素, 包含一个数据域和两个指针域(一个指向前驱节点,一个指向后继节点)
为了更形象的描述链表结构,我们把它抽象成一个实体类Node
用泛型E类型的data来表示数据域
用Node类型的数据next来表示链表的后指针
用Node类型的数据prev来表示链表的前指针
为了更贴合源码,我们把Node类设计成为LinkedList实现类里的一个静态内部类
如下面的代码所示:
/**
* @author xmr
* @description 单链表 只能通过前驱节点找到后继节点,不能通过后继节点找到前驱节点
*/
public class Node {
private static class Node<E> {
Node<E> prev; // 指向前一个节点的指针
Node<E> next; // 指向后一个节点的指针
E data; // 数据域
Node(Node<E> prev, E element, Node<E> next) {
this.data = element;
this.prev = prev;
this.next = next;
}
}
实现循环双链的LinkedList
我们需要三个成员变量来表示
LinkedList的头结点,Linked的尾结点,Linked的大小
Node<E> first; // 集合的首节点
Node<E> last; // 集合的尾结点
int size; // 集合大小
获取集合大小
成员变量size表示集合大小,返回它即可
@Override
public int size() {
return size;
}
获取集合元素
获取集合指定索引元素的数据域
@Override
public E get(int i) {
// 首先要进行参数检查
checkIndex(i);
// 按照惯性,我们会想到从头开始遍历链表,但对于双向循环链表来说? 这种方式真的合适?
// 我们判断索引离首节点近还是离尾结点近,离哪个节点近就从哪个节点开始遍历
if (i < (size >> 1)) {
// 证明离首结点更近,从首节点开始向后遍历
Node<E> p = first;
for (int j = 0; j < i; j++) {
p = p.next;
}
return p.data;
} else {
// 证明离尾结点更近,从尾结点开始向前遍历
Node<E> p = last;
for (int j = size - 1; j > i; j--) {
p = p.prev;
}
return p.data;
}
}
从代码中我们可以看到,链表查询的性能并不高,时间复杂度:T(n)=O(n)
获取指定元素第一次出现的索引
@Override
public int indexOf(Object e) {
Node p = head.next; // 这里p指的是链表的真正意义上的第一个元素,因为头结点的数据域永远为Null
for (int i = 0; i < size; i++) {
// 首先针对空值进行判断,以免出现空指针异常
if (e == null) {
if (p.data == null) {
return i;
}
} else {
if (e.equals(p.data)) {
return i;
}
}
p = p.next;
}
// 在链表中找不到对应的元素,返回-1
return -1;
}
判断集合是否包含指定元素
@Override
public boolean contains(Object e) {
// 若集合包含某个元素,则该元素在集合里对应的索引应该大于等于0
// 所以,我们在这里复用上面的indexOf方法,实现如下:
return indexOf(e) >= 0;
}
判断集合是否为空
@Override
public boolean isEmpty() {
// 集合为空,代表集合大小为0
return size == 0;
}
集合数据添加
我们常用的LinkedList的添加操作,默认是添加在集合末尾
下面我们就以这种常用的添加方式举例,感受下为什么LinkedList在数据添加上性能优越:
/*
添加元素到集合末尾
*/
@Override
public boolean add(E e) {
final Node<E> l = last; // 用一个临时变量存放集合尾指针,因为之后尾指针要重新赋值
final Node<E> newNode = new Node<>(l, e, null); // 创建一个新节点,数据域是e,前指针为之前的尾元素,后指针为空
last = newNode; // 因为插入到集合的最后面, 所以新插入的元素才是当前集合的尾元素
if (l == null)
first = newNode; // 若前尾指针为空,则集合是第一次插入数据,当前节点也是头结点
else
l.next = newNode; // 将前尾节点的尾指针指向新的节点
size++; // 集合元素添加
return true;
}
上面的代码实现实际上是很精妙的, 用很简洁的代码, 实现了LinkedList核心的操作
为了方便大家理解,我自己画了个图来模拟代码执行的状态,以给一个LinkedList添加
3个元素"12",’‘23’’,"34"为例
从上面的图片中大家应该能够更直观的感受到,这段代码从头到尾的执行状态:
注意: 每个add方法执行完毕后在栈中的空间会释放(我这里为了方便大家理解没有擦除)
从代码中我们可以看到, 双向循环链表尾端添加元素的时间复杂度为O(1),效率非常高!
集合数据删除
/*
删除集合对应索引的元素,返回旧值
删除操作的关键点 : 将要删除元素的前驱节点的指针直接指向要删除元素的后继节点
*/
@Override
public Object remove(int i) {
if (i >= size || i < 0) {
throw new RuntimeException("指针越界! " + i);
}
Node p = head;
// 找到索引i的前一个元素
for (int j = 0 ; j < i; j++) {
p = p.next;
}
Node node = p.next; // 索引i所在位置的元素
// 将前一个元素的指针指向 索引i的后一个元素
p.next = p.next.next;
System.out.println(node);
size --;
return node;
}
/*
删除集合里指定的元素
思路: 1. 首先对集合进行遍历,找到集合中的该元素, 若集合没有对应元素,返回false
2. 使用unlink方法删除元素(修改该元素前驱后继节点的指针,数据域置空,长度缩小)
*/
@Override
public boolean remove(Object e) {
if (e == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.data == null) {
unlink(x);
return true;
}
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (e.equals(x.data)) {
unlink(x);
return true;
}
}
}
return false;
}
// unlink方法实现
E unlink(Node<E> x) {
// assert x != null;
final E element = x.data;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.data = null;
size--;
return element;
}
集合数据替换
/*
将集合指定索引的元素替换为特定的值,并返回原数据元素
思路:集合元素的替换不需要考虑指针,
找到对应索引对应的元素,直接替换data域即可
*/
@Override
public E replace(int index, E e) {
Node<E> p = first;
for (int i = 0; i < index; i++) {
p = p.next;
}
E oldValue = p.data;
p.data = e;
return oldValue;
}
重写toString()方法
/*
对我来说,重写该方法的目的就是使输出变得更友好,
方便进行代码调试.如下:输出为[data1,data2]这种格式
*/
@Override
public String toString() {
if (size == 0) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append("[");
Node p = first;
for (int i = 0; i < size; i++) {
if (i != size - 1) {
sb.append(p.data).append(",");
} else {
sb.append(p.data);
}
p = p.next;
}
sb.append("]");
return sb.toString();
}
如上,我们通过自己的方式实现了循环双链的LinkedList的基本功能!
以上内容,主要是个人的理解
可能存在疏漏或者偏差,希望大家多多批评指正!