Python/자료구조 & 알고리즘

[알고리즘] 정렬 알고리즘② - 재귀, 퀵 정렬(Quick), 병합 정렬(Merge)

sdesign416 2026. 6. 11. 20:06
 

[알고리즘] 정렬 알고리즘 - 버블정렬(Bubble), 선택정렬(Selection), 삽입정렬(Insertion)

[파이썬 자료구조와 알고리즘] 시간 복잡도, 빅오 표기법(자료구조, 정렬 알고리즘의 시간 복잡프로그램을 개발할때에는 같은 결과를 만들어 내는 코드라도 실행 속도는 천차만별이다. 예를 들

sdesign416.tistory.com

 

이 전 글에선 정렬중에서도 안정 정렬에 해당하는 삽입 정렬, 버블 정렬, 병합 정렬을 보았다.

이번 글에서는 안정 정렬인 병합정렬과, 불안정 정렬인 퀵 정렬에 대해 살펴 볼 예정이다.

 

💡 안정 정렬(Stable Sort) 이란?

정렬 후에도 값이 같은 원소들의 순서가 그대로 유지되는 정렬 방식이다.

※ 원래 순서 : 정렬 전의 상대적인 위치


1. 재귀 함수(Recursive Function)란?

재귀 함수는 함수가 자기 자신을 다시 호출하여 문제를 해결하는 방식이다.

복잡한 문제를 더 작고 동일한 형태의 하위 문제로 분해할 수 있으며

하위 문제를 해결하면서 전체 문제를 해결하는 분할 정복 방식이다.

파이썬에서 재귀함수는 1000번까지 횟수가 정해져있어 1000번이상 호출될 경우 오류가 발생하는 규칙이 있다.

재귀 함수를 사용하는 대표적인 알고리즘으로는 퀵 정렬과 병합 정렬이 있다.


1_1) 일반 함수와 재귀 함수의 차이

일반 함수는 외부에서 호출되어 내부의 로직을 수행하고 결과를 반환한다.

재귀 함수는 함수 내부에서 자기 자신을 다시 호출하는 방식으로

복잡한 문제를 더 작은 단위로 쪼개 해결하는 방식이다.

 

즉, 두 함수의 차이는 함수가 자기 자신을 호출하냐 안하냐의 차이다.


1_2) 재귀 함수와 반복문의 차이

재귀와 반복문은 둘 다 특정 작업을 반복하는 같은 목적을 가지고있긴 하지만

동작하는 방식과 메모리 구조에 차이가 있다.

즉, 반복 작업을 진행하는 방법에 차이가 크다.

구분 반복문 재귀
동작 방식 같은 코드를 반복 실행 함수가 자기 자신을 호출
종료 조건 반복 조건이 거짓이 되면 종료 종료 조건 만족 시 종료
메모리 사용 적음 많음
속도 일반적으로 빠름 일반적으로 느림
코드 길이 길어질 수 있음 짧고 간결할때가 많음

 

❓ 재귀를 왜 사용할까?

반복문은 같은 작업을 여러번 수행하는 단순 반복 처리에 적합하지만

재귀는 문제를 더 작은 문제로 나눠 해결할 때 적합하다.

 

일반적인 반복 작업은 반복문을 사용하는것이 좋지만

트리 구조 탐색같은 문제를 계속 분할해야하는 경우엔 재귀를 사용하는것이 더 효율적이다.


1_3) 재귀 함수 종료 조건 (Base Case)

재귀를 사용할때는 종료 조건이 꼭 필요하다.

종료 조건이 없을경우 함수가 계속 자기 자신을 호출하게 되어 무한 반복처럼 동작한다.

그래서 재귀 함수를 작성할때는 종료 조건을 먼저 작성하는것이 좋다.


1_4) 재귀 함수의 장단점

👍 재귀함수 장점

  • 문제를 작은 단위로 나눠 해결할 수 있다.
  • 트리 구조, 폴더 탐색등 문제를 분할하는 알고리즘 구현에 적합하다.

