Obtenez les n premiers enregistrements pour chaque groupe de résultats groupés

140

L'exemple suivant est le plus simple possible, même si toute solution doit être capable de s'adapter au nombre de n résultats supérieurs nécessaires:

Étant donné un tableau comme celui-ci ci-dessous, avec des colonnes de personne, de groupe et d'âge, comment obtiendriez-vous les 2 personnes les plus âgées de chaque groupe? (Les égalités au sein des groupes ne devraient pas donner plus de résultats, mais donner les 2 premiers par ordre alphabétique)

+ -------- + ------- + ----- +
| Personne | Groupe | Age |
+ -------- + ------- + ----- +
| Bob | 1 | 32 |
| Jill | 1 | 34 |
| Shawn | 1 | 42 |
| Jake | 2 | 29 |
| Paul | 2 | 36 |
| Laura | 2 | 39 |
+ -------- + ------- + ----- +

Ensemble de résultats souhaité:

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

REMARQUE: Cette question s'appuie sur une précédente - Obtenir des enregistrements avec une valeur maximale pour chaque groupe de résultats SQL groupés - pour obtenir une seule ligne supérieure de chaque groupe, et qui a reçu une excellente réponse spécifique à MySQL de @Bohemian:

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

J'adorerais pouvoir développer cela, même si je ne vois pas comment.

Yarin
la source
2
Vérifiez cet exemple. C'est assez proche de ce que vous demandez: stackoverflow.com/questions/1537606/…
Savas Vedova
Utiliser LIMIT dans GROUP BY pour obtenir N résultats par groupe? stackoverflow.com/questions/2129693/…
Edye Chan

Réponses:

88

Voici une façon de le faire, en utilisant UNION ALL(Voir SQL Fiddle avec Démo ). Cela fonctionne avec deux groupes, si vous avez plus de deux groupes, vous devez spécifier le groupnombre et ajouter des requêtes pour chacun 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
)

Il existe différentes façons de procéder, consultez cet article pour déterminer le meilleur itinéraire pour votre situation:

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

Éditer:

Cela peut également fonctionner pour vous, cela génère un numéro de ligne pour chaque enregistrement. En utilisant un exemple du lien ci-dessus, cela ne retournera que les enregistrements avec un numéro de ligne inférieur ou égal à 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;

Voir la démo

Taryn
la source
52
s'il a plus de 1 000 groupes, cela ne ferait-il pas un peu peur?
Charles Forest
1
@CharlesForest oui, ça le ferait et c'est pourquoi j'ai déclaré que vous auriez à le spécifier pour plus de deux groupes. Cela deviendrait moche.
Taryn
1
@CharlesForest Je pense avoir trouvé une meilleure solution, voir ma modification
Taryn
1
Une note pour quiconque lit ceci: La version est que les variables sont presque correctes. Cependant, MySQL ne garantit pas l'ordre d'évaluation des expressions dans le SELECT(et, en fait, les évalue parfois dans le désordre). La clé de la solution est de mettre toutes les affectations de variables dans une seule expression; voici un exemple: stackoverflow.com/questions/38535020/… .
Gordon Linoff
1
@GordonLinoff J'ai mis à jour ma réponse, merci de l'avoir signalé. Il m'a également fallu beaucoup trop de temps pour le mettre à jour.
Taryn
63

Dans d'autres bases de données, vous pouvez le faire en utilisant ROW_NUMBER. MySQL ne prend pas en charge ROW_NUMBERmais vous pouvez utiliser des variables pour l'émuler:

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

Le voir fonctionner en ligne: sqlfiddle


Edit Je viens de remarquer que bluefeet a publié une réponse très similaire: +1 pour lui. Cependant cette réponse présente deux petits avantages:

  1. C'est une seule requête. Les variables sont initialisées dans l'instruction SELECT.
  2. Il gère les cravates comme décrit dans la question (ordre alphabétique par nom).

Je vais donc le laisser ici au cas où cela pourrait aider quelqu'un.

Mark Byers
la source
1
Mark- Cela fonctionne bien pour nous. Merci d'avoir fourni une autre bonne alternative au compliment @ bluefeet's - très apprécié.
Yarin
+1. Cela a fonctionné pour moi. Vraiment propre et réponse précise. Pouvez-vous expliquer comment cela fonctionne exactement? Quelle est la logique derrière cela?
Aditya Hajare
3
Belle solution mais il semble que cela ne fonctionne pas dans mon environnement (MySQL 5.6) car la clause order by est appliquée après select donc elle ne renvoie pas le meilleur résultat, voir ma solution alternative pour résoudre ce problème
Laurent PELE
En exécutant cela, j'ai pu supprimer JOIN (SELECT @prev := NULL, @rn := 0) AS vars. J'ai l'idée de déclarer des variables vides, mais cela semble étranger pour MySql.
Joseph Cho
1
Cela fonctionne très bien pour moi dans MySQL 5.7, mais ce serait génial si quelqu'un pouvait expliquer comment cela fonctionne
George B
41

