99

What would be an efficient and pythonic way to check list monotonicity?
i.e. that it has monotonically increasing or decreasing values?

Examples:

[0, 1, 2, 3, 3, 4]   # This is a monotonically increasing list
[4.3, 4.2, 4.2, -2]  # This is a monotonically decreasing list
[2, 3, 1]            # This is neither
Asclepius
  • 49,954
  • 14
  • 144
  • 128
Jonathan Livni
  • 93,232
  • 99
  • 253
  • 352
  • 6
    It's better to use the terms "strictly increasing" or "non decreasing" to leave any ambiguity out (and in a similar way it's better to avoid "positive" and use instead either "non negative" or "strictly positive") – 6502 Feb 13 '11 at 10:26
  • 15
    @6502 the term monotonic is defined as either a non-increasing or non-decreasing set of ordered values, so there was no ambiguity in the question. – Autoplectic Feb 13 '11 at 18:33
  • if you are looking for **extracting the data part with certain monotonicity**, please have a look at: https://github.com/Weilory/python-regression/blob/master/regression/mono.py – Weilory Sep 27 '20 at 13:31

15 Answers15

189

It's better to avoid ambiguous terms like "increasing" or "decreasing" as it's not clear if equality is acceptable or not. You should always use either for example "non-increasing" (clearly equality is accepted) or "strictly decreasing" (clearly equality is NOT accepted).

def strictly_increasing(L):
    return all(x<y for x, y in zip(L, L[1:]))

def strictly_decreasing(L):
    return all(x>y for x, y in zip(L, L[1:]))

def non_increasing(L):
    return all(x>=y for x, y in zip(L, L[1:]))

def non_decreasing(L):
    return all(x<=y for x, y in zip(L, L[1:]))

def monotonic(L):
    return non_increasing(L) or non_decreasing(L)
6502
  • 108,604
  • 15
  • 155
  • 257
  • 18
    This is clear, idiomatic Python code, and its complexity is O(n) where the sorting answers are all O(n log n). An ideal answer would iterate over the list only once so it works on any iterator, but this is usually good enough and it's by far the best answer among the ones so far. (I'd offer a single-pass solution, but the OP prematurely accepting an answer curbs any urge I might have to do so...) – Glenn Maynard Feb 13 '11 at 09:20
  • 2
    just out of curiosity tested your implementation against using sorted. Yours is clearly a lot slower [ I used L = range(10000000) ]. It seems complexity of all is O(n), and I could not find implementation of zip. – Asterisk Feb 13 '11 at 09:56
  • 4
    Sort is specialized if the list is already sorted. Did you try the speed with a randomly shuffled list? Also note that with sort you cannot distinguish between strictly increasing and non decreasing. Also consider that with Python 2.x using `itertools.izip` instead of `zip` you can get an early exit (in python 3 `zip` already works like an iterator) – 6502 Feb 13 '11 at 10:17
  • 3
    @6502: just one function needed: import operator; def monotone(L, op): return all(op(x,y) for x, y in zip(L, L[1:])) and then just feed in what you want: operator.le or .ge or whatever – akira Feb 13 '11 at 10:55
  • 5
    zip and the slice operator both return entire lists, obviating the shortcut abilities of all(); this could be greatly improved by using itertools.izip and itertools.islice, as either strictly_increasing or strictly_decreasing should shortcut-fail very early. – Hugh Bothwell Feb 13 '11 at 15:05
  • In some environments the non_decreasing() has returned True when it should have returned False. On my Windows7 64bit with Python 2.6.6 32bit this is 100% reproducible. I've found that adding [] within the all() (thus encapsulating the list comprehension) solves this problem. I've opened a ticket about this in Python's bug system - http://bugs.python.org/issue11221 – Jonathan Livni Feb 16 '11 at 08:38
  • 1
    @Jonathan: In the end appears the bug was not in Python but that in your code the original `all` function had been replaced by `numpy` version. Once again the danger of `from ... import *` shows up. – 6502 Feb 17 '11 at 06:46
  • @Hugh Bothwell: In Python 3 `zip` is already lazy. The problem remains for the slice operator however (that's anyway pretty fast)... – 6502 Feb 17 '11 at 06:49
  • @Glenn - I have changed answer acceptance in the past, don't be discouraged :) – Jonathan Livni May 17 '11 at 06:20
  • 1
    using **any(x<=y)** instead of **all(x>y)**, would reduce the complexity. – yosemite_k Aug 10 '15 at 19:14
  • @yosemite_k: you'd need to add a `not`, and why do you think it would reduce the complexity? – 6502 Aug 10 '15 at 20:17
  • @6502 , unlike **all**, if any of tuples evaluate to True **any** breaks the loop before finishing**all** comparisons. – yosemite_k Aug 11 '15 at 12:40
  • 2
    @yosemite_k: `all` will also break before finishing once a comparison returns `False`. It's exactly the same duality of `and`/`or` that are both short-circuiting... – 6502 Aug 11 '15 at 14:27
  • `pairwise()` from the docs is nice https://docs.python.org/3/library/itertools.html though being in `itertools` itself would be even better. – altendky Mar 31 '18 at 18:51
  • @Glenn Maynard Whatever you say, Fermat ;) – andreasdr Feb 26 '19 at 09:38
  • @HughBothwell: I just posted a simple alternative solution without the potentially expensive temporary slices. – chqrlie Jul 12 '20 at 15:58
