본문 바로가기
  • 꾸준히 앞으로
Algorithm

동적계획법(DP, Dynamic Programming), 쉬운 것 같은데 쉽지 않은..

by lijly 2020. 6. 14.

 

동적계획법이란?

전체 문제를 작은 문제로 단순화한 다음 점화식으로 만들어 재귀적인 구조를 활용해서 전체 문제를 해결하는 방식

큰 의미에서 분할 정복과 같은 접근 방식을 의미한다.

→ 처음 주어진 문제를 더 작은 문제들로 나눈 뒤 각 조각의 답을 계산하고, 이 답들로부터 원래 문제에 대한 답을 계산해 낸다.

동적 계획법과 분할 정복의 차이가 발생하는 부분은 문제를 나누는 방식이다.

→ 동적 계획법에서 어던 부분 문제는 두 개 이상의 문제를 푸는데 사용될 수 있기 때문에 값을 저장한 후, 같은 문제가 나왔을 경우 계산 결과를 재활용함으로써 속도의 향상을 꾀한다.

🤖
캐시(cache) : 이미 계산한 값을 저장해 두는 메모리의 장소 중복되는
부분 문제(overlapping subproblems) : 두 번 이상 계산되는 부분 문제

 

그리디 알고리즘과 비교

우리가 차량 정체 구간에서 A라는 지점에서 B라는 지점까지 가능한 빨리 이동하는 경로를 찾고 싶다고 하자. 이 문제에서

동적 계획법

→ 우리가 갈 수 있는 모든 상황과 교통 정체를 전부 감안하여 최적의 경로를 찾아낸다.

  • 단점

    약간의 시간이 걸린다

  • 장점

    이렇게 얻어낸 경로는 (교통 환경이 변하지 않았다는 가정 하에) 우리가 갈 수 있는 가장 빠른 길이 된다고 장담할 수 있다.

그리디 알고리즘

→ 전체적인 상황을 고려하지 않고, 순간순간 교차로가 보일 때마다 가장 빠른 경로를 검색하여 찾아줄 것이다.

  • 단점

    항상 최적의 경로를 찾아주지 않는다.
    → 각 구간마다 최적의 경로를 찾는다고 해도 그것이 전체적으로 최적의 경로가 되지 않기 때문

  • 장점

    즉효성이 있다.

 

적용

  1. 완전 탐색 알고리즘 설계
    1. 문제를 여러 조각으로 나눈 후, 한 조각을 해결
    1. 나머지는 재귀 호출을 통해 해결
  1. 메모이제이션 - 최적화

동적계획법 문제가 풀리지 않을 때 '캐시를 어떻게 적용할지 몰라서 못 풀겠다'라고 생각하며 막막해 했다. 그런데 그것이 점화식을 만들지 못해 발생했던 문제였던 것 같다.

만약 최적화가 잘되지 않는다면 재귀적인 구조를 활용할 수 있는 '점화식'을 어떻게 작성할 것인지 고민해보자.

  1. 부분 문제 정의
  1. 점화식 작성

으로 생각하고 문제를 풀어 나가보자.

( 항상 초점을 어디에 맞추느냐에 따라 생각 방식이 많이 바뀌게 되는 것 같다. )

 

 

연습문제

최단 경로 문제, 행렬의 제곱 문제 등의 최적화에 사용된다.

🔗 프로그래머스, 타일 장식물

타일 장식물은 정사각형 타일을 붙여 만든 형태였는데, 한 변이 1인 정사각형 타일부터 시작하여 마치 앵무조개의 나선 모양처럼 점점 큰 타일을 붙인 형태였다.
타일 장식물을 구성하는 정사각형 타일 한 변의 길이를 안쪽 타일부터 시작하여 차례로 적으면 다음과 같다.
[1, 1, 2, 3, 5, 8, .]
타일의 개수 N이 주어질 때, N개의 타일로 구성된 직사각형의 둘레를 return 하도록 solution 함수를 작성하시오.

 

  • 풀이
    1. 부분 문제 정의

      → 한 변의 길이는? 이전 정사각형 두 개를 합한 변으로 만들어진 정사각형이다.

    1. 점화식 작성
      🔑
      N번째 정사각형 변 = (N-1)번째 변 + (N-2)번째 변

       

    class Solution {
        public long solution(int N) {
            long[] nArr = new long[N];
            nArr[0] = 1;
            nArr[1] = 1;
            for(int i=2; i<N; i++){
                nArr[i] = nArr[i-1]+nArr[i-2];
            }
            long answer = (nArr[N-1]*2 + nArr[N-2])*2;
            
            return answer;
        }
    }

 

 

