公共祖先问题汇总

公共祖先

公共祖先是OI里面的一个问题,简称LCA(lowest Common Ancestor),详细的链接,下面是整理的一些力扣上关于LCA的题目

题目

剑指 Offer 68 - I. 二叉搜索树的最近公共祖先235. 二叉搜索树的最近公共祖先

剑指 Offer 68 - II. 二叉树的最近公共祖先236. 二叉树的最近公共祖先

面试题 04.08. 首个共同祖先

1123\. 最深叶节点的最近公共祖先 重复题 865. 具有所有最深节点的最小子树

1125\. 树节点的第 K 个祖先

剑指 Offer 68 - I. 二叉搜索树的最近公共祖先

之前写过一篇题解了,这里再把内容汇总一下

后序迭代

后序迭代遍历,当碰到结点node的时候,此时迭代栈中的所有结点都是node的祖先节点,通过两次遍历找两组祖先节点,比较即可

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        # 后序迭代遍历到p,q时两个栈
        sp=[]
        sq=[]
        self.visit(root,p,sp)
        self.visit(root,q,sq)

        # 从后往前找到第一个相同的元素
        for i in range(min(len(sp),len(sq))-1,-1,-1):
            # print(sp[i].val,sq[i].val)
            if sp[i]==sq[i]:
                return sp[i] 

    def visit(self,root,node,stack):
        # 后序迭代遍历
        pre = None
        while stack or root:
            if root:
                if root==node:
                    break
                stack.append(root)
                root=root.left
            else:
                root = stack[-1]
                if not root.right or pre == root.right:
                    stack.pop(-1) 
                    pre=root
                    root=None
                else:
                    root=root.right

没有利用到二叉搜索树的性质

先序递归

在后序的基础上改进成先序找到p、q结点的路径,然后利用二叉搜索树的性质,在遍历的时候剪枝,最终仍然会找到p、q的路径,然后按照相同的方法比较,这个方法比后序的效率更高一些
最基本的在root中寻找node的路径方法,没有加入剪枝

    def findRoad(self,root,node,road,res):
        if not root:
            return
        if root == node:
            road.append(root)
            res.extend(road.copy())
            return 
        road.append(root)
        self.findRoad(root.left,node,road,res)
        self.findRoad(root.right,node,road,res)
        road.pop()

使用上面的代码进行遍历,其实效果和后序迭代差不多,没有优化多少,只是看起来简洁一些

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        road_p,road_q=[],[] 
        self.findRoad(root,p,[],road_p)
        self.findRoad(root,q,[],road_q)
        
        for i in range(min(len(road_p),len(road_q))-1,-1,-1):
            if road_q[i] == road_p[i]:
                return road_p[i]


    def findRoad(self,root,node,road,res):
        if not root:
            return
        if root == node:
            road.append(root)
            res.extend(road.copy())
            return 
        road.append(root)
        self.findRoad(root.left,node,road,res)
        self.findRoad(root.right,node,road,res)
        road.pop()

考虑二叉搜索树的性质,可以在findRoad中避免无效的递归

  1. 如果当前root等于node,结束递归
  2. 如果当前的root.val>node.val,往左子树递归,不需要往右子树递归
  3. 如果当前的root.val<node.val,往右子树递归,不需要往左子树递归

现在代码变为

    def findRoad(self,root,node,road,res):
        if not root:
            return
        if root == node:
            road.append(root)
            res.extend(road.copy())
            return 
        road.append(root)
        if root.val > node.val:
	        self.findRoad(root.left,node,road,res)
	    else:
	        self.findRoad(root.right,node,road,res)
        road.pop()

最终代码

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        road_p,road_q=[],[] 
        self.findRoad(root,p,[],road_p)
        self.findRoad(root,q,[],road_q)
        
        for i in range(min(len(road_p),len(road_q))-1,-1,-1):
            if road_q[i] == road_p[i]:
                return road_p[i]

    def findRoad(self,root,node,road,res):
        if not root:
            return
        if root == node:
            road.append(root)
            res.extend(road.copy())
            return 
        road.append(root)
        if root.val > node.val:
	        self.findRoad(root.left,node,road,res)
        else:
	        self.findRoad(root.right,node,road,res)
        road.pop()

效率提高了很多
在这里插入图片描述

利用性质递归

利用二叉搜索树的性质,不断往下定位,因为两个结点的最近公共祖先,就是里根节点最远的祖先,我们一直往下跳就行了

给定一颗二叉搜索树root和两个结点p、q,那么pq结点的排列情况有以下:
1 p和q均位于root的左子树,即p.val<root.val and q.val<root.val
2 p和q均位于root的右子树p.val>root.val and q.val>root.val
3 p和q一个在左子树一个在右子树 (p.val<root.val and q.val>root.val ) or (p.val>root.val and q.val<root.val )

