[인증평가(5차) 기출] 업무 처리 문제 보러가기

 

Softeer

연습문제를 담을 Set을 선택해주세요. 취소 확인

softeer.ai

 

문제는 위 링크에 접속하면 직접 문제를 풀어보실 수 있습니다!

 

문제 설명

어떤 부서의 업무 조직은 완전이진트리 모양이다. 즉, 부서장이 루트이고 부서장 포함 각 직원은 왼쪽과 오른쪽의 부하 직원을 가진다. 부하 직원이 없는 직원을 말단 직원이라고 부른다.

모든 말단 직원은 부서장까지 올라가는 거리가 동일하다. 조직도 트리의 높이는 H이다. 아래는 높이가 1이고 업무가 3개인 조직도를 보여준다.

 

업무는 R일 동안 진행된다. 처음에 말단 직원들만 각각 K개의 순서가 정해진 업무를 가지고 있다. 각 업무는 업무 번호가 있다. 각 날짜에 남은 업무가 있는 경우, 말단 직원은 하나의 업무를 처리해서 상사에게 올린다. 다른 직원들도, 대기하는 업무가 있는 경우 업무를 올라온 순서대로 하나 처리해서 상사에게 올린다. 단, 홀수 번째 날짜에는 왼쪽 부하 직원이 올린 업무를, 짝수 번째 날짜에는 오른쪽 부하 직원이 올린 업무를 처리한다.

부서장이 처리한 일은 완료된 것이다. 업무를 올리는 것은 모두 동시에 진행한다. 따라서 그날 올린 업무를 상사가 처리하는 것은 그 다음날에야 가능하다.

부서의 조직과 대기하는 업무들을 입력 받아 처리가 완료된 업무들의 번호의 합을 계산하는 프로그램을 작성하라.

제약조건

1 ≤ H ≤ 10
1 ≤ K ≤ 10
1 ≤ R ≤ 1,000

입력형식

첫 줄에 조직도의 높이 H, 말단에 대기하는 업무의 개수 K, 업무가 진행되는 날짜 수 R이 주어진다.

그 다음 줄부터 각각의 말단 직원에 대해 대기하는 업무가 순서대로 주어진다.

제일 왼쪽의 말단 직원부터 순서대로 주어진다.

출력형식

완료된 업무들의 번호 합을 정수로 출력한다.

입력예제1
1 1 1
1
2
출력예제1
0
입력예제2
1 3 2
9 3 7
5 11 2
출력예제2
5

👨🏻‍💻 생각해보기

1. 말단 직원과 상사의 남은 일은 다르게 처리되어야 할 거 같다는 생각을 했다.

  • 말단 직원은 그냥 자신에게 남겨져있는 일을 하루에 하나씩 상사에게 올려주기만 하면된다.
  • 상사는 왼쪽 직원이 올려준 일, 오른쪽 직원이 올려준 일을 분류해서 처리해야 한다.
    • 날짜가 1일이라면 홀수번째(왼쪽) 직원이 준 일을 처리해서 상사에게 올려야한다.
    • 날짜가 2일이라면 짝수번째(오른쪽) 직원이 준 일을 처리해서 상사에게 올려야한다.

2. 들어온 일의 순서대로 처리되어야 하고, 말단과 상사를 구분해야 한다?

  • 들어온 일을 순서대로 처리하기 위해서는 Queue를 사용하기로 했다.
  • 직원 객체를 만들어서 사용했고, 생성자에서 말단과 상사를 구분하도록 만들었다.
  • 말단이라면 남아있는 일에 대한 Queue를 하나만 만들어준다.
  • 누군가의 상사라면 남아있는 일에 대한 Queue를 두 개(왼쪽, 오른쪽) 만들어준다.

3. R(날짜) 만큼 반복을 진행하면서 로직을 수행한다.

  • 직원 한 명을 뽑는다. (왼쪽 제일 아래 있는 직원 부터 탐색)
  • 뽑은 직원의 상사 직원을 가져온다.
  • 상사 직원 객체에서 오늘의 날짜(R)에 맞는 남아있는 일에 대한 Queue를 가져와서 부하 직원의 남아있는 일에 대한 Queue에서 하나 뽑아서(poll) 상사 직원 객체의 Queue에 offer() 해준다.
  • 위와 같은 로직을 반복하여 직원 번호가 0인 직원한테 처리된 업무 번호들을 answer에 누적하여 더해준다.
    • 직원 번호가 0인 직원은 가장 위에 있는 대장 직원이기 떄문에 문제에서 요구하는 것이 제일 위에 있는 대장 직원에 의해 처리된 업무 번호들을 더해서 출력하는 문제이다.

 

제출 답안

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


public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        String[] HKR = bufferedReader.readLine().split(" ");
        int H = Integer.parseInt(HKR[0]);
        int K = Integer.parseInt(HKR[1]);
        int R = Integer.parseInt(HKR[2]);
        int answer = 0;

        List<Employee> list = new ArrayList<>();
        List<Integer> index = new ArrayList<>();

        for (int i = (int)Math.pow(2, H) - 1; i < (int)Math.pow(2, H + 1) - 1; i++) {
            Employee employee = new Employee(i, false);
            for (String str : bufferedReader.readLine().split(" ")) {
                employee.remainTask.get(0).offer(Integer.valueOf(str));
            }
            list.add(employee);
            index.add(i);
        }

        for (int i = 1; i < R + 1; i++) {
            int size = list.size();
            for (int j = 0; j < size; j++) {
                Employee employee = list.get(j);

                if (employee.getRemainTask(i).isEmpty()) {
                    continue;
                }

                if (employee.employeeNumber == 0) {
                    answer += employee.getRemainTask(i).poll();
                    continue;
                }

                int superiorNumber = employee.getSuperiorNumber();
                Employee superior;

                if (index.contains(superiorNumber)) {
                    superior = list.get(index.indexOf(superiorNumber));
                } else {
                    superior = new Employee(superiorNumber, true);
                    list.add(superior);
                    index.add(superior.employeeNumber);
                }

                if (employee.employeeNumber % 2 == 0) {
                    superior.remainTask.get(1).offer(employee.getRemainTask(i).poll());
                } else {
                    superior.remainTask.get(0).offer(employee.getRemainTask(i).poll());
                }
            }
        }

        System.out.println(answer);
    }
}

