[알고리즘/JAVA] 동적 계획법(DP) : 개념 (파도반 수열 / 1로 만들기 / 계단 오르기)

2024. 4. 16. 18:46
반응형

https://en.wikipedia.org/wiki/Knapsack_problem

 

 

부분 문제 해결 : Top-Down & Bottom-Up

 

 

이전 포스팅에서 재귀와 반복 알고리즘을 살펴보면서, 복잡한 문제를 하위 문제로 나누어 해결하는 방법들에 대해서 살펴 보았었다.

부분 문제 해결 방법에는 크게 두 가지 방식이 있다.

 

1. Top-Down 방식 : 재귀적인 방식으로 문제를 해결해서 큰 부분의 문제를 작은 부분의 문제로 쪼개가면서 해결하고, 작은 부분 문제들을 결합하여 전체 문제의 해를 구하는 방식이다.

 

2. Bottom-Up 방식 : 주로 반복적인 방법을 사용하여, 먼저 작은 부분 문제들을 해결하고 이를 이용하여 더 큰 부분 문제들을 해결하는 과정이다. 

 

 

피보나치 수열의 Top-Down 방식

static int fib(int n){
    if(n <= 1){
        return n;
    }
    return fib(n-1) + fib(n-2);
}

 

 


피보나치 수열의 Bottom-Up 방식

 

static int fib(int n){
    int prev = 1;
    int current = 1;

    for(int i = 3; i <= n; i++){
        int tmp = prev + current;
        prev = current;
        current = tmp;
    }

    return current;
}

 

 

 

하지만 이런 재귀 및 반복 과정에서는 중복되는 연산이 많이 발생하게 될 수 있다.

아래의 fib(4)를 재귀호출 시 발생하는 연산을 살펴보자.

recursiveFib(4)
├─ recursiveFib(3)
│   ├─ recursiveFib(2)
│   │   ├─ recursiveFib(1)
│   │   │   └─ return 1
│   │   └─ recursiveFib(0)
│   │       └─ return 0
│   └─ recursiveFib(1)
│       └─ return 1
└─ recursiveFib(2)
    ├─ recursiveFib(1)
    │   └─ return 1
    └─ recursiveFib(0)
        └─ return 0

 

살펴보면 알 수 있듯 이전에 부분 문제의 해를 구하는 연산을 했음에도 불구하고 또다시 연산을 재귀적으로 호출하므로 답을 이미 알고 있는 부분해의 경우에도 연산을 수행하고 있는 것을 알 수 있다.

 

  • fib(3) : 1회 연산
  • fib(2) : 2회 연산
  • fib(1) : 3회 연산
  • fib(0) : 2회 연산

 

 


동적 계획법 (Dynamic Programing, DP)

 

 

앞의 부분 문제에 대한 중복된 연산을 방지하고자 매모리제이션(Memorization), 타뷸레이션(Tabulation)이라는 개념이 등장하였다.

 

메모리제이션은 Top-down 방식에서 사용되는 단어이고, 타뷸레이션은 Bottom-up 방식에서 사용되는 단어이다.

어려운 단어 같아 보이지만 둘 다 공통적으로, 앞에서 연산한 것을 기억해두고, 만약 똑같은 연산을 하게 되면 처음부터 풀지 말고 미리 풀어둔 해답을 사용하자는 뜻이다. 그리고 사실 이것이 동적 프로그래밍의 전부이다.

  • 메모리제이션 : 재귀 함수 내에서 이전에 계산한 값을 저장하여 동일한 재귀 호출에 대한 중복 계산을 피한다.
  • 타뷸레이션 : 작은 문제부터 시작하여 부분 문제를 먼저 해결한 후, 해당 값을 저장해두고, 이를 필요할 때 마다 사용한다.
  • 최적 부분 구조 : 해를 구하기 위해서 이전 부분 문제의 해를 계속 이용해야 하는 구조

 

사실 메모리에지션과 타뷸레이션은 용어를 구분해 두긴 했지만, 부분해들을 저장해 두는 기법들이라고만 묶어서 생각해 두면 될 것 같다.

 

 

최적 부분 구조 문제 해결

동적 계획법(DP) : 최적 부분 구조가 가진 중복된 하위 부분해를 사용해서 복잡한 문제를 풀이하는 방법

