WDP Reprezentacja liczb
Wcale nie jest oczywiste, jak należy reprezentować liczby w komputerze. Powszechnie wiadomo, że bardzo wygodny technologicznie jest układ dwójkowy, zwany też binarnym. Łatwiej jest bowiem reprezentować i rozróżniać dwie wartości, niż jakąkolwiek większą ich liczbę: wystarczy przecież rozróżnić "brak sygnału" od "jest sygnał", żeby reprezentować dwie cyfry binarne. Jakakolwiek próba zwiększenia liczby cyfr, które trzeba by było rozróżniać, wymusiłaby analizę ilościową sygnału, a nie tylko jakościową.
Liczby naturalne
System dwójkowy, zbadany i spopularyzowany przez Wilhelma Gotfrieda Leibniza w wieku XVII ma wiele zalet. Podobnie jak w dziesiętnym systemie pozycyjnym, liczby naturalne przedstawiamy jako sumę potęg bazy (2) z odpowiednimi wagami reprezentowanymi przez cyfry – w tym przypadku tylko dwie: 0 i 1. Każda liczba naturalna dodatnia ma zatem reprezentację postaci
Reprezentacja ta jest jednoznaczna, jeśli przyjmiemy, że nie stosujemy wiodących zer. Warto zapamiętać pierwszych 16 wartości:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ... |
0 1 10 11 100 101 110 111 1000 1001 1010 1011 1100 1101 1110 1111 |
Jeśli ustalimy z góry pewną liczbę cyfr, za pomocą których będziemy reprezentowali liczby naturalne, to w automatyczny sposób uzyskamy -cyfrowe reprezentacje, uzupełniając je do pełnych cyfr zerami z lewej strony. Kolejno mielibyśmy np. dla . Widać, że różnych wartości -cyfrowych jest : od do .
Jak w prosty sposób znajdować reprezentacje dwójkowe liczb naturalnych? Są dwie proste metody. Załóżmy, że chodzi nam o przedstawienie dwójkowe liczby .
- Znajdujemy największą liczbę nieprzekraczającą . Piszemy jedynkę, odejmujemy od wartość , a następnie kolejno dla wszystkich mniejszych potęg dwójki sprawdzamy, czy mieszczą się one w tym, co zostało z . Jeśli dana potęga dwójki nie mieści się, to dopisujemy zero, a jeśli mieści się, to dopisujemy jedynkę i odejmujemy tę wartość od tego, co zostało z . Przykładowo dla
zostało z | potega dwójki | wypisano |
13 5 1 1 0 |
8 4 2 1 |
1 11 110 1101 |
- Zaczynamy od liczby , a następnie dopóki m jest większe od zera dzielimy przez 2, zapisując kolejno otrzymywane reszty. Ciąg reszt odczytany od końca da nam poszukiwaną reprezentację. Przykładowo dla tego samego
zostało z po dzieleniu przez 2 | reszta z dzielenia przez 2 |
13 6 3 1 0 |
1 0 1 1 |
Ciąg reszt z ostatniej kolumny czytany od dołu do góry daje właśnie , czyli reprezentację dwójkową .
W odwrotną stronę trudno podać lepszą metodę, niż proste dodanie kolejnych potęg dwójki, zatem np. 10101, to
Teoretycznie zatem jesteśmy w stanie reprezentować każdą liczbę naturalną. Musimy jednak zdać sobie sprawę z tego, że to jest tylko ułuda. W rzeczywistości trzeba by mieć nieograniczoną pamięć, aby reprezentować bardzo duże liczby, a w każdym komputerze, ba, we wszystkich komputerach świata łączna pamięć jest i będzie ograniczona i skończona. Musimy zatem pogodzić się z tym, że będziemy w stanie reprezentować jedynie skończony podzbiór liczb naturalnych. Zazwyczaj robi się to tak, że do reprezentacji liczb całkowitych używa się standardowo określonej z góry liczby bitów (typowo 8, 16, 32 lub 64). Jeżeli chcemy reprezentować liczby o większej długości, to musimy sami sobie to zaprogramować, ale i tak wszystkich liczb naturalnych nie będziemy w stanie wyrazić.
Z tych prostych uwag wynikają ważne wnioski. Po pierwsze może nam zabraknąć liczb do realizacji założonych celów. Po drugie, świat liczb naturalnych różni się od świata liczb naturalnych reprezentowanych w komputerze choćby tym, że nie wszystkie działania będą wykonalne. I to nie tylko chodzi o dzielenie. W szczególności, skoro zbiór liczb reprezentowanych w komputerze jest skończony, to istnieją w nim dwie największe i dodanie ich do siebie spowoduje, że wynik będzie niereprezentowalny.
Liczby całkowite
Umówmy się zatem, że przeznaczymy określoną liczbę bitów, aby reprezentować liczby całkowite. Powstaje pytanie, jak zapisywać liczby ujemne? Istnieją co najmniej trzy sposoby, stosowane zresztą przez producentów różnych procesorów do dziś. Pokrótce je omówimy.
Znak – moduł
Sposób ten jest bardzo naturalny. Spośród dostępnych bitów wyróżniamy jeden – umówmy się, że pierwszy z lewej – i rezerwujemy go na określenie znaku liczby. Pozostałe bitów reprezentuje moduł liczby w tradycyjny sposób omówiony powyżej. Jeśli pierwszy bit znaku jest równy , to liczba jest nieujemna, a jeśli , to jest niedodatnia.
Oto tabela 16 liczb 4-bitowych zakodowanych tą metodą:
bity | wartość |
0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111 |
0 1 2 3 4 5 6 7 -0 -1 -2 -3 -4 -5 -6 -7 |
Kod znak – moduł
Zauważmy, że podane kodowanie prowadzi do dziwnej dość sytuacji, że zero reprezentujemy na 2 sposoby: nieujemne zero (0000) i niedodatnie zero (1000). To jest dość duża wada tego systemu kodowania; w szczególności trzeba uważać porównując wartości dwóch liczb, żeby nie stwierdzić, że , o co bardzo łatwo porównując tradycyjnie bit po bicie zawartości bitowe takich reprezentacji.
W kodzie tym dla liczb -bitowych mamy zakres i różnych liczb reprezentowanych jest w związku z tym .
Znak – moduł odwrotny
Sposób ten jest bardzo podobny do poprzedniego. Tak samo pierwszy bit oznacza znak, ale jeśli pierwszy bit jest 1 (co oznacza liczbę niedodatnią), to pozostałe bitów reprezentuje negatyw modułu liczby. Podobnie, jak poprzednio, jeśli pierwszy bit znaku jest równy Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle 0} , to liczba jest nieujemna, a jeśli Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle 1} , to jest niedodatnia.
Oto tabela 16 liczb 4-bitowych zakodowanych tą metodą w kodzie znak – moduł odwrotny:
bity | wartość |
0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111 |
0 1 2 3 4 5 6 7 -7 -6 -5 -4 -3 -2 -1 -0 |
Kod znak – moduł odwrotny
Zauważmy, że liczby nieujemne są reprezentowane dokładnie w taki sam sposób jak poprzednio, oraz że i tu podane kodowanie prowadzi do podwójnej reprezentacji zera. W kodzie tym dla liczb Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle n} -bitowych mamy ten sam zakres Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle -2^{n-1}+1\ldots 2^{n-1}-1} i różnych liczb reprezentowanych jest w związku z tym Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle 2^n-1} .
Po co sobie utrudniać kodowanie przez branie odwrotności modułu? Okazuje się, że system ten jest wygodniejszy, przynajmniej jeśli chodzi o dodawanie. W systemie znak-moduł, aby dodać dwie liczby przeciwnych znaków trzeba by najpierw ustalić znak wyniku i zdecydować, od którego modułu odjąć który. Okazuje się, że w kodzie znak-moduł odwrotny wystarczy, nie przejmując się znakami, dodać bitowo reprezentacje i, jeśli ostatecznie pojawi się w przeniesieniu całego wyniku jedynka, dodać ją jeszcze raz do otrzymanego rezultatu.
Przykładowo dodajmy w kodzie znak-moduł odwrotny Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle -4} do Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle 6} . Otrzymamy
1011 0110 -- --- (1) 0001
i teraz jedynkę przeniesienia Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle (1)} dodajemy do wyniku 0001 i otrzymujemy 0010, czyli reprezentację dwójki, czyli prawidłowego wyniku.
Kod uzupełnieniowy
Jest to najczęściej stosowany kod. Ma bardzo prostą interpretację – po prostu najstarszy bit Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle n} -bitowej reprezentacji traktujemy jako Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle -2^{n-1}} , a pozostałe tradycyjnie jako wartości, kolejno Parser nie mógł rozpoznać (SVG (MathML może zostać włączone przez wtyczkę w przeglądarce): Nieprawidłowa odpowiedź („Math extension cannot connect to Restbase.”) z serwera „https://wazniak.mimuw.edu.pl/api/rest_v1/”:): {\displaystyle 2^{n-2},\ldots,2^0} . Popatrzmy, jak wygląda nasza tabela czterobitowych wartości zapisanych w kodzie uzupełnieniowym. Kolejno od lewej bity nają wartości .Z trzech młodszych bitów generujemy wszystkie wartości od 0 do 7, a jeśli włączymy bit , to dodanie do niego tych wartości da nam liczby od do .
bity | wartość |
0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 1010 1011 1100 1101 1110 1111 |
0 1 2 3 4 5 6 7 -8 -7 -6 -5 -4 -3 -2 -1 |
Kod uzupełnieniowy
W n-bitowym kodzie uzupełnieniowym mamy następujące własności:
- Wartości są z przedziału
- Każda wartość jest reprezentowana jednoznacznie
- Liczb ujemnych jest o jeden więcej niż dodatnich
- to zawsze , a to zawsze
Kod uzupełnieniowy ma jeszcze jedną miłą cechę: dodawanie w nim nie wymaga żadnych specjalnych czynności. Po prostu dodaje się bitowo reprezentacje i jeśli pojawia się bit przepełnienia, to się go ignoruje. Na przykład: jeśli chcemy dodać do , to w naszym 4-bitowym systemie dostaniemy
1100 (-4) 0110 (6) -- --- (2) 0010
Od tej pory, jeśli wyraźnie tego nie zaznaczymy, to będziemy używali reprezentacji uzupełnieniowej. Zapamiętajmy.
We wszystkich stosowanych systemach liczby dodatnie reprezentuje się identycznie.
Powstaje jeszcze jeden problem. Co się dzieje, jeśli w wyniku wykonania jakiegoś działania wynik wyjdzie spoza zakresu reprezentowalności? Oczywiście cudów nie ma. Musimy pogodzić się z tym, że jakkolwiek to ustalimy, zawsze będzie możliwa taka właśnie sytuacja, że pewnych liczb, będących legalnymi wynikami działań, przedstawić się nie da. Mówimy w takiej sytuacji o problemie przepełnienia i uznajemy ją za błędną. Warto pamiętać jednak o jednej niezwykle istotnej rzeczy: niektóre systemy komputerowe nie zauważają sytuacji przepełnienia i wtedy może zajść dziwna sytuacja, w której np. dodanie dwóch liczb dodatnich daje ujemny wynik. Oto przykładowo w naszej reprezentacji 4-bitowej spróbujmy dodać do . dostaniemy
0100 (4) 0110 (6) -- --- (0) 1010 (-6)
Bit przeniesienia jest równy 0, ale wynik jest absurdalny. Czegóż innego jednak można się było spodziewać? Przecież liczby 10 nie umiemy w naszym czterobitowym systemie wyrazić. W rzeczywistości dodanie jedynki do największej liczby dodatniej w kodzie uzupełnieniowym (czyli do daje nam najmniejszą liczbę ujemną, czyli , zatem cały reprezentowany przedział się w ten sposób zacykla. Te same uwagi dotyczą rzecz jasna innych działań. Rozpoznać sytuację przepełniania można automatycznie i wiele systemów to robi, ale są środowiska programistyczne, które przechodzą nad tym problemem do porządku dziennego i wykonują błędnie działania bez kontroli przepełnienia.
Ułamki
Jak reprezentować liczby rzeczywiste? W tym przypadku mamy jeszcze większy problem, gdyż nie dość, że jest ich nieskończenie wiele, nie dość, że tworzą zbiór gęsty, to jeszcze jest ich nieprzeliczalnie wiele. Stoimy zatem przed problemem, jak wybrać skończony ich podzbiór tak, żeby jak najlepiej reprezentował nasze potrzeby. Działania, które będziemy wykonywali dadzą wyniki tylko w ramach tego podzbioru, więc może się zdarzyć, że wynik będzie odbiegał od rzeczywistości. Fenomen ten znamy dobrze z doświadczeń z kalkulatorami: zwykle, jeśli najpierw podzielimy jedynkę przez trzy, a potem pomnożymy wynik przez 3, otrzymamy coś w rodzaju . Błąd się wziął stąd, że w zestawie wartości niereprezentowanych przez kalkulator brakuje jednej trzeciej. Zamiast niej mamy jej przybliżenie , które pomnożone przez daje właśnie to, co widzimy na wyświetlaczu. Dzieje się tak nie tylko dlatego, że kalkulatory są prymitywnymi urządzeniami. Duże komputery cierpią na te same dolegliwości (choć akurat powyższe działanie mogą wykonać lepiej). Zajmiemy się teraz doborem właściwego podzbioru reprezentującego liczby rzeczywiste (a tak naprawdę wymierne, bo to takich wartości nasz podzbiór się będzie ograniczał).
Zacznijmy od reprezentacji binarnej ułamków. Podobnie, jak w systemie dziesiętnym korzystamy z ujemnych potęg dziesiątki po przecinku, tak tu będziemy rozważali binarne rozwinięcia ułamków za pomocą ujemnych potęg dwójki. Zatem po kropce binarnej, oddzielającej część całkowitą od ułamkowej, kolejne pozycje będą odpowiadały bitom reprezentującym kolejno wartości . Liczba będzie zatem miała przedstawienie , liczba będzie miała przedstawienie , liczba będzie miała przedstawienie itd. Wszystko jest prosto, jeśli w mianowniku ułamka są potęgi dwójki. Wystarczy bowiem zapisać licznik binarnie, a następnie kropkę binarną przesunąć w prawo o tyle pozycji, ile wynosi potęga dwójki w mianowniku. Na przykład ma przedstawienie : jest to po prostu czyli przesunięte o 4 pozycje w prawo.
Co jednak począć z mianownikami niebędącymi potęgami dwójki? W systemie dziesiętnym mamy podobny problem. Jeśli jedynymi dzielnikami mianownika są i , to otrzymujemy skończone rozwinięcie ułamka. W przeciwnym razie rozwinięcia są nieskończone i dla liczb wymiernych okresowe (można się umówić, że wszystkie rozwinięcia liczb wymiernych są okresowe, tylko niektóre mają okres złożony z samego zera!).
Jak zatem znajdować binarne rozwinięcia ułamków wymiernych? Oto algorytm, którego nieskomplikowany (ale i nieoczywisty) dowód poprawności można przeprowadzić indukcyjnie, i który działa również dla systemu dziesiętnego (oczywiście rolę dwójki pełni tam dziesiątka) – proszę sprawdzić.
Algorytm przedstawiania ułamka w binarnym systemie pozycyjnym. Założenie: . Napisz .. Kolejne cyfry rozwinięcia binarnego będziemy generować w następujący sposób: Dopóki otrzymujesz nienapotkane wcześniej wartości wykonuj 1. Pomnóż przez 2 2. Jeżeli otrzymana wartość jest mniejsza od 1, to dopisz cyfrę 3. Jeżeli otrzymana wartość jest równa no najmniej 1, dopisz cyfrę i odejmij od wyniku 1. Z chwilą, gdy powtórzy się wartość , zakończ procedurę; wypisany ciąg jest okresowy, a jego okresem jest ciąg bitów między powtórzeniami.
bity | wartość |
0 0 0 1 0 |
Binarne rozwinięcie
Widzimy, że ma rozwinięcie binarne . Okresem jest .
Spróbujmy znaleźć jeszcze rozwinięcie binarne dla .
bity | wartość |
0. 0 0 0 1 1 |
Binarne rozwinięcie
Zauważmy jeden wstrząsający fakt: nawet tak prosta liczba, jak ma nieskończone binarne rozwinięcie okresowe. Oczywiście takie rozwinięcia mają także . I rzeczywiście, gdy chcemy w komputerze (a nawet w kalkulatorach) reprezentować , jesteśmy zmuszeni do zaokrąglenia tej wartości i w rzeczywistości otrzymujemy tylko coś koło jednej dziesiątej. Przynajmniej tak to widzi komputer.
Zaokrąglenie
Skoro nie da się dokładnie reprezentować wartości wymiernych w komputerze, trzeba się pogodzić z ich przybliżaniem. Robimy to za pomocą zaokrąglania, przy czym reguły są bardzo proste: jeżeli mamy ochotę zaokrąglić na -tej pozycji, to patrzymy się na następną cyfrę i jeśli jest ona zerem, to po prostu cały ogon odrzucamy (zaokrąglenie w dół), a jeśli jest jedynką, to dodajemy ją do uciętego na -tym miejscu przybliżenia (zaokrąglenie w górę). Oto kolejne przybliżenia w zależności od stopnia dokładności, czyli określenia, ile cyfr po kropce binarnej chcemy mieć.
Numer bitu zaokrąglenia | reprezentacja |
1 2 3 4 5 6 7 8 9 ... |
0.0 0.00 0.001 0.0010 0.00011 0.000110 0.0001101 0.00011010 0.000110011 |
Kolejne zaokrąglenia
To, co uzyskujemy, postępując w pokazany sposób, jest ciągiem najlepszych przybliżeń zadanej wartości za pomocą ułamków o mianownikach będących kolejnymi potęgami dwójki. W przypadku jednej dziesiątej są to kolejno . Nie ma liczników, które przy tych mianownikach lepiej przybliżałyby .
Doszliśmy do momentu, w którym trzeba podjąć wyjątkowo ważną decyzję. Musimy określić, ile bitów będziemy potrzebowali na część całkowitą, a ile na część ułamkową przybliżenia liczby rzeczywistej. Decyzja wcale nie jest łatwa, szczególnie że komputerów używają ludzie potrzebujący zarówno operować liczbami bardzo małymi, jak i bardzo dużymi. Weźmy choćby fizyków kwantowych – ci działają na wielkościach rzędu . Astrofizycy z kolei używają wielkości astronomicznych; promień Wszechświata choćby to jest wielkość rzędu . Gdybyśmy chcieli zadowolić i jednych i drugich, trzeba by dysponować mniej więcej 70 bitami na część całkowitą i ponad setką bitów na część ułamkową. Łącznie na każdą liczbę rzeczywistą, biorąc pod uwagę pewien zapas, trzeba by przeznaczyć pewnie około 200 bitów, czyli 25 bajtów. Znając życie, skończyłoby się na 32 bajtach.
Warto zauważyć przy tym, że astronomowie w ogóle nie byliby zainteresowani tymi częściami ułamkowymi, a kwantowcy tymi całkowitymi. W ich obliczeniach na tych pozycjach stałyby zera i komputery niepotrzebnie mieliłyby zerowe bity przy każdych działaniach. Biorąc pod uwagę to, że choćby mnożenie (nie mówiąc o bardziej skomplikowanych procedurach liczących pierwiastki, sinusy czy logarytmy) tradycyjną metodą wymaga przemnożenia każdego bitu przez każdy, co oznaczałoby mnożeń jednobitowych na każdą parę liczb rzeczywistych. Tym niemniej w niszowych zastosowaniach można stosować układ nazywany stałopozycyjnym.
W układzie stałopozycyjnym z góry określamy, ile bitów przeznaczamy na część całkowitą (k), a ile na ułamkową (u). Cały przedział określoności rozciąga się między a i wartości w nim reprezentowane są równomiernie rozłożone co .
We współczesnych komputerach właściwie bez wyjątku spotykamy inną metodę zapisu liczb rzeczywistych. Pomysł zaczerpnięto od fizyków właśnie, którzy nie kwapią się, aby używać tak niewygodnej jak stałopozycyjna notacji, po to, żeby np. wyrazić wielkości typu stałej Plancka. Wielkość ta, zapisana w dziesiętnej notacji stałopozycyjnej, wygląda mniej więcej tak: . Gdyby podręczniki mechaniki kwantowej zawierały tak zapisywane wielkości kwantowe, byłyby nieczytelne. Zamiast takiego zapisu stosuje się znacznie poręczniejszy: . W zapisie tym podajemy kilka cyfr znaczących oraz określamy rząd wielkości poprzez podanie, o ile należy je przesunąć w lewo lub w prawo. Przy takim przedstawieniu cyfry znaczące nazywamy mantysą, a potęgę podstawy obrazującą, o ile należy przesunąć przecinek dziesiętny - cechą. Umówmy się ponadto, że w przypadku zapisów binarnych będziemy używali znaku kropki dla oddzielenia części całkowitej od ułamkowej i nazywali go kropką binarną.
Musimy rozwiązać jeszcze jeden mały problem. Sposobów przedstawienia konkretnej wartości jest nieskończenie wiele. Na przykład liczbę można przedstawić jako
i podobnie w drugą stronę:
Niewygodne byłoby używać kilku reprezentacji tej samej wartości. Stąd pomysł, żeby wybrać jedną z nich jako standardową i zapamiętywać wartości w takiej znormalizowanej postaci. Powszechnie przyjmuje się, że dobieramy tak mantysę, aby jej wartość mieściła się w przedziale dla wartości dodatnich oraz dla wartości ujemnych. Zero reprezentuje się w specyficzny sposób i zajmiemy się tym problemem później.
Podsumujmy zatem nasze ustalenia.
Każdą niezerową liczbę rzeczywistą reprezentujemy za pomocą przybliżenia wymiernego w postaci pary , gdzie jest mantysą, zaś , nazywane cechą, określa, o ile pozycji w prawo (dla ujemnych) bądź w lewo (dla dodatnich) należy przesunąć mantysę, aby uzyskać żądaną wartość. Interpretacja takiej reprezentacji wyraża się wzorem
Pozostaje nam zatem umówić się, ile bitów przeznaczymy na mantysę, a ile na cechę. Typowe wartości to , . Dla celów ilustracyjnych przyjmijmy, że i zobaczmy dokładnie, jak wygląda arytmetyka zmiennopozycyjna w tym mocno okrojonym zakresie. Mamy więc 8 różnych cech i 8 różnych mantys: 4 dodatnie i 4 ujemne – zauważmy, że warunki narzucone na mantysy ograniczają nam swobodę układania bitów, stąd gubimy jeden stopień swobody. Spodziewamy się więc możliwości przedstawienia różnych liczb niezerowych.
Przyjmujemy zatem uzupełnieniową reprezentację cechy i mantysy. Bity cechy mają zatem kolejno wartości , a bity mantysy kolejno . Przykładowo liczba ma zatem przedstawienie dokładne , czyli
Spróbujmy znaleźć reprezentację liczby . Zaczynamy od zamiany tej liczby na system binarny, otrzymując kolejno bity . Tu możemy już przerwać, mimo że jeszcze nie trafiliśmy okresu, gdyż i tak interesują nas tylko bity od wystąpienia pierwszej jedynki, a czwarty bit rozważamy tylko po to, żeby uzyskać stosowne zaokrąglenie. Po dodaniu ostatniej wyznaczonej jedynki do reszty, czyli zaokrągleniu do znaczących cyfr, dostajemy liczbę , czyli . Normalizujemy ją, przesuwając o 2 pozycje w lewo, czyli mnożymy przez , więc ostatecznie dostajemy . Cecha jest oczywiście równa - niweluje efekt przesunięcia o 2 pozycje. Zatem liczba najlepiej przybliża w naszym systemie, i ją właśnie przyjmujemy jako właściwego reprezentanta. Błąd względny, który przy tym popełniamy jest równy
Przyjrzyjmy się teraz dokładnie wszystkim wartościom reprezentowanym w naszym systemie. Zacznijmy od cechy 0 i mantys dodatnich. Są tylko legalne dodatnie mantysy:
cecha | mantysa | wartość |
000 000 000 000 |
0100 0101 0110 0111 |
Dla cechy wartości te mnożą się przez 2:
cecha | mantysa | wartość |
001 001 001 001 |
0100 0101 0110 0111 |
... i dalej dla cech 3 i 4:
|
|
Mamy teraz następującą sytuację:
\begin{rys.1} %Uwaga – przygotowuje go Wiesław Bartkowski ??
\end{rys 1}
\podpis{rozmieszczenie liczb w systemie 3 bitów cechy i 4 mantysy dla cech nieujemnych i mantys dodatnich)
Zajmijmy się teraz cechami ujemnymi. Kolejne tabele pokazują wartości dla cech równych .
Uzyskaliśmy zatem całkiem niezłą rozdzielczość, którą w systemie stałopozycyjnym musielibyśmy okupić siedmiobitową częścią ułamkową.
Oto przedstawienie na osi mantys dodatnich z ujemnymi cechami: \begin{rys.2 %Uwaga – przygotowuje go Wiesław Bartkowski \end{rys 2 \podpis{rozmieszczenie liczb w systemie 3 bitów cechy i 4 mantysy dla cech ujemnych i mantys dodatnich)
Zauważmy, jakie własności ma uzyskane rozmieszczenie dokładnie reprezentowanych wartości. Po pierwsze, są one rozmieszczone nierównomiernie. Im dalej od zera tym rzadziej (dla cechy 3 wartości skaczą już o 1). W rzeczywistości w niektórych systemach nie są reprezentowane nawet wszystkie liczby całkowite mieszczące się w przedziale określoności; luki między nimi są większe niż 1. W każdym przedziale, odpowiadającym danej cesze, wszystkie wartości są rozmieszczone równomiernie; jest ich tyle, ile mantys danego znaku. W sąsiednim z prawej przedziale, odpowiadającym cesze większej o 1, jest tyle samo wartości rozmieszczonych jednak dwukrotnie rzadziej, a po lewej – odpowiadającym cesze o 1 mniejszej – dwukrotnie gęściej.
Ujemne liczby są rozmieszczone według tego samego schematu, tyle że zaczynają się od i kolejne wartości to .
Przyjrzyjmy się, jak wygląda dodawanie w naszym systemie. Dostając dwa argumenty, musimy pamiętać, żeby dodawane bity odpowiadały sobie wartościami. Trzeba zatem przed rozpoczęciem dodawania ujednolicić cechy, przesuwając o odpowiednią liczbę bitów jedną z mantys. I tu panuje zasada, że dostosowujemy mniejszą cechę do większej. Następnie wykonujemy dodawanie i normalizujemy wynik, na końcu go zaokrąglając. Prześledźmy te kroki na przykładzie. Dla uproszczenia przyjmijmy, że wychodzimy od argumentów reprezentowanych dokładnie.
Przykład 1
Przykład 2
Przykład 3
NIE TYLKO ZERO NIE ZMIENIA WARTOŚCI DRUGIEGO ARGUMENTU PRZY DODAWANIU!
Dzieje się tak zawsze, gdy różnica cech argumentów przekracza o więcej niż jeden długość mantysy. Jest to pierwsza poważna różnica w stosunku do tradycyjnej algebry, do której jesteśmy przyzwyczajeni.
Drugą różnicę możemy wykryć, przyglądając się poprzednim przykładom. Jeśli będziemy wykonywali podwójne dodawanie , to w przypadku, gdy zaczniemy wykonywać działania od lewej strony, dostaniemy zarówno po pierwszym, jak i po drugim dodawaniu , ale gdy dodamy najpierw dwie mniejsze liczby , a potem wynik tego dodawania do , dostaniemy , tak jak w przykładzie pierwszym. Oznacza to, że
DODAWANIE ZMIENNOPOZYCYJNE NIE JEST ŁĄCZNE.
Ćwiczenie
Odpowiedź
Te dwie wskazane różnice między tradycyjną algebrą liczb wymiernych a arytmetyką komputerową trzeba znać. Nieznajomość pierwszej z nich prowadzi czasem do zapętlenia programu, kiedy oczekujemy, że ciąg rosnących wartości dotrze w końcu do pewnego oczekiwanego poziomu, którego się nigdy nie doczekamy, bo okazuje się, że stoimy w miejscu. Nieznajomość drugiej z nich wpływa na jakość obliczeń. Skoro wyniki można dostać różne, to znaczy, że zapewne któryś jest lepszy, a któryś gorszy. Jak wybrać najwłaściwszą kolejność? Przy dodawaniu dużej liczby wartości zawsze najlepiej zacząć od tych, które mają najmniejsze moduły – może z tych małych kawałeczków uzbiera sie coś godnego uwagi dla większych kolegów. Gdybyśmy zaczęli od argumentów o dużych różnicach cech, to mniejsze, żeby nie wiem ile ich było, mogą pozostać niezauważone przez większe.
Ćwiczenie
Odpowiedź
Błędy zaokrągleń, powstające przy reprezentacji liczb a także przy wykonywaniu działań na nich, są na tyle ważnym zagadnieniem, że rozwinęły się w samodzielną dyscyplinę matematyczną – analizę numeryczną, która stanowi w naszym kursie osobny moduł. Zauważmy, że w miarę wykonywania serii obliczeń, błędy zaokrągleń kumulują się – na stare nakładają się nowe. Jeśli dobierzemy zły numerycznie algorytm, to wynik, wskutek nawarstwiania się błędów, może dowolnie daleko odjechać od autentycznego wyniku. Warto tu zapamiętać sobie jedną z motywacji porządnego programowania.
Projektując szybkie algorytmy nie tylko przyspieszamy rozwiązywanie problemów, ale przy okazji ograniczamy możliwości nawarstwiania się błędów. Szybkie algorytmy nie mają czasu zrobić zbyt dużych błędów!
Zatem dobry algorytm działa szybko i zazwyczaj generuje łącznie mniejsze błędy zaokrągleń.