이미지 로딩 중...

유클리드 호제법 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 13. · 3 Views

유클리드 호제법 완벽 가이드

최대공약수를 구하는 가장 효율적인 알고리즘인 유클리드 호제법의 원리와 수학적 증명을 초급 개발자도 이해할 수 있도록 친근하게 설명합니다. 실무에서 바로 활용할 수 있는 코드 예제와 함께 알고리즘의 동작 원리를 단계별로 배워봅니다.


목차

  1. 유클리드 호제법 기본 개념 - 최대공약수를 효율적으로 구하는 방법
  2. 유클리드 호제법의 수학적 원리 - 왜 이 알고리즘이 작동하는가
  3. 반복문으로 구현하기 - 재귀 없이 효율적으로
  4. 확장 유클리드 호제법 - 베주 항등식 계산하기
  5. 최소공배수 구하기 - GCD를 활용한 LCM 계산
  6. 성능 분석과 시간 복잡도 - O(log n)의 비밀
  7. 실전 응용 - 분수 기약분수 만들기
  8. 모듈러 역원 계산 - 암호학의 핵심
  9. Binary GCD 알고리즘 - 비트 연산 최적화
  10. 실전 문제 풀이 - 경쟁 프로그래밍 예제

1. 유클리드 호제법 기본 개념 - 최대공약수를 효율적으로 구하는 방법

시작하며

여러분이 두 수의 최대공약수(GCD)를 구해야 할 때 어떤 방법을 사용하시나요? 아마 많은 분들이 두 수를 각각 소인수분해한 후 공통된 인수를 찾는 방법을 떠올리실 겁니다.

예를 들어 252와 105의 최대공약수를 구한다면, 252 = 2² × 3² × 7, 105 = 3 × 5 × 7로 분해한 후 공통 인수인 3 × 7 = 21을 찾는 식이죠. 하지만 이 방법은 숫자가 커질수록 비효율적입니다.

만약 1,234,567,890과 9,876,543,210의 최대공약수를 구해야 한다면 어떨까요? 소인수분해 자체가 매우 어렵고 시간이 오래 걸립니다.

실제 개발 현장에서 암호화, 분수 계산, 그래픽 좌표 처리 등을 할 때 이런 큰 숫자의 최대공약수를 빠르게 구해야 하는 경우가 많습니다. 바로 이럴 때 필요한 것이 유클리드 호제법입니다.

기원전 300년경 그리스 수학자 유클리드가 발견한 이 알고리즘은 2000년이 넘는 시간 동안 최대공약수를 구하는 가장 효율적인 방법으로 사용되고 있습니다. 나눗셈만으로 빠르게 답을 찾을 수 있다는 점에서 놀라운 알고리즘이죠.

개요

간단히 말해서, 유클리드 호제법은 "큰 수를 작은 수로 나눈 나머지로 작은 수를 계속 나누는 과정을 반복하다가, 나머지가 0이 되면 그때의 나누는 수가 최대공약수"라는 원리입니다. 왜 이 방법이 필요한가요?

실무에서 최대공약수는 생각보다 자주 사용됩니다. 화면 비율을 간단한 정수로 표현할 때(1920×1080을 16:9로), 분수를 기약분수로 만들 때, 그리고 RSA 암호화에서 키를 생성할 때도 필요합니다.

이런 경우 유클리드 호제법을 사용하면 O(log n)이라는 매우 빠른 시간 복잡도로 답을 구할 수 있습니다. 전통적인 방법과 비교해볼까요?

소인수분해 방식은 최악의 경우 O(√n)의 시간이 걸리지만, 유클리드 호제법은 O(log n)으로 훨씬 빠릅니다. 예를 들어 1억의 최대공약수를 구할 때, 소인수분해는 약 10,000번의 연산이 필요하지만 유클리드 호제법은 약 27번이면 충분합니다.

이 알고리즘의 핵심 특징은 세 가지입니다. 첫째, 나눗셈과 나머지 연산만 사용합니다.

둘째, 재귀적 구조로 코드가 매우 간결합니다. 셋째, 수학적으로 증명된 정확성을 보장합니다.

이러한 특징들이 유클리드 호제법을 2000년이 넘도록 사용되는 알고리즘으로 만든 이유입니다.

코드 예제

# Python으로 구현한 유클리드 호제법
def gcd(a, b):
    # a는 큰 수, b는 작은 수로 가정
    # 재귀 종료 조건: b가 0이면 a가 최대공약수
    if b == 0:
        return a

    # a를 b로 나눈 나머지를 구함
    remainder = a % b

    # 재귀 호출: b와 나머지로 다시 계산
    # gcd(a, b) = gcd(b, a % b)
    return gcd(b, remainder)

# 사용 예시
result = gcd(252, 105)  # 결과: 21
print(f"252와 105의 최대공약수: {result}")

설명

이것이 하는 일: 위 코드는 두 개의 정수 a와 b를 받아서 이들의 최대공약수를 재귀적으로 계산합니다. 핵심은 "gcd(a, b) = gcd(b, a % b)"라는 성질을 반복 적용하는 것입니다.

첫 번째로, 함수가 호출되면 먼저 종료 조건을 체크합니다. if b == 0이라는 조건문이 바로 그것인데, b가 0이 되는 순간이 바로 나눗셈이 정확히 떨어진 순간이며, 이때 a가 최대공약수가 됩니다.

왜 그럴까요? 나머지가 0이라는 것은 a가 b의 배수라는 뜻이고, 더 이상 작은 공약수를 찾을 수 없기 때문입니다.

그 다음으로, b가 0이 아니라면 a % b를 계산합니다. 이것은 a를 b로 나눈 나머지입니다.

예를 들어 gcd(252, 105)를 계산한다면, 252를 105로 나눈 나머지는 42입니다. 이제 문제가 gcd(105, 42)로 바뀌었네요.

원래 문제보다 숫자가 작아졌습니다! 세 번째 단계로, gcd(b, remainder)를 재귀 호출합니다.

이 과정이 반복되면서 숫자는 점점 작아지고, 결국 나머지가 0이 되는 순간 재귀가 종료됩니다. 252와 105의 예시를 계속 따라가보면: gcd(252, 105) → gcd(105, 42) → gcd(42, 21) → gcd(21, 0) → 21이 반환됩니다.

여러분이 이 코드를 사용하면 복잡한 소인수분해 없이도 빠르고 정확하게 최대공약수를 구할 수 있습니다. 특히 큰 숫자를 다룰 때 성능 차이가 극명하게 나타납니다.

실무에서 분수 계산 라이브러리를 만들 때, 좌표를 정규화할 때, 또는 암호화 알고리즘을 구현할 때 이 코드를 그대로 활용할 수 있습니다.

실전 팁

💡 재귀 대신 반복문으로도 구현할 수 있습니다. while b != 0: a, b = b, a % b 형태로 작성하면 스택 오버플로우 걱정 없이 더 큰 숫자도 처리할 수 있어요.

💡 Python의 math 모듈에는 math.gcd() 함수가 이미 구현되어 있습니다. 실무에서는 이를 사용하되, 알고리즘 면접이나 학습용으로는 직접 구현해보는 것이 중요합니다.

💡 a < b인 경우에도 코드가 정상 작동합니다. 첫 번째 재귀에서 자동으로 순서가 바뀌기 때문이죠. gcd(105, 252) → gcd(252, 105)로 자동 전환됩니다.

💡 음수가 입력되면 절댓값을 취한 후 계산하세요. return gcd(abs(a), abs(b))로 감싸면 안전합니다.

💡 세 개 이상의 수의 최대공약수는 gcd(gcd(a, b), c) 형태로 연쇄 적용하면 됩니다. 결합법칙이 성립하기 때문에 순서는 상관없어요.


2. 유클리드 호제법의 수학적 원리 - 왜 이 알고리즘이 작동하는가

시작하며

여러분은 알고리즘을 배울 때 "왜 이게 작동하지?"라는 궁금증을 가져본 적 있나요? 유클리드 호제법도 마찬가지입니다.

"큰 수를 작은 수로 나눈 나머지로 계속 반복하면 최대공약수가 나온다"는 말을 들었을 때, 직관적으로 이해하기 어려울 수 있습니다. 실제로 많은 개발자들이 유클리드 호제법을 "외워서" 사용하지만, 그 수학적 원리를 제대로 이해하는 경우는 드뭅니다.

하지만 원리를 이해하면 알고리즘을 응용하고 최적화하는 능력이 크게 향상됩니다. 또한 코딩 면접에서 "왜 이 알고리즘을 선택했나요?"라는 질문에 자신있게 대답할 수 있죠.