주로 [Top-Down + 메모리제이션]혹은 [Bottom-Up + 타뷸레이션]을 사용하는 전략을 사용하며 동일한 작은 문제가 여러 번 반복되는 경우가 많은 경우 유용하게 사용된다.

 

  • Top-down 방식 DP: 재귀 호출을 사용하여 문제를 작은 부분 문제로 나누고, 각 부분 문제의 해를 계산할 때 해당 결과를 저장하고 재활용한다. 이를 통해 중복 계산을 피하고 연산 시간을 절약할 수 있다.
  • Bottom-up 방식 DP : 작은 부분 문제부터 시작하여 상위 문제로 올라가면서 저장해둔 부분 문제의 결과를 사용한다.

 

 

분할 정복법 vs 동적 계획법

분할 정복법은 문제를 더 작은 부분 문제로 나눈 후 각각을 해결하고, 그 결과를 합쳐서 전체 문제의 해답을 구한다. 즉, 모든 부분 해를 합치게 된다.

동적 계획법은 주어진 문제를 작은 부분 문제로 나눈 후, 각 부분 문제의 해답을 저장하고 필요할 때 이를 활용하여 전체 문제의 해답을 구한다. 일부 해답만 사용하면서도 중복 계산을 피하기 위해 메모리제이션(타뷸레이션)을 사용하는 것이 DP의 아이디어이다.

 

 

 

이름이 다이나믹 프로그래밍라고 하는데 프로그래밍에서 등장하는 이름과 내용이 전혀 연관없는 케이스 중 하나인 것 같다.

 

 


Top-Down DP : 피보나치

public class Main {

    static int count = 0;
    static int countDP = 0;
    static Integer[] f;


    static int fib(int n){
        count++;
        if(n <= 1){
            return n;
        }
        return fib(n-1) + fib(n-2);
    }



    static int fib_dp(int n){
        if(n <= 1){
            return n;
        }
        if(f[n] == null){
            f[n] = fib_dp(n-1) + fib_dp(n-2);
            countDP++;
        }

        return f[n];
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        f = new Integer[n + 1];

        System.out.println(fib(n) + " " + count);
        System.out.println(fib_dp(n) + " " + countDP);

    }
}

 

fib_dp()는 앞에서 작성했던 피보나치에 메모리제이션을 적용시킨 것이다.

방법을 설명하면 부분해들을 저장시킬 배열을 선언해두고, 만약 처음 푸는 경우라면 해당 배열에 부분해를 저장해 둔다. 예를 들어 fib(4)를 계산하였다면 f[4]에 해당 값을 저장시켜 두고, 나중에 사용할 때 해당 값을 재사용하는 것이다.

 

위의 코드에서 연산을 몇 번 수행하는지도 파악하기 위해서 함께 작성해놓았는데, DP를 적용한 것과 그렇지 않은 것의 차이는 명확하다.

 

단순 재귀로 피보나치(30) 연산 횟수 : 2692537
DP로 피보나치(30) 연산 횟수 : 29

 

 

 


Bottom-Up DP : 피보나치

public class Main {

    static int count = 0;
    static int countDP = 0;


    static int fib(int n){
        int prev = 1;
        int current = 1;

        for(int i = 3; i <= n; i++){
            count++;
            int tmp = prev + current;
            prev = current;
            current = tmp;
        }

        return current;
    }



    static int fib_dp(int n){
        int[] f = new int[n + 1];
        f[1] = 1;
        f[2] = 1;

        for(int i = 3; i <= n; i++){
            f[i] = f[i-1] + f[i-2];
            countDP++;
        }
        return f[n];
    }

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();

        System.out.println(fib(n) + " " + count);
        System.out.println(fib_dp(n) + " " + countDP);

    }
}

 

Bottom-Up 방식에서도 타뷸레이션을 사용하는 예시이다. 피보나치의 경우 반복문 내에서 이전에 구해 둔 부분해를 여러번 사용하는 경우가 적기 때문에 적용의 효과가 미미하지만 Bottom-Up 방식의 DP의 예시로서 작성하였다. 

하지만 앞으로 작성하는 DP 문제들을 주로 Bottom-Up 방식으로 풀어 볼 예정이다.

 

 

 

 

 


언제 사용해야 할까?

 

DP를 사용하는 경우는 여러가지 경우가 있지만 아래의 프로세스를 따라갈 수 있다면 사용하기 적절할 확률이 높다.

