CS1602Introduction to Computation
Lecture 6Part 2 Built-in Data StructuresAI Level 0

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:

  1. A group of values that should not be altered — a coordinate (3, 4), say, where nobody should be able to append to it
  2. Looking things up by name rather than by position — “what did Alex score”, not “what did person number 37 score”
  3. Asking whether something is present — a list’s in scans 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:

SlotWrittenEffect
align< > ^left / right / center
fillany 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.ndecimal places, or truncation length for strings
typed f e % b o xinteger / 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.

listtuplestrdictset
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 speedslow (scan)slowslowfastfast

Choosing by the problem

What you needReach forWhy
a batch of similar values, processed in orderlistordered, and you can add and remove
a fixed group of values, e.g. a coordinatetupleimmutable, safe, usable as a key
data retrieved by name or iddictfast lookup, clear intent
“is this present”, or removing duplicatessetfast lookup, deduplicates for free
how many times each thing occursdictkey holds the thing, value holds the count
working with textstrit has the methods for it
a compound keydict keyed by tuplesd[(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) avoids KeyError; 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

  1. Write word_count(text: str) -> dict[str, int], counting occurrences of each word, case-insensitively.
  2. Write unique_ordered(items: list[int]) -> list[int], deduplicating while preserving order, using a set to make the membership test fast.
  3. Given two students’ sets of enrolled courses, print the ones both take, the ones only the first takes, and the union.
  4. Write invert(d: dict[str, int]) -> dict[int, str], swapping keys and values. Think about what happens if the original has duplicate values.
  5. Using zip and a dict comprehension, combine ["a","b","c"] and [1,2,3] into {"a":1,"b":2,"c":3} in a single line.
  6. 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)
  7. 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.