콘텐츠로 이동

최소 공통 조상 - Tarjan의 오프라인 알고리즘

$n$개의 노드로 이루어진 트리 $G$가 있고, $(u, v)$ 형태의 쿼리가 $m$개 있습니다. 각 쿼리 $(u, v)$마다 정점 $u$와 $v$의 최소 공통 조상, 즉 $u$와 $v$ 모두의 조상이면서 트리에서 깊이가 가장 깊은 노드를 찾으려고 합니다. 노드 $v$는 $v$ 자신의 조상이기도 하므로, LCA가 두 노드 중 하나일 수도 있습니다.

이 글에서는 문제를 오프라인으로 해결합니다. 즉, 모든 쿼리를 미리 알고 있다고 가정하므로 원하는 순서대로 쿼리에 답할 수 있습니다. 다음 알고리즘을 사용하면 $m$개의 쿼리 전체에 총 $O(n + m)$ 시간에 답할 수 있습니다. 즉, $m$이 충분히 크면 쿼리 하나당 $O(1)$입니다.

알고리즘

이 알고리즘은 1979년에 이를 발견한 Robert Tarjan의 이름을 딴 것입니다. Tarjan은 이 알고리즘에서 많이 사용하는 서로소 집합 유니온 자료 구조에도 여러 기여를 했습니다.

이 알고리즘은 트리를 DFS로 한 번 순회하여 모든 쿼리에 답합니다. 구체적으로, 쿼리 $(u, v)$는 노드 $v$를 이미 방문했다면 노드 $u$에서 답하고, 그 반대의 경우도 마찬가지입니다.

이제 현재 노드 $v$에 있고, 재귀적인 DFS 호출을 이미 마쳤으며, 쿼리 $(u, v)$의 다른 노드 $u$도 이미 방문했다고 가정합시다. 이 두 노드의 LCA를 찾는 방법을 알아봅시다.

$\text{LCA}(u, v)$는 노드 $v$이거나 $v$의 조상 중 하나라는 점에 유의하십시오. 따라서 $v$의 조상($v$ 포함) 중에서 노드 $u$를 자손으로 갖는 가장 낮은 노드를 찾아야 합니다. 또한 $v$를 고정하면 트리에서 방문한 노드들이 서로소인 집합 여러 개로 나뉜다는 점에 유의하십시오. 노드 $v$의 각 조상 $p$는 자기만의 집합을 가지며, 이 집합에는 $p$ 자신과, $p$의 자식 중 $v$에서 트리의 루트로 가는 경로에 속하지 않는 자식을 루트로 하는 모든 서브트리가 들어 있습니다. 노드 $u$를 포함하는 집합이 $\text{LCA}(u, v)$를 결정합니다. LCA는 그 집합의 대표, 즉 $v$와 트리의 루트 사이의 경로 위에 있는 노드입니다.

이제 이 집합들을 모두 효율적으로 관리하는 방법만 알면 됩니다. 이를 위해 DSU 자료 구조를 사용합니다. 랭크에 의한 합치기(Union by rank)를 적용할 수 있도록, 각 집합의 실제 대표($v$와 트리의 루트 사이의 경로 위에 있는 값)를 배열 ancestor에 저장합니다.

DFS의 구현을 살펴봅시다. 현재 노드 $v$를 방문하고 있다고 가정합시다. 이 노드를 DSU의 새 집합에 넣습니다: ancestor[v] = v. 평소처럼 $v$의 모든 자식을 처리합니다. 이를 위해 먼저 해당 자식 노드에서 DFS를 재귀적으로 호출한 다음, 그 노드와 그 서브트리 전체를 $v$의 집합에 추가해야 합니다. 이는 함수 union_sets와 그 뒤에 오는 대입 ancestor[find_set(v)] = v로 할 수 있습니다(union_sets가 집합의 대표를 바꿀 수 있으므로 이 대입이 필요합니다).

마지막으로 모든 자식을 처리한 뒤에는 $u$를 이미 방문한 $(u, v)$ 형태의 모든 쿼리에 답할 수 있습니다. 쿼리의 답, 즉 $u$와 $v$의 LCA는 노드 ancestor[find_set(u)]입니다. 각 쿼리에 한 번만 답한다는 것은 쉽게 알 수 있습니다.

이 알고리즘의 시간 복잡도를 구해 봅시다. 첫째로, DFS 때문에 $O(n)$이 소요됩니다. 둘째로, union_sets 함수 호출이 $n$번 일어나므로 이 역시 $O(n)$이 됩니다. 셋째로, 각 쿼리마다 find_set을 호출하므로 $O(m)$이 됩니다. 따라서 전체 시간 복잡도는 $O(n + m)$이며, 이는 $m$이 충분히 클 때 쿼리 하나에 답하는 데 $O(1)$이 걸리는 것과 같다는 뜻입니다.

구현

다음은 이 알고리즘의 구현입니다. DSU는 아무런 수정 없이 그대로 사용할 수 있으므로 DSU의 구현은 포함하지 않았습니다.

vector<vector<int>> adj;
vector<vector<int>> queries;
vector<int> ancestor;
vector<bool> visited;

void dfs(int v)
{
    visited[v] = true;
    ancestor[v] = v;
    for (int u : adj[v]) {
        if (!visited[u]) {
            dfs(u);
            union_sets(v, u);
            ancestor[find_set(v)] = v;
        }
    }
    for (int other_node : queries[v]) {
        if (visited[other_node])
            cout << "LCA of " << v << " and " << other_node
                 << " is " << ancestor[find_set(other_node)] << ".\n";
    }
}

void compute_LCAs() {
    // initialize n, adj and DSU
    // for (each query (u, v)) {
    //    queries[u].push_back(v);
    //    queries[v].push_back(u);
    // }

    ancestor.resize(n);
    visited.assign(n, false);
    dfs(0);
}