[알고리즘] 서로소 집합(Disjoint Set)

최영환·2023년 4월 25일
0

알고리즘

목록 보기
12/17

서로소 집합(Disjoint Set)

  • 공통 원소가 없는 두 집합
  • 서로소 부분 집합들로 나누어진 원소들의 데이터를 처리하기 위한 자료구조
  • 합집합(Union)찾기(Find) 2개의 연산으로 조작이 가능 ⇒ 합치기 찾기(Union-Find) 자료구조라고 불리기도 함
    • 합집합(Union) 연산 : 2개의 원소가 포함된 집합을 하나의 집합으로 합치는 연산
    • 찾기(Find) 연산 : 특정한 원소가 속한 집합이 어떤 집합인지 알려주는 연산
  • 두 집합이 서로소 관계인지를 확인할 수 있으므로 각 집합이 어떤 원소를 공통으로 가지고 있는지 확인할 수 있음
  • 트리(Tree) 자료구조를 이용하여 구현
  • 일반적으로 서로소 집합을 그림으로 표현할 때는 트리구조상 번호가 작은 노드가 부모가 되고, 번호가 큰 노드가 자식 노드가 됨

서로소 집합 계산 과정

  1. 합집합 연산을 확인하여 서로 연결된 두 노드 A, B 를 확인

    I. A 와 B의 루트 노드 A’, B’ 를 각각 찾음

    II. A’ 를 B’ 의 부모 노드로 설정(B’ 가 A’ 를 가리키도록 함)

  2. 모든 합집합 연산을 처리할 때까지 1번 과정을 반복함

  • 합집합 수행 시 각각 루트 노드를 찾아서 더 큰 루트 노드가 더 작은 루트 노드를 가리키도록 하면 됨
  • 서로소 집합 알고리즘으로 루트를 찾기 위해서는 재귀적으로 부모를 거슬러 올라가야 함

구현

  • **경로 압축 기법** 을 적용하여 최적화한 코드
    • 경로 압축 기법 : Find 메소드를 재귀적으로 호출한 뒤 부모 테이블 값을 갱신하는 기법
  • 소스코드
    import java.util.Scanner;
    
    public class UnionFind {
        // 노드의 개수(V) 와 간선(Union 연산) 의 개수(E)
        // 노드의 개수는 최대 100,000개라고 가정
        public static int v, e;
        public static int[] parent = new int[100001]; // 부모 테이블 초기화
    
        // 특정 원소가 속한 집합 찾기(Find 연산)
        public static int findParent(int x) {
            // 루트 노드가 아니라면 루트 노드를 찾을 때까지 재귀적으로 호출
            if (x == parent[x])
                return x;
            return parent[x] = findParent(parent[x]);
        }
    
        // 두 원소가 속한 집합을 합치기(Union 연산)
        public static void unionParent(int a, int b) {
            a = findParent(a);
            b = findParent(b);
            // 둘 중 작은 값을 부모 노드로 설정함
            if (a < b)
                parent[b] = a;
            else
                parent[a] = b;
        }
    
        public static void main(String[] args) {
            Scanner sc = new Scanner(System.in);
            v = sc.nextInt();
            e = sc.nextInt();
    
            // 부모 테이블상에서, 부모를 자기 자신으로 초기화
            for (int i = 1; i <= v; i++) {
                parent[i] = i;
            }
    
            // Union 연산 수행
            for (int i = 0; i < e; i++) {
                int a = sc.nextInt();
                int b = sc.nextInt();
                unionParent(a, b);
            }
    
            // 각 원소가 속한 집합 출력
            System.out.print("각 원소가 속한 집합: ");
            for (int i = 1; i <= v; i++) {
                System.out.print(findParent(i) + " ");
            }
            System.out.println();
    
            // 부모 테이블 내용 출력하기
            System.out.print("부모 테이블: ");
            for (int i = 1; i <= v; i++) {
                System.out.print(parent[i] + " ");
            }
            System.out.println();
            sc.close();
        }
    }

시간 복잡도

  • 경로 압축 기법을 사용하고, 노드의 개수가 V개, 최대 V-1 개의 Union 연산과 M 개의 Find 연산이 가능한 경우 : O(V+M(1+log2M/VV))O(V+M(1+log_{2-M/V}V))

응용

