오늘은 분할정복 알고리즘과 다이나믹 프로그래밍에 대해 알아보고 실제로 그 둘의 공통점과 차이점에 대해서도 공부해보려고 합니다.
분할정복(Divide and Concquer) 알고리즘은 그대로 해결할 수 없는 문제를 작은 문제 단위로 쪼개면서 해결하는 방법입니다. 분할정복 알고리즘의 설계 방식은 Divide
, Conquer
, Combine
으로 나뉘어서 설계를 합니다.
보통 분할정복 알고리즘에는 효율성을 위해 재귀(Recursion)이 사용되는 경우가 많습니다. 하지만 문제 해결 코드 작성 효율성 측면도 있지만 많은 재귀함수가 오히려 오버헤드를 야기시킬 수 있다는 것도 배제해서는 안됩니다.
분할정복 알고리즘은 Quick Sort, Merge Sort, Binary Search, 선택 문제, 고속 푸리에 변환(FFT) 가 가장 대표적인데요. 그 중에서 퀵 정렬과 병합 정렬은 비슷한 것 같으면서도 차이점을 가지고 있는 알고리즘입니다.
시간복잡도는 입니다. Concquer 과정에서 두개의 작은 배열들을 비교해 정렬을 진행하는데요. 이때, A의 모든 값이 B의 모든 값보다 작은 경우 모든 값들을 비교연산 할 필요 없다는 장점이 있습니다.
def merge_sort(arr):
if len(arr) <= 1:
return arr
# 리스트를 반으로 나누기
mid = len(arr) // 2
left_half = arr[:mid]
right_half = arr[mid:]
# 1. divide #
# 나눈 리스트에 대해 재귀적으로 병합 정렬 수행
left_half = merge_sort(left_half)
right_half = merge_sort(right_half)
# 정렬된 두 개의 리스트를 병합
return merge(left_half, right_half)
def merge(left, right):
merged = []
left_idx, right_idx = 0, 0
# 두 개의 리스트를 비교하면서 작은 값을 병합 리스트에 추가
# 2. Concquer, Combine #
while left_idx < len(left) and right_idx < len(right):
if left[left_idx] < right[right_idx]:
merged.append(left[left_idx])
left_idx += 1
else:
merged.append(right[right_idx])
right_idx += 1
# 남은 요소들을 병합 리스트에 추가
merged += left[left_idx:]
merged += right[right_idx:]
return merged
# 예시로 사용할 리스트
arr = [38, 27, 43, 3, 9, 82, 10]
# 병합 정렬 호출
sorted_arr = merge_sort(arr)
print("정렬된 리스트:", sorted_arr)
특정 원소 피봇(pivot) 을 기준으로 배열을 나누고 각 부분을 퀵 정렬로 적용하는 방식입니다.
병합 정렬과 다르게 퀵 정렬은 최악의 시간 복잡도가 가 나올 수 있습니다. 왜냐하면 피봇을 기준으로 하나하나 비교를 하기 때문에 N의 제곱이 나올 수 밖에 없습니다. 정렬 하고자 하는 배열 안에서 정렬이 일어나므로 따로 메모리 공간이 필요하지 않습니다. 단점으로는 나눌떄, 두 리스트의 길이가 서로 다르기 때문에 수행시간이 많이 일어날 수 있다는 단점이 있습니다.(불안정 정렬) 그래서 pivot
의 값이 중요한 것이죠.
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2] # 피벗을 리스트 중간에 위치한 요소로 선택
left = [x for x in arr if x < pivot] # 피벗보다 작은 요소들
middle = [x for x in arr if x == pivot] # 피벗과 같은 요소들
right = [x for x in arr if x > pivot] # 피벗보다 큰 요소들
# 작은 요소들과 큰 요소들에 대해 재귀적으로 퀵 정렬을 수행하고,
# 중간에 위치한 요소들은 그대로 두어 정렬을 완성
return quick_sort(left) + middle + quick_sort(right)
# 예시로 사용할 리스트
arr = [38, 27, 43, 3, 9, 82, 10]
# 퀵 정렬 호출
sorted_arr = quick_sort(arr)
print("정렬된 리스트:", sorted_arr)
퀵 정렬은 추가 메모리 공간이 필요없는 정렬입니다. 그러므로 cache
의 지역성 때문에 병합 정렬보다 속도가 빠르다는 것을 알 수 있습니다.
[스택오버플로우] 퀵정렬이 어떻게 캐시와 연관이 있을까?
💡 퀵소트(Quick Sort)와 머지소트(Merge Sort)의 차이점
앞에서 보셨듯이, 같은 분할정복 알고리즘이지만 차이가 있습니다. 퀵정렬은 피벗을 기준으로 리스트를 분할하기 때문에 피벗의 값이 최소값이나 최대값가 가까우면 최악의 경우가 되어버릴 수 있다는 단점이 있습니다. 그리고 병합정렬은 매번 리스트를 분할하기 때문에 메모리 공간을 사용합니다. 반면, 정복 후에는 각 리스트들이 정렬되어 있기 때문에 순차적인 비교가 가능합니다. 만약header
부터 탐색해야하는 연결리스트에 접근해야한다면 좋은 성능을 낼 수 있습니다.
동적 계획법(Dynamic Programming)은 Optimal SubStructure의 효과를 발휘하는 방법입니다. 반복하기 위해 메모리 공간을 사용해 연산 속도를 증가시킵니다. 이러한 동적 계획법을 구현하기 위해 두가지 방식을 사용합니다.
💡 Optimal SubStructure
답을 구하기 위해 같은 방식의 계산을 반복하는 구조입니다. 같은 방식을 이전에 구해둔 풀이 방식을 사용하는 건데요. 이 규칙을 점화식으로 DP에 적용하는 것입니다.
💡 "동적"이라는 의미의 차이
우리가 흔하게 접했던 동적 할당(Dynamic Allocation)은 실행 중 프로그램 실행에 필요한 메모리를 할당하는 기법입니다. 하지만 여기서다이나믹
이 동적 계획법의다이나믹
과는 다른 의미으로 유의해둡시다.
작은 문제부터 차근차근 답을 도출하는 방식으로 반복문을 사용합니다. 최하위의 답을 구한 다음에 점차 상위 문제로 풀어가는 방식입니다.
memo[1] = 1
memo[2] = 2
n = 99
# 메모를 채워가는 방식
for i in range(3, n+1):
memo[i] = memo[i - 1] + memo[i - 2]
큰 문제를 풀다가 풀리지 않으면 재귀함수를 호출해서 답을 도출하는 방식입니다.
memo = [0] * 100 # 메모리에 메모할 공간 생성
def fibo(x):
# 문제를 푸되
if x == 1 or x == 2:
return 1
# 만약에 계산한 적이 있는 경우
if memo[x] != 0:
return memo[x]
memo[x] = fibo(x-1) + fibo(x-2)
return memo[x]
이 두가지 방식 중 언제 어떤 방식을 사용하는게 좋을 지 고민을 해봤을 것 같습니다. 효율성을 생각해봤을 때, Bottom-up이 좋지만 만약에 문제의 크기가 그렇게 크진 않고 잘풀리지 않는다면 Top-down 방식을 사용하는게 좋을 것입니다. 스택의 크기를 벗어나게 되지 않는 경우를 말합니다. 사실 나도 점화식을 구하는게 더 어렵기 때문에 재귀로 푸는게 더 많습니다.
💡 메모이제이션(Memoization)
메모이제이션은 값을 구하기 위해 이전에 계산된 값을 저장해 계산하지 않고 속도를 높이는 기술입니다. 문제를 재활용하는 것이죠.memo = [0] * 100 # 메모리에 메모할 공간 생성 memo[0] = 0 # 결과 값 미리 저장 memo[1] = 1
가로 길이가 2이고 세로의 길이가 1인 직사각형모양의 타일이 있습니다. 이 직사각형 타일을 이용하여 세로의 길이가 2이고 가로의 길이가 n인 바닥을 가득 채우려고 합니다. 타일을 채울 때는 다음과 같이 2가지 방법이 있습니다.
def solution(n):
answer = 0
memo = [0] * 60001
for i in range(3, n+1):
memo[1] = 1
memo[2] = 2
memo[3] = 3
memo[i] = (memo[i - 1] + memo[i - 2]) % 1000000007![](https://velog.velcdn.com/images/dongwookang/post/80cdbed0-96f9-4662-9b31-30617c765904/image.png)
return memo[i]
최장 증가 수열은 N개의 원소가 있는 배열 중 일부 원소를 골라내어 만든 수열 중에서 다음과 같은 조건을 만족하는 수열을 최장 증가 수열(Longest Increasing Subsequence)이라고 합니다.
즉, 가장 긴 오름차순 부분 수열을 찾는 것입니다.
물론, 무조건 정답이 [2,3,5,6,7]
이라는 것은 아닙니다. [1,3,5,6,7]
도 될 수 있죠. 이처럼 LIS는 반드시 하나로 결정되는 것은 아닙니다.
LIS가 동작하기 위해 다이나믹 프로그래밍의 DP 테이블을 활용해 문제를 풀 수 있습니다. DP 테이블의 원소 값은 주어진 수열의 특정 값을 마지막 원소로 가지는 부분 수열의 최대 길이를 의미합니다. 그래서 초기에는 주어진 수열의 모든 값들이 1 로 초기화 합니다.
n = int(input()) # 수열의 길이
array = list(map(int, input().split())) # 수열
# Dp 테이블 초기화
dp = [1] * n
이제 수열의 두번째 값(2
)부터 하나씩 확인해서 두번째 값(2)가 직전의 값들보다 크다면 DP 테이블에 두번째 값을 업데이트 합니다. 이때 점화식을 사용하게 되는데 DP 테이블의 두번째 값과 첫번째 값에 1을 더한 값 중 최대값을 업데이트 합니다.
n = int(input()) # 수열의 길이
array = list(map(int, input().split())) # 수열
# Dp 테이블 초기화
dp = [1] * n
for i in range(1,n): # 현재 수열
for j in range(0, i): # 직전의 값들
if array[j] < array[i]:
dp[i] = max(dp[i], dp[j]+1)
# 가장 긴 증가하는 부분 수열의 길이
result = max(dp)
따라서 위와 같은 과정을 반복해서 갱신하면 아래와 같은 DP 테이블이 최종 갱신됩니다.
그러나, DP를 이용한 최장 증가 수열은 의 시간 복잡도를 갖습니다. 그래서 배열의 길이가 길다면 Lower Bound(으로 구현해야 합니다.
두 알고리즘은 문제를 작은 단위로 쪼갠다는 공통점이 존재합니다.
그러나 다만 차이점이 있다면, 다이나믹 프로그래밍은 문제들이 서로 영향을 미친다는 점(즉, 부분 문제들이 증복되어 재활용되는 DP와 다르게 분할 정복법은 서로 중복이 되지 않습니다.)입니다.