【算法修炼】二叉树算法一

本文深入探讨二叉树的前中后序遍历,讲解它们在处理节点时的不同时间点,以及如何利用遍历解决二叉树相关问题。举例介绍了最大深度、直径、路径总和等算法的实现,强调了遍历位置的重要性,并通过实例展示了如何在不同位置进行代码操作以满足题目需求。
摘要由CSDN通过智能技术生成

又开启了新的一章!数据结构中二叉树的相关算法的学习!
同样学习自大佬:https://labuladong.gitee.io/algo/2/18/

大佬一直强调先刷二叉树,但是自己相见恨晚,回溯、递归学完了才来刷二叉树,先刷二叉树可以对这之后的很多算法有更好的理解。

一、二叉树的遍历

树的问题就永远逃不开树的递归遍历框架这几行破代码,无非就是通过遍历来获取题目中需要的答案:

/* 二叉树遍历框架 */
void traverse(TreeNode root) {
    // 前序遍历
    traverse(root.left);
    // 中序遍历
    traverse(root.right);
    // 后序遍历
}

先不管所谓前中后序,单看这段代码是什么?

其实就是一个能够遍历二叉树所有节点的一个函数,和遍历数组或者链表本质上没有区别:

/* 迭代遍历数组 */
void traverse(int[] arr) {
    for (int i = 0; i < arr.length; i++) {

    }
}

/* 递归遍历数组 */
void traverse(int[] arr, int i) {
    if (i == arr.length) {
        return;
    }
    // 前序位置
    traverse(arr, i + 1);
    // 后序位置
}

/* 迭代遍历单链表 */
void traverse(ListNode head) {
    for (ListNode p = head; p != null; p = p.next) {

    }
}

/* 递归遍历单链表 */
void traverse(ListNode head) {
    if (head == null) {
        return;
    }
    // 前序位置
    traverse(head.next);
    // 后序位置
}

单链表和数组的遍历可以是迭代的,也可以是递归的,二叉树这种结构无非就是二叉链表,不过没办法简单改写成迭代形式,所以一般说二叉树的遍历框架都是指递归的形式。

你也注意到了,只要是递归形式的遍历,都会有一个前序和后序位置,分别在递归之前和之后。

所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候。

前序遍历

前序遍历首先访问根节点,然后遍历左子树,最后遍历右子树。

在这里插入图片描述
上面这颗二叉树的前序遍历顺序为:FBADCEGIH

中序遍历

中序遍历是先遍历左子树,然后访问根节点,然后遍历右子树。

在这里插入图片描述
中序遍历顺序为:ABCDEFGHI,通常来说,对于二叉搜索树,我们可以通过中序遍历得到一个递增的有序序列。

后序遍历

后序遍历是先遍历左子树,然后遍历右子树,最后访问树的根节点。

在这里插入图片描述
后序遍历顺序为:ACEDBHIGF

值得注意的是,当你删除树中的节点时,删除过程将按照后序遍历的顺序进行。 也就是说,当你删除一个节点时,你将首先删除它的左节点和它的右边的节点,然后再删除节点本身。

另外,后序在数学表达中被广泛使用。 编写程序来解析后缀表示法更为容易。 这里是一个例子:
在这里插入图片描述

牢记所谓前中后序只针对于根节点,而左右子节点永远都是先左后右的顺序。 所以,前序就是:根左右!中序就是:左根右!后续就是:左右根!而且还需要注意遍历时,一定要深入最底层!当前根节点的子节点可能是下一个节点的父节点,所以还得往下看才行!

遍历 or 位置?

这里强调一个初学者经常犯的错误:因为教科书里只会问你前中后序遍历结果分别是什么,所以对于一个只上过大学数据结构课程的人来说,他大概以为二叉树的前中后序只不过对应三种顺序不同的 List 列表。

但是我想说,前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List

  • 前序位置的代码在刚刚进入一个二叉树节点的时候执行;
  • 后序位置的代码在将要离开一个二叉树节点的时候执行;
  • 中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。

再回过头看看二叉树的遍历递归代码,是不是更好理解了:

/* 二叉树遍历框架 */
void traverse(TreeNode root) {
    // 前序位置:刚刚进入一个二叉树节点
    traverse(root.left);
    // 中序位置:左子树都遍历完了,即将开始遍历右子树
    traverse(root.right);
    // 后序位置,该二叉树根结点都遍历完了
}

