樹における最近共通祖先の効率的計算手法

木構造上の2頂点間の最近共通祖先(Lowest Common Ancestor, LCA)を高速に求めるには、複数のアルゴリズムが存在します。本稿では、倍増法、オイラー巡回+RMQ、および木の重軽分解(Heavy-Light Decomposition)の3つの代表的手法について、それぞれの設計思想・実装構造・計算量特性を解説します。

倍増法によるLCAクエリ

各頂点から根方向へ2kステップ先の祖先とそのパス上の重み和を前処理で保持します。クエリ時はまず深さを揃え、その後指数的に近づくことでO(log n)でLCAを特定します。以下は汎用的なテンプレート実装で、型パラメータに対応し、辺重みの累積も可能にしています:

template <typename WeightT>
class BinaryLiftingLCA {
    static constexpr int LOG = 21;
    int n, root;
    std::vector<int> depth;
    std::vector<std::vector<int>> parent;
    std::vector<std::vector<WeightT>> weight_sum;

    struct Edge { int to; WeightT w; };
    std::vector<std::vector<Edge>> graph;

public:
    BinaryLiftingLCA(int node_count, int root_node = 1)
        : n(node_count), root(root_node),
          depth(n + 1), parent(n + 1, std::vector<int>(LOG)),
          weight_sum(n + 1, std::vector<WeightT>(LOG)),
          graph(n + 1) {}

    void add_edge(int u, int v, WeightT w) {
        graph[u].emplace_back(v, w);
        graph[v].emplace_back(u, w);
    }

    void build() {
        depth[root] = 0;
        std::vector<bool> visited(n + 1);
        std::function<void(int, int)> dfs = [&](int u, int par) {
            visited[u] = true;
            parent[u][0] = par;
            for (int k = 1; k < LOG; ++k) {
                int anc = parent[u][k-1];
                parent[u][k] = anc ? parent[anc][k-1] : 0;
                weight_sum[u][k] = weight_sum[u][k-1] + 
                                  (anc ? weight_sum[anc][k-1] : WeightT{});
            }
            for (const auto& e : graph[u]) {
                if (e.to != par && !visited[e.to]) {
                    depth[e.to] = depth[u] + 1;
                    weight_sum[e.to][0] = e.w;
                    dfs(e.to, u);
                }
            }
        };
        dfs(root, 0);
    }

    int query(int u, int v) const {
        if (depth[u] < depth[v]) std::swap(u, v);
        // 深さを揃える
        int diff = depth[u] - depth[v];
        for (int k = 0; k < LOG && diff; ++k, diff >>= 1) {
            if (diff & 1) u = parent[u][k];
        }
        if (u == v) return u;
        // 共通祖先へ向かう
        for (int k = LOG - 1; k >= 0; --k) {
            if (parent[u][k] != parent[v][k]) {
                u = parent[u][k];
                v = parent[v][k];
            }
        }
        return parent[u][0];
    }
};

オイラー巡回と区間最小値クエリ

DFSによるオイラー巡回を生成し、訪問順序と対応する深さを記録します。この深さ配列に対してSparse Table(ST表)を構築することで、任意区間の最小深さ位置をO(1)で取得できます。最小深さに対応する頂点がLCAとなります。空間効率と定数時間クエリが特徴です:

class EulerTourLCA {
    int n, root;
    std::vector<std::vector<int>> graph;
    std::vector<int> euler, depth, first_occurrence;
    std::vector<std::vector<int>> st_min_idx;

    void dfs(int u, int d, int& time) {
        first_occurrence[u] = time;
        euler[time] = u;
        depth[time++] = d;
        for (int v : graph[u]) {
            if (v != first_occurrence.size() || first_occurrence[v] == -1) {
                dfs(v, d + 1, time);
                euler[time] = u;
                depth[time++] = d;
            }
        }
    }

public:
    EulerTourLCA(int node_count, int root_node = 1)
        : n(node_count), root(root_node),
          graph(n + 1), euler(2 * n), depth(2 * n),
          first_occurrence(n + 1, -1) {}

    void add_edge(int u, int v) {
        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    void build() {
        int time = 0;
        dfs(root, 0, time);
        int len = time;
        // ST表構築(最小深さのインデックス保持)
        st_min_idx.resize(len, std::vector<int>(20));
        for (int i = 0; i < len; ++i) {
            st_min_idx[i][0] = i;
        }
        for (int j = 1; (1 << j) <= len; ++j) {
            for (int i = 0; i + (1 << j) <= len; ++i) {
                int left = st_min_idx[i][j-1];
                int right = st_min_idx[i + (1 << (j-1))][j-1];
                st_min_idx[i][j] = (depth[left] < depth[right]) ? left : right;
            }
        }
    }

    int query(int u, int v) const {
        int l = first_occurrence[u], r = first_occurrence[v];
        if (l > r) std::swap(l, r);
        int k = std::bit_width(static_cast<unsigned int>(r - l + 1)) - 1;
        int idx = (depth[st_min_idx[l][k]] < depth[st_min_idx[r - (1 << k) + 1][k]])
                  ? st_min_idx[l][k] : st_min_idx[r - (1 << k) + 1][k];
        return euler[idx];
    }
};

重軽分解によるLCA

各ノードの最大部分木サイズに基づき「重子」を定義し、重子を辿るパスを「重鎖」として分割します。クエリ時は、異なる重鎖に属する場合、深い方のトップを親に移動させることでO(log n)ステップでLCAに到達します。線形空間かつ実用的な定数性能が利点です:

class HeavyLightLCA {
    int n, root;
    std::vector<std::vector<int>> graph;
    std::vector<int> parent, depth, size, heavy, top;

    void dfs1(int u, int p) {
        parent[u] = p;
        size[u] = 1;
        depth[u] = depth[p] + 1;
        int max_subtree = 0;
        for (int v : graph[u]) {
            if (v == p) continue;
            dfs1(v, u);
            size[u] += size[v];
            if (size[v] > max_subtree) {
                max_subtree = size[v];
                heavy[u] = v;
            }
        }
    }

    void dfs2(int u, int head) {
        top[u] = head;
        if (heavy[u] != -1) {
            dfs2(heavy[u], head);
        }
        for (int v : graph[u]) {
            if (v != parent[u] && v != heavy[u]) {
                dfs2(v, v);
            }
        }
    }

public:
    HeavyLightLCA(int node_count, int root_node = 1)
        : n(node_count), root(root_node),
          graph(n + 1), parent(n + 1), depth(n + 1),
          size(n + 1), heavy(n + 1, -1), top(n + 1) {}

    void add_edge(int u, int v) {
        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    void build() {
        depth[0] = -1;
        dfs1(root, 0);
        dfs2(root, root);
    }

    int query(int u, int v) const {
        while (top[u] != top[v]) {
            if (depth[top[u]] < depth[top[v]]) std::swap(u, v);
            u = parent[top[u]];
        }
        return depth[u] < depth[v] ? u : v;
    }
};

タグ: LCA 倍増法 オイラー巡回 重軽分解 SparseTable

8月5日 03:47 投稿