티스토리 뷰

주의 사항!

  • 이 글은 제가 직접 공부하는 중에 작성되고 있습니다.
  • 따라서 제가 이해하는 그대로의 내용이 포함됩니다.
  • 따라서 이 글은 사실과는 다른 내용이 포함될 수 있습니다.


앞서 단순한 정렬 알고리즘으로 버블 정렬, 선택 정렬, 삽입 정렬에 대해 알아보았습니다. 세 정렬 방법 모두 성능이 좋지 못한 정렬 알고리즘이라는 것을 알 수 있었습니다. 물론 데이터의 수가 적은 경우에는 간단하게 사용할 수 있는 좋은 알고리즘이지만 데이터의 수가 많아지면 이보다 더 효율적인 알고리즘이 필요하게 됩니다. 이번에 소개하는 알고리즘이 바로 그러한 알고리즘들입니다.


우리는 이전에 '힙'에 대해 배웠습니다. 바로 이 힙을 이용한 정렬 방법이 '힙 정렬'입니다. 힙에 대해서는 앞서 설명했기 때문에 다음의 예제만 보아도 쉽게 이해가 될 것으로 생각됩니다.

//main.c 소스 파일로 저장
#include <stdio.h>
#include "ArrayListBasedHeap.h"    //앞서 구현했던 힙의 헤더 파일을 인클루드

int PriorityComparision(char ch1, char ch2)
{
	if (ch1 == ch2)
	{
		return 0;
	}
	else
	{
		return (ch1 < ch2) ? 1 : -1;
	}
}

void HeapSort(int arr[], int n, PriorityComp pc)
{
	Heap heap;
	HeapInit(&heap, pc);

	//정렬 대상을 가지고 힙을 구성한다.
	int i;
	for (i = 0; i < n; i++)
	{
		HInsert(&heap, arr[i]);
	}

	//순서대로 하나씩 꺼내서 정렬을 완성한다.
	for (i = 0; i < n; i++)
	{
		arr[i] = HDelete(&heap);
	}
}

