Tuần 5

Subquery và Set Operations

SQL cho Interview — Lộ trình 10 tuần

Nhấn phím → để bắt đầu · T để xem mục lục

1. Tổng quan tuần 5

1.1

1.1 Mục tiêu — vũ khí giải Medium

Hết tuần 4, bạn đã thoải mái với Easy LeetCode. Subquery là chìa khóa unlock đa số Medium — đặc biệt các bài "tìm cái lớn/nhỏ nhất theo nhóm", "tìm dòng có liên hệ với dòng khác", "lọc theo aggregate".

Sau tuần 5 bạn cần làm được

Insight
Sau tuần 5, bạn giải được khoảng 50% Medium LeetCode SQL. Tuần 6 (window functions) sẽ unlock nửa còn lại — và làm gọn nhiều bài Medium tuần này từ 15 dòng xuống 5 dòng.
1.2

1.2 Mental model — query lồng query

Subquery là một SELECT đặt bên trong một SELECT khác. Engine xử lý theo nguyên tắc: chạy subquery trước (nếu độc lập), kết quả trở thành đầu vào cho query ngoài.

Outer query SELECT name FROM customers WHERE customer_id IN ( Subquery SELECT customer_id FROM orders WHERE total > 1000 );

Ba câu hỏi để phân loại một subquery

  1. Trả về cái gì? Một giá trị (scalar), một dòng (row), hay một bảng (table)?
  2. Đặt ở đâu? SELECT, FROM, WHERE, HAVING?
  3. Có tham chiếu cột của outer không? Nếu có ⇒ correlated, nếu không ⇒ non-correlated.

2. Vị trí của Subquery

2.1

2.1 Scalar subquery — trả 1 giá trị

Định nghĩa
Scalar subquery trả về chính xác 1 dòng × 1 cột. Có thể đặt ở bất cứ nơi nào một biểu thức được phép.
Trong SELECT
-- So tổng đơn của khách với tổng toàn hệ thống
SELECT
    customer_id,
    SUM(total)                          AS my_total,
    (SELECT SUM(total) FROM orders)     AS grand_total
FROM orders
GROUP BY customer_id;
Trong WHERE — so sánh với scalar
-- Đơn lớn hơn trung bình toàn hệ thống
SELECT * FROM orders
WHERE total > (SELECT AVG(total) FROM orders);
Bẫy: subquery trả nhiều dòng
Nếu subquery vô tình trả >1 dòng, query lỗi runtime: "Subquery returned more than 1 value". Khi cần "1 giá trị duy nhất", luôn dùng aggregator (MAX, MIN, AVG) hoặc LIMIT 1 để bảo đảm.
2.2

2.2 Subquery trong WHERE — IN, comparison, ANY/ALL

IN — kiểm tra thuộc tập

-- Khách đã có ít nhất 1 đơn paid
SELECT name FROM customers
WHERE customer_id IN (
    SELECT customer_id FROM orders WHERE status = 'paid'
);

ANY và ALL — so sánh với mọi/một dòng

-- Đơn lớn hơn ÍT NHẤT MỘT đơn của khách VIP
SELECT * FROM orders
WHERE total > ANY (
    SELECT total FROM orders WHERE customer_id = 1
);
-- Tương đương: total > MIN của tập đó

-- Đơn lớn hơn TẤT CẢ đơn của khách VIP
SELECT * FROM orders
WHERE total > ALL (
    SELECT total FROM orders WHERE customer_id = 1
);
-- Tương đương: total > MAX của tập đó
Khuyên
ANY/ALL hiếm khi xuất hiện trong code production. Phần lớn người đọc thấy lạ. Viết lại bằng MIN/MAX dễ đọc và nhanh hơn:
WHERE total > (SELECT MAX(total) FROM orders WHERE customer_id = 1)
Trong interview, có thể đề cập ANY/ALL khi cần "show off" — nhưng default vẫn dùng MIN/MAX.
2.3

2.3 Derived table — subquery trong FROM

