PYnative

Python Programming

  • Learn Python
    • Python Tutorials
    • Python Basics
    • Python Interview Q&As
  • Exercises
    • Python Exercises
    • C Programming Exercises
    • C++ Exercises
  • Quizzes
  • Code Editor
    • Online Python Code Editor
    • Online C Compiler
    • Online C++ Compiler
Home » Python Exercises » Python Itertools and Functools Exercises: 20 Coding Problems with Solutions

Python Itertools and Functools Exercises: 20 Coding Problems with Solutions

Updated on: June 13, 2026 | Leave a Comment

This article presents 20 coding exercises designed to build practical fluency with Python’s itertools and functools modules. You’ll work through topics including combinatorial tools like permutations, combinations, and Cartesian products; lazy iteration with chain, cycle, islice, and groupby; and functional programming patterns such as reduce, partial, lru_cache, and cmp_to_key.

Each coding challenge includes a Practice Problem, Hint, Solution code, and detailed Explanation, ensuring you don’t just copy code, but genuinely practice and understand how and why it works.

  • All solutions have been fully tested on Python 3.
  • Use our Online Code Editor to solve these exercises in real time.
  • Also, Solve Python Exercises: 29 topic-wise exercises with over 800+ coding questions

Let us know if you have any alternative solutions. It will help other developers.

+ Table of Contents (20 Exercises)

Table of contents

  • Exercise 1: All Permutations of a String
  • Exercise 2: Combinations Without Replacement
  • Exercise 3: Combinations With Replacement
  • Exercise 4: Cartesian Product
  • Exercise 5: Password Generator Using product()
  • Exercise 6: Chain Multiple Iterables
  • Exercise 7: Infinite Counter with islice
  • Exercise 8: Cycle Through a List
  • Exercise 9: Repeat a Value
  • Exercise 10: takewhile and dropwhile
  • Exercise 11: Group Consecutive Items
  • Exercise 12: Compress a Sequence
  • Exercise 13: Accumulate Running Totals
  • Exercise 14: Reduce to a Single Value
  • Exercise 15: Partial Functions
  • Exercise 16: Memoisation with lru_cache
  • Exercise 17: Custom Sorting with cmp_to_key
  • Exercise 18: Pipeline with chain and reduce
  • Exercise 19: Cached Combination Counter
  • Exercise 20: Lazy Data Pipeline with islice, filter, and partial

Exercise 1: All Permutations of a String

Problem Statement: Write a Python program that uses itertools.permutations() to generate all possible arrangements of the characters in the string "ABC" and prints each permutation as a joined string alongside the total count.

Purpose: Permutations are the foundation of combinatorial problem-solving – appearing in password strength analysis, anagram detection, scheduling problems, and brute-force search algorithms. itertools.permutations() generates them lazily without building the full list in memory, making it efficient even for moderately large inputs.

Given Input: chars = "ABC"

Expected Output: All 6 permutations printed one per line: ABC, ACB, BAC, BCA, CAB, CBA, followed by Total: 6

▼ Hint
  • Import permutations from itertools and call permutations(chars) to get an iterator of tuples, where each tuple is one arrangement.
  • Use "".join(p) inside the loop to convert each tuple of characters back into a readable string.
  • Pass a second argument r to restrict the length: permutations(chars, 2) gives all 2-character arrangements without repeating characters.
  • The total number of permutations of n distinct items is n!, so for 3 characters you expect 3! = 6 results.
▼ Solution & Explanation
from itertools import permutations

chars = "ABC"

all_perms = list(permutations(chars))

print(f"All permutations of '{chars}':")
for p in all_perms:
    print(f"  {''.join(p)}")

print(f"\nTotal: {len(all_perms)}")

# Partial permutations: choose r=2 from 3 characters
print(f"\n2-character permutations of '{chars}':")
for p in permutations(chars, 2):
    print(f"  {''.join(p)}")

# Verify count matches factorial formula
import math
print(f"\nExpected total (3!): {math.factorial(len(chars))}")
print(f"Expected 2-perm (P(3,2) = 3!/1!): {math.factorial(len(chars)) // math.factorial(len(chars) - 2)}")Code language: Python (python)

Explanation:

  • permutations(iterable, r): Returns an iterator of r-length tuples representing all possible orderings of the input elements. When r is omitted it defaults to the full length of the input. Elements are treated by position, not value – so if the input contains duplicate characters, duplicate permutation tuples may appear in the output.
  • "".join(p): Each permutation is yielded as a tuple of individual characters, such as ('A', 'B', 'C'). Joining with an empty string delimiter converts it to the readable string "ABC". This pattern applies any time you need to reassemble character-level itertools output into words or tokens.
  • Lazy evaluation: permutations() is a generator – it yields one tuple at a time without pre-building the full list. For a 10-character string this means 3,628,800 permutations can be iterated over without ever holding all of them in memory simultaneously. Wrapping in list() forces full materialisation, which is fine for small inputs but should be avoided for large ones.

Exercise 2: Combinations Without Replacement

Problem Statement: Write a Python program that uses itertools.combinations() to find all possible 2-player pairs from a team of 5 players, then prints each pair and the total number of unique pairings.

Purpose: Combinations model selection problems where order does not matter – tournament brackets, committee formation, lottery draws, and test case pair generation. Understanding how combinations() differs from permutations() in both output count and element ordering is a core concept in combinatorial programming.

Given Input: players = ["Alice", "Bob", "Charlie", "Diana", "Eve"]

Expected Output: All 10 unique pairs printed one per line, followed by Total pairs: 10

▼ Hint
  • Call combinations(players, 2) to get an iterator of 2-element tuples, where each tuple is a unique pair regardless of order.
  • Unlike permutations(), combinations do not produce both (Alice, Bob) and (Bob, Alice) – only one ordering appears.
  • Verify the count using the formula C(n, k) = n! / (k! × (n-k)!) or math.comb(5, 2).
▼ Solution & Explanation
from itertools import combinations
import math

players = ["Alice", "Bob", "Charlie", "Diana", "Eve"]

all_pairs = list(combinations(players, 2))

print(f"All 2-player pairs from {players}:\n")
for i, pair in enumerate(all_pairs, 1):
    print(f"  {i:2d}. {pair[0]} vs {pair[1]}")

print(f"\nTotal pairs: {len(all_pairs)}")
print(f"Verified by C(5,2) = math.comb(5,2): {math.comb(5, 2)}")

# Contrast with permutations count
from itertools import permutations
perm_count = len(list(permutations(players, 2)))
print(f"\nPermutations P(5,2): {perm_count}  (order matters - Alice-Bob and Bob-Alice counted separately)")
print(f"Combinations C(5,2): {len(all_pairs)}  (order ignored - Alice-Bob and Bob-Alice are the same pair)")
print(f"Ratio P/C = k! = {perm_count // len(all_pairs)}  (= 2! because each pair has 2 orderings)")Code language: Python (python)

Explanation:

  • combinations(iterable, r): Returns r-length tuples of elements chosen from the input in sorted order, without repetition. The key distinction from permutations() is that (Alice, Bob) and (Bob, Alice) are considered the same combination and only the lexicographically earlier one is yielded. This halves (or more) the output count compared to permutations.
  • Count formula: The number of r-combinations from n items is C(n, r) = n! / (r! × (n-r)!). For C(5, 2) this gives 10. The ratio of permutations to combinations is always r! – because each combination of r elements can be arranged in r! different orders, all of which permutations counts but combinations collapses into one.
  • Practical use: Any scheduling problem where A playing B is the same match as B playing A is a combinations problem. Round-robin tournament generation, feature pair analysis in machine learning, and pairwise comparison testing all use this pattern.

Exercise 3: Combinations With Replacement

Problem Statement: Write a Python program that uses itertools.combinations_with_replacement() to list all possible 2-topping pizza orders from 4 available toppings, including orders where the same topping is chosen twice.

Purpose: Combinations with replacement model selection problems where the same item can be chosen more than once and order still does not matter – such as choosing items from a menu, rolling dice, or sampling with replacement. This is a distinct combinatorial class from regular combinations and produces a larger result set.

Given Input: toppings = ["Cheese", "Pepperoni", "Mushrooms", "Olives"]

Expected Output: All 10 possible 2-topping orders including doubles like ('Cheese', 'Cheese'), followed by Total: 10

▼ Hint
  • Use combinations_with_replacement(toppings, 2) – this is similar to combinations() but allows the same element to appear more than once in a tuple.
  • Compare the output count with combinations(toppings, 2) to see how many extra entries the “with replacement” version adds.
  • The count formula for combinations with replacement is C(n+r-1, r). For n=4 toppings and r=2 choices: C(5, 2) = 10.