当1、2情况时,p和q最近的公共祖先不会是root,应该往root的左右子树走
当3情况时,p和q的公共祖先是root,并且还是最近的

最理想的情况就是3,我们可以唯一确定一个公共结点并返回
下面是伪码

def func(root,p,q):
	if 情况3:
		return root
	else if 情况1:
		return func(root.left,p,q) #往左子树方向找
	else:
		return func(root.right,p,q) #往右子树方向找

画出草图,发现情况1、2会有更具体的细分
最理想的情况是进入若干次1、2情况后,达到情况3
但是有可能存在p是q的祖先,或者q是p的祖先情况,当我们进行若干次迭代后,如果遇到root==p或者root==q的情况时,此时root必定是公共结点,直接返回root即可
更新后的伪代码:

def func(root,p,q):
	if root==p or root==q:
		return root
	if 情况3:
		return root
	else if 情况1:
		return func(root.left,p,q) #往左子树方向找
	else:
		return func(root.right,p,q) #往右子树方向找

通过的代码:

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        if root == p or root == q:
            return root
        if (p.val<root.val and q.val>root.val ) or (p.val>root.val  and q.val<root.val):
            return root
        elif p.val<root.val and q.val<root.val:
            return self.lowestCommonAncestor(root.left,p,q) 
        else:
            return self.lowestCommonAncestor(root.right,p,q) 

比后序迭代简单很多

剑指 Offer 68 - II. 二叉树的最近公共祖先

这一题是普通的二叉树,没有别的性质可以利用

后序迭代

仍然可以用后序迭代来解决

# Definition for a binary tree node.
# class TreeNode:
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Solution:
    def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
        # 后序迭代遍历到p,q时两个栈
        sp=[]
        sq=[]
        self.visit(root,p,sp)
        self.visit(root,q,sq)

        # 从后往前找到第一个相同的元素
        for i in range(min(len(sp),len(sq))-1,-1,-1):
            # print(sp[i].val,sq[i].val)
            if sp[i]==sq[i]:
                return sp[i] 

    def visit(self,root,node,stack):
        # 后序迭代遍历
        pre = None
        while stack or root:
            if root:
                if root==node:
                    break
                stack.append(root)
                root=root.left
            else:
                root = stack[-1]
                if not root.right or pre == root.right:
                    stack.pop(-1) 
                    pre=root
                    root=None
                else:
                    root=root.right

先序递归

这次的先序递归没有办法优化了,性能比后序迭代更差

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        road_p,road_q=[],[] 
        self.findRoad(root,p,[],road_p)
        self.findRoad(root,q,[],road_q)
        
        for i in range(min(len(road_p),len(road_q))-1,-1,-1):
            if road_q[i] == road_p[i]:
                return road_p[i]


    def findRoad(self,root,node,road,res):
        if not root:
            return
        if root == node:
            road.append(root)
            res.extend(road.copy())
            return 
        road.append(root)
        self.findRoad(root.left,node,road,res)
        self.findRoad(root.right,node,road,res)
        road.pop()

LCA的朴素解法

oi-wiki里面介绍了LCA问题的多种求法
另外找到了一篇很不错的文章 夜深人静写算法(六) - 最近公共祖先 ,讲得更加具体

这里我们面对的树是一颗普通的二叉树,并没有二叉搜索树的性质可以提供给我们来解题,不过我们仍然可以借助上一题剑指 Offer 68 - I. 二叉搜索树的最近公共祖先利用性质递归的思想来尝试写出这道题

首先lowestCommonAncestor(root, p, q)的功能是找出以root为根节点的两个节点p和q的最近公共祖先,所以递归体分三种情况讨论:

  • 如果p和q分别是root的左右节点,那么root就是我们要找的最近公共祖先,这个结论是一定的
  • 如果p和q都在root的左子树,那么返回lowestCommonAncestor(root.left,p,q)
  • 如果p和q都在root的右子树,那么返回lowestCommonAncestor(root.right,p,q)

关键是我们在一颗普通的二叉树里面怎么判断p、q、root的相对位置,如果能判断出来,那么这道题也就迎刃而解了

我们先分析边界条件
1 root为空 返回root
2 p == root或者 q == root,此时我们返回root即可

那么我们现在有lowestCommonAncestor(root, p, q)这个函数,它可以在树root中找到p和q的最近公共祖先,于是我们尝试调用
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
来找到root的左右子树中,是否有p、q的最近公共祖先,显然这里也可以分类讨论

  1. left和right都不为空
    此时root一定是公共祖先,前面分析过了
  2. right为空,那么公共祖先一定是left,返回left
  3. left为空,那么公共祖先一定是right,返回right

