Obtenga los mejores registros n para cada grupo de resultados agrupados

140

El siguiente es el ejemplo más simple posible, aunque cualquier solución debería poder escalar a la cantidad de n resultados necesarios:

Dada una tabla como la siguiente, con columnas de persona, grupo y edad, ¿cómo obtendría las 2 personas más viejas en cada grupo? (Los lazos dentro de los grupos no deberían arrojar más resultados, sino dar los 2 primeros en orden alfabético)

+ -------- + ------- + ----- +
El | Persona | Grupo | Edad |
+ -------- + ------- + ----- +
El | Bob | 1 | 32
El | Jill 1 | 34
El | Shawn 1 | 42 |
El | Jake 2 | 29 |
El | Paul | 2 | 36
El | Laura | 2 | 39
+ -------- + ------- + ----- +

Conjunto de resultados deseado:

+ -------- + ------- + ----- +
El | Shawn 1 | 42 |
El | Jill 1 | 34
El | Laura | 2 | 39
El | Paul | 2 | 36
+ -------- + ------- + ----- +

NOTA: Esta pregunta se basa en una anterior: obtenga registros con el valor máximo para cada grupo de resultados SQL agrupados , para obtener una sola fila superior de cada grupo y que recibió una gran respuesta específica de MySQL de @Bohemian:

select * 
from (select * from mytable order by `Group`, Age desc, Person) x
group by `Group`

Me encantaría poder construir a partir de esto, aunque no veo cómo.

Yarin
fuente
2
Mira este ejemplo. Está bastante cerca de lo que pides: stackoverflow.com/questions/1537606/…
Savas Vedova
¿Usa LIMIT dentro de GROUP BY para obtener N resultados por grupo? stackoverflow.com/questions/2129693/…
Edye Chan

Respuestas:

88

Aquí hay una manera de hacer esto, usando UNION ALL(Ver SQL Fiddle with Demo ). Esto funciona con dos grupos, si tiene más de dos grupos, deberá especificar el groupnúmero y agregar consultas para cada uno group:

(
  select *
  from mytable 
  where `group` = 1
  order by age desc
  LIMIT 2
)
UNION ALL
(
  select *
  from mytable 
  where `group` = 2
  order by age desc
  LIMIT 2
)

Hay una variedad de formas de hacer esto, consulte este artículo para determinar la mejor ruta para su situación:

http://www.xaprb.com/blog/2006/12/07/how-to-select-the-firstleastmax-row-per-group-in-sql/

Editar:

Esto también podría funcionar para usted, genera un número de fila para cada registro. Usando un ejemplo del enlace de arriba, esto devolverá solo aquellos registros con un número de fila menor o igual a 2:

select person, `group`, age
from 
(
   select person, `group`, age,
      (@num:=if(@group = `group`, @num +1, if(@group := `group`, 1, 1))) row_number 
  from test t
  CROSS JOIN (select @num:=0, @group:=null) c
  order by `Group`, Age desc, person
) as x 
where x.row_number <= 2;

Ver demo

Taryn
fuente
52
si tiene más de 1000 grupos, ¿no sería eso un poco aterrador?
Charles Forest el
1
@CharlesForest sí, lo haría y es por eso que dije que tendría que especificarlo para más de dos grupos. Se volvería feo.
Taryn
1
@CharlesForest Creo que encontré una solución mejor, vea mi edición
Taryn
1
Una nota para cualquiera que lea esto: la versión es que las variables están cerca de ser correctas. Sin embargo, MySQL no garantiza el orden de evaluación de las expresiones en SELECT(y, de hecho, a veces las evalúa fuera de orden). La clave de la solución es poner todas las asignaciones de variables en una sola expresión; Aquí hay un ejemplo: stackoverflow.com/questions/38535020/… .
Gordon Linoff
1
@GordonLinoff Actualizó mi respuesta, gracias por señalarlo. También me llevó demasiado tiempo actualizarlo.
Taryn
63

En otras bases de datos puedes hacer esto usando ROW_NUMBER. MySQL no es compatible, ROW_NUMBERpero puede usar variables para emularlo:

SELECT
    person,
    groupname,
    age
FROM
(
    SELECT
        person,
        groupname,
        age,
        @rn := IF(@prev = groupname, @rn + 1, 1) AS rn,
        @prev := groupname
    FROM mytable
    JOIN (SELECT @prev := NULL, @rn := 0) AS vars
    ORDER BY groupname, age DESC, person
) AS T1
WHERE rn <= 2

Véalo trabajando en línea: sqlfiddle


Editar Acabo de notar que bluefeet publicó una respuesta muy similar: +1 para él. Sin embargo, esta respuesta tiene dos pequeñas ventajas:

  1. Es una sola consulta. Las variables se inicializan dentro de la instrucción SELECT.
  2. Maneja los lazos como se describe en la pregunta (orden alfabético por nombre).

