Why Rationals Won’t Cure Your Floating Point Blues

Why Rationals Won’t Cure Your Floating Point Blues

By Richard Harris

Overload, 19(101):8-11, February 2011


Numerical computing is still proving hard to do accurately. Richard Harris considers another number representation.

In the first article in this series we described floating point arithmetic and noted that its oft criticised rounding errors are relatively inconsequential in comparison to the dramatic loss of precision than results from subtracting two almost equal numbers. We demonstrated that the order in which we perform operations, whilst irrelevant for real numbers, can affect the result of a floating point expression and that consequently we must be careful how we construct expressions if we wish their results to be as accurate as possible.

In the second article we discussed the commonly proposed alternative of fixed point numbers and found that, although it is supremely easy to reason about addition and subtraction when using them, they can suffer even more greatly than floating point numbers from truncation error, cancellation error and order of execution.

Rationals

So, can we do any better?

Perhaps if we were to implement a rational number type, in which we explicitly maintain both the numerator and the denominator, rather than declare by fiat that we are working to some fixed number of decimal places or significant figures.

The rules of rational arithmetic are pretty straightforward. Given two rationals a 0 / b 0 and a 1/ b 1 we have

One enormous advantage of rational numbers is that, provided we do not overflow the integers representing the numerator (the top of the fraction) and the denominator (the bottom) the order of execution of these arithmetic operations is irrelevant; the answer will always be the same. Given that we have gone to great lengths to create an integer type that cannot overflow, this behaviour will prove rather useful.

The only thing we need to watch out for is the fact that there are many ways of writing down the same number; 1/2, 2/4 and 3/6 all represent the same number, for example. We shall ensure that our representation is unique by insisting that the numerator and the denominator are the smallest numbers that yield the same rational, or equivalently have no common factors, and that the denominator is positive.

The latter condition is relatively straightforward to maintain. The former requires an algorithm to determine the highest common factor, or HCF, of a pair of numbers, the greatest positive integer that wholly divides both. We can subsequently divide out that factor and return any rational to its simplest form. Fortunately one such algorithm has been handed down to us from antiquity, courtesy of the great Euclid and it proceeds as follows.

Euclid’s algorithm

If the two numbers are equal, their value is the HCF.

If the smaller exactly divides the larger, the smaller is the HCF.

Otherwise, divide the larger by the smaller, and make note of the remainder. The HCF of the original numbers is equal to the HCF of the smaller number and the remainder.

In mathematical notation this can be expressed as

Recursively applying these rules is guaranteed to terminate and we can thus determine the HCF.

For example, applying the Euclidean algorithm to 2163 and 1785 yields the following steps

and hence the HCF of 2163 and 1785 is 21, a fact that is clear if we look at their prime factorisations.

As it happens, this is simply a special case of the more general result that for any integers x 0 , x 1 , a and b where

then x 0 and b must have the same highest common factor as x 0 and x 1 , as shown in derivation 1.

Proof of common shared factors

First, let us assume that x 0 and x 1 share a common factor of c . We can therefore rewrite the equation as

for some x 0 ' and x 1 '.

Now since the left hand side is wholly divisible by c then so must the right hand side and furthermore since the first term on the right hand side is wholly divisible by c then so must be the second term, allowing us to express the equation as

Second, let us assume that x 0 and b share a different common factor of d . We can now rewrite the equation as

for some x 0 " and b ".

But now the right hand side is wholly divisible by d and so therefore must be the left hand side.

Hence any factor shared by x 0 and x 1 must be shared by x 0 and b , and any factor shared by x 0 and b must be shared by x 0 and x 1 and that they must consequently have the exactly the same highest common factor.

Derivation 1

As a consequence, it should not be surprising that the algorithm converges faster if we round the result of the division to the nearest integer rather than round down, consequently admitting negative remainders, and use the absolute value of the remainder in the following step.

Applying this optimisation to the same pair of numbers yields the same result in fewer steps.

A rational class

Now that we have described the various arithmetic operations, and the scheme that we shall use to ensure that each rational has a unique representation, we are ready to actually implement it. Listing 1 illustrates the class definition of our rational number type.

template<class T>
class rational
{
public:
  typedef T term_type;

  rational();
  rational(const term_type &x);
  rational(const term_type &numerator,
           const term_type &denominator);

  const term_type & numerator() const;
  const term_type & denominator() const;
  int        compare(const rational &x) const;
  rational & negate();
  rational & operator+=(const rational &x);
  rational & operator-=(const rational &x);
  rational & operator*=(const rational &x);
  rational & operator/=(const rational &x);

private:
  rational & normalise();
  term_type numerator_;
  term_type denominator_;
};
			
Listing 1

The first thing we shall need is a helper function to compute the HCF of a pair of positive integers as given in listing 2.

template<class T>
T
hcf(T x0, T x1)
{
  if(x0<=0 || x1<=0)  
    throw std::invalid_argument("");

  if(x0<x1)
    std::swap(x0, x1);

  do
  {
    const T div = x0/x1;
    const T rem = x0 - div*x1;

    x0 = x1;
    if(rem+rem<x1)  x1  = rem;
    else            x1 -= rem;
  }
  while(x1!=0);

  return x0;
}
			
Listing 2

Note that we capture both termination conditions by checking whether the absolute remainder, now stored in x 1 , is equal to 0. This will be true both if the smaller number is equal to or wholly divides the larger.

We implement the more efficient version of the algorithm by checking whether the remainder is greater than half the divisor. If it is, then the absolute value of the remainder of the rounded closest, rather than rounded down, division is simply the divisor minus the remainder.

We can see that this is true by considering the implications on the remainder of increasing the result by 1. In mathematical notation, the initial step is

where the odd looking brackets mean the largest integer less than or equal to their contents.