▼ Solution & Explanation
from itertools import combinations_with_replacement, combinations
import math

toppings = ["Cheese", "Pepperoni", "Mushrooms", "Olives"]

orders_with_rep    = list(combinations_with_replacement(toppings, 2))
orders_without_rep = list(combinations(toppings, 2))

print(f"Toppings: {toppings}\n")
print(f"All 2-topping orders (with replacement):")
for i, order in enumerate(orders_with_rep, 1):
    double = " (double topping)" if order[0] == order[1] else ""
    print(f"  {i:2d}. {order[0]} + {order[1]}{double}")

print(f"\nWith replacement    : {len(orders_with_rep)} orders")
print(f"Without replacement : {len(orders_without_rep)} orders")
print(f"Extra (double) orders added: {len(orders_with_rep) - len(orders_without_rep)}")

# Count formula verification: C(n+r-1, r)
n, r = len(toppings), 2
formula_count = math.comb(n + r - 1, r)
print(f"\nFormula C({n}+{r}-1, {r}) = C({n+r-1}, {r}) = {formula_count}")Code language: Python (python)

Explanation:

  • combinations_with_replacement(iterable, r): Returns r-length tuples where elements are chosen from the input and can repeat. The tuples are still produced in sorted order (same element can appear consecutively), so ('Cheese', 'Cheese') is valid but ('Pepperoni', 'Cheese') would not appear – only ('Cheese', 'Pepperoni') does. This maintains the “order does not matter” property while relaxing the “no repeats” constraint.
  • Count formula C(n+r-1, r): The number of r-combinations with replacement from n items is the stars-and-bars formula C(n+r-1, r). For 4 toppings and 2 choices this gives C(5, 2) = 10, which is 6 unique-topping pairs plus 4 double-topping orders (one for each topping paired with itself).
  • When to use which variant: Use combinations() when each item can only be selected once (team selection, card dealing). Use combinations_with_replacement() when selecting multiple units of the same item is allowed (ordering quantities of a product, choosing dice values, sampling with replacement).

Exercise 4: Cartesian Product

Problem Statement: Write a Python program that uses itertools.product() to generate all possible (size, colour) combinations for a product catalogue with 3 sizes and 4 colours, then prints each variant and the total count.

Purpose: The Cartesian product is one of the most practically useful combinatorial operations in software – it drives configuration matrix generation, test case expansion, SKU generation in e-commerce, grid coordinate enumeration, and nested loop replacement. itertools.product() computes it lazily and cleanly without nested for loops.

Given Input: sizes = ["S", "M", "L"] and colours = ["Red", "Blue", "Green", "Black"]

Expected Output: All 12 (size, colour) pairs printed in a table, followed by Total variants: 12

▼ Hint
  • Call product(sizes, colours) to get an iterator of (size, colour) tuples. The first iterable varies slowest (like the outer loop), the last varies fastest (like the inner loop).
  • The total count of the Cartesian product is always the product of the lengths of all input iterables: 3 × 4 = 12.
  • Try passing more than two iterables to product() to extend the pattern – for example, adding a materials list to generate 3-attribute variants.
▼ Solution & Explanation
from itertools import product
sizes   = ["S", "M", "L"]
colours = ["Red", "Blue", "Green", "Black"]
variants = list(product(sizes, colours))
print(f"Sizes   : {sizes}")
print(f"Colours : {colours}")
print(f"\nAll product variants ({len(sizes)} × {len(colours)} = {len(variants)}):")
print(f"{'#':>4}  {'Size':<6}  {'Colour'}")
print("-" * 22)
for i, (size, colour) in enumerate(variants, 1):
    print(f"{i:>4}  {size:<6}  {colour}")
print(f"\nTotal variants: {len(variants)}")
# Equivalent nested loop for comparison
print("\nEquivalent nested loop output (first 4 only):")
count = 0
for size in sizes:
    for colour in colours:
        print(f"  ({size}, {colour})")
        count += 1
        if count == 4:
            print("  ...")
            break
    if count >= 4:
        break
# Three-attribute example
materials = ["Cotton", "Polyester"]
three_attr = list(product(sizes, colours, materials))
print(f"\n3-attribute variants (sizes × colours × materials): {len(three_attr)}")Code language: Python (python)

Explanation:

  • product(*iterables): Returns the Cartesian product of the input iterables, equivalent to nested for loops over each one. The rightmost iterable advances first (like the innermost loop), so the output order mirrors the natural loop nesting order. Passing more than two iterables extends the pattern to any number of dimensions.
  • Nested loop replacement: Every nested for loop over independent iterables can be replaced with a single product() call. This flattens multi-level indentation into a single loop, improves readability, and makes the combinatorial intent explicit rather than implicit in loop structure.
  • Total count is multiplicative: The number of tuples in the output is always the product of the lengths of all input iterables. For three inputs of lengths a, b, and c the output has a × b × c tuples. This grows quickly – adding a third attribute of length 2 doubles the total from 12 to 24.

Exercise 5: Password Generator Using product()

Problem Statement: Write a Python program that uses itertools.product() with the repeat parameter to generate all possible 4-digit PIN codes from digits 0-9, then reports the total count and demonstrates how quickly the search space grows with length.

Purpose: The repeat argument of itertools.product() is the idiomatic way to model fixed-length sequences drawn from the same alphabet – PIN codes, passwords, binary strings, and DNA sequences. This exercise also builds intuition for exponential growth of search spaces, which is central to understanding password security and brute-force attack complexity.

Given Input: digits = "0123456789", pin_length = 4

Expected Output: Total 4-digit PINs: 10000, the first 5 and last 5 PINs, and a growth table showing search space size for lengths 1 through 6.

▼ Hint
  • Use product(digits, repeat=4) rather than product(digits, digits, digits, digits) – both are equivalent but the repeat keyword is cleaner for same-alphabet repetition.
  • Use itertools.islice() to safely preview the first few PINs without materialising the entire 10,000-element list.
  • The total number of sequences of length r from an alphabet of size n is n^r. For 10 digits and length 4 this is 10^4 = 10,000.
▼ Solution & Explanation
from itertools import product, islice

digits     = "0123456789"
pin_length = 4

# Generate all 4-digit PINs lazily
all_pins = product(digits, repeat=pin_length)

# Preview first 5 without materialising all 10,000
first_5 = ["".join(p) for p in islice(product(digits, repeat=pin_length), 5)]
print(f"First 5 PINs : {first_5}")

# Total count using exponentiation (no need to generate all)
total = len(digits) ** pin_length
print(f"Total {pin_length}-digit PINs: {total:,}")

# Last 5 PINs (must materialise to access the end)
all_pins_list = ["".join(p) for p in product(digits, repeat=pin_length)]
print(f"Last 5 PINs  : {all_pins_list[-5:]}")

# Search space growth table
print(f"\nSearch space growth (alphabet size = {len(digits)}):")
print(f"{'Length':>8} | {'Total PINs':>14} | {'vs previous':>12}")
print("-" * 40)
prev = 1
for length in range(1, 7):
    count = len(digits) ** length
    factor = count // prev
    print(f"{length:>8} | {count:>14,} | {factor:>10}×")
    prev = countCode language: Python (python)

Explanation:

  • product(iterable, repeat=n): The repeat keyword is shorthand for passing the same iterable n times. product("AB", repeat=3) is identical to product("AB", "AB", "AB") and produces all 8 three-character binary strings. It is the correct tool whenever you need all fixed-length sequences drawn from a single character set.
  • islice(iterator, n): Consumes only the first n elements from an iterator without forcing the rest to be generated. This is essential when working with large or infinite generators – materialising all 10,000 PINs into a list is acceptable for this example, but for a 10-character password space (10^10 = 10 billion entries) only lazy access via islice is feasible.
  • Exponential growth: Each additional character multiplies the search space by the alphabet size. Going from a 4-digit PIN (10,000 combinations) to a 6-digit PIN (1,000,000) multiplies the work by 100. This exponential relationship is why longer passwords with larger character sets are dramatically harder to brute-force, even with fast hardware.

Exercise 6: Chain Multiple Iterables

Problem Statement: Write a Python program that uses itertools.chain() to merge three separate lists of department employees into a single iterable, then prints all names and the total headcount without creating an intermediate combined list.

Purpose: Chaining iterables is one of the most common operations in data processing pipelines – merging results from multiple database queries, combining log files from different servers, or aggregating records from several CSV files. itertools.chain() does this lazily, forwarding elements from each source in sequence without copying data into a new list.

Given Input: Three department lists: engineering = ["Alice", "Bob", "Charlie"], marketing = ["Diana", "Eve"], hr = ["Frank", "Grace", "Heidi"]

