Is this function to simultaneously retrieve min and max values any faster than using min and max separately?

StackOverflow https://stackoverflow.com/questions/22283331

  •  11-06-2023
  •  | 
  •  

Question

I have a function to simultaneously retrieve the min and max values of a list:

def min_max(iterable, key=None):
    """Retrieve the min and max values of `iterable` simultaneously."""
    if key is None: key = lambda x: x
    if not iterable:
        return None, None
    min_ = max_ = key(iterable[0])
    for i in iterable[1:]:
        if key(i) > key(max_): max_ = i
        if key(i) < key(min_): min_ = i
    return min_, max_

But it got me wondering, since I do two comparisons in the for loop anyways, would it not be faster to simply use min and max separately? If it is, how could I edit this function to make it more efficient?

Was it helpful?

Solution

The expensive part in finding out the minimum or maximum value in a list is not the comparison. Comparing values is pretty fast, and won’t make a problem here. Instead, what impacts the run time is the loop.

When you are using min() or max(), then each of those will have to iterate over the iterable once. They do that separately, so when you need both the minimum and the maximum value, by using the built-in functions, you are iterating twice.

Your function just iterates once over it, so its theoretical run time is shorter. Now as chepter mentioned in the comments, min and max are implemented in native code, so they are most certainly faster than when implementing it in Python code yourself.

Now, it depends a lot on your iterable whether the two native loops will be faster than your Python function. For longer lists, where iterating it is already expensive, iterating it once will definitely be better, but for shorter ones, you probably get better results with the native code. I can’t tell where the exact threshold is, but you can easily test out for your actual data what’s faster. In most cases though, it rarely matters as a min/max won’t be the bottleneck of your application, so you just shouldn’t worry about it until it becomes a problem.


Btw. your implementation has a few problems right now, which you should fix if you want to use it:

  • It requires iterable to be a sequence, and not an iterable (as you use indexes on it)
  • You also require it to have at least one item—which technically isn’t required either. While you do check for not iterable, that won’t necessarily tell you something about the length of the sequence/iterable. Custom types can easily provide their own boolean value and/or sequence behavior.
  • Finally, you initialize your _min and _max with the keyed values of the iterable item, but later you (correctly) just assign the original item from the iterable.

So I would suggest you to use iterators instead, and fix that key thing—you can also store the key results to save some computation for more complex key functions:

it = iter(iterable)
try:
    min_ = max_ = next(it)
    minv = maxv = key(min_)
except StopIteration:
    return None, None

for i in it:
    k = key(i)
    if k > maxv:
        max_, maxv = i, k
    elif k < minv:
        min_, minv = i, k

I did some testing on this, and it turns out that—without a custom key function—using the built-in max/min is kind-of impossible to beat. Even for very large lists, the purce C implementation is just way too fast. However, as soon as you add in a key function (which is written in Python code), the situation is completely reversed. With a key function, you get pretty much the same timing result for a single min or max call as for the full function doing both. So using the solution written in Python is a lot faster.

So this lead to the idea that, maybe, the implementation in Python wasn’t the actual problem, but instead the key function that is used. And indeed, the actual key function is what makes the Python implementation expensive. And it makes a lot of sense too. Even with an identity-lamba, you still have the overhead of function calls; len(iterable) many function calls (with my optimized variant above). And function calls are quite expensive.

In my tests, with support for the key function taken out, the actually expected results appeared: Iterating just once is faster than twice. But for non very-large iterables, the difference is really small. So unless iterating the iterable is very expensive (although you could then use tee and still iterate twice) or you want to loop over it anyway (in which case you would combine that with the min/max check), using the built-in max() and min() functions separately will be faster and also a lot easier to use. And, they both come with the internal optimization that they skip key functions if you don’t specify one.

Finally though, how could you add that key function optimization into your code? Well, unfortunately, there’s only one way to do this and that involves duplicating code. You essentially have to check whether or not a key function is specified and skip the function call when it wasn’t. So, something like this:

def min_max(iterable, key=None):
    if key:
        # do it with a key function
    else:
        # do it without

OTHER TIPS

TL;DR

If the input is a NumPy array, see here.

If the input is a sequence:

  • min() and max() is generally faster than manual looping (unless one uses the key parameter and then it depends on how fast the function call is)
  • manual looping can be made faster with Python, with performances outperforming two separate calls to min() and max().

If the input is a non-sequence iterable:

  • min() and max() cannot be used to the same iterable
    • iterable needs to be casted to list() or tuple() (or one could use itertools.tee(), but as per its documentation, list()/tuple() casting is faster in this case), but has a large memory footprint
    • one can use a single looping approach, which can again be accelerated with Cython.

The case with explicit key is not treated here in detail, but an effective adaptation of one of the efficient approaches that can be Cython-ized is reported below:

def extrema_for_with_key(items, key=None):
    items = iter(items)
    if callable(key):
        try:
            max_item = min_item = next(items)
            max_val = min_val = key(item)
        except StopIteration:
            return None, None
        else:
            for item in items:
                val = key(item)
                if val > max_val:
                    max_val = val
                    max_item = item
                elif val < min_val:
                    min_val = val
                    min_item = item
            return max_item, min_item
    else:
        try:
            max_item = min_item = next(items)
        except StopIteration:
            return None, None
        else:
            for item in items:
                if item > max_item:
                    max_item = item
                elif item < min_item:
                    min_item = item
            return max_item, min_item

Full benchmark here.


Longer answer