43

If you have large lists of numbers it might be best to use numpy, and if you are:

import numpy as np

def monotonic(x):
    dx = np.diff(x)
    return np.all(dx <= 0) or np.all(dx >= 0)

should do the trick.

Autoplectic
  • 7,468
  • 29
  • 30
  • Note that dx[0] is np.nan. You might want to use: dx = np.diff(x)[1:] to skip past it. Otherwise, at least for me, the np.all() calls always return False. – Ryan Aug 19 '15 at 18:51
  • @Ryan, why would `dx[0]` be `NaN`? What is your input array? – DilithiumMatrix Nov 16 '15 at 01:06
  • 1
    N/m, I was thinking that `np.diff()` made the first element `NaN` so the shape of the output matched the input, but that was actually a different piece of code that did that that bit me. :) – Ryan Dec 04 '15 at 00:00
26
import itertools
import operator

def monotone_increasing(lst):
    pairs = zip(lst, lst[1:])
    return all(itertools.starmap(operator.le, pairs))

def monotone_decreasing(lst):
    pairs = zip(lst, lst[1:])
    return all(itertools.starmap(operator.ge, pairs))

def monotone(lst):
    return monotone_increasing(lst) or monotone_decreasing(lst)

This approach is O(N) in the length of the list.

Michael J. Barber
  • 23,704
  • 8
  • 64
  • 85
20

@6502 has the perfect code for lists, I just want to add a general version that works for all sequences:

def pairwise(seq):
    items = iter(seq)
    last = next(items)
    for item in items:
        yield last, item
        last = item

def strictly_increasing(L):
    return all(x<y for x, y in pairwise(L))

def strictly_decreasing(L):
    return all(x>y for x, y in pairwise(L))

def non_increasing(L):
    return all(x>=y for x, y in pairwise(L))

def non_decreasing(L):
    return all(x<=y for x, y in pairwise(L))
Jochen Ritzel
  • 99,912
  • 29
  • 194
  • 188
17

The pandas package makes this convenient.

import pandas as pd

The following commands work with a list of integers or floats.

Monotonically increasing (≥):

pd.Series(mylist).is_monotonic_increasing

Strictly monotonically increasing (>):

myseries = pd.Series(mylist)
myseries.is_unique and myseries.is_monotonic_increasing

Alternative using an undocumented private method:

pd.Index(mylist)._is_strictly_monotonic_increasing

Monotonically decreasing (≤):

pd.Series(mylist).is_monotonic_decreasing

Strictly monotonically decreasing (<):

myseries = pd.Series(mylist)
myseries.is_unique and myseries.is_monotonic_decreasing

Alternative using an undocumented private method:

pd.Index(mylist)._is_strictly_monotonic_decreasing
Asclepius
  • 49,954
  • 14
  • 144
  • 128
  • The pandas method is now .is_monotonic which I think makes sense because it can be used in conjunction with the Python not keyword intuitively. This follows other conventions in Python such as heapq min and max heaps, where only min heap is default but max heap is just a reverse implementation with multiplying by -1. – mj_codec May 28 '22 at 16:51
4

Here is a functional solution using reduce of complexity O(n):

is_increasing = lambda L: reduce(lambda a,b: b if a < b else 9999 , L)!=9999

is_decreasing = lambda L: reduce(lambda a,b: b if a > b else -9999 , L)!=-9999

