一. 前言
树也是一种常见的数据结构,在算法题里最常见的就是数组,链表,哈希表以及树。前三者平时很常用到,所以也不会觉得太难。树如果刚开始没有练习,会觉得很难,但在经过一定的练习和总结之后,会觉得树的题目也是比较容易解决的。这里目前只记录了比较常见且典型的树型题目,一些奇形怪状的树题暂时不考虑。
二. 关于递归
递归是一种常见的解题方法,主要特征为反复调用自身。递归会让代码看起来很简洁,逻辑也很清晰,但有时候也会造成大量的重复计算。二叉树跟链表是很适合进行递归的数据结构,因为它们的数据就像一段一段的,递归可以使我们把思考的重点放在“当前层次的递归应该做什么”,而不必陷入整个复杂数据结构的复杂思考。
递归的核心是反复调用自身,我们有3个点需要重点思考:
①既然是反复调用自身,那么什么时候结束?即我们需要寻找递归的终止条件
②应该返回什么?对于第一层递归,返回值就是最终结果。同时第一层需要调用第二层的递归结果,那么第二层需要返回给上一级(也就是第一层)什么样的结果?
③每一层递归应该做什么?也就是当前层次的递归,需要进行什么逻辑?完成什么任务?
对于接下来的树题,我们都会优先思考递归解题模板,然后再寻找迭代式的解题方法。
三. 二叉树的深度
① 最大深度 LeetCode 104
描述: 给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。题目链接
分析:先以递归的思路来思考:
① 如果要用递归,什么时候结束?显然,当root为null的时候,就不必继续递归了,此时直接返回0。
② 应该返回什么?其实每一层的返回值都是一样的,因为递归是调用自身,所以不存在说第一层返回的是xxx概念,而其他层返回的是yyy概念。那么第一层返回什么呢,就是返回根节点root的深度。所以后续的每一层也是一样的,就是返回以当前结点为root的树的深度。
③每一层递归应该做什么?终止条件,返回值我们都已经想好了,这时候就是需要中间的逻辑。在当前层,我们需要获得root的深度,这时候我们要递归下去,就是把root.left和root.right成为下一层递归的根节点,这样我们就求得root的左子树深度,以及右子树的深度,然后它们二者较大的值加上root本身深度(也就是1),就是root的最大深度。所以 root的深度 = 1 + max(root.left的深度, root.right的深度)。
这就是递归的优雅之处,我们只需要考虑当前层的逻辑,对于当前层,root的深度只要考虑root.left的深度以及root.right的深度,至于root.left的结构是什么,是否为null,root.left的最大深度是root.left.left还是root.left.right?这些在下一层就会变成和当前层一样的逻辑,我们无须考虑。于是我们可以获得代码:
代码:
class Solution {
public int maxDepth(TreeNode root) {
if (root == null)
return 0;
int leftDepth = maxDepth(root.left);
int rightDepth = maxDepth(root.right);
return 1 + Math.max(leftDepth, rightDepth);
}
}
如果你更熟练,会发现leftDepth与rightDepth变量也是可以省略的,这时候就能写出更简洁的代码:
class Solution {
public int maxDepth(TreeNode root) {
return root == null ? 0 :
1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}
}
一行解决!相信你已经体会到了递归的美妙之处。当然,其实这里还是会产生了很多重复运算,所以可以使用一个Map来存储数据,提升效率。不要看LeetCode上效率更慢,那是测试用例的问题,如果存在一个巨型二叉树,上面的代码需要很长的时间,而使用Map会提升不少效率。
使用 Map作备忘录的递归代码:
class Solution {
public int maxDepth(TreeNode root) {
Map<TreeNode, Integer> map = new HashMap<>();
int res = helper(root, map);
return res;
}
public int helper(TreeNode root, Map<TreeNode, Integer> map) {
if (root == null)
return 0;
if (map.containsKey(root))
return map.get(root);
int left = helper(root.left, map);
int right = helper(root.right, map);
int res = 1 + Math.max(left, right);
map.put(root, res);
return res;
}
}
递归解法很简单,如果不允许用递归呢,如果一定要用迭代的方法,应该如何编码?这个我们等会再说。
② 最小深度 LeetCode 111
描述: RT。题目链接
分析:最大深度的递归结束是当结点为null的时候,而最小深度则是遇到第一个叶子结点就返回,同时要取min而不是max,于是很容易写出这种代码:
class Solution {
public int minDepth(TreeNode root) {
if (root == null)
return 0;
if (root.left == null && root.right == null)
return 1;
return 1 + Math.min(minDepth(root.left), minDepth(root.right));
}
}
然后就错了,因为max我们可以确保肯定会选最大的子树长度,而min可能会选择了空子树,使得结果出错,比如测试用例[1, 2],这时候右子树深度为0,答案就变成了1,而不是2,所以只有当root.left,root.right不为null的时候才放进去递归。当然,最开始的判断root是否为null还是不能省略的,因为可能输入的参数就刚好是root。
正确的代码:
class Solution {
public int minDepth(TreeNode root) {
if (root == null)
return 0;
if (root.left != null && root.right == null)
return 1 + minDepth(root.left);
if (root.right != null && root.left == null)
return 1 + minDepth(root.right);
return 1 + Math.min(minDepth(root.left), minDepth(root.right));
// 如果都为null, 也不会影响结果
}
}
四. 树的遍历
描述:树的遍历是很常见的题,包括前序遍历,中序遍历,后序遍历,层序遍历,垂序遍历(前三种比较常见)。然后还存在根据前序与中序还原树,后序与中序还原树等等的题目。所以我们先从树的遍历开始总结(上一题主要解释一下递归的过程)。值得注意的是,有一些题目因为递归解法太过简单,所以有时候题目会要求“不要用递归,而是用迭代”,一下子就使得题目难度增加不少。所以这里会尽量通过两种方法来解决。
① 二叉树的前序遍历 LeetCode 144
描述:RT。题目链接
递归解法:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
preOrder(root, list);
return list;
}
public void preOrder(TreeNode root, List<Integer> list) {
if (root == null) // 递归结束
return;
list.add(root.val); // 当前层的操作
preOrder(root.left, list); // 递归
preOrder(root.right, list); // 递归
}
}
如果要把递归解法转换成迭代解法,一般都可以通过栈来实现,因为递归实质也是使用栈,只是递归压栈的并不是数值对象,而是整个方法。所以才会有“递归栈"这种说法,在画出递归的过程时,实际上就是画递归栈。所以我们这里通过栈来放置树的结点,优先push右节点,因为后进先出,前序遍历的顺序是:根节点->左节点->右节点,所以先push右节点,再push左节点。
迭代解法:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
if (root == null)
return res;
stack.push(root);
while (!stack.isEmpty()) {
TreeNode current = stack.pop();
res.add(current.val);
if (current.right != null)
stack.push(current.right); //先push right node
if (current.left != null)
stack.push(current.left);
}
return res;
}
}
核心是先push right,然后处理所有的left结点。如果要与中序后序统一模板,可以改成下面的代码:
class Solution {
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
if (root == null)
return res;
TreeNode current = root;
while (current != null || !stack.isEmpty()) {
if (current != null) {
res.add(current.val); // root
stack.push(current);
current = current.left; // left
}
else {
current = stack.pop(); // right
current = current.right;
}
}
return res;
}
}
也可以作出改进,使得只需要push right结点。
class Solution {
// 只push right结点的方法
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
if (root == null)
return res;
TreeNode current = root;
while (true)
if (current != null) {
res.add(current.val);
if (current.right != null)
stack.push(current.right);
current = current.left;
}
else if (stack.isEmpty())
return res;
else
current = stack.pop();
}
}
② 二叉树的中序遍历 LeetCode 94
描述: RT。题目链接
递归解法:
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
inOrder(list, root);
return list;
}
public void inOrder(List<Integer> list, TreeNode root) {
if (root == null)
return;
inOrder(list, root.left);
list.add(root.val);
inOrder(list, root.right);
}
迭代同样要用到栈,只是要先处理所有left结点,然后在后续的出栈中,先把当前的栈顶值加入到结果集中(left和root),然后再遍历right结点,以达到:left -> root -> right的效果。
迭代解法:
class Solution {
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
if (root == null)
return res;
TreeNode current = root;
while (current != null || !stack.isEmpty())
if (current != null) {
stack.push(current); // 把所有left压栈
current = current.left;
}
else {
current = stack.pop(); // 出栈顺序,栈顶顺序为:left, root
res.add(current.val); // 添加到结果集
current = current.right; // 把right压栈
}
return res;
}
}
③ 二叉树的后序遍历 LeetCode 145
描述: RT。题目链接
递归解法:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
postOrder(list, root);
return list;
}
public void postOrder(List<Integer> list, TreeNode root) {
if (root == null)
return;
postOrder(list, root.left);
postOrder(list, root.right);
list.add(root.val);
}
}
后序遍历是在LeetCode中,三个遍历中唯一一个难度为hard的题目(当然,前提是不考虑递归)。这是因为前序遍历为:root -> left -> right,中序遍历为: left -> root -> right,而在压栈过程中,实际上我们第一个压栈的元素永远是root,也就是第一个根节点。而后续的操作中,我们可以先处理root,然后按照right,left的顺序压栈,以达到前序遍历的目的。又或者是先处理left,然后出栈的时候处理完left再处理root,最后处理right,达到中序遍历的效果。但是后序遍历有一个很特别的点,它的顺序是: left -> right -> root,也就是说我们要最后处理root,这与前面两种都不相同。然后发现大部分的解法都比较tricky,先进行前序遍历,然后再更换顺序,把它变成后序遍历。前序遍历是先push right结点,这里更改为先push left结点,这样结果就是:root -> right -> left,然后再完全倒序,就变成了:left -> right -> root
tricky的迭代解法:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
if (root == null)
return res;
stack.push(root);
while (!stack.isEmpty()) {
TreeNode current = stack.pop();
res.add(0, current.val); // addFirst, 使得最后的结果就是完全倒序
if (current.left != null)
stack.push(current.left); // 先push left
if (current.right != null)
stack.push(current.right);
}
return res;
}
}
这显然不是很好,其实后序的关键是,先遍历了left跟right子结点,然后才遍历当前结点,所以设置一个HashSet,记录已经访问过的结点。然后在获取栈顶元素的时候(不要直接出栈),先去确认子结点是否已经访问,如果已经访问了,这时候再把栈顶元素出栈,添加到结果集种。如果存在不为null的子结点却不在HashSet中,那么此时应该把子结点push到栈中,继续下一轮的循环。(同样地,先push right,再push left)
正确的迭代解法:
class Solution {
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
LinkedList<TreeNode> stack = new LinkedList<>();
Set<TreeNode> set = new HashSet<>(); // visited
if (root == null)
return res;
stack.push(root);
while (!stack.isEmpty()) {
TreeNode current = stack.peek();
boolean canVisited = true;
// 先push right, 使得出栈优先是left, 达到 left -> right的效果
if (current.right != null && !set.contains(current.right)) {
canVisited = false;
stack.push(current.right);
}
if (current.left != null && !set.contains(current.left)) {
canVisited = false;
stack.push(current.left);
}
// 只有left跟right都访问了,此时才访问root
if (canVisited) {
res.add(current.val);
set.add(current);
stack.pop(); // 访问了才出栈
}
}
return res;
}
}
④ 二叉树的层序遍历 LeetCode 102
描述:给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。 题目链接
递归解法:
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
helper(root, res, 0);
return res;
}
public void helper(TreeNode root, List<List<Integer>> res, int level) {
if (root == null)
return;
if (res.size() == level)
res.add(new ArrayList<>());
res.get(level).add(root.val);
helper(root.left, res, level + 1);
helper(root.right, res, level + 1);
}
}
对于迭代,同样也会用到栈或者队列来存储结点数据。因为是当前层,所以我们可以逐层遍历,把每一层的结点全部添加到Queue当中,然后记录size。如果size为0,说明当前层已经为空,循环结束。如果不为0,这时候我们要把Queue里所有的结点都出列,同时添加到List中,在出列的过程中,我们会添加下一层的结点(当前结点的左节点或者右节点)。因为我们要确保只把当前层的结点全部出列,而下一层的结点会留在下一次循环(另起一个List),所以要保证后面来的数据不会影响到前面数据的顺序,最恰当的数据结构是队列。
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null)
return res;
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
while (!queue.isEmpty()) { // Queue为空,说明下一层没有任何结点
List<Integer> cur = new ArrayList<>();
int size = queue.size(); // 把Queue里的当前数据全部出列
while (size > 0) {
TreeNode current = queue.poll();
cur.add(current.val);
if (current.left != null)
queue.add(current.left);
if (current.right != null)
queue.add(current.right);
size--;
}
res.add(cur); // 每一次循环就是遍历了一层
}
return res;
}
}
⑤ 二叉树的垂序遍历 LeetCode 987
描述:给定二叉树,按垂序遍历返回其结点值。
对位于 (X, Y) 的每个结点而言,其左右子结点分别位于 (X-1, Y-1) 和 (X+1, Y-1)。
把一条垂线从 X = -infinity 移动到 X = +infinity ,每当该垂线与结点接触时,我们按从上到下的顺序报告结点的值( Y 坐标递减)。
如果两个结点位置相同,则首先报告的结点值较小。
按 X 坐标顺序返回非空报告的列表。每个报告都有一个结点值列表。
分析:本题表述不够清楚。对于一个结点,先考虑它的x坐标(垂序),然后再考虑它的y坐标(同一垂线,从上往下输出),最后如果坐标相同(x,y都相同),再根据val的大小来输出。(这种遍历方式并不常见,也不实用,记住就好,前面4种的应用都很广泛)
class Solution {
public List<List<Integer>> verticalTraversal(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if (root == null)
return res;
Map<TreeNode, int[]> positions = new HashMap<>();
LinkedList<TreeNode> queue = new LinkedList<>();
queue.add(root);
positions.put(root, new int[] {0, 0});
int min = 0; // 记录最左和最右
int max = 0;
while (!queue.isEmpty()) { // 层序遍历, 记录每个端点的坐标
int size = queue.size();
while (size > 0) {
TreeNode current = queue.poll();
int[] pos = positions.get(current);
if (current.left != null) {
queue.add(current.left);
positions.put(current.left, new int[] {pos[0] - 1, pos[1] + 1});
min = min <= pos[0] - 1 ? min : pos[0] - 1;
}
if (current.right != null) {
queue.add(current.right);
positions.put(current.right, new int[] {pos[0] + 1, pos[1] + 1});
max = max >= pos[0] + 1 ? max : pos[0] + 1;
}
size--;
}
}
int length = max - min + 1;
for (int i = 0; i < length; i++)
res.add(new ArrayList<>());
List<int[]> list = new ArrayList<>(); // 改为使用三维数组存储{x, y, val}
for (Map.Entry<TreeNode, int[]> entry: positions.entrySet()) {
int[] value = entry.getValue();
int[] tmp = new int[] {value[0], value[1], entry.getKey().val};
list.add(tmp);
}
Collections.sort(list, new Comparator<int[]>() {
@Override
public int compare(int[] o1, int[] o2) {
int x = o1[0] - o2[0];
if (x != 0)
return x;
int y = o1[1] - o2[1];
if (y != 0)
return y;
return o1[2] - o2[2]; // x, y, val
}
});
for (int[] ints: list) {
List<Integer> currentList = res.get(ints[0] - min);
currentList.add(ints[2]);
res.set(ints[0] - min, currentList);
}
return res;
}
}
五. 根据两种遍历来还原树的结构
①前序 + 中序 LeetCode 105
主要思路:先从前序遍历中得知根结点,然后把根节点代入到中序遍历,把树划分成左子树和右子树,对子树进行递归,直到每一个子树都只有1个结点。直接看图操作:
之后继续对子树进行递归,左子树[9]已经无须继续操作,所以只需考虑右子树了。对右子树处理完毕之后,递归结束,也就得到了最后的结果。
class Solution {
public TreeNode buildTree(int[] preorder, int[] inorder) {
if (preorder.length == 0)
return null;
int rootVal = preorder[0];
int i;
for (i = 0; i < inorder.length; i++)
if (inorder[i] == rootVal)
break;
TreeNode root = new TreeNode(rootVal);
int[] leftInorder = Arrays.copyOfRange(inorder, 0, i);
int[] rightInorder = Arrays.copyOfRange(inorder, i + 1, inorder.length);
int[] leftPreorder = Arrays.copyOfRange(preorder, 1, leftInorder.length + 1);
int[] rightPreorder = Arrays.copyOfRange(preorder, 1 + leftPreorder.length, preorder.length);
root.left = buildTree(leftPreorder, leftInorder);
root.right = buildTree(rightPreorder, rightInorder);
return root;
}
}
这里的代码是比较偷懒的复制数组,实际上这会导致一定的时间开销。更好办法是,新建一个helper方法,传参增加起始的index。但主要的思路都是一样的。同时有一个细节,不要忘了去掉根节点,它在下一层循环里并不需要用到(我们只需要处理子树)。
② 后序 + 中序 LeetCode 106
主要思路:思路与上面一致,只是寻找根节点的方法改为了通过后序遍历。后序遍历的最后一个结点为根节点。后续的操作都一样,直接看图操作:
直接也是同样地递归,直到结束,就是最后的结果:
class Solution {
public TreeNode buildTree(int[] inorder, int[] postorder) {
if (inorder.length == 0)
return null;
TreeNode root = new TreeNode(postorder[postorder.length - 1]);
int index = 0;
for (index = 0; index < inorder.length; index++)
if (inorder[index] == postorder[postorder.length - 1])
break;
int[] inorder1 = new int[index];
int[] inorder2 = new int[inorder.length - index - 1];
System.arraycopy(inorder, 0, inorder1, 0, index);
System.arraycopy(inorder, index + 1, inorder2, 0, inorder.length - index - 1);
int[] postorder1 = new int[index];
int[] postorder2 = new int[inorder.length - index - 1];
System.arraycopy(postorder, 0, postorder1, 0, index);
System.arraycopy(postorder, index, postorder2, 0, inorder.length - index - 1);
root.left = buildTree(inorder1, postorder1);
root.right = buildTree(inorder2, postorder2);
return root;
}
}
代码同样地,也直接复制数组了,优化方法同上,不再赘述。
六. 左叶子之和
描述: 计算给定二叉树的所有左叶子之和。LeetCode 404. 题目链接
分析:求和等问题也是直接递归就能解决,关键是明白什么是左叶子:必须是某一个结点的左子结点,同时它还是一个叶子,所以递归的时候自然而然是有3个判定条件的。
class Solution {
public int sumOfLeftLeaves(TreeNode root) {
if (root == null)
return 0;
if (root.left != null && root.left.left == null && root.left.right == null)
return root.left.val + sumOfLeftLeaves(root.right);
return sumOfLeftLeaves(root.left) + sumOfLeftLeaves(root.right);
}
}
七. 路径和
① 路径总和 LeetCode 112.
描述:给定一个二叉树和一个目标和,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
分析:一开始看错题目了,以为任意路径都可以,然后才发现一定要以“叶子结点"结束,所以要判断当前是否为叶子。用递归的解法也很显然,如果不为叶子,减去root的值,然后递归。
递归解法:
class Solution {
public boolean hasPathSum(TreeNode root, int sum) {
if (root == null)
return false;
if (root.left == null && root.right == null)
return root.val == sum;
return hasPathSum(root.left, sum - root.val) ||
hasPathSum(root.right, sum - root.val);
}
}
这道题的迭代解法,需要开辟另一个栈来存储当前和的结果(不能直接用一个普通变量,因为存在不同的路径,比如出栈左子节点和右子节点时,要分别考虑)。
class Solution {
public boolean hasPathSum(TreeNode root, int sum) {
if (root == null)
return false;
LinkedList<TreeNode> node_stack = new LinkedList<>();
LinkedList<Integer> sum_stack = new LinkedList<>();
node_stack.push(root);
sum_stack.push(sum - root.val); // 每次node_stack pop,sum_stack减去该val
TreeNode current;
int currentSum;
while (!node_stack.isEmpty()) {
current = node_stack.pop();
currentSum = sum_stack.pop();
if (current.right == null && current.left == null && currentSum == 0)
return true; // 如果pop出来的是叶子,且sum变成0,说明是正确的路径
if (current.right != null) {
node_stack.push(current.right);
sum_stack.push(currentSum - current.right.val);
}
if (current.left != null) {
node_stack.push(current.left);
sum_stack.push(currentSum - current.left.val);
}
}
return false;
}
}
② 路径总和 Ⅱ LeetCode 113.
描述:给定一个二叉树和一个目标和,找到所有从根节点到叶子节点路径总和等于给定目标和的路径。
分析:像这种寻找所有路径的题目,很容易想到回溯算法,也就是DFS。在到终点的时候(叶子结点),判断是否符合结果(路径和是否为sum),如果是,添加到结果集。不管如何,后面都是返回上一层,也就是回溯,然后进行最关键的“状态reset”,也就是把上一个添加到list的值给去掉,也就是回溯的思路。
以本题基本测试用例 [5,4,8,11,null,13,4,7,2,null,null,5,1] 为例画出回溯过程:
可以先写出这题最基本的回溯模板:
class Solution {
public List<List<Integer>> pathSum(TreeNode root, int sum) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
if (root == null)
return res;
helper(root, sum, 0, res, list); // call the backtrack function
return res;
}
public void helper(TreeNode root, int sum, int current,
List<List<Integer> res, List<Integer> list) {
if (.....) {
res.add(new ArrayList<>(list));
return; // backtrack
}
// while or for or if ...
}
}
回溯的条件自然是到了叶子结点,此时判断是否为有效的路径,如果是,添加到结果集。回溯之后要把状态reset,所以后续都有list.remove(list.size() - 1)
的操作。因为这里就是普通的前序遍历,所以也不需要while循环,直接if就可以解决了。
代码:
class Solution {
public List<List<Integer>> pathSum(TreeNode root, int sum) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
if (root == null)
return res;
list.add(root.val);
helper(root, sum, root.val, res, list); // root.val必须要有
return res;
}
public void helper(TreeNode root, int sum, int current,
List<List<Integer>> res, List<Integer> list) {
if (root.left == null && root.right == null && current == sum) {
res.add(new ArrayList<>(list));
return; // 回溯
}
if (root.left != null) {
list.add(root.left.val);
helper(root.left, sum, current + root.left.val, res, list);
list.remove(list.size() - 1);
}
if (root.right != null) {
list.add(root.right.val);
helper(root.right, sum, current + root.right.val, res, list);
list.remove(list.size() - 1);
}
}
}
③ 路径总和 Ⅲ LeetCode 437.
描述:给定一个二叉树,它的每个结点都存放着一个整数值。
找出路径和等于给定数值的路径总数。
路径不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
二叉树不超过1000个节点,且节点数值范围是 [-1000000,1000000] 的整数。
分析:一开始觉得只是上一题的变种,做了一下才发现难多了,而这题竟然定义为Easy。我一开始是用背包问题的思维,当前root节点是否拿,分为拿和不拿,然后再分别递归到左子树和右子树。这时候的关键代码大概是:
return helper(root.left, sum, cur + root.val) + helper(root.left, sum, cur) + helper(root.right, sum, cur + root.val) + helper(root.right, sum, cur)
毁掉这段代码的倒也跟上一题的测试用例一样,“0”。当一个结点的值为0,或者sum为0,这时候这个代码就很多错误。可能会重复计算,也可能会少计算。后来还是要额外增加一个数据结构,也就是HashMap,用于记录到达当前元素的路径上,之前的所有元素的和(根节点到当前元素)。直觉上会觉得,该题路径不要求从根结点开始。但假设路径的起始结点为node1,它具有父节点node2,node2有父节点node3,结束结点为node0。
这时候应该是:node0 - node1 == sum
但我们记录的是:
f (node0) = node0 + node2 + node3
f (node1) = node1 + node2 + node3
所以: f(node0) - f(node1) = = node0 - node1 == sum
即结果是一样的。这个概念正式地称为 前缀和。值得注意的是,因为DFS实际上也是回溯的过程,那么回溯的时候也是要进行状态reset的,否则后面的结点在回溯的时候会影响前面的状态。
代码:
class Solution {
public int pathSum(TreeNode root, int sum) {
if (root == null)
return 0;
Map<Integer, Integer> map = new HashMap<>();
map.put(0, 1);
return helper(root, sum, 0, map);
}
public int helper(TreeNode root, int sum, int prev, Map<Integer, Integer> map) {
if (root == null)
return 0;
int res = 0;
prev += root.val;
res += map.getOrDefault(prev - sum, 0);
// 当前和为prev,寻找前面和为prev - sum的,差值就是sum(从后往前推)
map.put(prev, map.getOrDefault(prev, 0) + 1); // 当前节点的前缀和
res += helper(root.left, sum, prev, map);
res += helper(root.right, sum, prev, map); // 下一层
map.put(prev, map.get(prev) - 1); // 回溯, 状态reset
return res;
}
}
八. 二叉搜索树BST
二叉搜索树又称BST,其特点为左子树的元素都小于根结点,右子树的元素都大于根节点。这是不考虑重复元素的情况,如果考虑,可以规定相同的元素往左子树放,或者右子树放,但此时复杂性还是增加不少,对于查询的时候可能需要分别递归查找两个子树,所以一般考虑无重复元素的BST即可。
① 修剪二叉搜索树 LeetCode 669.
描述:给定一个二叉搜索树,同时给定最小边界L 和最大边界 R。通过修剪二叉搜索树,使得所有节点的值在[L, R]中 (R>=L) 。你可能需要改变树的根节点,所以结果应当返回修剪好的二叉搜索树的新的根节点。
分析:做树的题目一定要记住,先考虑递归,再考虑其他的。因为树的结构很复杂,如果你直接把问题整体地考虑,很难有思路。比如这一题,你可能需要改变根结点,那么修剪之后剩下哪些结点?哪个结点应该作为根节点?如果先得出剩余的结点,然后再手动构造BST,这个显然不是一个好办法。所以就要把问题细化,也就是回归递归的三个步骤。(为什么突然又要拿出“三大步骤”来做题?因为上一题把我卡了好久= =)
- 递归什么时候结束?
显然,root为null就结束,这时候就直接返回null - 应该返回什么?
同样地,每一层的返回值都是相同的,既然第一层返回的是根结点,所以每一层返回的都是根节点 - 每一层递归应该做什么?
既然要考虑的是根节点,所以自然就是先判断根节点与L,R的关系比较。如果root的值小于L,那么root就要去掉,此时最恰当的是返回比root大一点的结点,也就是root.right。大于R同理。如果root.right仍然不符合怎么办?同样地,这是下一层递归要考虑的,我们在当前层不需要考虑。那么在处理完root之后是否就结束了呢?并不,此时只是说明这个root不需要改变了,这时候还要递归地考虑root的左子树和右子树的值,它们有可能不属于[L, R]范围。处理方法也是同样地,一层一层循序渐进,所以考虑的是root.left, root.right
代码:
class Solution {
public TreeNode trimBST(TreeNode root, int L, int R) {
if (root == null)
return null;
// 返回右子树, 因为right >= root,如果仍然不符合,继续递归
if (root.val < L)
return trimBST(root.right, L, R);
if (root.val > R)
return trimBST(root.left, L, R); // 同上
// 处理子结点 (root满足了,递归去除子树不符合的值)
root.left = trimBST(root.left, L, R);
root.right = trimBST(root.right, L, R);
return root;
}
}
② 二叉搜索树中第K小的元素 LeetCode 230.
描述:给定一个二叉搜索树,编写一个函数 kthSmallest 来查找其中第 k 个最小的元素。
说明:
你可以假设 k 总是有效的,1 ≤ k ≤ 二叉搜索树元素个数。
分析:对BST进行中序遍历,将得到一个有序的线性表,因而再获取第K小的元素就很简单。
class Solution {
public int kthSmallest(TreeNode root, int k) {
List<Integer> list = new ArrayList<>();
inOrder(root, list, k);
return list.get(k - 1);
}
public void inOrder(TreeNode root, List<Integer> list, int k) {
if (root == null || list.size() == k)
return;
inOrder(root.left, list, k);
list.add(root.val);
inOrder(root.right, list, k);
}
}
看了一下排名最高的解法,其实也是中序遍历,只是用了全局变量提升了效率而已:
class Solution {
int res = 0;
int k = 0;
public int kthSmallest(TreeNode root, int k) {
this.k = k;
dfs(root);
return res;
}
public void dfs(TreeNode root) {
if (root == null)
return;
dfs(root.left);
k--;
if(k == 0)
res = root.val;
dfs(root.right);
}
}
进阶: 如果二叉搜索树经常被修改(插入/删除操作)并且你需要频繁地查找第 k 小的值,你将如何优化 kthSmallest
函数?
这时候可以维护一个大小为k的堆。如果要获取第k小,那么就维护最大堆,此时堆顶元素就是第k小。一次维护的时间复杂度为O(logN),效率很高。
③ 把二叉搜索树转换成累加树 LeetCode 538.
描述:给定一个二叉搜索树(Binary Search Tree),把它转换成为累加树(Greater Tree),使得每个节点的值是原来的节点值加上所有大于它的节点值之和。
分析:如果愿意,仍然可以直接中序遍历,然后再对List进行累加。但其实这道题需要的是从右往左添加(最右的元素不需要操作,倒数第二右的元素只需要添加最右元素,以此类推)。所以可以把遍历顺序改成:right -> root -> left,使用一个全局变量记录右边的元素值。
代码:
class Solution {
int right = 0;
public TreeNode convertBST(TreeNode root) {
inOrder(root);
return root;
}
public void inOrder(TreeNode root) {
if (root == null)
return;
inOrder(root.right);
root.val += right; // 加上右边的元素
right = root.val; // right更新为现在的root.val
inOrder(root.left);
}
}
因为这个递归的方法只有一个参数,让人很难去尝试直接去掉,递归convertBST方法本身,于是可以化简代码:
class Solution {
int right = 0;
public TreeNode convertBST(TreeNode root) {
if (root != null) {
convertBST(root.right);
root.val += right;
right = root.val;
convertBST(root.left);
}
return root;
}
}
④ 二叉搜索树的最近公共祖先 LeetCode 235
描述:给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。” 题目链接
分析:可以一直递归,直到找到最近的公共祖先,但是这就浪费了BST的条件,所以直接迭代就可以了。根节点root肯定是二者的公共祖先,迭代逐步找到最近的公共祖先。这时候root与p,q的大小只有四种可能:root介于二者之间,root比二者都大,root比二者都小,与二者之一相等。对于第一种情况,此时root是公共祖先,因而root.left跟root.right都不会是公共祖先了(root位于p,q中间,而root是从根节点开始迭代的)。对于第二种情况,p,q都位于root的左子树,所以直接:root = root.left
即可找到更近的公共祖先,第三种情况同理。第四种情况,显然也是直接返回(root与其中之一相等,且是它们两个的公共祖先,那自然是最近的公共祖先了)
代码:
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
while (root != p && root != q) {
if ((p.val < root.val && q.val > root.val) ||
(q.val < root.val && p.val > root.val))
return root;
// 如果root是公共祖先, 那么root.left跟root.right都不会是公共祖先(root是p,q中间)
else if (p.val < root.val) //p, q < root
root = root.left;
else
root = root.right; // p, q > root
}
return root;
}
}
⑤ 二叉树的最近公共祖先 LCA问题 LeetCode 236
描述:与上一题相同,只是这一题是普通的二叉树,而不是BST。题目链接
分析:可以创建一个判定是否为子结点的方法,然后对root进行递归。这样显然就进行了两次O(n ^ 2)的循环,所以效率非常低。并不推荐。这时候我们再次拿出递归三步骤来进行解题:
- 递归什么时候结束?
当root为null的时候,或者等于p或q二者之一。(其实就是问边界情况,但递归就是有了这个边界才不会无限循环下去) - 应该返回什么?
既然是调用自身,所以同样地,返回以当前root为根节点的树,对于结点p,q的最近公共祖先 - 每一层递归应该做什么?
判断root是否为LCA,如果是,直接返回,如果不是,继续递归左子树和右子树。其实每一次递归都只有4种可能:null(已经到达边界),p/q(如果root等于二者之一,同样也可以直接返回),root.left,root.right.显然,当root == null || root == p || root == q
,此时直接返回root,因为root就是LCA(跟BST一样)。如果不是,自然就是分别递归root.left,root.right,根据它们的结果来判定:
① root.left,root.right的返回值都不为null。说明当前的root就是LCA (就像BST的if情况)
② root.left的返回值为null,root.right返回值不为null。显然LCA在右子树,直接返回root.right的返回值
③root.left不为null,root.right为null。同上。
④二者都为null。return null。实际上不会到达,所以情况3和4直接写成else也是可以的。
代码:
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if (root == null || root == p || root == q)
return root;
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
if (left != null && right != null)
return root; // left最后递归到p, right递归到q,即分别在两侧, 自然当前root就是LCA
else if (left != null)
return left;
else
return right;
}
}