Essaye ça:

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

DEMO

priser
la source
6
snuffin sortant de nulle part avec la solution la plus simple! Est-ce plus élégant que celui de Ludo / Bill Karwin ? Puis-je avoir un commentaire
Yarin
Hm, je ne sais pas si c'est plus élégant. Mais à en juger par les votes, je suppose que bluefeet pourrait avoir la meilleure solution.
snuffn
2
Il y a un problème avec ça. S'il y a égalité pour la deuxième place dans le groupe, un seul résultat supérieur est renvoyé. Voir la démo
Yarin
2
Ce n'est pas un problème si on le souhaite. Vous pouvez définir l'ordre de a.person.
Alberto Leal
non, cela ne fonctionne pas dans mon cas, la DEMO ne fonctionne pas non plus
Choix
31

Que diriez-vous d'utiliser l'auto-adhésion:

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;

Donne moi:

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

J'ai été fortement inspiré par la réponse de Bill Karwin à sélectionner les 10 meilleurs disques pour chaque catégorie

De plus, j'utilise SQLite, mais cela devrait fonctionner sur MySQL.

Autre chose: dans ce qui précède, j'ai remplacé la groupcolonne par une groupnamecolonne pour plus de commodité.

Modifier :

Suite au commentaire du PO concernant les résultats de cravates manquants, j'ai augmenté la réponse de Snuffin pour montrer toutes les cravates. Cela signifie que si les derniers sont des égalités, plus de 2 lignes peuvent être renvoyées, comme indiqué ci-dessous:

.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;

Donne moi:

person      groupname   age       
----------  ----------  ----------
Shawn       1           42        
Jill        1           34        
Laura       2           39        
Paul        2           36        
Joe         2           36        
Chuck       3           112      
Communauté
la source
@ Ludo- Je viens de voir cette réponse de Bill Karwin - merci de l'appliquer ici
Yarin
Que pensez-vous de la réponse de Snuffin?
J'essaye
2
Il y a un problème avec ça. S'il y a égalité pour la deuxième place dans le groupe, un seul meilleur résultat est retourné - Voir la démo
Yarin
1
@ Ludo- l'exigence initiale était que chaque groupe renvoie les n résultats exacts, les égalités étant résolues par ordre alphabétique
Yarin
La modification pour inclure les liens ne fonctionne pas pour moi. Je reçois ERROR 1242 (21000): Subquery returns more than 1 row, probablement à cause du GROUP BY. Lorsque j'exécute la SELECT MINsous - requête seule, elle génère trois lignes: 34, 39, 112et là, il semble que la deuxième valeur devrait être 36, pas 39.
verbamour
12

La solution Snuffin semble assez lente à exécuter lorsque vous avez beaucoup de lignes et que les solutions Mark Byers / Rick James et Bluefeet ne fonctionnent pas sur mon environnement (MySQL 5.6) car order by est appliqué après l'exécution de select, voici donc une variante des solutions Marc Byers / Rick James pour résoudre ce problème (avec une sélection imbriquée supplémentaire):

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;

J'ai essayé une requête similaire sur une table ayant 5 millions de lignes et elle renvoie le résultat en moins de 3 secondes

