본문 바로가기

(6)

8. 위상 정렬

공부할 내용 서로소 집합 신장 트리 크루스칼 알고리즘 위상 정렬 공부 방법 기타 그래프 이론 - 동빈나 영상 시청 및 정리 이것이 코딩테스트다 책 참고 추천문제 풀기 (블로그에 문제 풀이를 게시하지는 않겠음) 위상 정렬 사이클이 없는 방향 그래프의 모든 노드를 방향성에 거스르지 않도록 순서대로 나열하는 것을 의미한다. 예시) 선수과목을 고려한 학습 순서 설정 위 세 과목을 모두 듣기 위한 적절한 학습 순서는? 자료구조 -> 알고리즘 -> 고급 알고리즘 (O) 자료구조 -> 고급 알고리즘 -> 알고리즘 (X) 진입차수와 진출차수 진입차수(Indegree): 특정한 노드로 들어오는 간선의 개수 진출차수(Outdegree): 특정한 노드에서 나가는 간선의 개수 위상 정렬 알고리즘 큐를 이용하는 위상 정렬 알고..

9. 그래프이론 - 신장 트리

공부할 내용 서로소 집합 신장 트리 크루스칼 알고리즘 위상 정렬 (해당 주제는 앞에서 정리했기 때문에 따로 정리하지 않아도 된다.) 공부 방법 기타 그래프 이론 - 동빈나 영상 시청 및 정리 이것이 코딩테스트다 책 참고 추천문제 풀기 (블로그에 문제 풀이를 게시하지는 않겠음) 신장 트리 그래프에서 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프를 의미한다. 모든 노드가 포함되어 서로 연결되면서 사이클이 존재하지 않는다는 조건은 트리의 조건이기도 하다. 원본 그래프에 존재하는 간선을 모두 활용하지 않고 일부 간선만 활용하여 모든 노드가 포함되어있는 하나의 부분 그래프를 만드는 것이다. 신장트리는 모든 노드가 다 연결되어있지만, 일부 간선을 사용하지 않아도 괜찮다는 점에서 실제 문제 상황에서도 이..

[알고리즘] GCD 최대공약수, LCM 최소공배수

오늘은 최대공약수 알고리즘을 구현 및 사용하는 방법에 대해 알아보자! GCD는 Greatest Common Divisor로 최대 공약수이다. 최대공약수는 두 수의 공통 약수중 최댓값을 말합니다. (약수는 나누어서 0 이 되는 수를 말한다) 이렇게 나누어서 0 이 되는 수중 공통적으로 들어가 있으며, 최댓값을 찾는 것입니다. 자바에서는 BigInteger 클래스에 최대공약수를 구할 수 있는 gcd() 함수를 제공합니다. 함수를 이용해서 구하는 방법과 직접 함수를 만드는 방법에 대해 알아 본다. 1. BigInteger 내장 함수를 사용 private static int gcdThing(int a, int b) { BigInteger b1 = BigInteger.valueOf(a); BigInteger b2..

[알고리즘] 순열(Permutation)

참고: https://gorakgarak.tistory.com/522 순열(Permutation) 알고리즘 1,2,3 와 같은 숫자들이이 있다. 이것을 중복하지 않고 순서를 끌어내는 방법을 생각해보자 1-2-3 1-3-2 2-1-3 2-3-1 3-1-2 3-2-1 여섯가지 방법이 존재한다. 이제 숫자 네개인 1,2,3,4를 한번 섞어본다. 1-2-3-4.. gorakgarak.tistory.com 프로그래머스의 코딩 테스트 문제 중에서 완적 탐색 기법 lever2 단계인 '소수 찾기'를 풀다가 '순열'의 개념이 필요하게 되었다. 직접 구현해보려고 했으나, 쉽지않아서 구글링을 통해서 학습하였다. (알고리즘이 직관적으로 이해하기가 쉽지 않아 꽤 오랜 시간 손 코딩을 하면서 따라가며 이해하였다) 참고: htt..

[알고리즘] 너비 우선 탐색 (BFS, Breadth First Search)

참고: https://blog.naver.com/ndb796/221230944971 15. 너비 우선 탐색(BFS) 너비 우선 탐색(Breadth First Search, BFS)은 탐색을 할 때 너비를 우선으로 하여 탐색을 수행하는 ... blog.naver.com 너비 우선 탐색 (BFS)은 탐색을 할 때, 너비를 우선으로 하여 탐색을 수행하는 알고리즘이다. 특히 '맹목적인 탐색'을 하고자 할 때 사용할 수 있는 탐색 기법이다. 너비 우선 탐색은 최단경로를 찾아준다는 점에서 최단길이를 보장해야 할 때 많이 사용한다. (DFS가 스택을 사용했다면, BFS는 큐(Queue)를 이용한다. * 너비 우선 탐색이란 ? 루트노드(또는 다른 임의의 노드)에서 시작해서 인접한 노드를 먼저 탐색하는 방법이다. 시작 ..

[알고리즘] 깊이 우선 탐색 ( DFS, Depth-First Search)

참고: https://gmlwjd9405.github.io/2018/08/14/algorithm-dfs.html [알고리즘] 깊이 우선 탐색(DFS)이란 - Heee's Development Blog Step by step goes a long way. gmlwjd9405.github.io 깊이 우선 탐색은 탐색을 함에 있어서 보다 깊은 것을 우선적으로 탐색하는 알고리즘이다. 조금 쉽게 얘기하자면 루트노드(또는 임의의 노드)에서 시작해서 다음 분기 (branch)로 넘어가기 전에 해당 분기를 완벽하게 탐색하는 방법이다. 이러한 깊이 우선 탐색은 맹목적으로 각 노드를 탐색할 때 주로 사용된다. 너비 우선 탐색(BFS)에서는 큐(Queue)가 사용되었다면, 깊이 우선 탐색에서는 스택(Stack)이 사용된다...

알고리즘

8. 위상 정렬

728x90

공부할 내용

  1. 서로소 집합
  2. 신장 트리
    • 크루스칼 알고리즘
  3. 위상 정렬

공부 방법

위상 정렬

  • 사이클이 없는 방향 그래프의 모든 노드를 방향성에 거스르지 않도록 순서대로 나열하는 것을 의미한다.
  • 예시) 선수과목을 고려한 학습 순서 설정