1. 사이클(Cycle) 판별

  • 무향 그래프 내에서의 사이클 판별 시에 사용이 가능

  • 방향 그래프에서의 사이클 여부는 DFS(Depth First Search)를 이용하여 판별 가능

  • 간선을 하나씩 확인하면서 두 노드가 포함되어 있는 집합을 합치는(Union) 과정을 반복하는 것으로 판별 가능

  • 과정

    1. 각 간선을 확인하며 두 노드의 루트 노드를 확인

      I. 루트 노드가 서로 다르다면 두 노드에 대하여 Union 연산 수행

      II. 루트 노드가 서로 같다면 사이클이 발생한 것

    2. 그래프에 포함되어 있는 모든 간선에 대하여 1번 과정을 반복

  • 구현

    import java.util.Scanner;
    
    public class Cycle {
        // 노드의 개수(V) 와 간선(Union 연산) 의 개수(E)
        // 노드의 개수는 최대 100,000개라고 가정
        public static int v, e;
        public static int[] parent = new int[100001]; // 부모 테이블 초기화
    
        // 특정 원소가 속한 집합 찾기
        public static int findParent(int x) {
            // 루트 노드가 아니라면 루트 노드를 찾을 때까지 재귀적으로 호출
            if (x == parent[x])
                return x;
            return parent[x] = findParent(parent[x]);
        }
    
        // 두 원소가 속한 집합을 합치기
        public static void unionParent(int a, int b) {
            a = findParent(a);
            b = findParent(b);
            // 둘 중 작은 값을 부모 노드로 설정함
            if (a < b)
                parent[b] = a;
            else
                parent[a] = b;
        }
    
        public static void main(String[] args) {
            Scanner sc = new Scanner(System.in);
    
            v = sc.nextInt();
            e = sc.nextInt();
    
            // 부모 테이블상에서, 부모를 자기 자신으로 초기화
            for (int i = 1; i <= v; i++) {
                parent[i] = i;
            }
    
            boolean cycle = false;
            for (int i = 0; i < e; i++) {
                int a = sc.nextInt();
                int b = sc.nextInt();
                // 사이클이 발생한 경우 종료
                if (findParent(a) == findParent(b)) {
                    cycle = true;
                    break;
                }
                // 사이클이 발생하지 않았으면 Union 연산 수행
                else {
                    unionParent(a, b);
                }
            }
    
            if (cycle) {
                System.out.println("사이클이 발생했습니다.");
            } else {
                System.out.println("사이클이 발생하지 않았습니다.");
            }
            sc.close();
        }
    }

2. 크루스칼 알고리즘

  • 최소 신장 트리 알고리즘 중 하나로, 그리디 알고리즘으로 분류됨
  • 먼저 모든 간선에 대하여 정렬을 수행한 뒤, **가장 거리가 짧은 간선**부터 집합에 포함시킴
  • 사이클을 발생시킬 수 있는 간선의 경우, 집합에 포함시키지 않음

신장 트리(Spanning Tree) : 하나의 그래프가 있을 때, 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프

최소 신장 트리 알고리즘(Minimum Spanning Tree Algorithm) : 신장 트리 중에서 최소 비용으로 만들 수 있는 신장 트리를 찾는 알고리즘

  • 과정
    1. 간선 데이터를 비용에 따라 오름차순으로 정렬

    2. 간선을 하나씩 확인하며 현재의 간선이 사이클을 발생시키는지 확인

      I. 사이클이 발생하지 않는 경우 MST 에 포함시킴

      II. 사이클이 발생하는 경우 MST 에 포함시키지 않음

    3. 모든 간선에 대하여 2의 과정을 반복함

    • 최소 신장 트리에 포함되어 있는 간선의 비용만 모두 더하면, 그 값이 최종 비용에 해당함
  • 구현
    import java.util.ArrayList;
    import java.util.Collections;
    import java.util.Scanner;
    
    class Edge implements Comparable<Edge> {
    
        private int distance;
        private int nodeA;
        private int nodeB;
    
        public Edge(int distance, int nodeA, int nodeB) {
            this.distance = distance;
            this.nodeA = nodeA;
            this.nodeB = nodeB;
        }
    
        public int getDistance() {
            return this.distance;
        }
    
        public int getNodeA() {
            return this.nodeA;
        }
    
        public int getNodeB() {
            return this.nodeB;
        }
    
        // 거리(비용)가 짧은 것이 높은 우선순위를 가지도록 설정
        @Override
        public int compareTo(Edge other) {
            if (this.distance < other.distance) {
                return -1;
            }
            return 1;
        }
    }
    
    public class Kruskal {
        // 노드의 개수(V)와 간선(Union 연산)의 개수(E)
        // 노드의 개수는 최대 100,000개라고 가정
        public static int v, e;
        public static int[] parent = new int[100001]; // 부모 테이블 초기화하기
        // 모든 간선을 담을 리스트와, 최종 비용을 담을 변수
        public static ArrayList<Edge> edges = new ArrayList<>();
        public static int result = 0;
    
        // 특정 원소가 속한 집합 찾기
        public static int findParent(int x) {
            // 루트 노드가 아니라면 루트 노드를 찾을 때까지 재귀적으로 호출
            if (x == parent[x])
                return x;
            return parent[x] = findParent(parent[x]);
        }
    
        // 두 원소가 속한 집합을 합치기
        public static void unionParent(int a, int b) {
            a = findParent(a);
            b = findParent(b);
            // 둘 중 작은 값을 부모 노드로 설정함
            if (a < b)
                parent[b] = a;
            else
                parent[a] = b;
        }
    
        public static void main(String[] args) {
            Scanner sc = new Scanner(System.in);
    
            v = sc.nextInt();
            e = sc.nextInt();
    
            // 부모 테이블상에서, 부모를 자기 자신으로 초기화
            for (int i = 1; i <= v; i++) {
                parent[i] = i;
            }
    
            // 모든 간선에 대한 정보를 입력 받기
            for (int i = 0; i < e; i++) {
                int a = sc.nextInt();
                int b = sc.nextInt();
                int cost = sc.nextInt();
                edges.add(new Edge(cost, a, b));
            }
    
            // 간선을 비용순으로 정렬
            Collections.sort(edges);
    
            // 간선을 하나씩 확인하며
            for (int i = 0; i < edges.size(); i++) {
                int cost = edges.get(i).getDistance();
                int a = edges.get(i).getNodeA();
                int b = edges.get(i).getNodeB();
                // 사이클이 발생하지 않는 경우에만 집합에 포함
                if (findParent(a) != findParent(b)) {
                    unionParent(a, b);
                    result += cost;
                }
            }
            System.out.println(result);
            sc.close();
        }
    }
  • 시간 복잡도
    • 간선의 개수가 E 일 때, O(ElogE)O(ElogE)
    • 간선을 정렬하는 작업이 가장 오래 걸리고, 이때의 시간 복잡도가 O(ElogE)O(ElogE)

