一、概念
链表是一个有序的列表,如下图所示
链表中的每个元素实际上是一个单独的对象,而所有对象都通过每个元素中的引用字段链接在一起。链表有两种类型:单链表和双链表。上面给出的例子是一个单链表,这里有一个双链表的例子:
二、单链表实战应用
1. 使用带 Head 的头节点实现单链表
场景:学校里的一个班级学生,他们有自己的学号和姓名,用链表来模拟对学生的增删改查。 定义链表节点:
public class StudentNode implements Serializable {
// 学号
public Long studentNo;
// 学生姓名
public String name;
// 指向下一个节点
public StudentNode next;
public StudentNode(Long studentNo, String name) {
this.studentNo = studentNo;
this.name = name;
}
@Override
public String toString() {
return "{" + "studentNo=" + studentNo + ", name='" + name + ''' + '}';
}
}
1.1 直接在链表尾部添加学生
思路:
- 先创建一个头节点,头节点不存放数据,只用来表示链表的头
- 通过遍历的方式不断地向链表尾部添加新节点
public class LinkedList implements Serializable {
// 初始化一个头节点
private StudentNode head = new StudentNode(0L, "");
/**
* 向链表尾部添加新节点
* @param newNode
*/
public void add(StudentNode newNode) {
// 因为链表头节点head不能被改变,所用用临时变量 temp 来辅助遍历链表
StudentNode temp = head;
while (true) {
// 到达链表尾部,结束遍历
if (temp.next == null) {
break;
}
temp = temp.next;
}
// 当跳出while循环后,temp指向了链表的尾部;将尾部节点的next指向新节点即可
temp.next = newNode;
}
}
1.2 根据学号在链表指定位置添加学生
思路:
- 通过遍历的方式找到新添加节点的位置,即 temp 指向被添加节点所在位置的前一个位置
- 新节点.next = temp.next
- 再将 temp.next = 新节点
public class LinkedList implements Serializable {
// 初始化一个头节点
private StudentNode head = new StudentNode(0L, "");
/**
* 按学号添加学生
* @param newNode
*/
public void addByNo(StudentNode newNode) {
boolean flag = false; // 标记添加的编号是否已在链表中存在
StudentNode temp = head;
while (true) {
// 说明当前链表是一个空链表
if (temp.next == null) {
break;
}
if (temp.next.studentNo > newNode.studentNo) {
// 说明找到了要插入的节点的位置
break;
} else if (temp.next.studentNo == newNode.studentNo) {
flag = true;
break;
}
temp = temp.next;
}
if (flag) {
System.out.printf("编号是 %d 学生已存在\n", newNode.studentNo);
} else {
// 新节点插入到 temp 后面
newNode.next = temp.next;
temp.next = newNode;
}
}
}
1.3 根据学号删除学生节点
思路:
- 找到要删除的节点的前一个节点 temp
- temp.next = temp.next.next
- 被删除的节点,将不会有其他的引用指向,会被自动回收
public class LinkedList implements Serializable {
// 初始化一个头节点
private StudentNode head = new StudentNode(0L, "");
/**
* 删除链表节点
* @param newNode
*/
public void deleteByNo(StudentNode newNode) {
boolean flag = false; // 标记被删除的节点是否存在在链表中
StudentNode temp = head;
while (true) {
if (temp.next == null) {
break;
}
// 说明找到了要删除的节点的位置
if (temp.next.studentNo == newNode.studentNo) {
flag = true;
break;
}
temp = temp.next;
}
if (flag) {
temp.next = temp.next.next;
} else {
System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
}
}
}
1.4 根据学号修改学生节点
思路:
- 遍历链表找要修改的节点。注意:这里遍历的开始节点不是头节点,因为头节点的数据域是空的,所以应该从头节点的下一个节点开始遍历比较
- 找到节点后进行数据域修改
public class LinkedList implements Serializable {
// 初始化一个头节点
private StudentNode head = new StudentNode(0L, "");
/**
* 根据学号修改
*/
public void updateByNo(StudentNode newNode) {
if (head.next == null) {
System.out.println("链表为空!");
return;
}
boolean flag = false;
// 修改的时候要注意,遍历的其实节点不是头节点,因为头节点是没有数据域的
StudentNode temp = head.next;
while (true) {
if (temp == null) {
break;
}
// 说明找到了要删除的节点的位置
if (temp.studentNo == newNode.studentNo) {
flag = true;
break;
}
temp = temp.next;
}
if (flag) {
temp.name = newNode.name;
} else {
System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
}
}
}
三、双向链表实战应用
- 单向链表的只能沿着一个方向查找;但双向链表可以向前查找也可以向后查找。
- 单链表删除节点时需要依赖辅助节点;而双向链表可以自我删除,不需要依赖辅助节点。
public class DoubleNode implements Serializable {
// 学号
public Long studentNo;
// 学生姓名
public String name;
// 指向下一个节点
public DoubleNode next;
// 指向前一个节点
public DoubleNode pre;
public DoubleNode(Long studentNo, String name) {
this.studentNo = studentNo;
this.name = name;
}
}
3.1 在链表尾部添加
思路:
- 找到双向链表的最后的节点
- 添加节点的核心代码
temp.next = newNode;
newNode.pre = temp;
public class DoubleLinkedList implements Serializable {
// 初始化一个头节点
private DoubleNode head = new DoubleNode(0L, "");
/**
* 向链表尾部添加新节点
*/
public void add(DoubleNode newNode) {
// 因为链表头节点head不能被改变,所用用临时变量 temp 来辅助遍历链表
DoubleNode temp = head;
while (true) {
// 到达链表尾部,结束遍历
if (temp.next == null) {
break;
}
temp = temp.next;
}
temp.next = newNode;
newNode.pre = temp;
}
}
3.2 删除链表节点
思路:
- 双向链表可以自我删除,直接找到要删除的节点 temp
- 删除节点的核心代码:
//temp.next = temp.next.next; 单链表的删除节点
if (temp.next != null) { // 如果temp是链表的最后一个节点,就不需要执行删除
temp.pre.next = temp.next;
temp.next.pre = temp.pre;
}
public void deleteByNo(DoubleNode newNode) {
boolean flag = false;
DoubleNode temp = head.next;
if (temp == null) {
System.out.println("空链表无法操作!");
return;
}
while (true) {
if (temp == null) {
break;
}
if (temp.studentNo == newNode.studentNo) {
flag = true;
break;
}
temp = temp.next;
}
if (flag) {
//temp.next = temp.next.next; 单链表的删除节点
if (temp.next != null) { // 如果temp是链表的最后一个节点,就不需要执行删除
temp.pre.next = temp.next;
temp.next.pre = temp.pre;
}
} else {
System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
}
}
3.2 修改链表节点
- 双向链表的修改节点的逻辑和单链表的修改逻辑一致
public void updateByNo(DoubleNode newNode) {
if (head.next == null) {
System.out.println("链表为空!");
return;
}
boolean flag = false;
// 修改的时候要注意,遍历的其实节点不是头节点,因为头节点是没有数据域的
DoubleNode temp = head.next;
while (true) {
if (temp == null) {
break;
}
// 说明找到了要删除的节点的位置
if (temp.studentNo == newNode.studentNo) {
flag = true;
break;
}
temp = temp.next;
}
if (flag) {
temp.name = newNode.name;
} else {
System.out.printf("编号是 %d 的学生不存在\n", newNode.studentNo);
}
}
四、链表面试题举例
1. 查找链表倒数第K个节点
-
注意:本题中我们的链表是不带头节点!!!在带有头节点的链表中,链表的长度是不包括头节点的。
-
思路:
(1) 遍历后得到链表元素的个数 size
(2) 然后再次顺序遍历到链表的第 size - k 个节点即为倒数第 k 个节点
public ListNode getKthFromEnd(ListNode firstNode, int k) {
if(firstNode == null) {
return null;
}
int size = 0;
ListNode temp = firstNode;
while(true) {
if(null == temp) {
break;
}
size ++;
temp = temp.next;
}
if(k < 0 || k > size) {
return null;
}
ListNode cur = firstNode;
for(int i=0;i<size - k;i++) {
cur=cur.next;
}
return cur;
}
2. 删除链表倒数第K个节点
这个题目其实是第一个题目的变种,唯一要注意的是如果要删除的倒数第K个节点刚好是链表的第一个节点的情况!
- 单链表删除节点的核心代码
temp.next = temp.next.next;
- 本题的代码实现
public ListNode removeNthFromEnd(ListNode head, int k) {
if(head == null) {
return null;
}
int size = 0;
ListNode temp = head;
while(true) {
if(null == temp) {
break;
}
size ++;
temp = temp.next;
}
if(k < 0 || k > size) {
return null;
}
// 表示要删除的是链表的第一个节点
if(size==k) {
return head.next;
}
// cur表示被删除的倒数第K个节点的前一个节点
ListNode cur = head;
for(int i=0;i<size - k - 1;i++) {
cur=cur.next;
}
cur.next = cur.next.next;
return head;
}
3. 单链表反转
其实这道题有两种解题思路:
3.1 利用栈先进先后出的特点来实现反转
其实这个思路也可用来解决 从尾到头打印链表的值 的问题
public ListNode reverseList(ListNode head) {
if (null == head) {
return head;
}
Stack<ListNode> stack = new Stack();
while (null != head) {
stack.push(head);
head = head.next;
}
if (stack.isEmpty()) {
return null;
}
ListNode head1 = stack.pop();
ListNode newH = head1;
while (!stack.isEmpty()) {
head1.next = stack.pop();
head1 = head1.next;
}
head1.next = null;
return newH;
}
3.2 直接遍历链表的同时进行反转
思路:双指针法
- prev 指向头节点的前一个位置,curr 指向头节点
- 反转流程如下图所示
public ListNode reverseList(ListNode head) {
if(null == head) {
return head;
}
ListNode prev = null;
ListNode curr = head;
while(null != curr) {
// 保存当前节点的下一个节点
ListNode temp = curr.next;
// 改变当前节点的next指向
curr.next = prev;
// 双指针同时往后移动
prev = curr;
curr = temp;
}
return prev;
}
五、单向循环链表的应用场景
单向循环链表其实就是一个首尾相连的单链表,如下图所示:
5.1 约瑟夫环问题
设编号为 1,2,... n 的 n 个人围坐一圈,约定编号为 k(1<= k <=n) 的人从 1 开始报数,数到 m 的那个人出列,他的下一位又从 1 开始报数,数到 m 的那个人又出列,依次类推,直到所有的人出列为止。那么这些人出列的编号顺序是多少?
5.2 利用单向循环链表解决约瑟夫环问题
- 举例说明出列的编号情况如下所示
如何创建环形链表?
public class JosephusNode implements Serializable {
public int id; // 编号
public JosephusNode next; // 指向下一个节点
public JosephusNode(int id) {
this.id = id;
}
}
环形链表创建示意图:
public class CircularLinkedList implements Serializable {
/**
* 表示环形链表的第一个节点
*/
private JosephusNode first = null;
/**
* 创建环形链表
* @param nodeCount 表示要创建的环形链表的节点个数
*/
public void addNode(int nodeCount) {
if (nodeCount < 1) {
System.out.println("环形链表节点数不能小于1");
return;
}
JosephusNode current = null;
for (int i = 1; i <= nodeCount; i++) {
JosephusNode temp = new JosephusNode(i);
// 表示此时创建的是环形链表的第一个节点
if (i == 1) {
first = temp;
first.next = first;
current = first;
} else {
current.next = temp;
temp.next = first;
current = temp;
}
}
}
}
遍历环形链表
public class CircularLinkedList implements Serializable {
/**
* 表示环形链表的第一个节点
*/
private JosephusNode first = null;
public void print() {
if (null == first) {
System.out.println("环形链表为空!");
return;
}
// 因为 first 节点不能改变,我们需要辅助变量来进行遍历
JosephusNode current = first;
while (true) {
System.out.printf("当前节点编号:%d\n", current.id);
if (current.next == first) {
break;
}
current = current.next;
}
}
}
实现约瑟夫环问题
public class CircularLinkedList implements Serializable {
/**
* 表示环形链表的第一个节点
*/
private JosephusNode first = null;
/**
* 打印约瑟夫问题的出圈的编号顺序
* @param k 表示从第几个节点开始报数
* @param m 表示报数报到是m的节点出圈
* @param n 表示环形链表总共有多少个节点
*/
public void printJosephusNo(int k, int m, int n) {
if (n < 1 || m > n || k > n) {
System.out.println("参数不合法!");
return;
}
if (null == first) {
System.out.println("环形链表为空!");
return;
}
// 1.先让 prev 指向环形链表的最后一个节点
JosephusNode prev = first;
while (true) {
if (prev.next == first) {
// 此时说明prev指向的节点就是最后一个节点
break;
}
prev = prev.next;
}
// 2.当报数开始前,让 prev 和 first 同时移动 k-1 次。目的是同时让 first 指向开始报数时的第一个节点
for (int i = 0; i < k - 1; i++) {
first = first.next;
prev = prev.next;
}
// 3. 找到出圈的那个节点并删除;即让 prev 和 first 同时移动 m-1 次
while (true) {
if (prev == first) {
// 说明此时圈中只有一个节点
break;
}
for (int i = 0; i < m - 1; i++) {
first = first.next;
prev = prev.next;
}
// 3.1 删除此时 first 指向的节点,该节点就是要出圈的节点
System.out.printf("节点 %d 出圈\n", first.id);
first = first.next;
prev.next = first;
}
System.out.printf("节点 %d 出圈\n", prev.id);
}
}