Skip to content

[week2] 곱셈#17

Open
ShinHyeongcheol wants to merge 4 commits intoSOPT-all:mainfrom
ShinHyeongcheol:week2/hyeongcheol-shin
Open

[week2] 곱셈#17
ShinHyeongcheol wants to merge 4 commits intoSOPT-all:mainfrom
ShinHyeongcheol:week2/hyeongcheol-shin

Conversation

@ShinHyeongcheol
Copy link

@ShinHyeongcheol ShinHyeongcheol commented Dec 3, 2025

문제

백준 1629번 곱셈

문제 설명

A를 B번 곱해 C로 나눈 나머지 값을 구하여라.

입력

  • A, B, C가 첫째 줄에 빈칸을 두고 입력을 받는다.
  • A, B, C는 2,147,483,647 이하의 자연수이다.

출력

  • 첫째 줄에 A를 B번 곱한 수를 C로 나눈 나머지를 출력한다.

시간 제한

  • 0.5초

해답 1 - 정직한 반복

제일 먼저 했던 방식은 정말 순수하게 A를 B번 곱해주고, C로 나눈 나머지를 구하는 방법입니다.

fun main() {
    val st = readln().split(" ").map{it.toLong()}
    val A = st[0]
    val B = st[1]
    val C = st[2]

    var result = 1L

    // 1부터 B까지 정직하게 하나씩 다 곱하기
    for (i in 1..B) {
        result = (result * A) % C
    }
}

입력 조건과 시간 제한에 따라 결과는 당연하게도 시간 초과
그렇다면 어떻게 수정을 해줘야할까요?

해답 2 - 일반 재귀(분할 정복)

fun main() {
    val st = readln().split(" ").map{it.toLong()}
    val A = st[0]
    val B = st[1]
    val C = st[2]

    // (A^B) % C
    println(multiplication(A, B, C))
}

fun multiplication(a: Long, b: Long, c: Long): Long {
    if (b == 1L) return a % c
    if (b == 0L) return 1L

    val half = multiplication(a, b / 2, c)

    val result = (half * half) % c

    return if (b % 2 == 1L) {
        (result * a) % c
    } else {
        result
    }
}

코드를 구현에서는 제일 먼저 multiplication이라는 함수를 만들어주었습니다.
multiplication에서는 b의 절반을 나누어 재귀반복을 해주었습니다.
다음은 재귀한 결과 값을 이제 C로 나머지를 구해 반환해줍니다.
이때 b가 홀수였다면 A를 한번 더 곱해줍니다.

풀이 과정 1

B가 10이라고 할때, A^10은 A^5 * A^5와 같은 지수 법칙을 이용한 방법입니다.
예시 : B = 10일때
B를 2로 나누어 주다가 홀수라면 A를 한번 더 곱해주는 과정을 거칩니다.

  1. A^10 = A^5 * A^5
  2. A^5 = A^2 * A^2 * A
  3. A^2 = A * A
  4. A = A

10에서 나누어지며 쭉 내려왔다가 A에 대한 값이 반환되며 결과적으로 A^B를 얻을 수 있습니다.

1과의 차이점은 수행시간으로 보면 큰 차이가 나는데,
기존 10번 반복해야하는 작업(A * A * A ... A)이 크게 3번의 연산작업(4->3->2->1)만 거치면 됩니다.

풀이 과정 2

나머지 연산에서는 곱한 후에 나눈 나머지는 각각 나눈 나머지를 곱한 후 다시 나눈 나머지와 같다.
(A*A) % C = ((A%C) * (A%C)) % C
예시: A = 9, B = 4일때
(9 * 9) % 4 = 1
((9%4) * (9%4)) % 4 = (1 * 1) % 4 = 1

A, B, C의 입력 조건을 생각해보았을 때, 연산과정에서 오버플로우가 발생할 확률이 높으므로 과정마다 C로 나누는 작업을 진행해 C 이하로 유지되도록 제한한다.

trailrec이란?

이 문제에서는 trailrec을 사용하지 않아도 괜찮지만, trailrec을 사용해보고 싶어 이번 주제에 재귀를 다루어 보게 되었습니다.
그냥 요런 것도 있더라 정도로만 알아두면 좋을 것 같아요.