int main(void)
{
	int arr[4] = { 3, 4, 2, 1 };
	HeapSort(arr, sizeof(arr) / sizeof(arr[0]), PriorityComparision);

	int i;
	for (i = 0; i < 4; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");

	return 0;
}

/*
실행결과

1 2 3 4

*/

위 예제에서,

void HeapSort(int arr[], int n, PriorityComp pc)
{
	Heap heap;
	HeapInit(&heap, pc);

	//정렬 대상을 가지고 힙을 구성한다.
	int i;
	for (i = 0; i < n; i++)
	{
		HInsert(&heap, arr[i]);
	}

	//순서대로 하나씩 꺼내서 정렬을 완성한다.
	for (i = 0; i < n; i++)
	{
		arr[i] = HDelete(&heap);
	}
}

위 함수는 배열과 배열 요소의 개수, 그리고 정렬 방법을 표현하는 함수를 전달받습니다. 그리고 힙을 생성하여 배열에 있는 값을 하나씩 힙에 저장하고, 모든 배열 요소의 저장이 끝나면 이를 다시 힙에서 꺼내서 배열에 차례로 저장합니다. 힙은 데이터를 저장하고 삭제하는 과정에서 자동으로 정렬이 되기 때문에 힙에 데이터를 넣고 빼는 것만으로도 오름차순 및 내림차순의 정렬이 가능합니다. 오름차순으로 정렬할 것인지 내림차순으로 정렬할 것인지는 매개변수 pc로 전달받는 함수에 따라 달라집니다.


이제 힙 정렬의 성능을 평가해 보겠습니다. 힙 정렬에서 정렬 알고리즘은 데이터를 힙에 저장하고 다시 빼내는 과정을 거쳐 이루어집니다. n번째 데이터 한 개를 힙에 저장할 때 최악의 경우 빅-오는 다음과 같습니다. 계산과정은 설명하지 않겠습니다.

만약 정렬해야 할 데이터가 n 개라면 이에 대한 빅-오는 다음과 같다고 책에서는 설명합니다.

하지만 정확히는 n개의 데이터를 힙에 하나하나 저장하는 경우 빅-오는 다음과 같습니다.

물론 위와 같은 빅-오 표기법이 있는지는 모르겠지만 아무튼 계산해보면 위와 같이 n팩토리얼이 들어가는 빅-오가 계산됩니다. 두 빅-오가 어떻게 다른지 그래프로 보이겠습니다.

이 둘을 n과 n^2과도 비교해 보겠습니다.

이상적인 알고리즘까지는 아니지만 확실히 n^2의 빅-오를 가졌던 단순 정렬 알고리즘들에 비하면 매우 효율적인 알고리즘이라는 것을 알 수 있습니다.

 

저는 여기서 한 가지 궁금점이 생겼습니다. 아래의 두 빅-오를 같다고 봐야 할까? 다르다고 봐야 할까? 에 대한 것입니다.

그래프의 모양새만 보면 서로 닮았습니다. 하지만 분명한 격차는 존재했고, 그 격차는 데이터의 수가 증가함에 따라 점점 벌어졌습니다. 두 그래프의 격차가 데이터의 수에 비례해 어느 정도로 벌어지는지 궁금했습니다.

다음 그래프를 보겠습니다.

둘의 격차는 빅-오 n 보다도 큰 폭으로 벌어지고 있었습니다. 이 정도의 격차를 보이는 두 빅-오를 같게 보아야 할지는 잘 모르겠습니다. 저는 서로 구분되게 다른 빅-오로 보고 싶습니다.

 

그런데 다시 생각해보니 결국 위 두 빅-오는 같은 것으로 봐야 할 것 같습니다. 그 이유는 n 팩토리얼을 분해해보는 과정을 통해서 확인할 수 있습니다.

n 팩토리얼은 다음과 같습니다.

이를 다르게 표현하면 다음과 같습니다.

그리고 위 수식을 풀면 결국 최고차항은 다음과 같이 되기 때문에

결국 두 빅-오는 같은 것으로 봐야 할 것 같습니다.


이번에는 다른 알고리즘을 설명합니다. 이번에 설명하는 병합 정렬은 '분할 정복'이라는 알고리즘 디자인 기법에 근거하여 만들어진 정렬 방식입니다. 분할 정복이란 복잡한 문제를 복잡하지 않은 문제로 분할하여 정복하는 방법입니다. 단, 분할해서 정복했으니 정복한 후에는 결합의 과정을 거쳐야 합니다. 즉, 다음과 같은 3단계를 거치도록 알고리즘을 디자인하는 것이 분할 정복법입니다.

  1. 해결이 용이한 단계까지 문제를 분할한다. (분할)
  2. 해결이 용이한 수준까지 분할된 문제를 해결한다. (정복)
  3. 분할해서 해결한 결과를 결합하여 마무리한다. (결합)

 

이 방법을 근거로 디자인된 병합 정렬 알고리즘의 기본 원리는 다음과 같습니다.

 

"8개의 데이터를 동시에 정렬하는 것보다, 이를 둘로 나눠서 4개의 데이터를 정렬하는 것이 쉽고, 또 이들 각각을 다시 한번 둘로 나눠서 2개의 데이터를 정렬하는 것이 더 쉽다." 

 

따라서 기본적인 정렬의 방식은 다음과 같이 설명할 수 있습니다.

8 2 3 7 1 5 4 6

위와 같이 8개의 데이터가 있을 때 이를 분할하여 4개씩 나눕니다.

8 2 3 7
1 5 4 6

그리고 각각 4개의 데이터를 정렬합니다.

2 3 7 8
1 4 5 6

이후 분할했던 데이터를 다시 결합합니다. 결합과정에서도 정렬이 이루어지도록 결합합니다.

1 2 3 4 5 6 7 8

이렇게 해서 정렬이 완료됩니다. 이 예는 8개의 데이터를 오름차순으로 정렬하기 위해 병합 정렬 알고리즘을 사용하였고, 8개의 데이터를 한번 분할하여 2개로 나누었습니다. 하지만 실제로는 훨씬 더 작게 분할을 해야 합니다.

 

위의 예에서 4개 데이터로 이루어진 배열을 다시 한번 나누어서 2개의 데이터로 이루어진 배열로 만들면 두 값을 비교해서 위치만 바꾸면 되기 때문에 아마 데이터가 2개 묶음으로 나뉠 때까지 분할하면 될 것으로 생각할지도 모르겠습니다. 하지만 분할과정은 데이터가 1개씩만 남을 때까지 수행됩니다. 

 

그럼 여기서 궁금증이 생길 수 있습니다. 어차피 데이터를 1개씩 남도록 분할할 거면 그냥 첫 번째 요소부터 시작해서 하나하나 분해하면 될 것 같은데 왜 번거롭게 반으로 쪼개고, 또 반으로 쪼개는 식으로 분할을 하는지 궁금할 수 있습니다. 이는 재귀적인 구현을 위한 것입니다. 병합 정렬을 진행하는 함수의 이름을 MergeSort라고 하면, 이 함수는 다음과 같이 선언됩니다.

void MergeSort(int arr[], int left, int right);

첫 번째 인자로 정렬 대상인 배열을 전달하고, left에는 배열의 가장 왼쪽에 있는 요소의 index, right에는 배열의 가장 오른쪽에 있는 요소의 index를 인자로 전달합니다. 병합 정렬의 경우 배열을 계속해서 반으로 나누어가기 때문에 배열의 범위를 지정할 수 있어야 합니다. 이는 이진 탐색 알고리즘과 이유가 같습니다.

 

MergeSort함수를 대략적으로 구현해 보면 다음과 같습니다.

void MergeSort(int arr[], int left, int right)
{
	int mid;

	if(left < right)    //left가 작다는 것은 더 나눌 수 있다는 뜻
	{
	//중간지점 계산
	mid = (left + right) / 2;

	//둘로 나눠서 각각을 정렬
	MergeSort(arr, left, mid);
	MergeSort(arr, mid+1, right);

	//정렬된 두 배열을 병합
	MergeTwoArea(arr, left, mid, right);
	}
}

위 함수 안에서 호출되는 또 다른 함수 MergeTwoArea는 정렬된 두 영역을 하나로 묶기 위한 함수입니다. left, mid, right을 인자로 주어, left ~ mid 영역과 mid+1 ~ right 영역으로 나뉘어 있음을 알려줍니다.

 

MergeTwoArea 함수가 어떻게 정의되어 있는지는 다음의 완성된 병합 정렬 코드를 통해 확인합니다.

//main.c 소스 파일로 저장
#include <stdio.h>
#include <stdlib.h>

void MergeTwoArea(int arr[], int left, int mid, int right)
{
	int frontIdx = left;      //첫 번째 영역을 참조할 인덱스
	int rearIdx = mid + 1;    //두 번째 영역을 참조할 인덱스

	int* sortArr = (int*)malloc(sizeof(int) * (right + 1));    //두 영역을 결합하면서 정렬된 데이터를 임시로 저장할 공간 생성
	if (sortArr == NULL)
	{
		printf("메모리 공간이 부족합니다. \n");
		exit(1);
	}

	int sortArrIdx = left;    //임시 공간을 참조할 인덱스

	while (frontIdx <= mid && rearIdx <= right)    //frontIdx나 rearIdx가 각자의 영역의 끝에 도달하지 않았을 경우
	{
		if (arr[frontIdx] <= arr[rearIdx])    //우선 순위에 따라 각 영역의 인덱스가 가리키는 데이터를 임시 공간에 저장
		{
			sortArr[sortArrIdx++] = arr[frontIdx++];
		}
		else
		{
			sortArr[sortArrIdx++] = arr[rearIdx++];
		}
	}

	if (frontIdx > mid)    //첫 번째 영역의 모든 데이터의 참조가 이뤄졌을 때 두 번째 영역은 데이터가 남아 있을 수 있음
	{
		int i;
		for (i = rearIdx; i <= right; i++)
		{
			sortArr[sortArrIdx++] = arr[i];
		}
	}
	else    //두 번째 영역의 모든 데이터의 참조가 이뤄졌을 때 첫 번째 영역은 데이터가 남아 있을 수 있음
	{
		int i;
		for (i = frontIdx; i <= mid; i++)
		{
			sortArr[sortArrIdx++] = arr[i];
		}
	}

	int i;
	for (i = left; i <= right; i++)    //결합이 완료된 임시 공간의 데이터를 arr에 복사
	{
		arr[i] = sortArr[i];
	}

	free(sortArr);
}

void MergeSort(int arr[], int left, int right)
{
	if (left < right)
	{
		int mid = (left + right) / 2;

		MergeSort(arr, left, mid);
		MergeSort(arr, mid + 1, right);

		MergeTwoArea(arr, left, mid, right);
	}
}

int main(void)
{
	int arr[] = { 3, 2, 4, 1, 7, 6, 5 };
	int arrLen = sizeof(arr) / sizeof(arr[0]);

	MergeSort(arr, 0, arrLen - 1);

	int i;
	for (i = 0; i < arrLen; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");

	return 0;
} 

/*
실행결과

1 2 3 4 5 6 7

*/

위 코드에서

int frontIdx = left;      //첫 번째 영역을 참조할 인덱스
int rearIdx = mid + 1;    //두 번째 영역을 참조할 인덱스

frontIdx는 두 개로 나뉜 영역 중 앞부분에 해당하는 영역을 참조할 때 사용하는 인덱스입니다. 이 인덱스를 앞부분 영역의 첫 번째 요소의 인덱스로 초기화합니다. rearIdx는 뒷부분 영역을 참조할 때 사용하는 인덱스입니다. 이 인덱스를 뒷부분 영역의 첫 번째 요소의 인덱스로 초기화합니다. 

int* sortArr = (int*)malloc(sizeof(int) * (right + 1));    //두 영역을 결합하면서 정렬된 데이터를 임시로 저장할 공간 생성
if (sortArr == NULL)
{
	printf("메모리 공간이 부족합니다. \n");
	exit(1);
}

두 영역을 결합하는 알고리즘은 두 영역에서 frontIdx 혹은 rearIdx가 가리키는 데이터를 비교하여 우선순위가 더 높은 데이터를 먼저 임시 공간에 저장하는 것으로 수행합니다. 즉 각각의 두 영역에서 가장 앞에 정렬되어 있는 데이터를 가져와 두 데이터를 비교한 후 우선순위가 더 높은 것을 임시 공간에 저장하는 식입니다. 해당 임시 공간을 동적 할당하고, 공간에 정상적으로 할당되었는지 확인합니다.

while (frontIdx <= mid && rearIdx <= right)    //frontIdx나 rearIdx가 각자의 영역의 끝에 도달하지 않았을 경우
{
	if (arr[frontIdx] <= arr[rearIdx])    //우선 순위에 따라 각 영역의 인덱스가 가리키는 데이터를 임시 공간에 저장
	{
		sortArr[sortArrIdx++] = arr[frontIdx++];
	}
	else
	{
		sortArr[sortArrIdx++] = arr[rearIdx++];
	}
}

두 영역 중 어느 한 곳의 데이터가 모두 참조될 때까지 각 영역에서 데이터를 꺼내 우선순위를 비교하고 임시 공간에 저장하기를 반복합니다.

if (frontIdx > mid)    //첫 번째 영역의 모든 데이터의 참조가 이뤄졌을 때 두 번째 영역은 데이터가 남아 있을 수 있음
{
	int i;
	for (i = rearIdx; i <= right; i++)
	{
		sortArr[sortArrIdx++] = arr[i];
	}
}
else    //두 번째 영역의 모든 데이터의 참조가 이뤄졌을 때 첫 번째 영역은 데이터가 남아 있을 수 있음
{
	int i;
	for (i = frontIdx; i <= mid; i++)
	{
		sortArr[sortArrIdx++] = arr[i];
	}
}

반복이 끝나면 어느 한 영역은 데이터가 모두 참조가 된 반면, 나머지 영역은 그렇지 않고 데이터가 남아 있을 수 있습니다. 이 잔여 데이터를 하나씩 임시 공간에 저장합니다.

int i;
for (i = left; i <= right; i++)    //결합이 완료된 임시 공간의 데이터를 arr에 복사
{
	arr[i] = sortArr[i];
}

임시 공간에 모든 데이터가 정렬되었으면 이 데이터들을 arr에 하나씩 복사합니다.


이렇게 해서 병합 정렬 알고리즘까지 설명했습니다. 이제 병합 정렬 알고리즘의 성능을 평가해 보겠습니다. 먼저 비교 연산의 횟수의 빅-오를 구해봅니다. 병합 정렬 알고리즘의 핵심은 MergeTwoArea 함수입니다. MergeSort함수는 데이터를 분할하는 역할만 할 뿐 실질적으로 데이터를 비교하고 정렬하는 함수는 MergeTwoArea 함수이기 때문입니다.

 

병합 정렬의 비교 연산 횟수의 빅-오는 다음과 같습니다. 계산 과정은 설명하지 않겠습니다.

병합 정렬의 데이터 이동은 데이터를 비교할 때마다 이뤄지기 때문에 데이터 이동 횟수의 빅-오도 위와 같습니다.


이번에 소개하는 '퀵 정렬(Quick Sort)'도 앞서 소개한 병합 정렬과 마찬가지로 '분할 정복'에 근거하여 만들어진 정렬 방법입니다. 퀵 정렬은 조금 이해하기가 까다로운 부분이 있습니다. 

위와 같은 배열이 있다고 가정하겠습니다.

해당 배열에 위와 같이 몇 가지 포인터를 주었습니다. 우선 left와 right은 배열의 왼쪽 끝과 오른쪽 끝을 의미합니다. 그리고 pivot은 퀵 정렬 알고리즘을 수행하기 위해 설정해두는 기준을 가리킵니다. 배열의 어느 요소를 pivot으로 설정할 것인지는 프로그래머가 결정하기 나름입니다. 이번 예시에서는 배열의 왼쪽 끝 요소를 pivot으로 설정했습니다. 그리고 low와 high는 각각 pivot을 제외한 왼쪽 끝과 오른쪽 끝을 가리킵니다.

 

퀵 정렬이 어떤 알고리즘으로 수행되는지 보겠습니다. 우선 low는 pivot 값보다 큰 값을 가리킬 때까지 오른쪽으로 이동합니다. 그리고 high는 pivot 값보다 작은 값을 가리킬 때까지 왼쪽으로 이동합니다. low부터 이동시켜 보겠습니다. 우선 현재 low가 가리키는 값은 1이므로 pivot 값인 5보다 작습니다. 따라서 오른쪽으로 한 칸 이동합니다. 그러면 이번에도 pivot 값보다 작은 값이므로 한 번 더 오른쪽으로 이동합니다. 따라서 low는 다음과 같이 7을 가리키게 됩니다.

이번에는 high를 이동시켜 보겠습니다. 우선 현재 high가 가리키는 값은 pivot 값보다 큽니다. 따라서 왼쪽으로 한 칸 이동합니다. 이동하면 해당 값은 6으로 이번에도 pivot 값보다 큽니다. 따라서 왼쪽으로 한 컨 더 이동합니다. 따라서 high는 다음과 같이 3을 가리키게 됩니다.

high의 이동까지 끝났으면 low와 high의 값을 서로 바꿉니다. 그럼 다음과 같이 됩니다.

이제 다시 low의 위치를 이동시킵니다. low를 오른쪽으로 한 칸 이동시키면 0을 가리키게 됩니다. 이 값은 5보다 작으므로 다시 오른쪽으로 한 칸 이동시킵니다. 따라서 low는 다음과 같이 9를 가리킵니다.

이제 high를 이동시킵니다. 왼쪽으로 한 칸 이동시키면 값은 2입니다. 

이제 low와 high의 값을 서로 바꿉니다. 그럼 다음과 같이 됩니다.

이제 다시 low의 위치를 이동시킵니다. 그럼 low는 9를 가리키게 됩니다. 이어서 high를 이동시키면 high는 2를 가리키게 됩니다.

이동시키고 보니 low가 high보다 더 오른쪽에 있습니다. 이런 경우에는 low와 high의 값을 바꾸지 않습니다. 이렇게 high와 low의 자리가 역전되면 pivot 값과 high의 값을 서로 교환합니다.

여기까지의 과정만 보면 처음 pivot이 가리키고 있던 값인 5는 현재 정확한 자리에 들어가게 됩니다. 5의 왼쪽에는 5보다 작은 숫자들만 존재하고, 5의 오른쪽에는 5보다 큰 숫자들만 존재합니다. 이제 이 5를 기준으로 좌우 영역을 나눕니다. 그리고 좌우 영역에서 각각 left, right, pivot, low, high를 새로 정의합니다.

그리고 지금까지 해온 과정을 좌우 영역에 대해 각각 적용합니다. 

 

위 알고리즘을 보면 알고리즘이 재귀적임을 눈치챌 수 있습니다. 그럼 이 알고리즘의 종료 조건이 있어야 할 것입니다. 위 알고리즘은 결국 데이터 범위를 점점 줄여나가며 정렬을 수행하므로 left가 right보다 크거나 같으면 정렬할 데이터가 1개만 있거나 없는 것이 되기 때문에 이를 종료 조건으로 활용할 수 있습니다.

 

퀵 정렬의 알고리즘을 이해했으면 이를 직접 구현해 봅니다.

//main.c 소스 파일로 저장
#include <stdio.h>
#include <stdlib.h>

void Swap(int arr[], int idx1, int idx2)
{
	int temp = arr[idx1];
	arr[idx1] = arr[idx2];
	arr[idx2] = temp;
}

void QuickSort(int arr[], int left, int right)
{
	int pivot = left;
	int low = left + 1;
	int high = right;

	int pivotValue = arr[pivot];
	
	while (low <= high)
	{
		while (arr[low] <= pivotValue || arr[high] >= pivotValue)
		{
			if (arr[low] <= pivotValue)
			{
				low++;
			}

			if (arr[high] >= pivotValue)
			{
				high--;
			}

			if (low > high) break;
		}

		if (low < high)
		{
			Swap(arr, low, high);
		}
	}
	
	Swap(arr, high, pivot);

	if (left < high - 1)
	{
		QuickSort(arr, left, high - 1);

	}
	if (high + 1 < right)
	{
		QuickSort(arr, high + 1, right);
	}
}

int main(void)
{
	int arr[] = { 3, 7, 3, 4, 2, 2, 2, 6, 1, 7, 5, 9, 8, 9 };
	int arrLen = sizeof(arr) / sizeof(arr[0]);

	QuickSort(arr, 0, arrLen - 1);

	int i;
	for (i = 0; i < arrLen; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");

	return 0;
} 

/*
실행결과

1 2 2 2 3 3 4 5 6 7 7 8 9 9

*/

우선은 배열 요소의 값을 교환하기 위해 다음과 같이 Swap함수를 정의했습니다.

void Swap(int arr[], int idx1, int idx2)
{
	int temp = arr[idx1];
	arr[idx1] = arr[idx2];
	arr[idx2] = temp;
}

그리고 다음의 반복문을 보겠습니다.

while (low <= high)
{
	while (arr[low] <= pivotValue || arr[high] >= pivotValue)
	{
		if (arr[low] <= pivotValue)
		{
			low++;
		}

		if (arr[high] >= pivotValue)
		{
			high--;
		}

		if (low > high) break;
	}

	if (low < high)
	{
		Swap(arr, low, high);
	}
}

가장 바깥의 while 반복문은 low보다 high가 크거나 같을 때 반복하도록 합니다. 배열 요소가 2개인 경우 low와 high 초기화 값은 같게 됩니다. 따라서 이 경우도 포함시켜 low와 high를 이동할 수 있도록 합니다.

while (arr[low] <= pivotValue || arr[high] >= pivotValue)
{
	if (arr[low] <= pivotValue)
	{
		low++;
	}

	if (arr[high] >= pivotValue)
	{
		high--;
	}

	if (low > high) break;
}

그 안의 반복문은 low와 high를 한 번씩 이동시킵니다. 그러는 중에 low가 high를 앞서면 이동을 멈추게 합니다. 

나머지 코드는 이해하는 데에 어려움은 없을 것입니다.


이제 퀵 정렬의 성능을 평가해 보겠습니다. 퀵 정렬의 비교 연산 횟수의 빅-오는 저도 구하질 못하겠습니다. 책에서는 최선의 경우 다음과 같은 빅-오를 가지고,

최악의 경우 다음과 같은 빅-오를 가진다고 합니다.

빅-오의 경우 최악의 경우를 기준으로 구하는 것이 옳지만 퀵 정렬의 경우에는 특별합니다. 퀵 정렬은 평균적으로 최선의 경우에 가까운 성능을 보인다고 합니다. 그래서 퀵 정렬의 빅-오는 최악의 경우가 아닌 평균의 경우에 대한 빅-오로 보기도 합니다. 그리고 동일한 빅-오를 갖는 다른 정렬 알고리즘 중에서 평균적으로 가장 빠른 정렬 속도를 보입니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2025/04   »
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
글 보관함