While looping in pure Python may be your bottleneck, it is nevertheless true that the problem of finding both the maximum and the minimum can be solved with significantly less steps (less comparisons and less assignments) than two separate calls to max() and min() -- on a randomly distributed sequence of values, and more specifically by traversing the sequence (or iterable) only once. This may be useful when using the functionality provided by the use of the key parameter, or when the input is an iterator and converting it to a tuple() or list() (or using itertools.tee()) would result in excessive memory consumption. In addition, such approaches may result in faster execution if it is viable to accelerate the looping via Cython or Numba. In case the input is not a NumPy array, Cython's acceleration is most effective, while if the input is a NumPy array then Numba's acceleration results in largest speed-up. Typically, the cost of converting a generic input to a NumPy array is not offset by the speed gain of using Numba. A discussion for the case of NumPy arrays can be found here.

The base implementation, ignoring the key parameter, is the following (where min_loops() and max_loops() are essentially re-implementation with loops of min() and max()):

def min_loops(seq):
    iseq = iter(seq)  # ensure iterator
    try:
        result = next(iseq)
    except StopIteration:
        return None
    else:
        for item in iseq:
            if item < result:
                result = item
        return result


def max_loops(seq):
    iseq = iter(seq)  # ensure iterator
    try:
        result = next(iseq)
    except StopIteration:
        return None
    else:
        for item in iseq:
            if item > result:
                result = item
        return result


def extrema_loops(items):
    seq = tuple(items)  # required if items is actually an iterable
    return max_loops(seq), min_loops(seq)

These can be naïvely combined into a single loop, similarly to OP proposal:

def extrema_for(seq):
    iseq = iter(seq)
    try:
        max_val = min_val = next(iseq)
    except StopIteration:
        return None, None
    else:
        for item in iseq:
            if item > max_val:
                max_val = item
            elif item < min_val:  # <-- reduces comparisons
                min_val = item
        return max_val, min_val

where the use of the elif effectively reduces the number of comparisons (and assignments) "on average" (on inputs with randomly distributed values) to 1.5 per element.

The number of assignments can be further decreased by considering two elements "at once" (the number of comparisons is on average 1.5 per element in both cases):

def extrema_for2(seq):
    iseq = iter(seq)
    try:
        max_val = min_val = next(iseq)
    except StopIteration:
        return None, None
    else:
        for x, y in zip(iseq, iseq):
            if x > y:  # reduces assignments
                x, y = y, x
            if x < min_val:
                min_val = x
            if y > max_val:
                max_val = y
        try:
            last = next(iseq)
        except StopIteration:
            pass
        else:
            if last < min_val:
                min_val = x
            if last > max_val:
                max_val = y
        return max_val, min_val

the relative speed of each method greatly depends on the relative speed of each instruction, and alternate implementations of extrema_for2() may be faster. For example, if the main loop (for x, y in zip(iseq, iseq)) is replaced with a while True: x = next(iseq); y = next(iseq) construct, i.e.:

def extrema_while(seq):
    iseq = iter(seq)
    try:
        max_val = min_val = x = next(iseq)
        try:
            while True:
                x = next(iseq)
                y = next(iseq)
                if x > y:
                    x, y = y, x
                if x < min_val:
                    min_val = x
                if y > max_val:
                    max_val = y
        except StopIteration:
            if x < min_val:
                min_val = x
            if x > max_val:
                max_val = x
            return max_val, min_val
    except StopIteration:
        return None, None

this turns out to be slower in Python BUT faster with Cython acceleration.

These and the following implementations as baseline:

def extrema(seq):
    return max(seq), min(seq)
def extrema_iter(items):
    seq = tuple(items)
    return max(seq), min(seq)

are compared below:

bm

Note that in general:

  • extrema_while() > extrema_loops() > extrema_for() > extrema_for2() (this is due to the expensive calls to next())
  • extrema_loops_cy() > extrema_for_cy() > extrema_for2_cy() > extrema_while_cy()
  • extrema_loops_cy() is actually slower than extrema().

The functions have Cython counterpats (with _cy suffix) and are essentially the same code except for def replaced with cpdef, e.g.:

%%cython -c-O3 -c-march=native -a
#cython: language_level=3, boundscheck=False, wraparound=False, initializedcheck=False, cdivision=True, infer_types=True

cpdef extrema_while_cy(seq):
    items = iter(seq)
    try:
        max_val = min_val = x = next(items)
        try:
            while True:
                x = next(items)
                y = next(items)
                if x > y:
                    x, y = y, x
                if x < min_val:
                    min_val = x
                if y > max_val:
                    max_val = y
        except StopIteration:
            if x < min_val:
                min_val = x
            if x > max_val:
                max_val = x
            return max_val, min_val
    except StopIteration:
        return None, None

Full benchmark here.

Please check here:

This is not exactly what you are looking but, I can reduce the loop:

def min_max(iterable):
    if not iterable:
        raise Exception('Required iterable object')
    _min = _max = iterable[0]
    ind = 0
    if len(iterable) & 1:
        ind = 1
    for elm in iterable[1::2]:
        ind += 2
        try:
            if iterable[ind] < iterable[ind + 1]:
                if _min > iterable[ind]:
                    _min = iterable[ind]
                if _max < iterable[ind + 1]:
                    _max = iterable[ind + 1]
            else:
                if _min > iterable[ind + 1]:
                    _min = iterable[ind + 1]
                if _max < iterable[ind]:
                    _max = iterable[ind]
        except:
            pass
    return _min, _max

print min_max([11,2,3,5,0,1000,14,5,100,1,999])

Output:

(0, 1000)

use this code:

for i in iterable[1:]:
    if key(i) > key(max_): 
        max_ = i
    elif key(i) < key(min_):
        min_ = i
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top