Subquery đặt trong FROM được coi như một bảng tạm (derived table / inline view). Phải có alias bắt buộc.

-- Top 5 khách theo doanh thu (yêu cầu 2 bước: aggregate rồi sort/limit)
SELECT s.customer_id, s.revenue, c.name
FROM (
    SELECT customer_id, SUM(total) AS revenue
    FROM orders
    GROUP BY customer_id
) s                                    -- alias bắt buộc
JOIN customers c ON c.customer_id = s.customer_id
ORDER BY s.revenue DESC
LIMIT 5;
Khi nào dùng derived table
Tuần 7 sẽ học CTE (WITH) — cú pháp đẹp hơn cho cùng mục đích. Tạm dùng derived table cho đến khi học CTE.
2.4

2.4 Tổng kết — vị trí và yêu cầu

Vị tríYêu cầuUse case điển hình
SELECTScalar (1×1)Tính chỉ số "phụ" cùng kết quả chính
FROMBảng (n×m), phải có aliasAggregate-then-join, multi-step query
WHERE — comparisonScalar (1×1)So với trung bình, max, min toàn hệ thống
WHERE — IN / NOT IN1 cột, n dòngKiểm tra thuộc/không thuộc tập
WHERE — EXISTS / NOT EXISTSKết quả có hay không (n×m bất kỳ)Tồn tại / không tồn tại — Section 4
HAVINGScalarLọc nhóm theo so sánh với aggregate khác
HAVING với subquery
-- Phòng ban có lương trung bình > trung bình toàn công ty
SELECT department, AVG(salary)
FROM employees
GROUP BY department
HAVING AVG(salary) > (SELECT AVG(salary) FROM employees);

3. Correlated vs Non-correlated

3.1

3.1 Non-correlated — subquery độc lập

Định nghĩa
Non-correlated subquery không tham chiếu bất kỳ cột nào của outer query. Có thể chạy độc lập như một query bình thường.
Ví dụ
SELECT name FROM customers
WHERE customer_id IN (
    SELECT customer_id FROM orders WHERE total > 1000
    -- Không nhắc đến customers ở trong → non-correlated
);

Engine xử lý

  1. Chạy subquery một lần, lưu kết quả tạm.
  2. Mỗi dòng outer kiểm tra xem có thuộc kết quả tạm không.
Hệ quả performance
Non-correlated thường nhanh — engine có thể hash hoặc sort kết quả tạm để lookup O(1) hoặc O(log n) cho mỗi dòng outer. Optimizer thậm chí có thể chuyển thành JOIN tự động.
3.2

3.2 Correlated — subquery phụ thuộc outer

Định nghĩa
Correlated subquery tham chiếu cột của outer query. Không thể chạy độc lập.
Ví dụ — đơn lớn nhất của mỗi khách
SELECT *
FROM orders o
WHERE total = (
    SELECT MAX(total)
    FROM orders o2
    WHERE o2.customer_id = o.customer_id   -- tham chiếu o (outer) → correlated
);

Engine xử lý

  1. Với mỗi dòng outer, chạy subquery lại với giá trị cột tham chiếu thay thế.
  2. Nếu outer có 1M dòng, subquery chạy 1M lần (về mặt logic — optimizer có thể tối ưu).
Cảnh báo performance
Correlated subquery tự nhiên là O(n²) nếu engine không tối ưu. Trong dữ liệu lớn, có thể chậm. Phương án thay thế: JOIN với derived table aggregate, hoặc window function (tuần 6).
3.3

3.3 Khi nào dùng cái nào

Dùng non-correlated khi

  • Filter điều kiện cùng cho mọi dòng outer.
  • So với một giá trị toàn cục (AVG, MAX, MIN của cả bảng).
  • Tập "thành viên" ổn định, không phụ thuộc dòng.

Dùng correlated khi

  • Cần điều kiện thay đổi theo dòng outer.
  • "Lớn nhất theo nhóm" trước khi học window function.
  • Existence check (EXISTS/NOT EXISTS — Section 4).
