코드잇의 알고리즘의 정석 수업을 수강하고 정리한 글입니다.
정확하지 않거나 빠트린 내용이 있을 수 있기 때문에 강의를 수강하시길 권장합니다.
분할정복을 공부하려면 재귀 개념에 대한 이해가 선행되어야 한다.
문제를 나누고, 정복하고, 합친다. 과정만 보면 간단하다.
하지만 Conquer단계의 동작 방식을 생각해보면 간단하지가 않다.
나눈 문제(, )조차 복잡하다면 또다시 작은 문제로 나누어야 하고 문제가 충분히 작아질 때 까지 divide 단계는 반복될 수 있다.
충분히 문제가 나누어졌다면 문제를 해결하고 합치는 동작을 반복하며 원래의 문제를 해결해야 한다.
내부적으로 복잡해보이는 이 동작이 분할정복을 어렵게 느끼도록 만든다.
그럼에도 꾸준히 연습하다 보면 복잡한 구조는 신경쓰지 않고 Divide, Conquer, Combine 이 세가지에만 집중해서 문제를 해결하게 될 것이다.
Divide and Conquer를 이용해서 1부터 n까지 더하는 consecutive_sum 함수를 작성하라.
이 함수는 정수 인풋 start와 end를 받고, start부터 end까지의 합을 리턴한다.
end는 start보다 크다고 가정한다.
def consecutive_sum(start, end):
# 코드를 작성하세요
# 테스트
print(consecutive_sum(1, 10))
print(consecutive_sum(1, 100))
print(consecutive_sum(1, 253))
print(consecutive_sum(1, 388))
def consecutive_sum(start, end):
if start == end:
return start
# 부분 문제를 반으로 나눠주기 위해 문제의 정중앙을 정의한다. (divide)
mid = (start + end) // 2
# 각 부분 문제를 재귀적으로 풀고(Conquer), 부분 문제의 답을 서로 더한다.(Combine)
return consecutive_sum(start, mid) + consecutive_sum(mid+1, end)
# 테스트
print(consecutive_sum(1, 10))
print(consecutive_sum(1, 100))
print(consecutive_sum(1, 253))
print(consecutive_sum(1, 388))
55
5050
32131
75466
Divide, Conquer, Combine으로 나누어서 보면 과정이 단순해 보인다. 하지만 두 리스트를 하나의 리스트로 합치는 Combine과정은 그리 간단하지 않다.
두 리스트를 하나의 정렬된 리스트로 합치는 방법을 생각해보자. 다음과 같이 정렬된 두 리스트가 있다고 가정하자.
list1 = [3, 7, 13, 15]
list2 = [2, 5, 9, 11]
combined = []
두 리스트 모두 첫번째 숫자가 가장 작기 때문에 첫번째 숫자를 combined에 넣으면 된다.
# 합병정렬 과정
list1, list2, combined = [3, 7, 13, 15], [2, 5, 9, 11], []
list1, list2, combined = [3, 7, 13, 15], [5, 9, 11], [2]
list1, list2, combined = [7, 13, 15], [5, 9, 11], [2, 3]
list1, list2, combined = [7, 13, 15], [9, 11], [2, 3, 5]
list1, list2, combined = [13, 15], [9, 11], [2, 3, 5, 7]
list1, list2, combined = [13, 15], [11], [2, 3, 5, 7, 9]
list1, list2, combined = [13, 15], [], [2, 3, 5, 7, 9, 11]
list1, list2, combined = [], [], [2, 3, 5, 7, 9, 11, 13, 15]
두 정렬된 리스트를 하나의 정렬된 리스트로 합병 → 합병 정렬(Merge Sort)
[16, 11, 6, 13, 1, 7, 10, 4]
→ 부분문제1: [16, 11, 6, 13]
, 부분문제2: [1, 7, 10, 4]
부분문제1: [16, 11, 6, 13]
→ 부분문제3: [16, 11]
, 부분문제4: [6, 13]
부분문제3: [16, 11]
→ 부분문제5: [16]
, 부분문제6: [11]
부분문제5: [16]
, 부분문제6: [11]
→ 부분문제5의 솔루션: [16]
, 부분문제6의 솔루션: [11]
[16]
, [11]
)은 정복되었다.부분문제5의 솔루션: [16]
, 부분문제6의 솔루션: [11]
→ 부분문제3의 솔루션:[11, 16]
부분문제4: [6, 13]
→ 부분문제7의 솔루션: [6]
, 부분문제8의 솔루션: [13]
부분문제7: [6]
, 부분문제8: [13]
→ 부분문제7의 솔루션: [6]
, 부분문제8의 솔루션: [13]
[6]
, [13]
)은 정복되었다.부분문제7의 솔루션: [6]
, 부분문제8의 솔루션: [13]
→ 부분문제4의 솔루션: [6, 13]
부분문제3의 솔루션: [11, 16]
, 부분문제4의 솔루션: [6, 13]
→ 부분문제1의 솔루션:[6, 11, 13, 16]
합병 정렬 알고리즘에 사용되는 merge함수를 작성해보자.
merge함수는 정렬된 두 리스트 list1, list2를 받아서, 하나의 정렬된 리스트를 반환한다.
def merge(list1, list2):
# 코드를 작성하세요.
# 테스트
print(merge([1],[]))
print(merge([],[1]))
print(merge([2],[1]))
print(merge([1, 2, 3, 4],[5, 6, 7, 8]))
print(merge([5, 6, 7, 8],[1, 2, 3, 4]))
print(merge([4, 7, 8, 9],[1, 3, 6, 10]))
최종적으로 merge sort를 구현해보기 위해서는 merge함수가 필요하다.
divide 단계를 통해 총분히 두 개의 문제로 나누고 conquer 단계를 통해 정복된 솔루션을 얻었다고 가정하자. 이 두 솔루션을 합치는 단계가 combine단계다.
combine단계를 수행하려면 어떻게 해야할까?
두 부분문제의 솔루션으로 solution1=[1, 2, 3, 4]와 solution2=[5, 6, 7, 8]리스트가 반환되었다면 combine을 결과는 [1, 2, 3, 4, 5, 6, 7, 8]이 되어야 한다.
앞서 combine과정에서 설명했던 것처럼 다음과 같은 순서를 반복하면 된다.
combine 결과를 merged_list라고 하자.
1. solution1, solution2의 첫번째 숫자 중 더 작은 숫자를 먼저 merged_list에 넣는다.
2. 남은 값 중 두 리스트(solution1, solution2)의 첫번째 숫자를 비교한다.
3. 1, 2를 반복하다 한쪽에만 남은 숫자를 모두 combined 뒤에 붙인다.
def merge(list1, list2):
merged_list = []
i, j = 0, 0
# list1, list2의 첫번째 숫자를 비교하며 merged_list에 combined
while (i < len(list1) and j < len(list2)):
if list1[i] < list2[j]:
merged_list.append(list1[i])
i += 1
else:
merged_list.append(list2[j])
j += 1
# list2의 남은 항목을 combined
if i == len(list1):
merged_list += list2[j:]
# list1의 남은 항목을 combined
elif j == len(list2):
merged_list += list1[i:]
return merged_list
# 테스트
print(merge([1],[]))
print(merge([],[1]))
print(merge([2],[1]))
print(merge([1, 2, 3, 4],[5, 6, 7, 8]))
print(merge([5, 6, 7, 8],[1, 2, 3, 4]))
print(merge([4, 7, 8, 9],[1, 3, 6, 10]))
Divide and Conquer 방식으로 merge_sort함수를 작성하라. merge_sort는 파라미터로 리스트 하나를 받고, 정렬된 새로운 리스트를 반환한다.
def merge(list1, list2):
merged_list = []
i, j = 0, 0
while (i < len(list1) and j < len(list2)):
if list1[i] < list2[j]:
merged_list.append(list1[i])
i += 1
else:
merged_list.append(list2[j])
j += 1
if i == len(list1):
merged_list += list2[j:]
elif j == len(list2):
merged_list += list1[i:]
return merged_list
# 합병 정렬
def merge_sort(my_list):
# 코드를 입력하세요.
# 테스트
print(merge_sort([1, 3, 5, 7, 9, 11, 13, 11]))
print(merge_sort([28, 13, 9, 30, 1, 48, 5, 7, 15]))
print(merge_sort([2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]))
앞서 combine단계는 구현하였다.
먼저 base case를 작성해본다. 문제가 충분히 작아져서 리스트의 정렬할 숫자가 하나밖에 없다면 어떨까? 그대로 반환하면 된다. base case는 다음과 같다.
if len(my_list) <= 1:
return my_list
이 장의 핵심은 divide and conquer다. 말 그대로 복잡한 문제는 분할해서 정복하면 된다.
[16, 11, 6, 13, 1, 7, 10, 4] 문제는 [16, 11, 6, 13]과 [1, 7, 10, 4]문제로 분할할 수 있다.
1. 두 개의 리스트로 나누어서 divide단계를 수행하는 코드를 작성한다.
2. 재귀적으로 함수를 호출해서 conquer를 수행하는 코드를 작성해보자.
3. conquer의 결과는 merge함수를 통해 combine을 수행하자.
def merge(list1, list2):
merged_list = []
i, j = 0, 0
while (i < len(list1) and j < len(list2)):
if list1[i] < list2[j]:
merged_list.append(list1[i])
i += 1
else:
merged_list.append(list2[j])
j += 1
if i == len(list1):
merged_list += list2[j:]
elif j == len(list2):
merged_list += list1[i:]
return merged_list
# 합병 정렬
def merge_sort(my_list):
# base case
if len(my_list) <= 1:
return my_list
mid = len(my_list) // 2 # 부분문제로 나누기 위한 리스트의 중간 인덱스 값
# 부분 문제로 나눈다 (divide) 두개의 merge_sort를 재귀적으로 호출한다. (divide, conquer)
left_half = my_list[:mid])
right_half = my_list[mid:]
# merge_sort를 재귀적으로 호출해서 부분문제를 해결한다.(conquer)
# merge를 통해 각 부분문제의 솔루션을 합친다. (combine)
return merge(merge_sort(left_half), merge_sort(right_half))
# 테스트
print(merge_sort([1, 3, 5, 7, 9, 11, 13, 11]))
print(merge_sort([28, 13, 9, 30, 1, 48, 5, 7, 15]))
print(merge_sort([2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]))
퀵정렬에서 리스트를 나누는 과정을 Partition이라고 한다.
[16, 11, 6, 13, 1, 4, 10, 7]
에서 7을 피봇이라고 한다[16, 11, 6, 13, 1, 4, 10, 7]
→ [6, 1, 4, 7, 11, 16, 10, 13]
[16, 11, 6, 13, 1, 4, 10, 7]
→ [6, 1, 4] [7] [11, 16, 10, 13]
[6, 1, 4] [7] [11, 16, 10, 13]
-> [1, 6, 4] [7] [10, 11, 13, 16]
[1, 6, 4, 7, 10, 11, 13, 16]
Conquer단계에서의 정렬을 보자. [6, 1, 4]와 [11, 16, 10, 13]은 재귀적으로 해결해야 한다. 그리고 재귀적으로 해결한다는 것은 base case가 존재해야 함을 의미한다.
base case는 어떻게 될까? 리스트의 길이가 1일때 그대로 리스트를 반환하면된다.
partition함수는 파라미터를 3개 받는다. (my_list, start, end)
[16, 11, 6, 13, 1, 4, 10, 7]의 파티션을 수행하기
i가 가리키고 있는 [16, 11, 6, 13, 1, 4, 10, 7]에서 마지막 숫자인 7을 Pivot으로 정한다.
i=0, b=0
i가 가리키고 있는 16은 피봇7보다 크므로 인덱스 i를 증가시킨다. (i=1, b=0)
피봇의 좌측에는 피봇보다 작은값이, 우측에는 피봇보다 큰 값이 들어간다. 파티션을 모두 수행하였다.
partition 함수 설명 내용을 토대로 partition 함수를 작성하라.
partiton함수는 리스트 my_list, 그리고 partition할 범위를 나타내는 인덱스 start와 인덱스 end를 파라미터로 받는다. my_list의 값들을 pivot기준으로 재배치한 후, pivot의 최종 위치 인덱스를 리턴해야 한다.
# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
# 코드를 작성하세요.
# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
# 코드를 작성하세요.
list1 = [4, 3, 6, 2, 7, 1, 5]
pivot_index1 = partition(list1, 0, len(list1) - 1)
print(list1) # [4, 3, 2, 1, 5, 6, 7]
print(pivot_index1) # 4
# 콘솔 출력
[4, 3, 2, 1, 5, 6, 7]
4
[1, 2, 3, 4, 6, 5, 6]
3
partition함수 설명을 참고하여 풀이한다.
# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
my_list[index1], my_list[index2] = my_list[index2], my_list[index1]
# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
i, b, p = 0, 0, len(my_list)-1
while i < p:
if my_list[i] < my_list[p]: # 현재 인덱스의 값이 피봇의 값보다 작으면 수행
swap_elements(my_list, i, b) #
b += 1
i += 1
swap_elements(my_list, p, b)
p = b
return p
# 테스트 1
list1 = [4, 3, 6, 2, 7, 1, 5]
pivot_index1 = partition(list1, 0, len(list1) - 1)
print(list1)
print(pivot_index1)
# 테스트 2
list2 = [6, 1, 2, 6, 3, 5, 4]
pivot_index2 = partition(list2, 0, len(list2) - 1)
print(list2)
print(pivot_index2)
# 콘솔 출력
[4, 3, 2, 1, 5, 6, 7]
4
[1, 2, 3, 4, 6, 5, 6]
3
Divide and Conquer 방식으로 quicksort 함수를 작성하라. quicksort는 파라미터로 리스트 하나와 리스트 내에서 정렬시킬 범위를 나타내는 인덱스 start와 인덱스 end를 받는다.
merge_sort와 달리 quicksort함수는 정렬된 새로운 리스트를 리턴하는게 아닌 파라미터로 받는 리스트 자체를 정렬시킨다.
swap_elements와 partition함수 이전과 동일하게 사용하라.
# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
# 이전 과제에서 작성한 코드를 붙여 넣으세요!
# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
# 이전 과제에서 작성한 코드를 붙여 넣으세요!
# 퀵 정렬
def quicksort(my_list, start, end):
# 코드를 작성하세요.
# 테스트 1
list1 = [1, 3, 5, 7, 9, 11, 13, 11]
quicksort(list1, 0, len(list1) - 1)
print(list1)
# 테스트 2
list2 = [28, 13, 9, 30, 1, 48, 5, 7, 15]
quicksort(list2, 0, len(list2) - 1)
print(list2)
# 테스트 3
list3 = [2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]
quicksort(list3, 0, len(list3) - 1)
print(list3)
# 콘솔 출력
[1, 3, 5, 7, 9, 11, 11, 13]
[1, 5, 7, 9, 13, 15, 28, 30, 48]
[1, 1, 2, 2, 4, 4, 4, 5, 6, 6, 7, 7, 10, 11, 13, 15]
Divide단계: 피봇을 기준으로 부분문제를 나눈다.
Conquer: 부분문제를 재귀적으로 해결한다. Conquer는 저절로 수행되지 않는다. base case를 먼저 생각해보고 앞서 구현한 파티션 과정을 수행해야한다.
Combine: 수행하지 않아도 된다.
# 두 요소의 위치를 바꿔주는 helper function
def swap_elements(my_list, index1, index2):
my_list[index1], my_list[index2] = my_list[index2], my_list[index1]
# 퀵 정렬에서 사용되는 partition 함수
def partition(my_list, start, end):
i, b, p = start, start, end
while i < p:
if my_list[i] < my_list[p]: # 현재 인덱스의 값이 피봇의 값보다 작으면 수행
swap_elements(my_list, i, b) #
b += 1
i += 1
swap_elements(my_list, p, b)
p = b
return p
# 퀵 정렬
def quicksort(my_list, start, end):
# base case
if start >= end:
return
pivot = partition(my_list, start, end)
# Divide: pivot을 기준으로 리스트를 나눈다.
# Conquer: pivot 왼쪽과 pivot 오른쪽을 quicksort로 정렬한다.
quicksort(my_list, start, pivot-1)
quicksort(my_list, pivot+1, end)
# 테스트 1
list1 = [1, 3, 5, 7, 9, 11, 13, 11]
quicksort(list1, 0, len(list1) - 1)
print(list1)
# 테스트 2
list2 = [28, 13, 9, 30, 1, 48, 5, 7, 15]
quicksort(list2, 0, len(list2) - 1)
print(list2)
# 테스트 3
list3 = [2, 5, 6, 7, 1, 2, 4, 7, 10, 11, 4, 15, 13, 1, 6, 4]
quicksort(list3, 0, len(list3) - 1)
print(list3)
# 콘솔 출력
[1, 3, 5, 7, 9, 11, 11, 13]
[1, 5, 7, 9, 13, 15, 28, 30, 48]
[1, 1, 2, 2, 4, 4, 4, 5, 6, 6, 7, 7, 10, 11, 13, 15]