유클리드 호제법의 핵심 원리는 바로 "gcd(a, b) = gcd(b, a mod b)"라는 등식입니다. 이 등식이 왜 성립하는지 이해하면, 알고리즘의 정확성을 수학적으로 보장할 수 있습니다.

개요

간단히 말해서, 이 원리는 "두 수의 최대공약수는 작은 수와 나머지의 최대공약수와 같다"는 의미입니다. 조금 더 풀어서 설명하면, a를 b로 나눈 몫을 q, 나머지를 r이라고 할 때, a = bq + r이라는 식이 성립합니다.

왜 이것이 중요할까요? 실무에서 알고리즘의 정확성은 매우 중요합니다.

특히 금융 계산이나 암호화 같은 분야에서는 작은 오차도 허용되지 않습니다. 수학적으로 증명된 알고리즘을 사용하면 이런 위험을 완전히 제거할 수 있습니다.

전통적으로 최대공약수를 구할 때는 모든 약수를 나열하고 비교했습니다. 하지만 유클리드 호제법은 이런 비효율적인 과정 없이 나눗셈의 성질만으로 답을 찾습니다.

핵심은 "a와 b의 공약수는 b와 r(나머지)의 공약수이기도 하다"는 사실입니다. 이 원리의 핵심 특징은 두 가지입니다.

첫째, 양방향 포함 관계가 성립합니다. a와 b의 공약수 집합과 b와 r의 공약수 집합이 완전히 같습니다.

둘째, 이 성질은 나머지가 0이 될 때까지 계속 유지됩니다. 이 두 가지가 알고리즘의 정확성을 보장하는 수학적 기반입니다.

코드 예제

# 유클리드 호제법의 수학적 원리를 단계별로 보여주는 코드
def gcd_with_steps(a, b):
    # 계산 과정을 저장할 리스트
    steps = []

    # 원본 값 저장
    original_a, original_b = a, b

    while b != 0:
        # 몫과 나머지 계산
        quotient = a // b
        remainder = a % b

        # 수학적 관계식 저장: a = b * q + r
        steps.append(f"{a} = {b} × {quotient} + {remainder}")

        # gcd(a, b) = gcd(b, r) 적용
        a, b = b, remainder

    # 결과 출력
    print(f"gcd({original_a}, {original_b})의 계산 과정:")
    for step in steps:
        print(f"  {step}")
    print(f"최대공약수: {a}")

    return a

# 예시 실행
gcd_with_steps(252, 105)

설명

이것이 하는 일: 위 코드는 유클리드 호제법의 각 단계를 시각화하여 수학적 원리를 명확히 보여줍니다. 단순히 답만 구하는 것이 아니라, 중간 과정의 나눗셈 관계식들을 모두 출력합니다.

첫 번째로, while b != 0 반복문 안에서 몫과 나머지를 각각 계산합니다. quotient = a // b는 정수 나눗셈의 몫이고, remainder = a % b는 나머지입니다.

이 두 값은 항상 "a = b × quotient + remainder"라는 관계를 만족합니다. 예를 들어 252 = 105 × 2 + 42처럼 말이죠.

그 다음으로, 이 관계식을 문자열로 저장합니다. 이렇게 하면 나중에 전체 계산 과정을 한눈에 볼 수 있습니다.

252와 105의 경우를 보면: "252 = 105 × 2 + 42", "105 = 42 × 2 + 21", "42 = 21 × 2 + 0"이라는 세 단계가 저장됩니다. 세 번째 단계에서는 a, b = b, remainder로 값을 갱신합니다.

이것이 바로 "gcd(a, b) = gcd(b, a mod b)"를 코드로 구현한 부분입니다. 252와 105로 시작했던 문제가 105와 42의 문제로, 다시 42와 21의 문제로 변환되는 과정이죠.

여러분이 이 코드를 실행하면 유클리드 호제법이 어떻게 작동하는지 눈으로 직접 확인할 수 있습니다. 특히 학습 단계에서 이런 시각화는 매우 유용합니다.

또한 디버깅할 때도 중간 과정을 출력하여 어디서 문제가 생겼는지 쉽게 파악할 수 있습니다. 실무에서 복잡한 알고리즘을 구현할 때는 이렇게 단계별 로깅을 추가하는 것이 좋은 습관입니다.

실전 팁

💡 수학적 증명을 이해하려면 "a의 약수는 (bq + r)의 약수"라는 점에서 시작하세요. a의 약수 d가 있다면, a = dk이므로 bq + r = dk입니다. 이것이 증명의 핵심이에요.

💡 거꾸로 계산하는 확장 유클리드 호제법도 있습니다. 이는 "ax + by = gcd(a, b)"를 만족하는 정수 x, y를 찾는 알고리즘으로, RSA 암호화에서 필수적으로 사용됩니다.

💡 계산 과정을 트리 구조로 그려보면 이해가 더 쉽습니다. 각 노드가 (a, b) 쌍이고, 간선이 재귀 호출을 나타내는 방식이죠.

💡 시간 복잡도가 O(log n)인 이유는 나머지가 최소한 절반 이하로 줄어들기 때문입니다. b > a/2이면 a mod b < a/2이고, b ≤ a/2이면 다음 단계에서 자동으로 절반 이하가 됩니다.

💡 최악의 경우는 연속된 피보나치 수를 입력할 때입니다. gcd(F_n, F_{n-1})은 정확히 n-1번의 재귀를 필요로 하는데, 이것이 유클리드 호제법과 피보나치 수열의 흥미로운 연결고리입니다.


3. 반복문으로 구현하기 - 재귀 없이 효율적으로

시작하며

여러분이 재귀 함수를 사용하다가 "Maximum recursion depth exceeded"라는 오류를 본 적 있나요? Python의 기본 재귀 깊이 제한은 1000입니다.

매우 큰 숫자로 유클리드 호제법을 실행하면 이 제한에 걸릴 수 있습니다. 실제 실무에서는 재귀보다 반복문이 선호되는 경우가 많습니다.

재귀는 함수 호출마다 스택 메모리를 사용하지만, 반복문은 고정된 메모리만 사용합니다. 특히 임베디드 시스템이나 메모리 제약이 있는 환경에서는 반복문이 필수적입니다.

바로 이럴 때 필요한 것이 반복문 기반 유클리드 호제법입니다. 재귀의 우아함은 조금 포기하지만, 안정성과 성능을 크게 향상시킬 수 있습니다.

특히 프로덕션 환경에서는 이 방식이 더 안전합니다.

개요

간단히 말해서, 반복문 버전은 재귀 호출 대신 while 루프를 사용하여 같은 결과를 얻습니다. 핵심 아이디어는 동일하지만, 함수 호출 스택 대신 변수 값만 계속 갱신합니다.

왜 이 방법이 필요한가요? 첫째, 재귀 깊이 제한이 없습니다.

아무리 큰 숫자라도 메모리 부족 없이 처리할 수 있습니다. 둘째, 함수 호출 오버헤드가 없어서 약간 더 빠릅니다.

셋째, 디버깅이 더 쉽습니다. 중간 값을 출력하거나 중단점을 설정하기가 재귀보다 간편하죠.

전통적인 재귀 방식과 비교해볼까요? 재귀는 코드가 3-4줄로 매우 간결하고 수학적 정의에 가깝습니다.

하지만 반복문은 약간 길어도 실행 흐름이 명확하고 제어하기 쉽습니다. 특히 최적화 단계에서 반복문이 유리합니다.

이 방법의 핵심 특징은 세 가지입니다. 첫째, 공간 복잡도가 O(1)로 일정합니다.

둘째, 스택 오버플로우 위험이 전혀 없습니다. 셋째, 대부분의 프로그래밍 언어에서 꼬리 재귀 최적화를 하면 자동으로 이 형태로 변환됩니다.

이러한 특징들이 실무에서 반복문 버전을 선호하게 만드는 이유입니다.

코드 예제

# 반복문으로 구현한 유클리드 호제법
def gcd_iterative(a, b):
    # a와 b의 절댓값 사용 (음수 처리)
    a, b = abs(a), abs(b)

    # b가 0이 될 때까지 반복
    while b != 0:
        # 나머지 계산
        remainder = a % b

        # 값 갱신: a ← b, b ← remainder
        # Python의 동시 할당 기능 활용
        a, b = b, remainder

    # b가 0이 되면 a가 최대공약수
    return a