👎 재귀함수 단점

  • 함수 호출이 반복되기 때문에 메모리 사용량이 증가한다.
  • 반복문보다 실행속도가 느릴수도 있다.
  • 종료 조건을 누락하거나 잘못 작성하면 무한 호출이 발생한다.
  • 재귀 호출이 너무 깊어지면 에러가 발생할 수 있다. (Recursion Error)

재귀 함수 사용 예시 1) 팩토리얼 (Factorial)

팩토리얼은 1부터 특정 숫자까지 모두 곱한 값이다.

5! = 5 × 4 × 3 × 2 × 1 = 120

 

✅ 기능 1) 종료 조건

재귀 함수의 특성상 종료조건이 필요하기 때문에

팩토리얼을 구할 때 더이상 계산할 수 없는 가장 작은값인 1에서 종료조건을 실행시킨다.

 

❓ 왜 1을 return할까?

팩토리얼은 곱셉 연산으로 이루어져있다.

그렇기 때문에 마지막 값이 1이어야 전체 결과가 유지되기 때문이다.

만약 0을 반환하면 전체 결과가 0이 되기 때문에 팩토리얼의 결과가 잘못된다.

if n == 1:
    return 1

 

✅ 기능 2) 재귀 호출

팩토리얼을 구하는 핵심부분으로 n과 n-1의 팩토리얼 결과를 곱하는 코드이다.

return n * factorial(n-1)

 

❓ n은 어떻게 줄어들까?

여기서 factorial(n-1) 을 통해 n이 줄어드는데

n 자체가 감소하는것이 아니라, 

n보다 1 작은 값을 가진 새로운 함수가 다시 호출되는 구조인것이다.

factorial(n-1)

# 실행 흐름
factorial(5)
= 5 * factorial(4)
= 5 * 4 * factorial(3)
= 5 * 4 * 3 * factorial(2)
= 5 * 4 * 3 * 2 * factorial(1)
= 5 * 4 * 3 * 2 * 1
= 120

즉, 현재 문제를 더 작은 동일한 문제로 계속 나누는 재귀의 방식을 사용한것이다.

  • 현재 숫자(n) : 결과를 곱해주는 용도
  • factorial(n-1) : 더 작은 문제로 나누는 용도

 

💡 팩토리얼 최종 코드

def factorial(n):
    # 종료 조건
    if n == 1:
        return 1

    # 재귀 호출
    return n * factorial(n-1)

print(factorial(5))

실행결과 ▶


재귀 함수 사용 예시 2) 피보나치 수열 (Fibonacci)

피보나치 수열은 앞의 숫자를 더해 다음 숫자를 만들어내는 수열이다.

현재 값 = 이전 값 + 이전의 이전 값

1, 1, 2, 3, 5, 8, 13 ...

 

✅ 기능 1) 종료 조건

피보나치 수열에선 첫 번째와 두 번째 값이 이미 정해져있기 때문에

종료조건을 n이 2 이하일때로 설정하고

더이상 하위 문제로 나누지 않고 수열의 시작 값인 1을 바로 반환한다.

if n <= 2:
    return 1

 

❓ 왜 n <= 2일까?

f(1) = 1
f(2) = 1

이건 피보나치 수열 시작의 특성으로

처음과 두번째는 이미 값이 정해져있어 값을 계산 할 필요가 없다.

그래서 1과 2는 그대로 1을 반환하도록 하는것이며,

2일때 종료를 하도록 하는것이다.

 

✅ 기능 2) 재귀 호출

피보나치 수열의 공식은 f(n) = f(n-1) + f(n-2) 이것과 같은데

이걸 코드로 표현하면 아래와 같아진다.

return fibonacci(n-1) + fibonacci(n-2)

 

❓ n은 어떻게 줄어들까?

팩토리얼처럼 n이 감소하는 구조가 아닌

더 작은 문제 두개로 나뉘어 새로운 함수가 호출이 되는 구조가 된다.

fibonacci(n-1)
fibonacci(n-2)