Expected Output: All 8 names printed in order department by department, followed by Total employees: 8

▼ Hint
  • Pass all lists as separate arguments to chain(engineering, marketing, hr) to get a single iterator that yields all elements in sequence.
  • Use chain.from_iterable(list_of_lists) when the number of iterables is unknown at write time or when they are stored in a collection such as a list of lists.
  • Count the total with sum(1 for _ in chain(...)) or materialise to a list and use len() – but note that consuming the iterator once exhausts it, so you may need to create it again for a second pass.
▼ Solution & Explanation
from itertools import chain

engineering = ["Alice", "Bob", "Charlie"]
marketing   = ["Diana", "Eve"]
hr          = ["Frank", "Grace", "Heidi"]

# chain() merges iterables lazily - no new list created
all_employees = list(chain(engineering, marketing, hr))

print("All employees (chained):")
for name in all_employees:
    print(f"  {name}")

print(f"\nTotal employees: {len(all_employees)}")

# chain.from_iterable: when the lists are inside a collection
departments = [engineering, marketing, hr]
all_from_iterable = list(chain.from_iterable(departments))
print(f"\nUsing chain.from_iterable: {all_from_iterable}")
print(f"Results match: {all_employees == all_from_iterable}")

# Contrast with list concatenation (creates a new list in memory)
combined_list = engineering + marketing + hr
print(f"\nList concatenation result : {combined_list}")
print(f"chain is lazy (no copy)   : True  |  '+' always copies: True")Code language: Python (python)

Explanation:

  • chain(*iterables): Returns a single iterator that yields elements from the first iterable until it is exhausted, then moves to the second, and so on. No new list is allocated – the original sources are read one at a time. This makes chain() significantly more memory-efficient than the + operator when working with large collections.
  • chain.from_iterable(iterable_of_iterables): An alternative constructor useful when the iterables are stored inside a container rather than known individually at write time. For example, chain.from_iterable(departments) lazily flattens a list of lists without requiring * unpacking, which becomes important when the outer list is itself a generator.
  • Iterator exhaustion: A chain() iterator can only be consumed once. Once all elements have been yielded, subsequent iteration returns nothing. If you need to iterate over the chained result multiple times, materialise it to a list first with list(chain(...)), accepting the one-time memory cost in exchange for reusability.

Exercise 7: Infinite Counter with islice

Problem Statement: Write a Python program that uses itertools.count() to create an infinite counter starting at 100 with a step of 5, then uses itertools.islice() to safely extract the first 8 values and print them.

Purpose: Infinite iterators model sequences that have no predetermined end – ID generators, timestamp sequences, mathematical series, and event numbering systems. itertools.count() paired with itertools.islice() is the canonical Python pattern for safely consuming a bounded slice of an infinite stream without risk of an infinite loop.

Given Input: start = 100, step = 5, extract n = 8 values.

Expected Output: [100, 105, 110, 115, 120, 125, 130, 135]

▼ Hint
  • Create the infinite counter with count(start=100, step=5). Never iterate over it directly with a bare for loop – always pair it with a termination mechanism.
  • Wrap it in islice(counter, 8) to cap the output at 8 elements. islice(iterable, stop) or islice(iterable, start, stop, step) mirrors Python slice syntax but works on any iterator.
  • You can also use takewhile(lambda x: x < 140, count(100, 5)) as an alternative condition-based stopping mechanism.
▼ Solution & Explanation
from itertools import count, islice, takewhile
# Infinite counter: start=100, step=5
counter = count(start=100, step=5)
# Safe extraction using islice
first_8 = list(islice(counter, 8))
print(f"First 8 values (start=100, step=5): {first_8}")
# islice with start, stop, step arguments
counter2  = count(start=0, step=1)
every_3rd = list(islice(counter2, 0, 15, 3))
print(f"Every 3rd from 0 (islice step=3)  : {every_3rd}")
# Alternative: takewhile for condition-based stopping
counter3    = count(start=100, step=5)
below_140   = list(takewhile(lambda x: x < 140, counter3))
print(f"Values below 140                   : {below_140}")
# Practical use: generating unique sequential IDs
id_gen = count(start=1001)
new_ids = [next(id_gen) for _ in range(5)]
print(f"\nGenerated IDs : {new_ids}")
# Float step support
float_counter = list(islice(count(0.0, 0.25), 6))
print(f"Float counter (step=0.25): {float_counter}")Code language: Python (python)

Explanation:

  • count(start=0, step=1): Produces an infinite sequence of evenly spaced values beginning at start. Unlike range(), it has no stop argument and never terminates on its own. It supports float steps, making it useful for generating evenly spaced grid values or time ticks without floating-point accumulation errors from repeated addition.
  • islice(iterable, stop): Consumes at most stop elements from the iterator and then stops, without reading further. The full form islice(iterable, start, stop, step) mirrors Python list slicing syntax but applies to any iterator – including infinite ones. It is the safest general-purpose way to cap consumption from an unbounded source.
  • next(id_gen): Calling next() on a count() object advances it by one step and returns the current value. This pattern is used to generate monotonically increasing IDs on demand, similar to an auto-increment database sequence, without pre-allocating a range.

Exercise 8: Cycle Through a List

Problem Statement: Write a Python program that uses itertools.cycle() to rotate through a list of three weekday shift labels and assigns one to each of 10 employees in order, wrapping back to the start when the labels run out.

Purpose: Cyclic assignment is common in scheduling (round-robin task allocation), load balancing (distributing requests across servers), and UI patterns (alternating row colours in tables). itertools.cycle() handles the wrap-around automatically, eliminating the manual modulo arithmetic that would otherwise be needed.

Given Input: shifts = ["Morning", "Afternoon", "Night"] and employees = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy"]

Expected Output: Each employee printed with their assigned shift, cycling through Morning, Afternoon, Night and repeating.

▼ Hint
  • Create the cycle with cycle(shifts) and pair it with the employees list using zip(employees, cycle(shifts)). The zip() stops at the shorter iterable (the employees list), safely capping the infinite cycle.
  • Never iterate over a bare cycle() without a termination mechanism – it will loop forever.
  • Use islice(cycle(shifts), n) as an alternative when you want exactly n items from the cycle rather than pairing with another iterable.
▼ Solution & Explanation
from itertools import cycle, islice
shifts    = ["Morning", "Afternoon", "Night"]
employees = ["Alice", "Bob", "Charlie", "Diana", "Eve",
             "Frank", "Grace", "Heidi", "Ivan", "Judy"]
# zip() terminates at the shorter iterable (employees list)
schedule = list(zip(employees, cycle(shifts)))
print("Shift Schedule:")
print(f"{'Employee':<12}  {'Shift'}")
print("-" * 24)
for employee, shift in schedule:
    print(f"  {employee:<10}  {shift}")
# Alternative: islice to get exactly n cycle values
n_items = 7
cycled_labels = list(islice(cycle(shifts), n_items))
print(f"\nFirst {n_items} shift labels (islice): {cycled_labels}")
# Practical: alternating row styles for a table
rows = ["Row A", "Row B", "Row C", "Row D", "Row E"]
styles = cycle(["light", "dark"])
print(f"\nAlternating table rows:")
for row, style in zip(rows, styles):
    print(f"  {row}: {style}")Code language: Python (python)

Explanation:

  • cycle(iterable): Returns an infinite iterator that repeatedly yields the elements of the input in order, starting over from the beginning each time the end is reached. It stores a copy of the input internally so it can replay it – meaning that unlike count(), it does consume memory proportional to the length of the input iterable.
  • zip(employees, cycle(shifts)): Pairing a finite iterable with an infinite one via zip() is the idiomatic way to safely consume a cycle. Python’s zip() stops as soon as the shortest iterable is exhausted – the finite employees list – so the infinite cycle is never read beyond what is needed.
  • Manual modulo alternative: Without cycle(), the same assignment would require shifts[i % len(shifts)] inside a loop. The cycle() approach is more readable, avoids index arithmetic entirely, and composes cleanly with other itertools functions like islice() and zip().

Exercise 9: Repeat a Value

Problem Statement: Write a Python program that uses itertools.repeat() to pair a fixed discount rate with each item in a product list using zip(), then applies the discount to compute each item’s sale price.

Purpose: itertools.repeat() provides a clean, expressive way to broadcast a single constant value across an iterable, commonly used with map(), zip(), and starmap(). It avoids creating a wasteful list of repeated values while making the intent – “apply this fixed parameter to every element” – immediately obvious.

