- ···
- 13.
[멀티캠퍼스] 풀스택 개발자 아카데미 (13) - Algorithm(2) - 14.[멀티캠퍼스] 풀스택 개발자 아카데미 (14) - Algorithm(3)현재
- 15.
[멀티캠퍼스] 풀스택 개발자 아카데미 (15) - Algorithm(4) - ···
0. 정렬 알고리즘
이전 글에서는 데이터를 순회하고 "탐색"하는 알고리즘에 대한 내용이었다.
이번 정렬 알고리즘은 데이터를 특정 순서로 나열하는, 컴퓨터 과학에서 가장 기본적이면서도 중요한 작업 중 하나이다.
효율적인 정렬은 이후의 데이터 검색이나 처리 속도를 극적으로 향상시키는 기반이 되기 때문에, 매우 유용한 알고리즘 중 하나이다.
이번 글에서는 저번 글의 '가능한 모든 경우를 시도하는' Brute-Force 전략의 아이디어가 어떻게 가장 단순한 정렬 알고리즘으로 이어지는지,
또 그 명확한 한계를 뛰어넘기 위해 어떻게 패러다임을 전환하는지, 즉, 분할 정복이라는 강력한 전략을 통해 O(n²)을 넘어, O(n log n)의 효율성을 달성하는 방법을 알아본다.
1. 완전 탐색 아이디어 접근
가장 직관적인 정렬 알고리즘은 '완전 탐색'의 아이디어를 공유한다. 즉, 각 요소를 다를 거의 모든 요소와 비교하고 자리를 바꾸는 과정을 반복하는 방법이다. 이 과정에서 이중 반복문이 필수적으로 사용되며, 이 때문에 해당 아이디어의 정렬 알고리즘이 O(n²)의 시간 복잡도를 갖는 근본적 원인이다.
1.1. 버블 정렬 (Bubble Sort) 알고리즘
버블 정렬은 정렬 알고리즘을 공부할 때 가장 먼저 접하는 알고리즘 중 하나로 꼽힐 정도로, 가장 직관적이며 단순한 알고리즘이다.
이 알고리즘은 한 번의 순회(Cycle)가 끝날 때마다, 가장 마지막에 올 데이터가 거품처럼 배열의 끝으로 올라오는 특징을 가진다.
1.1.1. 동작 원리
버블 정렬은 처음부터 끝까지 인접한 두 개의 데이터를 비교하며, 정렬 순서에 맞지 않으면 자리를 바꾸는 단순한 동작을 반복한다. 이 과정을 리스트의 끝까지 반복하면, 가장 마지막에 올 데이터가 리스트의 가장 끝에 위치하게 된다.
예를 들어, [4,1,3,5,2] 배열을 오름차순 정렬을 한다고 가정해보자.
1단계:
[4,1,3,5,2]4와1을 비교,1이 더 작으므로 자리 교체 ->[1,4,3,5,2]4와3을 비교,3이 더 작으므로 자리 교체 ->[1,3,4,5,2]4와5를 비교, 순서 문제가 없으므로 유지 ->[1,3,4,5,2]5와2를 비교,2가 더 작으므로 자리 교체 ->[1,3,4,2,5]
- 1단계가 끝나면 가장 큰 수인
5가 마지막에 위치
2단계:
[1,3,4,2,5]1과3을 비교, 순서 문제가 없으므로 유지 ->[1,3,4,2,5]3과4를 비교, 순서 문제가 없으므로 유지 ->[1,3,4,2,5]4와2를 비교,2가 더 작으므로 자리 교체 ->[1,3,2,4,5]
- 마지막의
5는 정렬된 상태이므로 비교하지 않음 - 2단계가 끝나면 2번쨰 큰수인
4가 마지막에서 2번째에 위치
3단계:
[1,3,2,4,5]1과3을 비교, 순서 문제가 없으므로 유지 ->[1,3,2,4,5]3과2를 비교,2가 더 작으므로 자리 교체 ->[1,2,3,4,5]
- 3번째 큰 수인
3이 제자리에 위치 - 버블 정렬은 특별한 처리없이는 현재 정렬이 끝났는지 알 수 없으므로 끝까지 진행
4단계:
[1,2,3,4,5]1과2를 비교, 순서 문제가 없으므로 유지 ->[1,2,3,4,5]
- 종료
- Java 구현
1.2. 선택 정렬 (Selection Sort) 알고리즘
선택 정렬은 브루트 포스의 '완전 탐색' 개념과 더욱 닮아있다. "정렬되지 않은 부분에서 최솟값(또는 최댓값)을 '완전 탐색'하여 찾은 뒤, 정렬되지 않은 부분의 가장 앞에 있는 요소와 자리를 바꾸는" 과정을 반복한다는 아이디어이다.
1.2.1. 동작 원리
예를 들어, [4,1,3,5,2] 배열을 오름차순 정렬을 한다고 가정해보자.
- 1회전:
[4,1,3,5,2]전체에서 최솟값1을 찾는다. 이후 첫 번째 요소4와 자리를 바꾼다. ->[1,4,3,5,2] - 2회전:
[4,3,5,2]부분에서 최솟값2를 찾는다. 이후 두 번째 요소4와 자리를 바꾼다. ->[1,2,3,5,4] - 3회전:
[3,5,4]부분에서 최솟값3를 찾는다. 3번째 자리에 위치 중(변화 없음) ->[1,2,3,5,4] - 4회전:
[5,4]부분에서 최솟값4를 찾는다. 4번쨰 요소5와 자리를 바꾼다. ->[1,2,3,4,5]
- Java 구현
2. 분할 정복 알고리즘
위와 같은 O(n²) 복잡도를 가진 완전 탐색법의 아이디어를 사용한 정렬 알고리즘은 데이터가 수만 개만 되어도 성능이 급격히 저하되어 사용할 수 없게 된다.
이러한 한계를 극복하기 위한 알고리즘이 분할정복 (Divide and Conquer)이다. 이는 한 번에 해결하기 어려운 문제를 해결 가능한 작은 문제들로 나눈 뒤, 그 해답들을 다시 합쳐 원래 문제의 답을 얻어내는 강력한 접근법이다.
이 전략이 강력한 이유는 문제의 복잡도를 선형적으로 줄이는 것이 아니라, 기하급수적으로 줄이기 때문이다. 100개의 데이터를 처리하는 문제에 대해, 99개와 1개로 나누는 것과, 50개와 50개로 나누는 것은 차원이 다르다. 후자의 경우, 문제의 크기를 절반으로 줄여나가기 때문에, 처리해야 할 연산의 총량을 극적으로 감소시킬 수 있다.
왜 O(n log n)인가?
log n(분할): 배열을 계속해서 '절반'으로 나누면, 그 깊이는log n이 된다. (8 -> 4 -> 2 -> 1, 3단계 =log₂8)n(정복 및 병합): 각 깊이(레벨)에서, 모든n개의 원소에 대한 비교 및 통합 작업이 한 번씩 수행된다.- 결론적으로
log n단계에 걸쳐, 각 단계마다n번의 작업을 수행하므로 시간 복잡도는O(n log n) 로 계산된다.
2.1. 병합 정렬 (Merge Sort)
병합 정렬은 분할 정복 패러다임의 교과서적인 예시이다. 어떠한 상황에서도 O(n log n)의 안정적인 성능을 보장하는 매우 신뢰도 높은 정렬 알고리즘이다.
또한 '선 분할, 후 정렬'의 대표주자로, 배열을 최소 단위(원소 1개)까지 쪼갠 뒤, 다시 합치는(Merge) 과정에서 정렬을 수행하는 방법이다. 이를 다른 말로 Top-down(위에서 아래로) 방식이라고 한다.
2.1.1. 동작 과정
- 분할: 배열이 1개가 될 때까지 재귀적으로 반으로 나눈다.
- 정복 및 병합: 두 개의 정렬된 부분 배열을 하나의 정렬된 배열로 병합한다. 이때 두 배열의 첫 요소부터 비교하며 더 작은 값을 새 배열에 추가하는 방식으로 진행된다.
- Java 구현
- 특징: 병합 정렬은 안정 정렬(Stable Sort)이며, 항상 O(n log n)을 보장하는 안정성이 최대 장점이지만, 정렬을 위한 추가적인 임시 배열(메모리)이 필요하다는 단점이 있다.
2.2. 퀵 정렬 (Quick Sort)
퀵 정렬은 '분할' 작업을 통해 실질적인 '정렬'이 이루어지는 독특한 알고리이다. 배열 내에서 기준점인 **피벗(Pivot)**을 정하고, 피벗을 기준으로 작은 값은 왼쪽, 큰 값은 오른쪽으로 나누는 분할(Partition) 작업을 통해 정렬을 진행한다. 이 과정은 전체 배열에서 시작해 더이상 나눌 수 없을 때까지 작은 부분 배열로 파고드는 Top-down 방식이다.
이 알고리즘은 배열 내에서 기준점인 **피벗(Pivot)**을 정하고, 피벗을 기준으로 작은 값은 왼쪽, 큰 값은 오른쪽으로 나누는 분할(Partition) 작업을 통해 정렬을 진행한다.
2.2.1. 동작 과정
이번 글에서는 **Lomuto 분할 방식(Lomuto Partition Scheme)**을 사용하며, 여러 피벗 선택 방법 중 배열의 가장 오른쪽 요소를 피벗으로 선택한다.
2.2.1.1. 1단계: partition 메서드 (분할)
partition 메서드의 목표는 피벗을 제자리에 위치시키고, 피벗을 기준으로 왼쪽에는 피벗보다 작은 값, 오른쪽에는 피벗보다 큰 값이 오도록 배열을 재배치하는 것이다.
- 피벗(Pivot) 선택:
partition이 호출되면, 인자로 받은 배열의 범위 (low~high)의 가장 우측 값(arr[high])을 피벗으로 선택한다. - 포인터 초기화:
i: 피벗보다 작거나 같은 값들이 위치하는 영역의 마지막 인덱스를 가리킨다.low-1로 초기화되며, 처음에는 아무런 값도 없음을 의미한다.j: 현재 탐색 중인 원소를 가리킨다.low에서 시작하여 피벗 바로 앞(high-1)까지 배열을 순회한다. 즉,high에 도달하면 탐색을 종료한다.
- 배열 탐색 및 교환(Swap):
for루프를통해j가low부터high-1까지 이동하며 다음을 반복한다.
만약
arr[j](현재 값)이 피벗보다 작거나 같으면:i를 1증가 시킨다(i가 다음 위치로 이동)arr[i]와arr[j]의 위치는 교환(Swap) 한다.- 해당 과정을 통해
i를 포함한 왼쪽 영역은 항상 피벗보다 작거나 같은 값이 남는다.
- 만약
arr[j](현재 값)이 피벗보다 크면,j만 증가시키고 아무런 작업을 하지 않는다.
- 피벗 위치 확정: 루프가 종료되면,
low부터i까지는 피벗보다 작거나 같은 값들이, 피벗을 제외한i+1부터j까지는 피벗보다 큰 값이 남게 된다. 이제 피벗을 제자리로 옯기기 위해arr[i+1](i다음 위치)와arr[high](피벗)의 위치를 교환한다. - 피벗 인덱스 반환: 이 교환을 통해, 피벗을 정렬된 배열에서의 자신의 위치를 찾게 된다. 이제,
partition은 피벗의 위치를 반환하여 피벗의 좌측과 우측을 정렬할 수 있도록 한다.
2.2.1.2. 2단계: sort 메서드
sort 메서드는 partition을 이용해 정체 정렬을 관리하고 실행한다.
- 종료 조건 확인: 먼저, 넘겨받은 인자 중,
low < high인지 확인한다. 만약low가high보다 크거나 같은 경우, 배열에 원소가 없거나 하나만 있다는 뜻으로, 별다른 작업 없이 재귀를 종료한다. - 분할 실행:
partition(arr, low, high)를 호출해 피벗을 올바른 위치에 놓고, 피벗의 위치(pi)를 받는다. - 하위 배열 정렬:
sort(arr, low, pi-1): 피벗을 기준으로 나뉜 왼쪽 부분 배열에 대해 다시sort메서드를 재귀 호출한다.sort(arr, pi+1, high): 마찬가지로, 오른쪽 부분 배열에 대해sort메서드를 재귀 호출한다.
이러한 재귀 호출이 더이상 분할 할 수 없는 부분 배열(크기가 1 이하)에 도달할 때까지 반복되면, 전체 배열의 정렬이 완료된다.
2.2.2. 실습
- Java 구현
- 특징: 퀵 정렬은 평균적으로 매우 빠른 속도(O(n log n))와 추가 메모리가 거의 필요 없는 '제자리 정렬'이라는 장점 때문에 실무에서 널리 쓰인다. 하지만 불안정 정렬(Unstable Sort)이며, 피벗 선택이 최악일 경우(예: 이미 정렬된 배열에서 첫 요소를 피벗으로 선택), O(n²)까지 성능이 저하될 수 있다.
3. 결론
지금까지 정렬이라는 하나의 목표를 위해 서로 다른 두 가지 접근법을 비교했다.
- O(n²) 정렬 (버블, 선택): Brute-Force처럼 직관적이고 구현이 쉽지만, 데이터가 많아지면 실용성이 떨어진다. 알고리즘의 기본 원리를 이해하는 데 큰 도움이 된다.
- O(n log n) 정렬 (병합, 퀵): 분할 정복이라는 세련된 패러다임을 통해 비교 횟수를 혁신적으로 줄여 대용량 데이터도 효율적으로 처리한다.
개발자에게 중요한 것은 단순히 코드를 암기하는 것이 아니라, 각 알고리즘의 근간이 되는 패러다임을 이해하는 것이다. 문제의 크기와 특성을 파악하고, 그에 맞는 최적의 도구(알고리즘)를 선택하는 능력이야말로 진정한 문제 해결 능력의 핵심이다.