1. 문제의 입력값과 시간 복잡도를 보고, 백트래킹, 브루트 포스 등 모든 경우의 수에 대한 탐색이 불가능 -> 규칙이 숨어있다는 것을 유추, 부분해로 표현 가능한지와, 그에 따르는 규칙을 찾아내볼 준비를 하자.
2. 큰 문제의 해가 부분 문제의 최적해로 분리할 수 있을 때 -> 큰 문제를 작은 문제로 표현 가능.
3. 큰 문제를 작은 문제로 표현할 수 있는 규칙(점화식)을 찾아내기 -> Top-down, Bottom-up
4. 부분 문제가 중복되어서 사용돠는 경우 -> 메모리제이션, 타뷸레이션

 

한번 아래의 DP 문제들을 함께 살펴보면서 해당 케이스들을 체화해보자.

 

 


문제 : 파도반 수열 (백준 9461)

 

 

P(1)부터 P(10)까지의 수열의 규칙을 한번 파악해보자. 1, 1, 1, 2, 2, 3, 4, 5, 7, 9이다. 뭔가 냄새가 난다. 규칙이 있는 듯한..

살펴보면 P(N)은 부분문제 P(N-2)와 P(N-3)의 합으로 점화식 규칙이 성립되는 것을 알 수 있다.

 

 

구현

static long[] p;
static void padoban(int k){
    p[1] = 1;
    p[2] = 1;
    p[3] = 1;
    for(int i = 4; i <= k; i++){
        p[i] = p[i - 2] + p[i - 3];
    }
}

 

DP(Bottom-Up 방식 + 타뷸레이션)로 파도반 수열을 구현한 코드이다.

 

1번째, 2번째, 3번째 수열은 1로 고정이다. 그리고 4번째 수열부터 1번째 수열과 2번째 수열의 합,  P(N) = P(N-2) + P(N-3)으로 표현되도록 계산하면 된다.

 

타뷸레이션의 효과를 한번 살펴보면 만약 i가 4일 때를 살펴보자. p[2] + p[1]의 계산이 이루어 질 것이다. 그리고 i가 5일 때도 살펴보자. p[3] + p[2]의 계산이 이루어진다. 타뷸레이션을 사용함으로서 p[2]를 또 다시 사용할 일이 생기더라도 언제든지 이전에 계산해 두었던 것을 다시 활용해서 사용할 수 있었다.

 

입출력 코드는 생략하도록 하겠다.

 

 


문제 : 1로 만들기 (백준 1463)

 

 

문제만 놓고 봤을 때 초심자의 마인드에서 오해하기 쉬울 수 있을 것 같다.

3으로 나누어 떨어지면 3으로 나누고, 그렇지 않으면 2로 나누어 떨어지면 2로 나누고, 이것도 안되면 1을 빼면 된다고 생각할 수 있다.

하지만 10의 경우를 놓고 생각하면 위의 생각으로는 총 4번의 연산을 수행해야 한다. 하지만 실제로 최소 연산의 수는 3번이다.

  • 4번의 연산 수행 : 10 / 2 = 5 -> 5 - 1 -> 4 -> 4 / 2 = 2 -> 2 / 2 = 1 
  • 3번의 연산 수행 : 10 -1 = 9 -> 9 / 3 -> 3 -> 3 / 3 = 1

 

10이라는 수만 딱 놓고 보았을 때 떠올릴 수 있는 수가 있겠는가? 찾더라도 큰 수의 경우라면? 규칙성을 찾아내야 한다.

모든 경우의 수를 찾는다? 시간 제한이 0.15초인데 반해, 입력값의 범위는 100만이다. n^2 이상의 연산은 꿈도 못꾼다. 따라서 브루트 포스나 백트래킹 등의 완전 탐색을 할 생각은 접자.

 

10을 연산하는 경우의 수는 3으로 나누는 경우, 2를 나누는 경우, 1을 빼는 경우 세 가지이다. 그리고 해당 세 가지 경우는 부분 문제의 해를 통해서 표현할 수 있음을 알 수 있다. 

 

10을 3으로 나누는 경우 연산수 : 나누어 떨어지지 않으므로 불가능
10을 2으로 나누는 경우 연산수 : calc(5) + 1
10을 1로 뺄셈하는 경우 연산수 : calc(9) + 1
(calc(N)는 N을 1로 만들 때 최소로 필요한 연산 수라고 하자, 앞의 식에서 파악할 수 있듯 calc(5)=3, calc(9)=2 이다)

 

 

