SQL cho Interview — Lộ trình 10 tuần
Nhấn phím → để bắt đầu · T để xem mục lục
Bảy tuần qua bạn đã build vũ khí đầy đủ. Tuần này không thêm vũ khí mới — mà rèn phản xạ nhận đề.
Sự khác biệt giữa candidate trung bình và xuất sắc trong mock interview SQL không phải là người biết nhiều cú pháp hơn — mà là người nhận ra pattern trong 30 giây đầu và viết ngay khung lời giải đúng.
Khoảng 80% bài Medium/Hard LeetCode SQL rơi vào một trong bảy pattern trên. Sau tuần này, bạn nhìn đề là biết "à đây là pattern X" và viết khung trong 1 phút.
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY group_key
ORDER BY rank_key DESC, tiebreaker
) AS rk
FROM source_table
[WHERE filters]
)
SELECT cols
FROM ranked
WHERE rk <= N;
| Yêu cầu | Function | WHERE |
|---|---|---|
| "Đúng N dòng / nhóm" | ROW_NUMBER (cần tie-breaker) | rk ≤ N |
| "N hạng / nhóm" (có thể >N dòng) | RANK | rk ≤ N |
| "N mức giá trị khác nhau / nhóm" | DENSE_RANK | rk ≤ N |
| "Top 1 / nhóm" | ROW_NUMBER hoặc tuple subquery | rk = 1 |
Trả về 3 đơn gần nhất của mỗi customer. Sắp xếp theo (customer_name, customer_id, order_date DESC).
"3 đơn gần nhất / customer" ⇒ Top-N per group, N=3, ORDER BY order_date DESC, group_key=customer_id.
WITH ranked AS (
SELECT
o.customer_id, c.name AS customer_name,
o.order_id, o.order_date, o.cost,
ROW_NUMBER() OVER (
PARTITION BY o.customer_id
ORDER BY o.order_date DESC, o.order_id DESC
) AS rk
FROM Orders o
JOIN Customers c ON c.customer_id = o.customer_id
)
SELECT customer_name, customer_id, order_id, order_date
FROM ranked
WHERE rk <= 3
ORDER BY customer_name, customer_id, order_date DESC;
Khung CTE quen thuộc — chỉ có một CTE, dễ đọc, deterministic nhờ tie-breaker order_id.
Median là dòng ở giữa khi sort. Trick: đánh số tăng và giảm, lấy dòng nơi |asc − desc| ≤ 1.
WITH ranked AS (
SELECT salary,
ROW_NUMBER() OVER (ORDER BY salary ASC) AS rn_asc,
ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn_desc
FROM employees
)
SELECT AVG(salary) AS median
FROM ranked
WHERE rn_asc IN (rn_desc, rn_desc - 1, rn_desc + 1);
Khi tổng số dòng lẻ: dòng giữa có rn_asc = rn_desc. Khi chẵn: hai dòng giữa có |rn_asc - rn_desc| = 1. AVG xử lý cả hai trường hợp.
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary) AS median FROM employees;
SELECT PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY salary) AS p90 FROM employees;
Nếu dialect hỗ trợ — dùng. Nếu MySQL/LeetCode — dùng trick ROW_NUMBER.
Tìm các employee có salary là median của company họ. Trả về tất cả các dòng đó.
WITH ranked AS (
SELECT id, company, salary,
ROW_NUMBER() OVER (PARTITION BY company ORDER BY salary, id) AS rn_asc,
COUNT(*) OVER (PARTITION BY company) AS n
FROM Employee
)
SELECT id, company, salary
FROM ranked
WHERE rn_asc IN (FLOOR((n + 1) / 2), FLOOR((n + 2) / 2))
ORDER BY company, salary;
Pattern FLOOR((n+1)/2), FLOOR((n+2)/2) chọn:
| Variant | Trick | Use case |
|---|---|---|
| Liên tiếp theo date | date − ROW_NUMBER * interval | Login streak, business uptime |
| Liên tiếp theo trạng thái | ROW_NUMBER all − ROW_NUMBER per status | Server up/down, level same |
WITH islands AS (
SELECT *,
DATE_SUB(date_col, INTERVAL ROW_NUMBER() OVER (
PARTITION BY group_key ORDER BY date_col
) DAY) AS island_id
FROM source
[WHERE event_predicate]
)
SELECT group_key, MIN(date_col) AS start_d, MAX(date_col) AS end_d, COUNT(*) AS streak
FROM islands
GROUP BY group_key, island_id
[HAVING COUNT(*) >= N];
"Active user": có ít nhất 5 ngày khác nhau liên tiếp đăng nhập. Trả về (id, name) các active user, sắp xếp theo id.
WITH
distinct_logins AS (
SELECT DISTINCT id, login_date FROM Logins
),
islands AS (
SELECT id, login_date,
DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (
PARTITION BY id ORDER BY login_date
) DAY) AS island
FROM distinct_logins
),
long_streaks AS (
SELECT DISTINCT id
FROM islands
GROUP BY id, island
HAVING COUNT(*) >= 5
)
SELECT a.id, a.name
FROM long_streaks ls
JOIN Accounts a ON a.id = ls.id
ORDER BY a.id;
Bài Medium giải bằng 4 CTE — đẹp, đọc tự nhiên, mỗi bước rõ ràng. Đây là showcase cho phong cách "CTE + pattern recognition" anh đã rèn.
-- "Số xuất hiện 3 lần liên tiếp" (LC 180)
WITH lagged AS (
SELECT num,
LAG(num, 1) OVER (ORDER BY id) AS p1,
LAG(num, 2) OVER (ORDER BY id) AS p2
FROM Logs
)
SELECT DISTINCT num FROM lagged WHERE num = p1 AND num = p2;
WITH islands AS (
SELECT num,
id - ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS island
FROM Logs
)
SELECT DISTINCT num
FROM islands
GROUP BY num, island
HAVING COUNT(*) >= N; -- chỉ thay N — code không đổi
Bài này phổ biến trong stock data, sensor data: "tìm chuỗi giá tăng liên tiếp ít nhất 3 lần".
-- Tăng so với dòng trước → flag = 1, ngược lại 0
WITH flagged AS (
SELECT
sale_date, price,
CASE WHEN price > LAG(price) OVER (ORDER BY sale_date)
THEN 1 ELSE 0 END AS is_up
FROM stocks
),
-- Khi is_up đổi giá trị → island mới
islands AS (
SELECT *,
ROW_NUMBER() OVER (ORDER BY sale_date)
- ROW_NUMBER() OVER (PARTITION BY is_up ORDER BY sale_date) AS island
FROM flagged
)
SELECT MIN(sale_date) AS streak_start,
MAX(sale_date) AS streak_end,
COUNT(*) AS length
FROM islands
WHERE is_up = 1
GROUP BY island
HAVING COUNT(*) >= 3;
Đây là kết hợp Pattern 3 (gaps-islands theo trạng thái) + Pattern 4 (consecutive) — hai pattern thường gặp ghép với nhau.
-- Long: (customer_id, status, amount)
-- Wide: customer_id | paid | pending | cancelled
SELECT
customer_id,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending,
SUM(CASE WHEN status = 'cancelled' THEN amount ELSE 0 END) AS cancelled
FROM orders
GROUP BY customer_id;
Schema: Department(id, revenue, month). Yêu cầu: 1 dòng/id, 12 cột Jan_Revenue..Dec_Revenue.
SELECT
id,
SUM(CASE WHEN month = 'Jan' THEN revenue END) AS Jan_Revenue,
SUM(CASE WHEN month = 'Feb' THEN revenue END) AS Feb_Revenue,
-- ... 10 dòng nữa
SUM(CASE WHEN month = 'Dec' THEN revenue END) AS Dec_Revenue
FROM Department
GROUP BY id;
Bỏ ELSE 0 → giá trị NULL khi không có data — phù hợp với yêu cầu LC.
Schema: Products(product_id, store1, store2, store3). Yêu cầu: (product_id, store, price), bỏ qua dòng có store NULL.
SELECT product_id, 'store1' AS store, store1 AS price
FROM Products WHERE store1 IS NOT NULL
UNION ALL
SELECT product_id, 'store2', store2
FROM Products WHERE store2 IS NOT NULL
UNION ALL
SELECT product_id, 'store3', store3
FROM Products WHERE store3 IS NOT NULL;
-- Running total mỗi store
SELECT store_id, sale_date, revenue,
SUM(revenue) OVER (
PARTITION BY store_id
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_total
FROM daily_sales;
-- 7-day moving average
SELECT sale_date, revenue,
AVG(revenue) OVER (
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS ma_7d
FROM daily_sales;
-- Mỗi đơn, % đóng góp vào running total đến giờ
SELECT order_id, total,
SUM(total) OVER (ORDER BY order_date ROWS UNBOUNDED PRECEDING) AS running,
100.0 * total / SUM(total) OVER (ORDER BY order_date ROWS UNBOUNDED PRECEDING) AS pct
FROM orders;
Đây là pattern dashboard rất phổ biến — "đóng góp incremental vs tổng tới đây".
Định nghĩa: Day-N retention = (số user có activity vào ngày N sau ngày đầu) / (tổng user). Pattern này xuất hiện nhiều trong product analytics interview.
WITH
first_login AS (
SELECT user_id, MIN(login_date) AS d0
FROM Logins
GROUP BY user_id
),
cohort AS (
SELECT
f.d0 AS cohort_date,
COUNT(DISTINCT f.user_id) AS cohort_size,
COUNT(DISTINCT CASE
WHEN DATEDIFF(l.login_date, f.d0) = 1 THEN l.user_id
END) AS day_1_returned,
COUNT(DISTINCT CASE
WHEN DATEDIFF(l.login_date, f.d0) = 7 THEN l.user_id
END) AS day_7_returned
FROM first_login f
LEFT JOIN Logins l ON l.user_id = f.user_id
GROUP BY f.d0
)
SELECT
cohort_date,
cohort_size,
ROUND(100.0 * day_1_returned / cohort_size, 2) AS day_1_retention,
ROUND(100.0 * day_7_returned / cohort_size, 2) AS day_7_retention
FROM cohort
ORDER BY cohort_date;
Cohort phân theo ngày đầu user xuất hiện. Conditional aggregation đếm số user quay lại tại offset N. Pattern này tích hợp cả 5 pattern trước trong một bài.
-- Last 30 days, dynamic vs current date
WHERE order_date >= CURRENT_DATE - INTERVAL 30 DAY
AND order_date < CURRENT_DATE
-- (nửa-mở — chuẩn an toàn cho mọi loại date/timestamp)
-- Within fiscal Q1 (Jan-Mar)
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'
-- Year-to-date
WHERE order_date >= DATE_FORMAT(CURRENT_DATE, '%Y-01-01')
-- Same day of week
WHERE DAYOFWEEK(order_date) = DAYOFWEEK('2024-03-15')
SELECT activity_date AS day, COUNT(DISTINCT user_id) AS active_users
FROM Activity
WHERE activity_date > '2019-07-27' - INTERVAL 30 DAY -- Note: WRONG bound vì đề
AND activity_date <= '2019-07-27' -- Đề LC dùng đóng-đóng
GROUP BY activity_date;
LC 1141 dùng đóng-đóng vì cột là DATE thuần (không có giờ). Khi cột TIMESTAMP, switch sang half-open.
Với mỗi user (kể cả không có confirmation), tính tỷ lệ action='confirmed'. Tỷ lệ = 0 nếu không có confirmation. Round 2 chữ số.
SELECT
s.user_id,
ROUND(
AVG(CASE WHEN c.action = 'confirmed' THEN 1.0 ELSE 0.0 END),
2
) AS confirmation_rate
FROM Signups s
LEFT JOIN Confirmations c ON c.user_id = s.user_id
GROUP BY s.user_id;
| Tín hiệu trong đề | Pattern | Tool chính |
|---|---|---|
| "top 3 / cao nhất ... mỗi (X)" | Top-N per group | ROW_NUMBER / DENSE_RANK |
| "median / trung vị / 50th percentile" | Median | 2× ROW_NUMBER trick |
| "top 10% / quartile / decile" | Percentile | NTILE / PERCENTILE_CONT |
| "consecutive days / streak / liên tục" | Gaps and Islands | date - ROW_NUMBER |
| "3 lần liên tiếp giống nhau" | Consecutive Sequences | LAG (N nhỏ) / Gaps-islands (N tổng quát) |
| "mỗi (X) một dòng, Y thành cột" | Pivot | SUM(CASE WHEN) |
| "running total / cumulative" | Cumulative | SUM OVER ROWS BETWEEN UNBOUNDED PRECEDING |
| "moving average / rolling" | Moving aggregate | AVG OVER ROWS BETWEEN N PRECEDING |
| "day-N retention / cohort" | Cohort retention | first_action CTE + DATEDIFF + cond agg |
| "trong 30 ngày qua / Q1 / YTD" | Date arithmetic | nửa-mở interval |
| "so với hôm qua / dòng trước" | Row-to-row comparison | LAG/LEAD |
| "chưa từng / không có" | Anti-join | NOT EXISTS |
| "kể cả khi 0 record" | Outer join | LEFT JOIN + COUNT(col), không COUNT(*) |
Để pattern recognition trở thành reflex, cần luyện có chủ đích, không random.
Interviewer nghe quy trình này thường accept candidate ngay tại bước 3 — code đã trở nên mechanical.
Một số bài cần kết hợp 2-3 pattern. Thử tự xác định pattern ghép trong bài này:
Đối với mỗi bài, viết note 2-3 dòng: "Pattern chính: ..., pattern phụ: ..., trick xử lý: ...".
Nhấn T hoặc Escape để đóng