每日算法 — 使用java实现最近公共祖先:倍增法与Tarjan离线查询

最近公共祖先(Lowest Common Ancestor,简称LCA)是树结构算法中的核心问题。给定一棵有根树和两个节点,求它们深度最大的公共祖先。LCA问题在文件系统路径解析、生物进化树分析、社交网络关系计算、游戏技能树导航等领域都有重要应用。

问题定义

在一棵有根树中,节点 u 和节点 v 的最近公共祖先是指同时是 u 和 v 的祖先且深度最大的节点。特别地,一个节点可以是它自己的祖先。

方法一:倍增法(在线查询,O(n log n)预处理 + O(log n)查询)

核心思路

预处理每个节点的 2^k 级祖先,存储在 up[k][u] 数组中。查询时,先将两个节点提升到同一深度,然后同时向上跳跃,直到找到公共祖先。

预处理公式:

up[k][u] = up[k-1][up[k-1][u]]

即节点u的2^k级祖先 = u的2^(k-1)级祖先的2^(k-1)级祖先。

Java实现

import java.util.*;

public class LCABinaryLifting {

    private int n; // 节点数
    private int maxLog; // 最大跳跃层数
    private List<Integer>[] tree; // 邻接表
    private int[][] up; // up[k][v]表示v的2^k级祖先
    private int[] depth; // 节点深度

    /**
     * 初始化LCA结构
     * @param n 节点数(节点编号从1到n)
     */
    @SuppressWarnings("unchecked")
    public LCABinaryLifting(int n) {
        this.n = n;
        this.maxLog = (int) (Math.log(n) / Math.log(2)) + 1;
        this.tree = new ArrayList[n + 1];
        this.up = new int[maxLog][n + 1];
        this.depth = new int[n + 1];

        for (int i = 0; i <= n; i++) {
            tree[i] = new ArrayList<>();
        }
    }

    /**
     * 添加无向边
     */
    public void addEdge(int u, int v) {
        tree[u].add(v);
        tree[v].add(u);
    }

    /**
     * 从根节点开始预处理祖先信息
     * @param root 根节点
     */
    public void preprocess(int root) {
        // 使用BFS或DFS填充up表和depth数组
        dfs(root, 0);

        // 动态规划填充倍增表
        for (int k = 1; k < maxLog; k++) {
            for (int v = 1; v <= n; v++) {
                int midAncestor = up[k - 1][v];
                up[k][v] = midAncestor == 0 ? 0 : up[k - 1][midAncestor];
            }
        }
    }

    private void dfs(int u, int parent) {
        up[0][u] = parent; // 直接祖先
        depth[u] = depth[parent] + 1;

        for (int v : tree[u]) {
            if (v != parent) {
                dfs(v, u);
            }
        }
    }

    /**
     * 查询u和v的最近公共祖先
     * @param u 节点u
     * @param v 节点v
     * @return LCA节点
     */
    public int query(int u, int v) {
        // 确保u是较深的节点
        if (depth[u] < depth[v]) {
            int temp = u;
            u = v;
            v = temp;
        }

        // 将u提升到与v同一深度
        int diff = depth[u] - depth[v];
        for (int k = maxLog - 1; k >= 0; k--) {
            if ((diff & (1 << k)) != 0) {
                u = up[k][u];
            }
        }

        // 如果v就是u的祖先
        if (u == v) {
            return u;
        }

        // 同时向上跳跃
        for (int k = maxLog - 1; k >= 0; k--) {
            if (up[k][u] != up[k][v]) {
                u = up[k][u];
                v = up[k][v];
            }
        }

        // 此时u和v的直接祖先就是LCA
        return up[0][u];
    }

    /**
     * 获取两个节点之间的距离(边数)
     */
    public int distance(int u, int v) {
        int lca = query(u, v);
        return depth[u] + depth[v] - 2 * depth[lca];
    }

    public static void main(String[] args) {
        // 构建示例树
        //        1
        //      / | \
        //     2  3  4
        //    / \    |
        //   5   6   7

        LCABinaryLifting lca = new LCABinaryLifting(7);
        lca.addEdge(1, 2);
        lca.addEdge(1, 3);
        lca.addEdge(1, 4);
        lca.addEdge(2, 5);
        lca.addEdge(2, 6);
        lca.addEdge(4, 7);

        lca.preprocess(1);

        System.out.println("LCA(5, 6) = " + lca.query(5, 6)); // 2
        System.out.println("LCA(5, 7) = " + lca.query(5, 7)); // 1
        System.out.println("LCA(5, 2) = " + lca.query(5, 2)); // 2
        System.out.println("LCA(3, 7) = " + lca.query(3, 7)); // 1
        System.out.println("Distance(5, 7) = " + lca.distance(5, 7)); // 4
    }
}

方法二:Tarjan离线算法(O(n + q) α(n))

核心思路

Tarjan算法利用并查集(Union-Find)在深度优先搜索过程中回答所有查询。其核心思想是:当访问完一个节点的所有子树后,将该节点与子树的根合并到同一集合,并记录该集合的”代表”为当前节点。此时,所有以该节点为LCA的查询都可以被回答。

Java实现