즉, 정답으로 나와야 하는 calc(10)은 min(calc(5), calc(9)) + 1로 표현할 수 있다. 이를 일반식으로 표현해보자.

calc(N) = min(calc(N/3), calc(N/2), calc(N-1)) + 1 (단 나누어 떨어지는 경우만 경우의 수 포함)

 

다시 10을 계산하는 과정을 살펴보자

 

calc(10) = min(calc(5), calc(9)) + 1 = 3
calc(9) = min(calc(3), calc(8)) + 1 = 2
calc(8) = min(calc(4), calc(7)) + 1 = 3
calc(7) = min(calc(6)) + 1 = 3
calc(6) = min(calc(3), calc(2), calc(4)) + 1 = 2
calc(5) = min(calc(4)) + 1 = 3
calc(4) = min(calc(2), calc(3)) + 1 = 2
calc(3) = min(calc(1), calc(2)) + 1 = 1
calc(2) = min(calc(1), calc(1)) + 1 = 1
calc(1) = 0

 

 

 

따라서 큰 문제를 작은 문제에 대한 식으로 표현이 가능하다면, 그리고 부분해가 중복해서 많이 사용되기까지 하였으므로 DP로 풀이할 준비가 되었다는 것이다.

 

 

 


구현

public class Main {
    static int[] numbers;

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        numbers = new int[n + 1];

        numbers[1] = 0;

        for(int i = 2; i <= n; i++){
            numbers[i] = numbers[i - 1] + 1;
            if(i % 3 == 0){
                numbers[i] =Math.min(numbers[i], numbers[i / 3] + 1);
            }
            if(i % 2 == 0){
                numbers[i] = Math.min(numbers[i], numbers[i / 2] + 1);
            }
        }

        System.out.println(numbers[n]);
    }
}

 

 

이번에도 Bottom-up 방식 + 타뷸레이션 사용한 DP로 문제를 풀이하였다.

 

1. numbers[i]에는 숫자 i에 대한 부분해의 결과를 저장한다.

2. 2부터 N까지의 해를 도출한다. 해당 해를 도출할 때에는 이전에 사용했던 부분해를 이용한다.

3. 도출식에는 numbers[i]를 1로 더하는 경우, (2로 나누어 떨어진다면) numbers[i]를 2로 나누는 경우, (3으로 나누어 떨어진다면) numbers[i]를 3으로 나누는 경우를 부분해를 사용해서 연산 횟수를 계산하고, 계산한 최솟값을 현재 숫자의 해로 설정한다.

4. 최종적으로 numbers[N]이 최종 해가 된다.

 

 

 

 


문제 : 계단 오르기 (백준 2579)

 

 

 

이번에도 문제의 접근을 DP의 관점에서 해보자.

 

1. 마지막 도착 계단을 밟았을 때의 최대 점수 = 이전 밟은 계단의 최대 점수 + 현재 계단의 점수
2. 이전 밟은 계단의 최대 점수 =  max(1번째 전 계단의 최대 점수, 2번째 전 계단의 최대 점수)

 

여기서 생각해 보아야 할 것이 문제의 조건중에 단 연속해서 3개의 계단을 밟을 수 없다고 하였다. 그렇다면 위의 예시에서 1번째 전 계단의 점수에는 2번째 전 계단의 점수가 포함될 수 없다는 뜻이다.

 

따라서 우리는 계단을 밟는 경우를 두 가지 케이스로 분리해 보아야 할 필요가 있다. 따라서 해당 케이스를 아래의 식으로 표현하도록 하겠다.

 

1. Jump(k) : k번째 계단을 첫 계단으로 밟는 경우의 최대 점수값, k-2번째 계단의 Jump/Step 케이스 중 상관없이 큰 값을 부분해로 포함할 것이다.

2. Step(k) : k번째 계단을 연속해서 두 번째 계단으로 밟는 경우의 최대 점수값. k-1번째 계단의 Jump 케이스를 부분해로 포함할 것이다.

 

 

 

이를 토대로 부분해들에 대해서 점수를 계산해보자.

점수 - - 10 20 15 25 10 20
k - - 1 2 3 4 5 6
Jump(k) 0 0 10 20 25 55 45 75
Step(k) 0 0 - 30 35 50 65 65

 

 

