가장 긴 증가하는 부분 수열 (LIS)
최장 증가 부분 수열 문제란,
주어진 수열에서 오름차순으로 되어있는 가장 긴 부분수열을 찾는 문제이다.
이 때, 부분수열은 반드시 연속적일 필요는 없으며,
필요하다면 중간을 건너뛰는 것도 가능하다.
예를 들어, 수열 A = {10, 20, 60, 50, 30, 40, 20} 에서,
가장 긴 증가하는 부분 수열은 A = {10, 20, 60, 50, 30, 40, 20} 이고, 길이는 4 이다.
대표적인 동적 계획법(Dynamic Programming) 문제이며,
이 게시글에서 설명할 주제는 다음과 같다.
- LIS의 특성
- N^2 문제풀이
- N*logN 문제풀이
- 경로 역추적 (백트래킹)
LIS의 특성
어떤 수열에 대해 적절히 n개를 삭제하여 최대한 긴 증가 부분 수열을 만드는 것이므로,
이 알고리즘을 수행하면 다음 두 가지 정보를 얻을 수 있다.
- LIS를 만들기 위해 선택해야 할 요소. (또는 길이)
- LIS를 만들기 위해 제거해야 할 요소. (또는 길이)
e.g)
수열 A = {10, 20, 60, 50, 30, 40, 20} 에서,
가장 긴 증가하는 부분 수열은 A = {10, 20, 60, 50, 30, 40, 20} 이다.
따라서,
- LIS를 만들기 위해 {10, 20, 30, 40}을 선택해야 하고,
- LIS를 만들기 위해 {60, 50, 20}을 제거해야 한다.
가장 긴 증가하는 부분수열 - N^2 알고리즘
먼저 간단한 동적 계획법을 사용하여 LIS를 풀어보자.
동적 계획법이란 동일한 형태의 작은 문제로 나누어 푸는 것 이므로,
큰 문제를 어떻게 작은문제의 합으로 표현할 수 있는지 생각해야 한다.
먼저, 다음과 같은 함수를 정의하자.
int solve(int x) // x번째 인덱스를 끝으로 하는, 증가하는 부분수열의 길이.
수열 A = {10, 20, 60, 50, 30, 40, 20} 에 대해,
최종적인 가장 긴 증가하는 부분 수열의 길이는,
solve(0) ~ solve(6) 중에서 가장 큰 값임이 자명하며,
다음 solve의 값은,
A[x]의 값보다 작은 이전 solve의 최댓값 + 1 임을 알 수 있다.
example)
solve(5)의 값은 A[5] 보다 작은 A[x]를 갖는 solve(x) 중에서 // {solve(0), solve(1), solve(4)}
가장 큰 값은 solve(4) 이므로, solve(5) = solve(4) + 1 이다.
TOP-DOWN 방식
int A[]; int LIS[]; int solve(int x){ //! 기저사항. if(x == 0) return 1; //! 메모이제이션. if(LIS[x] != 0) return LIS[x]; //! A[x]보다 작은 solve 중 최대값 구하기. int partial_max = 1; for(auto i=0; i<x; i++){ if(A[i] < A[x]) partial_max = max(partial_max, solve(i) + 1); } //! 메모한 뒤, 반환. LIS[x] = partial_max; return LIS[x]; }
BOTTOM-UP 방식
int A[]; int LIS[]; for(int i=0; i<N; i++){ int partial_max = 1; for(int j=0; j<i; i++){ if(A[j] < A[i]) partial_max = max(partial_max, LIS[j] + 1); } LIS[i] = partial_max; }
적어도 각 위치에 대해 한번씩 순회해야 하고, (N)
그 위치보다 작은 위치에도 한번씩 순회해야 하므로. (N)
시간 복잡도는 N^2이다.
N*logN 문제풀이
A[x]보다 작은 A[y]를 갖는 slove(y)의 최댓값을 구하는 동작을 개선하면,
더 효율적인 풀이가 가능하다.
기본 아이디어는 다음과 같다.
int[ ] seq(int x) // x번째 원소까지 이용했을 때, 증가 수열의 길이를 최대한 늘리는데 가장 유리한 증가 수열.
seq의 길이를 늘리는데 관심을 집중한다면,
모든 원소를 다 이용했을 때의 seq의 길이는, LIS의 길이와 동일할 것 이다.
그렇다면 seq(x)는 앞선 seq(x-1)을 이용해 정의될 수 있을까?
다음과 같은 경우를 살펴보자.
- case1, A[x]가 seq(x-1)의 마지막 항 보다 큰 경우.
seq(x) = seq(x-1)의 마지막 위치에 A[x]를 삽입한 것.
seq의 길이가 늘어나는 방향으로 진행되어야 되기 때문이다.
case2, A[x]가 seq(x-1)의 마지막 항에 비해서만 작은 경우.
seq(x) = seq(x-1)의 마지막 원소가 A[x]로 갱신된 것.
이 경우에는 seq의 길이를 직접적으로 늘릴 수 없는 것에 유의하자.
A[x]가 길이를 늘리는데 참여하려면 seq의 마지막 원소보다 먼저 나왔어야 했다.
하지만, 다음 seq의 길이가 늘어나는데 최대한 유리하게 갱신해야 하므로.
이전 seq의 마지막 원소를 A[x]로 갱신한다.
이 경우가 반복되면 seq의 마지막 원소가 항상 작아지는데,
이렇게 되면 작은 수로도 seq의 마지막 위치에 삽입될 수 있기 때문에 유리하다.
따라서, seq의 마지막 원소가 A[x]로 갱신되는 것이,
다음 seq의 길이가 늘어날 가능성을 최대한 높이는 것이 된다.
예를 들어, 다음의 그림을 보자.
마지막 원소가 작은쪽으로 갱신되었기 때문에, 40을 마지막에 붙일 수 있었다.
case3, A[x]가 중간항에 비교해서 작을 경우.
seq(x) = seq(x-1)에서 lower_bound(A[x]) 위치를 A[x]로 갱신한 것.
이유는 case2와 비슷한데, 다음 seq의 길이를 최대화 시키기 위해서는,
seq의 마지막 원소가 최대한 작은것이 유리하다.
또한, seq[last]가 작아지려면 seq[last-1]가 작아야 유리하고,
seq[last-1]이 작아지려면 seq[last-2]가 작아야 유리하다.
즉, seq[x]가 작아지려면 seq[x-1]이 작아야 유리하다.
seq의 정의상 정렬을 깰 수 없으므로,
A[x]보다 처음으로 큰 원소를 A[x]로 바꿔야 한다.
다음의 그림을 살펴보자.
seq(6)의 3번째 원소가 25로 작아졌으므로,
A[7]=30이 있었을 경우,
다음 seq(7)의 마지막 원소는 30으로 작아지는데 성공한다.
seq의 길이가 최대한 늘어나는 방향으로 갱신되므로,
seq(N)의 길이는 A[0...N]에서의 LIS의 길이임을 확신할 수 있다.
단, 이것으로 얻을 수 있는 것은 "정답의 길이"라는 것을 잊지 말아야 한다.
위의 예시에서 실제 LIS는 {10, 20, 30, 40} 이지만, seq(6)은 {10, 20, 25, 40} 이다.
단지, 다음 부분 수열을 늘리기 유리한 방향으로 업데이트 되기 때문이다.
BOTTOM-UP
int A[ ]; int seq[ ]; int len = 0; for(int i=0; i<N; i++){ int pos = lower_bound(seq, seq+len, A[i]) - seq; seq[pos] = A[i]; if(pos == len) len++; } cout << len;
이것으로 A[x]보다 작은 solve의 최댓값을 logN 시간만에 구할 수 있으므로,
최종 시간 복잡도는 N*logN 이다.
경로 찾기
실제 LIS의 경로를 역추적하기 위해서는, 간단한 수정이 필요하다.
int pos[ ] // 최선을 다했을 때, A[x]가 seq에 위치되는 인덱스 번호.
맨 오른쪽의 최댓값부터 시작하여,
가장 최선으로 갱신된 지점을 선택하면 된다.
pos[2..4] = 3 이지만, pos[4]가 가장 최선인 지점임을 유의하자.
LIS 관련 문제
'# Foundation > 알고리즘' 카테고리의 다른 글
그래프 이론 (1) (0) | 2019.07.23 |
---|---|
가장 긴 공통 부분 수열 (LCS - Longest Common Subsequence) 알고리즘 (0) | 2019.07.02 |
가장 긴 공통 부분 문자열 (LCS - Longest Common Substring) 알고리즘 (0) | 2019.07.01 |
동적 계획법(Dynamic Programming) 알고리즘 (0) | 2019.06.25 |
이분탐색(Binary Serach) 알고리즘 (0) | 2019.05.24 |