循环与递归?
递归:程序调用自身的编程技巧称为递归( recursion)
突然脑子闪现一个想法:递归是调用自身,再直白一点就是调用同一段代码,这不就是循环也可以做到的事吗?
以单链表转置代码为例:
// 双指针
class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
ListNode temp = null;
while (cur != null) {
temp = cur.next;// 保存下一个节点
cur.next = prev;
prev = cur;
cur = temp;
}
return prev;
}
}
改成递归写法:
class Solution {
ListNode temp = null;
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode cur = head;
return reverse(null, head);
}
public ListNode reverse(ListNode prev, ListNode cur) {
if (cur == null) { //递归出口等同于while的结束条件
return prev;
}
temp = cur.next;// 先保存下一个节点
cur.next = prev;// 反转
// 更新prev、cur位置
prev = cur;
cur = temp;
return reverse(prev, cur);
}
}
更直观一点观察:
可见,将循环改成递归,递归出口正是while循环的终止条件,甚至是翻转链表的核心代码都一模一样。
递归的写法?如何切入递归
尾递归
如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。
尾递归模式:
函数(){
//递归出口
... ...
//核心函数
... ...
//递归函数
... ...
}
非尾递归:
函数(){
//递归出口
... ...
//递归函数
... ...
//核心函数
... ...
}
递归出口怎么找
找递归出口只要考虑两个方面就行了,我们将递归出口想象成一条双端通道,如下图所示,它只有两个出口。
第一个出口:
我们也可以看做是入口,即判断给定的初始条件是否满足核心代码的要求。
第二个出口:
1、判断调用递归函数什么时候不满足核心代码的要求(某些时候相当于第一个出口)
总结:找不满足核心函数的条件,并按照所希望的需求返回。
下面通过两种翻转链表的递归写法来理解递归出口:
1、从前向后翻转
// 递归
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;// 反转
// 更新prev、cur位置
// prev = cur;
// cur = temp;
return reverse(cur, temp);
}
}
我们根据核心代码:
ListNode temp = null;
temp = cur.next;// 先保存下一个节点
cur.next = prev;// 反转
prev = cur;
cur = temp;
reverse(prev, cur);
不难看出,cur肯定不能为空,否则cur.next一定报NPE异常
所以一定有递归出口判断if(cur==null)
:
if (cur == null) {
return ?;
}
但是要返回什么值呢?根据图可以看出,无论单链表是个空节点还是非空节点,返回pre就可以满足需求,因此return prev
if (cur == null) {
return prev;
}
体会:此情况下第二出口不就是和第一出口一模一样吗?(就是将问题规模从大放小:出口限制其实一样。),所以,可以总结:当核心代码只有一个条件限制的时候,第一和第二出口其实是一样的。
2、从后向前翻转
// 从后向前递归
class Solution {
ListNode reverseList(ListNode head) {
// 边缘条件判断
if(head == null) return null;
if (head.next == null) return head;
// 递归调用,翻转第二个节点开始往后的链表
ListNode last = reverseList(head.next);
// 翻转头节点与第二个节点的指向
head.next.next = head;
// 此时的 head 节点为尾节点,next 需要指向 NULL
head.next = null;
return last;
}
}
根据核心代码:
reverseList(head.next);
// 翻转头节点与第二个节点的指向
head.next.next = head;
// 此时的 head 节点为尾节点,next 需要指向 NULL
head.next = null;
同理:head以及head.next都不能为空,否则也会报NPE异常
递归出口判断一定有:
if(head == null) return ?;
if(head.next == null) return ?;
具体每个条件下应该返回什么呢?
此时注意,当出现两个及以上的递归出口的时候,我们要分辨他们属于第一还是第二出口。
可以看出if(head == null) return ?;
这条出口判断,一定是用来判断给定的初始链表是否为空链表,肯定不会是非空链表递归到尾部而进行的空判断。
所以对于空链表,直接返回null就可以了;而if(head.next == null) return ?;
是非空链表递归到尾部的出口,直接返回head就行了。
我们可以验证一下:
根据图可以看到:将第一出口空链表判断注释掉,输入非空链表进行测试,测试成功。
但是如果输入空链表,则会报NPE:
正确如下:
因此根据不同出口,我们返回不同的需求值:
if(head == null) return null;//return head 也可以
if(head.next == null) return head;
同时也要注意,在递归出口判断上,也要注意先后顺序,例如这里
if(head.next == null) return head;
if(head == null) return null;
一定要先判断head==null
,再判断head.next==null
,顺序不能要换,否则也会报NPE。
总结
先写核心代码(一般情况下的核心代码),最后判断核心代码的不满足条件:第一出口,第二出口,再返回需求值。
我更偏好不带返回值的递归写法
题目:
官方代码递归写法:
class Solution {
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = head.next;
head.next = swapPairs(newHead.next);
newHead.next = head;
return newHead;
}
}
对于带返回值的递归,我将其归为:依赖于上一层递归中的变量。
因此,我将带返回值的递归函数写成不带返回值的递归函数,将依赖的变量通过递归函数的形参传递。
下面是我自己写的代码:
思路顺序同上:
1、一般情况下的核心代码
2、找不满足一般情况的递归出口
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode fakehead = new ListNode();
fakehead.next = head;
ListNode pre = fakehead;
swapnode(pre, head);
return fakehead.next;
}
public void swapnode(ListNode pre, ListNode head){
if(head == null || head.next == null){
return;
}
ListNode next = head.next.next;
pre.next = head.next;
pre.next.next = head;
head.next = next;
swapnode(head, next);
}
}
针对这道题,对于链表节点交换,我们需要交换节点的上一节点,因此可以考虑在递归函数的形参中传入。我认为这样看着更加直观也更好理解。