目录
1、题目:203. 移除链表元素 - 力扣(LeetCode)
方法2:设置一个虚拟头结点进行删除操作——可以把头结点和中间结点一起处理。
1、题目:24. 两两交换链表中的节点 - 力扣(LeetCode)
1、题目:19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
1、题目:面试题 02.07. 链表相交 - 力扣(LeetCode)
1、题目:142. 环形链表 II - 力扣(LeetCode)
一、链表理论基础
1、链表的定义、类型
- 定义:链表是一种通过指针串联在一起的线性结构(这里的指针只是一个概念,跟JAVA里面有没有指针无关)
- 每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针)。
- 有几种类型的链表:
- 1、单链表:如下图。
- 2、双链表:每一个节点有两个指针域,一个指向下一个节点,一个指向上一个节点
- 既可以向前查询,也可以向后查询。
- 3、循环链表:顾名思义,就是链表首尾相连。
2、链表的存储方式
数组是在内存中是连续分布的,但是链表在内存中可不是连续分布的。
- 链表是通过指针域的指针链接在内存中各个节点。所以链表中的节点在内存中是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。
3、链表的定义代码
public class ListNode { // 这个类有下面3个方法
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;
}
}
4、链表的操作
- 1、删除节点:只要将C节点的next指针 指向E节点就可以了。(JAVA有自己的内存回收机制,可以释放节点D的内存)
- 2、添加节点:
- 性能分析:
- 插入和删除:数组的时间复杂度是O(n),链表是O(1)(链表增删的时候,不需要移动其他元素,只需要修改指针。因为内存也不是连续的)
- 查询:数组的时间复杂度是O(1),链表是O(n) (相当于数组知道索引可以直接访问,但是链表没有索引,每次都要重新开始)
二、移除链表元素
视频课:手把手带你学会操作链表 | LeetCode:203.移除链表元素_哔哩哔哩_bilibili
1、题目:203. 移除链表元素 - 力扣(LeetCode)
2、思路
1)移除的不是头结点
直接删除,next重新链接就好了。
2)移除的是头结点
移除头结点和移除其他节点的操作是不一样的,因为链表的其他节点都是通过前一个节点来移除当前节点,而头结点没有前一个节点。
方法1:直接使用原来的链表进行删除操作。
只用将头结点向后移动一位即可。(然后java会自动释放内存,但是c++里面还要手动将原头结点从内存中删掉。)
但是这样的话,对头结点要单独处理,不能和普通结点统一处理。
方法2:设置一个虚拟头结点进行删除操作——可以把头结点和中间结点一起处理。
给链表添加一个虚拟头结点为新的头结点。在程序最后别忘了还要指回新的头结点。
3、代码
注意:链表用ListNode head来表示,也就是仅仅用一个头结点来进行索引,表示整个链表
1)使用原链表
public ListNode removeElements(ListNode head, int val) { // 只需要直到头结点,即可表示整个链表
while (head != null && head.val == val) {
head = head.next;
//当头结点的数值就是目标值,就用.next指向后一个结点(直接更改head头结点的位置)
// 这是一个循环,直到头结点不再为目标值为止
}
// 已经为null,提前退出
if (head == null) {
return head;
}
// 已确定当前head.val != val
ListNode pre = head; // pre表示上一结点(比较数值结点的上一个)
ListNode cur = head.next; // 当前比较结点
while (cur != null) {
if (cur.val == val) { // 但凡head.next下一结点等于目标值
pre.next = cur.next; // 直接上一结点的next指向再往后一个结点
} else {
pre = cur; // 如果不是目标值,就把当前结点置为pre
}
cur = cur.next; // 比较结点都要往后移
}
return head; // 返回这个链表head
}
2)添加虚拟头结点
用统一的规则来删除,这样代码简单一些(虽然时间复杂度是一样的)
要删元素,就要定位到该元素的前一个结点。
public ListNode removeElements(ListNode head, int val) {
if (head == null) {
return head; // 如果头结点都为null,表示整个链表为空,直接返回就好
}
// 因为删除可能涉及到头节点,所以设置dummy节点,统一操作
ListNode dummy = new ListNode(-1, head); //在原始head链表前加一个-1虚拟头结点
ListNode pre = dummy; // 初始把虚拟结点设为pre
ListNode cur = head; // 把head设为cur 然后就可以直接比较,不用把head单拎出来了
while (cur != null) {
if (cur.val == val) {
pre.next = cur.next;
} else {
pre = cur;
}
cur = cur.next;
}
return dummy.next; // 注意,此处返回的是虚拟头结点的next,就是真正的头结点
// 为什么不return head,因为head可能已经被删掉了
}
4、复杂度分析
- 使用原来的链表:时间复杂度: O(n);空间复杂度: O(1)。
- 使用虚拟头结点:时间复杂度: O(n);空间复杂度: O(1)。
三、设计链表
视频课:帮你把链表操作学个通透!LeetCode:707.设计链表_哔哩哔哩_bilibili
1、题目:707. 设计链表 - 力扣(LeetCode)
设计5个方法:实现对单链表结点的增删改查。
2、思路
这道题目设计链表的五个函数:
统一采用虚拟头结点的方式!!
- 获取链表第index个节点的数值
- index是从0开始的,所以如果index=0,就是要获取头结点。(0 ~ size-1)
- 在链表的最前面插入一个节点
- 在链表的最后面插入一个节点
- current.next为空了,就证明current指向了尾部结点
- 在链表第index个节点前面插入一个节点
- 找到前一个结点,所以其实current.next才是第index个结点。
- 删除链表的第index个节点
可以说这五个接口,已经覆盖了链表的常见操作,是练习链表操作非常好的一道题目。
3、代码
1)单链表
其实链表的宗旨,就是要操作哪个结点,都先定位到该结点的前一个结点。
其实增删改查操作都不难。主要是要清晰链表是怎么一个个链接起来的,并且在写代码的时候要清晰index对应到什么位置,才好写for循环。然后一般知道了index,都是会索引到操作节点的前一个。
下面的代码中的定义了一个虚拟头结点的。
// 这是定义了结点的class
class ListNode {
int val; // 结点有一个值val
ListNode next; // 结点还有一个next结点
ListNode(){}
ListNode(int val) {
this.val=val; // 可以用xx.val得到结点值
}
}
// 下面是一个包含这5个方法的一个链表类
class MyLinkedList {
int size; //size存储链表元素的个数
ListNode head; //虚拟头结点
//初始化链表
public MyLinkedList() {
size = 0;
head = new ListNode(0); // 这定义的是一个虚拟头结点
// head 被初始化为一个新的 ListNode 对象,其值为0,这表明它是一个虚拟节点
}
// 1)获取第index个节点的数值,注意index是从0开始的,第0个节点就是头结点
public int get(int index) {
if (index < 0 || index >= size) { //如果index非法,返回-1
return -1;
}
ListNode currentNode = head;
for (int i = 0; i <= index; i++) { // 包含一个虚拟头节点,所以查找第 index+1 个节点
currentNode = currentNode.next; // 一直向后遍历,得到index+1位置的元素
}
return currentNode.val; //返回目前索引结点的值即可
}
//在链表最前面插入一个节点,等价于在第0个元素前添加(可以用方法4的函数)
public void addAtHead(int val) {
addAtIndex(0, val);
}
//在链表的最后插入一个节点,等价于在(末尾+1)个元素前添加(可以用方法4的函数)
public void addAtTail(int val) {
addAtIndex(size, val);
}
// 在第 index 个节点之前插入一个新节点,例如index为0,那么新插入的节点为链表的新头节点。
// 如果 index 等于链表的长度,则说明是新插入的节点为链表的尾结点
public void addAtIndex(int index, int val) {
if (index > size) {
return;
}
if (index < 0) {
index = 0;
}
size++; // 都是增加了1个结点,链表长度+1
//找到要插入节点的前驱
ListNode pred = head; // 从虚拟头结点开始往后索引
for (int i = 0; i < index; i++) {
pred = pred.next; // 一直索引到index的前一个
}
ListNode toAdd = new ListNode(val);
toAdd.next = pred.next;
pred.next = toAdd; // 然后直接把toAdd结点插入进去就行
}
//删除第index个节点
public void deleteAtIndex(int index) {
if (index < 0 || index >= size) {
return;
}
size--;
if (index == 0) {
head = head.next; // 相当于删除头结点
return;
}
ListNode pred = head;
for (int i = 0; i < index ; i++) {
pred = pred.next; // 索引到要删除的结点前面一个
}
pred.next = pred.next.next; //建立新的链接
}
}
2)拓展:双链表
class ListNode{
int val;
ListNode next,prev; // 这是双链表
ListNode() {};
ListNode(int val){
this.val = val;
}
}
class MyLinkedList {
int size; //记录链表中元素的数量
ListNode head,tail; //记录链表的虚拟头结点和虚拟尾结点
public MyLinkedList() {
//初始化操作
this.size = 0;
this.head = new ListNode(0);
this.tail = new ListNode(0); // 这里相当于设计了一个虚拟头结点和尾结点
//这一步非常关键,否则在加入头结点的操作中会出现null.next的错误!!!
head.next=tail;
tail.prev=head; // 头尾相连(实际上是实现了循环链表)
}
// 索引查找方法
public int get(int index) {
//判断index是否有效
if(index<0 || index>=size){
return -1;
}
ListNode cur = this.head; //
//判断是哪一边遍历时间更短
if(index >= size / 2){
//tail开始
cur = tail; //从尾巴开始向前一个个遍历
for(int i=0; i< size-index; i++){
cur = cur.prev; // 一个个向前索引
}
}else{
for(int i=0; i<= index; i++){
cur = cur.next; //注意是有虚拟头节点/尾结点的,所以都要比实际多索引一个
}
}
return cur.val;
}
public void addAtHead(int val) {
//等价于在第0个元素前添加
addAtIndex(0,val);
}
public void addAtTail(int val) {
//等价于在最后一个元素(null)前添加
addAtIndex(size,val);
}
// 插入元素(在index的前面插入)
public void addAtIndex(int index, int val) {
if(index>size){
return; //index大于链表长度
}
if(index<0){
index = 0;
}
size++;
//目标是找到插入点的前驱
ListNode pre = this.head; // 这时候还是虚拟头节点
for(int i=0; i<index; i++){
pre = pre.next;
}
//新建结点
ListNode newNode = new ListNode(val);
newNode.next = pre.next;
pre.next.prev = newNode; //后一个结点的pre要改变
newNode.prev = pre; //当前结点的next和pre要链接
pre.next = newNode; //前一个结点的next要改变
}
// 删除结点
public void deleteAtIndex(int index) {
//判断索引是否有效
if(index<0 || index>=size){
return;
}
//删除操作
size--;
ListNode pre = this.head;
for(int i=0; i<index; i++){
pre = pre.next; // 这是找到删除结点的前一个
}
pre.next.next.prev = pre; // 删除节点的后一结点的pre要改变
pre.next = pre.next.next; // 删除节点的前一个结点的next要改变
}
}
4、复杂度分析
- 时间复杂度: 涉及
index
的相关操作为 O(index), 其余为 O(1) - 空间复杂度: O(n)
四、反转链表
1、题目:206. 反转链表 - 力扣(LeetCode)
反转一个单链表,输入: 1->2->3->4->5->NULL 输出: 5->4->3->2->1->NULL
2、思路
视频课:帮你拿下反转链表 | LeetCode:206.反转链表 | 双指针法 | 递归法_哔哩哔哩_bilibili
如果再定义一个新的链表来一个个遍历建立连接,会很浪费空间。其实就是要改变原来这个链表的所有指针的指向,原本指向next,现在指向pre。然后尾部结点变成头结点。
- 双指针法:定义prev和cur指针,表示前一结点(cur修改后应链接的)和当前结点(应修改.next的指针)。所以prev初始是一个空,cur初始为头结点。然后两个指针一起向后移动,一个个修改链接方向。最后返回的是尾结点,也就是prev所在位置。
- 递归法:其实和双指针法是一样的逻辑,稍微抽象一点。其实是用递归来代替循环(方法中传入prev,cur,然后每次修改链接方向完成后,应该是要向前移动prev和cur指针。这里就直接递归调用方法,传入(cur,temp),达到了令prev=cur;cur=temp的作用)
3、代码
1)双指针法
class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null; // prev初始为空
ListNode cur = head; // cur初始在头结点
ListNode temp = null;
while (cur != null) { // 但凡当前结点不为空,就要反转链接prev
temp = cur.next; // 保存下一个节点(因为下一步修改cur.next后就无法指向下一个节点了)
cur.next = prev; // 修改当前cur指针的链接方向,链接前一个结点pre
prev = cur; // prev前移
cur = temp; // cur前移
}
return prev; // 此时prev在尾部结点,也就是新链表的head
}
}
// 先保存下一个结点,再修改当前节点指向,再移动pre和cur位置。
2)递归法
class Solution {
public ListNode reverseList(ListNode head) {
return reverse(null, head);
}
private ListNode reverse(ListNode prev, ListNode cur) {
if (cur == null) {
return prev;
}
ListNode temp = null;
temp = cur.next;// 先保存下一个节点
cur.next = prev;// 反转
// 其实上面这些步骤和双指针方法都是一样的
return reverse(cur, temp); // 其实这里递归的目的是更新prev、cur位置
// 也就是实现了prev = cur; cur = temp;的效果 (这里递归也就相当于循环了)
}
}
4、复杂度分析
两种方法复杂度一样
- 时间复杂度: O(n), 要递归处理链表的每个节点
- 空间复杂度: O(n), 递归调用了 n 层栈空间
五、两两交换链表中的结点
1、题目:24. 两两交换链表中的节点 - 力扣(LeetCode)
给一个单链表。交换两个相邻结点的位置(如果是奇数个结点,最后一个就不处理)。
2、思路
视频课:帮你把链表细节学清楚! | LeetCode:24. 两两交换链表中的节点_哔哩哔哩_bilibili
仍然使用虚拟头结点的方法,不然还是不好对head结点进行操作。
步骤1:cur指向结点2,也就是把cur指向要右交换节点。
步骤2:结点2指向结点1,也就是右交换点指向左交换点。
步骤3:结点1指向结点3,也就是左交换点指向两个交换点的下一个。
每次都让cur处于要交换的两个结点的前一个结点。
- 遍历终止条件:如果有奇数个结点,那么遍历到cur是倒数第二个就行了,也就是cur.next.next为空的时候就停止,如果是偶数个结点,那么遍历到cur在尾结点,即cur.next为空就行了。所以要保证这两个都不为空。
3、代码
//按照前面所讲的步骤,一步步来
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dumyhead = new ListNode(-1); // 设置一个虚拟头结点
dumyhead.next = head; // 初始虚拟头结点指向head
ListNode cur = dumyhead; // cur初始在虚拟头
ListNode temp; // 临时节点,保存两个节点后面的节点
ListNode firstnode; // 临时节点,保存交换的两个节点之中的第一个节点
ListNode secondnode; // 临时节点,保存交换的两个节点之中的第二个节点
while (cur.next != null && cur.next.next != null) { // 确保两个交换的结点不为空
temp = cur.next.next.next; // 两个交换结点的下一个,最开始对应结点3
firstnode = cur.next; // 交换的左边结点
secondnode = cur.next.next; // 交换的右边结点
// 下面开启正式的交换操作(相当于cur要定位到交换结点的前一个)
cur.next = secondnode; // 步骤一:cur指向右交换点(cur指向结点2)
secondnode.next = firstnode; // 步骤二:右交换点指向左交换点(结点2指向结点1)
firstnode.next = temp; // 步骤三:左交换点指向两个交换的下一个点(结点1指向结点)
// 然后就移动cur的位置,要移动到下一次交换的前一个,也就是交换后的右边这个点
cur = firstnode; // cur移动,准备下一轮交换
}
return dumyhead.next; // 最后返回虚拟头结点的下一个点,也就是原始的head
}
}
4、复杂度分析
- 时间复杂度:O(n)
- 空间复杂度:O(1)
六、删除链表的倒数第N个节点
1、题目:19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
2、思路
视频课:链表遍历学清楚! | LeetCode:19.删除链表倒数第N个节点_哔哩哔哩_bilibili
步骤1:首先fast和slow都指向虚拟头结点。然后fast向后移动n+1步。(让fast和slow之间隔n个)
步骤2:fast和slow同时向后移动,直到fast指向null。
步骤2:此时slow已经指向待删除结点的前一个了,直接修改next进行删除即可。(其实目标就是让指针指向操作结点的前一个结点)
3、代码
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummyNode = new ListNode(0);
dummyNode.next = head; //新建一个虚拟头节点指向head
// 初始快慢指针指向虚拟头节点
ListNode fastIndex = dummyNode;
ListNode slowIndex = dummyNode;
// 只要快慢指针相差 n 个结点即可
for (int i = 0; i <= n; i++) {
fastIndex = fastIndex.next; // fast向后移动n+1个
}
// 然后同时向后移动fast和slow,直到fast指向null
while (fastIndex != null) {
fastIndex = fastIndex.next;
slowIndex = slowIndex.next;
}
// 此时 slowIndex 的位置就是待删除元素的前一个位置。
if (slowIndex.next != null) { // 检查 slowIndex.next 是否为 null,以避免空指针异常
slowIndex.next = slowIndex.next.next; // 直接删除
}
return dummyNode.next; // 返回head
}
}
4、复杂度分析
- 时间复杂度: O(n)
- 空间复杂度: O(1)
七、链表相交
1、题目:面试题 02.07. 链表相交 - 力扣(LeetCode)
给出listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3。(A链表相交前有2个结点)
输出Intersected at '8'
注意:这里的相交不是数值相等,而是指针相等。
2、思路
视频课:无
步骤1:curA和curB首先都指向head。然后计算两个链表的长度差值n,让curA向后移动n步,这样curA和curB就对齐了。
步骤2:再一次查看curA和curB是否相同,不相等则同时向右移动。
3、代码
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode curA = headA; // 题目是给定了headA和headB的
ListNode curB = headB; // 初始curA和curB都是指向头结点的
int lenA = 0, lenB = 0;
while (curA != null) { // 求链表A的长度
lenA++;
curA = curA.next;
}
while (curB != null) { // 求链表B的长度
lenB++;
curB = curB.next;
}
curA = headA; // 再重新指回头结点
curB = headB;
// 让curA为最长链表的头,lenA为其长度
if (lenB > lenA) {
int tmpLen = lenA;
lenA = lenB;
lenB = tmpLen; // 交换lenA和lenB
ListNode tmpNode = curA;
curA = curB;
curB = tmpNode; // 交换curA和curB
}
// 求长度差
int gap = lenA - lenB; // 保证了A肯定比B长
while (gap-- > 0) {
curA = curA.next; // 向右移动curA,直到curA和curB在同一起点上
}
// 遍历curA 和 curB,遇到相同则直接返回
while (curA != null) { // 因为curA和curB位置相同,所以要是为null就都为,只用判断一个
if (curA == curB) {
return curA; // 返回指针相同的结点
}
curA = curA.next;
curB = curB.next;
}
return null;
}
}
4、复杂度分析
- 时间复杂度:O(n + m)
- 空间复杂度:O(1)
八、环形链表2
1、题目:142. 环形链表 II - 力扣(LeetCode)
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
需要输出:pos。用从0开始的整数pos来表示环结束的位置,也就是链尾指向链中的地方)比如下面这个环,pos=1。
2、思路
视频课:把环形链表讲清楚! 如何判断环形链表?如何找到环形链表的入口? LeetCode:142.环形链表II_哔哩哔哩_bilibili
1)首先判断有没有环
从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。
因为fast移动2步、slow移动1步,这样相当于如果有环fast每次都更靠近1步slow,即在追赶。
2)找环的入口
假设从头结点到环形入口节点 的节点数为x。 环形入口节点到 fast指针与slow指针相遇节点 节点数为y。 从相遇节点 再到环形入口节点节点数为 z。需要计算出x的表达式:
- slow指针走过的节点数为:
x+y
, fast指针走过的节点数:x+y+n(y+z).
即fast走了n小圈后遇到slow
- fast指针走过的节点数 = slow指针走过的节点数 * 2:(x+y)*2 =
x+y+n(y+z).
化简这个式子后:x = n(y+z)-y = (n-1)(y+z)+z
n=1的时候,x=z。其实就是x的长度等于几个小圈,加上一个多余的z。
可见这道题主要是看推理能力。
其实x=(n-1)(y+z)+z。。在代码中,就是一个结点从头结点开始,一个结点从相遇节点开始。两个结点分别向后移动,直到相遇,相遇的位置就刚好是环开始的位置。
3、代码
代码的步骤就是先定义fast和slow结点判断有没有环。如果有环了,也就知道了相遇结点位置。再同时后移头结点和相遇结点,如果碰上了,那么此位置就刚好是环初始的位置。
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head; //初始fast和slow都指向head
while (fast != null && fast.next != null) {
slow = slow.next; // slow每次移动1步
fast = fast.next.next; // fast每次移动2步
if (slow == fast) { // 判断是否有环?但凡能相遇,就是有环
ListNode index1 = fast; // index1是相遇的结点
ListNode index2 = head; // index2是头结点
// 两个指针,从头结点和相遇结点,各走一步,直到相遇,相遇点即为环入口
while (index1 != index2) {
index1 = index1.next; // 也就是求X的过程 x= 几个小圈+z
index2 = index2.next;
}
return index1;
}
}
return null;
}
}
4、复杂度分析
- 时间复杂度: O(n),快慢指针相遇前,指针走的次数小于链表长度,快慢指针相遇后,两个index指针走的次数也小于链表长度,总体为走的次数小于 2n。
- 空间复杂度: O(1)。