정수론은 별도의 주제로 등장하기보다 다른 문제를 풀다가 중간에 필요해집니다.
나머지를 반복해 최대공약수를 찾습니다
두 수의 최대공약수(greatest common divisor) 를 구할 때 약수를 나열해 비교할 이유가 없습니다.
유클리드 호제법(Euclidean algorithm) 은 a와 b의 최대공약수가 b와 a를 b로 나눈 나머지의 최대공약수와 같다는 성질을 씁니다. 나머지는 b보다 작으므로 반복하면 빠르게 줄어들고, 나머지가 0이 되는 순간의 값이 답입니다.
function gcd(a, b) {
while (b !== 0) {
[a, b] = [b, a % b];
}
return a;
}
줄어드는 속도가 빠릅니다. 두 단계마다 값이 최소 절반 이하로 떨어져 O(log(min(a, b)))입니다.
최소공배수(least common multiple) 는 두 수의 곱을 최대공약수로 나누면 바로 나옵니다.
function lcm(a, b) {
return (a / gcd(a, b)) * b; // 먼저 나눠서 중간값이 커지는 것을 막는다
}
곱한 뒤 나누지 않고 먼저 나누는 이유는 a * b가 안전한 정수 범위를 넘으면 값이 틀어지기 때문입니다. 이 문제는 아래에서 다시 봅니다.
화면 비율을 줄일 때 이 계산을 씁니다. 1920과 1080의 최대공약수가 120이므로 양쪽을 나누면 16 대 9가 나옵니다. 서로 다른 주기로 도는 작업 두 개가 언제 다시 겹치는지 알아야 할 때는 최소공배수를 씁니다.
배수를 지워 소수만 남깁니다
특정 수 하나가 소수인지 확인하는 건 제곱근까지만 나눠보면 됩니다. N보다 작은 모든 소수를 구해야 한다면 이 방법을 N번 반복하는 대신 다른 접근이 낫습니다.
에라토스테네스의 체(sieve of Eratosthenes) 는 남은 수 중 가장 작은 것을 소수로 확정하고 그 배수를 전부 지웁니다.
function sieve(n) {
const isPrime = new Array(n + 1).fill(true);
isPrime[0] = isPrime[1] = false;
for (let p = 2; p * p <= n; p++) {
if (!isPrime[p]) continue;
// p보다 작은 배수는 더 작은 소수가 이미 지웠다
for (let multiple = p * p; multiple <= n; multiple += p) {
isPrime[multiple] = false;
}
}
const primes = [];
for (let i = 2; i <= n; i++) if (isPrime[i]) primes.push(i);
return primes;
}
두 가지 최적화가 들어 있습니다. 바깥 반복문이 제곱근까지만 도는 이유는, N 이하의 합성수라면 제곱근 이하의 약수를 반드시 갖기 때문입니다. 안쪽에서 p * p부터 시작하는 이유는 그보다 작은 배수는 더 작은 소수가 처리했기 때문입니다.
전체 복잡도는 O(N log log N)입니다. N이 백만이어도 log log N은 한 자리 수라 수백만 번 안팎으로, 사실상 선형에 가깝습니다.
미리 구해둔 소수 목록은 해시 테이블의 버킷 수를 정할 때 씁니다. 버킷 수를 소수로 두면 키가 특정 간격으로 몰려 있어도 나머지가 고르게 퍼집니다.
나머지는 계산 도중에 취해도 됩니다
큰 수를 다루는 문제에서 답을 특정 값으로 나눈 나머지로 요구하는 경우가 흔합니다. 최종 결과를 계산한 뒤 나누는 것이 아니라 계산 도중에 계속 나머지를 취해야 합니다. 그렇게 해도 되는 근거가 모듈러 연산(modular arithmetic) 의 성질입니다.
덧셈, 뺄셈, 곱셈은 중간에 나머지를 취해도 결과가 같습니다.
(a + b) % m === ((a % m) + (b % m)) % m(a * b) % m === ((a % m) * (b % m)) % m
나눗셈은 성립하지 않습니다. (a / b) % m을 그대로 쪼갤 수 없습니다. 모듈러 곱셈 역원이라는 별도의 개념이 필요합니다.
뺄셈에는 주의점이 하나 더 있습니다. 자바스크립트의 %는 음수에 대해 음수를 반환합니다. 나머지를 항상 양수로 유지하려면 한 번 더 처리해야 합니다.
const MOD = 1_000_000_007;
function subMod(a, b) {
return ((a - b) % MOD + MOD) % MOD; // 음수 방지
}
나머지 연산은 큰 수를 다루는 문제 밖에서도 자주 씁니다. 데이터를 여러 서버에 나눌 때 키의 해시를 서버 수로 나눈 나머지로 목적지를 정하고, 순환 버퍼는 인덱스를 크기로 나눈 나머지로 자리를 찾습니다.
정밀도가 깨지는 경계
자바스크립트의 Number는 배정밀도 부동소수점입니다. 정수를 정확히 표현할 수 있는 범위가 Number.MAX_SAFE_INTEGER, 즉 2의 53승에서 1을 뺀 값까지입니다.
이 범위를 넘으면 오류 없이 조용히 틀린 값이 나옵니다.
Number.MAX_SAFE_INTEGER; // 9007199254740991
Number.MAX_SAFE_INTEGER + 1; // 9007199254740992
Number.MAX_SAFE_INTEGER + 2; // 9007199254740992 (같은 값)
앞에서 최소공배수를 구할 때 먼저 나눈 이유가 여기에 있습니다. 두 수가 각각 10의 9승 정도만 되어도 곱하면 안전 범위를 넘어갑니다.
넘어갈 가능성이 있으면 BigInt를 써야 합니다.
const big = 9007199254740993n; // 접미사 n
big + 2n; // 9007199254740995n
BigInt는 임의 정밀도라 자릿수 제한이 없습니다. 대신 일반 숫자와 직접 섞어 연산할 수 없고, 연산 속도도 느립니다. 계산 규모를 먼저 어림해보고 필요할 때만 쓰는 것이 맞습니다.
분산 시스템에서 만든 64비트 ID가 이 경계에 걸립니다. 서버가 내려준 ID를 자바스크립트에서 숫자로 받으면 끝자리가 달라질 수 있으므로, 문자열이나 BigInt로 받아야 합니다.
여기서도 판단 순서는 자료구조 시리즈 ep.01 크기가 접근법을 정한다와 같습니다. 값의 범위를 먼저 계산하고, 그 결과로 도구를 정합니다.
두 시리즈를 마치며
「이름과 비용」은 이미 쓰고 있는 것들을 다시 확인하는 작업이었습니다.
자료구조 시리즈에서는 데이터를 담는 구조가 어떤 연산을 싸게 만들고 어떤 연산을 비싸게 만드는지 봤습니다. 배열의 연속성, 해시의 계산된 위치, 트리의 높이, 전처리와 갱신의 거래가 그 내용이었습니다.
알고리즘 시리즈에서는 그 위에서 무엇을 어떻게 찾을지 다뤘습니다. 완전탐색을 기준선에 두고, 구조와 단조성과 국소 최적과 재사용이라는 네 가지 근거로 탐색 공간을 줄이는 방법들을 정리했습니다.
전체를 관통하는 질문은 이 선택의 대가가 무엇인가였습니다. 자료구조를 고르는 일도, 알고리즘을 고르는 일도 결국 무엇을 싸게 만들고 무엇을 비싸게 둘 것인지를 정하는 일입니다.
이름을 알면 찾아볼 수 있고, 비용을 알면 고를 수 있습니다.
참고 자료
- Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press.
- Knuth, D. E. (1997). The Art of Computer Programming, Volume 2: Seminumerical Algorithms (3rd ed.). Addison-Wesley.
- MDN Web Docs. BigInt. Mozilla. https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/BigInt
- MDN Web Docs. Number.MAX_SAFE_INTEGER. Mozilla. https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER