Skip to main content

Counter, defaultdict & deque

Level 2: Level 2: Idiomsmedium12 mincollectionscounterdefaultdictdeque

Reach for specialized collections: count with Counter, group with defaultdict, queue with deque.

Reach for the right container, not a hand-rolled dict

When you tally, group, or queue, a plain dict or list works, but it forces you to write boilerplate that hides bugs. The collections module ships three focused upgrades that name your intent and delete that boilerplate: Counter for frequencies, defaultdict for grouping, and deque for queues.

Table
The first two remove boilerplate that hides bugs. The third removes an actual complexity class: list.pop(0) shifts every remaining element, so a queue built on a list is O(n²) overall while the same loop on a deque is O(n).
JobThe manual versionThe upgradeWhat the upgrade removes
Tally frequenciesd[k] = d.get(k, 0) + 1Counter(items)the get-with-default dance on every increment
Group into bucketsif k not in d: d[k] = []defaultdict(list)the existence check before every append
Pop from the frontlst.pop(0), which is O(n)deque.popleft(), O(1)a hidden quadratic in any queue loop
The first two remove boilerplate that hides bugs. The third removes an actual complexity class: list.pop(0) shifts every remaining element, so a queue built on a list is O(n²) overall while the same loop on a deque is O(n).

In interviews and in real data pipelines, reaching for the right one signals you know the standard library, and it usually cuts genuine complexity, not just line count.

Counter: build on the intro

The Modules, imports and the standard library lesson already introduced Counter and most_common(k). Here it earns its place next to defaultdict and deque, so focus on the two properties you lean on when tallying:

from collections import Counter

c = Counter(["a", "b", "a", "c", "a"])   # Counter({'a': 3, 'b': 1, 'c': 1})
c["a"]                                    # 3
c["z"]                                    # 0, not a KeyError

A missing key returns 0 instead of raising, so you never guard a read. And because Counter is a dict subclass, Counter(words) == {"a": 2, "b": 1} is True, so for the Apply exercise you can return the Counter directly, or wrap it in dict(...) if a caller insists on a plain dict.

defaultdict: group without the missing-key dance

Check yourself
Meaning for every new key to start as an empty list, you write groups = defaultdict([]). What happens?

A plain dict raises KeyError on a missing key, so grouping needs an if key not in d: d[key] = [] guard. defaultdict(list) calls the factory you pass (the callable list, not list()) the first time a key is touched:

from collections import defaultdict

groups = defaultdict(list)
for w in ["apple", "ant", "bee"]:
    groups[w[0]].append(w)
dict(groups)   # {'a': ['apple', 'ant'], 'b': ['bee']}

Keys keep insertion order and each list keeps append order, which is exactly what the Practice exercise checks.

deque: a real queue

A deque (double-ended queue) adds and removes at both ends in O(1):

from collections import deque

q = deque([1, 2, 3])
q.appendleft(0)   # deque([0, 1, 2, 3])
q.popleft()       # 0

Pitfalls

Check yourself
groups = defaultdict(list) currently holds one key, 'a'. To check whether 'b' has any entries you write if groups['b']: and the branch does not run. What is len(groups) now?
  • Merely reading a missing defaultdict key inserts it: touching d["x"] when x is absent leaves d["x"] == [] behind. Use d.get("x") when you only want to peek without mutating.
  • defaultdict([]) raises TypeError. The factory must be callable, so pass list, set, or int, never an instance.
  • Counter never raises on a missing key, which is handy but hides typos: c["speling"] quietly returns 0.
Check yourself
A BFS keeps its frontier in a plain list and takes the next node with queue.pop(0). Over a graph of n nodes, what does that dequeuing cost in total?

Interview nuance: using a list as a queue is a classic trap. list.pop(0) and list.insert(0, x) are O(n) because every remaining element shifts one slot, so a BFS built on list.pop(0) is secretly O(n²). deque.popleft() and deque.appendleft() are O(1), which is why a deque is the standard queue for BFS and sliding-window problems. The tradeoff is that a deque has no O(1) random indexing into its middle, unlike a list.

Worked example (Python)
from collections import Counter, defaultdict
print(Counter(["a", "b", "a", "c", "a"]))   # Counter({'a': 3, 'b': 1, 'c': 1})

groups = defaultdict(list)
for w in ["ant", "apple", "bee"]:
    groups[w[0]].append(w)
print(dict(groups))   # {'a': ['ant', 'apple'], 'b': ['bee']}

Apply

Your turn

The task this lesson builds to.

Implement word_counts(words): return a dict mapping each word to how many times it appears in the list words. Use Counter.

For ["a", "b", "a"] return {"a": 2, "b": 1}.

2 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement group_by_first(words): return a dict mapping each first letter to the list of words starting with it, preserving order. Use defaultdict(list).

["apple", "ant", "bee"] returns {"a": ["apple", "ant"], "b": ["bee"]}.

3 hints and 3 automated checks are waiting in the workspace.