AcWing 1172 祖孙询问

\(AcWing\) \(1172\) 祖孙询问

一、题目描述

给定一棵包含 \(n\) 个节点的有根无向树,节点编号互不相同,但不一定是 \(1 \sim n\)

\(m\) 个询问,每个询问给出了一对节点的编号 \(x\)\(y\),询问 \(x\)\(y\) 的祖孙关系。

找出多对节点的祖孙关系,不是一对

输入格式
输入第一行包括一个整数 表示节点个数;

接下来 \(n\) 行每行一对整数 \(a\)\(b\),表示 \(a\)\(b\) 之间有一条无向边。如果 \(b\)\(−1\),那么 \(a\) 就是树的根;

\(n+2\) 行是一个整数 \(m\) 表示询问个数;

接下来 \(m\) 行,每行两个不同的正整数 \(x\)\(y\),表示一个询问。

输出格式
对于每一个询问,若 \(x\)\(y\) 的祖先则输出 \(1\),若 \(y\)\(x\) 的祖先则输出 \(2\),否则输出 \(0\)

数据范围
\(1≤n,m≤4×10^4\),
\(1≤\)每个节点的编号\(≤4×10^4\)

输入样例

10
234 -1
12 234
13 234
14 234
15 234
16 234
17 234
18 234
19 234
233 19
5
234 233
233 12
233 13
233 15
233 19

输出样例

1
0
0
0
2

二、最近公共祖先

\(Least\) \(Common\) \(Ancestor\)),简称\(LCA\)

在一颗有根树中, 每个节点向上追溯都有一系列祖宗节点. 对于任意两个节点, 在它们祖宗节点的交集中离它们最近的节点称为它们的 最近公共祖先

注意: 节点本身也可作为其祖先节点.

比如上图中的\(D\)点和\(G\)点的最近公共祖先节点就是\(B\)点。下面介绍求解\(LCA\)问题的两种基本方法:

向上标记法

我们自己是如何判断\(LCA\)的呢?两只眼睛同时从\(D\)点和\(G\)点往上追溯,很快就可以定位到\(B\)点。计算机向上回溯自然不是问题,但是并不能很快的判断出回溯过程中最先相交于哪一点,因此只能 异步 的执行节点的回溯。首先\(D\)向上追溯到\(B\)点、\(A\)点,做好访问标记;然后\(G\)点向上追溯到\(E\)点,再往上到\(B\)点发现已经做好标记了,于是\(B\)点就是\(LCA\)节点。这种求解\(LCA\)的办法被称为 向上标记法 ,由其中一个节点向上走到根节点并做好标记,另一个节点再往上走的时候第一次遇见的已标记的节点就是最近公共祖先节点。向上标记法过程相当简单,书上说其复杂度是\(O(n)\),即正比于树上节点的规模。其实该算法的复杂度用\(O(h)\),也就是 树高 来衡量更加准确。

树上倍增法

向上标记法看似简单高效,求上图中\(D\)\(G\)\(LCA\)固然很快,但是如果要 求多对节点\(LCA\)呢?比如求\(D\)\(E\)的,求\(G\)\(H\)的,...,我们会发现\(G\)点向上走的时候标记了其祖先节点,然后\(E\)向上走又会标记一遍祖先节点,向上回溯的路径大都是相同的,这就 存在很大的冗余

如果 同步 的向上回溯会怎样呢?预处理 下每个节点向上回溯任意步后的位置,记录下来,然后用某种算法快速的求出两个节点的\(LCA\)

最先遇见的困难就是节点\(u\)和节点\(v\)离他们的\(LCA\)节点的 深度差不同

如果 两个节点处于同一深度,比如\(G\)\(H\)点,深度都是\(3\),我们完全可以 二分答案 了:先判断下\(G\)\(H\)向上两步是不是到达了同一个节点,如果没到达,就向上四步。如果两个节点是处于同一深度的,我们在预处理节点向上走若干步的信息后,就可以在\(O(log_2h)\)的时间内求出\(LCA\)了。

当然既可以使用二分求解也可以使用 倍增求解两个节点同时向上\(1\),\(2\),\(4\),\(8\),...步判断是否到达了同一点。那么我们在后面为什么要选择倍增而不是二分呢?这又是个问题。到这里我们其实已经不知不觉的理解了树上倍增法的第一个重要的步骤: 将两个节点调整到同一深度上。比如求\(u\)\(v\)\(LCA\)\(u\)的深度是\(10\)\(v\)的深度是\(6\),首先求出\(u\)向上回溯到深度为\(6\)的祖先节点\(u_1\),如果\(u_1\)就是\(v\)点,那么\(LCA\)就是\(v\),否则继续求\(u_1\)\(v\)\(LCA\)。此时就是求处于同一深度的两个节点的\(LCA\)了,就十分方便了。所以 树上倍增法 无非就是先将深度大的节点调整到与深度小的节点同一深度(向上回溯),然后继续求\(LCA\)

