Allow `sorted` to take list for `key` argument

Note on implementation: The test branch uses key: callable | list for
prototyping. The proposed API uses keylist= to avoid type ambiguity.

1. Background

So there already have been some discussions around this:

  1. I saw quite explicit request for this, but can not find it now. But here is stack: python - Sorting list according to corresponding values from a parallel list - Stack Overflow, which has 651 upvotes
  2. Add a dict.sort() method - #32 by Marco_Sulla
  3. Indexing, formatting, sorting (4) has hinted at this

So (1) is a generic problem, while (2) and (3) is a special case.

Motivation is 2 parts:

  1. Convenience
  2. Performance

2. Convenience

Often one has 2 lists:

  • one holds values of final interest
  • another holds keys according to which sorting is needed

Without this functionality, one needs to zip or/and key=:

pairs = zip(keys, values)
sorted_pairs = sorted(pairs, key=opr.itemgetter(0))
keys, values = zip(*sorted_pairs)

With this, things are much more straight forward:

values.sort(key=keys)

3. Performance

So sorting dict, unfortunately, is not going to benefit much in convenience.
But where it will benefit is performance.

For 2 cases below, performance benefit is 30-40%

Setup:

S="
import random
import operator as opr
N = 100
# N = 100_000
# keys = range(N, 0, -1)          # DEC
keys = random.sample(range(N), N) # RND
dct = dict(zip(keys, range(N)))
"

A="
d = dct.copy()
items = sorted(d.items())
d.clear()
d.update(items)
"

B="
d = dct.copy()
items = sorted(d.items(), key=opr.itemgetter(0))
d.clear()
d.update(items)
"

C="
d = dct.copy()
keys = list(d.keys())
values = list(d.values())
values.sort(key=keys)
d.clear()
d.update(zip(keys, values))
"

Results:

# DEC                                   # 100    | 100K
./python.exe -m timeit -s "$S" "$A"     # 9.0 µs | 15.0 ms
./python.exe -m timeit -s "$S" "$B"     # 9.0 µs | 15.0 ms
./python.exe -m timeit -s "$S" "$C"     # 8.0 µs | 10.0 ms

# RND                                   # 100   | 100K
./python.exe -m timeit -s "$S" "$A"     # 18 µs | 80 ms
./python.exe -m timeit -s "$S" "$B"     # 13 µs | 55 ms
./python.exe -m timeit -s "$S" "$C"     # 11 µs | 34 ms

(A) although often used, is not correct solution which works for all cases - it will use 2nd item in case of duplicates.
(B) is robust solution that one should would use now if generic solution applicable to all cases is needed.
(C) is using proposed functionality.

It can be clearly seen that avoiding copies and more importantly calling keyfunc can result in large performance gains.
And proposed functionality is offering straight and most optimal path for cases that can exploit this (or another way to structure the problem where extra performance can be beneficial).

4. API

API that I have tested with is:

def sort(..., key: callable | list, ...)

It works nicely and unambiguously.
But it can just as well be:

def sort(..., key: callable, keylist: list):
def sort(..., key: callable, keys: list):
    assert not (key and keys)

I thin the best would be to exclude sorted from this and only do this for list.sort.
The variation I am currently at:

def list.sort(*, key=None, keylist=None, reverse=False)
    """
    Sort list in place.
    
    keylist: list or None
        If provided, sort this list by the corresponding elements in keylist.
        Both this list and keylist are mutated in lockstep to maintain 
        correspondence. keylist must be a list of equal length.
        Cannot be used with key parameter.

    To preserve keylist order, use: values.sort(keylist=list(keys))
    """

keylist gets mutated, because it is easy to wrap it in list for non-mutability:

list.sort(keylist=list(keys))

but there is no way to achieve mutability if it is restricted.

5. Any thoughts?

So would others find this useful?
Do you have any cases in mind?
I can test performance benefit for them.
Or even better - you can test it yourself - GitHub - dg-pb/cpython at sort_keys

One final benchmark:

S='
import random, operator

N = 100_000
keys = random.sample(range(N), N)
values = list(range(N))

# Baseline: zip + sort
def baseline():
    pairs = list(zip(keys, values))
    pairs.sort(key=operator.itemgetter(0))
    return [v for _, v in pairs]