🔗 프로그래머스, 등굣길

집에서 학교까지 가는 길은 m x n 크기의 격자모양으로 나타낼 수 있습니다.
가장 왼쪽 위, 즉 집이 있는 곳의 좌표는 (1, 1)로 나타내고 가장 오른쪽 아래, 즉 학교가 있는 곳의 좌표는 (m, n)으로 나타냅니다.
격자의 크기 m, n과 물이 잠긴 지역의 좌표를 담은 2차원 배열 puddles이 매개변수로 주어집니다. 집에서 학교까지 갈 수 있는 최단경로의 개수를 1,000,000,007로 나눈 나머지를 return 하도록 solution 함수를 작성해주세요.

 

  • 풀이
    1. 부분 문제 정의

      → 경우의 수에서 경로 수 구하는 법을 떠올려보면,

      시작점에서 1부터 시작하여 아래-오른쪽으로 뻗어나가면서 대각선에 있는 것끼리 더해 다음 만나는 지점의 경우의 수가 정해진다.

      지나가지 못하는 곳은 0으로 더해나간다.

    1. 점화식 작성
      🔑
      현재 있는 곳의 경로 수 = 이전 위의 경로 수 + 왼쪽의 경로 수

      예외 혹은 조건들을 잘 걸러 주는 것도 중요하다.

       

    import java.util.*;
    class Solution {
        public int solution(int m, int n, int[][] puddles) {
            int[][] cache = new int[m+1][n+1];
            for(int[] v:puddles){
                cache[v[0]][v[1]] = -1;
            }
            for(int i=1; i<m+1; i++){            
                for(int j=1; j<n+1; j++){
                    if(i==1 && j==1) cache[i][j] = 1;
                    else if(cache[i][j] == -1) cache[i][j] = 0;
                    else cache[i][j] = (cache[i-1][j] + cache[i][j-1])%1000000007;
                }
            }
            return cache[m][n];
        }
    }

     

    + 이 문제는 약간의 논란(+오류)이 있는데, 오른쪽-아래로만 이동 가능하다는 가정 하에 풀어야 한다.

    예를 들어, 우물이 위아래로 있다면 구불구불한 길이라도 갈 수 있는 path 중 가장 짧은 거리를 구하는 것이 답이 된다. 하지만 지금 이 문제를 풀면 그 상황을 고려하지 않고 무조건 오른쪽-아래로만 이동이 가능할 때 답이 맞게 되어있다. 따라서 위아래로 있다면 무조건 0이 나오게 된다.

 

 

🔗 LeetCode, Coin Change

각각 다른 액수의 동전들 coins와 만들어야하는 총 금액 amount가 주어집니다.
동전들을 사용(중복 사용 가능)하여 amount를 만들 수 있는 가장 적은 동전 개수를 반환하세요.
가진 동전들로 amount를 만들 수 없다면, -1을 반환하세요.

 

  • 풀이

    바로 재귀적으로 풀고 메모이제이션을 적용하려 했을 때 (당연히) 감이 잡히지 않았던 문제였다. 부분 문제를 잘 정의해 풀어야 한다.

    1. 부분 문제 정의
      • 완전탐색으로 정의한 부분 문제 : 코인들을 더하면 금액이 만들어진다.
        🛠
        코인 종류별로 하나씩 개수를 늘려 만들 수 있는 amount 중 최소 개수

        → coin개수와 amount가 늘어날수록 만들어지는 수들은 기하급수적으로 늘어난다. 메모이제이션을 적용하기도 까다롭다.

      • 부분 문제 정의 수정 : 만들어질 수 있는 금액을 만들고 그 금액을 만들 수 있는지 검사한다.
        🛠
        제일 작은 코인 금액부터 시작하여 amount를 만들기까지 현재 가지고 있는 코인으로 만들 수 있는 수 인지 확인한다.

        → (amount-min(coins))*coin 개수만큼만 검사하면 된다.

        1. 만들어질 수 있는 금액을 배열주소로 하여 사용한 코인 개수를 기록할 수 있는 amount+1 길이의 배열을 만든다
        1. 그 수가 만들어질 수 있는지의 진위 여부는 우리가 생각하지않고 각 코인으로 만들 수 있는지 이전 기록들을 이용해 그 수를 만든다.
        1. 그리고 그 중 최소값을 계속 남겨 나간다.
    1. 점화식 작성
      🔑
      현재 검사하는 금액 = (현재 금액 - 각 코인 금액)의 기록 +1 과 현재 검사하는 금액에 들어가 있는 수 중 최소
      • cache 배열 초기화
        • 가장 큰 수(답이 될 수 없는 코인 개수 아무거나)로 초기화
        • 제시된 코인 금액과 동일하다면, 1로 초기화
      • 배열의 길이에 맞게 조건 검사 후 점화식 작성

       

    import java.util.*;
    class Solution {
        public int coinChange(int[] coins, int amount) {
            if(amount == 0) return 0;
            
            Arrays.sort(coins);
            int[] cache = new int[amount+1];
            Arrays.fill(cache, amount+1);
            int min=amount;
            for(int c: coins){
                if(c < amount){
                    cache[c] = 1;
                    min = Math.min(min, c);
                }
                else if(c == amount) return 1;
            }
            for(int i=min; i<amount+1; i++){
                for(int c: coins){
                    if(i >= c){
                        cache[i] = Math.min(cache[i], cache[i-c]+1);
                    }
                }
            }
            if(cache[amount] == amount+1) return -1;
            else return cache[amount];
        }
    }

 

 