Laurent PELE
la source
3
C'est la seule requête qui fonctionne dans mon environnement. Merci!
herrherr
3
Ajoutez LIMIT 9999999à n'importe quelle table dérivée avec un ORDER BY. Cela peut empêcher ORDER BYd'être ignoré.
Rick James
J'ai lancé une requête similaire sur une table contenant quelques milliers de lignes, et il a fallu 60 secondes pour renvoyer un résultat, donc ... merci pour le message, c'est un début pour moi. (ETA: jusqu'à 5 secondes. Bien!)
Evan
10

Regarde ça:

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;

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

Travesty3
la source
5
Mec, d'autres ont trouvé des solutions beaucoup plus simples ... Je viens de passer environ 15 minutes là-dessus et j'étais incroyablement fier de moi aussi d'avoir proposé une solution aussi compliquée. Ça craint.
Travesty3
J'ai dû trouver un numéro de version interne qui était 1 de moins que l'actuel - cela m'a donné la réponse pour le faire: max(internal_version - 1)- donc moins de stress :)
Jamie Strauss
8

Si les autres réponses ne sont pas assez rapides, essayez ce code :

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;

Production:

+---------------------------+------+------------------+------------+
| 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
la source
Regardé votre site - où puis-je obtenir la source de données sur les populations des villes? TIA et rgs.
Vérace
maxmind.com/en/worldcities - Je le trouve pratique pour expérimenter les recherches lat / lng , les requêtes, le partitionnement, etc. Il est assez grand pour être intéressant, mais suffisamment lisible pour reconnaître les réponses. Le sous-ensemble canadien est pratique pour ce genre de question. (Moins de provinces que de villes américaines.)
Rick James
2

Je voulais partager cela parce que j'ai passé beaucoup de temps à chercher un moyen simple de l'implémenter dans un programme java sur lequel je travaille. Cela ne donne pas tout à fait la sortie que vous recherchez, mais c'est proche. La fonction appelée dans mysql GROUP_CONCAT()fonctionnait très bien pour spécifier le nombre de résultats à renvoyer dans chaque groupe. Utiliser LIMITou l'une des autres façons sophistiquées d'essayer de faire cela COUNTne fonctionnait pas pour moi. Donc, si vous êtes prêt à accepter une sortie modifiée, c'est une excellente solution. Disons que j'ai une table appelée «étudiant» avec les identifiants des étudiants, leur sexe et gpa. Disons que je veux top 5 gpas pour chaque sexe. Ensuite, je peux écrire la requête comme ceci

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

Notez que le paramètre '5' lui indique le nombre d'entrées à concaténer dans chaque ligne

Et la sortie ressemblerait à quelque chose comme

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

Vous pouvez également modifier la ORDER BYvariable et les ordonner d'une manière différente. Donc, si j'avais l'âge de l'élève, je pourrais remplacer le «gpa desc» par «age desc» et cela fonctionnera! Vous pouvez également ajouter des variables à l'instruction group by pour obtenir plus de colonnes dans la sortie. C'est donc juste une façon que j'ai trouvée qui est assez flexible et fonctionne bien si vous êtes d'accord avec la simple liste des résultats.

Jon Bown
la source
0

Dans SQL Server row_numer()est une fonction puissante qui peut obtenir le résultat facilement comme ci-dessous

select Person,[group],age
from
(
select * ,row_number() over(partition by [group] order by age desc) rn
from mytable
) t
where rn <= 2
Prakash
la source
Avec 8.0 et 10.2 étant GA, cette réponse devient raisonnable.
Rick James
@RickJames que signifie «être GA»? Les fonctions de fenêtre ( dev.mysql.com/doc/refman/8.0/en/window-functions.html ) ont très bien résolu mon problème.
iedmrc
1
@iedmrc - "GA" signifie "Généralement disponible". C'est un discours technique pour «prêt pour les heures de grande écoute» ou «publié». Ils sont en train de développer la version et se concentreront sur les bogues manqués. Ce lien traite de l'implémentation de MySQL 8.0, qui peut être différente de l'implémentation de MariaDB 10.2.
Rick James
-1

Il y a une très bonne réponse à ce problème chez MySQL - Comment obtenir les N premières lignes par chaque groupe

Sur la base de la solution dans le lien référencé, votre requête serait comme:

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;

nest le top net your_tableest le nom de votre table.

Je pense que l'explication dans la référence est vraiment claire. Pour une référence rapide, je vais le copier et le coller ici:

Actuellement MySQL ne prend pas en charge la fonction ROW_NUMBER () qui peut attribuer un numéro de séquence dans un groupe, mais comme solution de contournement, nous pouvons utiliser des variables de session MySQL.

Ces variables ne nécessitent pas de déclaration et peuvent être utilisées dans une requête pour effectuer des calculs et stocker des résultats intermédiaires.

@current_country: = country Ce code est exécuté pour chaque ligne et stocke la valeur de la colonne country dans la variable @current_country.

@country_rank: = IF (@current_country = country, @country_rank + 1, 1) Dans ce code, si @current_country est le même, nous incrémentons le rang, sinon définissez-le sur 1. Pour la première ligne, @current_country est NULL, donc le rang est également mis à 1.

Pour un classement correct, nous devons avoir ORDER BY pays, population DESC

kovac
la source
Eh bien, c'est le principe utilisé par les solutions de Marc Byers, Rick James et la mienne.
Laurent PELE
Difficile de dire quel message (Stack Overflow ou SQLlines) était le premier
Laurent PELE
@LaurentPELE - Le mien a été publié en février 2015. Je ne vois ni horodatage ni nom sur SQLlines. Les blogs MySQL existent depuis assez longtemps pour que certains d'entre eux ne soient plus à jour et devraient être supprimés - les gens citent des informations erronées.
Rick James