Given Input: products = [("Laptop", 999.99), ("Mouse", 29.99), ("Keyboard", 79.99), ("Monitor", 349.99)] and discount_rate = 0.15

Expected Output: Each product printed with its original price, discount amount, and final sale price.

▼ Hint
  • Use repeat(discount_rate, len(products)) to generate exactly as many copies of the rate as there are products, then zip() them together.
  • Alternatively, use repeat(discount_rate) without a count and let zip() with the finite products list stop it naturally.
  • Use map(func, iterable, repeat(constant)) as a pattern for applying a two-argument function where the second argument is always the same value.
▼ Solution & Explanation
from itertools import repeat
products = [
    ("Laptop",   999.99),
    ("Mouse",     29.99),
    ("Keyboard",  79.99),
    ("Monitor",  349.99),
]
discount_rate = 0.15
# Pair each product with the fixed discount rate
print(f"Discount rate: {discount_rate * 100:.0f}%\n")
print(f"{'Product':<12}  {'Original':>10}  {'Discount':>10}  {'Sale Price':>10}")
print("-" * 48)
for (name, price), rate in zip(products, repeat(discount_rate)):
    discount   = price * rate
    sale_price = price - discount
    print(f"  {name:<10}  £{price:>9.2f}  £{discount:>9.2f}  £{sale_price:>9.2f}")
# map() with repeat: apply pow(price, exponent) to each price
import math
prices = [p for _, p in products]
log_prices = list(map(math.log, prices, repeat(10)))   # log base 10 of each price
print(f"\nlog10 of each price:")
for (name, _), lp in zip(products, log_prices):
    print(f"  {name:<10}: {lp:.4f}")
# repeat with a fixed count
fixed_repeat = list(repeat("N/A", 3))
print(f"\nrepeat('N/A', 3): {fixed_repeat}")Code language: Python (python)

Explanation:

  • repeat(object, times=None): Returns an iterator that yields the same object indefinitely when times is omitted, or exactly times times when specified. Because it yields a reference to the same object rather than copies, it uses O(1) memory regardless of the repeat count – far more efficient than a list comprehension like [rate] * n.
  • map(func, iterable, repeat(constant)): A powerful pattern for applying a two-argument function where one argument is fixed. map(math.log, prices, repeat(10)) computes math.log(price, 10) for every price without writing a lambda. This idiom is common in functional-style code where broadcasting a scalar over a vector is needed.
  • Finite vs infinite repeat: repeat(x) is infinite and must be paired with a finite iterable in zip() or capped with islice(). repeat(x, n) is finite and safe to materialise directly. Choose the finite form when you know the count upfront; choose the infinite form when pairing with another iterable of unknown length.

Exercise 10: takewhile and dropwhile

Problem Statement: Write a Python program that uses itertools.takewhile() to collect temperature readings while they stay below 30°C, then uses itertools.dropwhile() to skip all readings below 30°C and return the remainder of the sequence.

Purpose: takewhile() and dropwhile() model prefix-based filtering – operating on the leading portion of a sequence rather than all matching elements. They are used in log parsing (read lines until a sentinel), data stream processing (discard the warm-up period), and lazy evaluation of sorted data (stop as soon as a condition breaks). Understanding that they stop at the first failure, not at every failure, is the key conceptual distinction from filter().

Given Input: temperatures = [18.2, 21.5, 24.8, 27.1, 29.6, 31.3, 28.4, 33.7, 30.1, 26.5]

Expected Output: takewhile (below 30): [18.2, 21.5, 24.8, 27.1, 29.6] and dropwhile (skip below 30): [31.3, 28.4, 33.7, 30.1, 26.5]

▼ Hint
  • Use takewhile(lambda x: x < 30, temperatures) to yield elements while the condition is True. It stops permanently at the first element where the condition becomes False.
  • Use dropwhile(lambda x: x < 30, temperatures) to skip elements while the condition is True, then yield all remaining elements once the condition first becomes False – even those that would satisfy the condition again later.
  • Note that together, takewhile and dropwhile partition the sequence at the first point where the condition breaks. Concatenating their outputs via chain() recovers the original sequence.
▼ Solution & Explanation
from itertools import takewhile, dropwhile, chain
temperatures = [18.2, 21.5, 24.8, 27.1, 29.6, 31.3, 28.4, 33.7, 30.1, 26.5]
threshold    = 30.0
below_30 = list(takewhile(lambda x: x < threshold, temperatures))
from_hot = list(dropwhile(lambda x: x < threshold, temperatures))
print(f"Full sequence              : {temperatures}")
print(f"\ntakewhile (below {threshold}°C)  : {below_30}")
print(f"dropwhile (skip below {threshold}°C): {from_hot}")
# Verify: chain of both halves recovers the original
recovered = list(chain(below_30, from_hot))
print(f"\nchain(takewhile, dropwhile): {recovered}")
print(f"Matches original           : {recovered == temperatures}")
# Contrast with filter(): filter checks EVERY element, not just the prefix
filtered = list(filter(lambda x: x < threshold, temperatures))
print(f"\nfilter(< {threshold})          : {filtered}")
print("Note: filter() finds 26.5 at the end; takewhile() stops at 31.3 and misses it.")
# Practical: skip log header lines then process entries
log_lines = ["# header", "# generated 2025-01-01", "INFO starting", "ERROR failed", "INFO done"]
data_lines = list(dropwhile(lambda line: line.startswith("#"), log_lines))
print(f"\nLog after skipping headers : {data_lines}")Code language: Python (python)

Explanation:

  • takewhile(predicate, iterable): Yields elements as long as the predicate returns True. The moment the predicate returns False for any element, iteration stops immediately and permanently – even if later elements would have satisfied the predicate. This is fundamentally different from filter(), which tests every element independently.
  • dropwhile(predicate, iterable): Skips elements as long as the predicate returns True. Once the predicate first returns False, all subsequent elements are yielded without further testing – including those that would satisfy the predicate again. In the temperature example, 26.5 at the end is below 30 but is still included because the drop phase ended at 31.3.
  • Partitioning property: takewhile and dropwhile with the same predicate split the sequence at its first predicate-breaking point, so chain(takewhile(pred, seq), dropwhile(pred, seq)) always reconstructs the original sequence exactly. This makes them complementary tools for splitting a sequence around a structural boundary such as a header section or a warm-up period.

Exercise 11: Group Consecutive Items

Problem Statement: Write a Python program that uses itertools.groupby() to group a sorted list of transactions by category, then prints each group’s category, item count, and individual items.

Purpose: Grouping consecutive elements by a shared key is a core operation in data processing pipelines – aggregating log entries by severity, summarising sales by region, or batching database records by status. itertools.groupby() provides this lazily without building intermediate dictionaries, making it ideal for streaming or large sorted datasets.

Given Input: transactions = [("Food", 12.50), ("Food", 8.30), ("Travel", 45.00), ("Travel", 22.10), ("Travel", 15.75), ("Food", 6.90), ("Tech", 299.99)]

Expected Output: Each category printed as a heading with its items listed beneath, followed by the item count and subtotal for that group.

▼ Hint
  • Critical rule: groupby() groups only consecutive identical keys – it does not sort the data. Always sort by the grouping key first with sorted(data, key=...) before passing to groupby().
  • Call groupby(sorted_data, key=lambda x: x[0]) to group by the first element of each tuple. The function yields (key, group_iterator) pairs.
  • Materialise each group iterator into a list immediately inside the loop – the group iterator is invalidated as soon as groupby() advances to the next key.
▼ Solution & Explanation
from itertools import groupby

transactions = [
    ("Food",   12.50), ("Food",    8.30), ("Travel", 45.00),
    ("Travel", 22.10), ("Travel", 15.75), ("Food",    6.90),
    ("Tech",  299.99),
]

# MUST sort before groupby - it only groups consecutive identical keys
sorted_txns = sorted(transactions, key=lambda x: x[0])

print("Transactions grouped by category:\n")
for category, group in groupby(sorted_txns, key=lambda x: x[0]):
    items    = list(group)   # materialise immediately before iterator advances
    subtotal = sum(amount for _, amount in items)
    print(f"  {category} ({len(items)} items, subtotal: £{subtotal:.2f})")
    for _, amount in items:
        print(f"    £{amount:.2f}")

# Demonstrate the danger of NOT sorting first
print("\nWithout sorting (incorrect grouping):")
for category, group in groupby(transactions, key=lambda x: x[0]):
    items = list(group)
    print(f"  {category}: {len(items)} item(s)")Code language: Python (python)

