Fundamental Data Structures: Arrays, Linked Lists, Stacks and Queues
Prerequisite:Complexity and Big-O Notation: Measuring Speed as a Function of Input Size
0. Key points
Section titled “0. Key points”- A data structure is judged not by what it can do but by how fast each operation is. Arrays and linked lists represent the same kind of sequence, and the operations they are good at are exactly the opposite of one another.
- An array supports indexed access in time and insertion or deletion in the middle in time. A linked list, given a reference to the node marking the position, inserts or deletes right after it in time, but reaching the -th element costs (Proposition 3.2, Proposition 4.2).
- A dynamic array that doubles its capacity has worst-case cost for a single push, yet the total over pushes stays below . The amortized cost is (Theorem 3.3).
- “Amortized ” does not mean “fast on average”. No probability distribution on the input is assumed; the bound on the total cost holds for every sequence of operations.
- A stack (LIFO) and a queue (FIFO) are abstract data types, specifiable by axioms without mentioning any implementation. The same axioms can be met by an array and by a linked list alike.
- A queue can be realised with worst-case operations by a ring buffer (Proposition 7.2), or with amortized operations by two stacks (Theorem 8.2). We analyse the latter by the potential method.
1. Motivation
Section titled “1. Motivation”Most of the data a program handles is a sequence. Text is a sequence of characters, an image a sequence of pixels, an account history a sequence of transactions. How a sequence is laid out inside the machine is a matter of choice, and that choice can change the running time by orders of magnitude.
Let us start from a naive question. Why have two representations, the array and the linked list, both stayed in use for more than half a century? Because the operations they are good at are exactly complementary. An array is strong at “show me the -th element” and weak at “squeeze something into the middle”. The linked list is the reverse. Had either one won on every operation, the other would have disappeared from history.
The two also arose from different demands. Arrays have existed as a language feature since the earliest high-level language, Fortran (1957). Linked lists came slightly later: around 1956 Allen Newell, Cliff Shaw and Herbert Simon designed the information processing language IPL for their theorem-proving program Logic Theorist, and IPL introduced them; LISP inherited them in 1958. What those authors needed was a representation in which an element could be inserted in the middle without shifting everything behind it.
In this article we take the and notation of the previous chapter, Complexity and big-O notation (Definition 3.1[Complexity and Big-O Notation]), as our instrument, and begin by separating “what can be done” (the abstract data type) from “how it is realised” (the data structure). We then take up arrays, linked lists, stacks and queues, and determine the cost of each operation with proofs.
2. Preliminaries: the machine model and abstract data types
Section titled “2. Preliminaries: the machine model and abstract data types”To prove anything about running time we must first fix what counts as one unit of time. In this article we use the word RAM model. It refines the uniform-cost RAM model(Definition 2.1)[Complexity and Big-O Notation] of the previous chapter by making the addressing of memory explicit.
Memory is a sequence of words indexed by addresses , and one word can hold a single integer or a single address. Reading or writing for a given address , and adding, subtracting, multiplying, dividing or comparing words, each takes one unit of time. This assumption — that a read or write at a given address is constant time — is what will justify the claim that indexed access into an array costs .
The word RAM is an idealisation. Real machines have a hierarchy of caches, and the time to access one word is not constant. We still use the model because the essential differences between algorithms ( versus versus ) are unaffected by the presence of caches. Measured differences between two procedures lie outside the model; we discuss them in Remark 5.1.
Next we introduce the basic distinction on which any discussion of data structures rests.
Definition 2.2(Abstract data type)
An abstract data type (ADT) is a triple, containing no internal representation of values whatsoever, consisting of:
- a set of values;
- the names of the operations, together with the types of their arguments and return values;
- the properties the operations must satisfy. These may be written as preconditions and postconditions, or as equations (axioms) relating the operations to one another.
A realisation of an abstract data type by a concrete layout of memory together with concrete procedures is called a data structure.
This distinction earns its keep because one abstract data type can have many data structures. Which one to use is not settled by correctness; it is settled by how often each operation is used and what each operation costs.
3. Arrays and dynamic arrays
Section titled “3. Arrays and dynamic arrays”Definition 3.1(Array)
An array of length consists of cells of equal size (in words) placed at consecutive addresses starting from a base address . The -th element () is written , and the starting address of its cell is
This single formula in the definition determines both the strength and the weakness of the array. Since the address is a linear function of , any element can be reached by arithmetic alone. On the other hand, squeezing in one element shifts the index of every later element by one, and hence shifts all of their addresses too. We now verify this in turn.
Proposition 3.2(Cost of the basic array operations)
For an array of length , the following hold in the word RAM model.
- Reading or writing the element at a given index () takes time.
- Deciding whether a given value occurs in takes time in the worst case, assuming nothing about the order of the elements. Linear search, comparing the elements from the front, attains this bound.
- Insertion at position () requires exactly moves of existing elements, and deletion of the element at position () requires moves; both take time. In particular the worst case () is , and if the insertion position is uniformly distributed on , the average number of moves is .
Proof(Proposition 3.2)
1. By Definition 3.1, . Since and are fixed for a given array, this computation is one multiplication and one addition. In the word RAM an arithmetic operation and an addressed read or write each take one unit of time, so the total is constant, that is . What matters is that it depends neither on nor on .
2. The upper bound is given by linear search: for read and compare it with , returning true on a match and false if none occurs. Each iteration is constant time by part 1, so the whole is .
For the lower bound we use an adversary argument. Suppose a correct algorithm , on an input not containing , answers “not present” without ever reading some position . Feed it , obtained from by replacing position by . Since never reads , it follows the same computation and again answers “not present” — which is wrong, because contains . Hence all positions must be read, giving , and together with the upper bound, .
3. We treat insertion. The array after the insertion satisfies for , , and for . Every element with has its address shifted back by without exception, so at least writes are necessary. Conversely, moving from the back — for — loses nothing to overwriting and uses exactly writes, after which one further write finishes the job. One move is constant time by part 1, so the total is . Deletion is analogous: closing the gap forwards with for takes moves, that is .
The worst case is , with moves, hence . The average is
which is again . Averaging offers no escape from .
Arrays have a second weakness: their length must be fixed in advance. If the number of elements grows at run time, a bare array cannot cope. The dynamic array (resizable array) solves this. The naive scheme — “each time one element is added, allocate an array one longer and copy everything” — incurs copies over additions. Yet merely changing the growth rule from "" to "" brings the total down to linear.
Theorem 3.3(Amortized cost of the dynamic array)
Consider a data structure realising append-at-the-end, , by the following rule. It maintains an array of capacity and the current number of elements (), starting from , . The operation behaves as follows.
- If , set and then .
- If , first allocate a new array of length , move the existing elements into it, set , and then perform the step above.
Then the total number of element writes performed by pushes () starting from the empty state is less than . Hence the amortized cost per is . The worst-case cost of an individual , however, is .
Proof(Theorem 3.3)
We use the aggregate method: estimate the total cost directly and divide by the number of operations.
Split the writes into two kinds. The write at the end occurs exactly once in every , contributing writes in total.
The rest are the copies caused by capacity growth. Growth happens when just before a ; since the capacity runs through the powers , this happens exactly when the number of elements just before is (), and the number of copies is then exactly . During pushes, the element count reaches only for those with , so letting be the largest such , the total number of copies is
where the last inequality uses .
Hence the total number of writes is less than . Since operations cost less than in total, the amortized cost per operation is less than , that is .
As for the worst case, a in the state performs copies, and by part 1 of Proposition 3.2 this takes time.
Example 3.4(Counting ten pushes)
Let us count the writes made by ten pushes from the empty state. Growth occurs when the element count just before is , that is, at pushes number 2, 3, 5 and 9.
| push number | before | capacity after growth | copies | total writes this time |
|---|---|---|---|---|
| 1 | — | 0 | 1 | |
| 2 | 2 | 1 | 2 | |
| 3 | 4 | 2 | 3 | |
| 5 | 8 | 4 | 5 | |
| 9 | 16 | 8 | 9 | |
| 4, 6, 7, 8, 10 | — | — | 0 | 1 each |
The total is writes, below the bound guaranteed by the theorem. The ninth push alone needs 9 writes, but levelled out this is writes per operation.
Remark 3.5(Amortized is not average)
Amortized cost involves no probability at all. What Theorem 3.3 asserts is the deterministic guarantee that for every sequence of pushes the total is below . This differs in meaning and in strength from arguments that assume “if the input is uniformly distributed”, as in the average cost of a hash table (Theorem 5.3[探索アルゴリズム]). On this distinction see also Remark 6.5[探索アルゴリズム].
The theorem also depends on the growth being by a constant factor, so that the number of copies forms a geometric series. With ” each time”, the -th push causes copies, for a total of , and amortized collapses. Conversely the factor need not be : as long as the ratio is a constant greater than , the same argument goes through, whether the factor is or .
4. Linked lists
Section titled “4. Linked lists”The weakness of the array was that the address is determined by the index. Then let the elements carry their addresses themselves — that is the idea behind the linked list.
Definition 4.1(Singly linked list)
A node is a cell consisting of a field holding a value and a field holding either a reference to another node or . When nodes satisfy
this sequence is called a singly linked list, and the whole list is represented by a reference to its first node. The empty list is represented by . If each node also has a field satisfying , the list is called doubly linked.
There is no constraint at all on the addresses of the nodes. They may be scattered anywhere in memory; the links are carried entirely by the values of . This freedom is precisely what shapes the cost profile.
Proposition 4.2(Cost of the basic linked-list operations)
For a singly linked list of nodes, the following hold in the word RAM model.
- Given a reference to a node , inserting a new node immediately after , and deleting the node immediately after , each take time.
- Reaching the -th node counted from the front (zero-based, ) requires exactly traversals of and takes time. The worst case is .
- In a singly linked list, deleting itself takes time in the worst case even when a reference to is given. In a doubly linked list it takes time.
Proof(Proposition 4.2)
1. For insertion, prepare a new node and perform the two writes
Afterwards follows and the original follows , so the linking condition of Definition 4.1 is preserved. For deletion, when , the single write
suffices. In both cases the number of reads and writes depends neither on nor on , so the time is . Part 3 of Proposition 3.2 required moves for an insertion at position ; here not a single element has been moved.
2. The upper bound is given by the procedure that starts at and follows times. Each traversal reads one word and so is constant time, giving in total.
For the lower bound we use the fact that in this model there are only two ways to learn the address of a node: reading , and reading the field of an already reached node. Unlike the array of Definition 3.1, there is no formula computing the address of the -th node from . Hence the set of reached nodes grows by at most one per traversal, and at least traversals are needed to arrive at . This gives .
3. Deleting itself requires finding the preceding node and setting . In a singly linked list one cannot get from to , so the only option is to walk from looking for the node whose equals , which by part 2 is in the worst case. In a doubly linked list is available in constant time, so the two writes
suffice, giving time (the case analysis for at the two ends can be removed by the sentinel of Remark 4.4).
Example 4.3(Running insertion and deletion)
We check part 1 of Proposition 4.2 in Python.
class Node: __slots__ = ("value", "next")
def __init__(self, value, next=None): self.value = value self.next = next
def insert_after(p: Node, x) -> Node: """Insert a node with value x right after p and return it.""" q = Node(x, p.next) # q.next <- p.next p.next = q # p.next <- q return q
def delete_after(p: Node) -> None: """Delete the node right after p; do nothing if there is none.""" if p.next is not None: p.next = p.next.next
def to_list(head: Node) -> list: out, cur = [], head while cur is not None: out.append(cur.value) cur = cur.next return out
head = Node(3, Node(1, Node(4)))print(to_list(head)) # [3, 1, 4]insert_after(head, 5)print(to_list(head)) # [3, 5, 1, 4]delete_after(head.next)print(to_list(head)) # [3, 5, 4]The body of insert_after is two lines and that of delete_after is one, and neither contains a loop depending on the length of the list. That is what amounts to. By contrast to_list follows to the end and therefore costs ; even merely learning the length requires a full traversal (keeping a separate element count makes it ).
Remark 4.4(Sentinel nodes)
Implementations commonly place one dummy node carrying no value (a sentinel) at the front and keep a reference to the sentinel instead of . Then “insert at the front” and “delete the first element” both become “insert or delete right after the sentinel”, and part 1 of Proposition 4.2 applies verbatim. Testing for the empty list becomes testing whether the sentinel’s is . Fewer cases mean fewer bugs, so it is a good idea to start from a sentinel when implementing.
5. Comparing the two representations
Section titled “5. Comparing the two representations”Let us line up the results so far. First compare how the two look in memory.
Collecting Proposition 3.2, Proposition 4.2 and Theorem 3.3 gives the following table. The linked list is assumed to keep a reference to its last node as well.
| operation | dynamic array | singly linked list |
|---|---|---|
| read or write the -th element | (worst case ) | |
| search by value (no order assumed) | ||
| search by value (sorted) | (binary search) | |
| insert at the front, delete the first | ||
| append at the end | amortized | |
| delete the last element | (needs the preceding node) | |
| insert or delete at a position given by index | ||
| insert or delete right after a node in hand | ||
| memory beyond the elements themselves | unused capacity (up to about the element count) | one reference per node |
The second-to-last row captures the characters of the two most sharply. “Put something next to where I am” is for the linked list and for the array. Conversely “jump to the -th element” is for the array and for the linked list. The choice is decided by how often each of these two kinds of operation is used.
Remark 5.1(Constant factors and the cache)
The notation hides constant factors, and in measurements what is hidden can matter. Modern CPUs fetch main memory not word by word but in cache lines (typically 64 bytes), so scanning an array from the front loads several elements into the cache per fetch. In a linked-list traversal the address of the next node is unknown until the current one has been read, and the more the nodes are scattered in memory, the more cache misses occur.
As a result, the judgement “many insertions in the middle, therefore a linked list” is sometimes contradicted by measurement up to element counts in the thousands. When in doubt, write it with a dynamic array first, measure, and then decide.
6. Stacks
Section titled “6. Stacks”From here we move to the side of abstract data types. A stack is a type specified by one thing only: what goes in last comes out first (last in, first out, LIFO).
Definition 6.1(Stack)
A stack is the abstract data type with the following operations. Write for the type of values and for the set of stacks.
- (the empty stack)
- (put on top)
- (remove the top)
- (read the top)
These satisfy the following axioms for all and .
and are undefined (an implementation raises an error).
The axiom is LIFO itself: push and then remove it, and you are back exactly where you started. Nowhere in these four equations do arrays or nodes appear. Whatever the implementation, if it satisfies them it is a stack. There are two implementations, and the cost of each can be read off from results we already have.
Implementation by a dynamic array. Store the elements as with the top at . Then is an append at the end and so is amortized (Theorem 3.3), while is and is a read of , both worst-case (part 1 of Proposition 3.2). The element moves demanded by part 3 of Proposition 3.2 do not arise, because at the end .
Implementation by a linked list. Let the top be the first node, with an insertion at the front and a deletion of the first node. With a sentinel, both reduce to part 1 of Proposition 4.2 and are worst-case . Since this is in the worst case rather than amortized, it is preferable where a bound on the latency of a single operation is required. The price is one reference per node of extra space.
Let us look at one use of a stack in a form we can prove: deciding whether brackets match.
Theorem 6.2(Recognising balanced bracket strings)
Let the alphabet be and define the set of balanced bracket strings as the smallest set generated by the following three rules.
Consider the following algorithm on an input . Start with an empty stack and read one symbol at a time from the left.
- If the symbol read is an opening bracket, it onto .
- If it is a closing bracket, reject immediately if is empty; otherwise read , , and reject if the symbol removed is not the opening bracket of the same kind as this closing bracket.
When all symbols have been read, accept if is empty and reject otherwise. Then accepts if and only if . Moreover the running time of is .
Proof(Theorem 6.2)
Preliminary (relativity of the stack). consults only the top of the stack, and it rejects the moment it tries to an empty stack. Hence a run processing from an initial stack and a run processing the same from the empty stack undergo exactly the same changes above , except in the case where the latter rejects because of a ” from empty”. We use this fact repeatedly below.
() If then accepts. We prove the following stronger statement by structural induction on the generation rules of .
If then, from any initial stack , processing causes not to reject, and the stack afterwards is again .
- Case . Nothing is read, so there is no rejection and the stack remains .
- Case with . Reading the initial makes the stack . Applying the induction hypothesis to with initial stack , there is no rejection and the stack afterwards is again . Reading next, the stack is non-empty and its top is , so the matches in kind and there is no rejection. The stack afterwards is . The case is identical.
- Case with . Applying the induction hypothesis to with initial stack , there is no rejection and the stack returns to . Applying the same hypothesis to , again there is no rejection and the stack is .
Taking to be the empty stack in particular, does not reject, and the stack after reading everything is empty, so it accepts.
() If accepts then . We argue by strong induction on . If then by the first rule. Suppose .
If were a closing bracket, the stack would be empty at that moment and would reject, contradicting the hypothesis. So is an opening bracket; write it and write for the matching closing bracket. The symbol is pushed at position 1, and since the stack is empty on acceptance, it is popped somewhere. Say this happens when the -th symbol is read.
Since sits at the bottom of the stack and is removed for the first time at position , it remains on the stack throughout the processing of positions . That is, the processing of takes place entirely above , and the stack immediately before position consists of exactly the one symbol . By the preliminary observation, processing from an empty stack likewise causes no rejection and ends with an empty stack. Since , the induction hypothesis gives .
At position the symbol is popped, and since did not reject, . The processing of the remainder starts from an empty stack, and since the whole input is accepted, it ends with an empty stack. As , we get . Hence , and the second rule gives while the third gives .
Cost. Each symbol causes at most one or one together with at most one , and with the linked-list implementation these are worst-case (part 1 of Proposition 4.2). Hence the total is .
Example 6.3(Running the recogniser)
We trace on . The stack is written with its bottom on the left.
| symbol read | action | stack afterwards |
|---|---|---|
| push | ||
| push | ||
| pop (matches ) | ||
| pop (matches ) | empty | |
| push | ||
| pop (matches ) | empty |
The stack is empty at the end of the input, so the string is accepted. Indeed and , so the third rule gives . By contrast, for the at the second symbol yields , a different kind from , so the string is rejected. For no rejection occurs during the scan, but one is left on the stack at the end, so it is rejected. The implementation is as follows.
def is_balanced(w: str) -> bool: partner = {")": "(", "]": "["} stack = [] for c in w: if c in "([": stack.append(c) elif c in ")]": if not stack or stack.pop() != partner[c]: return False else: raise ValueError(f"unexpected symbol: {c!r}") return not stack
print(is_balanced("([])()")) # Trueprint(is_balanced("(]")) # Falseprint(is_balanced("(()")) # FalseThe final return not stack corresponds to the acceptance condition “the stack is empty at the end”. Forget that one line and "(((" is accepted.
This recogniser has the same shape as depth-first search and as the management of recursive calls. Wherever the structure “close what has been opened in the reverse order of opening” appears, a stack is usually there.
7. Queues
Section titled “7. Queues”A queue is the type in which what goes in first comes out first (first in, first out, FIFO). It obeys the same discipline as a line at a counter, which is why it is also called a waiting line.
Definition 7.1(Queue)
A queue is the abstract data type with the following operations. Write for the type of values and for the set of queues.
- (the empty queue)
- (add at the back)
- (remove the front)
- (read the front)
These satisfy the following axioms for all and .
and are undefined.
Compare this with Definition 6.1. For a stack, : what was just pushed comes straight back off. For a queue, slips past and burrows further in. This slipping past is what FIFO is, and it is why recursion appears on the right-hand side of the axioms.
The naive array implementation does not work. If the front is always kept at , then is an append at the end and so is amortized , but is a deletion at position , hence by part 3 of Proposition 3.2, giving over operations. The culprit is pinning the front to , so unpinning it solves the problem.
Proposition 7.2(Ring buffer)
Maintain an array of capacity , a front position () and an element count (), with the operations defined as follows.
- : when , set and .
- : when , return , then set and .
- : when , return .
Then, writing the contents of the queue from the front as , the identity holds at all times, and the axioms of Definition 7.1 are satisfied. Moreover each of the three operations takes worst-case time.
Proof(Proposition 7.2)
We prove the asserted identity () as an invariant, by induction on the number of operations.
In the initial state , so the condition holds vacuously.
Case . The new contents are and is unchanged. We check that the target position does not coincide with an existing position (). From the difference between and cannot be a multiple of , so the two differ modulo . Hence no existing element is destroyed, and the new last element satisfies the identity as the element with index .
Case . The value returned is , the front of the queue. Renumbering the new contents as and setting , we get
so the invariant is preserved.
That returns is the case itself. Hence and always act on the front element while adds only at the back, so the axioms of Definition 7.1 are satisfied. As for the cost, each operation consists of a constant number of additions, remainders and comparisons together with one array access ( by part 1 of Proposition 3.2), hence worst-case .
Example 7.3(A ring buffer of capacity 4)
Take and start from , . Unused cells of are written _.
| operation | cell written / read | |||
|---|---|---|---|---|
a _ _ _ | 0 | 1 | ||
a b _ _ | 0 | 2 | ||
a b c _ | 0 | 3 | ||
a b c _ | 1 | 2 | ||
a b c d | 1 | 3 | ||
e b c d | 1 | 4 | ||
e b c d | 2 | 3 |
In the final state the queue contains , and as the invariant demands, , and . Writing runs off the right end of the array and wraps to the left end — hence the name “ring” — and not a single element has been moved.
Remark 7.4(Distinguishing full from empty, and growing the capacity)
Some implementations keep only and the position past the back, ; but then both when and when , so full and empty cannot be told apart. There are two remedies: keep the element count explicitly, as in Proposition 7.2 above, or always leave one cell free so that the effective capacity is .
When the buffer fills up, allocate an array of capacity and repack the elements from the front. The cost and frequency of this growth have exactly the same form as in Theorem 3.3, so stays amortized . With a linked-list implementation holding references to both the front and the back, both operations are worst-case by part 1 of Proposition 4.2.
Stacks drive depth-first search and queues drive breadth-first search. Both are central to Graph algorithms: that breadth-first search computes shortest distances correctly follows from the FIFO discipline of the queue (Theorem 3.2[グラフアルゴリズム]), and in depth-first search the vertices under processing form exactly a stack (Lemma 4.1[グラフアルゴリズム]). Dynamic programming, in turn, uses an array as a table and relies entirely on indexed access (Theorem 3.2[動的計画法]).
8. The potential method and a queue from two stacks
Section titled “8. The potential method and a queue from two stacks”In Theorem 3.3 we counted the total cost directly (the aggregate method). When there are several kinds of operation that influence one another, such counting becomes hard. The tool for that situation is the potential method: represent the “accumulated work” stored in the data structure by a real-valued function and watch how it rises and falls with each operation.
Lemma 8.1(The potential method)
Let a sequence of states of a data structure be given ( the initial state and the state just after the -th operation), together with the actual cost of the -th operation. Suppose a real-valued function on the set of states satisfies
Defining the amortized cost by , we have
In particular, if some constant satisfies for all , then the total cost of operations is at most .
Proof(Lemma 8.1)
By definition,
The second sum on the right telescopes: adjacent terms cancel, leaving . Hence
and rearranging gives the equality. By hypothesis , that is , so the inequality follows. The last assertion is immediate from .
Think of as the debt carried by the current state. Cheap operations run up a little debt at a time, and an expensive operation repays it all at once. At the moment of repayment the actual cost is large, but drops sharply, so the amortized cost is small.
Theorem 8.2(A queue from two stacks)
Take two stacks all of whose operations are worst-case (for instance the linked-list implementation based on part 1 of Proposition 4.2), and call them and . Define the operations as follows.
- : onto .
- : if is empty, repeat ” from and onto ” until is empty. Then, if is empty raise an error; otherwise from and return the result.
- : perform the same transfer, then return the of .
Then this implementation satisfies the axioms of Definition 7.1. Moreover the total cost of any sequence of operations starting from the empty state is at most , so the amortized cost per operation is . The worst-case cost of an individual , however, is , where is the number of elements at that moment.
Proof(Theorem 8.2)
Correctness. Write the contents of the queue from the front as and take the following as the invariant.
There exists an () such that reading from the top to the bottom gives , and reading from the bottom to the top gives .
Initially and both stacks are empty, so the invariant holds with .
Under , reading from the bottom gives , and the new contents of the queue are , so the invariant is preserved with the same .
A transfer occurs when is empty, that is when , and then reading from the bottom gives . The order in which elements are popped from is , and pushing them onto in that order makes read from the bottom, that is from the top. Since is now empty, the invariant holds with . The order is reversed twice and thereby restored: that is the trick of this implementation.
When is non-empty (), its top is by the invariant, that is the front of the queue. Hence returns and removes . After the removal, holds from the top, so after renumbering the invariant is preserved with . When both stacks are empty, and remains empty even after a transfer, so an error is raised — consistent with Definition 7.1 leaving undefined.
Amortized cost. Define the potential by
Then and in the initial state, so the hypothesis of Lemma 8.1 is met. We count one stack operation as cost 1 (by assumption it takes worst-case time).
- : the actual cost is one , so . The number of elements in grows by one, so . Hence .
- with non-empty: the actual cost is one , so . Nothing changes in , so . Hence .
- with empty and elements in : the transfer performs pops and pushes, and the subsequent pop is one more, so . The number of elements in falls from to , so . Hence .
- : it only reads instead of popping, so the same computations as in the previous two cases give .
In every case . By Lemma 8.1, the total cost of operations is at most and the amortized cost per operation is .
Worst case. A in the state where all elements are on and is empty performs stack operations, hence . This does not contradict the amortized bound of , because before such an expensive can occur, enqueues are needed to pile up the elements.
flowchart LR E["enqueue x pushes onto in"] --> I["stack in (from the bottom: c, d, e)"] I -->|"when out is empty, move until in is empty"| O["stack out (from the top: c, d, e)"] O --> D["dequeue pops the top of out"]
Example 8.3(Running the two stacks)
Here is an implementation together with a trace following the changes in .
class TwoStackQueue: def __init__(self): self._in = [] # the last entry is the top self._out = [] # the last entry is the top
def enqueue(self, x): self._in.append(x)
def _transfer(self): while self._in: self._out.append(self._in.pop())
def dequeue(self): if not self._out: self._transfer() if not self._out: raise IndexError("dequeue from empty queue") return self._out.pop()
def front(self): if not self._out: self._transfer() if not self._out: raise IndexError("front of empty queue") return self._out[-1]
def __len__(self): return len(self._in) + len(self._out)
q = TwoStackQueue()for x in "abc": q.enqueue(x)print(q.dequeue(), q.dequeue()) # a bq.enqueue("d")print(q.dequeue(), q.dequeue()) # c dThe actual cost , the potential and the amortized cost of each operation come out as follows.
| operation | afterwards (from the bottom) | afterwards (from the top) | |||
|---|---|---|---|---|---|
| initial state | — | — | — | 0 | — |
| — | 1 | 2 | 3 | ||
| — | 1 | 4 | 3 | ||
| — | 1 | 6 | 3 | ||
| — | 7 | 0 | 1 | ||
| — | 1 | 0 | 1 | ||
| 1 | 2 | 3 | |||
| — | 1 | 2 | 1 | ||
| — | — | 3 | 0 | 1 |
The on the fourth row stands out with an actual cost of (three pops, three pushes, one pop), but falls from to , so its amortized cost is . The total actual cost is over operations, below the bound of Lemma 8.1.
9. Exercises
Section titled “9. Exercises”Exercise 9.1Easy
Using only the axioms of Definition 7.1, derive the following equation, stating explicitly which axiom is used at each step.
Then use this result together with the axioms for to confirm that removing one element from the queue built by inserting and then leaves .
Solution
Put . Applying the axiom with and gives . Hence the second case of the axiom applies:
Next, applying the first case of the same axiom (the inner argument being ) to gives . Substituting,
as required.
Furthermore, the first case of the axiom gives . The element , inserted first, left first: this is indeed FIFO. Carrying out the same computation with the axioms of Definition 6.1 gives , leaving . The difference in the axioms is exactly the difference in behaviour.
Exercise 9.2Standard
Write a procedure reversing a singly linked list in time and additional space. Create no new nodes; only rewrite the fields of the existing ones. State a loop invariant and use it to prove correctness.
Solution
Three variables suffice.
def reverse(head): prev = None cur = head while cur is not None: nxt = cur.next # save it first, or we lose our way the moment next is overwritten cur.next = prev # flip the direction prev = cur cur = nxt return prevInvariant. Write the original list as . At the start of each iteration the following hold for some ().
previs the head of the list consisting of in that order ( when ).curis ( when ).- The two lists are disjoint and together comprise all the original nodes.
Initialisation. Just before entering the loop, prev = None and cur = head , so the invariant holds with .
Maintenance. Suppose the invariant holds with at the start of an iteration and cur . We save (or if it does not exist) in nxt. The assignment cur.next = prev makes follow , so the list starting at prev becomes . The only thing lost by this assignment is the reference from to , and that is safely stored in nxt. Finally prev = cur and cur = nxt make the invariant hold for .
Termination. The loop ends when cur is , that is when . By the invariant, prev is the head of the list , which is the reversal of the original list.
Cost. The loop runs exactly once per node, times in total, and one pass through the body consists of four assignments, hence constant time (for the same reason as part 1 of Proposition 4.2). So the time is . The extra memory is three variables, hence .
Note that omitting the save into nxt destroys any means of reaching after cur.next = prev, because, as the proof of part 2 of Proposition 4.2 shows, the address of a node can only be obtained by following references.
Exercise 9.3Hard
Add to the dynamic array of Theorem 3.3 an element removal together with a shrinking rule for returning memory.
- With the rule “after a , halve the capacity as soon as the element count drops to at most half the capacity”, amortized fails. Construct an explicit sequence of operations for which operations cost in total.
- With the rule “halve the capacity as soon as the element count falls below one quarter of the capacity”, the amortized costs of and stay constant. Estimate how many operations must occur between two consecutive capacity changes, and explain why.
Solution
1. Start from capacity with elements (full) and alternate and .
- : the array is full, so the capacity grows to and elements are copied. Afterwards , .
- : afterwards , and since , the shrinking rule fires, the capacity returns to , and elements are copied.
This restores the initial state exactly, so the same thing repeats forever. Every two operations cause copies, so the total cost is , which is if we take . The cause is that the threshold for growing and the threshold for shrinking sit at the same point.
2. Separating the thresholds means that immediately after a capacity change we land far from either boundary. Look at the state just after a change.
- Just after growing: capacity , element count .
- Just after shrinking: the capacity before was , and the count had just fallen below , so (up to an error of one). The capacity is .
In both cases . For the next growth, must reach , requiring at least pushes; for the next shrink, must fall below , requiring at least pops. Hence at least operations lie between two consecutive capacity changes.
A capacity change at capacity costs , and spreading this over the at least preceding operations gives a constant per operation. Formally, taking as the potential of Lemma 8.1
one checks by cases on whether a growth or a shrink occurs that the amortized cost is bounded by a constant. The point is that both expressions vanish just after a capacity change () and grow as either boundary is approached.
References
Section titled “References”- T. H. Cormen, C. E. Leiserson, R. L. Rivest, C. Stein, Introduction to Algorithms, 4th ed., MIT Press, 2022 — Chapter 10 (Elementary Data Structures) covers arrays, linked lists, stacks and queues, and Chapter 16 (Amortized Analysis) covers the aggregate, accounting and potential methods. The analysis of the dynamic array (table doubling) is also in Chapter 16.
- D. E. Knuth, The Art of Computer Programming, Volume 1: Fundamental Algorithms, 3rd ed., Addison-Wesley, 1997 — §2.2 (Linear Lists). §2.2.1 treats stacks, queues and deques, §2.2.2 sequential allocation (arrays) and §2.2.3 linked allocation (linked lists), corresponding to §3 through §7 of this article. The historical background of linked lists is described in §2.6.
- A. V. Aho, J. E. Hopcroft, J. D. Ullman, Data Structures and Algorithms, Addison-Wesley, 1983 — Chapter 2 (Basic Abstract Data Types). The organisation of this article, fixing the abstract data type first and supplying implementations afterwards, follows the style of this book.
- R. E. Tarjan, “Amortized Computational Complexity”, SIAM Journal on Algebraic and Discrete Methods 6 (1985), 306–318. DOI: 10.1137/0606031 — the paper that systematically formulated amortized complexity and the potential method.
- R. Sedgewick, K. Wayne, Algorithms, 4th ed., Addison-Wesley, 2011 — §1.3 (Bags, Queues, and Stacks). Implements stacks and queues both with resizing arrays and with linked lists, and compares measured running times.
- Ishihata Kiyoshi, Algorithm to Data Kōzō (Algorithms and Data Structures), Iwanami Shoten (Iwanami Lectures on Software Science 3), 1989 (in Japanese) — a well-regarded textbook available in Japanese, treating the various representations of linear lists in detail.
Appendix: Correspondence with the standard libraries of major languages
Section titled “Appendix: Correspondence with the standard libraries of major languages”The data structures treated in this article are present as they stand in the standard libraries of the major languages. Since the names do not always reveal the implementation, we list the correspondences.
Dynamic arrays and linked lists. C++‘s std::vector, Java’s ArrayList and Python’s list are dynamic arrays. The growth factor differs by implementation: Java’s ArrayList uses roughly , and CPython’s list roughly (the capacity grows as ); but as noted in Remark 3.5, any constant greater than preserves amortized . Linked lists appear as C++‘s std::list (doubly linked) and std::forward_list (singly linked), and Java’s LinkedList (doubly linked). Python’s standard library has no pure linked-list type because occasions calling for one are rare, and the circumstances of Remark 5.1 lie behind that too.
Stacks and queues. C++‘s std::stack and std::queue are adapters exposing only the interfaces of Definition 6.1 and Definition 7.1 over an existing container (std::deque by default), so the separation of abstract data type from implementation appears directly in the types. Java’s ArrayDeque is a ring buffer (Proposition 7.2) usable both as a stack and as a queue. In Python one uses list (with append and pop) for a stack and collections.deque (a doubly linked list of fixed-size blocks, with worst-case addition and removal at both ends) for a queue. Using list as a queue via pop(0) costs per operation by part 3 of Proposition 3.2, hence over operations — a frequent cause of slowdowns in practice.
Report an error in this article ・Operated by: Mugen Giken LLC ・Pricing ・Terms ・Legal notice
© 2026 夢現技研合同会社 ・Feeding the text to an LLM is welcome. Code samples are MIT licensed.