# 실행 흐름
fibonacci(5)
= fibonacci(4) + fibonacci(3)
= (fibonacci(3) + fibonacci(2))
  +
  (fibonacci(2) + fibonacci(1))
  
# 트리 구조로 확인하기
          fib(5)
        /        \
     fib(4)     fib(3)
     /   \       /   \
 fib(3) fib(2) fib(2) fib(1)

 

  • f(n-1) → 바로 이전 값
  • f(n-2) → 두 칸 이전 값

 

 

💡 피보나치 수열 최종 코드

def fibonacci(n):
    # 종료 조건
    if n <= 2:
        return 1

    # 재귀 호출
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(7))

실행결과 ▶


2. 병합 정렬(Merge Sort)

병합 정렬을 대표적인 안정 정렬 알고리즘 중 하나로 분할 정복 방식을 사용한다.

데이터를 더이상 나눌 수 없는 가장 작은 단위로 나눈 뒤, 정렬된 상태로 합치는 방식으로

분할(Divide), 정렬(Sort), 병합(Merge) 3단계의 과정으로 작동한다.


2_1) 병합 정렬의 정렬과정

  1. 배열을 가운데를 기준으로 절반씩 분할한다.
  2. 배열의 크기가 1이 될 때 까지 분할을 반복한다.
  3. 분할된 배열들을 하나씩 합치면서 값을 비교하여 정렬한다.
  4. 정렬된 배열을 계속 합쳐가면서 전체 정렬을 진행한다.
# 병합 정렬 예시 리스트
[5, 3, 8, 2]

# [1단계 분할]
# 분할 1
# : 중앙을 기준으로 나눈다
[5, 3] [8, 2]

# 분할 2
# : 나눈 리스트에서 또 중앙을 기준으로 나눈다(원소가 1개 될 때 까지)
[5] [3] [8] [2]


# [2단계 정렬, 병합]
# 정렬1
# : 5와 3을 비교 후 정렬, 병합
[3, 5]

# 정렬2
# : 8과 2를 비교 후 정렬, 병합
[2, 8]

# 정렬3
# : [3,5]와 [2,8] 비교 후 정렬, 병합
[2, 3, 5, 8]


# 최종결과
[2, 3, 5, 8]

 

 


2_2) 병합 정렬의 시간복잡도와 공간복잡도

2_2_a) 시간 복잡도

병합 정렬은 데이터를 분할하고 병합하는 과정이 일관되게 이뤄진다.

그렇기 때문에 데이터의 정렬 상태와 관계없이 항상 동일한 시간 복잡도를 가진다.

  • 평균 / 최선 / 최악 : O(n log n)

2_2_b) 공간 복잡도

병합 정렬은 다시 합치는 과정에서 새로운 리스트를 또 만들어야한다.

그래서 원본 데이터의 크기만큼 추가 메모리가 필요하기 때문에 O(n)의 공간복잡도를 가진다.

  • 공간복잡도 : O(n)

2_3) 병합 정렬의 장단점

👍 병합정렬 장점

  • 시간 복잡도가 o(n log n)이기 때문에 대규모 데이터에서도 빠른 성능을 유지할 수 있다.
  • 최악의 경우에도 O(n log n)의 시간복잡도를 보장한다.
  • 같은 값의 순서는 바뀌지 않는다.

👎 병합정렬 단점

  • 임시로 하위 배열을 저장하기 위한 추가 메모리 공간이 필요하다.
  • 재귀 호출과 병합 프로세스 오버헤드가 발생할 수 있어 소규모 프로젝트에선 오히려 효율성이 떨어진다.

2_4) 병합 정렬 파이썬 코드로 구현하기

병합 정렬의 경우 배열을 분할하고 다시 합치는 과정으로 진행되기 때문에

배열을 반으로 분할하는 함수와 분할이 끝난 뒤 다시 합쳐 줄 함수 2개가 필요하다.

 

 기능 1) merge_divide_asc함수 if문 : 종료 조건 설정

