7.1 Space bounds

Besides time, space is the other computing resource that is commonly of concern to programmers. Just as computers have become much faster over the years, they have also received greater allotments of memory. Even so, the amount of available disk space or main memory can be significant constraints for algorithm designers.

The analysis techniques used to measure space requirements are similar to those used to measure time requirements. However, while time requirements are normally measured for an algorithm that manipulates a particular data structure, space requirements are normally determined for the data structure itself. The concepts of asymptotic analysis for growth rates on input size apply completely to measuring space requirements.

7.1.1 Space complexity of data structures

Time complexity helps us to abstract away from hardware-specific details, constant factors and lower-order terms, so that we can focus on what has the most impact for large inputs. In the same way we want to abstract away from the actual memory usage in bytes, and instead focus on how the memory used by a data structure depends on the data size.

Example: Arrays and linked lists

What are the space requirements for an array of nn integers? If each integer requires kk bytes, then the array requires knkn bytes, which is in O(n)O(n).

What are the space requirements for a linked list of nn integers? Each linked node requires kk bytes for the integer, plus (say) kk additional bytes for the pointer to the next node. Therefore the linked list requires 2kn2kn bytes, which is also in O(n)O(n).

A data structure’s primary purpose is to store data in a way that allows efficient access to those data. To provide efficient access, it may be necessary to store additional information about where the data are within the data structure. For example, each node of a linked list must store a pointer to the next value on the list. All such information stored in addition to the actual data values is referred to as overhead. Ideally, overhead should be kept to a minimum while allowing maximum access. The need to maintain a balance between these opposing goals is what makes the study of data structures so interesting.

Example: Integers

How much memory does an integer use? This might sound like a stupid question – don’t we usually just assume that integers use a fixed size, such as 32 bits or 64 bits?

Yes and no – most programming languages have fixed-size integers, and then their space usage is constant, O(1)O(1). But fixed-size integers are actually just an approximation of integers. A 64-bit integer can only store values in the range 263-2^{63} to 2632^{63}, and although this is enough for most purposes there are cases when we need to calculate integers of arbitrary size. Most modern languages have a special datatype for arbitrary-size integers (e.g., Javascript has BigInt and Java has BigInteger), while languages such as Python even has arbitrary-size integers as the default numeric type.

So, how much memory does in arbitrary-size integer use? Normally they need a number of bytes that is proportional to the number of bits in the binary representation. The space usage is therefore O(n)O(n), where nn is the length of the binary representation.

But what is the space usage in terms of the integer itself? This boils down to the question how many bits are there in the binary representation of a number. Since you can store 2n2^n different integers in nn bits, we need a logarithmic number of bits to store an arbitrary integer. Therefore the space usage of an integer is logarithmic, O(logn)O(\log n) to store an integer nn.

And finally a more complex example, about friendship:

This last example discussed two possible implementations of graphs, and we will discuss this further in Chapter 13.

7.1.2 Space complexity of algorithms

We are not only interested in knowing how much memory a data structure will use, but also what the space complexity of an algorithm is. When we analyse space usage of algorithms, we are usually only interested in the additional space that the algorithm uses during execution.

Let’s say that an algorithm is in-place if it only uses constant additional space, O(1)O(1). For example, Insertion sort is in-place, because it only allocates a constant number of variables to complete.

Example: Mergesort

How much additional space does Mergesort use? Mergesort is a divide-and-conquer algorithm that calls itself recursively, halving the size of the problem in each call. The step that uses additional memory is the merging process, where we have to allocate a new array that we can fill with the merged values.

In the first level we have to allocate one array of size nn. In the second level we allocate two arrays, each of size n/2n/2. In the third level we allocate 22=42^2 = 4 arrays, each of size n/22=n/4n/2^2 = n/4. Continuing down we see that in level kk we allocate 2k2^k arrays, each of size n/2kn/2^k.

As you can see, each level uses up an additional O(n)O(n) space, because 2kn/2k=n2^k \cdot n/2^k = n. And, since we already know that Mergesort continues for logn\log n levels, we get an additional space usage of O(nlogn)O(n \log n).

But it is possible to improve the space usage of Mergesort. Instead of allocating new arrays at each level, we can create one single additional array of size nn and then use only that auxilliary array. To make this work we have to change the implementation somewhat, and this can be done in several ways. The most common solution is called bottom-up Mergesort, and it has an additional space usage of O(n)O(n).

So, Mergesort is not an in-place algorithm because it uses at least linear additional space. But what about Quicksort, didn’t we say that it is in-place?

Example: Quicksort

