1. 실수 자료형의 이해
실수 연산의 어려움
아래 코드는 [1, n] 범위의 모든 자연수 중 1/x * x = 1 인 x의 수를 세는 countObvious()의 구현을 보여줍니다. 누가 보더라도 이 식은 항상 성립해야 하니, countObvious(n)을 호출하면 당연히 n이 반환되어야 할 것입니다. 그런데 이 함수를 직접 실행해보면 결과는 그렇지 않다는 것을 알게 됩니다. 대표적으로 제 컴퓨터에서는 countObvious(50) = 48 라고 나옵니다. 설마 우리 프로그램에 버그가 있는 것일까요? 아니면 CPU에 문제가 있는 것일까요? 이 의문을 해결하려면 컴퓨터가 사용하는 실수 표현 방식에 대해 알아야 합니다.
// 잘못된 실수 연산의 예제
int coundObvious(int n) {
int same = 0;
for(int x = 1; x <= n; ++x) {
double y = 1.0 / x;
if (y * x == 1.0)
++same;
}
return same;
}
실수와 근사값
우리가 일상적으로 다루는 정수들은 컴퓨터가 모두 정확하게 표현할 수 있습니다. 반면 실수에서는 이야기가 다릅니다. 모든 것이 셀 수 있고 단위가 정해져 있는 정수들과는 달리 실수를 다루게 되면 일상 생활에서도 무한의 세계로 날아가 버리게 됩니다. 컴퓨터의 메모리는 항상 유한하고, 이 모든 값들을 모두 정확하게 담을 수는 없으니 어쩔 수 없이 적절히 비슷한 값을 사용하는 것으로 만족해야 합니다. 이 뒤에서 자세히 설명하겟지만, 컴퓨터의 모든 실수 변수는 정확도가 제한된 근사 값을 저장합니다. 근사 값으로 연산한 결과는 수학적으로 정확하지 않을 수 있기 때문에 실수는 훨씬 다루기 까다롭습니다.
IEEE 754 표준
가장 많은 컴퓨터/컴파일러들에서 사용하는 실수 표기 방식은 IEEE 754 표준이라고 불립니다. IEEE754의 가장 큰 특징 몇 가지는 다음과 같습니다.
- 이진수로 실수를 표기
- 부동 소수점(floating-point) 표기법
- 무한대, 비정규 수, Nan 등의 특수한 값이 존재
부동 소수점 표기
32비트의 공간을 이용해 실수를 이진법으로 표기한다고 합시다. 이때 고민되는 것은 사용 가능한 비트들을 정수부와 소수부에 어떻게 배분할 것인지 입니다. 32비트 변수 중 첫 16비트는 정수부, 뒤 16비트는 소수부로 쓰는 식으로 실수를 표기할 수 있습니다. 이 방식은 이해하기 쉽다는 장점이 있지만, 모두를 만족시킬 수 있는 분할이 존재하지 않는다는 문제가 있습니다. 정수부에 너무 많은 비트를 사용해 버리면 소수부의 정확도가 떨어집니다. 그렇다고 소수부에 너무 많은 비트를 사용해 버리면 소수부의 정확도가 떨어집니다. 그렇다고 소수부에 너무 많은 비트를 배정하면 큰 수를 표현할 수 없겠지요. 이 문제를 해결하기 위해 IEEE754 를 포함한 대부분의 실수 표준에서는 소수점을 옮길 수 있도록 했습니다. 어떤 형태의 숫자건 소수점을 적절히 옮겨서 소수점 위에 한 자리만 남도록 한 뒤, 최상위 비트에서부터 표현할 수 있는 만큼 표시하고 나머지는 반올림 처리하는 것이죠. 소수점을 몇 칸이나 옮겼는지를 기록해두면 여기서부터 원래 값을 쉽게 재구성해 낼 수 있습니다.
예를 들어 11.625는 이진법으로 쓰면 1011.101이 됩니다. 이때 소수점을 왼쪽으로 세칸 옮기면 1.011101이 되고, 이 수를 맨 앞에서부터 저장 공간이 허락하는 만큼 저장하는 것입니다. 그런데 이진법에서 소수점 위에 있을 수 있는 유일한 숫자는 1입니다. 따라서 IEEE754에서는 1을 제외한 나머지를 저장하는 방식으로 1비트를 절약합니다. 예를 들어 5비트만을 저장할 수 있다면 우리는 최상위 비트를 제외한 다음 5비튼인 01110을 저장하게 되지요. 이때 실수 변수는 다음과 같은 3가지의 정보를 저장하게 됩니다.
- 부호 비트(sign bit) : 양수인지 음수인지 여부
- 지수(exponent) : 소수점을 몇 칸 옮겼나?
- 가수(mantissa) : 소수점을 옮긴 실수의 최상위 X 비트
IEEE754를 만든 사람들은 실수형에서 지수보다 가수에 훨씬 많은 비트 수를 부여하기로 결정했습니다. 지수는 소수점을 움직이는 횟수이기 때문에, 지수가 상대적으로 작더라도 실생활에서 사용하는 거의 모든 숫자들을 표현할 수 있습니다.
실수 비교하기
컴퓨터가 실수를 근사적으로 표현한다는 사실을 알고 나면 처음에 예로 들었던 소스 코드에서 왜 원하는 답이 나오지 않았는지를 이해할 수 있습니다. 이진법으로 표현할 수 있는 형태의 실수는 정확한 값이 아니라 근사 값으로 저장되는데, 이때 생기는 작은 오차가 계산 과정에서 다른 결과를 가져오는 것이죠
countObvious() 가 1/10 * 3 과 3/10 이 같은지 비교한다고 합시다. 1/10과 3/10은 둘 다 실수 변수에 정확하게 담을 수 없으며 표현할 수 있는 가장 가까운 실수로 근사해 표현하게 됩니다. 그런데 IEEE754에서 3/10 을 담은 실수 변수는 참 값보다 작은 값, 1/10을 담은 실수 변수는 참 값보다 약간 큰 값으로 근사됩니다. 참 값보다 크게 표현된 1/10에 3을 곱한 결과는 3/10 보다 약간 커집니다. 결과적으로 두 근사 값은 모두 참 값인 3/10과 굉장히 가깝지만, 하나는 그보다 크고 하나는 작으며 따라서 비교가 실패하게 됩니다. 두 실수 값이 같은지를 비교할 때는 항상 어느 정도의 오차를 염두에 두어야 합니다. 구체적으로는 두 값의 차이가 매우 작은 경우 두 값이 같다고 판단해야 합니다. 두 수가 같은지 비교하는 함수를 다음과 같이 구현할 수 있습니다.
bool absoluteEqual(double a, double b) {
return fabs(a - b) < 1e-10;
}
두 값의 오차가 1/(10의 10승) 보다 작은지를 확인합니다.
'AlgorithmBook > 알고리즘 문제해결 전략 1' 카테고리의 다른 글
[03 알고리즘 설계 패러다임] 6. 무식하게 풀기 (0) | 2025.02.07 |
---|---|
[02 알고리즘 분석] 5. 알고리즘의 정당성 증명 (0) | 2025.02.02 |
[02 알고리즘 분석] 4. 알고리즘의 시간 복잡도 분석 (0) | 2025.01.28 |
[01 문제 해결 시작하기] 2. 문제 해결 개관 (0) | 2025.01.28 |
[01 문제 해결 시작하기] 1. 문제 해결과 프로그래밍 대회 (0) | 2025.01.28 |