image

  • 위 세 과목을 모두 듣기 위한 적절한 학습 순서는?
    • 자료구조 -> 알고리즘 -> 고급 알고리즘 (O)
    • 자료구조 -> 고급 알고리즘 -> 알고리즘 (X)

진입차수와 진출차수

  • 진입차수(Indegree): 특정한 노드로 들어오는 간선의 개수
  • 진출차수(Outdegree): 특정한 노드에서 나가는 간선의 개수

image

위상 정렬 알고리즘

  • 를 이용하는 위상 정렬 알고리즘의 동작 과정은 다음과 같다.
    1. 진입차수가 0인 모든 노드를 큐에 넣는다.
    2. 큐가 빌 때까지 다음의 과정을 반복한다.
      1) 큐에서 원소를 꺼내 해당 노으데서 나가는 간선을 그래프에서 제거한다.
      2) 새롭게 진입차수가 0이 된 노드를 큐에 넣는다.

결과적으로 각 노드가 큐에 들어온 순서가 위상 정렬을 수행한 결과와 같다.

위상 정렬 동작 예시

  • 위상 정렬을 수행할 그래프를 준비한다.
    • 이때 그래프는 사이클이 없는 방향 그래 (DAG)여야 한다.**
    • 만약 사이클이 존재한다면, 그 사이클에 포함되어있는 모든 노드는 진입차수가 1이 된다.
      • 즉 사이클에 포함되어있는 모든 노드는 큐에 들어갈 수 없기 때문에 위상정렬을 수행할 수 없다.

image

  • [초기 단계] 초기 단계에서는 **진입차수가 0인 모든 노드를 큐에 넣는다.

    • 처음에 노드 1이 큐에 삽입된다.
      image
      (노드2 -> 노드6 으로 가는 화살표를 실수로 생략했습니다. 있다고 가정해주세요!)
  • [Step 1] 큐에서 **노드 1을 꺼낸 뒤에 노드 1에서 나가는 간선을 제거한다.

    • 새롭게 진입차수가 0이 된 노드들을 큐에 삽입한다.

image

  • [Step 2] 큐에서 **노드 2를 꺼낸 뒤에 노드 2에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드들을 큐에 삽입한다.
    • 동시에 여러 노드가 큐에 들어갈 수도 있는데, 사실 어떤 노드가 먼저 들어가든 크게 상관없다.

image

  • [Step 3] 큐에서 **노드 5를 꺼낸 뒤에 노드 5에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드를 큐에 삽입한다.

image

  • [Step 4] 큐에서 **노드 3를 꺼낸 뒤에 노드 3에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드가 없으므로 그냥 넘어간다.

image

  • [Step 5] 큐에서 **노드 6를 꺼낸 뒤에 노드 6에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드를 큐에 삽입한다.

image

  • [Step 6] 큐에서 **노드 4를 꺼낸 뒤에 노드 4에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드를 큐에 삽입한다.

image

  • [Step 7] 큐에서 **노드 7를 꺼낸 뒤에 노드 7에서 나가는 간선을 제거한다.
    • 새롭게 진입차수가 0이 된 노드가 없으므로 그냥 넘어간다.

image

  • **[위상 정렬 결과]
    • 큐에 삽입된 전체 노드 순서: 1 -> 2 -> 5 -> 3 -> 6 -> 4 -> 7

위상 정렬의 특징

  • 위상 정렬은 DAG에 대해서만 수행할 수 있다.
    • DAG(Direct Acyclic Graph): 순환하지 않는 방향 그래프
  • 위상 정렬에서는 여러 가지 답이 존재할 수 있다.
    • 한 단계에서 큐에 새롭게 들어가는 원소가 2개 이상인 경우가 있다면 여러 가지 답이 존재한다.
  • 모든 원소를 방문하기 전에 큐가 빈다면 사이클이 존재한다고 판단할 수 있다.
    • 사이클에 포함된 원소 중에서 어떠한 원소도 큐에 들어가지 못한다.
  • 스택을 활용한 DFS를 이용해 위상 정렬을 수행할 수도 있다.

위상 정렬 알고리즘

// 위상정렬
public class TopologicalSorting {

    // 노드의 개수(V)와 간선의 개수(E)
    // 노드의 개수는 최대 100,000개라고 가정
    private static int v, e;

    // 모든 노드에 대한 진입차수는 0으로 초기화
    private static int[] indegree = new int[100001];

