LCA(最近公共祖先)

LCA 就是最近公共祖先,表示为 lca ⁡ ( a , b ) \operatorname{lca}(a, b) lca(a,b),它的求解方法主要有两种。

倍增法

这是最常用的一种可以动态求 LCA 的算法。时间复杂度为 O ( log ⁡ n ) O(\log{n}) O(logn)

中心思想

这个算法中有两个特殊的数组: d e p t h [ i ] depth[i] depth[i] f a [ i ] [ k ] fa[i][k] fa[i][k]
d e p t h [ i ] depth[i] depth[i] i i i 点的深度,以 r o o t root root 0 0 0
f a [ i ] [ k ] fa[i][k] fa[i][k]:从 i i i 点向上跳 2 k 2^k 2k 步的点的序号。

对于两个点 a , b a,b a,b,规定 d e p t h [ a ] ≥ d e p t h [ b ] depth[a] \ge depth[b] depth[a]depth[b]
中心思想:我们先找到 a a a 上面深度和 b b b 相同的点,然后让这两个点同时向上跳,直到两个点刚开始重合,此点就是 a , b a,b a,b 的最近公共子节点。

对于中心思想里面的向上跳查询深度就是靠 d e p t h depth depth f a fa fa 两个数组实现的。

思路很简单,正确行显而易见,最近重合的点肯定是最近公共祖先。

关于 d e p t h depth depth,用 bfs 或者 dfs 可以直接处理出来。
f a [ i ] [ k ] fa[i][k] fa[i][k] 数组,我们有一个方法可以把它递推出来。我们向上跳 2 k 2^k 2k 步,可以看作是,先跳 2 k − 1 2^{k - 1} 2k1 步(到 f a [ i ] [ k − 1 ] fa[i][k - 1] fa[i][k1] 点),然后再跳 2 k − 1 2^{k-1} 2k1 步,写成代码即 fa[i][j] = fa[fa[i][k - 1]][k - 1],而这一步也可以用 dfs 或 bfs 处理出来,起始条件为 fa[i][0] = f f 为 i 的父节点。

关于 f a [ i ] [ k ] fa[i][k] fa[i][k] k k k 的大小,原则上,只要大于点的数量的 log ⁡ 2 \log_2 log2 即可。而对于跳过 r o o t root root 节点的情况,我们让它是 0 0 0 点,因为默认数组为 0 0 0,可以不管它,这样在后面,只要是在 0 0 0 点就可以判断跳过了 r o o t root root,而且可以方边后面的操作。(跳过了 r o o t root root 指向上跳的次数太多,把根节点 r o o t root root 都跳过去了)

对于上面的把 a a a 跳到和 b b b 一个高度,我们 f a fa fa 数组是倍增跳跃的,且可以一次直接跳过根节点,因为任何数都可以变成一个二进制的形式,我们就可以把相差高度按二进制拆开来跳,保证最多 O ( log ⁡ 2 n ) O(\log_2n) O(log2n) 的时间把 a a a 向上跳到和 b b b 想同的高度。在实现上,可以直接倒序枚举, k k k 让从大的开始,跳不过 b b b 的就跳,否则不跳,这样可以保证我们跳完了, a , b a,b a,b 同一深度。这里不懂可以看代码理解。

预处理时间复杂度 O ( n log ⁡ n ) O(n\log{n}) O(nlogn),求解 LCA 时间复杂度为 O ( log ⁡ n ) O(\log{n}) O(logn)

实操步骤

预处理

使用 dfs 或者 bfs 对 f a fa fa d e p t h depth depth 进行预处理。

  1. 进行 dfs,记录两个量,当前点 u u u u u u 的父节点 f f f
  2. 利用 f f f 的深度,更新 u u u 的深度,即depth[u] = depth[f] + 1;,处理边界条件 fa[u][0] = f ,从 1 1 1 到点数的 log ⁡ 2 \log_2 log2 (设为 k k k),即 从 1 1 1 k k k 枚举,利用转移方程 fa[u][i] = fa[fa[u][i - 1]][i - 1] i i i 为枚举的数) 递推出 f a fa fa 数组。
  3. 枚举 u 的子节点,注意判断防止搜回去。
  4. 对每个 dfs 重复 2 2 2 3 3 3
动态求解 LCA
  1. 对 a,b 进行求解。
  2. 判断 d e p t h depth depth 大小,使得 d e p t h [ a ] ≥ d e p t h [ b ] depth[a] \ge depth[b] depth[a]depth[b]
  3. 进行向上跳的操作,倒序枚举 k k k,如果跳后深度不超过 d e p t h [ b ] depth[b] depth[b] 就进行跳跃,否则不跳
  4. 跳完, a , b a,b a,b 一定在同一高度,这时候要判断一下 a , b a,b a,b 是否同一点,如果是,直接输出 a a a b b b
  5. 进行同时向上跳的操作,倒序枚举 k k k ,如果跳后 a , b a,b a,b 不是同点则继续向上跳,否则不跳。(这里解释一下,这里的原理和第 3 步差不多,但不尽相同。我们设置 f a fa fa 跳过 r o o t root root 都是 0 0 0,如果跳过了 r o o t root root,因为相同不跳,所以可以排除这些情况。而对于非跳过 r o o t root root 的情况, a , b a,b a,b 相同说明是 a , b a,b a,b 的祖宗节点,但是,这不一定是最近的,所以会错误。相反,如果相同的不跳,根据第 3 3 3 步的原理,我最后会跳到一个距离最近祖先最近的非祖先节点,即祖先节点下面一个点,这时候无论是 a a a 还是 b b b 只要再向上跳一个,就一定是最近公共祖先)。
  6. 向上跳完后, a , b a,b a,b 任意一个向上跳一步,就是最近公共祖先,即 f a [ a ] [ 0 ] fa[a][0] fa[a][0] 或者 f a [ b ] [ 0 ] fa[b][0] fa[b][0]
  7. 输出 f a [ a ] [ 0 ] fa[a][0] fa[a][0] 或者 f a [ b ] [ 0 ] fa[b][0] fa[b][0]