How much additional space does Quicksort use? Just as Mergesort, Quicksort is also a divide-and-conquer algorithm, but in this case we cannot be certain that it halves the problem size in each call. We have already showed that Quicksort is quadratic O(n2)O(n^2) in the worst case (if we are unlucky with the pivot selection).

Now, Quicksort is a recursive algorithm, and whenever we make a recursive call the system has to allocate some memory for storing information on what to do when returning from the recursion. This is done by pushing some memory block (of constant size) onto the call stack. But when Quicksort is unlucky and gets quadratic behaviour, it will use a linear number of recursion levels. Therefore the additional memory usage for Quicksort is linear O(n)O(n), so it is not in-place.

But let’s assume that we have a good pivot selection algorithm (and well-behaved input), so that we get the normal O(nlogn)O(n \log n) behaviour. Quicksort will still allocate memory on the call stack for the recursive calls, and just as Mergesort we will never get fewer than O(logn)O(\log n) recursive levels. Therefore the additional memory usage for Quicksort will be at least logarithmic O(logn)O(\log n) in the worst case.

The Quicksort example shows that perhaps we were too strict when we defined what an in-place algorithm is. A more realistic definition is to say that an algorithm is in-place if it never uses more than O(logn)O(\log n) additional space.

7.1.3 Space/time tradeoff

One important aspect of algorithm design is referred to as the space/time tradeoff principle. The space/time tradeoff principle says that one can often achieve a reduction in time if one is willing to sacrifice space or vice versa. Many programs can be modified to reduce storage requirements by “packing” or encoding information. “Unpacking” or decoding the information requires additional time. Thus, the resulting program uses less space but runs slower. Conversely, many programs can be modified to pre-store results or reorganise information to allow faster running time at the expense of greater storage requirements. Typically, such changes in time and space are both by a constant factor.

A classic example of a space/time tradeoff is the lookup table. A lookup table pre-stores the value of a function that would otherwise be computed each time it is needed. For example, 12! is the greatest value for the factorial function that can be stored in a 32-bit int variable. If you are writing a program that often computes factorials, it is likely to be much more time efficient to simply pre-compute and store the 12 values in a table. Whenever the program needs the value of n!n! it can simply check the lookup table. (If n>12n > 12, the value is too large to store as an int variable anyway.) Compared to the time required to compute factorials, it may be well worth the small amount of additional space needed to store the lookup table.

Lookup tables can also store approximations for an expensive function such as sine or cosine. If you compute this function only for exact degrees or are willing to approximate the answer with the value for the nearest degree, then a lookup table storing the computation for exact degrees can be used instead of repeatedly computing the sine function. Note that initially building the lookup table requires a certain amount of time. Your application must use the lookup table often enough to make this initialisation worthwhile.

Example: Binsort

Another example of the space/time tradeoff is typical of what a programmer might encounter when trying to optimise space. Here is a simple code fragment for sorting an array of integers. We assume that this is a special case where there are nn integers whose values are a permutation of the integers from 0 to n1n-1. This is an example of a binsort. Binsort assigns each value to an array position corresponding to its value.

newArr = new Array(arr.size)
for i in 0 .. arr.size-1:
    newArr[arr[i]] = arr[i]

This is efficient and requires O(n)O(n) time. However, it also requires two arrays of size nn, so the additional space usage is O(n)O(n).

Next is a code fragment that places the permutation in order but does so within the same array (thus it is an example of an in-place sort).

for i in 0 .. arr.size-1:
    while arr[i] != i:
        swap(arr, i, arr[i])  // Swap element arr[i] with arr[arr[i]]

The function swap(arr,i,j) exchanges elements i and j in array arr. It may not be obvious that the second code fragment actually sorts the array. To see that this does work, notice that each pass through the for loop will at least move the integer with value ii to its correct position in the array, and that during this iteration, the value of arr[i] must be greater than or equal to ii. A total of at most nn swap operations take place, because an integer cannot be moved out of its correct position once it has been placed there, and each swap operation places at least one integer in its correct position. Thus, this code fragment has cost O(n)O(n), just as the first fragment. However, it requires more time to run than the first code fragment. On an ordinary computer the second version takes nearly twice as long to run as the first, but it only needs constant additional space (for the i variable).

A second principle for the relationship between a program’s space and time requirements applies to programs that process information stored on disk. Strangely enough, the disk-based space/time tradeoff principle is almost the reverse of the space/time tradeoff principle for programs using main memory.

The disk-based space/time tradeoff principle states that the smaller you can make your disk storage requirements, the faster your program will run. This is because the time to read information from disk is enormous compared to computation time, so almost any amount of additional computation needed to unpack the data is going to be less than the disk-reading time saved by reducing the storage requirements. Naturally this principle does not hold true in all cases, but it is good to keep in mind when designing programs that process information stored on disk.