trailrec의 컴파일 과정

코틀린에서 trailrec은 StackOverflowError가 발생하지 않도록 컴파일러 차원에서 반복문으로 최적화해주는 기능입니다.
즉, trailrec은 코드 작성 시에는 재귀로 작성을 했지만, 컴퓨터는 컴파일 과정에서 while 반복문으로 실행하도록 합니다.

tailrec fun factorial(n: Int, a: Int = 1): Int {
    if (n == 1) return a
    return factorial(n - 1, n * a)
}

위와 같이 함수 앞에 tailrec키워드를 붙여 사용할 수 있습니다.
tailrec을 사용할 경우 앞서 말했듯이 내부적으로 while 반복문으로 바꾸어 스택 메모리를 낭비하지 않게 됩니다.

public int factorial(int n, int a) {
    // 1. 함수 내부를 감싸는 무한 루프가 생성됩니다.
    while (true) {
        if (n <= 1) {
            return a; // 종료 조건
        }

        // 2. 재귀 호출 대신 '파라미터 변수'를 업데이트합니다.
        // factorial(n - 1, n * a) 호출을 아래 두 줄로 바꿉니다.
        int nextN = n - 1;
        int nextA = n * a;

        // 3. 변수를 갱신하고 루프의 처음으로 돌아갑니다. (GOTO)
        n = nextN;
        a = nextA;
        
        // continue; (다시 while문의 처음으로 점프)
    }
}

저는 처음엔 반복문으로 바뀌더라도 결국 재귀도 반복이 되는데 같은거 아냐?라고 생각을 했는데 아니더라구요.

O(n)일때, 반복문의 경우 메모리를 사용하는 과정이 O(1)과 같이 조금만 사용하게 됩니다.
하지만 재귀 함수의 경우 함수를 호출할 때마다 스택 프레임이라는 메모리 공간을 새로 만들게 되어 변수, 돌아갈 주소들을 저장하게 됩니다.
즉, O(n)의 크기에 따라 무수히 많은 공간이 필요하게 됩니다.

이것이 바로 처음에 StackOverflowError를 발생하지 않도록 최적화해주는 기능이라고 설명한 이유입니다.

trailrec을 사용하는 경우

일반적인 반복문을 사용할 때는 trailrec을 사용하는 것이 성능적으로 좋을 수 있습니다.
하지만 우리가 일반적으로 사용하는 재귀의 경우 반복을 하며 해야하는 일이 남아있기 때문입니다.

fun multiplication(a: Long, b: Long, c: Long): Long {
    if (b == 1L) return a % c
    if (b == 0L) return 1L

    val half = multiplication(a, b / 2, c)

    val result = (half * half) % c

    return if (b % 2 == 1L) {
        (result * a) % c
    } else {
        result
    }
}

위 코드 중 일부인데, 결과에 대한 result를 b가 홀수인지 짝수인지 파악하고 추가 작업을 해준 뒤에 반환하게 됩니다.

이처럼 단순히 반복하는 작업에서 끝나는 것이 아니라 돌아와서 기억해야할 변수가 생기게 되는거죠.
tailrec은 최적화 과정에서 현재 함수의 스택 프레임을 지우고 다음 함수로 점프하는 방식이기에 뒤에 작업이 남아있다면, 변수가 덮어씌워지며 반복문이 예상하지 않은 방향으로 흘러갈 것입니다.

해답 3 - trailrec 사용해보기

fun main() {
    val st = readln().split(" ").map{it.toLong()}
    val A = st[0]
    val B = st[1]
    val C = st[2]

    println(powTailrec(A, B, C, 1L))
}

// 꼬리 재귀 함수
tailrec fun multiplication(base: Long, exp: Long, mod: Long, acc: Long): Long {
    if (exp == 0L) return acc

    return if (exp % 2 == 1L) {
        multiplication(base, exp - 1, mod, (acc * base) % mod)
    } else {
        multiplication((base * base) % mod, exp / 2, mod, acc)
    }
}

이전 함수와 전체적인 구조는 같지만, 나머지 값을 파라미터로 함여 함께 반복해준다.

tailrec...사실 main안에 while로 직접 만들면 되는거 아닐까요?ㅎㅎ
그래도 보기 편하니까~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant