SQL Algorithm Notes - MuxiaoWFSkip to main content
This page was machine-translated and may contain errors or omissions. / 本页面为机器翻译,可能存在错漏。

SQL Algorithm Notes

Algorithm notes for SQL50 on LeetCode

Sun Aug 09 2026
5880 words · 42 minutes

SQL Algorithm Notes

General Problem-Solving Patterns Overview

What Feature You See → Which SQL Technique to Use

Problem FeaturePreferred TechniqueTypical Problems
Joining two tables to fetch dataINNER JOIN / LEFT JOIN175, 1068, 1378
Keep all rows from the left table (return NULL if no match)LEFT JOIN175, 577, 1378, 1581
Comparing rows within the same table (employee vs manager)SELF JOIN181, 570, 1731, 1978
Find duplicate valuesGROUP BY + HAVING COUNT > 1182, 196
Filter after groupingGROUP BY + HAVING596, 570, 1045, 1084
Aggregate after groupingGROUP BY + COUNT/SUM/AVG511, 1693, 1729, 2356
Ranking / Top NWindow functions RANK / DENSE_RANK185, 1341
Running total / sliding windowSUM() 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 resultsUNION / UNION ALL1795, 1907, 1164
Find “all” / “every”HAVING COUNT(DISTINCT) = total count1045
Compare with previous day / N consecutive daysSELF JOIN + DATEDIFF197, 180, 550
Return even when there is no matchLEFT JOIN + IS NULL577, 581, 1978
String pattern matchingLIKE / REGEXP1527, 1517, 1683
Column-to-row (unpivot)UNION ALL1795
Row-to-column / concatenationGROUP_CONCAT1484
Conditional assignmentCASE WHEN610, 626, 627
Date formattingDATE_FORMAT1193, 1327

SQL Execution Order (Important!)

FROM + JOIN → First determine the data source and joins
WHERE → Row-level filtering
GROUP BY → Grouping
HAVING → Group-level filtering
SELECT → Select columns / aggregate / window functions
ORDER BY → Sorting
LIMIT → Truncation

Key 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, area
FROM World
WHERE 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 name
FROM Customer
WHERE referee_id != 2 OR referee_id IS NULL;

Execution flow:

idnamereferee_idJudgmentResult
1WillNULLNULL != 2 → NULL (not true) → but IS NULL → ✅output
2JaneNULLsame as above → ✅output
3Alex22 != 2 → false → ❌no output
4Bill33 != 2 → true → ✅output
5Zack11 != 2 → true → ✅output

⚠️ Key trap: referee_id != 2 will NOT match NULL! Comparing with NULL yields NULL (not true); you must handle it separately with IS NULL


0168. Invalid Tweets Easy

SELECT tweet_id
FROM Tweets
WHERE LENGTH(content) > 15;
tweet_idcontentLENGTHResult
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 cinema
WHERE description != 'boring' AND id % 2 = 1
ORDER BY rating DESC;

Execution flow:

idmoviedescriptionrating→ WHERE→ ORDER BY rating DESC
1Wargreat 3D8.9✅ odd + not boring8.9
2Scienceboring8.4❌ boring-
3IrishNOT boring7.0✅ odd + not boring7.0
4Ice SongFantacy8.6❌ even-
5House cardInteresting9.1✅ odd + not boring9.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.state
FROM Person p
LEFT JOIN Address a ON p.personId = a.personId;

Execution flow:

Person table:

personIdfirstNamelastName
1AllenWang
2BobAlice
3ZackSy

Address table:

addressIdpersonIdcitystate
12NYCNY
23BostonMA

LEFT JOIN result:

firstNamelastNamecitystate
AllenWangNULLNULL
BobAliceNYCNY
ZackSyBostonMA

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 Employee
FROM Employee e1
JOIN Employee e2 ON e1.managerId = e2.id
WHERE e1.salary > e2.salary;

Execution flow:

Employee table (one table playing two roles):

idnamesalarymanagerId
1Joe700003
2Henry800004
3Sam60000NULL
4Max90000NULL