注意位置和遍历的区别!一直说前中后序「位置」,就是要和大家常说的前中后序「遍历」有所区别: 你可以在前序位置写代码往一个 List 里面塞元素,那最后可以得到前序遍历结果;但并不是说你就不可以写更复杂的代码做更复杂的事。
在这里插入图片描述
在这里插入图片描述

※二叉树的最大深度(简单)

用下面这道题,加深对前、中、后序位置的理解!
在这里插入图片描述
二叉树的深度为:根节点到最远叶子节点到最长路径上的节点数。

当在前序位置时,说明进入了一个根节点,从无到有,只有一个根节点,其深度应该是1,深度++。当在中序位置时,说明该根节点的左孩子遍历完了,马上要进入右孩子,那和刚开始进入了一个根节点一样,深度不变。当在后序位置时,说明该根节点的左右孩子都遍历完了,要离开当前根节点了,深度应该–

深度变化知道了,那我们应该在哪里更新答案呢?很显然,题目中已经告诉了,在叶子节点时,我们就可以更新答案,叶子节点不就是null,那就可以写出下面的代码:

class Solution {
    // 全局最大值
    int ans = 0;
    // 局部深度
    int depth = 0;
    public int maxDepth(TreeNode root) {
        traverse(root);
        return ans;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            ans = Math.max(ans, depth);
            return;
        }
        // 前序位置深度++
        depth++;
        traverse(root.left);
        // 中序位置,depth深度不变
        traverse(root.right);
        // 后序位置,要离开该根节点了,depth深度--
        depth--;
    }
}

求二叉树的最大深度,往往作为题目求解的一部分,通过全局变量实现,并不具有通用性,如果需要通过函数返回结果,那可以写成下面的形式:

class Solution {
    public int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        // 二叉树的深度只可能从左右子树产生
        // 那我们比较左右子树即可
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // +1是别忘了根节点自己
        return 1 + Math.max(leftMax, rightMax);
    }
}
二叉树的前序遍历(简单)

我们再回头看看最基本的二叉树前中后序遍历,就比如算前序遍历结果吧。
在这里插入图片描述

class Solution {
    List<Integer> ans = new ArrayList<>();
    public List<Integer> preorderTraversal(TreeNode root) {
        traverse(root);
        return ans;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        // 注意:前序遍历,在前序位置加入list,因为前序位置就是刚进入根节点的位置
        // 此时的节点就是根节点,前序遍历就需要先拿到根节点
        // 前序位置
        ans.add(root.val);
        traverse(root.left);
        // 中序位置
        traverse(root.right);
        // 后序位置
    }
}

这里我们先只用递归实现二叉树的遍历,后续再使用迭代的方式实现遍历。

二叉树的中序遍历(简单)

在这里插入图片描述
考虑中序遍历,先进来的是左孩子,然后才是根节点,再是右孩子,所以中序遍历的代码应该放在中序位置。

class Solution {
    List<Integer> ans = new ArrayList<>();
    public List<Integer> inorderTraversal(TreeNode root) {
        traverse(root);
        return ans;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        // 前序位置
        // ...
        traverse(root.left);
        // 中序位置,在即将进入右孩子之前的节点,是根节点
        ans.add(root.val);
        traverse(root.right);
        // 后序位置
    }
}
二叉树的后序遍历(简单)

一样的道理,后序位置是遍历完了左右孩子,即将离开当前根节点,那这个时候恰好是后序遍历加入根节点的时机。
在这里插入图片描述

前、中、后序位置的深究

前序位置是刚刚进入节点的时刻,后序位置是即将离开节点的时刻。
但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。

举具体的例子,现在给你一棵二叉树,我问你两个简单的问题:

  • 1、如果把根节点看做第 1 层,如何打印出每一个节点所在的层数?
  • 2、如何打印出每个节点的左右子树各有多少节点?

第一题
需要考虑,什么时候层数才会增加?显然是在从根节点进入左右孩子时,什么时候打印层数呢?简单,就是在前序位置,刚进入该根节点的时候打印就行。所以可以写出下面的代码:

class Solution {
    void printLayers(TreeNode root) {
        traverse(root, 1);
        return;
    }
    void traverse(TreeNode root, int layer) {
        if (root == null) {
            return;
        }
        // 前序位置:输出层数
        System.out.println(layer);
        traverse(root.left, layer + 1);
        traverse(root.right, layer + 1);
    }
}

第二题
需要打印每个节点的左右孩子树有多少节点,左孩子树的节点数,在进入左节点时应该+1,而右孩子树的节点数,在进入右节点时应该+1。

