HL分解・オイラーツアー入門

初めに

この記事は、Heavy-Light分解(重軽分解、HL分解)・オイラーツアー・その融合形についての記事です。先日ある方のエントリに感動したのでこの記事を書きます。 HL分解とオイラーツアーは、論点を木から配列にずらすことで木に関するクエリをうまく処理する手法です。この記事ではこれらに加え、HL分解と「部分木に関するクエリを処理するオイラーツアー」を融合させることで、パス・部分木に絡むクエリ両方を処理できるよう方法を紹介します。

_人人人人人人人人人人人人人人人人人人人人_
> 多分これが一番わかりやすいと思います <
 ̄YYYYYYYYYYYYYYYYYYYY ̄

※クッソ長いです。ごめんね

オイラーツアー

雑にネットで調べたのですが、主に2種類あるようです。

  • パスに関するクエリを処理する オイラーツアーと
  • 部分木に関するクエリを処理する オイラーツアー です。

どちらも「クエリを列処理に還元できるようにBFS(深さ優先探索)をして頂点を並べる」のは変わりません。以下オイラーツアー(Euler Tour)をETと略すことにします。宇宙人ではありません。

冒頭のHL分解+部分木ETの理解のためには、パスETの方の理解は必ずしも必要ではありません。面倒な方はこれからのパスETは飛ばしてお読みください。

パスに関するET

次のような木を考えます。


ペイント最高!


この木上でDFSを行います。根から始めて素直に左側の子から訪れると、下の図のように訪れることになります。頂点の左の青い数字は来た順番です(一度来た頂点も記録します)。


今このBFSで訪れた順に、頂点を並べてみます。n番目の頂点をvi[n]とします:

n01234567891011121314
vi[n]012425267621310

次に各頂点vについて、

  • vが始めて訪れた時の添え字」\displaystyle{vd[v]}
  • vが最後に訪れた時の添え字」\displaystyle{vu[v]}

を記録します:

v01234567
\displaystyle{vd[v]}012123578
\displaystyle{vu[v]}141310123598

この2つは以下のBFSで求められます

void bfs(vector<vector<int>>& childs,int v,int& ind){
    vd[v]=ind++;
        for(int ch:childs[v]){
            bfs(childs,ch,ind);
            ind++;
        }
    vu[v]=ind-1;
}

この時2頂点\displaystyle{u,v} (ただし\displaystyle{vd[u]\leq vd[v]})について、以下が成り立ちます:

  • 頂点\displaystyle{vi[vd[u]]=u\rightarrow vi[vd[u+1]]\rightarrow \cdots\rightarrow vi[vd[v]]=v}u\rightarrow vのパスになる(ただし最短のパスではない)

例えば

  • u=1,v=6について、\displaystyle{vd[1]=1,vd[6]=7}
    \displaystyle{vi[1]~vi[7]},つまり\displaystyle{1\rightarrow 2\rightarrow 4\rightarrow 2\rightarrow 5\rightarrow 2\rightarrow 6}uからvへのパスである

  • u=7,v=3について、\displaystyle{vd[7]=8,vd[3]=12}
    \displaystyle{vi[8]~vi[12]},つまり\displaystyle{7\rightarrow 6\rightarrow 2\rightarrow 1\rightarrow 3}uからvへのパスである

この性質を用いて、以下の問題を解いてみます。

  • 頂点数Vの木が与えられる。各辺(v,p(v))には整数\displaystyle{a _ {v}}が定められている。初期値は0
  • 2種類のクエリが飛んでくる
  • 更新クエリ(v,x):\displaystyle{a _ {v}}+= x
  • パス和クエリ(u,v):頂点\displaystyle{u}から\displaystyle{v} (ただし\displaystyle{LCA(u,v)=u})への、最短のパス上の辺のa _ {*}の総和を出力する

素数2V-1の配列Aを用意します。各クエリを以下のように配列の処理に置き換えます。

  • 更新クエリ(v,x):\displaystyle{A[vd[v]-1]+=x,A[vu[v]]-=x}
  • パス和クエリ(u,v):\displaystyle{\sum _ {i=vd[u]}^ {vd[v]-1}A[i]}を求める

