1.怎么理解递归?
首先来复习Java的方法调用机制原理:
1)程序每次调用方法时,都会把当前的局部变量、参数值和返回地址等压入到栈中
2)当方法执行完毕或碰到return语句后,就会返回,返回到调用该方法的地方;同时,执行完毕的方法对应的栈空间释放,即从栈顶弹出上一层的各项参数,然后继续执行
3)返回到调用该方法的地方后继续执行后面的代码
而所谓递归,不过就是方法中自己调用自己,只是每次调用传入了不同的变量,导致不同的方法中,局部变量的值不同。
例如f(n)函数计算n的阶乘的递归中,就如图所示,每一次调用同名方法都会开辟一个独立的栈空间并存放n的值:
说人话就是,递归本质也是方法的调用,递归多次调用同名方法,但也是一个个互相独立的方法;
而每次调用一个方法的时候都会把当前方法的变量状态保存起来,等调用完毕、返回的时候继续用这些变量。
2.怎么写递归代码?
三点需要着重注意:
1)写出递推公式。比如阶乘f(n) = f(n-1) * n,就是递推公式,同样的还有斐波那契数列f(n) = f(n-1) + f(n-2)。递推公式的特征是:执行范围越来越接近终止条件。比如n越来越接近1。
2)写出递归终止条件。写递归代码一定要写终止条件,即满足什么条件时终止递归,如上面的if (n == 1) return n ,就在n == 1时返回了具体值,终止了递归。这点特别重要,因为如果你没有终止条件的话,递归就成了死循环,不停递归直到内存炸裂为止。
3)递归终止的判断要写在递归调用的前面。这样说可能有点抽象,举个栗子:
public void printN(int n){
System.out.println("Hello,world!");
printN(n-1);
if(n == 0){
return;
}
}
这个就是非常典型的错误。程序将会不断print(“hello,world”),在printN(0)调用后,仍然继续调用printN(-1),printN(-2).....直到程序崩溃为止。在这个过程中,每次递归调用后直接开了新方法,根本没有执行到递归终止条件(if语句)的判断,所以就会出错。
这点在编写复杂递归时非常重要。请记住:终止条件判断在递归调用的前面!
4)从大到小画图推演,验证递归方法编写的正确性。
小结:所以,在编写递归程序时,步骤是:
1)首先分情况讨论,思考递归终止条件,并将终止条件的判断写在方法的靠前位置;
2)然后思考递推公式怎么写,并将其写在方法的靠后位置。
3)递推公式和终止条件组合起来,变成完整的方法,并画过程图检验。
3.二叉树遍历(递归写法)
(有关二叉树前、中、后序遍历的概念请在我的主页看上一篇文章)
1.遍历二叉树的递归终止条件:以前序遍历为例,顺序为根节点、遍历左子树、遍历右子树;那么刚开始就会以根、左、根、左、根、左....的顺序一直执行,什么时候判断一个子树遍历完成呢?对了,就是当子树的根节点为null的时候,此时认为遍历完成并返回。找到了终止条件,再结合先访问根节点、再左子树、再右子树的规律,递归代码就很简单了,中序、后序同理:
二叉树节点定义如下:
public class TreeNode { public int val; public TreeNode left;//左孩子 public TreeNode right;//右孩子
}
public static void preOrder(TreeNode root,List<Integer> res){ //前序遍历 if (root == null){ return; } res.add(root.val); preOrder(root.left,res); preOrder(root.right,res); } public static void midOrder(TreeNode root,List<Integer> res){ //中序遍历 if (root == null){ return; } midOrder(root.left,res); res.add(root.val); midOrder(root.right,res); } public static void afterOrder(TreeNode root,List<Integer> res){ if (root == null){ return; } //后序遍历 afterOrder(root.left,res); afterOrder(root.right,res); res.add(root.val); }
这里需要注意的是,结果集res不能在方法内定义,否则每个方法都有自己的结果集,添加的数据不在一个结果集中,会造成错误。所以这里的res应该是在调用前定义(List res = new ArrayList<>()),调用时当做参数传入,然后所有的递归调用的res就都指向的是一个ArrayList。
4.二叉树遍历(迭代写法)
虽然二叉树的递归遍历很方便,但是有些面试会要求会写迭代遍历二叉树,所以这个也要掌握。
我们来看看二叉树递归是怎么执行的:
res.add(root.val);
preOrder(root.left,res);
preOrder(root.right,res);
以前序遍历为例,先访问根节点,再访问左子树,最后访问右子树。访问左子树的时候又会进入左子树的前序遍历,(相当于又进入了一个方法),先访问左子树的根节点,再访问这个左子树的左子树......所以我们可以发现,顺序是这样的:根-左-根-左-根-左......直到根节点为null为止。所以发现没有,可以使用一个循环来实现这个过程。那么根节点为null之后呢?从根节点一直根-左-根-左到null,这个Null是它父节点的左子树,那么就要拿出父节点的右子树进行遍历。
所以由此我们又得出:应该使用一段内存来保存遍历过程中走过的节点,这样我们才能在发现子树根节点为null时,能从这段内存中拿出保存过的父节点,然后再拿到右子树进行遍历。这里我们使用一个栈来实现,代码如下:
public static List<Integer> preOrderTraversal(TreeNode root){ ArrayList<Integer> res = new ArrayList<>(); if (root == null){ return res; } Stack<TreeNode> stack = new Stack<>(); TreeNode node = root; while (!stack.isEmpty() || node != null){ //栈非空或者当前节点不为null,就要一直进行前序遍历 while (node != null){ res.add(node.val); stack.push(node); node = node.left; } //根-左-根-左的顺序直到为null,走过的节点全部加入到栈中 TreeNode pop = stack.pop(); node = pop.right; //拿到right后再次进行根-左-根-左的重复 } return res; }
这里有两个细节:一个是当node = pop.right后,如果node不为null的话,就继续根-左-根-左;那如果为null怎么办?看个简单的例子:
前序遍历顺序如下:先访问节点3,节点3入栈;再访问3的左子树:访问9树时,节点9入栈;访问9的左子树:为null,stack.pop()出栈,拿到节点9,然后再拿到9的右子树,仍然为null。注意,这里就是上面所说的,node = pop.right之后为null的情况。这个时候node为null但栈不为null,大循环仍然进行;但小循环因为node == null所以直接跳过,再次stack.pop()。这个时候出栈的是谁呢?是节点3。也就是往上两层的父节点。然后再拿到3的右子树20,开始右子树的遍历......
这就是pop.right为null的情况。最后直到node也为null,栈也为null时(即访问到节点7的右子树发现为null时,此时栈也为null),这时就结束了大循环,完成了二叉树的迭代前序遍历。
有些复杂,可以好好消化一下。
同理,同样使用一个栈来实现二叉树的迭代中序遍历:
我们知道,中序
midOrder(root.left,res);
res.add(root.val);
midOrder(root.right,res);先遍历左子树,遍历左子树又要先遍历左子树的左子树,所以顺序就是左-左-左......这又是一个循环,我们写出如下代码:
public static List<Integer> midOrderTraversal(TreeNode root){ ArrayList<Integer> res = new ArrayList<>(); if (root == null){ return res; } Stack<TreeNode> stack = new Stack<>(); TreeNode node = root; while (!stack.isEmpty() || node != null){ while (node != null){ stack.push(node); node = node.left; } //一直左-左-左直到null为止 TreeNode pop = stack.pop(); res.add(pop.val); //然后拿到父节点并加入结果集 node = pop.right; //把pop.right赋给node继续循环 } return res; }
写到这里,可能有小伙伴觉得那后序遍历也很简单了,其实不是,我们来看看后序遍历还按照这个思路有什么问题。
后序遍历:
afterOrder(root.left,res);
afterOrder(root.right,res);
res.add(root.val);
左-左-左遍历到null了拿到右子树,但是遍历右子树又要左-左-左,那怎么循环?假设这么写:
while (!stack.isEmpty() || node != null){ while (node != null){ stack.push(node); node = node.left; } TreeNode pop = stack.pop(); }
假设像上面这么写,那就没有加入结果集的操作;假如把加入结果集放在TreeNode pop = stack.pop();后面,也不行,那就是右子树还没有遍历完就先加入了根节点,是错误的。
所以上面的思路是不行的。那后序遍历的迭代写法难道没有吗?有的,这里介绍一种简单的写法:反转法。
5.特殊情况:反转法实现后序迭代遍历
先看这个图,后序遍历的序列是啥?是【9 15 7 20 3】
那如果我们根据类似前序遍历的写法,不过将根-左-右改成根-右-左的顺序,我们会得到这样一个序列:【3 20 7 15 9】。发现了啥?这个序列是后序遍历的倒序。虽然看起来有点巧合,但是这个已经是相对简单的方法了,记住它就好了。
这样我们就可以改写前序遍历来完成后序迭代遍历:
public static List<Integer> afterOrderTraversal(TreeNode root){ ArrayList<Integer> res = new ArrayList<>(); if (root == null){ return res; } Stack<TreeNode> stack = new Stack<>(); TreeNode node = root; while (!stack.isEmpty() || node != null){ while (node != null){ res.add(node.val); stack.push(node); node = node.right; } //先通过根-右-根-右的循环直到子树根节点为null TreeNode pop = stack.pop(); node = pop.left; //再把出栈元素的右子树赋给node并继续大循环,原理同前序遍历的解释 } Collections.reverse(res);//Collections类的reverse方法反转ArrayList,得到后序序列。 return res; }
通过这篇文章你是否理解了二叉树遍历的递归与迭代写法呢?希望有所收获!