정렬 : 데이터를 특정한 기준에 따라서 순서대로 나열하는 것
개요
정렬 알고리즘은 이후에 나오는 이진 탐색을 위해서라도 제대로된 학습이 필요한 알고리즘이다. 정렬 알고리즘은 이진 탐색의 전처리 과정에 속하기 때문이다.
보통 데이터를 가공하면, 오름차순이든 내림차순이든 정렬을 사용하는 경우가 많기 때문에 정렬 알고리즘이라는 말에 큰 어려움이 느껴지지 않는다.
그런데 정렬 알고리즘은 생각보다 종류가 다양하다. 따라서 이 챕터에서는 자주 사용되는 선택 정렬, 삽입 정렬, 퀵 정렬, 계수 정렬 4가지의 정렬 알고리즘을 선별하여 학습한다.
1. 선택 정렬
선택 정렬은 가장 원시적인 방법의 정렬이다.
0~9까지의 숫자 카드가 있고 이를 정렬한다고 했을 때, 선택 정렬은 가장 작은 숫자를 선택하고 맨 앞에 있는 숫자와 바꾸고 그 다음 작은 숫자를 선택하고 두번째에 있는 숫자카드와 바꾸는 행위를 반복한다.
이 예시를 확장해서 숫자카드의 개수를 미지수 n으로 놓으면 n-1번 반복했을 때 우리가 원하는 정렬을 이루어 낼 수 있다.
숫자 카드가 n=10개 인 경우의 선택정렬을 파이썬 코드로 작성하면 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import random
n = 10
arr = list(range(n))
random.shuffle(arr)
print('정렬 전 :', arr)
for i in range(len(arr)):
min_index = i # 가장 작은 원소의 인덱스
for j in range(i+1, len(arr)):
if arr[min_index] > arr[j]:
min_index = j
arr[i], arr[min_index] = arr[min_index], arr[i] # 스왑
print('정렬 후 :', arr)
여기서 중요한건 스왑이라는 기술이다.
스왑은 특정한 리스트가 주어졌을 때, 두 변수의 위치를 변경하는 작업을 말한다. 다른 언어에 비해 파이썬은 간단하게 두 원소의 위치를 스왑할 수 있다.
1
2
3
4
5
6
# 스왑 예시
arr = [3, 5]
arr[0], arr[1] = arr[1], arr[0]
print(arr)
# [5, 3]
선택 정렬의 시간 복잡도
앞서 언급했지만 선택 정렬은 N개의 목록이 있을 때, N-1번의 작업을 통해 정렬한다.
이를 연산 횟수로 환산하면, (N*(N+1)/2)-1 가 된다.
이를 전개하면 ((N^2+N)/2) - 1이고 이를 빅오 표기법으로 표현하면 선택 정렬의 시간 복잡도는 O(N^2)이다. (빅오 표기법에서는 파라메터로 최고 차항이 들어간다)
그럼 이러한 시간복잡도를 갖는 선택 정렬은 효율적인 알고리즘일까?
이를 확인하기위해 교재에서 테스트한 내용은 아래와 같다.
N | 선택 정렬 | 퀵 정렬 | 기본 정렬 라이브러리(sorted) |
---|---|---|---|
100 | 0.0123s | 0.00156s | 0.00000753s |
1,000 | 0.354s | 0.00343s | 0.0000365s |
10,000 | 15.475s | 0.0312s | 0.000248s |
위의 결과에서 보다시피 다른 알고리즘에 비해 매우 비효율적이다.
다만, 특정한 리스트에서 가장 작은 데이터를 찾는 일이 코딩테스트에 자주 등장하기 때문에 선택 정렬의 소스코드를 기억해둘 필요가 있다.
2. 삽입 정렬
앞선 단락에서 봤듯이 선택 정렬은 알고리즘 문제풀이에서 사용하기에는 비효율적이다.
그렇다면 데이터를 하나씩 확인하여 적절한 위치에 삽입한다면 어떨까?
이번에도 0~9까지의 숫자카드를 정렬하는 상황을 가정해보자.
삽입 정렬에서 첫번째 위치한 카드는 이미 정렬된 것으로 간주한다. 왜냐하면 적절한 위치에 다른 카드들을 삽입할 것이기 때문이다.
만약 7이라는 카드가 맨 앞에 위치해 있다고 해보면, 나는 정렬을 위해 제일 먼저 두번째 카드를 어디에 위치시킬 건지 결정해야한다. 이때 나에게는 7이라는 카드 앞에 삽입하는 것과 또는 뒤에 삽입하는 두 가지 선택지밖에 없다.
두번째 카드가 5였다면 나는 7 앞에 5를 위치시킨다. 그리고 세번째카드가 9라면, 내가 옮길 수 있는 경우는 5 앞, 5와 7 사이, 7 뒤이다. 그리고 나는 7뒤에 9를 위치시킨다.
이처럼 삽입할 수 있는 경우의 수는 2, 3, 4, 5 …10까지 늘어난다.
여기서 삽입 정렬이 선택정렬와 다른 점이 나타난다.
삽입정렬은 앞에서 부터 하나씩 확인하고, 자신이 들어갈 자리인 것을 확인하면 바로 삽입된다. 이 이야기는 뒤에 불필요한 확인작업이 발생하지 않는 다는 것이다.
가령 뒤에서부터 삽입될 위치를 체크한다면, 9는 한번의 시도만에 7이 자신보다 작으므로 7뒤에 그대로 삽입된다. 경우의 수는 여전히 3임에도 불구하고 말이다.
이러한 내용들을 코드로 작성하면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import random
n = 10
arr = list(range(n))
random.shuffle(arr)
print('정렬 전 :', arr)
for i in range(len(arr)):
for j in range(i, 0, -1): # 인덱스 i부터 1까지 감소하며 반복
if arr[j] < arr[j-1]: # 한칸 씩 왼쪽 이동하며 체크
arr[j], arr[j-1] = arr[j-1], arr[j] # 스왑
else: # 자기보다 작은 데이터를 만나면 그 위치에서 멈춤
break
print('정렬 후 :', arr)
삽입 정렬의 시간 복잡도
만약 완벽하게 내림차순으로 나열이 되어있는 배열을 생각하면, 삽입 정렬에 필요한 연산 횟수는 선택정렬과 같은 (N*(N+1)/2)-1 가 된다. 따라서 이경우 삽입정렬의 시간복잡도 역시 선택 정렬의 시간 복잡도인 O(N^2) 와 같다.
그러나 어느정도 배열이 갖춰진 상태라고 가정하면, 정렬 속도가 점점 상승한다. 최상의 경우는 완벽하게 오름차순으로 정렬된 배열에 삽입정렬을 시행하는 경우로써, N-1의 연산횟수를 가지며 시간 복잡도는 O(N)이 된다.
물론 랜덤한 배열에 대해 여러번 테스트를 해보면 삽입 정렬과 선택 정렬의 평균적인 소요시간이 큰 차이를 보이지 않는다. 거의 정렬되어있는 경우는 꽤나 드물기 때문이다.
다만, 앞서 언급했듯이 삽입정렬은 거의 정렬되어 있는 경우 강력한 효율을 보이기 때문에, 이경우에 한정한다면 여타 정렬 알고리즘을 사용하는 것보다 정답확률을 높일 수 있다.
3. 퀵 정렬
딱 잘라서 말하자면, 퀵 정렬은 가장 많이 사용되는 정렬 알고리즘이다. 실제로 정렬 라이브러리의 근간이 되는 알고리즘이기도 하다.
퀵 정렬은 기준 데이터를 설정하고 그 기준보다 큰 데이터와 작은데이터를 교환한 후 리스트를 반으로 나누는 방식으로 작동한다. 이때 기준데이터를 피벗(Pivot)이라고 한다.
퀵 정렬은 피벗을 어떻게 설정할 건지 미리 명시해야한다. 그래서 퀵 정렬은 피벗을 설정하고 리스트를 분할하는 방식에 따라 여러가지로 나뉜다. 교재에서는 호어 분할 방식을 사용했다.
피벗을 설정하는 방식이 리스트의 가장 앞에 있는 데이터라고 해보자.
현재 주어진 리스트의 가장 앞에는 5가 있고, 따라서 5는 피벗이된다.
피벗이 설정되면, 왼쪽에서부터는 피벗보다 큰 데이터를 찾고 오른쪽에서부터 피벗보다 작은 데이터를 찾는다. 그리고 두 카드의 위치를 바꾼다.
이렇게 하다보면 엇갈리는 순간이 오게된다. 이 시점에서 둘 중 더 작은 데이터와 피벗의 위치를 바꾼다.
결과적으로 피벗을 기준으로 왼쪽에는 피벗보다 작은 수가, 그리고 오른쪽에는 피벗보다 큰 수가 오게끔 배열의 분할(파티션)이 완료된다.
이젠 왼쪽과 오른쪽에서 각각 피벗을 설정하고 동일한 분할 과정을 수행한다. 이는 정렬이 완료될 때 까지 반복하게된다.
이 과정을 완전히 이해했다면, 이 과정이 재귀함수의 동작원리와 같다는 것을 알 수 있다. 실제로 재귀함수를 사용하면 퀵 정렬의 구현이 간결해진다.
그럼 재귀함수는 종료조건을 걸어줘야하는데 퀵정렬의 종료조건은 무엇일까?
이는 바로 현재 리스트의 데이터 개수가 1개인 경우이다. 리스트의 원소가 1개라면, 이미 정렬이 되어 있다고 간주할 수 있으며 분할이 불가능하다.
이를 함수로 표현하면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import random
n = 10
arr = list(range(n))
random.shuffle(arr)
print('정렬 전 :', arr)
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)
이를 파이썬의 장점을 살려서 더욱 간결하게 코드를 짤 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import random
n = 10
arr = list(range(n))
random.shuffle(arr)
print('정렬 전 :', arr)
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)
arr = quick_sort(arr)
print('정렬 후 :', arr)
퀵 정렬의 시간 복잡도
퀵 정렬의 평균 시간 복잡도의 증명은 복잡하니 넘어가고 결론만 이야기하자면 퀵 정렬의 평균 시간 복잡도는 O(NlogN)이다.
앞선 두 정렬 알고리즘은 최악의 경우에도 항상 시간복잡도 O(N^2)를 보장한다는 점을 고려할 때, 매우 빠르다.
퀵정렬의 시간복잡도는 log값을 취하는 만큼 N의 값이 기하급수적으로 커질수록 그 속도가 다른 정렬알고리즘과 극명한 차이를 보인다.
교재에서 평균 시간 복잡도를 기준으로 각 정렬 알고리즘이 데이터의 개수에 따라 얼마나 많은 연산을 요구하는지를 테스트한 결과는 아래의 표와 같다.
N | ⁍ (선택 정렬, 삽입 정렬) | ⁍ (퀵 정렬) |
---|---|---|
1,000 | 약 1,000,000 | 약 10,000 |
1,000,000 | 약 1,000,000,000,000 | 약 20,000,000 |
다만, 퀵정렬에서 한가지 기억해둘 점이 있다.
이는 퀵정렬의 평균 시간 복잡도는 O(NlogN)지만, 최악의 경우에는 시간복잡도 O(N^2)를 갖는다는 사실이다.
여기서 말하는 최악의 경우는 리스트가 완벽하게 정렬되어 있는 경우를 말한다. 삽입 정렬의 경우와 정반대라고 이해하면 된다.
4. 계수 정렬
계수 정렬 알고리즘은 특정한 조건이 부합할 때만 사용가능하지만, 매우 빠른 정렬알고리즘이다.
여기서 말하는 특정한 조건은 데이터가 정수이고 범위가 제한 될 때를 말한다. 반대로 말하면 데이터의 범위가 무한대이고 실수이면 계수정렬을 사용할 수없다.
일반적으로 계수정렬은 가장 큰 데이터와 가장 작은 데이터의 차이가 1,000,000을 넘지 않을 때 효과적이다.
계수 정렬은 앞의 세개의 정렬 알고리즘같은 비교 기반의 정렬 알고리즘이 아니다. 대신 계수 정렬은 일반적으로 별도의 리스트를 선언하고 그안에 정렬에 대한 정보를 담는다.
예를들어 7 5 9 0 3 1 6 2 9 1 4 8 0 5 2라는 데이터가 주어졌다고 해보자.
계수 정렬은 먼저 가장 큰 데이터와 가장 작은 데이터의 범위가 모두 담길 수 있도록 하나의 리스트를 생성한다.
위의 예시의 범위는 0~9이므로 크기가 10인 리스트를 선언한다. 처음에는 리스트의 모든 데이터가 0이 되도록 초기화 한다.
그다음 데이터를 하나씩 확인하며 데이터의 값과 동일한 인덱스의 데이터를 1씩 증가시키면 계수정렬이 완료된다.
즉, 계수정렬이 끝나면 결과적으로 리스트에는 각 데이터가 몇번 등장했는지 그 횟수가 기록된다.
출력을 할때는 리스트의 첫번째 요소부터 차례로 그 값만큼 인덱스를 출력하면 된다. 가령 0 인덱스의 값이 2이면 0을 두번 출력한다.
이러한 내용을 코드로 구현하면 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# 모든 원소는 0보다 크거나 같고 정수임
arr = [7, 5, 9, 0, 3, 1, 6, 2, 9, 1, 4, 8, 0, 5, 2]
# 모든 범위를 포함하는 리스트 선언(크기가 최대값 +1인 리스트) & 모든 값은 0으로 초기화
count = [0] * (max(arr) + 1)
for i in range(len(arr)):
count[arr[i]] += 1 # 각 데이터에 해당하는 인덱스 값 증가시키기
for i in range(len(count)): # 리스트에 기록된 정렬 정보 확인
for j in range(count[i]):
print(i, end=' ') # 띄어쓰기를 구분으로 등장한 횟수만큼 인덱스 출력
계수 정렬의 시간 복잡도
모든 데이터가 양의 정수인 상황에서 데이터의 개수를 N, 데이터 중 최대값의 크기를 K라고 하면, 계수 정렬의 시간 복잡도는 O(N+K)다.
계수 정렬은 앞에서 부터 데이터를 하나씩 확인하면서 리스트에서 적절한 값을 1씩 증가시키기고, 추후에 리스트의 각 인덱스에 해당하는 값들을 확인할때 데이터 중 최대값의 크기만큼 반복을 수행하기 때문이다.
따라서 데이터의 범위만 한정되어 있다면, 효과적으로 사용할 수 있고 상당히 속도가 빠르다.
계수 정렬의 공간 복잡도
계수정렬의 공간 복잡도는 O(N+K)다.
0과 999,999 단 2개만 데이터가 존재한다고 할 때 크기가 백만인 리스트를 만들어서 백만개의 인덱스를 하나씩 반복문으로 확인해야한다는 점에서 상당히 비효율적일 수 있다.
계수 정렬은 동일한 값을 가지는 데이터가 여러 개 등장할 때 적합하다. 반면 퀵정렬은 일반적인 경우에서 평균적으로 빠르게 동작하기 때문에 데이터의 특성을 명확히 알 수 없다면 퀵정렬을 사용하면된다.
결국 계수 정렬은 데이터가 정수이고, 데이터의 범위가 한정되어 있고, 데이터가 많이 중복되어 있을수록 유리하다.
다만 계수정렬은 항상 사용할 수는 없지만, 조건만 만족한다면 정렬해야하는 데이터의 개수가 매무 많을 때에도 효과적으로 사용할 수 있다.
5. 파이썬의 정렬 라이브러리
파이썬에서 쓸 수 있는 정렬 라이브러리는 파이썬의 기본 정렬 라이브러리인 sorted 함수와 리스트 객체의 내장함수인 sort함수가 있다.
sorted 함수는 리스트, 딕셔너리, 튜플 등의 자료형을 입력받아서 정렬된 결과를 출력한다. 어떤 자료형이든 반환되는 결과는 리스트라는 특징이 있다.
1
2
3
4
5
6
import random
arr = list(range(10))
random.shuffle(arr)
result = sorted(arr)
print(reseult)
sort함수는 리스트 변수가 하나 있을 때 내부 원소를 바로 정렬하도록 한다. sorted 함수는 리스트를 반환하는데 반해 sort는 리스트의 내보 원소를 바로 정렬한다는 차이점이 있다.
1
2
3
4
5
6
import random
arr = list(range(10))
random.shuffle(arr)
arr.sort()
print(arr)
두 함수는 모두 key 라는 매개변수를 받을 수 있는데, key 값으로는 하나의 함수가 들어가고 이는 정렬 기준이 된다.
1
2
3
4
5
6
7
8
arr = [('바나나', 2), ('사과', 5), ('당근', 3)]
def setting(data):
return data[1]
result = sorted(arr, key=setting)
print(result)
# [('바나나', 2), ('당근', 3), ('사과', 5)]
정렬 라이브러리의 시간 복잡도
정렬 라이브러리는 항상 최악의 경우에도 시간 복잡도 O(NlogN)을 보장한다.
별도의 요구사항이 없이 단순 정렬해야할 때는 정렬 라이브러리를 쓰면 된다. 다만, 데이터의 범위가 한정되어 있고 더 빠르게 동작할 필요가 있다면 계수 정렬을 사용하면 된다.
코딩 테스트에서 정렬 알고리즘이 사용되는 경우를 3가지로 나눌 수 있다.
- 정렬 라이브러리로 풀수 있는 문제
- 정렬 알고리즘의 우런리에 대해서 물어보는 문제 : 선택 정렬, 삽입 정렬, 퀵 정렬 등의 원리를 이해하고 있어야 풀이 가능
- 더 빠른 정렬이 필요한 문제 : 퀵 정렬 기반의 정렬 기법으로는 풀 수 없고, 계수 정렬등의 알고리즘을 이용하거나 기존에 알려진 알고리즘의 구조적인 개선을 거쳐서 풀이 가능