Tuples, Dictionaries, Strings and Sets
Lists do not solve everything. This lecture fills in the other four containers, and answers the more important question — given a concrete problem, which one should you reach for.
By the end of this lecture you should be able to
- Explain how a tuple differs from a list, and why an immutable sequence is useful
- Store and retrieve data by name with dictionaries, avoiding KeyError and the resize-during-iteration trap
- Use sets for deduplication and membership tests, and say why they are fast
- Work comfortably with split, join, strip and the other string methods
- Given a scenario, name which of the five containers fits and why
Contents
Where lists run out
L5 covered lists: a batch of values, reached by position. Three kinds of problem give them trouble:
- A group of values that should not be altered — a coordinate
(3, 4), say, where nobody should be able toappendto it - Looking things up by name rather than by position — “what did Alex score”, not “what did person number 37 score”
- Asking whether something is present — a list’s
inscans from the front, and that gets slow as the data grows
The four containers in this lecture answer those three questions.
Tuples
A list you cannot change
Tuples are written with parentheses and behave much like lists otherwise:
t = (1, 2, 3)
print(t[0]) # same indexing
print(t[1:]) # same slicing
print(len(t)) # same length
print(3 in t) # same membership test
for x in t:
print(x, end=" ")
print()
There is exactly one difference: a tuple is immutable.
t = (1, 2, 3)
t[0] = 99
There is no append, remove or sort, because all of those modify in place. A tuple has only two methods:
t = (1, 2, 2, 3, 2)
print(t.count(2)) # how many times 2 appears
print(t.index(3)) # where 3 first appears
One comma makes all the difference
a = (1) # not a tuple — those are just grouping parentheses
b = (1,) # a one-element tuple; the comma is what counts
c = 1, 2, 3 # parentheses are optional
print(a, type(a))
print(b, type(b))
print(c, type(c))
Packing and unpacking
This is where tuples earn their keep, and you have been using them already:
# packing: several values become a tuple
point = 3, 4
print(point, type(point))
# unpacking: a tuple spreads across several names
x, y = point
print(f"x={x}, y={y}")
# swapping two variables — the classic Python one-liner
a, b = 1, 2
a, b = b, a
print(a, b)
The “return several values” functions from L3 were returning tuples all along:
def min_max(nums: list[int]) -> tuple[int, int]:
return min(nums), max(nums)
result = min_max([3, 9, 5])
print(result, type(result))
low, high = min_max([3, 9, 5]) # unpack directly
print(low, high)
Unpacking requires the counts to match, or it fails:
a, b = 1, 2, 3
A * collects whatever is left over:
first, *rest = [1, 2, 3, 4, 5]
print(first, rest)
*init, last = [1, 2, 3, 4, 5]
print(init, last)
When to use a tuple
- The values form one thing that should not be taken apart: a coordinate, a date, an RGB color
- You need it as a dictionary key (see below — a list cannot be one, a tuple can)
- A function returns several values
Dictionaries
Look things up by name
Suppose you are storing a class’s results. With a list you would have to remember that “Alex is number 37”, which is clearly hopeless.
A dictionary stores key: value pairs:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
print(scores["Alex"]) # look up by name
print(len(scores))
print("Blake" in scores) # is that key present
A dictionary lookup takes about the same time no matter how big it is. Ten thousand entries or three, one lookup costs the same. A list cannot do that, because its in compares from the front.
The mechanism is a hash table: the dictionary computes a position from the key and jumps straight there. L11 opens that up.
Creating one
a = {"x": 1, "y": 2} # a literal
b = dict(x=1, y=2) # keyword arguments
c = dict([("x", 1), ("y", 2)]) # a list of pairs
d = {} # empty
print(a, b, c, d)
print(a == b == c)
Asking for a key that is not there
scores = {"Alex": 87}
print(scores["Dana"])
KeyError is the most common dictionary error. Three ways to deal with it:
scores = {"Alex": 87}
# 1. check first
if "Dana" in scores:
print(scores["Dana"])
else:
print("no such person")
# 2. get(): returns None, or a default you supply
print(scores.get("Dana"))
print(scores.get("Dana", 0)) # supply a default — the usual choice
# 3. setdefault(): inserts it if missing
print(scores.setdefault("Dana", 0))
print(scores) # Dana is now in there
Adding, removing, changing
scores = {"Alex": 87, "Blake": 92}
scores["Chen"] = 65 # add
scores["Alex"] = 90 # change — same syntax, the key already exists
print(scores)
del scores["Blake"] # remove
print(scores)
value = scores.pop("Chen") # remove and return the value
print(value, scores)
Assignment both adds and updates, depending on whether the key was there. Lists behave differently — assigning to a non-existent index raises IndexError.
Iterating
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
for name in scores: # iterating a dict gives the keys
print(name, end=" ")
print()
for name in scores.keys(): # spelled out
print(name, end=" ")
print()
for score in scores.values(): # just the values
print(score, end=" ")
print()
for name, score in scores.items(): # both at once — what you want most often
print(f"{name}: {score}")
You cannot resize one while iterating it
scores = {"Alex": 87, "Blake": 92}
for name in scores:
if scores[name] < 90:
del scores[name]
Same reasoning as with lists (covered in L5), but a dictionary complains outright: RuntimeError: dictionary changed size during iteration.
The fix is to collect first, delete afterwards:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
to_delete = [name for name, s in scores.items() if s < 90]
for name in to_delete:
del scores[name]
print(scores)
Or build a new dictionary and skip the deletion entirely:
scores = {"Alex": 87, "Blake": 92, "Chen": 65}
passed = {name: s for name, s in scores.items() if s >= 90}
print(passed)
That last form is a dict comprehension — the same idea as a list comprehension.
Keys have to be immutable
d = {}
d[(1, 2)] = "a tuple works as a key"
d["abc"] = "so does a string"
d[42] = "and a number"
print(d)
d = {}
d[[1, 2]] = "a list does not"
The dictionary computes a fixed position from the key. If the key could change, that position would move and whatever was stored there would become unreachable. Whether something can be a key comes down to whether it can be modified.
Merging
a = {"x": 1, "y": 2}
b = {"y": 20, "z": 30}
print({**a, **b}) # unpack both; later wins on collisions
print(a | b) # Python 3.9+, equivalent
c = a.copy()
c.update(b) # update in place
print(c)
Application one: counting
The archetypal dictionary task:
text = "the quick brown fox jumps over the lazy dog the end"
counts: dict[str, int] = {}
for word in text.split():
counts[word] = counts.get(word, 0) + 1
print(counts)
# sort by frequency and take the top three
top = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)
print(top[:3])
Commit counts.get(word, 0) + 1 to memory: treat an unseen key as zero, then add one.
The standard library also has a tool built for exactly this:
from collections import Counter
text = "the quick brown fox jumps over the lazy dog the end"
print(Counter(text.split()).most_common(3))
Application two: replacing a chain of elifs
# the long way
def month_days_if(month: int) -> int:
if month in (1, 3, 5, 7, 8, 10, 12):
return 31
elif month == 2:
return 28
else:
return 30
# as a lookup table
MONTH_DAYS = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31}
def month_days(month: int) -> int:
return MONTH_DAYS[month]
print(month_days_if(2), month_days(2))
print(month_days_if(4), month_days(4))
When the branches are nothing more than input-to-output mapping, a dictionary reads better than an if-elif chain — and adding a case means adding a line of data rather than editing logic.
Sets
No duplicates, no order
s = {3, 1, 4, 1, 5, 9, 2, 6, 5}
print(s) # the duplicates are gone
print(len(s))
Two properties: no element appears twice, and no order is recorded — which is why you cannot index into one.
s = {1, 2, 3}
print(s[0])
Deduplicating
The most common use:
nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
print(list(set(nums))) # deduplicated, order not preserved
# to keep the original order, use a dict — keys are unique and ordered
print(list(dict.fromkeys(nums)))
Membership tests are fast
big_list = list(range(100000))
big_set = set(big_list)
# same answer, very different cost
print(99999 in big_list)
print(99999 in big_set)
A list’s in compares from the front. Sets, like dictionaries, hash the value and go almost straight to the answer. When the data is large and you keep asking “is this in there”, convert to a set first.
Set operations
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # union: in a or in b
print(a & b) # intersection: in both
print(a - b) # difference: in a but not b
print(a ^ b) # symmetric difference: in exactly one
# something real: which courses two students share
alex = {"maths", "physics", "english", "programming"}
blake = {"physics", "chemistry", "programming", "sport"}
print("both: ", alex & blake)
print("alex only: ", alex - blake)
print("between: ", alex | blake)
String methods
A string is an immutable sequence; L3 covered the basics. Here are the methods worth knowing. They all return a new string and leave the original alone.
Splitting and joining
line = "Alex,87,maths"
parts = line.split(",")
print(parts)
print("-".join(parts)) # join with hyphens
print("".join(parts)) # straight concatenation
text = " the quick brown fox "
print(text.split()) # no argument: split on any whitespace, ignoring runs
print(text.split(" ")) # a single space: strict, and you get empty strings
Trimming whitespace
s = " hello \n"
print(repr(s.strip())) # both ends
print(repr(s.lstrip())) # left
print(repr(s.rstrip())) # right
strip() is near-mandatory when handling input() or reading files — people type stray spaces, and every line from a file ends in a newline.
Case and classification
s = "Hello World"
print(s.upper())
print(s.lower())
print(s.title())
print(s.startswith("Hello"), s.endswith("!"))
for s in ["123", "12.3", "abc", "abc123", " ", ""]:
print(f"{s!r:10} isdigit={s.isdigit()!s:<6} isalpha={s.isalpha()!s:<6} isalnum={s.isalnum()}")
Searching and replacing
s = "the quick brown fox"
print(s.find("quick")) # index, or -1 when absent
print(s.find("cat"))
print(s.count("o"))
print(s.replace("quick", "slow"))
print(s) # unchanged
The full format specification
L3 covered the everyday f-string forms. The complete specification is assembled like this:
{value:[[fill]align][sign][#][0][width][,][.precision][type]}
Most of it you will never need. Consult this table when you do:
| Slot | Written | Effect |
|---|---|---|
| align | < > ^ | left / right / center |
| fill | any character before the align symbol | *^10 centers, padding with asterisks |
| sign | + - | always show / only for negatives / space for positives |
| grouping | , _ | {1234567:,} → 1,234,567 |
| precision | .n | decimal places, or truncation length for strings |
| type | d f e % b o x | integer / fixed / scientific / percentage / bases |
n = 1234567.891
print(f"{n:,.2f}") # grouping plus two decimals
print(f"{n:>15,.2f}") # and right-aligned in 15 columns
print(f"{0.856:.1%}") # as a percentage
print(f"{42:*^11}") # centered, asterisk fill
print(f"{'truncate':.4}") # strings can be cut short too
Five containers: which one?
This is where Part II closes. Choose the right container and your code halves in length and speeds up tenfold. Choose wrong and nothing you write feels comfortable.
| list | tuple | str | dict | set | |
|---|---|---|---|---|---|
| written | [1,2] | (1,2) | "ab" | {"k":1} | {1,2} |
| mutable | ✅ | ❌ | ❌ | ✅ | ✅ |
| ordered | ✅ | ✅ | ✅ | ✅ (insertion) | ❌ |
| indexable / sliceable | ✅ | ✅ | ✅ | ❌ (by key) | ❌ |
| duplicates allowed | ✅ | ✅ | ✅ | keys unique | ❌ |
| usable as a dict key | ❌ | ✅ | ✅ | ❌ | ❌ |
| lookup speed | slow (scan) | slow | slow | fast | fast |
Choosing by the problem
| What you need | Reach for | Why |
|---|---|---|
| a batch of similar values, processed in order | list | ordered, and you can add and remove |
| a fixed group of values, e.g. a coordinate | tuple | immutable, safe, usable as a key |
| data retrieved by name or id | dict | fast lookup, clear intent |
| “is this present”, or removing duplicates | set | fast lookup, deduplicates for free |
| how many times each thing occurs | dict | key holds the thing, value holds the count |
| working with text | str | it has the methods for it |
| a compound key | dict keyed by tuples | d[(x, y)] = value |
Two traps common to all of them
Modifying a container while iterating it
The rule holds for every container, and you have now met it on both lists and dictionaries:
# a list fails silently
a = [1, 2, 2, 3]
for x in a:
if x == 2:
a.remove(x)
print("list:", a) # not [1, 3]
# a set raises
s = {1, 2, 3}
for x in s:
if x == 2:
s.remove(x)
One remedy covers both cases: collect first and remove afterwards, or build a new container. The second is usually clearer.
A mutable argument gets modified
def add_item(items: list[int], x: int) -> None:
items.append(x) # modifies the caller's list directly
nums = [1, 2, 3]
add_item(nums, 4)
print(nums) # the list outside changed
That is not a bug — as L5 explained, what gets passed is a reference, not a copy. But if the caller did not expect it, the resulting problem is very hard to track down.
Two ways to handle it:
# 1. leave the input alone and return something new
def with_item(items: list[int], x: int) -> list[int]:
return items + [x] # + builds a new list
nums = [1, 2, 3]
new = with_item(nums, 4)
print(nums, new) # the original is untouched
# 2. modify, but make it obvious in the name and the docstring
def append_in_place(items: list[int], x: int) -> None:
"""Append x to items. Note: modifies the list you pass in."""
items.append(x)
nums = [1, 2, 3]
append_in_place(nums, 4)
print(nums)
A few useful built-ins
nums = [3, 1, 4, 1, 5]
print(sorted(nums)) # returns a new list; nums untouched
print(sorted(nums, reverse=True))
print(nums)
words = ["banana", "kiwi", "apple"]
print(sorted(words, key=len)) # by length
print(sorted(words, key=str.lower)) # case-insensitively
names = ["Alex", "Blake", "Chen"]
scores = [87, 92, 65]
for name, score in zip(names, scores): # walk two sequences together
print(f"{name}: {score}")
print(dict(zip(names, scores))) # straight into a dictionary
for i, name in enumerate(["a", "b", "c"], start=1): # with a running index
print(i, name)
zip and enumerate between them eliminate most of the ugly range(len(...)) code people write. Prefer them.
Summary
- Tuple = an unchangeable list. It is the comma that makes it, not the parentheses. Use it for data that should not change, for dictionary keys, and for returning several values
- Dict = storage by name, with fast lookup.
get(k, default)avoidsKeyError;items()gives you both halves - Dictionaries have preserved insertion order since 3.7
- Dictionary keys must be immutable — tuples qualify, lists do not
- Set = unique, unordered, fast to search. Good for deduplication and membership, but it loses the order
- String methods all return new strings;
split()with no argument is the useful form;strip()is essential on input - Never modify a container while iterating it — true of all of them
- Mutable arguments get modified by the function; immutable ones cannot
- To choose a container, ask: access by position? does each item have a name? do I only care whether it is present?
Exercises
- Write
word_count(text: str) -> dict[str, int], counting occurrences of each word, case-insensitively. - Write
unique_ordered(items: list[int]) -> list[int], deduplicating while preserving order, using a set to make the membership test fast. - Given two students’ sets of enrolled courses, print the ones both take, the ones only the first takes, and the union.
- Write
invert(d: dict[str, int]) -> dict[int, str], swapping keys and values. Think about what happens if the original has duplicate values. - Using
zipand a dict comprehension, combine["a","b","c"]and[1,2,3]into{"a":1,"b":2,"c":3}in a single line. - This is meant to group words by their first letter, but it fails. Find the cause and fix it:
words = ["apple", "avocado", "banana", "blueberry"] groups = {} for w in words: groups[w[0]].append(w) - Pick a container for each of these and justify the choice:
- a mapping from each of 116 student numbers to a name
- every score from one exam, from which you later want the mean and the maximum
- testing whether a word is in a list of ten thousand common English words
- a point on a 2D plane, to be used as a dictionary key
The lab problems for this lecture are in Lab 6.