由此我们可以写出代码

class Solution:
    def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
        if not root or root == q or root == p:
            return root
        left = self.lowestCommonAncestor(root.left,p,q)
        right = self.lowestCommonAncestor(root.right,p,q)

        if left and right:
            return root
        if not right:
            return left
        return right

对比剑指 Offer 68 - I. 二叉搜索树的最近公共祖先利用性质递归的答案,其实这两个答案思想是很相近的,只不过我们用二叉搜索树这个性质能更快的定位p、q与root的位置

class Solution:
    def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
        if root == p or root == q:
            return root
        if (p.val<root.val and q.val>root.val ) or (p.val>root.val  and q.val<root.val):
            return root
        elif p.val<root.val and q.val<root.val:
            return self.lowestCommonAncestor(root.left,p,q) 
        else:
            return self.lowestCommonAncestor(root.right,p,q) 

注意这两种解法都是后序递归的应用

面试题 04.08. 首个共同祖先

这题要求我们不能用额外的数据结构,也就是不能用后序迭代和先序递归两种方法了,但是我们在上一题刚刚推导出LCA的朴素解法,我们可以利用这个思路解决这一道题

class Solution:
    def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
        if not root or root == p or root == q:
            return root
        left = self.lowestCommonAncestor(root.left,p,q)
        right = self.lowestCommonAncestor(root.right,p,q)

        if left and right:
            return root
        return left if not right else right

在这里插入图片描述

1123. 最深叶节点的最近公共祖先

这道题相当于缺失了之前题目的p、q结点,我们要先找到p、q结点,然后再找到它们的公共祖先

这道题目的意思没描述清楚。。就是让我们找到最深叶子节点,最深叶子节点可能有多个,我们需要找到它们的最近公共祖先结点
在这里插入图片描述
例如这颗树,我们应该返回结点7。。
所以题目不仅仅是缺失了p、q,因为有多个叶子结点
一开始最朴素的做法就是
找到叶子节点p或者p、q
找到它们的最近祖先结点
但是这样做通过不了上面的样例,因为没有考虑到可能有多个最深的叶子结点

自顶向下

所以我们还是换一种思想来做题,这里最关键的地方就是我们要找的是最深叶子节点的最近祖先
遍历过程中叶子节点的判断很容易

if not root.left and not root.right:
	是叶子

但是如何判断最深是一个问题

首先我们还是假设lcaDeepestLeaves(root)能够返回最深叶子节点的最近公共祖先

  1. 边界情况
    如果root为空或者root左右结点为空,返回root
  2. 一般操作
    假设我们能够找到最深的叶子节点p、q
    如果p和q位于root的两侧,返回root
    如果p和q位于root的一侧,继续向这一侧迭代
  3. 返回部分
    返回上述继续迭代的情况

再继续思考一下,在递归过程中,判断某个结点是否是最深是困难的,但是最深结点的层次是最大的,如果我们开始遍历之前,先保存最深层次,然后判断当前的左右子树深度是否等于最深层次,如果等于的话,那么这颗子树一定存在一个最深的叶子结点,这样我们就能完善上述的步骤了

  1. 边界情况
    如果root为空或者root左右结点为空,返回root
  2. 一般操作
    记录ld为左子树深度,rd为右子树深度,md为整颗子树的深度
    如果ld==rd==md,那么当前结点一定是最近祖先,返回当前节点
    否则我们往更深的子树遍历
  3. 返回部分
    返回上述继续递归的情况

由此可以写出代码
注意递归过程中我们需要对mdepth进行-1操作

class Solution:
    def lcaDeepestLeaves(self, root: TreeNode) -> TreeNode:
        mdepth = self.getDepth(root)
        # 从根节点开始,深度需要减去1
        return self.helper(root,mdepth-1)

    def getDepth(self,root):
        if not root:
            return 0
        l = self.getDepth(root.left)
        r = self.getDepth(root.right)
        return max(l,r)+1

    def helper(self,root,mdepth):
        if not root or (not root.left and not root.right):
            return root
        ld = self.getDepth(root.left)
        rd = self.getDepth(root.right)
        # print(ld,rd,mdepth)
        if ld == mdepth and rd == mdepth:
            return root
        return self.helper(root.left,mdepth-1) if ld>rd else self.helper(root.right,mdepth-1)

在这里插入图片描述
虽然通过了,但是效率并不高,可以猜想是重复计算了太多次深度导致的,如果直接在一趟遍历过程中,保留结点的高度信息,会省去很多重复的访问次数,但是这样写起来会比较麻烦

