[算法系列之三十一]最近公共祖先(LCA)

【简介】

对于有根树T的两个结点u、v,最近公共祖先LCA(T,u,v)表示一个结点x,满足x是u、v的祖先且x的深度尽可能大。

另一种理解方式是把T理解为一个无向无环图,而LCA(T,u,v)即u到v的最短路上深度最小的点。

例如,对于下面的树,结点4和结点6的最近公共祖先LCA(T,4,6)为结点2。

求树中两个结点的最低公共祖先是面试中经常出现的一个问题。一般的做法,可能是针对是否为二叉查找树分情况讨论。

LCA问题的扩展主要在于结点是否只包含父结点指针,对于同一棵树是否进行多次LCA查询。下面分别进行说明。

【二叉查找树情况】

【创建二叉查找树】
// 二叉查找树节点
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};

// 插入
void TreeInsert(TreeNode*& root,int val){
    // 创建新节点
    TreeNode* node = new TreeNode(val);
    // 空树
    if(root == NULL){
        root = node;
        return;
    }//if
    TreeNode *pre = NULL;
    TreeNode *p = root;
    // 寻找插入位置
    while(p){
        // 父节点
        pre = p;
        // 沿左子树方向下降
        if(val < p->val){
            p = p->left;
        }//if
        // 沿右子树方向下降
        else{
            p = p->right;
        }//else
    }//while
    // 左子结点处插入
    if(val < pre->val){
        pre->left = node;
    }//if
    // 右子结点处插入
    else{
        pre->right = node;
    }//else
}
【思路】
  • 如果当前结点t 大于结点u、v,说明u、v都在t 的左侧,所以它们的共同祖先必定在t 的左子树中,故从t 的左子树中继续查找;
  • 如果当前结点t 小于结点u、v,说明u、v都在t 的右侧,所以它们的共同祖先必定在t 的右子树中,故从t 的右子树中继续查找;
  • 如果当前结点t 满足 u <t < v,说明u和v分居在t 的两侧,故当前结点t 即为最近公共祖先;
  • 而如果u是v的祖先,那么返回u的父结点,同理,如果v是u的祖先,那么返回v的父结点。

【版本一递归版】

这个版本默认要查找节点u,v在root树中

// 最近公共祖先
TreeNode *LCA(TreeNode *root,TreeNode *u,TreeNode *v){
    // 空树
    if(root == nullptr || u == nullptr || v == nullptr){
        return nullptr;
    }//if
    // u < t < v  or v < t < u
    if((u->val < root->val && root->val < v->val) ||
       (v->val < root->val && root->val < u->val)){
        return root;
    }//if
    // u < root and v < root  left sub tree
    if(u->val < root->val && v->val < root->val){
        // u是v祖先 or v是u的祖先
        if(root->left == u || root->left == v){
            return root;
        }//if
        return LCA(root->left,u,v);
    }//if
    // u > root and v > root  right sub tree
    if(u->val > root->val && v->val > root->val){
        // u是v祖先 or v是u的祖先
        if(root->right == u || root->right == v){
            return root;
        }//if
        return LCA(root->right,u,v);
    }//if
    return nullptr;
}
【版本二迭代版】
/**--------------------------------
*   日期:2015-02-02
*   作者:SJF0115
*   题目: 最近公共祖先(LCA)迭代版
*   博客:
------------------------------------**/
TreeNode *LCA(TreeNode *root,TreeNode *u,TreeNode *v){
    // 空树
    if (root == nullptr || u == nullptr || v == nullptr) {
        return nullptr;
    }//if
    TreeNode *leftNode = u;
    TreeNode *rightNode = v;
    TreeNode *parentNode = nullptr;
    //二叉查找树内,如果左结点大于右结点,不对,交换
    if (leftNode->val > rightNode->val) {
        TreeNode *tmp = leftNode;
        leftNode = rightNode;
        rightNode = tmp;
    }//if
    while(root){
         // u < t < v  or v < t < u   在root两侧
        if(leftNode->val < root->val && root->val < rightNode->val){
            return root;
        }//if
        // u < root v < root left sub tree
        if(rightNode->val < root->val){
            parentNode = root;
            root = root->left;
        }//if
        // u > root v > root right sub tree
        else if(leftNode->val > root->val){
            parentNode = root;
            root = root->right;
        }
        // u是v祖先 or v是u的祖先
        else if(root == leftNode || root == rightNode){
            return parentNode;
        }
    }//while
    return nullptr;
}

