203 移除链表元素
这道题目主要是考虑头部节点是否要删除。 用2种方法,第一种方法针对头部节点进行特殊考虑。
第二种方法是设置一个虚拟的节点让它和头部节点衔接起来 ,然后用常规while循环判断next节点的val值是否和target的值相等,这里的虚拟节点dummy永远会指向新的链表的头部节点,dummy.next =head .
具体代码如下:
方法一:
/**
* 方法1
* 时间复杂度 O(n)
* 空间复杂度 O(1)
* @param head
* @param val
* @return
*/
public ListNode removeElements(ListNode head, int val) {
while(head!=null && head.val==val) {
head = head.next;
}
ListNode curr = head;
while(curr!=null && curr.next !=null) {
if(curr.next.val == val){
curr.next = curr.next.next;
} else {
curr = curr.next;
}
}
return head;
}
方法二:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode removeElements(ListNode head, int val) {
// 特殊处理头节点,看看它是否属于val,对它进行剔除,
/**
* 头节点特殊性: 由于头节点没有前驱节点,无法通过前一个节点来删除它,只能直接移动头指针。
* 连续相同值的头节点: 如果链表开头有多个连续的值等于 val 的节点,需要逐个跳过它们,
* 直到找到一个值不等于 val 的节点作为新的头节点。
*/
ListNode dummy = new ListNode();
dummy.next = head;
ListNode curr = dummy;
while (curr.next != null) {
if (curr.next.val == val) {
curr.next = curr.next.next;
} else {
curr = curr.next;
}
}
return dummy.next;
}
}
707 链表设计
这个题目的各个方法体,主要考察的还是对指针的理解。 考虑到边界问题,我们为了简化问题的复杂度,需要创建一个虚拟的头部节点head。这样我们就可以把头部节点情况和其他节点情况一视同仁,简化了复杂度。
增加节点的过程,无论是插入到头节点之前,或者是中部,或者说是到尾部,这个过程都是需要先遍历找到当前index对应的节点的上一个节点。 然后我们通过设计pre节点的next的next,就会把当前的index节点删除掉。
在插入节点中,如果index是负数,这种情况,我们得先把index 变为0,然后再进行插入逻辑。
index=0后,会执行插入到头节点前的逻辑,这个符合预期。
注意:这个代码中的ListNode ,是默认已经创建好的对象,因为leetcode好像是之前已经有相关题目的定义,这里默认是可以调用的就行。
代码如下:
class MyLinkedList {
private ListNode head;
private int size;
//无参数构造函数
public MyLinkedList() {
//创建虚拟头节点
this.head =new ListNode(0);
this.size=0;
}
public int get(int index) {
ListNode curr =head ;
if(index<0||index>=size){
return -1;
}
for(int i=0;i<=index;i++){
curr=curr.next;
}
return curr.val ;
}
public void addAtHead(int val) {
ListNode curr =head;
ListNode newNode = new ListNode(val);
newNode.next=curr.next;
head.next =newNode;
size++;
}
public void addAtTail(int val) {
ListNode curr =head;
ListNode newNode = new ListNode(val);
while(curr.next !=null){
curr=curr.next ;
}
curr.next =newNode;
size++;
}
public void addAtIndex(int index, int val) {
ListNode newNode =new ListNode(val);
ListNode pre= head;//插入节点的前驱节点一定要找到
//curr.next 为index的位置, cur之后插入,curr.next之前插入
if(index>size){
return;
}
//如果是index为负数,我们要在头节点前插入,跳过下面的for循环进行插入替换
if(index<0){
index=0;
}
for(int i=0;i<index;i++){
pre=pre.next;
}
newNode.next=pre.next;
pre.next= newNode;
size++;
}
public void deleteAtIndex(int index) {
if(index<0||index>=size){
return ;
}
ListNode curr= head;
for(int i=0;i <index;i++){
curr=curr.next;
}
curr.next=curr.next.next;
size--;
}
}
/**
* Your MyLinkedList object will be instantiated and called as such:
* MyLinkedList obj = new MyLinkedList();
* int param_1 = obj.get(index);
* obj.addAtHead(val);
* obj.addAtTail(val);
* obj.addAtIndex(index,val);
* obj.deleteAtIndex(index);
*/
206.反转链表:
在反转链表中,我们需要把链表的next指向进行调换。 我们可以使用一个pre虚拟的null节点和curr当前节点2个信息,从头依次进行调换方向:pre->curr 调换成 curr->pre; 并且要顺利找到原来链表中的第三个节点(curr后面的节点),我们需要在调转curr->pre之前,把第三个节点保存下来,避免curr->pre 调转后,第三个节点断了链接关系,找不到它。
所以整体来讲,这个题目应该是三个指针的逻辑关系。 pre虚拟头节点,pre后紧跟的curr节点,以及curr之后的节点需要一个指针temp来保存它。
原来的顺序依次调转,知道最后curr 为null这个时候,pre就是原来的的尾部,也就是现在的头部节点,调转结束。 所以while循环的判断逻辑是: curr ==null 就会停止循环(while(curr !=null)) 。
这三个指针的顺序,我们得在pre和curr调转之前,做好原链表的第三个节点的临时储存。
所以顺序是:
step1 : temp =存原始链表curr.next;
step2: 调转pre和curr
注意:这里有个错误的写法如下:
pre=curr;
curr=pre;
这样写,最后结果就是都指向了curr节点,指向同一个节点。
问题解析:
-
pre = curr
的作用:- 这一步实际上是把
pre
更新为curr
,也就是说,现在pre
指向了当前节点curr
。 - 此时
pre
和curr
是指向同一个节点的。
- 这一步实际上是把
-
curr = pre
的影响:- 在上一步操作后,
pre
和curr
都指向了相同的节点。 - 然后我写了
curr = pre
,其实是在将curr
重新指向pre
。 - 因为
pre
和curr
已经指向相同的节点,所以这个操作实际上不会改变curr
的位置,只是让它继续指向自己。
- 在上一步操作后,
综上再梳理一下:
-
总体目标: 在反转链表的过程中,我们需要把链表中每个节点的
next
指向进行调换,从指向下一个节点,改为指向前一个节点。使用pre
和curr
两个指针。此外,我们还需要一个临时指针temp
来保存原链表中的下一个节点的信息。 -
步骤详细描述:
- 我们使用 三个指针:
pre
、curr
和temp
。初始时,pre
设置为null
,因为反转后原链表的第一个节点将成为新的尾部,其next
需要指向null
。 curr
则是当前处理的节点,初始时设置为链表的头节点。- 为了防止反转操作导致链表中断,我们使用
temp
来暂存当前节点之后的那个节点(即原链表中的下一个节点)。
- 我们使用 三个指针:
-
逻辑顺序:
如此往复,直到
curr
为null
,表示我们已经处理完了整个链表。- Step 1: 保存原链表中的下一个节点。
temp = curr.next;
- Step 2: 反转
curr
的next
,使其指向pre
。curr.next = pre;
- Step 3: 移动
pre
和curr
指针,准备处理下一个节点。pre = curr; curr = temp;
- Step 1: 保存原链表中的下一个节点。
-
终止条件:
- 循环的终止条件是
curr == null
,即当前节点遍历到了链表的末尾,链表已完成反转。
- 循环的终止条件是
代码如下:
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode pre = null;
ListNode curr =head;
while(curr !=null){
ListNode temp = curr.next ;
curr.next= pre;
pre =curr;
curr=temp;
}
return pre;
}
}
使用递归的思路来解决该问题:
递归反转链表中的顺序问题很重要,尤其是在递归中如何处理节点指向,可能确实会影响整体反转的实现。
递归反转链表:两种可能顺序的讨论
顺序 1:先反转指向,再进行递归调用
这种顺序的意思是:
- 先进行当前节点的反转,也就是
curr.next = pre
,让当前节点的next
指向前一个节点。 - 再进行递归调用,传递下一节点和当前节点作为参数,继续向下递归。
具体代码如下:
public ListNode reverse(ListNode curr, ListNode pre) {
if (curr == null) {
return pre; // 返回最终反转后的新头节点
}
// 首先将当前节点指向前一个节点,实现局部反转
curr.next = pre;
// 然后递归调用,将下一节点作为新的当前节点
return reverse(curr.next, curr);
}
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
return reverse(head,null);
}
public ListNode reverse (ListNode curr, ListNode pre){
if(curr ==null){
return pre;// 当到达链表的末尾,返回前一个节点,作为新头节点
}
ListNode temp= curr.next ;// 保存当前节点的下一个节点
curr.next=pre; // 反转当前节点的指向
ListNode newPre =reverse(temp,curr);
return newPre;// 递归调用,返回反转后的新头节点
}
}
在这个顺序中,首先对当前节点进行反转,然后将反转后的链表部分继续传递给递归函数,这样确保了每次递归调用时,节点的指向已经是反转后的。
为什么这种顺序是对的?
- 在当前节点进行反转之后:
curr.next = pre
,这确保了当前节点已经完成了反转。 - 递归调用的参数传递:递归调用时,
curr
变成了下一个节点,pre
变成了当前节点,这样每一层递归的操作都是在局部反转后继续进行。 - 确保链表不丢失:因为在每次递归调用之前,当前节点的
next
指向已经被更新为pre
,所以整个链表的结构不会丢失。
递归过程示例:
假设链表是 1 -> 2 -> 3 -> null
,那么递归的过程如下:
-
初始调用:
reverse(1, null)
:1.next = null
,反转节点1
。- 递归调用
reverse(2, 1)
。
-
第二层调用:
reverse(2, 1)
:2.next = 1
,反转节点2
。- 递归调用
reverse(3, 2)
。
-
第三层调用:
reverse(3, 2)
:3.next = 2
,反转节点3
。- 递归调用
reverse(null, 3)
。
-
终止条件:
curr == null
,返回pre
,即节点3
。
这样,递归回溯的过程中,我们的链表逐步被反转为 3 -> 2 -> 1 -> null
。
顺序 2:先递归,再反转指向
而先递归再反转的顺序是:
- 递归到链表末尾,然后开始反转。
- 递归返回时,再对当前节点进行反转。
这种方式的实现代码如下:
public ListNode reverse(ListNode curr, ListNode pre) {
// 基本情况:如果当前节点为空,则返回前一个节点作为新头节点
if (curr == null) {
return pre;
}
// 递归调用,传入当前节点作为前驱节点,传入下一个节点作为当前节点
ListNode newHead = reverse(curr.next, curr);
// 回溯过程中,反转当前节点的指向
curr.next = pre;
// 返回反转后的新头节点
return newHead;
}
// 对外公开的接口,用于初始调用时设置参数
public ListNode reverseList(ListNode head) {
return reverse(head, null); // 初始调用时,pre 为 null,curr 为 head
}
递归函数执行的具体流程
递归的执行可以拆分为两个阶段:
- 递归调用阶段(进入递归,压栈)
- 递归回溯阶段(回溯,弹栈并执行未完成的部分)
对于递归函数,所有的操作其实是由函数调用的顺序控制的,而不是简单的顺序代码执行。每次调用一个递归函数时,整个函数都会被压入调用栈中,只有在上一个递归调用结束之后,才会开始从调用栈中逐层弹出函数并继续执行其余的代码。因此,虽然函数里有 curr.next = pre
这样一行代码,它并不会在递归深入的过程中立即执行,而是会被推迟到递归返回(即回溯)时才执行。
调用栈的运作原理
代码再次回顾
我们以这个代码为例:
public ListNode reverse(ListNode curr, ListNode pre) {
// 基本情况:如果当前节点为空,则返回前一个节点作为新头节点
if (curr == null) {
return pre;
}
// 递归调用,传入当前节点作为前驱节点,传入下一个节点作为当前节点
ListNode newHead = reverse(curr.next, curr);
// 回溯过程,反转当前节点的指向
curr.next = pre;
// 返回反转后的新头节点
return newHead;
}
递归调用阶段(压栈)
当 reverse()
被调用时,程序的执行如下:
-
调用
reverse(1, null)
:- 首先检查
if (curr == null)
,由于curr
是1
,所以条件不成立。 - 然后,执行递归调用
reverse(curr.next, curr)
,也就是reverse(2, 1)
。 - 注意:在此时,
curr.next = pre;
并没有执行,因为还没有到这一步。当前递归帧被压入栈中,并等待reverse(2, 1)
的结果。
- 首先检查
-
调用
reverse(2, 1)
:- 检查
if (curr == null)
,由于curr
是2
,条件依然不成立。 - 执行递归调用
reverse(curr.next, curr)
,也就是reverse(3, 2)
。 - 同样地,此时
curr.next = pre;
仍然没有执行,当前帧被压入栈中,等待reverse(3, 2)
的结果。
- 检查
-
调用
reverse(3, 2)
:- 再次检查
if (curr == null)
,由于curr
是3
,条件依然不成立。 - 执行递归调用
reverse(curr.next, curr)
,也就是reverse(null, 3)
。 curr.next = pre;
仍然没有执行,当前帧被压入栈中。
- 再次检查
-
调用
reverse(null, 3)
:- 此时
curr == null
,满足条件,返回pre
,也就是节点3
。
- 此时
递归回溯阶段(弹栈并执行 curr.next = pre
)
在递归调用到达基准条件后,开始从栈中逐层弹出并执行每个递归帧中的其余代码:
-
回溯到
reverse(3, 2)
:- 当前
curr
为节点3
,pre
为节点2
。 - 现在执行
curr.next = pre;
,即3.next = 2
,将节点3
的next
指向2
。 - 然后
return newHead
,此时newHead
是节点3
,返回给上一层调用。
- 当前
-
回溯到
reverse(2, 1)
:- 当前
curr
为节点2
,pre
为节点1
。 - 现在执行
curr.next = pre;
,即2.next = 1
,将节点2
的next
指向1
。 - 然后返回
newHead
(节点3
)。
- 当前
-
回溯到
reverse(1, null)
:- 当前
curr
为节点1
,pre
为null
。 - 现在执行
curr.next = pre;
,即1.next = null
,将节点1
的next
指向null
。 - 然后返回
newHead
(节点3
)。
- 当前
为什么递归中的 curr.next = pre
没有在递归时立即执行?
这是因为在递归调用过程中,函数并没有执行到 curr.next = pre
这一行代码——实际上,所有在递归调用之后的代码都会在递归回溯时才执行。这是递归的执行机制所决定的:
- 递归调用的性质:每次递归调用相当于函数的重新进入,每一次调用都会将当前函数的状态(包括局部变量等)压入调用栈。递归调用后,当前的递归帧进入等待状态,直到后续的递归调用完成返回结果,这个时候才会继续执行递归调用之后的代码。
- 调用栈:递归调用的过程中,函数会被压入调用栈,每次函数调用都不会继续执行之后的代码,而是先等待后续调用完成,等回溯时再执行。
这就是为什么在递归调用中,虽然每个递归帧中都有 curr.next = pre;
这一行代码,但是它们都不会立即执行,而是等到递归回溯时才执行。
因此
- 在递归调用过程中,函数会被压入栈中,递归调用之后的代码不会立即执行。
- 只有在递归的回溯阶段,函数会逐层从栈中弹出,才会执行到
curr.next = pre;
这一行代码。 - 这就是为什么
curr.next = pre
会在整个递归深入完成后(即在回溯过程中)才执行。
详细地解释递归调用栈的工作原理,特别是每一次函数的递归调用如何进入栈以及如何从栈中弹出。
递归调用与回溯的栈顺序
首先,递归调用时,每个函数的调用都会被压入调用栈中,而在回溯阶段,栈会按照**后进先出(LIFO: Last In, First Out)**的顺序弹出。这意味着,最后被压入栈的函数,会最先弹出执行回溯部分的代码。
代码回顾
我们再回顾一下提到的代码:
public ListNode reverse(ListNode curr, ListNode pre) {
if (curr == null) {
return pre; // 到达链表末尾,返回前一个节点作为新头节点
}
ListNode newHead = reverse(curr.next, curr); // 递归调用
curr.next = pre; // 回溯阶段,反转指向
return newHead; // 返回反转后的新头节点
}
递归调用的过程分析
假设链表为 1 -> 2 -> 3 -> null
,我们来一步一步分析递归调用和回溯的过程,特别是栈的运作:
递归调用阶段(压栈)
在递归调用阶段,程序不断调用自身并压入栈中,函数的状态(包括当前的局部变量和参数)被保存,以便后续回溯时可以继续执行。
-
调用
reverse(1, null)
:curr
为1
,pre
为null
。- 调用
reverse(2, 1)
。 - 此时
reverse(1, null)
被压入栈中,等待后续返回结果。
-
调用
reverse(2, 1)
:curr
为2
,pre
为1
。- 调用
reverse(3, 2)
。 - 此时
reverse(2, 1)
被压入栈中。
-
调用
reverse(3, 2)
:curr
为3
,pre
为2
。- 调用
reverse(null, 3)
。 - 此时
reverse(3, 2)
被压入栈中。
-
调用
reverse(null, 3)
:curr
为null
,满足递归终止条件,返回pre
,即节点3
。- 此时,
reverse(null, 3)
被压入栈中,但在满足条件后立即返回。
递归回溯阶段(弹栈)
现在开始回溯。递归函数会从栈中逐层弹出并执行递归调用之后的代码。栈的弹出顺序是后进先出,所以我们来看如何逐层弹出栈:
-
弹出
reverse(null, 3)
:- 这层递归并没有后续代码执行,因为
curr == null
时直接返回pre
,也就是返回节点3
作为新的头节点。 - 没有真正意义上的回溯,因为它是基准情况直接返回,所以你会看到它只是返回新头节点,并没有涉及节点的反转。
- 这层递归并没有后续代码执行,因为
-
回溯到
reverse(3, 2)
:- 现在回到
reverse(3, 2)
,其中curr
为节点3
,pre
为节点2
。 - 继续执行
curr.next = pre
,也就是3.next = 2
,实现反转,使得节点3
指向节点2
。 - 然后返回
newHead
,即节点3
。
- 现在回到
-
回溯到
reverse(2, 1)
:- 现在回到
reverse(2, 1)
,其中curr
为节点2
,pre
为节点1
。 - 继续执行
curr.next = pre
,也就是2.next = 1
,实现反转,使得节点2
指向节点1
。 - 然后返回
newHead
,即节点3
。
- 现在回到
-
回溯到
reverse(1, null)
:- 现在回到
reverse(1, null)
,其中curr
为节点1
,pre
为null
。 - 继续执行
curr.next = pre
,也就是1.next = null
,实现反转,使得节点1
成为链表的尾部。 - 返回
newHead
,即节点3
。
- 现在回到
为什么不是回溯到 reverse(null, 3)
?
reverse(null, 3)
其实是在遇到基准条件时立即返回,并没有其他代码需要执行,这意味着它的返回不会执行任何额外的操作,而是直接返回结果给上一级的调用。这就是为什么在回溯的过程中,我们看到的首先是从 reverse(3, 2)
开始执行指针反转。
reverse(null, 3)
的作用只是终止递归,并返回最终的新头节点,而不涉及任何指针操作。- 实际的回溯操作是从
reverse(3, 2)
开始的,这个地方才是我们在调用栈弹出时真正需要处理节点指针反转的地方。
总结
- 递归到达基准条件后 (
reverse(null, 3)
) 返回的新头节点,然后递归开始逐层回溯。 - 回溯阶段从
reverse(3, 2)
开始,逐层执行curr.next = pre
,逐步反转节点的指向。 - 基准条件的递归调用
reverse(null, 3)
不涉及指针反转,它只是单纯地返回新头节点,因此并不是回溯开始反转链表的地方。
所以,回溯并不真正从 reverse(null, 3)
开始,因为这一层只是满足基准条件并直接返回结果,而实际的反转是从倒数第二层开始执行的。
回头看2种不同递归,为什么顺序1的顺序更直观?
结合三指针的逻辑,递归反转链表的过程可以通过以下几点来理解为什么它是直观且合理的:
- 立即反转当前节点的指向:每次反转操作之后,当前节点与前一个节点的关系就已经完成,这样避免了递归调用后指针丢失的风险。
- 递归参数明确:每次递归调用时,都是在明确了当前节点的指向之后,传递下一个节点。这确保了递归回溯的时候节点关系已经完全处理好。
结论
- 先反转,再递归调用的顺序在逻辑上是完全合理的,它确保在递归调用前已经反转了节点的指向,这样不会丢失节点的连接,保证整个链表能够正确反转。
- 先递归,再反转的方式也可以实现反转,只是实现方式略有不同,操作的步骤在递归回溯时完成。