Java —— 一文理解递归与二叉树遍历

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;
}

通过这篇文章你是否理解了二叉树遍历的递归与迭代写法呢?希望有所收获!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值