0

I want a function check to check that a given list reduces ("boils down") to exactly one value under a given function reduce_function. (A common example could be to check that a list of lists contains sublists of equal length only.)

I see at least the three following ways to achieve this. For each of them, I see some advantages and disadvantages. None of them really looks very readable to my eye. Could you give me an elaborate overview about:

Which one would be considered most readable and most "pythonic"?

1. Measure the length of the set of reduced values.

This seems most readable, but requires reduce_function to return a hashable:

def check(lst):
    return len(set(map(reduce_function, lst))) == 1

2. Count the number of groups

def check(lst):
    return len(list(itertools.groupby(lst, key=reduce_function)) == 1

3. Use all on comparison with first element

This needs an additional or-statement (which could be replaced by an if-else-statement) to cover the case of lst being empty.

def check(lst):
    return not lst or all([reduce_function(el) == reduce_function(lst[0]) for el in lst])
Jonathan Scholbach
  • 4,925
  • 3
  • 23
  • 44
  • What does `reduce_function` return? – Jab Nov 13 '19 at 12:31
  • @Jab I would like to get an answer that is agnostic of the return type of `reduce_function` :) Which scenarios come to your mind, depending on the return type? – Jonathan Scholbach Nov 13 '19 at 12:33
  • I think any of the three options are good (I like the first one better). I would not be worried about this implementation detail, especially regarding readability: just add a comment and it will be understandable:) – Corentin Pane Nov 13 '19 at 12:35
  • Maybe you need to check the performance of 3 methods, as the readability is good. – shaik moeed Nov 13 '19 at 12:44

3 Answers3

4

I like all 3 options although the third doesn't need to be a list comprehension, just take the square brackets off.

Like your second option the itertools documentation has a recipe called all_equal that checks if all elements in an iterable are equal using itertools.groupby as well, although they didn't account for a custom function and defaulting to false when empty but it can easily be implemented:

def all_equal(iterable, key=reduce_function):
    g = groupby(iterable, key)
    return next(g, False) and not next(g, False)

itertools.all_equal is the "pythonic" way to check if all elements are equal in an iterable; I only modified it to fit your needs.

Jab
  • 26,853
  • 21
  • 75
  • 114
  • I like the idea, but having a hard time wrapping my head around that `return` line, and I _think_ that this is not entirely correct, particularly if the list contains falsey elements. – tobias_k Nov 13 '19 at 12:59
  • Ah, no, falsey elements are not a problem as `g` is not the key but the `(key, group)` tuple and thus always truthy. – tobias_k Nov 13 '19 at 13:09
  • `groupby` returns an iterator, so if it's empty then the first `next` defaults to `False`, and if all elements are `False` then it still works because `False and False == True` and `False and True == False` vice verse – Jab Nov 13 '19 at 13:11
  • @tobias_k refer to my P.S I added in. *Actually I just made a full edit. – Jab Nov 13 '19 at 13:21
1

This is probably a bit opinion based, but there are also objective reasons for or against the different alternatives. I'll focus on the first and third one here.

The first approach, converting to a set and testing it's length, is IMHO the cleanest, but it has O(n) additional space requirement (in the worst case of all elements being not the same). It also works with any iterable, whereas the third one only works if lst is actually a list. In it's current form the third approach also has O(n) space complexity, too, (in all cases) due to the list comprehension [...] within the all; you can use a generator expression instead. Further, reduce_function(lst[0]) is recomputed for each other element. Finally, the not lst or is redundant, as all of an empty list is True.

Also, note that if you want to test if the list "boils down" to at most one value, as implied by the not lst or, you should check len(...) <= 1 for the first two approaches.


I did not test this, but I think this should work, and a) work with non-hashable reduce_function, b) be O(1) space complexity, c) work with lists or iterables, and d) respect the empty-list corner case:

def check(lst):
    return sum(1 for _ in itertools.groupby(lst, key=reduce_function)) <= 1

This will still evaluate reduce_function for the entire lst, though, even if it's already clear that there is more than one distinct value.

tobias_k
  • 81,265
  • 12
  • 120
  • 179
  • I think , `not lst or` is not redundant, because an empty `lst` would lead to `IndexError` in `lst[0]`. But you are right, this edge-case has not been taken care of in the first two methods. – Jonathan Scholbach Nov 13 '19 at 12:57
  • @jonathan.scholbach No, as the function will only be evaluated within the loop in the list comprehension. – tobias_k Nov 13 '19 at 12:58
0

If you want to check if all values can be reduced to the same value you don't need to go through the whole list. You can stop as soon as you find a value that is not equal to the first element in the list. It will be more efficient:

def check(func, lst):
    it = iter(lst)
    first = func(next(it))
    for i in it:
        if func(i) != first:
            return False
    return True

You can also replace the for loop with the function all. In your solution with all you calculate the first element in the list len(lst) + 1 times.

def check(func, lst):
    it = iter(lst)
    first = func(next(it))
    return all(first == func(i) for i in it)

check(sum, [[0, 1], [0, 1], [0, 1]])
# True

check(sum, [[1, 1], [0, 1], [0, 1]])
# False
Mykola Zotko
  • 15,583
  • 3
  • 71
  • 73
  • I think, `all(reduce_function(el) == reduce_function(lst[0]) for el in lst)` (using the generator, not the list, as in my original post) will break as soon as any element is `False`, too, doesn't it? – Jonathan Scholbach Nov 13 '19 at 13:55