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
permutationsfromitertoolsand callpermutations(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
rto 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
Explanation:
permutations(iterable, r): Returns an iterator of r-length tuples representing all possible orderings of the input elements. Whenris 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 inlist()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
Explanation:
combinations(iterable, r): Returns r-length tuples of elements chosen from the input in sorted order, without repetition. The key distinction frompermutations()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 tocombinations()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
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). Usecombinations_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 amaterialslist to generate 3-attribute variants.
▼ Solution & Explanation
Explanation:
product(*iterables): Returns the Cartesian product of the input iterables, equivalent to nestedforloops 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
forloop over independent iterables can be replaced with a singleproduct()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 thanproduct(digits, digits, digits, digits)– both are equivalent but therepeatkeyword 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
Explanation:
product(iterable, repeat=n): Therepeatkeyword is shorthand for passing the same iterable n times.product("AB", repeat=3)is identical toproduct("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 viaisliceis 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 uselen()– but note that consuming the iterator once exhausts it, so you may need to create it again for a second pass.
▼ Solution & Explanation
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 makeschain()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 withlist(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 bareforloop – always pair it with a termination mechanism. - Wrap it in
islice(counter, 8)to cap the output at 8 elements.islice(iterable, stop)orislice(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
Explanation:
count(start=0, step=1): Produces an infinite sequence of evenly spaced values beginning atstart. Unlikerange(), 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 moststopelements from the iterator and then stops, without reading further. The full formislice(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): Callingnext()on acount()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 usingzip(employees, cycle(shifts)). Thezip()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
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 unlikecount(), it does consume memory proportional to the length of the input iterable.zip(employees, cycle(shifts)): Pairing a finite iterable with an infinite one viazip()is the idiomatic way to safely consume a cycle. Python’szip()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 requireshifts[i % len(shifts)]inside a loop. Thecycle()approach is more readable, avoids index arithmetic entirely, and composes cleanly with other itertools functions likeislice()andzip().
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, thenzip()them together. - Alternatively, use
repeat(discount_rate)without a count and letzip()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
Explanation:
repeat(object, times=None): Returns an iterator that yields the same object indefinitely whentimesis omitted, or exactlytimestimes 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))computesmath.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 inzip()or capped withislice().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 isTrue. It stops permanently at the first element where the condition becomesFalse. - Use
dropwhile(lambda x: x < 30, temperatures)to skip elements while the condition isTrue, then yield all remaining elements once the condition first becomesFalse– even those that would satisfy the condition again later. - Note that together,
takewhileanddropwhilepartition the sequence at the first point where the condition breaks. Concatenating their outputs viachain()recovers the original sequence.
▼ Solution & Explanation
Explanation:
takewhile(predicate, iterable): Yields elements as long as the predicate returnsTrue. The moment the predicate returnsFalsefor any element, iteration stops immediately and permanently – even if later elements would have satisfied the predicate. This is fundamentally different fromfilter(), which tests every element independently.dropwhile(predicate, iterable): Skips elements as long as the predicate returnsTrue. Once the predicate first returnsFalse, all subsequent elements are yielded without further testing – including those that would satisfy the predicate again. In the temperature example,26.5at the end is below 30 but is still included because the drop phase ended at31.3.- Partitioning property:
takewhileanddropwhilewith the same predicate split the sequence at its first predicate-breaking point, sochain(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 withsorted(data, key=...)before passing togroupby(). - 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
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 asgroupby()advances to the next key, the previous group iterator is exhausted and returns nothing. Wrapping it inlist(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 fromproductswhose corresponding value inin_stockis 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
Explanation:
compress(data, selectors): Returns an iterator that yields elements fromdataat positions where the correspondingselectorsvalue 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, makingcompress()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. Passoperator.mulas the second argument for running products, or pass any two-argument function.- Use
accumulate(data, max)to compute the running maximum – the built-inmaxfunction works as a binary function here. - Pass an
initialkeyword argument (Python 3.8+) to prepend a starting value before the first element:accumulate(data, initial=0).
▼ Solution & Explanation
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 customlambda.- Built-in functions as binary operators: Python’s built-in
max(a, b)andmin(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. initialkeyword argument: When provided,initialis 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 lengthlen(data) + 1rather thanlen(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
reducefromfunctoolsand callreduce(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 aTypeErroron empty input. - Use
operator.mulinstead of a lambda for the multiplication case – it is faster and more readable:reduce(operator.mul, numbers).
▼ Solution & Explanation
Explanation:
reduce(function, iterable, initializer): Appliesfunctionto the first two elements, then applies it to that result and the third element, and so on until a single value remains. The optionalinitializeris placed before the iterable’s first element and is returned unchanged if the iterable is empty, making the call safe for empty inputs.operator.mulvs lambda:operator.mulis a pre-compiled C-level function that performs multiplication. Using it instead oflambda a, b: a * bis both faster and more readable, since the name clearly states the operation. Theoperatormodule 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. Usereduce()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 ofpow(base, exp)with2, leavingexpas 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
partialto pre-fill non-positional parameters:partial(print, end="\n\n")creates a print function that always double-spaces.
▼ Solution & Explanation
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
partialobject 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)andlambda exp: pow(2, exp)create a single-argument callable.partialis 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
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=Nonecreates 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()andcache_clear():cache_info()returns a named tuple withhits(how many calls were served from cache),misses(how many required actual computation),maxsize, andcurrsize.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 thekeyargument tosorted().
▼ Solution & Explanation
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’ssorted()andlist.sort()removed direct comparator support in Python 3 (it existed in Python 2 as thecmpargument), socmp_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
keyfunction 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. Usecmp_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, orreduce(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
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 withlist(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), andreduce()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 casesC(n, 0) = 1andC(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().missesof the cached version to see how many unique subproblems were actually computed.
▼ Solution & Explanation
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 toC(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). ForC(20, 10), there are only 231 unique subproblems but the uncached recursion makes tens of thousands of calls. Themissescount incache_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_cachepersists between calls to the same function, subproblems computed duringcomb_cached(10, 3)are reused duringcomb_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 tofilter(). - Chain the stages:
islice(data_stream, 200)caps the input,filter(predicate, ...)selects qualifying records, andreduce()orsum()aggregates amounts. - The pipeline stays lazy until
reduce()orlist()forces evaluation – no intermediate list is built at any stage before the final aggregation.
▼ Solution & Explanation
Explanation:
- Lazy pipeline composition: Each stage –
islice(),filter()– returns an iterator rather than a list. No records are read from the generator until the finallist()call forces evaluation. This means a pipeline processing 1 million records reads only the 200 it needs (due toislice), then evaluates only the filter predicate on those 200. Memory usage stays constant regardless of the stream size. partialas a pipeline adapter:filter()requires a single-argument predicate, butis_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 thatfilter()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. Usingreduce()directly on thefilteriterator instead oflist()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.

Leave a Reply