정렬(Sorting)이란 데이터를 특정한 기준에 따라서 순서대로 나열하는 것을 의미한다.
일반적으로 문제 상황에 따라서 적절한 정렬 알고리즘이 공식처럼 사용된다.
정렬 알고리즘으로 데이터를 정렬하면 이진 탐색이 가능해진다. (정렬 알고리즘은 이진 탐색의 전처리 과정이기도 하므로 중요하다!)
다양한 정렬 알고리즘에 대해서 알아보자.

선택 정렬은 처리되지 않은 데이터 중에서 가장 작은 데이터를 선택해 맨 앞에 있는 데이터와 바꾸는 것을 반복한다.# 선택 정렬을 사용하여 오름차순 정렬
arr = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(len(arr)):
min_idx = i # 가장 작은 원소의 인덱스
for j in range(i + 1, len(arr)):
if arr[min_idx] > arr[j]:
min_idx = j
temp = arr[min_idx]
arr[min_idx] = arr[i]
arr[i] = temp
print(arr)
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
O(N^2)이다.선택 정렬은 N번 만큼 가장 작은 수를 찾아서 맨 앞으로 보내야 한다. 그리고 구현 방식에 따라서 사소한 오차는 있을 수 있으나, 전체 연산 횟수는 N + (N - 1) + (N - 2) + ... + 2이다.(N² + N - 2) / 2로 표현할 수 있는데, 이는 빅오 표기법으로 간단히 O(N^2)이다.※ 선택 정렬은 데이터의 개수가 10,000개 이상이면 선택 정렬 속도가 급격히 급격히 느려지는 것을 확인할 수 있다.
| 데이터의 개수(N) | 선택 정렬 | 퀵 정렬 | 기본 정렬 라이브러리 |
|---|---|---|---|
| N=100 | 0.0123초 | 0.00156초 | 0.00000753초 |
| N=1,000 | 0.354초 | 0.00343초 | 0.0000365초 |
| N=10,000 | 15.475초 | 0.0312초 | 0.000248초 |
측정 시간은 각각의 컴퓨터마다 다를 수 있다. 상대적인 개념으로 이해하면 된다.

삽입 정렬은 특정한 데이터를 적절한 위치에 삽입한다.삽입 정렬은 특정한 데이터가 적절한 위치에 들어가기 이전에 그 앞까지의 데이터는 이미 정렬되어 있다고 가정한다. 정렬되어 있는 데이터 리스트에서 적절한 위치를 찾은 뒤에, 그 위치에 삽입된다.# 삽입 정렬을 사용하여 오름차순 정렬
arr = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
for i in range(1, len(arr)):
# 인덱스 i부터 1까지 감소하며 반복
for j in range(i, 0, -1):
# 한 칸씩 왼쪽으로 이동
if arr[j] < arr[j - 1]:
temp = arr[j]
arr[j] = arr[j - 1]
arr[j - 1] = temp
# 자기보다 작은 데이터를 만나면 그 위치에서 멈춤
else:
break
print(arr)
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
O(N^2)이다.N^2이다.삽입 정렬은 현재 리스트의 데이터가 거의 정렬되어 있는 상태라면 매우 빠르게 동작한다. 최선의 경우 O(N)의 시간 복잡도를 가진다. ※ 퀵 정렬과 비교했을 때, 보통은 삽입 정렬이 비효율적이나 '정렬이 거의 되어 있는 상황'에서는 퀵 정렬 알고리즘보다 더 강력하다! (=효율적이다.)

