[树 遍历的应用] 235.BST的最近公共祖先(利用BST特点做DFS) 236.二叉树的最近公共祖先( 路径栈法、后序遍历法,剑指offer 68 - II)
235. 二叉搜索树的最近公共祖先
题目链接:https://leetcode-cn.com/problems/lowest-common-ancestor-of-a-binary-search-tree/
分类:
- 二叉搜索树(利用二叉搜索树的特点做DFS寻找“分道扬镳”的节点)
- DFS
题目分析
题目提供的树是二叉搜索树,所以如果使用DFS遍历二叉搜索树也会更加方便,因为可以根据待查节点的大小进入指定的子树,而不需要把整棵树都遍历一遍。
通过示例可以发现,查找两个节点的最近公共祖先,就是寻找两个节点在遍历过程中不再进入相同的子树继续遍历时,也就是DFS寻找p,q时发生分道扬镳的那个节点。
思路:DFS
如何寻找这个p,q分道扬镳时刻的节点?
基于题目分析,可以从DFS的工作流程出发,针对二叉搜素树的DFS可以根据待查节点x的大小和当前树的root大小关系选择:
-
x == root 找到x
-
x > root 进入右子树继续查找
-
x < root 进入左子树继续查找
进入左右子树后,将root更新为当前子树的root,继续重复上述步骤。
把x替换为p和q,可以得到:
- 如果p,q和root的大小关系相同,但p!=root,q!=root,对p,q的查找就进入同一个子树继续遍历;
- 如果p,q和root的大小关系相同,且p == root或q == root,则此时的root是最近公共祖先。
- 如果p,q和root的大小关系不同,就是p,q产生分歧的时候,此时的root就是p,q的最近公共祖先。
递归实现
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
//p,q一个大于root,而一个小于root,则说明root就是最近公共祖先
if(p.val > root.val && q.val < root.val) return root;
if(p.val < root.val && q.val > root.val) return root;
//p,q至少有一个=root,则说明root就是最近公共祖先
if(p == root || q == root) return root;
//p,q均小于root,进入左子树继续dfs
if(p.val < root.val && q.val < root.val){
return lowestCommonAncestor(root.left, p,q);
}
else{
return lowestCommonAncestor(root.right,p,q);
}
}
迭代实现
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
while(root != null){
//如果一个大于root,而一个小于root,则说明root就是最近公共祖先
if((p.val > root.val && q.val < root.val)||(p.val < root.val && q.val > root.val)) break;
if(p == root || q == root) break;
if(p.val > root.val && q.val > root.val) root = root.right;
else if(p.val < root.val && q.val < root.val) root = root.left;
}
return root;
}
.
.
.
236.二叉树的最近公共祖先
分类:
- 树(路径、寻找最近公共祖先:栈、后序遍历)
- 栈(路径栈:记录路径上的每个节点)
题目分析
这题和235相比,二叉搜索树换成了一般的二叉树,其他条件和要求不变。
因此235中利用二叉搜索树特点的思路在这里不再适用,要在一般二叉树里找到指定的节点,最差情况下需要遍历所有节点才能找到,无法根据待查节点和root的大小关系自主选择接下去要遍历的子树。
所以要找到p,q的公共祖先,需要在遍历两个节点p,q时分别记录各自路径上的节点,当找到p,q之后,说明查找路径已经构成(p,q也要记得记录在内,因为题目明确节点自身也可以作为自己的祖先),再将路径上的节点一一拿出来对比,第一个相同的节点就是最近公共祖先。
这里就用到了栈的思想,所以可以显式地使用栈,也可以用递归来实现。
思路1:路径栈
-
开辟两个栈分别用于存放根节点到p和q的路径节点
-
DFS查找p和q,得到两个路径栈
-
利用两个路径栈寻找最近公共祖先:
- 如果两个栈大小相等,则开始同步弹出栈顶,遇到的第一个相同的栈顶就是最近公共祖先;
- 如果两个栈大小不相等,则先将较大栈多出的部分弹出,直到两个栈大小相等,再同步弹出栈顶,直到栈顶相同。
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
Deque<TreeNode> stack1 = new ArrayDeque<>();
Deque<TreeNode> stack2 = new ArrayDeque<>();
//dfs分别遍历两个节点,填充路径栈
dfs(root, p, stack1);
dfs(root, q, stack2);
//将较大栈弹出多出的部分
while(stack1.size() > stack2.size()){
stack1.pop();
}
while(stack2.size() > stack1.size()){
stack2.pop();
}
//开始同步弹出栈顶进行比较
while(stack1.peek() != stack2.peek()){
stack1.pop();
stack2.pop();
}
return stack1.peek();
}
//dfs查找节点target,同时记录从root到target的路径节点到栈中
public boolean dfs(TreeNode root, TreeNode target, Deque<TreeNode> stack){
if(root == target){
stack.push(target);
return true;
}
else{
stack.push(root);
if(root.left != null && dfs(root.left, target, stack)) return true;
if(root.right != null && dfs(root.right, target, stack)) return true;
stack.pop();
return false;
}
}
}
思路2:后序遍历(递归实现)
1、确定递归函数的功能、返回值
从顶层推到底层分析递归函数的功能:
在本题的遍历递归过程中,递归函数返回的是 从根节点到指定节点的路径上的点(不同层的递归函数返回的是路径上的不同点,最近公共祖先就包含在内),因为DFS是不断向下递归到的指定节点处,再一层层向上返回,所以最底层递归函数返回的是p(假设查找到p),然后一层层向上返回p的父节点,父节点的父节点,以此类推。
这一过程可以参考 Krahets 中的幻灯片,流程分析得很清晰。
递归的返回值:
返回值分为空节点和非空节点两种。
上面列举的是遍历过程中找到指定节点的情况,总的来说就是如果找到指定节点,就向上返回当前递归层中的root。例如递归到最底层找到p,此时的root = p,返回 root ;返回上一层递归,此时root=刚刚那个节点的父节点,也是路径上的一点,以此类推。
如果DFS直到叶子节点也没找到指定节点,则返回null。
为什么这里未找到选择返回null?
因为按照上面递归函数的设计,返回的非空节点都是路径上的各个点,而路径上可能包含哪些节点val是无法做出限定的,所以就用递归函数返回空节点还是非空节点将找到指定节点和未找到指定节点两种情况区分开。也就是说,递归函数返回 null 就说明该递归函数所遍历的子树下不包含指定节点。
总的来说,递归函数的返回值所表示的意义(这对理解这题的递归和编写代码有帮助),就是用来说明该递归函数所遍历的子树是否包含p,q。返回非空节点说明包含指定节点p或q,返回空节点说明不包含p和q。
而调用这两个递归函数的root就根据这两个函数的返回值确定p,q包含在哪一个子树中,再决定是否继续向下遍历,还是root自身就是最近公共祖先。
理解一个递归函数的流程,不一定要一层层递归分析下去,可以举最顶层的例子,忽略下一层递归函数的所有细节,
只关注它的返回值对当前层的影响。
当然理解一次递归的流程能够很清晰地对整体流程有更好的认识,通常推荐用画出树的方式来分解递归的流程,
每一层树代表一层递归。
2、后序遍历过程分析
根据上面对递归函数的功能和返回值的设置,具体分析这些递归函数及其返回值该如何应用于后序遍历,从而求解问题的解。
以整棵树的根节点root和它左右子树递归函数的返回值为例,可能的情况有:
如果root本身为空,说明到达递归出口,返回null; //可以写作return root,与下面的分支并在一起
如果root==p或root=q,说明找到p,q,而p,q本身也是路径的一部分,所以返回root;
如果root为其他非空节点时,则调用递归函数继续向左右子树遍历,对左右子树的返回值做进一步判断:
如果左子树返回非空节点,右子树也返回非空节点,则说明在左右子树分别找到了pq,这样两个节点的最近公共祖先就是root。
如果左子树返回非空,右子树返回空,则说明有指定节点在左子树,则返回left。
如果左子树返回空,右子树返回非空,则说明有指定节点在右子树,则返回right。
如果左子树返回空,右子树返回空,则说明遍历到不是p,q的叶子节点上,直接返回null。
这里设置返回left和right的一个目的是为了表明root的左子树、右子树包含p或q,另一个目的也是将left,right保留为潜在的最近公共祖先,因为他们就是在查找p,q 路径上的点,返回到上一层递归,交给上一层递归去判断。
3、算法实现
先明确采用的是那种遍历方式,这里先计算左右子树结果,再处理root,所以很明显是后序遍历,可以直接写出后序遍历的递归代码框架:
其中递归函数调用后的返回值要基于前面的分析理解返回值的意义和用处。
//递归出口
if(root == null) return root;
//左右子树递归遍历
left=f(root.left,p,q);
right=f(root.right,p,q);
//对root,左右子树递归函数返回值left,right的处理
...
框架给出后,就结合上面的2、的分析将框架补全,2、的分析包括了:
- 对递归出口的补充;
- 对左右子树递归函数返回值的处理;
- 对root的处理。
补充后得到:
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
//递归出口:遍历到叶子节点的下一个空节点或找到p,q
if(root == null || root == p || root == q) return root;
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
//如果left不为空,说明节点包含在left的子树中
if(left != null && right == null) return left;
//如果right不为空,说明节点包含在right中
if(left == null && right != null) return right;
//如果到达叶子节点且不为p,q,则返回null,说明没找到p,q
if(left == null && right == null) return null;
//两边都返回非空,说明当前root就是最近公共祖先
return root;
}