👨‍👩‍👦

[JS] LCA (최소 공통 조상)


최소 공통 조상 찾기

최소 공통 조상 찾기는 트리 구조에서 임의의 두 정점이 공통으로 갖는 가장 가까운 조상 정점을 찾는 문제다.

트리의 구조가 위의 예시와 같다면, 13과 15의 LCA는 5가 된다.

13과 11의 LCA는 1이 된다.

이 문제를 어떻게 해결할 수 있을까?

선형 탐색

가장 단순한 방법을 떠올려보자. 두 정점에서 시작해서 같아질 때까지 부모 노드로 거슬러 올라가면 될 것 같다.

여기서 주의해야할 점은 두 정점의 깊이가 다르다면 깊이를 맞춰줘야 한다.

위의 13과 15의 최소 공통 조상을 이 알고리즘을 통해 찾는다고 해보자. 깊이를 맞춰주지 않는다면 13은 부모인 9, 15는 부모인 5로 올라가고, 한 번 더 올라가면 둘은 깊이 차이 때문에 계속 엇갈려 결국 루트까지 올라가게 된다.

따라서 거슬러 올라가기 전에 일단 깊이를 동일하게 맞춰주고, 그 다음 가리키는 정점이 같아질 때까지 올라가면 된다.

부모로 올라가기 위해서 DFS를 통해 각 노드의 부모와 깊이를 저장해야 한다.

깊이를 따라 올라가기 때문에 O(Depth)O(Depth)의 시간복잡도를 가진다.

이분 탐색

“한 칸씩 올라가는건 너무 느리다”라는 생각에서 나온 아이디어로, 1칸씩 올라갔던 것을 1, 2, 4, 8, ...칸씩 올라감으로써 시간복잡도를 O(logDepth)O(\log Depth)로 줄일 수 있다. DP와 이분 탐색을 활용한다.

여러칸 올라가기 위해서는 부모를 저장한 배열에 1, 2, 4, 8, ... 칸 떨어진 부모가 누구인지 기록해야한다.

즉, 각 정점에 대해 1, 2, 4, ..번째(202^0, 212^1, 222^2, ... , 2k12^{k-1}) 부모의 정보를 기록한다. 이 배열을 parent라고 하면, parent[n][k]는 정점 n2^k 번째 조상을 의미한다. 여기서 k의 크기는 log2n\log_2n 의 올림이다. 왜냐하면 트리가 일자로 구성됐을때 2^kn보다 같거나 커야 끝까지 뛸 수 있기 때문이다.

먼저 DFS를 통해 첫 번째 부모를 저장하고 나서 2의 승수의 부모를 찾는다.

2의 승수 부모는 다음과 같은 아이디어를 통해 찾을 수 있다.

나를 기준으로 2칸 떨어진 부모는 1칸 떨어진 부모의 1칸 떨어진 부모다.

나를 기준으로 4칸 떨어진 부모는 2칸 떨어진 부모의 2칸 떨어진 부모다.

나를 기준으로 2k12^{k-1}칸 떨어진 부모는 2k12^{k-1}칸 떨어진 부모의 2k12^{k-1}부모다.

이 아이디어와 첫 번째 부모를 기록한 것을 통해 2칸, 4칸, ... 떨어진 부모를 차례대로 찾을 수 있다.

1for(let k = 1; k < MAX; k++) { // Max는 Math.ceil(Math.log2(n))
2 for(let node = 1; node <= n; node++) {
3 parent[node][k] = parent[parent[node][k - 1]][k - 1]
4 }
5}

배열을 완성했다면 이제 공통 조상을 찾을 수 있다.

공통 조상을 찾는 과정은 선형 탐색에서 한 것처럼 깊이를 맞춰주고 정점이 같아질 때까지 올라가지만 과정이 좀 더 복잡하다.

1. 깊이 맞춰주기

두 정점 uv중 깊이가 더 깊은 것을 u라고 하자. 깊이 차는 깊이를 기록한 배열을 통해 쉽게 구할 수 있다. 이제 uv까지 점프시켜야 하는데, 이걸 어떻게 할까?

깊이 차가 5인 경우를 한 번 보자. 우리는 점프를 2의 승수로 하고 있다. 5를 2진수로 바꿔보면 101(2)가 된다. 이 뜻은 4칸 한 번, 1칸 한 번을 뛰면 같은 높이가 될 수 있다는 뜻이다.

즉 두 정점이 주어지면 두 정점의 높이 차를 구한 후, 비트 연산을 통해 깊이를 맞춰줄 수 있다.

1function lca(u, v) {
2 // u를 더 깊은 정점으로 함
3 if(depth[u] < depth[v]) [u, v] = [v, u];
4 let diff = depth[u] - depth[v];
5 for(let hop = 1; diff !== 0; hop++) {
6 if(diff & 1) u = parent[u][hop];
7 diff >>= 1;
8 }
9}

2. 정점이 같아질 때까지 올라가기

이제 깊이가 같아졌으니 최소 공통 조상을 찾으면 된다. 선형 탐색에서는 현재 위치부터 점진적으로 올라갔지만, 여기서는 뛸 수 있는 최대 거리만큼 뛰고 밑으로 내려오면서 찾을 것이다.

두 정점에서 뛰었는데 부모가 동일하다면 ‘최소’ 공통 조상이 아닌 그냥 공통 조상을 찾은 것이다. 최소 공통 조상을 건너뛰었을 수도 있기 때문이다. 따라서 부모가 다를 때 각각 그 부모로 점프 시키면서 뛰는 거리를 절반씩 줄이면 두 정점의 위치는 LCA의 바로 아래가 된다.

위 예시에 알고리즘을 적용해보자.

먼저 13을 9로 올려 깊이를 맞춰준다. 트리의 노드 수가 15개이기 때문에 최대 뛸 수 있는 거리는 4다. 4만큼 뛰면 트리 범위를 넘어가니 무시하고, 절반을 줄인 거리인 2만큼 뛸 것이다. 9와 11에서 2만큼 뛰면 부모가 각각 2와 4다. 부모가 다르므로 각각 부모의 위치로 점프시킨다. 그 다음 1만큼 뛰면 부모가 1로 같으니 무시한다. 결과를 보면 두 정점이 LCA의 밑에 온 것을 확인할 수 있다.

1function lca(u, v) {
2 // 1. 깊이 맞추기
3 // u를 더 깊은 정점으로 함
4 if(depth[u] < depth[v]) [u, v] = [v, u];
5 let diff = depth[u] - depth[v];
6 for(let hop = 1; diff !== 0; hop++) {
7 if(diff & 1) u = parent[u][hop];
8 diff >>= 1;
9 }
10
11 // 2. LCA 찾기
12 if(u === v) return u;
13
14 for(let i = MAX - 1; i >= 0; i--) {
15 // 부모가 트리 범위 안에 있고 다르다면 그 위치로 점프
16 if(parent[u][i] !== -1 && parent[u][i] !== parent[v][i]) {
17 u = parent[u][i];
18 v = parent[v][i];
19 }
20 }
21 return parent[u][0];
22}

참조:
https://kibbomi.tistory.com/201
https://4legs-study.tistory.com/121