Cùng một bài, cả hai cách
Yêu cầu: tìm employee có salary cao nhất phòng họ.
-- Cách 1: correlated subquery (chạy mỗi dòng outer)
SELECT * FROM employees e
WHERE salary = (
    SELECT MAX(salary) FROM employees e2 WHERE e2.dept = e.dept
);

-- Cách 2: derived table non-correlated (chạy 1 lần, JOIN)
SELECT e.* FROM employees e
JOIN (
    SELECT dept, MAX(salary) AS max_sal FROM employees GROUP BY dept
) m ON m.dept = e.dept AND m.max_sal = e.salary;

Cách 2 thường nhanh hơn trên dữ liệu lớn — chỉ một lần aggregate. Cách 1 đọc tự nhiên hơn cho người mới.

4. EXISTS sâu

4.1

4.1 EXISTS — chỉ kiểm tra "có hay không"

Định nghĩa
EXISTS (subquery) trả TRUE nếu subquery có ít nhất 1 dòng, FALSE nếu rỗng. Nội dung dòng không quan trọng — vì vậy có thể dùng SELECT 1 hoặc SELECT *, kết quả như nhau.
-- Khách CÓ ít nhất 1 đơn
SELECT name FROM customers c
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id
);

Hầu hết EXISTS là correlated — phải có liên kết với outer (qua predicate trong WHERE) thì mới có nghĩa. EXISTS với non-correlated subquery thường vô nghĩa (luôn TRUE hoặc luôn FALSE).

Tại sao EXISTS nhanh
Engine có thể dừng ngay khi tìm được 1 dòng match — không cần scan hết. Trong khi đó COUNT(*) > 0 sẽ scan toàn bộ. Trong production code "có hay không":
-- KHÔNG hiệu quả
WHERE (SELECT COUNT(*) FROM orders WHERE customer_id = c.id) > 0
-- HIỆU QUẢ
WHERE EXISTS (SELECT 1 FROM orders WHERE customer_id = c.id)
4.2

4.2 EXISTS vs IN — khi NULL xuất hiện

Hai cách viết "thành viên" khác nhau ở semantic NULL:

AspectINEXISTS
Semantic NULL3VL — UNKNOWN có thể lanBoolean thuần — TRUE/FALSE
Subquery có NULL ⇒Có thể trả unexpected (NOT IN bug!)Không bị ảnh hưởng
Performance trên large dataEngine có thể materialize trướcEngine có thể dừng sớm khi tìm thấy
Cú pháp đọcNgắn, "natural"Dài hơn, cần predicate liên kết
Cùng kết quả nếu data sạch
-- IN
SELECT name FROM customers
WHERE customer_id IN (SELECT customer_id FROM orders);

-- EXISTS
SELECT name FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.customer_id);
Hai query trên cho kết quả giống nhau khi orders.customer_id không có NULL.
NOT IN vs NOT EXISTS — recap

Đã học tuần 2 và tuần 4. Nhắc lại lần nữa vì đây là bug đặc thù xuất hiện thường xuyên: NOT IN với subquery có NULL ⇒ tập rỗng. NOT EXISTS không bao giờ có bug này.

Reflex: thấy "không thuộc" trong yêu cầu ⇒ NOT EXISTS, không phải NOT IN.

4.3

4.3 Pattern interview — "có ít nhất một X thỏa Y"

Pattern này xuất hiện rất thường xuyên trong các bài Medium. Một vài dạng phrase:

Ví dụ
Yêu cầu: customer đã mua ít nhất một sản phẩm thuộc category 'Electronics'.
SELECT DISTINCT c.name
FROM customers c
WHERE EXISTS (
    SELECT 1
    FROM orders o
    JOIN order_items oi ON oi.order_id = o.order_id
    JOIN products p ON p.product_id = oi.product_id
    WHERE o.customer_id = c.customer_id
      AND p.category = 'Electronics'
);
Anti-pattern: dùng JOIN + DISTINCT
-- Cũng đúng nhưng có thể chậm và cần DISTINCT
SELECT DISTINCT c.name
FROM customers c
JOIN orders o      ON o.customer_id = c.customer_id
JOIN order_items oi ON oi.order_id = o.order_id
JOIN products p    ON p.product_id = oi.product_id
WHERE p.category = 'Electronics';
JOIN nhân lên dòng (1 khách × 5 đơn × 10 items = 50 dòng) rồi DISTINCT để gom lại. EXISTS chỉ kiểm tra "có hay không" → ngắn ngủi và sạch hơn về tư duy.