Así que lo dejaré aquí en caso de que pueda ayudar a alguien.

Mark Byers
fuente
1
Mark- Esto está funcionando bien para nosotros. Gracias por proporcionar otra buena alternativa para felicitar a @ bluefeet, muy apreciada.
Yarin
+1. Esto funcionó para mí. Muy limpio y al punto de respuesta. ¿Puede explicar cómo funciona exactamente esto? ¿Cuál es la lógica detrás de esto?
Aditya Hajare
3
Buena solución, pero parece que no funciona en mi entorno (MySQL 5.6) porque el orden por cláusula se aplica después de seleccionar para que no devuelva el resultado superior, vea mi solución alternativa para solucionar este problema
Laurent PELE
Mientras ejecutaba esto, pude eliminarlo JOIN (SELECT @prev := NULL, @rn := 0) AS vars. Tengo la idea de declarar variables vacías, pero parece extraño para MySql.
Joseph Cho
1
Esto funciona muy bien para mí en MySQL 5.7, pero sería increíble si alguien pudiera explicar cómo funciona
George B
41

Prueba esto:

SELECT a.person, a.group, a.age FROM person AS a WHERE 
(SELECT COUNT(*) FROM person AS b 
WHERE b.group = a.group AND b.age >= a.age) <= 2 
ORDER BY a.group ASC, a.age DESC

MANIFESTACIÓN

snuffn
fuente
66
¡Snuffin sale de la nada con la solución más simple! ¿Es esto más elegante que el de Ludo / Bill Karwin? ? ¿Puedo obtener algún comentario
Yarin
Hm, no estoy seguro si es más elegante. Pero a juzgar por los votos, supongo que bluefeet podría tener la mejor solución.
snuffn
2
Hay un problema con esto. Si hay un empate en el segundo lugar dentro del grupo, solo se devuelve un resultado superior. Ver demostración
Yarin
2
No es un problema si se desea. Puede establecer el orden de a.person.
Alberto Leal
no, no está funcionando en mi caso, tampoco funciona la DEMO
Choix
31

¿Qué tal usar auto-unión:

CREATE TABLE mytable (person, groupname, age);
INSERT INTO mytable VALUES('Bob',1,32);
INSERT INTO mytable VALUES('Jill',1,34);
INSERT INTO mytable VALUES('Shawn',1,42);
INSERT INTO mytable VALUES('Jake',2,29);
INSERT INTO mytable VALUES('Paul',2,36);
INSERT INTO mytable VALUES('Laura',2,39);

SELECT a.* FROM mytable AS a
  LEFT JOIN mytable AS a2 
    ON a.groupname = a2.groupname AND a.age <= a2.age
GROUP BY a.person
HAVING COUNT(*) <= 2
ORDER BY a.groupname, a.age DESC;

me da

a.person    a.groupname  a.age     
----------  -----------  ----------
Shawn       1            42        
Jill        1            34        
Laura       2            39        
Paul        2            36      

La respuesta de Bill Karwin me inspiró fuertemente a Seleccionar los 10 mejores registros para cada categoría

Además, estoy usando SQLite, pero esto debería funcionar en MySQL.

Otra cosa: en lo anterior, reemplacé la groupcolumna con una groupnamecolumna por conveniencia.

Editar :

Después del comentario del OP con respecto a los resultados de empate faltantes, incrementé la respuesta del snuffin para mostrar todos los empates. Esto significa que si los últimos son empates, se pueden devolver más de 2 filas, como se muestra a continuación:

.headers on
.mode column

CREATE TABLE foo (person, groupname, age);
INSERT INTO foo VALUES('Paul',2,36);
INSERT INTO foo VALUES('Laura',2,39);
INSERT INTO foo VALUES('Joe',2,36);
INSERT INTO foo VALUES('Bob',1,32);
INSERT INTO foo VALUES('Jill',1,34);
INSERT INTO foo VALUES('Shawn',1,42);
INSERT INTO foo VALUES('Jake',2,29);
INSERT INTO foo VALUES('James',2,15);
INSERT INTO foo VALUES('Fred',1,12);
INSERT INTO foo VALUES('Chuck',3,112);


SELECT a.person, a.groupname, a.age 
FROM foo AS a 
WHERE a.age >= (SELECT MIN(b.age)
                FROM foo AS b 
                WHERE (SELECT COUNT(*)
                       FROM foo AS c
                       WHERE c.groupname = b.groupname AND c.age >= b.age) <= 2
                GROUP BY b.groupname)
ORDER BY a.groupname ASC, a.age DESC;

me da

