What you'll learn
Quick Answer
collections gives you Counter for frequencies, defaultdict for grouping, deque for fast operations at both ends, and namedtuple for lightweight records. itertools gives you combinatorics and iterator plumbing.
Counter: frequency counting, solved
from collections import Counter
words = "the quick the lazy the dog".split()
print(Counter(words).most_common(2))
# [('the', 3), ('quick', 1)]
This replaces the loop everyone writes with dict.get(w, 0) + 1. It is shorter, and most_common(n) handles the sorting that would otherwise be a second step.
Counter also supports arithmetic, which is occasionally exactly what you need:
a = Counter("listen"); b = Counter("silent")
print(a == b) # True -- anagram check
print(a - Counter("lis")) # what remains
The equality comparison is the neatest anagram solution there is, and it is O(n) against sorting's O(n log n).
defaultdict: grouping without the guard clause
from collections import defaultdict
dd = defaultdict(list)
dd["a"].append(1)
dd["a"].append(2)
print(dict(dd)) # {'a': [1, 2]}
With a plain dict, the first append raises KeyError because the list does not exist yet, so you write if key not in d: d[key] = [] every time. defaultdict creates it on first access using the factory you supplied.
Grouping records is where it shines:
by_stream = defaultdict(list)
for s in students:
by_stream[s.stream].append(s.name)
One caution: reading a missing key also creates it, so if dd["nope"] silently adds an empty list. Use .get() or in when you only want to check.
deque and namedtuple
from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0)
dq.append(4)
print(list(dq)) # [0, 1, 2, 3, 4]
print(dq.popleft()) # 0
A list's pop(0) is O(n) because every remaining element shifts down. deque.popleft() is O(1). That matters in breadth-first search and any queue processing, where pop(0) in a loop turns a linear algorithm quadratic.
This is the practical answer to "should I implement a linked list" in Python — you almost never should, because deque already gives you fast operations at both ends.
from collections import namedtuple
Point = namedtuple("Point", "x y")
pt = Point(3, 4)
print(pt, pt.x) # Point(x=3, y=4) 3
namedtuple gives readable field access with tuple behaviour and very little memory. For anything needing mutability or defaults, a dataclass is the better choice.
itertools: the ones actually worth knowing
import itertools
print(list(itertools.chain([1,2], [3,4])))
# [1, 2, 3, 4]
print(list(itertools.combinations("abc", 2)))
# [('a','b'), ('a','c'), ('b','c')]
chain iterates several sequences as one without building a combined list. combinations and permutations save writing nested loops for subset problems, and appear constantly in brute-force interview solutions.
Others worth remembering: product for the cartesian product (nested loops flattened), islice to take the first n of an iterator without materialising it, zip_longest when sequences differ in length, and accumulate for running totals.
Most return lazy iterators, so nothing is computed until consumed and they can be exhausted only once. That is the point — chain over two enormous files uses almost no memory — but it surprises people who print one and see <itertools.chain object>.
The groupby trap
itertools.groupby does not behave like SQL's GROUP BY, and this catches almost everyone once.
print([(k, list(g)) for k, g in itertools.groupby(sorted("aabbc"))])
# [('a', ['a','a']), ('b', ['b','b']), ('c', ['c'])]
That works because the input was sorted. groupby only groups consecutive equal items. On unsorted input it produces a new group every time the value changes, so the same key appears several times and the result looks wrong without any error.
So either sort by the same key first, or use defaultdict(list), which does not care about order and is usually clearer for genuine grouping.
The second gotcha: the group is an iterator sharing state with the outer one, so it is invalidated as soon as you advance. If you need to keep it, wrap it in list() immediately, as above.