배열의 길이가 1 이하라면 더 이상 나눌 수 없으며 이미 정렬된 상태이므로 그대로 반환한다.

if len(arr) <= 1: # 탈출 조건
    return arr

 

 기능 2) 배열 절반으로 분할

병합 정렬은 배열을 계속 절반으로 나누어 가장 작은 단위까지 분할한다.

배열의 중간 위치를 구한 후 왼쪽과 오른쪽으로 나누어 각각 다시 병합 정렬을 진행한다.

# 배열의 중간 위치
mid = len(arr) // 2

# 배열의 왼쪽 절반을 정렬(재귀)
left = arr[:mid]

# 배열의 오른쪽 절반을 정렬(재귀)
right = arr[mid:]

 

 기능 3) merge_combine함수 while문 : 두 배열 비교하며 병합

분할이 끝난 배열들은 하나의 원소만 가지게 된다.

왼쪽 배열과 오른쪽 배열을 앞에서부터 비교하며 더 작은 값을 result에 추가한다.

# 왼쪽과 오른쪽 숫자 남아있을때까지 반복
while i < len(left) and j < len(right):

	# 만약 왼쪽 목록 현재 숫자가 오른쪽 현재 숫자보다 작으면
    if left[i] < right[j]:
        # : 왼쪽 목록의 숫자를 result에 넣고
        result.append(left[i])
        # : 왼쪽 목록 다음 숫자로 넘어감
        i += 1
        
    # 그렇지 않으면 오른쪽을 수행
    else:
        result.append(right[j])
        j += 1


 기능 4) 남은 데이터 추가

한쪽 배열의 모든 데이터를 사용했다면 다른 배열에 남아있는 데이터는 이미 정렬된 상태이므로 그대로 뒤에 붙여준다.

# 반복 끝난 후 왼쪽, 오른쪽 목록에 남은 숫자들 모두 result뒤에 붙임
result += left[i:]
result += right[j:]


 기능 5) 오름차순과 내림차순

부등호만 변경하면 내림차순 정렬을 구현할 수 있다.

# 오름차순
if left[i] < right[j]:

# 내림차순
if left[i] > right[j]:

 

💡 병합 정렬 최종코드 : 오름차순

# 오름차순 최종코드
import random

# 배열을 계속 반으로 나눌 함수
def merge_divide_asc(arr): 
    if len(arr) <= 1:
        return arr 

    mid = len(arr) // 2 
    left = merge_divide_asc(arr[:mid])
    right = merge_divide_asc(arr[mid:])

    return merge_combine_asc(left, right)

# 분할이 끝난 배열을 합치는 함수
def merge_combine_asc(left, right): # 이미 정렬된 왼쪽, 오른쪽

    result = [] # 합친 결과 담을 빈 목록
    i = 0 # 왼쪽 목록의 현재 위치
    j = 0 # 오른쪽 목록의 위치

    while i < len(left) and j < len(right): 
        if left[i] < right[j]:  
            result.append(left[i]) 
            i += 1 
        else: 
            result.append(right[j]) 
            j += 1 

    result += left[i:] 
    result += right[j:] 
    return result 

random_data = random.sample(range(1,101),5)
print("병합 정렬 전:" , random_data)
print("병합 정렬 후:", merge_divide_asc(random_data))

실행결과 ▶ 랜덤 데이터이기 때문에 실행할때마다 결과가 바뀐다.

💡병합 정렬 최종코드 : 내림차순

# 내림차순 최종코드
import random

# 배열을 계속 반으로 나눌 함수
def merge_divide_desc(arr): 
    if len(arr) <= 1:
        return arr 

    mid = len(arr) // 2 
    left = merge_divide_desc(arr[:mid])
    right = merge_divide_desc(arr[mid:])

    return merge_combine_desc(left, right)

