Timsort
평소에 자주 즐겨보는 테크블로그 중 재미있는 글이 있어 분석하고 공부한 내용을 공유하려고 합니다
부족한 부분에 대한 지적은 감사히 받겠습니다!
About Timsort
팀소트란 Tim Peters가 고안한 정렬 알고리즘으로 Insertion sort와 Merge sort를 결합한 정렬입니다
기존의 Insertion Sort의 장점을 가져오고 Merge Sort를 최적화 하여 시간 복잡도와 공간 복잡도를 줄였습니다
팀소트의 시간 복잡도와 공간 복잡도는 다음과 같습니다
복잡도 | Big O Notation |
---|---|
최선 시간 복잡도 | O(n) |
평균 시간 복잡도 | O(n logn) |
최악 시간 복잡도 | O(n logn) |
공간 복잡도 | O(n/2) |
Insertion Sort + Merge Sort == Time Sort ?
시간 복잡도가 O(n^2)인 삽입 정렬을 왜 합병 정렬과 결합하였을까
삽입 정렬의 시간 복잡도는 정확히 말하면 C * ( N ^ 2 ) + a
입니다
비교적 무시하는 값인 a를 제외하고 C값에 영향을 미치는 요소가 Cache Locality
입니다
삽입정렬의 경우는 캐시 지역성을 잘 반영한 정렬이기 때문에 적은 엔티티 요소에 있어서 시간 복잡도가 C * N * logn + a
의 합병 정렬보다 빠르다고 합니다
즉 삽입 정렬의 C값을 Ci 라 하고 합병 정렬의 C값을 Cm이라 하였을 때 Ci * ( N ^ 2 ) + a < Cm * N * logn + a
이 성립하게 됩니다
팀 소트는 적은 엔티티에 대해 삽입 정렬을 적용해 정렬을 수행합니다
이 때 삽입 정렬에 사용되는 엔티티 수가 2 ^ x
개 라고 할 때 팀소트의 수행 시간은 Ct * n * (logn - x) + a
이 됩니다
팀 소트는 2 ^ x
의 값을 2 ^ 5 ~ 2 ^ 6
으로 정의합니다
즉 32 ~ 64개의 엔티티 사이에선 합병 정렬보다 삽입 정렬이 더 우수하다고 해석할 수 있습니다
그렇기 때문에 두 소팅 알고리즘에 대해 비교를 수행했습니다
두 소팅 알고리즘을 비교하기 위해 이전에 만들어둔 소팅 알고리즘을 활용했고 순수 정렬 시간만을 최대한 측정하기 위해 inline 함수와 함수 호출간 파라미터 바인딩에 참조자(&)를 사용했습니다
두 소팅 수행 시간 비교에 대한 코드는 다음과 같습니다
1 |
|
또한 두 소팅 알고리즘의 수행 시간 결과는 다음과 같습니다
1 | Time difference merge sort = 21[µs] |
엔티티의 수가 1000개 일때의 두 소팅 알고리즘의 수행 시간 결과는 다음과 같습니다
1 | Time difference merge sort = 696[µs] |
실험을 해 본 결과 실제로 적은 엔티티의 데이터에 대해서 삽입 정렬이 합병 정렬보다 빠르게 정렬을 수행함을 확인할 수 있었습니다
TimSort 최적화
Run
팀소트의 a값을 유지하면서 x값을 늘려 전체 수행시간을 줄이는 방법을 통해 팀소트는 최적화를 진행합니다
이 때 x값을 늘리는 방법이 삽입 정렬을 통해 정렬 수행시간의 이익을 많이 볼 수 있는 부분을 최대한 가져가는 방법을 취합니다
삽입 정렬을 통해 만드는 단편적으로 정렬된 배열을 Run이라 표현하며 이 Run의 min값은 32 ~ 64로 정의합니다
이 Run을 생성하는 방법에 있어서도 오름차순과 내림차순중 최적의 방법을 선택하여 Run을 생성하게 됩니다
Binary Insrtion Sort
팀소트에서 사용되는 삽입 정렬은 일반 삽입정렬이 아닌 이분 탐색을 활용한 Binary Insertion Sort를 진행합니다
삽입 하는 위치를 찾는데 있어서 일반 삽입정렬보다 캐시 지역성은 떨어지지만 하나의 원소를 삽입하기 위해 O(n)의 비교 대신 O(logn)의 비교를 수행해 시간을 절약합니다
또한 삽입될 인덱스를 찾은 후엔 시프트 연산을 통해 정렬된 상태를 만들기 때문에 더욱 효율적입니다
Merge
삽입 정렬로 만들어지는 Run들을 어떻게 하면 효율적으로 병합을 수행할 수 있는가에 대한 이야기 입니다
병합에 있어 적은 메모리들을 사용해서 병합을 진행하기 위해 C > A + B의 조건과 B > A을 만족하는 스택을 사용하게 됩니다
해당 조건을 만족하는 스택을 사용하게 될 경우 스택에 있는 run 수를 작게 유지할 수 있고 비슷한 크기의 인접해있는 run끼리의 병합을 진행할 수 있게 됩니다
Run Merge
두 run의 병합에 있어서도 최대한 적은 메모리를 활용하기 위해 최적화를 적용합니다
Run1 : 1 2 3 5 7 9
Run2 : 4 6 8 10 11 12
On memory : 1 2 3 5 7 9 4 6 8 10 11 12
두 개의 Run이 존재 할 때 정렬을 수행해야할 지점을 찾기 위해 Run Merge를 수행합니다
Run1에서 반드시 정렬을 수행해야할 지점은 Run2[0]의 값보다 큰 Run1[3]이 해당됩니다
Run2에서 반드시 정렬을 수행해야할 지점은 Run1[5]의 값보다 작은 Run2[2]가 해당됩니다
이렇게 정렬을 수행해야할 지점을 찾아 필요없는 부분을 제외해가며 최적화된 정렬을 수행합니다
또한 병합을 위해 메모리 복사를 수행해야 하는데 이 두 정렬해야할 부분의 Run중 적은 메모리 공간을 차지하고 있는 곳을 선택해 복사를 진행한 후 정렬을 수행하기 때문에 공간복잡도가 O(n/2)가 나오게 됩니다
Galloping
또한 병합을 수행할 인덱스를 찾기 위해 Galloping이라는 방법을 사용하게 됩니다
하나씩 인덱스를 증가시켜 병합을 수행할 적절한 위치를 찾는 것 보다 합병정렬을 수행하는 시점에서 이미 엔티티들은 어느정도 정렬이 진행된 상태라는 점을 고려하여 인덱스를 찾게 됩니다
인덱스를 찾기 위해 하나씩 비교하는 One pair at a time mode를 3번 수행하다 인덱스를 찾지 못하면 1 2 4 8 … 과 같이 2 ^ k씩 점프해가며 삽입될 인덱스를 탐색합니다
구현
Naver D2에 나와있는 포스팅을 토대로 구현한 Timsort는 다음과 같습니다
1 |
|
1 | Time difference tim sort = 488[µs] |
이미 정렬된 엔티티들에 대한 팀소트 수행 결과
1 | Time difference = 15[µs] |