LYnLab

블로그취미로그

부동소수점의 MIN_VALUE는 엄청나게 작은 숫자가 아니다

코딩을 공부한 사람이라면 누구나 한 번쯤, 배열의 최댓값(최솟값)을 찾는 문제를 풀어본 적이 있을겁니다. 그런게 몇 시간의 발목을 잡게될 줄 누가 알았을까요...

2017-07-22#프로그래밍

💡 이 글은 작성된지 1년 이상 지났습니다. 정보글의 경우 최신 내용이 아닐 수 있음에 유의해주세요.

코딩을 공부한 사람이라면 누구나 한 번쯤, 배열의 최댓값(최솟값)을 찾는 문제를 풀어본 적이 있을겁니다.

처음 배우는 풀이

보통은 다음과 같은 풀이를 배웁니다.

  1. '충분히 작은' 어떤 숫자를 x라고 한다.
  2. 배열의 첫 번째 값이 x보다 크다면, x를 그 값으로 바꾼다.
  3. 배열의 나머지 값들에 대해서 2번 과정을 반복한다.
  4. 모든 값들의 비교가 끝나면 x는 그 배열의 최댓값이 된다.

그리고 이를 코드로 옮기면 다음과 같습니다.

#include <iostream>
#include <array>

using namespace std;

int main() {
    array<int, 5> arr = {5, 1, 7, 9, 3};
    int max = -1;

    for (int i = 0; i < arr.size(); i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }

    cout << max << endl;	// max == 9
}

충분히 작은 숫자?

위에서는 '충분히 작은 숫자'로 -1을 대입했지만, 이는 배열에 -1보다 작은 음수만 들어있는 경우를 처리하지 못합니다.

따라서 어떤 상황에서도 통용할 수 있는 '충분히 작은 숫자'를 표현하기 위해서는 다음과 같은 방식을 사용하는게 좋습니다.

int max = 0x80000000
// 또는...
int max = INT_MIN

(c++ 기준) 두 코드 모두 int가 표현할 수 있는 가장 작은 숫자를 나타냅니다. INT_MIN은 0x80000000의 매크로이므로, 두 표현은 사실상 동일합니다.

실수(實數)도 똑같이 하면 되겠지?

똑같은 방식으로 실수 배열의 최댓값을 구하는 코드를 짜보겠습니다.

int main() {
    array<float, 5> arr = {-5.2, -1.8, -7.4, -9.6, -3.0};
    float max = FLT_MIN;

    for (int i = 0; i < arr.size(); i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }

    cout << max << endl;
}

intfloat로, INT_MINFLT_MIN으로 변경했습니다. 당연히 기대하는 최댓값은 -1.8입니다. 하지만 위 코드를 실제로 실행해보면...

1.17549e-38
응, 아니야

이해할 수 없는 실수가 하나 튀어나옵니다. 배열에 있는 숫자도 아닐 뿐더러, 양수니까 다른 값들보다 작지도 않은데요, 왜 이런 결과가 나왔을까요?

레퍼런스를 찾아보면...

int main() {
      cout << FLT_MIN << endl;
}

1.17549e-38의 정체는 FLT_MIN이었습니다. 당연히 FLT_MIN은 float의 최솟값이라고 생각했었는데요, 왜 저런 애매한 값이 설정되어있을까요?

레퍼런스 문서(참고 링크)를 찾아보면, FLT_MIN은 'Minimum representable positive floating-point number.' 라고 써있습니다. 한국말로 옮기자면 '부동 소수점으로 표현 가능한 가장 작은 양수' 쯤 되겠네요. 즉, 0보다 큰 수 중 가장 작은 수를 의미합니다.

왜 이런 애매한 표현을 굳이 FLT_MIN이라는 매크로로 정의해놓았을까요?

float의 정의

float은 부동 소수점(floating point)이라는 단어에서 온 표현입니다. 단어 그대로 소수점이 떠다닌다는(floating) 뜻인데요, 예를 들어 3.141592라는 숫자는 그 값 자체가 아닌, + 부호, 3141592의 가수와 10^(-6)의 지수의 세 가지로 나누어 저장합니다.

숫자를 저장하는 방식에 대한 표준으로는 IEEE 754가 있습니다. 예를 들어 -118.625라는 값을 IEEE 754 32비트 부동 소수점으로 저장한다면 다음과 같습니다.

이미지
출처: 위키미디어 공용 (CC BY-SA 3.0)

이러한 방식의 문제는, 실수의 정확한 값이 아닌 근삿값이 저장된다는 점입니다. 알고리즘 문제를 풀어보신 분들은 근삿값으로 인한 연산의 오차가 생각보다 빈번하게, 사실상 무조건 발생한다는 점을 이미 알고 계실겁니다.

그래서 FLT_MIN이 나왔다