# 분할이 끝난 배열을 합치는 함수
def merge_combine_desc(left, right): # 이미 정렬된 왼쪽, 오른쪽

    result = [] # 합친 결과 담을 빈 목록
    i = 0 # 왼쪽 목록의 현재 위치
    j = 0 # 오른쪽 목록의 위치

    while i < len(left) and j < len(right): 
        if left[i] > right[j]:  
            result.append(left[i]) 
            i += 1 
        else: 
            result.append(right[j]) 
            j += 1 

    result += left[i:] 
    result += right[j:] 
    return result 

random_data = random.sample(range(1,101),5)
print("병합 정렬 전:" , random_data)
print("병합 정렬 후:", merge_divide_desc(random_data))

실행결과 ▶ 랜덤 데이터이기 때문에 실행할때마다 결과가 바뀐다.


3. 퀵 정렬(Quick Sort)

퀵 정렬은 분할 정복 알고리즘으로 기준값(Pivot)을 하나 정해서 작은 값은 왼쪽, 큰 값은 오른쪽으로 나누는 방식이다.

나눈뒤엔 왼쪽과 오른쪽의 영역에서 또 같은 방식으로 반복을 진행하며 정렬한다.

퀵정렬은 재귀를 사용하는 대표적인 알고리즘이다.


💡 피벗(Pivot)이란?

피벗은 기준값을 말한다.

퀵정렬에서 사용되는 용어로

피벗보다 작은 값은 왼쪽, 피벗보다 크거나 같은 값은 오른쪽으로 보낸다.

즉, 피벗은 왼쪽과 오른쪽 사이에 위치한다.


3_1) 퀵 정렬의 정렬과정

  1. 기준값(Pivot)을 하나 선택한다.
  2. 기준값보다 작으면 왼쪽, 크면 오른쪽으로 분할한다.
  3. 분할된 배열에서 다시 기준값을 선택하여 과정을 반복한다.
  4. 배열의 크기가 1이 될 때 까지 분할한 후 다시 결합하여 정렬을 진행한다.
# 퀵 정렬 예시 리스트
[5, 3, 8, 4, 2]

# 1회전
# : 피벗을 첫번째 값인 5로 선택
[3, 2, 4] → 5보다 작은 값(왼쪽)
[8] → 5보다 큰 값(오른쪽)
→ [3, 2, 4] + [5] + [8]

# 2회전
# : 왼쪽 리스트에서 첫번째 값 3 선택
[2] → 3보다 작은 값(왼쪽)
[4] → 3보다 큰 값(오른 쪽)
→ [2] + [3] + [4]

# 3회전
# : 오른쪽 리스트인 8은 값이 1개 → 정렬된것으로 봄

# 최종결과
[2, 3, 4, 5, 8]

 


3_2) 퀵 정렬의 시간복잡도

3_2_a) 시간 복잡도

퀵 정렬의 시간 복잡도는 피벗 선택에 따라 달라진다.

피벗을 항상 첫번째 값으로 선택하는 경우에

이미 정렬된 리스트가 주어지면 매번 피벗은 가장 작은 값이 된다. [1, 2, 3, 4, 5]

그렇게 될 경우 피벗기준 왼쪽은 계속 비어있고 오른쪽으로만 데이터가 쏠리게 되는데

이렇게 되면 퀵 정렬로 나누는 의미가 없으며 시간복잡도가 O(n²)이 되는것이다.

  • 평균 / 최선 : O(n log n)
  • 최악 : O(n²)

3_2_b) 공간 복잡도

퀵 정렬은 재귀 호출 스택을 사용하여 스택의 깊이에 따라 공간 복잡도가 결정된다.

배열이 균형 있게 분할될경우 스택의 깊이는 log n이 되고

불균형하게 분할될때는 n이 될 수 있다.

  • 평균 / 최선 : O(log n)
  • 최악 : O(n)

3_3) 퀵 정렬의 장단점

👍 퀵정렬 장점

  • 평균적으로 빠른 속도로 정렬할 수 있다.
  • 추가 메모리가 적게 필요하다.

