第二章-链表 part01
今日任务
- 链表理论基础 203.移除链表元素 707.设计链表 206.反转链表
链表理论基础
了解一下链接基础,以及链表和数组的区别
-
链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针)
- 最后一个节点的指针域指向 null(空指针的意思)
- 链表的入口节点称为链表的头结点也就是 head
单链表
双链表
-
单链表中的指针域只能指向节点的下一个节点
- 双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点
- 双链表 既可以向前查询也可以向后查询
循环链表
-
链表首尾相连
- 循环链表可以用来解决约瑟夫环问题
链表的存储方式
- 数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的
- 这个链表起始节点为 2, 终止节点为 7, 各个节点分布在内存的不同地址空间上,通过指针串联在一起
操作链表
定义链表
public class ListNode {
// 结点的值
int val;
// 下一个结点
ListNode next;
// 节点的构造函数(无参)
public ListNode() {
}
// 节点的构造函数(有一个参数)
public ListNode(int val) {
this.val = val;
}
// 节点的构造函数(有两个参数)
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
删除节点
- 删除 D 节点,如图所示:
-
只要将 C 节点的 next 指针 指向 E 节点就可以了
-
D 节点不是依然存留在内存里 -> 只不过是没有在这个链表里而已
- 所以在 C++ 里最好是再手动释放这个 D 节点,释放这块内存
- Java 就有自己的内存回收机制,就不用自己手动释放了。
添加节点
- 如图所示:
- 可以看出链表的增添和删除都是 O(1)操作,也不会影响到其他节点。
- 但是要注意,要是删除第五个节点,需要从头节点查找到第四个节点通过 next 指针进行删除操作,查找的时间复杂度是 O(n)。
性能分析
- 对比链表和数组
- 数组在定义的时候,长度就是固定的,如果想改动数组的长度,就需要重新定义一个新的数组 -> 查询快
- 链表的长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景 -> 增删快
203.移除链表元素
建议: 本题最关键是要理解 虚拟头结点的使用技巧,这个对链表题目很重要
题目链接:203. 移除链表元素 - 力扣(LeetCode)
文章讲解/视频讲解:代码随想录 (programmercarl.com)
方法一 - 普通链表删除
- 头节点: head 移动到下一个的 head (head next)
- 中间节点: head 给前一个的 next next 给后一个的 head
链表定义
/**
* 定义单链表
* Definition for singly-linked list.
*/
class ListNode {
// 结点的值
int val;
// 下一个结点
ListNode next;
// 节点的构造函数(无参)
public ListNode() {
}
// 节点的构造函数(有一个参数)
public ListNode(int val) {
this.val = val;
}
// 节点的构造函数(有两个参数)
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
具体实现
Code:
class Solution {
/**
* 方法一 - 普通链表删除
*
* @param head
* @param val
* @return
*/
public ListNode removeElements(ListNode head, int val) {
// 1. 头节点: head 移动到下一个的 head (head.next)
while (head != null && head.val == val) { // head 不能为 null 否则 NPE
head = head.next;
}
// 2. 中间节点: head 给前一个的 next next 给后一个的 head
// currentNode 临时指针为当前节点 -> head
ListNode currentNode = head;// 从头结点开始 因为经过上面的操作 头节点一定是空或者不是要删除的点 这样设置可以保证我们从第二个节点开始遍历
while (currentNode != null) { // 判断 当前节点是不是空
while (currentNode.next != null && currentNode.next.val == val) { // 若当前节点下一个节点不是空 且下一个节点满足目标值
currentNode.next = currentNode.next.next; // 将当前节点下个一个节点 -> 下下一个节点
}
currentNode = currentNode.next; // 向后移动当前指针
}
return head; // 返回头结点
}
}
- 时间复杂度: O(n)
- 空间复杂度: O(1)
对上面代码剖析:
-
由于头结点和其他节点判断是否删除的操作不相同
- 头结点的值若是目标值 -> 则将当前节点头结点指向下一个节点 head = head.next;
- 其他节点的值若是目标值 -> 则将其节点上一个节点 next 指向 其下一个节点 currentNode.next = currentNode.next.next
-
头结点处理:while (head != null && head.val == val)
-
判断头结点是不是 null 若是 则不进入循环 head != null
-
判断头结点的值是不是 目标值 head.val == val
-
需要用 while 遍历直到头结点一定是 null 或 不是目标值
- 若用 if 这种情况 [1,1,1,1] 当第一次将头结点 head 指向 下一个节点 则新的头节点将无法进行判断
-
-
中间节点处理:
-
ListNode currentNode = head 定义一个临时指针 currentNode 将 head 赋值给 currentNode
-
由于 Java 特性 currentNode 和 head 指向的是同一块内存区域 -> 即两者地址相同
-
这样在后进行操作时
- 既保证了 处理当前节点 currentNode 同时就是在处理 head 这个链表
- 又能保证我们返回时 head 头结点保持不变
-
Debug:[1,2,2,1] 2 -> [1,1] 观察一下更清晰
-
-
while (currentNode != null) 判断 当前节点是不是空
-
配合 currentNode = currentNode.next 向后移动当前指针
-
实现了 遍历中间节点的每一个节点 并且当 currentNode 是最后一个节点时 currentNode.next 一定是 null 即 currentNode 为 null
这样配合 循环里的判断语句 恰好实现了退出循环
-
-
while (currentNode.next != null && currentNode.next.val == val) -> 若当前节点下一个节点不是空 且下一个节点满足目标值
- 用 while 循环思路 与头结点处理使用 while 原理基本相同
-
方法二 - 使用虚拟头结点删除
-
问题:方法一中 逻辑判断是两种方式来完成的 -> 增加了代码量和复杂程度 不够优雅 -> 用虚拟头结点
-
设置一个虚拟头结点 - 这样原链表的所有节点就都可以按照统一的方式进行移除了
- 此时要移除这个旧头结点元素 1 -> 统一操作流程了~
-
return 头结点的时候 需要返回 return dummyNode->next; -> 这才是新的头结点
- head 已经还是指向原来的那个地址 -> 但我们知道新的头结点可能已经不是它了
Code:
class Solution {
/**
* 方法二 - 虚拟头结点删除
*
* @param head
* @param val
* @return
*/
public ListNode removeElements(ListNode head, int val) {
// 定义虚拟头节点
ListNode dummyNode = new ListNode(Integer.MAX_VALUE, head); // val:任意 next:指向 head
// 临时指针
ListNode currentNode = dummyNode;
while (currentNode != null) { // 判断 当前节点是不是空
while (currentNode.next != null && currentNode.next.val == val) { // 若当前节点下一个节点不是空 且下一个节点满足目标值
currentNode.next = currentNode.next.next; // 将当前节点下个一个节点 -> 下下一个节点
}
currentNode = currentNode.next; // 向后移动当前指针
}
return dummyNode.next; // 返回虚拟节点所指向节点才是真正的头节点
}
}
方法三 - 递归方式链表删除
- 据说还有递归的方法 - 以后再补充~挖个坑
707.设计链表
建议: 这是一道考察 链表综合操作的题目,不算容易,可以练一练 使用虚拟头结点
文章讲解/视频讲解:programmercarl.com/0707.%E8%AE…
在链表类中实现这些功能:
-
get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
-
addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
-
addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
-
addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。
- 如果 index 等于链表的长度,则该节点将附加到链表的末尾。
- 如果 index 大于链表长度,则不会插入节点。如果 index 小于 0,则在头部插入节点。
-
deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
方法一 - 虚拟头结点的方式实现单链表
-
使用虚拟头结点可以使处理更统一
-
注意链表长度 size 的的增减
-
画图理解细节部分
- 详细解析见代码部分
class ListNode {
int val; // 结点的值
ListNode next;// 下一个结点
// 节点的构造函数(无参)
public ListNode() {}
// 节点的构造函数(有一个参数)
public ListNode(int val) {this.val = val;}
// 节点的构造函数(有两个参数)
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
class MyLinkedList {
int size;//定义链表长度
ListNode dummyHead;//定义虚拟头节点
// 无参构造器
public MyLinkedList() {
// 初始化
size = 0;
dummyHead = new ListNode(0);
}
/**
* 获取索引处值
*
* @param index
* @return
*/
public int get(int index) {
if (index < 0 || index > size - 1) {
return -1;
}
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
// 遍历到最后一个结点
while (index-- != 0) {
currentNode = currentNode.next;
}
return currentNode.next.val; // 注意:由于虚拟节点的存在 这里要返回当前节点的下一个节点 才是我们要找的节点
}
/**
* 插入头结点
*
* @param val 新节点的值
*/
public void addAtHead(int val) {
ListNode newNode = new ListNode(val); // 新节点
// 注意顺序:1. 新节点 -> 虚拟节点的下一个节点 2. 虚拟节点 -> 指向新节点
newNode.next = dummyHead.next; // 新节点指向 虚拟节点的下一个节点
dummyHead.next = newNode; // 虚拟节点指向 新节点
size++;
}
/**
* 插入尾节点
*
* @param val 新节点的值
*/
public void addAtTail(int val) {
ListNode newNode = new ListNode(val); // 新节点
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
// 遍历到最后一个结点
while (currentNode.next != null) {
currentNode = currentNode.next;
}
currentNode.next = newNode;
size++;
}
/**
* 在指定索引处添加节点
*
* @param index
* @param val
*/
public void addAtIndex(int index, int val) {
// 添加索引小于等于 0 -> 添加到头部
if (index <= 0) {
addAtHead(val);
return;
}
// 添加索引大于 链表长度 -> 直接返回
if (index > size) {
return;
}
// 添加索引等于 链表长度 -> 添加到尾部
if (index == size) {
addAtTail(val);
return;
}
// 中间节点情况
ListNode newNode = new ListNode(val); // 新节点
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
while (index-- != 0) {
currentNode = currentNode.next;
}
//注意顺序: 原理同插入头结点
newNode.next = currentNode.next;
currentNode.next = newNode;
size++;
}
/**
* 删除索引处的结点
* @param index
*/
public void deleteAtIndex(int index) {
// 非法索引值
if (index < 0 || index > size - 1) {
return;
}
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
while (index-- != 0) {
currentNode = currentNode.next;
}
currentNode.next = currentNode.next.next;
size--;
}
}
方法二 - 方法一的优化版
-
观察需求 在指定节点插入 和 在头或尾插入 本质上是一个逻辑
- 虽然方法一进行了复用 -> 但依旧不够精炼
- 方案:将添加头节点和尾节点都算入添加节点一个逻辑!
class ListNode {
int val; // 结点的值
ListNode next;// 下一个结点
// 节点的构造函数(无参)
public ListNode() {}
// 节点的构造函数(有一个参数)
public ListNode(int val) {this.val = val;}
// 节点的构造函数(有两个参数)
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
/**
* 方法二 - 方法一优化版
*/
class MyLinkedList {
int size;//定义链表长度
ListNode dummyHead;//定义虚拟头节点
// 无参构造器
public MyLinkedList() {
// 初始化
size = 0;
dummyHead = new ListNode(0);
}
/**
* 获取索引处值
*
* @param index
* @return
*/
public int get(int index) {
if (index < 0 || index > size - 1) {
return -1;
}
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
// 遍历到最后一个结点
while (index-- != 0) {
currentNode = currentNode.next;
}
return currentNode.next.val; // 注意:由于虚拟节点的存在 这里要返回当前节点的下一个节点 才是我们要找的节点
}
/**
* 插入头结点
*
* @param val 新节点的值
*/
public void addAtHead(int val) {
addAtIndex(0, val);
}
/**
* 插入尾节点
*
* @param val 新节点的值
*/
public void addAtTail(int val) {
addAtIndex(size, val);
}
/**
* 在指定索引处添加节点
*
* @param index
* @param val
*/
public void addAtIndex(int index, int val) {
// 添加索引大于 链表长度 -> 直接返回
if (index > size) {
return;
}
// 中间节点情况
ListNode newNode = new ListNode(val); // 新节点
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
while (index-- != 0) {
currentNode = currentNode.next;
}
//注意顺序: 原理同插入头结点
newNode.next = currentNode.next;
currentNode.next = newNode;
size++;
}
/**
* 删除索引处的结点
*
* @param index
*/
public void deleteAtIndex(int index) {
// 非法索引值
if (index < 0 || index > size - 1) {
return;
}
ListNode currentNode = dummyHead; // 临时指针 -> 用于操作的链表
while (index-- != 0) {
currentNode = currentNode.next;
}
currentNode.next = currentNode.next.next;
size--;
}
}
方法三 - 双链表实现
- 以后再补充~
206.反转链表
建议先看我的视频讲解,视频讲解中对 反转链表需要注意的点讲的很清晰了,看完之后大家的疑惑基本都解决了。
文章讲解/视频讲解:programmercarl.com/0206.%E7%BF…
方法一 - 双指针
-
双指针 -> 一个 currentNode 一个 preNode
- currentNode 指向 head
- preNode 指针是 head 的前一个节点 初始化为 Null
-
临时节点 -> tempNode 保存当前节点的下一个节点
- 因为我们在将 将当前节点指向翻转 - currentNode.next = preNode 时
- 会使 currentNode 失去原本的指向节点 -> 用临时节点进行存储
/**
* 方法一 - 双指针
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode preNode = null; // 前指针 - 当前节点的前一个节点
ListNode currentNode = head; // 当前指针 - 当前节点 -> 从头结点开始
ListNode tempNode; // 临时存储节点
while (currentNode != null) {
tempNode = currentNode.next; // 临时存储节点 - 保存当前节点的下一个节点
currentNode.next = preNode; // 将当前节点指向翻转
preNode = currentNode; // 前指针 向后移动
currentNode = tempNode; // 当前指针 向后移动
}
return preNode;
}
}
方法二 - 递归解法
-
与双指针写法一一对应
- 代码更加简洁 更加晦涩难懂~ OvO
/**
* 方法二 - 递归
*/
class Solution {
public ListNode reverseList(ListNode head) {
return reverse(null, head);
}
public ListNode reverse(ListNode preNode, ListNode currentNode) {
if (currentNode == null) {
return preNode;
}
ListNode tempNode = currentNode.next; // 临时存储节点 - 保存当前节点的下一个节点
currentNode.next = preNode; // 将当前节点指向反转
preNode = currentNode; // 前指针 向后移动
// currentNode = tempNode; // 当前指针 向后移动
return reverse(preNode, tempNode); // 进行递归~
}
}
- 搞定搞定 - 去吃午饭 下午继续下一节~