class Employee {
    int employeeNumber;
    boolean isSuperior;
    List<Queue<Integer>> remainTask;

    public Employee(int employeeNumber, boolean isSuperior) {
        this.employeeNumber = employeeNumber;
        this.isSuperior = isSuperior;

        remainTask = new ArrayList<>();
        if (isSuperior) {
            remainTask.add(new LinkedList<>()); // 왼쪽 직원이 올린 일
            remainTask.add(new LinkedList<>()); // 오른쪽 직원이 올린 일
        }
        if (!isSuperior) {
            remainTask.add(new LinkedList<>()); //
        }
    }

    public int getSuperiorNumber() {
        if (employeeNumber % 2 != 0) {
            return (employeeNumber - 1) / 2;
        }
        return (employeeNumber - 2) / 2;
    }

    public Queue<Integer> getRemainTask(int date) {
        if (isSuperior) {
            if (date % 2 != 0) {
                return remainTask.get(0);
            }
            return remainTask.get(1);
        }
        return remainTask.get(0);
    }

    public String toString() {
        return String.valueOf(employeeNumber);
    }
}

 

🤔 FeedBack

결과를 보면 정말...집착으로 풀기도 했고, 코드도 가독성이 많이 떨어지는 건 맞다. 그래도 "맞앗습니다!" 이 글자가 보였을 떄...정말...짜릿했다.

 

처음에 소프티어에 홈페이지에 들어가서 Practice를 누르고 제일 위에 있는거 풀어봐야겠다 하고 시작한 것이... 맨날 프로그래머스 레벨 1, 2 풀다가 소프티어 문제 풀고 있는데 안 풀려서 이거 뭐야 하고 있었는데 난이도가 별 3개였다...

 

이미 시작해버렸고...끝은 봐야되기 때문에 계속해서 문제를 풀었다. 믿기지 않겠지만 3시26분까지 계속 하다가 해결이 안돼서 찝찝하게 누웠고, 자기 전에도 계속 생각했다. 

 

일어나서 생각한대로 코드 쭉 작성해보니깐 웬걸 "런타임 에러" 몇 개 빼고는 모두 정답이 나왔다. 오...이거 예외 하나만 잡아주면 통과될 거 같았다. 예상했던대로 나는 Queue를 사용했기 때문에 poll() 메서드를 사용하는데 있어서 예외가 발생해서 런타임 에러가 발생했던 거 같아서 모든 poll() 메서드 앞에 isEmpty()를 사용해서 예외처리를 해줬더니..통과됐다. 😂

 

📘 스택과 큐 (Stack / Queue)

📖 Stack (스택)

스택 자료구조는 마지막에 들어간 데이터가 제일 먼저 나오는 LIFO(Last In First Out) 구조입니다. 자바에서는 스택을 Stack class로 구현하여 제공하고 있어서 아래와 같이 사용할 수 있습니다.

Stack<Integer> stack = new Stack<>();

스택 활용의 간단한 예시로는 홈페이지의 뒤로가기, 앞으로가기 버튼의 기능과 같습니다.

초기 상태 (현재 있는 페이지는 "구글")
BackStack ["네이버", "다음", "구글"]
ForwardStack []

뒤로가기 버튼을 눌렀을 때 (현재 있는 페이지는 "다음")
BackStack ["네이버", "다음"]
ForwardStack ["구글"]

뒤로가기 버튼을 다시 한번 눌렀을 때 (현재 있는 페이지는 "네이버")
BackStack ["네이버"]
ForwardStack ["구글", "다음"]

📖 Stack Method

  1. empty()
    Stack이 비어있는 상태인지를 알려줍니다. 비어있다면 true를 반환합니다. 주로 while문을 사용하여 스택을 다룰 때 사용합니다.
  2. peek()
    Stack의 가장 마지막에 있는 객체를 반환합니다. peek()는 객체를 보여주기만 할 뿐 스택에서 제거하지 않습니다. 만약 Stack이 비어있다면 EmptyStackException을 발생시킵니다.
  3. pop()
    peek()와 유사하게 가장 마지막에 있는 객체를 반환합니다. 하지만 pop()의 경우 꺼내서 보여준 객체를 Stack에서 제거합니다.
  4. push(Object object)
    Stack에 객체를 저장합니다.
  5. search(Object object)
    Stackd에서 객체 object를 찾아서 위치(int)를 반환합니다. 배열과는 다르게 위치는 0이 아닌 1부터 시작합니다. 만약 찾고자하는 객체가 없다면 -1을 반환합니다.

📖 Queue(큐)

큐 자료구조는 처음에 들어간 데이터가 가장 먼저 나오는 FIFO(First In First Out) 형태의 자료구조 입니다. 자바에서는 Queue를 인터페이스로 정의만 해놓았기 때문에 Queue 인터페이스를 구현한 구현체들 중에서 하나를 골라서 사용해야 합니다.

자주 사용되는 구현체로는 LinkedList와 PriorityQueue가 있습니다. 사용법은 다음과 같습니다.

Queue<Integer> queue1 = new LinkedList<>();
Queue<Integer> queue2 = new PriorityQueue<>();

📖 Queue Method

  1. add(Object object)
    객체를 Queue에 추가합니다. 추가하는데 성공하면 true를 반환하고, 저장공간이 부족할 경우에는 illegalStateException을 발생시킵니다.
  2. peek()
    스택에서와 비슷하게 Queue에서 객체를 읽어서 반환합니다. 이때 Queue에서 객체를 삭제하지는 않습니다.
  3. poll()
    peek()와는 다르게 Queue에서 객체를 꺼내서 반환합니다. 이때 Queue에서 꺼낸 객체는 삭제됩니다. 만약 Queue가 비어있다면 null을 반환합니다.
  4. offer(Object object)
    Queue에 객체를 저장합니다. 저장에 성공하면 true, 저장에 실패하면 false를 반환합니다.
  5. remove()
    Queue에서 객체를 꺼내서 반환합니다. 만약 비어있다면 NoSuchElementException을 발생시킵니다.
  6. element()
    peek()와 기능이 동일합니다. 하지만 만약 Queue가 비어있을 경우에는 NoSuchElementException을 발생시킵니다.

