Lists, tuples, dicts and sets · about 40 min

Sets

Why this matters

Some questions are about membership, not order or position: Have I seen this item before? Which users are in both groups? Which required fields are missing? A set is an unordered collection of distinct items, built for exactly these questions. Testing whether an item is in a set takes about the same time whether the set has ten items or ten million, while the same test on a list gets slower with every item. Choosing a set at the right moment can turn a program that takes minutes into one that takes milliseconds.

Creating sets

Sets are written with curly braces, or created from any iterable with set():

primes = {2, 3, 5, 7}
letters = set("hello")
empty = set()           # not {}: that is an empty dict
print(len(primes), 3 in primes, sorted(letters))
4 True ['e', 'h', 'l', 'o']

Two properties define a set:

Never rely on set order

When you print a set or loop over it, the items come out in some order, determined by their hash values and the set's internal size. For small integers it often looks sorted; for strings it changes from one run of the program to the next, because string hashing is randomized per process:

$ python3 -c 'print({"apple", "banana", "cherry"})'
{'cherry', 'apple', 'banana'}
$ python3 -c 'print({"apple", "banana", "cherry"})'
{'banana', 'cherry', 'apple'}

So whenever output must be predictable (always, in tests and in learnprog exercises), sort before printing: sorted(s) returns a list in a defined order.

Modifying sets

tags = {"python"}
tags.add("tutorial")          # add one item
tags.update(["beginner", "python"])   # add many; duplicates ignored
tags.discard("missing")       # remove if present, no error otherwise
tags.remove("tutorial")       # remove; KeyError if absent
print(sorted(tags))
['beginner', 'python']

pop() removes and returns an arbitrary element, and clear() empties the set.

Set algebra

Sets support the operations from mathematics, as operators and as methods:

python_devs = {"ada", "bob", "cy", "dee"}
rust_devs = {"cy", "dee", "eve"}

print(sorted(python_devs | rust_devs))   # union: in either
print(sorted(python_devs & rust_devs))   # intersection: in both
print(sorted(python_devs - rust_devs))   # difference: in the first only
print(sorted(python_devs ^ rust_devs))   # symmetric difference: in exactly one
['ada', 'bob', 'cy', 'dee', 'eve']
['cy', 'dee']
['ada', 'bob']
['ada', 'bob', 'eve']
   python_devs          rust_devs
  +-----------+-----+-----------+
  |  ada bob  | cy  |    eve    |
  |           | dee |           |
  +-----------+-----+-----------+
   only python  both  only rust

The method forms (a.union(b), a.intersection(b), a.difference(b)) also accept any iterable, not just sets: python_devs.union(["zed"]). Comparisons test subset relations: a <= b means every element of a is in b, and a.isdisjoint(b) means they share nothing.

A typical real use, checking required fields:

required = {"name", "email", "password"}
submitted = {"name": "Ada", "password": "x"}
missing = required - submitted.keys()
print(sorted(missing))
['email']

(Dict key views behave like sets, so - works on them directly.)

What happens underneath: sets are hash tables

A set is implemented like a dict without values: a hash table of keys. To test x in s, Python computes hash(x), jumps to the corresponding slot, and checks equality with whatever is there. No scanning is needed, so membership, add and discard are O(1) on average. A list must compare x against items one by one, O(n).

The difference is dramatic at scale. Checking 10,000 lookups against 10,000 items:

import time
items_list = list(range(10_000))
items_set = set(items_list)

t = time.perf_counter()
hits = 0
for n in range(10_000):
    if n in items_list:
        hits += 1
print("list:", round(time.perf_counter() - t, 3), "s")

t = time.perf_counter()
for n in range(10_000):
    if n in items_set:
        hits += 1
print("set: ", round(time.perf_counter() - t, 4), "s")

On a typical machine the list version takes about half a second and the set version under a millisecond. The cost: a set uses more memory than a list, and loses order.

Because sets are hash tables, the same rule as for dict keys applies: elements must be hashable. Numbers, strings and tuples of hashable items are fine; lists, dicts and sets are not:

s = {[1, 2]}
TypeError: unhashable type: 'list'

frozenset

A set is mutable, so it is itself unhashable: you cannot put a set inside a set or use it as a dict key. The immutable variant, frozenset, can:

pair = frozenset({"ada", "bob"})
meetings = {pair: "Monday"}
print(meetings[frozenset({"bob", "ada"})])
Monday

Order does not matter for equality, so {"bob", "ada"} finds the same key. That makes frozensets handy for unordered pairs.

Common mistakes

Recap

Going further: read "Sets" in the official tutorial (section 5.4) and the set section of the Library Reference ("Set Types"). Try python3 -c 'print({"a", "b", "c"})' several times, then with PYTHONHASHSEED=0 set in the environment, and explain the difference.

Exercises

Exercise 1 · quiz

Set operations

With a = {1, 2, 3, 4} and b = {3, 4, 5}, which statements are true? Select all that apply.

Answer
  • a & b == {3, 4}

  • a | b == {1, 2, 3, 4, 5}

  • a ^ b == {1, 2, 5}

& is intersection (in both), | is union (in either), - is difference (in a but not in b, which is {1, 2}), and ^ is symmetric difference (in exactly one). A union holds each element once, so len(a | b) is 5, not 7.

Exercise 2 · quiz

Empty braces

What does print(type({})) print?

Answer
  • <class 'dict'>

{} is an empty dict; dicts had braces first. An empty set must be written set(). Non-empty braces without colons, like {1, 2}, are sets.

Exercise 3 · write code

Letters in common

Read two words, one per line. Print the letters that appear in both words, in alphabetical order with no separator, then on a second line the letters that appear in the first word but not the second, also sorted. Treat uppercase and lowercase the same (convert to lowercase). If a result is empty, print - for that line.

For input Python and typhoon:

hnopty
-

For input banana and bandana:

abn
-

File: main.py

Starting point

first = input().lower()
second = input().lower()
print(set(first) & set(second))

Example

input

Python
typhoon

expected output

hnopty
-

Example

input

banana
bandana

expected output

abn
-
Hint 1

set("banana") is the set of distinct letters {'a', 'b', 'n'} (in some order).

Hint 2

Never print a set directly: its order is not defined. "".join(sorted(s)) gives a sorted string.

Hint 3

An empty string is falsy, so text or "-" gives "-" for empty results.

Reference solution
first = set(input().lower())
second = set(input().lower())
both = "".join(sorted(first & second))
only_first = "".join(sorted(first - second))
print(both or "-")
print(only_first or "-")

Exercise 4 · write code

First repeated item

Define first_repeat(items) that returns the first item that occurs for the second time while scanning items from left to right, or None if no item repeats.

It must be fast for long inputs: the test uses a list of 200,000 items, so use a set of items seen so far, not items[:i].count(...) or in on a list.

File: solution.py

Starting point

def first_repeat(items):
    seen = []
    for item in items:
        if item in seen:
            return item
        seen.append(item)
    return None
Hint 1

Membership in a list is O(n); in a set it is O(1) on average.

Hint 2

Start with seen = set() and use seen.add(item).

Reference solution
def first_repeat(items):
    """Return the first item seen a second time, or None."""
    seen = set()
    for item in items:
        if item in seen:
            return item
        seen.add(item)
    return None

Exercise 5 · write code

Missing letters

Define missing_letters(text) that returns a string of the lowercase letters a-z that do not appear in text (case-insensitive), in alphabetical order. Non-letters are ignored.

Hint: import string gives you string.ascii_lowercase, which is "abcdefghijklmnopqrstuvwxyz".

File: solution.py

Starting point

import string

def missing_letters(text):
    return set(string.ascii_lowercase) - set(text)
Hint 1

Lowercase the text first, or "H" will not cancel out "h".

Hint 2

Set difference gives a set; return "".join(sorted(...)) so the result is a sorted string.

Reference solution
import string


def missing_letters(text):
    """Return the letters a-z absent from text, sorted, as a string."""
    missing = set(string.ascii_lowercase) - set(text.lower())
    return "".join(sorted(missing))