일상적인 연산에서는 약간의 오차는 눈감아줄만 하지만, 때로는 값이 정밀도를 따지기 위해 float이 표현할 수 있는 가장 미세한 값을 알 필요가 있습니다. FLT_MIN은 바로 그런 니즈를 위해 탄생한 매크로입니다.

다시금 정의를 되새겨 보자면, FLT_MIN은 부동 소수점으로 표현 가능한 가장 작은 양수입니다. 이 말은 곧 0보다는 크지만 0에서 가장 가까운, 즉 float으로 표현 가능한 가장 '미세한' 값이라는 뜻이 됩니다. 1.17549e-38라는 값은 float이 표현 가능한 가장 미세한 값인 셈입니다.

반대로 FLT_MAXfloat이 표현 가능한 가장 거대한 수, 값으로는 3.40282e+38을 나타냅니다.

그러면 실제로 float의 가장 작은 숫자는?

그렇다면 float으로 표현할 수 있는 가장 값이 작은 숫자는 어떻게 표현할 수 있을까요?

앞서 부동 소수점의 정의를 제대로 이해하셨다면 답은 의외로 간단한데요, 바로 -FLT_MAX 입니다.

부동 소수점은 int 등과는 달리 부호 그 자체에 하나의 비트가 할당되어있어, 모든 숫자의 음/양을 대칭으로 표현할 수 있습니다. 지수 비트와 가수 비트가 동일하고 부호 비트만 다른 두 수는, 쉽게 풀자면 '절댓값은 같고 부호가 다른 두 수'를 표현하기 때문입니다.

따라서 FLT_MAXfloat이 표현 가능한 가장 큰 수(== 0에서 거리가 가장 먼 양수)였으므로, 여기에 -1을 곱해 부호 비트를 반전시키면 0에서 거리가 가장 먼 음수, 즉 가장 작은 수가 됩니다.

이제 배열의 최댓값을 구하는 코드를 다시 짜보겠습니다.

int main() {
    array<float, 5> arr = {-5.2, -1.8, -7.4, -9.6, -3.0};
    float max = -FLT_MAX;

    for (int i = 0; i < arr.size(); i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
    }

    cout << max << endl;
}

코드를 실행해보면, 이제는 원하는 값인 -1.8이 제대로 출력됩니다.

이 매크로, 다른 언어에서는 어떨까?

하필이면 정의를 FLT_MIN라는 헷갈리는 명칭으로 정해서 꽤나 혼동을 유발하는데요, 다른 언어에서는 어떨까요?

우선 Java에서 java.lang.Float.MIN_NORMAL 정의는 다음과 같습니다.

A constant holding the smallest positive normal value of type float, 2^(-126)

Python에서는 sys.float_info.min이라는 값으로 저장되어있습니다.

minimum positive normalized float (DBL_MIN of the C99 standard)

아무래도 info라는 단어가 끼어있어 헷갈림이 조금 덜하지만, 여전히 같은 내용을 표현하고 있습니다. 그 밖에도 확인해본 모든 언어가 부동 소수점의 MIN을 가장 작은 양수를 표현하는 매크로로 사용하고 있었습니다.

.
.
.

조금의 변명을 해보자면, Stackoverflow의 영어 네이티브들도 굉장히 헷갈린다는 듯 합니다. 처음 정의한 사람이 좀 더 명확한 단어를 사용했으면 좋았겠다는 생각이 드네요.

이 내용은, 아주 단순한 로직을 짜던 중 도저히 이해할 수 없는 버그가 발생해 이를 디버깅하면서 시작되었습니다. 대략 시간 단위로 날려먹은 것 같네요. 이 글을 통해 대한민국 개발자들의 삽질이 조금이라도 줄어들수 있으면 좋겠습니다.

관련된 글

Rails와 GitHub Actions에 커버리지 레포트를 달아보자

이 블로그의 CMS이기도 한 Shiori를 대폭 리팩토링하면서 테스트가 얼마나 잘 작성되어있는지 궁금해졌습니다.

Rails Global ID로 전역 객체 식별하기

Global ID는 Rails의 모든 객체를 식별할 수 있는 URI(Uniform Resource Identifier)입니다.

Ruby on WebAssembly: 살짝 맛보기

Ruby 3.2에 추가된 WebAssembly 지원을 간단하게 테스트해봅시다.

작성한 댓글은 giscus를 통해 GitHub Discussion에 저장됩니다.

크리에이티브 커먼즈 라이선스크리에이티브 커먼즈 저작자표시크리에이티브 커먼즈 동일조건변경허락

본 사이트의 저작물은 별도의 언급이 없는 한 크리에이티브 커먼즈 저작자표시-동일조건변경허락 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© 2011 - 2024 Hoerin Doh, All rights reserved.

LYnLab 로고GitHubTwitterInstagram