    // 각 노드에 연결된 간선 정보를 담기 위한 연결 리스트 초기화
    private static List<List<Integer>> graph = new ArrayList<>();

    // 위상 정렬 함수
    public static void topologySort() {
        List<Integer> result = new ArrayList<>(); // 알고리즘 수행 결과를 담을 리스트
        Deque<Integer> q = new ArrayDeque<>();

        // 처음 시작할 때는 진입차수가 0인 노드를 큐에 삽입
        for (int i = 1; i <= v; i++) {
            if (indegree[i] == 0) {
                q.offer(i);
            }
        }

        // 큐가 빌 때까지 반복
        if (!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) + " ");
        }
        System.out.println();
    }

    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<>());
        }

        // 방향 그래프의 모든 간선 정보를 입력 받기
        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();
    }
}

위상 정렬 알고리즘 성능 분석

  • 위상 정렬을 위해 차례대로 모든 노드를 확인하며 각 노드에서 나가는 간선을 차례대로 제거해야 한다.
    • 위상 정렬 알고리즘의 시간 복잡도는 O(V + E) 이다.
728x90
알고리즘

9. 그래프이론 - 신장 트리

728x90

공부할 내용

  1. 서로소 집합
  2. 신장 트리
    • 크루스칼 알고리즘
  3. 위상 정렬 (해당 주제는 앞에서 정리했기 때문에 따로 정리하지 않아도 된다.)

공부 방법

신장 트리

  • 그래프에서 모든 노드를 포함하면서 사이클이 존재하지 않는 부분 그래프를 의미한다.
    • 모든 노드가 포함되어 서로 연결되면서 사이클이 존재하지 않는다는 조건은 트리의 조건이기도 하다.
    • 원본 그래프에 존재하는 간선을 모두 활용하지 않고 일부 간선만 활용하여 모든 노드가 포함되어있는 하나의 부분 그래프를 만드는 것이다.
      image

image

신장트리는 모든 노드가 다 연결되어있지만, 일부 간선을 사용하지 않아도 괜찮다는 점에서 실제 문제 상황에서도 이런 아이디어가 효과적으로 사용될 수 있는 경우가 존재한다.

최소 신장 트리

  • 최소한의 비용으로 구성되는 신장 트리를 찾아야 할 때 어떻게 해야할까?
  • 예를 들어 N개의 도시가 존재하는 상황에서 두 도시 사이에 도로를 놓아 전체 도시가 서로 연결될 수 있게 도로를 설치하는 경우를 생각해보자.
    • 두 도시 A, B를 선택했을 때 A에서 B로 이동하는 경로가 반드시 존재하도록 도로를 설치한다.

image

크루스칼 알고리즘

  • 대표적인 최소 신장 트리 알고리즘이다.
  • 그리디 알고리즘으로 분류된다.
  • 구체적인 동작 과정은 다음과 같다.
    1. 간선 데이터를 비용에 따라 오름차순으로 정렬한다.
    2. 간선을 하나씩 확인하며 현재의 간선이 사이클을 발생시키는지 확인한다.
      1) 사이클이 발생하지 않는 경우 최소 신장 트리에 포함시킨다.
      2) 사이클이 발생하는 경우 최소 신장 트리에 포함시키지 않는다.
    3. 모든 간선에 대하여 2번의 과정을 반복한다.

ex. 아래와 같이 7개의 노드가 존재하는 경우를 가정하자.

  • [초기 단계] 그래프의 모든 간선 정보에 대하여 **오름차순 정렬을 수행한다.

지금 예시의 테이블은 가독성을 위해 간선에 따라서 나열했지만, 실제 크루스칼 알고리즘은 비용을 오름차순으로 정렬하는 작업이 우선적으로 수행되어야한다.
image

참고로 최종적으로 만들어지는 최소신장트리에 포함되어있는 간선의 갯수는 전체 노드의 개수 - 1이다. 이는 기본적으로 트리가 가지는 특징이고 사이클 또한 존재하지 않는다는 특징이 있다.

  • [Step 1] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (3, 4)를 선택하여 처리한다.

image

  • [Step 2] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (4, 7)을 선택하여 처리한다.

image

  • [Step 3] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (4, 6)을 선택하여 처리한다.

image

  • [Step 4] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (6, 7)을 선택하여 처리한다.

    이미 같은 집합에 속해있기 때문에 해당 간선은 무시한다.

  • [Step 5] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (1, 2)을 선택하여 처리한다.

image

  • [Step 6] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (2, 6)을 선택하여 처리한다.

image

  • [Step 7] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (2, 3)을 선택하여 처리한다.

    이미 같은 집합에 속해있기 때문에 해당 간선은 무시한다.

  • [Step 8] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (5, 6)을 선택하여 처리한다.

image

  • [Step 9] 아직 처리하지 않은 간선 중에서 가장 짧은 간선인 (1, 5)을 선택하여 처리한다.

    이미 같은 집합에 속해있기 때문에 해당 간선은 무시한다.

  • [알고리즘 수행 결과]

    • 최소 신장 트리에 포함되어 있는 간선의 비용만 모두 더하면, 그 값이 최종 비용에 해당한다.

image

public class Kruskal {
    // 노드의 개수(V)와 간선(Union 연산)의 개수(E)
    // 노드의 개수는 최대 100,000개라고 가정
    private static int v, e;
    private static int[] parent = new int[1000001]; // 부모 테이블 초기화하기