class Solution {
    void printNodeNums(TreeNode root) {
        traverse(root, 0, 0);
        return;
    }
    void traverse(TreeNode root, int leftNodes, int rightNodes) {
        if (root == null) {
            return;
        }
        traverse(root.left, leftNodes + 1, rightNodes);
        traverse(root.right, leftNodes, rightNodes + 1);
        // 后序位置:要离开当前根节点了,那不就是统计完了,直接打印
        System.out.println("left" + leftNodes);
        System.out.println("right" + rightNodes);
    }
}

写成下面这样也可:

// 定义:输入一棵二叉树,返回这棵二叉树的节点总数
int count(TreeNode root) {
    if (root == null) {
        return 0;
    }
    int leftCount = count(root.left);
    int rightCount = count(root.right);
    // 后序位置
    printf("节点 %s 的左子树有 %d 个节点,右子树有 %d 个节点",
            root, leftCount, rightCount);

    return leftCount + rightCount + 1;
}

结合这两个简单的问题,你品味一下后序位置的特点,只有后序位置才能通过返回值获取子树的信息。

那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。

class Solution {
    List<Integer> ans = new ArrayList<>();
    public List<Integer> postorderTraversal(TreeNode root) {
        traverse(root);
        return ans;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        traverse(root.left);
        traverse(root.right);
        // 后序位置
        ans.add(root.val);
    }
}
※二叉树的直径(简单)

在这里插入图片描述
说实话,看了半天题目,靠英文勉强看懂了,每一条二叉树的「直径」长度,就是一个节点的左右子树的最大深度之和。

那我们直接把当前节点的左右子树的最大深度找到,相加,再与全局的直径长度相比较,就可以确定出全局的最大值。前面已经做过一个节点的最大深度怎么求,注意depth在什么地方++,在什么地方–。

class Solution {
    int maxDiameter = 0;
    public int diameterOfBinaryTree(TreeNode root) {
        // 先不管,肯定要先遍历
        traverse(root);    
        return maxDiameter;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // 按照题目意思更新最大值
        maxDiameter = Math.max(maxDiameter, leftMax + rightMax);
        
        // 上面都是前序位置要做的事,下面才遍历当前节点的左右孩子
        traverse(root.left);
        traverse(root.right);
    }
    int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        return 1 + Math.max(leftMax, rightMax);
    }
}

这个解法是正确的,但是运行时间很长,原因也很明显,traverse 遍历每个节点的时候还会调用递归函数 maxDepth,而 maxDepth 是要遍历子树的所有节点的,所以最坏时间复杂度是 O(N^2)。

这就出现了刚才探讨的情况,前序位置无法获取子树信息,所以只能让每个节点调用 maxDepth 函数去算子树的深度。

那如何优化?我们应该把计算「直径」的逻辑放在后序位置,准确说应该是放在 maxDepth 的后序位置,因为 maxDepth 的后序位置是知道左右子树的最大深度的。

换句话说traverse是多余的,只需要maxDepth就可以得到答案!
注意本题实际求的是边数,而不是节点数

class Solution {
    int maxDiameter = 0;
    public int diameterOfBinaryTree(TreeNode root) {
        maxDepth(root);
        return maxDiameter;
    }
    int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // 后序位置,已经知道左右子树的最大深度,在这里就可以顺便更新最大值
        maxDiameter = Math.max(maxDiameter, leftMax + rightMax);
        return 1 + Math.max(leftMax, rightMax);
    }
}

与二叉树的最大深度比较一下:

class Solution {
    public int maxDepth(TreeNode root) {
        if (root == null) {
            return 0;
        }
        // 二叉树的深度只可能从左右子树产生
        // 那我们比较左右子树即可
        int leftMax = maxDepth(root.left);
        int rightMax = maxDepth(root.right);
        // +1是别忘了根节点自己
        return 1 + Math.max(leftMax, rightMax);
    }
}

仅仅是多了一句代码:maxDiameter = Math.max(maxDiameter, leftMax + rightMax)
解释:二叉树的最大深度,最大深度只可能来自于根节点的左子节点,或右子节点。而二叉树的直径找的是任意两个节点的最大路径长度(可以经过根节点,也可以不经过),对于根节点来说,不就是左子节点的最大深度 + 右子节点的最大深度,但是它有可能不经过根节点呀,所以我们才需要在“后序位置”,对全局最大值进行更新,这样就可以保证不漏掉不经过根节点的情况。