再回到开头说的 预处理出所有节点向上回溯若干步的节点,这是十分没有必要的 。我们只需要 预处理出每个节点向上回溯\(2\)的倍数步的节点 就可以满足我们所有的需要了。比如要回溯\(11\)步,完全可以先走\(8\)步,再走\(2\)步,再走\(1\)步,任何十进制整数肯定是可以转化为二进制表达的,\(11 = (1011)_2\),二进制拆分的思想足以让我们走到任意的地方。

为什么是先走步数最大的再逐步缩小步数,而不是先\(1\)\(2\)\(8\)呢? 因为二进制拆分实现过程中并不需要我们真的去拆分\(11\),我们可以先尝试迈出一个很大的步数,比如\(16\),发现比\(11\)大,于是迈出\(8\)步,再尝试迈出\(4\)步,发现又超过\(11\)了,于是迈出\(2\)步,最后迈出\(1\)步,这是个不断尝试便于实现的倍增过程。调整到同一深度的两个节点需要再次使用倍增的方法求\(LCA\),比如先判断下两个节点向上回溯\(8\)步的节点是不是同一个,是就说明\(LCA\)不会在更高的位置了,于是重新判断向上回溯\(4\)步的是不是同一个,如果不是,则说明\(LCA\)还在上面,再对这个迈出\(4\)步的两个节点继续向上回溯\(2\)步,直至两个节点的父节点就是\(LCA\)为止。

算法实现

\(f[i][k]\):节点\(i\)向上回溯\(2^k\)步的节点标号

边界情况\(f[i][0]\)表示\(i\)父节点

\(f\)数组的求解就是使用倍增法常用的动态规划公式了,要想求出\(i\)向上走\(2^k\)步的节点,只需要先求出\(i\)向上走\(2^{k-1}\)步的节点\(j\),然后再求\(j\)向上走\(2^{k-1}\)步的节点即可。状态转移方程:

\[\large f[i][k] = f[f[i][k-1]][k-1] \]

预处理
可以在\(bfs\)遍历树的过程中实现,同时记录下所有节点的深度\(depth\)

void bfs(int root) {
    //根结点的深度为1
    depth[root] = 1;
    queue<int> q;
    q.push(root);
    while (q.size()) {
        int u = q.front();
        q.pop();
        for (int i = h[u]; ~i; i = ne[i]) {
            int v = e[i];
            if (!depth[v]) {
                depth[v] = depth[u] + 1; //记录每个节点的深度
                q.push(v);
                f[v][0] = u; // f[v][0]表示v的父节点
                //因为bfs的特性,所以在计算f[v][k]时,前序依赖数据
                // f[v][k - 1],f[f[v][k - 1]][k - 1]肯定已经被填充过
                for (int k = 1; k <= 20; k++)
                    f[v][k] = f[f[v][k - 1]][k - 1];
            }
        }
    }
}

初始情况下将所有节点的深度设置为\(0\),所以一旦从\(u\)可以走到\(v\)并且\(v\)的深度没有被标识过,就说明\(u\)\(v\)的父节点,同时可以更新\(v\)的深度了(无向图,防止\(v->u\)走回头路)。我们知道\(bfs\)遍历树的过程是层序遍历,遍历到\(v\)\(v\)的祖先节点的信息都已经被预处理过了,所以此时
\(f[v][k] = f[f[v][k - 1]][k - 1]\)中的\(f[v][k-1]\)一定已经求出来了。

倍增代码

//最近公共祖先
int lca(int a, int b) {
    // a保持深度更大,更下面
    if (depth[a] < depth[b]) swap(a, b);
    // 深度对齐
    for (int k = 15; k >= 0; k--)
        if (depth[f[a][k]] >= depth[b]) a = f[a][k];

    //如果a,b是同一个点了,则LCA找到
    if (a == b) return a;
    
    //倍增查找,向结果逼近
    for (int k = 15; k >= 0; k--)
        if (f[a][k] != f[b][k])
            a = f[a][k], b = f[b][k];
    //返回结果
    return f[a][0];
}