📖 PriorityQueue

Queue 인터페이스의 구현체 중 하나입니다. 저장하는 순서에 상관없이 우선순위가 높은 것부터 꺼내는 특징을 가지고 있습니다. null은 저장할 수 없습니다.

PriorityQueue는 가장 큰 값이나 가장 작은 값을 빠르게 찾을 수 있다는 특징을 가지고 있습니다.

예를 들어 보면 데이터를 3, 4, 1, 7, 8 순서대로 저장했을 때, 우선순위로는 숫자가 가장 작은 1이 제일 먼저 나옵니다. 데이터를 하나씩 뽑아서 확인했을 때 1 → 3 → 4 → 7 → 8 순서대로 나오는 것을 확인할 수 있습니다.

📖 Deque

Queue와 Stack을 합쳐놓은 형태와 유사한 자료구조이다. 즉, 양쪽에서 추가/삭제가 가능하다.

  1. offerLast()
    Queue의 offer(), Stack의 push()와 같은 기능을 가집니다. 객체를 Deque에 저장합니다.
  2. pollLast()
    마지막 데이터를 꺼내서 가져오기 때문에 Stack의 pop()과 같은 기능을 가집니다.
  3. pollFirst()
    첫 번째로 들어간 데이터를 꺼내서 가져오기 때문에 Queue의 poll()과 같은 기능을 가집니다.
  4. peekFirst()
    첫 번째로 들어간 데이터를 반환합니다. 데이터는 사라지지 않기때문에 Queue의 peek()와 같은 기능을 가집니다.
  5. peekLast()
    마지막으로 들어간 데이터를 반환합니다. 데이터는 사라지지 않기 때문에 Stack의 peek()와 같은 기능을 가집니다.

스택과 큐 관련 문제 풀어보기 - 프로그래머스

📘 Greedy Algorithm (그리디 알고리즘)

📖 그리디 알고리즘이란?

미리 정한 기준에 따라서 매번 가장 좋아보이는 답을 선택하는 알고리즘을 말한다. 즉, 다시 말해 백트래킹을 하여 내가 선택한 결과에 대해서 검증하지 않고, 이미 어떤 선택을 했다면 다른 선택의 가능성을 전혀 고려하지 않는다.

그래서 그리디 알고리즘은 속도가 빠르다는 장점이 있지만, 다른 선택의 가능성을 고려하지 않기 때문에 생기는 문제점이 존재한다. 그 문제점은 바로 그리디 알고리즘을 통해 나오는 결과 값이 항상 최적의 값이 될 수 없다는 것이다.

 

📖 그리디 알고리즘의 문제점

예를 들어 노드 1번에서 노드 5번까지 가는 길이 아래 그림 처럼 존재한다고 가정하고, 우리는 가장 최적의 값(가까운 거리)을 선택하면서 직진해 나갈 것이다. 이때 실제 최적의 값과 그리디 알고리즘을 통해 얻은 결과 값이 같은지 한번 확인해보자.

그리디 알고리즘을 사용하면 1번 노드에서 다음 노드로 이동할 때, 가장 가까운 경로인 3번 노드(10)를 선택해서 이동을 할 것이고, 이전에서 2번 노드와 4번 노드를 선택할 가능성을 고려하지 않고 다음 최종 목적지인 5번 노드(200)로 향하게 된다.

그럼 그리디 알고리즘을 사용하여 얻은 결과는 1 → 3 → 5 이고 그 결과 값은 210인 것을 확인할 수 있다.

하지만 실제 최적의 결과를 한번 확인해보면 결과가 105인 1 → 2 → 5 가 최적의 값임을 확인할 수 있다.

위 예시에서 확인할 수 있듯이 그리디 알고리즘에서 얻은 결과가 항상 최적의 값이 나오지는 않는 것을 확인해볼 수 있었다.

📖 그리디 알고리즘을 사용하기 위한 조건

  1. 탐욕 선택 속성 (Greedy Choice Property)위 예시에서 첫 번째 선택(이전 선택)이 이후 선택(두 번째 선택)에 영향을 주게 된다. 그 이유는 최적의 값(경로)이 누적이 돼서 최종 결과를 얻을 수 있기 때문이다.
  2. 탐욕 선택 속성이란 이전 선택이 이후 선택에 영향을 주지 않는 속성을 말한다.
  3. 최적 부분 구조 (Optimal Substructure)위 예시에서 첫 번째 선택(부분 문제)의 결과가 두 번째 선택을 지나 최종 선택으로 갔을 때 결과에 적용되지 않는다.
  4. 첫 번째(부분 문제)에 대한 결과는 최적의 결과 값이 맞지만 두 번째 선택까지 고려했을 때는 오히려 가장 좋지 않은 선택지가 된다.
  5. 최적 부분 구조란 부분 문제의 최적의 결과가 최종 결과에도 그대로 적용될 수 있어야 한다.

이 두 가지 조건을 모두 만족하는지 확인하기 위해서는 대부분 수학적 증명이 함께 있어야 하고, 이는 매우 어렵다. 그래서 보통은 테스트 코드를 먼저 작성해서 원하는대로 동작을 하는지 확인하고, 반례를 1개라도 찾게된다면 그리디 알고리즘을 적용하기 힘들다는 것을 의미한다.

 

📖 그리디 알고리즘 예시