    // 모든 간선을 담을 리스트와, 최종 비용을 담을 변수
    private static List<Edge> edges = new ArrayList<>();
    private static int result = 0;

    // 특정 원소가 속한 집합을 찾기
    private static int findParent(int x) {
        // 루트 노드가 아니라면, 루트 노드를 찾을 때까지 재귀적으로 호출
        if (x != parent[x]) {
            parent[x] = findParent(parent[x]);
        }

        return parent[x];
    }

    // 두 원소가 속한 집합을 합치기
    private static void unionParent(int a, int b) {
        a = findParent(a);
        b = findParent(b);
        if (a < b) {
            parent[b] = a;
            return;
        }

        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)) {
                continue;
            }

            unionParent(a, b);
            result += cost;
        }

        System.out.println(result);
    }

    static 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 distance;
        }

        public int getNodeA() {
            return nodeA;
        }

        public int getNodeB() {
            return nodeB;
        }

        // 거리(비용)가 짧은 것이 높은 우선순위를 가지도록 설정
        @Override
        public int compareTo(Edge other) {
            if (this.distance < other.distance) {
                return -1;
            }
            return 1;
        }
    }
}

크루스칼 알고리즘 성능 분석

  • 크루스칼 알고리즘은 간선의 개수가 E개일 때, O(ElogE)의 시간 복잡도를 가진다.
  • 크루스칼 알고리즘에서 가장 많은 시간을 요구하는 곳은 간선을 정렬을 수행하는 부분이다.
    • 표준 라이브러리를 이용해 E개의 데이터를 정렬하기 위한 시간 복잡도는 O(ElogE)이다.
728x90
알고리즘

[알고리즘] GCD 최대공약수, LCM 최소공배수

728x90

오늘은 최대공약수 알고리즘을 구현 및 사용하는 방법에 대해 알아보자!

GCD는 Greatest Common Divisor로 최대 공약수이다. 

최대공약수는 두 수의 공통 약수중 최댓값을 말합니다. (약수는 나누어서 0 이 되는 수를 말한다) 이렇게 나누어서 0 이 되는 수중 공통적으로 들어가 있으며, 최댓값을 찾는 것입니다. 자바에서는 BigInteger 클래스에 최대공약수를 구할 수 있는 gcd() 함수를 제공합니다. 함수를 이용해서 구하는 방법과 직접 함수를 만드는 방법에 대해 알아 본다.

1. BigInteger 내장 함수를 사용

private static int gcdThing(int a, int b) {

        BigInteger b1 = BigInteger.valueOf(a);
        BigInteger b2 = BigInteger.valueOf(b);
        BigInteger gcd = b1.gcd(b2);
        
        return gcd.intValue();
    }

2. 직접 구현

gcd 함수를 직접 구현하는 방법은 호제법을 이용한 방법이다. 
호제법이 두수를 나누어서 나온 나머지를 이전 나머지에 다시 나누고, 그렇게 0 이 나올 때까지 반복한 후 중지한다. 중지했을 때, 바로 직전의 나머지가 최대공약수가 되는 것이다. 

예를 들어 192와와 72의의 최대공약수를 구한다고 합시다. 아래 처럼 3번째 단계가 되면 나머지가 0 이죠. 그때 이전 몫인 24가 최대공약수가 되는 것입니다.

1. 192 = 72 x 2 + 48

2. 72 = 48 x 1 + 24

3. 48 = 24 x 2 + 0

이 호제법을 이용해서 직접 gcd 구현하는 방법에는 두 가지가 있다.

(1) 재귀 문을 이용

private static int gcd(int a, int b){
        if(b == 0) return a;
        return gcd(b, a % b);
    }

(2) 반복문을 이용

private static int gcd(int a, int b) {
        int tmp, n;

        //a에 큰 값을 위치시키기 위한 조건이다.
        if (a < b) {
            tmp = a;
            a = b;
            b = tmp;
        }

        //b가 0 될때까지(a%b), 반복문을 돌게되고, b가 0인 순간의 a를 GCD로 판단하고 리턴한다
        while (b != 0) {
            n = a % b;
            a = b;
            b = n;
        }
        return a;
    }

 

같은 기능을 수행하기 때문에 코드의 간결성을 위해서 BigInteger의 내장 함수 혹은 재귀 문을 이용하여 직접 구현하는 방법이 효율적이라고 판단된다. 

 

최소 공배수의 경우 두 수를 곱하고 그 값에 최대공약수를 단순히 나눠주면 구할 수 있다. 

최소공배수 LCM (Least Common Multiple)는 gcd() 함수를 이용하여 간단하게 구할 수 있다.

    public static int lcm(int x, int y) {

        //0이 아닌 두 수의 곱 / 두 수의 최대공약수
        return (x * y) / gcd(x, y);
    }
728x90
알고리즘

[알고리즘] 순열(Permutation)

728x90

참고: https://gorakgarak.tistory.com/522

 

순열(Permutation) 알고리즘

1,2,3 와 같은 숫자들이이 있다. 이것을 중복하지 않고 순서를 끌어내는 방법을 생각해보자 1-2-3 1-3-2 2-1-3 2-3-1 3-1-2 3-2-1 여섯가지 방법이 존재한다. 이제 숫자 네개인 1,2,3,4를 한번 섞어본다. 1-2-3-4..