# 사용 예시
print(gcd_iterative(252, 105))     # 21
print(gcd_iterative(-48, 18))      # 6 (음수도 처리)
print(gcd_iterative(1071, 462))    # 21

설명

이것이 하는 일: 위 코드는 재귀 대신 반복문을 사용하여 최대공약수를 계산합니다. 메모리 효율이 높고 안정적이며, 실무에서 가장 많이 사용되는 구현 방식입니다.

첫 번째로, abs() 함수로 음수를 처리합니다. 최대공약수는 항상 양수이므로, 입력이 음수여도 절댓값으로 계산하면 됩니다.

이렇게 하면 gcd(-48, 18)과 gcd(48, 18)이 같은 결과(6)를 반환합니다. 실무에서는 예상치 못한 음수 입력을 방어하는 것이 중요합니다.

그 다음으로, while b != 0 조건으로 반복합니다. b가 0이 되는 순간이 바로 나눗셈이 정확히 떨어진 때이고, 이때 a가 최대공약수입니다.

재귀의 "기저 사례"가 반복문에서는 "종료 조건"이 되는 것이죠. 예를 들어 gcd(252, 105)를 계산하면 (252, 105) → (105, 42) → (42, 21) → (21, 0) 순서로 변합니다.

세 번째 단계에서는 Python의 동시 할당 기능을 활용합니다. a, b = b, remainder는 오른쪽을 먼저 모두 평가한 후 왼쪽에 할당하므로, 임시 변수 없이도 값을 안전하게 교환할 수 있습니다.

다른 언어에서는 temp = a; a = b; b = remainder 형태로 작성해야 하지만, Python에서는 더 깔끔하게 표현할 수 있죠. 여러분이 이 코드를 사용하면 어떤 크기의 숫자든 안정적으로 처리할 수 있습니다.

재귀 깊이 제한에 걸릴 일이 없고, 메모리 사용량도 일정합니다. 실제 프로덕션 코드에서 유클리드 호제법을 구현할 때는 대부분 이 방식을 선택합니다.

또한 성능 측정 결과 재귀 버전보다 약 10-15% 빠른 것으로 알려져 있습니다.

실전 팁

💡 a, b = b, a % b 형태로 더 간결하게 작성할 수 있습니다. remainder 변수를 생략하고 한 줄로 표현하는 방식이죠.

💡 C나 Java에서는 임시 변수가 필요합니다. int temp = a; a = b; b = temp % b; 형태로 작성하세요.

💡 반복 횟수를 세고 싶다면 counter 변수를 추가하세요. 시간 복잡도 분석이나 성능 테스트에 유용합니다.

💡 입력 검증을 추가하면 더 견고합니다. if a == 0 and b == 0: raise ValueError("Both inputs cannot be zero")처럼 예외 처리를 하세요.

💡 벤치마크 결과 재귀보다 약간 빠르지만, Python의 math.gcd()는 C로 구현되어 훨씬 더 빠릅니다. 학습용으로는 직접 구현하되, 실무에서는 내장 함수를 사용하는 것이 좋습니다.


4. 확장 유클리드 호제법 - 베주 항등식 계산하기

시작하며

여러분이 "13x + 7y = 1"을 만족하는 정수 x, y를 찾아야 한다면 어떻게 하시겠어요? 일반적인 방정식 풀이 방법으로는 정수 해를 찾기 어렵습니다.

이런 문제는 암호학, 특히 RSA 알고리즘에서 핵심적으로 등장합니다. 실제로 RSA 암호화의 키 생성 과정에서는 "ed ≡ 1 (mod φ(n))"을 만족하는 d를 찾아야 하는데, 이것이 바로 확장 유클리드 호제법으로 해결되는 문제입니다.

인터넷 뱅킹, HTTPS 통신 등 우리가 매일 사용하는 보안 기술의 핵심에 이 알고리즘이 있습니다. 바로 이럴 때 필요한 것이 확장 유클리드 호제법(Extended Euclidean Algorithm)입니다.

