数据结构与算法 -- LeetCode中二叉树相关问题解题套路(1)

2,022 阅读10分钟

1 二叉树的最大深度

计算二叉树的最大深度,即根节点到叶子节点的最大深度,常见的方法有两种:深度优先遍历广度优先遍历

深度优先遍历,是分别获取到左子树和右子树的深度,从而根据公式:max(左子树深度,右子树深度) + 1获取二叉树的最大深度。

递归实现

public int maxDepth(TreeNode root) {
    if(root == null){
        return 0;
    }
    int leftHeight = maxDepth(root.left);
    int rightHeight = maxDepth(root.right);
    return Math.max(leftHeight,rightHeight) + 1;
}

image.png

其实通过递归求解二叉树的问题,很简单!记结论就行了:

  • 问下左树,能提供哪些信息,例如高度是多少?
  • 问下右树,能提供哪些信息,例如高度是多少?
  • 最终返回问题的答案,例如二叉树的最大高度就是:max(左子树深度,右子树深度) + 1

那么为什么能这么做,我们需要知道其中核心原理:遍历序。我们知道二叉树有3种遍历的方式,分别是前序、中序、后序,所谓遍历序就是遍历二叉树时,每个节点都会走3次。

按照上图中的二叉树,在遍历的过程是这样的:

head起始节点为5.

如果要遍历左树,首先会到节点2,然后再查节点2的左树发现为空,返回2,再检查2的右树发现为空,再返回2.

5 - 2 - 2 - 2 - 5

然后此时再转向右树,到节点1,如此反复,直到查到节点为空往上返回。

1 - 4 - 4 - 4 - 1 - 6 - 6 - 6 - 1 - 5

其实最终递归拿到的就是全部节点的深度,例如2因为左右节点均为空,因此深度为1;加上根节点,那么左子树的最大深度为2;

再看右边,节点4的深度为1,节点6的深度为1,因此节点1的深度为2,加上根节点,右子树的最大深度为3.

因此整个二叉树的最大深度为3.

整个的遍历过程:

5 - 2 - 2 - 2 - 5 - 1 - 4 - 4 - 4 - 1 - 6 - 6 - 6 - 1 - 5

//前序遍历:取第一个出现的数字  5- 2 - 1 - 4 - 6
//中序遍历:取第二个出现的数字  2 - 5 - 4 - 1 - 6
//后序遍历:取第三个出现的数字 2 - 4 - 6 - 1 - 5

当然在面试的时候,可能不会让我们这么简单地把题目做出来,因此需要使用非递归的方式,此时就需要使用栈来处理。

非递归实现

非递归实现,是广度优先遍历的实现方案,目的就是一层一层遍历二叉树,直到叶子节点。

所以通过递归的方式,我们需要知道每一层的开始和结束,每层遍历结束之后,深度+1,通过这种方式也可以完成宽度优先遍历,获取哪一层有最多的节点数。

那么如果只是查深度,只通过栈就可以完成,因为我们知道栈这种数据结构是先进后出:

//使用栈数据结构
Queue<TreeNode> stack = new LinkedList();
//先把头节点放进去
stack.offer(root);

image.png

此时栈内只有第一层的元素,那么我们只把这一层的元素全部弹出,但是不影响节点插入,所以需要使用while循环一直pop。

//开始一层一层遍历
while(!stack.isEmpty()){
    //获取当前栈内元素个数
    int size = stack.size();
    while(size > 0){
        //在while循环中,会弹出栈内全部的元素,也可以认为是这一层的元素
        TreeNode node = stack.poll();
        //判断是否存在左右节点
        if(node.left != null){
            stack.offer(node.left);
        }
        if(node.right != null){
            stack.offer(node.right);
        }
        size--;
    }
    //记录层数
    ans++;
}

此时stack的size为1,那么在while循环中,pop全部的节点,同时把pop出去的节点的左右节点入栈。

image.png

一层遍历完成之后,此时stack内部全部是二层的节点,而且stack不是空的,因此继续遍历把二层的全部节点均pop出去。

如此循环直到stack为空,此时所有的层级均遍历完成。


public int maxDepth(TreeNode root) {
    if(root == null){
        return 0;
    }
    //使用栈数据结构
    Queue<TreeNode> stack = new LinkedList();
    //先把头节点放进去
    stack.offer(root);
    //记录层数
    int ans = 0;

    //开始一层一层遍历
    while(!stack.isEmpty()){
        //获取当前栈内元素个数
        int size = stack.size();
        while(size > 0){
            //在while循环中,会弹出栈内全部的元素,也可以认为是这一层的元素
            TreeNode node = stack.poll();
            //判断是否存在左右节点
            if(node.left != null){
                stack.offer(node.left);
            }
            if(node.right != null){
                stack.offer(node.right);
            }
            size--;
        }
        //记录层数
        ans++;
    }

    return ans;

}