After self join (e1=employee, e2=manager):

e1.name(employee)e1.salarye2.name(manager)e2.salarye1.salary > e2.salary?
Joe70000Sam60000✅ 70000 > 60000
Henry80000Max90000❌ 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, bonus
FROM Employee e
LEFT JOIN Bonus b ON e.empId = b.empId
WHERE b.bonus < 1000
OR b.bonus IS NULL;

Execution flow:

Employee table:

empIdnamesupervisorsalary
1BradNULL5000
2John14000
3Dan13000
4Thomas12000

Bonus table:

empIdbonus
2500
3NULL
42000

After LEFT JOIN:

namebonusJudgment
BradNULLIS NULL → ✅
John500500 < 1000 → ✅
DanNULLIS NULL → ✅
Thomas20002000 ≥ 1000 → ❌

Final result:

namebonus
BradNULL
John500
DanNULL

⚠️ b.bonus < 1000 does not match NULL; you must add OR b.bonus IS NULL


1378. Replace Employee ID With The Unique Identifier Easy

SELECT euni.unique_id, e.name
FROM Employees e
LEFT JOIN EmployeeUNI euni ON e.id = euni.id;

Execution flow:

EmployeesEmployeeUNILEFT JOIN result
id=1, Aliceid=1, unique_id=10unique_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.price
FROM Sales s
JOIN Product p ON s.product_id = p.product_id;

Execution flow:

SalesProductJOIN result
sale_id=1, product_id=100, year=2008, price=5000product_id=100, NokiaNokia, 2008, 5000
sale_id=2, product_id=100, year=2009, price=5000product_id=100, NokiaNokia, 2009, 5000
sale_id=7, product_id=200, year=2011, price=7000product_id=200, AppleApple, 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_trans
FROM Visits v
LEFT JOIN Transactions t ON v.visit_id = t.visit_id
WHERE t.transaction_id IS NULL
GROUP BY v.customer_id;

Execution flow:

VisitsLEFT JOIN TransactionsWHERE IS NULLGROUP 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_idcount_no_trans
91
301
542

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_exams
FROM 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_name
GROUP BY s.student_id, su.subject_name
ORDER 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-NULL

Key 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_age
FROM Employees e1
JOIN Employees e2 ON e1.reports_to = e2.employee_id
GROUP BY e1.reports_to
ORDER BY e2.employee_id;

Execution flow:

Employees table:

employee_idnamereports_toage
9HercyNULL43
6Alice931
4Bob936
2Omer624

Self join (e1=subordinate, e2=manager):

e1.name(subordinate)e1.agee2.employee_id(manager)e2.name(manager)
Alice319Hercy
Bob369Hercy
Omer246Alice

After GROUP BY e1.reports_to:

employee_idnamereports_countaverage_age
9Hercy2ROUND((31+36)/2) = 34
6Alice124

1978. Company Employees Whose Manager Left the Company Easy (LEFT JOIN + IS NULL)

SELECT e1.employee_id
FROM Employees e1
LEFT JOIN Employees e2 ON e1.manager_id = e2.employee_id
WHERE e1.salary < 30000
AND e2.employee_id IS NULL
AND e1.manager_id IS NOT NULL
ORDER BY e1.employee_id;

Execution flow:

e1(employee)e1.manager_ide1.salarye2(manager) matche2 IS NULL?manager_id IS NOT NULL?Result
3, Mary125000(id=1 exists)❌ manager not left
7, Robert9920000(id=99 does not exist)
11, Brad528000(id=5 does not exist)
13, JasonNULL15000(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 functions

0511. 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_login
FROM Activity
GROUP BY player_id;

Execution flow:

Original Activity table→ GROUP BY player_id→ MIN(event_date)
1, 2, 2016-03-01, 5player_id=1: {2016-03-01, 2016-05-02}2016-03-01
1, 2, 2016-05-02, 6player_id=2: {2017-06-25}2017-06-25
2, 3, 2017-06-25, 1player_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 Email
FROM Person
GROUP BY email
HAVING COUNT(email) > 1;

Execution flow:

Person table:

idemail
1a@b.com
2c@d.com
3a@b.com

After GROUP BY email:

emailCOUNTHAVING COUNT > 1?
a@b.com2
c@d.com1

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 class
FROM Courses
GROUP BY class
HAVING COUNT(DISTINCT student) >= 5;

Execution flow:

Courses table:

studentclass
AMath
BEnglish
CMath
DBiology
EMath
FMath
GMath
HMath

After GROUP BY class:

classCOUNT(DISTINCT student)>= 5?
Math6
English1
Biology1

Final result: Math


0570. Managers with at Least 5 Direct Reports Medium (self join + GROUP BY + HAVING)

SELECT e1.name
FROM Employee e1
JOIN Employee e2 ON e1.Id = e2.managerId
GROUP BY e1.Id
HAVING 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 → Stephen

1045. Customers Who Bought All Products Medium (HAVING COUNT = subquery)

Table schema:

  • Customer(customer_id, product_key)
  • Product(product_key)
SELECT customer_id
FROM Customer
GROUP BY customer_id
HAVING 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, 3

Key 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_name
FROM Product p
JOIN Sales s ON p.product_id = s.product_id
GROUP BY s.product_id
HAVING MIN(s.sale_date) >= '2019-01-01'
AND MAX(s.sale_date) <= '2019-03-31';

Execution flow:

product_idall sale_dateMINMAXall in spring?
12019-02-17, 2019-02-252019-02-172019-02-25
22019-02-01, 2019-04-042019-02-012019-04-04❌ (April out of range)
32019-03-102019-03-102019-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 balance
FROM Users u
JOIN Transactions t ON u.account = t.account
GROUP BY t.account
HAVING SUM(t.amount) > 10000;

Execution flow:

UsersTransactionsJOIN + GROUP BYHAVING > 10000
account=1, Aliceaccount=1, +7000Alice: 7000+7000=14000
account=2, Bobaccount=1, +7000Bob: 3000-5000=-2000
account=2, +3000
account=2, -5000

Final result:

namebalance
Alice14000

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_amount
FROM Transactions
GROUP BY month, country;

Execution flow:

Original Transactions table:

idcountrystateamounttrans_date
121USapproved10002019-01-18
122USdeclined20002019-01-19
123USapproved30002019-01-27
124DEapproved20002019-01-14

After GROUP BY (month, country):

monthcountrytrans_countapproved_counttrans_totalapproved_total
2019-01US3SUM(1,0,1)=260001000+3000=4000
2019-01DE1SUM(1)=120002000

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_loss
FROM Stocks
GROUP BY stock_name;

Execution flow:

stock_nameoperationpriceCASE result
LeetcodeBuy1000-1000
LeetcodeSell9000+9000
CoronaBuy3000-3000
CoronaSell1580+1580

After GROUP BY stock_name:

stock_namecapital_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 products
FROM Activities
GROUP BY sell_date;

Execution flow:

Original Activities→ GROUP BY sell_date→ GROUP_CONCAT
2020-05-30, Headphone2020-05-30: {Headphone, Basketball, PC}2020-05-30: “Basketball,Headphone,PC”
2020-06-01, Pencil2020-06-01: {Pencil, Bathing}2020-06-01: “Bathing,Pencil”
2020-06-02, Mask2020-06-02: {Mask, Bathing}2020-06-02: “Bathing,Mask”
2020-05-30, BasketballCOUNT(DISTINCT)=3
2020-06-01, BathingCOUNT(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_partners
FROM DailySales
GROUP 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=0leads={0,1,1} → DISTINCT={0,1}unique_partners=2 (0,1)
2020-12-8, Toyota, lead=1, partner=2partners={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=0leads={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 num
FROM (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_2016
FROM Insurance
WHERE (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, 4
Subquery 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 = 45

Key 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 Salary
FROM Employee e
JOIN Department d ON e.departmentId = d.id
WHERE (SELECT COUNT(DISTINCT e2.salary)
FROM Employee e2
WHERE e.salary < e2.salary
AND e.departmentId = e2.departmentId) < 3;

Execution flow:

Employee table:

idnamesalarydeptId
1Joe850001
2Henry800002
3Sam600002
4Max900001
5Janet690001
6Randy850001

For each employee, the correlated subquery counts “the number of distinct salary amounts in the same department higher than mine”:

EmployeeDepartmentSubquery: distinct salaries in dept higher than mineCOUNT< 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_id
FROM Employee
WHERE 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_name
FROM (SELECT person_name,
turn,
SUM(weight) OVER(ORDER BY turn) AS sum_weight
FROM Queue) AS wei
WHERE sum_weight <= 1000
ORDER BY turn DESC LIMIT 1;

Execution flow:

turnperson_nameweightSUM() OVER(ORDER BY turn) running total
1Alice250250
2Bob350250+350=600
3Alex400600+400=1000
4John3001000+300=1300
5Winston5001300+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_amount
FROM (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 ranked
WHERE 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 | ✅ → output

Key point: ROWS BETWEEN 6 PRECEDING AND CURRENT ROW defines a 7-day window containing the current row and the previous 6 rows. ROW_NUMBER() > 6 filters 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 results
FROM (SELECT name, RANK() OVER(ORDER BY COUNT(title) DESC, name) AS rk
FROM uion
GROUP BY user_id) AS max_user
WHERE rk = 1
UNION ALL
SELECT title AS results
FROM (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_title
WHERE 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 comments
GROUP 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 rating
WHERE 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 triangle
FROM Triangle;

Execution flow:

xyzx+y>z?x+z>y?y+z>x?all satisfied?triangle
13153028>30 ❌43>15 ✅45>13 ✅No
10201530>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 id
END
AS id, student
FROM Seat ORDER BY id;

Execution flow:

original idstudentid % 2total rowsCASE resultnew id
1Abbot15odd and ≠5 → id+12
2Doris05even → id-11
3Emerson15odd and ≠5 → id+14
4Green05even → id-13
5Jeames15odd and =5 (last row) → id5

After ORDER BY id:

idstudent
1Doris
2Abbot
3Green
4Emerson
5Jeames

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 Salary
SET sex = CASE WHEN sex = 'm' THEN 'f' WHEN sex = 'f' THEN 'm' END;

Execution flow:

idnamesex(before)→ CASE →sex(after)
1Am→ ff
2Bf→ mm
3Cf→ mm
4Dm→ ff

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_count
FROM Accounts
UNION
SELECT 'Average Salary', SUM(IF(income >= 20000 AND income <= 50000, 1, 0))
FROM Accounts
UNION
SELECT '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 price
FROM Products
WHERE store1 IS NOT NULL
UNION ALL
SELECT product_id, 'store2' AS store, store2 AS price
FROM Products
WHERE store2 IS NOT NULL
UNION ALL
SELECT product_id, 'store3' AS store, store3 AS price
FROM Products
WHERE 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 NULL filters 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-16
SELECT product_id, new_price AS price
FROM Products
WHERE (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 10
SELECT product_id, 10 AS price
FROM Products
WHERE (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 fraction
FROM 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.33

Key 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.id
FROM Weather w1
JOIN Weather w2 ON DATEDIFF(w1.recordDate, w2.recordDate) = 1
WHERE w1.temperature > w2.temperature;

Execution flow:

w1 (today)w2 (yesterday)DATEDIFFtemperature comparisonresult
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 ConsecutiveNums
FROM Logs l1,
Logs l2,
Logs l3
WHERE 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 = 1

1517. Find Users With Valid E-Mails Easy (REGEXP)

SELECT *
FROM Users
WHERE 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.com
COLLATE utf8mb4_bin → case-sensitive (ensures the domain is lowercase)

Execution flow:

user_idnamemailmatch?
1Winstonwinston@leetcode.com
2Jonathanjonathanisgreat❌ no @
3Annabellebella-@leetcode.com
4Sallysally.come@leetcode.com
5Marwanquarz#2020@leetcode.com❌ # not allowed
6Daviddavid1@gmail.com❌ wrong domain
7GeorgeGeorge@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 Patients
WHERE conditions LIKE '% DIAB1%'
OR conditions LIKE 'DIAB1%';

Execution flow:

patient_idconditionsmatch methodresult
1DIAB100LIKE ‘DIAB1%’ ✅ (at start)output
2SADIAB100❌ no match (SAD is not space-separated)no output
3ASDIAB1❌ same as aboveno output
4FR DIAB100LIKE ’% DIAB1%’ ✅ (space before)output
5SAD DIAB100LIKE ’% 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 name
FROM Users
ORDER BY user_id;

Execution flow:

user_idname(original)SUBSTRING(name,1,1)UPPERSUBSTRING(name,2)LOWERCONCAT
1aLICEaALICEliceAlice
2bOBbBOBobBob

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_count
FROM tab
JOIN tab AS tab2
ON tab.user_id = tab2.user_id AND tab.category < tab2.category
GROUP BY tab.category, tab2.category
HAVING customer_count >= 3
ORDER 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.category ensures (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_rate
FROM Signups s
LEFT JOIN Confirmations c ON s.user_id = c.user_id
GROUP BY s.user_id;

Execution flow:

SignupsLEFT JOIN ConfirmationsactionIF(confirmed)COUNT(action)rate
user=3(3, confirmed)confirmed → 1
user=3(3, timeout)timeout → 0SUM=1COUNT=21/2=0.5
user=7(7, timeout)timeout → 0
user=7(7, timeout)timeout → 0SUM=0COUNT=20/2=0
user=6(no match)NULLSUM=NULLCOUNT=0IFNULL(NULL,0)=0

Final result:

user_idconfirmation_rate
30.50
70.00
60.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_ggg
FROM Samples;

Pattern explanation:

PatternLIKE/REGEXPMeaning
Starts with ATGLIKE 'ATG%'% matches any following characters
Ends with TAA/TAG/TGA`REGEXP ‘TAA$TAG$
Contains ATATLIKE '%ATAT%'% on both ends matches any prefix/suffix
Contains GGGLIKE '%GGG%'same as above

Execution example:

sample_iddna_sequencehas_starthas_stophas_atathas_ggg
1ATGCGATATGGGTAATAG1 (starts with ATG)1 (ends with TAG)1 (contains ATAT)1 (contains GGG)
2ATGCGGTAA11 (ends with TAA)00
3CGCGCG0000

1683. Invalid Tweets Easy

SELECT tweet_id
FROM Tweets
WHERE LENGTH(content) > 15;

0196. Delete Duplicate Emails Easy (DELETE + self join)

Table schema: Person(id, email)

DELETE
p1 FROM Person p1
JOIN 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.id ensures the record with the smallest id is kept


Appendix: Quick Reference of Common SQL Functions

Aggregate Functions

FunctionDescriptionExample
COUNT(*)Count rows (including NULL)COUNT(*) FROM Users
COUNT(col)Count non-NULL rowsCOUNT(email)
COUNT(DISTINCT col)Count distinctCOUNT(DISTINCT user_id)
SUM(col)SumSUM(amount)
AVG(col)AverageAVG(rating)
MIN/MAX(col)Minimum / maximumMIN(event_date)

Conditional Expressions

SyntaxDescription
IF(cond, a, b)Return a if condition is true, otherwise b
CASE WHEN cond THEN a ELSE b ENDMulti-condition branch
IFNULL(a, b)Return b if a is NULL

Window Functions

SyntaxDescription
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

FunctionDescriptionExample
DATEDIFF(a, b)a-b in daysDATEDIFF(d1, d2) = 1
DATE_FORMAT(d, fmt)Format dateDATE_FORMAT(d, '%Y-%m')
NOW() / CURDATE()Current time / date

String Functions

FunctionDescription
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
REGEXPRegex match
LIKEPattern 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.


Thanks for reading! Follow me if you'd like~

SQL Algorithm Notes

Sun Aug 09 2026
5880 words · 42 minutes
Cover
Sample track
Sample artist
Cover
Sample track
Sample artist
0:00 / 0:00