본문 바로가기

Computer Science

[Computer Science] Divide and Conquer(분할 정복) - 퀵정렬과 병합정렬(Quick Sort and Merge Sort)

Divide and Conquer(분할 정복)

복잡하거나 큰 문제를 여러 개로 나눠서 푸는 방법.
비슷한 크기로 문제를 분할하고, 해결된 문제를 제외하고 동일한 크기로 문제를 다시 분할한다.
병렬적으로 문제를 해결할 수 있다.
  • 단점: 동일한 문제를 다시 푼다. 문제해결함수가 재귀적으로 호출될 수 있으므로 메모리가 추가적으로 사용된다.

!!! 재귀호출과 분할정복의 차이 !!!

  • 재귀호출은 같은 함수코드를 재호출하는 것(같은 함수코드 사용)
  • 분할정복은 비슷한 작업을 재진행하는 것(같은 함수코드는 아닐 수 있음)
'''  1부터 10까지의 합 구하기 '''

#재귀 함수 사용
def r_func(num):
    if num < 1:
        return 0
    else:
        return num + r_func(num-1)

print('재귀함수', r_func(10))


# 분할 정복으로 풀이

def dc_func(num):
    if num == 1: # base case
        return 1 # Conquer ==> result
    if num % 2 == 1:  # Divide
        return dc_func(num - 1) + num  # combine => conquer & merger
    else:  # Divide
        return dc_func(num / 2) * 2 + (num / 2) * (num / 2)  # combine => conquer & merger

print('분할 정복', dc_func(10))

'''
재귀함수 55
분할 정복 55.0
'''

분할정복의 용어

  • Base case: 문제를 분할할 수 없거나, 할 필요없는 경우에 대한 답을 구함(기본 케이스)
  • Divide: 본 문제를 서브문제로 분할
    • 본 문제를 서브 문제로 분할 할 수 있는가?
  • Merge: 서브문제의 답을 구한 경우, 해당 답을 본 문제에 대한 답이 되도록 병합함
    • 서브 문제의 답을 병합하여 본문제의 답을 구하는 것이 효율 적인가? (Conquer)
    • 개별적으로 Conquer 후 Merge

 

퀵 정렬 (Quick Sort)

기준 데이터를 설정하고 그기준보다 큰 데이터와 작은 데이터의 위치를 바꾸는 방법.

일반적인 상황에서 가장 많이 사용되는 정렬 알고리즘.

퀵소트는 한정적인 범위에서 데이터를 정렬하기 때문에 캐시를 덜 활용하고, 하드웨어적으로 효율적이다

 

병합 정렬과 더불어 대부분의 프로그래밍 언어의 정렬 라이브러리의 근간이 되는 알고리즘

