---
# System prepended metadata

title: 재귀 알고리즘 (Recursive)
tags: [과외(하희영)]

---

# 재귀 알고리즘 (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: `과외(하희영)`