하위 문서: 정렬 알고리즘/예제
#!wiki style="display: inline; display: none;"
, }}}
'''이론 컴퓨터 과학 {{{#!wiki style="display: inline-block; font-family:Times New Roman, serif;font-style:italic"''' | |||||
{{{#!wiki style="margin: 0 -10px -5px; min-height: calc(1.5em + 5px)" {{{#!folding [ 펼치기 · 접기 ] {{{#!wiki style="margin: -5px -1px -11px" | <colbgcolor=#a36> 이론 | ||||
기본 대상 | 수학기초론{수리논리학(논리 연산) · 계산 가능성 이론 · 범주론 · 집합론} · 이산수학(그래프 이론) · 수치해석학 · 확률론 및 통계학 · 선형대수학 | ||||
다루는 대상과 주요 토픽 | |||||
계산 가능성 이론 | 재귀함수 · 튜링 머신 · 람다대수 · 처치-튜링 명제 · 바쁜 비버 | ||||
오토마타 이론 | FSM · 푸시다운 · 튜링 머신(폰노이만 구조) · 정규 표현식 · 콘웨이의 생명 게임 · 형식언어 | ||||
계산 복잡도 이론 | 점근 표기법 · 튜링 기계^고전, 양자, 비결정론적, 병렬 임의접근 기계^ · 알고리즘 · 자료구조 · 알고리즘 패러다임(그리디 알고리즘, 동적 계획법) | ||||
정보이론 | 데이터 압축(무손실 압축 포맷 · 손실 압축 포맷) · 채널 코딩(채널 용량) · 알고리즘 정보 이론(AIT) · 양자정보과학 | ||||
프로그래밍 언어이론 | 프로그래밍 언어(함수형 언어 · 객체 지향 프로그래밍 · 증명보조기) · 메타 프로그래밍 · 유형 이론 · 프로그래밍 언어 의미론 · 파싱 · 컴파일러 이론 | ||||
주요 알고리즘 및 자료구조 | |||||
기초 | 정렬 알고리즘 · 순서도 · 탐색 알고리즘 | ||||
추상적 자료형 및 구현 | 배열^벡터^ · 리스트^연결 리스트^ · 셋(set)^레드-블랙 트리, B-트리^ · 우선순위 큐^힙, 피보나치 힙^ | ||||
수학적 최적화 | 조합 최적화 | 외판원 순회 문제 · 담금질 기법 · 유전 알고리즘 · 기계학습 | |||
볼록 최적화 | 내부점 방법 · 경사하강법 | ||||
선형계획법 | 심플렉스법 | ||||
계산 수론 및 암호학 | 밀러-라빈 소수판별법 · Pollard-rho 알고리즘 · 쇼어 알고리즘 · LLL 알고리즘 · 해시(MD5 · 암호화폐 · 사전 공격(레인보우 테이블) · SHA) · 양자 암호 | ||||
대칭키 암호화 방식 | 블록 암호 알고리즘(AES · ARIA · LEA · Camellia) · 스트림 암호 알고리즘(RC4) | ||||
공개키 암호화 방식 | 공개키 암호 알고리즘(타원 곡선 암호 · RSA) · 신원 기반 암호 알고리즘(SM9) | ||||
계산기하학 | 볼록 껍질 · 들로네 삼각분할 및 보로노이 도형^Fortune의 line-sweeping 알고리즘^ · 범위 탐색^vp-tree, R-tree^ · k-NN | ||||
그래프 이론 | 탐색^BFS, DFS, 다익스트라 알고리즘, A* 알고리즘^ · 에드몬드-카프 · 크루스칼 알고리즘 · 위상 정렬 · 네트워크 이론 | ||||
정리 | |||||
정지 문제대각선 논법 · 암달의 법칙 · P-NP 문제미해결 · 콜라츠 추측미해결 | |||||
틀:이산수학 · 틀:수학기초론 · 틀:컴퓨터공학 | }}}}}}}}} |
1. 개요
정렬 알고리즘을 소리로 표현한 영상이다. 최신판인 0.6.5버전을 다운로드 받으면 위키 정렬등이 포함된 총 30가지 정렬 알고리즘을 직접 테스트해 볼 수 있다.
Java로 짠 버전 무지개색이나 sin함수 같은 다양한 시각화 모드를 추가로 지원한다. 소스코드 200가지 이상의 정렬알고리즘을 지원하며 w0rthy란 사람이 짠 걸 다른 사람이 개조하던 것이 기여자만 15명인 합동프로젝트가 되었다.
컴퓨터 분야에서 중요시되는 문제 가운데 하나로 어떤 데이터들이 주어졌을 때 이를 정해진 순서대로 나열하는 문제이다. 실제 컴퓨터 분야에서 사용하는 데이터의 경우 숫자의 순서나 어휘의 순서대로 정렬한 다음 사용해야 되는 경우가 거의 항상 발생하는데 이걸 얼마나 효과적으로 해결할 수 있느냐가 정렬 문제의 핵심이다.
2. 상세
데이터를 정렬해야 하는 이유는 탐색을 위해서이다. 사람은 수십에서 수백 개의 데이터를 다루는 데 그치지만 컴퓨터가 다뤄야 할 데이터는 보통이 백만 개 단위이며 데이터베이스 같은 경우 이론상 무한 개의 데이터를 다룰 수 있어야 한다. 탐색할 대상 데이터가 정렬되어 있지 않다면 순차 탐색 이외에 다른 알고리즘을 사용할 수 없지만 데이터가 정렬되어 있다면 이진 탐색이라는 강력한 알고리즘을 사용할 수 있다.[1] 삽입과 삭제가 자주 되는 자료의 경우 정렬에 더 많은 시간이 들어가므로 순차 탐색을 하는 경우도 있지만[2] 대부분의 경우 삽입/삭제보다는 데이터를 조회하는 것이 압도적으로 많고 조회에 필요한 것이 바로 검색이다.이미 정렬된 데이터의 특징은 어떤 값을 임의로 집었을 때 해당 값을 집은 위치의 오른쪽에는 무조건 그것보다 크거나 같은 값이 있고, 그 위치의 왼쪽에는 무조건 그것보다 작거나 같은 값이 있다는 것이다. 따라서 컴퓨터가 어떤 값을 딱 집었을 때 찾고자 하는 값보다 집어올린 값이 작다면 그 위치보다 왼쪽은 볼 필요가 없다. 그보다 오른쪽만 보면 된다. 컴퓨터가 어떤 값을 집어올리는 위치가 후보군의 가운데인 탐색 알고리즘이 이진 탐색 알고리즘이다. 이진 탐색 알고리즘은 최악의 경우라도 [math(\log {n})]의 성능을 보이는데 예를 들어 43억 개[3]의 정렬된 자료가 들어있는 데이터에서 어떤 값을 찾아야 할 때 최악의 비교 횟수(찾는 값이 없는 경우)는 겨우 32회[4]에 불과하다. 33회 비교시에는 약 86억 개[5]의 자료를 탐색할 수 있다. 더 발전한 알고리즘인 비례탐색 알고리즘(찾는 값이 후보군의 최솟값과 최댓값 사이의 몇 % 위치에 있는지 계산)은 더 적은 횟수의 비교로 원하는 값을 찾아낼 수 있다. 컴퓨터에서 정렬을 수행하는 이유 중 가장 큰 이유가 바로 이 이진 탐색이 가능한 데이터를 만들기 위해서이다.[6]
보통 컴퓨터 분야에서 연구되는 문제들의 경우 사람들이 푸는 방식을 흉내내는 경우가 많은데, 정렬 문제 역시 사람들이 푸는 방식을 흉내낸 형태이다. 주어진 데이터들이 있으면 값들을 서로 비교하여 순서에 맞게 자리를 바꿔주는 형태로 정렬을 하는데 이를 "비교정렬"이라고 부른다. 일반적으로 많이 쓰이는 정렬방법이고 만들기도 굉장히 쉬운 편이다. 그 외에 비교를 하지 않고 데이터를 보는 순서대로 나열하는 방식이 있긴 한데 이는 조금 특수한 경우에만 사용할 수 있다. 아주 간단한 예로 우편함 방식을 들 수 있다. "이름을 봐야되니 비교를 하지 않나요?"라고 질문하는 경우가 있는데 아니다. 약간 조작하면 비교없이 바로바로 집어넣을 수 있다.
전문적인 이야기를 최대한 생략하고 설명하면 입력된 데이터의 크기를 n이라고 했을 때 비교정렬의 경우에는 [math(n^2)]번만큼 비교를 해야 정렬이 되는데 알고리즘 연구하는 사람들 입장에서 보면 이게 비효율적인 방식이다. 그래서 온갖 수단과 방법을 동원해서 현재는 대략 [math((n \lg n))][7]번 비교할 수 있도록 줄인 상태이고 비교정렬의 경우 여기서 더이상 줄일 수 없다고 증명되었다.[8] 비교를 하지 않는 방법의 경우에는 데이터의 종류에 따라 다르지만, 그보다 더 작은 시간복잡도로도 정렬이 되는 것이 가능하다.
비전공자의 경우 "그게 뭐 그리 어려운가요?"라고 생각하는 사람들이 많은데 아주 효과적인 정렬방법을 만들기 위해서는 온갖 잔머리와 테크닉, 지혜, 아이디어를 갈아넣어야 된다. 농담이 아니라 정렬 문제 하나만 파서 박사학위까지 따는 경우도 있다. 빌 게이츠의 경우, 팬케이크 정렬을 구현하는 알고리즘을 냈는데 대체할 알고리즘이 나오기 까지 30여년이 걸렸다고.[9] 근래에는 많은 효과적인 정렬 방법들이 개발돼서 정말 획기적인 것을 제시하는 거 아니면 박사학위까지 가는 것은 좀 힘들긴 하겠지만…
이론과 달리 실제로 돌려보면 의외로 결과가 다르게 나오는 경우가 종종 있다. 주로 하드웨어 입출력이 관여해서 그 부분에 걸리는 시간이 정렬 알고리즘마다 과하게 달라서 더 느려야 하는 알고리즘이 더 빠르다든가, 아니면 정렬된 자료들을 대상으로 퀵소트 VS 다른 정렬 알고리즘에서 자주 관찰되는 현상인데, 이론상으로는 정렬된 알고리즘에서는 퀵소트가 더 느려야 하지만, 비교하는 기준점이 하나로 고정되어서 그 기준점이 많은 비교를 행하는 퀵소트 특성상 레지스터에 비교하는 기준점 원소를 올려놓고 신속하게 메모리에서 다음 비교할 원소를(그것도 메모리에서 연속된) 예측하여 가져오는 경우, 이 과정이 다른 정렬 알고리즘의 메모리에서 레지스터로 올리는 과정에 걸리는 시간이 생략되어 오히려 더 빠른 경우를 볼 수 있다. 이에 관해서는 컴파일러나 하드웨어 등등의 관점에서 자세히 다룬 논문들이 차고 넘치니 참조하도록 하자. [10] 실제로도 콜스택을 제대로 구현하기 어려운 임베디드 기기용으로는 퀵소트를 더 느린 셸소트로 대체 구현하기도 한다.
3. 안정 정렬과 불안정 정렬
안정 정렬(stable sort)은 중복된 값을 입력 순서와 동일하게 정렬한다. 예를 들어 다음 그림과 같이 지역별 발송 시각을 시간 순으로 정렬한 택배 발송 로그가 있다고 가정해보자. 안정 정렬의 경우에는 기존의 시간 순으로 정렬했던 순서는 지역명으로 재정렬하더라도 기존 순서가 그대로 유지된 상태에서 정렬이 이뤄진다. 그러나 불안정 정렬의 경우에는 시간순으로 정렬한 값을 지역명으로 재정렬하면 기존의 정렬 순서는 무시된 채 모두 뒤죽박죽 뒤섞이고 만다.이처럼 입력값이 유지되는 안정 정렬 알고리즘이 유지되지 않는 불안정 정렬 알고리즘보다 훨씬 더 유용하리라는 점은 쉽게 예상할 수 있을 것이다. 대표적으로 병합 정렬은 안정 정렬이며, 심지어 버블 정렬 또한 안정 정렬이다. 반면 퀵 정렬은 불안정 정렬이다. 게다가 입력값에 따라 버블 정렬 만큼이나 느려질 수 있다. 그야말로 최고의 알고리즘으로 칭송받는 퀵 정렬이 경우에 따라서는 최악의 알고리즘이 될 수도 있다. 이처럼 고르지 않은 성능 탓에 실무에서는 병합 정렬이 여전히 활발히 쓰이고 있으며, 파이썬의 기본 정렬 알고리즘으로는 병합 정렬과 삽입 정렬을 휴리스틱하게 조합한 팀소트(Timsort)를 사용한다.
4. 대표적인 정렬의 종류
실제 응용에서는 상황에 따라 두 가지 이상의 정렬 방법을 사용하는 경우가 많다. 예를 들면, 정렬 대상이 특정 크기 이하로 단편화될 때 까지는 퀵정렬을 쓰다가, 그 특정 크기 이하가 됐을 때에는 작은 규모에서 강점을 보이는 삽입정렬을 쓴다거나. 혹은 특정 횟수 이상 재귀호출이 발생하면 [math(O(n \log n))]이 보장되는 힙정렬을 쓴다거나.4.1. O(n²)인 것
대개 계산 시간이 정렬할 자료의 수의 제곱에 비례해서 늘어난다. 즉, 1만 개를 1초에 정렬하면 10만 개를 정렬하는 데에는 100초 정도가 필요하다.4.1.1. 버블 정렬(Bubble sort)
버블 정렬을 실행했을 때 나오는 그림.
1번째와 2번째 원소를 비교하여 정렬하고, 2번째와 3번째, ..., n-1번째와 n번째를 정렬한 뒤 다시 처음으로 돌아가 이번에는 n-2번째와 n-1번째까지, ...해서 최대 [math(\displaystyle \frac{n(n-1)}{2})]번 정렬한다.[11][12] 한 번 돌 때마다 마지막 하나가 정렬되므로 원소들이 거품이 올라오는 것처럼 보여 거품정렬이다.
거의 모든 상황에서 최악의 성능을 보여준다. 단, 이미 정렬된 자료에서는 1번만 돌면 되기 때문에 최선의 성능을 보여준다. 이미 정렬된 자료를 정렬하는 바보짓을 왜 하냐는 의문이 들 수 있지만, 정렬 알고리즘은 자료가 정렬되어 있는지 아닌지는 모르고 작동하기 때문에 의미가 있다.
가장 손쉽게 구현하여 사용할 수 있지만, 만들기가 쉽고 직관적일 뿐이지 알고리즘적 관점에서 보면 대단히 비효율적인 정렬 방식이다. 다른 몇 가지 정렬 방식과 비교해도 효율이 대략 시망.
성능은 최악이지만 교육용으로는 꽤 좋은 교재가 되는데, 이해하기 쉽고 코드가 짧은 덕에 각종 교습서에서 정렬 알고리즘의 예시로 많이 보여주며 가장 처음 배우게 되는 알고리즘이기도 하다. 버블 정렬 자체보다는 '수학적 직관이 없는 컴퓨터가 정렬이라는 개념을 어떻게 적용하는지'를 배우면서 컴퓨터에 대한 이해를 쌓는 과정으로서 의의가 있다. 그리고 버블 정렬이 왜 비효율적인지를 이해하는 것 역시 매우 중요하기 때문.
입력량이 작으면 어지간한 비효율적인 방법도 씹어먹고 수행이 가능하지만 실제 개발에서는 전혀 쓰이지 않는다고 봐도 무방하다. 정말 어지간한 경우가 아닌 이상 버블 소트는 피해야 한다.[13] 요즘은 웬만한 프로그래밍 언어 내부에 온갖 꼼수를 다 갈아넣은 고효율의 정렬 알고리즘이 내장되어 있어, 그냥 인클루드해서 갖다 쓰기만 하면 되는 세상이라[14] 버블 정렬의 장점이 거의 없다. 내장 정렬이 더 편하니
[ C언어 예시 코드 ]
#!syntax cpp
void Bubble_Sort(int arr[], int len) {
int i, j, tmp;
for (i = 0; i < len - 1; ++i) {
for (j = 0; j < len - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
tmp = arr[j];
arr[j] = arr[j+1];
arr[j+1] = tmp;
}
}
}
}
[ Python 예시 코드]
#!syntax cpp
for front_index in range(0, len(arr) - 1):
for index in range(0, len(arr) - 1 - front_index):
if arr[index] > arr[index + 1]:
arr[index], arr[index + 1] = arr[index + 1], arr[index]
4.1.1.1. 파생형
- 칵테일 정렬(cocktail sort)
칵테일 정렬의 과정을 나타낸 그림.
셰이커 정렬(shaker sort)라고도 한다. 홀수 번째 돌 때는 앞부터, 짝수 번째는 뒤부터 훑는 정렬. 당연하겠지만 이 쪽은 마지막과 처음이 번갈아가며 정렬된다. 제일 처음에 하나, 제일 뒤에 하나, 다시 제일 앞에 하나, 또 제일 뒤에 하나를 정렬하면서 마치 정렬하는 과정이 앞뒤로 마구 흔드는게 칵테일을 마구 흔들어 섞는것과 비슷해보인다 하여 칵테일(혹은 이름을 합쳐서 칵테일 셰이커) 정렬이라는 이름이 붙었다.
- 콤 정렬(comb sort)
기본 형태는 버블정렬과 같지만 예를 들어 처음에a[0]
에서 10칸 띄워서a[11]
과 비교해서 치환하는 식으로 대상을 띄웠다가 한 바퀴 돌면 띄우는 간격을 좁혀서 정렬하는 방식이다. 이렇게 하면 버블정렬과 다를 게 없어졌을 시점엔 정렬이 거의 끝나 있는데, 이 단계까지 가는 동안 모양이 마치 닭의 볏을 닮았다 하여 콤 정렬이라는 이름이 붙었다. 이렇게 본다면 콤 정렬이 버블정렬보다 좋아 보이지만 단점이 있는데 버블정렬이 stable sort이지만 콤 정렬은 그렇지 못하다.
4.1.2. 선택 정렬(Selection sort)
선택 정렬을 실행했을 때 나오는 그림.
버블 정렬이 비교하고 바로 바꿔 넣는 걸 반복한다면 이쪽은 일단 1번째부터 끝까지 훑어서 가장 작은 게 1번째, 2번째부터 끝까지 훑어서 가장 작은 게 2번째……해서 (n-1)번 반복한다. 어찌 보면 인간이 사용하는 정렬 방식을 가장 많이 닮았다. 어떻게 정렬이 되어 있든 일관성 있게 [math(\displaystyle \frac{n(n-1)}{2})]에 비례하는 시간이 걸린다는 게 특징. 또한, 버블 정렬보다 두 배 정도 빠르다
파생형으로 이중 선택 정렬(Double Selection Sort)도 있다. 이쪽은 끝까지 훑어서 최솟값과 최댓값을 동시에 찾아낸 뒤 최솟값은 1번째와 바꾸고 최댓값은 끝과 바꾼 다음 훑는 범위를 양쪽으로 한 칸씩 줄여서 반복하는 방식이다. 즉 선택 정렬에 칵테일 정렬 방식을 도입한 것. 이 방법을 쓰면 반복 횟수가 반으로 줄어든다.
4.1.3. 삽입 정렬(Insertion sort)
삽입 정렬을 알기 쉽게 만든 그림. 선택 정렬과 함께 인간에게 뭔가를 정렬하라고 하면 무의식적으로 사용하는 대표적인 알고리즘이다.[15]
k번째 원소를 1부터 k-1까지와 비교해 적절한 위치에 끼워넣고 그 뒤의 자료를 한 칸씩 뒤로 밀어내는 방식으로, 평균적으론 [math(\mathcal{O}(n^2))]중 빠른 편이나[16] 자료구조에 따라선 뒤로 밀어내는데 걸리는 시간이 크며, 앞의 예시처럼 작은 게 뒤쪽에 몰려있으면(내림차순의 경우 큰 게 뒤쪽에 몰려있으면) 그야말로 헬게이트다.
다만 이미 정렬되어 있는 자료구조에 자료를 하나씩 삽입/제거하는 경우에는 현실적으로 최고의 정렬 알고리즘이 되는데, 탐색을 제외한 오버헤드가 매우 적기 때문이다.
그밖에도 배열이 작을 경우에 역시 상당히 효율적이다. 일반적으로 빠르다고 알려진 알고리즘들도 배열이 작을 경우에는 대부분 삽입 정렬에 밀린다. 따라서 고성능 알고리즘들 중에서는 배열의 사이즈가 클때는 [math(\mathcal{O}(n\log n))] 알고리즘을 쓰다가 정렬해야 할 부분이 작을때 는 삽입 정렬로 전환하는 것들도 있다.
또 굳이 장점을 뽑자면 구현이 매우 쉽다는 것. 그 예로 C/C++에서 기본적인 삽입 정렬을 구현하는데는 서너줄의 코드면 충분하다.
- [짧은 C++ 삽입 정렬 코드]
#!syntax cpp #include <vector> template <typename T> void insertionSort(std::vector<T>& vec, const int& left, const int& right){ for (int i = left; i < right; ++i){ for (int j = i + 1; j > left && vec[j] < vec[j - 1]; --j){ std::swap(vec[j], vec[j - 1]); } } }
파생형으로 이진 삽입 정렬(Binary insertionSort)이 있다. 이진 탐색을 활용해 새로운 원소가 위치할 곳을 미리 찾아서 정렬하는 방식이다. 원소크기를 비교하는 조건 부분을 [math(\log{n})] 수준으로 낮춰 조금은 더 빠르게 수행할 수 있다는 점 정도.
4.2. O(n log n)인 것
이 세 알고리즘은 최선이나 평균적으로나 [math(\mathcal{O}(n \log n))]의 성능을 나타낸다.[17] 최악의 상황에서도 병합정렬이나 힙정렬은 [math(\mathcal{O}(n \log n ))]을 유지하는 반면 순수한 퀵정렬은 오히려 [math(\mathcal{O}(n^2))]으로 뒤진다. 하지만 실제로는 [math(\mathcal{O}(n \log n ))]일때는 퀵정렬이 보통 제일 빨라서 퀵정렬을 조금 개량해서 최악의 경우가 (거의) 발생하지 않도록 코드를 짜거나 나쁜 경우다 싶으면 힙정렬로 전환하게끔 코드를 짠다. 이 알고리즘들은 서로만의 특유한 성질과 장단점이 있다.4.2.1. 병합 정렬/합병 정렬(Merge sort)
병합 정렬의 예.
개발자는 존 폰 노이만으로 원소 개수가 1 또는 0이 될 때까지 두 부분으로 쪼개고 쪼개서 자른 순서의 역순으로 크기를 비교해 병합해 나간다. 병합된 부분 안은 이미 정렬되어 있으므로 전부 비교하지 않아도 제자리를 찾을 수 있다. 대표적인 분할 정복 알고리즘으로, 존 폰 노이만의 천재성을 엿볼 수 있는 알고리즘이다.
성능은 아래의 퀵 정렬보다 전반적으로 뒤떨어지고, 데이터 크기만한 메모리가 더 필요하지만[18] 최대의 장점은 데이터의 상태에 별 영향을 받지 않는다는 점[19]'이다. 힙이나 퀵의 경우에는 배열
A[25]=100
, A[33]=100
인 정수형 배열을 정렬한다고 할 때, 33번째에 있던 100이 25번째에 있던 100보다 앞으로 오는 경우가 생길 수 있다. 그에 반해서 병합정렬은 이런 일이 발생하지 않는다. 기본적으로 병합정렬은 쪼갠 후 두 값을 비교할 때 값이 같으면 정렬하지 않게 설계되기 때문이다. 그게 뭐가 중요하냐고 할 수 있지만, 실제 상황에서 여러 기준으로 정렬했을 때 동일 값에 대해선 기존 기준의 정렬순서가 유지되어야 한다. 예를 들어 동점일 경우 생년월일 기준으로 수상자를 뽑는 규정이 있는 대회에서 참가자들을 생년월일 순서대로 1차로 정렬해놓고 시험점수 기준으로 다시 정렬할 경우, 병합 정렬은 동점자들끼리 생년월일 순서대로 정렬된 것이 100% 유지된다. 이런 정렬을 안정 정렬이라고 한다. 정렬되어 있는 두 배열을 합집합할 때 이 알고리즘의 마지막 단계만을 이용하면 가장 빠르게 정렬된 상태로 합칠 수 있다.그림으로 병합 정렬 과정을 도식화 하면 다음과 같다.
이 그림에서 분할 정복으로 일정하게 정렬이 이뤄지는 병합 정렬의 특징을 잘 파악할 수 있다.
[38, 27, 43, 3, 9, 82, 10]
인 입력값은 [38, 27, 43, 3]
과 [9, 82, 10]
로 두 부분으로 분할, 다시 [38, 27],[43, 3],[9, 82],[10]
로 네 부분으로 분할 등의 방식으로 각각 더 이상 쪼갤 수 없을 때까지 계속해서 분할한 후, 분할이 끝나면 정렬하면서 정복해 나간다.4.2.2. 힙 정렬(Heap sort)
단계별로 보는 힙정렬 알고리즘.
춤으로 보는 영상 상대적으로 마이너한 쉘 정렬보다 무려 7년이나 늦게 추가되었다.
우선 힙이 뭔지 모른다면 힙 트리 항목 참고.
- 원소들을 전부 힙에 삽입한다.
- 힙의 루트에 있는 값은 남은 수들 중에서 최솟값(혹은 최댓값)을 가지므로 루트를 출력하고 힙에서 제거한다.
- 힙이 빌 때까지 2의 과정을 반복한다.
사실 선택 정렬과 거의 같은 알고리즘으로. 단지 가장 큰 원소를 뒤로 보내는 데에 단순히 매번 쭉 돌면서 알아내느냐 힙을 사용하여 알아내느냐가 유일한 차이점이다.
힙정렬은 추가적인 메모리를 전혀 필요로 하지 않는다는 점과, 최악의 경우에 [math(\mathcal{O}(n^2))]의 성능을 내는 퀵정렬과 달리 항상 [math(\mathcal{O}(n \log n))] 정렬의 성능을 발휘하는 장점이 있다. 하지만 실제 코드를 짜서 비교를 해 보면 퀵정렬이 힙정렬보다 일반적인 경우에 빠르게 동작한다.[20]
그러나 아래 퀵정렬의 경우 피벗을 잡는 전략에 어느 정도의 휴리스틱이 들어가야 최악의 경우를 회피할 수 있으나 힙정렬은 휴리스틱이 필요없이 항상 일정한 성능을 보이는 장점이 있다. 즉 알고리즘에 꼼수를 쓰지 않고, 각종 하드웨어 가속도 전혀 고려하지 않고 알고리즘이 정의하는 최소한만 구현할 경우 힙정렬이 가장 안정적인 성능을 보인다.
4.2.3. 퀵 정렬(Quick sort)
퀵 정렬의 적절한 예시.
토니 호어가 1959년에 개발한 알고리즘이다. 퀵이라는 이름에서 알 수 있듯이 평균적인 상황에서 최고의 성능을 나타낸다. 컴퓨터로 가장 많이 구현된 정렬 알고리즘 중 하나이다. C, C++, PHP등의 언어에서 자체적으로 제공하는 정렬 함수는 대개 퀵 정렬 혹은 퀵 정렬의 변형 알고리즘을 사용한다. 방식은 적절한[21] 원소 하나를 기준(피벗, pivot)으로 삼아 그보다 작은 것을 앞으로 빼내고 그 뒤에 피벗을 옮겨 피벗보다 작은 것, 큰 것으로 나눈뒤 나누어진 각각에서 다시 피벗을 잡고 정렬해서 각각의 크기가 0이나 1이 될 때까지 정렬한다. 이렇게 피벗을 잡고 이보다 작은 원소들을 왼쪽으로, 보다 큰 원소들을 오른쪽으로 나누는걸 partition step이라 한다. 퀵 정렬에도 이 partition step을 어떻게 하느냐에 따라 바리에이션이 매우 많으며, 성능 차이도 날 수 있다.[22]
퀵 정렬의 가장 간단한 분할 알고리즘인 로무토 파티션을 도식화 해보면 그림과 같다. 피벗은 맨 오른쪽 값을 기준으로 하며, 이를 기준으로 2개의 포인터가 이동해서 오른쪽 포인터의 값이 피벗보다 작다면 서로 스왑하는 형태로 진행된다. 오른쪽 right 포인터가 이동하면서 피벗의 값이 오른쪽 값보다 더 클때, 왼쪽과 오른쪽의 스왑이 진행된다. 스왑 이후에는 왼쪽 left 포인터가 함께 이동 한다. 여기서 피벗의 값은 4이므로, 오른쪽 포인터가 끝에 도달하게 되면 4 미만인 값은 왼쪽으로, 4 이상인 값은 오른쪽에 위치하게 된다. 그리고 왼쪽 포인터의 위치로 피벗 아이템이 이동한다. 즉 그림에서 최종 결과인 8)을 보면, 4를 기준으로 작은 값은 왼쪽에, 큰 값은 오른쪽으로 분할되어 있고, 피벗이 그 중앙으로 이동하는 모습을 확인할 수 있다. 이렇게 계속 분할하면서 정복을 진행하여 코드 기준으로 left < right를 만족하지 않을 때까지, 즉 서로 위치가 역전할 때까지 계속 재귀로 반복되면서 정렬이 완료된다.
하지만 앞서 위에서도 말했듯이 최악의 경우에는 시간복잡도가 [math(\mathcal{O}(n^2))]가 되는데, 피벗을 최솟값이나 최댓값으로 계속해서 잡게 되는 경우에 그렇다. 대표적인 예로는 피벗을 항상 배열의 첫 번째 원소를 잡도록 구현했을때 이미 정렬된 배열을 정렬할 경우. 다음 그림과 같이 전혀 분할이 진행되지 않고 하나씩 정렬되는 모습을 확인할 수 있다. 힙정렬이나 병합정렬은 이런 경우가 없지만, 데이터가 극단적이면 대충 구현된 퀵정렬은 안 쓰느니만 못한 최악의 결과를 초래한다.
이를 방지하기 위하여 여러 기법들이 개발 되었는데, 대표적인 것이 피벗을 랜덤으로 잡는 것(Random Quick sort). 또는, 무조건 배열의 위치상 중간에 있는걸 피벗으로 잡거나[23] 배열 중에 3개나 9개의 원소를 골라서 이들의 중앙값을 피벗으로 고르는 것이다.[24] 이 방법을 사용하더라도 최악의 경우가 나올 수는 있지만[25] 그 경우가 극히 드물게 된다. 다만 배열이 단순하게 비교 가능한 숫자가 아니라면 중앙값 피벗 방법이 비효율적일 수도 있다. 예를 들어서 비교하는데 연산이 아주 많이 들어가는 객체 또는 데이터 베이스. 이럴땐 그냥 무작위 또는 중간에 있는 원소를 피벗으로 잡는게 낫다.
하지만 피벗을 어떻게 정하더라도 순수 퀵 소트에서는 정렬 시간이 [math(\mathcal{O}(n^2))]로 나쁘게 될 가능성을를 완전히 없앨 수는 없다. 그래서 이런 나쁜 케이스들을 완전히 없애고 싶다면 순수 퀵 소트 보다는 특수한 상황이 나왔을때 다른 빠른 정렬 알고리즘을 섞어서 쓰는 하이브리드 퀵 소트가 좋다. 그래서 재귀 깊이가 어느 제한 이상으로 깊어질 경우 힙 정렬 알고리즘을 사용하여 항상 [math(\mathcal{O}(n \log n))]을 보장해주는 방법도 많이 쓰인다. (인트로 정렬)
- [C++11 랜덤 피벗 퀵 정렬 예시 코드]
#!syntax cpp #include <vector> #include <random> using namespace std; random_device rd; mt19937 mt(rd()); template <typename T> void quickSort(vector<T>& vec, const int& left, const int& right){ if (right <= left){ return; } uniform_int_distribution<int> randInt(left, right); int leftArrow = left + 1; int rightArrow = right; swap(vec[left], vec[randInt(mt)]); int current = left; bool focusedOnRight = true; do { if (focusedOnRight){ if (vec[current] >= vec[rightArrow]){ swap(vec[current], vec[rightArrow]); current = rightArrow; focusedOnRight = !focusedOnRight; } --rightArrow; } else { if (vec[current] <= vec[leftArrow]){ swap(vec[current], vec[leftArrow]); current = leftArrow; focusedOnRight = !focusedOnRight; } ++leftArrow; } } while (rightArrow >= leftArrow); quickSort(vec, left, current - 1); quickSort(vec, current + 1, right); }
링크드 리스트 역시 퀵정렬이 가능하다. 첫번째 노드 A를 피벗으로 놓고 나머지 노드들 중 피벗보다 작은 것들은 L1, 큰 것들은 L2로 연결한다. L1과 L2를 퀵정렬한 뒤 L1->A->L2 순으로 연결하면 정렬 완료. 힙소트나 머지소트 역시 가능.
파이썬은 퀵정렬을 하지 않는다. 파이썬은 안정(stable)[26] 정렬을 하는데, 퀵정렬은 불안정 정렬이다. 예를 들어 한글의 키값이 2, 영문의 키값이 1이라 두면 a, ㄱ, ㄷ, ㄹ, b를 퀵정렬해서 b, a, ㄹ, ㄱ, ㄷ 같은 게 나올 수도 있다. [math(\mathcal{O}(n))]의 추가 메모리를 이용하면 안정한 퀵정렬을 만들 수 있다.
현존하는 컴퓨터 아키텍처상에서 비교 연산자를 이용하여 구현된 정렬 알고리즘 중 가장 고성능인 알고리즘이 바로 이 퀵정렬이다. 단 데이터에 접근하는 시간이 오래 걸리는 외부 기억장소(하드디스크 등)에서 직접 정렬을 수행할 경우에는 병합 정렬이 더 빠른 것으로 알려져 있다. 요즘에는 디스크에서 데이터를 블럭 단위로 읽어서 각각을 퀵정렬한 뒤 정렬된 두 블럭을 병합정렬하는 식으로 알고리즘을 설계한다.
4.2.4. 트리 정렬(Tree sort)
이진 탐색 트리를 만들어 정렬하는 방식이다. 힙 정렬과 비슷해 보이지만 차이가 있는데, 정렬될 자료의 각 원소 크기에 따라 부모 노드의 왼쪽 자식이 되느냐 오른쪽 자식이 되느냐가 갈린다는 차이가 있다.
어떻게 진행되는지를 대략적으로 설명하자면,
- 정렬될 배열의 맨 첫 값이 루트 노드가 된다.
- 다음 값부터는 기존 노드 값과 비교한다. 루트 노드에서 출발해서 추가될 노드 값이 기존 노드 값보다 작은 경우는 왼쪽 자식을, 기존 노드 값보다 크거나 같을 경우는 오른쪽 자식을 찾는다. 내림차순은 반대로 기존 노드 값보다 크면 왼쪽, 작거나 같으면 오른쪽을 찾으면 된다.
- 위 2에서 해당 방향의 자식 노드가 없으면 그 방향의 자식 노드로 추가한다. 있으면 그 방향의 자식 노드로 가서 크기를 비교하고 위 2와 같은 방법으로 해당 방향의 자식 노드가 있으면 계속 그 방향으로 가서 조사하고 없으면 그 방향의 자식 노드로 추가한다.
- 모든 값이 노드로 추가되었으면 해당 트리를 중위 순회 방식으로 순회하여 그 순서대로 값을 정렬해 나간다.
예를 들어, \[4, 6, 1, 7, 5, 8, 2, 3]을 트리 정렬로 정렬하기 위해 이진 트리를 만들면 이렇게 된다.
4 | |||||||
1 | 6 | ||||||
2 | 5 | 7 | |||||
3 | 8 |
이 이진 트리를 중위 순회 방식(왼쪽 자식 - 자신 - 오른쪽 자식 순)으로 순회하면(위 그림에서 무지개색 순으로 순회한다) \[1, 2, 3, 4, 5, 6, 7, 8]이 된다.
최대값과 최소값을 탐색할때 매우 강력하다.
이를 배열로도 구현이 가능하다![27] 그러나 입력값이 적은것이 아니라면 별로 추천하지 않는다.[28]
4.3. 하이브리드 정렬
4.3.1. 팀 정렬(Tim sort)
변형된 병합 정렬.
2002년 Tim Peters가 파이썬을 위해 C로 구현한 병합 정렬을 바탕으로 온갖 휴리스틱, 테크닉과 삽입 정렬을 적절히 조합해 사용하는 정렬 알고리즘이다. 팀소트는 애초에 학계에서 받아들여질 만한 우아한 알고리즘을 목표로 하기보다는 '실제 데이터는 대부분 이미 정렬되어 있을 것이다'라고 가정하고 실제 데이터에서 고성능을 낼 수 있도록 설계한 알고리즘이다. 데이터가 엉망으로 뒤죽박죽 섞여 있는 일은 실제로는 거의 일어나지 않을 것이라 가정한 셈이다.
- 핵심
- 런(run)
전체 데이터 속 정렬되어있는 작은 구간을 run이라 부른다. 전체 데이터를 이러한 덩어리로 짜른 뒤 이를 활용하여 바텀업 병합 정렬을 시행한다. - 병합
두 run을 병합하는 순서는 스택과 피보나치 수 비슷한 조건을 활용하여 결정한다. - 휴리스틱, 테크닉
- 이진 삽입 정렬
위와 같은 방식으로 만든 run의 크기가 너무 작다면 삽입 정렬을 사용하여 특정 값(minrun, 보통 16 또는 32)이 될 때까지 정렬이 유지된 상태로 덩어리의 크기를 키운다. 병합 정렬은 원소의 개수가 적을 때 오버헤드가 발생하기 때문에 삽입 정렬이 빠르다. - 이진 병합
두 run을 병합할 때 병합이 필요없고 한 run만 사용해도 되는 구간을 미리 계산한다. - 병합 중 메모리
run A와 run B를 사용할 때 일반적인 병합 정렬은 A+B 크기의 버퍼를 사용하지만 팀 정렬은 min(A, B) 크기의 버퍼를 사용한다. 공간 복잡도는 [math(O(\frac{1}{2}n))]으로 개선되었긴 하지만 상수배의 차이이니 여전히 [math(O(n))]이다. - 질주(galloping)
두 run을 병합할 때 한 run만 계속 참조했다면 앞으로도 계속 그러할 것이라고 예상하고 2^k 뒤의 원소와 크기 비교를 하는 휴리스틱이다.
네이버 개발자 커뮤니티에 한국어로 자세한 설명이 되어있다.
병합 정렬과 비슷한 특징을 가지며 대부분의 경우 더 빠르다.
- 사용
- 파이썬
애초에 파이썬을 위해 만든 알고리즘인 만큼 파이썬의 기본 정렬 함수인sort()
는 팀소트로 동작한다. 3.11부터는 팀소트의 변형인 파워소트(powersort)로 옮겨갔다.# - 자바
자바의Array.sort()
는 조슈아 블로크(Joshua Bloch)가 구현한 병합 정렬의 개선 버전을 사용해오고 있었는데, 팀소트가 인기를 얻기 시작하면서 자바 진영에서 이를 포팅하기 시작했다. 이후 팀소트는 여러 벤치마크 테스트에서 최대 25배까지 빠른 성능을 올리는 것으로 확인되어 자바 7이 출시될 때는 자바 컬렉션의 공식 정렬 알고리즘으로 적용되기도 했다. - 기타
현재 팀소트는 안드로이드 뿐만 아니라 구글 크롬, 애플의 스위프트 등 현업에서 다양하게 활용된다. 다만 고(Go) 커뮤니티에서는 팀소트가 병합 정렬을 기반으로 하기 때문에 적어도 [math(O(\frac{1}{2}n))]의 추가 메모리 공간이 필요하다는 공간 복잡도를 이유로 들어 도입을 거절하기도 했다.
4.3.2. 블록 병합 정렬(Block merge sort)
추가 메모리는 [math(O(1))](상수)이면서 속도는 [math(\mathcal{O}(n \log n))]을 유지하는 병합 정렬 개조판이다. 위키정렬과 그레일정렬이 대표적이다. 둘 모두 2010년대 중반에 나온 방식이라 여러모로 최적화의 여지가 있다.
4.3.3. 인트로 정렬(Intro sort)
퀵+힙 정렬.
퀵 정렬을 사용하다가 잘못된 피봇의 설정으로 인해 재귀 깊이가 깊어지는 경우 그때부터는 힙 정렬을 사용한다. 이 역시 많이 쓰이며, 팀 정렬처럼 파티션이 작을 때 삽입 정렬로 전환하는 추가적인 최적화를 해주는 경우도 있다.
C++의 std::sort()에서 가장 널리 쓰이는 정렬 방법이며 (표준에 정의된 건 아니다) C#에서도 사용한다.
4.4. 그 밖에
특정한 상황에서는 O(n log n) 보다 더 빠른 O(n)으로 정렬을 실행할 수도 있다.4.4.1. 기수 정렬(Radix sort)
기수 정렬 알고리즘 중 LSD를 시각화한 것.
[math(\mathcal{O}(kn))] (k는 데이터의 자릿수를 의미한다.)
위에 나온 알고리즘은 모두 데이터끼리의 직접적인 비교를 이용하는데, 이렇게 데이터끼리 직접적인 비교를 하여 정렬할 경우 시간복잡도는 [math(\mathcal{O}(n \log n))]보다 작아질 수 없다. 이 기수 정렬은 자릿수가 있는 데이터(정수, 문자열 등)에서만 수행이 가능하며, 데이터끼리의 직접적인 비교 없이 정렬을 수행한다. 비교를 이용한 정렬이 아니기 때문에 k가 상수일 경우 시간복잡도가 [math(\mathcal{O}(n))]으로 퀵정렬보다 빠른 시간복잡도가 나오는 것이 가능하다. 다만 이 알고리즘은 자릿수가 적은 4바이트 정수 등에서나 제대로 된 성능을 발휘할 수 있으며, 자릿수가 무제한에 가까운 문자열 정렬 등에 사용할 경우 오히려 퀵정렬보다 느릴 수 있고, 부동 소수점의 경우는 부호여부, 지수부, 가수부에 대해 각각 기수정렬을 실행해야 한다.
기수 정렬의 방식은 대충 이렇다. 데이터가 x진법이라고 가정하자. 0번부터 x-1번까지의 리스트를 만들어 놓고, 각 데이터를 순서대로 현재 자릿수의 숫자가 가리키는 리스트에 밀어넣고, 리스트를 0번부터 x-1번까지 순서대로 이어붙인다. 이 과정을 가장 낮은 자릿수부터 가장 높은 자릿수까지 반복하면 정렬이 끝나게 된다.
(예시) 10, 5, 15, 234, 1: 10진법, 최대 3자리 수인 정수들을 정렬해보자. 편의상 010, 005, 015, 234, 001로 표기.
100의 자리: 0) 010, 1) 001, 4) 234, 5) 005, 015. 순서대로 이어붙이면 010, 001, 234, 005, 015.
101의 자리: 0) 001, 005, 1) 010, 015, 3) 234. 순서대로 이어붙이면 001, 005, 010, 015, 234.
102의 자리: 0) 001, 005, 010, 015, 2) 234. 순서대로 이어붙이면 001, 005, 010, 015, 234.
보다시피 마지막 자리까지 과정을 거치면 정렬된 결과인 1, 5, 10, 15, 234가 나온다. 실제 프로그램에서 정수를 정렬하는 코드를 짜는 경우, 10진법 대신 컴퓨터가 바이트 단위로 처리하기 쉬운 2진법을 사용하고, 각 자릿수를 정렬하는 과정은 밑의 카운팅 정렬을 이용하는 경우가 많다. 그렇게 코드를 짜면 퀵 정렬보다도 훨씬 빠른 속도로 정수를 정렬할 수 있다. 다만 위에 설명된 대로 이는 자릿수가 적은 상황에서만 그렇고 대부분의 경우에는 그렇지 않다.
그 이유는 시간 복잡도가 엄밀하게는 [math(\mathcal{O}(n))]이 아니라 [math(\mathcal{O}(kn))]이기 때문이다. 해당 데이터의 자릿수(k)는 상수로 간주하지만 어쨌든 k는 log에 해당된다.[29] 데이터의 수(n)가 데이터의 최댓값(m)보다 더 큰 경우 k=logm < m < n이니 k를 무시해도 되겠지만, 그렇지 않고 m이 충분히 크다면 m > k=logm > n, kn > n^2이 되니 k를 무시하면 안 된다.
4.4.2. 계수 정렬(Counting sort)
[math(\mathcal{O}(n+k))] (k는 데이터의 최댓값을 의미한다.)계수 정렬은 가장 큰 데이터에 따라 효율이 좌지우지된다. 쉽게 설명하자면 특정 데이터의 개수(1이 두 개 있다면 2)를 데이터의 값에 대응하는 위치에 저장한 뒤, 자신의 위치에서 앞에 있던 값을 모두 더한 배열을 만든 뒤, 거기서 데이터가 들어가야 할 위치를 찾아내는 정렬 알고리즘이다. 이 경우에 데이터의 최댓값을 k라 두면, 시간 복잡도는 [math(\mathcal{O}(n+k))]. 하지만, 만약 k가 억 단위를 넘어간다면?[30] n이 아무리 작아도 동작시간이 크다. 그럴 땐 위의 정렬들을 사용하는 게 바람직하다. 반대로 k가 매우 작다면, 오히려 선형시간의 효과를 볼 수 있다. 즉, k가 작다는 조건이라면 매우 효율적인 정렬. 또한 카운팅 정렬은 배열을 사용하는 특성상, 정수라는 전제를 깔고 한다.
4.4.2.1. 실행 과정
- 자료를 탐색해서 그 최댓값을 구한다.
input = [1, 5, 4, 6, 3, 7, 8, 9, 10, 2]
- 최댓값:
k = 10
k+1
만큼의 크기로 모든 자료가 0으로 초기화된 배열을 생성한다.- 배열
counts = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
(11개) input
의 모든 원소n
에 대하여counts
의n
에 대응하는 곳에 +1을 해준다.counts = [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- 이때
counts[n]
은 배열input
에 n이 몇 개 있는지를 의미한다. counts[i] += counts[i-1]
의 점화식을 1부터 k의 위치까지 행한다.counts = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
- 이때
counts[n]
은 배열input
에 n 이하의 원소가 몇 개 있는지를 의미한다. - 길이가
counts[k]
인 배열을 하나 더 생성한다. - 배열
ans = [N, N, N, N, N, N, N, N, N, N]
(10개, N은 Null) counts
의input[0]
에 대응하는 곳의 원소를 찾아서 t로 놓는다. 이제ans
의t-1
에 대응하는 곳에input[0]
을 저장하고,counts
의input[0]
에 대응하는 곳의 값은 -1 해준다.- 1이 주어짐
counts[1] = 1
- 대응하는 값인 1-1=0의 위치에 1을 삽입
ans = [1, N, N, N, N, N, N, N, N, N]
counts
는[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
에서[0, 0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
로 바뀜- 6의 과정을 남아 있는 자료에 대하여 반복한다.
- 5가 주어짐
counts[5] = 5
- 대응하는 값인 5-1=4의 위치에 5를 삽입
ans = [1, N, N, N, 5, N, N, N, N, N]
counts
는[0, 0, 2, 3, 4, 5, 6, 7, 8, 9, 10]
에서[0, 0, 2, 3, 4, 4, 6, 7, 8, 9, 10]
로 바뀜- ...
- 이런 식으로 n개의 자료를 모두 조사하면
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
순서로 정렬이 된다.
4.4.3. 셸 정렬(Shell's sort)
삽입 정렬이 거의 정렬된 배열에서 최적의 성능을 내는 것에서 착안한 정렬 방법이다. 삽입 정렬을 띄엄띄엄한 간격으로 먼저 수행하고, 그 간격을 점차 좁혀나가면서 최종적으로는 거의 정렬된 배열을 삽입 정렬로 마무리하게 된다. 지저분해 보이지만 삽입 정렬의 특성을 잘 이용한 정렬로, 실제 성능은 힙 정렬 등에 버금갈 정도로 빠르다! 이 정렬 알고리즘의 핵심은 어떤 간격으로 정렬을 수행해 나갈 것이냐는 것인데, 그 간격에 따라 시간 복잡도도 제각각이다. 그 간격을 잘 선택하는 경우 [math(\mathcal{O}(n^{1.25}))][31]까지도 가능하다고 하는데 일반적인 데이터 크기에 모두 적용할 수 있는 것은 아니다. 셸 정렬이란 이름은 개발자인 도널드 셸의 이름을 딴 것으로 유닉스의 셸과는 관련이 없다. 이것을 잘 몰랐는지 예전 번역서에는 '껍질 정렬'로 오역된 책이 있었다. 지금도 껍질 혹은 틀을 의미한다면서 큰 틀을 잡아나가면서 정렬해나가기에 셸 정렬이라는 말이 가끔 도는데 완전히 틀린 말이다.
4.4.4. 대기 정렬(Sleep sort)
#!syntax cpp
#include <chrono>
#include <string>
#include <thread>
#include <vector>
using namespace std;
int main(int argc, const char **argv)
{
vector<string> numbers(argv + 1, argv + argc);
vector<thread> thread_pool;
for (const auto &number : numbers)
{
thread_pool.push_back(thread([number]() {
this_thread::sleep_for(chrono::seconds(stoi(number)));
cout << number << endl;
}));
}
for (auto &thread : thread_pool)
{
thread.join();
}
return 0;
}
실행 결과:
$ ./a.out 5 1 3 2 11 6 4
1
2
3
4
5
6
11
간단히 설명을 하자면, 프로세스를 정렬할 배열 숫자만큼 생성한 뒤, 각 프로세스마다 각자 숫자를 하나씩 주면서 그 숫자의 시간만큼 슬립시킨다.
먼저 일어나는 프로세스부터 각자 가진 숫자를 표시한다.
시간 복잡도는 대충 [math(\mathcal{O}(n+k))](k는 데이터의 최댓값) 정도 되는데, 프로세스 n개를 생성하는 시간과 그 프로세스들이 슬립하는 시간에 비례할 것이기 때문이다. n이 적당히 작은 경우 [math(\mathcal{O}(k))]이라고 봐도 좋겠다.
수학적 해석을 위해서 사실과는 다르지만 컴퓨터가 프로세스를 하나씩 돌린다고 가정하면 [math(\mathcal{O}(nk))]이라고 할 수 있겠다.
근데 이 알고리즘은 레이스 컨디션이라는 문제를 간과하고 있다. 모든 fork된 프로세스가 순진하게 round robin으로 돌 거라 생각하면 안 된다. 저게 숫자가 작을 때는 문제가 안 되는데 만약 억 단위의 숫자를 정렬한다거나(시간도 억 단위로 걸리겠지만) 슬립 타이머를 마이크로초 이하로 아주 짧게 잡는다면 정렬이 완전하게 되지 않고 대략적으로 정렬이 된다. 즉 정렬 결과에 '노이즈'가 낀다.
알고리즘의 정의상 슬립 정렬은 실행 환경에 따라 결과가 달라지므로 '알고리즘'이라 부를 수 없고, 단지 '메서드'일 뿐이다. 그래서 알고리즘들을 소개할 때 슬립 정렬은 단독으로 등장하지 않고, 슬립 정렬 후 삽입 정렬을 이용하는 식으로 완벽하게 정렬해서 보여준다.
4.4.5. 중력 정렬(Gravity sort)
이 중력 정렬(Graivty sort)은 간단히 말하자면, 모든 값을 오른쪽 또는 왼쪽으로 미는 것으로 볼 수 있다. 오른쪽으로 밀면 오름차순 정렬이 되고 왼쪽으로 밀면 내림차순 정렬이 된다. 구슬 정렬(Bead sort) 혹은 주판 정렬이라고도 하는데, 아래 동영상에서 보다시피 가느다란 막대들이 나란히 있는 주판에 각기 다른 개수의 주판알을 꿰어놓은 뒤 막대들이 수직으로 세워지도록 주판을 돌려 보면 중력에 의해 주판을 돌린 방향대로 주판알들이 밀리면서 주판을 다시 정방향으로 세웠을 때 오름차순 또는 내림차순으로 정렬이 된 형태가 되기 때문이다.예를 들어 3,1,2를 오름차순으로 정렬한다고 가정해보자. 그러면 우선 저 세 개의 값을 T/F로 나타내야 한다. 나타내는 방법은 값이 n이면 n개의 T를 뒤쪽에 연속으로 배치하고 그 앞에는 최댓값에서 n을 뺀 숫자만큼의 F를 연속으로 배치한다. 예를들어 여기서 1은 FFT로 나타내는 식이다.
312 TFF TFT TTT | → | 132 FTF FTT TTT | → | 123 FFT FTT TTT |
그 후, 저 값의 T 개수를 다시 숫자로 환산해주면 1,2,3이 되는 형식으로 정렬이 진행된다.
중력 정렬은 배열 내에서 정렬될 값들의 크기를 비교하지 않는다는 특징이 있다. 따라서 안정 정렬도 아니고 불안정 정렬도 아니다. 그러나 이러한 특징 때문에 정수형에만 제한적으로 적용할 수 있으며, 정렬할 배열 크기가 커지면 그만큼 필요한 메모리가 기하급수적으로 커진다는 단점도 있다.
4.5. 비효율적인 정렬
비효율적인 정렬의 대표 주자인 스투지 정렬과 보고 정렬에 대해 설명하는 영상. |
4.5.1. 스투지 정렬(Stooge sort)
재귀함수를 사용하는 정렬법이다. 이름은 미국의 코미디언 그룹인 쓰리 스투지스에서 따왔다. 어원에서 알 수 있듯 세 개의 요소를 정렬하는 방법을 n개의 요소에 대해 재귀적으로 확장한 방법. 이 단어는 보통 '바보 삼총사' 정도로 번역되는데, 그 말 그대로 바보같은(...) 정렬법이다. 가끔 Stooge라는 단어를 그대로 번역하여 '꼭두각시 정렬'이라고 하기도 한다.
정렬할 요소가 3개인 경우를 생각해보자. 1번 요소와 3번 요소를 비교하여 정렬하고, 그 다음 1번 요소와 2번 요소를, 마지막으로 2번 요소와 3번 요소를 비교하여 정렬하면 3개의 요소 중 2개씩 고르는 모든 경우의 수에서 정렬하였으므로 세 개의 요소가 올바르게 정렬될 수 있음을 알 수 있다. 마지막 정렬에서 2번 요소와 3번 요소의 순서가 잡혔으므로, 세 요소가 올바르게 정렬됐는지를 확인하려면 1번 요소와 2번 요소만 마지막으로 한 번 더 비교하면 된다. 스투지 정렬은 이런 정렬 방식을 n개의 요소 전체로 확장한 것이다. 다음과 같다.
- 구간 전체에서 첫 번째 요소와 마지막 요소를 비교하여 정렬한다.
- 요소가 세 개 이상일 경우, 다음의 과정을 거친다.
- 구간 전체의 앞쪽 2/3에 해당하는 구간(올림)에서 스투지 정렬을 수행한다.
- 구간 전체의 뒤쪽 2/3에 해당하는 구간(올림)에서 스투지 정렬을 수행한다.
- 구간 전체의 앞쪽 2/3에 해당하는 구간(올림)에서 다시 스투지 정렬을 수행한다.
시간 복잡도는 [math(\displaystyle O(n^\frac{log 3}{log 1.5}) = O(n^{2.7095}))]이다. n의 지수가 2보다 크므로 위에서 비효율적이라고 언급됐던 [math(O(n^2))]배열보다도 확실하게 느리다.
4.5.2. 보고 정렬(Bogo sort, stupid sort)
평균 시간복잡도 [math(\mathcal{O}(n \times n!))], 최악일땐 [math(\mathcal{O}(\infty))]
이름 그대로 멍청한 정렬이다. 랜덤으로 데이터들을 재배열한 후, 정렬되었는지 검사한다. 정렬이 되어있지 않으면 다시 정렬될 때까지 랜덤하게 재배열한다. 덕분에 최악의 경우 정렬이 영원히 안 끝날 수도 있다! 물론 정렬된 데이터는 재배열 없이 한 방에 끝난다.
이딴 걸 어디에 써먹나 싶겠지만 이 보고 정렬은 유전 알고리즘과 결합하면 나름 쓸만해지게 된다. 데이터에 따라서는 하나의 값만으로 크기를 비교할 수 없는 경우도 있다. 예를 들어 n차 다항식을 풀어서 나오는 결과값으로 정렬해야 하는 경우도 있는데 이런 데이터는 위에 나온 모든 정렬 알고리즘들이 다 소용없어진다. 이때 n차 다항식은 배열에서 데이터의 현재 위치까지 변수에 들어있는 등 사전에 계산해두는 게 불가능한 데이터이다. 이때 적절한 평가 함수와 유전 알고리즘을 조합하고 나서 유전 알고리즘의 후손 생성 알고리즘에 이 보고 정렬을 결합하는 것이다. 정말로 멍청하게 정렬된 후손은 도태되고 조금이라도 우수하게 정렬된 후손이 선택되는 과정이 반복돼서 최종적으로는 완전히 정렬된 데이터가 나온다.
다만 유전 알고리즘의 단점을 그대로 물려받는데, 정답이 언제 나올지는 아무도 모른다. 그래도 실제로 돌려보면 대자연의 신비인지 생각보다 빠른 시간 안에 정답으로 수렴해간다.
4.5.3. 보고보고 정렬(Bogobogo sort)
관련 자료
보고 정렬을 이용해 만든 더욱 비효율적인 정렬 알고리즘이다.
원문에 따르면 시간 복잡도 [math(\mathcal{O}(n!^{n!}))]
최악의 상황은 [math(\mathcal{O}(\infty))]
보고 정렬은 위 문단에서 기술했듯이 "랜덤으로 데이터들을 재배열한 후, 정렬되었는지 검사" 하는 알고리즘이다. 보고보고 정렬은 보고 정렬의 "정렬되었는지 검사"하는 과정에서 처음부터 끝까지 순차적으로 검사하는게 아니라 보고보고 정렬을 재귀 호출하는 다른 알고리즘을 쓴다. 그 검사 알고리즘은 아래와 같다.
- 검사되어야 할 데이터의 카피를 만든다.
- 그 중 첫 n-1개의 데이터를 보고보고 정렬한다.
- n번째 숫자가 첫 n개의 데이터 중에서 가장 큰지 확인한다. 그렇다면 이 데이터는 정렬되었다. 아니라면 랜덤으로 데이터를 섞은 뒤 2로 돌아간다.
- 이 리스트가 원래의 리스트와 순서가 같은지 검사한다. 맞다면 이 리스트는 정렬된 것이다.
이 검사를 통과하지 못한다면 데이터를 섞은 뒤 다시 이 방법으로 정렬 여부를 확인한다.
참고로 2의 재귀호출 덕분에 이 짓거리를 n=2 에서부터 시작해야 한다. 거기다 스텝 3에서는 (n-1)/n의 확률[32]로 스텝 2에서 겨우겨우 정렬되었던 n-1개 데이터를 다시 섞어버린다. 그러면 다시 n-1개 데이터를 보고보고 정렬해야 한다.
원문에 따르면 고작 6개의 숫자를 정렬하는 데에 450초가 걸렸고, 7개부터는 밤새 컴퓨터를 돌려도 정렬이 끝나지 않아 기다리다 포기했다고 한다. 원문의 계산식에 따라 6개 정렬시간을 기준으로 7개를 정렬하는데 필요한 시간을 계산하면 대략 3년 이상 걸릴것으로 추정된다.
5. 관련 문서
[1] 알고리즘 문제 등을 풀 때 [math(\mathcal{O}(n \log n))] 보다 빠른 방법은 도저히 없을 것 같다고 판단되는 문제인 경우 일단 정렬하는 걸 가정하고 생각해봐도 무방할 정도이다. 물론 입력 데이터가 정렬 가능한 데이터 형식인 경우에만.[2] 이런 경우는 해시 탐색을 사용하기도 한다.[3] 정확히는 42억 9496만 7296개[4] 2^32 = 4,294,967,296[5] 정확히는 85억 8993만 4592개[6] 정렬을 필요로 하지 않는 해시 탐색이라는 [math(\mathcal{O}(1))]짜리 알고리즘도 있긴 한데 이건 탐색항목을 따로 작성해야 한다.[7] 전산학에서 로그의 밑은 2인 경우가 많다. [math(\log_2 x)]를 [math(\lg x)]로 줄여서 쓴다. 근데 Big-O 표기법에서 로그의 밑은 상관없다. 어차피 밑변환하면 상수배니까[8] 스털링 근사([math(\ln n! \approx n(\ln n -1))])로 유도 가능하다.[9] 물론 중퇴 후 명예학위라 결국 박사논문이 되지는 못했지만 그 정도 가치는 있었다고...[10] 단, 정말 변태적인 세팅을 하지 않는 한 일반 PC와 비주얼 스튜디오 같은 컴파일러에서는 관찰하기 어렵다. 온갖 방법으로 최적화를 하기 때문에.[11] 물론 이것은 최악의 경우이다. 최선의 경우(이미 정렬된 경우)는 0번.[12] [math(\displaystyle \sum \limits_{i=1}^{n-1} i = \displaystyle \frac{n(n-1)}{2})][13] 버블소트와 같은 [math(O(n^2))]짜리 알고리즘은 데이터량에 비례해서 수행시간이 제곱으로 성장한다. 이게 뭐가 문제냐 할 수 있겠지만 만약 1억 개의 데이터를 정렬한다고 치면 퀵정렬보다 무려 약 107=천만 배 느리다.[14] 엄청 최근 얘기처럼 들리지만 C++ STL은 90년대에 나왔다.[15] 예를 들어 카드 게임을 할 때나 번호대로 도서를 정리할 때. 필요한 임시 저장공간이 적고 컴퓨터와 달리 자료들을 밀어낼 때 소요시간이 없기 때문[16] 최악의 경우가 [math(\displaystyle \frac{n(n-1)}{2})]에 비례한다.[17] 이는 위에서 소개한 [math(\mathcal{O}(n^2))]보다 혁명적으로 성능이 개선된 것이다. 혹시 '겨우?'라는 생각이 든다면, n에 10, 100, 1000, 10000을 각각 대입해보자.[18] 추가적인 메모리 없이 병합 정렬을 수행할 수도 있지만, 그 경우 시간복잡도가 [math(O(n(\log n)^2))]으로 늘어난다.[19] stable sort. 정렬을 해도 같은 값의 앞뒤 순서가 바뀌지 않는 정렬 알고리즘을 보고 stable하다고 한다.[20] 퀵 정렬의 경우는 대개 원소들끼리 근접한 메모리 영역에 붙어 있는 배열을 사용하기 때문에 일반적으로 캐시 친화적이지만 힙정렬의 원소들은 좀 더 흩어져 있는 경우가 많아서 캐시 친화도가 떨어지는 문제가 있다. 또한 힙정렬은 일반적으로 포인터 연산을 많이 사용하기 때문에 거기에 걸리는 오버헤드도 무시할 수는 없는 수준[21] 의사난수를 이용할 수도 있으나 대개 중위법(처음 원소, 가운데 원소, 끝 원소의 중간값)을 사용하는 게 더 빠르다. 사실 이 기준 원소를 잘 잡는 법을 다룬 논문도 꽤 많다.[22] 그러니까 partition step을 비효율적으로 하면 느려터진 퀵 정렬이 된다.[23] 이러면 거의 정렬되거나 거의 역정렬된 리스트도 아주 빨리 정렬이 가능하다.[24] gcc와 Visual C++에 구현된 방법이 바로 이것.[25] gcc에서 [math(\mathcal{O}(n^2))]이 나오는 입력을 자동 생성해주는 알고리즘[26] 여러 원소의 키 값이 같을 경우 처음 데이터에서 앞서있는 원소가 정렬을 한 다음에도 앞서는 것[27] 예를 들면 N번째 노드의 왼쪽 노드를 N*2+1,오른쪽 노드를 N*2번째 값이라고 생각하고 프로그램을 구현 할 수 있다.[28] 배열로 이를 구현할 경우 최악의 경우 2(입력될 값의 수)이 되는데 이가 얼마나 심각하냐면 최악의 경우 10개의 값만 입력받더라도 210 즉 1,024개의 공간이, 20개가 되면 220 즉 1,048,576개의 공간이 필요해진다.경험담이다 하지만 만들기 편하잖아[29] x진법을 사용하고 데이터의 최댓값이 m이라면 [math(k = \log_{x} m)] 으로 나타낼 수 있다.[30] 데이터가 항상 작은 값만 들어오리라는 보장이 없기 때문에 범용적으로 쓰기엔 문제가 있다. 또한 이 경우엔 메모리 효율성도 떨어진다. 만약 k가 21억이라면... 8기가 메모리: 버틸 수가 없다! 물론 자료에다가 로그 취한 값을 각주로 달아준뒤 그 각주를 비교하여 자료들을 정렬시킬 수도 있다.[31] 알고리즘 교과서에는 [math(\mathcal{O}(n^{1.33}))]의 예제가 있다.[32] n번째 숫자가 가장 클 확률