我们可以记住一点,任何二叉树的问题都可以通过深度优先遍历完成,像二叉树一致性二叉树对称性等,可以通过深度优先遍历一层一层比较。

二叉树前序、中序、后序遍历

对于二叉树的前序、中序、后序遍历不在此多讲,实现的方式有很多:递归和非递归。通过递归实现非常简单:

private  List<Integer> list = new ArrayList();
public List<Integer> inorderTraversal(TreeNode root) {
     if(root != null){
         inorderTraversal(root.left);
         list.add(root.val);
         inorderTraversal(root.right);
     }
    return list;
}

例如中序遍历,在往List中添加元素的时候,是在遍历整个左子树之后,同理,前序和后序遍历,就需要将添加元素的时机放在遍历前和遍历后。

递归很简单,我不在此赘述,面试的时候,可能没有这么简单,需要我们使用非递归的方式完成,那么我们需要记住公式即可。

非递归前序遍历

  • 先将根节点入栈;
  • 弹出节点立刻打印,判断当前弹出的节点是否存在右孩子,有就压栈;
  • 随后再判断是否存在左孩子,有就压栈;
  • 直到栈内没有节点,结束。

为什么先压右孩子,是因为栈属于先进后出的数据结构,保证每次弹出节点都是先处理左孩子,符合前序遍历中左右的顺序。


private List<Integer> list = new ArrayList();
public List<Integer> preorderTraversal(TreeNode root) {
    if(root == null){
        return list;
    }

    Stack<TreeNode> stack =  new Stack();
    stack.push(root);
    while(!stack.isEmpty()){

        TreeNode node = stack.pop();
        list.add(node.val);
        if(node.right != null){
            stack.push(node.right);
        }
        if(node.left != null){
            stack.push(node.left);
        }

    }
    return list;
}

非递归后序遍历

非递归的后序遍历,其实也可以按照这样的方式,只不过稍微有些不同:

  • 先将根节点入栈;
  • 弹出节点立刻打印,将其保存在一个栈内,判断当前弹出的节点是否存在左孩子,有就压栈;
  • 随后再判断是否存在右孩子,有就压栈;
  • 直到栈内没有节点,结束。

此时遍历的过程,是头右左的顺序,将其反转就是左右头的顺序,满足后序遍历的顺序,这也是为什么需要额外一个堆栈的原因。


public List<Integer> postorderTraversal(TreeNode root) {
    List<Integer> list = new ArrayList();

    if(root == null){
        return list;
    }

    Stack<TreeNode> stack = new Stack();
    Stack<TreeNode> postorStack = new Stack();

    stack.push(root);

    while(!stack.isEmpty()){

        TreeNode node = stack.pop();
        postorStack.push(node);
        if(node.left != null){
            stack.push(node.left);
        }
        if(node.right != null){
            stack.push(node.right);
        }
    }

    while(!postorStack.isEmpty()){
        TreeNode node = postorStack.pop();
        list.add(node.val);
    }
    return list;
}

非递归中序遍历

  • 从根节点开始,从左孩子一直遍历,直到左孩子为空;
  • 此时弹出节点就打印,同时转向当前节点的右孩子;
  • 如果右孩子为空就继续弹出栈内的节点;如果不为空,那么就继续压栈当前节点的左孩子。
public void inorder(TreeNode root,List<Integer> list){

    //需要一个栈
    Stack<TreeNode> stack = new Stack();
    while(!stack.isEmpty() || root != null){

        if(root != null){
            stack.push(root);
            //左边一整条全部入栈
            root = root.left;
        }else{
            //弹出节点就打印
            TreeNode node = stack.pop();
            list.add(node.val);
            //然后就去右树找,如果右子树也为空,继续弹出
            root = node.right;
        }
    }
}

构造二叉树节点的附属节点

前面我们是介绍了二叉树一些常见的算法,其实在遍历二叉树的时候,我们的思维不要只局限于在树上的节点做事情,在遍历二叉树的时候,其实是可以记录一些与题目相关的节点信息,相当于二叉树节点的一些附属信息,但又不在树上, 这时就会对我们解题有很大的帮助。

题目

给你二叉树的根节点 root 和一个表示目标和的整数 targetSum 。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。如果存在,返回 true ;否则,返回 false 。

叶子节点 是指没有子节点的节点。

题解

这道题的核心是要遍历到叶子节点,即便中间有几个节点和为 targetSum 也不行,所以我们在遍历二叉树的同时,需要构建每个节点的附属节点,如下图:

image.png

这道题附属节点就是沿着一条路径上的节点之和,例如遍历到节点5时,其附属节点sum = 5;然后判断当前节点是否存在左右节点,如果存在那么就加入到队列中;例如左孩子4,其附属节点sum = 5+4;如此遍历。

当遍历到叶子节点时,即当前节点的left和right为空,此时判断当前节点的附属节点的值,如果等于 targetSum,那么直接return。