3. 위상 정렬(Topology Sort)

  • 순서가 정해져 있는 일련의 작업을 차례대로 수행해야 할 때 사용가능한 알고리즘

  • 방향 그래프의 모든 노드를 **방향성에 거스르지 않도록 순서대로 나열하는 것**

  • 답안이 여러가지가 될 수 있음

  • 과정

    1. 진입차수가 0인 노드를 큐에 넣음

    2. 큐가 빌 때까지 다음의 과정을 반복함

      I. 큐에서 원소를 꺼내 해당 노드에서 출발하는 간선을 그래프에서 제거

      II. 새롭게 진입차수가 0이 된 노드를 큐에 넣음

    • 모든 원소를 방문하기 전에 큐가 빈다면 사이클이 존재한다고 판단할 수 있으나, 기본적으로는 사이클이 발생하지 않는다고 명시하는 문제가 많음
  • 구현

    import java.util.ArrayList;
    import java.util.LinkedList;
    import java.util.Queue;
    import java.util.Scanner;
    
    public class TopoolgySort {
        // 노드의 개수(V)와 간선의 개수(E)
        // 노드의 개수는 최대 100,000개라고 가정
        public static int v, e;
        // 모든 노드에 대한 진입차수는 0으로 초기화
        public static int[] indegree = new int[100001];
        // 각 노드에 연결된 간선 정보를 담기 위한 연결 리스트 초기화
        public static ArrayList<ArrayList<Integer>> graph = new ArrayList<ArrayList<Integer>>();
    
        // 위상 정렬 메소드
        public static void topologySort() {
            ArrayList<Integer> result = new ArrayList<>();
            Queue<Integer> q = new LinkedList<>();
    
            // 처음 시작할 때는 진입차수가 0인 노드를 큐에 삽입
            for (int i = 1; i <= v; i++) {
                if (indegree[i] == 0) {
                    if (indegree[i] == 0) {
                        q.offer(i);
                    }
                }
            }
    
            // 큐가 빌 때까지 반복
            while(!q.isEmpty()) {
                // 큐에서 원소 꺼내기
                int now = q.poll();
                result.add(now);
                // 해당 원소와 연결된 노드들의 진입차수에서 1 빼기
                for (int i = 0; i< graph.get(now).size(); i++) {
                    indegree[graph.get(now).get(i)]--;
                    // 새롭게 진입차수가 0이 되는 노드를 큐에 삽입
                    if (indegree[graph.get(now).get(i)] == 0) {
                        q.offer(graph.get(now).get(i));
                    }
                }
            }
    
            // 위상 정렬을 수행한 결과 출력
            for (int i = 0; i < result.size(); i++) {
                System.out.print(result.get(i) + " ");
            }
        }
    
        public static void main(String[] args) {
            Scanner sc = new Scanner(System.in);
    
            v = sc.nextInt();
            e =  sc.nextInt();
    
            // 그래프 초기화
            for (int i = 0; i <= v; i++) {
                graph.add(new ArrayList<Integer>());
            }
    
            // 방향 그래프의 모든 간선 정보를 입력 받기
            for (int i = 0; i < e; i++) {
                int a = sc.nextInt();
                int b = sc.nextInt();
                graph.get(a).add(b);    // 정점 A에서 B로 이동 가능
                // 진입 차수를 1 증가
                indegree[b]++;
            }
    
            topologySort();
            sc.close();
        }
    }
  • 시간 복잡도
    • 노드의 개수 V, 간선의 개수 E 일 때, O(V+E)O(V+E)
    • 차례대로 모든 노드를 확인하면서, 해당 노드에서 출발하는 간선을 차례대로 제거해야함
      ⇒ 노드와 간선을 모두 확인하게 됨

profile
조금 느릴게요~

0개의 댓글