一. 什么是链表
通俗的说,链表就是用链条串起来的对象,是一种数据容器
- 对象:用属性保存数据信息
- 链条:对象的引用(指针)
二. 链表的结构
用图表示一个链表就是这样子
图中,每个大矩形称为一个节点(本质上就是对象)
- data:真正存储信息的部分
- previous:记录上一个节点
- next:记录下一个节点
三. 与数组的比较
1. 结构比较
数组是连续的内存单元,链表是基于节点和引用的
2. 空间比较
数组空间是连续的,链表的空间是散布的
当内存紧张时,链表可以充分利用内存碎片,存储更多的节点;
而且节点可以随时增加,删除,对整体的影响很小;
但链表的结构决定了,同样存储一个元素,链表消耗的内存要比数组更大
3. 定位比较
数组通过索引就可以访问任意位置,速度很快
链表的每个节点只知道它前一个,后一个节点是谁,想定位某个节点,只能从头到尾遍历,或从尾到头遍历,速度较慢
4. 查找比较
二者都是线性表,查找速度几乎相同
5. 结论
数组适用于:预先大概知道要存储多少元素,主要用于遍历,内存充足
链表适用于:预先不知道要存储多少元素,增加、删除操作频率较高,内存碎片化程度高
四. 实现一个链表
1. 声明节点类
因为节点只给链表服务,所以此处将节点类作为内部类
按照上图所示,我们需要三个属性,存储真实数据的data,两个节点类型的引用
public class LinkTable<E> {
private static class Node<E> {
E data;
Node<E> previous;
Node<E> next;
Node(E data){
this.data = data;
}
}
}
2. 搭建链表结构
观察上图,链表两端的节点,一个previous没有指向,一个next没有指向,这两个特殊的节点,需要特殊处理
不仅如此,这两个特殊的节点也是我们遍历链表的入口节点,二者可以形象的称为头节点、尾结点
因为链表必须总是得有头有尾,并未头尾节点不随中间节点变化而变化,所以二者的地位相对固定,即可以作为属性,保存在外部类中
public class LinkTable<E> {
private static class Node<E> {
E data;
Node<E> previous;
Node<E> next;
Node(E data){
this.data = data;
}
}
private Node<E> head;
private Node<E> tail;
}
3. 增加一个节点
对于双向链表,它的新增操作,就是在其头部或尾部,挂载一个新节点
为了达到这一目的,头或尾节点的指向需要重新调整
① 头、尾节点均为空
也就是说,当前是个空链表,此时新增的节点既是头节点,也是尾结点
② 链表只有一个节点
这种情况就是上图那样,此时如果新增节点 newNode
添加到尾部
newNode
先去关联head
:newNode.previous = head
head
再去关联newNode
:head.next = newNode
- 最后将
newNode
更新为新的tail
③ 头尾兼备
初始状态
最终状态
public void addLast(E e){
if(e == null){
throw new NullPointerException("传入的值不能为 null");
}
Node<E> newNode = new Node<>(e);
if(head == null){
head = newNode;
tail = newNode;
}else if(head == tail){
newNode.previous = head;
head.next = newNode;
tail = newNode;
}else{
newNode.previous = tail;
tail.next = newNode;
tail = newNode;
}
}
如果结合代码稍加分析②、③的情况,就会发现,其实他俩可以合并为一种操作
因为②的判定条件是 head == tail
,也就意味着,将head替换为tail,逻辑也是成立的,所以优化后的代码长这样
public void addLast(E e){
if(e == null){
throw new NullPointerException("传入的值不能为 null");
}
Node<E> newNode = new Node<>(e);
if(head == null){
head = newNode;
}else{
newNode.previous = tail;
tail.next = newNode;
}
tail = newNode;
}
4. 插入一个节点
插入节点的操作相较于增加节点复杂一些,因为他首先需要找到目标位置,然后更改原有节点的指向
插入分为两种情况,分别是插入到目标位置之前,和插入到目标位置之后,两种插入的逻辑相似,这里以插入到目标位置之前为例
目标位置又可分为三种情况
- 目标位置是头节点,插入到头节点之前,就是把头节点替换为新节点
- 目标位置非头节点,这时需要改动目标位置和它前一个节点的指向
- 目标位置不存在
不管哪种情况,都少不了链表的遍历
不管怎么插入,都是在那几根连线上做变动
/**
* @param newE 新节点
* @param aimE 目标值(以第一次出现为准)
* @return 插入成功返回 true
*/
public boolean insertBefore(E newE, E aimE){
//遍历链表基操
for(Node<E> current = head; current != null; current = current.next){
//以第一次出现目标为准
if(current.data == aimE || current.data.equals(aimE)){
Node<E> newNode = new Node<>(newE);
if(current == head){
newNode.next = head;
head.previous = newNode;
head = newNode;
}else{
//先把 newNode 挂上
newNode.next = current;
newNode.previous = current.previous;
//current前一个节点的next不再指向current,改为指向newNode
current.previous.next = newNode;
//current的previous改为指向newNode
current.previous = newNode;
}
return true;
}
}
return false;
5. 查找节点
查找节点简直so easy,就是链表的遍历,无fuck说
public boolean contains(E e) {
Node<E> current = head;
while (current != null) {
if(current.data == e || current.data.equals(e)){
return true;
}
current = current.next;
}
return false;
}
6. 删除节点
删除节点和插入节点类似,分为四种情况:删头、删尾、删中间、不存在
具体操作还是摆弄那几根线
public boolean remove(E e) {
for(Node<E> current = head; current != null; current = current.next) {
if(current.data == e || current.data.equals(e)) {
//删头
if(current == head){
//原头节点后面有节点
if(head.next != null){
//时刻保证头节点的 previous 为 null
head.next.previous = null;
head = head.next;
}
//否则证明当前链表只有一个待删除的节点,删完后为空链表
else {
head = tail = null;
}
}
//删尾
else if(current == tail){
if(tail.previous != null){
//时刻保证尾节点的 next 为 null
tail.previous.next = null;
tail = tail.previous;
}else {
head = tail = null;
}
}
//删中间
else{
current.next.previous = current.previous;
current.previous.next = current.next;
}
return true;
}
}
return false;
}