The extra overhead of calling lambda n: n
so many times is really just that expensive.
In [17]: key = lambda n: n
In [18]: x = [random() for _ in range(1234567)]
In [19]: %timeit nlargest(10, x)
33.1 ms ± 2.71 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [20]: %timeit nlargest(10, x, key=key)
133 ms ± 3.7 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [21]: %%timeit
...: for i in x:
...: key(i)
...:
93.2 ms ± 978 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
In [22]: %%timeit
...: for i in x:
...: pass
...:
10.1 ms ± 298 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
As you can see, the cost of calling key
on all the elements accounts for almost the entirety of the overhead.
Key evaluations are equally expensive for sorted
, but because the total work of sorting is more expensive, the overhead of key calls is a smaller percentage of the total. You should have compared the absolute overhead of using a key with nlargest
or sorted
, rather than the overhead as a percentage of the base.
In [23]: %timeit sorted(x)
542 ms ± 13.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [24]: %timeit sorted(x, key=key)
683 ms ± 12.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
As you can see, the cost of key
calls accounts for about half the overhead of using this key with sorted
on this input, the rest of the overhead probably coming from the work of shuffling more data around in the sort itself.
You might wonder how nlargest
manages to do so little work per element. For the no-key case, most iteration happens in the following loop:
for elem in it:
if top < elem:
_heapreplace(result, (elem, order))
top = result[0][0]
order -= 1
or for the case with a key:
for elem in it:
k = key(elem)
if top < k:
_heapreplace(result, (k, order, elem))
top = result[0][0]
order -= 1
The crucial realization is that the top < elem
and top < k
branches are almost never taken. Once the algorithm has found 10 fairly large elements, most of the remaining elements are going to be smaller than the 10 current candidates. On the rare occasions where a heap element needs to be replaced, that just makes it even harder for further elements to pass the bar needed to call heapreplace
.
On a random input, the number of heapreplace calls nlargest
makes is expected logarithmic in the size of the input. Specifically, for nlargest(10, x)
, aside from the first 10 elements of x
, element x[i]
has a 10/(i+1)
probability of being in the top 10 elements of l[:i+1]
, which is the condition necessary for a heapreplace call. By linearity of expectation, the expected number of heapreplace calls is the sum of these probabilities, and that sum is O(log(len(x))). (This analysis holds with 10 replaced by any constant, but a slightly more sophisticated analysis is needed for a variable n
in nlargest(n, l)
.)
The performance story would be very different for a sorted input, where every element would pass the if
check:
In [25]: sorted_x = sorted(x)
In [26]: %timeit nlargest(10, sorted_x)
463 ms ± 26 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
Over 10 times as expensive as the unsorted case!