# 재귀 알고리즘 (Recursive) ![](https://hackmd.io/_uploads/SJJbvpN43.png) ## 재귀 알고리즘이란? - 자신이 수행할 작업을 처리하는 도중에 자기 자신을 호출하여 작업을 수행하는 알고리즘 - 함수나 프로시저가 자기 자신을 호출하는 것 - 재귀 알고리즘은 깊이가 무한정하게 들어갈 수 있으며, 무한루프에 빠질 가능성도 있으므로 조심해서 사용해야 한다 - 장점 - 코드가 간결하고 이해하기 쉽다 - 명확하게 해결하고자 하는 문제를 표현하기 쉽다 - 단점 - 재귀 알고리즘은 반복문보다 실행 속도가 느릴 수 있다 - 함수 호출에 따른 메모리 공간이 필요하므로 메모리 사용량이 증가할 수 있다 - 재귀 호출이 무한루프에 빠질 가능성이 있으므로, 종료 조건을 반드시 설정해야 한다 ## 재귀 알고리즘 예시 1. 팩토리얼 계산 문제 - 정수 N이 주어졌을 때, N!을 계산하는 문제 2. 피보나치 수열 문제 - 정수 N이 주어졌을 때, 피보나치 수열의 N번째 항을 구하는 문제 3. 이진 검색 문제 - 정렬된 배열과 찾고자 하는 값이 주어졌을 때, 이진 검색을 이용하여 해당 값의 인덱스를 구하는 문제 4. 하노이의 탑 문제 - N개의 원판이 있는 탑이 있을 때, 모든 원판을 다른 기둥으로 옮기는 문제 5. 최대공약수 문제 - 두 수가 주어졌을 때, **유클리드 호재법**을 이용하여 최대공약수를 구하는 문제 > 재귀 문제는 반복문으로 대체가 가능하다. 반복문이 아닌 재귀를 사용할 경우 메모리 측면에서 비효율적이기 때문에 알고리즘 문제를 풀 때 적합한 방법은 아니다. ### 하노이탑 문제 - 백준 알고리즘 1914번 ==[링크](https://www.acmicpc.net/problem/1914)== - 위 문제를 보고 수도코드 작성 및 풀이를 해보자 #### 수도코드 해답 예 ``` 1. 원판의 개수 N을 입력받는다. 2. 최소 이동 횟수를 계산한다 (2^N - 1). 3. 최소 이동 횟수를 출력한다. 4. 원판 개수가 20개 이하인 경우 이동 과정을 출력한다. 5. 하노이 탑 알고리즘을 구현한다 (재귀 함수). a. 이동할 원판이 없는 경우 반환한다. b. N-1개의 원판을 출발 기둥에서 보조 기둥으로 이동한다 (하노이 탑 알고리즘 호출). c. N번째 원판을 출발 기둥에서 도착 기둥으로 이동한다. d. N-1개의 원판을 보조 기둥에서 도착 기둥으로 이동한다 (하노이 탑 알고리즘 호출). ``` #### 소스코드 해답 예 ``` java import java.math.BigInteger; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc = new Scanner(System.in); int N = sc.nextInt(); // 원판의 개수 입력 // 최소 이동 횟수 계산 및 출력 BigInteger minMoves = BigInteger.valueOf(2).pow(N).subtract(BigInteger.ONE); System.out.println(minMoves); // 원판 개수가 20개 이하인 경우 이동 과정 출력 if (N <= 20) { hanoi(N, 1, 2, 3); } } // 하노이 탑 알고리즘 구현 public static void hanoi(int N, int from, int aux, int to) { if (N == 0) return; // 이동할 원판이 없는 경우 반환 hanoi(N - 1, from, to, aux); // N-1개의 원판을 출발 기둥에서 보조 기둥으로 이동 System.out.println(from + " " + to); // N번째 원판을 출발 기둥에서 도착 기둥으로 이동 hanoi(N - 1, aux, from, to); // N-1개의 원판을 보조 기둥에서 도착 기둥으로 이동 } } ``` ### 최대공약수 구하기 #### 유클리드 호제법(Euclidean algorithm) - 두 개의 자연수의 최대공약수(GCD, Greatest Common Divisor)를 구하는 알고리즘 - 원리 - 두 수 a와 b (a > b)의 최대공약수는 a를 b로 나눈 나머지와 b의 최대공약수와 같다 - 즉, GCD(a, b) = GCD(b, a % b) - 예시 : a = 56이고 b = 48일 때 1. `GCD(56, 48) = GCD(48, 56 % 48) = GCD(48, 8)` 2. `GCD(48, 8) = GCD(8, 48 % 8) = GCD(8, 0)` 3. b가 0이 되었으므로, a와 b의 최대공약수는 8이다 #### 최소공배수를 구하는 방법 - 유클리드 호제법을 사용하여 최대공약수를 구했다면, 최소공배수(LCM, Least Common Multiple)도 쉽게 구할 수 있다. - LCM(a, b) = (a * b) / GCD(a, b) --- # 분할 정복 알고리즘 (Divide & Conquer) ![](https://hackmd.io/_uploads/H1_vmRNV3.png) ## 분할 정복이란? - 하나의 문제를 작은 부분 문제로 나누어 해결하는 알고리즘 - 문제를 분할하고, 각 부분 문제를 해결하여 전체 문제의 해결책을 찾는다 - 분할 정복 알고리즘은 다음과 같은 세 단계로 이루어진다 - 분할(Divide) 단계: 문제를 작은 부분 문제로 분할한다 - 정복(Conquer) 단계: 부분 문제를 해결한다 - 결합(Combine) 단계: 부분 문제의 해결책을 결합하여 전체 문제의 해결책을 구한다 - 장점 - 문제를 나누어서 해결하므로, 병렬 처리에 적합하다 - 문제를 나누어서 해결하기 때문에, 코드가 간결하고 이해하기 쉽다 - 분할된 문제가 서로 독립적이기 때문에, 처리 속도가 빠르다 - 단점 - 분할 단계에서 부분 문제를 작게 나누면 오버헤드가 발생하여 실행 시간이 증가할 수 있다 - 문제를 작게 나누어서 해결하므로, 스택 오버플로우 등의 문제가 발생할 수 있다 > 분할정복과 재귀함수 > - 분할정복과 재귀함수는 유사하지만 기본 원리와 적용 방식에 차이가 있다 > - 재귀 알고리즘은 문제를 해결하기 위해 자기 자신을 호출하는 방식으로 동작하며, 이는 분할 정복 알고리즘의 한 가지 구현 방식일 뿐이며, 두 알고리즘은 독립적인 개념으로 이해해야 한다. > - 분할 정복은 문제를 부분 문제로 나누고, 각 부분 문제를 정복하여 원래 문제의 해결책을 찾는 전략이며, 재귀 알고리즘은 함수가 자기 자신을 다시 호출하는 방식으로 문제를 해결하는 전략 > - 분할 정복 알고리즘은 문제를 작은 부분으로 나누어 해결하는 접근 방식이기 때문에, 재귀 함수가 아닌 반복문을 사용하여 구현할 수도 있다. 예를 들어 이진 검색 알고리즘은 반복문을 사용하여 구현할 수도 있으며, 이 경우에도 작은 부분 문제로 나누어 해결하는 분할 정복 알고리즘의 전략을 따른다 ## 분할 정복 알고리즘 예시 1. 병합 정렬(Merge Sort) - 정렬되지 않은 배열이 주어졌을 때, 분할 정복 알고리즘을 이용하여 배열을 정렬하는 문제 2. 퀵 정렬(Quick Sort) - 정렬되지 않은 배열이 주어졌을 때, 분할 정복 알고리즘을 이용하여 배열을 정렬하는 문제 3. 거듭제곱 계산 문제 - 양의 정수 a와 b가 주어졌을 때, a의 b제곱을 계산하는 문제 4. 이항 계수 계산 문제 - 양의 정수 n과 k가 주어졌을 때, 이항 계수(nCk)를 계산하는 문제 5. 최대 수익 문제 - 길이가 n인 정수 배열이 주어졌을 때, 두 수의 차이가 최대가 되는 경우의 수익을 구하는 문제 > 일반적으로 다음과 같은 특징이 보이면 분할 정복 알고리즘을 고려해본다 > - 문제를 독립적인 부분 문제로 나눌 수 있는 경우 > - 부분 문제의 해답을 병합하는 과정이 존재하는 경우 > - 중복되는 부분 문제가 없는 경우 > - 재귀적 구조를 가지고 있는 경우 ### 병합 정렬 (Merge sort) ![](https://hackmd.io/_uploads/rkRn3gr4h.png) - 배열을 반으로 나누어 각각을 정렬한 후, 정렬된 두 배열을 병합하여 전체를 정렬하는 알고리즘 - 원리 1. 분할(Divide): 주어진 배열이나 리스트를 반으로 나눈다. 이 과정을 더 이상 나눌 수 없을 때까지 반복한다. 이렇게 되면 모든 부분 배열은 하나의 원소를 가지게 된다. 2. 정복(Conquer): 가장 작은 부분 배열(하나의 원소를 가진 배열)부터 시작하여, 인접한 두 부분 배열을 정렬하면서 병합한다. 이렇게 정렬된 배열은 다시 인접한 정렬된 배열과 병합되어 최종 정렬된 배열을 얻을 때까지 반복한다. 3. 병합(Merge): 인접한 두 부분 배열을 정렬하면서 병합하는 과정. 각 부분 배열의 첫 번째 원소를 비교하여 더 작은 값을 결과 배열에 넣고, 해당 원소를 부분 배열에서 제거한다. 이 과정을 두 부분 배열의 원소가 모두 소진될 때까지 반복한 후, 남은 원소가 있는 배열의 나머지 원소를 결과 배열에 추가한다. - ![](https://hackmd.io/_uploads/ry-okWHV3.gif) - 장점 - 안정적인 정렬 알고리즘으로, 입력 데이터의 분포와 순서에 영향을 받지 않는다. - 최악의 경우에도 시간 복잡도가 O(nlogn)으로 빠르며, 대부분의 경우에 빠른 실행 속도를 보인다. - 분할 정복 알고리즘을 사용하므로, 병렬 처리가 가능하다. - 추가적인 메모리가 필요하지만, 입력 데이터의 크기가 크더라도 메모리 사용량이 고정적이며, 크기가 큰 데이터에 대해서도 처리가 가능하다. - 단점 - 입력 데이터의 크기가 작은 경우에는 다른 정렬 알고리즘들보다 느린 실행 속도를 보일 수 있다. - 추가적인 메모리 공간이 필요하므로, 메모리 사용량이 더 많아질 수 있다. - 합병 과정에서 인덱스를 이동하는 등의 연산이 필요하므로, 다른 알고리즘들보다 연산 속도가 느릴 수 있다. - 시간복잡도 : `O(nlogn)` - 입력 배열의 크기가 n일 때, 문제를 분할하는 단계 수는 log2(n)이므로, 분할된 문제를 해결하는 데 필요한 시간은 `O(logn)` 소요 - 병합 과정에서는 두 배열을 비교하여 더 작은 값을 임시 배열에 저장하고, 임시 배열을 입력 배열로 복사하는 과정을 거치므로, 상수 시간인 `O(n)`이 소요 https://gmlwjd9405.github.io/2018/05/08/algorithm-merge-sort.html **병합 정렬 코드** ``` java 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 = {21, 10, 12, 20, 25, 13, 15, 22}; int left = 0; int right = arr.length - 1; mergeSort(arr, left, right); System.out.println("정렬된 배열: " + Arrays.toString(arr)); } } ``` --- ### 퀵 정렬 (Quick sort) ![](https://hackmd.io/_uploads/S1EP7-S4h.png) - pivot 값을 기준으로 배열을 두 부분으로 분할하고, 각 부분을 재귀적으로 정렬하는 알고리즘 - 추가 메모리를 거의 사용하지 않는 인플레이스(in-place) 정렬 알고리즘이기 때문에, 메모리 사용량 측면에서 장점이 있다 - 원리 1. 피벗 선택(Select Pivot): 배열에서 피벗 엘리먼트를 선택한다. 피벗을 선택하는 방법에는 여러 가지가 있으며, 일반적으로는 배열의 첫 번째 원소, 마지막 원소 또는 무작위로 선택한 원소를 사용한다. 2. 분할(Partition): 피벗을 기준으로 배열을 두 부분으로 분할한다. 피벗보다 작은 값은 피벗 왼쪽에 위치하고, 피벗보다 큰 값은 피벗 오른쪽에 위치하게 된다. 분할 작업이 완료되면, 피벗은 최종 정렬 위치에 있게 된다. 3. 재귀 정렬(Recursive Sort): 피벗을 제외한 왼쪽 부분 배열과 오른쪽 부분 배열을 다시 퀵 정렬로 정렬한다. 이 과정은 부분 배열의 크기가 1 또는 0이 될 때까지 재귀적으로 반복된다. - ![](https://hackmd.io/_uploads/HyMR4-HVn.gif) - 장점 - 평균적으로 가장 빠른 실행 속도를 보이는 정렬 알고리즘 중 하나 - 입력 데이터의 크기가 큰 경우에도 빠른 실행 속도를 보인다 - 추가적인 메모리 공간이 필요하지 않으므로, 메모리 사용량이 적다 - 분할 정복 알고리즘을 사용하므로, 병렬 처리가 가능하다 - 단점 - pivot 값을 잘못 선택하면, 최악의 경우에는 시간 복잡도가 O(n^2)까지 증가할 수 있다. 이를 방지하기 위해, pivot 값을 잘 선택하는 것이 중요하다 - 입력 데이터가 무작위로 주어지는 경우에는 평균적으로 빠른 실행 속도를 보이지만, 이미 정렬되어 있는 경우나 입력 데이터의 분포가 균일하지 않은 경우에는 실행 속도가 저하될 수 있다 - 항상 안정적인 정렬을 보장하지 않는다 - 시간복잡도 - 평균 : O(nlogn) - 최악 : O(n^2) **퀵 정렬 코드** ``` java public class QuickSort { public static void quickSort(int[] arr, int left, int right) { if (left < right) { int pivot = partition(arr, left, right); // 피벗을 선택하고, 분할 작업을 수행한다 quickSort(arr, left, pivot - 1); // 피벗을 기준으로 왼쪽 부분 배열을 정렬한다 quickSort(arr, pivot + 1, right); // 피벗을 기준으로 오른쪽 부분 배열을 정렬한다 } } public static int partition(int[] arr, int left, int right) { int pivot = arr[right]; // 피벗을 오른쪽 끝 요소로 선택한다 int i = left - 1; // 피벗보다 작은 요소들을 저장할 인덱스 for (int j = left; j < right; j++) { if (arr[j] <= pivot) { i++; swap(arr, i, j); // 피벗보다 작은 요소를 왼쪽으로 이동시킨다 } } swap(arr, i + 1, right); // 피벗을 정렬된 위치로 이동시킨다 return i + 1; // 피벗의 인덱스를 반환한다 } public static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } public static void main(String[] args) { int[] arr = {21, 10, 12, 20, 25, 13, 15, 22}; int left = 0; int right = arr.length - 1; quickSort(arr, left, right); // 퀵 정렬을 수행한다 System.out.println("정렬된 배열: " + Arrays.toString(arr)); } } ``` --- > 안정 정렬 (Stable Sort)이란 중복된 값을 입력 순서와 동일하게 정렬하는 알고리즘의 특성을 의미한다. > - 안정 정렬 : 삽입정렬, 병합정렬, 버블정렬 > - 불안정 정렬 : 선택정렬, 퀵정렬 --- # 과제 : 백준 알고리즘 2609번 풀어보기 (유클리드 호제법) - 백준 알고리즘 2609번 ==[링크](https://www.acmicpc.net/problem/2609)== --- ###### tags: `과외(하희영)`