Nasty gotcha/bug in heapq.nlargest/nsmallest
George Sakkis
george.sakkis at gmail.com
Thu May 15 08:02:46 EDT 2008
On May 15, 3:06 am, Peter Otten <__pete... at web.de> wrote:
> George Sakkis wrote:
> > I spent several hours debugging some bogus data results that turned
> > out to be caused by the fact that heapq.nlargest doesn't respect rich
> > comparisons:
>
> > import heapq
> > import random
>
> > class X(object):
> > def __init__(self, x): self.x=x
> > def __repr__(self): return 'X(%s)' % self.x
> > if True:
> > # this succeeds
> > def __cmp__(self, other): return cmp(self.x , other.x)
> > else:
> > # this fails
> > def __lt__(self, other): return self.x < other.x
>
> > s = [X(i) for i in range(10)]
> > random.shuffle(s)
>
> > s1 = heapq.nlargest(5, s)
> > s2 = sorted(s, reverse=True)[:5]
> > assert s1 == s2, (s,s1,s2)
>
> > s1 = heapq.nsmallest(5, s)
> > s2 = sorted(s)[:5]
> > assert s1 == s2, (s,s1,s2)
>
> > According to the docs, nlargest is equivalent to: "sorted(iterable,
> > key=key, reverse=True)[:n]" and similarly for nsmallest. So that must
> > be at least a documentation bug, if not an implementation one.
>
> Implementing a subset of the rich comparisons is always dangerous. According
> to my ad hoc test you need <, <=, and == for nlargest()/nsmallest() to
> work:
>
> import heapq
> import random
>
> used_rich = set()
>
> class X(object):
> def __init__(self, x): self.x=x
> def __repr__(self): return 'X(%s)' % self.x
> def __lt__(self, other):
> used_rich.add("lt")
> return self.x < other.x
> def __eq__(self, other):
> used_rich.add("eq")
> return self.x == other.x
> def __gt__(self, other):
> used_rich.add("gt")
> return self.x > other.x
> def __ge__(self, other):
> used_rich.add("ge")
> return self.x >= other.x
> def __ne__(self, other):
> used_rich.add("ne")
> return self.x != other.x
> def __le__(self, other):
> used_rich.add("le")
> return self.x <= other.x
>
> s = [X(8), X(0), X(3), X(4), X(5), X(2), X(1), X(6), X(7), X(9)]
>
> smallest = sorted(s)[:5]
> largest = sorted(s, reverse=True)[:5]
>
> print "used by sorted:", used_rich
> used_rich = set()
>
> for i in range(10000):
>
> s1 = heapq.nlargest(5, s)
> assert s1 == largest, (s, s1, largest)
>
> s1 = heapq.nsmallest(5, s)
> assert s1 == smallest, (s, s1, smallest)
>
> random.shuffle(s)
>
> print "used by nlargest/nsmallest:", used_rich
>
> Output:
> used by sorted: set(['lt'])
> used by nlargest/nsmallest: set(['lt', 'le', 'eq'])
Interesting, I don't get 'eq' on one box ("2.5 (r25:51908, Sep 19
2006, 09:52:17) [MSC v.1310 32 bit (Intel)]") but I get it on another
("2.5.1 (r251:54863, May 9 2007, 15:27:54) [GCC 3.3.5 (Debian
1:3.3.5-13)]").
A more interesting question is how many (and which) of the rich
comparisons can you omit while maintaining correctness (see script
below). The results on my v2.5 on Windows are:
1. Use ('lt', 'le') if both present
2. If only 'lt' is missing use ('le', 'gt')
3. If only 'le' is missing use ('lt', 'ge')
4. If both ('lt', 'le') are missing use ('gt', 'ge')
5. If 3 or more of ('lt', 'le', 'gt', 'ge') are missing use the
remaining one (if any) plus 'cmp'
6. If (5) holds and there is no 'cmp', nlargest/nsmaller are WRONG
The results on the v2.5.1 debian box are the same, with the addition
of 'eq' in all steps; if 'eq' is not present, 'cmp' is called, and if
neither 'cmp' is present, the results are still correct (provided any
of the cases 1-4 above hold of course). IOW, it does some extra
equality checks if it can, but these are optional since it can be
correct without them.
For sorted(), the results are identical on both boxes:
- Use only 'lt' if present else
- Use only 'gt' if present else
- Use only 'cmp' if present else
- WRONG RESULTS
IOW, no attempt to use 'le','ge','eq','ne' is made.
I wonder how stable is this behavior across different versions and
implementations (Jython, IronPython, etc).
George
==== testing script ==================================
import heapq
from random import shuffle
used = set(); add = used.add
class X(object):
def __init__(self, x): self.x=x
def __repr__(self): return 'X(%s)' % self.x
# try to remove one or more of these included in a used set
# and see what happens
def __gt__(self, other): add('gt'); return self.x > other.x
def __ge__(self, other): add('ge'); return self.x >= other.x
def __lt__(self, other): add('lt'); return self.x < other.x
def __le__(self, other): add('le'); return self.x <= other.x
def __eq__(self, other): add('eq'); return self.x == other.x
def __ne__(self, other): add('ne'); return self.x != other.x
def __cmp__(self, other): add('cmp'); return cmp(self.x, other.x)
if __name__ == '__main__':
check_heapq = True
n = 1000
s = [X(8), X(0), X(3), X(4), X(5), X(2), X(1), X(6), X(7), X(9)]
used.clear()
if check_heapq:
for i in xrange(n):
s2 = heapq.nlargest(5, s)
assert [a.x for a in s2] == range(9,4,-1), s2
shuffle(s)
else:
for i in xrange(n):
s2 = sorted(s, reverse=True)[:5]
assert [a.x for a in s2] == range(9,4,-1), s2
shuffle(s)
used_largest = used.copy()
print "largest:", used_largest
used.clear()
if check_heapq:
for i in xrange(n):
s2 = heapq.nsmallest(5, s)
assert [a.x for a in s2] == range(5), s2
shuffle(s)
else:
for i in xrange(n):
s2 = sorted(s)[:5]
assert [a.x for a in s2] == range(5), s2
shuffle(s)
used_smallest = used.copy()
print "smallest:", used_smallest
assert used_largest == used_smallest
More information about the Python-list
mailing list