¿Cómo ordenar objetos por múltiples claves en Python?

97

O, prácticamente, ¿cómo puedo ordenar una lista de diccionarios por múltiples claves?

Tengo una lista de dictados:

b = [{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0},
 {u'TOT_PTS_Misc': u'Foster, Toney', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Lawson, Roman', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Lempke, Sam', u'Total_Points': 80.0},
 {u'TOT_PTS_Misc': u'Gnezda, Alex', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Kirks, Damien', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Worden, Tom', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Korecz, Mike', u'Total_Points': 78.0},
 {u'TOT_PTS_Misc': u'Swartz, Brian', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Burgess, Randy', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Smugala, Ryan', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Harmon, Gary', u'Total_Points': 66.0},
 {u'TOT_PTS_Misc': u'Blasinsky, Scott', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Carter III, Laymon', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Coleman, Johnathan', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Venditti, Nick', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Blackwell, Devon', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Kovach, Alex', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Bolden, Antonio', u'Total_Points': 60.0},
 {u'TOT_PTS_Misc': u'Smith, Ryan', u'Total_Points': 60.0}]

y necesito usar un tipo de clave múltiple invertido por Total_Points, luego no invertido por TOT_PTS_Misc.

Esto se puede hacer en el símbolo del sistema así:

a = sorted(b, key=lambda d: (-d['Total_Points'], d['TOT_PTS_Misc']))

Pero tengo que ejecutar esto a través de una función, donde paso la lista y las claves de clasificación. Por ejemplo def multikeysort(dict_list, sortkeys):,.

¿Cómo se puede usar la línea lambda que ordenará la lista, para un número arbitrario de claves que se pasan a la función multikeysort, y tener en cuenta que las sortkeys pueden tener cualquier número de claves y se identificarán aquellas que necesitan ordenaciones inversas? con un '-' antes?

simi
fuente

Respuestas:

73

Esta respuesta funciona para cualquier tipo de columna en el diccionario; la columna negada no necesita ser un número.

def multikeysort(items, columns):
    from operator import itemgetter
    comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else
                  (itemgetter(col.strip()), 1)) for col in columns]
    def comparer(left, right):
        for fn, mult in comparers:
            result = cmp(fn(left), fn(right))
            if result:
                return mult * result
        else:
            return 0
    return sorted(items, cmp=comparer)

Puedes llamarlo así:

b = [{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0},
     {u'TOT_PTS_Misc': u'Foster, Toney', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Lawson, Roman', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Lempke, Sam', u'Total_Points': 80.0},
     {u'TOT_PTS_Misc': u'Gnezda, Alex', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Kirks, Damien', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Worden, Tom', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Korecz, Mike', u'Total_Points': 78.0},
     {u'TOT_PTS_Misc': u'Swartz, Brian', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Burgess, Randy', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Smugala, Ryan', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Harmon, Gary', u'Total_Points': 66.0},
     {u'TOT_PTS_Misc': u'Blasinsky, Scott', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Carter III, Laymon', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Coleman, Johnathan', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Venditti, Nick', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Blackwell, Devon', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Kovach, Alex', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Bolden, Antonio', u'Total_Points': 60.0},
     {u'TOT_PTS_Misc': u'Smith, Ryan', u'Total_Points': 60.0}]

a = multikeysort(b, ['-Total_Points', 'TOT_PTS_Misc'])
for item in a:
    print item

Pruébelo con cualquier columna negada. Verá el orden inverso.

Siguiente: cámbielo para que no use clase extra ....


2016-01-17

Inspirándome en esta respuesta ¿Cuál es la mejor manera de obtener el primer elemento de un iterable que coincide con una condición? , Acorté el código:

from operator import itemgetter as i

def multikeysort(items, columns):
    comparers = [
        ((i(col[1:].strip()), -1) if col.startswith('-') else (i(col.strip()), 1))
        for col in columns
    ]
    def comparer(left, right):
        comparer_iter = (
            cmp(fn(left), fn(right)) * mult
            for fn, mult in comparers
        )
        return next((result for result in comparer_iter if result), 0)
    return sorted(items, cmp=comparer)

En caso de que le guste su código conciso.


Más tarde 2016-01-17

Esto funciona con python3 (que eliminó el cmpargumento a sort):

from operator import itemgetter as i
from functools import cmp_to_key

def cmp(x, y):
    """
    Replacement for built-in function cmp that was removed in Python 3

    Compare the two objects x and y and return an integer according to
    the outcome. The return value is negative if x < y, zero if x == y
    and strictly positive if x > y.

    https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function
    """

    return (x > y) - (x < y)

def multikeysort(items, columns):
    comparers = [
        ((i(col[1:].strip()), -1) if col.startswith('-') else (i(col.strip()), 1))
        for col in columns
    ]
    def comparer(left, right):
        comparer_iter = (
            cmp(fn(left), fn(right)) * mult
            for fn, mult in comparers
        )
        return next((result for result in comparer_iter if result), 0)
    return sorted(items, key=cmp_to_key(comparer))

Inspirado por esta respuesta ¿Cómo debo hacer una ordenación personalizada en Python 3?

hughdbrown
fuente
Esto funciona mejor porque puedo usar el reverso en cualquier clave o columna. ¡Gracias!
simi
Entonces esto funciona bien. Llamo a mi función con la lista y la cadena como parámetros. Primero divido la cadena y luego llamo al multikeysort con la lista y la lista de claves de la cadena dividida. No importa qué elemento de la cadena tenga el '-' al comienzo del nombre de la columna, porque funcionará con el elemento o con todos los elementos. Increíble. Gracias.
simi
2
¡Gracias, me salvaste el día!
Sander van Leeuwen
4
cmp()no está disponible para Python3, así que tuve que definirlo yo mismo, como se menciona aquí: stackoverflow.com/a/22490617/398514
pferate
8
@hughdbrown: eliminó la cmppalabra clave, pero la cmp()función todavía se usa 4 líneas arriba. Lo probé con 3.2, 3.3, 3.4 y 3.5, todos fallaron en la llamada a la función, porque cmp()no está definida. La tercera viñeta aquí ( docs.python.org/3.0/whatsnew/3.0.html#ordering-comparisons ) menciona el tratamiento cmp()como desaparecido.
pferate
56

Este artículo tiene un buen resumen de varias técnicas para hacer esto. Si sus requisitos son más simples que "multicaídas bidireccionales completas", eche un vistazo. Está claro que la respuesta aceptada y la publicación de blog a la que acabo de hacer referencia se influyeron mutuamente de alguna manera, aunque no sé en qué orden.

En caso de que el enlace muera, aquí hay una sinopsis muy rápida de ejemplos no cubiertos anteriormente:

mylist = sorted(mylist, key=itemgetter('name', 'age'))
mylist = sorted(mylist, key=lambda k: (k['name'].lower(), k['age']))
mylist = sorted(mylist, key=lambda k: (k['name'].lower(), -k['age']))
Scott Stafford
fuente
Por lo que puedo decir, stygianvision usa mi código y no da crédito. Google pararesult = cmp(fn(left), fn(right))
hughdbrown
4
Gracias por la sinopsis, Link está muerto ahora. :)
Amyth
49

Sé que esta es una pregunta bastante antigua, pero ninguna de las respuestas menciona que Python garantiza un orden de clasificación estable para sus rutinas de clasificación, como list.sort()y sorted(), lo que significa que los elementos que se comparan igual conservan su orden original.

Esto significa que el equivalente de ORDER BY name ASC, age DESC(usando notación SQL) para una lista de diccionarios se puede hacer así:

items.sort(key=operator.itemgetter('age'), reverse=True)
items.sort(key=operator.itemgetter('name'))

Observe cómo los elementos se ordenan primero por el atributo "menor" age(descendente), luego por el atributo "mayor" name, lo que lleva al orden final correcto.

La inversión / inversión funciona para todos los tipos que se pueden solicitar, no solo los números que puede negar poniendo un signo menos al frente.

Y debido al algoritmo Timsort usado en (al menos) CPython, esto es bastante rápido en la práctica.

wouter bolsterlee
fuente
2
muy agradable. para conjuntos de datos moderados donde no importa ordenar el conjunto varias veces, ¡esto es genial! Como señala, debe revertir la ordenación de python en comparación con la ordenación de sql. Gracias.
Greg
El segundo tipo romperá el resultado del primero. Es curioso que ninguno de los votantes lo haya notado.
volcán
9
Es curioso que no haya notado que el criterio de clasificación principal va al último, como se muestra en mi ejemplo, y se menciona explícitamente en el otro comentario para que quede muy claro en caso de que no se haya dado cuenta.
wouter bolsterlee
24
def sortkeypicker(keynames):
    negate = set()
    for i, k in enumerate(keynames):
        if k[:1] == '-':
            keynames[i] = k[1:]
            negate.add(k[1:])
    def getit(adict):
       composite = [adict[k] for k in keynames]
       for i, (k, v) in enumerate(zip(keynames, composite)):
           if k in negate:
               composite[i] = -v
       return composite
    return getit

a = sorted(b, key=sortkeypicker(['-Total_Points', 'TOT_PTS_Misc']))
Alex Martelli
fuente
¡Guauu! Eso es asombroso. Funciona muy bien. Soy tan novato que siento que nunca llegaré al punto de saber todo esto. Eso también fue rápido. Muchas gracias.
simi
Pero, ¿qué pasa si las claves enviadas al sortkeypicker son una cadena, como '-Total_Points, TOT_PTS_Misc'?
simi
1
Posteriormente, se podría dividir la cadena en una matriz primero llamandosome_string.split(",")
Jason Creighton
Gracias. Me di cuenta de que puedo dividir la cadena, después de que ya comenté. DOH!
simi
2
Pero, ¿qué pasa si niega el valor de la cadena en lugar del valor numérico? No creo que eso funcione.
Nick Perkins
5

Utilizo lo siguiente para ordenar una matriz 2d en varias columnas

def k(a,b):
    def _k(item):
        return (item[a],item[b])
    return _k

Esto podría ampliarse para trabajar en un número arbitrario de elementos. Tiendo a pensar que encontrar un mejor patrón de acceso a sus claves ordenables es mejor que escribir un comparador elegante.

>>> data = [[0,1,2,3,4],[0,2,3,4,5],[1,0,2,3,4]]
>>> sorted(data, key=k(0,1))
[[0, 1, 2, 3, 4], [0, 2, 3, 4, 5], [1, 0, 2, 3, 4]]
>>> sorted(data, key=k(1,0))
[[1, 0, 2, 3, 4], [0, 1, 2, 3, 4], [0, 2, 3, 4, 5]]
>>> sorted(a, key=k(2,0))
[[0, 1, 2, 3, 4], [1, 0, 2, 3, 4], [0, 2, 3, 4, 5]]
Mumrah
fuente
4

Tuve un problema similar hoy: tuve que ordenar los elementos del diccionario por valores numéricos descendentes y valores de cadena ascendentes. Para resolver el problema de direcciones conflictivas, negué los valores enteros.

Aquí hay una variante de mi solución, según corresponda a OP

sorted(b, key=lambda e: (-e['Total_Points'], e['TOT_PTS_Misc']))

Muy simple y funciona a las mil maravillas.

[{'TOT_PTS_Misc': 'Chappell, Justin', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Russo, Brandon', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Utley, Alex', 'Total_Points': 96.0},
 {'TOT_PTS_Misc': 'Foster, Toney', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Lawson, Roman', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Lempke, Sam', 'Total_Points': 80.0},
 {'TOT_PTS_Misc': 'Gnezda, Alex', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Kirks, Damien', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Korecz, Mike', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Worden, Tom', 'Total_Points': 78.0},
 {'TOT_PTS_Misc': 'Burgess, Randy', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Harmon, Gary', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Smugala, Ryan', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Swartz, Brian', 'Total_Points': 66.0},
 {'TOT_PTS_Misc': 'Blackwell, Devon', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Blasinsky, Scott', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Bolden, Antonio', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Carter III, Laymon', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Coleman, Johnathan', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Kovach, Alex', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Smith, Ryan', 'Total_Points': 60.0},
 {'TOT_PTS_Misc': 'Venditti, Nick', 'Total_Points': 60.0}]
volcán
fuente
0
from operator import itemgetter
from functools import partial

def _neg_itemgetter(key, d):
    return -d[key]

def key_getter(key_expr):
    keys = key_expr.split(",")
    getters = []
    for k in keys:
        k = k.strip()
        if k.startswith("-"):
           getters.append(partial(_neg_itemgetter, k[1:]))
        else:
           getters.append(itemgetter(k))

    def keyfunc(dct):
        return [kg(dct) for kg in getters]

    return keyfunc

def multikeysort(dict_list, sortkeys):
    return sorted(dict_list, key = key_getter(sortkeys)

Demostración:

>>> multikeysort([{u'TOT_PTS_Misc': u'Utley, Alex', u'Total_Points': 60.0},
                 {u'TOT_PTS_Misc': u'Russo, Brandon', u'Total_Points': 96.0}, 
                 {u'TOT_PTS_Misc': u'Chappell, Justin', u'Total_Points': 96.0}],
                "-Total_Points,TOT_PTS_Misc")
[{u'Total_Points': 96.0, u'TOT_PTS_Misc': u'Chappell, Justin'}, 
 {u'Total_Points': 96.0, u'TOT_PTS_Misc': u'Russo, Brandon'}, 
 {u'Total_Points': 60.0, u'TOT_PTS_Misc': u'Utley, Alex'}]

El análisis es un poco frágil, pero al menos permite un número variable de espacios entre las claves.

Torsten Marek
fuente
Pero, cuando tengo el segundo elemento de la cadena con un '-', me da un tipo de operando incorrecto para un error unario.
simi
No puedes tomar el negativo de una cuerda.
Torsten Marek
Sí, lo sé, pero así es como se pasan los parámetros. Incluso si hago una división, uno u otro comenzará con '-'. Creo que las claves de clasificación deben dividirse antes de llamar a key_getter, de esa manera cada elemento en la lista de claves verificará el primer carácter. ¿Estoy en el camino correcto?
simi
0

Como ya se siente cómodo con lambda, aquí tiene una solución menos detallada.

>>> def itemgetter(*names):
    return lambda mapping: tuple(-mapping[name[1:]] if name.startswith('-') else mapping[name] for name in names)

>>> itemgetter('a', '-b')({'a': 1, 'b': 2})
(1, -2)
A. Coady
fuente
Esto no funciona. Tengo: valores = ['-Total_Points', 'TOT_PTS_Misc'] luego b como la lista de dicts Cuando llamo g = itemgetter (valores) (b) obtengo AttributeError: el objeto 'list' no tiene atributo 'startswith'
simi
Toma un número variable de nombres, no una lista de nombres. Llámelo así: itemgetter (* valores). Eche un vistazo al operator.itemgetter incorporado similar para ver otro ejemplo.
A. Coady