B2.2 Data Structures
Static vs dynamic, lists, 2D lists, stacks and queues. All examples use Python.
The Big Picture
A data structure is a way of organising data in memory so it can be used efficiently. You need four, and each has a picture you can keep in your head.
B2.2.1: Static vs Dynamic
You are running a school concert. There are exactly 200 seats. You will never sell seat 201, so a fixed list of 200 is perfect. That is static. You also sell snacks, but nobody knows how many. That list has to grow and shrink. That is dynamic.
| Static | Dynamic | |
|---|---|---|
| Size | Fixed when it is declared (at compile time). Cannot change while the program runs. | Can grow and shrink while the program runs (at runtime). |
| Memory | All the memory is reserved up front, even for empty elements. Can waste space, or run out if you guessed too small. | Memory is allocated when items are added and freed when they are removed. Uses only what it needs, plus a little spare. |
| Speed | Slightly faster. Elements sit next to each other in memory, so any index is reached directly. | Slightly slower. Resizing takes time and memory can become fragmented. |
| Best when | You know the number of items in advance (seats, days of the week, a chess board). | The number of items is unknown or changes (snacks sold, bank transactions, players online). |
| Python | tuple (fixed, cannot be changed) | list (every Python list is dynamic) |
| Java | array: int[] a = new int[5]; | ArrayList |
Compile time is before the program runs, when the code is translated into machine code. Runtime is while the program is running in memory.
A tuple has a fixed size and its elements cannot be changed:
days = ("Mon", "Tue", "Wed", "Thu", "Fri")
print(days[0])
days[0] = "Monday"
Mon TypeError: 'tuple' object does not support item assignment
B2.2.2: Lists (1D)
A list stores many values under one variable name. Each value is an element, and each element has an index: its position, starting at 0. In Python, negative indexes count from the end.
| Index | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| Value | "Ansh" | "Fabio" | "Hana" | "Kai" | "Zoe" |
| Negative | -5 | -4 | -3 | -2 | -1 |
Five elements means the last index is 4, not 5. The last index is always len(list) - 1. Asking for index 5 gives an IndexError.
Cheat sheet
| Do this | Python |
|---|---|
| Create an empty list | names = [] |
| Create with values | names = ["Ansh", "Kai"] |
| Add to the end | names.append("Zoe") |
| Add at a position | names.insert(1, "Zoe") |
| Read an element | names[0] |
| Change an element | names[0] = "Angie" |
| Remove by index | del names[2] |
| Remove by value | names.remove("Kai") |
| Remove and return the last | names.pop() |
| Length | len(names) |
| Is it in there? | "Kai" in names |
Add
names = [] # empty list
names.append("Angelica") # add to the end
names.append("Ansh")
names.append("Kai")
names.append("Fabio")
names.append("Michelle")
names.insert(1, "Hana") # add at index 1, the rest shift right
print(names)
print(len(names))
['Angelica', 'Hana', 'Ansh', 'Kai', 'Fabio', 'Michelle'] 6
Read and change
names = ["Angelica", "Hana", "Ansh", "Kai", "Fabio", "Michelle"]
print(names[0]) # first
print(names[-1]) # last
names[2] = "Anshul" # change an element
print(names)
print("Kai" in names)
Angelica Michelle ['Angelica', 'Hana', 'Anshul', 'Kai', 'Fabio', 'Michelle'] True
Remove
names = ["Angelica", "Hana", "Ansh", "Kai", "Fabio", "Michelle"]
del names[2] # by index
print(names)
names.remove("Kai") # by value (first match)
print(names)
last = names.pop() # removes AND returns the last item
print(last, names)
['Angelica', 'Hana', 'Kai', 'Fabio', 'Michelle'] ['Angelica', 'Hana', 'Fabio', 'Michelle'] Michelle ['Angelica', 'Hana', 'Fabio']
Traverse
Traversing means visiting every element in order. There are three ways in Python. Use the index version when you need the position (or want to change elements).
names = ["Ansh", "Fabio", "Hana"]
# 1. value by value (simplest)
for name in names:
print(name)
# 2. using the index
for i in range(len(names)):
print(i, names[i])
# 3. enumerate gives index and value together
for i, name in enumerate(names):
print(i, name)
Ansh Fabio Hana 0 Ansh 1 Fabio 2 Hana 0 Ansh 1 Fabio 2 Hana
B2.2.2: 2D Lists
A 2D list is a list of lists. Picture a table: each inner list is one row. You reach an element with two indexes: [row][column]. Remember it as R before C, like a remote control car.
Daily temperatures for four weeks. Rows are weeks, columns are days.
| [0] | [1] | [2] | [3] | [4] | [5] | [6] | |
|---|---|---|---|---|---|---|---|
| [0] | 21 | 23 | 24 | 22 | 23 | 24 | 29 |
| [1] | 22 | 20 | 19 | 18 | 18 | 17 | 20 |
| [2] | 24 | 28 | 29 | 31 | 32 | 31 | 33 |
| [3] | 23 | 25 | 21 | 26 | 22 | 20 | 19 |
The highlighted cell is temperature[2][3]: row 2, then column 3.
temperature = [
[21, 23, 24, 22, 23, 24, 29],
[22, 20, 19, 18, 18, 17, 20],
[24, 28, 29, 31, 32, 31, 33],
[23, 25, 21, 26, 22, 20, 19]
]
print(temperature[2][3]) # row 2, column 3
print(len(temperature)) # number of rows
print(len(temperature[0])) # number of columns in row 0
temperature[3][2] = 34 # change one element
31 4 7
Traversing a 2D list: the double loop
To visit every element you need a loop inside a loop. The outer loop picks a row. The inner loop goes through every value in that row. Only when the inner loop finishes does the outer loop move to the next row. Python lists are dynamic, so each row can even have a different length.
temperature = [
[21, 23, 24, 22, 23, 24, 29],
[22, 20, 19, 18, 18, 17, 20],
[24, 28, 29, 31, 32, 31, 33],
[23, 25, 21, 26, 22, 20, 19]
]
temperature[1].append(24) # rows can grow: dynamic
temperature[2].append(25)
total = 0
count = 0
for row in temperature: # outer loop: each row
for value in row: # inner loop: each value in that row
total = total + value
count = count + 1
print("Average:", round(total / count, 1))
Average: 23.8
Need the positions too? Use indexes: for r in range(len(grid)): then for c in range(len(grid[r])): and read grid[r][c].
[[""] * 3] * 2 does not make two rows. It makes one row and points to it twice, so changing one "row" changes both. Build 2D lists with a loop or a list comprehension instead.
# RIGHT: a new row list is made each time
seats = [["" for col in range(3)] for row in range(2)]
seats[0][1] = "Ivan"
print(seats)
# WRONG: every row is the SAME list
bad = [[""] * 3] * 2
bad[0][1] = "Ivan"
print(bad)
[['', 'Ivan', ''], ['', '', '']] [['', 'Ivan', ''], ['', 'Ivan', '']]
Make a seating plan with 3 rows of 4 desks. Put three students in seats, then print the plan row by row, showing "empty" for free desks.
ROWS = 3
COLS = 4
plan = [["empty" for c in range(COLS)] for r in range(ROWS)]
plan[0][0] = "Ana"
plan[1][2] = "Ivan"
plan[2][3] = "Leo"
for r in range(ROWS):
print("Row", r, ":", plan[r])
Row 0 : ['Ana', 'empty', 'empty', 'empty'] Row 1 : ['empty', 'empty', 'Ivan', 'empty'] Row 2 : ['empty', 'empty', 'empty', 'Leo']
B2.2.3: Stacks (LIFO)
Think of a pile of plates, or a tube of Pringles. You can only add to the top and take from the top. The plate you put on last is the first one you take off. That is LIFO: Last In, First Out (also called FILO, first in, last out).
| Operation | What it does | Python list | Big O |
|---|---|---|---|
push(x) | Adds x to the top | stack.append(x) | O(1) |
pop() | Removes and returns the top item | stack.pop() | O(1) |
peek() | Returns the top item without removing it | stack[-1] | O(1) |
isEmpty() | True if the stack has no items | len(stack) == 0 | O(1) |
Every operation touches only the top, so it takes the same time however big the stack is: O(1). Reaching the bottom item is O(n), because everything above it must be popped first. (Big O comes properly in B2.4.)
A Python list already works as a stack: the end of the list is the top.
stack = []
stack.append("A") # push
stack.append("B") # push
stack.append("C") # push
print(stack)
print(stack[-1]) # peek: look at the top
print(stack.pop()) # pop: remove the top
print(stack.pop())
print(stack)
print(len(stack) == 0) # isEmpty
['A', 'B', 'C'] C C B ['A'] False
Wrapping it in a class gives the exact method names the exam uses. Here it powers an undo button:
class Stack:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
if self.is_empty():
return None # stack underflow: nothing to pop
return self.items.pop()
def peek(self):
if self.is_empty():
return None
return self.items[-1]
def is_empty(self):
return len(self.items) == 0
history = Stack()
history.push("type Hello")
history.push("make bold")
history.push("delete word")
print("Undo:", history.pop())
print("Undo:", history.pop())
print("Next undo would be:", history.peek())
Undo: delete word Undo: make bold Next undo would be: type Hello
| Error | When |
|---|---|
| Stack overflow | Pushing onto a stack that is full. Happens with a fixed size stack, or when recursion goes too deep and fills the call stack. |
| Stack underflow | Popping or peeking an empty stack. Always check isEmpty() first. |
Where stacks are used
| Use | How the stack helps |
|---|---|
| Undo (Ctrl+Z) | Each action is pushed. Undo pops the most recent action and reverses it. |
| Browser back button | Each page visited is pushed. Back pops to the previous page. |
| Recursion and function calls | Each call's state is pushed onto the call stack. When the base case is reached, the calls are popped and finished in reverse order. |
| Checking brackets (parsing) | Push every opening bracket, pop when a closing one appears. Empty at the end means every bracket was closed. |
| Reversing | Push each character, then pop them all. They come out backwards. |
def balanced(text):
stack = []
pairs = {")": "(", "]": "[", "}": "{"}
for ch in text:
if ch in "([{":
stack.append(ch) # opening: push
elif ch in ")]}":
if len(stack) == 0 or stack.pop() != pairs[ch]:
return False # closing with no match
return len(stack) == 0 # empty stack = all closed
print(balanced("print((a + b) * [c])"))
print(balanced("if (x > 1 {"))
True False
word = "NINJA"
stack = []
for ch in word:
stack.append(ch) # push every letter
reversed_word = ""
while len(stack) > 0:
reversed_word = reversed_word + stack.pop() # pop comes out backwards
print(reversed_word)
AJNIN
B2.2.4: Queues (FIFO)
Think of the line at a shop till. You join at the back. People are served from the front, in the order they arrived. Nobody pushes in. That is FIFO: First In, First Out.
| Operation | What it does | Python deque | Big O |
|---|---|---|---|
enqueue(x) | Adds x to the back | queue.append(x) | O(1) |
dequeue() | Removes and returns the front item | queue.popleft() | O(1) |
front() | Returns the front item without removing it | queue[0] | O(1) |
isEmpty() | True if the queue has no items | len(queue) == 0 | O(1) |
Reaching the item at the back is O(n), because everything in front of it must be dequeued first.
Python's deque (double ended queue, said "deck") is built for this. A plain list works too with pop(0), but that is slow, O(n), because every other item has to shift one place left.
from collections import deque
queue = deque()
queue.append("Job 1") # enqueue: join the back
queue.append("Job 2")
queue.append("Job 3")
print(list(queue))
print(queue[0]) # front: look without removing
print(queue.popleft()) # dequeue: leave from the front
print(queue.popleft())
print(list(queue))
print(len(queue) == 0) # isEmpty
['Job 1', 'Job 2', 'Job 3'] Job 1 Job 1 Job 2 ['Job 3'] False
from collections import deque
class Queue:
def __init__(self):
self.items = deque()
def enqueue(self, item):
self.items.append(item)
def dequeue(self):
if self.is_empty():
return None
return self.items.popleft()
def front(self):
if self.is_empty():
return None
return self.items[0]
def is_empty(self):
return len(self.items) == 0
playlist = Queue()
for song in ["Song A", "Song B", "Song C"]:
playlist.enqueue(song)
while not playlist.is_empty():
print("Now playing:", playlist.dequeue())
Now playing: Song A Now playing: Song B Now playing: Song C
Where queues are used
| Use | How the queue helps |
|---|---|
| Printer queue | Print jobs are enqueued as they are sent and printed in that order. When one finishes, the next is dequeued. |
| Playlists | Songs or videos are enqueued. When the current one ends, the next is dequeued. |
| Network traffic | Routers and switches hold packets in a queue (a buffer) until they can be forwarded. |
| CPU scheduling | Processes wait in a ready queue. Round Robin sends an unfinished process to the back (see A1.3.3). |
| Keyboard buffer | Key presses are queued so letters appear in the order you typed them. |
Try It: Live Stack and Queue
Click the buttons and watch where items go in and where they come out. Orange is the next item to leave.
Stack
Queue
Stack vs Queue
| Stack | Queue | |
|---|---|---|
| Order | LIFO: last in, first out | FIFO: first in, first out |
| Add | push onto the top | enqueue at the back |
| Remove | pop from the top | dequeue from the front |
| Look | peek at the top | front |
| Ends used | One | Two |
| Picture | Pile of plates | Line at the shop |
| Classic uses | Undo, back button, recursion, brackets | Printing, playlists, packets, scheduling |
Choosing the Right Structure
Ask three questions. Do I know how many items there will be? (static or dynamic) Is the data a line or a grid? (1D or 2D) Does the order of removal matter? (newest first = stack, oldest first = queue)
The textbook's thinking skills task, answered:
| Scenario | Suggested | Verdict |
|---|---|---|
| Animals in a zoo | 1D list | Agree. A simple line of names. |
| Classroom seating plan | 1D list | Disagree: 2D list. Desks have rows and columns. |
| Planes waiting to land | Stack | Disagree: queue. The first plane to arrive should land first. |
| People waiting to pay | Stack | Disagree: queue. First come, first served. |
| A shelf in a shop | 2D list | Agree. Shelf levels are rows, positions are columns. |
| Jobs for a network printer | Queue | Agree. Printed in the order sent. |
Practice Questions
Starting with an empty stack: push(5), push(3), pop(), push(7), push(1), pop(), peek(). What does each pop and the peek return, and what is left in the stack?
| Operation | Returns | Stack (bottom → top) |
|---|---|---|
| push(5) | 5 | |
| push(3) | 5, 3 | |
| pop() | 3 | 5 |
| push(7) | 5, 7 | |
| push(1) | 5, 7, 1 | |
| pop() | 1 | 5, 7 |
| peek() | 7 | 5, 7 |
Starting with an empty queue: enqueue(5), enqueue(3), dequeue(), enqueue(7), enqueue(1), dequeue(), front(). Same questions.
| Operation | Returns | Queue (front → back) |
|---|---|---|
| enqueue(5) | 5 | |
| enqueue(3) | 5, 3 | |
| dequeue() | 5 | 3 |
| enqueue(7) | 3, 7 | |
| enqueue(1) | 3, 7, 1 | |
| dequeue() | 3 | 7, 1 |
| front() | 7 | 7, 1 |
Same commands, different answers. That is the whole difference between LIFO and FIFO.
Two marks per difference: one for each side.
- Size: a static structure has a fixed size set when it is declared [1]; a dynamic structure can grow and shrink while the program runs [1].
- Memory: a static structure reserves all its memory up front, even if elements are empty, which can waste space [1]; a dynamic structure allocates memory as items are added and frees it when they are removed [1].
- Speed: a static structure is faster to access because elements are stored next to each other and reached directly by index [1]; a dynamic structure is slower because resizing takes time and memory may be fragmented [1].
A dynamic structure is suitable [1]. The number of transactions in a day is not known in advance and changes from day to day [1]. A dynamic structure can grow as each transaction happens, so it never runs out of space and does not waste memory reserved for transactions that never happen [1].
Each cell is one day and can hold several classes. In Python, store each day as a list of (hour, class) pairs. Rows are weeks, columns are days, and [0][0] is Monday of week 1.
gym = [
[[(8, "Pilates")], [(18, "Boxfit")], [(13, "Attack")], [(9, "Pilates"), (19, "Boxfit")], [(14, "Attack")], [(8, "Pilates")], [(8, "Pilates"), (10, "Boxfit")]],
[[(10, "Boxfit")], [(9, "Pilates"), (17, "Attack")], [(19, "Boxfit")], [(9, "Pilates")], [(9, "Baby Yoga")], [(8, "Pilates")], [(8, "Pilates")]],
[[(8, "Pilates")], [(11, "Boxfit"), (17, "Yoga")], [(18, "Pilates")], [(12, "Boxfit")], [(18, "Pilates"), (19, "Attack")], [(8, "Pilates"), (10, "Boxfit")], [(8, "Pilates")]],
[[(18, "Pilates")], [(18, "Boxfit")], [(19, "Pilates")], [(19, "Boxfit")], [(19, "Pilates")], [(8, "Pilates")], [(8, "Pilates")]],
]
a. Count how many Pilates classes there are in the month. [5]
b. Count how many evening classes there are (at 18 or 19). [5]
c. Return a list of Boxfit classes with the week and day they happen on. [6]
All three use the same pattern: loop over weeks, loop over days, loop over the classes in that day. Only the if changes.
a.
pilates = 0
for week in gym: # each row (week)
for day in week: # each cell (day)
for hour, name in day: # each class on that day
if name == "Pilates":
pilates = pilates + 1
print("Pilates classes:", pilates)
Pilates classes: 18
b.
evening = 0
for week in gym:
for day in week:
for hour, name in day:
if hour == 18 or hour == 19:
evening = evening + 1
print("Evening classes:", evening)
Evening classes: 11
c. Here you need the positions, so loop with indexes.
days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
boxfit = []
for w in range(len(gym)):
for d in range(len(gym[w])):
for hour, name in gym[w][d]:
if name == "Boxfit":
boxfit.append("Week " + str(w + 1) + ", " + days[d] + ", " + str(hour) + ":00")
for c in boxfit:
print(c)
Week 1, Tuesday, 18:00 Week 1, Thursday, 19:00 Week 1, Sunday, 10:00 Week 2, Monday, 10:00 Week 2, Wednesday, 19:00 Week 3, Tuesday, 11:00 Week 3, Thursday, 12:00 Week 3, Saturday, 10:00 Week 4, Tuesday, 18:00 Week 4, Thursday, 19:00
Stack: undo in a text editor. Every action the user makes is pushed onto a stack [1]. When the user presses undo, the most recent action is popped and reversed, because it is the last in and must be the first out [1].
Queue: a shared printer. Print jobs are enqueued at the back in the order they are sent [1]. When the printer finishes a job, the next is dequeued from the front, so jobs are printed first come, first served [1].
- It says stacks are always fixed size. They can be static (an array) or dynamic (a list). Stack overflow happens with fixed size stacks and the call stack.
- It says the push method resolves recursive calls after the base case. It is pop.
- It says a queue uses memory better than a stack. Both can be static or dynamic, so that is not a difference.
- Its "loop and indexes" Python example (
for name in myList) does not use indexes. Usefor i in range(len(myList)). - Its Python temperature data has typos (28 instead of 18 in week 2). The tables on this page use the correct data.
- Static = fixed size, faster, can waste memory. Dynamic = flexible size, uses only what it needs, slightly slower.
- Indexes start at 0. Last index = length − 1.
- 2D lists are
[row][column]. Traverse with a loop inside a loop. - Stack = LIFO = plates. push, pop, peek, isEmpty. All at the top.
- Queue = FIFO = shop line. enqueue at the back, dequeue at the front, front, isEmpty.
- Push, pop, peek, enqueue, dequeue and front are all O(1).
- Exam scenario? "Most recent first" means stack. "In order of arrival" means queue.