【普通二叉树】

【版本一】

所谓共同的父结点,就是两个结点都出现在这个结点的子树中。因此我们可以定义一函数,来判断一个结点的子树中是不是包含了另外一个结点。

这不是件很难的事,我们可以用递归的方法来实现:

// root树中是否包含节点node
bool Exits(TreeNode *root,TreeNode *node){
    // find the node
    if(root == node){
        return true;
    }//if
    // left sub tree
    bool isExits = false;
    if(root->left){
        isExits = Exits(root->left,node);
    }//if
    // right sub tree
    if(!isExits && root->right){
        isExits = Exits(root->right,node);
    }//if
    return isExits;
}

我们可以从根结点开始,判断以当前结点为根的树中左右子树是不是包含我们要找的两个结点。如果两个结点都出现在它的左子树中,那最低的共同父结点也出现在它的左子树中。如果两个结点都出现在它的右子树中,那最低的共同父结点也出现在它的右子树中。如果两个结点一个出现在左子树中,一个出现在右子树中,那当前的结点就是最低的共同父结点。

// 最近公共祖先
TreeNode *LCA(TreeNode *root,TreeNode *u,TreeNode *v){
    // 空树
    if(root == nullptr || u == nullptr || v == nullptr){
        return nullptr;
    }//if
    // check whether left child has u and v
    bool leftHasNode1 = false;
    bool leftHasNode2 = false;
    if(root->left){
        leftHasNode1 = Exits(root->left,u);
        leftHasNode2 = Exits(root->left,v);
    }//if
    if(leftHasNode1 && leftHasNode2){
        if(root->left == u || root->left == v){
            return root;
        }//if
        return LCA(root->left,u,v);
    }//if
    // check whether right child has u and v
    bool rightHasNode1 = false;
    bool rightHasNode2 = false;
    if(root->right){
        if(!leftHasNode1){
            rightHasNode1 = Exits(root->right,u);
        }//if
        if(!leftHasNode2){
            rightHasNode2 = Exits(root->right,v);
        }//if
    }//if
    if(rightHasNode1 && rightHasNode2){
        if(root->right == u || root->right == v){
            return root;
        }//if
        return LCA(root->right,u,v);
    }//if
    // both left and right child has
    if((leftHasNode1 && rightHasNode2) || (leftHasNode2 && rightHasNode1)){
        return root;
    }//if
    return nullptr;
}

接着我们来分析一下这个方法的效率。函数Exits的本质就是遍历一棵树,其时间复杂度是O(n)(n是树中结点的数目)。由于我们根结点开始,要对每个结点调用函数Exits。因此总的时间复杂度是O(n^2)。

我们仔细分析上述代码,不难发现我们判断以一个结点为根的树是否含有某个结点时,需要遍历树的每个结点。

接下来我们判断左子结点或者右结点为根的树中是否含有要找结点,仍然需要遍历。第二次遍历的操作其实在前面的第一次遍历都做过了。

由于存在重复的遍历,本方法在时间效率上肯定不是最好的。

【版本二】

前面我们提过如果结点中有一个指向父结点的指针,我们可以把问题转化为求两个链表的共同结点。现在我们可以想办法得到这个链表。我们在本面试题系列的第4题中分析过如何得到一条中根结点开始的路径。我们在这里稍作变化即可:

/**--------------------------------
*   日期:2015-02-02
*   作者:SJF0115
*   题目: 从根节点到目标节点的路径
------------------------------------**/
bool NodePath (TreeNode* root,TreeNode* node,vector<TreeNode*> &path) {
    if(root == node) {
        return true;
    }//if
    path.push_back(root);
    bool isExits = false;
    // left sub tree
    if(root->left) {
        isExits = NodePath(root->left,node,path);
    }//if
    if(!isExits && root->right) {
        isExits = NodePath(root->right,node,path);
    }//if
    if(!isExits) {
        path.pop_back();
    }//if
    return isExits;
}

由于这个路径是从跟结点开始的。最低的共同父结点就是路径中的最后一个共同结点:

/*-------------------------------------
*   日期:2015-04-24
*   作者:SJF0115
*   题目: 最小公共祖先
*   博客:
------------------------------------*/
#include <iostream>
#include <vector>
using namespace std;

