Drobne programowanie

Kaczuś zaprasza do opowieści o algorytmach, językach programowania i strukturach danych

Na stronie stosowane są pliki cookies. Więcej na podstronie.
odsłon: 7513

Kilka słów o liczbach zmiennoprzecinkowych

Co jakiś czas trafiam na osoby próbujące rozwiązać jakis problem związany z liczbami zmiennoprzecinkowymi. Wiem, że temat dość mocno opisany jest w internecie, ale spróbuję do niego podejść w troszkę inny sposób. Może to pomoże niektórym ustrzec się błędów i długiego zastanawiania się, dlaczego program nie działa, tak jakby autor tego oczekiwał.

Troszkę teorii

Na początek odrobina teorii, gdyż moim zdaniem będzie to pomocne w zrozumieniu czym są liczby zmiennoprzecinkowe.
Stworzono je by emulować liczby rzeczywiste, ale w taki sposób, że przy małych wartościach błąd reprezentacji jest mały, przy dużych natomiast, błąd ten rośnie.

W sumie jest to logiczne, bo gdy rozmiawiamy o ułamkach to znaczy, że ma dla nas znaczenie, czy rozważamy 0.00111 czy 0.00112, ale gdy rozmawiamy o milionach to interesuje nas zazwyczaj, czy będa to 2, czy 2.5 miliona. Wszystko byłoby dobrze, gdyby ktoś nie chciał do tych 2 milonów dodać swoich 3 groszy, bo mogą zacząć się problemy (o czym będzie dalej). Ogólnie wzór mamy w postaci:
x = M*NW
gdzie:
  • M - mantysa liczby x
  • W - wykładnik części potęgowej
  • N - część potęgowa (zazwyczaj 2, zdarza się 10, pojawiła się też maszyna, gdzie było to 16)
Dokładność i zakres liczb zmiennoprzecinkowych zależna jest od ilości bitów użytych do zapamiętania liczby oraz zastosowanego podziału na wielkość mantysy i wykładnika. Można podzielić te liczby na zdefiniowane przez standard IEEE 754 i pozostałe. Ze zdefiniowanych przez standard mamy: Half (16 bitów), single/float (32 bity), double (64 bity), quad (128 bitów). W standardzie opisano też format używany w obecnie najpopularniejszym koprocesorze matematycznym dla procesorów x86(80 bitów). Inne spotykane wielkości liczb zmiennoprzecinkowych to stary real (obecnie real48) - 48 bitów, wewnnętrzny format w procesorach Coldfire 96 bitów.

Wiemy już jak mniej więcej wyglądają takie liczby. A teraz co się z tym wiąże.
Zastanówmy sie jak wyglądają operacje na tych liczbach. O ile mnożenie jest w miarę proste (mnożymy mantysy przez siebie i dodajemy wykładniki), to operacje dodawania (i odejmowania) już takie banalne nie są. Tu najpierw musimy sprowadzić liczby do tego samego wykładnika (jeśli liczby znacznie różnią sie wielkością, to tracimy część informacji), a następnie dopiero dodać. Wprowadza nam to dodatkowy błąd - błąd zaokrągleń przy obliczeniach.

Liczba zmiennoprzecinkowa jako licznik

W narodzie istnieje pokusa, aby wykorzystywać liczbę zmiennoprzecinkową jako licznik. Czemu tak jest? Ano jeśli spojrzymy na zakres liczb, to zauważymy, że rozpiętość liczbowa jest bardzo duża - w większości przypadków dużo wieksza od zwykłego inta, a nawet long longa. Niestety to nie jest najlepszy pomysł, co pokaże przykład:
#include <stdio.h>

int main()
{
    float f, v = -1;
    for (f = 0;; f = f + 1)
    {
        if (f <= v)
        {
        	printf ("Mamy cię %f + 1 = %f\n", v, f);
            break;
        }
        else
        	v = f;
    }
    return 0;
}
u mnie przy wartości 16777216 nie dodaje jedynki! W zależności od koprocesora i parametrów kompilacji może się to zdażyć w odrobinę innym miejscu, ale się zdarzy!
Stąd - należy pamiętać, iż dodawanie małych wartości liczb do dużych w formatach zmiennoprzecinkowych wprowadza duży błąd w obliczeniach. Więc - jeśli istotny dla nas jest wynik takiej operacji, to należy zmienić typ używanych liczb.

