一、前言
递归在算法题中是很常用的,但是可能是因为人的思维方式的问题,很多时候会觉得一看都会一写就废。我也做了不少递归的题目了,但是大部分都没办法完全自己独立完整地写出来,所以感觉有必要好好整理一下,一味得追求数量也没有用,总结出自己的东西来才能以不变应万变。
二、正文
在进行分析之前,我们还必须要搞清楚递归程序执行过程:
每调用一次函数,都是等它彻底执行完毕之后,才会返回去继续执行。
递归调用其实调用的并不是本身,调用程序里的一个函数其实并不是真的执行了它,而是开辟了一份空间,把其中的数据(包括参数和函数里的一些变量等)存在其中,然后根据写的代码去修改这些数据,每次调用一个函数就会开辟一份这样的空间,,互相独立,执行完毕之后释放。
举个简单的例子:
public static void C(){
System.out.println("Method C is called");
}
public static void B(){
C();
System.out.println("Method B is called");
}
public static void A(){
B();
System.out.println("Method A is called");
}
public static void main(String[] args) {
A();
}
输出:
Method C is called
Method B is called
Method A is called
2.1、方法总结
总的来说,递归无非就是写一个方法,先写出一个终止条件,然后根据题目,找出递推关系,进行递归。具体又可以拆分对应成以下三步:
- 将大问题分解为小问题:明确函数功能,把原问题分解为若干个相对简单类同的子问题;
- 寻找递归结束条件:也就是递归的出口,需要找出当参数为什么时,递归结束,之后直接把结果返回(这个时候我们必须能根据这个参数的值,能够直接知道函数的结果是什么);
- 本级递归实现:要弄清楚在这一级递归中,应该完成什么任务,应该给上一级返回什么信息。
最需要注意的是,一定要走出思维误区,重点去关注一级递归的解决过程;因为递归是一个反复调用自身的过程,说明它每一级的功能都是一样的。
2.2、实战分析
实战目录:
- 两两交换链表中的节点
- 反转单链表
- 求二叉树的最大深度
- 平衡二叉树
2.2.1. 两两交换链表中的节点
对应上面的三步
①将一个链表(大问题)分解为三个结点(小问题)。
②当链表只剩一个节点或者没有节点的时候,不能再交换了,递归就终止了。
③只考虑本级递归,所以这个链表在我们眼里其实也就三个节点:head、head.next、已处理完的链表部分(为什么是这个顺序可以结合上文中递归程序执行过程思考一下)。而本级递归的任务也就是交换这3个节点中的前两个节点。需要返回给上一级递归的是已经完成交换处理的链表。
代码:
public ListNode swapPairs(ListNode head) {
//终止条件:链表只剩一个节点或者没节点了,没得交换了。返回的是已经处理好的链表
if(head == null || head.next == null){
return head;
}
//一共三个节点:head, next, swapPairs(next.next)
//下面的任务便是交换这3个节点中的前两个节点
ListNode next = head.next;
head.next = swapPairs(next.next);
next.next = head;
//返回给上一级的是当前已经完成交换后,即处理好了的链表部分
return next;
}
2.2.2. 反转单链表
对应上面的三步
①将一个链表(大问题)分解为两个结点(小问题)。
②当链表只剩一个节点或者没有节点的时候,不能再反转了,递归就终止了。
③只考虑本级递归,所以这个链表在我们眼里其实也就两个节点:head、head.next(已处理好的链表的头结点)。而本级递归的任务也就是变换这2个节点指针的方向。需要返回给上一级递归的是已经完成交换处理的链表。
代码:
public static Node reverseList(Node head){
//递归结束条件
if (head == null || head.next == null) {
return head;
}
//递归反转子链表
Node newList = reverseList(head.next);
//改变head,head.next节点的指向。
Node t1 = head.next;
//让head.next指向head
t1.next = head;
//head的next指向null.
head.next = null;
//把调整之后的链表返回。
return newList;
}
小结:
由上述两个例子可以看出,若是涉及到整个链表的操作,其实一般都可以分解成2/3个结点来进行递归;它们的终止条件也大部分都是当链表只剩一个节点或者没有节点的时候。
2.2.3. 求二叉树的最大深度
对应上面的三步
①将一个树(大问题)分解为三个结点(小问题)。
②当树为空的时候,此时树的深度为0,递归就结束了。
③只考虑本级递归,所以这个树在我们眼中就三个节点:root、root.left、root.right。而本级递归的任务也就是在root的左右子树中选择较大的一个,再加上1就是以root为根的子树的最大深度。需要返回给上一级递归的也就是这个深度即是当前树的最大深度。
代码:
public int hight(TreeNode node){
//终止条件:当树为空时结束递归,并返回当前深度0
if(node==null){
return 0;
}else{
//root的左、右子树的最大深度
int i=hight(node.left);
int j=hight(node.right);
//返回的是左右子树的最大深度+1
return (i<j)?(j+1):(i+1);
}
}
2.2.4. 平衡二叉树
对应上面的三步
①将一个树(大问题)分解为三个结点(小问题)
②当树为空的时候,空树自然是平衡二叉树,递归就结束了。
③只考虑本级递归,所以这个树在我们眼中就三个节点:root、root.left、root.right。而本级递归的任务也就是首先判断left子树和right子树是否是平衡二叉树,如果不是则直接返回false;再判断两树高度差,如果大于1也直接返回false。否则说明以root为节点的子树是平衡二叉树,那么就返回true和它的高度。返回的信息应该是既包含子树的深度的int类型的值,又包含子树是否是平衡二叉树的boolean类型的值。可以单独定义一个ReturnNode类但是这样比较麻烦,我们可以简化,一旦树不平衡就不用往下接着判断,所以可以通过一个int值来判断。
代码:
public static boolean isBalanced(TreeNode root){
return subB(root)>=0;
}
private static int subB(TreeNode root) {
if (root==null) return 0;
int left=subB(root.left);
int right=subB(root.right);
if (left>=0&&right>=0&&Math.abs(left-right)<=1){
return Math.max(left,right)+1;
}else {
//-1表示子树已经不平衡了,没必要继续判断
return -1;
}
}
小结:
树中可以利用递归的情况也很多,绝大部分也都是分解成root、root.left、root.right来进行递归;它们的终止条件也大部分都是当树为空的时候。
Tip:另外我们也会发现,很多树的递归方法一般也可以通过栈迭代来实现
三、尾声
总结一下可以用递归的算法(待更新…):
链表:
- 删除排序链表中的重复元素
二叉树:
- 二叉树结点个数
- 二叉树的叶子结点个数
- 翻转二叉树
- 判断树是否为镜像对称的
- 二叉树的最小深度
- 合并二叉树
- 最大二叉树
- 二叉搜索树转换为双向链表
其他:
- 数组求和
- 斐波那契数列
- 青蛙跳台阶(普通跳和变态跳)
- 矩形覆盖
- 找零钱问题
- 汉诺塔问题
- 矩阵中的路径
总结:
递归本身其实并不困难,但是因为执行方式比较特别所以一时很难直接想到,其实掌握一定的规律适应了之后用起来也能得心应手,并且能更加方便地解决许多单纯用循环较难解决的问题。
但是递归真的一定好吗,正如我的另一篇文章:斐波那契数列递归算法优化 所说的,递归算法很多时候看起来会很简洁,但是效率往往让人咂舌。然而掌握递归的思想还是很重要的,有些问题可能用迭代真的会特别复杂,这时候牺牲一些效率用递归也未尝不好。
那有没有暴力递归的优化呢,当然是有的,那就是可能比递归更常用到的方法——动态规划(待更新…)。