Python for Loops Internals Practice Problems & Exercises
Practice: for Loops Internals
← Back to lessonEasy
Use enumerate() with a custom start index to print each fruit with a number starting at 3.
fruits = ["cherry", "date", "elderberry", "fig"]
for i, fruit in enumerate(fruits, start=3):
print(f"{i}: {fruit}")Solution
fruits = ["cherry", "date", "elderberry", "fig"]
for i, fruit in enumerate(fruits, start=3):
print(f"{i}: {fruit}")
Output:
3: cherry
4: date
5: elderberry
6: fig
How it works: enumerate(iterable, start=0) yields (index, element) tuples. The start parameter shifts the counter but does not skip elements — it simply changes what number the counter begins at. This is useful for 1-based indexing, page numbering, or continuing a count from a previous batch.
Key insight: enumerate does not slice or skip. start=3 means the first element gets index 3, the second gets 4, and so on. Every element in the iterable is still visited.
Expected Output
3: cherry\n4: date\n5: elderberry\n6: figHints
Hint 1: enumerate() accepts a second argument `start` that sets the initial counter value.
Hint 2: The default start is 0, but you can pass any integer.
Use zip() to pair each student with their score and print the result.
students = ["Alice", "Bob", "Carla", "Dan"]
scores = [88, 92, 75, 81]
for student, score in zip(students, scores):
print(f"{student} scored {score}")Solution
students = ["Alice", "Bob", "Carla", "Dan"]
scores = [88, 92, 75, 81]
for student, score in zip(students, scores):
print(f"{student} scored {score}")
Output:
Alice scored 88
Bob scored 92
Carla scored 75
Dan scored 81
How it works: zip(a, b) creates a lazy iterator that yields tuples (a[0], b[0]), (a[1], b[1]), etc. Under the hood, it calls next() on each iterable in lockstep and stops as soon as any iterable is exhausted.
Key insight: zip is lazy — it does not build a list of tuples in memory. It yields one tuple at a time, making it memory-efficient even for very large iterables. If lengths differ, zip silently truncates to the shortest. Use itertools.zip_longest when you need to handle unequal lengths.
Expected Output
Alice scored 88\nBob scored 92\nCarla scored 75\nDan scored 81Hints
Hint 1: zip() takes two or more iterables and yields tuples of corresponding elements.
Hint 2: zip() stops at the shortest iterable — no IndexError if lengths differ.
Iterate over a dictionary three different ways: by keys, by values, and by items.
person = {"name": "Alice", "age": 30, "city": "Portland"}
# Iterate over keys
print("Keys:", " ".join(str(k) for k in person.keys()))
# Iterate over values
print("Values:", " ".join(str(v) for v in person.values()))
# Iterate over items (key-value pairs)
items_str = " ".join(f"{k}={v}" for k, v in person.items())
print(f"Items: {items_str}")Solution
person = {"name": "Alice", "age": 30, "city": "Portland"}
print("Keys:", " ".join(str(k) for k in person.keys()))
print("Values:", " ".join(str(v) for v in person.values()))
items_str = " ".join(f"{k}={v}" for k, v in person.items())
print(f"Items: {items_str}")
Output:
Keys: name age city
Values: Alice 30 Portland
Items: name=Alice age=30 city=Portland
How it works: Python dictionaries provide three view objects:
dict.keys()— a dynamic view of the dictionary's keys. Iterating over the dict directly (for k in person:) does the same thing.dict.values()— a dynamic view of values. No way to get the corresponding key from this view alone.dict.items()— a dynamic view of(key, value)tuples, which is the most useful for most tasks.
These are views, not copies. They reflect changes to the dictionary in real time. Since Python 3.7, dictionaries maintain insertion order, so iteration order is guaranteed to match insertion order.
Key insight: Use .items() when you need both key and value. Use .keys() or direct iteration when you only need keys. Avoid calling .keys() explicitly when iterating — for k in person: is idiomatic.
Expected Output
Keys: name age city\nValues: Alice 30 Portland\nItems: name=Alice age=30 city=PortlandHints
Hint 1: dict.keys() returns a view of keys, dict.values() returns values, dict.items() returns (key, value) tuples.
Hint 2: Iterating over a dict directly is equivalent to iterating over its keys.
Use range() with a negative step to count down from 10 to 1, stepping by -3.
for n in range(10, 0, -3):
print(n)Solution
for n in range(10, 0, -3):
print(n)
Output:
10
7
4
1
How it works: range(10, 0, -3) starts at 10 and subtracts 3 each step. The sequence is 10, 7, 4, 1. The next value would be -2, which is less than the stop value 0 (when stepping down, range stops when the value goes below stop), so iteration ends.
The math behind range length: For range(start, stop, step), the number of elements is max(0, ceil((stop - start) / step)). Here: ceil((0 - 10) / -3) = ceil(3.33) = 4. So we get exactly 4 values.
Key insight: range is lazy — it does not store all values in memory. It computes each value on demand using the formula start + step * i. This makes range(0, 10**12) use the same memory as range(0, 10). It also supports in membership testing in O(1) time, not O(n).
Expected Output
10\n7\n4\n1Hints
Hint 1: range(start, stop, step) generates numbers from start up to (but not including) stop, incrementing by step.
Hint 2: A negative step counts downward. The stop value is still exclusive.
Medium
Reimplement enumerate() using only iter(), next(), and a counter. Do not use the built-in enumerate.
def my_enumerate(iterable, start=0):
"""Reimplement enumerate using iter() and next()."""
iterator = iter(iterable)
index = start
while True:
try:
value = next(iterator)
except StopIteration:
return
yield (index, value)
index += 1
# Test it
colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
print(f"{i}: {color}")Solution
def my_enumerate(iterable, start=0):
"""Reimplement enumerate using iter() and next()."""
iterator = iter(iterable)
index = start
while True:
try:
value = next(iterator)
except StopIteration:
return
yield (index, value)
index += 1
colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
print(f"{i}: {color}")
Output:
1: red
2: green
3: blue
How it works step by step:
iter(iterable)converts the input into an iterator. If it is already an iterator, it returns it unchanged.- We maintain a counter
indexstarting atstart. - Each iteration calls
next(iterator)to fetch the next value. If the iterator is exhausted,StopIterationis raised and wereturnfrom the generator (which also signalsStopIterationto the caller). - We
yielda tuple(index, value)— this makesmy_enumeratea generator function, which is itself an iterator.
Why return not break: Inside a generator, return raises StopIteration, which is exactly what the for loop expects to end iteration. Using break would also work here since the while True would end, but return is more explicit about the intent.
Key insight: This is the core of the iterator protocol — iter() to get an iterator, next() to advance it, StopIteration to signal completion. Every for loop in Python follows this exact protocol under the hood.
def my_enumerate(iterable, start=0):
"""Reimplement enumerate using iter() and next()."""
# Your code here
pass
# Test it
colors = ["red", "green", "blue"]
for i, color in my_enumerate(colors, start=1):
print(f"{i}: {color}")Expected Output
1: red\n2: green\n3: blueHints
Hint 1: Use iter() to get an iterator from the iterable, then call next() in a loop.
Hint 2: You can use a while loop with a try/except StopIteration, or yield from a generator.
Build a Countdown iterator class that counts down from n to 1. Implement __iter__ and __next__ to make it work with a for loop.
class Countdown:
"""Iterator that counts down from n to 1."""
def __init__(self, n):
self.current = n
def __iter__(self):
return self
def __next__(self):
if self.current < 1:
raise StopIteration
value = self.current
self.current -= 1
return value
# Test it
for num in Countdown(5):
print(num)Solution
class Countdown:
"""Iterator that counts down from n to 1."""
def __init__(self, n):
self.current = n
def __iter__(self):
return self
def __next__(self):
if self.current < 1:
raise StopIteration
value = self.current
self.current -= 1
return value
for num in Countdown(5):
print(num)
Output:
5
4
3
2
1
The iterator protocol requires two methods:
__iter__(self)— must return an iterator object. When the object is its own iterator (as here), it returnsself. This is whatiter()calls.__next__(self)— must return the next value or raiseStopIterationwhen exhausted. This is whatnext()calls.
What for does under the hood:
_iter = iter(Countdown(5)) # calls __iter__(), gets the object back
while True:
try:
num = next(_iter) # calls __next__()
except StopIteration:
break
print(num) # loop body
Important caveat: This iterator is single-use. Once exhausted, calling next() again will keep raising StopIteration. If you need to iterate multiple times, separate the iterable (which has __iter__) from the iterator (which has __next__). The iterable's __iter__ should return a new iterator each time.
class Countdown:
"""Iterator that counts down from n to 1."""
def __init__(self, n):
# Your code here
pass
def __iter__(self):
# Your code here
pass
def __next__(self):
# Your code here
pass
# Test it
for num in Countdown(5):
print(num)Expected Output
5\n4\n3\n2\n1Hints
Hint 1: __iter__ should return self — the object is its own iterator.
Hint 2: __next__ should return the current value and decrement, raising StopIteration when done.
Use itertools.zip_longest to merge two lists of different lengths into a dictionary, filling missing values with a default.
from itertools import zip_longest
def merge_with_defaults(keys, values, default="N/A"):
"""Merge keys and values into a dict, using default for missing values."""
return dict(zip_longest(keys, values, fillvalue=default))
# Test with unequal lengths
keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
print(f"{k}: {v}")Solution
from itertools import zip_longest
def merge_with_defaults(keys, values, default="N/A"):
"""Merge keys and values into a dict, using default for missing values."""
return dict(zip_longest(keys, values, fillvalue=default))
keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
print(f"{k}: {v}")
Output:
age: 30
city: N/A
country: N/A
name: Alice
How zip_longest differs from zip:
| Behavior | zip | zip_longest |
|---|---|---|
| Unequal lengths | Truncates to shortest | Pads to longest |
| Missing values | Silently dropped | Replaced with fillvalue |
| Default fill | N/A (no padding) | None (customizable) |
The pairing produced: zip_longest(keys, values, fillvalue="N/A") yields:
("name", "Alice")— both present("age", 30)— both present("city", "N/A")— values exhausted, filled with default("country", "N/A")— values exhausted, filled with default
Key insight: When keys outnumber values, zip would silently lose data. zip_longest makes the mismatch explicit. In Python 3.10+, you can also use zip(strict=True) to raise a ValueError on length mismatch instead of silently truncating.
from itertools import zip_longest
def merge_with_defaults(keys, values, default="N/A"):
"""Merge keys and values into a dict, using default for missing values."""
# Your code here
pass
# Test with unequal lengths
keys = ["name", "age", "city", "country"]
values = ["Alice", 30]
result = merge_with_defaults(keys, values)
for k, v in sorted(result.items()):
print(f"{k}: {v}")Expected Output
age: 30\ncity: N/A\ncountry: N/A\nname: AliceHints
Hint 1: zip_longest(*iterables, fillvalue=None) pads shorter iterables with fillvalue.
Hint 2: You can pass a custom fillvalue to replace None.
Remove all even numbers from a list while iterating. Demonstrate the safe approach using a copy.
The naive approach (for x in lst: if even: lst.remove(x)) is a classic Python bug — it skips elements because the iterator's internal index gets out of sync with the shrinking list.
numbers = [1, 2, 3, 4, 5, 6, 7]
# Safe approach: iterate over a copy
for n in numbers[:]: # numbers[:] creates a shallow copy
if n % 2 == 0:
print(f"Removing: {n}")
numbers.remove(n)
print(f"Result: {numbers}")Solution
numbers = [1, 2, 3, 4, 5, 6, 7]
for n in numbers[:]:
if n % 2 == 0:
print(f"Removing: {n}")
numbers.remove(n)
print(f"Result: {numbers}")
Output:
Removing: 2
Removing: 4
Removing: 6
Result: [1, 3, 5, 7]
Why the naive approach fails:
# BUG: modifying while iterating
nums = [1, 2, 3, 4, 5, 6]
for n in nums:
if n % 2 == 0:
nums.remove(n)
# Result: [1, 3, 5] — WRONG! 4 was skipped
When you remove element at index 1 (value 2), all subsequent elements shift left. The iterator's internal index advances from 1 to 2, but position 2 now holds what was at position 3 (value 4 is now at index 2, but the iterator jumps to index 2 which is now 4... wait, 3 is at index 1 now). The iterator skips 3 and sees 4. Then removing 4 causes 6 to be skipped.
Three safe alternatives:
- Copy iteration:
for n in numbers[:]— iterate the copy, mutate the original. - List comprehension:
numbers = [n for n in numbers if n % 2 != 0]— build a new list (preferred for simple filters). - Reverse iteration:
for i in range(len(numbers) - 1, -1, -1)— removing from the end does not affect earlier indices.
Key insight: List comprehensions are almost always the cleanest solution for filtering. Reserve in-place mutation for cases where other references to the same list object must see the change.
Expected Output
Removing: 2\nRemoving: 4\nRemoving: 6\nResult: [1, 3, 5, 7]Hints
Hint 1: Never modify a list while iterating over it directly — it skips elements.
Hint 2: Iterate over a copy (list[:]) or build a new list instead.
Implement a sliding window function that yields tuples of size consecutive elements from an iterable.
def sliding_window(iterable, size):
"""Yield sliding windows of the given size over the iterable."""
lst = list(iterable)
# Create 'size' slices, each offset by 1
slices = [lst[i:] for i in range(size)]
return zip(*slices)
# Test
data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
print(window)Solution
def sliding_window(iterable, size):
"""Yield sliding windows of the given size over the iterable."""
lst = list(iterable)
slices = [lst[i:] for i in range(size)]
return zip(*slices)
data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
print(window)
Output:
(10, 20, 30)
(20, 30, 40)
(30, 40, 50)
(40, 50, 60)
How it works: For size=3, we create three slices:
lst[0:]=[10, 20, 30, 40, 50, 60]lst[1:]=[20, 30, 40, 50, 60]lst[2:]=[30, 40, 50, 60]
Then zip(*slices) pairs corresponding elements across all three slices:
(10, 20, 30),(20, 30, 40),(30, 40, 50),(40, 50, 60)
Since zip stops at the shortest iterable, the last slice (lst[2:]) determines the number of windows: len(lst) - size + 1.
Alternative using collections.deque (more memory-efficient for large iterables):
from collections import deque
def sliding_window_deque(iterable, size):
it = iter(iterable)
window = deque(maxlen=size)
for _ in range(size):
window.append(next(it))
yield tuple(window)
for item in it:
window.append(item) # oldest element auto-drops
yield tuple(window)
Key insight: The zip-of-slices approach is elegant and Pythonic but materializes the entire input. For streaming data or very large sequences, the deque approach processes elements one at a time. In Python 3.12+, itertools.batched exists but does not overlap — sliding windows still need custom code (or more-itertools.windowed).
def sliding_window(iterable, size):
"""Yield sliding windows of the given size over the iterable."""
# Your code here
pass
# Test
data = [10, 20, 30, 40, 50, 60]
for window in sliding_window(data, 3):
print(window)Expected Output
(10, 20, 30)\n(20, 30, 40)\n(30, 40, 50)\n(40, 50, 60)Hints
Hint 1: One approach: zip multiple slices of the iterable offset by 1.
Hint 2: zip(data[0:], data[1:], data[2:]) gives you a window of size 3.
Hard
Build a FloatRange class that works like range() but supports floating-point start, stop, and step. It must support iteration, len(), and in membership testing.
import math
class FloatRange:
"""A range-like class that supports float start, stop, and step."""
def __init__(self, start, stop=None, step=1.0):
if stop is None:
self.start = 0.0
self.stop = float(start)
else:
self.start = float(start)
self.stop = float(stop)
self.step = float(step)
if self.step == 0:
raise ValueError("FloatRange step must not be zero")
def __iter__(self):
# Use a generator so each call to __iter__ starts fresh
count = len(self)
for i in range(count):
yield self.start + i * self.step
def __len__(self):
if self.step > 0 and self.start >= self.stop:
return 0
if self.step < 0 and self.start <= self.stop:
return 0
return max(0, math.ceil((self.stop - self.start) / self.step))
def __contains__(self, value):
if self.step > 0:
if value < self.start or value >= self.stop:
return False
else:
if value > self.start or value <= self.stop:
return False
# Check if value falls on a step boundary
steps = (value - self.start) / self.step
return abs(steps - round(steps)) < 1e-9
def __repr__(self):
return f"FloatRange({self.start}, {self.stop}, {self.step})"
# Test
print("Forward:")
for x in FloatRange(0.0, 1.0, 0.3):
print(f" {x:.1f}")
print(f"Length: {len(FloatRange(0.0, 1.0, 0.3))}")
print(f"0.3 in FloatRange(0.0, 1.0, 0.3): {0.3 in FloatRange(0.0, 1.0, 0.3)}")
print("Backward:")
for x in FloatRange(1.0, 0.0, -0.4):
print(f" {x:.1f}")Solution
import math
class FloatRange:
def __init__(self, start, stop=None, step=1.0):
if stop is None:
self.start = 0.0
self.stop = float(start)
else:
self.start = float(start)
self.stop = float(stop)
self.step = float(step)
if self.step == 0:
raise ValueError("FloatRange step must not be zero")
def __iter__(self):
count = len(self)
for i in range(count):
yield self.start + i * self.step
def __len__(self):
if self.step > 0 and self.start >= self.stop:
return 0
if self.step < 0 and self.start <= self.stop:
return 0
return max(0, math.ceil((self.stop - self.start) / self.step))
def __contains__(self, value):
if self.step > 0:
if value < self.start or value >= self.stop:
return False
else:
if value > self.start or value <= self.stop:
return False
steps = (value - self.start) / self.step
return abs(steps - round(steps)) < 1e-9
def __repr__(self):
return f"FloatRange({self.start}, {self.stop}, {self.step})"
Output:
Forward:
0.0
0.3
0.6
0.9
Length: 4
0.3 in FloatRange(0.0, 1.0, 0.3): True
Backward:
1.0
0.6
0.2
Key design decisions:
-
__iter__usesstart + i * step, not cumulative addition. Floating-point errors accumulate when you repeatedly addstep. Usingstart + i * stepkeeps the error bounded to a single multiplication.Cumulative: 0.0 → 0.3 → 0.6000000000000001 → 0.9000000000000001 (drift)Index-based: 0.0 + 0*0.3 → 0.0 + 1*0.3 → 0.0 + 2*0.3 → 0.0 + 3*0.3 (stable) -
__iter__is a generator, so eachforloop call gets a fresh iterator. This is crucial — if__iter__returnedselfwith mutable state, the object could only be iterated once. -
__contains__uses epsilon comparison (1e-9) because floating-point values are almost never exactly equal. We compute how many steps fromstarttovalueand check if that is close to an integer. -
__len__usesmath.ceilto matchrangesemantics — the stop value is exclusive.ceil((1.0 - 0.0) / 0.3) = ceil(3.33) = 4, giving us values at indices 0, 1, 2, 3.
Why Python's range does not support floats: Floating-point arithmetic is inherently imprecise. The built-in range guarantees exact values and O(1) membership testing, which is only possible with integers. A float range must make tradeoffs around precision, as this implementation shows with the epsilon comparison.
class FloatRange:
"""A range-like class that supports float start, stop, and step."""
def __init__(self, start, stop=None, step=1.0):
# Handle single-argument case: FloatRange(5.0) means FloatRange(0, 5.0)
# Your code here
pass
def __iter__(self):
# Your code here
pass
def __len__(self):
# Your code here
pass
def __contains__(self, value):
# Your code here
pass
def __repr__(self):
return f"FloatRange({self.start}, {self.stop}, {self.step})"
# Test
print("Forward:")
for x in FloatRange(0.0, 1.0, 0.3):
print(f" {x:.1f}")
print(f"Length: {len(FloatRange(0.0, 1.0, 0.3))}")
print(f"0.3 in FloatRange(0.0, 1.0, 0.3): {0.3 in FloatRange(0.0, 1.0, 0.3)}")
print("Backward:")
for x in FloatRange(1.0, 0.0, -0.4):
print(f" {x:.1f}")Expected Output
Forward:\n 0.0\n 0.3\n 0.6\n 0.9\nLength: 4\n0.3 in FloatRange(0.0, 1.0, 0.3): True\nBackward:\n 1.0\n 0.6\n 0.2Hints
Hint 1: Use math.ceil((stop - start) / step) for __len__, clamped to 0 for empty ranges.
Hint 2: For __contains__, check if (value - start) / step is close to an integer and within bounds.
Hint 3: Use a generator in __iter__ so the object can be iterated multiple times.
Build a LineReader class that lazily reads lines from a file one at a time. It must implement both the iterator protocol (__iter__/__next__) and the context manager protocol (__enter__/__exit__).
Since we cannot create actual files in this sandbox, we simulate using io.StringIO.
import io
class LineReader:
"""Lazy line iterator that works as a context manager."""
def __init__(self, file_obj):
self._file_obj = file_obj
self._file = None
self.lines_read = 0
def __enter__(self):
self._file = self._file_obj
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._file.close()
return False # Do not suppress exceptions
def __iter__(self):
return self
def __next__(self):
line = self._file.readline()
if not line:
raise StopIteration
self.lines_read += 1
return line.rstrip('\n')
# Simulate a file with io.StringIO
fake_file = io.StringIO("Hello World\nPython Iterators\nAre Powerful\n")
with LineReader(fake_file) as reader:
for line_num, line in enumerate(reader, 1):
print(f"[{line_num}] {line}")
print(f"Lines read: {reader.lines_read}")
print(f"File closed: {fake_file.closed}")Solution
import io
class LineReader:
"""Lazy line iterator that works as a context manager."""
def __init__(self, file_obj):
self._file_obj = file_obj
self._file = None
self.lines_read = 0
def __enter__(self):
self._file = self._file_obj
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self._file.close()
return False
def __iter__(self):
return self
def __next__(self):
line = self._file.readline()
if not line:
raise StopIteration
self.lines_read += 1
return line.rstrip('\n')
fake_file = io.StringIO("Hello World\nPython Iterators\nAre Powerful\n")
with LineReader(fake_file) as reader:
for line_num, line in enumerate(reader, 1):
print(f"[{line_num}] {line}")
print(f"Lines read: {reader.lines_read}")
print(f"File closed: {fake_file.closed}")
Output:
[1] Hello World
[2] Python Iterators
[3] Are Powerful
Lines read: 3
File closed: True
Two protocols in one class:
Iterator protocol:
__iter__(self)returnsself— the object is its own iterator.__next__(self)reads one line usingreadline(), which reads only until the next newline. This is lazy — it never loads the entire file into memory. Whenreadline()returns an empty string, the file is exhausted and we raiseStopIteration.
Context manager protocol:
__enter__(self)sets up the resource (opens/assigns the file) and returns the object to bind to theasvariable.__exit__(self, exc_type, exc_val, exc_tb)is called when leaving thewithblock, whether normally or due to an exception. We close the file here. ReturningFalsemeans exceptions propagate normally.
Why this matters for real files:
# With a real file, __init__ would store the path,
# and __enter__ would open it:
class LineReader:
def __init__(self, filename):
self._filename = filename
def __enter__(self):
self._file = open(self._filename, 'r')
return self
def __exit__(self, *args):
self._file.close()
return False
Key insight: Combining iterator + context manager lets you write with LineReader('huge.csv') as reader: for line in reader: ... — the file is guaranteed to close even if an exception occurs mid-iteration, and memory usage stays constant regardless of file size because we read one line at a time.
class LineReader:
"""Lazy line iterator that works as a context manager.
Usage:
with LineReader('file.txt') as reader:
for line in reader:
print(line)
"""
def __init__(self, filename):
# Your code here
pass
def __enter__(self):
# Your code here
pass
def __exit__(self, exc_type, exc_val, exc_tb):
# Your code here
pass
def __iter__(self):
# Your code here
pass
def __next__(self):
# Your code here
passExpected Output
[1] Hello World\n[2] Python Iterators\n[3] Are Powerful\nLines read: 3\nFile closed: TrueHints
Hint 1: __enter__ should open the file and return self.
Hint 2: __exit__ should close the file and return False (do not suppress exceptions).
Hint 3: __next__ should read one line at a time, strip the newline, and raise StopIteration at EOF.
Build a round_robin generator that takes multiple iterables and yields their elements in interleaved order. When one iterable is exhausted, continue with the remaining ones.
This pattern is useful for fair scheduling, load balancing, and merging data streams.
def round_robin(*iterables):
"""Yield items from multiple iterables in round-robin order."""
# Convert all to iterators
iterators = [iter(it) for it in iterables]
while iterators:
# Track which iterators are still active this round
still_active = []
for it in iterators:
try:
yield next(it)
still_active.append(it)
except StopIteration:
pass # This iterator is exhausted — drop it
iterators = still_active
# Test
print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))Solution
def round_robin(*iterables):
"""Yield items from multiple iterables in round-robin order."""
iterators = [iter(it) for it in iterables]
while iterators:
still_active = []
for it in iterators:
try:
yield next(it)
still_active.append(it)
except StopIteration:
pass
iterators = still_active
print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))
Output:
[1, 'a', True, 2, 'b', 3]
['A', 'C', 'G', 'B', 'D', 'H', 'E', 'F']
[0, 0, 1, 1, 2, 2, 3, 4]
Trace through the first test case — round_robin([1,2,3], ['a','b'], [True]):
Round 1: iterators = [it0, it1, it2]
next(it0) -> 1 (active)
next(it1) -> 'a' (active)
next(it2) -> True (active)
still_active = [it0, it1, it2]
Round 2: iterators = [it0, it1, it2]
next(it0) -> 2 (active)
next(it1) -> 'b' (active)
next(it2) -> StopIteration (dropped)
still_active = [it0, it1]
Round 3: iterators = [it0, it1]
next(it0) -> 3 (active)
next(it1) -> StopIteration (dropped)
still_active = [it0]
Round 4: iterators = [it0]
next(it0) -> StopIteration (dropped)
still_active = []
while iterators is now empty -> done
Why we build still_active instead of removing in-place: Modifying a list while iterating over it causes skipped elements (the same bug from Problem 8). Building a fresh list each round is safe and clear.
Alternative using itertools:
from itertools import cycle, islice
def round_robin_itertools(*iterables):
"""Round-robin using itertools (from the official Python docs)."""
num_active = len(iterables)
nexts = cycle(iter(it).__next__ for it in iterables)
while num_active:
try:
for next_func in nexts:
yield next_func()
except StopIteration:
num_active -= 1
nexts = cycle(islice(nexts, num_active))
This version from the itertools documentation is more concise but harder to understand. The manual approach above is clearer and works just as well for most use cases.
Key insight: Round-robin iteration is a fundamental scheduling algorithm. It guarantees fairness — no single iterable monopolizes the output. This same pattern appears in CPU scheduling, network packet scheduling, and distributed task queues. The core technique — maintaining a list of active iterators and pruning exhausted ones — is widely applicable beyond just round-robin.
def round_robin(*iterables):
"""Yield items from multiple iterables in round-robin order.
round_robin([1,2,3], ['a','b'], [True])
-> 1, 'a', True, 2, 'b', 3
When an iterable is exhausted, skip it and continue
with the remaining iterables.
"""
# Your code here
pass
# Test
print(list(round_robin([1, 2, 3], ['a', 'b'], [True])))
print(list(round_robin("AB", "CDEF", "GH")))
print(list(round_robin(range(3), range(5))))Expected Output
[1, 'a', True, 2, 'b', 3]\n['A', 'C', 'G', 'B', 'D', 'H', 'E', 'F']\n[0, 0, 1, 1, 2, 2, 3, 4]Hints
Hint 1: Convert all iterables to iterators first using iter().
Hint 2: Maintain a list of active iterators. Each round, try next() on each. Remove exhausted ones.
Hint 3: Be careful not to modify a list while iterating over it — collect survivors into a new list.
