文章目录
- 1. 二叉树的三种遍历方式的实质
- 2. 二叉树的序列化与反序列化
- 3. 根据前序中序反序列创建二叉树
- 4. 二叉树的路径问题
- 5. LCA公共祖先问题
- 6. 二叉搜索树的LCA问题
- 7. 验证搜索二叉树
- 8. 修建搜索二叉树
- 9. 二叉树打家劫舍问题
1. 二叉树的三种遍历方式的实质
这个相信大家都不会陌生, 但是大家学习这个知识点的时候, 往往并不会在意这三种遍历的顺序到底有什么意义(一般都只会打印一下值即可), 而且对这个是怎么来的也不是很清晰, 下面我们通过代码的注释仔细解释一下
public static void dfs(TreeNode node){
//递归的终止条件(其实也就是深度优先搜索)
//即当递归(其实也就是搜索到空节点的时候, 就直接结束(返回),但是该函数无返回信息)
if(node == null){
return;
}
//下面是第一次来到该节点的时机(对应直接对节点进行操作,前序)
System.out.println(node.val);
//往左子树搜索
dfs(node.left);
//下面是第二次来到该节点的时机(左子树搜索完毕之后进行操作,中序)
System.out.println(node.val);
//往右子树搜索
dfs(node.right);
//下面是第三次来到该节点的时机(左右子树都搜索完毕进行操作,后序)
System.out.println(node.val);
}
我们现在创建一颗树, 树的结构是[1,2,3,4,null], 看一下上面的代码的结果
public static void main(String[] args) {
TreeNode node1 = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
TreeNode node4 = new TreeNode(4);
node1.left = node2;
node1.right = node3;
node2.left = node4;
dfs(node1);
}
运行结果是 1 2 4 4 4 2 2 1 3 3 3 1
我们仔细研究一下上面的打印结果
把每一个数字
第一次出现的结果提取出来也就是
1 2 4 3(前序)
第二次出现的结果提取出来也就是
4 2 1 3(中序)
第三次出现的结果提取出来也就是
4 2 3 1(后序)
上述的结果其实正式对应二叉树中的递归序
也就是几大遍历顺序的实质
下面我们做题的时候要时刻分析我们的遍历顺序!
2. 二叉树的序列化与反序列化
对于一个树状存储的数据来说我们需要将其序列化转换为文本文件(字符串)便于存储与传输,然后在通过反序列化的方式将其还原出来, 值得一提的是, 反序列化只能操作前序或者后序或者层序的字符串,而不能操作中序的字符串, 原因是中序的字符串是不唯一的,比如下面这一行代码
public static void main(String[] args) {
//第一棵树
TreeNode node1 = new TreeNode(1);
TreeNode node2 = new TreeNode(1);
node1.left = node2;
//第二棵树
node1.right = node2;
}
第一颗树的序列化结果是 " # 1 # 1 # "
第二棵树的序列化结果也是 " # 1 # 1 # "
但是二者不是同一棵树, 所以中序的序列化是有问题的
首先展示的我们序列化的代码(前序举例子)
//创建一个拼接字符的
static StringBuilder sp = new StringBuilder();
//序列化的过程其实就是前序遍历输出结果的时候进行拼接即可
public static String creatStringUsePreOrder(TreeNode node){
//递归终止条件
if(node == null){
sp.append("#,");
return sp.toString();
}
sp.append(node.val + ",");
//递归的返回值其实没有被接收(
//实质上只有最高层级的结果被调用者接收了)
creatStringUsePreOrder(node.left);
creatStringUsePreOrder(node.right);
return sp.toString();
}
序列化的过程是比较简单的,其实就是前序遍历加上字符串的拼接, 那如何将字符串还原为一颗二叉树呢, 下面是我们的实现(已经用split方法去除连接的","而转化为一个String[]数组)
//定义一个下标遍历字符串(思考为什么定义到外侧)
private static int index = 0;
public static TreeNode creatTreeUsePreString(String[] s){
//递归终止条件
if(s[index++].equals("#")){
return null;
}
//递归创建二叉树
//(前序遍历的方案, 树的连接其实是从底部连接的, 逐级返回)
TreeNode root = new TreeNode(Integer.parseInt(s[index++]));
root.left = creatTreeUsePreString(s);
root.right = creatTreeUsePreString(s);
return root;
}
上述代码我们从递归的角度解释, 该函数的作用就是(创建一个二叉树), 那创建一颗二叉树需要创建出左子树, 也需要创建出一颗右子树, 所以出现了子问题的反复调用, 我们只需要把这个函数想象为一个"黑匣子"…
3. 根据前序中序反序列创建二叉树
其实该问题就是给我们一个两个数组(无重复数据), 一个是前序遍历的结果, 一个是中序遍历的结果, 然后让我们创建出一颗完整的二叉树, 我们这个问题给定的两个数组, 可以是前序加中序, 也可以是中序加后序, 但是不可以是前序加后序(创建不出唯一的二叉树), 例子自己想…, 比如下面这个例子
TreeNode node1 = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
//第一颗树
node1.left = node2;
//第二颗树
node1.right = node2
下面我们分析一下如何创建一个二叉树(前序加上中序)
假设有一个函数 func, 这个函数的功能就是创建二叉树, 那么该函数的参数应该就是(传入数据的所有信息)
> func(int[] pre,int l1,int r1,int[] in,int l2,int r2)
我们假设数组的长度都是5, 那么我们创建二叉树的过程一定是从前序的结果开始的, 首先我们new出来根节点, 然后找到根节点在中序数组中所处的位置, 也就是在这个左侧就是我们的左树的部分(我们可以知道节点的规模), 右侧就是右树, 也就是说我们可以知道每一个元素在中序的位置然后控制左右边界的位置, 最终完成二叉树的创建, 由于这个过程相对抽象, 我们下面举一个例子方便大家理解
下面是我们的代码实现(用HashMap加速查询的过程)
/**
* 从前序跟中序构建一颗完整的二叉树
* 用的是一个封装的构建函数来构建 f(pre,l1,r1,in,l1,r1)
*/
public TreeNode buildTree(int[] preorder, int[] inorder) {
if (preorder == null || inorder == null || preorder.length != inorder.length) return null;
//构建出来一张表加快构建二叉树的过程
HashMap<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < inorder.length; ++i) {
map.put(inorder[i], i);
}
return creatTree(preorder, 0, preorder.length - 1, inorder, 0, inorder.length, map);
}
private TreeNode creatTree(int[] pre, int l1, int r1, int[] in, int l2, int r2, HashMap<Integer, Integer> map) {
//递归的终止条件
if (l1 > r1) {
return null;
}
if (r1 == l1) {
return new TreeNode(pre[r1]);
}
//借助HashMap加快查找的过程
int k = map.get(pre[l1]);
TreeNode node = new TreeNode(pre[l1]);
node.left = creatTree(pre, l1 + 1, l1 + k - l2, in, l2, k, map);
node.right = creatTree(pre, l1 + k - l2 + 1, r1, in, k + 1, r2, map);
return node;
}
从递归的角度来说, 创建二叉树的这个函数相当于就是一个"黑盒", 我们要相信它可以根据我们给定的参数创建出一颗完整的树, 然后我们需要去创建我们的左子树, 创建右子树(就出现了子问题复现的情况), 所以出现了递归
后序加上中序其实是同理的
4. 二叉树的路径问题
该问题涉及到回溯这一算法的概念, 其实递归的过程天然就带着回溯, 那我们为什么要用回溯呢, 是因为在该类问题当中, 我们用一个stack收集节点的时候, 如果该节点的左右深度优先搜索完毕之后, 我们需要把该节点弹出, 进行其他深度路径的搜索, 其实回溯的实际也正式我们该节点的搜索任务结束的时机, 从内存的角度来看, 也是该函数栈帧弹栈的时机
/**
* 二叉树的所有路径
* 1. 返回值是void类型 2. 遍历的顺序我们采用的是前序遍历的顺序
* 3. 中途会进行节点的弹出其实也就是回溯的思路(回溯和递归是不分家的)
*/
private List<String> binaryTreePathsRes = new ArrayList<>();
private ArrayDeque<Integer> stack = new ArrayDeque<>();
public List<String> binaryTreePaths(TreeNode root) {
binaryTreePathsFunc(root);
return binaryTreePathsRes;
}
private void binaryTreePathsFunc(TreeNode node) {
//递归的终止条件
if (node == null) return;
stack.add(node.val);
//证明递归到了叶子节点该收获了, 不能在null节点处收获
if (node.left == null && node.right == null) {
StringBuilder sp = new StringBuilder();
Iterator<Integer> it = stack.iterator();
while (it.hasNext()) sp.append(it.next() + "->");
sp.delete(sp.lastIndexOf("-"), sp.lastIndexOf(">") + 1);
binaryTreePathsRes.add(sp.toString());
stack.removeLast();
}
//下面就是深度优先搜索的过程
binaryTreePathsFunc(node.left);
binaryTreePathsFunc(node.right);
stack.removeLast();
}
还有一个搜索和为targerSum的路径然后返回, 其实是一样的思路, 定义一个全局变量sum, 每次遍历到的时候进行 sum += root.val , 搜索完毕之后进行回溯操作, sum -= root.val, 思路其实是一样的
5. LCA公共祖先问题
其实就是求两个节点p,q在一颗二叉树的最近的公共祖先, 也是著名的LCA问题(该问题本身是很复杂的,我们先弄一个入门题), 我们知道, p,q对于一颗树来说, 他们可能在一支上(此时q或者是p就是最近的公共祖先), 又或者是分属于两颗不同的树, 此时树的根节点就是最近的公共祖先, 我们现在创建, 其实也就是dfs对左右子树进行深度优先搜索的思路)
其实代码是很简单的
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if(root == null || root == q || root == p){
return root;
}
//往左边搜索
TreeNode ln = lowestCommonAncestor(root.left,p,q);
//往右边搜索
TreeNode rn = lowestCommonAncestor(root.right,q,p);
if(ln == null && rn == null) return null;
if(ln != null && rn != null) return root;
return ln == null ? rn : ln;
}
}
其实就是一个简单的搜索的逻辑
总结一下本节, 我们递归要如何想到, 我们要把这个方法当成一个黑盒子, 然后确定返回值, 终止条件等等, 另外递归的过程其实就是深度优先搜索, 搜索和递归是不分家的, 还有搜索跟回溯是不分家的
6. 二叉搜索树的LCA问题
常规树的LCA解决之后, 二叉搜索树的LCA问题其实也被包括在里面了, 但是是否有一个更好的思路来解决二叉搜索树的最近公共祖先呢?
我们都知道, 对于二叉搜索树来说, 左侧的所侧节点都小于当前节点, 右侧都大于
所以我们可以把问题抽象为下面这个问题, 记作当前的节点为cur
cur == p || cur == q : cur就是我们要找到的祖先节点
cur > Math.max(p,q) : cur = cur.left (向左侧移动)
cur < Math.min(p,q) : cur = cur.right (向右侧移动)
Math.min(p,q) < cur < Math.max(p,q) 此时 cur就是最近公共祖先
翻译为实现代码如下
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
return p.val > q.val ? lca(root,q,p) : lca(root,p,q);
}
//该方法默认的p为较小的值, q为较大的值
public TreeNode lca(TreeNode root,TreeNode p,TreeNode q){
while(p != root && q != root){
if(root.val > p.val && root.val < q.val){
return root;
}
root = root.val > q.val ? root.left : root.right;
}
return root;
}
}
7. 验证搜索二叉树
这个问题如何想到递归呢, 首先二叉搜索树的左子树也是一个二叉搜索树, 右子树也是一个二叉搜索树, 递归的终点其实就是叶子节点, 对于任何一个树, 左子树的最大值, 一定要小于中间节点的值, 右侧子树的最小值一定要大于中间节点的值, 而对于任意一个树, 如何找到最大最小值呢, 其实就是左子树一直向右侧扎, 右子树一直向左侧扎, 代码实现如下
class Solution {
public boolean isValidBST(TreeNode root) {
//递归的终止条件
if(root == null) return true;
if(root.left == null && root.right == null) return true;
//寻找左侧最大, 右侧最小(设置一个前驱节点)
TreeNode curl = root.left;
TreeNode prel = null;
TreeNode curr = root.right;
TreeNode prer = null;
while(curl != null){
prel = curl;
curl = curl.right;
}
while(curr != null){
prer = curr;
curr = curr.left;
}
if(prel == null){
if(root.val >= prer.val) return false;
}
if(prer == null){
if(root.val <= prel.val) return false;
}
if(prel != null && prer != null){
if(root.val <= prel.val || root.val >= prer.val) return false;
}
return isValidBST(root.left) && isValidBST(root.right);
}
}
遍历方式其实就是后续遍历, 收集到左右子树的信息然后返回
8. 修建搜索二叉树
这个题的意思就是上面描述的这样, 分析的思路如下
/**
* 修剪二叉搜索树
* 问题分析 : 如果当前节点的值小于左边界, 那么我们当前节点及其左边都不会被保留
* 如果当前节点的值大于右边界, 那么我们当前节点及其右边都不会被保留
* 如果当前节点的值介于范围内部, 那么就保留当前节点递归修建左子树跟右子树
*
* 代码逻辑 : 我们的 "黑盒函数" 的功能是修建一颗二叉树, 并返回修剪之后的头节点
* 实在不行自己画递归图去理解
*/
public TreeNode trimBST(TreeNode root, int low, int high) {
//递归的终止条件
if(root == null){
return null;
}
if(root.val < low){
return trimBST(root.right,low,high);
}
if(root.val > high){
return trimBST(root.left,low,high);
}
root.left = trimBST(root.left,low,high);
root.right = trimBST(root.right,low,high);
return root;
}
9. 二叉树打家劫舍问题
这道题其实已经是树形dp了, 但是我们今天的解法大家都能听懂, 我们用递归去做这道题, 我们首先定义一个全局变量yes, 和一个全局变量no, 前者代表的含义是对一个头节点来说, 我偷了头节点的最大收益, 后者来说是我
不偷头节点的最大收益, 设置我们的当前节点的状态就是n(不偷当前节点), y(偷当前节点), 所以我们的状态转移方程就是
y += this.no;
n += Math.max(this.yes,this.no);
我们下面的func函数执行完毕之后就会更新全局的yes和no变量
class Solution {
//yes的意思是偷头节点的情况下, 我们能获得的最大收益
public int yes = 0;
//no的意思是不偷头节点的情况下, 我们能获得的最大收益
public int no = 0;
public int rob(TreeNode root) {
func(root);
return Math.max(this.yes,this.no);
}
//该函数的功能就是给定一个头节点, 可以得到我们此时节点的两种最优解的情况
private void func(TreeNode root){
//递归终止条件
if(root == null){
this.yes = 0;
this.no = 0;
}else{
int y = root.val;
int n = 0;
func(root.left);
y += this.no;
n += Math.max(this.no,this.yes);
func(root.right);
y += this.no;
n += Math.max(this.no,this.yes);
this.yes = y;
this.no = n;
}
}
}