この処理はセグ木に任せることでO(log\ n)で実現可能です。


vi[*]012425267621310
A _ {*}a _ {1}a _ {2}a _ {4}-a _ {4}a _ {5}-a _ {5}a _ {6}a _ {7}-a _ {7}-a _ {6}-a _ {2}a _ {3}-a _ {3}-a _ {1}

具艇的なコードは書きません、めんどいので...。

(u,v)の一方がLCA(u,v)でなくとも、LCAを取ることでこのパターンに帰着できます。また辺の重みの足し算ではなくほかの演算でも可能です。この演算が可換でない場合も、二方向の演算結果を持っておくことで適用できます。ただし逆元や単位元を持つ必要があります。したがって行列積や最大最小には使えません。総じて、いわゆる群っちゅう奴なら乗せられるということです。

LCAも求められます。DFSの時各頂点の深さも配列Dに記録しておきます。頂点u,vLCAは、\displaystyle{D[vd[u]]からD[vd[v]]}の中で値が最も小さくなるような頂点です。

部分木に関するET

この場合も同様の準備を行います。ただし今度のBFSでは一度訪れた頂点は飛ばして記録します。

l[v] 01234567
v 01245673

以下、頂点vを何番目に訪れたかをl[v]で表します。 頂点6の部分木は6,7です。これは先の表下段のl[6]=5,6番目です(0-index)。同様に、頂点2の部分木は2,4,5,6,7です。これは先の表下段のl[2]=2~6番目です。このように、各部分木が表下段の数列の部分列に対応しています。
よって、頂点vに関する情報を配列のl[v]番目においておけば、部分木に関するクエリは列に関するクエリに変換できます。具体的に以下のような問題で見てみます。

  • 頂点数Vの木が与えられる。各頂点vには整数\displaystyle{a _ {v}}が定められている。初期値は0
  • 2種類のクエリが飛んでくる
  • 更新クエリ(v,x):\displaystyle{a _ {v}}+= x
  • 部分木和クエリ(v):頂点\displaystyle{v}の部分木に含まれるすべての頂点ua _ {u}の和を出力する

まず次のようなBFSを行います。

void bfs(vector<vector<int>>& childs,int v,int& ind){
    l[v]=ind++;
        for(int ch:childs[v]){
            bfs(childs,ch,ind);
        }
    r[v]=ind-1;
}

BFS順に頂点を並べた時に、頂点vの部分木は\displaystyle{l[v]}番目~ \displaystyle{r[v]}番目の部分列に対応します。次に要素数Vの配列Aを用意します。\displaystyle{a _ {l}}\displaystyle{A[l[v]]}に入れます。
更新クエリ(v,x)に対しては、\displaystyle{A[l[v]]+=x}をします。
部分木和クエリに対しては、\displaystyle{A[l[v]]~A[r[v]]}の和を求めればいいです。
したがってこの問題はセグ木で解くことができます。

以上のようにETは、枝をうまい具合に列に変換して部分木・パス関連のクエリをうまく捌こうという案だと思っています。

HL分解

一方HL分解そのものは、「列がノードである扱いやすい木に変換する」という手法です。扱いやすいというのは、深さが低い、という意味です。

まず辺を2種類の辺、Heavyな辺とLightな辺に分けます:

  • (高さ1以上の)各頂点から、部分木が最も大きい子頂点への辺をHeavyな辺、
  • それ以外の辺はLightな辺

水色の数字は各頂点の部分木の要素数、水色の辺がHeavyな辺です。例えば頂点2の子頂点は4,5,6ですが、それぞれの部分木の要素数1,1,2です。よって頂点2から葉方向へのHeavyな辺は2\rightarrow 6です。

次に、Heavyな辺で結ばれた頂点たちを、元の木での上下関係をとどめ一つにまとめます。以下この1つにまとめたものを「Heavy集合」とでも呼ぶことにします。あるHeavy集合に含まれる集合と、別のHeavy集合に含まれる頂点がLightな辺で結ばれている場合2つのHeavy集合を結ぶと、元の木より低い木ができます。 各Heavy集合は持っている頂点列を管理します。