如果\(a\)深度大于\(b\),就交换\(a\)\(b\)节点 ,从而保持\(a\)节点一直在下面。然后就是按照上面所说的 倍增 的操作二进制拆分调整\(b\)到与\(a\)同一深度,如果两点重合,\(LCA\)就是\(a\)点。否则,继续对\(a\)\(b\)向上倍增求\(LCA\),最后的结果为什么是\(a\)的父节点呢?这是因为倍增的终点就是\(a\)\(b\)调整到\(LCA\)节点的下一层。举个例子,比如\(a\)\(b\)\(LCA\)\(6\)步,首先\(a\)\(b\)都向上走\(4\)步,然后想向上走\(2\)步发现此时两点重合了,于是只走一步,此时倍增终止,\(a\)\(b\)\(LCA\)恰好是一步之遥。

解释

for (int k = 15; k >= 0; k--)
       if (f[a][k] != f[b][k])
           a = f[a][k], b = f[b][k];

这里的条件是不相等的才赋值吧?最后\(a,b\)是不等的。也就是它俩的处在等、不等的临界点的,现在是不等,再多一点点就是等,而多一点点其实就是 \(f[a][0]\),即父节点。

五、实现代码

#include <bits/stdc++.h>
using namespace std;
const int N = 40010, M = 80010;
// 邻接表
int e[M], h[N], idx, ne[M];
void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

int n;
int depth[N]; // 每个节点的深度
int f[N][16]; // 设f[i][k]表示节点i向上回溯2^k步的节点标号
// 预处理
//  1、每个点的深度
//  2、记录节点i向上回溯2^k步的节点标号
void bfs(int root) {
    queue<int> q;    // 声明队列,准备从根开始对整棵树进行宽搜
    q.push(root);    // 根节点入队列
    depth[root] = 1; // 根结点深度为1
    while (q.size()) {
        int u = q.front();
        q.pop();
        for (int i = h[u]; ~i; i = ne[i]) {
            int v = e[i];
            if (!depth[v]) {             // 如果深度为0,表示没有访问过,防止走回头路
                depth[v] = depth[u] + 1; // 记录每个节点的深度
                f[v][0] = u;             // f[j][0]表示j跳2^0=1到达的是它的父节点
                q.push(v);               // 下一次该j发挥作用了
                /*举个栗子:
                j 向上跳 2^0=1,2^1=2,2^2=4,2^3=8,2^4=16,2^5=32,...步,
                分别记录这样跳到达的节点是什么编号,这样其实是按二进制思想记录的信息。
                f[j][k] = f[f[j][k - 1]][k - 1] 采用了递推的思路,比如i向上想走8级,现在向上走了4级到达了j,也可以继续从j出发向上再走4次,是一回事, 但由于这样利用了已有结果会更快
                因为bfs特性,在计算f[j][k]时,前序依赖数据已经被填充过,可以快速利用
                */
                for (int k = 1; k <= 15; k++) f[v][k] = f[f[v][k - 1]][k - 1];
            }
        }
    }
}
// 最近公共祖先
int lca(int a, int b) {
    // a保持深度更大
    if (depth[a] < depth[b]) swap(a, b);

    // 对齐a,b,跳冒不跳,跳少再跳
    for (int k = 15; k >= 0; k--)
        if (depth[f[a][k]] >= depth[b]) a = f[a][k];
        
    if (a == b) return a;

    // 同级,跳冒不跳,跳少再跳
    for (int k = 15; k >= 0; k--)
        if (f[a][k] != f[b][k])
            a = f[a][k], b = f[b][k];

    // a的父节点,就是lca(a,b)
    return f[a][0];
}
int main() {
    memset(h, -1, sizeof h);

    scanf("%d", &n);
    int a, b, m, root;

    for (int i = 0; i < n; i++) {
        scanf("%d %d", &a, &b);
        if (b == -1)
            root = a; // 找到根节点
        else
            add(a, b), add(b, a); // 无向树
    }
    // 预处理,生成倍增的下2^k级跳位置数据f[i][k]
    bfs(root);

    scanf("%d", &m);
    while (m--) {
        scanf("%d %d", &a, &b);
        int p = lca(a, b);
        if (p == a)
            puts("1");
        else if (p == b)
            puts("2");
        else
            puts("0");
    }
    return 0;
}

\(Q1\):为什么上面实现代码中用的上限是\(16\)

int res = 1;
for (int i = 1;; i++) {
    res *= 2;
    if (res > 4 * 1e4) {
        printf("%d\n", i);
        break;
    }
}