1. 이전 밟은 계단의 최대 점수 =  max(Jump(k-1), max(Jump(k-2), Step(k-2)))

  • Jump(k) = max(Jump(k-2), Step(k-2))) + Score(k)
  • Step(k) = Jump(k-1) + Score(k)

2. 마지막 계단(N)번째 계단의 최대 점수 : max(Jump(N), Step(N)

 

k=4 를 예시로 들면

  • Jump(첫 번째로 밟는 경우의 최대점수)에는 k=2일 때의 최대(Jump/Step)와 현재 점수를 더해서 30+25 = 55가 되었다.
  • Step(두 번째로 연속 밟는 경우의 최대점수)에는 k=3일 때의 Step과 현재 점수를 더해서 25+25 = 50이 되었다.

 

선택된 계단의 값들은 위의 표에서 보라색 볼트체로 표시된 것과 같으며, 1,2,4,6번째 계단을 밟았을 때 최대 점수를 얻을 수 있는 것을 확인하여 해당 점화식이 사용 가능하다는 것을 확인하자.

 

 


구현

 

public class Main {
    static int n = 0;
    static int[] values;
    static int[][] steps; //steps[i][0]은 Step, steps[i][1]은 Jump
    
    static void stepOut(){

       //1번째, 2번째 계단은 i-2번째 계단이 존재하지 않으므로 점화식을 이용할 수 없기 때문에 값을 채워준다.
        steps[1][0] = values[1]; //1번째 계단 Jump 
        steps[1][1] = 0; //1번째 계단 Step (Step은 연속으로 오를 계단이 없으므로 존재X -> 0점)
        steps[2][0] = values[2]; //2번째 계단 Jump
        steps[2][1] = values[2] + steps[1][0]; //2번째 계단 Step
        
        for(int i = 3; i <= n; i++){
            //Jump : 2번째 이전 계단의 (Jump/Step) 중 최대점수 + 현재 점수 
            steps[i][0] = Math.max(steps[i-2][0], steps[i-2][1]) + values[i]; 
            //Step : 1번째 이전 계단의 Jump 점수 + 현재 점수 
            steps[i][1] = steps[i-1][0] + values[i]; 
        }
        
        int max = Integer.MIN_VALUE;

        // N번째 계단(마지막 계단)의 Jump, Step 케이스 중 최대 점수 계산
        for(int j = 0; j < 2; j++){
            max = Math.max(max, steps[n][j]);
        }

        System.out.println(max);

    }
    
    public static void main(String[] args) throws IOException {
    
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = new StringTokenizer(br.readLine());
        n = Integer.parseInt(st.nextToken());
        values = new int[n + 2];
        steps = new int[n + 2][2];
        for(int i = 1; i <= n; i++){
            st = new StringTokenizer(br.readLine());
            values[i] = Integer.parseInt(st.nextToken());
        }
        stepOut();
        
    }
    
}

 

각 계단에 대한 점수를 저장할 배열(values) : 1번째 인덱스부터, 1번째 계단의 점수를 저장할 배열이다.

 

DP 부분해 저장(타뷸레이션)을 위한 배열(step): 1번째 인덱스부터 각 계단에서의 Jump, Step 두 가지 케이스에 대한 최대 점수를 저장할 배열이다. steps[i][0]에는 Jump를, steps[i][1]에는 Step을 저장할 것이다.



1번째 계단과 2번째 계단 : 해당 계단들은 i-2번째 계단이 존재하지 않으므로 이를 사용한 점화식을 사용할 수 없다. 따라서 초기값을 설정해준다.

 

i번째 계단 계산 : 해당 계단들은 점화식을 사용해서 이전에 구해놓은 부분해를 사용하여 해를 도출해낸다. Jump, Step 두 가지 경우의 수에 대해서 나올 수 있는 최대값들을 계산하여 DP 배열에 저장해두고, 이를 다음 계단에서 부분해로 사용한다.

 

N번째 계단 계산 : 점화식을 통해 최종적으로 나온 N번째 계단에 대해서 Jump된 경우, Step된 경우 중 더 큰 값을 선택하고, 해당 값이 마지막 계단을 올랐을 때의 최대 점수가 된다.

 

 

 

 

 

 

반응형

BELATED ARTICLES

more