Sumowanie liczb

Zachodzi czasami potrzeba, aby wykonać obliczenia na jakimś przedziale liczbowym. Mamy podzielony równo odcinek i:
#include <stdio.h>

int main()
{
    float x;
    float a = 1, b = 3, sum1 = 0, sum2 = 0;
    int n = 5000;
    int i;

    x = (b - a)/n;
    for (i = 0; i < n; ++i)
    {
       sum1 += x;
    }
    sum2 = n*x;
    printf("Powinno być 2.\nsuma 1 = %f, suma 2 = %f, błąd 1 = %f, błąd = %f\n", sum1, sum2, (2 - sum1), (2 - sum2));

    return 0;
}
po wykonaniu operacji w moim wypadku jest:
Powinno być 2.
suma 1 = 1.999882, suma 2 = 2.000000, błąd 1 = 0.000118, błąd = 0.000000
Oczywiście - biorąc zamiast float liczbę double zmniejszylibyśmy błąd, co nie znaczy, że on nie występuje. Dlatego tu wskazówka, jeśli możemy ograniczyć ilość dodawań, to róbmy to! Jak widać mnożenie wprowadza mniejszy błąd, choć to tez może byc złudne, bo gdy po przemnożeniu otrzymamy dużą liczbę, a potem trzeba będzie dodać małą, to też błąd się pojawi. Dlatego wersję algorytmu należy stosować właściwą do tego, jakich danych się spodziewamy. Należy też pamiętać, że kolejność wykonywania działań też czasem ma znaczenie!

Łączność i przemienność operacji na liczbach zmiennoprzecinkowych.

Należy tez pamiętać, że tak naprawdę ze względu na swoje właściwości operacje na liczbach zmiennoprzecinkowych nie są łączne i przemienne. Mały programik testowy to pokaże:
#include <stdio.h>

int main()
{
    float x = 1100000, y = -10, z = 10.000001;
    float eval1 = 0, eval2 = 0;

    eval1 = (x * y) + (x * z);
    eval2 = x * (y + z);
    printf("(x * y) + (x * z) = %f\nx * (y + z) = %f\n\n", eval1, eval2);

    x = 127.9;
    y = 1;
    z = -0.999999;

    eval1 = (x + y) + z;
    eval2 = x + (y + z);
    printf("(x + y) + z = %f\nx + (y + z) = %f\n\n", eval1, eval2);


    return 0;
}
wynik działania:
(x * y) + (x * z) = 1.000000
x * (y + z) = 1.049042

(x + y) + z = 127.899994
x + (y + z) = 127.900002
licząc "na piechotę" w pierwszym przypadku powinno byc 1.1, w drugim 127.900001. Czego należy się wystrzegać? - Wystrzegać należy się operacji odejmowania, które prawie się znoszą, gdyż zostaje bardzo mała wartość, którą trzeba będzie operować na dużej wartości.

Przypomnienie

Jedno z najważniejszych (było o tym w skrzyni porad) - pamiętać należy, że nie porównujemy naszego wyniku obliczeń z zerem!
Przyjmujemy, że zerem jest wynik:
-epsilon<nasz wynik<epsilon
gdzie epsilon, to dokładność.

Przydatne

Z przydatnych wartości mamy:
  • NAN - czyli to nie liczba - zwracane, gdy wykonujemy operacje niedozwolone, np wyciaganie pierwiastka z liczby ujemnej, albo mnożenie 0 przez nieskończoność
  • HUGE_VAL - czyli wynik operacji, gdy nastepuje wyjście poza zakres liczb (występuje tez w wersji ze znakiem -)
  • INFINITY - czyli nieskończoność - może być np wynikiem operacji dzielenia przez 0 (występuje tez ze znakiem)
  • -0 - liczba bliska zeru od strony ujemnej.
Na tym dziś kończę. Z nadzieją, że niektórym się te wiadomości przydadzą.
Pozdrawiam
Tomasz Kaczanowski

2015-03-29 19:26:58


C porady double float mantysa wykładnik