遇到子树问题,首先想到的是给函数设置返回值,然后在后序位置做文章。

反过来,如果你写出了类似一开始的那种递归套递归的解法,大概率也需要反思是不是可以通过后序遍历优化了。

层序遍历(简单)

先来看看什么是层序遍历:
在这里插入图片描述
很简单,就是一层一层的遍历,显而易见的,应该需要两层循环,一层竖向遍历,一层横向遍历。联想到之前的BFS算法,是不是很相似?都是靠队列实现的,都是先把相邻的全部遍历完,再往下遍历。这也是为什么大佬叫我们先学二叉树算法,再去看回溯、搜索高级算法。
在这里插入图片描述

在这里插入图片描述

class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> ans = new ArrayList<>();
        Queue<TreeNode> Q = new LinkedList<>();
        if (root == null) {
            return ans;
        }
        // 头节点入队
        Q.offer(root);
        // 从上到下遍历二叉树每一层(标准的BFS模板)
        while (!Q.isEmpty()) {
            // 看当前层有多少节点
            int sz = Q.size();
            // 记录当前层的节点
            List<Integer> tmp = new ArrayList<>();
            // 从左到右遍历当前层的每个节点
            for (int i = 0; i < sz; i++) {
                TreeNode cur = Q.poll();
                // 当前层节点入list
                tmp.add(cur.val);
                // 将下层节点存入队
                if (cur.left != null) {
                    Q.offer(cur.left);
                }
                if (cur.right != null) {
                    Q.offer(cur.right);
                }
            }
            // 当前层统计完
            ans.add(new ArrayList(tmp));
        }
        return ans;
    }
}

二、树的相关算法该怎么做?

写树相关的算法,简单说就是,先搞清楚当前 root 节点「该做什么」以及「什么时候做」,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。 不需要整颗树都分析清楚,分析一颗较小的子树往往就可以得到答案。

所谓「该做什么」就是让你想清楚写什么代码能够实现题目想要的效果,所谓「什么时候做」,就是让你思考这段代码到底应该写在前序、中序还是后序遍历的代码位置上。

对称二叉树(简单)

在这里插入图片描述
在这里插入图片描述
对称嘛,肯定要分成对称的两部分来考虑,两部分不就只能左子树、右子树。

先想想什么才叫轴对称,如果当前根节点只有一个左孩子,一个有孩子,那我们只需要判断左右孩子的值是否相等即可。如果没有左右孩子,那肯定是true,如果只有一个左孩子或者一个右孩子,肯定是false。我们可以把一棵树分成两半,同时进行遍历。

class Solution {
    public boolean isSymmetric(TreeNode root) {
        return isMirror(root, root);
    }
    boolean isMirror(TreeNode root1, TreeNode root2) {
        if (root1 == null && root2 == null) return true;
        if (root1 == null || root2 == null || root1.val != root2.val) {
            return false;
        }
        // 比较左孩子的左节点和右孩子的右节点,以及左孩子的右节点和右孩子的左节点
        return isMirror(root1.left, root2.right) && isMirror(root1.right, root2.left);
    }
}

也可以写出迭代方法,能够更加深入理解整个过程:(本质和递归方法类似)

class Solution {
    public boolean isSymmetric(TreeNode root) {
        Queue<TreeNode> queue = new LinkedList<>();
        queue.offer(root);
        queue.offer(root);
        while (!queue.isEmpty()) {
            TreeNode t1 = queue.poll();
            TreeNode t2 = queue.poll();
            if (t1 == null && t2 == null) continue;
            if (t1 == null || t2 == null || t1.val != t2.val) return false;
            queue.offer(t1.left);
            queue.offer(t2.right);

            queue.offer(t1.right);
            queue.offer(t2.left);
        }
        return true;
    }
}
路径总和(简单)

在这里插入图片描述
之前求的是最大深度,没有涉及求路径总和的问题,题目中说根节点到叶子节点的路径,那不就是说必须要到叶子节点才能判断和是否相等么,左子树还是右子树是叶子节点呢?那就拆成两坨,一坨看左子树,一坨看右子树。

如果题目告诉目标和,让你找满足目标和的情况,不要傻乎乎的去累加求和,直接对目标和 - 当前遍历的值不就行了吗~

这道题可以和对称二叉树解法归为一类,都是通过拆分成左右两部分来进行查找。

鸡汤来咯~