Explanation:

  • groupby(iterable, key): Returns consecutive (key, group_iterator) pairs where each group contains all consecutive elements that share the same key value. It is a streaming operation – it does not look ahead or back, so only adjacent elements with the same key are grouped together. This is intentional and efficient, but it means the data must be pre-sorted on the grouping key.
  • Materialise group immediately: The group iterator returned by groupby() shares an internal pointer with the parent iterator. As soon as groupby() advances to the next key, the previous group iterator is exhausted and returns nothing. Wrapping it in list(group) immediately inside the loop prevents this silent data loss.
  • Without sorting: The output of the unsorted example in the code shows “Food” appearing twice as a separate group because it is interrupted by “Travel”. This is the most common bug when using groupby() and it produces no error – just silently incorrect groups – making the pre-sort requirement essential to understand and remember.

Exercise 12: Compress a Sequence

Problem Statement: Write a Python program that uses itertools.compress() to filter a list of product names using a parallel boolean selector list, keeping only the products whose corresponding selector value is truthy.

Purpose: itertools.compress() is the idiomatic tool for mask-based filtering – selecting elements at positions where a parallel boolean array is True. This pattern appears in data science (applying a boolean mask to a sequence), feature selection (choosing columns based on a selector array), and configuration-driven visibility toggles.

Given Input: products = ["Laptop", "Mouse", "Keyboard", "Monitor", "Headphones", "Webcam"] and in_stock = [1, 0, 1, 1, 0, 1]

Expected Output: Available products: ['Laptop', 'Keyboard', 'Monitor', 'Webcam']

▼ Hint
  • Call compress(products, in_stock) – it yields each element from products whose corresponding value in in_stock is truthy (True, 1, or any non-zero/non-empty value).
  • If the selector list is shorter than the data list, compress() stops at the end of the selector – not at the end of the data.
  • Generate the selector dynamically using a condition: [price > 50 for _, price in products_with_prices] to select products above a price threshold.
▼ Solution & Explanation
from itertools import compress

products  = ["Laptop", "Mouse", "Keyboard", "Monitor", "Headphones", "Webcam"]
in_stock  = [1, 0, 1, 1, 0, 1]

available = list(compress(products, in_stock))
print(f"All products  : {products}")
print(f"In stock mask : {in_stock}")
print(f"Available     : {available}")

# Out-of-stock items (inverted selector)
out_of_stock = list(compress(products, [not s for s in in_stock]))
print(f"Out of stock  : {out_of_stock}")

# Dynamic selector: products with price above threshold
products_with_prices = [
    ("Laptop", 999.99), ("Mouse", 29.99), ("Keyboard", 79.99),
    ("Monitor", 349.99), ("Headphones", 49.99), ("Webcam", 89.99),
]
threshold = 75.0
selector  = [price > threshold for _, price in products_with_prices]
names     = [name for name, _ in products_with_prices]

premium = list(compress(names, selector))
print(f"\nProducts above £{threshold}: {premium}")
print(f"Selector used            : {selector}")Code language: Python (python)

Explanation:

  • compress(data, selectors): Returns an iterator that yields elements from data at positions where the corresponding selectors value is truthy. It stops as soon as either iterable is exhausted. Selector values can be any type – integers, booleans, strings, or objects – as long as their truthiness is well-defined.
  • Inverted selector: To get the complement (out-of-stock items), negate the selector with a list comprehension: [not s for s in in_stock]. This mirrors how NumPy boolean array negation works with ~mask, making compress() the pure-Python equivalent of mask-based indexing.
  • Dynamic selectors: The selector does not need to be a static list – it can be any iterable, including a generator expression computed from the data itself. This makes compress() flexible for condition-driven filtering where the keep-or-discard decision is computed at runtime rather than hardcoded.

Exercise 13: Accumulate Running Totals

Problem Statement: Write a Python program that uses itertools.accumulate() to compute the running total of monthly sales figures, then uses it again with operator.mul to compute a running product, and finally uses it with a custom function to track the running maximum.

Purpose: Running aggregations – cumulative sums, products, maximums, and minimums – appear in financial balance tracking, inventory management, sports standings, and time-series analysis. itertools.accumulate() computes them lazily in a single pass, and its support for custom binary functions makes it a general-purpose tool far beyond simple summation.

Given Input: monthly_sales = [12000, 15400, 9800, 18200, 21000, 14300, 16700]

Expected Output: Running total, running product (of growth factors), and running maximum printed side by side for each month.

▼ Hint
  • accumulate(data) defaults to addition. Pass operator.mul as the second argument for running products, or pass any two-argument function.
  • Use accumulate(data, max) to compute the running maximum – the built-in max function works as a binary function here.
  • Pass an initial keyword argument (Python 3.8+) to prepend a starting value before the first element: accumulate(data, initial=0).
▼ Solution & Explanation
from itertools import accumulate
import operator
monthly_sales = [12000, 15400, 9800, 18200, 21000, 14300, 16700]
months        = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"]
running_total   = list(accumulate(monthly_sales))
running_max     = list(accumulate(monthly_sales, max))
running_min     = list(accumulate(monthly_sales, min))
print(f"{'Month':<6}  {'Sales':>8}  {'Cumulative':>12}  {'Running Max':>12}  {'Running Min':>12}")
print("-" * 58)
for month, sales, total, rmax, rmin in zip(months, monthly_sales, running_total, running_max, running_min):
    print(f"{month:<6}  £{sales:>7,}  £{total:>11,}  £{rmax:>11,}  £{rmin:>11,}")
# Running product using operator.mul (on small growth factors)
growth_factors = [1.05, 0.98, 1.12, 1.03, 0.97, 1.08, 1.01]
running_product = list(accumulate(growth_factors, operator.mul))
print(f"\nRunning compound growth:")
for month, factor, product in zip(months, growth_factors, running_product):
    print(f"  {month}: factor={factor:.2f}  cumulative={product:.4f}  ({(product - 1)*100:+.2f}%)")
# initial keyword (Python 3.8+): prepend a starting value
with_initial = list(accumulate(monthly_sales[:3], initial=0))
print(f"\nWith initial=0: {with_initial}")Code language: Python (python)

Explanation:

  • accumulate(iterable, func=operator.add, initial=None): Applies a two-argument binary function cumulatively across the iterable, yielding each intermediate result. The default function is addition, making it a running sum by default. Any associative binary function can be substituted – operator.mul, max, min, or a custom lambda.
  • Built-in functions as binary operators: Python’s built-in max(a, b) and min(a, b) accept two arguments and return the larger or smaller value, making them directly usable as the accumulation function. accumulate(data, max) therefore computes the running maximum – the largest value seen up to each position – without any helper code.
  • initial keyword argument: When provided, initial is prepended as the first output value before any element of the iterable is processed. This shifts the output by one position – the result list has length len(data) + 1 rather than len(data). It is useful when the accumulation needs a well-defined starting state, such as a balance account starting at 0.

Exercise 14: Reduce to a Single Value

Problem Statement: Write a Python program that uses functools.reduce() to compute the product of all integers in a list without using a loop, math.prod(), or any other built-in aggregator, then extends it to find the maximum value and build a flattened list from nested lists.

Purpose: functools.reduce() is the fundamental higher-order function for collapsing a sequence into a single value by repeatedly applying a binary function. Understanding it builds the mental model for fold operations that underpin MapReduce, aggregate pipelines, and functional programming patterns across all languages.

Given Input: numbers = [2, 3, 4, 5, 6]

Expected Output: Product: 720, verified against math.prod(), plus demonstrations of custom reduce operations.

▼ Hint
  • Import reduce from functools and call reduce(lambda a, b: a * b, numbers) to multiply all elements together.
  • Pass an optional third argument as the initial value: reduce(func, iterable, initializer). This is returned unchanged if the iterable is empty, preventing a TypeError on empty input.
  • Use operator.mul instead of a lambda for the multiplication case – it is faster and more readable: reduce(operator.mul, numbers).
▼ Solution & Explanation
from functools import reduce
import operator
import math

numbers = [2, 3, 4, 5, 6]

# Product using reduce with operator.mul
product = reduce(operator.mul, numbers)
print(f"Numbers          : {numbers}")
print(f"Product (reduce) : {product}")
print(f"math.prod verify : {math.prod(numbers)}")
print(f"Results match    : {product == math.prod(numbers)}")

# Maximum value using reduce
maximum = reduce(lambda a, b: a if a > b else b, numbers)
print(f"\nMaximum (reduce) : {maximum}  (built-in max: {max(numbers)})")

# String concatenation using reduce
words = ["Python", " is", " powerful"]
sentence = reduce(lambda a, b: a + b, words)
print(f"\nConcatenated     : '{sentence}'")

