Top SQL Interview Test Questions & Techniques (Part 1)
By: Ian Ho
Tags: Data Scientist, Data, Sql, Coding, Data Science

Q1: Select Top N per Category

For this type of question, you will be required to select the rows which contain the top N values within a category.

Task: Extract the names and classes for the top 2 test scores for each class in the school, ordered by class and then by score. If there are any tie breakers, use the lower student_id to break the tie.

The scores dataset contains the scores for all students in the school. Each student has a unique student_id, which is the primary key. The class dataset contains the classes for all students in the school. The foreign key here is student_id, which matches the primary key from scores. Similarly, the names dataset contains names for all students with student_id as the foreign key.

Note: A FOREIGN KEY is a field in one table that references a PRIMARY KEY in another table.

scores | student_id | scores |

+------------+--------+

| 1 | 90 |

| 2 | 83 |

| 3 | 79 |

| 4 | 97 |

| 5 | 90 |

| 6 | 92 |

| 7 | 94 |

... classes | student_id | class |

+------------+--------+

| 1 | A |

| 2 | B |

| 3 | A |

| 4 | B |

| 5 | D |

| 6 | B |

| 7 | A |

... names | student_id | name |

+------------+----------+

| 1 | James |

| 2 | Michael |

| 3 | Valarie |

| 4 | Jessie |

| 5 | Douglas |

| 6 | Shin |

| 7 | Joanne |

...

Therefore, the expected result should look something like this:

EXPECTED RESULT: | class | name | score |

+-------+--------+-------+

| A | Joanne | 94 |

| A | James | 90 |

| B | Jessie | 97 |

| B | Shin | 92 |

...

SOLUTION

WITH class_ranking AS (

SELECT

c.class, n.name, s.score,

ROW_NUMBER() OVER (PARTITION BY c.class ORDER BY s.score DESC, s.student_id) AS rank

FROM scores s

JOIN classes c ON s.student_id = c.student_id

JOIN names n ON s.student_id = c.student_id

)

SELECT class, name, score

FROM class_ranking

WHERE rank <= 2

ORDER BY class ASC, score DESC;

The difficulty in this task lies in selecting top scores not for the entire dataset but for each category. If the task were for the entire dataset, we could have just used a combination of ORDER BY and LIMIT obtain the top scores.

To help us solve this task in an efficient and parsimonious manner, we can use COMMON TABLE EXPRESSIONS (CTE) to create a temporary result that can be subsequently referenced by clauses like SELECT and INSERT, UPDATE or DELETE.

First, we use a JOIN between scores, classes and names to obtain a complete dataset where each row contains all the relevant data for each individual. Then, in addition to selecting these three data points, we use the PARTITION BY clause to divide the result set according to c.class and to assign a ROW_NUMER(), starting from 1, according to the field after ORDER BY. In this case, within each partition of c.class, we order firstly in DESC by s.score to give the highest score in each class a rank of 1, and the second highest a rank of 2, and so on. In addition, we use s.student_id as the secondary field to order by, so as to achieve the objective of the task which is eliminate ties using the lower student_id.

For more details on how to use the PARTITION BY clause, check us this link here.

Next, we make use of CTE to store this partitioned ranking as a temporary result set known as class_ranking. In general, the CTE’s are used in the following manner:

WITH temp_table AS (

SELECT ...

)

SELECT _ FROM temp_table

You can also use a chain of CTEs to solve problems. This is very useful in cases where the solution requires sequential working on intermediate CTEs to derive a final CTE for the actual results. The format would look something like:

WITH temp_table AS (

SELECT ...

), WITH temp_table_2 AS (

SELECT ...

), SELECT _ FROM temp_table_2

For examples covering the use of multiple CTEs, stay tuned for future posts!

For this question, the CTE class_ranking is basically an extension of the original triple join, with an additional field rank to indicate the rank for each student within each partition or class.

With this CTE, we can then SELECT the relevant fields of class, name, score and use the WHERE clause to subset for rows that have rank ≤ 2, effectively giving us the top 2 scores for each class with ties broken by lower student_id. We omit the rank in the final solution as this is not required. We also ORDER BY class ASC, score DESC to obtain the desired ordering using class by alphabetical order first, and with the higher score on top.

VARIATION: Alternatives to ROW_NUMBER()