class Solution {
    public boolean hasPathSum(TreeNode root, int targetSum) {
        if (root == null) {
            return false;
        }
        return check(root, targetSum);
    }
    boolean check(TreeNode root, int targetSum) {
        if (root.left == null && root.right == null) {
            // 叶子节点的特征就是没有左右子树
            return targetSum == root.val;
        }
        return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val);
    }
}

更简洁的写法:

class Solution {
    public boolean hasPathSum(TreeNode root, int targetSum) {
        if (root == null) return false;
        if (root.left == null && root.right == null) {
            return root.val == targetSum;
        }
        return hasPathSum(root.left, targetSum - root.val) || hasPathSum(root.right, targetSum - root.val);
    }
}

有了上面题目的总结,你会发现,这些题目要么是通过前、中、后序位置的不同操作来满足题目要求,要么是把左、右子树拆开来分别判别。

路径总和Ⅱ(中等)

在这里插入图片描述
这道题在上一题的基础上,还需要找到具体的方案情况,找方案情况,不就是DFS干的事情吗,DFS干就完了!注意回溯!

class Solution {
    List<List<Integer>> ans = new LinkedList<>();
    LinkedList<Integer> tmp = new LinkedList<>();
    public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
        if (root == null) return ans;
        tmp.add(root.val);
        build(root, targetSum);
        return ans;
    }
    void build(TreeNode root, int targetSum) {
        // 到根节点了
        if (root.left == null && root.right == null) {
            if (targetSum == root.val) {
                ans.add(new LinkedList(tmp));
            }
            return;
        }
        // 左边没为空,继续往左边找
        if (root.left != null) {
            tmp.add(root.left.val);
            build(root.left, targetSum - root.val);
            // 回溯!(我离开这个节点了,我不要它了)
            tmp.removeLast();
        }
        // 右边没为空,继续往右边找
        if (root.right != null) {
            tmp.add(root.right.val);
            build(root.right, targetSum - root.val);
            // 回溯!
            tmp.removeLast();
        }
    }
}
翻转二叉树(简单)

在这里插入图片描述
通过观察示例二最简单的样子,我们发现只要把二叉树上的每一个节点的左右子节点进行交换,最后的结果就是完全翻转之后的二叉树。 如果是示例一呢,我们把7、2节点左右交换后,7下面还是6、9,2下面还是3、1,还需要继续交换,怎么交换?还是得分左右子节点来翻转呀~

这样思考一下就明了了。

class Solution {
    public TreeNode invertTree(TreeNode root) {
        traverse(root);
        return root;
    }
    void traverse(TreeNode root) {
        if (root == null) {
            return;
        }
        // 前序位置
        // 把当前根节点的左右子节点交换了
        TreeNode tmp = root.left;
        root.left = root.right;
        root.right = tmp;
        // 左右子节点还需要交换
        traverse(root.left);
        // 中序位置
        traverse(root.right);
    }
}
二叉树中的最大路径和(困难)

前面一直在谈树的直径、树的深度,以及根节点到叶子节点的路径和,下面就来看看二叉树中的最大路径和怎么求?
在这里插入图片描述

在这里插入图片描述
注意题目中对路径的定义,是从子节点->父节点->子节点(当然你可以完全不含子节点,只有父节点,例如:叶子节点,只有它自己,题目要求至少包含一个节点就行),下面这个图为例,应该是4 > -2 > 6 > 7,这样才满足题意。
在这里插入图片描述
我们可以遍历每一个节点,把它当成父节点,来看由它组成的路径长度为多少,该父节点的左右孩子可以取也可以不取,但父节点必须取(保证至少有一个节点)。

在这里插入图片描述

class Solution {
    int max = Integer.MIN_VALUE;
    public int maxPathSum(TreeNode root) {
        maxPath(root);
        return max;
    }
    int maxPath(TreeNode root) {
        if (root == null) {
            return 0;
        }
        // 左右子树可以不选,但是父节点必选(至少有一个节点)
        int maxLeft = Math.max(0, maxPath(root.left));
        int maxRight = Math.max(0, maxPath(root.right));
        // 把每个节点当作父节点来考虑当前路径的最大和
        // 更新全局和(+root.val是因为至少包含一个节点)
        max = Math.max(max, maxLeft + maxRight + root.val);

        // 下面的返回结果是最难理解的
        // 考虑上面的maxLeft、maxRight结果应该怎么得到?
        return Math.max(maxLeft, maxRight) + root.val;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@u@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值