🔗 LeetCode, Word Break

비어 있지 않은 문자열 s와 비어 있지 않은 단어 목록 wordDict가 주어집니다.
주어진 단어 목록 문자열을 연속으로 조각낼 수 있다면 true를 그럴 수 없다면 false를 반환하세요.

 

  • 풀이
    1. 부분 문제 정의

      처음 문제를 보고 단순화 시킬 때 단어가 일치할 경우를 있던 지점에서 단어 끝으로 'jump한다'고 생각하여 접근했다. 징검다리처럼 연속해서 끝까지 갈 수 있다면 true, 중간에 비는 곳이 있다면 false.

      • 완전 탐색으로 정의한 부분 문제 : jump 지점을 만들어 가며 끝에 도달할 수 있는지 확인
        🛠
        일치하는 단어들의 끝점을 저장한 후 가장 멀리 간 지점 순서대로 다시 검사
        • 가장 멀리 간 지점이 답이 아닐 경우 그 다음 멀리 간 지점을 검사하며 점점 뒤로 간다.

          → 여기에서 가장 긴 단어 길이보다 더 뒤로가게 되는 경우는 답이 만들어질 수 없는 경우이기 때문에 false를 반환한다.

        → 처음 이렇게 문제를 풀었는데 이렇게 풀기까지의 과정이 아주 복잡했다...

      • 부분 문제 정의 수정 : 각 지점들을 나열한 후 그 까지 도달할 수 있는지를 검사
        🛠
        각 지점들(s의 인덱스들)을 배열로 만들어 해당 지점에서 단어들 중 연속해 지나갈 수 있는지 검사
        1. 각 지점들(s의 인덱스들)에 도달할 수 있는지를 저장하는 s.length()+1 길이의 배열을 만든다
        1. 각 인덱스를 지나가며 wordDict를 검사하는데 해당 지점에 오기까지 검사하는 단어를 사용했을 때,
          • 이전 지점이 연속되지 않는 지점(false)이라면 그대로 false
          • 이전 지점이 연속되는 지점이라면 해당 단어로 jump할 수 있는지(substring이 단어와 같은 단어 인지) 확인
            • 해당 단어로 jump할 수 있다면 나머지 단어들을 검사할 필요 없이 다음 index로 넘어간다.
        1. 끝까지 검사하면 마지막에 결과가 나온다.
    1. 점화식 작성
      🔑
      index까지 도달 가능 여부 = (index - 비교 단어 길이)의 지점 도달 가능 여부 && (비교 단어 길이, index에서 끝나는 s의 substring)단어 일치 여부
      • 배열의 길이에 맞게 조건 검사 후 점화식 작성

       

    class Solution {
        public boolean wordBreak(String s, List<String> wordDict) {
            if(s.length() == 0 || wordDict.size() == 0) return false;
            boolean[] cache = new boolean[s.length()+1];
            cache[0] = true;
            for(int i=1; i<cache.length; i++){
                for(String v: wordDict){
                    if(i-v.length() >= 0){
                        if(!cache[i-v.length()]) continue;
                        else if(v.equals(s.substring(i-v.length(), i))){
                            cache[i] = true;
                            break;
                        }
                    }
                }
            }
            return cache[s.length()];
        }
    }

 

 


⍞ Reference

  • 도서 : 알고리즘 문제해결전략, 인사이트

 

 

'Algorithm' 카테고리의 다른 글

이분 탐색(Binary Search), 어떨 때 적용할까?  (0) 2020.05.26

댓글