Numeric Algorithms
This article presents some of the key algorithms associated with mathematical computations. These include prime-finders, factorization, numeric approximations, and bit manipulations.
Number Representation
Question: How many symbols do we need to represent any number? There are infinitely many numbers, but we cannot have infinitely many symbols. Thus, we need a finite number of symbols that allows us to represent every conceivable number. For most of the world, that finite number is ten:
Number systems that use ten symbols to represent all numbers are called decimal number systems, or base-10 systems. In the Western decimal number system, ten symbols (called the Hindu-Arabic numerals) are used to represent every conceivable number. The number of unique symbols used in a numbering system to represent a number in that system is called the base or radix of the numbering system. For example, with the decimal system, the radix is For the ancient Mayans, the radix was (called a vigesimal system). For the Babylonians, the radix was (called a base-60 system).
To understand how the base works, we'll consider the decimal system. First, we have enough symbols to represent the numbers zero through nine:
The symbols above, however, hide a critical fact: They all have an infinite amount of "slots" or "places", immediately to the left, occupied by a zero:
When we reach the number nine, we've run out of unique symbols. So to represent the number ten, we go to the slot on the left (occupied by a zero), and increment it by
Once we get to nineteen, we increment the slot again:
Because there are ten symbols, and for each of these ten we can assign one of the ten symbols immediately to its left, we have unique symbols. This allows us to represent numbers from zero () to ninety-nine ().
Once we reach we've run out of symbols for the second slot, so we turn to the third slot: Now we have unique symbols. This allows us to count from zero to nine hundred and ninety-nine (). Once we've reached the process continues. We go to the fourth slot, giving us unique symbols. Once that's run out we go to the fifth— unique symbols; then the sixth— unique symbols; seventh— eighth— ad infinitum.
So, we have a way to generate unique symbols. But using this approach, we have a problem: How do we ensure that someone seeing doesn't interpret it as and This is accomplished by everyone agreeing to intrepret the second position as indicating how many times we've used up symbols for the first position. For example, in the case of we interpet the as: "We've run through the symbols six times in the first position." With the included, the number translates to "Six of the ten symbols and seven of the symbols":
When we see we think: "Eight of ten of the ten symbols, two of ten of the symbols, and six of the symbols:"
This process continues over and over, embodying the following pattern:
where and are positive integers. Each of the terms above is called a place, and they are multiples of ten. For example, is the tenth's place, is the hundredth's place, and so on. The term is often called the one's place, and it is mathematically denoted
Octal Number System
Although the decimal system allows us to write large numbers with fewer symbols, the more symbols we use, the more computations we must perform and the more likely we are to commit computational errors. Inversely, the fewer symbols we need, the less computations we must perform, and the less likely we are to commit computational errors. The tradeoff, however, is readability. With fewer digits, larger numbers are long and difficult to decipher. Nevertheless, some problems (as we'll later see) do not require humans to directly read the numbers, so we've gone ahead and used number systems with fewer symbols. One such system is the octal number system.
To understand the octal number system, it's worth mentioning a bit of its history. Before the French Revolution, most of Europe used a dizzying array of inconsistent measurement systems. At the turn of the nineteenth century, global trade increased dramatically, and the ancient units proved costly for international transactions. The French merchant would appear in court arguing he received less than the agreed upon amount, pied du roi (the length of the King's foot, about cm), with the German merchant responding that he gave the correct amount, Fuß (possibly cm; there were competing values of Fuß).
In 1799, the Académie des sciences, a French learned society known for solving scientific and mathematical problems, tasked a panel of scientists and mathematicians to remedy these problems.1 Their solution: a decimal system of measurement relying on ten symbols, using prefixes like deci, centi, milli, deca, hecto, kilo, and several others. As we can guess, we know this system today as the metric system.
Across the Channel, the English had their own system and its proponents. One such defender was the Scottish economist James Anderson. Weary of the costs of changing a centuries-old measurement system, Anderson advocated for an alternative more closely aligned with the English system. Instead of relying on ten symbols, Anderson's system relied on eight:
Likely inspired by the Latin octo, meaning "eight," Anderson called his alternative the octal system. Knowing the basics of number systems, we see that in the octal number system, the radix is eight—a base-8 number system. And with eight symbols, we can represent the numbers zero through seven. Like what we saw with the decimal system, we run out of symbols once we reach To represent the number eight, we add a new symbol to the left, and start the symbol to the right back at
Thus, in the octal number system, the number eight is represented as Nine is ten is eleven is and so on. Once we reach the number fifteen, we increment the number to the right and start at zero on the left: The number sixteen is
Similar to what we saw with the decimal system, going from right to left, the first position indicates the one's, and the second position indicates how many times we've gone through the first position. The third position indicates how many times we've gone through the second, the fourth the third, the third the fourth, and so on. The difference, however, is that instead of using a base of ten, we use a base of eight:
Thus, the octal number is actually:
Because octal numbers use the same symbols as the decimal numbers, we indicate them with subscripted brackets containing the base. For example, the octal number is formally written as:
Alternatively (and in some computer languages), the number would be indicated as:
Following standard practice, we will use the former syntax in exposition, but the latter when referring to code. To convert quickly from decimal to octal, it's helpful to know the first ten powers of eight by heart:
Primality Tests
Suppose we're given a number We want to answer the following question: Is a prime number?
Recall the definition for a prime number. is a prime number only if it satisfies the following propositions:
- is a natural number,
- is greater than and
- consists of only two divisors: and
We denote the set of primes with the symbol The set below illustrates this, listing the first twenty-four prime numbers, with ellipses to indicate more:
Algorithms that determine whether a given number is a prime number are called primality tests. These algorithms are immensely important in cryptography. Cryptography itself is what ensures sensitive data like passwords, text messages, emails, and credit card numbers stay secure as they are transmitted across networks.
Brute-force Primality Test
There are a variety of ways to implement a primality test, but we start with the simplest algorithm: a brute-force primality test. This algorithm is part of a family of algorithms called brute-force algorithms. These algorithms solve problems in the most simple, direct, or obvious way. For the prime number testing, the brute-force primality test is closely related to proof-by-exhaustion, a method of proof in mathematics. We exhaust all of the possibilities before we exhaust ourselves (or more accurately, our machine's RAM).
To implement the brute-force primality test, we study the mathematical definition:
definition. If a natural number is prime, then is greater than and is not divisible by any positive integer other than and
When implementing algorithms based on mathematical definitions, it's helpful to read the definitions in all directions. In this case, let's read the definition in terms of its contrapositive:
definition. If is less than or equal to or is divisible by a positive integer other than and then is not prime.
The contrapositive essentially lays out the brute-force algorithm. The implementation is simple: Using a for-loop, we go through all of the integers from to verifying no number divides without a remainder. If there is such a number, that would imply that has a divisor other than and itself. This violates the last requirement above, and is not a prime number. Otherwise, is a prime number. Implementing in pseudocode:
fn brute_force_primality_test(int n):
if (n < 2): return "n is not prime";
else:
for (int i = 2; i < n - 1; i++):
if (n % i == 0): return "n is not prime";
return "n is prime";
With brute-force primality test, we must iterate through elements. As such, the running time function for this algorithm can be expressed as Applying asymptotic analysis, the brute-force primality test, in the worst-case scenario, has a running time of order This is linear time. Can we do better? Let's consider another algorithm.
Trial Division
The second primality test we investigate is trial division. This algorithm relies on the same definition above, but the key insight lies in honing in on what constitutes a composite number—a number that is not prime.
definition. If is composite, then the following proposition is true: where and
With the brute-force primality test, we attempted to find the divisor The index represents the divisor and we iterated up to checking to see if Now, given any two numbers and or in our case, and one will be less than or equal to the other. This proposition originates in the trichotomy law in real analysis. Suppose This is a perfectly reasonable assumption to start with; we don't lose any generality in doing so.
So, what's next? This is where we get creative. We look at the facts we have so far and try to draw further facts. We assumed that And before that, we saw that the composite number has the form Our inequality looks like so what if we tried getting it to look like To do so, we multiply to both sides:
That's an interesting result. If and we have:
Now if we took the square root of both sides:
This is another big insight. If and then Given that must be less than we don't have to search up to Instead, we only have to search up to The implementation is thus:
trial_division_primality(int n):
if (n < 2): return "n is not prime";
else:
for (int i = 2; i < (sqrt(n)); i++):
if n % i == 0:
return "n is not prime";
return "n is prime";
With trial-division, the algorithm only takes operations
to execute. Hence, this algorithm has a time complexity of
or This is fractional power time, which is faster than
linear time. Note that the time complexity for this algorithm may vary
depending on how sqrt(n)
is implemented. To avoid these issues, we can
instead establish the test condition as i*i < n
rather than
i < sqrt(n)
:
trial_division_primality(int n):
if (n < 2): return "n is not prime";
else:
for (int i = 2; i*i < n; i++):
if n % i == 0:
return "n is not prime";
return "n is prime";
Prime Factorization
Closely related to primality tests is prime factorization—expressing a number as a product of their prime factors. For example, the number in terms of its prime factors is:
We can visualize prime factorization in terms of a tree. For example, the prime factors of We start with the first prime number. Since is divisible by we get
Then we consider the prime factors of We start again with and we see that is also divisible by
Then we look at We go back to testing is not divisible by so we consider the next prime number, is not divisible by so test the next prime number, Here, is divisible by
Finally, we have which leads to:
Once we've reached the factor we know we've found the prime factors.
Implementing the procedure above in pseudocode:
int factors[100];
int exponents[100];
int length = 0;
fn primeFactors(int m) -> void:
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = m / d;
k++;
if (k > 0):
length++;
factors[length] = d;
exponents[length] = k;
d++;
In the pseudocode above, the factors[]
array will hold the prime factors
we find, and the exponents[]
array will hold the exponents we find. Let's
run through the code above with a simple example. Suppose we want to find
the prime factors of First, we call primeFactors(24)
:
primeFactors(24):
int m = 24;
int d = 2;
We then reach while-loop-1. Here, the test condition is (m > 1)
. In this
case, 24 > 1
, so we proceed:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
We then encounter our while-loop-2. The test condition here is
(m % d == 0)
. In this case, (24 % 2 == 0)
is true, so we proceed:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = m / d; m = 24 / 2 = 12
k++; k = 1
Given that m = 12
, while-loop-2's condition is still true, so we execute
again:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = 12
k = 1
m = m / d; m = 12 / 2 = 6
k++; k = 2
Once more our test condition is true, so we execute again
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = 6
k = 2
m = m / d;
m = 6 / 2 = 3
k++;
k = 3
Going back to while-loop-2, the test condition returns
false—(3 % 2 != 0)
. So, we go to the next line in while-loop-1. In this
case, it's an if-statement, whose test condition is true:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = m / d;
k++;
k = 3
if (k > 0):
factors[length] = d;
factors[0] = 2
exponents[length] = k;
exponents[0] = 3
length++ factors = 1
d++; d = 3
Now m = 3
, and we go back to while-loop-1's test condition. Here, we see
that m > 1 == 3 > 1
, which is true. So, we again execute while-loop-1's
body. We set int k = 0
, and reach while-loop-2. Here, we see that
m % d == 0
is true—3 % 3 == 0
. So, we proceed executing while-loop-2's
body:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = m / d; m = 3 / 3 = 1
k++; k = 1
Now, with m = 1
, while-loop-2's condition is false, so we exit
while-loop-2 and continue with the rest of the statements in while-loop-1.
We see that k > 0 ${\equiv}$ 1 > 0
, so we execute the if-block:
primeFactors(24):
int m = 24;
int d = 2;
while (m > 1):
int k = 0;
while (m % d == 0):
m = m / d;
k++;
k = 1
if (k > 0):
length = 1
factors[length] = d;
factors[1] = 3;
exponents[length] = k;
exponents[1] = 1;
length++ length = 2
d++; d = 4
With m = 1
, our while-loop-1's condition is false, and we have finished.
With the data from these two arrays, we have:
Which is, in fact, the prime factorization of Intuitive as this
algorithm is, it performs poorly in terms of time complexity. The worst
case scenario in this case is when m
is a prime number. If we call
primeFactors(23)
, for example, we would have to increment all the
way up to This leaves us with a time complexity of
Bitwise Operations
For most developers, the bitwise operations are the least used. They can, however, come in very handy with certain problems. To begin, let's review these operations:
Operation | Description |
---|---|
a & b | Bitwise-AND. Given two binary digits, returns 0 if any of the two digits are 0, otherwise 1. |
a | b | Bitwise-OR. Given two binary digits, returns 1 if any of the two gits are 1, otherwise 0. |
a ^ b | Bitwise-XOR. Given two binary digits, returns 1 if exactly one of the two digits is 1, otherwise 0. |
~a | Bitwise-NOT. Given a single binary digit, returns 0 if the digit is 1, and 1 if the digit is 0. |
a<<b | Bitwise-left-shift. Moves all of the bits to the left, with the position previously occupied by the least-significant bit filled with a 0. |
a>>b | Bitwise-right-shift. Moves all of the bits to the right, with the position previously occupied by the most-significant bit filled with a 0. |
Let's see some examples. For the bitwise-AND:
Notice that we get 1
if, and only if, we have 1 & 1
. Otherwise, it's
0
. Furthermore, if we converted the binary numbers above into decimal, we
get:
Now let's see the bitwise-OR:
In contrast to the bitwise-AND, for the bitwise-OR we get 0
if, and only
if, we have 0 | 0
. Otherwise, get 1
. Thus, we have:
Closely related is the bitwise-XOR. This is essentially bitwise-OR, but
with one restriction: We can't have two 1
s. In other words, we get 1
if, and only if, we have exactly one 1
:
Thus, we have:
Next, the bitwise-NOT. Unlike the previous operators, bitwise-NOT is a unary operator—it applies to only one operand. Thus:
This yields:
Footnotes
-
Among the panelists were the mathematicians Joseph-Louis Lagrange, Pierre-Simon Laplace, and Nicolas de Condorcet. ↩