person      groupname   age       
----------  ----------  ----------
Shawn       1           42        
Jill        1           34        
Laura       2           39        
Paul        2           36        
Joe         2           36        
Chuck       3           112      
Comunidad
fuente
@ Ludo- Acabo de ver esa respuesta de Bill Karwin - gracias por aplicarla aquí
Yarin
¿Qué opinas de la respuesta de Snuffin? Estoy tratando de comparar los dos
Yarin
2
Hay un problema con esto. Si hay un empate por el segundo lugar dentro del grupo, solo se devuelve un resultado superior. Ver demostración
Yarin, el
1
@ Ludo: el requisito original era que cada grupo devolviera los n resultados exactos, y cualquier vínculo se resolviera alfabéticamente
Yarin
La edición para incluir los lazos no me funciona. Me da ERROR 1242 (21000): Subquery returns more than 1 row, presumiblemente por el GROUP BY. Cuando ejecuto la SELECT MINsubconsulta sola, genera tres filas: 34, 39, 112y parece que el segundo valor debería ser 36, no 39.
verbamour
12

La solución Snuffin parece bastante lenta de ejecutar cuando tienes muchas filas y las soluciones Mark Byers / Rick James y Bluefeet no funcionan en mi entorno (MySQL 5.6) porque el orden por se aplica después de la ejecución de select, así que aquí hay una variante de las soluciones de Marc Byers / Rick James para solucionar este problema (con una selección imbricada adicional):

select person, groupname, age
from
(
    select person, groupname, age,
    (@rn:=if(@prev = groupname, @rn +1, 1)) as rownumb,
    @prev:= groupname 
    from 
    (
        select person, groupname, age
        from persons 
        order by groupname ,  age desc, person
    )   as sortedlist
    JOIN (select @prev:=NULL, @rn :=0) as vars
) as groupedlist 
where rownumb<=2
order by groupname ,  age desc, person;

Intenté una consulta similar en una tabla que tiene 5 millones de filas y devuelve el resultado en menos de 3 segundos

Laurent PELE
fuente
3
Esta es la única consulta que ha estado funcionando en mi entorno. ¡Gracias!
herrherr
3
Agregar LIMIT 9999999a cualquier tabla derivada con un ORDER BY. Esto puede evitar que ORDER BYse ignore.
Rick James
Ejecuté una consulta similar en una tabla que contenía unos pocos miles de filas, y tardé 60 segundos en devolver un resultado, así que ... gracias por la publicación, es un comienzo para mí. (ETA: hasta 5 segundos. ¡Bien!)
Evan
10

Mira esto:

SELECT
  p.Person,
  p.`Group`,
  p.Age
FROM
  people p
  INNER JOIN
  (
    SELECT MAX(Age) AS Age, `Group` FROM people GROUP BY `Group`
    UNION
    SELECT MAX(p3.Age) AS Age, p3.`Group` FROM people p3 INNER JOIN (SELECT MAX(Age) AS Age, `Group` FROM people GROUP BY `Group`) p4 ON p3.Age < p4.Age AND p3.`Group` = p4.`Group` GROUP BY `Group`
  ) p2 ON p.Age = p2.Age AND p.`Group` = p2.`Group`
ORDER BY
  `Group`,
  Age DESC,
  Person;

Fiddle de SQL: http://sqlfiddle.com/#!2/cdbb6/15

Travesty3
fuente
55
Hombre, otros encontraron soluciones mucho más simples ... Acabo de pasar como 15 minutos en esto y estaba increíblemente orgulloso de mí mismo por encontrar una solución tan complicada también. Eso apesta.
Travesty3
Tenía que encontrar un número de versión interna que fuera 1 menos que el actual; esto me dio la respuesta para hacer esto: max(internal_version - 1)así que menos estrés :)
Jamie Strauss
8

Si las otras respuestas no son lo suficientemente rápidas Prueba este código :

SELECT
        province, n, city, population
    FROM
      ( SELECT  @prev := '', @n := 0 ) init
    JOIN
      ( SELECT  @n := if(province != @prev, 1, @n + 1) AS n,
                @prev := province,
                province, city, population
            FROM  Canada
            ORDER BY
                province   ASC,
                population DESC
      ) x
    WHERE  n <= 3
    ORDER BY  province, n;

Salida:

+---------------------------+------+------------------+------------+
| province                  | n    | city             | population |
+---------------------------+------+------------------+------------+
| Alberta                   |    1 | Calgary          |     968475 |
| Alberta                   |    2 | Edmonton         |     822319 |
| Alberta                   |    3 | Red Deer         |      73595 |
| British Columbia          |    1 | Vancouver        |    1837970 |
| British Columbia          |    2 | Victoria         |     289625 |
| British Columbia          |    3 | Abbotsford       |     151685 |
| Manitoba                  |    1 | ...
Rick James
fuente
Revisé su sitio: ¿dónde obtendría la fuente de datos para las poblaciones de las ciudades? TIA y rgs.
Vérace
maxmind.com/en/worldcities : me resulta útil para experimentar con búsquedas de lat / lng , consultas, particiones, etc. Es lo suficientemente grande como para ser interesante, pero lo suficientemente legible como para reconocer las respuestas. El subconjunto canadiense es útil para este tipo de preguntas. (Menos provincias que ciudades de EE. UU.)
Rick James
2

