java算法day14
- 222 完全二叉树的节点个数。
- 110 平衡二叉树
- 257 二叉树的所有路径
- 124 二叉树中的最大路径和
222 完成二叉树的节点个数
解法1,层序遍历,迭代解法。
就是层序遍历的模板题。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public int countNodes(TreeNode root) {
Deque<TreeNode> que = new ArrayDeque<>();
if(root==null){
return 0;
}
que.offerLast(root);
int count = 0;
while(!que.isEmpty()){
int size = que.size();
count +=size;
while(size>0){
TreeNode temp = que.pollFirst();
if(temp.left!=null){
que.offerLast(temp.left);
}
if(temp.right!=null){
que.offerLast(temp.right);
}
size--;
}
}
return count;
}
}
解法二:递归解法。
首先还是不要扣细节,从大体上观察,然后才是拆分子问题求解。
大体上就是计算root的左右子树的节点个数总和相加,然后算上root就是+1。
如果是为null了,那说明下面没有了,直接return 0。所以递归出口也找到了。
class Solution {
public int countNodes(TreeNode root) {
if(root==null){
return 0;
}
//返回左右子树的节点总和,然后加上本层的节点。
return 1+countNodes(root.left)+countNodes(root.right);
}
}
110 平衡二叉树
解法1:
还是递归思想。
粗略的来想。
递归的过程中,每一层需要做的事就是计算左右子树的最大高度的绝对值相减是否大于。然后递归检查左右子树。根据这个思想可以得到。
这题我写的时候没注意到一个问题,一定要紧扣题目问什么,这题我就忘写了与题目相关的进入下一层的逻辑。
class Solution {
public boolean isBalanced(TreeNode root) {
//能走到这说明到底了,那就是这个过程都是满足条件,所以为true
if(root==null){
return true;
}
//每层要干的事
if(Math.abs(maxDepth(root.left)-maxDepth(root.right))>1){
return false;
}
//进入下一层。
return isBalanced(root.left) && isBalanced(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);
}
}
这个解法有弊端,我这种解法属于自顶向下的,所以有很多的冗余计算,冗余的地方在于,我每次进入下一层,我就要调用一次计算左右子树最大高度,算一次最大高度,那就要往下递归。
所以这就是优化的地方。因此有了解法二
解法二:自底向下解法。
https://leetcode.cn/problems/balanced-binary-tree/solutions/746538/shu-ju-jie-gou-he-suan-fa-ping-heng-er-c-ckkm/
这篇题解是我看过最直观的图解。
思路:
1.这也是做本题得到的一个新思考,怎样才能做到自顶向上? 回答是利用后序遍历的思想。在后序遍历中,我们先处理左子树,然后右子树,最后才是处理当前节点。这样就做到了得到左右子树的全部信息后才来处理当前节点。而且还是在回溯的过程中进行。
总结:后序遍历提供了自底向上的信息流
2.代码整体思路:
使用一个辅助函数height来同时计算树的高度和检查平衡性。如果树是平衡的,height返回树的实际高度。如果不平衡,返回-1。
主函数要干的就是返回这个辅助函数的返回结果,如果辅助函数返回-1,那么,在底部就有一个位置不满足平衡二叉树的条件。平衡二叉树的判定是,每个节点都要递归的满足平衡二叉树的定义。由于是自底向上返回结果,所以,这个-1的值是可以带上来的,只要做条件判断即可。
3.辅助函数的具体细节
就按上面所说的想法实现,但是用的是后序遍历。由于平衡二叉树的判断本质还是左右子树高度查,因此在每一层还是要计算左右子树的高度查。但他不一样的点就在于,结果直接是从底层开始计算,所以只要底层返回了下面的高度,就可以避免重复的计算。
接下来直接看代码。
class Solution {
public boolean isBalanced(TreeNode root) {
//就是返回辅助函数的结果
return height(root)>=0;
}
//传根节点进来
public int height(TreeNode root){
//递归出口,到底了,返回值就是0。没有高度
if(root==null){
return 0;
}
//后序遍历的思想,先往底部走
int leftHeight = height(root.left);
int rightHeight = height(root.right);
//这里就是到了底层之后,归 的逻辑
//之前说了,一旦有一个子树不满足平衡二叉树的定义,那么整体就不满足,所以这个结果要带上去。每一层都做这样的判断,那就能够带到顶部。
//返回-1的情况有三种,左子树上有不满足的,或者右子树上有不满足的,或者到当前层才不满足的。
if(leftHeight==-1 || rightHeight==-1 || Math.abs(leftHeight-rightHeight)>1){
return -1;
}else{
//能走到这说明左右子树都满足了二叉平衡树,那么加上本层的高度,往上返回。
return Math.max(leftHeight,rightHeight)+1;
}
}
}
从底部,得出底部的信息,归的时候带上底部的信息,可以避免重复计算。从而实现优化。
二叉树路径问题
主要分为两大类。
1.自顶向下(一般路径):
就是从某一个节点(不一定是根节点),从上到下寻找路径,到某一个节点(不一定是叶节点)结束。
自顶向下解题模板
首先想想自顶向下这个过程,可以清楚的类比为前序遍历的过程。因此理解下面模板的过程中,就当为前序遍历。
不回溯版本
class Solution {
//用于收集所有路径的结果集
List<List<Integer>> res = new ArrayList<>();
//主函数,用来启动dfs,最后返回所有路径的结果集
public List<List<Integer>> findPaths(TreeNode root) {
dfs(root, new ArrayList<>());
return res;
}
private void dfs(TreeNode root, List<Integer> path) {
//递归出口。一旦走到了这里,说明走到了叶子节点下面的空节点。所以下面就不用担心path.add收集到空的问题。
if (root == null) return;
//所以每进到下一层,就先收集节点。
path.add(root.val);
//然后判断是否是叶子节点,是的话就把路径加入结果集res。然后return,这条路就已经走完了。
if (root.left == null && root.right == null) {
//这里也是一个细节,这里是复制一份path,因为如果直接用那份path,在之后的状态会影响到里面的元素,所以这里是复制一份路径,加入结果集。
res.add(new ArrayList<>(path));
return;
}
//这里也是递归左右子树,因为你要分开了,两条路的后面的路径肯定是不相同的,所以后面的路径要分别新弄一个副本记录。如果用同一个path,那么就会导致path里面的元素会互相影响。
dfs(root.left, new ArrayList<>(path));
dfs(root.right, new ArrayList<>(path));
}
}
回溯版本
class Solution {
//结果集
List<List<Integer>> res = new ArrayList<>();
//主函数
public List<List<Integer>> findPaths(TreeNode root) {
dfs(root, new ArrayList<>());
return res;
}
private void dfs(TreeNode root, List<Integer> path) {
//递归出口
if (root == null) return;
path.add(root.val);
//检查是否为叶子节点,这里不同于非回溯的点就在于,你在判断为是叶子节点后,也不能直接停下来,而是要进行回溯。即处理完这层的结果集之后,还要把刚刚加进来的元素给删了。这才完成了该叶子节点的任务。
//本质还是前序遍历。
if (root.left == null && root.right == null) {
//我之前还对这里有疑问,感觉这里还是浪费了空间,然而事实是存储结果是必要的操作,并不是浪费空间
res.add(new ArrayList<>(path));
} else {
//递归遍历左右子树。现在可以看到,用的一直都是同一个path了。
dfs(root.left, path);
dfs(root.right, path);
}
//回溯
//这里回溯的思考很重要
//回溯并不是只删除刚加入的叶子节点,而是不管是任意节点,在完成当前节点的探索之后,需要将当前节点从路径中移除。所以上面的dfs下一层之后,回来的时候还是要走remove回溯。
//确保我们回到父节点时,路径恢复到进入当前节点的状态。
path.remove(path.size() - 1); // 回溯
}
}
2.自顶向下(给定和的路径):
找出所有从根节点到叶子节点的路径,使得路径上所有节点的值之和等于给定的目标和。
解题模板(给定和的路径)
和一般路径基本相同,只不过在递归下去的过程中要计算对节点值求和。
不回溯写法
不回溯的模板:
用一个例子来看模板怎么用:
5
/ \
4 8
/ / \
11 13 4
/ \ \
7 2 1
来模拟一下。
假设目标和为22。
1.那么初始调用就是pathSum(root,22)。
2.然后开始dfs(root,22,new ArrayList<>())。
3.首先处理根节点
path=[5]
sum = 22-5=17
4.递归到左子树4
path=[5,4]
sum = 17-4=13
5.递归到11
path = [5,4,11]
sum = 13-11 = 2
6.递归到7
path = [5,4,11,7]
sum = 2-7=-5
不满足条件回溯。
7.回溯到11,然后递归到2
path=[5,4,11,2]
sum=2-2=0
满足条件,添加路径[5,4,11,2]到结果集
8.回溯到4,完成左子树遍历。
9.回溯到5,开始右子树遍历。
右边的是同理的。我就不再多说。
从总体的思想来看,是前序遍历的思想。
因为访问顺序:根节点->左子树->右子树。在遍历的过程中,我们首先处理了当前节点,然后才递归的处理左右子树。
前序遍历的特性为什么能帮助解决这个问题?
1.构建路径:前序遍历运行我们在深入子树之前先处理当前节点,这正式构建从根到叶的路径所需要的,
2.早期检查:我们可以在递归进入子树之前就更新路径和,这使得我们能够在到达叶子节点时,立即判断额能否找到了符合条件的路径
3.自然的回溯:前序遍历的特性使得回溯变得非常自然,当我们完成一个节点及其子树的遍历后,正好可以讲这个节点从路径中移除。
总的来说,前序遍历是解决这种问题最直观的方式,另外的遍历都不太合适。
现在具体来看代码的细节:
可总结出几个关键点:
1.每次递归调用时创建一个新的路径列表。(因为不回溯,所以后面的路径必然是不同的,就不能公用一个path了,否则会相互影响。)
2.讲当前节点添加到新的路径列表当中
3.不需要在递归调用后进行回溯操作
class Solution {
//用来存结果集
List<List<Integer>> res = new ArrayList<>();
//主函数
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
dfs(root, targetSum, new ArrayList<>());
return res;
}
private void dfs(TreeNode root, int sum, List<Integer> path) {
//递归出口,说明走到底了,走到空节点了,直接返回
if (root == null) {
return;
}
//每次到递归下一层的时候都需要复制一个副本,将新的节点加入之后,传递给下一层。
List<Integer> newPath = new ArrayList<>(path);
//节点加入路径
newPath.add(root.val);
//求和减去当前节点的值
sum -= root.val;
//判断是否满足求和,如果满足,把这条路径加入res结果集。不满足就继续递归下一层。从这里你也可以看出,即使这个题中有节点为负值,一样能解决,因为就是会把所有路径给走完。即使sum已经小于0了。只不过只有sum=0才加入结果集。
if (root.left == null && root.right == null && sum == 0) {
res.add(newPath);
} else {
//递归左右子树,把刚刚创建好的路径传递给下一层。
dfs(root.left, sum, newPath);
dfs(root.right, sum, newPath);
}
}
}
回溯写法
与回溯写法的流程基本相同,但核心在于多了一行这样的代码:path.remove(path.size() - 1);
1.这行代码是回溯算法的核心,他使得我们在探索完一个路径后,撤销最后的选择,返回到上一个状态。
2.这行代码位置的重要性。这行代码可以看到放在了递归左右子树的后面。这就说明这行代码的逻辑是在递归的归的过程中进行的。也就是后面的已经探索完了,这行代码才执行的回溯。
class Solution {
//结果集
List<List<Integer>> res = new ArrayList<>();
//主函数
public List<List<Integer>> pathSum(TreeNode root, int targetSum) {
dfs(root, targetSum, new ArrayList<>());
return res;
}
//递归
private void dfs(TreeNode root, int sum, List<Integer> path) {
//递归出口
if (root == null) {
return;
}
//处理当前节点
path.add(root.val);
sum -= root.val;
//处理完后进行判断,是否要加入结果集
if (root.left == null && root.right == null && sum == 0) {
res.add(new ArrayList<>(path));
} else {
//递归左右子树
dfs(root.left, sum, path);
dfs(root.right, sum, path);
}
//回溯
path.remove(path.size() - 1); // 回溯
}
}
3.非自顶向下:
就是从任意节点到任意节点的路径,不需要自顶向下。
先看模板的思路:
1.问题类型:
这种方法主要用于解决非自顶而下的二叉树路径问题.这类问题的特点是最优路径不一定从根节点开始或结束.
2.核心思想:
设计一个递归函数,在遍历树的过程中同时完成两个任务.
a.更新卷据最优解
b.为父节点提供必要的信息
2.1 路径计算的核心思想
(1)路径定义:
在二叉树中,路径被定义为从一个节点到另一个节点的一系列连接的边。
重要的是,这个路径可以不需要经过根节点,可以在树的任何位置。
(2)路径的特性:
路径可以是向下的(父节点到子节点)。
路径可以在某个节点处拐弯(从一个子节点经过父节点到另一个子节点)。
路径不能重复经过同一个节点。
(3)路径计算方式:
对于每个节点,我们考虑以该节点为顶点的路径。
这个路径可能包括:
(a)只有节点自身
(b) 节点加上左子树的一条路径
© 节点加上右子树的一条路径
(d) 节点加上左子树和右子树的路径(形成一个拐点)
(4)递归中的路径计算:
在递归过程中,我们自底向上计算路径。
对于每个节点:
(a) 计算并返回以该节点为起点的最优单向路径(供父节点使用)。
(b) 计算经过该节点的最优路径(可能包括左右子树),更新全局最优解。
(5)具体计算步骤:
递归到叶子节点。
在回溯过程中,对每个节点:
(a) 获取左子树的最优路径值(left)。
(b) 获取右子树的最优路径值(right)。
© 计算经过当前节点的最优路径:node.val + left + right。
(d) 更新全局最优解(如果需要)。
(e) 返回 node.val + max(left, right) 给父节点。
(6)关键点:
1.返回给父节点的值只包括单向路径,因为路径不能分叉。
2.更新全局最优解时可以考虑经过当前节点的所有可能路径,包括可能的拐点。
这个方法的精妙在于,它在每个节点都考虑了所有的路径组合,同时经过巧妙的返回值设置,确保路径的合法性(不会在上层节点形成环或重复计算)
通过这这种方式,我们可以在一次遍历中,找到整个树种的最优路径,无论这个路径位于树的哪个位置,是否经过根节点,是否在中间有拐点.
class Solution {
//res用于存储全局最优解,初始化为0或Integer.MIN_VALUE,取决于问题是否允许负值路径
private int res = 0;
//主方法,调用递归maxPath,返回全局最优解res
public int solve(TreeNode root) {
maxPath(root);
return res;
}
//递归方法
private int maxPath(TreeNode root) {
//递归出口,如果节点为空,返回0,就是没有值的意思
if (root == null) {
return 0;
}
//递归左右子树,分别计算左右子树的最优路径值
int left = maxPath(root.left);
int right = maxPath(root.right);
// 更新全局最优解
res = Math.max(res, left + right + root.val);
// 返回以当前节点为起点的最优解
return Math.max(left, right) + root.val;
}
}
细节解答:
1.为什么老是要去算这个
res = Math.max(res, left + right + root.val);
回答: 因为是从根节点,开始往左右递归的,所以整个流程走下来,全局的最长路径都已经迭代到了,最后res存的一定是树中的最长路径
- 为什么返回值是这个:return Math.max(left, right) + root.val;
回答:因为做递归的时候我们都要从宏观把握一下,我们递归到底在做什么?
我们在更新res的时候,需要用到左子树和右子树的最大长度.所以我们的返回值是需要给当前节点提供左右子树的信息,所以这里return给上一层,就是要返回本层为及本层之下的最大长度
3.有时候老是爱考虑一个问题,我一个节点,是不是有可能向上拐弯的时候取到最大值?
回答是有的,但是这里属于是多考虑了,因为你是从上面下来的,从下面下来,那不就在上面已经算过这种情况了?
4.还要看到遍历的本质,这个递归二叉树的方式可以看出是后序遍历,后序遍历有什么好处? 自底向上,减少冗余计算,允许我们在处理一个节点之前,先获取其子节点的信息,这种遍历在解决路径问题中特别有效,使得每个节点只被访问和计算了一次.
257 二叉树的所有路径
类别为:自顶向下
所以用回溯的模板做:
面试建议都用回溯,这个优化的点也可以说说。
这个题有个注意的点。如果你用字符串来记录path,那么这个回溯就并没有什么意义。因为每拼接一个新的节点进来,由于字符串是不可变对象,那么就会又创建一个新的对象。因此这个题想不创建新的对象,还能不断的拼接节点进来。那我们就会想到StringBuilder。到时候将结果加入结果集就是调用toString()方法就转为了字符串。
所以现在一旦用了StringBuilder。那么就和我们的模板没什么区别了。
class Solution {
//主函数
public List<String> binaryTreePaths(TreeNode root) {
List<String> result = new ArrayList<>();
if (root == null) return result;
dfs(root, new StringBuilder(), result);
return result;
}
//递归
private void dfs(TreeNode node, StringBuilder path, List<String> result) {
//递归出口
if (node == null) return;
//上来先计算长度。
//这个长度一举两得,既能拿来判断字符串是不是空,还能用来回溯,StringBuilder做删除操作就是setLength(len)方法。
int len = path.length();
//一个特判,因为题目有要求结果加->,如果字符串中没有元素,就不用加->,就才加。
if (len > 0) path.append("->");
//加入结果集
path.append(node.val);
if (node.left == null && node.right == null) {
//判断是否加入结果集
result.add(path.toString());
} else {
//如果不满足,那就继续递归左右子树
dfs(node.left, path, result);
dfs(node.right, path, result);
}
//能走到这里,说明下面的路一句走完了,该回去了。这个代码一执行,说明这一层的逻辑要删除了,就要回到上一层了。
path.setLength(len); // 回溯
}
}
关于StringBuilder的使用细节:
1.用于处理可变字符序列。运行我们在不创建新对象的情况下修改字符串内容。
特性:1.与String不同,可变。2.现成不安全。3.对于频繁的字符串操作,效率更高
2.增删查改操作。
1.增:
append()在末尾增。
insert(index,num)在指定位置增
2.删:
delete(begin,end) 删除指定范围
deleteCharAt(index)删除指定索引
setLength(len),适合删除末尾元素
3.查
charAt(index)根据索引查指定的
indexOf(subStr)查字串的位置
subString()获取子字符串
4.改
setCharAt()
replace()
5.其他
reverse()
length()获取当前长度。
124 二叉树中的最大路径和
直接就是上面模板中的非自顶向下模板,用了就直接结束了.
class Solution {
//题目要求里存在负值
private int maxSum = Integer.MIN_VALUE;
//主函数
public int maxPathSum(TreeNode root) {
maxGain(root);
return maxSum;
}
//递归
private int maxGain(TreeNode node) {
if (node == null) {
return 0;
}
// 递归计算左右子节点的最大贡献值
// 只有在最大贡献值大于 0 时,才会选取对应子节点
//这里的想法非常重要,这里产生了思考主要是因为你考虑到了全是负数的情况,在全是负数的情况,那么你应该想到,路径有不同的情况,其中一种就是节点本身.所以对最大长度没有什么共享,那就不选它就完事了,所以返回0就是压根没把这条路径选上.
int leftGain = Math.max(maxGain(node.left), 0);
int rightGain = Math.max(maxGain(node.right), 0);
// 节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值
int priceNewpath = node.val + leftGain + rightGain;
// 更新答案(维护一个最大值)
maxSum = Math.max(maxSum, priceNewpath);
// 返回节点的最大贡献值
return node.val + Math.max(leftGain, rightGain);
}
}