![[Algorithm] Python으로 구현하는 (QuickSort, MergeSort) Algorithm](/static/516076847d6abd57f064d50daeb89c7c/f2752/Python_Algorithm.jpg)
이번 포스팅에서는 정렬 알고리즘 중 가장 유명한 Quick Sort(퀵 정렬), Merge Sort (병합 정렬)을 자료 구조적 관점에서의 설명과 풀이를 동반해서 정리해봤습니다.
이번 포스트의 이해를 돕기위한 알고리즘 문제는 아래 두 사이트의 문제를 기반으로 작성되었습니다.
제가 풀이 한 코드 - NASA1515 Algorithm Repo제가 풀이 한 코드 - NASA1515 Algorithm RepoQuick Sort , Merge Sort 두가지 정렬 알고리즘은 동일하게 분할 정복 (Devide and Conquer)과 재귀 알고리즘을 이용한 정렬 알고리즘 입니다.
• 퀵 정렬은 배열의 한 요소를 피벗(pivot)으로 선택한 후, 나머지 요소들을 피벗을 기준으로 작은 값과 큰 값으로 나누어 배열을 재배치합니다.
• 재배치 후, 피벗의 왼쪽에는 피벗보다 작은 값들이, 오른쪽에는 큰 값들이 위치하게 됩니다.
• 재귀적으로 왼쪽과 오른쪽 부분 배열에 대해 동일한 작업을 반복하여 전체 배열을 정렬합니다.
• 분할(Partitioning): 피벗을 기준으로 배열을 두 부분으로 나눕니다. 피벗보다 작은 값들은 왼쪽, 큰 값들은 오른쪽에 위치하게 됩니다.
• 정복(Conquer): 분할된 배열에 대해 재귀적으로 퀵 정렬을 수행합니다.
• 결합(Combine): 분할된 배열을 정렬할 필요가 없으며, 재귀 호출이 끝나면 이미 배열은 정렬되어 있습니다.
• 피벗은 배열의 첫 번째 요소, 마지막 요소, 임의의 요소 또는 중간 값으로 선택할 수 있습니다. 피벗 선택 전략에 따라 성능이 달라질 수 있습니다.
• 일반적으로, 무작위 피벗 선택 또는 중간 값 피벗 선택이 최악의 경우를 방지하는데 유리합니다.
import sys
def quick_sort(arr, start, end):
if start < end:
pivot_index = partition(arr, start, end)
quick_sort(arr, start, pivot_index - 1) # 왼쪽 부분 정렬
quick_sort(arr, pivot_index + 1, end) # 오른쪽 부분 정렬
def partition(arr, start, end):
pivot_index = (start + end) // 2
pivot_value = arr[pivot_index]
_swap(arr, start, pivot_index)
left = start + 1
right = end
while left <= right:
while left <= right and arr[left] < pivot_value:
left += 1
while left <= right and arr[right] > pivot_value:
right -= 1
if left <= right:
_swap(arr, left, right)
left += 1
right -= 1
_swap(arr, start, right)
return right
def _swap(arr, first, second):
arr[first], arr[second] = arr[second], arr[first]
if __name__ == "__main__":
inputs = sys.stdin.readline
n = int(inputs())
arr = list(map(int, inputs().split()))
quick_sort(arr, 0, len(arr) - 1) # 퀵 정렬 실행
print(" ".join(map(str, arr))) # 정렬된 결과 출력
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
lesser_arr, equal_arr, greater_arr = [], [], []
for num in arr:
if num < pivot:
lesser_arr.append(num)
elif num > pivot:
greater_arr.append(num)
else:
equal_arr.append(num)
return quick_sort(lesser_arr) + equal_arr + quick_sort(greater_arr)
def quick_sort(arr):
def sort(low, high):
if high <= low:
return
mid = partition(low, high)
sort(low, mid - 1)
sort(mid, high)
def partition(low, high):
pivot = arr[(low + high) // 2]
while low <= high:
while arr[low] < pivot:
low += 1
while arr[high] > pivot:
high -= 1
if low <= high:
arr[low], arr[high] = arr[high], arr[low]
low, high = low + 1, high - 1
return low
return sort(0, len(arr) - 1)
이해를 쉽게 돕기 위해서, BOJ 의 문제의 풀이를 기반으로, 정렬되어 있지 않는 수를 가진 배열로
Quick Sort를 그림으로 설명해보겠습니다.
inputs = sys.stdin.readline n = int(inputs())
input에 [3,7,8,5,2,1,9,5,4]의 값이 입력된 Array를 기준으로, Pivot의 경우 중앙 값 (len(array)//2)로 지정한 뒤, Pivot 값을 맨 끝 element와 교환합니다.Pointer 두개를 기준으로 Index를 왼쪽, 오른쪽으로 이동해가며, Pivot 값 보다 작은 경우와 큰 경우의 값을 Swiching 합니다. while left <= right:
while left <= right and arr[left] < pivot_value:
left += 1
while left <= right and arr[right] > pivot_value:
right -= 1
if left <= right:
_swap(arr, left, right)
left += 1
right -= 1
_swap(arr, start, right)
return right
def _swap(arr, first, second):
arr[first], arr[second] = arr[second], arr[first]
left : pivot 값 보다 큰 값, Right : pivot 값 보다 작은 값을 찾습니다. _swap 함수를 통해서, 큰 값과 작은 값을 교환합니다. = 작은 값을 왼쪽으로 몰아넣기 위해left index의 pointer의 값이 right 보다 같거나, 커질 경우, while loop을 종료합니다. 퀵 정렬 (QuickSort)과 마찬가지로 분할 정복 방법을 통한 정렬 알고리즘이지만, 모든 경우의 수에서 시간 복잡도가 일정되는 안정 정렬(stable sort)입니다.
• 병합 정렬은 한개의 배열을 두개의 균등한 크기로 분할하고, 분할된 부분 리스트를 정렬한 다음, 정렬된 부분 리스트를 합하여, 전체가 정렬된 리스트를 얻습니다.
• 보통 재귀함수를 사용해, 배열 안의 인자 값이 1개일 때 까지 리스트를 분할하게 되고, 1개 일 경우에 분할을 멈추게 됩니다.
• 분할(Divide): 배열, 입력 리스트를 같은 크기의 2개의 부분 리스트로 분할합니다.
• 정복(Conquer): 2개로 분할된 배열의 분할이 멈추면, 부분 리스트 인자의 정렬을 수행합니다. (이때 분할이 멈추는 경우는 리스트 크기가 1일 경우)
• 결합(Combine): 정렬된 부분 리시트를 하나의 리스트로 통합합니다.
def merge_sort(arr:list[int]) -> None:
if len(arr) <= 1: # 배열의 크기가 1이 될 경우 재귀 종료
return
L_ARR = arr[:len(arr)//2] # 왼쪽 배열은 중간 값 이전 값
R_ARR = arr[len(arr)//2:] # 오른쪽 배열은 중간 값 이후 값
merge_sort(L_ARR) # 재귀를 실행시켜 각 인자가 1개씩으로 분할
merge_sort(R_ARR) # 재귀를 실행시켜 각 인자가 1개씩으로 분할
L_index, R_index, merge_index = 0, 0, 0
while L_index < len(L_ARR) and R_index < len(R_ARR):
if L_ARR[L_index] < R_ARR[R_index]:
arr[merge_index] = L_ARR[L_index]
L_index += 1
else:
arr[merge_index] = R_ARR[R_index]
R_index += 1
merge_index += 1
while L_index < len(L_ARR):
arr[merge_index] = L_ARR[L_index]
L_index += 1
merge_index += 1
while R_index < len(R_ARR):
arr[merge_index] = R_ARR[R_index]
R_index += 1
merge_index += 1
if __name__ == "__main__":
input = sys.stdin.readline
N, K = map(int, input().split()) ## N , K 에 해당하는 입력 값 받기
arr = list(map(int, input().split())) # arr 받기
merge_sort(arr) # arr를 merge sort
print(arr[K-1])
이해를 쉽게 돕기 위해서, BOJ 의 문제의 풀이를 기반으로, 정렬되어 있지 않는 수를 가진 배열로 Merge Sort를 그림으로 설명해보겠습니다.
[9,12,20,25,12,14,21]의 값이 입력된 Array를 기준으로, 1(len(array)//2)로 나눈 값 이전, 이후로 Array를 2개로 분할 합니다. 재귀되면서 분할된 Array의 인자 값의 개수가 1개일 경우, 재귀와, 분할을 종료합니다. L_index, R_index, merge_index = 0, 0, 0
while L_index < len(L_ARR) and R_index < len(R_ARR):
if L_ARR[L_index] < R_ARR[R_index]:
arr[merge_index] = L_ARR[L_index]
L_index += 1
else:
arr[merge_index] = R_ARR[R_index]
R_index += 1
merge_index += 1
while L_index < len(L_ARR):
arr[merge_index] = L_ARR[L_index]
L_index += 1
merge_index += 1
while R_index < len(R_ARR):
arr[merge_index] = R_ARR[R_index]
R_index += 1
merge_index += 1
L_index, R_index, merge_index = 0, 0, 0 총 3개의 INDEX Pointer를 생성합니다. ARR에 각 인덱스의 인자값을 삽입합니다. LEFT, RIGHT 인덱스의 Two Pointer로 비교한 오름차순의 데이터로 정렬됩니다. LinkedList에 보다 효율적입니다.퀵 정렬과 알고리즘이 동일하나, 퀵 정렬의 경우 배열이 이미 정렬 되어있거나, Pivot 값이 최소 혹은 최대 원소일 경우, O(n^2)의 시간 복잡도를 갖습니다.
따라서 위의 예시와 같은 배열에서 사용하기엔 부적합합니다., 보통은 퀵 정렬의 진화형인 QuickSelect를 사용하거나, Merge Sort를 사용하죠.
import sys
import random
def q_select(arr:list[int], K:int):
if len(arr) == 1:
return arr[0]
pivot = random.choice(arr) # random 함수를 사용해서, 무작위의 pivot 값을 지정 합니다.
left = [x for x in nums if x > pivot]
mid = [x for x in nums if x == pivot]
right = [x for x in nums if x < pivot]
if K <= len(left):
return q_select(left, K)
elif K <= len(left) + len(middle):
return pivot
else:
return q_select(right, k - len(left) - len(middle))
if __name__ == "__main__":
inputs = sys.stdin.readline
n, k = map(int, inputs().split())
arr = list(map(int, inputs().split()))
result = q_select(arr, k)
로직은 퀵정렬과 동일합니다. 다만 효율적으로 배열의 구분 부분을
list comprehension으로 구현했습니다.
결론적으로 가장 중요한 부분은,K 라는 index의 위치와array list의 len = element 갯수를 이용해서targer index를 구하는 로직입니다.
해당 알고리즘을 사용하는 경우는 특정 배열에서 특정 INDEX를 찾아야 할 때 등 모든 배열을 다 확인하는 것이 비효율적인 경우 입니다.
left = [x for x in nums if x > pivot] # element 값이 pivot 보다 작은 경우
mid = [x for x in nums if x == pivot] # element 값이 pivot과 같은 경우는
right = [x for x in nums if x < pivot] # element 값이 pivot 보다 큰 경우
if K <= len(left): # Target k가 index의 경우 arr의 갯수로 확인
return q_select(left, K) # 이 경우 k 보다 left arr의 개수가 작으니 그 안에 target num이 있는 경우
elif K <= len(left) + len(middle): # 이 경우 pivot 값을 더 했을 때 동일하다면 pivot 값이 해당 target num 인 경우
return pivot
else: # 아니면, 더 큰것이기 때문에, target num k의 숫자도 그만큼 감소 시킨 뒤 Recursive
return q_select(right, k - len(left) - len(middle))
최종적으로는 큰 맥락에서 이번 포스트에 사용된 두개의 알고리즘의 차이를 아래와 같은 표로 비교할 수 있습니다.
결국 분할을 이용해서 특정 element를 탐색하는 로직은 비슷하지만, 해당 배열의 상태나 메모리의 상태에 따라 효율적인 알고리즘을 사용해야합니다.
| 구분 | 퀵 정렬 | 병합 정렬 |
|---|---|---|
| 분할(Partitioning) 방식 | 나뉘어진 배열은 여러 비율로 나뉜다. | 배열은 항상 반으로 나뉜다. |
| 최악의 경우 시간복잡도 | O(n^2) | O(n log n) |
| 사용 용도 | 작은 크기의 배열에서 잘 동작 | 어떤 크기의 Dataset에서도 적절히 동작 |
| 효율성 | 작은 크기 Dataset에서는 병합 정렬보다 빠르다. | 큰 Dataset에서는 퀵 정렬보다 빠르다. |
| 정렬 방식 | 내부 정렬 | 외부 정렬 |
| 별도 저장 공간 | 불 필요 | 필요 |
| 참조 지역성 | 좋음 | 퀵 정렬 대비 떨어짐 |
| Stable | X (그러나 구현 방식에 따라 가능) | O |
이 외에도 추가적인 알고리즘에 대한 풀이를 확인하고 싶으시면, 제 알고리즘 Repo를 확인해주세요.