이 문제는 해당 링크에서 발췌해온 문제입니다. 개인적으로 한번에 이해하기에 좋았던 문제였습니다.

  1. 활동 선택(Action Selection)활동 선택 문제는 N개의 활동이 있고 각 활동에는 시작 시간 및 종료 시간이 있을 때, 한 사람이 최대한 많이 할 수 있는 활동(Activity)의 수를 구하는 문제이다.즉, 각각의 활동(Activity)에는 시간이 소요되므로 하나를 선택했다면 그 동안 해당 시간에 다른 Activity를 할 수 없다. 이러한 상황일 때 가장 많은 활동에 참여하려면 어떻게 해야 할까?하지만 여기서 주의해야할 점은 활동 종료 시간이 빠른 활동을 선택하고나서 다음 활동을 선택할 때, 기존에 선택했던 활동 종료 시간(End Time)보다 다음 활동의 시작 시간(Start Time)이 겹치면 안 된다.
     다음 활동의 시작 시간 >= 이전 활동의 종료 시간
    이제 이런 알고리즘을 바탕으로 해서 문제를 해결해보자.
  2. 다시 말해 다음 활동을 선택할 때 다음과 같은 조건을 만족하면 된다.
  3. 가장 많은 활동에 참여하기 위한 알고리즘을 작성하기 위해서는 활동 종료 시간(End Time)이 가장 빠른 것을 위주로 선택을 해야 한다. 활동이 종료되는 시간이 빠른 것을 따라서 선택하다 보면 가장 많은 활동을 할 수 있기 때문이다.
  4. 그리디 알고리즘의 가장 대표적인 예시인 활동 선택(Action Selection) 문제에 대해서 알아보자.

 

  • 활동이 저장되어있는 배열을 정렬하기
    여기서 정렬할 때 주의해야 할 점은 우리는 “활동 종료 시간(End Time)”이 빠른 순서대로 활동을 선택할 것이다. 그럼 End Time을 기준으로 정렬(오름차순)을 하면 된다.

    정렬을 완료하면 위와 같은 배열을 얻을 수 있다. 그럼 이제 그리디 알고리즘을 사용해서 순서대로 데이터를 선택해보자.
  • 그리디 알고리즘을 적용하기
    먼저 활동 d를 가장 먼저 선택하고 나서 다음 활동인 c가 선택될 수 있는 조건에 들어와 있는지 확인한다.

    활동 c가 끝나고 활동 b를 뽑아야 된다. 하지만 여기서 활동 c의 종료시간이 6이고, 활동 b의 시작 시간은 5이다. 그렇다는 것은 활동 c가 종료되고 활동 b를 진행할 수 없다는 것을 말한다. 그럼 다음 활동인 a를 뽑아서 조건을 확인한다.
    활동 f의 시작시간이 활동 a의 종료시간보다 크기 때문에 활동 f는 선택할 수 있다.
  • 같은 방법으로 다음 활동 e를 뽑고, 활동 a의 종료 시간과 비교해본다. 활동 e의 시작시간이 더 작기 때문에 활동 e도 불가능하다. 그럼 다음 활동인 f를 뽑는다.
  • 이전에 했던 활동 c의 종료 시간보다 활동 a의 시작 시간이 더 크기 때문에 활동 a는 선택할 수 있다.
  • 활동 d의 종료 시간은 2이고, 다음 활동인 c의 시작 시간은 3이다. 즉, 활동 d 다음에 활동 c를 진행할 수 있다.
그리디 알고리즘의 최종 결과는 위 그림과 같다. 우리는 활동 `d → c → a → f` 를 선택했고, 최종적으로 4개의 활동을 할 수가 있다.

 

📖 코드로 작성해보기 (Java)

package selfstudy;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class GreedyExample {
    public static void main(String[] args) {
        List<Activity> activities = new ArrayList<>(){{
            add(new Activity("a", 7, 8));
            add(new Activity("b", 5, 7));
            add(new Activity("c", 3, 6));
            add(new Activity("d", 1, 2));
            add(new Activity("e", 6, 9));
            add(new Activity("f", 10, 11));
        }};
        Collections.sort(activities); // 정의해준 compareTo에 따라서 정렬 진행

        for (Activity activity : greedy(activities)) {
            System.out.print(activity.toString() + " ");
        }
                System.out.println();
        System.out.println("최대 " + greedy(activities).size() + "개의 활동을 할 수 있습니다.");
    }

    public static List<Activity> greedy(List<Activity> activities) {
        List<Activity> result = new ArrayList<>();
        int PreviousActivityEndTime = 0;

        // 그리디 알고리즘의 선택 기준을 만족하기 위한 조건에 맞으면 선택
        for (Activity activity : activities) {
            if (activity.startTime >= PreviousActivityEndTime) {
                result.add(activity);
                PreviousActivityEndTime = activity.endTime;
            }
        }
        return result;
    }
}

class Activity implements Comparable<Activity> {
    String activityName;
    int startTime;
    int endTime;

    public Activity(String activityName, int startTime, int endTime) {
        this.activityName = activityName;
        this.startTime = startTime;
        this.endTime = endTime;
    }

    // Activity 객체의 정렬 기준을 설정한다. endTime 기준으로 오름차순 정렬을 진행
    @Override
    public int compareTo(Activity activity) {
        return this.endTime - activity.endTime;
    }

    @Override
    public String toString() {
        return this.activityName;
    }
}

우리가 이미 사전에 정의해뒀던 조건들이 위 코드에 모두 들어간다. 먼저 End Time 기준으로 오름차순 정렬에 대해서 알아보자.

@Override
public int compareTo(Activity activity) {
    return this.endTime - activity.endTime;
}

위 코드를 보면 우리는 List에 Activity라는 객체를 넣어서 정렬을 할 것이고, Comparable 인터페이스를 상속받아 compareTo의 기준을 재정의해줄 것이다.

위 코드대로 내 endTime과 비교 대상으로 들어온 객체의 endTime을 빼서 return 해주면 된다. 여기 까지하면 오름차순 정렬이 완성되고 다음은 다음 활동을 선택하는 기준에 대한 코드를 확인해보자

for (Activity activity : activities) {
    if (activity.startTime >= PreviousActivityEndTime) {
        result.add(activity);
        PreviousActivityEndTime = activity.endTime;
    }
}

정렬된 List인 activities를 for each문을 이용하여 하나씩 뽑아서 확인한다. 뽑아서 이전 활동의 endTime과 같거나 크다면 선택 기준을 만족하고, 선택된 활동을 새로운 List인 result에 넣는다.

위 코드를 실행해보면 다음과 같은 결과를 얻을 수 있다.

우리가 그림으로 그려서 확인해봤던 결과와 동일한 d → c → a → f 가 나오는 것을 확인할 수 있다.

🔎 Reference

