编程之法:面试和算法心得 -最近公共祖先LCA问题

最近公共祖先LCA问题

小结:

  1. 暴力
    • 二叉查找树(左右子树递归找
    • 非二叉查找树
      • 转换为单向链表第一个公共点
      • 递归
        缺点:适合一次查询,不适合多次,多次复杂度扩大N倍
  2. Tarjan算法
    是一个找强连通分量的算法。dfs+并查集,每次将两个节点对的最近公共祖先的查询保存起来,然后dfs更新一次。
    复杂度:O(n + Q), Q为查询个数
  3. RMQ(没看)
    但前面暴力求a[i,j]最小值的位子可以考虑一下: 1. 普通的O(n^3) 2. 动态规划将为O(n^2)
  4. 线段树
    用M[i]保存节点i区间的最小值的位置,其中左节点为2*i, 右节点为2*i+1
    复杂度, 构建O(n),查询O(logn)

问题描述

求有根树的任意两个节点的最近公共祖先

分析与解法

直观的做法,可能是针对是否为二叉查找树分情况讨论,这也是一般人最先想到的思路。除此之外,还有所谓的Tarjan算法、倍增算法、以及转换为RMQ问题(求某段区间的极值)

解法一、暴力对待

1.1 是二叉查找树

算法:
从root开始:

  • 如果当前节点t大于u,v,说明u,v都在t的左侧,所以他们的公共祖先必定在t的左子树中,从t的左子树继续查找
  • 如果t小于节点u,v.…, 故从t的右子树中继续查找
  • 如果当前节点满足 u < t < v, 说明u,v分居在t的两侧,故当前节点t为最近公共祖先
  • 如果u是v的祖先,那么返回u的父节点,同理,如果v是u的祖先,返回v的父节点

代码:

//copyright@eriol 2011  
//modified by July 2014  
public int query(Node t, Node u, Node v) {    
    int left = u.value;    
    int right = v.value;    
  
    //二叉查找树内,如果左结点大于右结点,不对,交换  
    if (left > right) {    
        int temp = left;    
        left = right;    
        right = temp;    
    }    
  
    while (true) {    
        //如果t小于u、v,往t的右子树中查找  
        if (t.value < left) {    
            t = t.right;    
  
        //如果t大于u、v,往t的左子树中查找  
        } else if (t.value > right) {    
            t = t.left;    
        } else {    
            return t.value;    
        }    
    }    
}  

1.2 不是二叉查找树

  1. 我们可以从任何一个节点出发,得到一个到达根节点的单向链表。因此这个问题转换成两个单向链表的第一个公共节点。

  2. 此外,如果给出根节点,LCA问题可以用递归很快解决。关于树的问题一般都可以转换成递归
    参考代码如下:

node* geteLCA(node* root, node* node1, node* node2)
{
	if(root == null)
		return null;
	if(root == node1 || root == node2)
		return root;
	node* left = getLCA(root->left, node1, node2);
	node* right = getLCA(root->right, node1, node2);
	if(left != null && right != null)
		return root;
	else if(left  != null)
		return left;
	else if(right != null)
		return right;
	else
		return null;
}

然不论是针对普通的二叉树,还是针对二叉查找树,上面的解法有一个很大的弊端就是:如需N 次查询,则总体复杂度会扩大N 倍,故这种暴力解法仅适合一次查询,不适合多次查询

解法二:Tarjan算法

2.1 Tarjan算法

Tarjan算法是一个在图中寻找强连通分量的算法。算法的基本思想为:任选一节点开始进行深度优先搜索dfs(若深度优先搜索结束后仍有未访问的节点,则再从中任选一点再次进行)。搜索过程中已访问的节点不再访问。搜索树的若干子树构成了图的强连通分量。

对于LCA问题:对于新搜索到的一个节点u,先创建u构成的集合,在对u的每棵子树进行搜索,每搜索完一棵子树,这时候子树中所有的节点的最近公共祖先就是u了

举例,如下图(不同颜色的节点相当于不同的集合):
编程之法:面试和算法心得 -最近公共祖先LCA问题_第1张图片
假设遍历完10的孩子,要处理关于10的请求了,取根节点到当前正在遍历的节点的路径为关键路径,即1-3-8-10,集合的祖先便是关键路径上距离集合最近的点。

比如:

1,2,5,6为一个集合,祖先为1,集合中点和10的LCA为1
3,7为一个集合,祖先为3,集合中点和10的LCA为3
8,9,11为一个集合,祖先为8,集合中点和10的LCA为8
10,12为一个集合,祖先为10,集合中点和10的LCA为10

得出的结论便是:LCA(u,v)便是根至u的路径上到节点v最近的点。

2.2 Tarjan算法流程

Procedure dfs(u):
	begin
		设置u号节点的祖先为u
		若u的左子树不为空, dfs(u -> 左子树);
		若u的右子树不为空,dfs(u -> 右子树);
		访问每一条与u相关的询问u,v
			若v已经被访问过,则输出v的当前祖先t(t 即u, v的LCA)
		标记u为已经访问,将所有u的孩子包括u本身的祖先改为u的父亲

普通的dfs 不能直接解决LCA问题,故Tarjan算法的原理是dfs + 并查集它每次把两个结点对的最近公共祖先的查询保存起来,然后dfs 更新一次。如此,利用并查集优越的时空复杂度,此算法的时间复杂度可以缩小至O(n+Q),其中,n为数据规模,Q为询问个数。

参考https://www.cnblogs.com/shadowland/p/5872257.html

解法三: 转换为RMQ问题

转换为RMQ问题,用Sparse Table(简称ST)算法解决。

RMQ问题

RMQ问题:
全称为Range Minimum Query,顾名思义,则是区间最值查询,它被用来在数组中查找两个指定索引中最小值的位置。即RMQ相当于给定数组A[0, N-1],找出给定的两个索引如 i、j 间的最小值的位置

假设我们定义:
假设一个算法预处理时间为 f(n),查询时间为g(n),那么这个算法复杂度的标记为。我们将用RMQA(i, j) 来表示数组A 中索引i 和 j 之间最小值的位置。 u和v的离树T根结点最远的公共祖先用LCA T(u, v)表示。

举例:
如下图所示,RMQA(2,7 )则表示求数组A中从A[2]~A[7]这段区间中的最小值:
编程之法:面试和算法心得 -最近公共祖先LCA问题_第2张图片
很显然,从上图中,我们可以看出最小值是A[3] = 1,所以也就不难得出最小值的索引值RMQA(2,7) = 3。

RMQ问题解决

1. Trivial algorithm for RMQ

我们对对每一对索引(i, j),将数组中索引i 和 j 之间最小值的位置 RMQA(i, j) 存储在M[0, N-1][0, N-1]表中。

先引出3种计算方法:

  1. 普通的计算将得到一个的算法。通过一个简单的动态规划,我们可以将复杂度降为
//copyright@  
//modified by July 2014  
void process1(int M[MAXN][MAXN], int A[MAXN], int N)  
{  
    int i, j;  
    for (i =0; i < N; i++)  
        M[i][i] = i;  
  
    for (i = 0; i < N; i++)  
        for (j = i + 1; j < N; j++)  
            //若前者小于后者,则把后者的索引值付给M[i][j]  
            if (A[M[i][j - 1]] < A[j])  
                M[i][j] = M[i][j - 1];  
            //否则前者的索引值付给M[i][j]  
            else  
                M[i][j] = j;  
}  
  1. 一个比较有趣的点子是把向量分割成sqrt(N)大小的段。我们将在M[0,sqrt(N)-1]为每一个段保存最小值的位置。如此,M可以很容易的在O(N)时间内预处理。

编程之法:面试和算法心得 -最近公共祖先LCA问题_第3张图片
3. 一个更好的方法预处理RMQ 是对2^k 的长度的子数组进行动态规划。我们将使用数组M[0, N-1][0, logN]进行保存,其中M[ i ][ j ] 是以i 开始,长度为 2^j 的子数组的最小值的索引。这就引出了咱们接下来要介绍的Sparse Table (ST) algorithm。

2. Sparse Table(ST) algorithm

…(待看)

解法四:线段树

线段树是一个类似堆的数据结构,可以在基于区间数组上用对数时间进行更新和查询操作。我们用下面递归方式来定义线段树的[i, j]区间:

  • 第一个结点将保存区间[i, j]区间的信息
  • 如果i 注意具有N个区间元素的线段树的高度为[logN] + 1。下面是区间[0,9]的线段树:
    编程之法:面试和算法心得 -最近公共祖先LCA问题_第4张图片
    线段树和堆具有相同的结构,因此我们定义x是一个非叶结点,那么左孩子结点为2x,而右孩子结点为2x+1。想要使用线段树解决RMQ问题,我们则要要使用数组 M[1, 2 * 2[logN] + 1],这里M[i]保存结点i区间最小值的位置。初始时M的所有元素为-1。树应当用下面的函数进行初始化(b和e是当前区间的范围):
调用函数时使用node=1, b = 0, e = N -1
void initialize(int node, int b, int e, int M[MAXIND], int A[MAXN], int N)  
{  
    if (b == e)  
        M[node] = b;  
    else  
    {  
        //compute the values in the left and right subtrees  
        initialize(2 * node, b, (b + e) / 2, M, A, N);  
        initialize(2 * node + 1, (b + e) / 2 + 1, e, M, A, N);  
  
        //search for the minimum value in the first and  
        //second half of the interval  
        if (A[M[2 * node]] <= A[M[2 * node + 1]])  
            M[node] = M[2 * node];  
        else  
            M[node] = M[2 * node + 1];  
    }  
}

现在我们可以开始进行查询了。如果我们想要查找区间[i, j]中的最小值的位置时,我们可以使用下一个简单的函数:

int query(int node, int b, int e, int M[MAXIND], int A[MAXN], int i, int j)  
{  
    int p1, p2;  
    //if the current interval doesn't intersect  
    //the query interval return -1  
    if (i > e || j < b)  
        return -1;  
  
    //if the current interval is included in  
    //the query interval return M[node]  
    if (b >= i && e <= j)  
        return M[node];  
  
    //compute the minimum position in the  
    //left and right part of the interval  
    p1 = query(2 * node, b, (b + e) / 2, M, A, i, j);  
    p2 = query(2 * node + 1, (b + e) / 2 + 1, e, M, A, i, j);  
  
    //return the position where the overall  
    //minimum is  
    if (p1 == -1)  
        return M[node] = p2;  
    if (p2 == -1)  
        return M[node] = p1;  
    if (A[p1] <= A[p2])  
        return M[node] = p1;  
    return M[node] = p2;  
}

可以很容易的看出任何查询都可以在O(log N)内完成。注意当我们碰到完整的in/out区间时我们停止了,因此数中的路径最多分裂一次。用线段树我们获得了的算法

线段树非常强大,不仅仅是因为它能够用在RMQ上,还因为它是一个非常灵活的数据结构,它能够解决动态版本的RMQ问题和大量的区间搜索问题。

其余解法

除此之外,还有倍增法、重链剖分算法和后序遍历也可以解决该问题。其中,倍增思路相当于层序遍历,逐层或几层跳跃查,查询时间复杂度为O(log n),空间复杂度为nlogn,对于每个节点先存储向上1层2层4层的节点,每个点有depth信息。

你可能感兴趣的:(数据结构)