Your code is good from a readability standpoint. I appreciate the type hints. My only minor suggestions are using if __name__ == "__main__":
for your driver code and lst
instead of arr
(arr
usually refers to a NumPy array, but there are also builtin Python arrays that aren't lists).
Here's a somewhat terser suggestion that uses a generator expression and a classic iterable flattening pattern:
from itertools import zip_longest
from typing import Generator
def bidirectional_iterate(lst: list, index: int) -> Generator:
zipped = zip_longest(lst[index:], reversed(lst[:index]))
return (x for pair in zipped for x in pair if x is not None)
if __name__ == "__main__":
lst = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
# 7 5 3 1 0 2 4 6 8 9 <- Expected order of iteration
print(list(bidirectional_iterate(lst, 4)))
That said, it's an antipattern to return a linear-sized generator in an algorithm that needs to use linear space just to set up the generator. Returning a generator implies the algorithm uses space substantially less than the output set.
Changing the generator expression to a list comprehension gives:
def bidirectional_iterate(lst: list, index: int) -> Generator:
zipped = zip_longest(lst[index:], reversed(lst[:index]))
return [x for pair in zipped for x in pair if x is not None]
As a bonus, the caller need not use list()
on the return value.
On the other hand, iteration algorithms are generally conducive to laziness, so it's usually a shame to throw the baby out with the bath water purely for the sake of more clever code; try to write it to keep the generator and get rid of the slices.
One approach that seems worth mentioning is treating the list as a graph with neighbors i - 1 if i < index else i + 1
running a breadth-first traversal on the list.
This incurs some overhead from the queue operations, so I wouldn't suggest it as categorically better than a purely index-based approach, but it's lazy, requires no dependencies and is pretty readable.
If q.pop(0)
looks scary, keep in mind the list will never have more than 3 elements, so it's constant time, probably not much slower than a collections.deque.popleft()
, which is the normal queue in Python. Feel free to use that and benchmark it if pop(0)
(rightfully) makes you nervous.
def bidirectional_iterate(lst: list, index: int) -> Generator:
q = [index, index - 1, index + 1]
while q:
i = q.pop(0)
if i >= 0 and i < len(lst):
yield lst[i]
if i != index:
q.append(i - 1 if i < index else i + 1)
Having offered this as a proof of concept, as suggested above, the nature of this algorithm is such that the entire list would need to be in memory anyway because the indices don't move in a forward-only direction.
Typical itertools algorithms and one-direction iteration functions like file readers seem like dramatically better fits for generators. For file readers, the input need not be fully in memory so your memory consumption can be line-by-line or even byte-by-byte.
In other cases, like itertools.permutations
, the size of the result set is dramatically (exponentially) larger than the input iterables and liable to blow up memory. Other itertools functions like cycle
return infinite sequences, another great use case for generators.
For this algorithm, I'd recommend going with the list version until you really find a critical use case for the generator. I'd be curious to hear what motivated you to pursue a generator in the first place.