# Flatten nested lists using reduce
nested = [[1, 2], [3, 4], [5, 6], [7]]
flat = reduce(lambda acc, lst: acc + lst, nested, [])
print(f"\nNested lists     : {nested}")
print(f"Flattened        : {flat}")

# initializer prevents TypeError on empty input
empty_product = reduce(operator.mul, [], 1)
print(f"\nreduce on empty list (initializer=1): {empty_product}")Code language: Python (python)

Explanation:

  • reduce(function, iterable, initializer): Applies function to the first two elements, then applies it to that result and the third element, and so on until a single value remains. The optional initializer is placed before the iterable’s first element and is returned unchanged if the iterable is empty, making the call safe for empty inputs.
  • operator.mul vs lambda: operator.mul is a pre-compiled C-level function that performs multiplication. Using it instead of lambda a, b: a * b is both faster and more readable, since the name clearly states the operation. The operator module provides equivalents for all Python operators: operator.add, operator.sub, operator.truediv, and so on.
  • When to use reduce: Prefer built-ins (sum(), max(), math.prod()) when they exist – they are more readable and often faster. Use reduce() for custom aggregations that have no built-in equivalent, such as flattening nested lists, building a running intersection of sets, or composing a chain of functions.

Exercise 15: Partial Functions

Problem Statement: Write a Python program that uses functools.partial() to create a specialised power_of_2() function from the built-in pow() function, then applies it to a list of exponents and compares the result with equivalent lambda and loop approaches.

Purpose: Partial application creates a new function by pre-filling one or more arguments of an existing function, reducing its arity. It is a cornerstone of functional programming that enables function reuse, cleaner callbacks, and more expressive APIs – particularly when working with map(), filter(), and event handlers where you need a single-argument callable but the underlying function requires more.

Given Input: exponents = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Expected Output: Powers of 2 for each exponent: [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]

▼ Hint
  • Create the partial with power_of_2 = partial(pow, 2). This pre-fills the first argument of pow(base, exp) with 2, leaving exp as the only remaining argument.
  • Use list(map(power_of_2, exponents)) to apply the partial function across the exponents list in a single expression.
  • Use keyword arguments with partial to pre-fill non-positional parameters: partial(print, end="\n\n") creates a print function that always double-spaces.
▼ Solution & Explanation
from functools import partial

exponents = list(range(11))   # [0, 1, 2, ..., 10]

# Create specialised functions via partial
power_of_2  = partial(pow, 2)
power_of_3  = partial(pow, 3)
square      = partial(pow, base=None)   # illustrative - see note below

# Apply using map()
powers_of_2 = list(map(power_of_2, exponents))
powers_of_3 = list(map(power_of_3, exponents))

print(f"Exponents   : {exponents}")
print(f"Powers of 2 : {powers_of_2}")
print(f"Powers of 3 : {powers_of_3}")

# Partial with keyword arguments
def greet(name, greeting="Hello", punctuation="!"):
    return f"{greeting}, {name}{punctuation}"

formal_greet   = partial(greet, greeting="Good morning", punctuation=".")
casual_greet   = partial(greet, greeting="Hey")

names = ["Alice", "Bob", "Charlie"]
print(f"\nFormal greetings:")
for name in names:
    print(f"  {formal_greet(name)}")

print(f"\nCasual greetings:")
for name in names:
    print(f"  {casual_greet(name)}")

# Inspect the partial object
print(f"\npartial object info:")
print(f"  power_of_2.func  : {power_of_2.func}")
print(f"  power_of_2.args  : {power_of_2.args}")
print(f"  power_of_2.keywords: {power_of_2.keywords}")Code language: Python (python)

Explanation:

  • partial(func, *args, **kwargs): Returns a new callable with the given positional and keyword arguments pre-filled. When the partial is called, the pre-filled arguments are prepended to any additional positional arguments provided at call time. The result behaves exactly like calling the original function with all arguments combined.
  • Partial object introspection: A partial object exposes three attributes: .func (the wrapped function), .args (the pre-filled positional arguments), and .keywords (the pre-filled keyword arguments). This is useful for debugging, logging, and building tools that inspect or serialise callable objects.
  • Partial vs lambda: Both partial(pow, 2) and lambda exp: pow(2, exp) create a single-argument callable. partial is preferred when the function and its fixed arguments are all you need – it is more explicit, introspectable, and avoids the closure pitfalls that lambdas can introduce in loop bodies.

Exercise 16: Memoisation with lru_cache

Problem Statement: Write a Python program that applies functools.lru_cache() to a recursive Fibonacci function, then compares its execution time and call count against the uncached version to quantify the speedup.

Purpose: Memoisation with lru_cache is one of the most impactful single-line optimisations available in Python. It transforms exponential-time recursive functions into linear-time ones by caching results of previous calls. Understanding it is essential for dynamic programming, recursive tree traversal, and any scenario where the same subproblem is solved multiple times.

Given Input: Compute fibonacci(35) with and without caching.

Expected Output: The Fibonacci result, execution times for both versions, and cache statistics from cache_info().

▼ Hint
  • Decorate the cached function with @lru_cache(maxsize=None) for an unbounded cache, or @lru_cache(maxsize=128) to limit memory use (the LRU eviction policy discards the least recently used entry when full).
  • Use time.perf_counter() before and after each call to measure elapsed time in seconds.
  • Call fib_cached.cache_info() after the computation to inspect hits, misses, current size, and maximum size of the cache.
▼ Solution & Explanation
from functools import lru_cache
import time
# Uncached recursive Fibonacci - O(2^n) time complexity
call_count_uncached = 0
def fib_uncached(n):
    global call_count_uncached
    call_count_uncached += 1
    if n <= 1:
        return n
    return fib_uncached(n - 1) + fib_uncached(n - 2)
# Cached recursive Fibonacci - O(n) time complexity
@lru_cache(maxsize=None)
def fib_cached(n):
    if n <= 1:
        return n
    return fib_cached(n - 1) + fib_cached(n - 2)
n = 35
# Time the uncached version
start = time.perf_counter()
result_uncached = fib_uncached(n)
time_uncached   = time.perf_counter() - start
# Time the cached version
start = time.perf_counter()
result_cached = fib_cached(n)
time_cached   = time.perf_counter() - start
print(f"fibonacci({n}) = {result_cached}")
print(f"\nUncached:")
print(f"  Time       : {time_uncached:.4f}s")
print(f"  Calls made : {call_count_uncached:,}")
print(f"\nCached:")
print(f"  Time       : {time_cached:.6f}s")
info = fib_cached.cache_info()
print(f"  Cache hits : {info.hits}")
print(f"  Cache miss : {info.misses}")
print(f"  Cache size : {info.currsize}")
speedup = time_uncached / time_cached if time_cached > 0 else float("inf")
print(f"\nSpeedup     : ~{speedup:.0f}x")
# Cache management
fib_cached.cache_clear()
print(f"\nAfter cache_clear(): {fib_cached.cache_info()}")Code language: Python (python)

Explanation:

  • @lru_cache(maxsize=None): Decorates a function so that its return values are stored in a dictionary keyed by the arguments. On subsequent calls with the same arguments, the cached value is returned immediately without re-executing the function body. maxsize=None creates an unbounded cache. Using @cache (Python 3.9+) is equivalent and slightly faster than @lru_cache(maxsize=None).
  • Exponential to linear: The uncached fib(35) makes 29,860,703 recursive calls because every call recomputes all subproblems from scratch. The cached version makes only 36 calls (one per unique n from 0 to 35) because each result is stored and reused. This transforms the time complexity from O(2^n) to O(n).
  • cache_info() and cache_clear(): cache_info() returns a named tuple with hits (how many calls were served from cache), misses (how many required actual computation), maxsize, and currsize. cache_clear() empties the cache entirely, useful in tests or when cached data may have become stale.

Exercise 17: Custom Sorting with cmp_to_key

Problem Statement: Write a Python program that uses functools.cmp_to_key() to sort a list of software version strings (e.g. "2.10.1") using a custom comparator function that correctly orders them numerically rather than lexicographically.

Purpose: Python’s sorted() accepts a key function but not a comparator (compare) function. cmp_to_key() bridges this gap by wrapping a comparator – which returns negative, zero, or positive – into a key object that Python’s sort algorithm can use. This is essential when the ordering logic is inherently pairwise and cannot be expressed as a simple key extraction.

Given Input: versions = ["1.10.2", "1.9.1", "2.0.0", "1.10.1", "1.2.3", "2.1.0", "1.10.10"]

Expected Output: Versions sorted correctly in ascending order: 1.2.3, 1.9.1, 1.10.1, 1.10.2, 1.10.10, 2.0.0, 2.1.0

