Obtenha os principais n registros para cada grupo de resultados agrupados

140

A seguir, é apresentado o exemplo mais simples possível, embora qualquer solução deva ser dimensionada para o máximo de n resultados necessários:

Dada uma tabela como essa abaixo, com colunas de pessoa, grupo e faixa etária, como você obteria as 2 pessoas mais velhas de cada grupo? (Os laços dentro dos grupos não devem produzir mais resultados, mas fornecer os 2 primeiros em ordem alfabética)

+ -------- + ------- + ----- +
| Pessoa Grupo | Idade
+ -------- + ------- + ----- +
| Bob 1 | 32
| Jill 1 | 34
| Shawn 1 | 42
| Jake 2 29
| Paul 2 36
| Laura 2 39
+ -------- + ------- + ----- +

Conjunto de resultados desejado:

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

NOTA: Esta pergunta baseia-se em uma anterior - Obter registros com valor máximo para cada grupo de resultados SQL agrupados - para obter uma única linha superior de cada grupo e que recebeu uma ótima resposta específica do MySQL do @Bohemian:

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

Adoraria ser capaz de construir isso, embora eu não veja como.

Yarin
fonte
2
Veja este exemplo. É bem parecido com o que você pergunta: stackoverflow.com/questions/1537606/…
Savas Vedova
Usando LIMIT no GROUP BY para obter N resultados por grupo? stackoverflow.com/questions/2129693/…
Edye Chan

Respostas:

88

Aqui está uma maneira de fazer isso, usando UNION ALL(consulte SQL Fiddle with Demo ). Isso funciona com dois grupos, se você tiver mais de dois grupos, será necessário especificar o groupnúmero e adicionar consultas para cada um 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
)

Há várias maneiras de fazer isso, consulte este artigo para determinar a melhor rota para sua situação:

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

Editar:

Isso também pode funcionar para você, pois gera um número de linha para cada registro. Usando um exemplo do link acima, ele retornará apenas os registros com um número de linha menor ou 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 demonstração

Taryn
fonte
52
se ele tiver mais de 1 000 grupos, isso não tornaria isso um pouco assustador?
Charles Floresta
1
@CharlesForest sim, seria e é por isso que afirmei que você precisaria especificá-lo para mais de dois grupos. Ficaria feio.
Taryn
1
@CharlesForest Acho que encontrei uma solução melhor, ver a minha edição
Taryn
1
Uma observação para quem lê isso: A versão é que as variáveis ​​estão próximas de estar corretas. No entanto, o MySQL não garante a ordem de avaliação das expressões no SELECT(e, de fato, às vezes as avalia fora de ordem). A chave da solução é colocar todas as atribuições de variáveis ​​em uma única expressão; Aqui está um exemplo: stackoverflow.com/questions/38535020/… .
Gordon Linoff
1
@GordonLinoff Atualizei minha resposta, obrigado por apontar. Também demorou muito para eu atualizá-lo.
Taryn
63

Em outros bancos de dados, você pode fazer isso usando ROW_NUMBER. O MySQL não suporta, ROW_NUMBERmas você pode usar variáveis ​​para emular:

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

Veja-o trabalhando on-line: sqlfiddle


Editar Acabei de notar que o bluefeet postou uma resposta muito semelhante: +1 a ele. No entanto, esta resposta tem duas pequenas vantagens:

  1. É uma única consulta. As variáveis ​​são inicializadas dentro da instrução SELECT.
  2. Ele lida com os laços, conforme descrito na pergunta (ordem alfabética por nome).

Então, vou deixar aqui para o caso de ajudar alguém.

Mark Byers
fonte
1
Mark- Isso está funcionando bem para nós. Obrigado por fornecer outra boa alternativa para elogiar @ bluefeet's - muito apreciada.
Yarin
+1. Isso funcionou para mim. Realmente limpo e direto ao ponto. Você pode explicar como exatamente isso funciona? Qual é a lógica por trás disso?
Aditya Hajare
3
Solução agradável, mas parece que não está funcionando no meu ambiente (MySQL 5.6) porque a cláusula order by é aplicada após select para que não retorne o resultado principal. Consulte minha solução alternativa para corrigir esse problema
Laurent PELE
Durante a execução, consegui excluir JOIN (SELECT @prev := NULL, @rn := 0) AS vars. A ideia é declarar variáveis ​​vazias, mas parece estranho para o MySql.
Joseph Cho
1
Isso funciona muito bem para mim no MySQL 5.7, mas seria incrível se alguém pudesse explicar como funciona #
George B
41

