记一次
LeetCode92
反转链表IILeetCode206
反转链表 算法解题笔记,解题方法和思路有参考网上大佬们的解题方法,这里做下笔记以便查询。
题目
首先,实现单链表的反转并不是一件很困难的事情,使用迭代法实现起来很方便,但要是通过递归的方法可能就有需要一些思考了,然后如果是反转链表的一部分的话,难度就又增加了一点点。
现在就用LeetCode
的两道题来一点点的拓展整个递归思路,希望能给大家一点点思路上的帮助。
// 单链表节点的结构
public class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
注意这里的索引是从 1 开始的。 迭代的思路大概是:先用一个 for 循环找到第 m 个位置,然后再用一个 for 循环将 m 和 n 之间的元素反转。但是我们的递归解法不用一个 for 循环,纯递归实现反转。
迭代实现思路看起来虽然简单,但是细节问题很多的,反而不容易写对。相反,递归实现就很简洁优美,下面就由浅入深,先从反转整个单链表说起。
递归法
一、递归反转整个链表
其实这个算法很多同学都看过或听说过,这里详细的介绍一下,先看实现代码:
ListNode reverse(ListNode head) {
if (head.next == null) return head;
ListNode last = reverse(head.next);
head.next.next = head;
head.next = null;
return last;
}
如果对递归不是很熟练的同学看这段代码估计是会有一些不知所云,“啥子嘛!这就完成反转链表了?”这就对了,这个算法常常被拿来彰显递归的巧妙和有没,不理解的同学们不妨再仔细推敲一下,下面我们一块来解释一下这段代码。
对于递归算法,最重要的就是明确递归函数的定义。具体来说,我们的 reverse
函数定义是这样的:
输入一个节点 head,将「以 head 为起点」的链表反转,并返回反转之后的头结点。
明白了函数的定义,在来看这个问题。比如说我们想反转这个链表:
那么输入
reverse(head)
后,会在这里进行递归:
ListNode last = reverse(head.next);
这里大家先不要往深处压栈(先不跳入递归),而是要根据刚才的函数定义,来弄清楚这个段代码吗会产生什么结果:
这个reverse(head.next)
执行完成后,整个链表就成了这样:
并且根据函数定义,reverse 函数会返回反转之后的头结点,我们用变量 last 接收了。
注:如果这里不好理解的话,可以直接将原链表当做是长度为2的链表进行带入思考
现在再来看下面的代码:
head.next.next = head;
接下来:
head.next = null;
return last;
这样整个链表就反转过来了!递归代码就是这么简洁优雅,不过其中有两个地方需要注意:
1、递归函数要有 base case,也就是这句:
if (head.next == null) return head;
意思是如果链表只有一个节点的时候反转也是它自己,直接返回即可。
2、当链表递归反转之后,新的头结点是 last,而之前的 head 变成了最后一个节点,别忘了链表的末尾要指向 null:
head.next = null;
理解了这两点后,我们就可以进一步深入了,接下来的问题其实都是在这个算法上的扩展。
二、反转链表的前N个节点
这次我们实现一个这样的函数:
// 将链表的前 n 个节点反转(n <= 链表长度)
ListNode reverseN(ListNode head, int n)
比如说对于下图链表,执行reverseN(head, 3)
:
解决思路和反转整个链表差不多,只要稍加修改即可:
ListNode successor = null; // 后驱节点
// 反转以 head 为起点的 n 个节点,返回新的头结点
ListNode reverseN(ListNode head, int n) {
if (n == 1) {
// 记录第 n + 1 个节点
successor = head.next;
return head;
}
// 以 head.next 为起点,需要反转前 n - 1 个节点
ListNode last = reverseN(head.next, n - 1);
head.next.next = head;
// 让反转之后的 head 节点和后面的节点连起来
head.next = successor;
return last;
}
具体的区别:
1、base case 变为 n == 1,反转一个元素,就是它本身,同时要记录后驱节点。
2、刚才我们直接把 head.next 设置为 null,因为整个链表反转后原来的 head 变成了整个链表的最后一个节点。但现在 head 节点在递归反转之后不一定是最后一个节点了,所以要记录后驱 successor(第 n + 1 个节点),反转之后将 head 连接上。
OK,如果这个函数你也能看懂,就离实现「反转一部分链表」不远了。
三、反转链表的一部分
现在解决我们最开始提出的问题,给一个索引区间 [ m, n ](索引从 1 开始),仅仅反转区间中的链表元素。
ListNode reverseBetween(ListNode head, int m, int n)
首先,如果 m == 1
,就相当于反转链表开头的 n 个元素嘛,也就是我们刚才实现的功能:
ListNode reverseBetween(ListNode head, int m, int n) {
// base case
if (m == 1) {
// 相当于反转前 n 个元素
return reverseN(head, n);
}
// ...
}
如果 m != 1
怎么办?如果我们把 head
的索引视为 1,那么我们是想从第 m
个元素开始反转对吧;如果把 head.next
的索引视为 1 呢?那么相对于 head.next
,反转的区间应该是从第 m - 1`` 个元素开始的;那么对于
head.next.next` 呢……
区别于迭代思想,这就是递归思想,所以我们可以完成代码:
ListNode reverseBetween(ListNode head, int m, int n) {
// base case
if (m == 1) {
return reverseN(head, n);
}
// 前进到反转的起点触发 base case
head.next = reverseBetween(head.next, m - 1, n - 1);
return head;
}
至此,我们的最终大 BOSS 就被解决了。
迭代
1、反转整个链表
相对递归法,个人认为迭代是比较容易理解的,先看看代码:
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
这迭代是从表头开始处理,依次将每个节点变成新的表头curr
,并将其地址域指向上一个节点prev
(即旧的表头,若为第一个节点,则地址域指向NULL),以下面链表作为例子:
在while
循环体外,定义了一些初值,前一个节点prev
初值为null
,首节点为传入的头结点head
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
然后第二遍,第三遍,直至循环到条件,curr != null
没有下一个结点了,就返回prev
。
相比递归的复杂度,迭代更好理解一些,接下来的拓展到反转部分链表就不在讲了,很简单就能实现(主要是觉得文章篇幅太长没人看),有兴趣的同学可以自己研究一下。
最后总结
递归的思想相对迭代思想,稍微有点难以理解,处理的技巧是:不要跳进递归,而是利用明确的定义来实现算法逻辑。
处理看起来比较困难的问题,可以尝试化整为零,把一些简单的解法进行修改,解决困难的问题。
值得一提的是,递归操作链表并不高效。和迭代解法相比,虽然时间复杂度都是 O(N),但是迭代解法的空间复杂度是 O(1),而递归解法需要堆栈,空间复杂度是 O(N)。所以递归操作链表可以作为对递归算法的练习或者拿去和小伙伴装逼,但是考虑效率的话还是使用迭代算法更好。