▼ Hint
  • A comparator function takes two arguments and returns a negative number if the first is less, zero if equal, and a positive number if the first is greater.
  • Split each version string by "." and convert the parts to integers before comparing to avoid the lexicographic pitfall where "10" < "9" as strings.
  • Wrap the comparator with cmp_to_key(comparator) and pass it as the key argument to sorted().
▼ Solution & Explanation
from functools import cmp_to_key
versions = ["1.10.2", "1.9.1", "2.0.0", "1.10.1",
            "1.2.3",  "2.1.0", "1.10.10"]
# Lexicographic sort (WRONG for version numbers)
lex_sorted = sorted(versions)
print(f"Lexicographic (wrong) : {lex_sorted}")
# Custom comparator: split on '.' and compare as integer tuples
def version_comparator(v1, v2):
    parts1 = [int(x) for x in v1.split(".")]
    parts2 = [int(x) for x in v2.split(".")]
    if parts1 < parts2:
        return -1
    if parts1 > parts2:
        return 1
    return 0
correct_sorted = sorted(versions, key=cmp_to_key(version_comparator))
print(f"Numeric (correct)     : {correct_sorted}")
# Reverse order (newest first)
desc_sorted = sorted(versions, key=cmp_to_key(version_comparator), reverse=True)
print(f"Descending            : {desc_sorted}")
# Simpler alternative using a key function (preferred when possible)
def version_key(v):
    return tuple(int(x) for x in v.split("."))
key_sorted = sorted(versions, key=version_key)
print(f"\nKey function (simpler): {key_sorted}")
print(f"Both approaches match : {correct_sorted == key_sorted}")Code language: Python (python)

Explanation:

  • cmp_to_key(comparator): Wraps a two-argument comparator function into a key class whose instances support <, >, and == comparisons by calling the original comparator. Python’s sorted() and list.sort() removed direct comparator support in Python 3 (it existed in Python 2 as the cmp argument), so cmp_to_key() is the compatibility bridge for pairwise ordering logic.
  • Why lexicographic sorting fails for versions: String comparison is character-by-character, so "1.10.2" sorts before "1.9.1" because "1" (the character) is less than "9". Converting version parts to integers before comparison fixes this: the tuple (1, 10, 2) correctly sorts after (1, 9, 1).
  • Key function vs comparator: When the ordering can be expressed by transforming each element independently (as with version tuples), a key function is simpler and more efficient than a comparator – Python only calls the key function once per element, while a comparator may be called O(n log n) times during sorting. Use cmp_to_key() only when the comparison logic genuinely requires both elements simultaneously, such as locale-aware string sorting or custom domain rules with cross-element dependencies.

Exercise 18: Pipeline with chain and reduce

Problem Statement: Write a Python program that uses itertools.chain() to merge sales data from multiple regions into a single iterable, then uses functools.reduce() to compute the total, maximum, and minimum revenue in a clean pipeline without intermediate lists.

Purpose: Combining chain() for lazy merging with reduce() for aggregation models a real-world ETL (Extract-Transform-Load) pattern: collect data from multiple sources, stream it through a unified pipeline, and collapse it into a result. Keeping the pipeline lazy avoids materialising large intermediate datasets into memory.

Given Input: Three regional sales lists: north = [15200, 18400, 12100], south = [22300, 19800, 25100], east = [11500, 14200, 16800, 13400]

Expected Output: Total revenue, maximum single-region sale, minimum single-region sale, and average, all computed from the chained stream.

▼ Hint
  • Use chain(north, south, east) to create a unified lazy stream. Remember that iterators are single-use – if you need multiple passes (for total and max separately), materialise to a list once.
  • Use reduce(lambda acc, x: acc + x, all_sales) for the total, or reduce(operator.add, all_sales) for cleaner syntax.
  • Combine total, max, and min into a single reduce() pass by accumulating a tuple (total, max_val, min_val) as the running state.
▼ Solution & Explanation
from itertools import chain
from functools import reduce
import operator
north = [15200, 18400, 12100]
south = [22300, 19800, 25100]
east  = [11500, 14200, 16800, 13400]
# Materialise once so we can do multiple aggregations
all_sales = list(chain(north, south, east))
n         = len(all_sales)
print(f"North sales : {north}")
print(f"South sales : {south}")
print(f"East sales  : {east}")
print(f"Combined    : {all_sales}")
# Aggregations using reduce
total   = reduce(operator.add, all_sales)
maximum = reduce(lambda a, b: a if a > b else b, all_sales)
minimum = reduce(lambda a, b: a if a < b else b, all_sales)
average = total / n
print(f"\nTotal revenue   : £{total:,}")
print(f"Maximum sale    : £{maximum:,}")
print(f"Minimum sale    : £{minimum:,}")
print(f"Average sale    : £{average:,.2f}")
# Single-pass tuple accumulation: (total, max, min)
def combined_reducer(acc, x):
    total, mx, mn = acc
    return (total + x, max(mx, x), min(mn, x))
first = all_sales[0]
total2, max2, min2 = reduce(combined_reducer, all_sales[1:], (first, first, first))
print(f"\nSingle-pass reduce:")
print(f"  Total: £{total2:,}  Max: £{max2:,}  Min: £{min2:,}")
print(f"  Matches separate passes: {total == total2 and maximum == max2 and minimum == min2}")Code language: Python (python)

Explanation:

  • Materialise once, aggregate many times: chain() returns a one-use iterator. If you need the same data for multiple aggregations, convert it to a list with list(chain(...)) once. The cost is one full allocation, but it avoids re-creating the chain for every aggregation call and prevents the silent empty-result bug that occurs when a spent iterator is iterated a second time.
  • Tuple accumulation in a single reduce pass: Instead of making three separate reduce() calls over the data, you can accumulate a state tuple (total, max, min) in a single pass. The reducer function updates all three values simultaneously, touching each element exactly once. For very large datasets this halves or thirds the number of iterations needed.
  • chain + reduce as a pipeline pattern: This combination mirrors the Map-Reduce pattern: chain() is the gather step (collecting data from multiple sources), and reduce() is the fold step (collapsing it into a result). For more complex transformations, map() or a generator expression can sit between the two to form a full Extract-Transform-Reduce pipeline.

Exercise 19: Cached Combination Counter

Problem Statement: Write a Python program that implements a recursive combination function based on Pascal’s triangle identity using functools.lru_cache() for memoisation, then compares its results and call counts against math.comb() and the uncached recursive version.

Purpose: Pascal’s triangle recurrence is a classic dynamic programming problem that demonstrates the synergy between recursion and memoisation. Building it from scratch reinforces understanding of how lru_cache() eliminates overlapping subproblem recomputation and why memoised recursion and bottom-up dynamic programming produce identical results.

Given Input: Compute comb(10, 3), comb(15, 6), and comb(20, 10).

Expected Output: Cached results matching math.comb(), with cache statistics showing the dramatic reduction in recursive calls.

▼ Hint
  • The Pascal’s triangle recurrence is: C(n, k) = C(n-1, k-1) + C(n-1, k) with base cases C(n, 0) = 1 and C(n, n) = 1.
  • Decorate the recursive function with @lru_cache(maxsize=None) to cache every (n, k) pair computed.
  • Use a global counter in the uncached version to count recursive calls, and compare it to the cache_info().misses of the cached version to see how many unique subproblems were actually computed.
▼ Solution & Explanation
from functools import lru_cache
import math
# Uncached Pascal's triangle recurrence
uncached_calls = 0
def comb_uncached(n, k):
    global uncached_calls
    uncached_calls += 1
    if k == 0 or k == n:
        return 1
    return comb_uncached(n - 1, k - 1) + comb_uncached(n - 1, k)
# Cached version
@lru_cache(maxsize=None)
def comb_cached(n, k):
    if k == 0 or k == n:
        return 1
    return comb_cached(n - 1, k - 1) + comb_cached(n - 1, k)
test_cases = [(10, 3), (15, 6), (20, 10)]
print(f"{'(n,k)':<12}  {'comb_cached':>12}  {'math.comb':>10}  {'Match':>6}")
print("-" * 46)
for n, k in test_cases:
    cached  = comb_cached(n, k)
    builtin = math.comb(n, k)
    print(f"C({n},{k}){'':<6}  {cached:>12,}  {builtin:>10,}  {str(cached == builtin):>6}")
info = comb_cached.cache_info()
print(f"\nCache statistics after all calls:")
print(f"  Hits    : {info.hits}")
print(f"  Misses  : {info.misses}  (unique subproblems computed)")
print(f"  Size    : {info.currsize}")
# Count uncached calls for C(20,10) alone
uncached_calls = 0
comb_uncached(20, 10)
print(f"\nC(20,10) uncached calls : {uncached_calls:,}")
print(f"C(20,10) cached misses  : {info.misses}")
print(f"Call reduction          : {uncached_calls / info.misses:.0f}x")Code language: Python (python)