结果输出是\(16\),题目中,当前节点\(i\)占了一层,它的父节点表示是\(f[i][0]\),所以,最多向上返\(2^{15}\)

\(Q2:\)如果不写成\(16\),写成\(20\)会怎么样?

\(k\)变大了,会跳到一个不存在的位置上去,代码显示跳到的是\(0\)号位置,而我们知道,一般这样的题目,都是要求从\(1 \sim N\),避开了\(0\)号。

注意:如果真的想偷懒,不想仔细计算到底应该跳多少步,可以盲写成\(32\),但要注意的是除了要把代码中所有\(16\)修改为\(32\)外,还要记得把\(f\)数组二维的大小改大,否则会\(WA\),别问我怎么知道的~

f[19][0]=234 f[19][1]=0 f[19][2]=0 f[19][3]=0 f[19][4]=0 f[19][5]=0 f[19][6]=0 f[19][7]=0 f[19][8]=0 f[19][9]=0 f[19][10]=0 f[19][11]=0 f[19][12]=0 f[19][13]=0 f[19][14]=0 f[19][15]=0 
f[18][0]=234 f[18][1]=0 f[18][2]=0 f[18][3]=0 f[18][4]=0 f[18][5]=0 f[18][6]=0 f[18][7]=0 f[18][8]=0 f[18][9]=0 f[18][10]=0 f[18][11]=0 f[18][12]=0 f[18][13]=0 f[18][14]=0 f[18][15]=0 
f[17][0]=234 f[17][1]=0 f[17][2]=0 f[17][3]=0 f[17][4]=0 f[17][5]=0 f[17][6]=0 f[17][7]=0 f[17][8]=0 f[17][9]=0 f[17][10]=0 f[17][11]=0 f[17][12]=0 f[17][13]=0 f[17][14]=0 f[17][15]=0 
f[16][0]=234 f[16][1]=0 f[16][2]=0 f[16][3]=0 f[16][4]=0 f[16][5]=0 f[16][6]=0 f[16][7]=0 f[16][8]=0 f[16][9]=0 f[16][10]=0 f[16][11]=0 f[16][12]=0 f[16][13]=0 f[16][14]=0 f[16][15]=0 
f[15][0]=234 f[15][1]=0 f[15][2]=0 f[15][3]=0 f[15][4]=0 f[15][5]=0 f[15][6]=0 f[15][7]=0 f[15][8]=0 f[15][9]=0 f[15][10]=0 f[15][11]=0 f[15][12]=0 f[15][13]=0 f[15][14]=0 f[15][15]=0 
f[14][0]=234 f[14][1]=0 f[14][2]=0 f[14][3]=0 f[14][4]=0 f[14][5]=0 f[14][6]=0 f[14][7]=0 f[14][8]=0 f[14][9]=0 f[14][10]=0 f[14][11]=0 f[14][12]=0 f[14][13]=0 f[14][14]=0 f[14][15]=0 
f[13][0]=234 f[13][1]=0 f[13][2]=0 f[13][3]=0 f[13][4]=0 f[13][5]=0 f[13][6]=0 f[13][7]=0 f[13][8]=0 f[13][9]=0 f[13][10]=0 f[13][11]=0 f[13][12]=0 f[13][13]=0 f[13][14]=0 f[13][15]=0 
f[12][0]=234 f[12][1]=0 f[12][2]=0 f[12][3]=0 f[12][4]=0 f[12][5]=0 f[12][6]=0 f[12][7]=0 f[12][8]=0 f[12][9]=0 f[12][10]=0 f[12][11]=0 f[12][12]=0 f[12][13]=0 f[12][14]=0 f[12][15]=0 
f[233][0]=19 f[233][1]=234 f[233][2]=0 f[233][3]=0 f[233][4]=0 f[233][5]=0 f[233][6]=0 f[233][7]=0 f[233][8]=0 f[233][9]=0 f[233][10]=0 f[233][11]=0 f[233][12]=0 f[233][13]=0 f[233][14]=0 f[233][15]=0 

总结

\(LCA\)算法,本质就是一个二进制的拼凑组装大法,关键词如下:

  • ① 深度
  • ② 对齐
  • ③ 倍增,二进制
  • ④ 每个节点都记录信息
  • ⑤ 尝试\(8,4,2,1\)
  • ⑥ 动态规划
  • ⑦ 差一步到\(LCA\)
posted @ 2022-03-29 13:32  糖豆爸爸  阅读(198)  评论(0编辑  收藏  举报
Live2D