3. 퀵 정렬
퀵 정렬은 지금까지 배운 정렬 알고리즘 중 가장 많이 사용되는 알고리즘이다.
퀵 정렬과 비교할 만큼 빠른 알고리즘으로 '병합 정렬' 알고리즘이 있다.
이 두 알고리즘은 대부분의 프로그래밍 언어에서 정렬 라이브러리의 근간이 되는 알고리즘이다.
퀵 정렬이 어떻게 동작하기에 이름부터 '빠른 정렬 알고리즘'일까?
'기준 데이터를 성정하고 그 기준보다 큰 데이터와 작은 데이터의 위치를 바꾸면 어떨까?'
퀵 정렬은 기준을 설정한 다음 큰 수와 작은 수를 교환한 후 리스트를 반으로 나누는 방식으로 동작한다.
퀵 정렬에서는 '피벗(pivot)' 이 사용된다.
큰 수와 작은 수를 교환할 때, 교환하기 위한 '기준' 이 바로 '피벗'이다.
퀵 정렬을 수행하기 전에는 피벗을 어떻게 할 것인지 미리 명시해야한다.
피벗을 설정하고 리스트를 분할하는 방법에 따라서 여러 가지 방식으로 퀵 정렬을 구분하는데, 가장 대표적인 방식인 호어 분할 (Hoare Partition) 방식을 기준으로 퀵 정렬을 설명해보자.
호어 분할 방식에서는 다음과 같은 규칙에 따라서 피벗을 설정한다.
- 리스트에서 첫 번째 데이터를 피벗으로 정한다.
이처럼 피벗을 설정한 뒤, 왼쪽에서부터 피벗보다 큰 데이터를 찾고, 오른쪽에서부터 피벗보다 작은 데이터를 찾는다.
그 다음 큰 데이터와 작은 데이터의 위치를 서로 교환해준다. 이러한 과정을 반복하면 '피벗'에 대하여 정렬이 수행된다.
그림을 통해 자세한 과정을 살펴보며 이해해보자.
아래와 같이 초기 데이터가 구성되어 있다고 가정한다.
퀵 정렬은 전체를 3개의 파트로 나눠서 보는 게 편하다. 편의상 part 1, part 2, part 3로 나눠서 보자!
[Part 1️]
step 0️⃣ : 리스트의 첫 번째 데이터를 피벗으로 설정하므로, 피벗은 '5'이다. 이후에 왼쪽에서부터 '5'보다 큰 데이터를 선택하므로 '7'이 선택되고, 오른쪽에서부터 '5'보다 작은 데이터를 선택하므로 '4'가 선택된다. 이제 이 두 데이터의 위치를 서로 변경한다.
step 1️⃣ : 그 다음 다시 피벗보다 큰 데이터와 작은 데이터를 각각 찾는다. 찾은 뒤에는 두 값의 위치를 서로 변경하는데, 현재 '9'와 '2'가 선택되었으므로 이 두 데이터의 위치를 서로 변경한다.
step 2️⃣ : 그다음 다시 피벗보다 큰 데이터와 작은 데이터를 찾는다. 단, 현재 왼쪽에서부터 찾는 값과 오른쪽에서부터 찾는 값의 위치가 서로 엇갈린 것을 알 수 있다. 이렇게 두 값이 엇갈린 경우에는 '작은 데이터'와 '피벗'의 위치를 서로 변경한다. 즉, 작은 데이터인 '1'과 피봇인 '5'의 위치를 서로 변경하여 분할을 수행한다.
step 3️⃣ : 분할 완료
이와 같이 피벗이 이동한 상태에서 왼쪽 리스트와 오른쪽 리스트를 살펴보자. 이제 '5'의 왼쪽에 있는 데이터는 모두 '5'보다 작고, 오른쪽에 있는 데이터는 모두 '5'보다 크다는 특징이 있다.
이렇게 피벗의 왼쪽에는 피벗보다 작은 데이터가 위치하고, 피벗의 오른쪽에는 피벗보다 큰 데이터가 위치하도록 하는 작업을 분할(Divide) 혹은 파티션(Partition) 이라고 한다.
이러한 상태에서 왼쪽 리스트와 오른쪽 리스트를 개별적으로 정렬시켜보자!
왼쪽 리스트의 값들은 모두 '5'보다 작고, 오른쪽 리스트의 값들은 모두 '5'보다 크기 때문에 각각 정렬을 하면 전체 리스트에 대하여 모두 정렬이 이루어질 것이다.
[Part 2]
왼쪽 리스트에서는 아래 그림과 같이 정렬이 진행되며, 구체적인 정렬 과정은 동일하다.
[Part 3]
오른쪽 리스트에서는 다음 그림과 같이 정렬이 진행되며 구체적인 정렬 과정은 동일하다.
[정렬 과정 총 정리]
퀵 정렬에서는 이처럼 특정한 리스트에서 피벗을 설정하여 정렬을 수행한 이후에, 피벗을 기준으로 왼쪽 리스트와 오른쪽 리스트에서 각각 다시 정렬을 수행한다.
즉, 재귀 함수와 동작 원리가 같다고 볼 수 있고 실제로 퀵 정렬을 재귀 함수 형태로 작성하면 구현이 간결해진다.
재귀 함수로 구현했을 때 퀵 정렬의 종료 조건은 현재 리스트의 데이터 개수가 1개인 경우이다.
리스트의 원소가 1개하면 이미 정렬 되어 있다고 간주하고 분할이 불가능하다.
전체 과정을 그림과 함께 살펴보며 이해해보자.
1) 정형적 형태의 quickSort.py
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array, start, end):
if start >= end : #원소가 1개인 경우 종료
return
pivot = start # 피봇은 첫번째 원소
left = start + 1
right = end
while left <= right:
# 피벗보다 큰 데이터를 찾을 때 까지 반복
while left <= end and array[left] <= array[pivot]:
left += 1
# 피벗보다 작은 데이터를 찾을 때 까지 반복
while right > start and array[right] >= array[pivot]:
right -= 1
if left > right: # 엇갈렸다면 작은 데이터와 피벗을 교체
array[right], array[pivot] = array[pivot], array[right]
else: # 엇갈리지 않았다면 작은 데이터와 큰 데이터를 교체
array[left], array[right] = array[right], array[left]
# 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬 수행
quick_sort(array, start, right - 1)
quick_sort(array, right + 1, end)
quick_sort(array, 0, len(array) - 1)
print(array)
- 실행 결과
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
2) 파이썬의 장점을 살린 quickSort.py
array = [5, 7, 9, 0, 3, 1, 6, 2, 4, 8]
def quick_sort(array):
# 리스트가 하나 이하의 원소만을 담고 있다면 종료
if len(array) <= 1:
return array
pivot = array[0] # 피벗은 첫 번째 원소
tail = array[1:] # 피벗을 제외한 리스트
left_side = [x for x in tail if x <= pivot] # 분할된 왼쪽 부분
right_side = [x for x in tail if x > pivot] # 분할된 오른쪽 부분
# 분할 이후 왼쪽 부분과 오른쪽 부분에서 각각 정렬을 수행하고, 전체 리스트를 반환
return quick_sort(left_side) + [pivot] + quick_sort(right_side)
print(quick_sort(array))
- 실행 결과
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+) 두 코드의 차이
전통적은 퀵 정렬 방식보다 파이썬의 장점을 살린 코드 쪽이 피벗과 데이터를 비교하는 비교 연산 횟수가 증가하므로 시간 명에서는 조금 비효율적이다.
하지만 더 직관적이고 기억하기 쉽다는 장점이 있다.
3) 퀵 정렬의 시간 복잡도
앞서 다룬 선택 정렬과 삽입 정렬의 시간 복잡도는 O(N^2)을 보장한다.
퀵 정렬의 경우 평균 시간 복잡도는 O(NlogN) 이다.
이를 직관적으로 이해하기 위해서 먼저 퀵 정렬에서 최선의 경우를 생각해보자.
**피벗값의 위치가 변경되어 분할이 일어날 때마다 정확히 왼쪽 리스트와 오른쪽 리스트를 절반씩 분할한다면 어떻게 될까?
데이터의 개수를 8개라고 가정하고 정확히 절반씩 나눈다고 가정해보자.
이때 '높이' 를 확인해보면, 데이터의 개수가 N개일 때, 약 logN 이라고 판단할 수 있다.
(컴퓨터학부에서 logN은 log2(N) 즉, 밑수가 2인 로그를 의미한다!)
다시 말해 분할이 이루어지는 횟수가 기하 급수적으로 감소하게 되는 것을 의미 한다.
[데이터 개수에 따른 평균 시간 복잡도 기준의 연산 수]
데이터의 개수 | N^2 (선택 정렬, 삽입 정렬) | Nlog2(N) 퀵 정렬) |
---|---|---|
N = 1,000 | ≈ 1,000,000 | ≈ 10,000 |
N = 1,000,000 | ≈ 1,000,000,000,000 | ≈ 20,000,000 |
주의) 퀵 정렬의 평균적인 시간 복잡도는 O(NlogN)이지만, 최악의 경우 시간 복잡도가 O(N^2) 이다.
데이터가 무작위로 입력되는 경우 퀵 정렬은 빠르게 동작할 확률이 높다.
하지만 퀵 정렬처럼 리스트의 가장 왼쪽 데이터를 피벗으로 삼을 때, '이미 데이터가 정렬되어 있는 경우' 에서는 매우 느리게 동작한다.
이전의 삽입 정렬의 경우 데이터가 이미 정렬되어 있는 경우에는 매우 빠르게 동작한다고 했는데, 퀵 정렬의 경우 이와 반대된다.
참고) C++과 같이 퀵 정렬을 기반으로 작성된 정렬 라이브러리를 사용하는 경우, 시간 복잡도가 O(NlogN)이 되도록 보장하기 위해 피벗값을 설정할 때, 추가적인 로직을 더해준다.
'CS > 알고리즘' 카테고리의 다른 글
[알고리즘] 정렬 - (5) 파이썬 정렬 라이브러리 및 실전 문제 (0) | 2024.01.22 |
---|---|
[알고리즘] 정렬 - (4) 계수 정렬 (1) | 2024.01.22 |
[알고리즘] 정렬 - (2) 삽입 정렬 (0) | 2024.01.19 |
[알고리즘] 정렬 - (1) 선택 정렬 (0) | 2024.01.18 |
[알고리즘] DFS&BFS - (3) (0) | 2024.01.17 |