5. Set Operations

5.1

5.1 UNION vs UNION ALL

Định nghĩa
-- Khách VN hoặc khách AU (không có trùng vì 1 khách chỉ ở 1 nước)
SELECT name FROM customers WHERE country = 'Vietnam'
UNION ALL
SELECT name FROM customers WHERE country = 'Australia';

-- Khách có đơn paid HOẶC đơn pending — dùng UNION nếu sợ trùng
SELECT customer_id FROM orders WHERE status = 'paid'
UNION
SELECT customer_id FROM orders WHERE status = 'pending';
Khuyên — UNION ALL khi có thể
Nguyên tắc: default UNION ALL, chỉ UNION khi thực sự cần loại trùng.
5.2

5.2 INTERSECT và EXCEPT

INTERSECT — phần giao

-- Khách CẢ paid VÀ pending
SELECT customer_id FROM orders WHERE status = 'paid'
INTERSECT
SELECT customer_id FROM orders WHERE status = 'pending';

EXCEPT (PostgreSQL/SQL Server) hoặc MINUS (Oracle) — phần hiệu

-- Khách paid NHƯNG chưa có đơn pending
SELECT customer_id FROM orders WHERE status = 'paid'
EXCEPT
SELECT customer_id FROM orders WHERE status = 'pending';
Phương ngữ — MySQL không có INTERSECT, EXCEPT
MySQL chỉ có UNION/UNION ALL. Mô phỏng:
-- INTERSECT mô phỏng
SELECT customer_id FROM orders WHERE status = 'paid'
  AND customer_id IN (SELECT customer_id FROM orders WHERE status = 'pending');

-- EXCEPT mô phỏng (dùng NOT EXISTS để an toàn)
SELECT DISTINCT customer_id FROM orders o WHERE status = 'paid'
  AND NOT EXISTS (SELECT 1 FROM orders o2
                  WHERE o2.customer_id = o.customer_id
                    AND o2.status = 'pending');
LeetCode mặc định MySQL ⇒ luyện kỹ hai pattern này.
5.3

5.3 Quy tắc tương thích schema

Để dùng UNION/INTERSECT/EXCEPT, hai query phải:

  1. Cùng số cột.
  2. Cột tương ứng cùng kiểu (hoặc convertible — engine sẽ implicit cast).
  3. Tên cột lấy từ query đầu tiên — query sau bỏ qua tên.
Ví dụ — tổng hợp dữ liệu từ hai bảng cấu trúc tương tự
-- Bảng customers và bảng leads cùng có (id, name, email)
SELECT id, name, email, 'customer' AS source FROM customers
UNION ALL
SELECT id, name, email, 'lead'     AS source FROM leads
ORDER BY name;
Trick "tag source" bằng literal column rất hữu ích cho báo cáo.
Bẫy ORDER BY
ORDER BY chỉ đặt ở cuối toàn bộ, không trong từng SELECT (trừ khi có LIMIT). Tên cột reference phải tồn tại ở query đầu tiên.

6. Bridge — Refactor patterns

6.1

6.1 Cùng một bài, ba paradigm

Yêu cầu: tìm employee có salary cao nhất phòng họ. Schema: employees(id, name, dept, salary).

Cách A — Correlated subquery (intuitive)

SELECT name, dept, salary
FROM employees e
WHERE salary = (
    SELECT MAX(salary) FROM employees e2 WHERE e2.dept = e.dept
);

Cách B — Self-join với derived table aggregate (faster on large data)

SELECT e.name, e.dept, e.salary
FROM employees e
JOIN (
    SELECT dept, MAX(salary) AS max_sal
    FROM employees GROUP BY dept
) m ON m.dept = e.dept AND m.max_sal = e.salary;