알고리즘 - 그리디 알고리즘(Greedy Algorithm)

📘 DFS (Depth First Search)

📖 DFS란?

깊이 우선 탐색이라고 하며, 그래프에서 깊은 부분을 우선적으로 탐색하는 알고리즘입니다.

📖 DFS 동작 방식

  • 스택(Stack) 자료구조를 사용합니다.
    1. 탐색을 시작할 노드를 스택에 넣고, 방문 처리를 진행합니다.
    2. 방문하지 않은 근접 노드가 존재한다면 스택에 넣고, 방문 처리를 진행합니다.
    3. 만약 근접 노드가 모두 방문처리가 되어있다면 해당 노드를 스택에서 제거합니다.
    4. 위 과정을 반복해서 진행하고, 더이상 진행할 수 없을 때 탐색을 종료합니다. (스택이 비어있을 때 종료)

📖 DFS 그림으로 이해하기

위와 같은 그래프가 존재한다고 생각해봅시다. 우리는 1번 부터 탐색을 시작할 것 입니다. 먼저 사용할 Stack과 방문처리용 Visit를 하나 만들어주고, 시작 노드 1을 스택에 넣고 방문처리를 진행합니다.

1과 근접한 노드 2, 3, 4 중에서 가장 근접한 노드 2를 다시 Stack에 넣고 방문 처리를 합니다.

동일한 방법으로 2와 근접한 노드 1, 4, 8 중에서 근접한 노드 4(노드 1은 이미 지나온 노드이기 때문에 제외)를 스택에 넣고 방문처리를 진행하고, 바로 다음으로 4와 근접한 노드 1, 2, 6, 7 중에서 6을 스택에 넣고 방문처리를 진행합니다. 이 과정이 끝나면 아래와 같은 그래프 상태를 확인할 수 있습니다.

(이미 방문처리가 되어 있는 노드는 신경쓰지 않습니다.)

이제 가장 깊은 6까지 탐색을 끝냈기 때문에 6을 Stack에서 제거한 후 4와 근접했던 다음 노드인 7을 Stack에 넣고 방문처리를 합니다.

위와 같은 방법으로 7을 Stack에서 제거 한 후 4와 근접한 노드를 모두 확인합니다. 6과 7모두 방문처리가 되어있기 때문에 4도 Stack에서 제거합니다.

현재 노드는 2이고, 근접한 노드 4와 8 중에서 4는 이미 방문처리가 되어있기 때문에 8을 Stack에 넣고 방문처리하고, 8과 근접한 노드는 9밖에 없기 때문에 9도 이어서 Stack에 넣고 방문처리를 합니다.

9에서는 더이상 근접한 노드가 없고, 8도 근접한 9번을 이미 탐색했고, 2도 모든 근접하는 노드를 탐색했기 때문에 1을 제외한 모든 노드를 Stack에서 제거합니다.

마지막으로 1과 근접한 노드 중에서 아직 방문처리가 되지 않은 3번 노드를 Stack에 넣고 방문처리를 진행하고, 3과 근접한 노드 중에서 방문처리가 되지 않은 5번 노드도 Stack에 넣고 방문처리를 진행합니다.

이제 모든 방문을 마친 5번 노드와 3번 노드 1번 노드 순으로 Stack에서 모두 제거되고, 탐색을 종료합니다.

정리를 해보면 노드 탐색 순서는 1 → 2 → 4 → 6 → 7 → 8 → 9 → 3 → 5 가 됩니다.

DFS 알고리즘은 스택 자료구조를 사용하기 때문에 재귀 함수를 이용하여 구현하면 가독성도 챙기면서 코드를 구현할 수 있습니다.

📖 위 예제를 코드로 구현해보기

  • 재귀함수를 이용해서 DFS를 구현해보기
public class Example {
    // 0은 사용하지 않지만, 0~9까지의 노드 방문처리를 false로 초기화
    public static boolean[] visit = new boolean[10];

    public static int[][] graph = {
            {},           // 0번 노드와 인접한 노드들
            {2, 3, 4},    // 1번 노드와 인접한 노드들
            {1, 4, 8},    // 2번 노드와 인접한 노드들
            {1, 5},       // 3번 노드와 인접한 노드들
            {1, 2, 6, 7}, // 4번 노드와 인접한 노드들
            {3},          // 5번 노드와 인접한 노드들
            {4},          // 6번 노드와 인접한 노드들
            {4},          // 7번 노드와 인접한 노드들
            {2, 9},       // 8번 노드와 인접한 노드들
            {8}           // 9번 노드와 인접한 노드들
    };

    public static void main(String[] args) {
        dfs(1); // dfs 탐색 시작
    }

    public static void dfs(int node) {
        visit[node] = true; // 해당 node를 방문처리 합니다.
        System.out.print(node + " ");

        for (int n : graph[node]) {
            if (!visit[n]) { // 만약 근접한 노드가 방문처리가 되어있지 않은 경우
                dfs(n); // 근접한 노드를 가지고 재귀함수를 실행
            }
        }
    }
}
/* 출력 결과 */
1 2 4 6 7 8 9 3 5
  • stack을 이용해서 DFS 구현해보기
import java.util.Stack;

public class Example {
    // 0은 사용하지 않지만, 0~9까지의 노드 방문처리를 false로 초기화
    public static boolean[] visit = new boolean[10];

    public static int[][] graph = {
            {},           // 0번 노드와 인접한 노드들
            {2, 3, 4},    // 1번 노드와 인접한 노드들
            {1, 4, 8},    // 2번 노드와 인접한 노드들
            {1, 5},       // 3번 노드와 인접한 노드들
            {1, 2, 6, 7}, // 4번 노드와 인접한 노드들
            {3},          // 5번 노드와 인접한 노드들
            {4},          // 6번 노드와 인접한 노드들
            {4},          // 7번 노드와 인접한 노드들
            {2, 9},       // 8번 노드와 인접한 노드들
            {8}           // 9번 노드와 인접한 노드들
    };

    public static void main(String[] args) {
        Stack<Integer> stack = new Stack<>();
        dfs(1, stack); // dfs 탐색 시작
    }