일반 유클리드 호제법이 최대공약수만 구한다면, 확장 버전은 "ax + by = gcd(a, b)"를 만족하는 정수 x, y까지 함께 찾아줍니다. 이를 베주 항등식(Bézout's identity)이라고 합니다.

개요

간단히 말해서, 확장 유클리드 호제법은 최대공약수 g를 구하는 동시에, "ax + by = g"를 만족하는 정수 계수 x, y를 찾는 알고리즘입니다. 왜 이것이 중요할까요?

첫째, 모듈러 역원을 계산할 수 있습니다. gcd(a, m) = 1일 때, ax + my = 1에서 x가 바로 a의 m에 대한 역원입니다.

둘째, 일차 디오판토스 방정식의 해를 구할 수 있습니다. 셋째, 중국인의 나머지 정리를 구현할 때 사용됩니다.

이런 응용은 암호학, 정수론 프로그래밍, 경쟁 프로그래밍에서 필수적입니다. 전통적인 유클리드 호제법과 비교하면 어떨까요?

일반 버전은 재귀가 끝나면 최대공약수만 반환하고 끝입니다. 하지만 확장 버전은 재귀가 돌아오는 과정에서 계수 x, y를 역계산합니다.

마치 재귀 호출 스택을 거슬러 올라가며 답을 조립하는 것과 같습니다. 이 알고리즘의 핵심 특징은 세 가지입니다.

첫째, 백트래킹 방식으로 계수를 계산합니다. 둘째, 시간 복잡도는 일반 유클리드 호제법과 같은 O(log n)입니다.

셋째, 암호학적 응용에서 필수적인 도구입니다. 이러한 특징들이 확장 유클리드 호제법을 고급 알고리즘 문제에서 자주 등장하게 만듭니다.

코드 예제

# 확장 유클리드 호제법: gcd와 베주 계수를 함께 계산
def extended_gcd(a, b):
    # 기저 사례: b가 0이면 gcd는 a
    # 이때 a*1 + b*0 = a이므로 x=1, y=0
    if b == 0:
        return a, 1, 0

    # 재귀 호출로 gcd(b, a%b)의 계수 구하기
    gcd, x1, y1 = extended_gcd(b, a % b)

    # 역추적: 현재 단계의 x, y 계산
    # b*x1 + (a%b)*y1 = gcd에서
    # b*x1 + (a - b*(a//b))*y1 = gcd
    # a*y1 + b*(x1 - (a//b)*y1) = gcd
    x = y1
    y = x1 - (a // b) * y1

    return gcd, x, y

# 사용 예시: 13x + 7y = gcd(13, 7) = 1
g, x, y = extended_gcd(13, 7)
print(f"gcd: {g}, x: {x}, y: {y}")
print(f"검증: 13*{x} + 7*{y} = {13*x + 7*y}")

설명

이것이 하는 일: 위 코드는 재귀적으로 최대공약수를 구하면서, 돌아오는 과정에서 베주 항등식의 계수 x, y를 역계산합니다. 일반 유클리드 호제법을 확장하여 세 개의 값(gcd, x, y)을 반환합니다.

첫 번째로, 기저 사례는 b == 0일 때입니다. 이때 gcd(a, 0) = a이고, "a×1 + 0×0 = a"가 성립하므로 x=1, y=0을 반환합니다.

이것이 역추적의 시작점이 됩니다. 예를 들어 gcd(13, 7)을 계산하면 최종적으로 gcd(7, 6) → gcd(6, 1) → gcd(1, 0)에 도달하고, 이때 (1, 1, 0)을 반환합니다.

그 다음으로, 재귀 호출 extended_gcd(b, a % b)를 통해 더 작은 문제의 해를 얻습니다. 이것이 반환한 (gcd, x1, y1)은 "b×x1 + (a mod b)×y1 = gcd"를 만족합니다.

우리가 원하는 것은 "a×x + b×y = gcd"이므로, x1과 y1을 이용해 x와 y를 계산해야 합니다. 세 번째 단계에서는 수학적 변환을 수행합니다.

"a mod b = a - b×(a//b)"라는 관계를 이용하면, "b×x1 + (a - b×(a//b))×y1 = gcd"를 정리하여 "a×y1 + b×(x1 - (a//b)×y1) = gcd"를 얻습니다. 따라서 x = y1, y = x1 - (a//b)×y1이 됩니다.

이 과정이 재귀가 풀리면서 반복되어 최종 계수를 구성하는 것이죠. 여러분이 이 코드를 사용하면 모듈러 역원을 쉽게 계산할 수 있습니다.

예를 들어 7의 mod 13에서의 역원은 extended_gcd(7, 13)에서 x 값입니다. 또한 RSA 암호화의 개인키를 생성할 때도 이 알고리즘을 직접 사용합니다.

경쟁 프로그래밍에서도 정수론 문제의 90%가 이 알고리즘으로 해결됩니다.

실전 팁

💡 모듈러 역원을 구할 때는 gcd가 1인지 먼저 확인하세요. gcd(a, m) ≠ 1이면 역원이 존재하지 않습니다.

💡 음수 계수가 나올 수 있습니다. x가 음수면 x = x % m으로 양수로 변환하세요. 모듈러 연산에서는 음수와 양수가 동치입니다.

💡 반복문 버전도 구현할 수 있습니다. 스택에 중간 몫들을 저장했다가 역순으로 계산하는 방식이죠. 메모리 효율이 더 좋습니다.

💡 검증 코드를 항상 추가하세요. assert a*x + b*y == gcd 형태로 결과가 맞는지 확인하면 디버깅이 쉬워집니다.

💡 Python의 pow(a, -1, m)은 3.8 버전부터 내장 모듈러 역원 함수입니다. 하지만 알고리즘 이해를 위해서는 직접 구현해보는 것이 중요합니다.


5. 최소공배수 구하기 - GCD를 활용한 LCM 계산

시작하며

여러분이 두 개의 주기적인 이벤트가 동시에 발생하는 시점을 찾아야 할 때가 있나요? 예를 들어 A 버스는 12분마다, B 버스는 18분마다 온다면, 두 버스가 동시에 도착하는 것은 몇 분마다일까요?

이런 문제는 최소공배수(LCM, Least Common Multiple)로 해결됩니다. 실무에서도 작업 스케줄링, 주기적인 배치 작업 동기화, 애니메이션 타이밍 조정 등에서 최소공배수를 자주 사용합니다.

예를 들어 두 개의 크론 작업이 충돌하지 않게 배치하려면 최소공배수를 계산해야 합니다. 놀라운 사실은 최소공배수를 구하는 데도 유클리드 호제법이 사용된다는 것입니다.

"LCM(a, b) = (a × b) / GCD(a, b)"라는 간단한 공식으로 효율적으로 계산할 수 있죠. 직접 공배수를 찾는 것보다 훨씬 빠릅니다.

개요

간단히 말해서, 최소공배수는 두 수의 곱을 최대공약수로 나눈 값입니다. 수학적으로 LCM(a, b) × GCD(a, b) = a × b라는 관계가 성립합니다.

왜 이 공식이 유용할까요? 첫째, 최대공약수만 구하면 최소공배수는 자동으로 계산됩니다.

둘째, 오버플로우를 방지할 수 있습니다. a × b를 먼저 계산하면 정수 범위를 초과할 수 있지만, (a / GCD(a, b)) × b 순서로 계산하면 안전합니다.

셋째, 시간 복잡도가 O(log n)으로 매우 빠릅니다. 전통적인 방법과 비교해볼까요?

예전에는 두 수의 배수를 하나씩 나열하며 공통 배수를 찾았습니다. 12의 배수: 12, 24, 36, 48, 60, 72...

18의 배수: 18, 36, 54, 72... 첫 번째 공통 값인 36이 최소공배수입니다.

하지만 이 방법은 숫자가 크면 비효율적이죠. 이 방법의 핵심 특징은 두 가지입니다.

첫째, GCD를 먼저 구하므로 유클리드 호제법의 효율성을 그대로 물려받습니다. 둘째, 세 개 이상의 수로 확장하기 쉽습니다.

LCM(a, b, c) = LCM(LCM(a, b), c)처럼 연쇄 적용하면 됩니다. 이러한 특징들이 실무에서 최소공배수를 구할 때 이 공식을 사용하게 만듭니다.

코드 예제

# GCD를 활용한 효율적인 LCM 계산
def gcd(a, b):
    # 유클리드 호제법으로 최대공약수 계산
    while b != 0:
        a, b = b, a % b
    return a

def lcm(a, b):
    # 0 처리: 하나라도 0이면 LCM은 0
    if a == 0 or b == 0:
        return 0

    # 오버플로우 방지: (a / gcd) * b 순서로 계산
    # a * b / gcd보다 안전함
    gcd_value = gcd(abs(a), abs(b))
    return abs(a * b) // gcd_value

# 여러 수의 LCM을 구하는 함수
def lcm_multiple(*numbers):
    from functools import reduce
    return reduce(lcm, numbers)

# 사용 예시
print(lcm(12, 18))              # 36
print(lcm(21, 14))              # 42
print(lcm_multiple(4, 6, 8))    # 24

설명

이것이 하는 일: 위 코드는 유클리드 호제법으로 구한 최대공약수를 이용하여 최소공배수를 안전하고 효율적으로 계산합니다. 또한 여러 개의 수로 확장하는 방법도 보여줍니다.

첫 번째로, gcd() 함수는 앞서 배운 반복문 버전의 유클리드 호제법입니다. 이것이 O(log n)의 시간에 최대공약수를 구합니다.

절댓값을 사용하는 이유는 음수가 입력되어도 정상 작동하게 하기 위함입니다. 그 다음으로, lcm() 함수에서 먼저 0을 검사합니다.

수학적으로 0과 다른 수의 최소공배수는 0입니다. 이 예외 처리를 빼먹으면 나중에 0으로 나누는 오류가 발생할 수 있습니다.

실무에서는 이런 엣지 케이스 처리가 매우 중요합니다. 세 번째 단계에서는 abs(a * b) // gcd_value로 최소공배수를 계산합니다.

여기서 중요한 점은 정수 나눗셈(//)을 사용한다는 것입니다. 최대공약수는 항상 a × b의 약수이므로 나머지가 0이지만, 부동소수점 오류를 피하기 위해 정수 나눗셈을 사용하는 것이 안전합니다.

마지막으로, lcm_multiple() 함수는 Python의 reduce()를 활용하여 여러 수의 최소공배수를 구합니다. reduce는 리스트의 첫 두 원소에 함수를 적용하고, 그 결과와 다음 원소에 또 적용하는 식으로 동작합니다.

예를 들어 lcm_multiple(4, 6, 8)은 lcm(lcm(4, 6), 8) = lcm(12, 8) = 24를 계산합니다. 여러분이 이 코드를 사용하면 작업 스케줄링, 주기 계산, 분수의 통분 등 다양한 문제를 해결할 수 있습니다.

특히 크론 작업의 충돌을 피하거나, 여러 애니메이션의 동기화 시점을 찾을 때 매우 유용합니다. 실무에서 최소공배수는 생각보다 자주 필요하므로, 이 코드를 유틸리티 함수로 저장해두면 좋습니다.

실전 팁

💡 오버플로우를 완전히 방지하려면 (a // gcd_value) * b로 계산하세요. 먼저 나눈 후 곱하면 중간 결과가 작아집니다.

💡 Python 3.9부터는 math.lcm() 함수가 내장되어 있습니다. 여러 인수도 지원합니다: math.lcm(4, 6, 8).

💡 분수의 덧셈에서 통분할 때 LCM을 사용합니다. a/b + c/d = (a*d + c*b) / (b*d)보다 lcm(b, d)를 분모로 쓰는 것이 효율적입니다.

💡 세 수 이상의 LCM은 순서와 무관합니다. LCM(a, b, c) = LCM(a, LCM(b, c)) = LCM(LCM(a, b), c) 모두 같은 결과입니다.

💡 GCD와 LCM의 관계를 검증하려면 assert gcd(a, b) * lcm(a, b) == abs(a * b)를 테스트하세요.


6. 성능 분석과 시간 복잡도 - O(log n)의 비밀

시작하며

여러분이 알고리즘의 성능을 분석할 때 "왜 O(log n)일까?"라는 궁금증을 가져본 적 있나요? 유클리드 호제법은 고대 알고리즘임에도 현대적 기준으로도 매우 효율적인 O(log n)의 시간 복잡도를 가집니다.

이것이 얼마나 빠른지 실감하기 어려울 수 있습니다. 구체적으로 말하면, 10억(10^9)의 최대공약수를 구할 때 최대 약 30번의 나눗셈이면 충분합니다.

이는 이진 탐색과 같은 수준의 효율성이죠. 실제로 1초에 수백만 번의 GCD 계산을 할 수 있을 정도입니다.

실무에서 성능 분석을 정확히 이해하는 것은 매우 중요합니다. 특히 대용량 데이터 처리, 실시간 시스템, 경쟁 프로그래밍에서는 시간 복잡도가 성공과 실패를 가릅니다.

유클리드 호제법의 시간 복잡도를 이해하면 다른 알고리즘의 효율성도 더 잘 판단할 수 있습니다.

개요

간단히 말해서, 유클리드 호제법의 시간 복잡도는 O(log min(a, b))입니다. 여기서 log는 밑이 약 1.618(황금비)인 로그인데, 실용적으로는 log₂로 근사합니다.

왜 이것이 중요할까요? 첫째, 입력 크기가 두 배가 되어도 연산 횟수는 고작 1-2번만 증가합니다.

둘째, 소인수분해(O(√n))나 단순 반복(O(n))보다 압도적으로 빠릅니다. 셋째, 최악의 경우를 수학적으로 증명할 수 있어 신뢰성이 높습니다.

다른 알고리즘과 비교해볼까요? 1부터 min(a, b)까지 하나씩 나누어보는 방법은 O(n)입니다.

100만의 최대공약수를 구하려면 최악의 경우 100만 번 연산이 필요합니다. 하지만 유클리드 호제법은 약 20번이면 충분합니다.

이 차이는 입력이 커질수록 기하급수적으로 벌어집니다. 이 알고리즘의 성능 특징은 세 가지입니다.

첫째, 최악의 경우는 연속된 피보나치 수입니다. 둘째, 평균적으로는 약 0.84 log₂(n)번의 반복이 필요합니다.

셋째, 나머지 연산의 비용이 지배적이므로 하드웨어에 따라 실제 성능이 달라집니다. 이러한 분석이 알고리즘 선택의 과학적 근거가 됩니다.

코드 예제

# 유클리드 호제법의 성능을 측정하는 코드
import time

def gcd_with_counter(a, b):
    # 반복 횟수를 세는 카운터
    count = 0
    original_a, original_b = a, b

    while b != 0:
        a, b = b, a % b
        count += 1

    return a, count

# 다양한 입력에 대한 성능 분석
test_cases = [
    (100, 50),           # 일반적인 경우
    (1000000, 999999),   # 큰 인접한 수
    (89, 55),            # 피보나치 수 (최악)
    (2**20, 2**19),      # 2의 거듭제곱
]

print("입력 크기별 반복 횟수:")
for a, b in test_cases:
    gcd_val, iterations = gcd_with_counter(a, b)
    log_estimate = len(bin(min(a, b))) - 2  # log₂ 근사값
    print(f"gcd({a}, {b}) = {gcd_val}")
    print(f"  반복 횟수: {iterations}, 예상(log₂): ~{log_estimate}\n")

# 실행 시간 측정
large_a, large_b = 10**18, 10**18 - 1
start = time.time()
result, count = gcd_with_counter(large_a, large_b)
elapsed = time.time() - start
print(f"10^18 크기: {count}회 반복, {elapsed*1000:.3f}ms")

설명

이것이 하는 일: 위 코드는 유클리드 호제법의 실제 반복 횟수를 측정하고, 이론적 예상값(log₂)과 비교하여 시간 복잡도를 실증적으로 검증합니다. 첫 번째로, gcd_with_counter() 함수는 일반 유클리드 호제법에 카운터를 추가한 버전입니다.

count += 1로 while 루프가 몇 번 반복되는지 세어서, 최대공약수와 함께 반환합니다. 이렇게 하면 이론과 실제를 직접 비교할 수 있습니다.

그 다음으로, 다양한 테스트 케이스를 준비합니다. (100, 50)은 일반적인 경우, (1000000, 999999)는 큰 인접한 수, (89, 55)는 피보나치 수로 최악의 경우, (2^20, 2^19)는 2의 거듭제곱입니다.

각 경우마다 반복 횟수가 어떻게 다른지 비교할 수 있습니다. 세 번째 단계에서는 len(bin(min(a, b))) - 2로 log₂의 근사값을 계산합니다.

bin()은 이진 표현을 반환하는데, '0b' 접두사를 빼면 비트 수가 나옵니다. 이것이 바로 ⌈log₂(n)⌉과 같습니다.

예를 들어 100의 이진수는 1100100(7비트)이므로 log₂(100) ≈ 6.64입니다. 마지막으로, 매우 큰 수(10^18)로 실제 실행 시간을 측정합니다.

time.time()으로 시작과 끝 시간을 재어 밀리초 단위로 출력합니다. 놀랍게도 10^18 크기의 수도 몇 밀리초 안에 계산됩니다.

이것이 O(log n)의 위력입니다. 여러분이 이 코드를 실행하면 "로그 시간"이 실제로 얼마나 빠른지 체감할 수 있습니다.

또한 알고리즘 면접에서 시간 복잡도를 물어볼 때 단순히 "O(log n)"이라고 답하는 것이 아니라, 실제 반복 횟수를 측정한 경험을 이야기할 수 있습니다. 이런 실증적 분석 능력은 시니어 개발자의 중요한 역량입니다.

실전 팁

💡 최악의 경우를 만드는 피보나치 수의 성질: gcd(F_n, F_{n-1})은 정확히 n-1번 반복합니다. F_13 = 233, F_12 = 144를 시험해보세요.

💡 비트 연산으로 최적화한 Binary GCD 알고리즘도 있습니다. 2의 거듭제곱으로 나누는 경우 시프트 연산이 더 빠릅니다.

💡 프로파일링 도구(cProfile)로 실제 병목을 찾으세요. 나머지 연산(%)이 전체 시간의 대부분을 차지합니다.

💡 입력 크기가 64비트 정수 범위라면 최대 약 60번 반복이 필요합니다. 이것이 재귀 깊이 제한을 고려할 때의 기준입니다.

💡 평균 반복 횟수는 약 0.84 log₂(n)으로 알려져 있습니다(Knuth). 최악은 약 1.44 log₂(n)입니다. 무작위 입력에서는 평균에 가깝습니다.


7. 실전 응용 - 분수 기약분수 만들기

시작하며

여러분이 계산기 앱을 만들다가 "8/12를 어떻게 2/3로 자동 변환하지?"라는 문제에 부딪힌 적 있나요? 분수 계산은 수학 교육 앱, 공학 계산기, 음악 프로그램(박자 계산) 등에서 필수적입니다.

실무에서 분수를 다룰 때 가장 중요한 것은 기약분수로 만드는 것입니다. 12/18과 2/3은 수학적으로 같지만, 사용자에게 보여줄 때는 2/3이 훨씬 직관적입니다.

또한 내부 계산에서도 분자와 분모가 작을수록 오버플로우 위험이 줄어듭니다. 바로 이럴 때 유클리드 호제법이 완벽한 해결책입니다.

분자와 분모의 최대공약수를 구한 후 둘 다 나누면 자동으로 기약분수가 됩니다. 간단하지만 매우 실용적인 응용 사례입니다.

개요

간단히 말해서, 기약분수는 분자와 분모를 그들의 최대공약수로 나눈 분수입니다. 수학적으로 a/b = (a÷gcd)/(b÷gcd) 형태입니다.

왜 이것이 필요할까요? 첫째, 사용자 경험이 향상됩니다.

50/100보다 1/2가 훨씬 읽기 쉽습니다. 둘째, 메모리 효율이 좋습니다.

큰 수 대신 작은 수로 저장할 수 있습니다. 셋째, 분수 비교가 쉬워집니다.

2/3과 4/6을 비교하려면 기약분수로 만들어야 정확합니다. 전통적인 방법과 비교해볼까요?

예전에는 분자와 분모를 작은 수부터 하나씩 나누어보며 공약수를 찾았습니다. 하지만 유클리드 호제법을 사용하면 한 번의 GCD 계산과 두 번의 나눗셈만으로 끝납니다.

예를 들어 252/105는 gcd(252, 105) = 21을 구한 후 12/5로 간단히 변환됩니다. 이 응용의 핵심 특징은 세 가지입니다.

첫째, 항상 유일한 기약분수가 보장됩니다. 둘째, 음수 분수도 올바르게 처리할 수 있습니다.

셋째, 분수 사칙연산의 기반이 됩니다. 이러한 특징들이 Python의 fractions.Fraction 같은 표준 라이브러리의 핵심 알고리즘으로 사용되는 이유입니다.

코드 예제

# 유클리드 호제법을 활용한 분수 클래스
class Fraction:
    def __init__(self, numerator, denominator):
        if denominator == 0:
            raise ValueError("분모는 0이 될 수 없습니다")

        # 최대공약수 계산
        g = self.gcd(abs(numerator), abs(denominator))

        # 기약분수로 만들기
        self.num = numerator // g
        self.den = denominator // g

        # 분모를 항상 양수로 (부호는 분자에)
        if self.den < 0:
            self.num = -self.num
            self.den = -self.den

    @staticmethod
    def gcd(a, b):
        # 유클리드 호제법
        while b != 0:
            a, b = b, a % b
        return a

    def __str__(self):
        return f"{self.num}/{self.den}"

    def __add__(self, other):
        # 분수 덧셈: a/b + c/d = (ad + bc)/(bd)
        new_num = self.num * other.den + other.num * self.den
        new_den = self.den * other.den
        return Fraction(new_num, new_den)  # 자동으로 기약분수화

# 사용 예시
f1 = Fraction(8, 12)      # 자동으로 2/3으로 변환
f2 = Fraction(3, 4)
f3 = f1 + f2              # 2/3 + 3/4 = 17/12
print(f"f1: {f1}")        # 2/3
print(f"f2: {f2}")        # 3/4
print(f"f1 + f2: {f3}")   # 17/12

설명

이것이 하는 일: 위 코드는 유클리드 호제법을 활용하여 항상 기약분수 형태를 유지하는 분수 클래스를 구현합니다. 생성 시점과 연산 후에 자동으로 약분됩니다.

첫 번째로, __init__() 생성자에서 분모가 0인지 체크합니다. 0으로 나누는 것은 수학적으로 정의되지 않으므로 ValueError를 발생시킵니다.

이런 입력 검증은 실무 코드에서 필수적입니다. 사용자가 잘못된 값을 입력했을 때 명확한 오류 메시지를 주는 것이 중요하죠.

그 다음으로, gcd()로 최대공약수를 구하고 분자와 분모를 모두 나눕니다. 절댓값을 사용하는 이유는 음수 분수도 올바르게 처리하기 위함입니다.

예를 들어 Fraction(-8, 12)는 GCD를 4로 구한 후 -2/3이 됩니다. 세 번째 단계에서는 분모의 부호를 정규화합니다.

if self.den < 0일 때 분자와 분모에 모두 -1을 곱하여 분모를 양수로 만듭니다. 이렇게 하면 -2/3과 2/-3이 같은 형태(-2/3)로 통일됩니다.

이런 정규화는 비교 연산을 구현할 때 매우 유용합니다. 마지막으로, __add__() 메서드는 분수 덧셈을 구현합니다.

새로운 분자와 분모를 계산한 후 Fraction 생성자에 넘기면, 생성자가 자동으로 기약분수로 만들어줍니다. 예를 들어 2/3 + 3/4 = (2×4 + 3×3)/(3×4) = 17/12인데, GCD(17, 12) = 1이므로 이미 기약분수입니다.

여러분이 이 코드를 사용하면 분수 계산을 안전하고 정확하게 처리할 수 있습니다. 교육용 앱, 공학 계산기, 음악 소프트웨어 등 다양한 분야에 응용 가능합니다.

Python의 표준 라이브러리 fractions.Fraction도 내부적으로 같은 원리를 사용합니다. 실무에서는 표준 라이브러리를 쓰되, 원리를 이해하면 커스터마이징이 가능합니다.

실전 팁

💡 뺄셈, 곱셈, 나눗셈도 같은 방식으로 구현하세요. 곱셈은 (a/b) × (c/d) = (ac)/(bd), 나눗셈은 (a/b) ÷ (c/d) = (ad)/(bc)입니다.

💡 비교 연산(eq, lt)을 구현하려면 교차 곱셈을 사용하세요. a/b < c/d는 ad < bc와 같습니다(양수 분모 가정).

💡 float로 변환하려면 __float__()를 추가하세요: return self.num / self.den. 하지만 정확도가 떨어질 수 있습니다.

💡 repr()도 구현하면 디버깅이 편합니다. f"Fraction({self.num}, {self.den})" 형식으로 반환하세요.

💡 Python의 fractions.Fraction과 비교해보세요. from fractions import Fraction as F; print(F(8, 12))로 같은 결과가 나오는지 확인할 수 있습니다.


8. 모듈러 역원 계산 - 암호학의 핵심

시작하며

여러분이 "7x ≡ 1 (mod 13)"을 만족하는 x를 찾아야 한다면 어떻게 하시겠어요? 이런 문제는 RSA 암호화, 디피-헬만 키 교환, 타원곡선 암호 등 현대 암호학의 핵심입니다.

실제로 HTTPS로 웹사이트에 접속할 때마다 이런 모듈러 역원 계산이 수십 번 일어납니다. 서버와 클라이언트가 안전하게 암호키를 교환하고 검증하는 과정에서 필수적이죠.

또한 해시 테이블의 충돌 해결, 난수 생성기, 오류 정정 코드에서도 사용됩니다. 바로 이럴 때 필요한 것이 확장 유클리드 호제법입니다.

모듈러 역원은 "ax ≡ 1 (mod m)"을 만족하는 x인데, 이는 "ax + my = 1"의 정수해를 구하는 문제와 같습니다. 확장 유클리드 호제법으로 정확하게 계산할 수 있죠.

개요

간단히 말해서, a의 mod m에서의 역원은 "a × x ≡ 1 (mod m)"을 만족하는 x입니다. 일반 산술의 곱셈 역원(역수)과 같은 개념이지만, 모듈러 연산에서만 적용됩니다.

왜 이것이 중요할까요? 첫째, 암호화 알고리즘의 기반입니다.

RSA의 개인키를 생성할 때 "ed ≡ 1 (mod φ(n))"의 d를 구해야 합니다. 둘째, 모듈러 나눗셈을 가능하게 합니다.

(a/b) mod m = (a × b^(-1)) mod m으로 계산할 수 있습니다. 셋째, 많은 정수론 알고리즘의 구성 요소입니다.

전통적인 방법과 비교해볼까요? 1부터 m-1까지 하나씩 대입해보는 방법은 O(m)이 걸립니다.

하지만 확장 유클리드 호제법은 O(log m)으로 훨씬 빠릅니다. m이 10억이라면 차이가 엄청납니다.

또한 페르마의 소정리를 이용한 방법도 있지만, m이 소수가 아니면 사용할 수 없습니다. 이 방법의 핵심 특징은 세 가지입니다.

첫째, gcd(a, m) = 1일 때만 역원이 존재합니다. 둘째, 역원은 0부터 m-1 범위에서 유일합니다.

셋째, 확장 유클리드 호제법으로 정확하고 빠르게 계산됩니다. 이러한 특징들이 암호학 라이브러리의 핵심 함수로 사용되는 이유입니다.

코드 예제

# 확장 유클리드 호제법으로 모듈러 역원 계산
def extended_gcd(a, b):
    if b == 0:
        return a, 1, 0
    gcd, x1, y1 = extended_gcd(b, a % b)
    x = y1
    y = x1 - (a // b) * y1
    return gcd, x, y

def mod_inverse(a, m):
    """a의 mod m에서의 역원을 계산"""
    # a를 양수로 정규화
    a = a % m

    # 확장 유클리드 호제법으로 ax + my = gcd 계산
    gcd, x, _ = extended_gcd(a, m)

    # 역원이 존재하지 않는 경우
    if gcd != 1:
        raise ValueError(
            f"{a}{m}은 서로소가 아니므로 역원이 존재하지 않습니다"
        )

    # x를 0 ~ m-1 범위로 정규화
    return x % m

# 사용 예시
print(f"7의 mod 13 역원: {mod_inverse(7, 13)}")      # 2
print(f"검증: (7 * 2) % 13 = {(7 * 2) % 13}")        # 1

# RSA 예제 (간단한 버전)
e, phi_n = 3, 20  # 공개지수와 오일러 파이
d = mod_inverse(e, phi_n)  # 개인지수
print(f"RSA 개인키: {d}")                            # 7
print(f"검증: (3 * 7) % 20 = {(3 * 7) % 20}")        # 1

설명

이것이 하는 일: 위 코드는 확장 유클리드 호제법을 활용하여 모듈러 역원을 안전하고 효율적으로 계산합니다. 역원이 존재하지 않는 경우도 올바르게 처리합니다.

첫 번째로, extended_gcd() 함수는 앞서 배운 확장 유클리드 호제법입니다. 이것이 "ax + my = gcd(a, m)"을 만족하는 x를 반환합니다.

역원이 존재하려면 gcd가 1이어야 하므로, "ax + my = 1"이 되고, 여기서 x가 바로 우리가 찾는 역원입니다. 그 다음으로, a % m으로 입력을 정규화합니다.

a가 m보다 크거나 음수여도 올바르게 처리하기 위함입니다. 예를 들어 mod_inverse(20, 13)은 mod_inverse(7, 13)과 같은 결과를 반환해야 합니다.

모듈러 연산의 성질을 활용한 것이죠. 세 번째 단계에서는 gcd가 1인지 검증합니다.

gcd ≠ 1이면 a와 m이 공약수를 가지므로 역원이 존재하지 않습니다. 이때 ValueError를 발생시켜 명확한 오류 메시지를 제공합니다.

예를 들어 mod_inverse(6, 9)는 gcd(6, 9) = 3이므로 오류가 발생합니다. 마지막으로, x % m으로 결과를 0부터 m-1 범위로 정규화합니다.

확장 유클리드 호제법이 반환한 x는 음수일 수 있기 때문입니다. 예를 들어 x = -11이고 m = 13이면, -11 % 13 = 2로 양수로 변환됩니다.

이것이 표준적인 역원 표현입니다. 여러분이 이 코드를 사용하면 암호화 알고리즘의 핵심 연산을 직접 구현할 수 있습니다.

RSA 키 생성, 디지털 서명 검증, 타원곡선 연산 등에서 필수적으로 사용됩니다. 실무에서는 라이브러리를 사용하지만, 원리를 이해하면 보안 취약점을 파악하고 최적화할 수 있습니다.

실전 팁

💡 Python 3.8+에서는 pow(a, -1, m)으로 간단히 계산할 수 있습니다. 내부적으로 확장 유클리드 호제법을 사용합니다.

💡 m이 소수라면 페르마의 소정리를 사용할 수 있습니다: a^(-1) ≡ a^(m-2) (mod m). pow(a, m-2, m)으로 계산 가능하지만 느립니다.

💡 여러 번 역원을 계산한다면 사전 계산 테이블을 만드세요. O(m) 공간으로 O(1) 조회가 가능합니다.

💡 역원의 역원은 원래 수입니다. mod_inverse(mod_inverse(a, m), m) == a를 검증해보세요.

💡 암호학에서는 타이밍 공격을 방어하기 위해 일정 시간 알고리즘(constant-time)을 사용합니다. 실무 암호 라이브러리는 이런 보안 강화가 되어 있습니다.


9. Binary GCD 알고리즘 - 비트 연산 최적화

시작하며

여러분이 임베디드 시스템이나 성능이 중요한 환경에서 GCD를 계산해야 할 때, 나눗셈 연산이 너무 느리다고 느낀 적 있나요? 특히 하드웨어 나눗셈기가 없는 마이크로컨트롤러에서는 나눗셈이 수십 사이클이 걸릴 수 있습니다.

실제로 1960년대 컴퓨터에서는 나눗셈이 매우 비싼 연산이었습니다. 이에 Josef Stein이 1967년에 나눗셈 대신 비트 시프트와 뺄셈만 사용하는 GCD 알고리즘을 발표했습니다.

이것이 바로 Binary GCD 또는 Stein's 알고리즘입니다. 현대 컴퓨터에서는 나눗셈이 빨라졌지만, 여전히 특정 상황에서는 Binary GCD가 유리합니다.

특히 매우 큰 정수(수백 비트)를 다루는 암호학 라이브러리나, 하드웨어 제약이 있는 IoT 기기에서 사용됩니다. 비트 연산은 거의 모든 하드웨어에서 1 사이클에 실행되기 때문이죠.

개요

간단히 말해서, Binary GCD는 세 가지 성질을 반복 적용합니다: (1) gcd(2a, 2b) = 2·gcd(a, b), (2) gcd(2a, b) = gcd(a, b) (b가 홀수), (3) gcd(a, b) = gcd(a-b, b) (a > b이고 둘 다 홀수). 왜 이 방법이 유용할까요?

첫째, 나눗셈이나 나머지 연산을 전혀 사용하지 않습니다. 둘째, 비트 시프트(>>)는 하드웨어에서 극도로 빠릅니다.

셋째, 큰 정수에서도 안정적인 성능을 보입니다. 넷째, 병렬화가 쉬워서 GPU나 멀티코어에 적합합니다.

전통적인 유클리드 호제법과 비교해볼까요? 일반 버전은 나머지 연산(%)이 지배적인데, 이것은 하드웨어 나눗셈기를 사용합니다.

작은 수에서는 빠르지만 큰 수에서는 느려집니다. Binary GCD는 일정한 비트 연산만 사용하므로 예측 가능한 성능을 보입니다.

이 알고리즘의 핵심 특징은 네 가지입니다. 첫째, 시간 복잡도는 여전히 O(log n)입니다.

둘째, 공간 복잡도가 O(1)로 매우 효율적입니다. 셋째, 캐시 친화적이고 분기 예측이 잘 됩니다.

넷째, 큰 정수 라이브러리(GMP 등)에서 실제로 사용됩니다. 이러한 특징들이 특정 상황에서 Binary GCD를 선호하게 만드는 이유입니다.

코드 예제

# Binary GCD (Stein's 알고리즘) 구현
def binary_gcd(a, b):
    # 절댓값 처리
    a, b = abs(a), abs(b)

    # 예외 처리
    if a == 0:
        return b
    if b == 0:
        return a

    # 공통 2의 인수를 센다 (shift 횟수)
    # a와 b가 모두 짝수인 동안 반복
    shift = 0
    while ((a | b) & 1) == 0:  # 둘 다 짝수
        a >>= 1
        b >>= 1
        shift += 1

    # a를 홀수로 만들기
    while (a & 1) == 0:
        a >>= 1

    # 메인 루프
    while b != 0:
        # b를 홀수로 만들기
        while (b & 1) == 0:
            b >>= 1

        # a > b이면 교환 (a가 항상 작거나 같게)
        if a > b:
            a, b = b, a

        # 둘 다 홀수이므로 차이는 짝수
        b = b - a

    # 공통 2의 인수를 복원
    return a << shift

# 사용 예시 및 비교
test_cases = [(252, 105), (48, 18), (1071, 462)]
for a, b in test_cases:
    result = binary_gcd(a, b)
    print(f"binary_gcd({a}, {b}) = {result}")

설명

이것이 하는 일: 위 코드는 비트 연산만으로 최대공약수를 계산하는 Binary GCD 알고리즘을 구현합니다. 나눗셈 없이도 정확한 결과를 얻습니다.

첫 번째로, 예외 케이스를 처리합니다. 둘 중 하나가 0이면 다른 수가 GCD입니다.

이것은 모든 GCD 알고리즘에 공통적인 처리입니다. 절댓값을 취하는 것도 음수를 안전하게 다루기 위함이죠.

그 다음으로, 공통 2의 인수를 추출합니다. (a | b) & 1은 a 또는 b 중 하나라도 홀수면 1입니다.

이것이 0이라는 것은 둘 다 짝수라는 뜻이죠. 이때 둘 다 2로 나누고(>> 1) shift를 증가시킵니다.

예를 들어 gcd(48, 18)에서 둘 다 짝수이므로 gcd(24, 9)로 변환하고 shift = 1이 됩니다. 세 번째 단계에서는 a를 홀수로 만듭니다.

(a & 1) == 0은 a가 짝수라는 뜻이므로, 홀수가 될 때까지 계속 2로 나눕니다. 이제 a는 홀수가 보장됩니다.

이것이 메인 루프의 불변 조건입니다. 메인 루프에서는 b도 홀수로 만든 후, a와 b 중 큰 값에서 작은 값을 뺍니다.

두 홀수의 차이는 항상 짝수이므로, b = b - a 후 다시 루프를 돌면 b가 짝수로 시작합니다. 이 과정이 반복되면서 숫자가 점점 작아지고, 결국 b가 0이 되면 a가 GCD입니다.

마지막으로 << shift로 처음에 추출한 2의 인수를 복원합니다. 여러분이 이 코드를 사용하면 나눗셈이 비싼 환경에서 성능 향상을 얻을 수 있습니다.

특히 큰 정수 연산 라이브러리를 만들 때나, 임베디드 시스템에서 유용합니다. 또한 비트 연산 기법을 배우는 좋은 예제이기도 합니다.

실무에서는 GMP(GNU Multiple Precision) 같은 라이브러리가 이 알고리즘을 C로 최적화하여 제공합니다.

실전 팁

💡 & 1은 홀짝 판정, >> 1은 2로 나누기, << 1은 2 곱하기의 비트 연산 버전입니다. 나눗셈보다 10배 이상 빠릅니다.

💡 (a | b) & 1은 "둘 중 하나라도 홀수인가?"를 한 번에 체크합니다. (a & 1) or (b & 1)보다 효율적이죠.

💡 벤치마크 결과 작은 수(<10^6)에서는 일반 유클리드 호제법이 빠르지만, 큰 수(>10^12)에서는 Binary GCD가 우세합니다.

💡 재귀 버전도 구현할 수 있지만, 반복문 버전이 메모리 효율이 좋습니다. 임베디드에서는 스택을 아끼는 것이 중요합니다.

💡 현대 CPU의 나눗셈 성능이 좋아져서, 64비트 이하에서는 일반 유클리드 호제법을 쓰는 것이 좋습니다. Binary GCD는 큰 정수 전용으로 생각하세요.


10. 실전 문제 풀이 - 경쟁 프로그래밍 예제

시작하며

여러분이 알고리즘 문제를 풀다가 "최대공약수를 이용하면 될 것 같은데, 어떻게 적용하지?"라고 고민한 적 있나요? 유클리드 호제법은 경쟁 프로그래밍과 코딩 인터뷰에서 매우 자주 등장합니다.

실제로 백준, 코드포스, 리트코드 같은 플랫폼에서 GCD 관련 문제는 수백 개가 있습니다. 단순히 GCD를 구하는 것뿐 아니라, 수학적 성질을 이용한 최적화, 조합론 문제, 그리고 복잡한 알고리즘의 서브루틴으로 사용되죠.

문제 해결 능력을 키우는 데 필수적입니다. 바로 이런 상황에서 유클리드 호제법의 응용력이 빛을 발합니다.

여기서는 실전 문제 패턴을 배우고, 어떻게 GCD를 창의적으로 활용하는지 익혀봅시다. 이런 경험이 쌓이면 새로운 문제에서도 GCD를 사용할 타이밍을 직관적으로 알 수 있습니다.

개요

간단히 말해서, GCD를 활용하는 대표적인 문제 패턴은 다음과 같습니다: (1) 배열의 모든 원소의 GCD 구하기, (2) 격자 위의 최단 경로 개수, (3) 분수 배열 정규화, (4) 순환 소수 주기 찾기. 왜 이런 패턴을 알아야 할까요?

첫째, 문제를 보자마자 "아, 이건 GCD 문제네"라고 인식할 수 있습니다. 둘째, 시간 복잡도를 획기적으로 줄일 수 있습니다.

예를 들어 O(n²)을 O(n log n)으로 만들 수 있죠. 셋째, 코딩 인터뷰에서 수학적 센스를 보여줄 수 있습니다.

실전 문제와 교과서 문제의 차이는 무엇일까요? 교과서는 "두 수의 GCD를 구하시오"처럼 직접적입니다.

하지만 실전은 "좌표 (0, 0)에서 (a, b)로 가는 격자 경로에서 지나는 격자점 개수는?"처럼 GCD가 숨겨져 있습니다. 답은 gcd(a, b) + 1인데, 이걸 알아채는 것이 핵심입니다.

이 응용의 핵심 특징은 세 가지입니다. 첫째, 문제의 본질을 수학적으로 모델링하는 능력이 필요합니다.

둘째, GCD의 성질(결합법칙, 분배법칙 등)을 활용합니다. 셋째, 최적화 아이디어가 숨어있는 경우가 많습니다.

이러한 패턴들을 익히면 문제 해결력이 크게 향상됩니다.

코드 예제

# 실전 문제 1: 배열의 모든 원소의 GCD
def gcd(a, b):
    while b != 0:
        a, b = b, a % b
    return a

def array_gcd(arr):
    """배열의 모든 원소의 GCD를 계산"""
    from functools import reduce
    return reduce(gcd, arr)

# 실전 문제 2: 격자 경로의 격자점 개수
def lattice_points(a, b):
    """(0,0)에서 (a,b)로 가는 직선이 지나는 격자점 개수"""
    # 공식: gcd(a, b) + 1
    # 이유: 직선을 gcd(a, b)개의 동일한 선분으로 나눌 수 있음
    return gcd(abs(a), abs(b)) + 1

# 실전 문제 3: 분수 배열에서 서로소 쌍 찾기
def count_coprime_pairs(arr):
    """배열에서 gcd가 1인 쌍의 개수"""
    count = 0
    n = len(arr)
    for i in range(n):
        for j in range(i + 1, n):
            if gcd(arr[i], arr[j]) == 1:
                count += 1
    return count

# 테스트
print(f"배열 GCD: {array_gcd([12, 18, 24])}")          # 6
print(f"격자점: {lattice_points(4, 6)}")                # 3
print(f"서로소 쌍: {count_coprime_pairs([2, 3, 4, 5])}") # 4

# 최적화 팁: 큰 배열에서는 소인수분해 활용
def optimized_array_gcd(arr):
    """최적화된 배열 GCD (조기 종료)"""
    result = arr[0]
    for num in arr[1:]:
        result = gcd(result, num)
        if result == 1:  # 더 이상 줄어들지 않음
            break
    return result

설명

이것이 하는 일: 위 코드는 경쟁 프로그래밍에서 자주 나오는 세 가지 GCD 응용 패턴을 구현합니다. 각각 다른 수학적 통찰력을 필요로 합니다.

첫 번째 array_gcd() 함수는 Python의 reduce()를 활용합니다. reduce는 함수를 누적 적용하는데, gcd(gcd(a, b), c) = gcd(a, b, c)라는 성질 덕분에 정확하게 작동합니다.

예를 들어 [12, 18, 24]에서 gcd(12, 18) = 6, gcd(6, 24) = 6을 순서대로 계산합니다. 배열의 길이가 n이면 O(n log max(arr))의 시간이 걸립니다.

두 번째 lattice_points() 함수는 수학적 통찰이 필요한 문제입니다. (0, 0)에서 (a, b)로 직선을 그으면, 이 직선이 지나는 격자점의 개수는 gcd(a, b) + 1입니다.

왜 그럴까요? 벡터 (a, b)를 gcd(a, b)로 나누면 기약 벡터 (a', b')를 얻는데, 이 벡터로 격자를 gcd(a, b)번 반복하면 (a, b)에 도달합니다.

각 단계마다 하나씩 격자점을 지나므로 총 gcd + 1개입니다. 세 번째 count_coprime_pairs() 함수는 브루트포스 접근입니다.

모든 쌍을 확인하므로 O(n²)입니다. 작은 배열에서는 괜찮지만, n이 크면 최적화가 필요합니다.

고급 기법으로는 뫼비우스 역변환이나 포함-배제 원리를 사용할 수 있지만, 이는 고급 주제입니다. 마지막 optimized_array_gcd()는 조기 종료 최적화를 보여줍니다.

GCD가 1이 되면 더 이상 작아질 수 없으므로 즉시 종료합니다. 예를 들어 [10, 15, 7, 21]에서 gcd(10, 15) = 5, gcd(5, 7) = 1이므로 21은 확인할 필요가 없습니다.

이런 작은 최적화가 큰 배열에서 차이를 만듭니다. 여러분이 이 코드를 익히면 코딩 인터뷰나 알고리즘 대회에서 GCD 문제를 빠르게 해결할 수 있습니다.

특히 "이 문제는 GCD로 풀 수 있는가?"를 판단하는 능력이 생깁니다. 실전 팁은 항상 작은 예제로 손으로 풀어보고, 패턴을 찾은 후 수학적으로 증명하는 것입니다.

실전 팁

💡 격자 경로 문제는 피크의 정리, 오일러의 정리와도 연결됩니다. gcd(a, b) - 1이 내부 격자점 개수인 경우도 있으니 문제를 잘 읽으세요.

💡 배열의 GCD가 1이면 "서로소 배열"이라고 합니다. 이런 배열은 선형 결합으로 모든 정수를 만들 수 있습니다(베주 항등식의 일반화).

💡 최대 공약수 대신 최소 공배수를 구하는 문제도 많습니다. LCM 배열은 오버플로우 주의! 로그를 취하거나 큰 정수 라이브러리를 쓰세요.

💡 "구간 GCD 쿼리" 문제는 세그먼트 트리나 희소 테이블(Sparse Table)을 사용합니다. GCD는 결합법칙이 성립하므로 이런 자료구조에 적합합니다.

💡 코딩 인터뷰에서는 GCD의 시간 복잡도를 물어볼 수 있습니다. "O(log min(a, b))"라고 답하고, 피보나치 수가 최악의 경우임을 언급하면 좋은 인상을 줍니다.


#Algorithm#GCD#Math#EuclideanAlgorithm#NumberTheory#기본

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.