# This proposal
def proposed():
    values.sort(key=list(keys))
    return values
'

./python.exe -m timeit -s "$S" "baseline()"     # 60 ms
./python.exe -m timeit -s "$S" "proposed()"     # 20 ms
4 Likes

Do you have any examples from the stdlib? I found myself not having been in this situation often, if at all. And for all I know, it is pretty rare in the stdlib too.

1 Like

Didn’t find much in the stdlib.
But it is not surprising.

Also, depends on the case.
This is just a generic extension that aims to cover as much as possible and extract as much value from Python’s sorting algorithm with as little change as possible.

For example, one case that it improves is argsort.
And in github many wrappers can be found around current sorted for it:

/def argsort\(/ Language:Python - 15k files (quite a few false negatives)
Although this does not provide convenient function, but it gives performance:

S='
import random
N = 100_000
keys = random.sample(range(N), N)

def baseline_argsort():
    return sorted(range(N), key=lambda i: keys[i])

def baseline_argsort2():
    return sorted(range(N), key=keys.__getitem__)

def proposed_argsort():
    indices = list(range(N))
    indices.sort(key=list(keys))
    return indices
'

./python.exe -m timeit -s "$S" "baseline_argsort()"     # 40 ms
./python.exe -m timeit -s "$S" "baseline_argsort2()"    # 32 ms (current best)
./python.exe -m timeit -s "$S" "proposed_argsort()"     # 27 ms (-15%)

Personally, at least currently, I don’t have many use cases of this either.
Occasional dict-sort, sometimes argsort, but in fairly trivial places, nothing that I would actively be optimising for.

This is only an educated guess that such extra value extraction could be of benefit.

1 Like

I think this one is quite accurate where most of cases would benefit (with very few false positives):
/\bsorted\(.*__getitem__/ Language:Python - 7.9k files

In general, I like this. The need does come up, and zipping/unzipping is always annoying - that’s why key= was added to begin with.

Please don’t abuse that by making it do different things depending on the type of the key argument. A distinct keylist= argument is a much better idea! At most one of key= and keylist= can be specified.

And please don’t break the equivalence of arguments to .sort() and sorted(). I see no reason to imagine that keylist= is somehow ā€œless usefulā€ for sorted(),

I’m afraid that now you’re going to be the target for "hyper-generalization" :wink:.keylist= is a great name so long tis value is restricted to an actual list object. Not so great if you yield to demands that it accept any iterable.

FYI, I personally don’t zip/unzip for this even now. Instead

ixs = sorted(list(range(len(keys))), key=keys.__getitem__)
result = [values[i] for i in ixs]

# or this for the second line instead to cut the number of
# Python-level indexing operations
result = list(map(values.__getitem__, ixs))

That is, the first line returns the appropriate permution of the index set. Then that can be used to rearrange any number of other lists in the same way.

Although I can count the number of times ā€œmore than oneā€ of those came up in my code using one finger :wink:

2 Likes

Example of prior art in this area: Pike doesn’t have a key= parameter, but does allow parallel sorting of arrays. So the easiest way to sort using a key is to build the parallel array, then sort both together; using Python syntax, that would look like this:

stuff = ...
keys = [word.casefold() for word in stuff]
sort(keys, stuff)

The first array (list) defines the sort order, and every other one is perturbed identically.

This is quite convenient using Pike’s automap syntax; I can sort(lower_case(stuff[*]), stuff) to construct the temporary array of keys, use that as the sort order, and then discard it. In Python, it’s often cleaner to use a lambda function instead, although I wouldn’t be surprised if a list comp is faster (though, OTOH, I doubt that the performance difference will be enough to care about).

+1 on having this as a feature. -0.5 on the key=otherlist syntax though; +0.5 on sort(keylist=otherlist) and +0.5 on otherlist.sort(parallel=stuff) where the list of keys is considered the primary.

2 Likes

I am playing with this now.
The API feels somewhat more intuitive.

If both key and parallel are restricted to be used at the same time, then all is good.

However, this opens an interesting possibility - both of them at the same time actually makes sense. The API is quite naturally orthogonal without undermining existing usage:

class list2(list):
    def sort(self, *, key=None, parallel=None, reverse=False):
        """Sort the list in place.
        Args:
            parallel: list or None
                If provided, sort provided list in parallel
                based on primary inputs.
        """
        if parallel is None:
            super().sort(key=key, reverse=reverse)
        else:
            assert isinstance(parallel, list)
            assert len(self) == len(parallel)
            if key is None:
                keyfunc = lambda x: x[1]
            else:
                keyfunc = lambda x: key(x[1])
            indices = sorted(enumerate(self), key=keyfunc, reverse=reverse)
            indices = [i[0] for i in indices]
            self[:] = [self[i] for i in indices]
            parallel[:] = [parallel[i] for i in indices]

, but there is a slight issue - the implementation is much more involved to allow both at the same time.

So maybe restrict for a start and keep this possibility open for the future if somehow it is worth it?

Which API feels better?
keys is the one being sorted
values is the one which gets reordered in parallel

  • values.sort(keylist=keys)
  • keys.sort(parallel=values)
0 voters

Not sure I’m following your grammar here - you mean that you may use key= on its own, parallel= on its own, but may not use both at once? If so, then I agree; it’s one or the other, mutually exclusive parameters.

(Grammatically, if you had said ā€œare restriced to BEING used at the same timeā€, this would have the opposite meaning - they must be used together. Hence my uncertainty.)

The rest of your post makes me think I understood you correctly, but I’m not completely sure.

Yes, I meant that this case is easily implementable.
However, in this variant it can also make sense to use both at the same time.
Such would need multiple effort.
But from design POV things fall into places quite nicely:

list.sort(key=foo, parallel=other)

– makes sense. As it would be the same as:

list.sort(key=foo)

just reorders parallel at the same time.

This API is quite clean, IMO.
It seems like it could have been a good fit for Python from the beginning.

list.sort does this with key implicitly anyways.
Having key=callable saves some effort on manually needing typing list(map(func, list)).
But at the same time trims possibilities that this proposal is aimed to ā€œrecoverā€.

My problem with this is that Python isn’t Pike :frowning: Python already has a notion of sorting stuff by keys. The original idea here was a very simple variant of that: rather than compute the keys, I’m giving you the list of keys to use. In conception and in implementation, it’s a very easy fit to what’s already there, and so also to what people are already used to.

Indeed, keylist= can even be viewed as fundamental. The value of sorted(xs, key=func) could be defined as the result of sorted(xs, keylist=[func(x) for x in xs)) That’s what the implementation actually does under the covers. Unless people complicate it, @dg-pb’s original idea amounts to little more than just skipping the implementation’s ā€œmap a function call across the input listā€ step in favor of just using the passed-in list directly.

ā€œIf the implementation is easy to explain, it may be a good ideaā€ applies :smile:.

Importing Pike’s view of sorting into Python is strained to me. Python took a different direction from its start (which, at first, was just wrapping the platform C library’s qsort() function).

5 Likes

Oh yes, there’s nothing inherently superb about that order of parameters, other than that you could potentially have multiple parallel-sorted arrays (though I cannot think of a single time that I’ve actually done that).

Agreed. I’m kinda torn on ā€œhow to teach thisā€ and whether that’s easier or harder given that the key= already exists; currently I’m in the ā€œharderā€ camp, and maybe a different name than keylist might make it easier to teach?? But maybe the similarity of names is a good thing.

1 Like

The name precomputed_keys= would, I think, make it all very clear - but at a horrid cost in verbosity. Python isn’t Java either :wink:

My reasoning for this was that it might be a bit of a pitfall as keylist gets mutated.
And sorted has a feel of not mutating its input.
But in full honesty, this was pointed out to me by AI after I asked for criticism.
Personally, I have no issue with sorted mutating keylist.
So you think it is ok for sorted as well?

Accepting any iterabe sounds attractive.
However, this causes ambiguity.
I am quite confident that to extract appropriate benefit keylist needs to be mutated.
And if arbitrary Iterable is allowed then it mutates when list and does not when Iterator?
I think this is beyond the line of what amount of special handling is acceptable.
My idea is to accept list only.
Not even subclasses of list.
You in agreement with this?

So I take it you are definitely pro-keylist?
Put a vote on the poll for the record if you don’t mind. :slight_smile:

I guess this is what @dg-pb was referring to as ā€œargsortā€, at least numpy calls it argsort. And when the most complex sorting mechanisms are involved (more than two sequences, partial sorting, etc…) the argsort is the most flexible tool.

From experience the use cases can be splitted in two major cases :

  • Sorting several sequences together, pointing out which one should be the reference for sorting, in a very concise and straightforward way (which I would call a ā€˜zipsort’, informally).
  • Seeking more complexity will always involve an argsort at some point.

I have had a few cases like this in the past, but then I did something like the following which gave me what I needed.

keys: list[int] = [20, 10, 14, 30]
values: list[int] = [1, 2, 3, 4]

next_keys = iter(keys)                                                   # ln 1
sorted_values: list[int] = sorted(values, key=lambda _: next(next_keys)) # ln 2

print(sorted_values)  # [2, 3, 1, 4]

So it is 2 lines, but it adds an extra variable which you will not need again or use again.

What if there was a function added to functools like the cmp_to_key function that did the same?

def cmp_to_list[T](keys: list[T]) -> Callable[[Any], T]:
    next_keys: Iterator[T] = iter(keys)
    def inner(_: Any) -> T:
        return next(next_keys)
    return inner


sorted_values: list[int] = sorted(values, key=cmp_to_list(keys))

print(sorted_values)  # [2, 3, 1, 4]

1 Like

That’s what I’ve been thinking of for a while, except with keys being any iterable, not necessarily a list (and I wouldn’t mutate a given list).

One hack I’ve used to sort X by Y:

X = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]
Y = [ 0,   1,   1,   0,   1,   2,   2,   0,   1 ]

from functools import partial

X.sort(key=partial(next, iter(Y)))

print(X)

Attempt This Online!

3 Likes

There are many different ways to achieve this. E.g. @Stefan2’s solution.
But one of main points of this thread is performance.
list.sort algorithm has been privy to a lot of great effort.
And various transformations to achieve certain things quickly add up penalties eradicating significant part of performance.

Mutating, I think, is quite important - it covers extra mile - see dictionary sorting example.

To not mutate, one can simply:

sorted(iterable, keylist=list(key_iterable))

Although can be slight inconvenience for cases where mutation is undesirable, but this just factors out copying outside and covers both variations.

Performant wrappers can be constructed on top for different cases if something is used often.
E.g.:

def argsort(seq):
    indices = list(range(N))
    indices.sort(key=list(keys))
    return indices

def sort_dict(d, by='keys'):
    assert by in ('keys', 'values')
    keys = list(d.keys())
    values = list(d.values())
    d.clear()
    if by == 'keys'
        values.sort(keylist=keys)
    else:
        keys.sort(keylist=values)
    d.update(zip(keys, values))

This exposes API to achieve good performance.

Also, current most performant solution of argsort involved seq.__getitem__.
I think it would be ā€œniceā€ to have ā€œone --obvious way to do itā€ that does not involve explicit dunders calls.

Ouch. Yes, that feels like a bug magnet to me. I’m pretty sure I’d expect keylist to remain unchanged. I could imagine (although I have no real-world example) using keylist repeatedly, to sort multiple lists into the same order.

I do quite like the parallel_sort(keys, lst2, lst3, ...) version, even though it’s over-generalised (there seem to be no real use cases for more than two lists).

Having said that, Tim’s earlier approach:

ixs = sorted(list(range(len(keys))), key=keys.__getitem__)
result = [values[i] for i in ixs]

seems very natural to me, now it’s been explained[1]. Generating an actual permutation and then using it feels intuitive.


  1. I’m not sure I would have thought of it, but I’ll remember it in future! ā†©ļøŽ

2 Likes

This is specifically aimed to improve upon this by:

  1. Boosting performance
  2. Not needing to use explicit dunder

For 2 inputs this gives much better performance - see dict-sort example.
For more than 2…
I don’t think I have ever seen this being done, but…
Need to do argsort (which is more efficient with this proposal):

ixs = list(range(len(keys)))
ixs.sort(keylist=keys)
sorter = operator.itemgetter(*ixs)
sorter(values1)
sorter(values2)
sorter(values3)
...

or can use listitemgetter if you have one or comprehension.
The second step becomes orthogonal problem once indices are available.