gorakgarak.tistory.com

 

프로그래머스의 코딩 테스트 문제 중에서 완적 탐색 기법 lever2 단계인 '소수 찾기'를 풀다가 '순열'의 개념이 필요하게 되었다.
직접 구현해보려고 했으나, 쉽지않아서 구글링을 통해서 학습하였다. (알고리즘이 직관적으로 이해하기가 쉽지 않아 꽤 오랜 시간 손 코딩을 하면서 따라가며 이해하였다) 

참고: https://jar100.tistory.com/16

 

프로그래머스 알고리즘 : 소수 찾기 (java)

프로그래머스 알고리즘 (java) 소수 찾기 프로그래머스 (소수 찾기) 코드 리뷰 각 종이 조각에 적힌 숫자가 적힌 문자열 numbers가 주어졌을 때, 종이 조각으로 만들 수 있는 소수가 몇 개인지 return 하는 문제입..

jar100.tistory.com

 

일반적으로 순열을 만들 때 ex. 1,2,3,4 

  • 1-2-3-4
  • 1-2-4-3
  • 1-3-2-4
  • 1-3-4-2
  • 1-4-2-3
  • 1-4-3-2
  • 2-1-3-4
  • .....

이렇게 숫자를 뒤에서부터 섞으면(= 순서를 변경), 순열의 모든 경우를 구할수 있다. 그렇다면 이를 알고리즘으로 어떻게 구현할 것인가?

위의 그림을 보자, 위 그림은 ABC의 알파벳을 재귀적으로 푸는 방법이다. ABC 중 첫번째 알파벳을 뭘로 할 것인가. 종류는 세 가지밖에 없다. 처음을 배열은 ABC 순서로 정해져 있다.

  • A
  • B
  • C

위와같은 경우에서 첫 번째가 A인 경우는 첫 번째[0] 인자인 A를 첫 번째[0] 인자 A와 바꾼 것이나 다름이 없다. (실질적으로는 바뀌는 건 없다)

첫 번째 인자가 B로 설정되어있는 경우는 첫 번째[0] 인자인 A를 두 번째[1] 인자 B와 바꾼 것이다.

세 번째 인자가 C로 설정되어있는 경우는 첫 번째[0] 인자인 A를 세 번째[2] 인자 C와 바꾼 것이다.

첫 번째[0] 인자는 이렇게 바뀌고 있다.

  • [0] <-> [0]
  • [0] <-> [1]
  • [0] <-> [2]

이제 첫번째 인자가 고정되었으니, 두 번째 인자와 세 번째 인자에 대해 고민을 할 필요가 있다. 위와 정확히 마찬가지로 같다.

(첫 번째[0] 인자가 [0]으로 고정된 경우)

  • [0][1] <-> [0][1]   즉, 두 번째 인자[1]인 B와 두번째 인자[1]인 B를 바꾼다. (실질적으로 바뀌는건 없다) ABC
  • [0][1] <-> [0][2]  두번째 인자[1]인 B와 두번째 인자[2]인 C를 바꾼다. ACB

이제 마지막[2] 인자를 살펴본다.

  • [0][1] <-> [0][1]의 경우에 --> [0][1][2] <-> [0][1][2]  (바뀌는 건 없다) ABC
  • [0][1] <-> [0][2]의 경우에 --> [0][1][2] <-> [0][2][1] (바뀌는 건 없다)  ACB

이제 A에 대한 경우를 모두 살펴보았고 첫 번째[0] 인자가 [0] <-> [1] , [0] <-> [2]인 경우도 이런 방향으로 알고리즘을 재귀적으로
짠다면, 그림과 같이 ABC, ACB, BAC, BCA, CBA, CAB 순으로 튀어나오게 될 것이다. (순서대로 나오지 않는다!) 

nPk(고등학교 수학 시간에 nPr을 많이 썼던 거 같지만..)의 순열을 구해야 하는 문제, 즉 n개중 k개로 이루어진 순열을 구하려고 한다. 샘플에서는 1,23,4 네 개로 이루어진 배열을 가지고 4개로 이루어진 순열을 구해보고자 한다.

다음과 같은 시그니처를 사용한다.

perm ( int[] arr , int depth , int n , int k )

배열 arr은 계속해서 데이터를 들고 다니면서 교환 작업을 하고 있는지에 대한 변수이다. 맨 처음 깊이라면 depth = 0의 위치에서 작업하고 있을 것이며, 이는 첫 번째[0]가 첫번째[0] 또는 두 번째[1] 또는 세 번째[2] 또는 네 번째[3] 인자를 교환하는 중이다.
depth는 0에 대한 것을 다 끝마치고 1로 넘어가는 것이 아니라 0,1,2,3,2,3,1,2,3과 같은 형태로 내부적으로 변하고 있다. (물론 프로그램의 시작점에서는 0으로 넣어주어야 한다)

