이 포스팅은 <프로그래밍 대회에서 배우는 알고리즘 문제 해결 전략>, 구종만, 인사이트(2012)을 읽고 개인 학습용으로 정리한 글입니다.
동적 계획법은 큰 의미에서 분할 정복과 같은 접근 방식을 의미한다
동적 계획법은 문제를 나누는 방식에서 분할 정복과 차이가 있다
중복되는 부분 문제(overlapping subproblems) 발생 -> 계산 결과를 캐시(cache)메모리에 저장하여 재활용 -> 속도 향상
= 메모이제이션 기법(memoization)
int bino(int n, int r){
if(r == 0 || n == r) return 1;
return bino(n-1, r-1) + bino(n-1, r);
}
//캐시 -1로 초기화해 둔다
int cache[30][30];
int bino2(int n, int r){
if(r == 0 || n == r) return 1;
if(cache[n][r] != -1) return cache[n][r];
return cache[n][r] = bino(n-1, r-1) + bino(n-1, r);
}
참조적 투명성(referential transparency)
: 함수의 반환값이 그 함수의 입력값으로만 결정되는 특성
참조적 투명 함수(referential transparent function)
: 입력이 고정되어 있을 때 결과가 항상 같은 함수들
메모이제이션은 참조적 투명 함수의 경우에만 적용할 수 있다
항상 기저 사례(base case)를 제일 먼저 처리
cache[]를 모두 -1로 초기화
-> 만약 함수의 반환값이 음수일 수도 있다면 다른 값을 사용해야한다
-> memset()을 이용해 cache[] 초기화하는 방법은 매우 제한적인 경우에만 가능하다
ret은 cache[a][b]에 대한 참조형(reference)
//a, b는 각각 [0, 2500) 구간 안의 정수
//반환 값은 항상 int형 안에 들어가는 음이 아닌 정수
int someObscureFunction(int a, int b);
//캐시 -1로 초기화해 둔다
int cache[2500][2500];
int someObscureFunction(int a, int b){
//기저 사례 제일 먼저 처리
if(...) return ...;
//(a, b)에 대한 답을 구한 적이 있으면 곧장 반환
int &ret = cache[a][b];
if(ret != -1) return ret;
//(a, b)에 대한 답 계산
...
return ret;
}
int main(){
//memset()을 이용해 cache배열 초기화
memset(cache, -1, sizeof(cache));
}
주어진 문제를 완전 탐색을 이용해 해결
중복된 부분 문제를 한 번만 계산하도록 메모이제이션 적용
동적 계획법의 가장 일반적인 사용처: 최적화 문제의 해결
최적 부분 구조(optimal structure)
: 각 부분 문제의 최적해만 있으면 전체 문제의 최적해를 쉽게 얻어낼 수 있는 경우 성립하는 조건
포함된 숫자들이 순증가(strictly increasing)하는 부분 수열
= 증가 부분 수열
증가 부분 수열 중 가장 긴 증가 부분 수열
= 최대 증가 부분 수열(LIS, Longest increasing Sebsequence)
최대 증가 부분 수열(LIS)찾기 문제
: 주어진 수열에서 얻을 수 있는 증가 부분 수열 중 가장 긴 것을 찾는 문제
lis(S): 수열 S를 입력받아 LIS의 길이를 반환하는 재귀함수
-> 입력이 정수의 배열 -> 메모이제이션 까다롭다
int lis(const vector<int>& S){
//base case
if(S.empty()) return 0;
int ret = 0;
for(int i = 0; i< S.size(); ++i){
vector<int> smallS;
for(int j = i+1; j< S.size(); ++j)
if(S[i] < S[j]) smallS.push_back(S[j]);
ret = max(ret, 1 + lis(smallS));
}
return ret;
}
int n;
int cache[100], S[100];
int lis2(int start){
//lis2(start)에 대한 답을 구한 적이 있으면 곧장 반환
int& ret = cache[start];
if(ret != -1) return ret;
//lis2(start)에 대한 답 계산
//항상 S[start]는 있기 때문에 길이는 최하 1
ret = 1;
for(int next = start+1; next < n; ++next)
if(S[start] < S[next])
ret = max(ret, 1 + lis2(next));
return ret;
}
int maxLen = 0;
for(int begin = 0; begin < n; ++begin)
maxLen = max(maxLen, lis2(begin));
int n;
int cache[101], S[100];
int lis2(int start){
//lis2(start)에 대한 답을 구한 적이 있으면 곧장 반환
int& ret = cache[start+1];
if(ret != -1) return ret;
//lis2(start)에 대한 답 계산
//항상 S[start]는 있기 때문에 길이는 최하 1
ret = 1;
for(int next = start+1; next < n; ++next)
if(start == -1 || S[start] < S[next])
ret = max(ret, 1 + lis2(next));
return ret;
}
모든 답을 만들어보고 그 중 최적해의 점수를 반환하는 완전 탐색 알고리즘 설계
전체 답의 점수를 반환하는 것이 아닌,
앞으로 남은 선택들에 해당하는 점수만을 반환하도록 부분 문제의 정의 바꾸기
메모이제이션 적용
재귀 호출의 입력에 이전 선택에 관련된 정보가 있다면 꼭 필요한 것만 남기고 줄인다
-> 최적 부분 구조 성립할 경우 이전 선택에 관련된 정보 완전히 없앨 수 있다
재귀 호출의 입력이 배열이거나 문자열인 경우 가능하다면 적절히 변환
-> 입력의 종류가 줄어들수록 많은 부분 문제 중복됨 -> 메모이제이션 효과적
두 개의 정수 배열 A와 B의 각각 길이가 0 이상의 증가 부분 수열을 얻은 뒤 이들을 크기 순서대로 합친 것
= 합친 증가 부분 수열
합칩 증가 부분 수열 중 가장 긴 수열
= 합친 LIS(JLIS, Joined Longest increasing Sebsequence)
jlis(indexA, indexB): A[indexA]와 B[indexB]에서 시작하는 합친 증가 부분 수열의 최대 길이
이 증가 부분 수열의 다음 숫자:
A[indexA+1]이후 혹은 B[indexB+1]이후의 수열 중 max(A[indexA], B[indexB])보다 큰 수 중 하나
A[-1] = B[-1] = -infinite 로 두고, 이 둘은 항상 jlis에 포함된다고 가정
//입력이 32비트 부호있는 정수의 모든 값을 가질 수 있으므로
//인위적인 최소치(-infinite)는 64비트여야 한다
const long long NEGINF = numeric_limits<long long>::min();
int n, m, A[100], B[100];
int cache[101][101];
//첫번째 숫자가 min(A[indexA], B[indexB])이고, 두번째 숫자가 max(A[indexA], B[indexB])인
//합친 증가 부분 수열의 최대 길이 반환
//단, indexA == indexB == -1 부터 시작하고 A[indexA] != B[indexB]라고 가정한다
int jlis(int indexA, int indexB){
//메모이제이션
// indexA와 indexB의 값으로 -1이 주어질 수 있음
// cache[indexA][indexB] 대신 cache[indexA+1][indexB+1] 사용
int& ret = cache[indexA+1][indexB+1];
if(ret != -1) return ret;
//A[indexA], B[indexB]가 이미 존재하므로 길이는 최하 2
ret = 2;
//A[indexA] (A[-1] = -infinite)
long long a = (indexA == -1 ? NEGINF : A[indexA])
//B[indexB] (B[-1] = -infinite)
long long b = (indexB == -1 ? NEGINF : B[indexB])
//다음 원소 찾기
long long maxElement = max(a, b);
for(int nextA = indexA + 1; nextA < n ; ++nextA){
if(maxElement < A[nextA])
ret = max(ret, 1 + jlis(nextA, indexB));
}
for(int nextB = indexB + 1; nextB < n ; ++nextB){
if(maxElement < B[nextB])
ret = max(ret, 1 + jlis(indexA, nextB));
}
return ret;
}
원주율의 일부가 입력으로 주어질 때, 난이도의 합을 최소화하도록 숫자들을 3~5자리로 끊어 읽으려고 한다
-> 이때 가능한 최소 난이도를 계산하는 프로그램 작성
각 조각들의 난이도
-> 모든 숫자가 같을 때 = 1 (ex. 333, 555)
-> 숫자가 1씩 단조 증가/감소할 때 = 2 (ex. 23456, 3210)
-> 두 개의 숫자가 번갈아가며 나타날 때 = 4 (ex. 323, 54545)
-> 숫자가 등차 수열을 이룰 때 = 5 (ex. 147, 8642)
-> 이 외의 모든 경우 = 10 (ex. 331, 17912)
첫 조각의 길이를 하나하나 시도해보며 남은 수열을 재귀적으로 쪼개도록 구현
-> 길이 3인 조각의 난이도 + 3글자 빼고 나머지 수열에 대한 최적해
-> 길이 4인 조각의 난이도 + 4글자 빼고 나머지 수열에 대한 최적해
-> 길이 5인 조각의 난이도 + 5글자 빼고 나머지 수열에 대한 최적해
한 조각의 숫자가 주어졌을 떄 난이도 판정
const int INF = 987654321;
string N;
//N[a .. b]구간의 난이도를 반환한다
int classify(int a, int b){
//숫자 조각을 가져온다
string M = N.substr(a, b-a+1);
//모든 숫자가 같을 때 -> 1
if(M == string(M.size(), M[0])) return 1;
//등차 수열인지 판별
bool progressive = true;
for(int i = 0; i<M.size()-1; ++i)
if(M[i+1] - M[i] != M[1] - M[0]){
progressive = false;
break;
}
//숫자가 1씩 단조 증가/감소할 때 -> 2
if(progressive && abs(M[1] - M[0]) == 1) return 2;
//숫자가 등차 수열을 이룰 때 -> 5
if(progressive) return 5;
//두 개의 숫자가 번갈아가며 나타나는지 판별
bool alternating = true;
for(int i = 0; i<M.size(); ++i){
if(M[i] != M[i%2]){
alternating = false;
break;
}
}
//두 개의 숫자가 번갈아가며 나타날 때 -> 4
if(alternating)
return 4;
//이 외의 모든 경우 -> 10
return 10;
}
int cache[10002];
//숫자 N[begin .. ]를 외우는 방법 중 난이도의 최소 합을 구한다
int memorize(int begin){
//base case
if(begin == N.size()) return 0;
//메모이제이션
int& ret = cache[begin];
if(ret != -1) return ret;
ret = INF;
for(int L = 3; L <= 5; ++L)
if(begin + L <= N.size())
ret = min(ret, classify(begin, begin+L-1) + memorize(begin+L));
return ret;
}
- #include
- void memset ( void * ptr, int value, size_t num );
-> ptr 포인터에 의해 지정된 메모리 블록의 num 바이트만큼을 지정된 value로 채웁니다
->이 때 메모리 블록을 채우는 기준이 1byte(8bit = unsigned char) 이다
-> 0이 아닌 다른 값으로 메모리를 초기화하고자 할 때 문제가 발생할 수 있다
모든 답을 직접 만들어서 세어보는 완전 탐색 알고리즘 설계
이때, 재귀 호출의 각 단계에서 고르는 각 선택지는 다음과 같은 속성이 성립해야한다
-> 모든 경우는 이 선택지들에 포함되어야 한다
-> 어떤 경우도 두개 이상의 선택지에 포함되지 않아야 한다
최적화 문제를 해결할 때와 같이, 이전 조각에서 결정한 요소들에 대한 입력을 없애거나 변형해서 줄인다
-> 재귀 함수는 앞으로 남아있는 조각들을 고르는 경우의 수만을 반환해야
메모이제이션 적용