Analysis of data structures
You’ve now seen two implementations of two ADTs: a stack built on a growing array, and a queue built on a linked list. Here we compare them directly, so you can reason about why each implementation was built the way it was — and so you’re ready to measure it yourself.
The first part of this page states the costs of the two data structures. The rest of the page shows where such costs come from: how to analyze the running time of a piece of code, loop by loop, and how to summarize the result with big-O, big-Ω, and big-Θ notation. The analysis of loops and of searching is a review of material from CS 1; the amortized analysis of a growing array near the end is new.
Describing cost: big-O
Section titled “Describing cost: big-O”We describe an operation’s cost using (“big-O”) notation, which describes how the number of steps grows as the size of the data, , grows: (constant — doesn’t grow with ), , (grows proportionally with ), and so on. The costs in this first part are worst-case costs unless they say otherwise, and they use big-O loosely. The second half of the page makes both ideas precise: best and worst cases, and big-O as an upper bound.
The growing array
Section titled “The growing array”data[i](indexing): — constant time. A growing array is one contiguous block of memory; the address of element is computed directly from , with no searching needed.data.append(value): amortized. Most of the time, there’s spare room at the end of the underlying array, and appending is just writing into the next slot — . Occasionally, the array is full, and Python allocates a new, larger array and copies every element over — an operation. But Python grows the array geometrically (multiplying its capacity by a constant factor each time it resizes, rather than adding a fixed number of slots), so those expensive resizes happen less and less often as the list grows. Spread out (“amortized”) over many appends, the average cost per append works out to . Amortized analysis, near the end of this page, shows why.data.pop()(from the end): — no resizing, no shifting; just remove the last element.data.pop(0)ordata.insert(0, value)(from the front): — every other element has to shift over by one position to close (or open) the gap.
The linked list
Section titled “The linked list”- Adding or removing at the front or back (given a reference to that end, as
LinkedQueuekeeps inself.frontandself.back): — just re-point a couple of references, no shifting. - Indexing,
data[i]: in the worst case — there’s no way to jump directly to node ; you have to follow links from the front. (The best case, , is .) - Adding or removing in the middle, given a reference to the right spot: to actually splice in or out — but to find that spot in the first place, since finding it means walking the list from one end.
Putting the comparison side by side
Section titled “Putting the comparison side by side”| Operation (worst case) | Growing array | Linked list |
|---|---|---|
| Add / remove at the end | amortized | |
| Add / remove at the front | ||
| Access by index |
This is why a growing array makes a great stack (all the action is at one end — the end where arrays are fast) but a poor queue (queues need fast access at both ends), and why a linked list makes a great queue but a poor choice if you need fast random access by index.
Neither implementation is better in general — they’re better at different things. Picking a data structure means matching its strengths to the operations your problem actually needs.
Analyzing running time
Section titled “Analyzing running time”The costs above were stated, not derived. The rest of this page shows how to derive them, and costs like them, from code. Whatever the problem, we are concerned with two aspects of an algorithm:
- Correctness: showing that the algorithm produces the correct answer.
- Efficiency: understanding how much of some resource the algorithm uses. The resource we analyze most often is time.
The input size n, and the steps of an analysis
Section titled “The input size n, and the steps of an analysis”Running times are written as functions of , where stands for whatever characteristic of the input determines the running time. It might be the length of a list passed to the function, the value of a numeric parameter, or the number of nodes in a linked list. Writing lets us talk about rates of growth in general terms, but before discussing a running time, always know what refers to.
Given an algorithm or a piece of code, you should be able to:
- Identify the quantity that the running time depends on.
- Decide which case to analyze, the best case or the worst case, and describe the inputs of size that produce it.
- Compute the running time of that case in terms of constants and , section by section.
- Only then, describe that running time using big-O, big-Ω, or big-Θ notation, and say which case the bound is for.
Steps 2 and 4 answer different questions. Step 2 chooses which function of we are talking about. Step 4 describes how fast that function grows. Keeping them apart avoids a common mistake: thinking that big-O means “worst case.” It does not, as the section on cases and bounds near the end explains. The rest of this page follows these steps in order.
Run-time analysis of linear search
Section titled “Run-time analysis of linear search”The method for finding a running time starts from code. Here is linear search, which checks the items of a list one at a time, starting at the front:
def linear_search(the_list, key): index = 0 while index < len(the_list): if key == the_list[index]: return index else: index += 1
return NoneLet the_list have length . The method is to divide the code into sections, where every line in a section runs the same number of times, give each section a constant for the time it takes to run once, count how many times each section runs, and add up the products. How many times the loop runs depends on where the key is, so start with the slowest possibility, the worst case: the key is not in the list at all. (The next section defines the worst case precisely.)
- First, Python creates the local variable
index. That is fast, and it happens only once, but we account for it anyway: call its time , a constant that does not depend in any way on . - Next, the while loop. In the worst case, the key is not in the list at all, and the body of the loop runs times. It is reasonable to assume that each iteration, the loop test plus the body, takes a constant amount of time (or at most a constant); call it . The loop costs in total.
- Finally, in the worst case the loop test is made one last time, when
indexequalslen(the_list), and comes upFalse. That is the st test, since the first tests were forindexequal to . Then the function returnsNone. The last test and the return together take some constant time; call it (again, independent of ).
Adding up the sections, the worst-case running time of linear search is
Best case, worst case, and average case
Section titled “Best case, worst case, and average case”The running time of an algorithm usually depends on the size of its input: searching a list of a million items takes longer than searching a list of ten. But it also depends on which input of that size the algorithm gets. Linear search, above, checks the items of a list one at a time. If the key is at index 0, it finishes after one check. If the key is not in the list, it has to check every item before it can report that. Two inputs of the same size can take different amounts of time.
So for each size , there is not one running time but many: one for each input of size . To get a single function of to analyze, we choose which input of each size to consider:
- The worst-case running time is, for each , the longest running time of any input of size .
- The best-case running time is, for each , the shortest running time of any input of size .
- The average-case running time is, for each , the average running time over all inputs of size . Computing it requires knowing how likely each input is, and we won’t use it on this page.
The figure below shows the idea for linear search. Each dot is one input: a list of length with the key at some index, or with the key missing. For each , the dots form a column. The worst-case running time is the top dot of each column, and the best-case running time is the bottom dot.
Two points about these definitions are easy to get wrong:
- The best case is not a small input. The best case is the best input of each size . The best case of linear search is a list of any length whose first item is the key. It is not a list of length 1.
- Each case is a function of . In the figure, the worst case of linear search grows with , and the best case does not. Both are functions that we can analyze, and later we will describe each of them with big-O or big-Θ notation.
The worst case is usually of most interest, because it is a guarantee: “this algorithm is fast enough, as long as the list is not too long” is useful information. “This algorithm might be fast, if you are lucky and give it the right input” is much less useful. The best case is still worth knowing: it tells you what the algorithm does on favorable inputs, and for some algorithms, such as insertion sort below, favorable inputs are common in practice.
Sometimes the best case and the worst case are the same. To find the smallest item in an unsorted list of length , the obvious (and best) algorithm looks at every item, keeping track of the smallest seen so far. Even if the smallest item happens to be first, you can’t know that it’s the smallest until you’ve looked at all the others. Every input of size takes the same time.
And some running times do not depend on at all: returning a randomly selected item from a list, or the smallest item of a sorted list, takes the same time however long the list is.
Best-case running time of linear search
Section titled “Best-case running time of linear search”The best case uses the same sections with different counts. When the key is at index 0, section runs once, section runs once (the first iteration finds the key and returns), and section does not run at all. The best-case running time is
a constant that does not depend on . These two running times are the two lines in the figure in the previous section.
The values of , , and depend on the speed of the computer and the efficiency of the Python implementation. The form of each expression does not: in the worst case, a constant times plus a constant; in the best case, a constant.
Linear search vs. binary search
Section titled “Linear search vs. binary search”One of the most basic problems in computer science is finding the index of an item in a list. Suppose the list is sorted, so that binary search is an option: a list of country names, sorted alphabetically.
countries = ["Afghanistan", "Albania", "Algeria", "Andorra", "Angola", "Anguilla", "Argentina", "Armenia", "Aruba", ..., "Zimbabwe"](The ... stands for the rest of the country names; assume that every country is in the list.) To find the index of a country, such as "Yemen" or "Afghanistan", there are at least three approaches:
- Random search. Pick a random index. Check whether that location contains the item you are looking for. If not, repeat.
- Linear search. Loop over the items in the list, starting at index 0, then index 1, index 2, and so on, until you either find the item or run out of list.
- Binary search. This approach requires the list to be sorted. Keep track of the indices bounding the sublist still under consideration. If the sublist is ever empty, the item is not in the list. Otherwise, check the item at the midpoint of the sublist. If it is the item, return its index. If not, compare: if the midpoint item is greater than the item sought, discard the second half of the sublist; if smaller, discard the first half. Repeat with the half that remains.
Best and worst cases for the three searches
Section titled “Best and worst cases for the three searches”Which method finds the item most quickly? It depends on the input. Consider the best and worst inputs for each approach.
- Random search. In the best case, the first index picked holds the item, and the time taken is the time to examine a single item. In the worst case, random indices are picked forever, and the item is never found. (The probability of this outcome approaches 0 as time goes on, but probabilistic analysis of running time is beyond this page.) And if the item is not in the list at all, random search never discovers that fact.
- Linear search. In the best case, the item is first in the list, as in the best case for random search. In the worst case, the item is at the end of the list or not present at all. If the list contains items, all must be examined.
- Binary search. In the best case, the item is at the midpoint of the whole list, and it is found in the first place we look. In the worst case, the algorithm halves the list over and over until the remaining sublist is empty. A list of length can be halved about times before the sublist is empty, and this number is much less than for large .
All three have the same best case: one look. They differ in the worst case, and the rest of this section makes the worst cases of linear and binary search precise.
Review of logarithms
Section titled “Review of logarithms”Binary search is an example of repeated halving. We divide the list in half. The item cannot be in one of the halves, so we discard that half entirely; the item might be in the other half, so we divide that half in half, and so on, until we either find the item or the sublist is empty. The worst-case running time therefore depends on how many times a list can be halved before the sublist is empty. The answer is the base-2 logarithm of the length of the list.
Halve a number , getting . Halve it again, getting , and again, getting . Continue until the number is less than 1. A sublist cannot contain half an item, so a sublist size less than 1 must be 0, and to reach size 0, the sublist must have had size 1 the time before. So consider this question:
How many times do we halve until we get down to 1?
To answer it, try the opposite operation: start with 1 and keep doubling until reaching . Doubling undoes halving, so the number of doublings needed to get from 1 up to is the same as the number of halvings needed to get from down to 1.
| Doublings | Result |
|---|---|
| 0 | 1 |
| 1 | 2 |
| 2 | 4 |
| 3 | 8 |
| 4 | 16 |
| 5 | 32 |
| 6 | 64 |
Each doubling multiplies by another factor of 2, so after doublings the result is . The number of doublings needed to reach is therefore the value of for which . That number is : the exponent to which 2 must be raised to get .
if and only if .
Starting at 1 and doubling times gives ; so starting at and halving times gives 1. This argument assumes that is a power of 2. When it isn’t, the count is rounded, a detail the notation introduced later lets us ignore. It also counts halvings down to 1, not to 0, which is what the worst case of binary search (item not found) needs; but once the sublist has size 1, the next halving gives size 0, so the number of halvings to reach 0 is one more: .
When computer scientists write logarithms, they almost always mean base-2 logarithms.
Run-time analysis of binary search
Section titled “Run-time analysis of binary search”Binary search is analyzed the same way. Here it is written with a loop: left and right are the indices at the two ends of the sublist still under consideration, and each iteration of the loop discards half of that sublist.
def binary_search(the_list, key): left = 0 right = len(the_list) - 1 while left <= right: midpoint = (left + right) // 2 if the_list[midpoint] == key: return midpoint # found it elif key < the_list[midpoint]: right = midpoint - 1 # discard the second half else: left = midpoint + 1 # discard the first half return None # empty sublist: not found- Setting up
leftandrighthappens once; call its time . - One iteration of the loop tests whether the sublist is empty, computes the midpoint, compares the key with the item at the midpoint, and moves
leftorright. Each of these steps takes constant time; call the total for one iteration . In the worst case, the key is not in the list, and the loop runs once for each sublist size from down through 1: times, as the halving figure above shows. - Finally, the loop test fails, when the sublist is empty, and the function returns
None. Call the time for these .
So the worst-case running time of binary search is
In the best case, the key is at the midpoint of the whole list. The setup runs once, the loop runs once and returns, and the final section does not run, for a best-case running time of , a constant.
(Binary search can also be written recursively, as it was in CS 1, and the running time is the same.)
Run-time analysis of find_node on a linked list
Section titled “Run-time analysis of find_node on a linked list”The same method applies to operations on data structures. Here is find_node, a method of a circular, doubly linked list with a sentinel. It walks the list from the sentinel and returns the first node holding value, or None. Let be the number of items in the list.
This is linear search on a linked list, and the analysis has the same shape. In the best case, the first node holds the value: the loop runs once and returns, for a running time of . In the worst case, no node holds the value: the loop runs once for each of the nodes, and then the loop test fails at the sentinel, for . The walk is what costs time; any linked-list operation that has to find a node by value or by index, starting from one end, has the same worst case.
Nested loops and constant time
Section titled “Nested loops and constant time”In some code, the counts do not depend on which input of size is given, and the best and worst cases are the same. Here is a number passed to the function:
def do_something(n): for i in range(n): for j in range(n): print(i, j)The inner loop’s body, the print, takes some constant time. How many times does it run? For each of the iterations of the outer loop, the inner loop iterates times, so times in all. The outer loop’s own step runs only times. The running time is , for every call with argument .
At the other extreme, some code takes the same time regardless of its input:
def do_something_else(n): print("My name is Inigo Montoya")Its running time is a constant , whatever is.
Run-time analysis of insertion sort
Section titled “Run-time analysis of insertion sort”In linear search and binary search, the best case is a constant: the key is found on the first look. Insertion sort, from CS 1, is an example where the best and worst cases are different and both grow with .
def insertion_sort(the_list): n = len(the_list) for i in range(1, n): key = the_list[i] # the value to insert j = i - 1 # look to the left of position i while j >= 0 and the_list[j] > key: the_list[j + 1] = the_list[j] j -= 1 the_list[j + 1] = keyOnly the while loop’s section, , runs a number of times that depends on the values in the list, not only on .
Each iteration of the outer loop inserts one value into the sorted part of the list, but it does not work like inserting into a growing array. An array insertion needs a free slot at the end, and possibly a doubling, before it can shift values right to open a gap at a known index. Insertion sort needs neither. When it copies key = the_list[i] out of the list, position i becomes a hole, right next to the sorted part. Each iteration of the while loop copies one value one slot right, into the hole, which moves the hole one slot left. When the loop stops, key is written into the hole:
The hole also combines two jobs that an array insertion does separately: finding the position and shifting. Insertion sort does not know in advance where key belongs. It finds out by walking left from the end of the sorted part, and every comparison that does not stop the loop pays for one shift. Each shift is a single assignment, not a three-assignment swap.
In the worst case, the list starts out sorted in reverse order, and each item has to move all the way to the left: the item at position 1 moves 1 place, the item at position 2 moves 2 places, and so on, up to places. The total number of moves is . To add this up, write the sum forward and then backward, and add the two rows column by column:
Each of the columns adds up to , so twice the sum is , and the sum is . The worst-case running time is
In the best case, the list starts out already sorted. Each time the while loop is reached, the_list[j] is already less than or equal to key, so the loop body runs 0 times, and the best-case running time is
The best case here is a list of any length that happens to be sorted, not a short list. And it is a realistic input: lists that are already sorted, or nearly sorted, come up often in practice.
Asymptotic analysis of functions
Section titled “Asymptotic analysis of functions”We want to compare running times: is binary search faster than linear search, and by how much? Every analysis so far ended with a function of with constants in it, one for each case. (The constants belong to each analysis separately: in one row is not the same constant as in another.)
| Algorithm | Best-case running time | Worst-case running time |
|---|---|---|
| Linear search | ||
| Binary search | ||
find_node | ||
do_something | ||
do_something_else | ||
| Insertion sort |
These functions are cumbersome to compare, and the constants depend on the computer and the Python implementation. As the next example shows, once is large enough, the constants matter much less than how fast each function grows. Asymptotic analysis compares functions by their growth as grows large, ignoring constant factors. It applies to any function of : a best case, a worst case, or a function that has nothing to do with running time.
Constants matter less than growth
Section titled “Constants matter less than growth”Which is faster, linear search or binary search? It depends on the input (linear search finds "Afghanistan" faster than binary search does, in the list above), on the length of the list, and on the constants. For the worst case, the question is: when is
The claim is that, whatever the constants, binary search is faster for large enough . Suppose linear search runs on a blazingly fast, ultra-modern HAL 9000 that executes each iteration of the while loop in 0.00001 seconds, so , and let . Binary search runs on a vintage Apple II, which takes 0.01 seconds per iteration of its loop, so : a thousand times slower per step. Let as well.
| HAL 9000, linear search: | Apple II, binary search: | ||
|---|---|---|---|
| 1,024 | 10 | 0.01 s | 0.11 s |
| 16,384 | 14 | 0.16 s | 0.15 s |
| 1,000,000 | about 20 | 10 s | about 0.21 s |
| 1,000,000,000 | about 30 | about 2.8 hours | about 0.31 s |
At , the Apple II already wins. It might look as though the constants were rigged to make that happen, but lengthening the list makes the point: no difference between and can overcome the Apple II’s advantage of a factor where the HAL 9000 has a factor of .
The two functions grow in different ways. Linear search’s worst case grows linearly: if the list doubles in length, the running time about doubles. Binary search’s worst case grows logarithmically: if the list doubles in length from to , the loop runs one more time, and the running time grows by only . With equations,
since means . The function grows much more slowly than , and for large enough that difference outweighs any difference in the constants.
The three notations below, big-O, big-Ω, and big-Θ, make “grows like” precise. Each compares a function , such as a running time, with a simpler function , such as or .
Big-O: an upper bound
Section titled “Big-O: an upper bound”is (pronounced “big-O of ”) if, for large enough , is at most a constant times . Precisely: there are positive constants and such that
The constant is what lets big-O ignore constant factors, and is what lets it ignore small . In the figure, and . The wiggly function crosses several times while is small, but after about it never goes back above. With , for every , so is :
For example, the worst-case running time of linear search, , is : for every , it is at most . The worst-case running time of binary search is . The best-case running time of linear search, , is a constant, at most , so it is .
When a function is a sum, such as
only the fastest-growing term matters: it is . The definition shows why. For every , and , so each lower-order term is at most its coefficient times :
Choosing and satisfies the definition. The constant absorbs every coefficient, which is why both the lower-order terms and the leading constant can be dropped. So the running time of do_something, , is , and the running time of do_something_else, a constant , is .
The base of a logarithm doesn’t matter either, because for any constants and ,
Logarithms to base and base differ only by the constant factor , and constant factors don’t matter in big-O notation. So the worst case of binary search is written .
Big-O is only an upper bound. It says that a function grows no faster than , and nothing about how much slower it might grow. Any function that is is also , and also . It is true that the worst case of binary search is , but it is not the most informative statement; is.
Big-Ω: a lower bound
Section titled “Big-Ω: a lower bound”is (Ω is the Greek letter omega; “big-omega of ”) if, for large enough , is at least a constant times . Precisely: there are positive constants and such that
This is big-O with the inequality reversed. In the figure, and . The wiggly function dips below twice while is small, but from on it stays at or above, so is :
For example, the running time of do_something, , is at least for every , so it is . The worst-case running time of linear search is at least , so it is .
Big-Ω is only a lower bound, in the same way that big-O is only an upper bound. The worst case of linear search is also and , true statements that say less than .
Big-Θ: both bounds
Section titled “Big-Θ: both bounds”is (Θ is the Greek letter theta) if it is both and : for large enough , it is at least one constant times and at most another constant times . It grows like , no faster and no slower.
The examples above already show both halves. The running time of do_something is and , so it is . The worst-case running time of linear search is and , so it is .
When a function is known exactly, as all the functions in the table above are, finding its big-Θ is the same work as finding its big-O: keep the fastest-growing term and drop its constant. Use big-Θ when you know the growth rate exactly, and big-O or big-Ω when you can bound it from only one side.
Ranking growth rates
Section titled “Ranking growth rates”For large enough , any function that is is smaller than any function that is , whatever the constants. So functions can be grouped into families by growth rate, and the families can be ranked:
A table of values shows how quickly these families separate:
| 10 | about 3 | about 33 | 100 | 1,024 |
| 100 | about 7 | about 664 | 10,000 | about |
| 1,000 | about 10 | about 9,966 | 1,000,000 | about |
An algorithm whose running time grows like runs for a long time, even for moderate .
The take-home method: choose the case, and compute its running time section by section, using constants. Then keep only the fastest-growing term and drop its constant. That case’s running time is big-Θ of that term.
Cases and bounds are separate questions
Section titled “Cases and bounds are separate questions”Putting the two steps together gives one tight bound for each case:
| Algorithm | Best case | Worst case |
|---|---|---|
| Linear search | ||
| Binary search | ||
find_node | ||
do_something | ||
do_something_else | ||
| Insertion sort |
Read each entry as a sentence that names a case and a bound: “the best-case running time of linear search is .” The case says which function; the bound says how fast it grows. Any case can be paired with any kind of bound:
- “The best-case running time of insertion sort is ” is true. Big-O is used here for a best case.
- “The worst-case running time of linear search is ” is also true. It is an upper bound, but not a tight one; says more.
- “The worst-case running time of insertion sort is ” is true. Big-Ω is used here for a worst case.
- “The worst-case running time of binary search is ” is a tight bound on a worst case.
So big-O does not mean worst case, big-Ω does not mean best case, and big-Θ does not mean average case. Big-O, big-Ω, and big-Θ describe a function; best case and worst case pick the function.
A bound can also be stated for all inputs at once, with no case named. Since no input takes longer than the worst case, a big-O bound on the worst case holds for every input. “Insertion sort runs in time” is therefore true: no input of size takes more than a constant times . But “insertion sort runs in time” is false as a statement about every input, because a sorted list takes time proportional to only . For insertion sort, the strongest statement about all inputs is . Linear search is similar: it runs in time on every input, but not in time on every input. For do_something, whose best and worst cases are the same, “do_something runs in time” is true for every input.
So a statement such as “linear search is ” is ambiguous: it is true of the worst case but false of the best case. Name the case, unless one bound holds for every input.
Amortized analysis: why doubling makes append constant-time
Section titled “Amortized analysis: why doubling makes append constant-time”A growing array stores a list’s values in a block of fixed capacity, and when the block fills up, it allocates a larger block and copies the values into it. Why should the new block have double the capacity, rather than some fixed number of additional slots?
First, the individual costs. An array stores its items in order in one contiguous block of memory. Inserting at the front of an -item array moves every item one slot to the right, so the worst-case cost of inserting is ; deleting from the front is for the same reason. Deleting from the end moves nothing, so pop() is . Appending at the end usually moves nothing either. But if the block is full, and something else is stored in memory right after it, the whole list must be copied to a new, larger block, which takes time. So in the worst case, a single append takes time, much worse than the you might have expected.
The trick is to make that worst case rare. When the block of items fills up, the list requests room not for items but for . Overallocating like this may seem wasteful, since up to half of the block may be unused, but it means that a long sequence of appends copies the list only occasionally. Starting from capacity 1, appending 128 items resizes the block at capacity 1, 2, 4, 8, 16, 32, and 64, ending at 128.
To add up the cost, follow one block from one doubling to the next. Say that writing a value into a free slot costs 1, and that copying a value into a new block costs some constant per value. Suppose the block has just doubled to capacity 8, so it holds 4 values and has 4 free slots. Each of the next 4 appends finds a free slot and costs 1:
Now the block is full. The next append, of 90, must first copy all 8 values into a new block of capacity 16, at a cost of , and then write 90 into slot 8:
The append of 90 is expensive only because the 4 appends before it filled the block, so charge the cost of the copy to those 4 appends. Together they cost : to write their own values and to copy the block they filled. That is per append.
The same calculation works at every size. Right after a block doubles from capacity to capacity , it holds values and has free slots. The next appends cost 1 each, and the copy that follows costs . Charged to those appends, that is in total, or per append, whatever is. (The very first appends, into blocks of capacity 1 and 2, don’t start half full, but they add only a constant to the total.)
The next figure shows the cost of each of the first 16 appends, starting from capacity 1, with . Most appends cost 1. The appends that find the block full, the 2nd, 3rd, 5th, and 9th, also copy every value, and cost 2, 3, 5, and 9. A single append can be expensive, but spread over the appends that filled each block, the copying never raises the average above :
Since the appends cost at most the constant each, on average, appending takes amortized time.
Doubling is not the only growth factor that works. Any constant factor greater than 1 gives amortized appends; a smaller factor wastes less memory but leaves each append with a larger share of the copying. Python’s own list grows by only about 12.5% at a time. After the block grows from capacity to , only cheap appends happen before the next copy of values, so each of them is charged rather than . That is still a constant.
This kind of analysis is called an amortized running-time analysis: the occasional expensive append is paid for by many cheap appends, so that averaged over any long sequence of operations, each append costs . It is a statement about the whole sequence, not about any one call: a single append can still take time.
Growing by a fixed number of slots instead, say 10, breaks this argument. After the block grows from capacity to , only 10 cheap appends happen before the next copy, and that copy moves all values. Charged to those 10 appends, that is per append, which grows with . Once is large, nearly every tenth append copies the whole list, and appends cost in total: each, on average.
Source material for these notes
Section titled “Source material for these notes”The analyses of linear and binary search, the review of logarithms, and the introduction to big-O are adapted from the CS 1 notes by Balkcom et al., chapter 14, “Analyzing algorithms”.
The rest of these notes were drafted by Claude (Anthropic) at Balkcom’s direction and then edited by Balkcom. This includes the comparison of the growing array and the linked list, the discussion of best, worst, and average cases, the analyses of find_node, nested loops, and insertion sort, big-Ω and big-Θ, the ranking of growth rates, the amortized analysis of doubling, the figures, and the practice problems. Claude’s drafts reflect the standard treatment of these topics, which can also be found in Cormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, chapters 2 and 3 and the chapter on amortized analysis, and in Sedgewick and Wayne, Algorithms, section 1.4.
Further reading
Section titled “Further reading”- TimeComplexity: the running times of operations on Python’s built-in data structures
timeit: for measuring the running time of small pieces of code
Practice problems
Section titled “Practice problems”Try these on your own before checking the solutions. For each function, first compute the running time in terms of constants (, , and so on) and , section by section. Then give the tightest big-Θ bound you can. If the best and worst cases differ, analyze both. (Every running time in these problems is , but you can say something much more informative about each.)
Problem 1: Nested loops
Section titled “Problem 1: Nested loops”def f(n): i = 0 while i < n: j = 0 while j < n: print(i, j) j += 1 i += 1(Solution)
Problem 2: A fixed inner loop
Section titled “Problem 2: A fixed inner loop”def f(n): i = 0 while i < n: j = 0 while j < 1000: print(i, j) j += 1 i += 1(Solution)
Problem 3: A loop that calls a function
Section titled “Problem 3: A loop that calls a function”def g(x): for i in range(x): print(i)
def f(n): i = n while i > 0: g(n) i -= 1(Solution)
Problem 4: A doubling loop variable
Section titled “Problem 4: A doubling loop variable”def f(n): i = 1 while i < n: i *= 2 print(i)(Solution)
Problem 5: Factors up to the square root
Section titled “Problem 5: Factors up to the square root”from math import sqrt
def f(n): i = 1 while i <= sqrt(n): if n % i == 0: print(i, n // i) i += 1 print("And that's all the integer factors of", n)(Solution)
Problem 6: Linked-list operations
Section titled “Problem 6: Linked-list operations”For a circular, doubly linked list with a sentinel and items, give the worst-case running time of __init__, insert_after, prepend, append, deleting a given node, get(i), and a length() that walks the list to count its items. For each, say in a sentence why.
(Solution)
Problem 7: Growing by a constant
Section titled “Problem 7: Growing by a constant”A growing array that adds 10 slots each time it fills up, instead of doubling, starts empty with capacity 10. How many item copies does it make, in total, over the course of appends, where is a multiple of 10? Give an exact formula and a big-Θ bound, and compare with doubling.
(Solution)
Problem 8: Counting digits
Section titled “Problem 8: Counting digits”Let be a positive integer.
def count_digits(n): count = 0 while n > 0: n = n // 10 count += 1 return count(Solution)
Problem 9: An inner loop that starts at i
Section titled “Problem 9: An inner loop that starts at i”def f(n): for i in range(n): for j in range(i, n): print(i, j)(Solution)
Problem 10: Looking for a repeated value
Section titled “Problem 10: Looking for a repeated value”Let be the length of the_list.
def has_duplicate(the_list): n = len(the_list) for i in range(n): for j in range(i + 1, n): if the_list[i] == the_list[j]: return True return False(Solution)
Problem 11: Building a list at the front
Section titled “Problem 11: Building a list at the front”Python’s list is a growing array. result.insert(0, i) shifts every value already in result one slot to the right before writing i into slot 0.
def countdown(n): result = [] for i in range(n): result.insert(0, i) return resultHow would you build the same list in time?
(Solution)
Problem 12: A doubling inner loop
Section titled “Problem 12: A doubling inner loop”def f(n): for i in range(n): j = 1 while j < n: j *= 2(Solution)
Problem 13: Cases and bounds
Section titled “Problem 13: Cases and bounds”Say whether each statement is true or false, and why. has_duplicate is the function from Problem 10.
- The worst-case running time of
has_duplicateis . - The best-case running time of
has_duplicateis . has_duplicateruns in time on every input.has_duplicateruns in time on every input.- If the worst-case running time of an algorithm is , then so is its best-case running time.
- If the best-case running time of an algorithm is , then every input of size takes time.
(Solution)
Problem 14: Shrinking a growing array
Section titled “Problem 14: Shrinking a growing array”A growing array doubles its capacity when an append finds the block full. To avoid wasting memory after many deletions, it also shrinks: whenever a pop from the end leaves the block exactly half full, it copies the remaining values into a new block of half the capacity.
- The array is full, with values and capacity , where is a power of 2. Find a sequence of operations in which every operation copies values. What does this say about the amortized cost of appends and pops?
- Suppose instead that a
popshrinks the block to half its capacity only when it leaves the block a quarter full. Explain why the sequence from part 1 no longer causes a copy on every operation. How many operations must happen, at least, between one copy and the next?
(Solution)