Quería compartir esto porque pasé mucho tiempo buscando una manera fácil de implementar esto en un programa Java en el que estoy trabajando. Esto no da la salida que estás buscando, pero está cerca. La función llamada mysql GROUP_CONCAT()funcionó muy bien para especificar cuántos resultados devolver en cada grupo. Usar LIMITo alguna de las otras formas elegantes de tratar de hacer esto COUNTno funcionó para mí. Entonces, si está dispuesto a aceptar una salida modificada, es una gran solución. Digamos que tengo una tabla llamada 'estudiante' con identificación de estudiante, su género y gpa. Digamos que quiero superar los 5 gpas para cada género. Entonces puedo escribir la consulta así

SELECT sex, SUBSTRING_INDEX(GROUP_CONCAT(cast(gpa AS char ) ORDER BY gpa desc), ',',5) 
AS subcategories FROM student GROUP BY sex;

Tenga en cuenta que el parámetro '5' le dice cuántas entradas concatenar en cada fila

Y la salida se vería algo así

+--------+----------------+
| Male   | 4,4,4,4,3.9    |
| Female | 4,4,3.9,3.9,3.8|
+--------+----------------+

También puede cambiar la ORDER BYvariable y ordenarlas de una manera diferente. Entonces, si tuviera la edad del estudiante, podría reemplazar el 'gpa desc' con 'age desc' y ¡funcionará! También puede agregar variables al grupo por declaración para obtener más columnas en la salida. Entonces, esta es solo una forma en que encontré que es bastante flexible y funciona bien si está de acuerdo con solo enumerar los resultados.

Jon Bown
fuente
0

En SQL Server row_numer()es una función poderosa que puede obtener resultados fácilmente como a continuación

select Person,[group],age
from
(
select * ,row_number() over(partition by [group] order by age desc) rn
from mytable
) t
where rn <= 2
Prakash
fuente
Con 8.0 y 10.2 siendo GA, esta respuesta se está volviendo razonable.
Rick James
@RickJames, ¿qué significa 'ser GA'? Las funciones de ventana ( dev.mysql.com/doc/refman/8.0/en/window-functions.html ) resolvieron mi problema muy bien.
iedmrc
1
@iedmrc - "GA" significa "Generalmente disponible". Es hablar de tecnología para "listo para el horario estelar" o "lanzado". Están a través del desarrollo de la versión y se centrarán en el error que se perdieron. Ese enlace discute la implementación de MySQL 8.0, que podría ser diferente a la implementación de MariaDB 10.2.
Rick James
-1

Hay una muy buena respuesta a este problema en MySQL: cómo obtener las mejores filas N por cada grupo

Según la solución en el enlace al que se hace referencia, su consulta sería como:

SELECT Person, Group, Age
   FROM
     (SELECT Person, Group, Age, 
                  @group_rank := IF(@group = Group, @group_rank + 1, 1) AS group_rank,
                  @current_group := Group 
       FROM `your_table`
       ORDER BY Group, Age DESC
     ) ranked
   WHERE group_rank <= `n`
   ORDER BY Group, Age DESC;

donde nesta el top nyyour_table es el nombre de tu mesa.

Creo que la explicación en la referencia es realmente clara. Para una referencia rápida, lo copiaré y pegaré aquí:

Actualmente MySQL no admite la función ROW_NUMBER () que puede asignar un número de secuencia dentro de un grupo, pero como solución alternativa podemos usar variables de sesión de MySQL.

Estas variables no requieren declaración, y pueden usarse en una consulta para hacer cálculos y almacenar resultados intermedios.

@current_country: = country Este código se ejecuta para cada fila y almacena el valor de la columna del país en la variable @current_country.

@country_rank: = IF (@current_country = country, @country_rank + 1, 1) En este código, si @current_country es el mismo, incrementamos el rango; de lo contrario, configúrelo en 1. Para la primera fila @current_country es NULL, por lo que el rango es también establecido en 1.

Para una clasificación correcta, necesitamos ORDENAR POR país, población DESC

kovac
fuente
Bueno, es el principio utilizado por las soluciones de Marc Byers, Rick James y el mío.
Laurent PELE
Difícil decir qué publicación (Stack Overflow o SQLlines) fue la primera
Laurent PELE
@LaurentPELE - La mía se publicó en febrero de 2015. No veo ninguna marca de tiempo ni nombre en SQLlines. Los blogs de MySQL han existido durante el tiempo suficiente como para que algunos de ellos estén desactualizados y deberían eliminarse; la gente cita información errónea.
Rick James