Lesson 4
Data Structures and Graphs
Taught
Data Structures
Every program of the last lesson held a bounded number of values at once: a guess, a counter, a running total, the string being built up. A program that has to keep many values, for example to sort a list of numbers or to remember which candidates have been ruled out, needs somewhere to store them and a way to reach any one of them. A data structure is such a place together with the operations for using it.
We compare algorithms by explicit models of cost, and refine an implementation only once the broad algorithmic choice has been made. For every algorithm there are two things to prove: that it terminates, and that it is correct, meaning that the output it returns is a correct output for its input in the sense of a computational problem.
Functions
From here on an algorithm is usually written as a function: a named piece of code that takes inputs, called its arguments, and returns an output.
def square(x):
return x * x
print(square(7)) # 49
print(square(-3) + 1) # 10
The line def square(x): names the function and its parameter x; the indented body is its code. A call square(7) binds the parameter x to the argument 7, runs the body, and evaluates to the value named by return, which ends the call at once. A function whose body finishes without reaching a return returns None. Names bound inside the body are local: they exist only while that call runs and do not disturb names of the same spelling elsewhere.
def smallest(a, b, c):
target = a
if b < target:
target = b
if c < target:
target = c
return target
target = 100
print(smallest(4, -2, 9)) # -2
print(target) # 100, untouched by the call
A function may return several values at once as a tuple, and the caller may unpack them by multiple assignment: return p, q and then p, q = f(p, q).
In pseudocode we give a function a name and write its arguments in brackets, as in Fact(n), and return has its Python meaning.
Recursion
A function may call itself. The call f(n) then waits while f(n - 1) runs, and that call has its own local names, separate from those of the call that made it. The part of memory that keeps track of this is organised as a stack: each call places a frame holding its local names on top, and returning removes the top frame, so the most recent call is always the first to finish. The number of frames present at once is the depth of the recursion.
Recursion needs a case that does not call the function again, reached after finitely many calls; otherwise the stack grows until Python stops the program with a RecursionError.
def count_down(n):
if n == 0:
print('done')
else:
print(n)
count_down(n - 1)
count_down(3) # 3, 2, 1, done
Write a function digit_sum(n) that returns the sum of the decimal digits of a positive integer , once with a while loop and once recursively, using // and % only. State the depth of the recursion in terms of .
Induction and Loop Invariants
For a statement about every nonnegative integer, proof by induction has two parts: prove , then assume for an arbitrary and use that assumption to prove . If the statement begins at , start with . The base case gives , and the step then gives , and so on. The last lesson used this pattern for the closed forms of recurrences; we now use it for recursive algorithms and for loops.
Definition 4.1 (Loop invariant).
A loop invariant is a statement about the program state that
- holds before the first iteration (initialisation),
- is preserved by every iteration (maintenance), and
- gives the desired conclusion when the loop stops (use at termination).
An invariant plays the part of the induction hypothesis: initialisation is the base case, and maintenance is the step. It says nothing about whether the loop stops. That is proved separately by a decrementing function, often called a variant in this context.
Theorem 4.2 (Chocolate-bar strategy).
A rectangular chocolate bar has a marked corner square. A move removes a nonempty strip by one horizontal or vertical cut, retaining the piece containing the marked square. The player who receives the bar loses. The first player has a winning strategy exactly when the starting rectangle is not a square.
Discussion.
Write for the current numbers of rows and columns. The strategy is to hand the opponent a square every time: from a nonsquare, cut the longer side down to the shorter. The invariant is the pair of facts that the strategy user always receives a nonsquare and always hands over a square; initialisation is the starting position, and maintenance holds because every legal move from a square produces a nonsquare. The product falls with every move, so it serves as the variant and play reaches . Since is a square, the strategy user never receives it. The two cases of the statement say which player can use the strategy.
Proof.
Write for the current positive numbers of rows and columns. If , the player to move can cut the larger coordinate down to the smaller one and hand the other player a square. Every legal move from a square changes exactly one coordinate, so it makes the coordinates unequal; the next player can therefore make a square again. The invariant is that the player using this strategy always receives a nonsquare rectangle and always hands over a square.
Every move strictly reduces the positive integer , so play eventually reaches . The strategy user cannot receive , since that state is square. If the initial rectangle is nonsquare, Player One uses the strategy; if it is square, Player Two uses it after Player One’s first cut.
Starting from , Player One cuts to . If the opponent cuts to , Player One cuts to and the opponent loses.
One full round can be implemented as a loop: check whether Player One has received , let Player One move, check whether the opponent has received , then let the opponent move. Player One’s move is the square-producing cut:
Player One's Move
Input: the numbers p, q of rows and columns of the retained rectangle.
Output: the rectangle after the move.
if p > q then p ≝ q
else if q > p then q ≝ p
else p ≝ p − 1
return p, q
def player(p, q):
if p > q:
p = q
elif q > p:
q = p
else:
p = p - 1 # reached only from a square
return p, q
For a nonsquare starting position, the last branch is never reached under the winning strategy. The opponent may choose any legal cut; the proof above covers all such choices.
Recursive and Iterative Factorials
Recursive and iterative factorial functions show the connection between induction and invariants. Define and for .
Recursive Factorial Iterative Factorial
Input: n ∈ N₀. Input: n ∈ N₀.
Output: n!. Output: n!.
Fact(n): r ≝ 1
if n = 0 then return 1 for j ≝ 1 to n do r ≝ r · j
else return n · Fact(n − 1) return r
The recursive version returns at and otherwise returns ; induction on proves it correct, the base case being the first branch and the step the second. The iterative version begins with and multiplies by for . Before iteration the invariant is ; after the multiplication it becomes , so at exit . The variant decreases to zero.
def fact_recursive(n):
if n == 0:
return 1
return n * fact_recursive(n - 1)
def fact_iterative(n):
r = 1
for j in range(1, n + 1):
r = r * j
return r
print(fact_recursive(10), fact_iterative(10)) # 3628800 3628800
The recursive version has depth : the call for waits on the call for , down to . The iterative version uses a single frame. Python integers have no fixed size, so both return exactly however large it is. A product of large integers is then not an elementary operation, and costs what the schoolbook bound says.
The loop below is meant to compute for an integer and with about multiplications.
def power(a, n):
result, base, e = 1, a, n
while e > 0:
if e % 2 == 1:
result = result * base
base = base * base
e = e // 2
return resultShow that is a loop invariant, give a variant, and deduce that power is correct. How many multiplications does it perform, in terms of the binary expansion of ?
In the chocolate-bar game a move may instead remove a strip of width one or two only. Decide, for each starting rectangle with , which player has a winning strategy, and state and prove a rule covering every .
Measuring Cost
A program can spend most of its running time in a small part of its code. Choosing a better algorithm for that part usually does more than rewriting individual instructions, and much of the low-level rewriting is done automatically in any case by the software that translates a program into machine instructions. Costs other than time and memory can matter too, energy and money among them. For example, a “Penny Sort” measure asks how many externally stored records can be sorted for a US cent, after the purchase cost of a fixed machine is spread over an assumed working life. That measure combines hardware cost and algorithm throughput.
Example 4.4 (Which method is faster depends on ).
Suppose method A uses exactly operations on an input of size , while method B uses exactly . At , A uses operations and B uses ; at , A uses and B uses . The method with fewer operations depends on the input size, and the two cross at .
The running time of the last lesson counted elementary operations. We also measure memory.
Definition 4.5 (Time cost and auxiliary space).
The time cost of an algorithm counts operations in a stated model. Its auxiliary space, or memory footprint, is the maximum amount of extra storage present at any instant of a run, excluding the input.
Storage may be released and reused later, which is why the footprint is a maximum and not a cumulative total. In a conventional model allocating or touching a memory cell costs at least one operation, so an algorithm’s footprint cannot exceed a constant multiple of its running time. This claim depends on the model, and it does not say that every algorithm uses as much space as time.
The unit-cost assumption has to be stated with care. A comparison of two strings is not necessarily a single operation: it may inspect many characters. A comparison of two integers of bounded size is commonly counted as one operation, but for arbitrarily large integers or variable-length records the cost must be stated.
Lower and Two-Sided Bounds
Landau’s from the last lesson is an upper bound. It has a lower counterpart, and the two together give a two-sided bound.
Let . We write
As with , the equals sign is shorthand for membership of a set of functions, not equality of functions. The last lesson wrote , read ” grows strictly more slowly than ”, when tends to zero. Written out without limits: from some point on, and for every there is an with for every . This is stronger than , since the constant multiplier can be made as small as we wish by taking large enough.
The growth rates met most often are, from slowest to fastest,
Logarithm bases differ only by a constant factor, since by the change of base; so and are the same class and the base is usually left off.
Show that by exhibiting the constants, and that .
Lower Bounds
The comparison model of computation acts on a set of comparable objects. The objects are treated as black boxes supporting only binary tests called comparisons, namely , , , , and : each takes two objects and returns True or False according to their relative order. Nothing else about the objects may be inspected, so an algorithm in this model learns about its input only through the answers to comparisons, and its cost is counted as the number of comparisons it makes.
Definition 4.7 (Problem lower bound and optimality).
A problem lower bound is a bound that applies to every algorithm solving the problem in a specified model. An algorithm is asymptotically optimal for a cost measure when its upper bound matches the problem’s lower bound up to constants.
An upper bound is about one algorithm, and a lower bound is about every algorithm for the problem. A lower bound therefore has to fix a model, which lists the operations an algorithm may use.
Proposition 4.8 (Finding a maximum).
Finding the position of the maximum among pairwise distinct, otherwise unordered comparable elements requires at least comparisons in the comparison model, and comparisons suffice.
Discussion.
For the lower bound we count losses: an element can be ruled out as the maximum only once it has been seen to be smaller than something, and one comparison produces exactly one loser. All non-maximal elements must be ruled out, so at least comparisons are needed. For the upper bound a single left-to-right scan with a running maximum makes one comparison per element after the first, and its invariant says that the stored index is maximal in the prefix scanned so far.
Proof.
Before an element can be ruled out as the maximum, it must lose a comparison against a larger element. One comparison can give a first loss to at most one candidate. All but the true maximum, namely candidates, must be ruled out, so at least comparisons are necessary.
A scan keeps the index of the largest element seen and compares each of the remaining elements with it once; its invariant is that the stored index is maximal in the scanned prefix. Thus it meets the lower bound.
A second proof uses an adversary, an imagined opponent who may change the input as long as every answer already given stays true.
Proof.
Fix an input of distinct values and a nonmaximum element . Suppose is never compared with an element larger than itself. Change only its value, to one larger than every original value. Every comparison involving previously had a smaller other operand, so its outcome stays the same; all other comparisons are unchanged. A deterministic comparison algorithm therefore follows the same sequence of branches and returns the same index. But is now the true maximum, a contradiction. Thus each nonmaximum element must lose a comparison, which gives losses.
The scan in pseudocode and in Python:
Position of the Maximum
Input: a sequence T[0], …, T[n − 1] of comparable elements, n ⩾ 1.
Output: an index k with T[k] maximal.
k ≝ 0
for i ≝ 1 to n − 1 do
if T[i] > T[k] then k ≝ i
return k
def arg_max(T):
k = 0
for i in range(1, len(T)):
if T[i] > T[k]:
k = i
return k
print(arg_max((3, 9, 2, 9, 4))) # 1
Python’s len(T) gives the length of a tuple, as it does for a string, and T[i] its entry at position i.
Give an algorithm that finds both the maximum and the minimum of distinct elements with at most comparisons. Then use an adversary to show that at least comparisons are necessary.
The Word-RAM Model
To calculate the resources an algorithm uses we need to say how long a computer takes to perform basic operations. Fixing such a set of operations gives a model of computation, on which the analysis is then based. We use the -bit Word-RAM model, which treats a computer as a random-access array of machine words called memory, together with a processor that performs operations on that memory.
A machine word is a sequence of bits, read as an integer in . A Word-RAM processor performs each of the following in constant time:
- addition, subtraction, multiplication, integer division, remainder, bitwise operations and comparisons of two machine words;
- given a word , reading or writing the word stored in memory at address .
A machine word of bits can name at most addresses, so the processor can read and write at most locations of memory. When a problem’s input occupies machine words we therefore always assume a word size of bits, or the machine could not reach all of its input. For comparison, a Word-RAM model of a byte-addressable -bit machine allows inputs of up to about gigabytes.
Arrays
An array is a fixed number of storage slots in a row, numbered from , any of which may be read or written in a single elementary operation. The th slot of an array is written . In the Word-RAM an array of words is a block of consecutive addresses, and reaching means reading the address of plus : one addition and one read, whatever is and whatever the length of .
Python Lists
Python’s counterpart of an array is a list, written in square brackets. A list is a sequence like a tuple, but its entries may be changed.
p = [3, 1, 4, 1, 5]
print(len(p), p[0], p[4]) # 5 3 5
p[1] = 9
print(p) # [3, 9, 4, 1, 5]
q = [True] * 4 # [True, True, True, True]
r = [None] * 3 # [None, None, None]
Positions are numbered from , as they are for strings, and p[i] = x writes to position i. The expression [x] * n builds a list of n copies of x. Several further operations will be used below.
p.append(x)addsxat the end, andp.pop()removes the last entry and returns it;p.pop(i)removes and returns the entry at positioni.- The slice
p[i:j]is a new list holding the entries at positionsiup to but not includingj, with the same convention as for strings;p[i:]runs to the end andp[:j]starts at the beginning. Building a slice copies its entries. p + qis a new list holding the entries ofpfollowed by those ofq.- The comprehension
[f(a) for a in X]builds the list of valuesf(a)asaruns throughX. x is Nonetests whetherxis the objectNone.
Unlike a tuple, a list can grow and shrink; how Python does this is described under dynamic arrays below. Used with a fixed length it behaves as an array.
Listing the Primes
Deciding whether one number is prime was a decision problem; listing the primes up to a bound is a general discrete computational problem. It is the first problem here whose best algorithm uses an array, and it has a much faster algorithm than testing each number for primality.
List of Prime Numbers
Input: n ∈ N.
Task: compute all prime numbers p with p ⩽ n.
Running the trial-division test on each of in turn settles it in operations. The sieve of Eratosthenes does better by never testing divisibility at all: it writes down every candidate, then crosses out the multiples of each survivor in turn. It uses an array indexed by .
Example 4.10 (The sieve of Eratosthenes).
The algorithm marks every index as a candidate and then strikes out the multiples of each index that is still marked.
The Sieve of Eratosthenes
Input: n ∈ N.
Output: all prime numbers less than or equal to n.
for i ≝ 2 to n do p[i] ≝ "yes"
for i ≝ 2 to n do
if p[i] = "yes" then
output i
for j ≝ i to ⌊n / i⌋ do p[i · j] ≝ "no"The inner loop starts at rather than at : the multiples have a factor smaller than and were struck out already.
The running time depends on the total work of the inner loops, which is a sum of harmonic numbers, which have no closed form. What we need instead is a bound on them. The lower bound is not needed for the sieve; it is used for quicksort in the next lesson.
Proposition 4.11 (Bounds on the harmonic numbers).
For every ,
Discussion.
We group the terms into blocks between consecutive powers of two. The block running from to has terms, each at most and more than , so the block contributes between and whatever is. The number of blocks needed to cover is about , and both bounds follow. At the top the last block may be incomplete: for the upper bound we enlarge the sum to the end of that block, and for the lower bound we drop it.
Proof.
Let , so that . For the block of indices has terms, and each has , so
Every term is positive, so enlarging the range of summation increases the sum and shrinking it decreases the sum. The blocks cover , and the blocks cover . Hence
Theorem 4.12 (The sieve is correct and runs in ).
The sieve of Eratosthenes outputs exactly the primes less than or equal to , and performs elementary operations.
Discussion.
Correctness and running time are proved separately.
Correctness has two directions. No prime is ever struck out, because an entry is written to only as with both and at least , and such an index is composite by definition. In the other direction every composite must be struck before the outer loop reaches it, and the index that strikes it is its least divisor above : that divisor is itself prime, so it still carries “yes” when the outer loop arrives at it, and its partner divided by it is large enough to fall in the range the inner loop covers. That the partner is at least the divisor is Proposition 3.14, which is why the inner loop may start at .
For the running time, the outer loop costs by itself, and the inner loop belonging to runs at most times. Summing over gives times a harmonic sum, and the upper bound just proved turns that into .
Proof.
Correctness. An entry of is set to “no” only in the inner loop, where the index written to is with and . Such an index is a product of two integers greater than and so is composite; hence no prime is ever struck out, and every prime still carries “yes” when the outer loop reaches it and is output.
Conversely let be composite, and let be its least divisor with . Then is prime: a divisor of with would divide as well and contradict minimality. By Proposition 3.14, , so writing we have
and is an integer, so . Since is prime it is not struck out, so when the outer loop reaches the test succeeds and the inner loop runs, setting to “no”. Finally , so this happens before the outer loop reaches , and is not output. The algorithm therefore outputs the primes and nothing else.
Running time. The first loop performs assignments. In the second loop, each of the values of costs a bounded amount for the test and the output, contributing in total. The inner loop belonging to runs only when is “yes”, and then makes at most passes, each of bounded cost. Summing over ,
by the previous proposition. Adding the three contributions, the running time is .
In Python the array is a list of n + 1 entries, True for “yes” and False for “no”; positions and are never read.
def sieve(n):
p = [True] * (n + 1)
primes = []
for i in range(2, n + 1):
if p[i]:
primes.append(i)
for j in range(i, n // i + 1):
p[i * j] = False
return primes
print(sieve(50))
# [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
Count the assignments p[i * j] = False performed by the sieve for , and compare the count with the bound of the theorem. Then count how many of them write “no” to an entry that was already “no”, and say which composite numbers are struck more than once.
Modify the sieve so that the inner loop starts at rather than . Show that the output is unchanged, and say how the count of assignments changes for .
Show that the sieve may stop its outer loop at provided the surviving indices above that point are output afterwards. Which part of the proof of the theorem does this rely on?
Data Structures and Interfaces
A data structure is a way to store a non-constant amount of data, supporting a set of operations to interact with that data. The set of operations a data structure supports is its interface. Many data structures may support the same interface and differ in the cost of each operation, and many problems become easy once the data are stored in a suitable structure.
The most primitive data structure native to the Word-RAM is the static array: a contiguous sequence of words reserved in memory, supporting the static sequence interface.
StaticArray(n): allocate a new static array of size , every entry initialised to , in time.get_at(i): return the word stored at index , in time.set_at(i, x): write the word to index , in time.
The operations get_at(i) and set_at(i, x) run in constant time because every item of the array has the same size, one machine word. To store a larger object at an index, the machine word there is read as the memory address of a larger piece of memory holding the object. A Python tuple is like a static array without set_at(i, x).
Classes
Python writes a data structure as a class: a bundle of stored values, called attributes, together with the functions that act on them, called methods.
class Counter:
def __init__(self, start):
self.value = start
def increment(self):
self.value = self.value + 1
def __len__(self):
return self.value
c = Counter(5)
c.increment()
print(c.value, len(c)) # 6 6
Calling Counter(5) creates a new object of the class and runs __init__ on it, with self bound to the new object and start to 5; the assignment self.value = start creates the attribute value. A call c.increment() runs the method with self bound to c. A few method names are special: len(c) calls c.__len__(), and a for loop over c calls c.__iter__().
Inside a method, yield x hands x to the for loop that is running over the object, then carries on from the same point when the loop asks for the next value; yield from X hands on every value of X in turn. This is how range produces its entries one at a time. The statement raise IndexError stops the program with an error naming a bad index, and assert test stops it with an error if test is False.
A class may be built on another: class B(A): gives B every method of A, and any method B defines with the same name replaces the one from A. Inside B, super().__init__() runs the __init__ of A.
A Static Array in Python
Python has no static array, so we imitate one with a list whose length we never change.
class StaticArray:
def __init__(self, n):
self.data = [None] * n
def get_at(self, i):
if not (0 <= i < len(self.data)): raise IndexError
return self.data[i]
def set_at(self, i, x):
if not (0 <= i < len(self.data)): raise IndexError
self.data[i] = x
Birthday Matching
Given the students of a class, each with a name and a birthday, we want two students who share a birthday, or a report that there are none. The algorithm keeps a record of the students seen so far, and checks each new student against it before adding them.
Birthday Match
Input: n students, each a pair (name, birthday).
Output: the names of two students with the same birthday, or None.
record ≝ a static array of length n
for k ≝ 0 to n − 1 do
(name₁, bday₁) ≝ student k
for i ≝ 0 to k − 1 do
(name₂, bday₂) ≝ record[i]
if bday₁ = bday₂ then return (name₁, name₂)
record[k] ≝ (name₁, bday₁)
return None
def birthday_match(students):
# students: tuple of (name, bday) tuples
n = len(students) # O(1)
record = StaticArray(n) # O(n)
for k in range(n): # n passes
(name1, bday1) = students[k] # O(1)
for i in range(k): # k passes: check the record
(name2, bday2) = record.get_at(i) # O(1)
if bday1 == bday2: # O(1)
return (name1, name2) # O(1)
record.set_at(k, (name1, bday1)) # O(1)
return None # O(1)
print(birthday_match((('Ada', 'Dec 10'), ('Alan', 'Jun 23'), ('Emmy', 'Mar 23'), ('Kurt', 'Jun 23'))))
# ('Kurt', 'Alan')
We assume that each name and each birthday fits into a constant number of machine words, so that one student’s information can be read and compared in constant time. This allows names and birthdays of characters from a fixed alphabet, and since it still allows every student’s information to be distinct.
Every line then takes constant time except three. Building record takes time; the outer loop makes at most passes; and the inner loop on pass runs through the entries already in the record. The running time is therefore at most
using the sum of an arithmetic progression. This is quadratic in . A different data structure for the record does better, and the hashing section at the end of this chapter gives one.
Suppose birthdays are given as integers (with for 29 February). Rewrite birthday_match so that it runs in time, using a static array of length indexed by birthday. Where does your running-time argument use the fact that the number of possible birthdays does not grow with ?
Sequences and Sets
We use two interfaces, which differ in what decides the order of the stored items.
Sequences maintain a collection of items in an extrinsic order: each stored item has a rank in the sequence, including a first item and a last item. Extrinsic means that the first item is first not because of what the item is, but because some external party put it there. An iterable below is anything a for loop can run over, such as a tuple, a string, a list or a range.
| Operation | Meaning | |
|---|---|---|
| Container | build(X) | given an iterable X, build a sequence from the items of X |
len() | return the number of stored items | |
| Static | iter_seq() | return the stored items one by one in sequence order |
get_at(i) | return the item of rank i | |
set_at(i, x) | replace the item of rank i with x | |
| Dynamic | insert_at(i, x) | add x as the item of rank i |
delete_at(i) | remove and return the item of rank i | |
insert_first(x) | add x as the first item | |
delete_first() | remove and return the first item | |
insert_last(x) | add x as the last item | |
delete_last() | remove and return the last item |
The insert and delete operations change the rank of every item after the one inserted or deleted. Two restricted forms of a sequence have names of their own.
Definition 4.13 (Stack and queue).
A stack is a sequence used only through insert_last and delete_last: the item removed is always the one most recently added, “last in, first out”. A queue is a sequence used only through insert_last and delete_first: the item removed is always the one added longest ago, “first in, first out”.
The call stack of the functions section is a stack in this sense. With a Python list, append and pop() are insert_last and delete_last.
Sets, by contrast, maintain a collection of items based on an intrinsic property of what the items are, usually a unique key x.key attached to each item x. Sets generalise dictionaries and other databases queried by content.
| Operation | Meaning | |
|---|---|---|
| Container | build(X) | given an iterable X, build a set from the items of X |
len() | return the number of stored items | |
| Static | find(k) | return the stored item with key k |
| Dynamic | insert(x) | add x to the set, replacing the item with key x.key if there is one |
delete(k) | remove and return the stored item with key k | |
| Order | iter_ord() | return the stored items one by one in key order |
find_min() | return the stored item with smallest key | |
find_max() | return the stored item with largest key | |
find_next(k) | return the stored item with smallest key larger than k | |
find_prev(k) | return the stored item with largest key smaller than k |
The find operations return None if no qualifying item exists. In Python an item with a key can be an object of a small class:
class Item:
def __init__(self, key, value):
self.key = key
self.value = value
We now give three data structures for the sequence interface. None of them supports insertion or deletion at an arbitrary rank in less than linear time.
Array Sequences
Computer memory is a finite resource. On a modern computer many running programs share the same main memory, so the operating system assigns a fixed range of memory addresses to each of them. When a program asks to store a variable it must say how much memory, how many bits, the variable needs; the operating system finds that much free memory in the program’s assigned range and reserves it, or allocates it, until it is no longer needed. Python hides memory management from the programmer, but whenever Python is asked to store something it makes such a request, for a fixed amount of memory, behind the scenes.
Now suppose a program wants to store two arrays, each of ten -bit words. It makes two requests, for bits each, and the operating system might reserve the first ten words of the program’s range for the first array and the next ten for the second array . Later an eleventh word has to be added to , and there is no room next to : the start of the range is to its left, and is to its right. One could shift right to make room, but much other data may already be reserved beyond and would have to move too. It is better to request eleven new words, copy into the start of the new allocation, store at the end, and release the old ten words for later requests.
Memory itself is one large fixed-length array, from which the operating system allocates. Implementing a sequence with an array, so that index of the array holds the item of rank , makes get_at and set_at take time by random access. Inserting or deleting, however, means moving items and resizing the array, and these operations take linear time in the worst case.
class Array_Seq:
def __init__(self): # O(1)
self.A = []
self.size = 0
def __len__(self): return self.size # O(1)
def __iter__(self): yield from self.A # O(n) iter_seq
def build(self, X): # O(n)
self.A = [a for a in X] # stands in for a static array
self.size = len(self.A)
def get_at(self, i): return self.A[i] # O(1)
def set_at(self, i, x): self.A[i] = x # O(1)
def _copy_forward(self, i, n, A, j): # O(n)
for k in range(n):
A[j + k] = self.A[i + k]
def _copy_backward(self, i, n, A, j): # O(n)
for k in range(n - 1, -1, -1):
A[j + k] = self.A[i + k]
def insert_at(self, i, x): # O(n)
n = len(self)
A = [None] * (n + 1)
self._copy_forward(0, i, A, 0)
A[i] = x
self._copy_forward(i, n - i, A, i + 1)
self.build(A)
def delete_at(self, i): # O(n)
n = len(self)
A = [None] * (n - 1)
self._copy_forward(0, i, A, 0)
x = self.A[i]
self._copy_forward(i + 1, n - i - 1, A, i)
self.build(A)
return x
def insert_first(self, x): self.insert_at(0, x) # O(n)
def delete_first(self): return self.delete_at(0) # O(n)
def insert_last(self, x): self.insert_at(len(self), x) # O(n)
def delete_last(self): return self.delete_at(len(self) - 1) # O(n)
_copy_forward(i, n, A, j) copies the items starting at index into the array A starting at index , from left to right; _copy_backward copies the same items from right to left, which matters when source and destination overlap. A method name starting with an underscore is a convention for “used only inside the class”.
In a sorted array, whose items are arranged in the order of their keys, an item can be found far faster than by scanning, by the bisection of the last lesson; binary search in the next lesson makes this precise. Inserting an object while preserving the order is then harder, and costs time. Deleting an element, even when its index is known, also costs time if the array is to have no gaps and keep the order of the remaining elements.
Trace Array_Seq on build((5, 7, 9)), then insert_at(1, 6), then delete_at(0), giving the list self.A after each operation. Count the item copies each of the two dynamic operations makes, and give that count for insert_at(i, x) on a sequence of length .
Linked Lists
In a linked list, inserting or deleting an item does not move the others. Its items are kept in a certain order, as in an array, but they can be stored anywhere in memory, in places independent of one another. With each item we store a reference to the place of the next item, its successor; for the last item this reference is None, which marks the end of the list. One further reference to the first item, the head, is needed to reach the list at all.
Linked lists can be singly or doubly linked. In a doubly linked list each item also stores a reference to its predecessor, and the list keeps a reference to its last item, the tail. This allows an item whose place is known to be deleted with a number of steps bounded by a constant independent of the length of the list: the running time is .
None. The names first and last refer to the two ends. Deleting the accented parts leaves a singly linked list.The Python below is a singly linked list. A node holds an item and a reference next; later_node(i) walks steps along the list.
class Linked_List_Node:
def __init__(self, x): # O(1)
self.item = x
self.next = None
def later_node(self, i): # O(i)
if i == 0: return self
assert self.next
return self.next.later_node(i - 1)
class Linked_List_Seq:
def __init__(self): # O(1)
self.head = None
self.size = 0
def __len__(self): return self.size # O(1)
def __iter__(self): # O(n) iter_seq
node = self.head
while node:
yield node.item
node = node.next
def build(self, X): # O(n)
for a in reversed(X):
self.insert_first(a)
def get_at(self, i): # O(i)
node = self.head.later_node(i)
return node.item
def set_at(self, i, x): # O(i)
node = self.head.later_node(i)
node.item = x
def insert_first(self, x): # O(1)
new_node = Linked_List_Node(x)
new_node.next = self.head
self.head = new_node
self.size += 1
def delete_first(self): # O(1)
x = self.head.item
self.head = self.head.next
self.size -= 1
return x
def insert_at(self, i, x): # O(i)
if i == 0:
self.insert_first(x)
return
new_node = Linked_List_Node(x)
node = self.head.later_node(i - 1)
new_node.next = node.next
node.next = new_node
self.size += 1
def delete_at(self, i): # O(i)
if i == 0:
return self.delete_first()
node = self.head.later_node(i - 1)
x = node.next.item
node.next = node.next.next
self.size -= 1
return x
def insert_last(self, x): self.insert_at(len(self), x) # O(n)
def delete_last(self): return self.delete_at(len(self) - 1) # O(n)
reversed(X) runs through X from its last entry to its first, so that inserting each at the front leaves them in their original order; a test while node: holds as long as node is not None.
Linked lists have a disadvantage: bisection cannot be applied to them, since reaching the middle item means walking to it. Scanning a linked list is also slower than scanning an array, by a constant factor only, because today’s computers reach consecutive storage places substantially faster than places far apart.
Add a method reverse() to Linked_List_Seq that reverses the order of the items in time and auxiliary space, by changing the next references rather than the items. State the invariant your loop maintains.
Dynamic Arrays
The array sequence’s dynamic operations take time linear in the length of the array. One way to add items without paying a linear transfer cost every time is to over-allocate: request more space than the array currently needs, so that inserting an item means writing it into the next empty slot. This trades a little extra space for constant-time insertion. Any extra allocation is bounded, however; repeated insertions eventually fill it, and the array must be reallocated and copied again. Extra space reserved also means less space for the rest of the program.
Python does not append to the end of a list in worst-case time. Sometimes appending to a Python list requires time to transfer the array to a larger allocation, so sometimes appending takes linear time. Allocating extra space in the right way guarantees that any sequence of insertions takes time in total, because the linear-time transfers happen rarely, so insertion takes time per insertion on average over the sequence.
Definition 4.14 (Amortized cost).
An operation has amortized cost if every sequence of operations, starting from an empty data structure, takes at most time in total, where is the largest size the structure reaches.
The cost of an expensive operation is amortized, that is spread, across the many cheap ones. To achieve amortized constant-time insertion at the end of an array, the strategy is to allocate extra space in proportion to the size of the array stored. Allocating extra space ensures that a linear number of insertions must occur before an insertion overflows the allocation. A typical implementation allocates double the space needed for the current array, which is called table doubling; any constant fraction of extra space achieves the same bound. The list implementation of CPython, the standard Python interpreter, has used the rule
new_allocated = newsize // 8 + (3 if newsize < 9 else 6) # extra slots
when a list must grow to newsize items, translated here from C; recent versions use a rule of the same shape. The extra allocation is modest, about one eighth of the size of the array, but it is still linear in that size, so on average insertions are performed for every linear-time reallocation: amortized constant time.
Now consider removing items from the end. Popping the last item can be done in constant time by decrementing a stored length, which Python does. But if many items are removed from a large list, the unused allocation can hold a large amount of memory that is not available for other purposes. Once the array is small enough we transfer its contents to a smaller allocation and release the larger one. The new allocation cannot be exactly the size of the array, since an immediate insertion would then trigger another reallocation. For constant amortized time over any sequence of appends and pops, there must remain a linear fraction of unused space whenever we rebuild into a smaller array, which guarantees that further operations must occur before the next reallocation.
The implementation below does both with table-doubling proportions. When an append would pass the end of the allocation, the contents move to an allocation twice as large. When removals bring the array down to a quarter of its allocation, the contents move to an allocation half as large. Python lists already work this way; the code shows how amortized constant-time append and pop can be implemented.
class Dynamic_Array_Seq(Array_Seq):
def __init__(self, r = 2): # O(1)
super().__init__()
self.size = 0
self.r = r
self._compute_bounds()
self._resize(0)
def __len__(self): return self.size # O(1)
def __iter__(self): # O(n)
for i in range(len(self)): yield self.A[i]
def build(self, X): # O(n)
for a in X: self.insert_last(a)
def _compute_bounds(self): # O(1)
self.upper = len(self.A)
self.lower = len(self.A) // (self.r * self.r)
def _resize(self, n): # O(1) or O(n)
if (self.lower < n < self.upper): return
m = max(n, 1) * self.r
A = [None] * m
self._copy_forward(0, self.size, A, 0)
self.A = A
self._compute_bounds()
def insert_last(self, x): # O(1) amortized
self._resize(self.size + 1)
self.A[self.size] = x
self.size += 1
def delete_last(self): # O(1) amortized
self.A[self.size - 1] = None
self.size -= 1
self._resize(self.size)
def insert_at(self, i, x): # O(n)
self.insert_last(None)
self._copy_backward(i, self.size - (i + 1), self.A, i + 1)
self.A[i] = x
def delete_at(self, i): # O(n)
x = self.A[i]
self._copy_forward(i + 1, self.size - (i + 1), self.A, i)
self.delete_last()
return x
def insert_first(self, x): self.insert_at(0, x) # O(n)
def delete_first(self): return self.delete_at(0) # O(n)
def __init__(self, r = 2) gives the parameter r a default value: Dynamic_Array_Seq() uses r = 2, and Dynamic_Array_Seq(3) uses r = 3. The class inherits get_at, set_at and the two copying methods from Array_Seq.
Proposition 4.15 (Appending is amortized constant time).
Starting from an empty Dynamic_Array_Seq with , any sequence of calls of insert_last takes time in total.
Discussion.
Each call does a constant amount of work apart from reallocations, so we bound the total cost of the reallocations. A reallocation at size allocates about slots and copies items, so it costs . The sizes at which reallocations happen are the points where the allocation fills up, and because the allocation doubles each time these sizes grow geometrically. Their sum is therefore dominated by the last one, which is less than , and a geometric sum bounds the total by a constant times .
Proof.
Without removals lower never exceeds a quarter of the allocation, so _resize(s + 1) reallocates exactly when reaches the current allocation upper. The initial call _resize(0) allocates slots. After a reallocation triggered at size the allocation becomes . So the allocations are , and the insertions that reallocate are those that bring the size to ; the one bringing the size to copies items and allocates slots, at cost at most for a constant .
Over insertions these are the with , that is with . Their total cost is at most
by the geometric sum. Every other part of every call costs , contributing . The total is .
The worst-case costs of the three sequence structures are collected below, with (a) marking an amortized bound.
| Data structure | build(X) | get_at(i), set_at(i, x) | insert_first(x), delete_first() | insert_last(x), delete_last() | insert_at(i, x), delete_at(i) |
|---|---|---|---|---|---|
| Array | |||||
| Linked list | |||||
| Dynamic array | (a) |
Each entry is the bound as a function of the number of stored items.
Take and start from an empty Dynamic_Array_Seq. Show that any sequence of operations, each an insert_last or a delete_last on a nonempty array, takes time in total. Then show that if the array were halved as soon as it fell to half full, rather than a quarter, some sequence of operations would take time.
Extend Linked_List_Seq with a reference to its last node so that insert_last takes time, and extend Dynamic_Array_Seq so that insert_first and delete_first take amortized time. Which operation cannot be made in a singly linked list with a tail reference, and why?
Hashing
The set interface asks for find(k). Stored in an array in no particular order, a set answers find(k) by scanning, in time. With comparisons alone we cannot do much better: the lower bound for searching in the next lesson shows that comparisons are needed for a search among items. Reading memory at an address computed from the key is not a comparison, and it reaches any memory cell in one step. Hashing uses this.
Direct Access Arrays
A direct access array is a static array with a meaning attached to each index: an item with key is stored at index . This makes sense only when keys are integers. Anything stored in a computer can be associated with an integer, for example its sequence of bits read as a binary number, or its address in memory, so from now on keys are integers.
Suppose we want to store a set of items whose unique integer keys lie in the range . We store them in a direct access array of length , whose slot holds the item with key if there is one. To find the item with key , look in slot : worst-case constant time. The order operations are slow: the first, last or next item could be in any slot, so they may take time.
class DirectAccessArray:
def __init__(self, u): self.A = [None] * u # O(u)
def find(self, k): return self.A[k] # O(1)
def insert(self, x): self.A[x.key] = x # O(1)
def delete(self, k): self.A[k] = None # O(1)
def find_next(self, k): # O(u)
for i in range(k + 1, len(self.A)):
if self.A[i] is not None:
return self.A[i]
def find_max(self): # O(u)
for i in range(len(self.A) - 1, -1, -1):
if self.A[i] is not None:
return self.A[i]
def delete_max(self): # O(u)
for i in range(len(self.A) - 1, -1, -1):
x = self.A[i]
if x is not None:
self.A[i] = None
return x
A direct access array needs a slot for every possible key in the range. When is very large compared with the number of items stored, the array is wasteful, or impossible to store at all. Suppose we wanted find(k) on ten-letter names with a direct access array. There are possible names, and even an array of one bit per name would need terabytes.
The following counting principle is used below to show that collisions cannot be avoided.
Proposition 4.16 (The pigeonhole principle).
If objects are placed in boxes, some box contains at least objects.
Discussion.
The argument is by contradiction on the total. If every box held fewer than objects, each would hold at most , and the boxes together would hold fewer than . It remains to check the arithmetic step that , which is the defining property of the ceiling.
Proof.
Suppose every box contains at most objects. By the definition of the ceiling, , so the total number of objects is at most , a contradiction.
Hash Functions
To keep fast search while using only space when is much smaller than , we store the items in a smaller direct access array of slots, growing and shrinking it like a dynamic array according to the number of items stored. This needs a way to send each key to one of the slots.
Definition 4.17 (Hash function and hash table).
A hash function is a function
and is the hash of the key . The smaller direct access array of slots in which an item with key is stored at slot is a hash table. Two keys collide if .
If happens to be injective on the keys being stored, so that no two of them collide, the hash table acts as a direct access array over the smaller range and supports worst-case constant-time search. When , however, the pigeonhole principle puts at least two of the possible keys in some slot, and when the keys to be stored are not known in advance it is very unlikely that a chosen hash function avoids collisions among them. (If all the keys are known in advance, a scheme called perfect hashing can be designed to avoid collisions between them.)
A slot can hold one item, so colliding items must be stored somewhere. Either they are stored elsewhere in the same array, which is called open addressing and is how most hash tables are implemented in practice, though it is harder to analyse; or they are stored in a separate structure, which is called chaining and is the strategy we adopt.
Chaining
In chaining each slot of the hash table holds a reference to a chain, a separate data structure supporting the dynamic set operations find(k), insert(x) and delete(k). A chain is usually a linked list or a dynamic array, and any implementation will do provided each operation takes at most linear time in the length of the chain. To insert an item , insert it into the chain at slot ; to find or delete a key , find or delete it in the chain at slot .
A chain in Python can be a list of items searched from the front:
class Chain:
def __init__(self): self.items = [] # O(1)
def __iter__(self): yield from self.items # O(length)
def find(self, k): # O(length)
for x in self.items:
if x.key == k: return x
return None
def insert(self, x): # O(length)
for i in range(len(self.items)):
if self.items[i].key == x.key:
self.items[i] = x # replace, nothing added
return False
self.items.append(x)
return True
def delete(self, k): # O(length)
for i in range(len(self.items)):
if self.items[i].key == k:
return self.items.pop(i)
return None
We want chains to be short: if every chain holds a constant number of items, the dynamic set operations run in constant time. If instead the hash function sends every stored key to the same slot, one chain has linear length and the operations can take linear time. A good hash function keeps collisions rare, so that no chain grows long.
Choosing a Hash Function
The simplest map from keys in to is the division method: , in Python k % m. If the keys stored are spread evenly over the range, it spreads them roughly evenly among the slots and the chains stay short. But if all of them happen to leave the same remainder on division by , every one lands in one chain. We want performance that does not depend on which keys are stored, and no single hash function gives that.
Remark (Every fixed hash function has bad inputs).
If , then every hash function from to sends some keys to the same slot. By the pigeonhole principle some slot receives at least keys, and .
Instead we choose the hash function at random, from a large family, after the keys are fixed. Then no set of keys is bad for most of the family, and we can bound the cost on average over the choice of function. The averages we need are over a finite set of equally likely choices.
Definition 4.18 (Uniform choice from a finite family).
Let be a finite nonempty set, and let be chosen from with every member equally likely. For a property of members of , and a function , the probability of and the expectation of are
Two facts follow from the laws of summation. Expectation is linear: and , because the sum defining the left side splits into the sums defining the right. And the expectation of an Iverson bracket is a probability: , since the bracket contributes for each with and otherwise. The expectation here is over the choice of hash function, which is made independently of the input. It is not an average over possible input keys.
Definition 4.19 (Universal family).
A finite family of hash functions from to is universal if for any two keys in ,
A family that performs well is
where is a prime larger than . A single function of the family is specified by choosing concrete values of and . This family is universal. The proof uses arithmetic modulo a prime, which these notes have not developed, and we take it as given.
Proposition 4.20 (Expected chain length).
Let be a universal family, and let distinct keys be stored in a hash table of slots with chaining, using chosen uniformly from . For each , the expected number of stored keys in the chain at slot is at most .
Discussion.
The chain holding contains exactly the stored keys that collide with , together with itself. So its length is a sum of Iverson brackets, one for each stored key, and linearity turns the expectation of the sum into a sum of expectations. Each bracket’s expectation is a collision probability: the one for is , and each of the others is at most by universality.
Proof.
For each let , which is if and collide under and otherwise. The number of stored keys in the chain at slot is , and for every . By linearity and universality,
If the table is at least linear in the number of items stored, , the expected length of any chain is . A hash table with chaining and a hash function chosen at random from a universal family therefore performs the dynamic set operations in expected constant time, the expectation being over the choice of hash function and not over the input keys. To keep , insertions and deletions may have to rebuild the table at a different size and reinsert every item, as a dynamic array does; this makes the bounds for the dynamic operations amortized as well.
A Hash Table in Python
The statement from random import randint makes the function randint available: randint(a, b) returns an integer chosen uniformly from . The keys are assumed to be integers below the prime . In [Chain() for _ in range(m)] the name _ is the usual name for a loop variable whose value is not used.
from random import randint
class Hash_Table_Set:
def __init__(self): # O(1)
self.A = []
self.size = 0
self.p = 2**31 - 1 # a prime larger than every key
self.a = randint(1, self.p - 1)
self.b = randint(0, self.p - 1)
self._compute_bounds()
self._resize(0)
def __len__(self): return self.size # O(1)
def __iter__(self): # O(n)
for chain in self.A:
yield from chain
def build(self, X): # O(n) expected
for x in X: self.insert(x)
def _hash(self, k, m): # O(1)
return ((self.a * k + self.b) % self.p) % m
def _compute_bounds(self): # O(1)
self.upper = len(self.A)
self.lower = len(self.A) // 4
def _resize(self, n): # O(n)
if self.lower < n < self.upper: return
m = max(n, 1) * 2
A = [Chain() for _ in range(m)]
for x in self:
A[self._hash(x.key, m)].insert(x)
self.A = A
self._compute_bounds()
def find(self, k): # O(1) expected
h = self._hash(k, len(self.A))
return self.A[h].find(k)
def insert(self, x): # O(1) amortized expected
self._resize(self.size + 1)
h = self._hash(x.key, len(self.A))
added = self.A[h].insert(x)
if added: self.size += 1
return added
def delete(self, k): # O(1) amortized expected
assert len(self) > 0
h = self._hash(k, len(self.A))
x = self.A[h].delete(k)
if x is not None:
self.size -= 1
self._resize(self.size)
return x
def find_min(self): # O(n)
out = None
for x in self:
if (out is None) or (x.key < out.key):
out = x
return out
def find_max(self): # O(n)
out = None
for x in self:
if (out is None) or (x.key > out.key):
out = x
return out
def find_next(self, k): # O(n)
out = None
for x in self:
if x.key > k:
if (out is None) or (x.key < out.key):
out = x
return out
def find_prev(self, k): # O(n)
out = None
for x in self:
if x.key < k:
if (out is None) or (x.key > out.key):
out = x
return out
def iter_ord(self): # O(n^2)
x = self.find_min()
while x:
yield x
x = self.find_next(x.key)
The number of items stays strictly between a quarter of the number of slots and the number of slots; when it leaves that range the table is rebuilt with twice as many slots as items, which is the rule of Dynamic_Array_Seq with . So throughout, and by the proposition every chain has expected constant length.
Insert the keys in that order into a hash table of slots with chaining and the division method , and draw the table. Then do the same with . Describe every set of keys that makes the division method with put all keys into one chain.
Rewrite birthday_match using a Hash_Table_Set keyed by birthday, with birthdays given as integers, so that it runs in expected time. Explain why the bound is an expectation and what it is taken over.
Graphs
A graph records which pairs of objects are related: towns joined by roads, people who know each other, states of a computation that lead to one another. The objects are the vertices and the related pairs are the edges.
Undirected Graphs
Definition 4.21 (Undirected graph).
An undirected graph is a pair , where is a finite set of vertices and is a set of unordered pairs of vertices. Such a pair is an edge. Unless stated otherwise, the two vertices of an edge are distinct.
The vertices joined by an edge are adjacent, and we write
for the set of vertices adjacent to . We draw an edge as a line between its endpoints.
Here and . Vertex is isolated: it belongs to but to no edge. For example, and .
Definition 4.22 (Loops, simple graphs and multigraphs).
A loop joins a vertex to itself. A graph is simple if it has no loops and at most one edge between any two vertices. If repeated edges are allowed, the edge collection is a multiset and the graph is a multigraph. We normally work with simple graphs.
Definition 4.23 (Order and size).
The order of a graph is its number of vertices. Its size is its number of edges, counted with multiplicity for a multigraph.
The graph of Figure 4.3 has order and size , even though one vertex is isolated. The pair is the same unordered edge as ; listing both would not add an edge to a simple graph.
Running times of graph algorithms are functions of the graph, not of a single number, and the notation of the last chapter is extended to cover them. For a graph we write and for its vertex set and edge set.
Definition 4.24 (Landau notation on graphs).
Let be the set of all graphs, and let . We say that if there exist and such that
The notations and are extended in the same way.
In other words, if is greater than , then by at most a constant factor, with exceptions allowed only among graphs with fewer than vertices and edges together. The same definition can be made on any countable set of inputs, with a measure of size in place of . The function usually describes the running time of an algorithm or some memory requirement, and often depends only on the numbers of vertices and edges, which we write and throughout. Thus means at most a constant times the number of vertices plus edges.
Directed Graphs
Definition 4.25 (Directed graph).
A directed graph is a pair with finite vertex set and arc set . An arc starts at and ends at , and is drawn . The vertex is a successor of , and a predecessor of .
We write
In Figure 4.4,
so for example and .
Definition 4.26 (Directed loops and -graphs).
A directed loop is an arc . A directed graph without loops is sometimes called elementary. A directed -graph permits at most parallel arcs with any given ordered endpoints; in a -graph the arcs form a set, not a multiset.
The pair and the pair are different arcs, and removing one changes the graph. Reversing every arc of a drawing exchanges each successor set with the corresponding predecessor set.
Let and put an arc whenever and divides . List , and every vertex with no successors, and count the arcs.
Degree
In an undirected graph, the degree of a vertex is the number of edge-ends at , a loop counting twice. In a directed graph, the out-degree counts the arcs beginning at , the in-degree counts the arcs ending at , and . A directed loop contributes one to each of and .
We write for the set of edges with an end at in an undirected graph, and and for the sets of arcs beginning and ending at in a directed graph.
In a graph without loops , and in a directed graph and , counting parallel arcs separately. In a simple graph , and in a directed -graph and . For multigraphs, arcs are counted with multiplicity rather than by taking the sizes of these sets.
In Figure 4.3 the degrees of the vertices are . Their sum is . In Figure 4.4, and : the loop counts once in each.
Proposition 4.28 (Counting edge-ends).
For an undirected graph and for a directed graph respectively,
Discussion.
Both sides of each identity count the same thing in two ways. The left side of the first sums, vertex by vertex, the edge-ends at that vertex; the right side counts the same edge-ends edge by edge, and every edge has exactly two ends, a loop included. For a directed graph every arc has one tail and one head, so summing out-degrees counts each arc once by its tail, and summing in-degrees counts it once by its head.
Proof.
Count the pairs in which is an end of the edge , a loop at giving two such pairs. Grouping by gives , by the definition of degree. Grouping by gives for every edge, so . For a directed graph, count the pairs in which the arc begins at : grouping by gives , and grouping by gives for every arc, so . Counting the pairs in which ends at gives in the same way.
Example 4.29 (Odd degrees and repeated degrees).
The degree sum of an undirected graph is , an even number. A sum of integers is even exactly when it contains an even number of odd terms: each even degree contributes zero modulo , and each odd degree contributes one. Thus the number of vertices of odd degree is even.
Also, among vertices of a simple graph, two have the same degree. Every degree lies in . However, degree means a vertex is adjacent to every other vertex, so no vertex can have degree at the same time. At most of the listed values can occur, and putting vertices into at most degree classes forces two into one class by the pigeonhole principle, Proposition 4.16 .
Kinds of Graphs
We may attach numbers to edges or arcs, to express distance, cost or capacity.
Definition 4.30 (Weighted graph).
A weighted graph is a graph together with a function , the weight. An undirected weight belongs to the unordered edge .
Definition 4.31 (Complete graph).
A simple undirected graph is complete if every pair of distinct vertices is joined; it is written when it has vertices. A loop-free directed -graph is complete when both and are arcs for every .
To count the edges of , choose two distinct vertices without regard to order. There are choices for the first vertex and for the second. This counts each unordered pair twice, once in each order, so has edges. We write
read ” choose ”, for this number of unordered pairs among objects. In the complete directed graph both directions are arcs, so it has arcs.
Let be a directed or undirected graph. For , the induced subgraph has vertex set and contains all edges or arcs of whose endpoints lie in . A subgraph may keep only some of these: in the directed case, with the analogous rule for unordered edges. A spanning subgraph, also called a partial subgraph, keeps every vertex but possibly deletes edges.
For a simple undirected graph, the possible edges of a subgraph on are the unordered pairs of distinct vertices of that were already edges of . Figure 4.6 shows both operations on a four-vertex directed graph.
Here is the spanning subgraph obtained by deleting , and . In contrast, the induced subgraph retains the arcs , , and : vertex disappears, while every arc of joining the remaining vertices stays. An arc between retained vertices cannot be omitted from an induced subgraph.
Definition 4.33 (Clique and independent set).
In a simple undirected graph, a clique is a vertex set for which is complete, and an independent set, also called a stable set, is a vertex set for which has no edges. For a loop-free directed -graph, a directed clique contains both arcs between each pair of its vertices, while a directed stable set has no arcs at all.
In Figure 4.7, is a clique of maximum size and is an independent set of maximum size. To test whether is a clique, check the three pairs , , ; to test whether is independent, check that none of its three pairs is an edge. Checking a particular set requires only pairwise tests. Proving that it is of maximum size also requires ruling out every larger set, and finding maximum cliques in arbitrary graphs is much harder than checking whether a proposed set is a clique.
Definition 4.34 (Bipartite and regular graphs).
An undirected graph is bipartite if its vertices can be divided into disjoint sets so that every edge has one endpoint in each. It is complete bipartite, written , if and every pair with one vertex in each set is an edge. A graph is -regular if every vertex has degree .
Example 4.35 ( and the four-cycle).
In , each of the two vertices of is joined to each of the three vertices of , giving edges. Its degrees are on and on , so it is not regular. The graph with vertices and edges , the cycle on four vertices, is -regular and bipartite, with classes and .
In general has edges. Fix a vertex of ; it is joined to each of the vertices of . There are such vertices, and every edge has exactly one endpoint in , so this counts each edge exactly once.
Example 4.36 (Counting the same thing twice).
Let a bipartite graph with parts be -regular, where , and let it have edges. Sum the degrees of the vertices in . Every edge has exactly one endpoint there, so this sum counts every edge once and equals . Each of the vertices has degree , so the same sum is . Therefore . Repeating the count over gives . The two expressions for are equal, and dividing by the positive number gives .
Example 4.37 (A monochromatic triangle).
Colour each edge of red or blue. Then there is a triangle whose three edges have the same colour. Pick a vertex . Five edges leave , so by the pigeonhole principle at least of them, say , share a colour; suppose it is red. If any edge among is red, that edge and the two red edges to make a red triangle. If none is red, all three edges among are blue, making a blue triangle. These cases cover every colouring, so the argument works for all of them.
Show that has a red and blue colouring of its edges with no triangle all of one colour, so that in the last example cannot be replaced by .
Show that a graph is bipartite if it has no cycle of odd length, in the sense of the next section, by colouring each vertex according to the parity of its distance from a fixed vertex in its component. Show conversely that a bipartite graph has no cycle of odd length.
Walks, Paths, Cycles and Distance
Definition 4.38 (Walks and paths in a directed graph).
In a directed graph, a walk from to is a sequence of vertices with , and for every . Its length is . The vertex is reachable from if such a walk exists. A simple path is a walk with no repeated vertex. A closed walk has and ; a simple directed cycle is a closed walk with no further repeated vertices. A loop is a cycle of length one.
A walk may repeat vertices; a simple path may not. We allow the walk of length zero from to itself, so that every vertex is reachable from itself. This convention is used for matrix powers below. When a positive number of arcs is required we say positive-length reachability explicitly.
In Figure 4.8, is a simple path, is a walk with the repeated vertex , is a simple directed cycle, and is a closed walk that is not a simple cycle.
Definition 4.39 (Walks, trails and cycles in an undirected graph).
In an undirected graph, consecutive vertices of a walk are joined by an edge, and walks, length, reachability and simple paths are as in the directed case. A trail is a walk that uses no edge twice. A cycle is a closed trail of positive length; a simple cycle additionally has no repeated vertices except its first and last. A graph with no cycles is acyclic. In a simple undirected graph a simple cycle has length at least three.
Proposition 4.40 (A walk contains a simple path).
If there is a walk from to , there is a simple path from to .
Discussion.
If a walk visits some vertex twice, the part between the two visits can be cut out, leaving a shorter walk between the same endpoints. So a walk with the fewest edges can have no repeated vertex. The proof takes such a shortest walk, which exists because walk lengths are nonnegative integers and at least one walk exists.
Proof.
Among all walks from to , choose one, , with the fewest edges. If it repeated a vertex, say with , then would be a walk from to with edges, since is an arc, or edge, of the graph. Hence no vertex repeats.
Definition 4.41 (Distance and diameter).
The distance is the length of a shortest walk from to , or if is not reachable from . The diameter of a nonempty graph is , which may be . In directed graphs, and may differ.
The two-argument is a distance and the one-argument a degree; the number of arguments tells them apart. A shortest walk to a different vertex is a simple path, by the proof of the last proposition, so it has at most arcs.
In Figure 4.8, via , and via . The arc gives , but there is no directed route from to , so .
Give the distance for every ordered pair of vertices of Figure 4.8, as a table, and find the diameter. Which vertices can reach every other vertex?
Representing a Graph
To run an algorithm on a graph we must store it. Let have its vertices numbered .
Adjacency Lists
An adjacency list lists the vertices for which is an arc, or is an edge. For an undirected edge with , appears in and appears in . The order within a list is arbitrary.
More generally, an adjacency list representation keeps for every vertex a list of all the edges incident with it, the set ; for simple graphs a list of the adjacent vertices, as above, is often enough. For a directed graph one keeps two lists for each vertex, one of the outgoing arcs and one of the incoming arcs .
Example 4.42 (Adjacency lists of a directed graph).
The directed graph of Figure 4.6 has
Its arc set is exactly the nine ordered pairs described by these lists. The loop at contributes the entry to , and the loop at contributes the entry to . There are entries, hence nine arcs; counting list entries is a direct way to check the arc count.
Storage. There are list headers. The total number of entries is for a directed graph with arcs and for an undirected graph with edges, a loop also taking two entries if loops are allowed. Thus storage is .
Operations. Reading gives the successors and the out-degree of quickly. Testing for one particular arc may require scanning . Finding all predecessors of requires scanning all the lists, unless reverse lists are stored as well. Visiting all arcs takes time, not merely , when isolated vertices must also be visited.
Adjacency Lists in Two Arrays
We can store the adjacency lists one after another in a single array . A second array gives the final position of each list, and gives the starting boundary of the first. The successors of then occupy
and an empty list has equal consecutive boundaries. For the graph of the last example,
The empty first list is encoded by ; the second list ends at position , and so on. These boundaries are cumulative counts: if is the length of list , then .
To find the predecessors of , scan each list and record its owner whenever an entry equals ; the result is . This costs even though only three names are recorded. The same cumulative-count idea produces all the predecessor lists in : count how many times each vertex occurs in , which gives the in-degrees; take cumulative sums to allocate a contiguous block to each vertex; then scan the arcs once more to fill the blocks.
Predecessor Lists
Input: n, and the arrays Head[0..n] and Succ[1..m] of a directed graph.
Output: arrays PHead[0..n] and Pred[1..m] storing the predecessor lists
in the same way.
for v ≝ 1 to n do c[v] ≝ 0
for i ≝ 1 to m do c[Succ[i]] ≝ c[Succ[i]] + 1
PHead[0] ≝ 0
for v ≝ 1 to n do
PHead[v] ≝ PHead[v − 1] + c[v]
free[v] ≝ PHead[v − 1] + 1
for u ≝ 1 to n do
for i ≝ Head[u − 1] + 1 to Head[u] do
w ≝ Succ[i]
Pred[free[w]] ≝ u
free[w] ≝ free[w] + 1
Python numbers list positions from , so the successors of are the slice Succ[Head[v - 1]:Head[v]] and the positions of the blocks shift down by one.
def predecessor_lists(n, Head, Succ):
count = [0] * (n + 1)
for w in Succ:
count[w] += 1
PHead = [0] * (n + 1)
free = [0] * (n + 1)
for v in range(1, n + 1):
PHead[v] = PHead[v - 1] + count[v]
free[v] = PHead[v - 1]
Pred = [None] * len(Succ)
for u in range(1, n + 1):
for w in Succ[Head[u - 1]:Head[u]]:
Pred[free[w]] = u
free[w] += 1
return PHead, Pred
PHead, Pred = predecessor_lists(4, [0, 0, 2, 6, 9], [2, 1, 1, 4, 3, 2, 1, 2, 3])
print(PHead, Pred) # [0, 3, 6, 8, 9] [2, 3, 4, 2, 3, 4, 3, 4, 3]
print(Pred[PHead[1]:PHead[2]]) # [2, 3, 4], the predecessors of 2
Each of the loops runs over the vertices or over the arcs once, so the running time is .
Matrices
An matrix is a table of numbers with rows and columns. Its entry in row and column is written ; rows run across and columns down. An matrix is square, and the identity matrix is the square matrix with on its diagonal and elsewhere.
Two matrices of the same shape are added entry by entry. If is and is , their product is the matrix with
and the powers of a square matrix are and . This is a definition, not ordinary entrywise multiplication. For example,
because the top-right entry is . We use only this rule and ordinary arithmetic below. Computing one entry of the product of two matrices takes multiplications, so the whole product takes arithmetic operations.
Adjacency Matrices
For a directed -graph with vertices , the adjacency matrix is the matrix with
For a simple undirected graph we put when is an edge. Then , so is symmetric, and one can store only the entries on and above the diagonal and recover the rest by reflection; this uses about half as many entries, but still space. For a directed multigraph one may instead put the number of parallel arcs into .
Example 4.44 (An adjacency matrix).
The graph of Figure 4.6 has
The rows match the adjacency lists: the third row has four ones, one for each successor of .
A matrix stores entries. Testing whether an arc exists takes one entry lookup; scanning the successors of a vertex takes a row scan of entries; and scanning all arcs takes time, however few arcs there are.
A second matrix records which vertices lie on which edges.
Definition 4.45 (Incidence matrix).
Let be a graph without loops, with vertices and edges . Its incidence matrix is the matrix with, for an undirected graph,
and for a directed graph if the arc begins at , if it ends at , and otherwise.
Example 4.46 (An incidence matrix).
Number the edges of the graph of Figure 4.3 as , , , . Its incidence matrix, with rows for the vertices , is
Every column has exactly two ones, the two ends of its edge, and the sum of the entries of row is the degree . Adding all the entries both ways is the count of edge-ends.
The memory requirements of the adjacency matrix and the incidence matrix are therefore and . For graphs with edges, which is often the case, this is far more than required: adjacency lists need memory proportional to .
For most purposes an adjacency list is the preferred data structure. It allows , or and , to be scanned for every vertex in time linear in their size, and its memory requirement is proportional to the number of vertices and edges, if we assume, as usual, that references, vertex numbers and edge numbers each need only a constant amount of memory. In the Word-RAM this is the assumption that one machine word holds any of them, which allows for vertex numbers. Graph algorithms are therefore stated for this representation, and moving to the next edge in a list of edges, or reading an endpoint of an edge, is counted as an elementary operation. An adjacency matrix remains the better choice for a graph with very many edges, or when many single-arc tests are needed.
Proposition 4.47 (Powers of the adjacency matrix count walks).
Let be the adjacency matrix of a directed -graph, and . Then is the number of walks of length from to .
Discussion.
The definition of the power is a recursion on , so the proof is an induction on . For the step, a walk of length from to is a walk of length from to some vertex followed by one arc from to , and , the second-to-last vertex, is determined by the walk. So the walks can be sorted by , and the number through is the number of -walks from to times , which is or according as the last arc exists. Summing over is exactly the formula for the entry of . Walks, not paths, are counted: vertices may repeat.
Proof.
For , has in position and elsewhere, and there is exactly one walk of length from each vertex to itself and none to any other vertex.
Suppose the claim holds for . Every walk of length has a unique second-to-last vertex , and consists of a walk of length from to followed by the arc . For fixed there are choices of the first part, by the hypothesis, and choices of the last arc, so walks pass through last. Summing over ,
The directed graph has adjacency matrix . Its square is : there is one walk of length two from each vertex back to itself, and none to the other vertex.
Reachability from Matrix Powers
To turn walk counts into yes-or-no answers, let when and when , applied to every entry of a matrix.
Definition 4.48 (Transitive closure).
For a directed graph on vertices with adjacency matrix , the reflexive transitive closure is
and the transitive closure is
By the proposition, exactly when is reachable from , the walk of length zero included: a shortest walk to a different vertex is a simple path and uses at most arcs. Likewise exactly when there is a walk of positive length from to , and . The power matters on the diagonal, since a shortest closed walk of positive length may be a cycle of length . Thus need not be , while always.
In the two-vertex graph with no return arc,
and the difference on the diagonal is exactly the zero-length walk.
Computing from its definition takes matrix products; repeated squaring needs far fewer.
Proposition 4.49 (Closure by repeated squaring).
For every ,
Discussion.
Multiplying out the product, each term chooses from every factor either or , and the chosen powers multiply to raised to a sum of distinct powers of two. The claim is that every exponent arises exactly once, which is the existence and uniqueness of the binary expansion of a number below . The proof is an induction on : multiplying the sum up to by keeps the sum and adds a copy shifted up by , which fills in the exponents .
Proof.
For both sides are . Suppose the identity holds for . Since powers of commute with one another,
Let , so that . Powers beyond reveal no new reachable vertex, so , and we may replace every positive entry by after each product. The same result follows by working throughout with Boolean matrix addition (OR) and multiplication (AND followed by OR). For there are squarings to form and products to combine the factors: at most matrix products in total. One more multiplication by gives . For , and no product is needed. For comparison, takes products by repeated multiplication when , and at most by squaring, where is the number of digits in the binary expansion of . These counts are of matrix products; each product of two matrices takes arithmetic operations.
Reflexive Transitive Closure
Input: the adjacency matrix M of a directed graph on n vertices.
Output: the matrix R with R[i][j] = 1 exactly when j is reachable from i.
R ≝ sgn(I + M); P ≝ M; k ≝ 2
while k < n do
P ≝ sgn(P · P)
R ≝ sgn(R · (I + P))
k ≝ 2k
return R
At each test of the loop, and , by the proposition; the loop stops once , when covers every power up to . In Python a matrix is a list of rows, each row a list, and M[i][j] is the entry in row i and column j, numbered from .
def product(X, Y): # Boolean product, O(n^3)
n = len(X)
Z = [[0] * n for _ in range(n)]
for i in range(n):
for j in range(n):
for l in range(n):
if X[i][l] == 1 and Y[l][j] == 1:
Z[i][j] = 1
return Z
def plus_identity(X): # sgn(I + X)
n = len(X)
return [[1 if i == j else X[i][j] for j in range(n)] for i in range(n)]
def closure(M):
n = len(M)
R = plus_identity(M)
P = M
k = 2
while k < n:
P = product(P, P)
R = product(R, plus_identity(P))
k = 2 * k
return R
M = [[0, 1, 0], [0, 0, 1], [0, 0, 0]] # 1 -> 2 -> 3, numbered 0, 1, 2
print(closure(M)) # [[1, 1, 1], [0, 1, 1], [0, 0, 1]]
The inner [0] * n must be built afresh for each row, which is what the comprehension does: [[0] * n] * n would make references to one and the same row.
Weight Matrices
For a weighted directed graph with vertices , the weight matrix is
The value means that there is no direct arc; it is a convention of computation, not an arc of the graph, and has entries in . A zero-weight arc is present and must not be confused with an absent arc.
The graph of Figure 4.9 has, with rows and columns in the order ,
Reading row : the arcs , and have weights , and . The entry says that there is no arc ; it says nothing about longer routes. For an undirected weighted graph the weight matrix is symmetric. A diagonal entry is unless a loop is present; a distance matrix, which puts on the diagonal, is a different object.
For the graph of Figure 4.8, write down the adjacency lists, the arrays and , and the adjacency matrix . Compute and , and check the entries and against walks you can list.
In a directed graph a universal sink is a vertex of in-degree and out-degree . Give an algorithm that decides whether a graph given by its adjacency matrix has a universal sink, using matrix lookups.
Exercises on Data Structures
An increasing subarray of an array of integers is a run of consecutive entries whose values strictly increase. Write a Python function count_long_subarrays(A) which takes a tuple of positive integers and returns the number of longest increasing subarrays of , that is, the number of increasing subarrays whose length is at least that of every other. For it should return , since the longest increasing subarrays have length three and there are two of them, and . Your function should run in time.
Order the following functions so that if appears before then , and indicate which pairs satisfy both and . Here means .
Let and be functions . Using the definition of , prove that .
A data structure supports the sequence operations D.build(X) in time, and D.insert_at(i, x) and D.delete_at(i) each in time, where is the number of items stored at the time of the operation. Using only these operations, describe algorithms for the following, each running in time. Recall that delete_at returns the deleted item.
reverse(D, i, k): reverse the order of the items of starting at index , that is, those at indices to .move(D, i, k, j): move the items of starting at index , in order, to be in front of the item at index , where is false.
Each node x of a doubly linked list keeps a reference x.prev to the node before it as well as x.next to the node after it, and the list L keeps L.head and L.tail, its first and last nodes. The list does not store its length.
- Describe algorithms for
insert_first(x),insert_last(x),delete_first()anddelete_last(), each in time. - Given two nodes
x1andx2of a listL, withx1beforex2, describe a constant-time algorithm that removes all nodes fromx1tox2inclusive fromLand returns them as a new doubly linked list. - Given a node
xof a listL1and a second listL2, describe a constant-time algorithm that splicesL2intoL1afterx, leavingL2empty. - Implement these operations in Python, in a class built like
Linked_List_Seq.
A student keeps pages of notes in a binder, the first at index and the last at index , and has two bookmarks and . Describe a data structure supporting the following operations, where is the number of pages at the time of the operation. Assume both bookmarks are placed before any shift or move, and that is always at a lower index than . For each operation, say whether your running time is worst-case or amortized.
| Operation | Effect | Time |
|---|---|---|
build(X) | initialise with the pages of the iterable X | |
place_mark(i, m) | place bookmark between the pages at indices and | |
read_page(i) | return the page at index | |
shift_mark(m, d) | move bookmark , in front of the page at index , to be in front of the page at index , for | |
move_page(m) | move the page in front of bookmark to be in front of the other bookmark |
- Insert the integer keys in this order into a hash table of size using the hash function . Each slot stores a linked list of the keys hashing to it, later insertions being appended at the end. Draw the table after all keys have been inserted.
- Suppose instead for a positive integer . Find the smallest for which no collisions occur when inserting these keys.
A university assigns new students to rooms, numbered to , by hashing their IDs. Each ID is a positive integer less than , with much larger than ; no two students have the same ID, and students choose their own IDs. The university publishes a family of hash functions before IDs are chosen, and afterwards chooses the rooming function uniformly from . Two students want to be roommates. For each family below, either show that they can choose IDs that guarantee it, or prove that no choice guarantees it and find the highest probability of being roommates they can achieve.
- .
- .
A wall is lined with boxes of paper, box standing feet from the left end and containing reams, where the are distinct positive integers. A pair of boxes is close if , and it fulfils an order of reams if . Given and , describe an algorithm running in expected time that decides whether contains a close pair fulfilling the order.
Imagine inserting the keys , in that order, into a hash table of size that resolves collisions by chaining, with . Draw the table after the insertion of the keys up to . Explain how the table evolves for arbitrary , and derive the worst-case time for the whole operation of inserting the keys.
Exercises on Graphs
Construct a -regular graph on vertices. Is there a -regular graph on vertices?
Show that every graph whose average degree is has a subgraph in which every vertex has degree at least .
Let be a graph with no loops in which every vertex has the same odd degree . Show that the number of edges is a multiple of and that the number of vertices is even.
- Can line segments be drawn in the plane so that each intersects exactly others? Prove your answer.
- Suppose a simple graph has exactly two vertices of odd degree. Prove that there is a path between them.
Check Yourself
Fresh questions on the whole lesson — none of them is worked out above. Work each one out on paper before opening Python; the box only tells you whether you got there.
Answers are checked in your browser, as often as you like. Nothing is sent anywhere and
nothing is kept but your own work. A formula may be written with the symbols themselves or
with ~ & | -> <-> ^, and \and, \or, \to expand as you type.
Which class contains ?
What is the least number of comparisons that finds the maximum of distinct numbers in the worst case?
How many numbers does the sieve of Eratosthenes output for ?
Starting from an empty Dynamic_Array_Seq with , how many slots are allocated after calls of insert_last?
What is the worst-case cost of get_at(i) in Linked_List_Seq with items?
Twenty-five objects are placed in seven boxes. What is the largest number that is certain to be in some single box?
Ten distinct keys are stored with chaining in a table of slots, the hash function drawn from a universal family. What bound does the proposition on chain length give for the expected length of the chain holding a given stored key?
A simple graph has edges. What is the sum of its vertex degrees?
How many edges does have?
How many edges does have?
For the directed graph with arcs and and adjacency matrix , what is the entry ?