일반적으로 퀵소트의 시간복잡도가 병합정렬보다 크다.(둘 다 nlogn 정도의 시간복잡도를 갖는다.

가장 기본적인 퀵 정렬은 첫번째 데이터를 기준데이터(Pivot)로 설정 함.

분할정복(divide and conquer)을 통해 정렬하고, 피벗이라는 별도의 노드를 지정해두고 재귀적으로 수행을 하기 때문에 더 빠르다.

 

단점: 악의 경우에 첫번째 노드를 피벗으로 설정하고 불균형하게 분할정복을 시도하여 성능이 안 좋아진다.

  • 속도는 알고리즘을 처리하고 결과를 도출하는 속도(주로 소프트웨어에 영향을 주고 받음)
  • 성능은 메모리에 영향을 주는 정도(주로 하드웨어에 영향을 주고 받음)

퀵정렬 순서 (수도코드방향)

  • 파티션(영역)을 나눈다.
  • 정해진 피벗을 중심으로 양옆으로 영역을 나눈다.
  • 나눠진 영역을 중심으로 재귀호출을 진행하는 분할정복구조를 갖고 있다.
array = [7, 5, 9, 0, 3, 1, 6, 2, 8, 4]

'''퀵정렬 소스코드'''
def q_sort(array, start, end):
    if start >= end: #원소가 1개인 경우 종료됨
        return array 
    pivot = start #피벗은 1번째 원소
    left = start + 1 # 2번째 원소가 가장 왼쪽이다 선언
    right = end #가장 마지막 원소가 가장 오른쪽이다 선언

    while(left <= right): # 왼쪽이 오른쪽보다 작거나 같은 경우 반복 = 피벗보다 큰 데이터를 찾을 때 까지 반복
        # 왼쪽과 오른쪽의 진행방향이 교차되는 지점을 찾는 함수
        while(left <= end and array[left] <= array[pivot]):
            left += 1 # 오른쪽 끝의 수가 왼쪽 끝수보다 작거나 같고 왼쪽 1번째 수(pivot)보다 2번째(left)수가 작거나 같으면 오른쪽으로 하나씩 증가 시킨다.
        while(right > start and array[left] >= array[pivot]):
            left -= 1 # 가장 마지막 원소가 1번째 원소 보다 크고, 왼쪽 1번째 수(pivot)보다 2번째 수(left)가 크거나 같으면 왼쪽으로 한칸씩 이동
        if(left>right): #진행방향이 교차되어 엇갈린다면 작은 데이터와 피벗을 스와핑
            array[right], array[pivot] = array[pivot], array[right]
        else: # 엇갈리지 않은경우 서로 데이터를 스와핑
            array[left], array[right] = array[right], array[left]

    #분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬을 재귀적으로 수행
    q_sort(array, start, right-1) #왼쪽 부분
    q_sort(array, right+1, end) #오른쪽 부분

q_sort(array, 0, len(array)-1)


''''퀵정렬을 리스트 슬라이싱과 컴프리핸션을 이용해서 만든 소스코드'''
def quick_sort(array):
    #리스트가 하나 이하의 원소만을 담고 있다면 종료.
    if len(array) <= 1:
        return array
    pivot = array[0] # pivot 은 array의 첫번째 원소
    tail = array[1:] #pivot을 제외한 리스트 슬라이싱

    left_side = [x for x in tail if x<=pivot] # 분할된 왼쪽 부분, x는 첫번재 이후 범위에 있는 인자로 피봇보다 작거나 같다 
    righ_side = [x for x in tail if x > pivot] # 분할된 오른쪽 부분, x는 첫번재 이후의 범위에 있는 피봇보다 큰인자

    #분할된 왼쪽 부분과 오른쪽 부분에서 각 정렬이 수행되고 전체 리스트 반환 시키기
    return quick_sort(left_side) + [pivot] + quick_sort(righ_side)

print(quick_sort(array))

 

퀵 정렬의 시간 복잡도

Avg O(NlogN)
Worst O(n^2)
  • 평균의 경우 O(nlogn)
  • 최악의 경우(한쪽 방향으로 편향된 정렬이 이루어질 경우) O(n^2)의 시간 복잡도를 가진다. 

1) 퀵소트의 최선의 경우

 

2) 퀵소트의 최악의 경우

퀵정렬이 빠른이유

직관적이 이해

이상적인 경우 분할이 절반씩 일어난다면 전체 연산 횟수로 O(nlogn)을 기대할 수 있다. 

너비x높이 = n x logn = nlogn

 

 

병합 정렬(Merge Sort)

병합 정렬과 더불어 대부분의 프로그래밍 언어의 정렬 라이브러리의 근간이 되는 알고리즘
병합정렬은 분할과 교체를 반복한다.
일단 시간을 들여서 1개의 서브리스트가 나올때까지 분할을 진행한다.
  • 배열값을 반복문을 통해 비교->정렬->교환 후 합치는 과정을 연속적으로 진행한다.

 

''' 병합정렬 소스코드'''

