链表是一种最基本的结构,普通的单链表就是只给你一个指向链表头的指针head,如果想访问其他元素,就只能从head开始一个个向后找,遍历链表最终会在访问尾结点之后如果继续访问,就会返回null。
在工程应用,极少见到普通单链表,比较多的是带头结点的单链表和双向循环链表。 有时候会将多个链表组合从而实现更丰富的功能,这种操作在很多底层软件里大量使用,例如操作系统、虚拟机等。
1.单向链表
1.1 链表的内部结构
首先看一下什么是链表?使用链表存储数据,不强制要求数据在内存中集中存储,各个元素可以分散存储在内存中。例如,使用链表存储 {4,,15,,7,,40},各个元素在内存中的存储状态可能是:如下图:
显然,我们只需要记住元素 4 的存储位置,通过它的指针就可以找到元素 15,通过元素 15 的指针就可以找到元素 7,以此类推,各个元素的先后次序一目了然。
可以看到,数据不仅没有集中存放,在内存中的存储次序也是混乱的。那么,链表是如何存储数据间逻辑关系的呢?链表存储数据间逻辑关系的实现方案是:为每一个元素配置一个指针,每个元素的指针都指向自己的直接后继元素,也就是上图图所示的样子。
像上图这样,数据元素随机存储在内存中,通过指针维系数据之间“一对一”的逻辑关系,这样的存储结构就是链表。
我们来看如何构造链表,
链表的基本单位是结点,有些地方也叫节点,都是一个意思,在我们的讲义里也是混着用的。
在链表中,每个结点数据元素都配有一个指针,这意味着,链表上的每个“元素”都长下图这个样子:
数据域用来存储元素的值,指针域用来存放指针。数据结构中,通常将上图这样的整体称为结点。
也就是说,链表中实际存放的是一个一个的结点,数据元素存放在各个结点的数据域中。举个简单的例子,图 2 中 {1,2,3} 的存储状态用链表表示,如下图所示:
public class BasicLink {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Node head = initLinkedList(arr);
System.out.println(head.next.data); //2
}
/**
* 初始化一个链表
*
* @param arr
* @return 头结点
*/
private static Node initLinkedList(int[] arr) {
Node head = null, cur = null;
for (int i = 0; i < arr.length; i++) {
Node newNode = new Node(arr[i]);
newNode.next = null;
if (i == 0) {
head = newNode;
} else {
cur.next = newNode;
}
cur = newNode;
}
return head;
}
/**
* 定义一个结点
*/
static class Node {
int data;
Node next;
public Node(int data) {
this.data = data;
next = null;
}
}
}
1.2 遍历链表
对于单链表,不管进行什么操作,一定是从头开始逐个向后访问,所以操作之后是否还能找到表头非常重要。一定要注意"狗熊掰棒子"问题,也就是只顾当前位置而将标记表头的指针丢掉了。
/**
* 打印链表
* @param head
*/
private static void printLink(Node head) {
Node temp = head; //temp指针用来遍历链表
while (temp != null) {
System.out.print(temp.data + " ");
temp = temp.next;
}
System.out.println();
}
/**
* 获取链表长度
* @param head
* @return length
*/
private static int getLength(Node head) {
int length = 0;
while (head != null) {
length++;
head = head.next;
}
return length;
}
测试结果:
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
Node head = initLinkedList(arr);
printLink(head); //1 2 3 4 5 6 7 8 9 10
}
1.3 链表的插入
单链表的插入,和数组的插入一样,过程不复杂,但是在编码时会发现处处是坑。单链表的插入操作需要要考虑三种情况:首部、中部和尾部。
(1) 在链表的表头插入
链表表头插入新结点非常简单,容易出错的是经常会忘了head需要重新指向表头。 我们创建一个新结点newNode,怎么连接到原来的链表上呢?执行newNode.next=head即可。之后我们要遍历新链表就要从newNode开始一路next向下了是吧,但是我们还是习惯让head来表示,所以让head=newNode就行了,如下图:
(2)在链表中间插入
在中间位置插入,我们必须先遍历找到要插入的位置,然后将当前位置接入到前驱结点和后继结点之间,但是到了该位置之后我们却不能获得前驱结点了,也就无法将结点接入进来了。这就好比一边过河一边拆桥,结果自己也回不去了。
为此,我们要在目标结点的前一个位置停下来,也就是使用cur.next的值而不是cur的值来判断,这是链表最常用的策略。
例如下图中,如果要在7的前面插入,当cur.next=node(7)了就应该停下来,此时cur.val=15。然后需要给newNode前后接两根线,此时只能先让new.next=node(15).next(图中虚线),然后node(15).next=new,而且顺序还不能错。
想一下为什么不能颠倒顺序?
由于每个节点都只有一个next,因此执行了node(15).next=new之后,结点15和7之间的连线就自动断开了,如下图所示:
(3)在单链表的结尾插入结点
表尾插入就比较容易了,我们只要将尾结点指向新结点就行了。
🖥️代码实现
public class BasicLinkedList {
public static void main(String[] args) {
BasicLinkedList basicLinkedList = new BasicLinkedList();
basicLinkedList.insert(1, 0);
basicLinkedList.insert(2, 1);
basicLinkedList.insert(3, 1);
basicLinkedList.insert(4, 2);
printLink(basicLinkedList.head);
}
// 头指针
private Node head;
// 尾指针
private Node tail;
// 链表长度
private int size;
/**
* 链表的插入
*
* @param data 插入的元素
* @param index 插入的位置
*/
public void insert(int data, int index) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("index is illegal");
}
Node newNode = new Node(data);
if (size == 0) {
// 插入头部
head = newNode;
tail = newNode;
} else if (size == index) {
// 插入尾部
tail.next = newNode;
tail = newNode;
} else {
// 插入中间
Node cur = head;
for (int i = 0; i < size; i++) {
if (i == index-1) {
newNode.next= cur.next;
cur.next = newNode;
break;
} else {
cur = cur.next;
}
}
}
size++;
}
/**
* 链表结点
*/
private static class Node {
int data;
Node next;
public Node(int data) {
this.data = data;
next = null;
}
}
/**
* 打印链表
* @param head
*/
private static void printLink(Node head) {
Node temp = head; //temp指针用来遍历链表
while (temp != null) {
System.out.print(temp.data + " ");
temp = temp.next;
}
System.out.println();
}
}
1.4 链表的删除
删除同样分为在删除头部元素,删除中间元素和删除尾部元素。
(1)删除表头结点
删除表头元素还是比较简单的,一般只要执行head=head.next就行了。如下图,将head向前移动一次之后,原来的结点不可达,会被JVM回收掉。
(2)删除最后一个结点
删除的过程不算复杂,也是找到要删除的结点的前驱结点,这里同样要在提前一个位置判断,例如下图中删除40,其前驱结点为7。遍历的时候需要判断cur.next是否为40,如果是,则只要执行cur.next=null即可,此时结点40变得不可达,最终会被JVM回收掉。
(3)删除中间结点
删除中间结点时,也会要用cur.next来比较,找到位置后,将cur.next指针的值更新为cur.next.next就可以解决,如下图所示:
🖥️代码实现
/**
* 链表的删除
*
* @param index 删除的位置
* @return 删除的节点
*/
public Node remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("index is illegal");
}
Node removeNode = null;
if (index == 0) {
// 删除头结点
removeNode = head;
head = head.next;
} else if (index == size - 1) {
// 删除尾结点
Node cur = head;
for (int i = 0; i < size; i++) {
if (i == size - 2) {
removeNode = cur.next;
cur.next = null;
tail = cur;
break;
} else {
cur = cur.next;
}
}
} else {
// 删除中间结点
Node cur = head;
for (int i = 0; i < size; i++) {
if (i == index - 1) {
removeNode = cur.next;
cur.next = cur.next.next;
break;
} else {
cur = cur.next;
}
}
}
size--;
return removeNode;
}
2. 双向链表设计
2.1 基本概念
双向链表顾名思义就是既可以向前,也可以向后。有两个指针的好处自然是移动元素更方便。
/**
* 双向链表的结点
*/
class DoubleNode {
int data;
DoubleNode next;
DoubleNode prev;
public DoubleNode(int data) {
this.data = data;
}
}
双向链表的定义及遍历:
public class DoubleLinkedList {
private DoubleNode head;
private DoubleNode tail;
public DoubleLinkedList() {
head = null;
tail = null;
}
// 从头打印
public void printForward() {
System.out.println("List(head--->tail):");
DoubleNode cur = head;
while (cur != null) {
System.out.print(cur.data + " ");
cur = cur.next;
}
System.out.println();
}
// 从尾打印
public void printBackward() {
System.out.println("List(tail--->head):");
DoubleNode cur = tail;
while (cur != null) {
System.out.print(cur.data + " ");
cur = cur.pre;
}
System.out.println();
}
}
2.2插入元素
(1)头尾插入
//头部插入
public void insertFirst(int data) {
DoubleNode newNode = new DoubleNode(data);
if (head == null) {
head = newNode;
tail = newNode;
} else {
newNode.next = head;
head.pre = newNode;
head = newNode;
}
}
//尾部插入
public void insertLast(int data) {
DoubleNode newNode = new DoubleNode(data);
if (head == null) {
head = newNode;
tail = newNode;
} else {
newNode.pre = tail;
tail.next = newNode;
tail = newNode;
}
}
(2)从某个元素后面插入
/**
* 某个结点后面插入
* @param data 插入的值
* @param key 被插入的值
*/
public void insertAfter(int data, int key) {
DoubleNode newNode = new DoubleNode(data);
DoubleNode cur = head;
while ((cur != null)&&(cur.data != key)) {
cur = cur.next;
}
//若当前结点cur为空
if (cur == null) {
if (head == null) {//1.链表为空
head = newNode;
tail = newNode;
} else {//2.找不到key值
tail.next = newNode;
newNode.pre = tail;
tail = newNode;
}
}else {
if (cur == tail){//3.找到key值,分两种情况
//key值与最后结点的data相等
newNode.next = null;
tail = newNode;
}else {
//两结点中间插入
newNode.next = cur.next;
cur.next.pre = newNode;
}
cur.next = newNode;
newNode.pre = cur;
}
}
2.3删除元素
双向链表的不足就是增删改的时候,需要修改的指针多了,操作更麻烦了。由于双向链表在算法中不是很重要,我们先看一下删除的大致过程。首尾元素的删除还比较简单,直接上代码:
(1)删除头尾
// 删除头结点
public DoubleNode deleteFirst() {
DoubleNode removedNode = head;
// 若链表只有一个结点,删除后链表为空,将tail指向null
if (head.next == null) {
tail = null;
} else {
// 若链表有两个以上的结点,头结点删除,则head.next将变成第一个结点
head.next.pre = null;
}
head = head.next;
return removedNode;
}
//删除尾结点
public DoubleNode deleteLast() {
DoubleNode removedNode = tail;
// 若链表只有一个结点,删除后链表为空,将tail指向null
if (head.next == null) {
head = null;
} else {
// 若链表有两个以上的结点,将上一个结点的next域指向null
tail.pre.next = null;
}
// 前一个结点变成尾结点
tail = tail.pre;
return removedNode;
}
(2)删除某个位置结点
我们再看删除中间元素的情况,要标记出几个关键结点的位置,也就是图中的cur,cur.next和cur.prev结点。由于在双向链表中可以走回头路,所以我们使用cur,cur.next和cur.prev任意一个位置都能实现删除。假如我们就删除cur,图示是这样的:
我们只需要调整两个指针,一个是cur.next的prev指向cur.prev,第二个是cur.prev的next指向cur.next。此时cur结点没有结点访问了,根据垃圾回收算法,此时cur就变得不可达,最终被回收掉,所以这样就完成了删除cur的操作。想一下,这里调整两条线的代码是否可以换顺序?
可以,并没有影响当前结点的指针
/**
* 删除某个位置的结点
*
* @param key 要删除的值
* @return 被删除的结点
*/
public DoubleNode deleteKey(int key) {
DoubleNode cur = head;
//遍历链表寻找该值所在的结点
while ((cur != null) && (cur.data != key)) {
cur = cur.next;
}
//若当前结点指向null则返回null
if (cur == null) {
return null;
} else {
if (cur == head) {
deleteFirst();
} else if (cur == tail) {
deleteLast();
} else {
cur.pre.next = cur.next;
cur.next.pre = cur.pre;
}
return cur;
}
}