本文主要参考labuladong的算法笔记及B站视频:
https://labuladong.online/algo/essential-technique/binary-tree-summary/
二叉树问题往往都是递归问题,而递归问题又分为回溯和动规两种
一、回溯:
核心是遍历:
对二叉树进行遍历,本质上都是先遍历左子树,再遍历右子树,区别在于遍历的顺序是前序中序还是后序,分别对应在两个遍历的前中后插入核心代码:
遍历的模板一般是另外写一个遍历函数void traverse(TreeNode root , 需要的外部参数),如上图所示,然后在主函数里调用遍历函数
这里对前中后序进行更深刻的理解:
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,不仅仅是三个顺序不同的List:
前序位置的代码在刚刚进入一个二叉树节点的时候执行;
后序位置的代码在将要离开一个二叉树节点的时候执行;
中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行
这里的前中后序强调位置,每个节点都有唯一的前中后序位置,在递归的过程中,需要在进入/离开/切换节点的时候执行核心代码,就把核心代码放到对应的前中后序位置!
一般来说,中序位置主要用在BST(二叉搜索树)场景中,完全可以把BST的中序遍历认为是遍历有序数组
而前序位置本身其实没有什么特别的性质,之所以很多题都是在前序位置写代码,实际上是因为我们习惯把那些对前中后序位置不敏感的代码写在前序位置罢了
除此之外,由图可以看出来,前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的。这意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
因此,一旦题目和子树有关,那么大概率要给函数设置合理的定义和返回值,然后在后序位置写核心代码。
二、动态规划:
核心是分解:将大问题分解成小问题
分解的模板一般是直接在主函数里调用自己
三、回溯和动规的辨析:
例如求整个树的最大深度。
如果采用遍历,也就是回溯的方法,其思路是遍历一遍二叉树,用一个外部变量记录每个节点所在的深度,取最大值就可以得到最大深度。
// 记录最大深度
int res = 0;
// 记录遍历到的节点的深度
int depth = 0;
// 主函数
int maxDepth(TreeNode root) {
traverse(root);
return res;
}
// 二叉树遍历框架
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
depth++;
if (root.left == null && root.right == null) {
// 到达叶子节点,更新最大深度
res = Math.max(res, depth);
}
traverse(root.left);
traverse(root.right);
// 后序位置
depth--;
}
如果采用分解,也就是动规的方法,其思路就是二叉树的最大深度=max(左子树的最大深度,右子树的最大深度)+1
// 定义:输入根节点,返回这棵二叉树的最大深度
int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
// 利用定义,计算左右子树的最大深度
int leftMax = maxDepth(root.left);
int rightMax = maxDepth(root.right);
// 整棵树的最大深度等于左右子树的最大深度取最大值,
// 然后再加上根节点自己
int res = Math.max(leftMax, rightMax) + 1;
return res;
}
四、总结:
综上,遇到一道二叉树的题目时的通用思考过程是:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。
五、扩展:
图论当中,BFS对应的是树的层序遍历,DFS见下。
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
动态规划算法属于分解问题的思路,它的关注点在整棵「子树」。
回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
DFS 算法属于遍历的思路,它的关注点在单个「节点」。