Dictionaries & sets
dict, set and their collections relatives in Python 3.14: creating, reading and writing, views,
what can be a key, merging and copying, Counter and defaultdict, sets, costs, the hash-map idioms
DS&A solutions are built from, typing, and how JS Object, Map and Set map over. Group-by and
dedupe recipes are in Fundamentals; lists are in
Lists.
Creating
A dict maps hashable keys to values and keeps insertion order (guaranteed since 3.7).
| Expression | Result | Notes |
|---|---|---|
{}, dict() | {} | an empty dict; an empty set is set() |
{"a": 1, "b": 2} | literal | a repeated key keeps the last value |
dict(a=1, b=2) | {'a': 1, 'b': 2} | keyword keys are always strings that are valid names |
dict([("a", 1), ("b", 2)]) | from pairs | any iterable of 2-item iterables |
dict(zip(keys, values, strict=True)) | from parallel lists | strict raises ValueError on unequal lengths |
{w: len(w) for w in words} | comprehension | later duplicates overwrite earlier ones |
{x: i for i, x in enumerate(xs)} | value → index | the last index wins for repeated values |
dict.fromkeys(xs) | keys from xs, values None | dedupes and keeps first-seen order |
dict.fromkeys(xs, 0) | every value 0 | fine for immutable defaults |
dict.fromkeys(xs, []) | one list shared by every key | use {x: [] for x in xs} |
Counter(xs), defaultdict(list) | counting and grouping dicts | see Counter, defaultdict & friends |
words = ["apple", "bob", "apple", "cy"]
lengths = {w: len(w) for w in words}
# {'apple': 5, 'bob': 3, 'cy': 2}
last = {w: i for i, w in enumerate(words)}
# {'apple': 2, 'bob': 1, 'cy': 3}: last index wins
pairs = dict(zip("abc", [1, 2, 3], strict=True))
# {'a': 1, 'b': 2, 'c': 3}
shared: dict[str, list[int]] = dict.fromkeys("ab", [])
shared["a"].append(1)
print(shared) # {'a': [1], 'b': [1]}: one list
# the fix: a new list per key
own: dict[str, list[int]] = {k: [] for k in "ab"}Reading & writing
| Operation | Returns | Notes |
|---|---|---|
d[k] | the value | KeyError if k is missing |
d.get(k), d.get(k, default) | the value, else None or default | never raises |
k in d, k not in d | bool | checks keys only; v in d.values() is a linear scan |
d[k] = v | insert or overwrite; a new key goes last, an overwritten key keeps its place | |
d.setdefault(k, default) | the value now stored | inserts default only when k is missing |
d.update(other), d |= other | None | overwrite from a dict, pairs, or update(k=v) keywords |
d.pop(k), d.pop(k, default) | the removed value | KeyError without a default |
d.popitem() | the last (key, value) inserted | LIFO; KeyError when empty |
del d[k] | KeyError if missing | |
d.clear() | None | empties it for every name that refers to it |
len(d) | number of keys | O(1) |
stock = {"apple": 3, "pear": 0}
stock["apple"] # 3
stock.get("kiwi") # None
stock.get("kiwi", 0) # 0
"pear" in stock # True: in checks the keys
0 in stock.values() # True: a linear scan of the values
stock["kiwi"] = 5 # a new key goes last
stock["apple"] = 4 # an overwrite keeps its place
list(stock) # ['apple', 'pear', 'kiwi']
stock.setdefault("fig", 1) # 1: inserted
stock.setdefault("fig", 9) # 1: already there
stock.pop("pear") # 0
stock.pop("plum", None) # None: no KeyError
stock.popitem() # ('fig', 1): the last inserted
stock # {'apple': 4, 'kiwi': 5}Iterating & views
| Expression | Gives | Notes |
|---|---|---|
for k in d | keys | in insertion order |
d.keys(), d.values(), d.items() | live views | they reflect later changes; list(...) takes a snapshot |
for k, v in d.items() | key-value pairs | |
reversed(d), reversed(d.items()) | newest first | 3.8+ |
d.keys() & other, d.keys() - other, d.items() & other | a new set | key and item views act like sets; value views don't (TypeError) |
sorted(d) | sorted keys | |
sorted(d.items(), key=itemgetter(1)) | pairs by value | reverse=True for largest first; ties keep insertion order |
max(d, key=d.__getitem__) | the key with the largest value | ties: the first in order; key=d.get runs too, but type checkers reject it because get may return None |
next(iter(d)) | the first key | StopIteration when empty |
from operator import itemgetter
score = {"ann": 90, "bo": 72, "cy": 90}
keys = score.keys()
score["di"] = 85
list(keys) # ['ann', 'bo', 'cy', 'di']: a live view
score.keys() & {"bo", "zed"} # {'bo'}
sorted(score.keys() - {"bo"}) # ['ann', 'cy', 'di']
max(score, key=score.__getitem__) # 'ann': first of ties
sorted(score.items(), key=itemgetter(1), reverse=True)
# [('ann', 90), ('cy', 90), ('di', 85), ('bo', 72)]
sorted(score, key=lambda k: (-score[k], k)) # score, name
# ['ann', 'cy', 'di', 'bo']
for name in list(score): # a snapshot: safe to delete
if score[name] < 80:
del score[name]
score # {'ann': 90, 'cy': 90, 'di': 85}Keys & hashing
A key must be hashable: its hash() never changes, and keys that compare equal hash equal. A
lookup hashes the key, jumps to a slot, then confirms the match with ==.
1/8
A small dict is two arrays: an indices table of 8 slots and an entries array that holds up to 5 entries (two thirds of 8) in insertion order. Both start empty.
| Key type | Hashable | Notes |
|---|---|---|
int, float, str, bytes, bool, None | yes | |
tuple | if every item is | (1, 2) is; (1, [2]) is not; see Tuples |
frozenset | yes | an unordered collection as a key |
list, dict, set, bytearray | no | 3.14 says TypeError: cannot use 'list' as a dict key (unhashable type: 'list') |
| a class instance (default) | yes, by identity | two objects with equal fields are two different keys |
a class with __eq__ but no __hash__ | no | defining __eq__ sets __hash__ to None |
@dataclass(frozen=True) | yes, by field values | a plain @dataclass is unhashable for the same reason |
float("nan") | yes, but | nan != nan, so only the very same nan object finds its entry |
from dataclasses import dataclass
d = {1: "int", 1.0: "float", True: "bool"}
print(d) # {1: 'bool'}: 1 == 1.0 == True, so one key
grid = {(0, 1): "#"} # tuple keys: coordinates
grid[0, 1] # '#': the parentheses are optional
@dataclass(frozen=True)
class Point:
x: int
y: int
seen = {Point(1, 2)}
Point(1, 2) in seen # True: hashed by field values
path = [1, 2]
by_path = {tuple(path): 1} # a list key raises TypeError
hash(-1) == hash(-2) # True in CPython; still two keys- Equal hashes don't make equal keys.
hash(-1) == hash(-2)in CPython (-1is reserved), yet-1and-2are separate keys, because the lookup confirms with==. - Never mutate an object that is a key. If its hash changes, its entry can't be found again; that is why the hashable built-ins are immutable.
strandbyteshashes are salted per process (PYTHONHASHSEED), so a set of strings iterates in a different order on each run. Ints hash to themselves, so a set of small ints often looks sorted ({3, 1, 2}prints{1, 2, 3}), but{33, 10, 2}prints{33, 10, 2}. Sort before you print, compare or return.
Merging, copying & comparing
| Expression | Gives | Notes |
|---|---|---|
a | b | a new dict | b wins on shared keys (3.9+); both operands must be dicts |
a |= b, a.update(b) | a, changed in place | the right side may also be pairs: a |= [("k", 1)] |
{**a, **b, "k": 1} | a new dict | works with any mappings and mixes in literal keys |
ChainMap(a, b) | a layered view, no copy | lookups try a first |
d.copy(), dict(d), {**d} | shallow copy | nested lists and dicts are shared |
copy.deepcopy(d) | deep copy | nothing shared |
a == b | bool | same keys and values; order is ignored |
OrderedDict == OrderedDict | bool | order matters only when both sides are OrderedDicts |
import copy
from collections import OrderedDict
base = {"db": {"host": "x", "port": "1"}}
over = {"db": {"host": "y"}}
base | over # {'db': {'host': 'y'}}: port is gone
# | replaces whole values; deep merge is in Recipes
shallow = base.copy()
shallow["db"]["host"] = "z" # base changes too
deep = copy.deepcopy(base) # shares nothing
{"a": 1, "b": 2} == {"b": 2, "a": 1} # True
od1 = OrderedDict(a=1, b=2)
od2 = OrderedDict(b=2, a=1)
od1 == od2, od1 == dict(od2) # (False, True)Counter, defaultdict & friends
| Tool | Does | Watch out |
|---|---|---|
Counter(iterable) | counts items | c[missing] is 0 and does not insert the key |
c.most_common(k) | the top k as (item, count) pairs | ties keep first-seen order; no k sorts them all |
c.total() | the sum of the counts (3.10+) | |
c1 + c2, c1 - c2 | add, subtract counts | results drop counts of zero or less |
c1 & c2, c1 | c2 | min, max of each count | |
c.update(xs), c.subtract(xs) | add, subtract in place | subtract keeps zero and negative counts; +c drops them |
c1 <= c2, c1 == c2 | inclusion, equality (3.10+) | a missing key counts as 0: Counter(a=1, b=0) == Counter(a=1) |
c.elements() | each item repeated by its count | |
defaultdict(list), (int), (set) | a missing key gets list(), 0, set() | reading d[k] inserts k; test with k in d or d.get(k) |
defaultdict(lambda: defaultdict(int)) | two-level counts | d[a][b] += 1 |
OrderedDict | move_to_end(k), popitem(last=False) | LRU bookkeeping; a plain dict's popitem() only removes the newest |
ChainMap(*maps) | layered lookups | writes and deletes go to the first map |
functools.cache | a memo dict around a function | the arguments must be hashable |
from collections import Counter, OrderedDict, defaultdict
c = Counter("abracadabra")
c["a"], c["z"] # (5, 0)
"z" in c # False: reading a missing key didn't insert it
c.most_common(2) # [('a', 5), ('b', 2)]
c.total() # 11
Counter(a=3, b=1) - Counter(a=1, b=2) # Counter({'a': 2})
Counter("aab") <= Counter("aabbc") # True: fits inside
graph: defaultdict[str, list[str]] = defaultdict(list)
graph["a"].append("b")
graph["c"] # [], and "c" is now a key
sorted(graph) # ['a', 'c']
lru: OrderedDict[str, int] = OrderedDict(a=1, b=2, c=3)
lru.move_to_end("a") # a is now the newest
lru.popitem(last=False) # ('b', 2): evict the oldestSets
A set works like a dict with keys and no values: unordered, unique, hashable items, O(1) average in.
| Expression | Gives | Notes |
|---|---|---|
set(), {1, 2} | a set | {} is an empty dict |
set(xs), {x % 3 for x in xs} | from an iterable, a comprehension | duplicates collapse |
frozenset(xs) | an immutable, hashable set | usable as a dict key or a set member |
s.add(x), s.update(xs) | None | add("ab") adds one string; update("ab") adds 'a' and 'b' |
s.remove(x), s.discard(x) | None | remove raises KeyError when x is missing; discard doesn't |
s.pop() | an arbitrary item | KeyError when empty; not "the smallest" or "the oldest" |
a | b, a & b, a - b, a ^ b | union, intersection, difference, symmetric difference | both operands must be sets |
a.union(xs), a.intersection(xs), a.difference(xs) | the same | the methods accept any iterable |
a |= b, a &= b, a -= b | in place | |
a <= b, a < b, a >= b | subset, proper subset, superset | also issubset, issuperset |
a.isdisjoint(b) | no shared items | stops at the first shared item |
len(s), x in s | size, membership | O(1) average membership, the reason to use a set |
set.intersection(*sets), set().union(*sets) | across many collections | the unbound set.intersection() raises TypeError when given nothing; set().union() is safe with zero arguments |
frozenset(a) | {2} | a frozenset | a mixed operation returns the left operand's type |
sorted(s), min(s), max(s) | ordered output | iteration order is arbitrary: sort before printing or returning |
a = {1, 2, 3}
b = {3, 4}
a | b, a & b, a - b, a ^ b
# ({1, 2, 3, 4}, {3}, {1, 2}, {1, 2, 4})
{1, 2} <= a, a < a, a.isdisjoint({9}) # (True, False, True)
a.union([5, 6]) # methods take any iterable
# a | [5, 6] raises TypeError
seen: set[tuple[int, int]] = set()
seen.add((0, 0)) # coordinates as tuples
(0, 0) in seen # True
state = frozenset({"key", "door"})
best = {state: 3} # an unordered state as a key
best[frozenset({"door", "key"})] # 3
lists = [[1, 2, 3, 4], [2, 3, 5], [3, 2, 9]]
set.intersection(*map(set, lists)) # {2, 3}: in every list
sorted(set().union(*lists)) # [1, 2, 3, 4, 5, 9]
sorted(set("hello")) # ['e', 'h', 'l', 'o']: sort to show1/5
a = {1, 2, 3} and b = {3, 4}. Only 3 is in both.
When a plain set isn't enough
| Need | Use | Notes |
|---|---|---|
| insertion order | dict.fromkeys(xs) | the standard library has no ordered set; use the dict's keys |
| sorted order with inserts and removals | bisect.insort on a list, or SortedList from sortedcontainers (third-party, PyPI) | there is no sorted set built in; insort is O(n) per insert |
| how many of each | Counter | set("aab") - set("ab") is empty; Counter("aab") - Counter("ab") keeps the extra 'a' |
| the smallest item, repeatedly, with removals | heapq plus a set of removed items | lazy deletion: pop while the top is marked |
| members drawn from a small range of ints | an int bitmask (next section) | O(1) union and intersection; hashable for free |
| a set as a dict key or inside another set | frozenset | {frozenset({1, 2})} works, {{1, 2}} raises TypeError |
Bitmasks as sets
When every possible member maps to a small int (letters, up to about 20 nodes for bitmask DP, the
keys collected in a maze), one int can hold the whole set: bit i set means member i is in.
Bitmask states are ints, so they go straight into a seen set or a memo key. The bit tricks
themselves are in Bits.
| Set operation | Bitmask |
|---|---|
empty set, all of n members | 0, (1 << n) - 1 |
add i, remove i | m | 1 << i, m & ~(1 << i) |
i in s | m >> i & 1 |
| union, intersection, difference | a | b, a & b, a & ~b |
| size | m.bit_count() (3.10+) |
subset a <= b | a & b == a |
FRUITS = ["apple", "banana", "cherry", "date"]
bit = {f: 1 << i for i, f in enumerate(FRUITS)}
a = bit["apple"] | bit["cherry"]
b = bit["cherry"] | bit["date"]
both, either = a & b, a | b
only_a = a & ~b
bin(either), either.bit_count() # ('0b1101', 3)
a & either == a # True: a is a subset of either
def members(mask: int) -> list[str]:
out: list[str] = []
while mask:
low = mask & -mask # the lowest set bit
out.append(FRUITS[low.bit_length() - 1])
mask ^= low
return out
members(only_a), members(both) # (['apple'], ['cherry'])Costs & memory
CPython's dict and set are open-addressing hash tables that grow before they fill up, so lookups stay O(1) on average. Every operation also pays to hash the key.
| Operation | Average | Worst case / notes |
|---|---|---|
d[k], d[k] = v, del d[k], k in d | O(1) | O(n) when many keys collide |
d.get, d.setdefault, d.pop | O(1) | |
len(d) | O(1) | |
for k in d, d.copy(), list(d.items()) | O(n) | |
v in d.values() | O(n) | a scan: keep a reverse dict or a set instead |
a.update(b), a | b | O(len(b)), O(len(a) + len(b)) | |
set x in s, s.add(x), s.remove(x) | O(1) | O(n) with collisions |
set a | b, a & b, a - b | O(len(a) + len(b)), O(min(len(a), len(b))), O(len(a)) | |
hash(s) of a str | O(len(s)) the first time | cached on the string after that |
hash(t) of a tuple | O(items) | CPython 3.14 caches it too; 3.13 and earlier rehash on every lookup |
import sys
sys.getsizeof({}) # 64: an empty dict (64-bit CPython)
sys.getsizeof({i: i for i in range(1000)}) # 36952
sys.getsizeof(set()) # 216
R, C = 3, 4
by_tuple = {(r, c): 0 for r in range(R) for c in range(C)}
by_int = {r * C + c: 0 for r in range(R) for c in range(C)}
r, c = divmod(7, C) # back from an int key: (1, 3)getsizeof counts the table, not the keys and values. Tuple keys are the clear choice for grid
coordinates; on 3.14 a 300 × 300 grid of lookups ran about twice as fast with r * C + c int keys,
because every (r, c) lookup builds a new tuple and hashes it. Switch only when profiling says so.
DS&A idioms
The hash-map moves that problem solutions are built from, and the trap in each. The patterns themselves, with TypeScript and JavaScript beside Python, are in the DS&A sheets (linked in the last column).
| Job | Python | Watch out | More |
|---|---|---|---|
| Count items | cnt = Counter(xs), or cnt[x] = cnt.get(x, 0) + 1 | a Counter read doesn't insert; a defaultdict(int) read does | Counter & defaultdict |
| Complement lookup (two-sum) | if target - x in seen: … then seen[x] = i | store after the check, or x pairs with itself | Hash maps & sets |
| Value → index | pos = {x: i for i, x in enumerate(xs)} | the last index wins; pos.setdefault(x, i) in a loop keeps the first | |
| Group by a key | groups = defaultdict(list), groups[key(x)].append(x) | anagram key: "".join(sorted(w)) or tuple(counts); lists can't be keys | Group anagrams |
| Visited set | seen = {start}, then seen.add(v) when v is enqueued | marking on dequeue lets one node enter the queue many times | BFS, Graphs |
| Word set as visited | words = set(word_list), then words.remove(w) when w is enqueued | one set answers both "is it a word?" and "seen yet?" | Word ladder |
| Duplicates | len(set(xs)) != len(xs); to stop early, return on the first x in seen | the one-liner always reads all of xs | Hash maps & sets |
| Common to every list | set.intersection(*map(set, lists)) | raises TypeError when lists is empty | |
| Missing or extra items | set(range(n + 1)) - set(xs), set(a) ^ set(b) | sets drop repeats: subtract Counters when counts matter | |
| Cycle detection | while x not in seen: then seen.add(x) and x = step(x) | O(n) memory; fast and slow pointers need O(1) | Fast & slow pointers |
| Small universe | an int bitmask: m | 1 << i, m >> i & 1, m.bit_count() | members must map to small ints | Bitmasks as sets |
| Lazy deletion from a heap | removed.add(x), then pop while h[0] in removed | repeated values need a Counter, not a set | Heaps |
| Adjacency list | g = defaultdict(list), g[u].append(v) (and g[v].append(u) if undirected) | g[v] for a missing v inserts it; inside for u in g that raises RuntimeError, so read with g.get(v, []) | Setup, Graphs |
| Prefix-sum counts | count = {0: 1}, ans += count.get(pre - k, 0), then count[pre] = count.get(pre, 0) + 1 | the {0: 1} seed counts subarrays that start at index 0 | Prefix sums |
| Window, last-seen index | if last.get(ch, -1) >= lo: lo = last[ch] + 1, then last[ch] = i | lo only moves right: an index from before the window must not pull it back | Sliding window |
| Window, distinct count | count[ch] -= 1, then del count[ch] at zero | a zero count is still a key, so len(count) overcounts | Sliding window |
| Memoization | @cache on dp(i, j), or a memo dict keyed by (i, j) | the arguments must be hashable: pass indexes or tuples, not lists | Top-down DP |
| LRU cache | OrderedDict: move_to_end(k) on every use, popitem(last=False) to evict | a plain dict's popitem() evicts the newest | LRU cache |
| Top k frequent | Counter(xs).most_common(k) | O(n log k); buckets indexed by count give O(n) | Top k frequent |
| Union-find | parent = {x: x for x in nodes}, find with path halving | parent.setdefault(x, x) when nodes appear lazily | Union-find, Python |
| Trie | node = node.setdefault(ch, {}); mark word ends with a key like "$" | the end marker must never be a real character | Tries |
| Longest consecutive run | s = set(xs); count up only from an x with x - 1 not in s | without that check the scan is O(n²) | |
| Dedupe, keeping order | list(dict.fromkeys(xs)) | set(xs) loses the order | Dedupe |
| Float keys | exact keys: Fraction, or a slope as (dy // g, dx // g) with g = gcd(dy, dx) | 0.1 + 0.2 and 0.3 are different keys | Numbers |
from collections import defaultdict
def two_sum(nums: list[int], target: int) -> list[int]:
seen: dict[int, int] = {} # value -> index
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i # after the check: no self-pairs
return []
def count_sums(nums: list[int], k: int) -> int:
count = {0: 1} # the empty prefix
pre = ans = 0
for x in nums:
pre += x
ans += count.get(pre - k, 0)
count[pre] = count.get(pre, 0) + 1
return ans
print(two_sum([3, 2, 4], 6)) # [1, 2]
print(count_sums([1, 1, 1], 2)) # 2
edges = [("a", "b"), ("b", "c")]
g: defaultdict[str, list[str]] = defaultdict(list)
for u, v in edges:
g[u].append(v)
g[v].append(u)
print(dict(g)) # {'a': ['b'], 'b': ['a', 'c'], 'c': ['b']}1/5
count_sums([1, 1, 1], 2) starts with count = {0: 1}: the empty prefix sums to 0. pre and ans are 0.
Two string-shaped dict problems live on the Strings sheet: the longest unique substring window and the fixed-size anagram window.
Typing
| Annotation | Accepts | Use for |
|---|---|---|
dict[K, V] | dicts | values you build and mutate |
Mapping[K, V] | any read-only mapping: dict, Counter, MappingProxyType, ... | parameters you only read |
MutableMapping[K, V] | dict-like objects you write to | parameters you edit without demanding a dict |
defaultdict[K, V], Counter[K], OrderedDict[K, V] | those classes | annotate them: defaultdict(list) alone infers a vague type |
a TypedDict class | dicts with a fixed set of string keys, each with its own type | JSON-like records |
set[T], frozenset[T] | sets | collections.abc.Set[T] for read-only parameters |
Import the abstract types from collections.abc; typing.Dict is a deprecated alias of dict.
from collections import Counter, defaultdict
from collections.abc import Mapping
from typing import NotRequired, TypedDict
def total(prices: Mapping[str, float]) -> float:
return sum(prices.values())
counts: dict[str, int] = {"a": 1}
total(counts) # OK: Mapping is covariant in its values
floats: dict[str, float] = counts # ❌ type error: invariant
empty: dict[str, list[int]] = {} # annotate empty dicts
groups: defaultdict[str, list[int]] = defaultdict(list)
freq: Counter[str] = Counter("abca")
class User(TypedDict):
id: int
name: str
email: NotRequired[str]
u: User = {"id": 1, "name": "ann"} # email may be absent
type Trie = dict[str, Trie] # a recursive alias (3.12+)dict is invariant in its values because it is writable: if dict[str, int] were accepted as
dict[str, float], the callee could store 0.5 in your dict of ints. Mapping has no setters, so
it is covariant in its values. Both stay invariant in their keys, because __getitem__ and get
take a key as an argument.
Coming from JavaScript
The TypeScript versions of this sheet are Maps & sets and Objects.
| JavaScript | Python |
|---|---|
obj.key, obj["key"], map.get(k) | d["key"] (raises KeyError), or d.get("key") (None) |
obj?.a?.b | d.get("a", {}).get("b") |
"k" in obj | "k" in d; JS also finds inherited keys ("toString" in {} is true), Python checks only the dict's own keys |
Object.hasOwn(obj, k), map.has(k) | k in d |
Object.keys(o), Object.values(o), Object.entries(o) | list(d), list(d.values()), list(d.items()); without list they are live views |
Object.fromEntries(pairs) | dict(pairs) |
{ ...a, ...b } | {**a, **b} or a | b |
delete obj.k, map.delete(k) | del d[k], or d.pop(k, None) when it may be missing |
map.set(k, v), map.size | d[k] = v, len(d) |
obj[1] and obj["1"] are the same key | 1 and "1" are different keys; 1, 1.0 and True are the same key |
| integer-like object keys iterate first, in numeric order | every key keeps insertion order |
a Map compares object keys by identity: [1, 2] and [1, 2] are two keys | tuple keys compare by value: (1, 2) and (1, 2) are one key |
new Set(xs), set.has(x), set.add(x) | set(xs), x in s, s.add(x) |
a.union(b), a.intersection(b), a.difference(b), a.symmetricDifference(b) (ES2025) | a | b, a & b, a - b, a ^ b |
a.isSubsetOf(b), a.isDisjointFrom(b) | a <= b, a.isdisjoint(b) |
Object.groupBy(xs, f), Map.groupBy(xs, f) | a defaultdict(list) loop |
JSON.stringify({ 1: "a" }) | json.dumps({1: "a"}) gives '{"1": "a"}': int keys come back from json.loads as strings |
Object.freeze(obj) | types.MappingProxyType(d), a read-only view |
WeakMap | weakref.WeakKeyDictionary |
structuredClone(obj) | copy.deepcopy(d) |
Recipes
Invert a dict
When you need value → keys, and values may repeat.
from collections import defaultdict
owner = {"ann": "red", "bo": "blue", "cy": "red"}
by_color: defaultdict[str, list[str]] = defaultdict(list)
for name, color in owner.items():
by_color[color].append(name)
print(dict(by_color))
# {'red': ['ann', 'cy'], 'blue': ['bo']}
unique = {"a": 1, "b": 2}
print({v: k for k, v in unique.items()}) # {1: 'a', 2: 'b'}Deep merge
When layering config dicts and nested dicts should merge instead of being replaced.
from typing import Any
def deep_merge(
a: dict[str, Any], b: dict[str, Any]
) -> dict[str, Any]:
out = dict(a)
for k, v in b.items():
old = out.get(k)
if isinstance(old, dict) and isinstance(v, dict):
out[k] = deep_merge(old, v)
else:
out[k] = v
return out
base = {"db": {"host": "x", "port": 5432}, "debug": False}
env = {"db": {"host": "prod"}, "debug": True}
print(deep_merge(base, env))
# {'db': {'host': 'prod', 'port': 5432}, 'debug': True}Values that aren't merged are shared with the inputs, not copied; copy.deepcopy the result before
mutating it.
Trie with nested dicts
When looking up words or prefixes one character at a time.
type Trie = dict[str, Trie]
END = "$" # not a letter, so it can't clash
def insert(root: Trie, word: str) -> None:
node = root
for ch in word:
node = node.setdefault(ch, {})
node[END] = {}
def walk(root: Trie, s: str) -> Trie | None:
node = root
for ch in s:
if ch not in node:
return None
node = node[ch]
return node
root: Trie = {}
for w in ["car", "cart", "cat"]:
insert(root, w)
found = walk(root, "car")
print(found is not None and END in found) # True: a word
print(walk(root, "ca") is not None) # True: a prefix
print(walk(root, "cow")) # NoneLongest consecutive run
When you need the longest run of consecutive integers in unsorted input, in O(n).
def longest_run(nums: list[int]) -> int:
s = set(nums)
best = 0
for x in s:
if x - 1 in s:
continue # not the start of a run
y = x
while y + 1 in s:
y += 1
best = max(best, y - x + 1)
return best
print(longest_run([100, 4, 200, 1, 3, 2])) # 4: 1 to 4Loop over the set, not the list: duplicates in the input would otherwise repeat the work.
Word ladder
When BFS walks between words that differ by one letter; the word set doubles as the visited set.
from collections import deque
from string import ascii_lowercase
def ladder(begin: str, end: str, words: list[str]) -> int:
unseen = set(words) # remove a word once it's queued
if end not in unseen:
return 0
unseen.discard(begin)
q = deque([(begin, 1)])
while q:
word, steps = q.popleft()
if word == end:
return steps
for i in range(len(word)):
for ch in ascii_lowercase:
nxt = word[:i] + ch + word[i + 1 :]
if nxt in unseen:
unseen.remove(nxt) # mark on enqueue
q.append((nxt, steps + 1))
return 0
words = ["hot", "dot", "dog", "lot", "log", "cog"]
print(ladder("hit", "cog", words)) # 5
print(ladder("hit", "cog", words[:-1])) # 0Each word tries 26 × L one-letter changes, each an O(1) average lookup. Removing a word as it is
queued keeps the BFS from revisiting it.
Happy number
When a process might loop: remember every state you've seen.
def is_happy(n: int) -> bool:
seen: set[int] = set()
while n != 1 and n not in seen:
seen.add(n)
n = sum(int(d) ** 2 for d in str(n))
return n == 1
print(is_happy(19), is_happy(2)) # True FalseThe seen set costs memory for every state; Floyd's fast and slow pointers find the same cycle in
O(1) space (Fast & slow pointers).
Can one multiset cover another
When checking a ransom note against a magazine, or a word against a rack of letter tiles.
from collections import Counter
def can_build(note: str, letters: str) -> bool:
return Counter(note) <= Counter(letters) # 3.10+
print(can_build("aab", "baa"), can_build("aa", "ab"))
# True False
missing = Counter("aabc") - Counter("ab")
print(sorted(missing.elements())) # ['a', 'c']Rank by count, then by key
When ordering items by frequency with a deterministic tie-break.
import heapq
from collections import Counter
words = "the cat and the hat and the bat".split()
cnt = Counter(words)
ranked = sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0]))
print(ranked[:3]) # [('the', 3), ('and', 2), ('bat', 1)]
top = heapq.nsmallest(
2, cnt.items(), key=lambda kv: (-kv[1], kv[0])
) # [('the', 3), ('and', 2)]most_common breaks ties by first appearance; the explicit key breaks them alphabetically, and
nsmallest does it in O(n log k).
At most k distinct in a window
When the number of distinct keys in a sliding window is the constraint.
def longest_k_distinct(s: str, k: int) -> int:
count: dict[str, int] = {}
lo = best = 0
for hi, ch in enumerate(s):
count[ch] = count.get(ch, 0) + 1
while len(count) > k:
gone = s[lo]
count[gone] -= 1
if count[gone] == 0:
del count[gone] # else len(count) lies
lo += 1
best = max(best, hi - lo + 1)
return best
print(longest_k_distinct("eceba", 2)) # 3: "ece"References
- Python docs: Dictionaries (opens in a new tab) and Sets (opens in a new tab) (tutorial)
- Python docs: Mapping types, dict (opens in a new tab) and dict views (opens in a new tab): every method, ordering, view set operations
- Python docs: Set types (opens in a new tab):
set,frozenset, operators and methods - Python docs: collections (opens in a new tab):
Counter,defaultdict,OrderedDict,ChainMap - Python docs: hashable (opens in a new tab) and
object.__hash__(opens in a new tab): the rules for keys - Python FAQ: How are dictionaries implemented in CPython? (opens in a new tab) and Why must dictionary keys be immutable? (opens in a new tab)
- Python docs: typing.TypedDict (opens in a new tab) and collections.abc.Mapping (opens in a new tab)
- Python docs: functools.cache (opens in a new tab): a memo dict around a function
- PEP 584: Add union operators to dict (opens in a new tab):
|and|= - Python wiki: Time complexity (opens in a new tab): costs of
dictandsetoperations - MDN: Map (opens in a new tab) and Set (opens in a new tab): the JS side