SQL Algorithm Notes
Algorithm notes for SQL50 on LeetCode
SQL Algorithm Notes
General Problem-Solving Patterns Overview
What Feature You See → Which SQL Technique to Use
| Problem Feature | Preferred Technique | Typical Problems |
|---|---|---|
| Joining two tables to fetch data | INNER JOIN / LEFT JOIN | 175, 1068, 1378 |
| Keep all rows from the left table (return NULL if no match) | LEFT JOIN | 175, 577, 1378, 1581 |
| Comparing rows within the same table (employee vs manager) | SELF JOIN | 181, 570, 1731, 1978 |
| Find duplicate values | GROUP BY + HAVING COUNT > 1 | 182, 196 |
| Filter after grouping | GROUP BY + HAVING | 596, 570, 1045, 1084 |
| Aggregate after grouping | GROUP BY + COUNT/SUM/AVG | 511, 1693, 1729, 2356 |
| Ranking / Top N | Window functions RANK / DENSE_RANK | 185, 1341 |
| Running total / sliding window | SUM() OVER(ORDER BY ROWS BETWEEN) | 1204, 1321 |
| Conditional aggregation (case-by-case stats) | SUM(IF(…)) or SUM(CASE WHEN) | 1193, 1661, 1393 |
| Combine multiple query results | UNION / UNION ALL | 1795, 1907, 1164 |
| Find “all” / “every” | HAVING COUNT(DISTINCT) = total count | 1045 |
| Compare with previous day / N consecutive days | SELF JOIN + DATEDIFF | 197, 180, 550 |
| Return even when there is no match | LEFT JOIN + IS NULL | 577, 581, 1978 |
| String pattern matching | LIKE / REGEXP | 1527, 1517, 1683 |
| Column-to-row (unpivot) | UNION ALL | 1795 |
| Row-to-column / concatenation | GROUP_CONCAT | 1484 |
| Conditional assignment | CASE WHEN | 610, 626, 627 |
| Date formatting | DATE_FORMAT | 1193, 1327 |
SQL Execution Order (Important!)
FROM + JOIN → First determine the data source and joinsWHERE → Row-level filteringGROUP BY → GroupingHAVING → Group-level filteringSELECT → Select columns / aggregate / window functionsORDER BY → SortingLIMIT → TruncationKey point: WHERE comes before GROUP BY and cannot use aggregate functions; HAVING comes after GROUP BY and can use aggregate functions.
I. Basic Queries (WHERE / ORDER BY / LIMIT)
0595. Big Countries Easy
Table schema: World(name, continent, area, population, gdp)
Problem: Find countries with an area ≥ 3 million or a population ≥ 25 million.
SELECT name, population, areaFROM WorldWHERE area >= 3000000 OR population >= 25000000;Execution flow:
| Original World table | → WHERE filter |
|---|---|
| Afghanistan, Asia, 652230, 38928341, … | ✅ area≥3M |
| Albania, Europe, 28748, 2837743, … | ❌ neither condition met |
| Algeria, Africa, 2381741, 37100000, … | ✅ area≥3M |
| Andorra, Europe, 468, 27457, … | ❌ neither condition met |
| India, Asia, 3287590, 1324171354, … | ✅ area≥3M |
Key point: WHERE uses OR to connect two independent conditions; satisfying either one is enough
0584. Find Customer Referee Easy
Table schema: Customer(id, name, referee_id)
Problem: Find customers whose referee is not id=2 (including those who have no referee).
SELECT nameFROM CustomerWHERE referee_id != 2 OR referee_id IS NULL;Execution flow:
| id | name | referee_id | Judgment | Result |
|---|---|---|---|---|
| 1 | Will | NULL | NULL != 2 → NULL (not true) → but IS NULL → ✅ | output |
| 2 | Jane | NULL | same as above → ✅ | output |
| 3 | Alex | 2 | 2 != 2 → false → ❌ | no output |
| 4 | Bill | 3 | 3 != 2 → true → ✅ | output |
| 5 | Zack | 1 | 1 != 2 → true → ✅ | output |
⚠️ Key trap:
referee_id != 2will NOT match NULL! Comparing with NULL yields NULL (not true); you must handle it separately withIS NULL
0168. Invalid Tweets Easy
SELECT tweet_idFROM TweetsWHERE LENGTH(content) > 15;| tweet_id | content | LENGTH | Result |
|---|---|---|---|
| 1 | “Vote for Biden” | 14 | ❌ |
| 2 | “Let us make sure … (30 chars)” | 30 | ✅ |
0176. Second Highest Salary Medium
Table schema: Employee(id, salary)
Problem: Find the second-highest distinct salary; return NULL if it does not exist.
SELECT (SELECT DISTINCT salary FROM Employee ORDER BY salary DESC LIMIT 1 OFFSET 1 ) AS SecondHighestSalary;Execution flow:
Original Employee table → DISTINCT deduplicate → ORDER BY DESC → LIMIT 1 OFFSET 1┌────┬────────┐ ┌────────┐ ┌────────┐ ┌────────┐│ id │ salary │ │ salary │ │ salary │ │ salary │├────┼────────┤ ├────────┤ ├────────┤ ├────────┤│ 1 │ 100 │ │ 100 │ │ 400 │ │ 200 ││ 2 │ 200 │ → │ 200 │ → │ 300 │ → │ ││ 3 │ 300 │ │ 300 │ │ 200 │ │ ││ 4 │ 300 │ │ 400 │ │ 100 │ │ ││ 5 │ 400 │ └────────┘ └────────┘ └────────┘└────┴────────┘Key point: The outer
SELECT (subquery)ensures that NULL is returned instead of an empty table when there is no result
0620. Not Boring Movies Easy
SELECT *FROM cinemaWHERE description != 'boring' AND id % 2 = 1ORDER BY rating DESC;Execution flow:
| id | movie | description | rating | → WHERE | → ORDER BY rating DESC |
|---|---|---|---|---|---|
| 1 | War | great 3D | 8.9 | ✅ odd + not boring | 8.9 |
| 2 | Science | boring | 8.4 | ❌ boring | - |
| 3 | Irish | NOT boring | 7.0 | ✅ odd + not boring | 7.0 |
| 4 | Ice Song | Fantacy | 8.6 | ❌ even | - |
| 5 | House card | Interesting | 9.1 | ✅ odd + not boring | 9.1 |
| 6 | … | … | … | … | … |
II. JOIN (INNER JOIN / LEFT JOIN / SELF JOIN)
JOIN Type Comparison
Shading = where the rows appearing in the result come from: INNER JOIN keeps only the overlapping part of the two circles; LEFT JOIN keeps the entire left circle (including the overlap), and only the overlapping part of the right circle counts—positions with no match are filled with NULL.
INNER (inner join) LEFT (left join)┌────┐ ┌────┐ ┌────┐ ┌────┐│ │ │ │ │████│ │ ││ ██│ │██ │ │████│ │██ ││ ██│ │██ │ │████│ │██ ││ │ │ │ │████│ │ │└────┘ └────┘ └────┘ └────┘ left right left right left right left right
Using the same set of data to see the result differences (A = left table Person, B = right table Address, join key id):
A table (Person) B table (Address)┌────┬──────┐ ┌────┬──────┐│ id │ name │ │ id │ city │├────┼──────┤ ├────┼──────┤│ 1 │ Allen│ │ 2 │ NYC ││ 2 │ Bob │ │ 3 │ Boston││ 3 │ Zack │ └────┴──────┘└────┴──────┘
① INNER JOIN (inner join) — keeps only rows where A and B can match by id┌────┬──────┬──────┐│ id │ name │ city │├────┼──────┼──────┤│ 2 │ Bob │ NYC │ ← A.2 = B.2 ✅│ 3 │ Zack │ Boston│ ← A.3 = B.3 ✅└────┴──────┴──────┘(Allen has no match in B → does not appear)
② LEFT JOIN (left join) — keeps all rows of A; B fills with NULL when there is no match┌────┬──────┬──────┐│ id │ name │ city │├────┼──────┼──────┤│ 1 │ Allen│ NULL │ ← B has no match → city is NULL│ 2 │ Bob │ NYC ││ 3 │ Zack │ Boston│└────┴──────┴──────┘
③ SELF JOIN — use the same table as two tables, distinguished by aliases In Employee: employee.managerId = manager.id (see 0181 / 1731 / 0570 for details)0175. Combine Two Tables Easy
Table schema:
Person(personId, firstName, lastName)Address(addressId, personId, city, state)
Problem: Report each person’s first name, last name, city, and state. Those without an address must also be returned.
SELECT p.firstName, p.lastName, a.city, a.stateFROM Person p LEFT JOIN Address a ON p.personId = a.personId;Execution flow:
Person table:
| personId | firstName | lastName |
|---|---|---|
| 1 | Allen | Wang |
| 2 | Bob | Alice |
| 3 | Zack | Sy |
Address table:
| addressId | personId | city | state |
|---|---|---|---|
| 1 | 2 | NYC | NY |
| 2 | 3 | Boston | MA |
LEFT JOIN result:
| firstName | lastName | city | state |
|---|---|---|---|
| Allen | Wang | NULL | NULL |
| Bob | Alice | NYC | NY |
| Zack | Sy | Boston | MA |
Allen has no match in the Address table → city and state are NULL. LEFT JOIN guarantees that all rows of the Person table are preserved.
0181. Employees Earning More Than Their Managers Easy (self join)
Table schema: Employee(id, name, salary, managerId)
Problem: Find employees who earn more than their manager.
SELECT e1.name AS EmployeeFROM Employee e1 JOIN Employee e2 ON e1.managerId = e2.idWHERE e1.salary > e2.salary;Execution flow:
Employee table (one table playing two roles):
| id | name | salary | managerId |
|---|---|---|---|
| 1 | Joe | 70000 | 3 |
| 2 | Henry | 80000 | 4 |
| 3 | Sam | 60000 | NULL |
| 4 | Max | 90000 | NULL |
After self join (e1=employee, e2=manager):
| e1.name(employee) | e1.salary | e2.name(manager) | e2.salary | e1.salary > e2.salary? |
|---|---|---|---|---|
| Joe | 70000 | Sam | 60000 | ✅ 70000 > 60000 |
| Henry | 80000 | Max | 90000 | ❌ 80000 < 90000 |
Final result:
| Employee |
|---|
| Joe |
Key point: Join the same table to itself, using different aliases to distinguish the employee and manager roles
0577. Employee Bonus Easy (LEFT JOIN + IS NULL)
Table schema:
Employee(empId, name, supervisor, salary)Bonus(empId, bonus)
Problem: Report employees whose bonus < 1000 or who have no bonus.
SELECT name, bonusFROM Employee e LEFT JOIN Bonus b ON e.empId = b.empIdWHERE b.bonus < 1000 OR b.bonus IS NULL;Execution flow:
Employee table:
| empId | name | supervisor | salary |
|---|---|---|---|
| 1 | Brad | NULL | 5000 |
| 2 | John | 1 | 4000 |
| 3 | Dan | 1 | 3000 |
| 4 | Thomas | 1 | 2000 |
Bonus table:
| empId | bonus |
|---|---|
| 2 | 500 |
| 3 | NULL |
| 4 | 2000 |
After LEFT JOIN:
| name | bonus | Judgment |
|---|---|---|
| Brad | NULL | IS NULL → ✅ |
| John | 500 | 500 < 1000 → ✅ |
| Dan | NULL | IS NULL → ✅ |
| Thomas | 2000 | 2000 ≥ 1000 → ❌ |
Final result:
| name | bonus |
|---|---|
| Brad | NULL |
| John | 500 |
| Dan | NULL |
⚠️
b.bonus < 1000does not match NULL; you must addOR b.bonus IS NULL
1378. Replace Employee ID With The Unique Identifier Easy
SELECT euni.unique_id, e.nameFROM Employees e LEFT JOIN EmployeeUNI euni ON e.id = euni.id;Execution flow:
| Employees | EmployeeUNI | LEFT JOIN result |
|---|---|---|
| id=1, Alice | id=1, unique_id=10 | unique_id=10, Alice |
| id=2, Bob | (no match) | unique_id=NULL, Bob |
1068. Product Sales Analysis I Easy (INNER JOIN)
Table schema:
Sales(sale_id, product_id, year, quantity, price)Product(product_id, product_name)
SELECT p.product_name, s.year, s.priceFROM Sales s JOIN Product p ON s.product_id = p.product_id;Execution flow:
| Sales | Product | JOIN result |
|---|---|---|
| sale_id=1, product_id=100, year=2008, price=5000 | product_id=100, Nokia | Nokia, 2008, 5000 |
| sale_id=2, product_id=100, year=2009, price=5000 | product_id=100, Nokia | Nokia, 2009, 5000 |
| sale_id=7, product_id=200, year=2011, price=7000 | product_id=200, Apple | Apple, 2011, 7000 |
1581. Customer Who Visited but Did Not Make Any Transactions Easy (LEFT JOIN + IS NULL)
Table schema:
Visits(visit_id, customer_id)Transactions(transaction_id, visit_id, amount)
SELECT v.customer_id, COUNT(v.customer_id) AS count_no_transFROM Visits v LEFT JOIN Transactions t ON v.visit_id = t.visit_idWHERE t.transaction_id IS NULLGROUP BY v.customer_id;Execution flow:
| Visits | LEFT JOIN Transactions | WHERE IS NULL | GROUP BY |
|---|---|---|---|
| visit=1, customer=23 | + trans=1, amount=950 | ❌ has transaction | - |
| visit=2, customer=9 | + NULL (no transaction) | ✅ | customer=9, count=1 |
| visit=4, customer=30 | + NULL (no transaction) | ✅ | customer=30, count=1 |
| visit=5, customer=54 | + NULL (no transaction) | ✅ | customer=54, count=2 |
| visit=7, customer=54 | + NULL (no transaction) | ✅ | (54 in total 2 times) |
| visit=8, customer=96 | + trans=5, amount=540 | ❌ has transaction | - |
Final result:
| customer_id | count_no_trans |
|---|---|
| 9 | 1 |
| 30 | 1 |
| 54 | 2 |
1280. Students and Examinations Easy (CROSS JOIN + LEFT JOIN)
Table schema:
Students(student_id, student_name)Subjects(subject_name)Examinations(student_id, subject_name)
SELECT s.student_id, s.student_name, su.subject_name, COUNT(e.subject_name) AS attended_examsFROM Students s JOIN Subjects su -- first do the cartesian product LEFT JOIN Examinations e -- then left join the examinations table ON e.student_id = s.student_id AND e.subject_name = su.subject_nameGROUP BY s.student_id, su.subject_nameORDER BY s.student_id, su.subject_name;Execution flow:
Students × Subjects (cartesian product):┌─────────────┬──────────┐│ student_id │ subject │├─────────────┼──────────┤│ 1, Alice │ Math │ ← LEFT JOIN Exam: found 1 row → count=1│ 1, Alice │ Physics │ ← LEFT JOIN Exam: found 0 rows → count=0│ 1, Alice │ Math │ ...│ 2, Bob │ Math ││ 2, Bob │ Physics │└─────────────┴──────────┘
→ LEFT JOIN Examinations (keep all student×subject combinations)→ GROUP BY (student_id, subject_name)→ COUNT(e.subject_name) only counts non-NULLKey point: First Students JOIN Subjects generates the full combination of “every student × every subject”, then LEFT JOIN the examinations table to compute the number of attendances
1731. Number of Employees in Each Department Easy (self join)
SELECT e2.employee_id, e2.name, COUNT(e1.reports_to) AS reports_count, ROUND(AVG(e1.age), 0) AS average_ageFROM Employees e1 JOIN Employees e2 ON e1.reports_to = e2.employee_idGROUP BY e1.reports_toORDER BY e2.employee_id;Execution flow:
Employees table:
| employee_id | name | reports_to | age |
|---|---|---|---|
| 9 | Hercy | NULL | 43 |
| 6 | Alice | 9 | 31 |
| 4 | Bob | 9 | 36 |
| 2 | Omer | 6 | 24 |
Self join (e1=subordinate, e2=manager):
| e1.name(subordinate) | e1.age | e2.employee_id(manager) | e2.name(manager) |
|---|---|---|---|
| Alice | 31 | 9 | Hercy |
| Bob | 36 | 9 | Hercy |
| Omer | 24 | 6 | Alice |
After GROUP BY e1.reports_to:
| employee_id | name | reports_count | average_age |
|---|---|---|---|
| 9 | Hercy | 2 | ROUND((31+36)/2) = 34 |
| 6 | Alice | 1 | 24 |
1978. Company Employees Whose Manager Left the Company Easy (LEFT JOIN + IS NULL)
SELECT e1.employee_idFROM Employees e1 LEFT JOIN Employees e2 ON e1.manager_id = e2.employee_idWHERE e1.salary < 30000 AND e2.employee_id IS NULL AND e1.manager_id IS NOT NULLORDER BY e1.employee_id;Execution flow:
| e1(employee) | e1.manager_id | e1.salary | e2(manager) match | e2 IS NULL? | manager_id IS NOT NULL? | Result |
|---|---|---|---|---|---|---|
| 3, Mary | 1 | 25000 | (id=1 exists) | ❌ | ✅ | ❌ manager not left |
| 7, Robert | 99 | 20000 | (id=99 does not exist) | ✅ | ✅ | ✅ |
| 11, Brad | 5 | 28000 | (id=5 does not exist) | ✅ | ✅ | ✅ |
| 13, Jason | NULL | 15000 | (no match) | ✅ | ❌ | ❌ no manager |
All three conditions are required: salary < 30000 + manager does not exist (LEFT JOIN results in IS NULL) + has a manager (IS NOT NULL)
III. GROUP BY + HAVING (Grouping and Aggregation)
WHERE vs HAVING Comparison
WHERE: filter rows before grouping HAVING: filter groups after grouping┌─────────────┐ ┌─────────────┐│ Raw data │ │ Grouped result ││ WHERE filter│ → GROUP BY → │ HAVING filter │ → Final result│ (row-level)│ │ (group-level) │└─────────────┘ └─────────────┘Cannot use aggregate functions Can use aggregate functions0511. Game Play Analysis I Easy
Table schema: Activity(player_id, device_id, event_date, games_played)
Problem: Query the first login date for each player.
SELECT player_id, MIN(event_date) AS first_loginFROM ActivityGROUP BY player_id;Execution flow:
| Original Activity table | → GROUP BY player_id | → MIN(event_date) |
|---|---|---|
| 1, 2, 2016-03-01, 5 | player_id=1: {2016-03-01, 2016-05-02} | 2016-03-01 |
| 1, 2, 2016-05-02, 6 | player_id=2: {2017-06-25} | 2017-06-25 |
| 2, 3, 2017-06-25, 1 | player_id=3: {2016-03-02, 2018-07-03} | 2016-03-02 |
| 3, 1, 2016-03-02, 0 | ||
| 3, 4, 2018-07-03, 5 |
0182. Duplicate Emails Easy
Table schema: Person(id, email)
Problem: Find duplicated emails.
SELECT email AS EmailFROM PersonGROUP BY emailHAVING COUNT(email) > 1;Execution flow:
Person table:
| id | |
|---|---|
| 1 | a@b.com |
| 2 | c@d.com |
| 3 | a@b.com |
After GROUP BY email:
| COUNT | HAVING COUNT > 1? | |
|---|---|---|
| a@b.com | 2 | ✅ |
| c@d.com | 1 | ❌ |
Final result: a@b.com
Key point: After GROUP BY, HAVING filters out the groups where COUNT > 1 (i.e. the duplicated emails)
0596. Classes More Than 5 Students Easy
Table schema: Courses(student, class)
SELECT classFROM CoursesGROUP BY classHAVING COUNT(DISTINCT student) >= 5;Execution flow:
Courses table:
| student | class |
|---|---|
| A | Math |
| B | English |
| C | Math |
| D | Biology |
| E | Math |
| F | Math |
| G | Math |
| H | Math |
After GROUP BY class:
| class | COUNT(DISTINCT student) | >= 5? |
|---|---|---|
| Math | 6 | ✅ |
| English | 1 | ❌ |
| Biology | 1 | ❌ |
Final result: Math
0570. Managers with at Least 5 Direct Reports Medium (self join + GROUP BY + HAVING)
SELECT e1.nameFROM Employee e1 JOIN Employee e2 ON e1.Id = e2.managerIdGROUP BY e1.IdHAVING COUNT(e2.Id) >= 5;Execution flow:
Employee table (self join)e1=manager, e2=subordinate┌─────────────┬──────────────────┐│ e1.name(manager) │ e2.Id(subordinate) │├─────────────┼──────────────────┤│ Stephen │ 1, 2, 3, 4, 5 │ → COUNT=5 ✅│ Alice │ 6, 7 │ → COUNT=2 ❌└─────────────┴──────────────────┘→ GROUP BY e1.Id → HAVING COUNT >= 5 → Stephen1045. Customers Who Bought All Products Medium (HAVING COUNT = subquery)
Table schema:
Customer(customer_id, product_key)Product(product_key)
SELECT customer_idFROM CustomerGROUP BY customer_idHAVING COUNT(DISTINCT product_key) = (SELECT COUNT(*) FROM Product);Execution flow:
Product table has 3 products in total (product_key: 1, 2, 3)→ subquery: SELECT COUNT(*) FROM Product = 3
Customer table:┌─────────────┬────────────┐│ customer_id │ product_key│├─────────────┼────────────┤│ 1 │ 1 ││ 1 │ 2 ││ 1 │ 3 │ → COUNT(DISTINCT)=3 = 3 ✅│ 2 │ 1 ││ 2 │ 2 │ → COUNT(DISTINCT)=2 ≠ 3 ❌│ 3 │ 1 ││ 3 │ 2 ││ 3 │ 3 ││ 3 │ 1(duplicate)│ → COUNT(DISTINCT)=3 = 3 ✅└─────────────┴────────────┘
Final result: customer_id = 1, 3Key point: Compare COUNT(DISTINCT product_key) (deduplicated) against the total number of Products
1084. Sales Analysis III Easy (HAVING + MIN/MAX)
Table schema:
Product(product_id, product_name, unit_price)Sales(seller_id, product_id, buyer_id, sale_date, quantity, price)
Problem: Find products sold only in the spring of 2019.
SELECT p.product_id, p.product_nameFROM Product p JOIN Sales s ON p.product_id = s.product_idGROUP BY s.product_idHAVING MIN(s.sale_date) >= '2019-01-01' AND MAX(s.sale_date) <= '2019-03-31';Execution flow:
| product_id | all sale_date | MIN | MAX | all in spring? |
|---|---|---|---|---|
| 1 | 2019-02-17, 2019-02-25 | 2019-02-17 | 2019-02-25 | ✅ |
| 2 | 2019-02-01, 2019-04-04 | 2019-02-01 | 2019-04-04 | ❌ (April out of range) |
| 3 | 2019-03-10 | 2019-03-10 | 2019-03-10 | ✅ |
Key point: Use MIN/MAX to check whether all sales records fall within the target range
1587. Bank Account Summary II Easy (GROUP BY + HAVING SUM)
SELECT u.name, SUM(t.amount) AS balanceFROM Users u JOIN Transactions t ON u.account = t.accountGROUP BY t.accountHAVING SUM(t.amount) > 10000;Execution flow:
| Users | Transactions | JOIN + GROUP BY | HAVING > 10000 |
|---|---|---|---|
| account=1, Alice | account=1, +7000 | Alice: 7000+7000=14000 | ✅ |
| account=2, Bob | account=1, +7000 | Bob: 3000-5000=-2000 | ❌ |
| account=2, +3000 | |||
| account=2, -5000 |
Final result:
| name | balance |
|---|---|
| Alice | 14000 |
1193. Monthly Transactions I Medium (conditional aggregation SUM + IF)
Table schema: Transactions(id, country, state, amount, trans_date)
Problem: Count the number of transactions, approved transactions, and amounts per month and country.
SELECT DATE_FORMAT(trans_date, '%Y-%m') AS month, country, COUNT(state) AS trans_count, SUM(IF(state = 'approved', 1, 0)) AS approved_count, SUM(amount) AS trans_total_amount, SUM(IF(state = 'approved', amount, 0)) AS approved_total_amountFROM TransactionsGROUP BY month, country;Execution flow:
Original Transactions table:
| id | country | state | amount | trans_date |
|---|---|---|---|---|
| 121 | US | approved | 1000 | 2019-01-18 |
| 122 | US | declined | 2000 | 2019-01-19 |
| 123 | US | approved | 3000 | 2019-01-27 |
| 124 | DE | approved | 2000 | 2019-01-14 |
After GROUP BY (month, country):
| month | country | trans_count | approved_count | trans_total | approved_total |
|---|---|---|---|---|---|
| 2019-01 | US | 3 | SUM(1,0,1)=2 | 6000 | 1000+3000=4000 |
| 2019-01 | DE | 1 | SUM(1)=1 | 2000 | 2000 |
Key point:
SUM(IF(state='approved', 1, 0))implements conditional counting — counts 1 for approved, 0 otherwise
1393. Capital Gain/Loss Medium (CASE WHEN + SUM)
Table schema: Stocks(stock_name, operation, operation_day, price)
SELECT stock_name, SUM(CASE WHEN operation = 'Buy' THEN -price WHEN operation = 'Sell' THEN price END) AS capital_gain_lossFROM StocksGROUP BY stock_name;Execution flow:
| stock_name | operation | price | CASE result |
|---|---|---|---|
| Leetcode | Buy | 1000 | -1000 |
| Leetcode | Sell | 9000 | +9000 |
| Corona | Buy | 3000 | -3000 |
| Corona | Sell | 1580 | +1580 |
After GROUP BY stock_name:
| stock_name | capital_gain_loss |
|---|---|
| Leetcode | -1000 + 9000 = 8000 |
| Corona | -3000 + 1580 = -1420 |
Buy is negative (expense), Sell is positive (income); SUM gives the net gain/loss
1484. Group Sold Products By The Date Easy (GROUP_CONCAT)
Table schema: Activities(sell_date, product)
SELECT sell_date, COUNT(DISTINCT product) AS num_sold, GROUP_CONCAT(DISTINCT product ORDER BY product) AS productsFROM ActivitiesGROUP BY sell_date;Execution flow:
| Original Activities | → GROUP BY sell_date | → GROUP_CONCAT |
|---|---|---|
| 2020-05-30, Headphone | 2020-05-30: {Headphone, Basketball, PC} | 2020-05-30: “Basketball,Headphone,PC” |
| 2020-06-01, Pencil | 2020-06-01: {Pencil, Bathing} | 2020-06-01: “Bathing,Pencil” |
| 2020-06-02, Mask | 2020-06-02: {Mask, Bathing} | 2020-06-02: “Bathing,Mask” |
| 2020-05-30, Basketball | COUNT(DISTINCT)=3 | |
| 2020-06-01, Bathing | COUNT(DISTINCT)=2 | |
| 2020-05-30, PC | ||
| 2020-06-02, Bathing |
Key point:
GROUP_CONCAT(DISTINCT product ORDER BY product)deduplicates + sorts + concatenates with commas
1693. Daily Leads and Partners Easy
SELECT date_id, make_name, COUNT(DISTINCT lead_id) AS unique_leads, COUNT(DISTINCT partner_id) AS unique_partnersFROM DailySalesGROUP BY date_id, make_name;Execution flow:
| Original DailySales | → GROUP BY (date_id, make_name) | → COUNT(DISTINCT) |
|---|---|---|
| 2020-12-8, Toyota, lead=0, partner=1 | (2020-12-8, Toyota): | unique_leads=2 (0,1) |
| 2020-12-8, Toyota, lead=1, partner=0 | leads={0,1,1} → DISTINCT={0,1} | unique_partners=2 (0,1) |
| 2020-12-8, Toyota, lead=1, partner=2 | partners={1,0,2} → DISTINCT={0,1,2} | |
| 2020-12-7, Toyota, lead=0, partner=1 | (2020-12-7, Toyota): | unique_leads=1 (0) |
| 2020-12-7, Toyota, lead=0, partner=0 | leads={0,0} → DISTINCT={0} | unique_partners=2 (0,1) |
IV. Subqueries (IN / EXISTS / Correlated Subqueries)
0619. Biggest Single Number Easy
Table schema: MyNumbers(num) (no primary key, may have duplicates)
SELECT MAX(num) AS numFROM (SELECT num FROM MyNumbers GROUP BY num HAVING COUNT(num) = 1) AS numbers;Execution flow:
Original MyNumbers: → GROUP BY num + HAVING COUNT=1: → MAX():┌──────┐ ┌──────┐ ┌──────┐│ num │ │ num │ │ num │├──────┤ ├──────┤ ├──────┤│ 8 │ │ 8 │ ← appears 1 time ✅ │ ││ 8 │ │ 6 │ ← appears 1 time ✅ │ 6 ││ 3 │ │ │ 3 appears 2 times ❌ │ ││ 3 │ │ │ 1 appears 3 times ❌ │ ││ 7 │ │ 7 │ ← appears 1 time ✅ │ ││ 6 │ └──────┘ └──────┘│ 6 ││ 1 ││ 1 ││ 1 │└──────┘0585. Insurance Company II Medium (multiple subqueries)
Table schema: Insurance(pid, tiv_2015, tiv_2016, lat, lon)
Problem: 2015 insured amount same as someone else’s + unique city coordinates → sum of 2016 insured amounts.
SELECT ROUND(SUM(tiv_2016), 2) AS tiv_2016FROM InsuranceWHERE (lat, lon) IN (SELECT lat, lon FROM Insurance GROUP BY lat, lon HAVING COUNT(pid) = 1) AND tiv_2015 IN (SELECT tiv_2015 FROM Insurance GROUP BY tiv_2015 HAVING COUNT(pid) > 1);Execution flow:
Original Insurance table:┌─────┬──────────┬──────────┬──────┬──────┐│ pid │ tiv_2015 │ tiv_2016 │ lat │ lon │├─────┼──────────┼──────────┼──────┼──────┤│ 1 │ 10 │ 5 │ 10 │ 10 ││ 2 │ 20 │ 20 │ 20 │ 20 ││ 3 │ 10 │ 30 │ 20 │ 20 │ ← lat,lon same as pid=2!│ 4 │ 10 │ 40 │ 40 │ 40 │└─────┴──────────┴──────────┴──────┴──────┘
Subquery 1: unique city coordinates → (10,10) and (40,40) → pid=1, 4Subquery 2: duplicated 2015 insured amounts → tiv_2015=10 (appears 3 times) → pid=1,3,4
Intersection of the two conditions: pid=1 and pid=4→ SUM(tiv_2016) = 5 + 40 = 45Key point: The two subqueries filter separately; WHERE uses AND to take the intersection
0185. Department Top Three Salaries Hard (correlated subquery)
Table schema:
Employee(id, name, salary, departmentId)Department(id, name)
SELECT d.name AS Department, e.name AS Employee, e.salary AS SalaryFROM Employee e JOIN Department d ON e.departmentId = d.idWHERE (SELECT COUNT(DISTINCT e2.salary) FROM Employee e2 WHERE e.salary < e2.salary AND e.departmentId = e2.departmentId) < 3;Execution flow:
Employee table:
| id | name | salary | deptId |
|---|---|---|---|
| 1 | Joe | 85000 | 1 |
| 2 | Henry | 80000 | 2 |
| 3 | Sam | 60000 | 2 |
| 4 | Max | 90000 | 1 |
| 5 | Janet | 69000 | 1 |
| 6 | Randy | 85000 | 1 |
For each employee, the correlated subquery counts “the number of distinct salary amounts in the same department higher than mine”:
| Employee | Department | Subquery: distinct salaries in dept higher than mine | COUNT | < 3? |
|---|---|---|---|---|
| Joe(85000) | 1 | {90000} | 1 | ✅ |
| Henry(80000) | 2 | {} | 0 | ✅ |
| Sam(60000) | 2 | {80000} | 1 | ✅ |
| Max(90000) | 1 | {} | 0 | ✅ |
| Janet(69000) | 1 | {85000, 90000} | 2 | ✅ |
| Randy(85000) | 1 | {90000} | 1 | ✅ |
Key point: The correlated subquery references the outer e.salary and e.departmentId; COUNT(DISTINCT) ensures salaries of the same amount are not double-counted
1789. Primary Department for Each Employee Easy
Table schema: Employee(employee_id, department_id, primary_flag)
SELECT employee_id, department_idFROM EmployeeWHERE primary_flag = 'Y' OR employee_id IN (SELECT employee_id FROM Employee GROUP BY employee_id HAVING COUNT(employee_id) = 1);Execution flow:
Employee table:┌─────────────┬──────────────┬──────────────┐│ employee_id │ department_id│ primary_flag │├─────────────┼──────────────┼──────────────┤│ 1 │ 1 │ N │ ← only 1 department → subquery hits ✅│ 2 │ 1 │ Y │ ← primary_flag=Y ✅│ 2 │ 2 │ N │ ← satisfies no condition ❌│ 3 │ 2 │ N │ ← only 1 department → subquery hits ✅│ 4 │ 1 │ N │ ← has 2 departments, neither primary is Y → see:│ 4 │ 2 │ N │ subquery COUNT=2≠1 ❌, primary≠Y ❌└─────────────┴──────────────┴──────────────┘
Final result: (1,1), (2,1), (3,2)Key point: OR connects two conditions — either flagged as primary department, or has only one department
V. Window Functions (RANK / SUM OVER / ROWS BETWEEN)
How Window Functions Work
Regular GROUP BY: one row output per group Window function: every row is output, but the aggregation scope is the "window"┌────────────┐ ┌────────────────────────┐│ dept │ AVG │ │ emp │ dept │ salary │ AVG(dept) │├──────┼─────┤ ├─────┼──────┼────────┼──────────┤│ A │ 75 │ │ 1 │ A │ 80 │ 75 │ ← average of same dept│ B │ 60 │ │ 2 │ A │ 70 │ 75 │└────────────┘ │ 3 │ B │ 60 │ 60 │ └─────┴──────┴────────┴──────────┘1204. Last Person to Fit in the Bus Medium (running total window)
Table schema: Queue(person_id, person_name, weight, turn)
Problem: Find the last person to board such that the total weight ≤ 1000.
SELECT person_nameFROM (SELECT person_name, turn, SUM(weight) OVER(ORDER BY turn) AS sum_weight FROM Queue) AS weiWHERE sum_weight <= 1000ORDER BY turn DESC LIMIT 1;Execution flow:
| turn | person_name | weight | SUM() OVER(ORDER BY turn) running total |
|---|---|---|---|
| 1 | Alice | 250 | 250 |
| 2 | Bob | 350 | 250+350=600 |
| 3 | Alex | 400 | 600+400=1000 |
| 4 | John | 300 | 1000+300=1300 |
| 5 | Winston | 500 | 1300+500=1800 |
After WHERE sum_weight <= 1000: Alice(250), Bob(600), Alex(1000)
ORDER BY turn DESC LIMIT 1: take the largest turn → Alex
Key point:
SUM() OVER(ORDER BY turn)computes the running total from the first row to the current row for each row
1321. Restaurant Growth Medium (7-day rolling window)
Table schema: Customer(customer_id, name, visited_on, amount)
Problem: Compute the 7-day rolling window total consumption and daily average.
SELECT visited_on, amount, average_amountFROM (SELECT visited_on, SUM(amount) OVER(ORDER BY visited_on ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS amount, ROUND(AVG(amount) OVER(ORDER BY visited_on ROWS BETWEEN 6 PRECEDING AND CURRENT ROW), 2) AS average_amount, ROW_NUMBER() OVER(ORDER BY visited_on) AS rn FROM (SELECT visited_on, SUM(amount) AS amount FROM Customer GROUP BY visited_on) AS daily) AS rankedWHERE rn > 6;Execution flow:
Step 1: Aggregate by day first visited_on | amount (daily total) 2019-01-01 | 130 2019-01-02 | 110 2019-01-03 | 140 2019-01-04 | 100 2019-01-05 | 110 2019-01-06 | 130 2019-01-07 | 150 2019-01-08 | 120 2019-01-09 | 200
Step 2: 7-day rolling window (ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
visited_on | window range | SUM | AVG | rn | >6? 2019-01-01 | [01-01] | 130 | 130.00 | 1 | ❌ 2019-01-02 | [01-01 ~ 01-02] | 240 | 120.00 | 2 | ❌ ... | window less than 7 days | ... | ... | .. | ❌ 2019-01-07 | [01-01 ~ 01-07] | 870 | 124.29 | 7 | ❌ 2019-01-08 | [01-02 ~ 01-08] | 860 | 122.86 | 8 | ✅ → output 2019-01-09 | [01-03 ~ 01-09] | 950 | 135.71 | 9 | ✅ → outputKey point:
ROWS BETWEEN 6 PRECEDING AND CURRENT ROWdefines a 7-day window containing the current row and the previous 6 rows.ROW_NUMBER() > 6filters out the first few rows whose window is incomplete (less than 7 days)
1341. Movie Rating Medium (CTE + RANK + UNION ALL)
Table schema:
Movies(movie_id, title)Users(user_id, name)MovieRating(movie_id, user_id, rating, created_at)
Problem: ① The user with the most comments ② The movie with the highest average rating in February 2020
WITH uion AS (SELECT u.user_id, u.name, m.title, mr.rating, mr.created_at FROM MovieRating mr JOIN Users u ON mr.user_id = u.user_id JOIN Movies m ON mr.movie_id = m.movie_id)SELECT name AS resultsFROM (SELECT name, RANK() OVER(ORDER BY COUNT(title) DESC, name) AS rk FROM uion GROUP BY user_id) AS max_userWHERE rk = 1UNION ALLSELECT title AS resultsFROM (SELECT title, RANK() OVER(ORDER BY AVG(rating) DESC, title) AS rk FROM uion WHERE created_at BETWEEN '2020-02-01' AND '2020-02-29' GROUP BY title) AS max_titleWHERE rk = 1;Execution flow:
Step 1: CTE uion — three-table JOIN┌────────┬────────┬─────────────┬───────┬────────────┐│ user_id│ name │ title │ rating│ created_at │├────────┼────────┼─────────────┼───────┼────────────┤│ 3 │ Daniel │ Ice │ 5 │ 2020-02-14 ││ 2 │ Daniel │ Detention │ 3 │ 2020-02-12 ││ ... │ ... │ ... │ ... │ ... │└────────┴────────┴─────────────┴───────┴────────────┘
Step 2: Subquery 1 — rank users by number of commentsGROUP BY user_id → COUNT(title) → RANK() OVER(ORDER BY count DESC, name)┌────────┬───────┬─────┐│ name │ count │ rk │├────────┼───────┼─────┤│ Daniel │ 3 │ 1 │ ← WHERE rk=1 → output "Daniel"│ Gloria │ 1 │ 2 │└────────┴───────┴─────┘
Step 3: Subquery 2 — rank February movies by average ratingWHERE created_at BETWEEN '2020-02-01' AND '2020-02-29'GROUP BY title → AVG(rating) → RANK() OVER(ORDER BY avg DESC, title)┌─────────────┬───────┬─────┐│ title │ avg │ rk │├─────────────┼───────┼─────┤│ Ice │ 4.5 │ 1 │ ← WHERE rk=1 → output "Ice"│ Detention │ 3.0 │ 2 │└─────────────┴───────┴─────┘
Step 4: UNION ALL merge┌──────────┐│ results │├──────────┤│ Daniel ││ Ice │└──────────┘Key point: RANK() handles ties (ORDER BY count DESC, name ensures that on a tie, the lexicographically smaller one is taken)
VI. CASE WHEN (Conditional Expression)
0610. Triangle Judgement Easy
Table schema: Triangle(x, y, z)
SELECT x, y, z, CASE WHEN x + y > z AND x + z > y AND y + z > x THEN 'Yes' ELSE 'No' END AS triangleFROM Triangle;Execution flow:
| x | y | z | x+y>z? | x+z>y? | y+z>x? | all satisfied? | triangle |
|---|---|---|---|---|---|---|---|
| 13 | 15 | 30 | 28>30 ❌ | 43>15 ✅ | 45>13 ✅ | ❌ | No |
| 10 | 20 | 15 | 30>15 ✅ | 25>20 ✅ | 35>10 ✅ | ✅ | Yes |
0626. Exchange Seats Medium
Table schema: Seat(id, student) — id is consecutive starting from 1
Problem: Swap adjacent seat numbers; if the number of students is odd, the last one stays put.
SELECT CASE WHEN id % 2 = 0 THEN id - 1 WHEN id % 2 = 1 AND id != (SELECT COUNT(*) FROM Seat) THEN id + 1 ELSE idENDAS id, studentFROM Seat ORDER BY id;Execution flow:
| original id | student | id % 2 | total rows | CASE result | new id |
|---|---|---|---|---|---|
| 1 | Abbot | 1 | 5 | odd and ≠5 → id+1 | 2 |
| 2 | Doris | 0 | 5 | even → id-1 | 1 |
| 3 | Emerson | 1 | 5 | odd and ≠5 → id+1 | 4 |
| 4 | Green | 0 | 5 | even → id-1 | 3 |
| 5 | Jeames | 1 | 5 | odd and =5 (last row) → id | 5 |
After ORDER BY id:
| id | student |
|---|---|
| 1 | Doris |
| 2 | Abbot |
| 3 | Green |
| 4 | Emerson |
| 5 | Jeames |
Key point: The subquery
COUNT(*)determines the total number of rows; if the last row has an odd id, it stays unchanged
0627. Swap Salary Easy (UPDATE + CASE)
UPDATE SalarySET sex = CASE WHEN sex = 'm' THEN 'f' WHEN sex = 'f' THEN 'm' END;Execution flow:
| id | name | sex(before) | → CASE → | sex(after) |
|---|---|---|---|---|
| 1 | A | m | → f | f |
| 2 | B | f | → m | m |
| 3 | C | f | → m | m |
| 4 | D | m | → f | f |
1907. Count Salary Categories Medium (UNION + IF)
Table schema: Accounts(account_id, income)
Problem: Count accounts by Low(<20000) / Average(20000-50000) / High(>50000).
SELECT 'Low Salary' AS category, SUM(IF(income < 20000, 1, 0)) AS accounts_countFROM AccountsUNIONSELECT 'Average Salary', SUM(IF(income >= 20000 AND income <= 50000, 1, 0))FROM AccountsUNIONSELECT 'High Salary', SUM(IF(income > 50000, 1, 0))FROM Accounts;Execution flow:
Accounts table: income = [8000, 25000, 30000, 60000, 15000]
1st SELECT: 'Low Salary', SUM(IF(income<20000,1,0)) → 8000→1, 25000→0, 30000→0, 60000→0, 15000→1 → SUM=2
2nd SELECT: 'Average Salary', SUM(IF(20000≤income≤50000,1,0)) → 8000→0, 25000→1, 30000→1, 60000→0, 15000→0 → SUM=2
3rd SELECT: 'High Salary', SUM(IF(income>50000,1,0)) → 8000→0, 25000→0, 30000→0, 60000→1, 15000→0 → SUM=1
UNION merge:┌──────────────────┬───────────────┐│ category │ accounts_count│├──────────────────┼───────────────┤│ Low Salary │ 2 ││ Average Salary │ 2 ││ High Salary │ 1 │└──────────────────┴───────────────┘Key point: UNION ensures all three categories are output (even if a category is 0), using string literals as category names
VII. UNION / UNION ALL (Combining Result Sets)
UNION vs UNION ALL
UNION: merge with deduplication UNION ALL: merge without deduplication┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐│ A │ │ A │ │ A │ │ A ││ B │ │ C │ │ B │ │ C │└──────┘ └──────┘ └──────┘ └──────┘ ↓ ↓┌──────┐ ┌──────┐│ A │ ← deduplicated │ A ││ B │ │ A │ ← kept│ C │ │ B │└──────┘ │ C │ └──────┘1795. Rearrange Product Table Easy (column-to-row)
Table schema: Products(product_id, store1, store2, store3)
Problem: Unpivot columns to rows, output (product_id, store, price).
SELECT product_id, 'store1' AS store, store1 AS priceFROM ProductsWHERE store1 IS NOT NULLUNION ALLSELECT product_id, 'store2' AS store, store2 AS priceFROM ProductsWHERE store2 IS NOT NULLUNION ALLSELECT product_id, 'store3' AS store, store3 AS priceFROM ProductsWHERE store3 IS NOT NULL;Execution flow:
Original Products table (wide table):┌────────────┬────────┬────────┬────────┐│ product_id │ store1 │ store2 │ store3 │├────────────┼────────┼────────┼────────┤│ 0 │ 95 │ 100 │ 105 ││ 1 │ 70 │ NULL │ 80 │└────────────┴────────┴────────┴────────┘
After UNION ALL (long table):┌────────────┬────────┬───────┐│ product_id │ store │ price │├────────────┼────────┼───────┤│ 0 │ store1 │ 95 ││ 0 │ store2 │ 100 ││ 0 │ store3 │ 105 ││ 1 │ store1 │ 70 ││ 1 │ store3 │ 80 │ ← store2=NULL filtered out└────────────┴────────┴───────┘Key point: UNION ALL converts the multiple columns of the wide table into multiple rows of the long table;
WHERE IS NOT NULLfilters out stores with no price
1164. Product Price at a Given Date Medium (UNION + subquery)
Table schema: Products(product_id, new_price, change_date)
Problem: Find the price of all products on 2019-08-16 (initial price=10).
-- Products with change records: take the most recent price ≤ 2019-08-16SELECT product_id, new_price AS priceFROM ProductsWHERE (product_id, change_date) IN (SELECT product_id, MAX(change_date) FROM Products WHERE change_date <= '2019-08-16' GROUP BY product_id)UNION-- Products with no change records: price defaults to 10SELECT product_id, 10 AS priceFROM ProductsWHERE (product_id, change_date) IN (SELECT product_id, MIN(change_date) FROM Products GROUP BY product_id HAVING MIN(change_date) > '2019-08-16')ORDER BY product_id;Execution flow:
Products table:┌────────────┬───────────┬─────────────┐│ product_id │ new_price │ change_date │├────────────┼───────────┼─────────────┤│ 1 │ 20 │ 2019-08-14 │ ← ≤ 08-16, most recent│ 2 │ 50 │ 2019-08-01 │ ← ≤ 08-16, most recent│ 1 │ 10 │ 2019-08-17 │ ← > 08-16│ 3 │ 30 │ 2019-08-19 │ ← first change > 08-16 → default 10└────────────┴───────────┴─────────────┘
Subquery 1: most recent change ≤ 08-16 → (1, 2019-08-14), (2, 2019-08-01) → product 1 → price=20, product 2 → price=50
Subquery 2: first change > 08-16 → (3, 2019-08-19) → product 3 → price=10 (default)
UNION merge:┌────────────┬───────┐│ product_id │ price │├────────────┼───────┤│ 1 │ 20 ││ 2 │ 50 ││ 3 │ 10 │└────────────┴───────┘VIII. String and Date Functions
0550. Game Play Analysis IV Medium (DATEDIFF + subquery)
Table schema: Activity(player_id, device_id, event_date, games_played)
Problem: The ratio of players who logged in again on the day after their first login.
SELECT ROUND(COUNT(m.player_id) / COUNT(DISTINCT a.player_id), 2) AS fractionFROM Activity a LEFT JOIN (SELECT player_id, MIN(event_date) AS event_date FROM Activity GROUP BY player_id) m ON a.player_id = m.player_id AND DATEDIFF(a.event_date, m.event_date) = 1;Execution flow:
Step 1: Subquery finds the first login date player_id=1 → 2016-03-01 player_id=2 → 2017-06-25 player_id=3 → 2016-03-02
Step 2: LEFT JOIN condition: same player + DATEDIFF=1 (login on the next day) Activity(1, 2016-03-02) JOIN m(1, 2016-03-01) → DATEDIFF=1 ✅ → m.player_id=1 Activity(1, 2016-03-01) JOIN m(1, 2016-03-01) → DATEDIFF=0 ❌ Activity(2, 2017-06-25) JOIN m(2, 2017-06-25) → DATEDIFF=0 ❌ Activity(3, 2018-07-03) JOIN m(3, 2016-03-02) → DATEDIFF=488 ❌
Step 3: Compute the ratio COUNT(m.player_id) = 1 (only player 1 logged in the next day) COUNT(DISTINCT a.player_id) = 3 (total number of players) fraction = 1/3 = 0.33Key point: LEFT JOIN ensures all players are counted in the denominator; DATEDIFF(a, b) = the number of days a - b
0197. Rising Temperature Easy (DATEDIFF + self join)
Table schema: Weather(id, recordDate, temperature)
SELECT w1.idFROM Weather w1 JOIN Weather w2 ON DATEDIFF(w1.recordDate, w2.recordDate) = 1WHERE w1.temperature > w2.temperature;Execution flow:
| w1 (today) | w2 (yesterday) | DATEDIFF | temperature comparison | result |
|---|---|---|---|---|
| 2015-01-02, 25° | 2015-01-01, 10° | 1 ✅ | 25 > 10 ✅ | id=2 |
| 2015-01-03, 20° | 2015-01-02, 25° | 1 ✅ | 20 > 25 ❌ | - |
| 2015-01-04, 30° | 2015-01-03, 20° | 1 ✅ | 30 > 20 ✅ | id=4 |
0180. Consecutive Numbers Medium (self join ×3)
Table schema: Logs(id, num)
SELECT DISTINCT l1.Num AS ConsecutiveNumsFROM Logs l1, Logs l2, Logs l3WHERE l1.Id = l2.Id - 1 AND l2.Id = l3.Id - 1 AND l1.Num = l2.Num AND l2.Num = l3.Num;Execution flow:
Logs table:┌────┬─────┐│ id │ num │├────┼─────┤│ 1 │ 1 ││ 2 │ 1 ││ 3 │ 1 │ ← 1 appears 3 times consecutively ✅│ 4 │ 2 ││ 5 │ 1 ││ 6 │ 2 ││ 7 │ 2 │ ← 2 only appears 2 times consecutively ❌└────┴─────┘
Three-table self join condition: l1.id=l2.id-1 AND l2.id=l3.id-1→ match: (id=1,num=1), (id=2,num=1), (id=3,num=1)→ l1.Num=l2.Num=l3.Num=1 ✅→ after DISTINCT result: ConsecutiveNums = 11517. Find Users With Valid E-Mails Easy (REGEXP)
SELECT *FROM UsersWHERE mail REGEXP '^[a-zA-Z][a-zA-Z0-9_.-]*@leetcode\.com$' COLLATE utf8mb4_bin;Regex explanation:
^[a-zA-Z] → starts with a letter[a-zA-Z0-9_.-]* → followed by any number of letters/digits/underscores/dots/hyphens@leetcode\.com$ → ends with @leetcode.comCOLLATE utf8mb4_bin → case-sensitive (ensures the domain is lowercase)Execution flow:
| user_id | name | match? | |
|---|---|---|---|
| 1 | Winston | winston@leetcode.com | ✅ |
| 2 | Jonathan | jonathanisgreat | ❌ no @ |
| 3 | Annabelle | bella-@leetcode.com | ✅ |
| 4 | Sally | sally.come@leetcode.com | ✅ |
| 5 | Marwan | quarz#2020@leetcode.com | ❌ # not allowed |
| 6 | David | david1@gmail.com | ❌ wrong domain |
| 7 | George | George@leetcode.com | ❌ G is uppercase (utf8mb4_bin is case-sensitive) |
1527. Patients With a Condition Easy (LIKE fuzzy match)
Table schema: Patients(patient_id, patient_name, conditions)
Problem: Find patients with type I diabetes (starting with DIAB1).
SELECT *FROM PatientsWHERE conditions LIKE '% DIAB1%' OR conditions LIKE 'DIAB1%';Execution flow:
| patient_id | conditions | match method | result |
|---|---|---|---|
| 1 | DIAB100 | LIKE ‘DIAB1%’ ✅ (at start) | output |
| 2 | SADIAB100 | ❌ no match (SAD is not space-separated) | no output |
| 3 | ASDIAB1 | ❌ same as above | no output |
| 4 | FR DIAB100 | LIKE ’% DIAB1%’ ✅ (space before) | output |
| 5 | SAD DIAB100 | LIKE ’% DIAB1%’ ✅ | output |
⚠️
LIKE '%DIAB1%'would falsely match SADIAB100! You must use'% DIAB1%'(with a leading space) or'DIAB1%'(at the start)
1667. Fix Names in a Table Easy (string functions)
SELECT user_id, CONCAT(UPPER(SUBSTRING(name, 1, 1)), LOWER(SUBSTRING(name, 2))) AS nameFROM UsersORDER BY user_id;Execution flow:
| user_id | name(original) | SUBSTRING(name,1,1) | UPPER | SUBSTRING(name,2) | LOWER | CONCAT |
|---|---|---|---|---|---|---|
| 1 | aLICE | a | A | LICE | lice | Alice |
| 2 | bOB | b | B | OB | ob | Bob |
IX. CTE and Complex Queries
3554. Find Category Recommendation Pairs Hard (CTE + self join)
Table schema:
ProductPurchases(user_id, product_id, quantity)ProductInfo(product_id, category, price)
Problem: Find category pairs where the number of users who purchased both categories ≥ 3.
WITH tab AS (SELECT pp.user_id, pi.category FROM ProductPurchases pp JOIN ProductInfo pi ON pp.product_id = pi.product_id)SELECT tab.category AS category1, tab2.category AS category2, COUNT(DISTINCT tab.user_id) AS customer_countFROM tab JOIN tab AS tab2 ON tab.user_id = tab2.user_id AND tab.category < tab2.categoryGROUP BY tab.category, tab2.categoryHAVING customer_count >= 3ORDER BY customer_count DESC, category1, category2;Execution flow:
Step 1: CTE tab — join purchase records and product categories┌─────────┬──────────┐│ user_id │ category │├─────────┼──────────┤│ 1 │ A ││ 1 │ B ││ 2 │ A ││ 2 │ B ││ 3 │ A ││ 3 │ B ││ 4 │ A │ ← only bought A, no B└─────────┴──────────┘
Step 2: Self join to generate category pairs (condition category1 < category2 avoids duplicates)tab JOIN tab ON same user_id AND tab.category < tab2.category┌─────────┬───────────┬───────────┐│ user_id │ category1 │ category2 │├─────────┼───────────┼───────────┤│ 1 │ A │ B ││ 2 │ A │ B ││ 3 │ A │ B ││ 4 │ (no B) │ │ ← no match└─────────┴───────────┴───────────┘
Step 3: GROUP BY (category1, category2) + HAVING ≥ 3┌───────────┬───────────┬────────────────┬──────┐│ category1 │ category2 │ COUNT(DISTINCT)│ ≥ 3? │├───────────┼───────────┼────────────────┼──────┤│ A │ B │ 3 │ ✅ │└───────────┴───────────┴────────────────┴──────┘Key point:
tab.category < tab2.categoryensures (A,B) and (B,A) appear only once
1934. Confirmation Rate Medium (LEFT JOIN + IF + IFNULL)
Table schema:
Signups(user_id, time_stamp)Confirmations(user_id, time_stamp, action)
Problem: The confirmation rate for each user = number confirmed / total requests; 0 if no requests.
SELECT s.user_id, ROUND(IFNULL(SUM(IF(c.action = 'confirmed', 1, 0)) / COUNT(c.action), 0), 2) AS confirmation_rateFROM Signups s LEFT JOIN Confirmations c ON s.user_id = c.user_idGROUP BY s.user_id;Execution flow:
| Signups | LEFT JOIN Confirmations | action | IF(confirmed) | COUNT(action) | rate |
|---|---|---|---|---|---|
| user=3 | (3, confirmed) | confirmed → 1 | |||
| user=3 | (3, timeout) | timeout → 0 | SUM=1 | COUNT=2 | 1/2=0.5 |
| user=7 | (7, timeout) | timeout → 0 | |||
| user=7 | (7, timeout) | timeout → 0 | SUM=0 | COUNT=2 | 0/2=0 |
| user=6 | (no match) | NULL | SUM=NULL | COUNT=0 | IFNULL(NULL,0)=0 |
Final result:
| user_id | confirmation_rate |
|---|---|
| 3 | 0.50 |
| 7 | 0.00 |
| 6 | 0.00 |
X. Regular Expressions and Pattern Matching
3475. DNA Pattern Recognition Medium (CASE WHEN + LIKE + REGEXP)
Table schema: Samples(sample_id, dna_sequence, species)
SELECT sample_id, dna_sequence, species, CASE WHEN dna_sequence LIKE 'ATG%' THEN 1 ELSE 0 END AS has_start, CASE WHEN dna_sequence REGEXP 'TAA$|TAG$|TGA$' THEN 1 ELSE 0 END AS has_stop, CASE WHEN dna_sequence LIKE '%ATAT%' THEN 1 ELSE 0 END AS has_atat, CASE WHEN dna_sequence LIKE '%GGG%' THEN 1 ELSE 0 END AS has_gggFROM Samples;Pattern explanation:
| Pattern | LIKE/REGEXP | Meaning |
|---|---|---|
| Starts with ATG | LIKE 'ATG%' | % matches any following characters |
| Ends with TAA/TAG/TGA | `REGEXP ‘TAA$ | TAG$ |
| Contains ATAT | LIKE '%ATAT%' | % on both ends matches any prefix/suffix |
| Contains GGG | LIKE '%GGG%' | same as above |
Execution example:
| sample_id | dna_sequence | has_start | has_stop | has_atat | has_ggg |
|---|---|---|---|---|---|
| 1 | ATGCGATATGGGTAATAG | 1 (starts with ATG) | 1 (ends with TAG) | 1 (contains ATAT) | 1 (contains GGG) |
| 2 | ATGCGGTAA | 1 | 1 (ends with TAA) | 0 | 0 |
| 3 | CGCGCG | 0 | 0 | 0 | 0 |
1683. Invalid Tweets Easy
SELECT tweet_idFROM TweetsWHERE LENGTH(content) > 15;0196. Delete Duplicate Emails Easy (DELETE + self join)
Table schema: Person(id, email)
DELETEp1 FROM Person p1JOIN Person p2 ON p1.email = p2.email AND p1.id > p2.id;Execution flow:
Person table before delete:┌────┬───────────┐│ id │ email │├────┼───────────┤│ 1 │ john@mail │ ← kept (smallest id)│ 2 │ bob@mail ││ 3 │ john@mail │ ← deleted (same email, larger id)└────┴───────────┘
Self join condition: p1.email = p2.email AND p1.id > p2.id→ p1(3, john@mail) JOIN p2(1, john@mail): same email AND 3>1 ✅ → DELETE p1→ p1(1, john@mail) JOIN p2(3, john@mail): 1>3 ❌ → not deleted→ p1(2, bob@mail): no smaller id with same email → not deleted
After delete:┌────┬───────────┐│ id │ email │├────┼───────────┤│ 1 │ john@mail ││ 2 │ bob@mail │└────┴───────────┘Key point:
p1.id > p2.idensures the record with the smallest id is kept
Appendix: Quick Reference of Common SQL Functions
Aggregate Functions
| Function | Description | Example |
|---|---|---|
| COUNT(*) | Count rows (including NULL) | COUNT(*) FROM Users |
| COUNT(col) | Count non-NULL rows | COUNT(email) |
| COUNT(DISTINCT col) | Count distinct | COUNT(DISTINCT user_id) |
| SUM(col) | Sum | SUM(amount) |
| AVG(col) | Average | AVG(rating) |
| MIN/MAX(col) | Minimum / maximum | MIN(event_date) |
Conditional Expressions
| Syntax | Description |
|---|---|
IF(cond, a, b) | Return a if condition is true, otherwise b |
CASE WHEN cond THEN a ELSE b END | Multi-condition branch |
IFNULL(a, b) | Return b if a is NULL |
Window Functions
| Syntax | Description |
|---|---|
RANK() OVER(ORDER BY col) | Rank (ties share rank, with gaps) |
DENSE_RANK() OVER(ORDER BY col) | Rank (ties share rank, no gaps) |
ROW_NUMBER() OVER(ORDER BY col) | Row number (no duplicates) |
SUM(col) OVER(ORDER BY col) | Running total |
SUM(col) OVER(ORDER BY col ROWS BETWEEN N PRECEDING AND CURRENT ROW) | N+1 row sliding window |
Date Functions
| Function | Description | Example |
|---|---|---|
| DATEDIFF(a, b) | a-b in days | DATEDIFF(d1, d2) = 1 |
| DATE_FORMAT(d, fmt) | Format date | DATE_FORMAT(d, '%Y-%m') |
| NOW() / CURDATE() | Current time / date |
String Functions
| Function | Description |
|---|---|
| SUBSTRING(s, pos, len) | Extract substring |
| UPPER(s) / LOWER(s) | Uppercase / lowercase |
| CONCAT(s1, s2) | Concatenate |
| LENGTH(s) | Length |
| GROUP_CONCAT(col ORDER BY col) | Concatenate within group |
| REGEXP | Regex match |
| LIKE | Pattern match (% any, _ single char) |
💡 Suggestion: What SQL tests is not complex syntax, but the ability to break business requirements down into SQL execution steps. When you get a problem, first think: ① Which tables do I need? ② Do I need to join? ③ Do I need to group? ④ At which step does the filter condition apply? ⑤ Do I need to sort / truncate? Once you think through these five steps clearly, the SQL will naturally come out.