この図の場合、

  • 0番目のHeavy集合が持つ頂点は、根の方から順に0,1,2,6,7
  • 1番目のHeavy集合が持つ頂点は、根の方から順に4
  • 2番目のHeavy集合が持つ頂点は、根の方から順に5
  • 3番目のHeavy集合が持つ頂点は、根の方から順に3,8

深く書く気力もないので証明等は割愛しますが(は?)、この変換で木の深さをlog\ V程度に抑えることができます。

この方法の利点は、長い枝を配列で処理し木上を行き来する回数を抑えられることです。具体的にどういう処理を行うか、改めて「パスに関するET」での例題で考えることにします。うまい抽象論を述べる日本語力がないので。

  • 頂点数Vの木が与えられる。各頂点vには整数\displaystyle{a _ {v}}が定められている。初期値は0
  • 2種類のクエリが飛んでくる
  • 更新クエリ(v,x):\displaystyle{a _ {v}}+= x
  • パス和クエリ(u,v):頂点\displaystyle{u}から\displaystyle{v} (ただし\displaystyle{LCA(u,v)=u})への、最短のパス上の頂点のa _ {*}の総和を出力する

やはり具体的な実装は疲れるのでやめときますが(気が向いたら書くかも)、図を使いつつまあざっくり説明します。

  • 前準備:HL分解をします
    • 各頂点の部分木のサイズを求める
    • 各頂点についてどのHeavy集合に属するか求める(私はHeavy集合を最も根に近い頂点で見分けてます)
    • 各Heavy集合の(Heavy集合のみの木での)深さを求める...LCAを求める際には必要です
    • 各Heavy集合にセグ木を用意させる
  • 更新クエリ(v,x)の場合、
    • 頂点vを含むHeavy集合を求め、セグ木のvに対応する場所を+=xする
  • パス和クエリ(u,v)の場合、
    • vの属するHeavy集合Huの属するHeavy集合が異なる間、(whileループ)
      • Hの持つ一番根に近い頂点tからvまでの和をHのセグ木で求める
      • utの親で置き換え
    • uからvまでの和を求める

Heavy集合の木を1個ずつ上がっているわけですが、深さは高々log\ Vですから計算量はO(log^ 2\ V)です。

Easiest HLD with subtree queries

正直ここからが本題です。長いね。すずろに書いてたらめちゃくちゃ長くなりました。ごめんね

ご紹介するのは、CodeForces上のadamant様のこのエントリです。まあざっくりとアイデアを日本語にすると、「0番目の子がHeavyな辺でつながってくれれば、オイラーツアーしただけでHeavy集合がならんでくれるよね」という話です。

codeforces.com

ソースコード部分の下、おじさん?お兄さん?の上の英文は大体つぎのような感じです:

このBFSすれば、

  • 頂点vの部分木が\displaystyle{[in _ v,out _ v)}に、
  • vからHeavy集合の一番上の頂点nxt _ v へのパスが\displaystyle{[in _ {nxt _ v},in _ v]}

対応する配列がゲットできるよ これで同じセグ木でパスと部分木両方処理できるよ

具体的にソースコードを見てみます。

void dfs_sz(int v = 0) {
    sz[v] = 1;
    for(auto &u: g[v]) {
        dfs_sz(u);
        sz[v] += sz[u];
        if(sz[u] > sz[g[v][0]]) {
            swap(u, g[v][0]);
        }
    }
}

基本的には頂点vの部分木のサイズsz[v]を求める処理です。普通と違うのは、swapの部分です。
これはつまり、「部分木のサイズが一番大きい子を強制的に0番目にする」わけです。もっと言うと、Heavy辺の決め方から「0番目の子が親と同じHeavy集合に属する」わけです。

次に2個目のDFS関数を見てみます。

