Series: Stacks · Queues and deques · Linked lists
COL 1101 Programming with Data Structures — Lecture 12, plus the stack parts of Lecture 14, rewritten as one guide
Stacks
A stack is the simplest useful rule for organising data: you may only touch the most recent thing. That one restriction is exactly what undo buttons, the Back button, bracket checkers, calculators and your own program's function calls need. This page builds the idea, two implementations, their costs, and three algorithms the lecture uses it for.
Push 5, push 3, then pop. The 3 comes out first because it went in last. Everything on this page follows from that.
How to use this page
Read the parts in order. Each one ends with a Check yourself box: try it before opening the answers, because retrieving an answer from memory is what makes it stick. Boxes with a dashed border, marked Beyond the lecture, go past the slides; skip them on a first pass if you are short of time. Part 6 is the whole topic on one page for revision.
Words you will meet everywhere:
Abstract data type (ADT): a list of operations and what each one promises to do, with nothing said about how. “Stack” is an ADT.
Implementation: real storage plus code that keeps those promises. “A stack stored in a Python list” is an implementation.
O(·) (big-O): how the work grows as the number of elements n grows. O(1) means the work does not depend on n; O(n) means it grows in step with n; O(n²) means it grows with the square.
Amortized cost: the average cost per operation over a long run of operations, even if a few individual operations are expensive. Part 2 makes this precise.
Part 1
The stack idea
1.1The problem: undo
You type “Hello”, then “ world”, then “!!”. Pressing Ctrl+Z should remove “!!” first, then “ world”, then “Hello”. Undo always reverses the most recent change that has not yet been undone. Whatever stores the history must hand back the last thing it was given, then the one before that, and so on.
A pile of plates works the same way. You add a plate to the top and you take a plate from the top. The plate you put down last is the first one you pick up. The plate at the bottom waits until every plate above it is gone.
1.2The Stack ADT
A stack stores arbitrary objects. Insertions and removals both happen at one end, called the top, so they follow a last-in, first-out (LIFO) order.
| Operation | What it does | On an empty stack |
|---|---|---|
push(e) | Put e on top | fine |
pop() | Remove the top element and return it | error |
top() | Return the top element without removing it | error |
len(S) | Number of elements stored | returns 0 |
is_empty() | True if no elements are stored | returns True |
push and pop are the main operations; the other three are auxiliary: convenient, but you could fake top() with a pop followed by a push.
Here is the lecture's example trace. Read the right-hand column as “bottom … top”.
| Operation | Returns | Stack afterwards |
|---|---|---|
| S.push(5) | – | [5] |
| S.push(3) | – | [5, 3] |
| len(S) | 2 | [5, 3] |
| S.pop() | 3 | [5] |
| S.is_empty() | False | [5] |
| S.pop() | 5 | [ ] |
| S.is_empty() | True | [ ] |
| S.pop() | error | [ ] |
| S.push(7) | – | [7] |
| S.push(9) | – | [7, 9] |
| S.top() | 9 | [7, 9] |
| S.push(4) | – | [7, 9, 4] |
| len(S) | 3 | [7, 9, 4] |
| S.pop() | 4 | [7, 9] |
| S.push(6) | – | [7, 9, 6] |
| S.push(8) | – | [7, 9, 6, 8] |
| S.pop() | 8 | [7, 9, 6] |
1.3Where stacks appear
- Undo (Ctrl+Z). Each edit pushes the old state; undo pops it.
- The browser's Back button. Each page you leave is pushed; Back pops the most recent one.
- Function calls. When
fcallsgandgcallsh, the program must finishhfirst, then return intog, then intof. The runtime keeps a call stack: each call pushes a record of where to return to and its local variables; eachreturnpops one. Infinite recursion fails with “stack overflow” because the call stack runs out of room.
f calls g, which calls h. Calls push a frame; returns pop it. The last function called is always the first to finish.The undo example in code, using the stack class written in Part 2:
text, history = "", ArrayStack()
def type_text(s):
global text
history.push(text) # save the state BEFORE the change
text += s
def undo():
global text
if not history.is_empty():
text = history.pop() # most recent change is undone first
type_text("Hello"); type_text(" world"); type_text("!!")
print(repr(text))
undo(); print(repr(text))
undo(); print(repr(text))'Hello world!!' 'Hello world' 'Hello'
Note that each step pushes the old text before changing it. Undo is then just “restore whatever was pushed most recently”.
Check yourself: the ADT
- Starting empty, run: push(1), push(2), pop(), push(3), push(4), pop(), pop(). What does each pop return, and what is left?
- A stack contains, bottom to top, [a, b, c, d]. How many pops do you need before you can read b?
- Your browser history is visited in the order A, B, C, D (you are on D). You press Back twice, then visit E. Using a stack of “pages to go Back to”, what does Back show now?
Show answers
- The pops return 2, 4, 3. Left: [1].
- Two pops (d, then c). Then
top()returns b without a third pop. - On D the Back stack is [A, B, C]. Back pops C (you see C), Back pops B (you see B), so the stack is [A]. Visiting E pushes B (the page you left): [A, B]. Back now shows B. Pages C and D are gone from the Back history; real browsers keep them on a second, “Forward” stack, which is cleared when you visit a new page.
Part 2
Implementing a stack and paying for it
2.1Array-based stack: the Adapter pattern
Python already has a list that can grow at its end. We do not want users to call arbitrary list methods (inserting into the middle would break the LIFO promise), so we write a new class that keeps a list as a hidden field and exposes only the five stack operations. Building a new interface around an existing class like this is called the Adapter design pattern.
The one design decision is: which end of the list is the top? The answer is the end (highest index), because append and pop() work there without moving anything. If the top were index 0, every push and pop would shift all the other elements by one place: O(n) each.
| Stack operation | List operation | Cost |
|---|---|---|
| push(e) | L.append(e) | O(1) amortized |
| pop() | L.pop() | O(1) amortized |
| top() | L[-1] | O(1) |
| len(S) | len(L) | O(1) |
| is_empty() | len(L) == 0 | O(1) |
| space | O(n) |
These are the answers to the in-class polls: space O(n); top O(1); is_empty O(1); push and pop O(1) amortized.
class Empty(Exception):
"""Raised when you read from an empty stack."""
pass
class ArrayStack:
"""LIFO stack that hides a Python list (the Adapter pattern)."""
def __init__(self):
self._data = [] # the hidden list; the top is its END
def __len__(self):
return len(self._data) # O(1): lists store their length
def is_empty(self):
return len(self._data) == 0 # O(1)
def push(self, e):
self._data.append(e) # O(1) amortized
def top(self):
if self.is_empty():
raise Empty("stack is empty")
return self._data[-1] # O(1): index access
def pop(self):
if self.is_empty():
raise Empty("stack is empty")
return self._data.pop() # O(1) amortized: removes from the end
S = ArrayStack()
S.push(5)
S.push(3)
print(len(S), S.pop(), S.is_empty(), S.pop(), S.is_empty())
try:
S.pop()
except Empty as err:
print("Empty:", err)2 3 False 5 True Empty: stack is empty
Two details worth copying. First, popping an empty stack raises our own Empty exception, with a clear message, instead of letting Python's IndexError leak out with a message about lists that the user never asked for. Second, the leading underscore in _data is the Python convention for “internal; do not touch from outside”.
2.2What a Python list really is
A Python list is a dynamic referential array:
- Array: one block of consecutive memory slots. To find slot i, the computer does one calculation: start address + i × slot size. That is why
L[i]is O(1). - Referential: each slot holds a reference (an address, 8 bytes on a 64-bit machine) to an object stored elsewhere, not the object itself. That is how one list can hold numbers, strings and other lists side by side.
- Dynamic: the block has a fixed capacity, usually more than the number of elements actually stored. When an append finds the block full, Python allocates a bigger block, copies every reference across, and frees the old block.
2.3Why push is still O(1) amortized
Count primitive operations: writing one reference into a slot costs 1. An ordinary append costs 1. An append that triggers a resize from capacity k costs k (copying) + 1 (the new element).
Suppose the array starts with capacity 1 and doubles whenever it is full. Over n appends, resizes happen when the size reaches 1, 2, 4, 8, …, up to the largest power of 2 below n. The total copying is
Add the n ordinary writes and the total is less than 3n. So n appends cost O(n) in total, which is O(1) per append on average. That average is the amortized cost.
The same argument works for any growth factor f > 1 (new capacity = old capacity × f). The resizes happen at sizes f, f², …, fk with k = logf n, and the geometric series sums to
because fk ≤ n and ff − 1 is a constant (2 for doubling, 3 for f = 1.5).
The growth must be a factor, not a fixed amount. If the array instead grew by a constant c slots each time, resizes would happen at sizes c, 2c, 3c, … and the copying would total c(1 + 2 + … + n/c) ≈ n²/(2c). That is O(n²) for n appends, so O(n) per append on average. A bigger c only divides the cost by a constant; it never fixes it.
Growth-strategy race
Pick how the array grows, then drag n. The chart plots the average cost per append after each append. Geometric strategies level off at a constant; fixed increments keep climbing.
Beyond the lecture: what CPython actually does
CPython does not double. It grows a list to roughly n + n/8 + a small constant, rounded, which is a factor of about 1.125 for large lists. It is still a constant factor greater than 1, so the geometric-series argument still gives O(1) amortized appends, just with a larger constant and less wasted space. You can watch it happen; each line is a moment the list moved to a bigger array:
import sys
L = []
print("len bytes slots")
last = sys.getsizeof(L)
for k in range(1, 41):
L.append(None)
size = sys.getsizeof(L)
if size != last: # the list just moved to a bigger array
slots = (size - sys.getsizeof([])) // 8 # 8 bytes per reference
print(f"{k:3} {size:5} {slots:5}")
last = sizelen bytes slots 1 88 4 5 120 8 9 184 16 17 248 24 25 312 32 33 376 40
The trade-off is general: a bigger factor means fewer copies but more empty slots on average; a smaller factor wastes less memory but copies more often.
2.4Why pop is also “amortized”
Removing the last element is a single step. The lecture still says O(1) amortized for pop because a well-behaved dynamic array also shrinks when it becomes mostly empty, so that a stack that once held a million items and now holds ten does not keep a million slots forever. Shrinking copies, just like growing, and the same averaging argument applies. (The queue page shows the standard rule: halve when the array drops below a quarter full.)
Stack on a dynamic array
Push and pop and watch the hidden array. Dashed cells are allocated but unused capacity; the solid cell is the one just touched. The cost counter charges 1 per write and 1 per reference copied during a resize; the array doubles when full and halves when it drops below a quarter full.
Empty stack, capacity 1.
2.5Linked-list stack: no resizing at all
The other standard implementation stores each element in its own small node, and each node holds a reference to the node below it. The stack only remembers the top node (the head). The full story of linked lists is on the Linked Lists page; here is all a stack needs.
Where should the top go: the head or the tail of the list? The head, because both inserting and removing at the head are O(1). Removing at the tail of a singly linked list is O(n), because you would have to walk from the head to find the node before the tail.
class LinkedStack:
"""LIFO stack on a singly linked list; the top is the HEAD."""
class _Node:
__slots__ = "_element", "_next"
def __init__(self, element, nxt):
self._element, self._next = element, nxt
def __init__(self):
self._head = None
self._size = 0
def __len__(self):
return self._size
def is_empty(self):
return self._size == 0
def push(self, e):
self._head = self._Node(e, self._head) # new node points at old head
self._size += 1
def top(self):
if self.is_empty():
raise IndexError("stack is empty")
return self._head._element
def pop(self):
if self.is_empty():
raise IndexError("stack is empty")
answer = self._head._element
self._head = self._head._next # unlink the old head
self._size -= 1
return answer
S = LinkedStack()
for x in [7, 9, 4]:
S.push(x)
print(S.pop(), S.top(), len(S))4 9 2
| Array-based stack | Linked stack | |
|---|---|---|
| push, pop | O(1) amortized; occasionally O(n) | O(1) every time |
| top, len, is_empty | O(1) | O(1) |
| memory | one reference per element, plus spare capacity | one node per element (element + next reference + object overhead) |
| speed in practice | usually faster: contiguous memory is cache-friendly | usually slower: one allocation per push |
| when to choose it | almost always | when a single slow operation is unacceptable (real-time systems) |
Check yourself: costs
- Why does an array-based stack put its top at the end of the list?
- An array starts at capacity 1 and doubles. How many reference copies happen in total during the first 17 appends?
- An array grows by +100 slots each time it is full. Roughly how many copies for 10,000 appends? For 20,000?
- True or false: “amortized O(1)” means no single push ever costs more than a constant.
Show answers
- append and pop at the end are O(1) amortized and move nothing; at index 0 every operation shifts all other elements, O(n).
- Resizes happen when appending the 2nd, 3rd, 5th, 9th and 17th element, copying 1, 2, 4, 8, 16 references: 31 in total.
- Copies ≈ 100 + 200 + … + 9,900 = 100 × (1 + … + 99) = 495,000. For 20,000 appends: 100 × (1 + … + 199) = 1,990,000, about four times as many for twice the appends: quadratic growth.
- False. A push that triggers a resize costs O(n). Amortized O(1) is a statement about the total over many operations.
Part 3
Application: matching brackets
3.1The task
Given an expression, check that every opening symbol has a closing partner of the same type, and that pairs nest properly (they may contain each other but must not cross). The pairs are parentheses ( ), braces { } and brackets [ ]. Other characters (numbers, variables, operators) are ignored.
| Expression | Verdict | Why |
|---|---|---|
| ( )(( )){([( )])} | correct | every opener closed, properly nested |
| ((( )(( )){([( )])})) | correct | same, one level deeper |
| )(( )){([( )])} | incorrect | starts with a closer that has nothing to close |
| ({[ ])} | incorrect | pairs cross: ] arrives while ( is still open inside [ |
| ( | incorrect | opener never closed |
Slide typo. The lecture lists “((( )(( )){([( )])}” as correct. As printed it is not: it has three more ( than ). The textbook version ends with two extra closers, “((( )(( )){([( )])}))”, which is what the table above uses.
3.2Why a stack is the right tool
When you read a closer, which opener must it match? Not the first one, and not any one: it must match the most recent opener that has not been closed yet. In { ( [ ] ) }, the ] closes [ (the latest), then ) closes (, then } closes {. “Most recent unfinished thing” is precisely what the top of a stack holds.
3.3The algorithm
Algorithm ParenMatch(X, n):
S = empty stack
for i = 0 to n - 1:
if X[i] is an opening symbol:
S.push(X[i])
else if X[i] is a closing symbol:
if S.is_empty():
return false # nothing to match with
if S.pop() does not match the type of X[i]:
return false # wrong type
if S.is_empty():
return true # every symbol matched
else:
return false # some opener was never closedThere are exactly three ways to fail, and each line catches one: a closer with an empty stack (too many closers), a closer whose partner is wrong (crossing or mismatched types), and a non-empty stack at the end (too many openers).
def is_matched(expr):
"""Return True if every ( { [ in expr is closed by its partner, in order."""
lefty, righty = "({[", ")}]"
S = ArrayStack()
for c in expr:
if c in lefty:
S.push(c) # remember the opener
elif c in righty:
if S.is_empty():
return False # a closer with nothing to close
if righty.index(c) != lefty.index(S.pop()):
return False # wrong type of closer
return S.is_empty() # anything left was never closed
for t in ["()(()){([()])}", "((()(()){([()])}))", ")(()){([()])}", "({[])}", "("]:
print(f"{t:20} {is_matched(t)}")()(()){([()])} True
((()(()){([()])})) True
)(()){([()])} False
({[])} False
( FalseCost. One pass over n symbols. Each symbol is pushed at most once and popped at most once, and each push or pop is O(1) (amortized), so the whole check is O(n). That answers the poll. Space is O(n) in the worst case (all openers).
Bracket checker, step by step
Type any expression. Step through it one symbol at a time and watch the stack. The highlighted symbol is the one being read.
Beyond the lecture: the same idea for HTML tags
An HTML page is valid only if tags like <b> … </b> nest properly. The checker is identical: push each opening tag's name, and on each closing tag pop and compare names. Compilers use the same stack to match begin/end blocks and report “unexpected }” errors.
Check yourself: brackets
- Trace
[ ( ) ] }. At which symbol does the algorithm return, and with what? - Why is “count the openers and closers and compare” not enough?
- What is the largest the stack ever gets while checking
((([[]])))?
Show answers
[push;(push;)pops ( (match);]pops [ (match);}finds the stack empty, so it returns False there.- Counting cannot see order or type.
({[ ])}has three openers and three closers, one of each type, yet the pairs cross. Even with a single type,)(has equal counts but is wrong. - Five: after reading
((([[.
Part 4
Application: evaluating expressions
4.1Two rules decide the order
To evaluate 14 ≤ 4 − 3 × 2 + 7 correctly, you cannot just go left to right. Two rules decide which operation happens first.
Precedence: some operators bind more tightly than others. Multiplication and division come before addition and subtraction, and comparisons such as ≤ come last. So 4 − 3 × 2 is 4 − (3 × 2).
Associativity: when operators have equal precedence, they are applied left to right. So x − y + z is (x − y) + z, and x / y × z is (x / y) × z, not x / (y × z).
The slide lists precedence as “division, multiplication, addition/subtraction, comparison”. Read that as three levels, not four: × and ÷ share a level, which is exactly why the slide's own example x / y * z is decided by associativity, not precedence.
| Level | Operators | Value used below |
|---|---|---|
| highest | × ÷ | 2 |
| + − | 1 | |
| lowest real operator | ≤ (and other comparisons) | 0 |
| end marker | $ | −1 |
By hand: 3 × 2 = 6; then left to right, 4 − 6 = −2, −2 + 7 = 5; finally 14 ≤ 5 is False.
4.2The two-stack idea
Read tokens left to right with two stacks: valStk for numbers and opStk for operators waiting to be applied.
- A number is pushed onto
valStk. - An operator first forces out every waiting operator on top of
opStkwhose precedence is higher than or equal to its own (each one pops two values and pushes the result), and is then pushed itself. - At the end, a special token
$with the lowest precedence of all forces out everything still waiting.
Why “higher or equal”? “Higher” enforces precedence: a waiting × must happen before a new + arrives. “Equal” enforces left-to-right associativity: in 10 − 4 − 3, when the second − arrives, the first − (equal precedence, further left) must be done first. With “strictly higher” you would compute 10 − (4 − 3) = 9 instead of 3.
Algorithm EvalExp(tokens):
while there is another token z:
if isNumber(z):
valStk.push(z)
else:
repeatOps(z)
opStk.push(z)
repeatOps($) # $ = end of input, lowest precedence
return valStk.top()
Algorithm repeatOps(curOp):
while valStk.size() > 1 and prec(curOp) <= prec(opStk.top()):
doOp()
Algorithm doOp():
x = valStk.pop() # right operand: it was pushed last
y = valStk.pop() # left operand
op = opStk.pop()
valStk.push(y op x)Operand order. doOp pops x first, but computes y op x. The right operand was pushed most recently, so it comes off first. Getting this backwards silently breaks − and ÷: 10 − 4 would become −6.
4.3Full trace of 14 ≤ 4 − 3 × 2 + 7
Stacks are written bottom … top.
| Token | What happens | valStk | opStk |
|---|---|---|---|
| 14 | push value | [14] | [ ] |
| ≤ | opStk empty, nothing to do; push ≤ | [14] | [≤] |
| 4 | push value | [14, 4] | [≤] |
| − | top is ≤ (0); 1 ≤ 0? no. Push − | [14, 4] | [≤, −] |
| 3 | push value | [14, 4, 3] | [≤, −] |
| × | top is − (1); 2 ≤ 1? no. Push × | [14, 4, 3] | [≤, −, ×] |
| 2 | push value | [14, 4, 3, 2] | [≤, −, ×] |
| + | top × (2): 1 ≤ 2, do 3 × 2 = 6 | [14, 4, 6] | [≤, −] |
| top − (1): 1 ≤ 1, do 4 − 6 = −2 | [14, −2] | [≤] | |
| top ≤ (0): 1 ≤ 0? no, stop. Push + | [14, −2] | [≤, +] | |
| 7 | push value | [14, −2, 7] | [≤, +] |
| $ | top + : do −2 + 7 = 5 | [14, 5] | [≤] |
| top ≤ : do 14 ≤ 5 = False | [False] | [ ] |
The answer is valStk.top() = False, which is the poll answer.
PREC = {"<=": 0, "+": 1, "-": 1, "*": 2, "/": 2, "$": -1} # $ = end of input
def do_op(vals, ops):
x = vals.pop() # right operand (pushed last)
y = vals.pop() # left operand
op = ops.pop()
result = {"+": y + x, "-": y - x, "*": y * x, "/": y / x, "<=": y <= x}[op]
vals.push(result)
def repeat_ops(cur, vals, ops):
# do every waiting operation that binds at least as tightly as cur
while len(vals) > 1 and not ops.is_empty() and PREC[cur] <= PREC[ops.top()]:
do_op(vals, ops)
def eval_exp(tokens):
vals, ops = ArrayStack(), ArrayStack()
for z in tokens:
if isinstance(z, (int, float)):
vals.push(z)
else:
repeat_ops(z, vals, ops)
ops.push(z)
repeat_ops("$", vals, ops) # flush everything at the end
return vals.top()
print(eval_exp([14, "<=", 4, "-", 3, "*", 2, "+", 7]))
print(eval_exp([10, "-", 4, "-", 3]))
print(eval_exp([8, "/", 4, "*", 2]))False 3 4.0
The last line shows associativity at work: 8 / 4 × 2 is (8 / 4) × 2 = 4, not 8 / 8 = 1.
Two-stack evaluator
Enter integers separated by + − * / and <= (spaces optional). Step token by token; each step says why an operator waits or is applied.
Beyond the lecture: parentheses and cost
The lecture's version has no parentheses. Adding them takes two extra rules: push ( onto opStk as a barrier that repeatOps never pops past; on ), apply operators until the matching ( is on top, then discard it. This is Dijkstra's shunting-yard algorithm, the standard way compilers and calculators parse arithmetic.
Cost: every token is pushed once and popped at most once, so evaluation is O(n) for n tokens.
Check yourself: expressions
- Evaluate
6 − 2 × 2 + 1 ≤ 3using the algorithm. What is on the two stacks just before$is processed? - Which single change to
repeatOpswould make10 − 4 − 3evaluate to 9? - Why does the end marker need a lower precedence than ≤?
Show answers
- 6, −, 2, × , 2 → on + : do 2 × 2 = 4, then 6 − 4 = 2, push +. Then 1. On ≤ (0): do 2 + 1 = 3; push ≤. Then 3. Before
$: valStk [3, 3], opStk [≤].$applies 3 ≤ 3 = True. - Using strict
<instead of≤(prec(curOp) < prec(top)). Equal-precedence operators would then wait, making the operators right-associative. repeatOps($)keeps applying operators while prec($) ≤ prec(top). For that to hold for every operator that could still be waiting, $ must be at or below the lowest of them. If $ were ranked above ≤, a waiting ≤ would never be applied and the answer would be left as two values onvalStk.
Part 5
Application: computing spans
5.1What a span is
The span S[i] of X[i] is the largest number of consecutive elements, ending at X[i] and counting backwards (including X[i] itself), that are all ≤ X[i]. In finance: for each day, on how many consecutive days up to and including today was the stock price at most today's price?
| i | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| X[i] | 6 | 3 | 4 | 5 | 2 |
| S[i] | 1 | 1 | 2 | 3 | 1 |
Day 3 (price 5) looks back over 4 and 3 (both ≤ 5) and stops at 6, so its span is 3 (days 1, 2, 3). Day 4 (price 2) stops immediately at 5, so its span is 1.
5.2The direct method: O(n²)
Algorithm spansNoStack(X, n):
S = array of n zeros
for i = 0 to n - 1:
j = i
while j >= 0 and X[j] <= X[i]: # walk left until something bigger
S[i] = S[i] + 1
j = j - 1
return SThe worst case is a rising sequence, where every day walks all the way back to the start: 1 + 2 + … + n = n(n+1)/2 steps, which is O(n²).
5.3The key observation
The span depends on only one thing: the nearest earlier element strictly greater than X[i]. If that element is at index h, then everything between h and i is ≤ X[i], so
(The second case is the first with h = −1, an imaginary infinitely tall day before the start.)
Now the trick. Suppose day j has price ≤ day i's, with j < i. Can day j ever be “the nearest greater element” for any later day k > i? No: any later day that looks back far enough to reach j must first pass i, and i is at least as tall as j. Day j is hidden behind day i forever and can be thrown away.
What survives is the visible skyline: the days you could see looking left from the current day, each strictly taller than every day after it. Their prices decrease from left to right, and the nearest one is always the one added most recently. Most recent first: a stack.
5.4The stack algorithm: O(n)
Algorithm spansStack(X, n):
S = array of n ones
A = empty stack # holds INDICES, not prices
for i = 0 to n - 1:
while not A.is_empty() and X[A.top()] <= X[i]:
A.pop() # hidden behind day i: discard
if A.is_empty():
S[i] = i + 1 # nothing to the left is bigger
else:
S[i] = i - A.top() # top = nearest greater element
A.push(i)
return SThe stack holds indices because the formula needs a position (i − h), and the price is always available as X[h].
| i | X[i] | popped | top after popping | S[i] | stack after push |
|---|---|---|---|---|---|
| 0 | 6 | none | empty | 0 + 1 = 1 | [0] |
| 1 | 3 | none | 0 (X = 6 > 3) | 1 − 0 = 1 | [0, 1] |
| 2 | 4 | 1 (3 ≤ 4) | 0 (6 > 4) | 2 − 0 = 2 | [0, 2] |
| 3 | 5 | 2 (4 ≤ 5) | 0 (6 > 5) | 3 − 0 = 3 | [0, 3] |
| 4 | 2 | none | 3 (5 > 2) | 4 − 3 = 1 | [0, 3, 4] |
5.5Why it is O(n) despite the nested loop
A while inside a for often means O(n²), so look closer. Do not count per iteration of the outer loop; count per index over the whole run. Each index is pushed exactly once (at its own iteration) and popped at most once (after that it is gone). So across the entire algorithm there are at most n pops, and the while body runs at most n times in total. Everything else is O(1) per iteration. Total: O(n).
This style of argument, charging each unit of work to the element that causes it, is the same one that proved append is O(1) amortized. It returns on the queue page for the two-stack queue.
def spans_no_stack(X):
S, work = [0] * len(X), 0
for i in range(len(X)):
j = i
while j >= 0 and X[j] <= X[i]: # walk left past smaller-or-equal days
S[i] += 1
j -= 1
work += 1
return S, work
def spans_stack(X):
S, work = [1] * len(X), 0
A = ArrayStack() # holds INDICES of still-visible days
for i in range(len(X)):
while not A.is_empty() and X[A.top()] <= X[i]:
A.pop() # hidden behind day i forever
work += 1
S[i] = i + 1 if A.is_empty() else i - A.top()
A.push(i)
return S, work
print(spans_no_stack([6, 3, 4, 5, 2]))
print(spans_stack([6, 3, 4, 5, 2]))
rising = list(range(1000)) # worst case for the naive version
print("naive steps:", spans_no_stack(rising)[1], " stack pops:", spans_stack(rising)[1])([1, 1, 2, 3, 1], 8) ([1, 1, 2, 3, 1], 2) naive steps: 500500 stack pops: 999
The second number in each pair counts loop steps. On a rising array of 1,000 prices the direct method takes 500,500 steps; the stack method pops 999 times.
Span calculator with the visible skyline
Enter prices. Step through the days. Solid bars are on the stack, the shaded bar with a marker is the current day, dashed bars have been popped for good, and plain outlined bars have not been reached yet.
Check yourself: spans
- Compute the spans of X = [2, 4, 1, 3, 5].
- For which input does the stack grow to size n? For which does it never exceed 1?
- Why does the algorithm pop when X[\text{top}] ≤ X[i] rather than only when X[\text{top}] < X[i]?
Show answers
- S = [1, 2, 1, 2, 5].
- A strictly falling sequence never pops, so the stack ends holding all n indices. A rising (non-decreasing) sequence pops the previous index every time, so the stack holds 1 index after each step.
- The span counts days with price ≤ today's, so an equal earlier price is inside today's span and cannot be the “strictly greater” boundary. Popping it is required for correctness, not just tidiness.
Part 6
Stacks on one page
| Operation | Array-based (top = end of list) | Linked (top = head) |
|---|---|---|
| push | O(1) amortized | O(1) |
| pop | O(1) amortized | O(1) |
| top, len, is_empty | O(1) | O(1) |
| space | O(n) | O(n) |
| Algorithm | What the stack holds | Cost |
|---|---|---|
| Bracket matching | openers not yet closed | O(n) |
| Expression evaluation | values, and operators waiting their turn | O(n) |
| Spans (direct) | no stack | O(n²) |
| Spans (stack) | indices of the visible skyline | O(n) |
Ideas to carry forward
- Geometric growth makes dynamic arrays O(1) amortized; growth by a fixed amount makes them O(n) per append.
- Amortized ≠ worst case. One push can cost O(n); a long run averages O(1).
- Count per element, not per loop iteration. “Each item is pushed once and popped at most once” turns a nested loop into O(n).
- Spot the pattern. “Most recent unfinished”, “nearest earlier bigger”, “undo the last” all mean stack.
- In doOp, the right operand comes off first.
Final mixed check
- A program pushes 1,000,000 items onto an array-based stack that doubles from capacity 1. Give an upper bound on total reference copies.
- Is
[(])balanced? What is on the stack when the algorithm fails? - Evaluate
20 / 5 / 2with the two-stack algorithm. - Spans of [3, 3, 3]?
Show answers
- Fewer than 2 × 1,000,000 = 2,000,000 (exactly 1 + 2 + … + 524,288 = 1,048,575).
- No.
[push,(push,]pops(: wrong type, return False. The stack then holds[. - (20 / 5) / 2 = 2: the second / forces the first out because they have equal precedence.
- [1, 2, 3]: equal prices are ≤, so each day's span reaches back to the start.