    public static void dfs(int node, Stack<Integer> stack) {
        stack.push(node);
        visit[node] = true;
        System.out.print(node + " ");

        while (!stack.empty()) {
            // 현재 노드를 가져옵니다. (아직 스택에서 제거는 하지 않음)
            int currentNode = stack.peek();
            // 근접하는 노드가 모두 방문처리 되어있는지 확인하기 위한 불값
            boolean hasNearbyNode = false;

            for (int n : graph[currentNode]) {
                if (!visit[n]) { // 근접 노드가 방문처리 되어있지 않다면 방문처리를 진행
                    visit[n] = true;
                    hasNearbyNode = true; // 방문처리 되어있지 않은 노드가 존재
                    stack.push(n); // 스택에 해당 노드를 추가해줍니다.
                    System.out.print(n + " ");
                    break;
                }
            }

            if (!hasNearbyNode) { // 근접 노드가 모두 방문처리되어 있다면
                stack.pop(); // 해당 노드는 탐색이 모두 끝났기 때문에 stack에서 제거합니다.
            }
        }
    }
}
/* 출력 결과 */
1 2 4 6 7 8 9 3 5

📘 BFS (Breadth First Search)

📖 BFS란?

너비 우선 탐색이라고 하며, 가까운 노드부터 탐색하는 방법을 말합니다. 위에서 말했던 DFS는 깊이를 우선으로 하는 탐색으로서 가장 깊은 곳까지 탐색하는 방면 BFS는 너비를 우선으로 탐색하기 때문에 넓게 탐색을 시작합니다.

📖 BFS 동작 방식

  • 큐(Queue) 자료구조를 사용합니다.
    1. 탐색 시작 노드를 큐에 넣고, 방문처리를 진행합니다.
    2. 큐에서 노드를 꺼내 인접해있는 노드 중 방문처리되어 있지 않은 노드를 모두 큐에 넣고, 방문처리를 진행합니다.
    3. 1번과 2번을 할 수 없을 때까지 반복해서 진행합니다. (큐가 비어있으면 탐색을 종료합니다.)

📖 BFS 그림으로 이해하기

DFS에서 사용한 그래프와 동일한 그래프로 예시를 들어보겠습니다. 먼저 시작 노드 1을 큐에 넣고 방문처리를 진행합니다.

1번 노드를 Queue에서 꺼내고, 1번 노드와 근접한 노드 2, 3, 4 중에서 방문처리가 되어있지 않은 2, 3, 4 노드 모두 Queue에 추가하고, 방문처리를 진행합니다.

2번 노드를 Queue에서 꺼내서 근접 노드를 확인합니다. 근접 노드 1, 4, 8번 중에 방문처리가 되어있지 않은 8번 노드를 Queue에 추가합니다.

3번 노드를 Queue에서 꺼내서 근접 노드를 동일하게 확인합니다. 3과 근접노드 1, 5 중에서 방문처리가 되어있지 않은 노드 5번을 Queue에 넣고, 방문처리를 진행합니다.

위와 같은 방법으로 계속 반복합니다. 4번 노드를 Queue에서 꺼내서 인접 노드의 방문처리 상태를 확인합니다. 6, 7번이 방문처리가 되어있지 않기 때문에 6, 7번을 Queue에 추가합니다.

8번 노드를 Queue에서 꺼내서 인접 노드의 방문처리 상태를 확인한 후 9번 노드를 Queue에 추가합니다.

모든 방문처리가 완료되어있기 때문에 나머지 5, 6, 7, 9는 Queue에서 하나씩 제거되고, 탐색을 종료합니다.

모든 탐색이 끝나면 탐색 순서는 1 → 2 → 3 → 4 → 8 → 5 → 6 → 7 → 9 입니다.

BFS의 경우 자바에서 제공하는 라이브러리 중 Queue를 이용하여 구현할 수 있습니다. Queue는 Interface이기 때문에 구현체를 이용해서 구현해야 합니다. 일반적인 경우에는 DFS보다 BFS가 탐색 속도가 빠릅니다.

📖 위 예제를 코드로 구현해보기

  • Queue를 이용하여 BFS 구현하기
import java.util.LinkedList;
import java.util.Queue;

public class BFSExample {
    // 0은 사용하지 않지만, 0~9까지의 노드 방문처리를 false로 초기화
    public static boolean[] visit = new boolean[10];

    public static int[][] graph = {
            {},           // 0번 노드와 인접한 노드들
            {2, 3, 4},    // 1번 노드와 인접한 노드들
            {1, 4, 8},    // 2번 노드와 인접한 노드들
            {1, 5},       // 3번 노드와 인접한 노드들
            {1, 2, 6, 7}, // 4번 노드와 인접한 노드들
            {3},          // 5번 노드와 인접한 노드들
            {4},          // 6번 노드와 인접한 노드들
            {4},          // 7번 노드와 인접한 노드들
            {2, 9},       // 8번 노드와 인접한 노드들
            {8}           // 9번 노드와 인접한 노드들
    };

    public static void main(String[] args) {
        Queue<Integer> queue = new LinkedList<>();
        bfs(1, queue);
    }

    public static void bfs(int node, Queue<Integer> queue) {
        visit[node] = true;
        queue.offer(node);
        System.out.print(node + " ");

        while (!queue.isEmpty()) {
            int currentNode = queue.poll();

            for (int n : graph[currentNode]) {
                if (!visit[n]) {
                    visit[n] = true;
                    queue.offer(n);
                    System.out.print(n + " ");
                }
            }
        }
    }
}
/* 출력 결과 */
1 2 3 4 8 5 6 7 9

처음에는 DFS와 BFS 구분이 힘들었는데 DFS는 "깊이 우선 탐색"이라는 이름 처럼 탐색을 시작하면 시작한 탐색의 가장 깊은 곳까지 파고들어 탐색하고, 더이상 탐색할 곳이 없다면 다시 뒤로 돌아와서 재탐색을 시작하는 것이고, BFS는 "너비 우선 탐색"이라는 이름처럼 탐색을 시작했을 때 깊은 곳까지 파고드는 탐색이 아닌 넓게 탐색을 하는 방법이라고 이해하니 쉽게 이해했습니다.

