2751. 수 정렬하기 2
https://www.acmicpc.net/problem/2751
문제
N개의 수가 주어졌을 때, 이를 오름차순으로 정렬하는 프로그램을 작성하시오.
입력
첫째 줄에 수의 개수 N(1<=N<=1,000,000)이 주어진다. 둘째 줄부터 N개의 줄에는 숫자가 주어진다. 이 수는 절대값이 1,000,000보다 작거나 같은 정수이다. 수는 중복되지 않는다.
출력
첫째 줄부터 N개의 줄에 오름차순으로 정렬한 결과를 한 줄에 하나씩 출력한다.
예제 입력
5
5
4
3
2
1
예제 출력
1
2
3
4
5
1. 병합 정렬 merge sort 소개
앞선 두 개의 정렬 알고리즘은 어찌됐건 O(n ^ 2)의 시간복잡도를 가진다.
위의 문제를 보면 최악의 경우 백만 칸의 배열을 정렬해야 한다.
백만의 제곱으로는 시간 제한내에 들어오지 못한다.
그러면 더 빠른 알고리즘을 알아햐 하니까, 병합 정렬에 대해 알아보려고 한다.
우선 병합 정렬은 분할 정복이다. 즉 문제를 쪼개어서 부분 문제로 만들어 해결하자는 뜻으로, 결국 재귀적인 패러다임이다.
때문에 하위 문제는 상위 문제보다 적용 범위가 작아야 하고, 범위가 작아지는 한계인 탈출지점이 있어야 한다.
분할 정복은 다음 세 부분으로 나눌 수 있다.
1) 분할 : 원래의 문제를 분할하여 같은 문제 유형의 더 작은 하위 문제들로 나눈다.
2) 정복 : 하위 문제들을 재귀적으로 해결해야 한다. 충분히 하위 문제가 작아졌다면 탈출해야 한다.
3) 합치기 : 하위 문제들의 답을 합쳐 원래 문제를 해결한다.
도식화 하자면 다음과 같다.
이제 병합 정렬을 분할 정복해보자. 하위 문제를 잘 정의해야 한다.
당연히 원래의 문제는 수열을 정렬하는 것이다. 하위 문제는 수열의 일부분만 정렬하는 문제라고 정의하자.
폰 노이만은 병합 정렬을 만들 때 다음의 단계를 거치라고 했다.
1) 분할 : 수열을 반으로 쪼갠다. [14, 7, 3, 12, 9, 11, 6, 2] -> [14, 7, 3, 12] / [9, 11, 6, 2]
2) 정복 : 쪼개진 수열 각각에 대해 정렬한다. [3, 7, 12, 14] / [2, 6, 9, 11]
3) 결합 : 두 수열을 합쳐서 정렬한다 [2, 3, 6, 7, 9, 11, 12, 14]
하위 배열 [14, 7, 3, 12]은 어떻게 정렬할까? 마찬가지로 분할-정복-결합을 거쳐 [3, 7, 12, 14] 로 만든다.
다음은 위의 예시가 어떻게 재귀적으로 전개되는지 과정을 보여준다.
2. 코드
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | #include <stdio.h> #include <vector> using namespace std; int n, tmp; vector<int> v; void mergesort(int from, int to); void merge(int from, int mid, int to); void mergesort(int from, int to) { if (from == to) { return; } int mid = (from + to) / 2; mergesort(from, mid); mergesort(mid + 1, to); merge(from, mid, to); } void merge(int from, int mid, int to) { int l = from, r = mid + 1; int len = to - from + 1; vector<int> tmp(len); for (int i = 0; i < len; ++i) { if (l > mid) tmp[i] = v[r++]; else if (r > to) tmp[i] = v[l++]; else if (v[l] < v[r]) tmp[i] = v[r++]; else tmp[i] = v[l++]; } for (int i = 0; i < len; ++i) v[from + i] = tmp[i]; } int main() { scanf("%d", &n); for (int i = 0; i < n; ++i) { scanf("%d", &tmp); v.push_back(tmp); } mergesort(0, n - 1); for (int i = 0; i < n; ++i) printf("%d ", v[i]); return 0; } | cs |
아마 병합단계를 구성하는게 가장 어려웠을 것이다.
단순히 두 배열을 합치고 지금까지 배운 삽입-정렬이나 거품-정렬을 쓸 수도 있지만
그리한다면 오히려 더 느린 알고리즘이 되어버린다.
이를 선형시간에 해결할 아이디어는, 우리가 문제를 재귀적으로 풀고 있었다는 점에서 떠올릴 수 있다.
우리가 합쳐서 정렬할 두 부분 배열의 특징은 무엇인가? 바로 이미 재귀적으로 정렬되어버린 배열이라는 점이다.
따라서 다음과 같은 방법을 쓸 수 있다.
4칸 부분 배열 두 개를 합쳐 8칸 짜리 배열을 만들어야 한다고 해보자.
첫칸에는 어떤 숫자가 와야 하는가? 8개 숫자 중에 가장 작은 수이다.(오름차순)
고맙게도 두 부분 배열의 맨 앞은 가장 작은 숫자의 두 후보이다. 따라서 두 배열의 맨 앞의 숫자 중에 작은 수를 가져다 쓰면 된다.
둘째 칸에는 어떤 숫자가 와야 하는가? 두번째로 작은 숫자이다.
후보는 두 배열의 맨 앞 숫자이다.(인덱스로는 1 / 0)이다. 두 숫자중 작은 수를 가져다 쓴다.
....
그래서 n칸 짜리 임시배열을 만들고, 채우고, 다시 원본 배열에 복사해주는 편이 편하다.
원본 배열만 써서 이를 구현하면(in-place) 번거롭고, 오래걸린다.
3. 시간 복잡도
결론적으로 병합-정렬의 시간복잡도는 O(n * log n)이다. 백만 개가 주어진다면, O(백만 * 6)이다.
어떻게 차수는 줄어들 수 있었을까? 재귀적으로 분석해보자.
결국 최종적으로 완성되는 배열은 두 부분 배열(이미 정렬된)을 합치면서 완성된다. 위에서 합치는 방법이 선형시간임을 보였으므로,
합치는데는 O(n)이다.
그러면 정렬된 두 부분배열을 만드는데는 얼마나 걸렸을까? 각각의 크기는 n/2 이므로 O(2 * (n / 2)) = O(n) 만큼 걸렸다.
그림으로 알아보자.
그림을 통해 알 수 있는 사실은 O(n)은 트리의 높이만큼 수행된다는 것이다.
트리의 높이는 얼마나 되는가? log n 이다. 따라서 시간복잡도는 O(n * log n)이다.
또한 생각할 점은 원래의 배열이 어찌되었건 간에 쪼개는 상황은 반복된다는 것이다.
따라서 최선의 경우(이미 정렬되어 있어도)에도 시간복잡도는 O(n * log n) 이다.
'알고리즘 > 정렬 알고리즘' 카테고리의 다른 글
5. 카운팅 정렬 counting sort (0) | 2017.09.27 |
---|---|
4. 힙 정렬 heap sort (0) | 2017.09.27 |
2. 거품 정렬 bubble sort (0) | 2017.09.25 |
1. 삽입 정렬 insertion sort (0) | 2017.09.25 |