Explanation:

  • Pascal’s triangle recurrence: C(n, k) = C(n-1, k-1) + C(n-1, k) says that the number of ways to choose k items from n equals the number of ways that include a specific item (C(n-1, k-1)) plus the ways that exclude it (C(n-1, k)). Without caching, this tree of recursive calls has massive overlap – C(18, 5) is computed dozens of times during a single call to C(20, 10).
  • Cache hits vs misses: Each unique (n, k) pair is computed exactly once (a cache miss) and then served from cache on all subsequent calls (cache hits). For C(20, 10), there are only 231 unique subproblems but the uncached recursion makes tens of thousands of calls. The misses count in cache_info() directly measures the number of unique subproblems, confirming the O(n×k) space and time complexity of the memoised solution.
  • Shared cache across calls: Because lru_cache persists between calls to the same function, subproblems computed during comb_cached(10, 3) are reused during comb_cached(20, 10). The cache accumulates across the entire program lifetime, making subsequent calls progressively cheaper when they share subproblems with earlier calls.

Exercise 20: Lazy Data Pipeline with islice, filter, and partial

Problem Statement: Write a Python program that builds a lazy data processing pipeline combining itertools.islice() to limit input, filter() with a functools.partial() predicate to select records, and functools.reduce() to aggregate the final result – without materialising any intermediate collection until the final step.

Purpose: Real-world data pipelines must process large streams efficiently. Composing lazy itertools functions with functools higher-order tools creates pipelines that read only what they need, filter as they go, and aggregate in a single pass. This exercise ties together all the concepts from the module into a pattern directly applicable to log analysis, sensor data processing, and batch ETL jobs.

Given Input: A simulated stream of 1000 sales records as (product, region, amount) tuples. Process only the first 200, keep those from the "North" region with amount above a threshold, and sum the qualifying amounts.

Expected Output: Total revenue from qualifying North region sales in the first 200 records, with a step-by-step pipeline trace showing counts at each stage.

▼ Hint
  • Use partial(is_north_above_threshold, region="North", min_amount=500) to create a single-argument predicate from a multi-parameter function, then pass it to filter().
  • Chain the stages: islice(data_stream, 200) caps the input, filter(predicate, ...) selects qualifying records, and reduce() or sum() aggregates amounts.
  • The pipeline stays lazy until reduce() or list() forces evaluation – no intermediate list is built at any stage before the final aggregation.
▼ Solution & Explanation
from itertools import islice
from functools import reduce, partial
import random
random.seed(42)
# Simulate a large data stream as a generator (never fully in memory)
regions  = ["North", "South", "East", "West"]
products = ["Widget", "Gadget", "Doohickey", "Thingamajig"]
def sales_stream(n):
    """Generator simulating n sales records."""
    for _ in range(n):
        yield (
            random.choice(products),
            random.choice(regions),
            round(random.uniform(50, 2000), 2),
        )
# Define a multi-parameter predicate
def is_qualifying(record, region, min_amount):
    _, rec_region, amount = record
    return rec_region == region and amount >= min_amount
# Create a single-argument predicate using partial
north_high_value = partial(is_qualifying, region="North", min_amount=500)
# Build the lazy pipeline
stream      = sales_stream(1000)          # infinite-ish generator
capped      = islice(stream, 200)          # stage 1: limit to first 200
qualifying  = filter(north_high_value, capped)  # stage 2: filter by region + amount
results     = list(qualifying)             # materialise only at the end
# Aggregate
if results:
    total   = reduce(lambda acc, r: acc + r[2], results, 0.0)
    maximum = reduce(lambda a, b: a if a[2] > b[2] else b, results)
    count   = len(results)
else:
    total, count = 0.0, 0
print(f"Pipeline stages:")
print(f"  Stage 1 (islice)  : capped stream at 200 records")
print(f"  Stage 2 (filter)  : kept North region with amount >= £500")
print(f"  Stage 3 (reduce)  : aggregated qualifying records")
print(f"\nQualifying records  : {count}")
print(f"Total revenue       : £{total:,.2f}")
if results:
    print(f"Highest single sale : £{maximum[2]:,.2f} ({maximum[0]}, {maximum[1]})")
print(f"\nSample qualifying records (first 5):")
for product, region, amount in results[:5]:
    print(f"  {product:<15}  {region:<6}  £{amount:,.2f}")Code language: Python (python)

Explanation:

  • Lazy pipeline composition: Each stage – islice(), filter() – returns an iterator rather than a list. No records are read from the generator until the final list() call forces evaluation. This means a pipeline processing 1 million records reads only the 200 it needs (due to islice), then evaluates only the filter predicate on those 200. Memory usage stays constant regardless of the stream size.
  • partial as a pipeline adapter: filter() requires a single-argument predicate, but is_qualifying() needs three parameters (record, region, min_amount). partial(is_qualifying, region="North", min_amount=500) pre-fills the configuration arguments and returns a single-argument function that filter() can use directly. This avoids a lambda and keeps the predicate logic in a named, testable function.
  • Materialise at the last responsible moment: The pipeline stays lazy until the list(qualifying) call, which forces all stages to execute in a single coordinated pass. The resulting list is small (only qualifying records) rather than a copy of the full 200-record slice. Using reduce() directly on the filter iterator instead of list() would avoid even this final allocation, at the cost of not being able to inspect the results or compute multiple aggregations without re-running the pipeline.

Filed Under: Python, Python Exercises

Did you find this page helpful? Let others know about it. Sharing helps me continue to create free Python resources.

TweetF  sharein  shareP  Pin

About Vishal

I’m Vishal Hule, the Founder of PYnative.com. As a Python developer, I enjoy assisting students, developers, and learners. Follow me on Twitter.

Related Tutorial Topics:

Python Python Exercises

All Coding Exercises:

C Exercises
C++ Exercises
Python Exercises

Python Exercises and Quizzes

Free coding exercises and quizzes cover Python basics, data structure, data analytics, and more.

  • 15+ Topic-specific Exercises and Quizzes
  • Each Exercise contains 25+ questions
  • Each Quiz contains 25 MCQ
Exercises
Quizzes

Leave a Reply Cancel reply

your email address will NOT be published. all comments are moderated according to our comment policy.

Use <pre> tag for posting code. E.g. <pre> Your entire code </pre>

In: Python Python Exercises
TweetF  sharein  shareP  Pin

  Python Exercises

  • All Python Exercises
  • Basic Exercises for Beginners
  • Loop Exercises
  • Intermediate Python Exercises
  • Input and Output Exercises
  • Functions Exercises
  • String Exercises
  • List Exercises
  • Dictionary Exercises
  • Set Exercises
  • Tuple Exercises
  • Data Structure Exercises
  • Comprehensions Exercises
  • Collections Module Exercises
  • Date and Time Exercises
  • OOP Exercises
  • Exception Handling Exercises
  • Math and Statistics Exercises
  • File Handling Exercises
  • OS and Sys Module Exercises
  • Regex Exercises
  • Lambda & Functional Programming Exercises
  • Iterators & Generators Exercises
  • Itertools & Functools Exercises
  • Random Data Generation Exercises
  • NumPy Exercises
  • Pandas Exercises
  • Matplotlib Exercises
  • Python Database Exercises
  • Python JSON Exercises

 Explore Python

  • Python Tutorials
  • Python Exercises
  • Python Quizzes
  • Python Interview Q&A
  • Python Programs

All Python Topics

Python Basics Python Exercises Python Quizzes Python Interview Python File Handling Python OOP Python Date and Time Python Random Python Regex Python Pandas Python Databases Python MySQL Python PostgreSQL Python SQLite Python JSON

About PYnative

PYnative.com is for Python lovers. Here, You can get Tutorials, Exercises, and Quizzes to practice and improve your Python skills.

Follow Us

To get New Python Tutorials, Exercises, and Quizzes

  • Twitter
  • Facebook
  • Sitemap

Explore Python

  • Learn Python
  • Python Basics
  • Python Databases
  • Python Exercises
  • Python Quizzes
  • Online Python Code Editor
  • Python Tricks

Coding Exercises

  • C Exercises
  • C++ Exercises
  • Python Exercises

Legal Stuff

  • About Us
  • Contact Us

We use cookies to improve your experience. While using PYnative, you agree to have read and accepted our:

  • Terms Of Use
  • Privacy Policy
  • Cookie Policy

Copyright © 2018–2026 pynative.com