Exercise 04 · 모듈러 거듭제곱과 최소 변환
Exercise 04의 개념, 문제 조건, 알고리즘, 정답 코드와 실행 흐름을 정리합니다.
Exercise 02 · Exercise 03 · Exercise 04 · Exercise 05
출처: CPP Programming Exercise 04.pdf. 정답은 Solution 04 PDF의 코드를 사용하고 해설도 해당 구현에 맞췄습니다. 정답 코드는 접어서 확인할 수 있습니다.
핵심 개념
1. 함수의 구성과 값 전달
int addOne(int value) { // int: 반환형, value: 매개변수
return value + 1;
}
// addOne(5)에서 5는 인자이며, 반환값은 6입니다.| 개념 | 동작 |
|---|---|
| 값 전달 | 복사본 사용. 함수 안에서 b를 바꿔도 호출자의 변수는 유지 |
void 함수 | 반환값이 없으며 return;으로 종료 가능 |
| 함수 선언 | 정의가 호출보다 뒤에 있다면 호출 전에 선언 필요 |
2. 재귀가 끝나려면
재귀에는 다음 두 조건이 함께 필요합니다.
- 기저 조건: 더 호출하지 않고 종료하는 경우
- 입력 변화: 호출할수록 기저 조건에 가까워지는 변화
거듭제곱의 지수: 10 → 5 → 2 → 1 → 0
기저 조건에서 반환3. 이름의 범위와 변수 관련 키워드
블록 안의 이름은 해당 범위에서 사용합니다. 내부에 같은 이름을 선언하면 외부 이름이 가려질 수 있습니다.
| 문법 | 기억할 의미 |
|---|---|
지역 static | 호출 사이에도 값 유지 |
extern | 다른 곳의 정의를 참조하는 선언 |
auto | 초기값으로 자료형 추론 |
const | 초기화 후 변경 제한 |
| 네임스페이스 | 같은 이름의 충돌을 구분. std::가 대표적인 예 |
4. 수학 함수와 정확한 정수 계산
<cmath>에는 ceil, floor, fabs, sqrt, log 등이 있습니다.
| 예시 | 결과 | 방향 |
|---|---|---|
ceil(-2.3) | -2 | 입력 이상인 가장 작은 정수 값 |
floor(-2.3) | -3 | 입력 이하인 가장 큰 정수 값 |
Exercise 04
Quiz 1 - 모듈러 거듭제곱
난이도: 상
요구사항
- a의 b제곱을 m으로 나눈 나머지를 구합니다.
- 큰 거듭제곱 자체가 아니라 나머지를 정확한 정수로 출력합니다. 아래 공식 풀이는 분할 정복을 사용합니다.
- 0의 0제곱은 1로 처리합니다.
입력
한 줄에 정수 a, b, m이 주어집니다. a와 b는 0 이상 1,000,000,000 이하이고, m은 2 이상 1,000,000,000 이하입니다.
출력
a의 b제곱을 m으로 나눈 나머지를 출력합니다.
입력 예시
2 10 1000출력 예시
24알고리즘: 절반 거듭제곱을 한 번 계산하기
P(b) = a^b % m이라고 두면 P(0) = 1입니다. 양의 지수에서 half = P(b / 2)를 한 번 계산합니다. 짝수 지수 b = 2k이면 a^b = (a^k)², 홀수 지수 b = 2k + 1이면 a^b = (a^k)² × a이므로 half를 제곱하고 홀수일 때만 a를 한 번 더 곱합니다.
매번 곱한 직후 % m을 적용합니다. (x × y) % m = ((x % m) × (y % m)) % m이므로 중간에 나머지만 저장해도 최종 답은 같습니다. x = qm + r, y = pm + s로 놓고 곱하면 rs를 제외한 모든 항에 m이 포함되어 나머지가 0이 됩니다.
정답 코드 · Solution 04
출처: CPP Programming Exercise Solution 04.pdf, PDF 3쪽(슬라이드 23). 제공된 재귀 구현을 사용합니다.
정답 코드 보기정답 코드 숨기기
#include <iostream>
long long powerMod(long long a, long long b,
long long m) {
if (b == 0) {
return 1;
}
long long half = powerMod(a, b / 2, m);
long long result = half * half % m;
if (b % 2 != 0) {
result = result * (a % m) % m;
}
return result;
}
int main() {
long long a = 0, b = 0, m = 0;
std::cin >> a >> b >> m;
std::cout << powerMod(a, b, m) << '\n';
return 0;
}코드 해설과 재귀 호출 추적
b == 0이면 즉시 1을 반환합니다. 문제의0^0 = 1조건도 여기서 처리합니다.powerMod(a, b / 2, m)가 끝날 때까지 현재 호출은 기다립니다. 각 호출의b,half,result는 별개의 지역 변수입니다.- 반환된 half를 두 번 곱한 뒤 나머지를 구합니다. 같은 함수를 두 번 호출하지 않고 결과를 재사용하는 것이 중요합니다.
- b가 홀수라면
a % m을 추가로 곱하고 다시 나머지를 구합니다. - 계산된 result를 바로 위 호출에 돌려줍니다. 마지막에는 main이 받아 출력합니다.
2 10 1000의 호출은 지수 10 → 5 → 2 → 1 → 0으로 내려간 뒤 거꾸로 돌아옵니다.
| 반환 중인 지수 b | half | 제곱의 나머지 | 홀수 보정 | 반환값 |
|---|---|---|---|---|
| 0 | 해당 없음 | 해당 없음 | 기저 조건 | 1 |
| 1 | 1 | 1 | 1 × 2 % 1000 | 2 |
| 2 | 2 | 4 | 없음 | 4 |
| 5 | 4 | 16 | 16 × 2 % 1000 | 32 |
| 10 | 32 | 24 | 없음 | 24 |
3 13 1000은 323, 0 0 7은 1을 출력합니다. a가 0이고 b가 양수이면 답은 0입니다.
자료형, 복잡도, 실수하기 쉬운 점
두 나머지는 각각 m보다 작으므로 곱은 10^18 미만입니다. long long은 이를 담을 수 있지만 보통의 32비트 int는 담지 못합니다. % m은 곱셈 이후 실행되므로 int로 곱한 뒤 long long에 저장하는 방식은 안전하지 않습니다.
지수가 매번 절반이 되어 재귀 단계는 로그 개수입니다. 시간과 호출 스택 공간은 모두 **O(1 + log(b + 1))**입니다. b가 0인 경우에도 기저 조건을 확인하는 상수 비용이 있습니다. 입력 범위에서는 최대 31개의 호출이 쌓입니다.
powerMod(a, b / 2, m)를 두 번 호출하면 같은 하위 문제를 반복 계산합니다. 지수를 1씩 줄이거나 a를 b번 곱하는 방법은 큰 입력에서 너무 느립니다. ^는 C++에서 거듭제곱 연산자가 아니라 비트 XOR입니다.
Quiz 2 - 최소 변환 횟수
난이도: 최상
요구사항
- 양의 정수 a에서 시작하여 b에 도달합니다.
- 한 번에 현재 값을 두 배로 만들거나 1을 뺄 수 있습니다.
- 계산 도중 값은 항상 양수여야 합니다.
- 필요한 연산 횟수의 최솟값을 구합니다.
입력
한 줄에 1 이상 1,000,000,000 이하의 두 정수 a, b가 주어집니다.
출력
a를 b로 바꾸는 최소 연산 횟수를 출력합니다.
입력 예시
4 7출력 예시
2접근 방법
- a가 b보다 크거나 같으면 값을 늘리는 두 배 연산은 도움이 되지 않습니다. 1을 a-b번 빼는 것이 최적입니다.
- b가 더 크면 정방향에서 두 선택을 비교하기보다 b에서 a로 거꾸로 이동합니다.
- 정방향의 두 배 연산은 역방향에서 짝수를 2로 나누는 연산이 됩니다.
- 정방향의 1 빼기 연산은 역방향에서 1 더하기 연산이 됩니다.
- 현재 b가 홀수라면 어떤 정수의 두 배일 수 없으므로 먼저 1을 더해야 합니다.
- 현재 b가 짝수라면 2로 나누어 값을 가장 빠르게 줄입니다.
- 역방향 값이 a 이하가 되면, 남은 차이만큼 정방향에서 1을 빼면 됩니다.
- 입력과 역연산 중의 값은 10억 이하입니다. 연산 횟수도
a - 1 + 60이하라는 충분한 상한을 잡을 수 있어, 일반적인 32비트 int 범위 안에서 계산할 수 있습니다.
정답 코드 · Solution 04
출처: CPP Programming Exercise Solution 04.pdf, PDF 6쪽(슬라이드 26). 제공된 minOperations 함수를 사용합니다.
정답 코드 보기정답 코드 숨기기
#include <iostream>
long long minOperations(long long a, long long b) {
long long steps = 0;
while (b > a) {
if (b % 2 != 0) {
++b;
} else {
b /= 2;
}
++steps;
}
return steps + a - b;
}
int main() {
long long a = 0, b = 0;
std::cin >> a >> b;
std::cout << minOperations(a, b) << '\n';
return 0;
}코드 해설: 목표에서 거꾸로 돌아오기
정방향에서 무작정 두 배와 1 빼기를 모두 시도하면 후보 경로가 많아집니다. 목표 b에서 시작해 연산을 거꾸로 생각하면 선택이 단순해집니다.
| 정방향에서 허용되는 연산 | 역방향에서 대응하는 연산 |
|---|---|
x → 2x | 짝수 y → y / 2 |
x → x - 1 | y → y + 1 |
코드에서 a는 처음 출발점으로 유지하고, b는 역방향으로 움직이는 현재 값으로 사용합니다. steps에는 지금까지 수행한 역연산 수를 셉니다. 경로를 뒤집어도 연산 개수는 같습니다.
b > a일 때만 반복합니다. 아직 목표에서 출발점까지 내려와야 하는 상태입니다.b가 홀수이면 2로 나눌 수 없으므로 1을 더합니다.b가 짝수이면 2로 나눕니다.- 어느 연산이든 한 번 수행했으므로
steps를 1 늘립니다. b <= a가 되면 역방향에서a - b번 1을 더하면 도착합니다.return steps + a - b로 반복문 없이 합산해 반환합니다.
왜 짝수일 때 바로 나누는 것이 최적인가요?
그냥 “빨리 작아진다”는 이유만으로 최소 횟수를 보장할 수는 없습니다. 다른 선택이 더 나을 수 없는지 비교해야 합니다.
홀수인 경우: 역방향에서 허용되는 연산 중 2로 나누기는 불가능합니다. 따라서 +1이 반드시 필요합니다. 정수 나눗셈으로 홀수를 억지로 버림 처리하면 원래 허용된 연산을 거꾸로 한 것이 아닙니다.
짝수 y > a인 경우: 지금 나누지 않고 +1만 한다면 더 큰 값으로 가므로 a에 도착할 수 없습니다. 결국 언젠가는 나누어야 합니다. 첫 나눗셈 직전까지 더한 횟수는 짝수여야 하므로 이를 2k번이라고 해 봅시다.
나눗셈을 미룬 경로:
y → (+1을 2k번) → y + 2k → (/2) → y/2 + k
필요한 연산: 2k + 1번
먼저 나눈 경로:
y → (/2) → y/2 → (+1을 k번) → y/2 + k
필요한 연산: k + 1번같은 값에 도달하는데 먼저 나누면 연산을 더 많이 쓰지 않습니다. k > 0이면 오히려 k번 적습니다. 따라서 나눗셈을 먼저 하는 최적 경로가 항상 존재하며, 짝수에서 바로 나누는 선택이 정당합니다. 중간에 a에 먼저 도착한다면 거기서 종료하면 되므로 더 유리합니다.
왜 b <= a이면 차이만 더하나요?
역방향에서 작은 b를 큰 a로 올려야 할 때, 값을 늘리는 방법은 +1뿐입니다. /2는 값을 더 작게 만들어 필요한 증가량만 늘립니다. 따라서 남은 최소 횟수는 정확히 a - b입니다.
정방향으로 보면 a에서 그 작은 값까지 1씩 빼는 구간입니다. 경로상의 값은 모두 1 이상이므로 양수 조건도 지킵니다.
실제 입력 4 7 추적
| 단계 | 역방향의 현재 b | 선택 | 연산 후 b | 누적 횟수 |
|---|---|---|---|---|
| 1 | 7 | 홀수이므로 +1 | 8 | 1 |
| 2 | 8 | 짝수이므로 /2 | 4 | 2 |
| 종료 | 4 | b > a가 거짓 | 남은 차이 4 - 4 = 0 | 2 |
역방향은 7 → 8 → 4입니다. 이를 뒤집으면 실제로 실행할 정방향 경로 4 → 8 → 7이 됩니다. 두 배 한 번, 1 빼기 한 번으로 총 2번입니다.
추가 입력 3 10: 홀수와 짝수가 번갈아 나오는 경우
역방향: 10 → 5 → 6 → 3
연산: /2 +1 /2
누적 횟수: 1 2 3
정방향: 3 → 6 → 5 → 10
연산: ×2 -1 ×2출력은 3입니다. 여기서 초기 a - b = -7은 연산 횟수의 상한이 될 수 없습니다. 차이는 반복이 끝난 뒤 b <= a인 상태에서만 남은 횟수로 사용합니다.
추가 입력 5 8: 시작값 아래로 내려간 경우
역방향 첫 연산: 8 → 4 steps = 1
이제 4 <= 5이므로 반복 종료
남은 증가: 4 → 5 a - b = 1
전체 횟수: 1 + 1 = 2
정방향으로 복원: 5 → 4 → 8처음부터 a >= b라면 반복문 없이 a - b를 출력합니다. 예를 들어 10 3은 7번, 5 5는 0번입니다.
자료형과 반복 횟수 확인
공식 코드는 입력과 횟수를 모두 long long으로 선언합니다. 이 범위는 32비트 int로도 계산 가능하지만 정답 코드는 제공된 자료형을 그대로 유지합니다.
입력 상한 10^9은 짝수입니다. 역방향에서 1을 더하는 값은 홀수일 때뿐이므로, 가장 큰 경우에도 999999999 + 1 = 1000000000입니다. 나눗셈은 값을 줄이므로 현재 값은 입력 상한을 넘지 않습니다.
홀수라면 +1 후 다음 단계에서 /2가 가능합니다. 즉 최대 두 연산마다 값을 절반 수준으로 줄이고, 이런 단계를 최대 30번 거치면 1에 도달합니다. 실제로는 b <= a가 되는 순간 더 일찍 멈춥니다. 반복 횟수에 대한 충분한 상한은 60이고, 마지막 차이는 양수인 b 때문에 a - b <= a - 1입니다.
따라서 전체 횟수는 60 + a - 1 <= 1000000059로 제한할 수 있습니다. 이는 정확한 최대값을 주장하는 식이 아니라 32비트 int에 안전하게 들어간다는 것을 보이는 여유 있는 상한입니다.
복잡도
- 시간 복잡도:
a < b이면 O(log b),a >= b이면 O(1)입니다. 남은 차이는 반복하지 않고 한 번에 더합니다. - 공간 복잡도: O(1)
내용을 이해했다면 학습 완료를 체크해 주세요.