In this particular task, the ties were to be broken using the student_id, and this was simply achieved by assigning ROW_NUMBER() for each partition and ordering by first the score and then the student_id. This would ensure that only one student would be ranked 1 and one student would be ranked 2.

However, what if the question specified: Let s1 and s2 be the top 2 scores achieved in each class. If there is more than one student who attained s1 and/or more than one student who attained s2, include all the names as long as they have attained one of the top two scores.

Practically this would mean that the modified result set may look something like:

MODIFIED RESULT: | class | name | score |

+-------+--------+-------+

| A | Joanne | 94 |

| A | James | 90 |

| A | Caleb | 90 |

| B | Jessie | 97 |

| B | Shin | 92 |

...

In order to account for this variation, all we have to change is the ROW_NUMBER() window function to DENSE_RANK(). Assuming James has a lower student_id than Caleb, ROW_NUMBER() would assign rank=2 to James and rank=3 to Caleb. However, DENSE_RANK() would assign rank=2 to both James and Caleb. In addition ,the difference between DENSE_RANK() and RANK() is also noteworthy — DENSE_RANK() is ‘dense’ in the sense that it will not skip ranks after identical rankings, but RANK() will. Therefore, the next highest score that comes after Caleb will be ranked as rank=3 under DENSE_RANK() but rank=4 under RANK(). Depending on the application case, one should be aware of the differences between these window applications.

For those of you wondering what a window function is and how it relates to other functions like the aggregate function, PostgreSQL provides quite a good explanation:

A window function performs a calculation across a set of table rows that are somehow related to the current row. This is comparable to the type of calculation that can be done with an aggregate function. But unlike regular aggregate functions, use of a window function does not cause rows to become grouped into a single output row — the rows retain their separate identities. Behind the scenes, the window function is able to access more than just the current row of the query result.

If your SQL dialect does not support the ROW_NUMBER() function or the PARTITION BY clause, we can use a combination of equi join and non-equi join logic in a correlated subquery to obtain the desired result:

SELECT c.class, n.name, s.score

FROM scores s

JOIN classes c ON s.student_id = c.student_id

JOIN names n ON s.student_id = n.student_it

WHERE

(

SELECT COUNT(DISTINCT s.score)

FROM scores s2 JOIN classes c2 ON s2.student_id = c2.student_id

WHERE s2.score > s.score

AND c2.class = c.class) < 2

An equi join is a join that joins tables according to matching values in columns. A non-equi join is a join with a condition other than equals.

A subquery is a query nested inside another query. A correlated subquery is a subquery which uses values from the outer query.

In this case the subquery uses s.score and c.class from the outer query to compare with values from the same table scores, but now with alias s2, and to count the number of scores which are greater than each row in the original table scores s. The condition is to select those with fewer than 2 corresponding matches because the top score would have 0 matches, and the second highest score would have 1 match.

And that’s all! On to the next question …

Q2: Conditional Consecutive Select

Select Rows with Minimum number of Consecutive Rows meeting Condition(s)

A common task in SQL Interviews is to select rows which not only meet a certain criteria but do so for a minimum number of rows in order, usually by date. Here we will look at an example that explains how we can go about using cross-joins to filter for such cases given the relation between consecutive rows that we are looking for.

The table cases contains the number of new COVID cases for each date.

Task: Display the rows which contain 3 or more consecutive days of more than 100 cases in those days.

cases +------+------------+-----------+

| id | date | new |

+------+------------+-----------+

| 1 | 2021-01-01 | 20 |

| 2 | 2021-01-02 | 149 |

| 3 | 2021-01-03 | 150 |

| 4 | 2021-01-04 | 99 |

| 5 | 2021-01-05 | 145 |

| 6 | 2021-01-06 | 200 |

| 7 | 2021-01-07 | 199 |

| 8 | 2021-01-08 | 188 |

| 9 | 2021-01-09 | 88 |

+------+------------+-----------+

...

The expected result will look something like:

EXPECTED RESULT +------+------------+-----------+

| id | date | new |

+------+------------+-----------+

| 5 | 2021-01-05 | 145 |

| 6 | 2021-01-06 | 200 |

| 7 | 2021-01-07 | 199 |

| 8 | 2021-01-08 | 188 |

+------+------------+-----------+



SOLUTION

WITH cases_over_hundred AS (

SELECT * FROM cases WHERE new > 100

) SELECT DISTINCT c1.*

FROM cases_over_hundred as c1,

FROM cases_over_hundred as c3,