代码

显而易见的代码。
预处理推荐 dfs,比较易写不易错

预处理 dfs
void dfs(int u, int f)
{
    depth[u] = depth[f] + 1;
    fa[u][0] = f;
    for (int i = 1; i <= 15; i ++ ) 
        fa[u][i] = fa[fa[u][i - 1]][i - 1];

    for (int i = h[u]; i != -1; i = ne[i])
    {
        int j = e[i];
        if (j == f) continue;
        dfs(j, u);
    }
}
预处理 bfs
void bfs(int root)
{
    memset(depth, 0x3f, sizeof depth);
    depth[0] = 0, depth[root] = 1;
    int tt = 0, hh = 0;
    q[hh] = root;

    while (hh <= tt)
    {
        int t = q[hh ++ ];

        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1)
            {
                depth[j] = depth[t] + 1;
                fa[j][0] = t;
                q[ ++ tt] = j;
                for (int k = 1; k <= 15; k ++ ) fa[j][k] = fa[fa[j][k - 1]][k - 1];
            }
        }
    }
}
动态求解LCA
int lca(int a, int b)
{
    if (depth[a] < depth[b]) swap(a, b);

    for (int k = 15; k >= 0; k -- )
        if (depth[fa[a][k]] >= depth[b])
            a = fa[a][k];

    if (a == b) return a;

    for (int k = 15; k >= 0; k -- )
        if (fa[a][k] != fa[b][k])
        {
            a = fa[a][k];
            b = fa[b][k];
        }

    return fa[a][0];
}

向上标记法

算是比较劣质的算法,不太可以离线,每次查询最坏时间复杂度为 O ( n ) O(n) O(n)
基本上不用,只给思想。

中心思想

设求 a , b a,b a,b 的最近公共祖先,
a a a 点先不断向上标记,包括它本身,然后从 b b b 点向上走,遇到的第一个被标记的点就是它们的最近公共祖先。如下图。

正确性显而易见。

虽然说是 O ( n ) O(n) O(n) ,但是实现起来,空间时间都比较差,大部分情况下动态使用是 O ( n ) O(n) O(n) 的,和Tarjan 的 O ( n + m ) O(n + m) O(n+m) 离线不一样。并不优秀。

Tarjan

每错,又双叒叕是 Tarjan。
这是一种离线的做法,时间复杂度为 O ( n + m ) O(n + m) O(n+m) n n n 是节点数, m m m 是询问数
其本质相当于对向上标记法的优化。

中心思想

这里把树上的点分为了三类

  1. 已经被搜完的点(即点所在的函数已经结束了)
  2. 正在被搜的点(即点所在的函数没有结束)
  3. 未搜的点(还没开始搜到)

以下根据图片讲解。
我们能发现一件事,正在被搜的点(下面简称红点)一定成一条链,因为是正在被搜,不是在当前函数中,就是在之前转移来的函数中,因此一条链。所有已经被搜完的点(绿点)和红点都有接触(因为不可能和蓝点有接触),如果把某个绿点 j 归为对应最近的红点 k 之内的话,那个红点 k,就是现在红点 u 和 j 的最大公共祖先。可以把这里的红点看作上面向上标记法中的标记,那么原理就显而易见了。而这样,在 u 时可以求出来,所有绿点和 u 的最大公共祖先。

对于把 j 和 k 合并成一个点,我们只需要用到并查集,而这个搜索是在 dfs 中的,每个点只搜一遍。而我们是离线算法,并查集查询后可以把所有的答案存下来。因此可知查询是 O ( 1 ) O(1) O(1) 的,而每个点遍历一遍是 O ( n ) O(n) O(n),有 m m m 次查询,总的时间复杂度就是 O ( n + m ) O(n + m) O(n+m) 的。当然这个时间复杂度也不是太严谨,因为并查集不总是 O ( 1 ) O(1) O(1) 的时间复杂度。

对于每个点的状态我们分为 1 , 2 , 0 1,2,0 1,2,0 三种,对应,正在搜的点,未搜的点,已经被搜完的点。

实操步骤

  1. 进行 tarjan,设当前点为 u u u
  2. u u u 的状态设置为 1 1 1
  3. 枚举子节点,把没进行的点进行 tarjan,完成后把子节点和父节点合并成一个集合。
  4. 枚举和 u u u 点有关的问题,如果对应点在已经完成点即状态为 2 2 2 的话,查询并查集,记录结果
  5. 循环 1 1 1 4 4 4

代码

很简单 awa

void tarjan(int u)
{
	st[u] = 1;
	for (int i = h[u]; i != -1; i = ne[i])
	{
		int j = e[i];
		if (st[j]) continue;
		tarjan(j);
		p[j] = u;
	}
	for (auto itme : query[u])
	{
		int y = itme.y, id = itme.id;
		if (st[y] == 2) res[id] = find(y);
	}
	st[u] = 2;
}
  • 16
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值