struct TreeNode{
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode *parent;
    TreeNode(int x):val(x),left(nullptr),right(nullptr),parent(nullptr){}
};
/*
题目:
给定两个节点,求它们在一棵二叉树中的最小公共祖先。
每个节点除了有左右节点之外,还有一个指向父节点的指针
*/
// 找到u,v在二叉树中对应的节点
bool FindPath(TreeNode* root,TreeNode* node,vector<TreeNode*> &path){
    if(root == nullptr || node == nullptr){
        return false;
    }//if
    path.push_back(root);
    // 找到目标节点
    if(root == node){
        return true;
    }//if
    bool isFind = false;
    // 左子树中查找
    if(root->left){
        isFind = FindPath(root->left,node,path);
    }//if
    // 右子树查找(左子树中未找到的情况下)
    if(!isFind && root->right){
        isFind = FindPath(root->right,node,path);
    }//if
    // 左右子树均未找到
    if(!isFind){
        path.pop_back();
    }//if
    return isFind;
}

TreeNode* LCA(TreeNode* root,TreeNode* u,TreeNode* v){
    vector<TreeNode*> path1,path2;
    // 根节点到u的路径
    bool isFound1 = FindPath(root,u,path1);
    // 根节点到v的路径
    bool isFound2 = FindPath(root,v,path2);
    // 二叉树中是否存在uv节点
    if(!isFound1 || !isFound2){
        return nullptr;
    }//if
    int size1 = path1.size();
    int size2 = path2.size();
    TreeNode* node = nullptr;
    for(int i = 0,j = 0;i <= size1 && j <= size2;++i,++j){
        if(i == size1 || j == size2 || path1[i] != path2[j]){
            node = path1[i-1];
            break;
        }//if
    }//for
    return node;
}

int main(){
    TreeNode* root = new TreeNode(0);
    TreeNode* node1 = new TreeNode(1);
    TreeNode* node2 = new TreeNode(2);
    TreeNode* node3 = new TreeNode(3);
    TreeNode* node4 = new TreeNode(4);
    TreeNode* node5 = new TreeNode(5);
    TreeNode* node6 = new TreeNode(6);

    root->left = node1;
    root->right = node2;
    node1->left = node3;
    node1->right = node4;
    node2->right = node5;
    node4->left = node6;

    vector<TreeNode*> path;
    if(FindPath(root,node5,path)){
        for(int i = 0;i < path.size();++i){
            cout<<path[i]->val<<" ";
        }//for
        cout<<endl;
    }//if
    path.clear();
    if(FindPath(root,node6,path)){
        for(int i = 0;i < path.size();++i){
            cout<<path[i]->val<<" ";
        }//for
        cout<<endl;
    }//if
    TreeNode* parent = LCA(root,node5,node6);
    cout<<parent->val<<endl;
    return 0;
}

这种思路的时间复杂度是O(n),时间效率要比第一种方法好很多。但同时我们也要注意到,这种思路需要两个链表来保存路径,空间效率比不上第一个方法。

时间: 2024-09-20 18:44:21

[算法系列之三十一]最近公共祖先(LCA)的相关文章

算法系列(十一) 圆生成算法

在平面解析几何中,圆的方程可以描述为(x – x0)2 + (y – y0)2 = R2,其中(x0, y0)是圆心坐 标,R是圆的半径,特别的,当(x0, y0)就是坐标中心点时,圆方程可以简化为x2 + y2 = R2.在计算 机图形学中,圆和直线一样,也存在在点阵输出设备上显示或输出的问题,因此也需要一套光栅扫描 转换算法.为了简化,我们先考虑圆心在原点的圆的生成,对于中心不是原点的圆,可以通过坐标的 平移变换获得相应位置的圆. 在进行扫描转换之前,需要了解一个圆的特性,就是圆的八分对 成

[算法系列之三十三]杨氏矩阵

即对于矩阵Table有Table[i][j] ≤Table[i][j + 1], Table[i][j] ≤ Table[i + 1][j],我们也称这样的矩阵为杨氏矩阵. 给出判定某个数是否存在该矩阵中的高效算法.   分析: 为了便于复杂度分析,我们暂时假定该矩阵为大小n*n.如下图所示为一个杨氏矩阵. 二分搜索解法: 许多人都观察到了矩阵在二维上都是有序的,所以使用在每一行(或者每一列)使用二分搜索是很自然的想法.由于每一行二分搜索需要O(lgn)时间,搜索n行需要O(nlogn)的时间.