n은 총 배열 안에 들어있는 숫자를 뜻하며 고정값이다. (예제는 1,2,3,4 모두를 사용해 순열을 만드므로 4로 고정된다

k는 몇 개를 뽑아내서 순열을 만들 것인지를 뜻하며 고정값이다. 샘플을 1,2,3,4 모두를 사용해 순열을 만드므로 4로 고정된다.

public void perm(char[] arr, int depth, int n , int k ) {
        if (depth == k) {
            // 한번 depth 가 k로 도달하면 사이클이 돌았음. 출력함.
            print(arr);
            return;
        } else {
            for (int i = depth; i < n; i++) {
                swap(arr, i, depth);
                perm(arr, depth + 1, k, sosuList);
                swap(arr, i, depth);
            }
        }
    }

 

실제적인 코드의 구현은 위와 같다. 일단 depth를 검사해 k와 같으면 더 이상 순열을 만들지 않는다. 우리가 원하는 것은 k개의 숫자가 있는 순열이기 때문이다. 4에 도달하면 출력해야 하는 숫자 네 개가 모두 세팅되었다는 뜻이므로 출력해주면 된다

depth에 따라서 for문의 시작점이 다르다. depth가 0이라면 1 XXX, 2 XXX, 3 XXX, 4 XXX를 뽑아줘야 하므로 총 네 번의 루프가 돌아야 하며, 이는 i < n이라는 조건에서 n은 4로 고정이고 i는 depth에 따라 0부터 시작하기 때문에 0,1,2,3 네 번이 도는 것이다.

다만,

(depth 0) 1 XXX이 만들어지면, (2 XXX를 만드는 것이 아니라)
(depth 1) 2XXX를 만드는 것이 아니라 12XX, 13XX, 14XX를 만들고
(depth 2) 바로 다음으로 123X
(depth 3) 마지막으로 1234가 채워져 지며
(depth 4) 출력한다

perm (arr, depth + 1 , n , k ) 함수에  ( {1,2,3,4} , 4 , 4 , 4 ) 인자가 채워져서 호출된다. 그렇지만, depth는 이제 k와 같으므로 바로 return을 통해, perm문을 끝내고 다음 문장인 swap()을 실행하게 된다. 
마지막으로 의문인 점은 왜 swap 함수를 두 번 쓰는 것인가?인데, 깔끔해 보이지 않음에도, swap() 함수는 꼭 필요하다. 재귀 함수를 쓰면서 perm() 함수에 자꾸 걸려서 뒤쪽 swap() 함수가 드디어 호출된다. 두 번째 swap()은 전 단계의 분기점에서의 배열의 순서를 기억하고 이를 초기화하는 작업에 지나지 않는다. 트리구조에서 depth는 0,1,2,3,2,3,1,2,3.... 이런 식으로 한두 칸씩 뒤로 돌아가면서 다시 계산을 하는데 그 지점에서의 배열 안의 숫자 순서를 기억하고 있어야 한다. swap() 없이 바로 사용하고자 한다면, 분기로 돌아가서 다시 교환을 하는 게 아니라, 이미 다른 분기에서 망가뜨려버린 배열의 순서를 그대로 이용하게 되어, 황당한 순열들이 튀어나오게 된다. 

perm 메서드를 사용하기 위해서는 준비작업적 함수가 두 가지 필요하다.

  1. 배열의 특정 위치를 교환하는 함수 (필수)
  2. 배열을 출력하는 함수 (선택)

배열의 특정위치를 교환하는 함수가 필요한 이유는 , C의 경우에는 포인터가 있어 주소 값을 바로 찾아서 바꿔버릴 수 있어 조금 더 편리하게 값들을 바로 바꿀 수 있는 반면, 다른 언어들은 주소에 직접 접근하지 않고 참조형 변수로 접근하거나, 배열 자체를 전달해 이의 값을 바꾸도록 하기 때문이다. 그렇다고 포인터를 남발하면 남발할수록 프로그램은 파악하기도 어렵고 버그가 자주 나니 지양하는 편이 낫다.

배열의 특정 위치를 교환하는 함수는 swap 메서드이고 다음과 같다. 이렇게 교환을 하면 굳이 포인터를 넘기지 않아도 알아서 배열 안에 있는 값들이 교환된다. (다만 리턴 값이 없기 때문에 이러한 프로그램 방식은 좋지 않다)

public void swap(int[] arr, int i, int j) { 
		int temp = arr[i]; 
  	  	arr[i] = arr[j]; 
  	  	arr[j] = temp; 
    }

 

배열을 출력하는 함수는 그냥 배열을 출력하는 함수이므로 중요하지 않기에 생략하겠다.

최종적인 자바 코드는 아래와 같다.

package algo; public class Permutation { 
	public static void main(String[] args) { 
    	int[] arr = { 1, 2, 3, 4 }; 
        
        perm(arr, 0, 4, 4); 
    } 
        
    public static void perm(int[] arr, int depth, int n, int k) {
        if (depth == k) { // 한번 depth 가 k로 도달하면 사이클이 돌았음. 출력함.
        	print(arr,k); return; 
        } 
        for (int i = depth; i < n; i++) {
        	swap(arr, i, depth);
            perm(arr, depth + 1, n, k);
            swap(arr, i, depth); 
        } 
    } // 자바에서는 포인터가 없기 때문에 아래와 같이, 인덱스 i와 j를 통해 바꿔줌. 
            
    public static void swap(int[] arr, int i, int j) {
            
    	int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
                
    } 
            
    public static void print(int[] arr, int k) { 
    
    	for (int i = 0; i < k; i++) { 
        	
            if (i == k - 1) 
            	System.out.println(arr[i]); 
            else 
            	System.out.print(arr[i] + ","); 
         } 
    } 
}

이러한 순열( 조합 또한)은 코딩 테스트 문제를 풀면서 요구하는 경우가 더러 있었고, 정해진 시간에서 갑자기 떠올리기가 쉽지 않았다. 
지금까지 코딩 테스트를 망했었던 이유들을 분석하면서 이런 알고리즘들을 차례차례 정리해뒀다가 그 원리를 잘 떠올려서 적시적소에 써먹도록 해야겠다.

728x90
알고리즘

[알고리즘] 너비 우선 탐색 (BFS, Breadth First Search)

728x90

참고: https://blog.naver.com/ndb796/221230944971

 

15. 너비 우선 탐색(BFS)

너비 우선 탐색(Breadth First Search, BFS)은 탐색을 할 때 너비를 우선으로 하여 탐색을 수행하는 ...

blog.naver.com

 

너비 우선 탐색 (BFS)은 탐색을 할 때, 너비를 우선으로 하여 탐색을 수행하는 알고리즘이다. 특히 '맹목적인 탐색'을 하고자 할 때 사용할 수 있는 탐색 기법이다. 너비 우선 탐색은 최단경로를 찾아준다는 점에서 최단길이를 보장해야 할 때 많이 사용한다. (DFS가 스택을 사용했다면, BFS는 큐(Queue)를 이용한다.

*  너비 우선 탐색이란 ?
 루트노드(또는 다른 임의의 노드)에서 시작해서 인접한 노드를 먼저 탐색하는 방법이다. 

  • 시작 정점으로부터 가까운 정점을 먼저 방문하고 멀리 떨어져있는 정점을 나중에 방문하는 순회방법
  • 즉 깊게(deep) 탐색하기 전에 넓게(wide) 탐색하는 것
  • 사용하는 경우: 두 노드 사이의 최단 경로 혹은 임의의 경로를 찾고 싶을 때
  • 너비 우선 탐색(BFS)이 깊이 우선 탐색(DFS)보다 좀 더 복잡하다

* 특징

  • 직관적이지 않다 -> 너비 우선 탐색(BFS)은 시작 노드에서 시작해서 거리에 따라 단계별로 탐색한다고 볼 수 있다
  • 너비 우선 탐색(BFS)은 재귀적으로 동작하지 않는다
  • 그래프 탐색의 경우 어떤 노드를 방문했었는지 여부를 반드시 검사해야 한다 -> 이를 검사하지 않으면 무한루프에 빠질 수 있다
  • BFS는 방문한 노드들을 차례로 저장한 후 꺼낼 수 있는 자료 구조인 큐(Queue)를 사용한다
    -> 즉, 선입선출(FIFO) 원칙으로 탐색
    -> 일반적으로 큐를 이용해서 반복적 형태로 구현하는 것이 가장 잘 동작한다

* 너비 우선 탐색(BFS)의 시간 복잡도

  • 인접 리스트로 표현된 그래프: O(N + E)
  • 인접 행렬로 표현된 그래프: O(N^2) 
  • 깊이 우선 탐색(DFS)와 마찬가지로 그래프 내에 적은 숫자의 간선만을 가지는 희소 그래프(Sparse Graph)의 경우
    인접 행렬보다 인접 리스트를 사용하는 것이 좋다

* BFS 구현 알고리즘

package queue;

import java.util.Iterator;
import java.util.LinkedList;

//인접 리스트를 이용한 방향성 있는 그래프 클래스
public class Graph {
    private int v; //노드의 개수
    private LinkedList<Integer> adj[]; //인접 리스트

    //생성자
    Graph(int v) {
        this.v = v;
        adj = new LinkedList[v];
        for (int i = 0; i < v; i++) //인접 리스트 초기화
            adj[i] = new LinkedList();
    }

    // 노드를 연결 v -> w
    void addEdge(int v, int w) {
        adj[v].add(w);
    }

    // s를 시작 노드 한 BFS로 탐색하면서 탐색한 노드들을 출력
    void BFS(int s) {
        // 노드의 방문 여부 판단 (초기값: false)
        boolean visted[] = new boolean[v];
        // BFS 구현을 위한 큐(Queue) 생성
        LinkedList<Integer> queue = new LinkedList<>();

        // 현재 노드를 방문한 것으로 표시하고 큐에 삽입(enqueue)
        visted[s] = true;
        queue.add(s);

        // 큐(Queue)가 빌 때 까지 반복
        while (queue.size() != 0) {
            // 방문한 노드를 큐에서 추출(dequeue)하고 값을 출력
            s = queue.poll();
            System.out.print(s + " ");

            // 방문한 노드와 인접한 모든 노드를 가져온다.
            Iterator<Integer> i = adj[s].listIterator();
            while (i.hasNext()) {
                int n = i.next();
                // 방문하지 않은 노드면 방문한 것으로 표시하고 큐에 삽입(enqueue)
                if (!visted[n]) {
                    visted[n] = true;
                    queue.add(n);
                }
            }
        }
    }
}
 //사용방법 --> Graph 클래스 객체 사용
 
    public static void main(String[] args) {

        Graph g = new Graph(4);

        g.addEdge(0,1);
        g.addEdge(0,2);
        g.addEdge(1,2);
        g.addEdge(2,0);
        g.addEdge(2,3);
        g.addEdge(3,3);

        g.BFS(2); //주어진 노드를 시작 노드로 BFS 탐색

    }
728x90
알고리즘

[알고리즘] 깊이 우선 탐색 ( DFS, Depth-First Search)

728x90

참고: https://gmlwjd9405.github.io/2018/08/14/algorithm-dfs.html

 

[알고리즘] 깊이 우선 탐색(DFS)이란 - Heee's Development Blog

Step by step goes a long way.

gmlwjd9405.github.io

 

깊이 우선 탐색은 탐색을 함에 있어서 보다 깊은 것을 우선적으로 탐색하는 알고리즘이다.
조금 쉽게 얘기하자면 루트노드(또는 임의의 노드)에서 시작해서 다음 분기 (branch)로 넘어가기 전에
해당 분기를 완벽하게 탐색하는 방법이다. 이러한 깊이 우선 탐색은 맹목적으로 각 노드를 탐색할 때 주로 사용된다. 
너비 우선 탐색(BFS)에서는 큐(Queue)가 사용되었다면, 깊이 우선 탐색에서는 스택(Stack)이 사용된다. 
(사실 컴퓨터는 구조적으로 항상 스택의 원리를 사용하기 때문에, 스택을 사용하지 않아도 구현이 가능하다) 

깊이 우선 탐색은 탐색기법 자체로는 큰 의미가 없고 이 탐색법을 활용해서 문제를 해결하고 하는 것에 초점을 둔다. (작동원리에 대해서만 빠삭하게 이해하면된다)

 

●  DFS의 특징 

  • 사용하는 경우: 모든 노드를 방문하고자 하는 경우에 이 방법을 선택
  • 깊이 우선 탐색이 너비 우선 탐색보다 좀 더 간단하다
  • 단순 검색속도 자체는 너비 우선탐색에 비해서 느리다 
  • 자기 자신을 호출하는 순환 알고리즘의 형태를 가지고있다 (재귀)

전위 순회(Pre-OrderTraversals)를 포함한 다른형태의 트리 순회는 모두 DFS의 한 종류이다. 

전위 순회 개념 (출처: 위키피아)

이 알고리즘을 구현할 때 가장 큰 차이점은, 그래프 탐색의 경우 '어떤 노드를 방문했었는지 여부'를 반드시 검사해야한다. (이를 검사하지 않으면 무한루프에 빠질 위험성이 생긴다)

●  깊이 우선 탐색(DFS)의 구현 

  1. 순환 호출 이용
  2. 명시적인 스택 이용
    · 명시적인 스택을 사용하여 방문한 정점들은 스택에 저장하였다가 다시 꺼내서 작업한다 

● 깊이 우선 탐색(DFS)의 시간 복잡도 

  • DFS는 그래프(정점의 수: N, 간선의수: E)의 모든 간선을 조회한다
    * 인접 리스트로 표현된 그래프: O(N + E)
    * 인접 행렬로 표현된 그래프: O(N^2)
  • 즉 그래프 내에 적은 숫자의 간선만을 가지는 희소 그래프(Spare Graph)의 경우 인접 행렬보다 인접 리스트를 사용하는 것이 유리하다

● 순환 호출을 이용한 DFS구현

package stack;

import java.util.Iterator;
import java.util.LinkedList;

// 인접 리스트를 이용한 방향성 있는 그래프 클래스
public class Graph {
    private int v; // 노드의 개수
    private LinkedList<Integer> adj[]; // 인접 리스

    Graph(int v) {
        this.v = v;
        adj = new LinkedList[v];
        // 인접 리스트 초기화
        for (int i = 0; i < v; i++)
            adj[i] = new LinkedList<>();
    }

    // 노드를 연결 v -> w
    void addEdge(int v, int w) {
        adj[v].add(w);
    }

    // DFS에 의해 사용되는 함수
    void DFSUtil(int v, boolean[] visited) {
        // 현재 노드를 방문한 것으로 표시하고 값을 출력
        visited[v] = true;
        System.out.print(v + " ");

        // 방문한 노드와 인접한 모든 노드를 가져온다
        Iterator<Integer> i = adj[v].listIterator();
        while (i.hasNext()) {
            int n = i.next();
            // 방문하지 않은 노드면 해당 노드를 시작 노드로 다시 DFSUtil 호출(재귀)
            if (!visited[n])
                DFSUtil(n, visited);
        }
    }

    // 주어진 노드를 시작 노드로 DFS 탐색
    void DFS(int v) {
        // 노드의 방문 여 판단 (초깃: false)
        boolean visited[] = new boolean[this.v];

        // v를 시작 노드로 DFSUtil 순환 호출
        DFSUtil(v, visited);
    }

    // DFS 탐색
    void DFS() {
        // 노드의 방문 여부 판단 (초깃값: false)
        boolean visited[] = new boolean[this.v];

        // 비연결형 그래프의 경우, 모든 정점을 하나씩 방문
        for (int i = 0; i < this.v; i++) {
            if (visited[i] == false)
                DFSUtil(i, visited);


        }
    }
}

 

 public static void main(String[] args) {
        Graph g = new Graph(4);

        g.addEdge(0, 1);
        g.addEdge(0, 2);
        g.addEdge(1, 2);
        g.addEdge(2, 0);
        g.addEdge(2, 3);
        g.addEdge(3, 3);

        g.DFS(2); // 주어진 노드를 시작 노드로 DFS 탐색
        g.DFS(); // 비연결형 그래프의 경우
        
    }
728x90