FROM cases_over_hundred as c2,

WHERE (c1.id = c2.id - 1 AND c1.id = c3.id - 2)

OR (c1.id = c2.id + 1 AND c1.id = c3.id - 1)

OR (c1.id = c2.id + 2 AND c1.id = c3.id + 1)

ORDER BY c1.id;

For efficiency purposes, we first create a CTE to choose the rows where there are more than a 100 cases. We call this CTE cases_over_hundred.

Next, we use a conditional cross-join to find all the rows which are part of 3 or more consecutive days with more than 100 cases. A cross-join, also known as a cartesian product, where each row from one table is combined with each row from another table.

Image by Author

In this case, we are cross-joining three tables because we want to compare the dates of the different combinations of three dates to check if they are consecutive. However, instead of having a full cross-join, we will use conditions to ensure that we only join dates that fall in any one of three of the following categories:

c1 date is one day before c2 date and two days before c3 date

c1 date is one day after c2 date and one day before c3 date

c1 date is two days after c2 date and one day after c3 date

Note: swapping c2 with c3 for any of these conditions will not matter because here we are using a cross-join, so all permutations will be considered anyway

These three possibilities are captured by the following three conditions respectively:

WHERE (c1.id = c2.id - 1 AND c1.id = c3.id - 2)

OR (c1.id = c2.id + 1 AND c1.id = c3.id - 1)

OR (c1.id = c2.id + 2 AND c1.id = c3.id + 1)

With this conditional cross-join, we successfully choose all the combinations of dates such that all three dates in the row are consecutive when arranged properly and they all have cases hundred and above.

Finally, we need to use SELECT ( DISTINCT (c1.*)) to remove all the repetitions. For each row subsetted, there will be two duplicates due to the conditional cross-join, and therefore all we need to do is to select the distinct values of the dates from c1 only to get the sequences of dates with more than 100 cases 3 or more days in a row.

ALTERNATIVE SOLUTION

In fact, there is another way to solve this problem that involves using window functions (again)! For this case, we can use the window function LAG() and LEAD(). As you might have already expected, LAG() allows us to obtain the previous values for a given field, where order is determined by another field. In this case, we get the previous values of new according to the order of id. Similarly, LEAD() returns us values ahead of the current row in reference.

The standard syntax is as such:

LAG(return_value ,offset [,default])

OVER ([PARTITION BY partition_expression, ... ]

ORDER BY sort_expression [ASC | DESC], ... )

This syntax is very similar to the one which we used in the Question 1, with the ROW_NUMBER() window function.

For this question, we first construct a CTE which contains the values 1, 2 days before and after the date of each row using the LAG() and LEAD() window functions. Subsequently, we reference this CTE and condition for rows where the criteria of three consecutive days with over 100 cases is met.’

WITH interval_cases AS (

SELECT *,

LAG(new, 2) OVER (ORDER BY id) AS before_2,

LAG(new, 1) OVER (ORDER BY id) AS before_1,

LEAD(new, 1) OVER (ORDER BY id) AS after_1,

LEAD(new, 2) OVER (ORDER BY id) AS after_2

FROM cases

)

SELECT id, date, new

FROM cases

WHERE new > 100

AND ((before_2 > 100 AND before_1 > 100)

OR (before_1 > 100 AND after_1 > 100)

OR (after_1 > 100 AND after_2 > 100))

ORDER BY id;

Let’s take a look at the query logic:

Firstly, the CTE interval_cases will produce a result set that will look something like this for each row:

+----+------------+-----+----------+----------+---------+---------+

| id | date | new | before_2 | before_1 | after_1 | after_2 |

+----+------------+-----+----------+----------+---------+---------+

| 5 | 2021-01-05 | 145 | 150 | 99 | 200 | 199 |

...

Subsequently, we filter for those rows where new is greater than 100 and where it belongs to a consecutive row of three days which are also all greater than 300. This can happen in one of three ways, which is why there are three alternative conditions that accompany the new>100 condition. This will get us the same result as the previous solution.

Conclusion

I hope these two questions have been useful in demonstrating how to approach some of the most common questions you would find in SQL coding tests. If you like what I’ve shared, do give me a clap and share the article!

If there’s sufficient interest, I’ll also be doing a Part 2 with more questions, and if you have any comments or inputs on how you would tackle these problems (especially from an efficiency perspective), feel free to get in touch or comment!

Update: Check out Part 2 Here!