Lists, tuples, sets, dicts — and when to reach for each

Time complexity, memory trade-offs, and the collections module (deque, Counter, defaultdict).

🐍 Module 1 9 min read Not started

Why this matters

Choosing the right built-in container is the difference between O(n²) and O(n). Fluent use of list, dict, set, tuple (plus deque, Counter, defaultdict from collections) is the most common signal of a senior Python developer. Comprehensions are how you express transformations Pythonically.

Learning objectives

  1. Pick the right container for the access pattern.
  2. Use slicing and indexing fluently.
  3. Write list, dict, set, generator comprehensions.
  4. Apply collections.Counter, defaultdict, deque, namedtuple.
  5. Reason about complexity of common operations.

1. The four core containers

TypeMutableOrderedIndex byUse when
listyesyesintsequence of homogeneous items, frequent append
tuplenoyesintfixed record, hashable, returned from function
dictyesyes (insertion order, 3.7+)hashable keykey→value lookup
setyesnomembership, dedup, set algebra

Plus frozenset (immutable set) and bytes/bytearray for binary.

Complexity cheat sheet (CPython)

Operationlistdictset
index / getO(1)O(1) avg
append / addO(1) amortisedO(1)O(1)
insert at startO(n)
x in containerO(n)O(1)O(1)
iterateO(n)O(n)O(n)
sortO(n log n)

If in is hot, use a set/dict — not a list.


2. Lists

python
xs = [1, 2, 3, 4]
xs.append(5)                # add to end
xs.extend([6, 7])           # bulk add
xs.insert(0, 0)             # add at index (O(n))
xs.pop()                    # remove + return last (O(1))
xs.pop(0)                   # remove + return first (O(n))
xs.remove(3)                # remove first occurrence by value
del xs[0]
xs.reverse()                # in-place
xs.sort()                   # in-place sort
sorted(xs, reverse=True)    # new list
xs.count(2), xs.index(2)

# Slicing
xs[1:4]                     # slice
xs[::-1]                    # reversed copy
xs[::2]                     # every other
xs[:] = [99]                # replace all in place

list vs array vs numpy

  • list: heterogeneous; pointers to Python objects.
  • array (stdlib): homogeneous, compact (array('i', [1,2,3])).
  • numpy.ndarray: homogeneous, vectorised — the right choice for numeric data (Phase 5.1).

Sorting

python
sorted(records, key=lambda r: r["age"])
sorted(records, key=lambda r: (r["last_name"], r["first_name"]))
sorted(records, key=operator.itemgetter("age"))     # faster

# Stable sort: items with equal keys keep relative order

3. Tuples

Immutable, hashable (if contents are hashable), often used as records.

python
p = (1, 2, 3)
x, y, z = p                 # unpack
p[0]
len(p)

# Single-element tuple needs the trailing comma:
single = (1,)               # tuple
not_tuple = (1)             # int

# Returned from functions
def divmod_(a, b):
    return a // b, a % b    # implicit tuple
q, r = divmod_(7, 2)

For named records, prefer NamedTuple or dataclass(frozen=True):

python
from typing import NamedTuple
class Point(NamedTuple):
    x: float
    y: float
    z: float = 0.0

p = Point(1, 2)
p.x, p.y, p[0]              # both work

4. Dicts

python
d = {"a": 1, "b": 2}
d["a"]                      # KeyError if missing
d.get("z", 0)               # default
d.setdefault("a", 99)       # only sets if missing
d.update({"c": 3})

# Iteration
for k in d: ...
for k, v in d.items(): ...
for v in d.values(): ...

# Construction
dict(a=1, b=2)
dict([("a", 1), ("b", 2)])
{x: x*x for x in range(5)}             # comprehension

# Merge (3.9+)
e = {"d": 4}
merged = d | e              # new dict
d |= e                      # in-place

# Membership: O(1) average
"a" in d

Keys must be hashable

  • int, str, tuple of immutables — yes.
  • list, dict, set — no (TypeError: unhashable type).
  • Custom classes — by default hash on id(); override __hash__/__eq__ (Phase 2.1).

collections upgrades

python
from collections import Counter, defaultdict, OrderedDict, ChainMap, deque

# Counter — frequency table
c = Counter("abracadabra")
c.most_common(2)                # [('a', 5), ('b', 2)]
c["z"]                          # 0, not KeyError

# defaultdict — auto-create missing values
buckets = defaultdict(list)
for word in words:
    buckets[len(word)].append(word)

# OrderedDict — rarely needed since 3.7 (regular dict is ordered)
# Use only for .move_to_end() and equality-with-order semantics

# ChainMap — layer dicts; lookup falls through
config = ChainMap(local_overrides, defaults)

# deque — O(1) appends/pops from BOTH ends
from collections import deque
q = deque(maxlen=10)
q.append(1); q.appendleft(0); q.pop(); q.popleft()

5. Sets

Unordered, fast in, set algebra.

python
s = {1, 2, 3}
empty = set()                   # NOT {} — that's an empty dict
s.add(4); s.remove(1); s.discard(99)  # discard doesn't raise

# Set operations
a = {1, 2, 3}
b = {2, 3, 4}
a | b                       # union {1,2,3,4}
a & b                       # intersection {2,3}
a - b                       # difference {1}
a ^ b                       # symmetric diff {1, 4}
a <= b                      # subset?
a.isdisjoint({99, 100})