위에서 사용된 스택과 큐가 더 궁금하다면 링크를 통해서 읽어보시면 좋을 거 같습니다!

스프링부트로 웹 서비스 출시하기 1, 2, 3편을 보면서 실습하고 정리해둔 내용 입니다. 틀린 내용이 있다면 피드백 주시면 감사할 거 같습니다. 🙂

 

3번째 글까지 모두 완주했고, SpringBoot는 2.7.7 버전을 사용해서 진행했습니다. 자세한 소스코드는 깃허브 주소에 가시면 볼 수 있습니다.

 

📘 JPA/Javax 에서 제공하는 Annotation

@Entity

  • 데이터베이스 테이블과 연결되는 객체임을 나타내줍니다. 테이블의 이름은 ‘_’ 언더바를 이용하여 매칭합니다.
  • 예를 들어 파일이름이 MyPage.java라면 테이블 이름은 my_page로 매핑됩니다.

 

@Id

  • 해당 테이블의 primary key임을 나타냅니다.

 

@GeneratedValue

  • PK 생성 규칙을 결정할 수 있습니다. springboot 2.0 버전 이전에는 기본 값이 Auto로 설정되어 있지만 그 이후로는 직접 옵션을 추가해줘야 합니다.
  • 여기서 Auto란 데이터가 생성될 때마다 1씩 자동으로 증가하는 것을 의미합니다.
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    저는 springboot 2.7 버전을 사용하기 때문에 직접 IDENTITY 옵션을 추가해줬습니다.

 

@Column

  • Entity 객체 안에 정의되는 변수들은 모두 데이터베이스의 컬럼이 됩니다. Column 어노테이션을 쓰는 이유는 기본값 외에 옵션을 추가하고 싶을 때 사용합니다.
  • VARCHAR의 경우 기본값이 255인데 만약 500으로 늘리고 싶거나, TEXT 타입으로 컬럼을 사용하고 싶을 때 사용합니다.
    @Column(length = 500, nullable = false)
    private String title;
    
    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

 

@MappedSuperclass

  • 만약 JPA의 Entity 클래스가 MappedSuperclass로 선언된 추상 클래스를 상속받을 경우 createdTime와 modifiedTime도 Entity의 컬럼으로 인식하도록 합니다.
    @MappedSuperclass
    @EntityListeners(AuditingEntityListener.class)
    public abstract class BaseTimeEntity {
        @CreatedDate
        private LocalDateTime createdTime;
    
        @LastModifiedDate
        private LocalDateTime modifiedTime;
    }

 

@EntityListeners(AuditingEntityListener.class)

  • 해당 클래스에 Auditing 기능을 포함시킵니다.

 

@CreatedDate

  • Entity가 생성될 때의 시간을 자동으로 저장합니다.

 

@LastModifiedDate

  • 조회한 Entity의 값을 변경했을 때의 시간을 저장합니다.

 

@EnableJpaAuditing

  • main 메서드가 있는 객체에서 Jpa의 Auditing 기능을 사용할 수 있게 해줍니다.
    @EnableJpaAuditing
    @SpringBootApplication
    public class SpringWebApplication {
    	public static void main(String[] args) {
    		SpringApplication.run(SpringWebApplication.class, args);
    	}
    }

 

@Transactional

  • Service 로직을 구성할 때 반드시 따라오는 어노테이션 중에 하나입니다.
  • 메서드 하나를 하나의 트랜잭션으로 하겠다는 것을 의미합니다. 여기서 하나의 트랜잭션이란 만약 save()라는 메서드에서 10개의 데이터를 저장해야 되는데, 6개를 저장하고 오류가 발생했다면 해당 데이터는 저장하지 않고 모두 rollback 시키는 것을 의미합니다.
    @Transactional
    public Long save(PostsSaveRequestDto dto){
        return postsRepository.save(dto.toEntity()).getId();
    }

 

 

📘 Lombok 라이브러리의 Annotation

 

@NoArgsConstructor

  • 기본 생성자를 자동으로 추가해줍니다.
  • 사용하는 이유는 프로젝트 코드 상에서는 Entity 객체를 기본 생성자로 생성하는 것을 막되, JPA에서 Entity 객체를 생성하는 것은 허용하기 위해서 추가해줍니다.
  • access = AccessLevel.PROTECTED 옵션을 추가해주면 기본 생성자가 protected로 생성됩니다.

 

@AllArgsConstructor

  • 해당 클래스 내에 있는 모든 필드를 인자 값으로 하는 생성자를 만들어줍니다.

 

@Getter

  • 클래스 내에 있는 모든 필드의 getter 메서드를 생성해줍니다.

 

@Setter

  • 클래스 내에 있는 모든 필드의 setter 메서드를 생성해줍니다.
  • Entity 객체에서는 setter는 잘 사용하지 않는다고 했지만, DTO와 같은 경우에는 setter를 사용합니다.
    • @RequestBody를 통해서 외부로 부터 데이터를 받는 경우에는 기본생성자 + setter를 통해서만 값이 할당되기 때문에 이때는 setter를 허용합니다.

 

@Builder

  • 해당 클래스의 빌더 패턴 클래스를 생성합니다.
  • 생성자 상단에 선언할 경우 생성자에 포함되어 있는 필드만 빌더에 포함됩니다.
    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

 

 

📘 Springboot/SpringFramework Annotation

 

