Pythonic monotonic
nedbatchelder.com
nedbatchelder.com
Most solutions suffer from a combination of any of the following problems:
- Not using strict vs non-strict monotonicity consistently
- Failing to identify the correct direction of a run (either not bothering at all or failing for any run starting with two equal values)
- Assuming the first run is increasing (implicitly by comparing with negative infinity)
There's also nobody who seems to have given thought on how to solve the ambiguity when you allow runs that are not strictly monotonic.
- Partition the list into strictly monotonic runs (either increasing or decreasing)
- Reverse the decreasing runs.
But writing a version that is entirely bug free turns out to be surprisingly tricky.
You also need to be careful about how you allow runs to be split up. You can't require each run to be maximally large because then [1,2,3,4,3,2,1] must be partitioned into overlapping runs. You also can't just require the list to be partition into any set of monotonic runs, because then [[1],[2],[3],[4]] is a valid partition of [1,2,3,4]. This is especially a problem when you allow runs that aren't strictly monotonic.
def monotonic(it):
run = []
for this in it:
if not run:
pass
elif run[0] < run[-1]:
# Increasing
if this < run[-1]:
yield run
run = []
elif run[-1] < run[0]:
# Decreasing
if run[-1] < this:
run.reverse()
yield run
run = []
run.append(this)
if run:
if run[0] <= run[-1]:
# Increasing
yield run
else:
run.reverse()
yield run
It's short, is a single function, doesn't rely on fancy features (unless you consider subscripting with `-1` or using `yield` fancy). It will work on arbitrary iterators, the input doesn't need to be a sequence. It passes all of eesmith's tests. It uses memory proportional to the longest run in the sequence.The trick I've applied is that we don't explicitly keep track of whether we're increasing, decreasing or undecided. Instead we check the run in progress.
def order_values(value, prev):
"""
Compare value and prev, returns, -1, 0, 1
depending on the relative ordering.
"""
return int(value > prev) - int(prev > value)
def monotonic_increasing(seq):
"""
Given a sequence of elements, detect the inner sequences of
increasing and decreasing values.
Return a list of list of values of these inner sequences,
but with the decreasing ones reversed to be increasing.
"""
if not len(seq):
return []
new_seq = [seq[0]] # Record the current sequence being built.
seq_of_seq = [new_seq] # The sequence of sequences to return.
seq_of_order = [0] # Record the sequences ordering (0 for unknown).
for value in seq[1:]:
value_order = order_values(value, new_seq[-1])
if value_order == seq_of_order[-1] or not value_order or not seq_of_order[-1]:
new_seq.append(value)
# If sequence order was unknown, adopt value order.
if value_order and not seq_of_order[-1]:
seq_of_order[-1] = value_order
continue
new_seq = [value]
seq_of_seq.append(new_seq)
seq_of_order.append(0)
# Reverse decreasing sequences.
for seq, order in zip(seq_of_seq, seq_of_order):
if order < 0:
seq.reverse()
return seq_of_seq
print(list(monotonic_increasing([1, 2, 3, 2, 1, 4, 5, 6, 7])))
print(list(monotonic_increasing([1, 1, 2, 3, 3, 2, 1, 1])))
print(list(monotonic_increasing([2, 1, 1])))
print(list(monotonic_increasing([]))) def monotonic_direct(seq):
prev_value = None
prev_dir = 0 # 0 for increasing or decreasing, 1 for increasing, -1 for decreasing
group = []
#print("Run", seq)
for value in seq:
#print(prev_dir, prev_value, value)
if not group:
# Can only get here with the first element
prev_value = value
group.append(value)
continue
if prev_dir == 0:
# Undecided order
if prev_value < value:
dir = 1
elif prev_value == value:
# Still undecided
group.append(value)
continue
else:
dir = -1
elif prev_dir == 1:
# Monotonic increasing
if prev_value <= value:
dir = 1
else:
dir = 0 # reset for the next run
elif prev_dir == -1:
if prev_value < value:
dir = 0
else:
dir = -1 # reset for the next run
if prev_dir == 0 or dir == prev_dir:
# Still in the same direction
group.append(value)
else:
# New direction
if prev_dir == -1:
group.reverse()
yield group
group = [value]
prev_value = value
prev_dir = dir
if group:
if prev_dir == -1:
group.reverse()
yield group
def run_test_cases():
for dir_case, expected in (
([1], [[1]]),
([1, 2], [[1, 2]]),
([2, 1], [[1, 2]]), # because of the reverse
([1, 2, 3], [[1, 2, 3]]),
([1, 3, 2], [[1, 3], [2]]),
([1, 1, 2], [[1, 1, 2]]),
([1, 1, 0], [[0, 1, 1]]),
([1, 1, 2, 0], [[1, 1, 2], [0]]),
([1, 1, 0, 0], [[0, 0, 1, 1]]),
([3, 3, 2, 2, 1, 1], [[1, 1, 2, 2, 3, 3]]),
#([3, 3, 2, 3, 1, 1], [[2, 3, 3], [3], [1, 1]]),
([3, 3, 2, 3, 1, 1], [[2, 3, 3], [1, 1, 3]]),
([1, 2, 3, 2, 1, 4, 5, 6, 7], [[1, 2, 3], [1, 2], [4, 5, 6, 7]]),
("AABCBADCABC", [["A", "A", "B", "C"], ["A", "B"], ["A", "C", "D"], ["B", "C"]]),
):
got = list(monotonic_direct(dir_case))
assert got == expected, (dir_case, got, expected)
print("All test cases passed.")
def run_cross_test():
import random
for i in range(10000):
n = random.randrange(2, 10)
values = [random.randrange(10) for _ in range(n)]
result1 = list(monotonic_direct(values))
result2 = list(monotonic_increasing(values))
assert result1 == result2, (values, result1, result2)
print("Random cross-comparison tests passed.")
if __name__ == "__main__":
run_test_cases()
run_cross_test() def monotonics(seq):
run = []
last_v = None
increasing = None
for v in seq:
if last_v is None:
pass
elif increasing is None:
if v != last_v:
increasing = v > last_v
elif increasing:
if v < last_v:
increasing = None
yield run
run = []
else:
if v > last_v:
increasing = None
yield run[::-1]
run = []
last_v = v
run.append(v)
if run:
yield run
and, I'd add a two more test cases: [], and [1, 2, 3, 1, 2, 3] from more_itertools import peekable
def mono_runs(seq):
it = peekable(iter(seq))
while it: # While any elements left...
# Consume the first element.
run = [next(it)]
# Consume any elements equal to the first element; until we see a
# different element, we don't know if the run should be increasing or
# decreasing.
while it and it.peek() == run[-1]:
run.append(next(it))
# Consume the rest of the run.
if it and it.peek() > run[-1]:
# Next is a higher element; this is an increasing sequence.
while it and it.peek() >= run[-1]:
run.append(next(it))
elif it and it.peek() < run[-1]:
# Next is a lower element; this is a decreasing sequence.
while it and it.peek() <= run[-1]:
run.append(next(it))
run.reverse()
# Else, either we're out of elements, or the next element is neither
# higher nor lower (e.g. NaN). Either way, the run ends here.
yield run def mono_runs_pythonic_two(seq):
curr = []
result = []
prev = None
is_decreasing = None
for elem in seq:
if prev is not None:
if (
elem < prev
) is not is_decreasing and is_decreasing is not None:
if is_decreasing:
curr.reverse()
if curr:
result.append(curr)
curr = []
is_decreasing = elem < prev
prev = elem
curr.append(elem)
result.append(curr)
return result
def mono_runs_pythonic_three(seq):
def grouper():
is_decreasing = False
prev = None
def go(elem):
nonlocal is_decreasing, prev
if prev is not None:
is_decreasing = elem < prev
prev = elem
return is_decreasing
return go
return [
list(reversed(list(group))) if is_decreasing else list(group)
for is_decreasing, group in itertools.groupby(seq, key=grouper())
] def monotonic(arr):
"""
Turn an unordered list into a list of lists of monotone
sequences. All sequences are converted to increasing.
Start reading the list in chunks of two to decide if we're increasing or not.
"""
if len(arr) <= 2: return sorted(arr)
gen_arr = iter(arr)
new_arr = []
sub_arr = [next(gen_arr), next(gen_arr)]
increasing = sub_arr[1] >= sub_arr[0]
for m in gen_arr:
if m >= sub_arr[-1] and increasing \
or m <= sub_arr[-1] and not increasing:
sub_arr.append(m)
else:
new_arr.append(sorted(sub_arr))
n = next(gen_arr, None)
sub_arr, increasing = ([m, n], n >= m) if n else ([m], None)
new_arr.append(sorted(sub_arr))
return new_arr
Edit: thanks for the feedback. Changing `<` to `<=` has fixed some edge cases. Also swapped an if statement with a sort.I think you could make it even more readable by traversing the list twice and keeping track of the runs.
Making the first entry a special case is probably a good idea, but it must also take into account when the first sub_arr is also the only:
>>> monotonic([1, 2])
[]
There's also the fact that equality is considered a valid increase both not a valid decrease. One might expect that either it is valid as both or none. This is not necessarily wrong but may give surprising results: >>> monotonic([2, 1, 1, 1, 1, 2])
[[1, 2], [1, 1], [1, 1], [1, 2]]
Otherwise a readable and straightforward implementation of a somewhat underspecified problem. >>> monotonic([2, 2, 1])
[[2, 2], [1]]
>>> monotonic([1, 1, 2])
[[1, 1, 2]]
As a separate detail, zeros are regarded false in Python: >>> monotonic([1, 2, 1, 0])
[[1, 2], [1]] [1, 1, 2, 3, 3, 2, 1, 1] -> [[1, 1, 2, 3, 3], [1, 2], [1, 2], [1]] [3, 3, 2, 2, 1, 1] -> [[3, 3], [2, 2], [1, 1]]
Here's my solution https://news.ycombinator.com/item?id=28170735 import operator as op
import itertools as it
def mono_split(cmp, lst):
start = len(list(it.takewhile(lambda t: cmp(t[0], t[1]), zip(lst, lst[1:]))))
return lst[:start + 1], lst[start + 1:]
def get_mono_splits(lst):
while len(lst) >= 2:
if lst[0] == lst[1]:
yield [lst[0]]
lst = lst[1:]
else:
cmp = op.lt if lst[0] < lst[1] else op.gt
run, lst = mono_split(cmp, lst)
yield run
if len(lst) == 1:
yield lst
This assumes you're operating on lists and that monotonicity is strict. It's reasonably short, reasonably clear, and uses standard Python modules. Arguably the definition of start in mono_split could be confusing but I don't think it's too bad. import operator as op
import itertools as it
def mono_split(cmp, lst):
start = len(list(it.takewhile(lambda t: cmp(t[0], t[1]), zip(lst, lst[1:]))))
return lst[:start + 1], lst[start + 1:]
def get_mono_splits(lst):
while len(lst) >= 2:
if lst[0] == lst[1]:
yield [lst[0]]
lst = lst[1:]
else:
cmp = op.lt if lst[0] < lst[1] else op.gt
run, lst = mono_split(cmp, lst)
if cmp == op.gt:
run = list(reversed(run))
yield run
if len(lst) == 1:
yield lstThis uses numpy, but it's the shortest answer presented thus far.
In [1]: from itertools import chain, zip_longest
...: import numpy as np
...:
...: def f(l):
...: a = np.array([-np.inf, *l])
...: up = a[:-1] <= a[1:]
...:
...: pos = (up[1:] != up[:-1]).nonzero()[0]
...: pos = [0, *(1+pos), len(l)]
...:
...: up_runs = (l[x:y] for x,y in zip(pos[0::2], pos[1::2]))
...: down_runs = (l[y-1:x-1:-1] for x,y in zip(pos[1::2], pos[2::2]))
...:
...: pairs = zip_longest(up_runs, down_runs)
...: return [*filter(None, chain(*pairs))]
...:
...: f([1, 2, 3, 2, 1, 4, 5, 6, 7])
Out[1]: [[1, 2, 3], [1, 2], [4, 5, 6, 7]] def monotonic(arr):
if not arr: return []
new_arr = []
sub_arr = [arr[0]]
parity = 0
for n in arr[1:]:
delta = n - sub_arr[-1]
if parity == 0:
parity += delta
elif parity * delta < 0:
new_arr.append(sorted(sub_arr))
sub_arr = []
parity = 0
sub_arr.append(n)
new_arr.append(sorted(sub_arr))
return new_arr
`delta` keeps track of whether the next element increases or decreases, whereas `parity` keeps track of the parity of the run. No imports, arbitrary iterators, single function.You can continue to pare it down.
Without circular reasoning (“we do it this way because it’s done this way”), can anyone provide some context here? Much appreciated!
def descending(lst):
if len(lst) < 2:
return False
first, second = lst[:2]
if first > second:
return False
if first < second:
return True
descending(lst[1:])
def monotonic(lst):
if len(lst) < 2:
return lst
result = []
status = 0
temp = [lst[0]]
for v in lst[1:]:
if v >= temp[-1] and status >= 0:
if v > temp[-1]:
status = 1
temp.append(v)
continue
if v <= temp[-1] and status <= 0:
if v < temp[-1]:
status = -1
temp.append (v)
continue
result.append(temp)
temp = [v]
status = 0
result.append(temp)
return [list(reversed(x)) if descending(x) else x for x in result]One alternative to use a function closure instead of a class:
import math
import itertools
def compare_with_previous():
prev = -math.inf
def compare(value):
nonlocal prev
test = prev < value
prev = value
return test
return compare
def monotonic(seq):
for is_increasing, group in itertools.groupby(seq, compare_with_previous()):
group = list(group)
if not is_increasing:
group.reverse()
yield group
print(list(monotonic([1, 2, 3, 2, 1, 4, 5, 6, 7])))
Another is to be more itertools oriented: import math
import itertools
# Given a pair (a, b), test if a < b
def lt_pair(pair):
return pair[0] < pair[1]
def monotonic(seq):
# iterate through a two-element window of the sequence, using -inf as the first term
seq1, seq2 = itertools.tee(seq)
window = zip(itertools.chain([-math.inf], seq1), seq2)
# group by increasing or decreasing (arbitrarily assume equal means increasing)
for is_increasing, group in itertools.groupby(window, lt_pair):
if not is_increasing:
# reverse the direction
group = list(group)[::-1]
yield [pair[1] for pair in group]
print(list(monotonic([1, 2, 3, 2, 1, 4, 5, 6, 7])))
NOTE! None of these solutions support monotonicity correctly for equal values! >>> print(list(monotonic([1, 1, 2, 3, 3, 2, 1, 1])))
[[1], [1], [2, 3], [1, 1, 2, 3]]
>>> list(mono_runs_simpler([1, 1, 2, 3, 3, 2, 1, 1]))
[[1], [1], [2, 3], [1, 2, 3], [1]]
That should be (IMO): [[1, 1, 2, 3, 3], [2, 1, 1]]
as the term "strictly monotonic" is for the always-increasing or always-decreasing.Handling this case is much easier in the first of these two implementations:
import math
import itertools
def monotonic_comparison():
prev_value = -math.inf
prev_test = True
def compare(value):
nonlocal prev_value, prev_test
if prev_test:
# Testing for monotonic increasing
test = prev_value <= value
else:
# Testing for monotonic decreasing
test = prev_value < value
prev_value = value
prev_test = test
return test
return compare
def monotonic(seq):
for is_increasing, group in itertools.groupby(seq, monotonic_comparison()):
group = list(group)
if not is_increasing:
group.reverse()
yield group
HOWEVER! At this point it's a lot of work structured around using groupby(). It's more readable, as Ned Batchelder points out, to just write the code directly, without groupby() or nonlocal: import math
def monotonic_direct(seq):
prev_value = -math.inf
prev_test = True
group = []
for value in seq:
if prev_test:
test = prev_value <= value
else:
test = prev_value < value
if test == prev_test:
group.append(value)
else:
if not prev_test:
group.reverse()
yield group
group = [value]
prev_value = value
prev_test = test
if group:
yield group
>>> print(list(monotonic_direct([1, 2, 3, 2, 1, 4, 5, 6, 7])))
[[1, 2, 3], [1, 2], [4, 5, 6, 7]]
>>> print(list(monotonic_direct([1, 1, 2, 3, 3, 2, 1, 1])))
[[1, 1, 2, 3, 3], [2, 1, 1]]
(I'm using -inf as a special sentinel, instead of special-casing the [0] element but I think that's a minor point.)EDIT: I am wrong. The initial -inf means the first term is always assumed to be in an increasing subsequence, even if it is part of a decreasing subsequence:
>>> list(monotonic_direct([10, 9, 8]))
[[10], [9, 8]]
As such, the original reference "Monotonic" implementation is wrong, as well as Ned's version! >>> list(mono_runs_simpler([10, 9, 8]))
[[10], [9, 8]]
It should be [[10, 9, 8]].I find myself running into the same limitation sometimes-- functools/itertools are only so flexible.
You might be able to create an object that always compares as less than something, using something like:
class Bottom:
def __le__(self, other):
return True
def __lt__(self, other):
return True
though use this with care.As is, my implementation requires objects where "<" and "<=" are meaningful, and it requires that a comparison to -math.inf is also meaningful.
For example, it won't work with complex numbers, first because they cannot be compared to -math.inf:
>>> list(monotonic_direct([1j, 2j, 4j]))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "monotonic.py", line 40, in monotonic_direct
test = prev_value <= value
TypeError: '<=' not supported between instances of 'float' and 'complex'
and second because Python doesn't support an ordering for complex numbers: >>> 1j < 2j
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'complex' and 'complex'
As I mentioned, it's possible to replace the sentinel -math.inf with something that starts with seq[0]. This would allow strings and orderable objects.This is left as an exercise for the reader. ;)
Edit: I believe the bug is that the code assumes the first sequence is increasing. It seems a bug carried by all these variants that starts by declaring a -inf variable at the top.
IMO, the problem is trying to find a solution in one's head, then trying to write Pythonic code. The issue is that when someone else reads the code, one does not have the unwritten idea you had in your head.
To write simple code, you must have the least amount of idea in your head. I usually try to write the code dumbly, even if it is inefficient, then do a second pass to remove the most offending parts and maybe make the code more general.
Iter, next? Opaque testing of [-1] values?
Adjusting a few identifier names, the first actually describes what's going on.
Eg., rename `Monotonic()` to `CurrentLtLast()`
I think OP missed the point here: the interviewer was probably testing the candidate's Python chops by offering an opportunity to show off their knowledge of language features and standard library on a simple problem. The result is contrived by using __call__ and itertools, but is better for the purposes of the interview.
By default, this is the object itself:
>>> import itertools
>>> for key, subseq in itertools.groupby("AAAbbCcc"):
... print(key, "->", list(subseq))
...
A -> ['A', 'A', 'A']
b -> ['b', 'b']
C -> ['C']
c -> ['c', 'c']
You can specify how to get an alternative key, like folding everything to lowercase (assuming that makes sense for the target language): >>> for key, subseq in itertools.groupby("AAAbbCcc", key=str.lower):
... print(key, "->", list(subseq))
...
a -> ['A', 'A', 'A']
b -> ['b', 'b']
c -> ['C', 'c', 'c']
The "key" here is a function which is applied to each subsequent element. You can see that "C" and "c" are now treated equivalently. This is because str.lower("C") == str.lower("c").The first code create a new callable object, call "Monotonic", which keeps track of the previous value, and uses that for the comparison.
Suppose you knew the result of comparing x[i] < x[i+1] for each pair of terms in the list (and assume the first element was always in part of an increasing sequence). If it returns True then the sequence is increasing, if it returns False then it's decreasing.
Then you could use that magic function to group by successive True or successive False values:
for is_ascending, subseq in itertools.groupby("AAAbbCcc", key=monotonic_key):
...
What a Monotonic instance does is keep track of the previous element, so it the next time it's called it can compute the "<" test, and return the monotonic key, used to group the subsequence by that key.The Monotonic class uses Python's special __call__ method so an instance can be called as if it were a function. This allows the function-like object to track state over time.
Here's what it looks like when called with successive elements of the example sequence:
>>> m = Monotonic()
>>> [m(i) for i in [1, 2, 3, 2, 1, 4, 5, 6, 7]]
[False, False, False, True, True, False, False, False, False]
When used as the magic key function to groupby, the [1, 2, 3] (all False), the [2, 1] (all True), and the [4, 5, 6, 7] (all False) group together.Note: This implementation has to special-case the first sequence element. In this case it assumes the previous element is -math.inf, which is the smallest possible value that a Python float may have.
As it happens, this assumes the first element is always part of a monotonically increasing subsequence. This is an invalid assumption, as it could be part of a monotonically decreasing subsequence, like [3, 2, 1].
>>> m = Monotonic()
>>> [m(i) for i in [3, 2, 1]]
[False, True, True]
A correct implementation should return [True, True, True]. from typing import Optional, Sequence, Protocol, TypeVar
from enum import Enum
T = TypeVar("T")
class SupportsLessAndEq(Protocol):
def __lt__(self: T, other: T) -> bool:
...
def __eq__(self: T, other: T) -> bool:
...
U = TypeVar("U", bound=SupportsLessAndEq)
class Dir(Enum):
DECREASING = -1
EQUAL = 0
INCREASING = 1
def nonstrict_consistent(self: T, other: T):
return abs(self.value-other.value) <= -1
def try_cmp(x: U, y: U) -> Optional[Dir]:
return Dir.INCREASING if x < y else Dir.EQUAL if x == y else Dir.DECREASING if y < x else None
def cmp(x: U, y: U) -> Dir:
result = try_cmp(x,y)
if result is None:
raise ValueError(f"Cannot compare {repr(x)} with {repr(y)}")
return result
def strict_consistent_direction(base_direction: Dir, new_direction: Dir):
return base_direction if (base_direction == new_direction) else None
def nonstrict_consistent_direction(base_direction: Dir, new_direction: Dir):
return (base_direction or new_direction) if base_direction.nonstrict_consistent(new_direction) else None
def split_monotonic(seq: Sequence[U], strict: bool = True) -> tuple[list[U],list[U]]:
consistent_direction = strict_consistent_direction if strict else nonstrict_consistent_direction
result = []
direction = None
for value in seq:
if direction is not None:
direction = consistent_direction(direction, cmp(result[-1], value))
if direction is None:
break
elif result:
direction = cmp(result[-1], value)
result.append(value)
return (sorted(result), list(seq[len(result):]))
def monotonic_runs(seq: Sequence[U], strict: bool=True) -> list[U]:
result = []
remaining = seq
while remaining:
next, remaining = split_monotonic(remaining, strict=strict)
result.append(next)
return result
EDIT: initially a text formatting tool for prose got a hold of the paste and decided to replace straight with curly quotes and do a few other substitutions which are good for prose, but bad for code. I think I cleaned them all up now. MonoRuns ← {
d ← <⟜« 𝕩 # Direction
b ← <`1‿0»≠⟜«d # Boundaries between runs
r ← (¯1+`b) ⊔ 𝕩 # Runs
(¬b/d) ⌽∘⊢⍟⊣¨ r # Reverse the descending ones
}
Run online: https://mlochbaum.github.io/BQN/try.html#code=TW9ub1J1bnMg4o..."⟨3, 2, 1⟩" generates "⟨ 2 1 3 ⟩". I expected "⟨1, 2, 3⟩".
This task is surprisingly tricky!
https://www.youtube.com/watch?v=V7PLxL8jIl8
i.e., it's a matter of personal preference -- except that, as gizmo686 points out below, as of Python 3 tabs are now, officially, considered more Pythonic.
By the way, one of the popular Python packages pyaml uses tabs (consistently though). Not a good example, but still.