[알고리즘] 코테 유형 분석 25. 최소 공통 조상

최민길(Gale)·2023년 9월 10일
1

알고리즘

목록 보기
127/172

안녕하세요 이번 시간에는 최소 공통 조상(LCA)에 대해 알아보는 시간을 갖도록 하겠습니다.

최소 공통 조상이란 트리에서 임의의 두 노드를 선택했을 때 두 노드가 각각 자신을 포함해 거슬러 올라가면서 부모 노드를 탐색할 때 처음 공통으로 만나게 되는 부모 노드입니다.

최소 공통 조상을 구하는 방법은 다음과 같습니다.

  1. 트리 구현 : 주어진 노드들의 연관관계로 트리를 구현합니다.
  2. 루트 노드에서 탐색하여 각 노드의 부모 노드와의 깊이 저장 : dfs 또는 bfs를 이용하여 부모 및 깊이 배열을 초기화합니다.
  3. 최소 공통 조상 찾기
    3-1. 선택된 두 노드의 깊이가 다른 경우 깊이가 더 깊은 노드를 부모 노드로 올려주면서 같은 깊이로 맞춤
    3-2. 깊이가 같은 상태에서 동시에 부모 노드로 올라가면서 두 노드가 같은 노드가 될 때까지 반복, 이 때 처음 만나는 노드가 최소 공통 조상

최소 공통 조상을 백준 11437(LCA) 문제로 구현해보겠습니다. N개의 정점으로 이루어진 트리가 있을 때 주어진 두 노드의 가장 가까운 공통 조상이 몇 번인지를 구하는 문제입니다. 위에서 주어진 조건을 하나씩 구현해나가면 됩니다.

import java.util.*;
import java.io.*;

class Main{
    static List<Integer>[] tree;
    static int[] depth, parent;
    static boolean[] check;
    static int N,M;

    public static void main(String[] args) throws Exception{
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        N = Integer.parseInt(br.readLine());

        // 1. 트리 구현
        tree = new List[N+1];
        for(int i=1;i<=N;i++) tree[i] = new ArrayList<>();
        for(int i=0;i<N-1;i++){
            StringTokenizer st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());
            int e = Integer.parseInt(st.nextToken());
            tree[s].add(e);
            tree[e].add(s);
        }

        // 2. 루트 노드에서 탐색하여 각 노드의 부모 노드와의 깊이 저장
        depth = new int[N+1];
        parent = new int[N+1];
        check = new boolean[N+1];
        bfs(1);

        // 3. 최소 공통 조상 찾기 시작
        M = Integer.parseInt(br.readLine());
        StringBuilder sb = new StringBuilder();
        for(int i=0;i<M;i++){
            StringTokenizer st = new StringTokenizer(br.readLine());
            int a = Integer.parseInt(st.nextToken());
            int b = Integer.parseInt(st.nextToken());
            
            // 정의한 함수에서 a의 깊이가 크거나 같기 때문에 b의 깊이가 더 클 경우 순서 바꿔서 대입 
            if(depth[a] < depth[b]) sb.append(lca(b,a)).append("\n");
            else sb.append(lca(a,b)).append("\n");
        }
        System.out.println(sb);
    }

    static int lca(int a, int b){
        // 선택된 두 노드의 깊이가 다른 경우 깊이가 더 깊은 노드를 부모 노드로 올려주면서 같은 깊이로 맞춤
        while(depth[a] != depth[b]) a = parent[a];

        // 깊이가 같은 상태에서 동시에 부모 노드로 올라가면서 두 노드가 같은 노드가 될 때까지 반복
        // 이 때 처음 만나는 노드가 최소 공통 조상
        while(a != b){
            a = parent[a];
            b = parent[b];
        }
        return a;
    }

    static void bfs(int node){
        check[node] = true;
        Queue<Integer> queue = new ArrayDeque<>();
        queue.add(node);

        int level = 1;
        int size = 1;
        int cnt = 0;

        while(!queue.isEmpty()){
            int curr = queue.poll();
            for(int next : tree[curr]){
                if(!check[next]){
                    check[next] = true;
                    queue.add(next);

                    // 탐색하면서 다음 노드가 현재 노드의 자식 노드이기 때문에 부모 및 깊이 배열에 해당 값 추가
                    parent[next] = curr;
                    depth[next] = level;
                }
            }

            // 현재 레벨에서의 탐색이 끝나면 다음 레벨로 넘어가기
            cnt++;
            if(cnt == size){
                cnt = 0;
                size = queue.size();
                level++;
            }
        }
    }
}

최소 공통 조상을 더 빠르게 구하는 방법이 있습니다. 바로 DP를 이용하여 하나의 노드가 가질 수 있는 모든 부모들을 저장하는 것입니다.

위의 방식과 차이점은 점화식을 이용하여 모든 부모 노드들을 저장하는 것입니다. P[k][n] = n번 노드의 2^k번째 부모의 노드 번호로 정의한 후 P[k][n] = p[k-1]p[k-1][n]]로 나머지 부모들을 채워나갑니다.