👎 퀵정렬 단점

  • 안정 정렬이 아니기 때문에 동일한 값의 순서가 보장되지 않는다.
  • 최악의 경우 성능이 좋지않기 때문에 Pivot 선택이 중요하다.

3_4) 퀵 정렬 파이썬 코드로 구현하기

 기능 1) 종료 조건 설정

퀵 정렬은 재귀를 사용하고 있기 때문에 종료조건이 필요하여 종료조건을 추가해주었다.

배열의길이가 1이하일 경우 이미 정렬된 상태이기에 그대로 반환하며 종료

if len(arr) <= 1:
    return arr

 

 기능 2) Pivot(기준값) 선택

퀵 정렬의 기준이 되는 Pivot값을 하나 선택한다.

보통 첫번째값이나 마지막값 또는 중앙값을 사용하는데

예제에선 첫번째 값을 Pivot으로 선택하여 사용하였다.

pivot = arr[0]

 

 기능 3) 데이터 분할

Pivot 보다 작은값은 왼쪽으로 큰 값은 오른쪽으로 데이터를 분할하여 

각각 left, right 리스트에 담는다.

left = []   
right = []    

#피벗을 제외한 나머지 숫자들 확인
for i in range(1,len(arr)):

    # 피벗보다 작으면 left에추가
    if arr[i] < pivot:     
        left.append(arr[i]) 
        
    # 피벗보다 크면 right에 추가
    else:                  
        right.append(arr[i])


 기능 4) 재귀 호출, 결과 결합

left목록을 퀵 정렬한 결과와 right를 퀵 정렬한 결과 사이에

pivot을 넣어 하나의 리스트로 합친 후 반환한다.

return quick_sort(left) + [pivot] + quick_sort(right)


 기능 5) 오름차순과 내림차순

for문 안의 if문 비교연산자 부등호만 바꾸면 사용할 수 있다.

# 오름차순
if arr[i] < pivot:

# 내림차순
if arr[i] > pivot:

 

💡 퀵 정렬 최종코드 : 오름차순

# 오름차순 최종코드
import random
def quick_asc(array):
    if len(array) <= 1: 
        return array 

    pivot = array[0] 
    left = []   
    right = []     

    for i in range(1,len(array)): 
        if array[i] < pivot:      
            left.append(array[i]) 
        else:                   
            right.append(array[i])

    return quick_asc(left) + [pivot] + quick_asc(right)


random_data = random.sample(range(1,101),5)
print("퀵 정렬 전:" , random_data)
print("퀵 정렬 후:", quick_asc(random_data))

실행결과 ▶ 랜덤 데이터이기 때문에 실행할때마다 결과가 바뀐다.

💡 퀵 정렬 최종코드 : 내림차순

# 내림차순 최종코드
import random
def quick_desc(array):
    if len(array) <= 1: 
        return array 

    pivot = array[0] 
    left = []   
    right = []     

    for i in range(1,len(array)): 
        if array[i] > pivot:      
            left.append(array[i]) 
        else:                   
            right.append(array[i])

    return quick_asc(left) + [pivot] + quick_asc(right)


random_data = random.sample(range(1,101),5)
print("퀵 정렬 전:" , random_data)
print("퀵 정렬 후:", quick_desc(random_data))

실행결과 ▶ 랜덤 데이터이기 때문에 실행할때마다 결과가 바뀐다.


4. 퀵 정렬과 병합 정렬 비교

💡 퀵 정렬이 유리한 경우

평균적으로 빠른 속도가 필요한 경우 퀵 정렬이 더 유리하다.

또한 추가 메모리를 많이 쓰고싶지 않을 때(메모리가 한정적일때)에도 퀵 정렬이 더 유리하다.


💡 병합 정렬이 유리한 경우

항상 안정적인 성능을 유지해야할 때 병합 정렬이 유리하다.

최악의 경우에도 O(n log n)의 시간복잡도를 보장해야하는 상황일 때 병합 정렬이 더 유리하며

안정 정렬이 필요한 경우 퀵 정렬보다 병합 정렬이 더 유리하다.