[算法系列之三十]Dijkstra单源最短路径算法

单源最短路径问题 给定一个带权有向图 G=(V,E) ,其中每条边的权是一个非负实数.另外,还给定 V 中的一个顶点,称为源.现在我们要计算从源到所有其他各顶点的最短路径长度.这里的长度是指路上各边权之和.这个问题通常称为单源最短路径问题. 前面Bellman-Ford最短路径算法讲了单源最短路径的Bellman-Ford算法(动态规划算法).这里介绍另外一个更常见的算法Dijkstra算法. Dijkstra算法和 最小生成树Prim算法最小生成树算法非常类似,大家可以先熟悉下个算法.两个算法

[算法系列之三]二叉树中序前序序列(或后序)求解树

[思路] 这种题一般有二种形式,共同点是都已知中序序列.如果没有中序序列,是无法唯一确定一棵树的. <1>已知二叉树的前序序列和中序序列,求解树. 1.确定树的根节点.树根是当前树中所有元素在前序遍历中最先出现的元素. 2.求解树的子树.找出根节点在中序遍历中的位置,根左边的所有元素就是左子树,根右边的所有元素就是右子树.若根节点左边或右边为空,则该方向子树为空:若根节点 边和右边都为空,则根节点已经为叶子节点. 3.递归求解树.将左子树和右子树分别看成一棵二叉树,重复1.2.3步,直到所有的

[算法系列之三十二]1的数目

题目 Given an integer n, count the total number of digit 1 appearing in all non-negative integers less than or equal to n. For example: Given n = 13, Return 6, because digit 1 occurred in the following numbers: 1, 10, 11, 12, 13. 思路一 这个问题看上去并不是一个困难的问题,

剑指offer系列之三十一:把数组排成最小的数

题目描述 输入一个正整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个.例如输入数组{3,32,321},则打印出这三个数字能排成的最小数字为321323. 根据结果判断,所谓最小的数字实际上就是对数组中所有元素的一个组合.一种笨拙的方法是求出所有元素的全排列,然后对所有排列的值的大小进行排序,那么就可以得到最小的数了.求全排列的算法已经在之前的文章中提到.那么是不是还有其他思路呢?联想到Java库函数中有一个sort方法,是不是可以直接使用呢?(该sort方法的时

[算法系列之十一]荷兰国旗问题

[问题] 现有红白蓝三个不同颜色的小球,乱序排列在一起,请重新排列这些小球,使得红白蓝三色的同颜色的球在一起.这个问题之所以叫荷兰国旗问题,是因为我们可以将红白蓝三色小球想象成条状物,有序排列后正好组成荷兰国旗. [分析] 这个问题我们可以将这个问题视为一个数组排序问题.红白蓝分别对应数字0.1.2.红.白.蓝三色小球数量并不一定相同. [思路一] First, iterate the array counting number of 0's, 1's, and 2's, then overwr

[算法系列之十九]最长公共子序列

题目 最长公共子序列 分析 有两个字符串S1和S2,求一个最长公共子串,即求字符串S3,它们同时是S1和S2的子串,且要求它们的长度最长,并确定这个长度.这个问题我们称之为最长公共子序列问题. 与求最长递增子序列一样,我们首先将原问题分割成一些子问题,我们用dp[i][j]表示S1中前i个字符和S2中前j个字符分别组成的两个前缀字符串的最长公共子串长度.显然的,当i,j较小时我们可以直接给出答案,如dp[0][j] 必等于0.那么,假设我们已经求的dp[i][j](0 <= i < x,0 &

[算法系列之二十八]并查集(不相交集合)

一 概述 并查集(Disjoint set或者Union-find set)是一种树型的数据结构,常用于处理一些不相交集合(Disjoint Sets)的合并及查询问题. 有一个联合-查找算法(union-find algorithm)定义了两个操作用于此数据结构: Find:确定元素属于哪一个子集.它可以被用来确定两个元素是否属于同一子集. Union:将两个子集合并成同一个集合. 因为它支持这两种操作,一个不相交集也常被称为联合-查找数据结构(union-find data structur