The new remainder is equal to

which is guaranteed to be negative, meaning that the absolute value of the new remainder must be x 1 r .

We could improve performance a little for bignum s by overloading this function to exploit the fact that their division helper function also calculates the remainder. However, since our division algorithm is O ( n 2 ) in the number of bits and our multiplication algorithm is only O ( n 2 ) in the number of digits, it would probably not make that much difference in most cases.

Next we shall implement the normalise member function which we shall use to ensure that our rational s are always represented in a standard form, as shown in listing 3. In this form, common factors are removed, the denominator is always positive and shall furthermore be equal to 1 when the numerator is 0.

template<class T>
rational<T> &
rational<T>::normalise()
{
  if(denominator_==0)  
    throw std::invalid_argument("");

  if(denominator_<0)
  {
    numerator_   = -numerator_;
    denominator_ = -denominator_;
  }

  if(numerator_==0)
  {
    denominator_ = 1;
  }
  else
  {
    const T c = hcf(abs(numerator_),
                    denominator_);

    numerator_   /= c;
    denominator_ /= c;
  }

  return *this;
}
			
Listing 3

We shall first call this function in one of the constructors, as given in listing 4. Specifically, we shall not entrust the correct representation to the user when construction from numerator and denominator.

template<class T>
rational<T>::rational()
: numerator_(0), denominator_(1)
{
}

template<class T>
rational<T>::rational(const term_type &x)
: numerator_(x), denominator_(1)
{
}

template<class T>
rational<T>::rational(const term_type &numerator,
                      const term_type &denominator)
: numerator_(numerator), denominator_(denominator)
{
  normalise();
}
			
Listing 4

The remaining member functions are equally straightforward which should come as no surprise given the simplicity of rational arithmetic.

The data access member functions, numerator and denominator , together with the compare and negate member functions are shown in listing 5.

template<class T>
const rational<T>::term_type &
rational<T>::numerator() const
{
  return numerator_;
}

template<class T>
const rational<T>::term_type &
rational<T>::denominator() const
{
  return denominator_;
}

template<class T>
int
rational<T>::compare(const rational &x) const
{
  const term_type lhs = numerator_ *
     x.denominator_;
  const term_type rhs = denominator_ *
     x.numerator_;

  if(lhs<rhs)  return -1;
  if(lhs>rhs)  return  1;
  return 0;
}

template<class T>
rational<T> &
rational<T>::negate()
{
  numerator_ = -numerator_;
  return *this;
}
			
Listing 5

Note that we must multiply the numerators and denominators during comparison which, for fixed width integer types, introduces the possibility of overflow and, for bignum s, unfortunately makes it a relatively costly operation.

The arithmetic operators, given in listing 6, are similarly sensitive to overflow when using fixed width integers and similarly expensive when using bignum s. Most irritating is that fact that addition and subtraction are now more sensitive to these factors than multiplication and division.

template<class T>
rational<T> &
rational<T>::operator+=(const rational &x)
{
  numerator_    = numerator_   * x.denominator_ +
                  denominator_ * x.numerator_;
  denominator_ *= x.denominator_;
  return normalise();
}

template<class T>
rational<T> &
rational<T>::operator-=(const rational &x)
{
  numerator_    = numerator_   * x.denominator_ -
                  denominator_ * x.numerator_;
  denominator_ *= x.denominator_;
  return normalise();
}

template<class T>
rational<T> &
rational<T>::operator*=(const rational &x)
{
  numerator_   *= x.numerator_;
  denominator_ *= x.denominator_;
  return normalise();
}

template<class T>
rational<T> &
rational<T>::operator/=(const rational &x)
{
  numerator_   *= x.denominator_;
  denominator_ *= x.numerator_;
  return normalise();
}
			
Listing 6

The problem with rationals

Recall that I mentioned that the square root of 2 is irrational and hence cannot be equal to any integer divided by another. A demonstration of this fact is given in derivation 2.

Proving that the square root of 2 is not rational

Let us assume that there are integers a and b such that

and that we have cancelled all common factors so that their HCF is 1. Trivially, we have

Now any odd number multiplied by itself results in another odd number, so a must be even and hence equal to 2 a ' for some a '. Hence

But this similarly means that b must be even and that consequently a and b have a common factor of 2; a contradiction.

The square root of 2 cannot, therefore, be rational.

Keep it to yourself though; you might get drowned.

Derivation 2

We cannot therefore exactly represent any such number with our rational type. However, it is also true that for every irrational number there are an infinite number of rationals to be found within any given positive distance, no matter how small.

Perhaps we could represent an irrational with one of its rational neighbours?

Well, yes we could, but we’d have to decide exactly how distant that rational should be and, whatever distance we choose, we could match its accuracy with a floating point representation of sufficient precision.

So, whilst rational number types are supremely accurate for addition, subtraction, multiplication and division and are consequently not sensitive to the order of execution of these operations, they require no less care and attention than floating point number types the instant we start mucking about with non-linear equations.

I am reluctant to categorise this capable numeric type as a lame duck, but am compelled to observe that, so far as general purpose arithmetic is concerned, it does seem to have a pronounced limp.

Quack, quack, quack.

Further reading

Boost: http://www.boost.org/doc/libs/1_43_0/libs/rational/index.html






Your Privacy

By clicking "Accept Non-Essential Cookies" you agree ACCU can store non-essential cookies on your device and disclose information in accordance with our Privacy Policy and Cookie Policy.

Current Setting: Non-Essential Cookies REJECTED


By clicking "Include Third Party Content" you agree ACCU can forward your IP address to third-party sites (such as YouTube) to enhance the information presented on this site, and that third-party sites may store cookies on your device.

Current Setting: Third Party Content EXCLUDED



Settings can be changed at any time from the Cookie Policy page.