가장 긴 증가하는 부분 수열 문제 (Longest Increasing Subsequence, LIS) 는 다이나믹 프로그래밍의 전형적인 문제 중 하나로써, 다이나믹 프로그래밍의 예시로 자주 나오는 문제이다. 비교적 쉬운 O(N^2) 알고리즘과 좀 더 어렵지만 효율적인 O(N log N) 알고리즘이 있다.
부분 수열
어떠한 수열에서 일부 숫자들을 지워서 만들어지는 수열을 부분 수열이라고 한다. 자기 자신도 자기 자신에 대한 부분 수열이라고 볼 수 있다.
예를 들어 [1, 2, 3, 5, 2, 8] 에 대한 부분 수열은 [1], [1, 3, 5] 등이 있다.
증가하는 부분 수열
부분 수열 중 오름차순으로 정렬되어 있는 부분 수열을 증가하는 부분 수열이라고 한다.
예를 들어 [1, 2, 3, 5, 2, 8] 에 대한 증가하는 부분 수열은 [1], [1, 3, 5, 8] 등이 있다.
가장 긴 증가하는 부분 수열
가장 긴 증가하는 부분 수열은 당연히 증가하는 부분 수열 중 가장 긴 것을 뜻한다.
예를 들어 [1, 2, 3, 5, 2, 8] 에 대한 가장 긴 증가하는 부분 수열은 [1, 2, 3, 5, 8] 이 될 것이다.
O(N^2) 알고리즘
dp 테이블의 i번째 원소 (편의상 0번째 원소는 없다고 가정한다) 를 마지막으로 하는 LIS의 길이를 구하면 될 것이다. 즉 1 ~ i - 1 까지의 원소들 중에서 i번째 원소보다 값이 작은 것들 중 가장 큰 dp값을 가지는 원소의 dp값에 1을 더한 값이된다. 점화식은 다음과 같다.
dp[i] = max(dp[i], dp[j] + 1) if array[j] < array[i]
모든 원소들에 대해 이와 같은 처리를 해준 후, dp 테이블에서 가장 값이 큰 원소가 곧 가장 긴 증가하는 부분 수열의 길이가 될 것이다.
def get_lis_length(sequence):
length = len(sequence)
# dp table initialize
dp = [1] * length
for i in range(length):
for j in range(i):
if sequence[i] > sequence[j]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
sequence = [1, 2, 3, 5, 8, 2, 9, 10]
print(get_lis_length(sequence)) # [1, 2, 3, 5, 8, 9, 10], answer = 7
O(N log N) 알고리즘
O(N log N) 의 시간 복잡도를 가지는 LIS 알고리즘을 보기 전에 lower bound라는 것을 먼저 알아두자. 어떠한 정렬된 배열 arr에서 어떠한 값 val의 lower bound란 arr을 정렬된 상태로 유지하면서 val이 삽입될 수 있는 위치들 중 가장 인덱스가 작은 것이다. 파이썬을 사용한다면 이를 bisect 모듈의 bisect_left로 구할 수 있을 것이다. 다음 코드를 보자.
from bisect import bisect_left
def get_lower_bound(array, num):
return bisect_left(array, num)
array = [1, 4, 6, 8, 9]
print(get_lower_bound(array, 2)) # 1 출력
이를 이용하여 O(N log N) 알고리즘을 구현할 수 있다. 기존 O(N^2) 알고리즘은 i번째 원소 직전에 올 수 있는 원소들 중 가장 길게 만들 수 있는 것을 찾을 때 1 ~ i - 1까지의 모든 원소를 순회했다. 그러나 이 과정을 이진 탐색을 통해 O(log N) 시간 안에 수행하게 하여 O(N log N) 의 시간 복잡도를 갖도록 할 수 있다.
이 알고리즘은 새로운 리스트인 L이 필요하다. 리스트 L의 원소 L[i] 는 길이가 i인 증가하는 부분 수열을 만들 수 있는 마지막 원소 중 가장 작은 값을 의미한다. 따라서 L의 크기가 곧 현재까지 만들 수 있는 LIS의 길이이며, 따라서 처음에는 빈 리스트로 시작한다.
이제 각 원소들을 앞에서부터 순회하며, 다음과 같은 과정을 통해 L을 업데이트 한다.
- L이 비어있거나 현재 L의 마지막 원소보다 i번째 원소가 더 큰 경우
- 그렇지 않은 경우
1번의 경우엔 L의 뒤에 i번째 원소를 추가하면 될 것이다. 지금까지 만들 수 있는 가장 긴 증가하는 부분 수열의 마지막 원소보다 i번째 원소가 더 크다는 것은 곧 그것이 가장 긴 증가하는 부분 수열이라는 뜻이기 때문이다.
2번의 경우에는 L배열에서 i번째 원소의 lower bound를 찾아서 그 자리를 i번째 원소로 바꿔준다.
이 과정을 반복한다면 리스트 L은 정렬된 상태가 깨지지 않는다. 따라서 2번의 과정에 이진 탐색을 사용할 수 있다. 위 과정을 마친 후의 L의 길이가 곧 LIS의 길이가 되는 것이다.
이를 파이썬으로 구현하면 다음과 같다.
# O(N log N)
from bisect import bisect_left
def get_lis_improved(sequence):
L = [sequence[0]]
for i in range(1, len(sequence)):
if L[-1] < sequence[i]:
L.append(sequence[i])
else:
lower_bound = bisect_left(L, sequence[i])
L[lower_bound] = sequence[i]
# print(L)
return len(L)
sequence = [2, 3, 6, 8, 1, 4, 4, 9] # L = [1, 3, 4, 8, 9]
print(get_lis_improved(sequence)) # 5 출력
출처
'Python' 카테고리의 다른 글
알고리즘 - 유클리드 호제법을 이용한 최대공약수와 최소공배수 구하기 (Using Euclidean Algorithm to Get GCD and LCM) (0) | 2020.08.30 |
---|---|
파이썬 알고리즘 관련 시간을 줄이는 팁들 (0) | 2020.08.27 |
알고리즘 - 하노이의 탑 재귀적 접근 (Hanoi Tower and Recursive Solving) (0) | 2020.08.17 |
시간 복잡도와 공간 복잡도 (Time Complexity and Space Complexity) (0) | 2020.08.02 |