二分ライフティングを使った祖先テーブル入門:LCAとk-th祖先をO(log N)で解く実装解説
イントロダクション:祖先テーブルとは何か
「祖先テーブル」(ancestor table、あるいは binary lifting テーブル)は、木(ツリー)構造における各ノードの 2^k 上位(祖先)を高速に求めるための前処理データ構造です。競技プログラミングやアルゴリズム設計で広く用いられ、特に「k 個上の祖先を求める」「Lowest Common Ancestor(LCA、最近共通祖先)を高速に求める」問題で役立ちます。
概念の要点
- 木の各ノード v について up[0][v] を直接の親(parent)とする。
- 一般に up[j][v] を v から 2^j 上に上がった先の祖先と定義する。したがって再帰的に
up[j][v] = up[j-1][ up[j-1][v] ](存在しない場合は -1 などの無効値)となる。 - この表を前処理で構築しておけば、k を二進展開して該当するビット分だけ上れば k-th ancestor を O(log N) で求められる。
- LCA も深さ調整+二分ライフティングで O(log N) で計算可能。
なぜ有用か(利点)
- 前処理 O(N log N)、各問い合わせ O(log N) として非常に高速(N はノード数)。
- 実装が比較的簡単で、メモリは O(N log N) だが定数が小さく扱いやすい。
- k-th ancestor や LCA、木上での距離計算などの基盤となる操作を効率化できる。
基本アルゴリズムと実装方針
典型的な実装フローは次の通りです。
- 1) 深さ優先探索(DFS)や幅優先探索(BFS)で各ノードの深さ depth[v] と up[0][v](親)を求める。
- 2) LOG = ceil(log2(maxN))+1 を決め、up[1..LOG-1][v] を再帰式で埋める。
- 3) k-th ancestor:k の各ビットを走査し、対応する up[j] を辿る。
- 4) LCA:深さをそろえた後、上位のビットから同時に lift し、最終的に up[0] を返す。
主要な計算量
- 前処理:O(N log N)
- 1回の k-th ancestor クエリ:O(log N)
- 1回の LCA クエリ:O(log N)
- メモリ使用量:O(N log N)
代表的な C++ 実装(概念を示す簡潔な例)
以下は典型的な二分ライフティングを用いた LCA と k-th ancestor の実装例です(0-indexed)。WordPress に貼る場合は <pre><code> タグで囲うと見やすくなります。
// N: ノード数, LOG: 上限(例: ceil(log2(N))+1)
vector> up(LOG, vector(N, -1));
vector depth(N, 0);
// DFS または BFS で depth と up[0] を設定
void dfs(int v, int p){
up[0][v] = p;
for(int to : adj[v]){
if(to == p) continue;
depth[to] = depth[v] + 1;
dfs(to, v);
}
}
// テーブル構築
for(int j = 1; j < LOG; ++j){
for(int v = 0; v < N; ++v){
int mid = up[j-1][v];
up[j][v] = (mid == -1 ? -1 : up[j-1][mid]);
}
}
// k 個上の祖先
int kth_ancestor(int v, int k){
for(int j = 0; j < LOG && v != -1; ++j){
if(k & (1 << j)) v = up[j][v];
}
return v;
}
// LCA
int lca(int a, int b){
if(depth[a] < depth[b]) swap(a,b);
int diff = depth[a] - depth[b];
a = kth_ancestor(a, diff);
if(a == b) return a;
for(int j = LOG-1; j >= 0; --j){
if(up[j][a] != up[j][b]){
a = up[j][a];
b = up[j][b];
}
}
return up[0][a];
}
実装上の注意点・落とし穴
- LOG の取り方:LOG = floor(log2(N))+1 で十分ですが、余裕を持って 20〜30 を固定することも多い(N ≤ 2e5 なら 20〜18 付近)。
- 根(root)や森林(複数の連結成分)の扱い:親が存在しないノードは -1 などで扱い、DFS をすべての未訪問ノードから行っておく。
- k が深さを超える場合:kth_ancestor が -1 を返すように実装しておき処理を止める。
- スタック深さ:再帰 DFS を用いる場合、N が大きいとスタックオーバーフローする可能性があるため、BFS や明示的なスタックを推奨する場合もある。
- 1-indexed と 0-indexed の混同に注意する。配列初期化も忘れずに。
応用例と派生テクニック
祖先テーブルは次のような場面で使われます。
- 最近共通祖先(LCA)を利用した木上の距離計算(dist(u,v) = depth[u] + depth[v] - 2*depth[lca(u,v)])。
- k 個上の祖先を求める問題(クエリで任意の k が与えられる)。
- 木の移動シミュレーションや ancestor-based DP(木 DP で高速に遡る必要がある場合)。
- 重心探索や up-sweep/down-sweep 系のアルゴリズムでの高速な親アクセス。
他の手法との比較
- Euler Tour + RMQ(スパーステーブル): LCA を O(1) で答えられる手法(事前 O(N log N) または O(N) 構築)。クエリ回数が非常に多い場合に有利。だが k-th ancestor 単体のクエリでは直接的ではない。
- Tarjan のオフライン LCA: オンラインではなく、事前に全クエリが分かっている場合にほぼ O(N + Q α(N)) で処理できる(Disjoint Set Union を使用)。
- 動的木(Link-Cut Tree 等): 木がリンク・カットで動的に変わる場合は祖先テーブルは向かない。動的な更新をサポートしたデータ構造が必要。
- Heavy-Light Decomposition (HLD): 距離やパス上の区間クエリと合わせて祖先操作を行う際に便利。LCA は HLD で O(log N) で求められる。
実際の利用上のコツ
- LOG を固定値にしておくと実装が簡単(例: 20–30)。ただし N が非常に大きい場合は計算してから確保する方がメモリ効率良い。
- 複数の root を扱うときは、各 root の depth を 0 にしておくと扱いやすい(必要に応じて root の parent を -1 に)。
- 配列やベクタの初期化は忘れずに(-1 初期化など)。単純なバグの原因となりやすい。
まとめ
祖先テーブル(二分ライフティング)は、木構造に対する基本かつ強力な前処理テクニックです。前処理 O(N log N) で k-th ancestor や LCA を O(log N) に抑えられるため、多くの問題で有効です。実装は比較的単純で、深さ配列と up テーブルを正しく初期化することがポイントになります。クエリ数や木の動的性質に応じて Euler Tour + RMQ、Tarjan 法、Link-Cut Tree などの別手法と使い分けるとよいでしょう。
参考文献
- CP‑Algorithms: Binary Lifting (LCA)
- Wikipedia: Lowest common ancestor
- AtCoder などの解説記事(競技プログラミングでの利用例)
- 競技プログラミングのメモ:LCA と関連手法