Tente o seguinte:

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

snuffn
fonte
6
snuffin saindo do nada com a solução mais simples! Isso é mais elegante que o de Ludo / Bill Karwin ? Posso obter um comentário
Yarin
Hum, não tenho certeza se é mais elegante. Mas, a julgar pelos votos, acho que o bluefeet pode ter a melhor solução.
snuffn
2
Há um problema com isso. Se houver um empate para o segundo lugar no grupo, apenas um resultado será retornado. Veja demo
Yarin
2
Não é um problema, se desejado. Você pode definir a ordem de a.person.
Alberto Leal
Não, ele não está funcionando no meu caso, nem faz o trabalho DEMONSTRA
Choix
31

Que tal usar a união automática:

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 dá:

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

Fiquei fortemente inspirado pela resposta de Bill Karwin para selecionar os 10 melhores registros para cada categoria

Além disso, estou usando SQLite, mas isso deve funcionar no MySQL.

Outra coisa: acima, substituí a groupcoluna por uma groupnamecoluna por conveniência.

Editar :

Seguindo o comentário do OP sobre os resultados ausentes dos empates, eu aumentei a resposta do snuffin para mostrar todos os empates. Isso significa que, se os últimos forem empates, mais de 2 linhas poderão ser retornadas, conforme mostrado abaixo:

.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 dá:

person      groupname   age       
----------  ----------  ----------
Shawn       1           42        
Jill        1           34        
Laura       2           39        
Paul        2           36        
Joe         2           36        
Chuck       3           112      
Comunidade
fonte
@ Ludo- Só vi que resposta de Bill Karwin - graças para aplicá-lo aqui
Yarin
O que você acha da resposta de Snuffin? Eu estou tentando comparar os dois
Yarin
2
Há um problema com isso. Se houver um empate para o segundo lugar no grupo, apenas um resultado superior é returned- Ver demonstração
Yarin
1
@ Ludo- a exigência inicial era de que cada grupo de devolver os n exatas resultados, com quaisquer laços sendo resolvido em ordem alfabética
Yarin
A edição para incluir os laços não funciona para mim. Eu recebo ERROR 1242 (21000): Subquery returns more than 1 row, presumivelmente por causa do GROUP BY. Quando eu executar a SELECT MINsubconsulta sozinho, ele gera três linhas: 34, 39, 112e aí aparece o segundo valor deve ser 36, não 39.
verbamour
12

A solução Snuffin parece bastante lenta de executar quando você tem muitas linhas e as soluções Mark Byers / Rick James e Bluefeet não funcionam no meu ambiente (MySQL 5.6) porque order by é aplicado após a execução de select, então aqui está uma variante das soluções Marc Byers / Rick James para corrigir esse problema (com uma seleção extra imbricada):

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;

Eu tentei consulta semelhante em uma tabela com 5 milhões de linhas e retorna resultado em menos de 3 segundos

Laurent PELE
fonte
3
Essa é a única consulta que está funcionando no meu ambiente. Obrigado!
herrherr
3
Adicione LIMIT 9999999a qualquer tabela derivada com um ORDER BY. Isso pode impedir que ORDER BYseja ignorado.
21718 Rick
Fiz uma consulta semelhante em uma tabela que contém alguns milhares de linhas e demorou 60 segundos para retornar um resultado, então ... obrigado pela postagem, é um começo para mim. (ETA: até 5 segundos. Bom!)
Evan
10

Veja isso:

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
fonte
5
Cara, outros encontraram soluções muito mais simples ... Passei uns 15 minutos nisso e fiquei incrivelmente orgulhosa de ter encontrado uma solução tão complicada também. Isso é péssimo.
precisa saber é o seguinte
Eu tinha de encontrar um número de versão interna que foi 1 a menos do que o atual - isso me deu a resposta para isso: max(internal_version - 1)- assim menos estresse :)
Jamie Strauss
8

Se as outras respostas não forem rápidas o suficiente Experimente 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;

Resultado:

