Intuition What this page does
The parent note showed you the map of drawers . This page opens each drawer and runs the tools against every kind of input you will ever hand them — the normal case, the empty case, the zero case, the "it repeats forever" case, and the exam trick that looks wrong until you slow down. If you have met a scenario here, no test can surprise you.
Parent: Standard Library (parent topic)
Definition Imports used on this page
Every code snippet below assumes these imports are already at the top of your file. Whenever you see a bare name like combinations or reduce, it comes from here:
import math
import random
from itertools import combinations, permutations, product, count, islice
from collections import Counter, defaultdict, deque
from functools import reduce
from operator import mul, add
from datetime import datetime, timedelta
Before any example, here is the complete list of case-classes these modules can throw at you. Every cell below is covered by a worked example (its number is in brackets).
#
Case class
What makes it tricky
Covered by
A
Normal combinatorial count
plug into a formula
Ex 1
B
Degenerate / boundary input
( 0 n ) , ( n n ) , empty iterable
Ex 2
C
Zero / edge numeric input
gcd ( 0 , 0 ) , hypot(0,0), factorial(0)
Ex 3
D
Inclusive-vs-exclusive ends (sign of the boundary)
randint vs randrange vs range
Ex 4
E
Reproducibility (same seed → same stream)
deterministic "randomness"
Ex 5
F
Counting occurrences + missing-key default
Counter, defaultdict
Ex 6
G
Both-ends queue , big-O contrast
deque vs list
Ex 7
H
Infinite / lazy iterator, must be truncated
count, product, exhaustion
Ex 8
I
Fold to one value (empty-fold degenerate case too)
reduce, initial value
Ex 9
J
Real-world word problem (dates) + exam twist
timedelta, strftime, month rollover
Ex 10
The matrix has three "danger axes" that appear again and again:
Boundary — what happens at 0 , at n , at "nothing"?
Inclusive vs exclusive — does the top end count or not?
Lazy vs eager — is the result a finished list, or a one-shot stream?
Keep those three in your head as you read.
A pizza shop has 8 toppings. How many ways to choose exactly 3, order not mattering? Use math.comb.
Forecast: guess the number before reading on. (Hint: it is under 100.)
Recognise "order not mattering" → combinations.
Why this step? If AB and BA were different we'd want permutations; "a set of 3 toppings" has no order, so we want ( k n ) .
Write the formula. ( 3 8 ) = 3 ! ( 8 − 3 )! 8 ! = 3 ⋅ 2 ⋅ 1 8 ⋅ 7 ⋅ 6 .
Why this step? The parent note derived ( k n ) = k ! ( n − k )! n ! : arrange k in order (8 ⋅ 7 ⋅ 6 ), then divide by the 3 ! internal orderings we over-counted.
Compute. 6 336 = 56 . In code: math.comb(8, 3) → 56.
Why this step? math.comb returns an exact integer , no float rounding.
Verify: the two symmetric halves must agree — ( 3 8 ) = ( 5 8 ) , and math.comb(8,5) is also 56. ✓ Units: a pure count, dimensionless.
Evaluate ( 0 5 ) , ( 5 5 ) , and list(combinations('AB', 0)), and list(combinations([], 2)).
Forecast: how many of these are 0? How many are 1? (Careful — a count of ways can be 1 even when you "pick nothing".)
( 0 5 ) : choose nothing. There is exactly one way to pick nothing (the empty choice). So ( 0 5 ) = 1 .
Why this step? Formula check: 0 ! 5 ! 5 ! and 0 ! = 1 by definition, giving 1 ⋅ 120 120 = 1 . The definition 0 ! = 1 exists precisely so this boundary behaves.
( 5 5 ) : choose everything. One way — take all of them. = 1 .
Why this step? 5 ! 0 ! 5 ! = 1 ; mirror image of step 1.
combinations('AB', 0) → [()]: one item, the empty tuple.
Why this step? Same idea as step 1 in code: "choose 0 elements" yields exactly one arrangement — the empty one. Not an empty list.
combinations([], 2) → []: you cannot choose 2 from nothing.
Why this step? Mathematically ( 2 0 ) = 0 ; the iterator is empty. This is the other degenerate case, and it looks almost identical in code — do not confuse [()] (one empty tuple) with [] (no tuples).
Verify: math.comb(5,0) == 1, math.comb(5,5) == 1, len(list(combinations('AB',0))) == 1, len(list(combinations([],2))) == 0. ✓
combinations([], 2) gives [], but math.comb(0, 2) does not
The itertools version returns an empty iterator (0 ways), matching ( 2 0 ) = 0 . But math.comb(0, 2) — wait, actually math.comb(0, 2) returns 0 cleanly (it defines ( k n ) = 0 when k > n ). The genuine trap is negative arguments: ==math.comb(-1, 2) raises ValueError== because math.comb refuses negative inputs. So ( 2 0 ) = 0 is fine, but never feed comb a negative n or k .
Compute math.factorial(0), math.gcd(0, 0), math.gcd(0, 12), and math.hypot(0, 0).
Forecast: which one is 0? Which are 1? Which is 12?
factorial(0) → 1. The empty product is 1.
Why this step? n ! multiplies n numbers; multiplying zero numbers leaves the multiplicative identity, 1 . This is what makes ( 0 n ) = 1 work in Ex 2.
gcd(0, 0) → 0. By Python's convention the greatest common divisor of two zeros is 0.
Why this step? Every integer divides 0, so there is no largest common divisor; Python fixes the answer at 0 so the function is total (never errors).
gcd(0, 12) → 12. gcd(0, n) == abs(n).
Why this step? 0 = 0 ⋅ 12 , so 12 divides 0 ; 12 also divides itself, and nothing bigger divides 12 . So the gcd is 12 .
hypot(0, 0) → 0.0. 0 2 + 0 2 = 0 .
Why this step? The parent note used hypot for distances; the degenerate "same point" distance must be 0 , and it is — returned as a float 0.0, not int 0.
Verify: factorial(0)==1, gcd(0,0)==0, gcd(0,12)==12, hypot(0,0)==0.0. ✓ Units: hypot returns a length (same units as its inputs); the others are dimensionless.
This is the classic mistake the parent note steel-manned. Look at the picture — three tools, three different treatments of the top end.
Intuition What to see in the figure above
Three rows, one per tool: range(1,6), randrange(1,6), randint(1,6). Each row shows the dots 1 2 3 4 5 6. A filled dot means "this value is reachable"; a hollow dot means "excluded". The top two rows (teal, plum) have a hollow 6 — the top is excluded. The bottom row (orange) has a filled 6 — randint includes it. That single hollow-vs-filled 6 is the whole lesson.
For a=1, b=6, which integers can each of these produce: range(1,6), random.randrange(1,6), random.randint(1,6)?
Forecast: which one can output a 6 ?
range(1, 6) → 1,2,3,4,5.
Why this step? range (and randrange, its random twin) uses a half-open interval [ 1 , 6 ) : bottom included, top excluded . Look at the figure: the top dot is hollow.
random.randrange(1, 6) → one of 1,2,3,4,5.
Why this step? Same half-open rule; it is literally "pick a random element of range(1,6)".
random.randint(1, 6) → one of 1,2,3,4,5,6.
Why this step? randint is inclusive on both ends [ 1 , 6 ] — designed for human "roll a 1-to-6 die" thinking. Only this one can output 6.
Verify: the number of possible outcomes is range → 5, randrange → 5, randint → 6. So list(range(1,6)) == [1,2,3,4,5] and the max reachable by randint(1,6) is 6. ✓
randint(a, b) includes b; randrange(a, b) and range(a, b) exclude b. ==Only randint mirrors human counting.==
After random.seed(42), what does random.randint(1, 100) give? And if I seed with 42 again, do I get the same value?
Forecast: is a "random" number reproducible? (Yes — that's the point of a seed.)
Set the seed. random.seed(42) fixes the internal starting state.
Why this step? A computer's "random" numbers come from a deterministic formula (a PRNG). The seed is where that formula starts. Same start ⇒ same whole sequence.
Draw once. random.randint(1, 100) gives some fixed value determined entirely by the seed.
Why this step? We fixed the state, so this draw is fully determined — but the exact number depends on your Python version, so don't memorise it; memorise that it's repeatable .
Re-seed and redraw. random.seed(42); random.randint(1,100) gives the same value as step 2.
Why this step? Reproducibility is a feature — it makes tests that use randomness deterministic and debuggable.
Verify: seeding twice with the same value yields the identical result — the check re-seeds and compares equality (not a hard-coded number, which would be version-dependent). ✓
From the word "mississippi", get the 2 most common letters with Counter, then group the indices of each letter using defaultdict(list).
Forecast: which letter wins, and how many times?
Count with Counter. Counter("mississippi") → Counter({'i':4,'s':4,'p':2,'m':1}).
Why this step? Counter is a dict that auto-tallies — no manual += 1 loop.
Top 2. .most_common(2) → the two highest counts, [('i', 4), ('s', 4)] (in some order).
Why this step? most_common(k) sorts by count descending and slices the top k. Both 'i' and 's' have count 4, so which one prints first among equal counts is not guaranteed by the language spec — treat tied elements as an unordered set {('i',4),('s',4)}.
Group indices with defaultdict(list).
pos = defaultdict( list )
for i, ch in enumerate ( "mississippi" ):
pos[ch].append(i)
# pos['s'] == [2, 3, 5, 6]
Why this step? The first time we touch pos['s'] the key is missing; defaultdict(list) silently creates an empty list [] so .append works — no KeyError, no if key not in. Pass the factory list, not list().
Verify: Counter("mississippi")['i'] == 4, total letters sum(Counter("mississippi").values()) == 11, the top-2 counts form the set {('i',4),('s',4)}, and pos['s'] == [2,3,5,6]. ✓
The parent note claimed deque.popleft() is O ( 1 ) while list.pop(0) is O ( n ) . Here is why , visually.
Intuition What to see in the figure above
Top row (teal): a deque [0,1,2,3]. A plum arrow adds 0 at the front — labelled appendleft O(1) — and an orange arrow removes at the right — labelled pop() O(1) . Neither disturbs the other elements. Bottom row (ink): a plain list; removing the front element (dashed orange box marked x) forces every later element to slide one slot left (the row of orange shift-arrows), labelled pop(0): shift all left O(n) . The contrast of "no shifting" vs "shift everything" is the point.
Start with deque([1,2,3]). Do appendleft(0) then pop(). What is left, and why is this cheaper than a list?
Forecast: what does the deque hold at the end?
appendleft(0) → deque([0,1,2,3]).
Why this step? A deque is a double-ended queue : adding at the front is O ( 1 ) — it just links a new node at the left, no shifting.
pop() (removes from the right ) → deque([0,1,2]).
Why this step? pop() with no argument takes the rightmost element, also O ( 1 ) .
Contrast with a list. my_list.pop(0) must shift every remaining element left by one slot → O ( n ) (see the figure's red shifting arrows).
Why this step? A Python list stores items in a contiguous block; removing the front leaves a hole that all later items must slide into.
Verify: the final container equals [0,1,2]. ✓ Complexity claim: for a queue of n items, n front-removals cost O ( n 2 ) on a list but O ( n ) on a deque — see Big-O Notation .
count(10, 2) is an infinite stream. Take its first 4 values. Also list product([0,1], repeat=2) and show why re-walking an iterator fails.
Forecast: what are the 4 numbers? And how many pairs does product give?
Never list(count(...)). count(10, 2) yields 10, 12, 14, … forever.
Why this step? itertools is lazy — it computes one item per request. list() would try to realise infinitely many and hang. See Python Iterators and Generators .
Truncate with islice. list(islice(count(10, 2), 4)) → [10, 12, 14, 16].
Why this step? islice pulls exactly the first 4, then stops — you consume only what you need.
product([0,1], repeat=2) → [(0,0),(0,1),(1,0),(1,1)] — ∣ A ∣ ⋅ ∣ B ∣ = 2 ⋅ 2 = 4 pairs (the multiplication rule, see Combinatorics ).
Why this step? product is the Cartesian product; repeat=2 means "pairs from the same 2-element set".
Exhaustion trap. An iterator is one-shot :
it = combinations( 'ABC' , 2 )
list (it) # [('A','B'),('A','C'),('B','C')]
list (it) # [] <-- already consumed!
Why this step? Once you walk a lazy iterator to the end it is empty. Wrap in list(...) once if you need it twice; you cannot do combinations(...)[0].
Verify: first 4 of count(10,2) = [10,12,14,16]; len(list(product([0,1],repeat=2))) == 4; len(list(combinations('ABC',2))) == 3. ✓
Use reduce (from functools) with mul/add (from operator) to (a) multiply [1,2,3,4], and (b) sum [] safely.
Forecast: part (a) is a familiar factorial-shaped number. Part (b) — does an empty reduce crash?
Product fold. reduce(mul, [1,2,3,4]) → 24.
Why this step? reduce collapses left-to-right: (( 1 ⋅ 2 ) ⋅ 3 ) ⋅ 4 = 24 , exactly the pattern the parent note wrote as f ( f ( f ( x 1 , x 2 ) , x 3 ) , x 4 ) . mul from operator is just lambda a,b: a*b, pre-written.
Empty input WITHOUT an initial value → error. reduce(add, []) raises TypeError.
Why this step? With no items and no starting value, there is nothing to return. This is the degenerate boundary.
Fix with an initial value. reduce(add, [], 0) → 0.
Why this step? The third argument is the starting accumulator . It also defines the answer for an empty list — here 0, the additive identity. (For a product-fold you'd use 1.)
Verify: reduce(mul, [1,2,3,4]) == 24 and reduce(add, [], 0) == 0. ✓ (Compare with Recursion and Memoization — a fold is iteration doing a recursion's job.)
A library loans a book on 2024-01-31 for a 30-day period, using from datetime import datetime, timedelta. (a) What is the due date? (b) Format it as YYYY-MM-DD. (c) Exam twist: what if the loan were only 1 day — does the month roll over correctly from Jan 31?
Forecast: what month does the 30-day due date land in — February or March?
Parse the start date. d = datetime.strptime('2024-01-31', '%Y-%m-%d').
Why this step? strptime = p arse string → object. Memory hook from parent: strf = to string, strp = parse from string.
Add the loan window. due = d + timedelta(days=30).
Why this step? Adding a bare 1 to a date is ambiguous (1 what?). timedelta(days=30) makes the unit explicit , and it handles month/year lengths for you.
Compute (a). 2024 is a leap year, so February has 29 days. Counting real days: Jan 31 +29 days reaches Feb 29, and +1 more reaches 2024-03-01 .
d = datetime.strptime( '2024-01-31' , '%Y-%m- %d ' )
due = d + timedelta( days = 30 ) # datetime(2024, 3, 1)
Why this step? Calendar arithmetic by hand is exactly where humans slip — timedelta counts real days across variable month lengths.
Format (b). due.strftime('%Y-%m-%d') → '2024-03-01'.
Why this step? strftime turns the object back into the display string.
Twist (c). d + timedelta(days=1) → datetime(2024, 2, 1), i.e. strftime('%Y-%m-%d') → '2024-02-01'.
Why this step? Adding 1 day to Jan 31 rolls into February automatically — you never manually reset the day to 1 or bump the month.
Verify: (strptime('2024-01-31') + timedelta(days=30)).strftime('%Y-%m-%d') == '2024-03-01'; the 1-day case == '2024-02-01'. ✓ Units: dates in, dates out; timedelta carries the "days" unit.
Recall Which of
range, randrange, randint can produce the top endpoint?
Which one includes b in (1, b)? ::: Only random.randint(1, b) — the other two use a half-open interval and exclude b.
Recall What is
list(combinations([], 2)) versus list(combinations('AB', 0))?
One is empty, one holds a single empty tuple — which is which? ::: combinations([],2) → [] (can't choose 2 from nothing); combinations('AB',0) → [()] (exactly one way to choose nothing).
Recall Why must
reduce(f, []) be given a third argument?
What breaks on an empty fold? ::: With no items and no initial accumulator there is nothing to return, so it raises TypeError. Supply an initial value (the identity: 0 for sums, 1 for products).
Mnemonic The three danger axes
B oundary (0 / n / empty) · I nclusive-vs-exclusive top · L azy-vs-eager. Say "BIL" before every stdlib exam question.