반응형
해당 글에서는 알고리즘 중 분할정복에 대해서 이해를 돕기 위해 작성한 글입니다.
1) 분할정복(Divide and Conquer Algorithm)
💡 분할정복(Divide and Conquer Algorithm)이란?
- ‘큰 문제’를 ‘작은 문제’로 나누어서 해결하는 알고리즘을 의미합니다. 해당 알고리즘을 활용하여 크고 방대한 문제를 해결할 때 유용한 알고리즘입니다.
- 구체적으로 하나의 큰 문제를 작은 부분 문제들로 나눕니다. 그리고 나눈 부분 문제를 해결하고 해결된 해들을 모아 원래의 문제를 해결해 나아가는 방식을 의미합니다.(분할 → 정복 → 결합 과정)
[ 더 알아보기 ]
💡 분할 정복과 동적 계획법 알고리즘 차이
- 동적 계획법(Dynamic Programming)과 분할 정복(Divide and Conquer)은 서로 비슷한 알고리즘 기법이지만 ‘목적’과 '적용 대상’이 다릅니다.
- 분할정복의 경우는 큰 문제를 작은 문제들로 나누어서 해결해 나아가면서 큰 수의 곱셈, 퀵 정렬 등과 같은 문제를 해결합니다.
- 동적 계획법의 경우는 작은 문제들을 풀면서 그 결과를 저장해 나아가면서 전체 문제를 해결해 나가면서 최적화 문제나 최단 경로 문제등의 문제를 해결합니다.
[참고] 동적 계획법에 대해서 알아보기
1. 분할정복의 단계
💡 분할정복 알고리즘을 활용하여 문제를 해결하는 단계는 ‘분할’ → ‘정복’ → ‘결합’의 단계로 결과를 호출합니다.
💡 해당 3단계 과정을 통해서 반복하면서 전체 문제를 해결할 수 있습니다.
단계 | 설명 |
분할(Divide) | 첫번째로 문제를 ‘작은 부분 문제들로 나눕니다.’ |
정복(Conquer) | 두번째로 각각의 ‘부분 문제’를 해결합니다. |
결합(Combine) | 세번째로 ‘부분 문제’들의 해를 모아서 원래 문제의 해를 구합니다. |
2. 분할정복의 재귀적 방법에 대한 비교
💡 분할정복 알고리즘과 재귀함수는 모두 문제를 작은 부분으로 나누어 해결하는 ‘재귀적인 방법’을 통해 해결을 합니다.
💡 일반 재귀 호출의 경우 자기 자신을 호출하면서 문제를 해결 해 나아가기에 해결하는 과정에서 결과를 메모리에 저장합니다. 그렇기에 호출 스택과 같은 추가적인 메모리에 대한 사용이 있기에 속도가 느려질 수 있습니다. 또한 재귀 함수의 경우는 문제를 해결하는 과정이 하나의 과정(문제 해결, 분할)으로 이루어집니다.
💡 분할정복의 경우는 분할 → 정복 → 결합 과정을 통해서 해결해 나아가기에 해결하는 과정이 ‘분할’하는 과정과 ‘해결’하는 과정으로 분리되어 있습니다. 그리고 별도의 메모리를 사용하지 않고 문제를 작은 부분으로 나누어 해결하기에 속도가 상대적으로 빠릅니다.
분류 | 분할정복 알고리즘 | 재귀 함수 |
정의 | 문제를 작은 부분으로 나누어 해결하는 방법 | 함수 내부에서 자기 자신을 호출하여 문제를 해결하는 방법 |
해결 과정의 차이 | 문제를 작은 부분으로 분할하여 해결하는 과정과 해결하는 과정이 분리됨 | 자기 자신을 호출하여 문제를 해결하는 과정이 하나의 과정으로 이루어짐 |
속도의 차이 | 일반적으로 더 빠른 속도로 동작함 | 호출 스택 등의 추가적인 메모리를 사용하므로, 속도가 느려질 수 있음 |
대규모 문제에 대한 차이 | 대규모 문제를 해결하기 용이함 | 호출 스택의 한계로 인해, 대규모 문제를 해결하기 어려움 |
3) 분할정복의 장단점
1. 분할정복의 장점
분류 | 설명 |
문제 해결 과정 간편 | 문제를 더 작고 쉬운 문제로 분할하여 해결하므로 해결 과정이 간단해지고 대규모 문제를 해결하기 용이합니다. |
병렬 처리 수행 | 부분 문제들이 독립적이기 때문에 병렬 처리가 가능하다. |
빠른 속도 | 분할정복을 이용하면 일반적으로 더 빠른 알고리즘을 만들 수 있다. |
문제 해결 전략 명확 | 문제를 분할하고 각각 해결하는 방식이므로, 문제 해결 전략이 명확해진다. |
2. 분할정복의 단점
분류 | 설명 |
스택 오버 플로우 문제 | 재귀적인 방법을 사용하므로, 스택 오버플로우 등의 문제가 발생할 수 있습니다. |
추가적 메모리 필요 | 문제를 분할하는 과정에서 추가적인 메모리가 필요할 수 있다. |
효율성이 떨어짐 | 분할된 부분 문제들이 같은 크기를 갖지 않을 경우, 효율성이 떨어질 수 있다. |
추가 시간이 소요 | 분할하는 과정에서 추가적인 시간이 소요될 수 있다. |
4) 분할정복 시간 복잡도
💡 분할정복 알고리즘의 시간 복잡도
- 분할정복의 시간 복잡도는 각 하위 문제의 크기가 n / b로 줄어들고, 분할되는 문제의 수가 a개인 경우, 일반적으로 다음과 같이 나타낼 수 있습니다. (a는 분할되는 문제의 수, b는 각 하위 문제의 크기, d는 분할과정을 제외한 각 문제를 해결하는 데 필요한 시간복잡도)
- T(n) = aT(n/b) + O(n^d)
-분할정복 알고리즘의 시간복잡도는 Master theorem에 따라 결정됩니다.
- 분할정복 알고리즘의 시간복잡도는 빅오 표기법을 이용하였을 때 일반적으로 O(n log n)을 가집니다.
💡 [ 더 알아보기 ]
💡 시간 복잡도(Time Complexity)
- 알고리즘이 실행될 때 필요한 ‘입력 값’과 ‘연산 수행 시간’에 따라 효율적인 알고리즘을 나타내는 척도를 의미합니다.**
💡 Master theorem 란?
- T(n)을 분석하여 시간복잡도를 추정하는 데 유용한 방법입니다.
💡 빅오 표기법(Big-O notation)이란?
- 알고리즘의 입력 크기에 대해 수행 시간이 어떤 방식으로 증가하는지를 표기하는 것으로 최악의 경우의 시간 복잡도를 의미합니다.
표기법 | 이름 | 시간 복잡도 | 설명 | 예시 |
O(1) | 상수 | 상수 시간 | 입력 크기와 상관없이 일정한 실행 시간을 가진다. | 배열에서 원소 하나 찾기 |
O(logn) | 로그 | 로그 시간 | 입력 크기가 증가함에 따라 실행 시간이 로그함수의 형태로 증가한다 | 이진 탐색 알고리즘 |
O(n) | 선형 | 선형 시간 | 입력 크기와 비례하는 실행 시간을 가진다. | 선형 탐색 알고리즘 |
O(nlogn) | 로그 선형 | 선형 로그 시간 | 입력 크기가 증가함에 따라 실행 시간이 로그함수와 선형 함수의 곱의 형태로 증가한다. | 병합 정렬, 힙 정렬 알고리즘 |
O(n^2) | 이차 | 이차 시간 | 입력 크기의 제곱에 비례하는 실행 시간을 가진다. | 선택 정렬, 버블 정렬, 퀵 정렬 알고리즘 |
O(2^n) | 지수 | 지수 시간 | 입력 크기의 지수에 비례하는 실행 시간을 가진다. | 부분집합 |
O(n!) | 계승 | 팩토리얼 시간 | 입력 크기의 팩토리얼에 비례하는 실행 시간을 가진다. | 외판원 문제 |
5) 분할정복 활용한 알고리즘
1. 퀵 정렬 알고리즘
💡 해당 예시는 ‘퀵 정렬 알고리즘’을 이용하여서 정수 배열을 정렬하는 예시입니다.
💡 퀵 정렬(Quick Sort) 수행 과정
1. 배열에서 피벗(pivot)을 선택합니다. 피벗은 배열의 중간 값이나, 첫 번째 값, 마지막 값 등 여러 기준으로 선택될 수 있습니다.
2. 피벗을 기준으로 배열을 두 개의 서브 배열로 분할합니다. 피벗보다 작은 값은 왼쪽 서브 배열에, 큰 값은 오른쪽 서브 배열에 위치시킵니다.
3. 각 서브 배열을 재귀적으로 퀵 정렬합니다.
4. 정렬된 서브 배열들을 합칩니다.
/**
* 퀵 정렬을 수행합니다.
* @param arr
* @param left
* @param right
*/
public static void quickSort(int[] arr, int left, int right) {
if (left >= right) return;
int pivot = partition(arr, left, right);
quickSort(arr, left, pivot - 1);
quickSort(arr, pivot + 1, right);
}
/**
* 분할 적용 : 주어진 구간에서 피봇을 선택하고, 분할을 수행하는 함수
* @param arr
* @param left
* @param right
* @return
*/
public static int partition(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left + 1;
int j = right;
while (i <= j) {
while (i <= right && arr[i] < pivot) i++;
while (j >= left + 1 && arr[j] > pivot) j--;
if (i > j) {
swap(arr, left, j);
} else {
swap(arr, i, j);
}
}
return j;
}
/**
* 값 변경 : 배열에서 두 원소의 위치를 바꾸는 함수
* @param arr
* @param i
* @param j
*/
public static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
2. 이진검색 알고리즘
💡 해당 예시는 이진 검색 알고리즘을 사용하여 정렬된 정수 배열에서 특정 값을 찾는 함수입니다.
💡 분할정복의 3단계인 분할, 정복, 결합 단계 관점에서 확인해봅니다.
1. 분할 : 배열을 절반으로 나누는 과정을 수행합니다.
2. 정복 : 찾으려는 값이 절반 중 하나에 위치에 있는지 확인하는 부분 문제를 해결하는 과정을 수행합니다.
3. 결합 : 배열을 절반으로 나누고, 찾으려는 값이 절반 중 하나에 위치해 있는지 확인하는 과정을 통해 부분문제를 모아서 해결하는 원래의 해를 구하는 과정을 수행합니다.
/**
* 이진검색 알고리즘
*
* @param arr {int[]}: 전체 배열
* @param key {int}: 찾고자 하는 요소
* @return ResponseEntity<ApiResponse>
* @link
* @since 2023.05.
*/
@GetMapping("/1")
public ResponseEntity<ApiResponse<Object>> binSearchCourse(@RequestParam int[] arr, @RequestParam int key) {
int answer = 0;
// [STEP1] 시작 인덱스와 마지막 인덱스 값을 지정합니다.
int low = 0;
int high = arr.length - 1;
// [STEP2] 마지막 인덱스를 보다 첫번째 인덱스가 같아지거나 작을 경우까지 순회합니다.
while (low <= high) {
// [STEP3] 중간 값을 구합니다.
int mid = (low + high) / 2;
// [CASE4-1] 중간 값보다 찾으려는 값(key)가 큰 경우 : 중간 값에 1을 더하여 오른쪽 절반을 탐색합니다.
if (arr[mid] < key) {
low = mid + 1;
}
// [CASE4-2] 중간 값보다 찾으려는 값(key)가 작은 경우 : 중간값에 1을 빼서 왼쪽 절반을 탐색합니다.
else if (arr[mid] > key) {
high = mid - 1;
}
// [CASE4-3] 해당 경우가 아니면 중간값을 최종 값으로 반환합니다.
else {
answer = mid;
}
}
// [STEP5] 최종 탐색을 하지 못할 경우 -1을 반환합니다.
if (answer == 0) answer = -1;
ApiResponse<Object> ar = ApiResponse
.builder()
.result(answer)
.resultCode(SUCCESS_CODE)
.resultMsg(SUCCESS_MSG).build();
return new ResponseEntity<>(ar, HttpStatus.OK);
}
💡 [참고] 이진검색 알고리즘
3. 병합 정렬 알고리즘
💡 해당 예시에서는 ‘병합 정렬 알고리즘’을 사용하여서 리스트를 두 개의 균등한 크기로 분할한 후 각각을 정렬한 다음, 두 개의 정렬된 리스트를 합하여 전체를 정렬하는 방식입니다.
💡 병합 정렬 알고리즘의 과정
1. 리스트의 길이가 0 또는 1이면 이미 정렬된 것으로 본다.
2. 그렇지 않으면 리스트를 두 개의 균등한 크기로 분할한다.
3. 각 부분 리스트를 재귀적으로 합병 정렬을 이용해 정렬한다.
4. 두 부분 리스트를 다시 하나의 정렬된 리스트로 합병한다.
public class MergeSort {
public static 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);
merge(arr, left, mid, right);
}
}
public static void merge(int[] arr, int left, int mid, int right) {
int[] temp = new int[arr.length];
int i = left;
int j = mid + 1;
int k = left;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) {
temp[k++] = arr[i++];
}
while (j <= right) {
temp[k++] = arr[j++];
}
for (int index = left; index < k; index++) {
arr[index] = temp[index];
}
}
public static void main(String[] args) {
int[] arr = { 5, 2, 4, 7, 1, 3, 2, 6 };
mergeSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
}
💡 [참고] 좀 더 다양한 알고리즘에 대해 알고 싶으시면 아래의 글을 참고하시면 도움이 됩니다.
분류 | 알고리즘 | 링크 |
알고리즘 구현시간 | 시간 복잡도, 공간 복잡도, 빅오 표기법 | https://adjh54.tistory.com/186 |
탐색 알고리즘 | 선형탐색 | https://adjh54.tistory.com/193 |
탐색 알고리즘 | 이진탐색 | https://adjh54.tistory.com/187 |
탐색 알고리즘 | 완전탐색 : 기본구조 | https://adjh54.tistory.com/196 |
탐색 알고리즘 | 완전탐색 : 종류 | https://adjh54.tistory.com/197 |
탐색 알고리즘 | 완전탐색: 문제로 이해하기 | https://adjh54.tistory.com/227 |
알고리즘 설계 방법 | 동적 계획법 알고리즘 | https://adjh54.tistory.com/201 |
알고리즘 설계방법 | 그리디 알고리즘 | https://adjh54.tistory.com/212 |
알고리즘 설계방법 | 분할정복 알고리즘 | https://adjh54.tistory.com/219 |
기타 알고리즘 | 재귀 함수 | https://adjh54.tistory.com/194 |
기타 알고리즘 | 피보나치 수열: 경우의 수 | https://adjh54.tistory.com/183 |
기타 알고리즘 | 유클리드 호제법: 최대공약수, 최대공배수 | https://adjh54.tistory.com/179 |
오늘도 감사합니다. 😀
반응형
'Java > 알고리즘 & 자료구조' 카테고리의 다른 글
[Java/알고리즘] 완전 탐색(Exhaustive Search) 이해하기 -3 : 문제로 이해하기 (0) | 2023.07.23 |
---|---|
[Java/자료구조] 선형구조 - 큐(Queue) 이해하기: 일반 큐, 우선순위 큐(Priority Queue) 이해하기 (2) | 2023.07.22 |
[Java/알고리즘] 그리디 알고리즘(탐욕법, Greedy Algorithm) 이해하기 (6) | 2023.06.24 |
[Java/알고리즘] 동적 계획법(DP: Dynamic Programming) 이해하기 (0) | 2023.06.07 |
[Java/알고리즘] 완전 탐색(Exhaustive Search) 이해하기 -2 : 종류 별 이해 (1) | 2023.06.03 |