여기서 주의할 점은 부모 배열이 1차원에서 2차원으로 바뀌게 되면서 부모 배열 초기화 및 탐색 방법이 변경된다는 점입니다. DFS 또는 BFS로 탐색할 때 부모 자식 관계는 1단계, 즉 2^0단계이므로 k=0을 대입하여 값을 추가하며 최소 공통 조상을 탐색할 때 최대 K값을 기준으로 k가 0이 될때까지 감소시키면서 모든 K에 대한 노드들의 부모를 탐색하여 값을 조정합니다.

이를 백준 11438(LCA2)로 구현해보겠습니다.

import java.util.*;
import java.io.*;

class Main{
    static List<Integer>[] tree;
    static int[] depth;

    // 이차원 배열로 부모 노드 저장 배열 생성
    // parent[k][n] = n번 노드의 2^k번째 부모의 노드 번호
    // parent[k][n] = parent[k-1][parent[k-1][n]]
    static int[][] parent;
    static boolean[] check;
    static int N,M,K;

    public static void main(String[] args) throws Exception{
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        N = Integer.parseInt(br.readLine());

        // 1. 트리 구현
        tree = new List[N+1];
        for(int i=1;i<=N;i++) tree[i] = new ArrayList<>();
        for(int i=0;i<N-1;i++){
            StringTokenizer st = new StringTokenizer(br.readLine());
            int s = Integer.parseInt(st.nextToken());
            int e = Integer.parseInt(st.nextToken());
            tree[s].add(e);
            tree[e].add(s);
        }

        // 2. K의 최댓값 구하기 : 트리의 깊이 > 2^k를 만족하는 최댓값
        // 즉 현재 노드의 2^k번째 부모를 부모 배열에 다 저장, 이렇게 하면 하나의 노드에 대한 모든 부모를 알 수 있음
        int tmp = 1;
        K = 0;
        while(tmp <= N){
            tmp <<= 1;
            K++;
        }

        // 3. 루트 노드에서 탐색하여 각 노드의 부모 노드와의 깊이 저장
        depth = new int[N+1];
        parent = new int[K+1][N+1];
        check = new boolean[N+1];
        bfs(1);

        // 4. 부모 배열 점화식에 맞게 BFS에서 탐색하지 못한 부분 초기화
        for(int k=1;k<=K;k++){
            for(int n=1;n<=N;n++){
                parent[k][n] = parent[k-1][parent[k-1][n]];
            }
        }

        // 5. 최소 공통 조상 찾기 시작
        M = Integer.parseInt(br.readLine());
        StringBuilder sb = new StringBuilder();
        for(int i=0;i<M;i++){
            StringTokenizer st = new StringTokenizer(br.readLine());
            int a = Integer.parseInt(st.nextToken());
            int b = Integer.parseInt(st.nextToken());

            // 정의한 함수에서 b의 깊이가 크거나 같기 때문에 a의 깊이가 더 클 경우 순서 바꿔서 대입
            if(depth[a] > depth[b]) sb.append(lca(b,a)).append("\n");
            else sb.append(lca(a,b)).append("\n");
        }
        System.out.println(sb);
    }

    static int lca(int a, int b){
        // K를 기준으로 두 노드의 깊이를 같게 맞춰줌
        // 선택된 두 노드의 깊이의 차가 2^k 보다 크거나 같을 경우, 즉 두 노드의 깊이가 서로 다를 경우 
        // 현재 k의 노드의 부모의 깊이가 다른 노드의 깊이보다 크다면 해당 노드를 부모 노드로 올려줌 
        for(int k=K;k>=0;k--){
            if(Math.pow(2,k) <= depth[b] - depth[a]){
                if(depth[a] <= depth[parent[k][b]]) b = parent[k][b];
            }
        }

        // K를 기준으로 현재 K일 때의 최소 공통 조상 구하기
        // 깊이가 같은 상태에서 동시에 부모 노드로 올라가면서 두 노드가 같은 노드가 될 때까지 반복
        // 이 때 가장 큰 K값에서 a와 b가 같을 때가 최소 공통 조상
        for(int k=K;k>=0;k--){
            if(parent[k][a] != parent[k][b]){
                a = parent[k][a];
                b = parent[k][b];
            }
        }

        // a와 b가 다를 경우 바로 위의 부모를 구하기
        if(a != b) return parent[0][a];
        else return a;
    }

    static void bfs(int node){
        check[node] = true;
        Queue<Integer> queue = new ArrayDeque<>();
        queue.add(node);

        int level = 1;
        int size = 1;
        int cnt = 0;

        while(!queue.isEmpty()){
            int curr = queue.poll();
            for(int next : tree[curr]){
                if(!check[next]){
                    check[next] = true;
                    queue.add(next);

                    // 탐색하면서 다음 노드가 현재 노드의 자식 노드이기 때문에 부모 및 깊이 배열에 해당 값 추가
                    // 이 때 부모 - 자식 관계는 1단계, 즉 2^0단계이므로 k=0을 대입
                    parent[0][next] = curr;
                    depth[next] = level;
                }
            }

            // 현재 레벨에서의 탐색이 끝나면 다음 레벨로 넘어가기
            cnt++;
            if(cnt == size){
                cnt = 0;
                size = queue.size();
                level++;
            }
        }
    }
}
profile
저는 상황에 맞는 최적의 솔루션을 깊고 정확한 개념의 이해를 통한 다양한 방식으로 해결해오면서 지난 3년 동안 신규 서비스를 20만 회원 서비스로 성장시킨 Software Developer 최민길입니다.

0개의 댓글