Part 1 · Foundations · Module 06
Collections
Real programs juggle many values at once: a shopping cart, a phone book, the set of users who are online. Python's four built-in collections (list, tuple, dict, and set) cover almost every case, and choosing the right one is one of the most important design decisions you make every day.
- Create, index, slice, and modify lists, and know which list operations are fast or slow
- Use tuples for fixed records, with packing, unpacking, and star-unpacking
- Store and look up data by key with dicts: create, read, update, delete, iterate, merge
- Use sets for uniqueness, fast membership tests, and set algebra
- Write list, dict, and set comprehensions, including conditional and nested ones
- Sort with
keyandreverse, and iterate withzipandenumerate - Explain aliasing, shallow copies, and deep copies, and pick the right one
Lists: ordered and changeable
A list is an ordered sequence of items that can grow, shrink, and change in place. Write one with square brackets. Items can be any type, though in practice a list usually holds items of one kind.
groceries = ["eggs", "milk", "bread"]
empty = []
mixed = [1, "two", 3.0, None] # legal, but usually a sign a tuple or dict fits better
Indexing and slicing work exactly as they did for strings in M03: xs[0] is the first item,
xs[-1] the last, xs[1:3] a new list with items 1 and 2, xs[::-1] a
reversed copy. The difference is that lists are mutable: you can assign to an index or a
slice.
Methods that change a list in place, such as append, sort, and
reverse, return None. This is deliberate: it reminds you that no new list was
made.
How fast are list operations?
A list is stored as a contiguous array of references, with some spare capacity at the end. That design makes some operations very fast and others slow. "O(1)" means the time does not depend on the list's length; "O(n)" means it grows in proportion to the length (Part 3 makes this precise).
| Operation | Cost | Why |
|---|---|---|
xs[i], xs[i] = v, len(xs) | O(1) | Jump straight to position i. |
xs.append(v), xs.pop() | O(1) amortized | Uses spare capacity at the end; occasionally grows. |
xs.insert(0, v), xs.pop(0) | O(n) | Every other item must shift by one. |
v in xs, xs.index(v), xs.remove(v) | O(n) | Checks items one by one. |
xs.sort() | O(n log n) | Timsort. |
Watch what append really does: most appends drop into spare space, and once in a while the list
allocates a bigger block and copies everything across. Averaged over many appends the cost per append stays
constant.
Tuples: fixed records, packing, and unpacking
A tuple is an ordered, immutable sequence. Once created it cannot change.
Use a tuple for a fixed group of related values (a record) where the position means something: a point
(x, y), an RGB colour (255, 128, 0), a row ("Ada", 36, "London"). Use a
list for a variable-length collection of similar things.
It is the comma that makes a tuple; the parentheses are usually optional. This is called packing. The reverse, unpacking, assigns each item to its own name.
Star-unpacking
A starred name collects "everything else" into a list. It is handy for splitting off a head or a tail.
Because they are immutable, tuples can be dictionary keys and set members (as long as everything inside is also immutable), while lists cannot. Immutability also signals intent: readers know the record will not change.
Dictionaries: look things up by key
A dict maps keys to values. Instead of asking for "item
number 3", you ask for "the value stored under "Ada"". Looking up, adding, and deleting a key are
all O(1) on average, no matter how big the dict grows, because dicts are hash tables: Python computes
a number (the hash) from the key and uses it to jump straight to the right slot.
Keys must be hashable: strings, numbers, booleans, None, and tuples of hashable
things. Lists, dicts, and sets cannot be keys. Values can be anything. Since Python 3.7, dicts remember
insertion order, so iterating gives keys in the order they were added.
Iterating
for name in ages: # keys
for value in ages.values(): # values
for name, age in ages.items(): # (key, value) pairs: by far the most common
Counting and grouping: get and setdefault
Two patterns appear constantly. Counting uses get with a default of 0.
Grouping uses setdefault, which returns the existing value for a key or inserts
and returns a default.
Merging
The | operator merges two dicts into a new one; when keys clash, the right-hand side wins.
|= (like update) merges in place.
Sets: uniqueness and fast membership
A set is an unordered collection of unique, hashable items. It is a dict with keys
and no values. Write one with braces ({1, 2, 3}), but note that {} is an empty
dict; an empty set is set().
The real superpower is speed. Checking x in some_list scans the list item by item, but
x in some_set computes a hash and checks one slot. Try it:
A frozenset is an immutable set, so it is hashable and can be a dict key or a member of another
set.
Comprehensions
A comprehension builds a new collection from an iterable in one expression. It reads almost
like English: "the square of n, for each n in nums, if n
is even".
squares = []
for n in nums:
if n % 2 == 0:
squares.append(n * n)squares = [n * n for n in nums if n % 2 == 0]There are list ([...]), set ({...}), and dict ({k: v ...})
comprehensions. Two different uses of if are easy to confuse:
- Filter at the end:
[x for x in xs if x > 0]keeps only some items. - Choose in the expression:
[x if x > 0 else 0 for x in xs]keeps every item but transforms it with a conditional expression.
Nested comprehensions
Multiple for clauses run in the same order as nested loops, outermost first.
Wrapping a comprehension in parentheses instead of brackets makes a generator expression, which
produces items one at a time without building a list: sum(n * n for n in range(1_000_000)).
You will study these in M14.
Sorting, zip, and enumerate
sorted(iterable) returns a new sorted list from any iterable. list.sort()
sorts a list in place and returns None. Both accept key= (a function that
produces the value to compare) and reverse=True.
Python's sort is stable: items that compare equal keep their original order. That is why sorting by a tuple key, or sorting twice (secondary key first, then primary key), gives predictable results.
enumerate and zip
enumerate(xs, start=0) gives (index, item) pairs, so you never need
range(len(xs)). zip(a, b, ...) walks several iterables in step, producing tuples, and
stops at the shortest one. Pass strict=True to raise ValueError if the lengths
differ, which catches bugs early.
Aliases, shallow copies, and deep copies
Recall from M02 that assignment never copies; it binds another name to the same object. With mutable collections this matters enormously.
- Alias:
b = a. Two names, one object. Mutating through either is visible through both. - Shallow copy:
b = a.copy(),list(a),a[:],dict(a),copy.copy(a). A new outer container whose slots refer to the same inner objects. - Deep copy:
copy.deepcopy(a). A new outer container and, recursively, new copies of everything inside.
Step through a shallow copy. The outer lists are different objects, but they share their contents:
With nested data, a shallow copy shares the inner lists, so changing an inner list shows up in both:
A deep copy duplicates every level:
Use the tracer to confirm with object identity tags: alias shares the outer object's tag;
shallow has its own outer tag but its rows share tags with board's rows.
A preview of the collections module
The standard library's collections module provides specialized containers for jobs you just
did by hand. Part 3 studies the data structures behind them.
Choose a list
Ordered items you will add to, iterate, or index by position.
Choose a tuple
A fixed record, or a hashable key made of several values.
Choose a dict
Look up values by a key, count, or group.
Choose a set
Uniqueness, membership tests, or set algebra.
Practice
Write dedupe(items) that returns a new list with duplicates removed, keeping the
first occurrence of each item in its original order. Do not modify items.
Hint
Keep a set of items already seen, or use the fact
that dicts keep insertion order: dict.fromkeys(items).
Show solution
def dedupe(items):
seen = set()
result = []
for item in items:
if item not in seen:
seen.add(item)
result.append(item)
return result
Write word_counts(text) returning a dict of lower-case word → count. Split on whitespace and
strip the punctuation characters .,!?;:"' from both ends of each word. Ignore words that become
empty.
Hint
word.strip('.,!?;:"\'').lower(), then
counts[w] = counts.get(w, 0) + 1.
Show solution
def word_counts(text):
counts = {}
for raw in text.split():
word = raw.strip(".,!?;:\"'").lower()
if word:
counts[word] = counts.get(word, 0) + 1
return counts
Write invert(d). Several keys may share a value, so return a dict mapping each value to a
sorted list of the keys that had it. Example: {"a": 1, "b": 2, "c": 1} →
{1: ["a", "c"], 2: ["b"]}.
Hint
Group with setdefault(value, []).append(key), then
sort each list.
Show solution
def invert(d):
result = {}
for key, value in d.items():
result.setdefault(value, []).append(key)
return {value: sorted(keys) for value, keys in result.items()}
Write top_n(scores, n) where scores maps name → score. Return a list of the
top n (name, score) tuples, highest score first; ties are broken by name in
alphabetical order.
Hint
sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))[:n]
Show solution
def top_n(scores, n):
ranked = sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
return ranked[:n]
Write transpose(matrix) that turns rows into columns using a comprehension or
zip. The result must be a list of lists. All rows have the same length; an empty
matrix gives [].
Hint
zip(*matrix) yields the columns as tuples.
Show solution
def transpose(matrix):
return [list(column) for column in zip(*matrix)]
Write merge_settings(defaults, overrides) that returns a new dict. Nested dicts are
merged recursively; any other value in overrides replaces the default. Neither input may be
changed, and the result must not share nested dicts with defaults.
Hint
Start from copy.deepcopy(defaults). For each override,
recurse when both sides are dicts; otherwise assign a deep copy of the override value.
Show solution
import copy
def merge_settings(defaults, overrides):
result = copy.deepcopy(defaults)
for key, value in overrides.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = merge_settings(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
Mini-project: an in-memory contact book
Quiz
Recap
- Lists: ordered, mutable, cheap at the end, O(n) at the front and for searching.
- Tuples: immutable records; packing with commas, unpacking with names,
*restfor the remainder. - Dicts: O(1) average lookup by hashable key;
get,setdefault,items(),|. - Sets: unique items, O(1) membership, union/intersection/difference.
- Comprehensions build lists, sets, and dicts; trailing
iffilters,a if c else bchooses. sorted(key=..., reverse=...)is stable; use tuple keys for multi-level sorts;zip/enumeratereplace index juggling.- Assignment aliases;
.copy()is shallow;copy.deepcopycopies every level.