Les premiers 70% du cours SQL semblent assez simples. Les difficultés commencent sur les 30% restants.De 2015 à 2019, j'ai effectué quatre entretiens pour les postes d'analyste de données et de spécialiste en analyse de données dans plus d'une douzaine d'entreprises. Après une autre entrevue infructueuse en 2017 - lorsque je me suis trompé sur des questions SQL complexes - j'ai commencé à compiler un livre de tâches avec des questions SQL de complexité moyenne et élevée afin de mieux préparer les entretiens. Ce guide a été utile lors de la dernière série d'entretiens en 2019. Au cours de la dernière année, j'ai partagé ce guide avec quelques amis, et grâce au temps libre supplémentaire dû à une pandémie, je l'ai peaufiné et j'ai compilé ce document.Il existe de nombreux didacticiels SQL pour les débutants. Mes favoris sontCours interactifs SQL et Select Star SQL de Codecademy par Zi Chung Kao. Mais en réalité, les premiers 70% du cours SQL sont assez simples, et les vraies difficultés commencent dans les 30% restants, qui ne sont pas couverts dans les guides du débutant. Ainsi, lors des entretiens avec les analystes de données et les spécialistes de l'analyse de données dans les entreprises technologiques, ils posent souvent des questions sur ces 30%.Étonnamment, je n'ai pas trouvé de source exhaustive sur ces questions de difficulté moyenne, j'ai donc compilé ce guide.Il est utile pour les entretiens, mais en même temps, il augmentera votre efficacité dans vos emplois actuels et futurs. Personnellement, je pense que certains des modèles SQL mentionnés sont également utiles pour les systèmes ETL qui exécutent des outils de reporting et des fonctions d'analyse de données pour identifier les tendances.
Vous devez comprendre que lors des entretiens avec les analystes de données et les analystes de données, ils posent des questions non seulement sur SQL. D'autres sujets communs incluent la discussion de projets antérieurs, les tests A / B, le développement de métriques et les problèmes analytiques ouverts. Il y a environ trois ans, Quora a publié des conseils sur les entretiens pour un poste d'analyste produit sur Facebook. Là , ce sujet est discuté plus en détail. Cependant, si l'amélioration de vos connaissances de SQL vous aidera dans votre entretien, ce guide en vaut la peine.À l'avenir, je pourrai porter le code de ce guide vers un site comme Select Star SQLpour faciliter l'écriture d'instructions SQL et voir le résultat de l'exécution du code en temps réel. En option, ajoutez des questions en tant que problèmes à la plate-forme pour préparer les entretiens LeetCode . En attendant, je voulais juste publier ce document pour que les gens puissent désormais se familiariser avec ces informations.Hypothèses formulées et comment utiliser le manuel
Hypothèses sur la connaissance du langage SQL: On suppose que vous avez une connaissance pratique de SQL. Vous l'utilisez probablement souvent au travail, mais souhaitez perfectionner vos compétences dans des sujets tels que les auto-associations et les fonctions de fenêtre.Comment utiliser ce manuel: Puisqu'un tableau ou un cahier virtuel est souvent utilisé dans une interview (sans compiler le code), je recommande de prendre un crayon et du papier et d'écrire des solutions pour chaque problème, et après avoir terminé, comparez vos notes avec les réponses. Ou élaborez vos réponses avec un ami qui fera office d'intervieweur!- Les erreurs de syntaxe mineures importent peu lors d'un entretien avec un tableau blanc ou un bloc-notes. Mais ils peuvent distraire l'intervieweur, essayez donc idéalement de réduire leur nombre afin de concentrer toute votre attention sur la logique.
- Les réponses données ne sont pas nécessairement le seul moyen de résoudre chaque problème. N'hésitez pas à écrire des commentaires avec des solutions supplémentaires que vous pouvez ajouter à ce guide!
Conseils pour résoudre des tâches complexes dans les entretiens SQL
Tout d'abord, des conseils standard pour toutes les interviews de programmation ...- Écoutez attentivement la description du problème, répétez l'essence du problème à l'intervieweur
- Formulez un cas limite pour démontrer que vous comprenez vraiment le problème (c'est-à -dire une ligne qui ne sera pas incluse dans la requête SQL finale que vous allez écrire)
- ( ) , — : ,
- SQL, , . , .
Certains des problèmes répertoriés ici sont adaptés d'anciennes entrées du blog Periscope (principalement écrites par Sean Cook vers 2014, bien que sa paternité semble avoir été retirée des documents après la fusion de SiSense avec Periscope ), ainsi que des discussions sur StackOverflow. Si nécessaire, les sources sont marquées au début de chaque question.On the Select Star SQL est également une bonne sélection de casse - tête , numéros complémentaires de ce document.Veuillez noter que ces questions ne sont pas des copies littérales des questions de mes propres entretiens et qu'elles n'ont pas été utilisées dans les entreprises dans lesquelles j'ai travaillé ou travaille.Tâches d'auto-association
No. 1. Variation en pourcentage d'un mois Ă l'autre
Contexte: il est souvent utile de savoir comment une métrique clé change, par exemple, l'audience mensuelle des utilisateurs actifs, d'un mois à l'autre. Disons que nous avons un tableau logins
sous cette forme:| user_id | date |
| --------- | ------------ |
| 1 | 2018-07-01 |
| 234 | 2018-07-02 |
| 3 | 2018-07-02 |
| 1 | 2018-07-02 |
| ... | ... |
| 234 | 04/10/2018 |
Objectif : trouver la variation mensuelle en pourcentage de l'audience mensuelle des utilisateurs actifs (MAU).Solution:(Cette solution, comme les autres blocs de code de ce document, contient des commentaires sur les éléments de syntaxe SQL qui peuvent différer entre les différentes variantes SQL et d'autres notes)WITH mau AS
(
SELECT
DATE_TRUNC('month', date) month_timestamp,
COUNT(DISTINCT user_id) mau
FROM
logins
GROUP BY
DATE_TRUNC('month', date)
)
SELECT
a.month_timestamp previous_month,
a.mau previous_mau,
b.month_timestamp current_month,
b.mau current_mau,
ROUND(100.0*(b.mau - a.mau)/a.mau,2) AS percent_change
FROM
mau a
JOIN
mau b ON a.month_timestamp = b.month_timestamp - interval '1 month'
No. 2. Structure de l'arbre de marquage
Contexte: supposons que vous ayez une table tree
avec deux colonnes: la première indique les nœuds et la seconde les nœuds parents.parent de nœud
12
2 5
3 5
4 3
5 NUL
La tâche: écrire du SQL de manière à désigner chaque nœud comme interne (racine), racine (racine) ou nœud final / feuille (feuille), de sorte que pour les valeurs ci-dessus, vous obtenez les éléments suivants:node label
1 Leaf
2 Inner
3 Inner
4 Leaf
5 Root
(Remarque: plus d'informations sur la terminologie de la structure de données arborescente peuvent être trouvées ici . Cependant, ce n'est pas nécessaire pour résoudre ce problème!)Solution:Remerciements: Fabian Hoffman a proposé cette solution plus généralisée le 2 mai 2020. Merci FabianWITH join_table AS
(
SELECT
cur.node,
cur.parent,
COUNT(next.node) AS num_children
FROM
tree cur
LEFT JOIN
tree next ON (next.parent = cur.node)
GROUP BY
cur.node,
cur.parent
)
SELECT
node,
CASE
WHEN parent IS NULL THEN "Root"
WHEN num_children = 0 THEN "Leaf"
ELSE "Inner"
END AS label
FROM
join_table
Solution alternative, sans connexions explicites:Remerciements: William Chardgin le 2 mai 2020 a attiré l'attention sur la nécessité de la condition WHERE parent IS NOT NULL
que cette solution revienne Ă la Leaf
place NULL
. Merci William!SELECT
node,
CASE
WHEN parent IS NULL THEN 'Root'
WHEN node NOT IN
(SELECT parent FROM tree WHERE parent IS NOT NULL) THEN 'Leaf'
WHEN node IN (SELECT parent FROM tree) AND parent IS NOT NULL THEN 'Inner'
END AS label
from
tree
No. 3. Rétention des utilisateurs par mois (plusieurs parties)
Remerciements: Cette tâche a été adaptée de l'article de blog SiSense, «Utilisation des auto-associations pour calculer la rétention, le flux sortant et la réactivation » .Partie 1
Contexte: supposons que nous ayons des statistiques sur l'autorisation des utilisateurs sur un site dans le tableau logins
:| user_id | date |
| --------- | ------------ |
| 1 | 2018-07-01 |
| 234 | 2018-07-02 |
| 3 | 2018-07-02 |
| 1 | 2018-07-02 |
| ... | ... |
| 234 | 04/10/2018 |
Tâche: rédiger une demande qui reçoit le nombre d'utilisateurs retenus par mois. Dans notre cas, ce paramètre est défini comme le nombre d'utilisateurs qui se sont connectés au système au cours de ce mois et du mois précédent.Décision:SELECT
DATE_TRUNC('month', a.date) month_timestamp,
COUNT(DISTINCT a.user_id) retained_users
FROM
logins a
JOIN
logins b ON a.user_id = b.user_id
AND DATE_TRUNC('month', a.date) = DATE_TRUNC('month', b.date) +
interval '1 month'
GROUP BY
date_trunc('month', a.date)
Remerciements:Tom Moertel a souligné que la pré-duplication de user_id avant l'auto-jonction rend la solution plus efficace et a suggéré le code ci-dessous. Merci Tom!Solution alternative:WITH DistinctMonthlyUsers AS (
SELECT DISTINCT
DATE_TRUNC('MONTH', a.date) AS month_timestamp,
user_id
FROM logins
)
SELECT
CurrentMonth.month_timestamp month_timestamp,
COUNT(PriorMonth.user_id) AS retained_user_count
FROM
DistinctMonthlyUsers AS CurrentMonth
LEFT JOIN
DistinctMonthlyUsers AS PriorMonth
ON
CurrentMonth.month_timestamp = PriorMonth.month_timestamp + INTERVAL '1 MONTH'
AND
CurrentMonth.user_id = PriorMonth.user_id
Partie 2
Tâche: nous prenons maintenant la tâche précédente de calculer le nombre d'utilisateurs retenus par mois - et la retournons. Nous rédigerons une demande pour compter les utilisateurs qui ne sont pas revenus sur le site ce mois-ci. Autrement dit, les utilisateurs "perdus".Décision:SELECT
DATE_TRUNC('month', a.date) month_timestamp,
COUNT(DISTINCT b.user_id) churned_users
FROM
logins a
FULL OUTER JOIN
logins b ON a.user_id = b.user_id
AND DATE_TRUNC('month', a.date) = DATE_TRUNC('month', b.date) +
interval '1 month'
WHERE
a.user_id IS NULL
GROUP BY
DATE_TRUNC('month', a.date)
Veuillez noter que ce problème peut également être résolu avec LEFT
ou RIGHT
.3e partie
Remarque: il s'agit probablement d'une tâche plus difficile que celle qui vous sera proposée lors d'un véritable entretien. Pensez-y plutôt comme un puzzle - ou vous pouvez sauter et passer à la tâche suivante.Contexte : nous avons donc fait du bon travail avec deux problèmes précédents. Selon les termes de la nouvelle tâche, nous avons maintenant un tableau des utilisateurs perdus user_churns
. Si l'utilisateur était actif au cours du dernier mois, mais non actif dans ce cas, il est alors inscrit dans le tableau de ce mois. Voici à quoi ça ressemble user_churns
:| user_id | mois_date |
| --------- | ------------ |
| 1 | 2018-05-01 |
| 234 | 2018-05-01 |
| 3 | 2018-05-01 |
| 12 | 2018-05-01 |
| ... | ... |
| 234 | 01/10/2018 |
Tâche : vous voulez maintenant effectuer une analyse de cohorte, c'est-à -dire une analyse de la totalité des utilisateurs actifs qui ont été réactivés dans le passé . Créez une table avec ces utilisateurs. Vous pouvez utiliser des tableaux user_churns
et créer une cohorte logins
. Dans Postgres, l'horodatage actuel est accessible via current_timestamp
.Décision:WITH user_login_data AS
(
SELECT
DATE_TRUNC('month', a.date) month_timestamp,
a.user_id,
MAX(b.month_date) as most_recent_churn,
MAX(DATE_TRUNC('month', c.date)) as most_recent_active
FROM
logins a
JOIN
user_churns b
ON a.user_id = b.user_id AND DATE_TRUNC('month', a.date) > b.month_date
JOIN
logins c
ON a.user_id = c.user_id
AND
DATE_TRUNC('month', a.date) > DATE_TRUNC('month', c.date)
WHERE
DATE_TRUNC('month', a.date) = DATE_TRUNC('month', current_timestamp)
GROUP BY
DATE_TRUNC('month', a.date),
a.user_id
HAVING
most_recent_churn > most_recent_active
No. 4. Augmentation du total
Remerciements: cette tâche a été adaptée de l'article de blog SiSense , Cash Flow Modeling in SQL .Contexte: supposons que nous ayons un tableau transactions
sous cette forme:| date | cash_flow |
| ------------ | ----------- |
| 01-01-2018 | -1000 |
| 2018-01-02 | -100 |
| 2018-01-03 | 50 |
| ... | ... |
OĂą cash_flow
est le revenu moins les coûts de chaque jour.Objectif: rédiger une demande pour obtenir un total cumulé des flux de trésorerie chaque jour de telle sorte qu'au final, vous obtenez un tableau sous cette forme:| date | cumulative_cf |
| ------------ | --------------- |
| 01-01-2018 | -1000 |
| 2018-01-02 | -1100 |
| 2018-01-03 | -1050 |
| ... | ... |
Décision:SELECT
a.date date,
SUM(b.cash_flow) as cumulative_cf
FROM
transactions a
JOIN b
transactions b ON a.date >= b.date
GROUP BY
a.date
ORDER BY
date ASC
Une solution alternative utilisant une fonction fenĂŞtre (plus efficace!):SELECT
date,
SUM(cash_flow) OVER (ORDER BY date ASC) as cumulative_cf
FROM
transactions
ORDER BY
date ASC
No. 5. Moyenne mobile
Remerciements: cette tâche est adaptée de l'article de blog SiSense , Moving Averages in MySQL and SQL Server .Remarque: la moyenne mobile peut être calculée de différentes manières. Ici, nous utilisons la moyenne précédente. Ainsi, la métrique du septième jour du mois sera la moyenne des six jours précédents et de lui-même.Contexte : supposons que nous ayons un tableau signups
sous cette forme:| date | inscriptions |
| ------------ | ---------- |
| 01-01-2018 | 10 |
| 2018-01-02 | 20 |
| 2018-01-03 | 50 |
| ... | ... |
| 01/10/2018 | 35 |
Tâche : rédiger une demande pour obtenir une moyenne mobile sur 7 jours des inscriptions quotidiennes.Décision:SELECT
a.date,
AVG(b.sign_ups) average_sign_ups
FROM
signups a
JOIN
signups b ON a.date <= b.date + interval '6 days' AND a.date >= b.date
GROUP BY
a.date
No. 6. Plusieurs conditions de connexion
Remerciements: cette tâche est adaptée de l'article de blog SiSense, «Analyse de votre messagerie à l'aide de SQL» .Contexte: disons que notre tableau emails
contient des emails envoyés depuis l'adresse zach@g.com
et reçus dessus:| id | sujet | de | à | horodatage |
| ---- | ---------- | -------------- | -------------- | --- ------------------ |
| 1 | Yosemite | zach@g.com | thomas@g.com | 2018-01-02 12:45:03 |
| 2 | Big Sur | sarah@g.com | thomas@g.com | 2018-01-02 16:30:01 |
| 3 | Yosemite | thomas@g.com | zach@g.com | 2018-01-02 16:35:04 |
| 4 | Running | jill@g.com | zach@g.com | 2018-01-03 08:12:45 |
| 5 | Yosemite | zach@g.com | thomas@g.com | 2018-01-03 14:02:01 |
| 6 | Yosemite | thomas@g.com | zach@g.com | 2018-01-03 15:01:05 |
| .. | .. | .. | .. | .. |
Tâche: rédigez une demande pour obtenir le temps de réponse pour chaque lettre ( id
) envoyée à zach@g.com
. N'incluez pas de lettres à d'autres adresses. Supposons que chaque fil ait un thème unique. Gardez à l'esprit que le fil peut avoir plusieurs lettres aller-retour entre zach@g.com
et d'autres destinataires.Décision:SELECT
a.id,
MIN(b.timestamp) - a.timestamp as time_to_respond
FROM
emails a
JOIN
emails b
ON
b.subject = a.subject
AND
a.to = b.from
AND
a.from = b.to
AND
a.timestamp < b.timestamp
WHERE
a.to = 'zach@g.com'
GROUP BY
a.id
Tâches pour les fonctions de fenêtre
Non. 1. Trouvez l'identifiant avec la valeur maximale
Contexte: Supposons que nous ayons un tableau salaries
avec des données sur les départements et les salaires des employés dans le format suivant: depname | empno | salaire |
----------- + ------- + -------- +
développer | 11 | 5200 |
développer | 7 | 4200 |
développer | 9 | 4500 |
développer | 8 | 6000 |
développer | 10 | 5200 |
personnel | 5 | 3500 |
personnel | 2 | 3900 |
ventes | 3 | 4800 |
ventes | 1 | 5000 |
ventes | 4 | 4800 |
Tâche : rédiger une demande pour obtenir empno
le salaire le plus élevé. Assurez-vous que votre solution gère les cas d'égalité de salaire!Décision:WITH max_salary AS (
SELECT
MAX(salary) max_salary
FROM
salaries
)
SELECT
s.empno
FROM
salaries s
JOIN
max_salary ms ON s.salary = ms.max_salary
Solution alternative utilisant RANK()
:WITH sal_rank AS
(SELECT
empno,
RANK() OVER(ORDER BY salary DESC) rnk
FROM
salaries)
SELECT
empno
FROM
sal_rank
WHERE
rnk = 1;
No. 2. Valeur moyenne et classement avec une fonction de fenĂŞtre (plusieurs parties)
Partie 1
Contexte : supposons que nous ayons une table salaries
dans ce format: depname | empno | salaire |
----------- + ------- + -------- +
développer | 11 | 5200 |
développer | 7 | 4200 |
développer | 9 | 4500 |
développer | 8 | 6000 |
développer | 10 | 5200 |
personnel | 5 | 3500 |
personnel | 2 | 3900 |
ventes | 3 | 4800 |
ventes | 1 | 5000 |
ventes | 4 | 4800 |
Tâche: écrire une requête qui renvoie le même tableau, mais avec une nouvelle colonne qui montre le salaire moyen pour le département. Nous nous attendrions à une table comme celle-ci: depname | empno | salaire | avg_salary |
----------- + ------- + -------- + ------------ +
développer | 11 | 5200 | 5020 |
développer | 7 | 4200 | 5020 |
développer | 9 | 4500 | 5020 |
développer | 8 | 6000 | 5020 |
développer | 10 | 5200 | 5020 |
personnel | 5 | 3500 | 3700 |
personnel | 2 | 3900 | 3700 |
ventes | 3 | 4800 | 4867 |
ventes | 1 | 5000 | 4867 |
ventes | 4 | 4800 | 4867 |
Décision:SELECT
*,
ROUND(AVG(salary),0) OVER (PARTITION BY depname) avg_salary
FROM
salaries
Partie 2
Tâche: écrire une requête qui ajoute une colonne avec la position de chaque employé dans la feuille de temps en fonction de son salaire dans son département, où l'employé avec le salaire le plus élevé obtient la position 1. Nous nous attendrions à une table sous cette forme: depname | empno | salaire | salaire_rank |
----------- + ------- + -------- + ------------- +
développer | 11 | 5200 | 2 |
développer | 7 | 4200 | 5 |
développer | 9 | 4500 | 4 |
développer | 8 | 6000 | 1 |
développer | 10 | 5200 | 2 |
personnel | 5 | 3500 | 2 |
personnel | 2 | 3900 | 1 |
ventes | 3 | 4800 | 2 |
ventes | 1 | 5000 | 1 |
ventes | 4 | 4800 | 2 |
Décision:SELECT
*,
RANK() OVER(PARTITION BY depname ORDER BY salary DESC) salary_rank
FROM
salaries
Autres tâches de difficulté moyenne et élevée
N ° 1. Histogrammes
Contexte: Disons que nous avons un tableau sessions
où chaque ligne représente une session de streaming vidéo d'une durée en secondes:| session_id | durée_secondes |
| ------------ | ---------------- |
| 1 | 23 |
| 2 | 453 |
| 3 | 27 |
| .. | .. |
Tâche: écrivez une requête pour calculer le nombre de sessions qui tombent à des intervalles de cinq secondes, c'est-à -dire pour le fragment ci-dessus, le résultat sera quelque chose comme ceci:| seau | compter |
| --------- | ------- |
| 20-25 | 2 |
| 450-455 | 1 |
Le score maximum compte pour les étiquettes de ligne appropriées ("5-10", etc.)Solution:WITH bin_label AS
(SELECT
session_id,
FLOOR(length_seconds/5) as bin_label
FROM
sessions
)
SELECT
CONCATENTATE(STR(bin_label*5), '-', STR(bin_label*5+5)) bucket,
COUNT(DISTINCT session_id) count
GROUP BY
bin_label
ORDER BY
bin_label ASC
No. 2. Connexion croisée (plusieurs parties)
Partie 1
Contexte: disons que nous avons un tableau state_streams
où le nom de l'État et le nombre total d'heures de streaming depuis l'hébergement vidéo sont indiqués sur chaque ligne:| état | total_streams |
| ------- | --------------- |
| NC | 34569 |
| SC | 33999 |
| CA | 98324 |
| MA | 19345 |
| .. | .. |
(En fait, les tables agrégées de ce type ont généralement une colonne de date, mais nous l'exclurons pour cette tâche)Tâche: écrire une requête pour obtenir des paires d'états avec un nombre total de threads dans un millier les uns des autres. Pour l'extrait ci-dessus, nous aimerions voir quelque chose comme:| state_a | state_b |
| --------- | --------- |
| NC | SC |
| SC | NC |
Décision:SELECT
a.state as state_a,
b.state as state_b
FROM
state_streams a
CROSS JOIN
state_streams b
WHERE
ABS(a.total_streams - b.total_streams) < 1000
AND
a.state <> b.state
Pour information, les jointures croisées peuvent également être écrites sans spécifier explicitement les jointures:SELECT
a.state as state_a,
b.state as state_b
FROM
state_streams a, state_streams b
WHERE
ABS(a.total_streams - b.total_streams) < 1000
AND
a.state <> b.state
Partie 2
Remarque: il s'agit d'une question bonus plutôt que d'un modèle SQL vraiment important. Vous pouvez le sauter!Tâche: comment puis-je modifier SQL d'une solution précédente pour supprimer les doublons? Par exemple, l'exemple de la même table, à vapeur NC
et SC
il n'y avait qu'une seule fois, pas deux.Décision:SELECT
a.state as state_a,
b.state as state_b
FROM
state_streams a, state_streams b
WHERE
ABS(a.total_streams - b.total_streams) < 1000
AND
a.state > b.state
No. 3. Calculs avancés
Remerciements: Cette tâche a été adaptée d'une discussion sur une question que j'ai posée sur StackOverflow (mon surnom est zthomas.nc).Remarque: il s'agit probablement d'une tâche plus difficile que celle qui vous sera proposée lors d'un véritable entretien. Pensez-y plutôt comme un puzzle - ou vous pouvez le sauter!Contexte: supposons que nous ayons une table de table
ce type, oĂą user
différentes valeurs d'une classe peuvent correspondre au même utilisateur class
:| utilisateur | classe |
| ------ | ------- |
| 1 | a |
| 1 | b |
| 1 | b |
| 2 | b |
| 3 | a |
Problème: supposons qu'il n'y a que deux valeurs possibles pour une classe. Écrivez une requête pour calculer le nombre d'utilisateurs dans chaque classe. Dans ce cas, les utilisateurs avec les étiquettes a
et b
doivent se référer à la classe b
.Pour notre échantillon, nous obtenons le résultat suivant:| classe | compter |
| ------- | ------- |
| a | 1 |
| b | 2 |
Décision:WITH usr_b_sum AS
(
SELECT
user,
SUM(CASE WHEN class = 'b' THEN 1 ELSE 0 END) num_b
FROM
table
GROUP BY
user
),
usr_class_label AS
(
SELECT
user,
CASE WHEN num_b > 0 THEN 'b' ELSE 'a' END class
FROM
usr_b_sum
)
SELECT
class,
COUNT(DISTINCT user) count
FROM
usr_class_label
GROUP BY
class
ORDER BY
class ASC
Une solution alternative utilise des instructions SELECT
dans les opérateurs SELECT
et UNION
:SELECT
"a" class,
COUNT(DISTINCT user_id) -
(SELECT COUNT(DISTINCT user_id) FROM table WHERE class = 'b') count
UNION
SELECT
"b" class,
(SELECT COUNT(DISTINCT user_id) FROM table WHERE class = 'b') count