自底向上

粗略的看了一眼评论区,递归写得更加简洁,是把求深度和求祖先结点的两个函数合并在一起了,采用自底向上的方式计算高度
这种自底向上的方式计算,其实在之前的题目里面有出现过

现在我们重新设计一下代码
假设我们有一个find(root)函数,它可以返回root的最大深度,返回值为depth
显然边界条件是如果root不存在,我们返回-1作为标识
否则我们分别获得左右子树的高度,返回它们最大值+1

def find(root):
	if not root:
		return -1
	ld = find(root.left)
	rd = find(root.right)
	return max(ld,rd)+1

其实上面就是求深度的递归函数
然后我们想要改造成找到最深叶子节点的最近祖先,那么我们需要修改一下上面的函数

def find(root):
	if not root:
		return -1
	ld = find(root.left)
	rd = find(root.right)
	if ld == rd:
		return root
	return ld if rd == -1 else rd
class Solution:
    def lcaDeepestLeaves(self, root: TreeNode) -> TreeNode:
        return self.find(root)
        
    def find(self,root):
        if not root:
            return -1
        ld = self.find(root.left)
        rd = self.find(root.right)

        if ld == rd:
            return root
        return ld if rd == -1 else rd

我们按照这个思路编写代码提交,发现答案并不对,原因就是递归过程中,会存在多个ld == rd的情况,此时的ld、rd并不等最大深度,也会导致root的返回
现在我们要修改上面的代码,修改它的返回条件,返回树高和最近祖先结点

  1. 边界条件
    如果root不存在,我们返回0和None
  2. 一般操作
    我们对左右子树调用递归,然后记录它们的返回值信息
    按照上面的分类情况,比较左右子树的高度情况
  3. 返回部分
    同上面的分析

然后得出更新后的代码,参考了 这个回答

class Solution:
    def lcaDeepestLeaves(self, root: TreeNode) -> TreeNode:
        return self.find(root)[1]

    def find(self,root):
        if not root:
            return 0,None
        L = self.find(root.left)
        R = self.find(root.right)
        if L[0]>R[0]:
            return L[0]+1,L[1]
        elif L[0]<R[0]:
            return R[0]+1,R[1]
        else:
            return L[0]+1,root

通过了,但是如果我们把find函数改成下面的样子,就不行,给返回值加上括号,还是返回错误的结果

    def find(self,root):
        if not root:
            return 0,None
        L = self.find(root.left)
        R = self.find(root.right)

        if L[0] == R[0]:
            return L[0]+1,root
        else:
            return L[0]+1,L[1] if L[0]>R[0] else R[0]+1,R[1]

但是我们改成这样是可行的。。。从语义上来说,和上面代码的效果一样

    def find(self,root):
        if not root:
            return 0,None
        L = self.find(root.left)
        R = self.find(root.right)

        if L[0] == R[0]:
            return L[0]+1,root
        elif L[0]>R[0]:
            return L[0]+1,L[1]
        else:
            return R[0]+1,R[1]

看来以后写代码还是尽量写得详细一些,这个if表达式有一些坑

最后我们来分析一下上面的做法为什么能通过
原因之前提过了,合并计算高度和返回结点的两个功能到一个函数里面,其实这样写更像是LCA的朴素解法
然后这种自底向上的计算方法,之前做题的时候碰到过
https://blog.csdn.net/hhmy77/article/details/108939799 110. 平衡二叉树
这一题就有自底向上的解法

1125. 树节点的第 K 个祖先

待续

总结

最基本的LCA问题解法其实很简洁,下面就是代码

def LCA(root,p,q):
	if not root or root == p or root == q:
		return root
	L = LCA(root.left,p,q)
	R = LCA(root.right,p,q)
	if L and R:
		return root
	elif L:
		return L
	else:
		return R

我们可以用它顺利解决下列LCA的一般问题
剑指 Offer 68 - I. 二叉搜索树的最近公共祖先
剑指 Offer 68 - II. 二叉树的最近公共祖先
面试题 04.08. 首个共同祖先
当然后序迭代、前序遍历也是这个问题的解决方法,只不过时间开销会高一些

然后我们也看到了LCA问题的变形
1123. 最深叶节点的最近公共祖先
1125. 树节点的第 K 个祖先
它们的具体代码与LCA最基本的解法不同,不过思想是一样的

LCA的求解,可以帮助我们在一颗二叉树里面找到p、q两个结点的距离,不过目前我没看到类似的题目

猜你喜欢

转载自blog.csdn.net/hhmy77/article/details/109525937