algorithms
algorithms
Algorithms
15.1 Introduction
The techniques we’ve developed earlier in this course can be applied to ana-
lyze how much time a computer algorithm requires, as a function of the size
of its input(s). We will see a range of simple algorithms illustrating a variety
of running times. Three methods will be used to analyze the running times:
nested loops, resource consumption, and recursive definitions.
We will figure out only the big-O running time for each algorithm, i.e.
ignoring multiplicative constants and behavior on small inputs. This will
allow us to examine the overall design of the algorithms without excessive
complexity. Being able to cut corners so as to get a quick overview is a
critical skill when you encounter more complex algorithms in later computer
science classes.
176
CHAPTER 15. ALGORITHMS 177
1, and you often need to examine the last subscript to find out the length.
Sequences can be stored using either an array or a linked list. The choice
sometimes affects the algorithm analysis, because these two implementation
methods have slightly different features.
An array provides constant-time (O(1)) access to any element. So you
can quickly access elements in any order you choose and the access time does
not depend on the length of the array. However, the length of an array is
fixed when the array is built. Changing the array length takes O(n) time,
where n is the length of the array. This is often called linear time. Adding
or deleting objects in the middle of the array requires pushing other objects
sideways, which typically also takes linear time. Two-dimensional arrays are
similar, except that you need to supply two subscripts e.g. ax,y .
In a linked list, 1 each object points to the next object in the list. An
algorithm has direct access only to the element at the head of the list. Other
elements can be accessed only by walking element-by-element from the head.
However, it’s comparatively easy to add and remove elements.
Specifically, a linked list starts with its head and ends with its tail. For
example, suppose our list is L = (1, 7, 3, 4, 7, 19). Then
• The function first returns the first element of the list. E.g. first(L)
returns 1.
• The function rest returns the list missing its first element. E.g. rest(L)
returns (7, 3, 4, 7, 19).
• The function cons adds a new element onto the head of the list, return-
ing the new (longer) list. So cons(35,L) will return (35, 1, 7, 3, 4, 7, 19).
It takes constant time to add, remove, or read/write the value at the head
of the list. The same applies to locations that are constant number of places
from the head, e.g. changing the value at the the third position in the list.
Adding, removing or accessing the values at other positions takes linear time.
With careful bookkeeping, 2 it is also possible to read/write or add values at
1
If you know some data structures, we’re assuming a singly-linked list.
2
See a data structures textbook for the gory details.
CHAPTER 15. ALGORITHMS 178
the tail of a list in constant time. However, accessing slightly earlier values
in the list or removing values from the tail takes O(n) time.
For some algorithms, the big-O performance does not depend on whether
arrays or linked lists are used. This happens when the number of objects is
fixed and the objects are accessed in sequential order. However, remember
that a big-O analysis ignores multiplicative constants. All other things be-
ing equal, array-based implementations tend to have smaller constants and
therefore run faster.
This code examines each pair of 2D points twice, once in each order.
We could avoid this extra work by having the inner loop (j) run Ponly from
i + 1 to n. In this case, the code inside both loops will execute ni=1 (n − i)
Pn−1
times. This is equal to i=0 i = n(n−1)
2
. This is still O(n2 ): our optimization
improved the constants but not the big-O running time. Improving the big-
O running time—O(n log n) is possible–requires restructuring the algorithm
and involves some geometrical detail beyond the scope of this chapter.
At line 13, we could have chosen to add n to the tail of Q. This algorithm
will work fine with either variation. This choice changes the order in which
nodes are explored, which is important for some other algorithms.
It’s not obvious how many times the while loop will run, but we can put
an upper bound on this. Suppose that G has n nodes and m edges. The
marking ensures that no node is put onto the list M more than once. So the
code in lines 7-9 runs no more than n times, i.e. this part of the function
takes O(n) time. Line 02 looks innocent, but notice that it must check all
nodes to make sure they are unmarked. This also takes O(n) time.
Now consider the code in lines 11-13. This code runs once per edge
traversed. During the whole run of the code, a graph edge might get traced
twice, once in each direction. There are m edges in the graph. So lines 11-13
cannot run more than 2m times. Therefore, this part of the code takes O(m)
time.
In total, this algorithm needs O(n + m) time. This is an interesting case
because neither of the two terms n or m dominates the other. It is true
that the number of edges m is no O(n2 ) and thus the connected component
algorithm is O(n2). However, in most applications, relatively few of these
CHAPTER 15. ALGORITHMS 181
potential edges are actually present. So the O(n + m) bound is more helpful.
Notice that there is a wide variety of graphs with n nodes and m edges.
Our analysis was based on the kind of graph that wold causes the algorithm
to run for the longest time, i.e. a graph in which the algorithm reaches every
node and traverses every edge, reaching t last. This is called a worst-case
analysis. On some input graphs, our code might run much more quickly, e.g.
if we encounter t early in the search or if much of the graph is not connected to
s. Unless the author explicitly indicates otherwise, big-O algorithm analyses
are normally understood to be worst-case.
√
Figure 15.3: Binary search for n
clean-up work in the main squareroot function takes only constant time. So
we can basically ignore its contribution. The function squarerootrec makes
one recursive call to itself and otherwise does a constant amount of work.
The base case requires only a constant amount of work. So if the running
time of squarerootrec is T (n), we can write the following recursive definition
for T (n), where c and d are constants.
• T (1) = c
• T (n) = T (n/2) + d
Because the two input lists are sorted, we can find the first (aka smallest)
element of the output list by comparing the first elements in the two input
lists (line 6). We then use a recursive call to merge the rest of the two lists
(line 7 or 9).
CHAPTER 15. ALGORITHMS 184
For merge, a good measure of the size of the input is the sum of the
lengths of the two input arrays. Let’s call this n. We can write the following
recursive definition for the running time of merge:
• T (1) = c
• T (n) = T (n − 1) + d
15.7 Mergesort
Mergesort takes an input linked list of numbers and returns a new linked list
containing the sorted numbers.4 Mergesort divides its big input list (length
n) into two smaller lists of length n/2. Lists are divided up repeatedly until
we have a large number of very short lists, of length 1 or 2 (depending on
the preferences of the code writer). A length-1 list is necessarily sorted. A
length 2 list can be sorted in constant time. Then, we take all these small
sorted lists and merge them together in pairs, gradually building up longer
and longer sorted lists until we have one sorted list containing all of our
original input numbers. Figure 15.5 shows the resulting pseudocode.
01 mergesort(L = a1 , a2 , . . . , an : list of real numbers)
02 if (n = 1) then return L
03 else
04 m = ⌊n/2⌋
05 L1 = (a1 , a2 , . . . , am )
06 L2 = (am+1 , am+2 , . . . , an )
07 return merge(mergesort(L1 ),mergesort(L2 ))
by element, from the head of the list down to the middle position. And it
does O(n) work merging the two results. So if the running time of mergesort
is T (n), we can write the following recursive definition for T (n), where c and
d are constants.
• T (1) = c
• T (n) = 2T (n/2) + dn
dn
dn/2 dn/2
The tree has O(log n) non-leaf levels and the work at each level sums
up to dn. So the work from the non-leaf nodes sums up to O(n log n). In
addition, there are n leaf nodes (aka base cases for the recursive function),
each of which involves c work. So the total running time is O(n log n) + cn
which is just O(n log n).
• T (1) = c
• T (n) = 2T (n − 1) + d
T (n) = 2T (n − 1) + d
= 2 · 2(T (n − 2) + d) + d
= 2 · 2(2(T (n − 3) + d) + d) + d
= 23 T (n − 3) + 22 d + 2d + d
k−1
X
= 2k T (n − k) + d 2i
i=0
k−1
X
k
T (n) = 2 T (n − k) + d 2i
i=0
n−2
X
= 2n−1c + d 2i
i=0
= 2n−1c + d(2n−1 − 1)
= 2n−1c + 2n−1 d − d
= O(2n )
suppose that our input numbers are x and y and they each have 2m digits.
We can then divide them up as
x = x1 2m + x0
y = y 1 2m + y 0
xy = A22m + B2m + C
• T (1) = c
• T (n) = 4T (n/2) + dn
B = (x1 + x0 )(y1 + y0 ) − A − C
This means we can compute B with only one multiplication rather that two.
So, if we use this formula for B, the running time of multiplication has the
recursive definition
CHAPTER 15. ALGORITHMS 189
• P (1) = c
It’s not obvious that we’ve gained anything substantial, but we have. If we
build a recursion tree for P , we discover that the kth level of the tree contains
3k problems, each involving n 21k work. So each non-leaf level requires n( 32 )k
work. The sum of the non-leaf work is dominated by the bottom non-leaf
level.
The tree height is log2 (n), so the bottom non-leaf level is at log2 (n) − 1.
This level requires n( 23 )log2 n work. If you mess with this expression a bit,
using facts about logarithms, you find that it’s O(nlog2 3 ) which is approxi-
mately O(n1.585 ).
The number of leaves is 3log2 n and constant work is done at each leaf.
Using log identities, we can show that this expression is also O(nlog2 3 ).
So this trick, due to Anatolii Karatsuba, has improved our algorithm’s
speed from O(n2 ) to O(n1.585 ) with essentially no change in the constants.
If n = 210 = 1024, then the naive algorithm requires (210 )2 = 1, 048, 576
multiplications, whereas Katatsuba’s method requires 310 = 59, 049 multipli-
cations. So this is a noticable improvement and the difference will widen as
n increases.
There are actually other integer multiplication algorithms with even faster
running times, e.g. Schoöhage-Strassen’s method takes O(n log n log log n)
time. But these methods are more involved.