Cách C — Window function (tuần 6 preview)

SELECT name, dept, salary FROM (
    SELECT name, dept, salary,
           RANK() OVER (PARTITION BY dept ORDER BY salary DESC) AS rk
    FROM employees
) t WHERE rk = 1;
Cảm nhận
Cả ba đều đúng. Cách A đọc dễ nhất; Cách B nhanh trên dữ liệu lớn; Cách C ngắn nhất và xử lý ties tốt nhất. Sau khi học window function, bạn sẽ thấy Cách A và B trở thành "code di sản".
6.2

6.2 Khi nào chọn paradigm nào

ParadigmMạnh khiYếu khi
Correlated subqueryBài đơn giản, trực giác cao, không cần tối ưuDữ liệu lớn (O(n²) tự nhiên), nhiều bước, ties phức tạp
Self-join + derived tableDữ liệu lớn, aggregate-then-filter, tương thích mọi dialect cũCode dài, phức tạp khi cần ranking/percentile
Window functionRanking, running totals, top-N per group, percentileCần dialect mới (PostgreSQL/MySQL 8+/SQL Server 2012+); chưa học
Điều quan trọng cho mock interview
Bạn không phải chọn đúng paradigm ngay lần đầu. Nhưng sau khi viết, đề xuất một paradigm thay thế và giải thích trade-off:
"Tôi viết bằng correlated subquery. Trên dữ liệu lớn, có thể refactor thành self-join với derived table aggregate để chạy nhanh hơn. Hoặc với window function trong dialect mới, query gọn hơn nhiều — sẽ trông như thế này..."
Đây là tín hiệu của senior candidate.

7. Worked Examples

7.1

7.1 LeetCode 1581 — Customers Without Transactions

Schema

Visits(visit_id INT PK, customer_id INT) Transactions(transaction_id INT PK, visit_id INT, amount INT)

Yêu cầu

Với mỗi customer, đếm số lượt visit không tạo bất kỳ transaction nào. Trả về (customer_id, count_no_trans).

Phân rã

  1. "Visit không có transaction nào" ⇒ anti-join giữa Visits và Transactions.
  2. Đếm theo customer ⇒ GROUP BY.

Lời giải

SELECT customer_id, COUNT(*) AS count_no_trans
FROM Visits v
WHERE NOT EXISTS (
    SELECT 1 FROM Transactions t WHERE t.visit_id = v.visit_id
)
GROUP BY customer_id;
Tín hiệu trong đề
"không có / chưa từng / chưa được" ⇒ phản xạ là NOT EXISTS. Đừng nhảy ngay sang LEFT JOIN — đôi khi đúng nhưng cần thêm WHERE NULL và dễ nhầm. NOT EXISTS thẳng tắp nhất.
7.2

7.2 LeetCode 176 — Second Highest Salary (Medium)

Schema

Employee(id INT PK, salary INT)

Yêu cầu

Trả về salary cao thứ hai. Nếu không có (chỉ có 1 mức salary), trả NULL.

Bẫy

Lời giải

-- Cách 1: subquery bao ngoài để bắt NULL
SELECT (
    SELECT DISTINCT salary
    FROM Employee
    ORDER BY salary DESC
    LIMIT 1 OFFSET 1
) AS SecondHighestSalary;

-- Cách 2: dùng MAX với điều kiện loại đỉnh
SELECT MAX(salary) AS SecondHighestSalary
FROM Employee
WHERE salary < (SELECT MAX(salary) FROM Employee);
Vì sao bao ngoài bằng SELECT?
Subquery LIMIT 1 OFFSET 1 không trả dòng nào nếu chỉ có 1 mức salary. Bao bằng SELECT (...) tận dụng đặc tính scalar subquery — không có dòng ⇒ trả NULL. Trick rất gọn cho yêu cầu "trả NULL nếu không có".

Tuần 6 sẽ có cách viết với DENSE_RANK đẹp hơn cho bài tổng quát N-th highest.