퀵 정렬은 기준 데이터(pivot)를 설정하고 그 기준보다 큰 데이터와 작은 데이터의 위치를 바꾸는 정렬 방법이다.가장 기본적인 퀵 정렬은 첫 번째 데이터를 기준 데이터(pivot)로 설정한다.
# 퀵 정렬을 사용하여 오름차순 정렬
arr = [7, 5, 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(arr, 0, len(arr) - 1)
print(arr)
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Python의 장점을 살린 퀵 정렬
def quick_sort_better(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_better(left_side) + [pivot] + quick_sort_better(right_side)
print(quick_sort_better(arr))
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
O(NlogN)이고, 최악의 경우 O(N^2)의 시간 복잡도를 가진다.※ 데이터의 개수가 많을수록 퀵 정렬은 앞서 다루었던 선택 정렬, 삽입 정렬에 비해 압도적으로 빠르게 동작한다. 하지만, 위 코드처럼 가장 왼쪽 데이터를 피벗으로 할 때, '이미 데이터가 정렬되어 있는 경우'에는 느리게 동작한다. (이를 해결하기 위해서는 피벗값을 설정할 때 추가적인 로직을 더하면 된다.)
| 데이터의 개수(N) | N²(선택 정렬, 삽입 정렬) | NlogN(퀵 정렬) |
|---|---|---|
| N=1,000 | 약 1,000,000 | 약 10,000 |
| N=1,000,000 | 약 1,000,000,000,000 (1조) | 약 20,000,000 |
위 표는 데이터의 개수에 따라 얼마나 많은 연산을 요구하는지를 보여주며, 정확한 연산 횟수 비교는 아니다.

병합 정렬은 정렬되지 않은 전체 데이터를 하나의 단위로 분할한 후에 분할한 데이터들을 다시 병합하며 정렬하는 방식이다.# 병합 정렬을 사용하여 오름차순 정렬
arr = [7, 5, 9, 0, 3, 1, 6, 2, 4, 8]
def merge(left, right):
sorted_list = []
i, j = 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
sorted_list.append(left[i])
i += 1
else:
sorted_list.append(right[j])
j += 1
# 남은 값들을 삽입한다.
while i < len(left):
sorted_list.append(left[i])
i += 1
while j < len(right):
sorted_list.append(right[j])
j += 1
return sorted_list
def merge_sort(array):
if len(array) <= 1:
return array
mid = len(array) // 2
left = merge_sort(array[:mid])
right = merge_sort(array[mid:])
return merge(left, right)
print(merge_sort(arr))
>>> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
O(NlogN)이다. (최악, 평균, 최선의 경우의 시간 복잡도가 동일하다.)N/2 덩어리 2개가 생기고, 그다음 분할하면 N/4덩어리가 4개가 된다. 이를 반복하면 최종적으로 N/N 덩어리가 N개가 생긴다. 즉, 분할과정은 매번 반으로 감소하므로 각 분할별로 병합하는 과정을 수행하여 O(NlogN)의 시간 복잡도를 가진다.
계수 정렬은 특정한 조건이 부합할 때만 사용할 수 있지만, 매우 빠르게 동작하는 정렬 알고리즘이다. (여기서 특정한 조건이란 계수 정렬은 데이터의 크기 범위가 제한되어 정수 형태로 표현할 수 있을 때 사용이 가능.)계수 정렬은 동일한 값을 가지는 데이터가 여러개 등장할 때 효과적으로 사용할 수 있다. (Ex) 학생들의 성적, 자동차들의 속도 데이터# 계수 정렬을 사용하여 오름차순 정렬
# 단, 리스트의 모든 원소의 값이 0보다 크거나 같다고 가정
arr = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2]
# 모든 범위를 포함하는 리스트 선언 (모든 값을 0으로 초기화)
count = [0] * (max(arr) + 1)
for a in arr:
# 각 데이터에 해당하는 인덱스의 값 증가
count[a] += 1
# 리스트에 기록된 정렬 정보 확인
for i in range(len(count)):
for j in range(count[i]):
# 띄어쓰기를 구분으로 등장한 횟수만큼 인덱스 출력
print(i, end=' ')
>>> 0 0 1 1 2 2 3 4 5 5 6 7 8 9 9
O(N+K)를 보장한다.※ 계수 정렬은 때에 따라서 심각한 비효율성을 초래할 수 있다. 데이터가 0과 999,999로 단 2개만 존재할 때에도 리스트의 크기가 100만 개가 되도록 선언해야 한다. 이는 굉장히 비효율적이다.
1. 정렬 라이브러리로 풀 수 있는 문제 : 단순히 정렬 기법을 알고 있는지 물어보는 문제. 기본 정렬 라이브러리의 사용 방법을 숙지하고 있으면 어렵지 않게 풀 수 있다!
2. 정렬 알고리즘의 원리에 대해서 물어보는 문제 : 선택 정렬, 삽입 정렬, 퀵 정렬, 병합 정렬 등의 원리를 알고 있어야 문제를 풀 수 있다!
3. 더 빠른 정렬이 필요한 문제 : 퀵 정렬 기반의 정렬 기법으로는 풀 수 없으며 계수 정렬 등의 다른 정렬 알고리즘을 이용하거나, 문제에서 기존에 알려진 알고리즘의 구조적인 개선을 거쳐야 풀 수 있다!
시각 자료 출처 : https://commons.wikimedia.org/wiki/Main_Page