最近公共祖先

1、暴力算法

复杂度:O(m * logn)(最大O(n * m));

先记录每个层数,再对层数高的依次向上移动,直到找到相同祖先,此处不写代码

2、倍增思想

复杂度:O(nlog(n)+mlog(n));

原理:

记录其第2^i个父节点的内容,按二进制进位行运算

首先初始化sic[x] [0]为其上第一个父节点,随后从i到所需1<<n,依次根据上一层父节点的父节点进行记录:

sic[x][i] = sic[sic[x][i - 1]][i - 1];

查找最近公共祖先时,首先将两结点放在同一层

for (int i = 15; i > -1; i--){
    if (len[sic[a][i]] >= len[b])a = sic[a][i];
}

因为父节点层数一定比a与b的最小层数小或相等,因此不会出现有跳过父节点的情况。

之后,同时将a与b进行跳跃操作,跳跃到最近公共父节点的下一层(因每次跳跃的判定条件是)sic[a] [i] != sic[b] [i],因此只能找到最近公共父节点下一层,最后返回sic[a] [0]即可得到最近公共祖先。

代码如下:
#include<iostream>
#include<algorithm>
#include<stdio.h>
#include<string>
#include<string.h>
#include<vector>
#include<queue>

using namespace std;

int n, m;
int sic[1005][16], len[1005];
vector<int>v[1005];

void lca() {
    queue<int>q;
    q.emplace(1);
    while (!q.empty()) {
        int k = q.front();
        q.pop();
        for (auto x : v[k]) {
            len[x] = len[k] + 1;
            for (int i = 1; i < 15; i++){
                if (sic[sic[x][i - 1]][i - 1] == 0)break;
                sic[x][i] = sic[sic[x][i - 1]][i - 1];
            }
            q.emplace(x);
        }
    }
}

int solve(int a,int b) {
    if (len[a] < len[b])swap(a, b);
    for (int i = 15; i > -1; i--){
        if (len[sic[a][i]] >= len[b])a = sic[a][i];
    }
    if (a == b)return a;
    for (int i = 15; i > -1; i--) {
        if (sic[a][i] != sic[b][i]) {
            a = sic[a][i]; b = sic[b][i];
        }
    }
    return sic[a][0];
}

int main() {
    int a, b;
    cin >> n >> m;
    memset(sic, -1, sizeof sic);
    sic[1][0] = -1;
    len[1] = 1;
    for (int i = 0; i < n; i++){
        cin >> a >> b;
        v[a].push_back(b);
        sic[b][0] = a;
    }
    lca();
    for (int i = 0; i < m; i++){
        cin >> a >> b;
        cout << solve(a, b) << endl;
    }
    return 0;
}
样例:

7 6

1 2 2 3 3 4 2 5 5 6 6 7 1 8

4 7

3 2

5 2

1 8

7 8

4 6

预期输出:

2

2

2

1

1

2

3、Tarjan

复杂度:O(n+m);

原理:

此算法可算作是暴力求解算法的优化,是离线查询,其原理如下:

将树上的点分为三个部分:

【1】已经遍历且回溯过的点,如蓝色部分

【2】正在遍历且未回溯的点,如红色分支

【3】还未遍历的点

如下图所示:

image-20210728190257140

此时,与并查集的思想相结合,已经回溯过的点,其根结点记录在目前正在遍历分支上。当要查询的结点中的一个被遍历到,且另一个点已经被回溯过,则直接找到另一个点的根节点,就是这两个点的最近公共祖先。

那么,如何记录已经回溯过的点在目前正在遍历的点的根节点?

首先初始化每个结点的根节点是其本身,在每次回溯过后,再更改其根节点位置,如此,其已回溯过的分支的根节点在其回溯之前的根节点一定是此结点本身,在遍历此节点的子节点分支时,这个状态不会改变。

image-20210728191212886

image-20210728191239926

如上图所示,当回溯到3时,3的根节点更新为其父节点。

根据如上原理,就可有如下操作:

void tarjan(int u) {
    sic[u] = 1;
    for (auto x:v[u]){
        //st[x] = st[u] + 1;
        tarjan(x);
        len[x] = u;
    }
    for (auto x : vis[u]) {
        int y = x.first, p = x.second;
        if (sic[y] == 2) {
            ans[p] = fa(y);
        }
    }
    sic[u] = 2;
}

sic数组为标记数组,标记点是否被遍历/已回溯/未遍历;

len数组为记录数组,记录其根节点,在每次回溯后更新当前回溯点的根节点。

注:此代码建立的是有向图,因此不须剪枝。

代码如下:
#include<iostream>
#include<algorithm>
#include<stdio.h>
#include<string>
#include<string.h>
#include<vector>
#include<queue>

using namespace std;

int n, m;
int sic[1005], len[1005];
int st[1005];
int ans[1005], o;
vector<int>v[1005];
vector<pair<int,int>>vis[1005];

int fa(int x) {
    if (len[x] != x)len[x] = fa(len[x]);
    return len[x];
}

void tarjan(int u) {
    sic[u] = 1;
    for (auto x:v[u]){
        //st[x] = st[u] + 1;
        tarjan(x);
        len[x] = u;
    }
    for (auto x : vis[u]) {
        int y = x.first, p = x.second;
        if (sic[y] == 2) {
            ans[p] = fa(y);
        }
    }
    sic[u] = 2;
}

int main() {
    int a, b;
    cin >> n >> m;
    len[1] = 1;
    for (int i = 0; i < 1005; i++){
        len[i] = i;
    }
    for (int i = 0; i < n; i++){
        cin >> a >> b;
        v[a].push_back(b);
    }
    for (int i = 0; i < m; i++){
        cin >> a >> b;
        vis[a].push_back({ b,i });
        vis[b].push_back({ a,i });
    }
    //st[1] = 0;
    tarjan(1);
    for (int i = 0; i < m; i++) {
        cout << ans[i] << endl;
    }
    return 0;
}