+---------------------------+------+------------------+------------+
| 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
fonte
Olhou para o seu site - onde obteria a fonte de dados para as populações das cidades? TIA e rgs.
Vérace 11/04
maxmind.com/en/worldcities - acho útil para experimentar pesquisas , consultas, particionamentos de lat / lng , etc. É grande o suficiente para ser interessante, mas legível o suficiente para reconhecer as respostas. O subconjunto canadense é útil para esse tipo de pergunta. (Províncias Menos do que cidades dos Estados Unidos.)
Rick James
2

Eu queria compartilhar isso porque passei muito tempo procurando uma maneira fácil de implementar isso em um programa java no qual estou trabalhando. Isso não dá exatamente o resultado que você está procurando, mas está próximo. A função no mysql chamada GROUP_CONCAT()funcionou muito bem para especificar quantos resultados retornar em cada grupo. Usar LIMITou qualquer outra maneira sofisticada de tentar fazer isso COUNTnão funcionou para mim. Portanto, se você deseja aceitar uma saída modificada, é uma ótima solução. Digamos que eu tenha uma tabela chamada 'aluno' com os IDs dos alunos, seu sexo e gpa. Vamos dizer que eu quero top 5 gpas para cada gênero. Então eu posso escrever a consulta assim

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

Observe que o parâmetro '5' informa quantas entradas concatenar em cada linha

E a saída seria algo como

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

Você também pode alterar a ORDER BYvariável e ordená-la de uma maneira diferente. Portanto, se eu tivesse a idade do aluno, poderia substituir o 'gpa desc' por 'age desc' e ele funcionará! Você também pode adicionar variáveis ​​ao grupo por instrução para obter mais colunas na saída. Portanto, essa é apenas uma maneira que achei bastante flexível e funciona bem se você estiver bem apenas listando os resultados.

Jon Bown
fonte
0

No SQL Server row_numer()é uma função poderosa que pode obter resultados facilmente como abaixo

select Person,[group],age
from
(
select * ,row_number() over(partition by [group] order by age desc) rn
from mytable
) t
where rn <= 2
Prakash
fonte
Com 8.0 e 10.2 sendo GA, esta resposta está se tornando razoável.
Rick James
@RickJames, o que significa 'ser GA'? As funções da janela ( dev.mysql.com/doc/refman/8.0/en/window-functions.html ) resolveram meu problema muito bem.
Iedmrc 01/10/1918
1
@iedmrc - "GA" significa "Geralmente disponível". É um discurso técnico para "pronto para o horário nobre" ou "liberado". Eles estão desenvolvendo a versão e se concentrarão nos erros que eles perderam. Esse link discute a implementação do MySQL 8.0, que pode ser diferente da implementação do MariaDB 10.2.
Rick James
-1

Existe uma resposta muito boa para esse problema no MySQL - Como obter as principais linhas N por cada grupo

Com base na solução no link referenciado, sua consulta seria 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;

onde nestá o top neyour_table é o nome da sua tabela.

Eu acho que a explicação na referência é realmente clara. Para uma referência rápida, copia e colo aqui:

Atualmente, o MySQL não suporta a função ROW_NUMBER () que pode atribuir um número de sequência dentro de um grupo, mas como solução alternativa, podemos usar variáveis ​​de sessão do MySQL.

Essas variáveis ​​não requerem declaração e podem ser usadas em uma consulta para fazer cálculos e armazenar resultados intermediários.

@current_country: = country Este código é executado para cada linha e armazena o valor da coluna country na variável @current_country.

@country_rank: = IF (@current_country = country, @country_rank + 1, 1) Neste código, se @current_country for o mesmo, incrementamos a classificação, caso contrário, defina-o como 1. Na primeira linha @current_country é NULL, a classificação é também definido como 1.

Para uma classificação correta, precisamos ter ORDER BY country, população DESC

kovac
fonte
Bem, é o princípio usado pelas soluções de Marc Byers, Rick James e o meu.
Laurent PELE
Difícil dizer qual post (Stack Overflow ou SQLlines) foi o primeiro #
Laurent PELE
@LaurentPELE - O meu foi publicado em fevereiro de 2015. Não vejo carimbo de data / hora ou nome no SQLlines. Os blogs MySQL já existem há tempo suficiente para que alguns deles estejam desatualizados e devam ser removidos - as pessoas estão citando informações incorretas.
Rick James