Replace 9999 with the top limit of your values, and -9999 with the bottom limit. For example, if you are testing a list of digits, you can use 10 and -1.


I tested its performance against @6502's answer and its faster.

Case True: [1,2,3,4,5,6,7,8,9]

# my solution .. 
$ python -m timeit "inc = lambda L: reduce(lambda a,b: b if a < b else 9999 , L)!=9999; inc([1,2,3,4,5,6,7,8,9])"
1000000 loops, best of 3: 1.9 usec per loop

# while the other solution:
$ python -m timeit "inc = lambda L: all(x<y for x, y in zip(L, L[1:]));inc([1,2,3,4,5,6,7,8,9])"
100000 loops, best of 3: 2.77 usec per loop

Case False from the 2nd element: [4,2,3,4,5,6,7,8,7]:

# my solution .. 
$ python -m timeit "inc = lambda L: reduce(lambda a,b: b if a < b else 9999 , L)!=9999; inc([4,2,3,4,5,6,7,8,7])"
1000000 loops, best of 3: 1.87 usec per loop

# while the other solution:
$ python -m timeit "inc = lambda L: all(x<y for x, y in zip(L, L[1:]));inc([4,2,3,4,5,6,7,8,7])"
100000 loops, best of 3: 2.15 usec per loop
Community
  • 1
  • 1
Assem
  • 10,850
  • 5
  • 53
  • 90
4

I timed all of the answers in this question under different conditions, and found that:

  • Sorting was the fastest by a long shot IF the list was already monotonically increasing
  • Sorting was the slowest by a long shot IF the list was shuffled/random or if the number of elements out of order was greater than ~1. The more out of order the list of course corresponds to a slower result.
  • Michael J. Barbers method was the fastest IF the list was mostly monotonically increasing, or completely random.

Here is the code to try it out:

import timeit

setup = '''
import random
from itertools import izip, starmap, islice
import operator

def is_increasing_normal(lst):
    for i in range(0, len(lst) - 1):
        if lst[i] >= lst[i + 1]:
            return False
    return True

def is_increasing_zip(lst):
    return all(x < y for x, y in izip(lst, islice(lst, 1, None)))

def is_increasing_sorted(lst):
    return lst == sorted(lst)

def is_increasing_starmap(lst):
    pairs = izip(lst, islice(lst, 1, None))
    return all(starmap(operator.le, pairs))

if {list_method} in (1, 2):
    lst = list(range({n}))
if {list_method} == 2:
    for _ in range(int({n} * 0.0001)):
        lst.insert(random.randrange(0, len(lst)), -random.randrange(1,100))
if {list_method} == 3:
    lst = [int(1000*random.random()) for i in xrange({n})]
'''

n = 100000
iterations = 10000
list_method = 1

timeit.timeit('is_increasing_normal(lst)', setup=setup.format(n=n, list_method=list_method), number=iterations)

timeit.timeit('is_increasing_zip(lst)', setup=setup.format(n=n, list_method=list_method), number=iterations)

timeit.timeit('is_increasing_sorted(lst)', setup=setup.format(n=n, list_method=list_method), number=iterations)

timeit.timeit('is_increasing_starmap(lst)', setup=setup.format(n=n, list_method=list_method), number=iterations)

If the list was already monotonically increasing (list_method == 1), the fastest to slowest was:

  1. sorted
  2. starmap
  3. normal
  4. zip

If the list was mostly monotonically increasing (list_method == 2), the fastest to slowest was:

  1. starmap
  2. zip
  3. normal
  4. sorted