import java.util.*;

public class LCATarjan {

    private int n;
    private List<Integer>[] tree;
    private List<int[]>[] queries; // 存储每个节点相关的查询
    private int[] answer;
    private boolean[] visited;
    private int[] parent;
    private int[] ancestor; // 并查集的"代表"祖先

    @SuppressWarnings("unchecked")
    public LCATarjan(int n, int queryCount) {
        this.n = n;
        this.tree = new ArrayList[n + 1];
        this.queries = new ArrayList[n + 1];
        this.answer = new int[queryCount];
        this.visited = new boolean[n + 1];
        this.parent = new int[n + 1];
        this.ancestor = new int[n + 1];

        for (int i = 0; i <= n; i++) {
            tree[i] = new ArrayList<>();
            queries[i] = new ArrayList<>();
            parent[i] = i;
        }
    }

    public void addEdge(int u, int v) {
        tree[u].add(v);
        tree[v].add(u);
    }

    /**
     * 添加查询,记录查询编号
     * @param u 节点u
     * @param v 节点v
     * @param idx 查询编号
     */
    public void addQuery(int u, int v, int idx) {
        queries[u].add(new int[]{v, idx});
        queries[v].add(new int[]{u, idx});
    }

    /**
     * 执行Tarjan离线LCA算法
     * @param root 根节点
     */
    public void solve(int root) {
        tarjanDFS(root);
    }

    private void tarjanDFS(int u) {
        visited[u] = true;
        ancestor[u] = u; // 初始时自己是自己的祖先代表

        for (int v : tree[u]) {
            if (!visited[v]) {
                tarjanDFS(v);
                union(u, v); // 将子树合并到当前节点
                ancestor[find(u)] = u; // 更新合并后的代表祖先
            }
        }

        // 处理所有与u相关的查询
        for (int[] q : queries[u]) {
            int v = q[0];
            int idx = q[1];
            if (visited[v]) {
                answer[idx] = ancestor[find(v)];
            }
        }
    }

    private int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]);
        }
        return parent[x];
    }

    private void union(int x, int y) {
        int fx = find(x);
        int fy = find(y);
        if (fx != fy) {
            parent[fx] = fy;
        }
    }

    public int getAnswer(int idx) {
        return answer[idx];
    }

    public static void main(String[] args) {
        // 构建示例树
        //        1
        //      / | \
        //     2  3  4
        //    / \    |
        //   5   6   7

        int queryCount = 4;
        LCATarjan lca = new LCATarjan(7, queryCount);

        lca.addEdge(1, 2);
        lca.addEdge(1, 3);
        lca.addEdge(1, 4);
        lca.addEdge(2, 5);
        lca.addEdge(2, 6);
        lca.addEdge(4, 7);

        // 添加查询
        lca.addQuery(5, 6, 0); // LCA = 2
        lca.addQuery(5, 7, 1); // LCA = 1
        lca.addQuery(5, 2, 2); // LCA = 2
        lca.addQuery(3, 7, 3); // LCA = 1

        lca.solve(1);

        System.out.println("LCA(5, 6) = " + lca.getAnswer(0));
        System.out.println("LCA(5, 7) = " + lca.getAnswer(1));
        System.out.println("LCA(5, 2) = " + lca.getAnswer(2));
        System.out.println("LCA(3, 7) = " + lca.getAnswer(3));
    }
}

两种方法的对比

特性 倍增法 Tarjan离线算法
预处理时间 O(n log n) O(n α(n))
单次查询时间 O(log n) O(1)(均摊)
空间复杂度 O(n log n) O(n + q)
查询模式 在线(实时查询) 离线(需预先知道所有查询)
适用场景 动态查询、交互式应用 批量查询、竞赛编程

扩展应用:树上路径问题

路径节点权值和查询

结合LCA和前缀和,可以在O(log n)时间内查询树上任意两节点之间路径的权值和。

public class TreePathQuery {

    private LCABinaryLifting lca;
    private long[] prefixSum; // 根到节点的权值前缀和

    public TreePathQuery(int n, int[] nodeValues) {
        this.lca = new LCABinaryLifting(n);
        this.prefixSum = new long[n + 1];
        // 初始化前缀和需要在构建树后执行DFS
    }

    /**
     * 查询u到v路径上的权值和
     */
    public long pathSum(int u, int v) {
        int ancestor = lca.query(u, v);
        return prefixSum[u] + prefixSum[v] - 2 * prefixSum[ancestor] + nodeValue(ancestor);
    }

    private int nodeValue(int u) {
        // 返回节点u的权值
        return 0; // 占位
    }
}

复杂度分析

倍增法:
– 预处理:O(n log n) 时间,O(n log n) 空间
– 单次查询:O(log n) 时间

Tarjan算法:
– 总时间:O((n + q) α(n)),其中α为反阿克曼函数,可视为常数
– 空间:O(n + q)

总结

最近公共祖先问题是树算法的基石。倍增法以空间换时间,支持在线查询,适合交互式场景;Tarjan算法通过并查集实现近乎线性的离线处理,适合批量查询场景。掌握这两种方法,能够高效解决绝大多数树上路径、距离和关系类问题。