7.3

7.3 LeetCode 184 — Department Highest Salary (Medium)

Schema

Employee(id INT PK, name, salary INT, departmentId INT) Department(id INT PK, name)

Yêu cầu

Với mỗi phòng, trả về tên phòng + tên employee có salary cao nhất phòng (có thể nhiều người nếu ties).

Lời giải — dùng tuple subquery (rất sạch)

SELECT
    d.name AS Department,
    e.name AS Employee,
    e.salary AS Salary
FROM Employee e
JOIN Department d ON d.id = e.departmentId
WHERE (e.departmentId, e.salary) IN (
    SELECT departmentId, MAX(salary)
    FROM Employee
    GROUP BY departmentId
);
Pattern: tuple subquery
Subquery trả về nhiều cột, outer compare bằng tuple (col1, col2) IN (...). Pattern cực gọn cho "tìm dòng có giá trị max/min theo nhóm". Hoạt động trên MySQL, PostgreSQL.
SQL Server không hỗ trợ trực tiếp — phải EXISTS hoặc JOIN với derived table.

8. Knowledge Check

8.Q

Knowledge Check — Tuần 5

Q1: Subquery nào là CORRELATED?
B là correlated — subquery tham chiếu o.customer_id của outer. A và D là non-correlated; C là derived table (cũng non-correlated). Correlated chạy lại với mỗi dòng outer; non-correlated chạy 1 lần.
Q2: Bảng A(x) chứa {1, 2, 3, 4}, B(y) chứa {2, 3, 5}. Query SELECT x FROM A UNION SELECT y FROM B trả gì?
UNION loại trùng, ghép thành tập. UNION ALL mới giữ trùng (cho A). C là kết quả của INTERSECT. D là kết quả của EXCEPT theo chiều cụ thể. Trong production, default UNION ALL nếu biết hai tập rời nhau — UNION đắt hơn vì phải sort/hash.
Q3: Yêu cầu "khách CÓ ít nhất 1 đơn paid và CÓ ít nhất 1 đơn cancelled". Query nào sạch nhất?
A sai logic — lấy cả khách CHỈ có paid hoặc CHỈ có cancelled. B sai logic — không có dòng nào status='paid' AND status='cancelled' đồng thời (status là 1 giá trị/dòng). D đúng nhưng MySQL không có INTERSECT. C đúng — hai EXISTS độc lập, semantic rõ ràng, chạy mọi dialect.

9. Bài tập về nhà

9.1

9.1 Bài tập LeetCode (5 problem, mix)

  1. 1581. Customer Who Visited but Did Not Make Any Transactions (Easy) — đã giải tại lớp.
  2. 176. Second Highest Salary (Medium) — đã giải tại lớp; viết lại không nhìn.
  3. 184. Department Highest Salary (Medium) — đã giải tại lớp; thêm: viết lại bằng correlated subquery (Cách A của Section 6).
  4. 1731. The Number of Employees Which Report to Each Employee (Easy) — bonus tuần 4; giải lại bằng EXISTS thay vì JOIN.
  5. 1112. Highest Grade For Each Student (Medium) — pattern tuple subquery như LC 184. Bẫy: ties phải lấy course có mã nhỏ nhất.

Bonus — refactor exercise

Lấy LC 184 Department Highest Salary bạn vừa giải, viết bằng cả ba paradigm (correlated, derived table, và preview window function nếu bạn đã đọc RANK()). So sánh:

9.2

9.2 Đọc thêm và tự kiểm tra

Đọc

Tự kiểm tra (không nhìn slide)

Tuần sau — Window Functions (vũ khí giải Hard)
Tuần 6: OVER, PARTITION BY, ROW_NUMBER, RANK, DENSE_RANK, LAG/LEAD, running totals, frame clauses. Đây là tuần "click moment" — bạn sẽ thấy nhiều bài Medium tuần này thu nhỏ thành 5 dòng code, và Hard LeetCode bắt đầu giải được.

Mục lục

Nhấn T hoặc Escape để đóng