# Comprehension
{x * 2 for x in range(5)}

# Frozen for keys / set elements
fs = frozenset([1, 2, 3])

Use for: deduplication, fast in, set algebra, hashable-only collections.


6. Comprehensions

The single most distinctive Pythonic construct.

python
# List
squares = [x * x for x in range(10)]
even_squares = [x * x for x in range(10) if x % 2 == 0]

# With multiple loops (Cartesian product)
pairs = [(x, y) for x in range(3) for y in range(3) if x != y]

# Dict
inverted = {v: k for k, v in d.items()}

# Set
unique_lengths = {len(w) for w in words}

# Nested
matrix = [[i * j for j in range(3)] for i in range(3)]

Generator expressions

Same syntax with (...) instead of [...]lazy:

python
gen = (x * x for x in range(10**9))    # doesn't materialise
sum(x * x for x in range(10**9))       # streams; constant memory

Use generator expressions when you only need to iterate once and the input is huge.

When to NOT use a comprehension

  • Three or more for/if clauses — write a loop.
  • You want a side effect (print, append to external list) — write a loop.
  • Readability suffers.

7. Iteration helpers

python
from itertools import (
    chain, zip_longest, accumulate, count, cycle, islice,
    groupby, combinations, permutations, product, takewhile,
)

# Chain — flatten
list(chain([1, 2], [3, 4]))             # [1, 2, 3, 4]
list(chain.from_iterable([[1,2],[3,4]]))

# Accumulate — running totals
list(accumulate([1, 2, 3, 4]))          # [1, 3, 6, 10]
list(accumulate([1, 2, 3, 4], func=max))  # [1, 2, 3, 4]

# Islice — slice a generator
list(islice(count(), 5))                # [0, 1, 2, 3, 4]

# Group consecutive equal items
for key, group in groupby("AAABBC"):
    print(key, list(group))            # A AAA, B BB, C C

# All pairs / combinations
list(combinations(range(4), 2))         # [(0,1),(0,2),(0,3),(1,2),(1,3),(2,3)]
list(product([1, 2], ["a", "b"]))       # [(1,"a"),(1,"b"),(2,"a"),(2,"b")]

itertools is the standard library's best gift. Memorise the names.


8. Worked example: counting words

python
from collections import Counter
from pathlib import Path

text = Path("alice.txt").read_text().lower()
words = (w.strip(".,!?;:'\"") for w in text.split())
c = Counter(words)

# Top 10 (ignoring trivial stopwords)
stop = {"the", "and", "a", "of", "to", "in", "is", "it"}
top = [(w, n) for w, n in c.most_common(50) if w not in stop][:10]
print(top)

8 lines from "raw text" to "top words." Few languages match this conciseness.


9. Sorting deep cuts

python
records = [
    {"name": "Ada", "score": 90},
    {"name": "Bob", "score": 85},
    {"name": "Cara", "score": 90},
]

# By score desc, then name asc
records.sort(key=lambda r: (-r["score"], r["name"]))

# Stable: items equal on key keep original order
sorted([3, 1, 2, 1], key=lambda x: x)   # [1, 1, 2, 3] — first 1 came first

# Functools.cmp_to_key for legacy compare functions
from functools import cmp_to_key
def cmp(a, b):
    return (a > b) - (a < b)
sorted([3, 1, 2], key=cmp_to_key(cmp))

For huge data, NumPy / pandas sort is faster (compiled).


10. Choosing the right container — quick guide

ProblemUse
Fixed-size record returned from functiontuple (or NamedTuple)
List of things, frequent appendlist
Append/pop from both endsdeque
Lookup by namedict
Membership testset
Counting occurrencesCounter
Grouping by keydefaultdict(list) or itertools.groupby
Numerical homogeneous datanumpy.ndarray
Layered config (env > file > defaults)ChainMap
LRU cachefunctools.lru_cache or OrderedDict
Priority queueheapq

Hands-on lab (2 hours)

  1. Implement unique_preserve_order(seq) returning a list of items without duplicates, keeping order.
  2. Build a word-frequency histogram from a text file; print top 20.
  3. Group words by their first letter using defaultdict(list).
  4. Implement an LRU cache using OrderedDict (move_to_end + popitem(last=False)).
  5. Use itertools.groupby to find runs of identical chars in a string.
  6. Sort a list of dicts by multiple keys with operator.itemgetter.
  7. Convert a list of records into a dict keyed by id; benchmark in lookup against the original list.
  8. Bonus: solve "two-sum" (return indices that sum to target) using a single dict pass.

Common pitfalls

  1. {} is a dict, not a set. Empty set is set().
  2. Modifying a list while iterating: confusing skips. Iterate over a copy or build a new list.
  3. Using a list for membership test on big data.
  4. Comprehensions that build up huge lists when a generator would suffice.
  5. d.keys() returns a view, not a list — fine for iteration; index it with list(d.keys())[0] if needed (but that suggests a different data structure).

Self-check

  1. Complexity of x in list vs x in set.
  2. How to dedup while preserving order?
  3. What's the difference between a list and a tuple beyond mutability?
  4. Why is OrderedDict rarely needed now?
  5. State three uses of itertools.

References

Sign in to save your progress and earn badges.