public boolean hasPathSum(TreeNode root, int targetSum) {
    //一定是到叶子节点
    if(root == null){
        return false;
    }
    //两个队列,记录节点和总和
    Queue<TreeNode> nodeStack = new LinkedList();
    Queue<Integer> valStack = new LinkedList();

    nodeStack.offer(root);
    valStack.offer(root.val);

    while(!nodeStack.isEmpty()){


        TreeNode node = nodeStack.poll();
        int sum = valStack.poll();

        //记住得是叶子节点
        if(node.left == null && node.right == null){
            if(sum == targetSum){
                return true;
            }
        }

        if(node.left != null){
            nodeStack.offer(node.left);
            valStack.offer(sum + node.left.val);
        }

        if(node.right != null){
            nodeStack.offer(node.right);
            valStack.offer(sum + node.right.val);
        }
    }

    return false;

}

题目

给你一个二叉树的根节点 root ,树中每个节点都存放有一个 0 到 9 之间的数字。

每条从根节点到叶节点的路径都代表一个数字:

  • 例如,从根节点到叶节点的路径 1 -> 2 -> 3 表示数字 123 。

计算从根节点到叶节点生成的 所有数字之和 。

叶节点 是指没有子节点的节点。

题解

其实这道题的核心在于不是简单的加减法,而是从根节点到叶子节点路径上所有的数字组合成一个整数,如何求这个整数?

1 -> 2 -> 3

//遍历到1
sum = 1
//遍历到2
sum = sum * 10 + 212
//遍历到3
sum = sum * 10 + 312 * 10 + 3 = 123

所以根据上述求和算法,这道题就很简单了,依然去构造每个节点的附属节点,碰到叶子节点即判断是否满足题意。


public int sumNumbers(TreeNode root) {
    if(root == null){
        return 0;
    }

    //首先需要广度遍历全部的节点
    Queue<TreeNode> nodeStack = new LinkedList();
    Queue<Integer> arrStack = new LinkedList();
    nodeStack.offer(root);
    arrStack.offer(root.val);

    int sum = 0;

    while(!nodeStack.isEmpty()){

        TreeNode node = nodeStack.poll();
        int res = arrStack.poll();

        //查到了叶子节点
        if(node.left == null && node.right == null){
            sum += res;
            continue;
        }


        if(node.left != null){
            nodeStack.offer(node.left);
            arrStack.offer(res * 10 + node.left.val);
        }

        if(node.right != null){
            nodeStack.offer(node.right);
            arrStack.offer(res * 10 + node.right.val);
        }

    }
    return sum;

}

二叉树节点的公共祖先

求解二叉树两个节点的公共祖先是常见的面试问题,一般来说会有两种:最近或者最远的公共祖先。

image.png

例如求节点p = 6和q = 4的公共祖先,节点6的祖先节点为3、5、6,节点4的祖先节点为3、5、2、4,所以两者的公共祖先就是3、5,其中5为最近的公共祖先,3为最远的公共祖先。

因此求解这种题目的思路就比较明确了:

  • 求节点p、q的路径;
  • 求两条路径上公共节点,并根据题意取最近或者最远的节点。

那么如何寻找某个节点的路径,但凡涉及到路径的问题,均可以构造附属节点来完成。

image.png

public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
    if(root == null){
        return null;
    }
    if(p == null || q == null){
        return null;
    }
    List<TreeNode> p_path = getPath(root,p);
    List<TreeNode> q_path = getPath(root,q);
    //开始找最近节点
    return getCommonAncestor(p_path,q_path);

}

private TreeNode getCommonAncestor(List<TreeNode> p,List<TreeNode> q){
    TreeNode res = null;
    if(p == null || q == null){
        return res;
    }

    for(int i = 0;i<p.size() && i<q.size();i++){
        if(p.get(i).val == q.get(i).val){
            res = p.get(i);
        }else {
            break;
        }
    }

    return res;
}

private List<TreeNode> getPath(TreeNode root,TreeNode target){

    Queue<TreeNode> stack = new LinkedList();
    //这里存储了每个节点的祖先节点
    Queue<List<TreeNode>> path = new LinkedList();
    //先把根节点压栈
    stack.offer(root);
    List<TreeNode> list = new ArrayList();
    list.add(root);
    path.offer(list);

    if(root.val == target.val){
        return list;
    }

    while(!stack.isEmpty()){
        TreeNode node = stack.poll();
        List<TreeNode> nodePath = path.poll();
        if(node.val == target.val){
            return nodePath;
        }
        if(node.left != null){
            stack.offer(node.left);
            List<TreeNode> leftPath = new ArrayList(nodePath);
            leftPath.add(node.left);
            path.offer(leftPath);
        }
        if(node.right != null){
            stack.offer(node.right);
            List<TreeNode> rightPath = new ArrayList(nodePath);
            rightPath.add(node.right);
            path.offer(rightPath);
        }
    }
    //都没找到,就直接返回null
    return null;
}

具体的思路是这样的,还有很大的优化空间,感兴趣的伙伴把优化的算法贴在评论区。