@SpringBootTest

  • 통합 테스트를 기본으로 제공하는 어노테이션입니다.
  • Junit4를 사용할 때는 @RunWith(SpringRunner.class) 또는 @ExtendWith(SpringExtension.class과 같은 어노테이션을 추가해줘야 한다.
  • Junit5를 사용할 때는 별도로 추가해주지 않아도 된다.

 

@Controller

  • View를 반환하기 위해서 사용합니다. 여기서 말하는 View는 html과 같은 랜더링되는 파일을 의미합니다.
  • 만약 JSON 형태의 데이터를 반환하고 싶다면 @ResponseBody와 함께 사용해야 합니다.

 

@RestController

  • @Controller에 @ResponseBody를 더한 기능을 합니다. 즉, JSON 형태로 객체의 데이터를 반환합니다.
  • view를 반환하는 Controller와 JSON 형태의 데이터를 반환하는 RestController는 분리하여 사용하는 것이 좋습니다.

 

 

👨🏻‍💻 알아두면 좋을 지식들

1. 스프링 프레임워크에서 Bean을 주입(의존성 주입)받는 방식

  • 생성자를 통한 주입 (권장하는 방식)
    • 대부분의 의존 관계는 Application이 끝나기 전까지 변경될 일이 없기 때문에 생성자를 통한 의존성 주입 방법이 가장 권장되는 방법입니다.
    • 생성자를 자동으로 생성해주는 @AllArgsConstructor 또는 @RequiredArgsConstructor 어노테이션을 사용하면 생성자로 Bean을 주입받을 수 있다. 대신 Lombok을 사용해야하는 번거로움이 있지만, 코드 수정 시 필드가 추가되면 생성자에 일일히 코드를 추가시켜주는 번거로움을 없앨 수 있음.
    • Lombok을 사용하지 않는다면 생성자 위에 @Autowired 어노테이션을 선언하여 의존성을 주입할 수 있습니다. 만약 생성자가 하나밖에 없는 객체라면 굳이 @Autowired를 써주지 않아도 자동으로 주입이 됩니다.
  • setter 메서드를 통한 주입
    • setter 메서드 위에 @Autowired 어노테이션을 선언해주면 됩니다.
  • 필드(변수)를 통한 주입 (권장하지 않는 방식)
    • 필드에 직접 @Autowired를 선언하여 의존성을 주입하는 방법으로 실제 코드에서는 사용하지 않는 것이 좋습니다.
  • 일반 메서드를 통한 주입
    • 필드 여러 개에 동시에 의존성을 주입해줄 수 있지만 일반적으로 많이 사용되는 방법이 아닙니다.

 

2. DTO 객체를 따로 사용하는 이유

  • Entity 객체는 DB에 직관적으로 연결되어있는 객체이기 때문에 Entity를 건드리는 것은 위험이 있다.
  • 즉, DB를 위한 Layer와 View를 위한 Layer를 철저하게 구분하기 위헤서 DTO를 사용한다.
    • DB Layer는 Entity 객체에서 관리하고, View Layer는 DTO 객체에서 관리한다.

 

3. JPA Auditing을 사용하여 생성/수정 시간 자동화

  • 새로운 추상 클래스를 만들어서 Entity의 생성, 수정 시간을 자동적으로 저장할 수 있다.
  • LocalDateTime 클래스로 필드들을 선언하고, @MappedSuperclass와 @EntityListeners(AuditingEntityListener.class)를 사용하면 간단하게 기능을 생성할 수 있다.

🫤 문제 발생

메모리 데이터베이스인 H2에서 서버를 열고 닫을 때 마다 데이터가 없어지기 때문에 별도의 data-h2.sql 파일을 만들어 서버를 열 때마다 초기 데이터를 저장하도록 하려는 도중 모든 설정을 끝내고 확인해봤는데 데이터가 초기화되지 않는 문제가 발생

data-h2.sql

INSERT INTO posts (id, created_date, modified_date, title, content, author) VALUES (1, now(), now(), '제목1', '본문1', '작성자1');
INSERT INTO posts (id, created_date, modified_date, title, content, author) VALUES (2, now(), now(), '제목2', '본문2', '작성자2');

application.yml

spring:
  config:
    activate:
      on-profile:
        active: local

---
spring:
  profile: local

  jpa:
    open-in-view: false
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: create-drop

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
    data: classpath:data-h2.sql

  h2:
    console:
      enabled: true

  main:
    allow-circular-references: true

🔎 문제 해결 방안 찾기

1. 첫 번째로 찾았던 방법은 spring boot 2.5 버전 이상에서는 아래와 같은 옵션을 추가해줘야된다고 한다.

jpa.defer-datasource-initialization=true

Spring Boot 2.5버전 부터 스크립트 기반 초기화의 동작과정을 Flyway, Liquibase와 일치시키기 위해서 data.sql은 Hibernate 초기화되기 전에 실행된다고 한다.

즉, 생성된 스키마에 data.sql의 데이터를 초기화하고 싶다면 위에서 말한 jpa.defer-datasource-initialization=true에 대한 옵션을 추가해줘야 한다. 더 자세한 내용이 궁금하다면 참고했던 블로그인데, 참고하면 좋을 것 같다.

 

해당 옵션을 추가해주고난 후 실행해봤다. 근데 왜 작동이 안 되지…라는 생각을 하고 더 찾아봤다. 속 시원하게 나왔던 stackoverflow의 내용을 가져와봤다.

 

2. data.sql과 data-h2.sql의 차이점

내가 지금하고 있는 실습에서는 data.sql이 아닌 data-h2.sql을 사용하고 있다. 위 링크에 들어가서 확인해본 내용은 다음과 같다.

data-h2.sql 또는 data-mysql.sql의 경우에는 내가 설정한 database platform에 따라서 결정된다고 한다.

spring.sql.init.platform=h2 # Spring Boot >=v2.5.0
spring.datasource.platform=h2 # Spring Boot <v2.5.0

나는 2.7.7 버전을 사용하고 있기 때문에 spring.sql.init.platform=h2 옵션을 추가해줬다.

최종 application.yml 파일

jpa에 해당하는 부분만 수정이 있었기 때문에 그 부분만 확인해보시면 빠를 거 같습니다!

spring:
  config:
    activate:
      on-profile:
        active: local

---
spring:
  profile: local

  sql:
    init:
      platform: h2

  jpa:
    open-in-view: false
    defer-datasource-initialization: true
    show-sql: true
    generate-ddl: true
    hibernate:
      ddl-auto: create-drop

  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
    data: classpath:data-h2.sql

  h2:
    console:
      enabled: true

  main:
    allow-circular-references: true

정상적으로 돌아가는 것을 확인했다.

실습 권장 버전은 1.5.0버전이었는데 젤 최근 버전에 맞춰서 해보고 싶어서 하고 있는데, 시간은 오래걸리는데 나름 배워가는 것이 더 많은거 같아서 재밌다. 😮

+ Recent posts