def mergesort(arr):
    if len(arr) > 1:        # array가 유효한 경우 (1보다 큰경우)
        mid = len(arr)//2   # 전체array 길이의 중간을 지정

        left = arr[:mid]    # 왼쪽은 처음부터 중간지점 인덱스전까지
        right = arr[mid:]   # 오른쪽은 중간지점 인덱스 부터 끝까지지

        mergesort(left)     # 왼쪽 부분을  재귀적으로 값을 분할정복 하여 함수 호출하여 병합 정렬
        mergesort(right)    # 오른쪽쪽 부분을  재귀적으로 값을 분할정복 하여 함수 호출하여 병합 정렬

        i = j = k = 0       # 인덱스 인자 초기화 
        
        # Merge 정렬 - 배열의 인덱스를 참조하여 비교->정렬->교환
        while i < len(left) and j < len(right): # 인덱스의 숫자가 왼쪽과 오른쪽의 길이보다 작은 경우 반복복
            if left[i] < right[j]:  # 오른쪽값 > 왼쪽값
                arr[k] = left[i]    # 왼쪽값을 결과값에 저장장
                i+=1                # 왼쪽배열값의 인덱스를 +1 증가시키고, 결과를 위한 배열에 저장

            else:                   # 오른쪽값 < 왼쪽값
                arr[k] = right[j]   # 오른쪽값을 결과 값에 저장장
                j+=1                # 오른쪽 배열값값의 인덱스를 +1 증가시키고, 결과를 위한 배열에 저장
            
            k+=1                    # 다음으로 진행하기위해 배열의 인덱스 +1증가 시키고 저장

            # 중간 확인 (필수 아님, 공부위함함)
            print('배열의 오른쪽',right)
            print('배열의 왼쪽', left)
            ''' 
            배열의 오른쪽 [3]
            배열의 왼쪽 [7]
            배열의 오른쪽 [16]
            배열의 왼쪽 [2]
            배열의 오른쪽 [2, 16]
            배열의 왼쪽 [3, 3]
            배열의 오른쪽 [2, 16]
            배열의 왼쪽 [3, 3]
            배열의 오른쪽 [2, 16]
            배열의 왼쪽 [3, 3]
            배열의 오른쪽 [4]
            배열의 왼쪽 [24]
            배열의 오른쪽 [9]
            배열의 왼쪽 [11]
            배열의 오른쪽 [9, 9]
            배열의 왼쪽 [4, 4]
            배열의 오른쪽 [9, 9]
            배열의 왼쪽 [4, 4]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            배열의 오른쪽 [4, 4, 11, 9]
            배열의 왼쪽 [2, 3, 3, 16]
            '''
            
            # 왼쪽 확인
            while i < len(left):    # 왼쪽의 길이가 i보다 클경우 = 왼쪽 끝나지 않음음 
                arr[k] = left[i]    # 정렬된 왼쪽 값을 array에 저장한다
                i+=1                # 왼쪽 인덱스 +1 해준다음 저장
                k+=1                # array 인덱스 +1 해준다음 저장

            # 오른쪽 확인
            while j < len(right):   # 오른쪽 길이가 j보다 클경우 = 오른쪽 안끝남
                arr[k]= right[j]    # 정렬된 오른쪽 값을 array에 저장한다
                j+=1                # 왼쪽 인덱스 +1 해준다음 저장
                k+=1                # array 인덱스 +1 해준다음 저장

# 정렬된 결과 출력 함수
def printlist(arr): 
    for i in range(len(arr)):   # array 길이의 범위안에 있는 i 인자를 
        print(arr[i], end=' ')  # 구분은 ' ' 로 array의 각 인자들을 출력
    print ()

# test 
arr = [7,3,2,16,24,4,11,9]  
print ("정렬되기 전 테스트 배열", end ="\n") 
printlist(arr) 
'''
정렬되기 전 테스트 배열
7 3 2 16 24 4 11 9 
'''
# 병합정렬 알고리즘을 적용하기 위해 처음으로 병합정렬 함수를 호출한다.
mergesort(arr) 
print("병합정렬로 정렬된 결과 배열: ", end ="\n") 
printlist(arr) 
'''
병합정렬로 정렬된 결과 배열:
2 3 7 16 4 24 9 11
'''

병합정렬의 시간복잡도

O(nlogn)
  • 병합정렬은 divide and conquer 로직으로 인해, 전체 데이터를 기준으로 처음과 끝을 계속해서 탐색하기 때문에 퀵소트에 비해 느리다.
  • 병합정렬이 활용되는 이유는 퀵소트보다 빠르지는 않지만, 안정(stable) 정렬이기 때문에 데이터가 중복되었더라도 영향을 덜 받는다