void dfs_hld(int v = 0) {
    in[v] = t++;
    for(auto u: g[v]) {
        nxt[u] = (u == g[v][0] ? nxt[v] : u);
        dfs_hld(u);
    }
    out[v] = t;
}

まず\displaystyle{in,out}は前に示した通り、部分木に関するETにおける頂点vの部分木の範囲です。一方、子uについて、

  • u == g[v][0]、つまりuvが同じHeavy集合ならば\displaystyle{nxt[v]}nxt[u]
  • u \neq g[v][0]、つまり辺(u,v)はLightな辺ならば\displaystyle{nxt[u] = u}

これでnxtは各頂点が属するHeavy集合の、最も根に近い頂点になります。

わざわざ2つの子をswapしたのにも意味があります。素直に0番目の子から訪ねるオイラーツアーをした結果がHeavy集合を並べた形になるからです。例えば先の図の木の場合、ETの結果は0,1,2,6,7,5,4,3,8になります。この列は確かにHeavy集合を並べた形になっています。

実装例

HLD本体

class HLD {
public:
    Tree& tree;
    LL V;
    VLL depth;   //各頂点が属するHeavy集合の深さ
    VLL top;   //各頂点が属するHeavy集合の一番上の頂点
    VLL in;   //各頂点がEuler-Tourでどこにいるか
    VLL out;   //各頂点の部分木の終わり
    HLD(Tree& t, LL root = 0):tree(t) {
        V = t.size();
        VLL subtrees(V, -1);
        //各部分木のサイズを求める
        {
            stack<LL> order;
            stack<LL> hold;
            hold.push(root);
            while (!hold.empty()) {
                LL cur = hold.top();
                hold.pop();
                order.push(cur);
                for (LL ch : tree[cur].childs) {
                    hold.push(ch);
                }
            }
            while (!order.empty()) {
                LL cur = order.top();
                order.pop();
                subtrees[cur] = 1;
                for (LL& ch : tree[cur].childs) {
                    subtrees[cur] += subtrees[ch];
                    if (subtrees[ch] > subtrees[tree[cur].childs[0]]) {
                        swap(ch, tree[cur].childs[0]);
                    }
                }
            }
        }
        //HL分解 with eulertour
        {
            in.resize(V);
            out.resize(V);
            depth.resize(V);
            top.resize(V);
            LL cur = root;
            LL nextid = 0;
            dfs(cur, nextid);
        }
    }
    void dfs(LL cur,LL& nextind) {
        in[cur] = nextind++;
        for (auto ch : tree[cur].childs) {
            //0番目の子は同じHeavy集合
            if (ch == tree[cur].childs[0]) {
                top[ch] = top[cur];
                depth[ch] = depth[cur];
            }
            //それ以外は新しいHeavy集合
            else {
                top[ch] = ch;
                depth[ch] = depth[cur] + 1;
            }
            dfs(ch, nextind);
        }
        out[cur] = nextind - 1;
    }
    LL LCA(LL u, LL v) {
        //uの属するnode.depth >= vの属するnode.depthにする
        if (depth[u] < depth[v]) {
            swap(u, v);
        }
        while (depth[u] > depth[v]) {
            u = tree[top[u]].parent;
        }
        while (top[u] != top[v]) {
            u = tree[top[u]].parent;
            v = tree[top[v]].parent;
        }
        if (in[u] > in[v])return v;
        else return u;
    }
};

LLはlong longです(たぶん定数倍的にはよくないんですよね)
bfs_szに対応する部分はコンストラクタ中にいて、bfs_hldはbfsという名前になっています。LCAに対応するために、bfsで各頂点の属するHeavy集合の深さdepthも求めています。
LCAも求められます。

  • uのHeavy集合とvのHeavy集合を一個ずつ上がり続ける(u,vも一緒に更新)
  • 両者が一緒になったら(つまりtop[u] == top[v])、オイラーツアーで先に来た方をLCAとする

<そのうち増やす>

疲れました。思ったよりめちゃくちゃ長くなりました。最初からもうちょっとソーシャルディスタンスな感じで書けばよかったですね。