(Whether or not the starmap or zip was fastest depended on the execution and I couldn't identify a pattern. Starmap appeared to be usually faster)

If the list was completely random (list_method == 3), the fastest to slowest was:

  1. starmap
  2. zip
  3. normal
  4. sorted (extremely bad)
Matthew Moisen
  • 14,590
  • 25
  • 104
  • 205
4
import operator, itertools

def is_monotone(lst):
    op = operator.le            # pick 'op' based upon trend between
    if not op(lst[0], lst[-1]): # first and last element in the 'lst'
        op = operator.ge
    return all(op(x,y) for x, y in itertools.izip(lst, lst[1:]))
akira
  • 5,812
  • 27
  • 36
  • I was thinking about a solution like this - but it fails if the list is monotonically increasing and the first two elements are equal. – Hugh Bothwell Feb 13 '11 at 15:02
  • @Hugh Bothwell: i check now the first and the last to get the trend: if they are equal then all other elements should be equal as well which then works for both operator.le and operator.ge – akira Feb 14 '11 at 08:02
4

@6502 has elegant python code for this. Here is an alternative solution with simpler iterators and no potentially expensive temporary slices:

def strictly_increasing(L):
    return all(L[i] < L[i+1] for i in range(len(L)-1))

def strictly_decreasing(L):
    return all(L[i] > L[i+1] for i in range(len(L)-1))

def non_increasing(L):
    return all(L[i] >= L[i+1] for i in range(len(L)-1))

def non_decreasing(L):
    return all(L[i] <= L[i+1] for i in range(len(L)-1))

def monotonic(L):
    return non_increasing(L) or non_decreasing(L)
chqrlie
  • 114,102
  • 10
  • 108
  • 170
1
L = [1,2,3]
L == sorted(L)

L == sorted(L, reverse=True)
Zack Bloom
  • 8,199
  • 2
  • 19
  • 26
Asterisk
  • 3,476
  • 2
  • 33
  • 53
  • I'd have gone for `sorted()` if it didn't actually sort anything, just check. Badly named -- sounds like a predicate when it isn't. – mike3996 Feb 13 '11 at 09:07
  • 15
    What's next? Using `sorted(L)[0]` instead of `min`? – 6502 Feb 13 '11 at 09:12
  • 5
    This is algorithmically poor; this solution is O(n log n), when this problem can be done trivially in O(n). – Glenn Maynard Feb 13 '11 at 09:14
  • @all agree with all of you, thanks for constructive criticism. – Asterisk Feb 13 '11 at 09:21
  • 1
    I tested all the solutions in this thread [here](http://stackoverflow.com/a/40581844/1391717), and found that the sorted method actually is the best... if the list is actually monotonically increasing. If the list has any items out of order, it becomes the slowest. – Matthew Moisen Nov 14 '16 at 04:25
  • While this code may answer the question, providing additional context regarding how and/or why it solves the problem would improve the answer's long-term value. - [From Review](https://stackoverflow.com/review/low-quality-posts/16233985) – Donald Duck May 25 '17 at 19:13
1

Here's a variant that accepts both materialized and non-materialized sequences. It automatically determines whether or not it's monotonic, and if so, its direction (i.e. increasing or decreasing) and strictness. Inline comments are provided to help the reader. Similarly for test-cases provided at the end.

    def isMonotonic(seq):
    """
    seq.............: - A Python sequence, materialized or not.
    Returns.........:
       (True,0,True):   - Mono Const, Strict: Seq empty or 1-item.
       (True,0,False):  - Mono Const, Not-Strict: All 2+ Seq items same.
       (True,+1,True):  - Mono Incr, Strict.
       (True,+1,False): - Mono Incr, Not-Strict.
       (True,-1,True):  - Mono Decr, Strict.
       (True,-1,False): - Mono Decr, Not-Strict.
       (False,None,None) - Not Monotonic.
    """    
    items = iter(seq) # Ensure iterator (i.e. that next(...) works).
    prev_value = next(items, None) # Fetch 1st item, or None if empty.
    if prev_value == None: return (True,0,True) # seq was empty.

    # ============================================================
    # The next for/loop scans until it finds first value-change.
    # ============================================================
    # Ex: [3,3,3,78,...] --or- [-5,-5,-5,-102,...]
    # ============================================================
    # -- If that 'change-value' represents an Increase or Decrease,
    #    then we know to look for Monotonically Increasing or
    #    Decreasing, respectively.
    # -- If no value-change is found end-to-end (e.g. [3,3,3,...3]),
    #    then it's Monotonically Constant, Non-Strict.
    # -- Finally, if the sequence was exhausted above, which means
    #    it had exactly one-element, then it Monotonically Constant,
    #    Strict.
    # ============================================================
    isSequenceExhausted = True
    curr_value = prev_value
    for item in items:
        isSequenceExhausted = False # Tiny inefficiency.
        if item == prev_value: continue
        curr_value = item
        break
    else:
        return (True,0,True) if isSequenceExhausted else (True,0,False)
    # ============================================================

    # ============================================================
    # If we tricked down to here, then none of the above
    # checked-cases applied (i.e. didn't short-circuit and
    # 'return'); so we continue with the final step of
    # iterating through the remaining sequence items to
    # determine Monotonicity, direction and strictness.
    # ============================================================
    strict = True
    if curr_value > prev_value: # Scan for Increasing Monotonicity.
        for item in items:
            if item < curr_value: return (False,None,None)
            if item == curr_value: strict = False # Tiny inefficiency.
            curr_value = item
        return (True,+1,strict)
    else:                       # Scan for Decreasing Monotonicity.
        for item in items: 
            if item > curr_value: return (False,None,None)
            if item == curr_value: strict = False # Tiny inefficiency.
            curr_value = item
        return (True,-1,strict)
    # ============================================================


# Test cases ...
assert isMonotonic([1,2,3,4])     == (True,+1,True)
assert isMonotonic([4,3,2,1])     == (True,-1,True)
assert isMonotonic([-1,-2,-3,-4]) == (True,-1,True)
assert isMonotonic([])            == (True,0,True)
assert isMonotonic([20])          == (True,0,True)
assert isMonotonic([-20])         == (True,0,True)
assert isMonotonic([1,1])         == (True,0,False)
assert isMonotonic([1,-1])        == (True,-1,True)
assert isMonotonic([1,-1,-1])     == (True,-1,False)
assert isMonotonic([1,3,3])       == (True,+1,False)
assert isMonotonic([1,2,1])       == (False,None,None)
assert isMonotonic([0,0,0,0])     == (True,0,False)

I suppose this could be more Pythonic, but it's tricky because it avoids creating intermediate collections (e.g. list, genexps, etc); as well as employs a fall/trickle-through and short-circuit approach to filter through the various cases: E.g. Edge-sequences (like empty or one-item sequences; or sequences with all identical items); Identifying increasing or decreasing monotonicity, strictness, and so on. I hope it helps.

NYCeyes
  • 4,695
  • 4
  • 48
  • 59
0
def IsMonotonic(data):
    ''' Returns true if data is monotonic.'''
    data = np.array(data)
    # Greater-Equal
    if (data[-1] > data[0]):
        return np.all(data[1:] >= data[:-1])
    # Less-Equal
    else:
        return np.all(data[1:] <= data[:-1])

My proposition (with numpy) as a summary of few ideas here. Uses

  • casting to np.array for creation of bool values for each lists comparision,
  • np.all for checking if all results are True
  • checking diffrence between first and last element for choosing comparison operator,
  • using direct comparison >=, <= instead of calculatin np.diff,
s.paszko
  • 508
  • 1
  • 5
  • 18
0

Here are two ways of determining if a list if monotonically increasing or decreasing using just range or list comprehensions. Using range is slightly more efficient because it can short-circuit, whereas the list comprehension must iterate over the entire list. Enjoy.

a = [1,2,3,4,5]
b = [0,1,6,1,0]
c = [9,8,7,6,5]

def monotonic_increase(x):
    if len(x) <= 1: return False

    for i in range(1, len(x)):
        if x[i-1] >= x[i]:
            return False
    return True

def monotonic_decrease(x):
    if len(x) <= 1: return False

    for i in range(1, len(x)):
        if x[i-1] <= x[i]:
            return False

    return True

monotonic_increase = lambda x: len(x) > 1 and all(x[i-1] < x[i] for i in range(1, len(x)))
monotonic_decrease = lambda x: len(x) > 1 and all(x[i-1] > x[i] for i in range(1, len(x)))

print(monotonic_increase(a))
print(monotonic_decrease(c))
print(monotonic_decrease([]))
print(monotonic_increase(c))
print(monotonic_decrease(a))
print(monotonic_increase(b))
print(monotonic_decrease(b))
Soubriquet
  • 2,206
  • 7
  • 32
  • 48
0
def solution1(a):
    up, down = True, True
    for i in range(1, len(a)):
        if a[i] < a[i-1]: up = False
        if a[i] > a[i-1]: down = False
    return up or down
    
def solution2(a):
    return a == sorted(a[::-1])

Solution1 is O(n) and the shorter solution2 is O(n log n)

mattino
  • 85
  • 7
-1
>>> seq = [0, 1, 2, 3, 3, 4]
>>> seq == sorted(seq) or seq == sorted(seq, reverse=True)
Asclepius
  • 49,954
  • 14
  • 144
  • 128
Senthil Kumaran
  • 51,269
  • 14
  • 86
  • 124